Testing iOS dependency graphs on a cloud Mac

Testing iOS dependency graphs on a cloud Mac

Testing iOS dependency graphs on a cloud Mac

The same batch of resource-processing tasks runs as expected locally, but occasionally generates the index before the resources after moving to a cloud Mac pipeline. The machine may not be at fault: if task order comes from dictionary iteration, or if “queued” is mistaken for “dependencies completed,” starting a new process may expose the bug. The standalone Swift script below validates the dependency graph without requiring you to launch a simulator first.

Define edge direction and failure semantics first

A hard-coded sequence works well for a short, fixed workflow. A dependency graph is useful when tasks come and go or multiple steps share prerequisites. If all the work is inherently sequential, introducing a graph only adds maintenance overhead.

This article represents the input as “task name → set of prerequisite tasks.” For example, if index depends on assets, the latter must appear first in the output. Task names are stable identifiers that do not change with the display language. Tasks with no dependency relationship are selected in lexicographic order by identifier.

The input contract also needs three rules: merge duplicate edges, fail immediately on unknown dependencies, and treat self-dependencies as cycles. An empty graph is valid and returns an empty array. Do not silently create unknown nodes; otherwise, a misspelled name can pass validation.

Sorting establishes only whether the dependencies can be arranged into a valid sequence. It does not prove that tasks have finished, let alone that their outputs are correct. Validate execution state separately from graph structure.

Implement a runnable, deterministic sorter

Save the following as graph-check.swift in the build node’s working directory. The code uses only the Swift standard library and runs with the currently selected toolchain; no additional packages are needed. The algorithm tracks each task’s unmet dependency count and selects the lexicographically smallest ready task at each step.

enum GraphError: Error, Equatable {
    case unknown(task: String, dependency: String)
    case blocked([String])
}

func ordered(_ graph: [String: [String]]) throws -> [String] {
    let names = graph.keys.sorted()
    let known = Set(names)
    var remaining: [String: Int] = [:]
    var successors: [String: [String]] = [:]

    for task in names {
        let dependencies = Set(graph[task]!)
        for dependency in dependencies.sorted() {
            guard known.contains(dependency) else {
                throw GraphError.unknown(
                    task: task, dependency: dependency
                )
            }
            successors[dependency, default: []].append(task)
        }
        remaining[task] = dependencies.count
    }

    var ready = names.filter { remaining[$0] == 0 }
    var result: [String] = []

    while !ready.isEmpty {
        let task = ready.removeFirst()
        result.append(task)
        for next in successors[task, default: []] {
            remaining[next]! -= 1
            if remaining[next] == 0 {
                ready.append(next)
            }
        }
        ready.sort()
    }

    guard result.count == names.count else {
        throw GraphError.blocked(
            names.filter { remaining[$0]! > 0 }
        )
    }
    return result
}

func check() throws {
    let graph = [
        "assets": ["fetch", "fetch"],
        "fetch": [],
        "index": ["assets"],
        "lint": []
    ]
    let result = try ordered(graph)
    precondition(result == ["fetch", "assets", "index", "lint"])

    let position = Dictionary(
        uniqueKeysWithValues: result.enumerated().map {
            ($0.element, $0.offset)
        }
    )
    precondition(result.count == graph.count)
    precondition(Set(result) == Set(graph.keys))
    for (task, dependencies) in graph {
        for dependency in dependencies {
            precondition(position[dependency]! < position[task]!)
        }
    }

    let empty = try ordered([:])
    precondition(empty.isEmpty)

    do {
        _ = try ordered(["export": ["missing"]])
        preconditionFailure("Unknown dependency was accepted")
    } catch let error as GraphError {
        precondition(error == .unknown(
            task: "export", dependency: "missing"
        ))
    }

    do {
        _ = try ordered([
            "a": ["b"], "b": ["a"], "report": ["b"]
        ])
        preconditionFailure("Cycle was accepted")
    } catch let error as GraphError {
        precondition(error == .blocked(["a", "b", "report"]))
    }
}

try check()
xcrun swift graph-check.swift

A successful run produces no output; a failure returns a nonzero process exit code. Do not run this validation script in an optimization mode that disables precondition checks. Once integrated into a project, the same assertions can move into a dedicated test target so that application build settings do not affect the checks.

Use property assertions to cover gaps in example cases

An exact expected result can catch ordering changes, but it can also lock in an incorrect implementation. That is why the code also checks the output count, the set of nodes, and the ordering of every edge. Together, these checks guard against omissions, duplicates, and reversed dependencies.

Next, add a diamond-shaped graph: two tasks depend on the same root, and a final task depends on both. The root must appear only once, and the task where the branches converge must appear last. Also test multiple independent subgraphs, a single node, a self-dependency, and an input in which every node belongs to a cycle.

For the same graph, insert dictionary entries in different orders, then reverse each dependency array. The result should remain identical. Determinism here comes from explicit sorting, not from repeated runs that “always seem to produce the same result.” A dictionary traversal that happens to be correct once is not an API guarantee.

Do not report every blocked task as a cycle member

In the example, a and b depend on each other. Although report only depends on b, it cannot become ready either. The error is therefore named blocked, rather than claiming that every remaining task belongs to a cycle.

What evidence to retain for troubleshooting

A failure report only needs the original graph, the blocked set in a deterministic sorted order, the commit identifier, and the toolchain version. If the interface needs to highlight the exact cycle members, add strongly connected component analysis. Do not draw a “cycle path” merely because some nodes still have a positive in-degree.

When a cycle exists, planning as a whole should fail even if some independent tasks have already been ordered. Unless the product explicitly supports partial execution, do not hand that initial portion to the executor as a complete plan.

Keep the scheduling boundary clear when integrating with CI

Run the graph check on the cloud Mac before starting time-consuming build steps. Preserve the nonzero exit code so dependency configuration errors surface before minutes-long tasks begin, rather than after the executor stalls.

This implementation re-sorts the ready array at every step, which is suitable for application task graphs of limited size. As the node count grows, you can replace it with a min-heap, but preserve the same lexicographic ordering contract and reuse the existing tests to compare results.

During actual parallel execution, a successor’s unmet dependency count must decrease only after a prerequisite has completed successfully. Failure, cancellation, and retries need their own states; they cannot be inferred from positions in a topological order. Final validation should establish all three conditions: invalid inputs produce explainable failures, valid graphs have deterministic ordering, and the executor never releases dependent tasks prematurely. Moving to another build node will then no longer carry accidental ordering into the release pipeline.

Frequently asked questions

Does every node left after sorting belong to a cycle?

No. Downstream tasks that depend on a cycle can remain too. Use strongly connected component analysis when you need the exact cycle members.

Does deterministic ordering require sequential execution?

No. It only fixes the output order. A parallel scheduler must maintain a ready set and release successors after their prerequisites actually finish.

Try your build on a dedicated node

A full physical Mac mini, reserved for one customer—not a virtual machine. Choose a configuration based on your project size, then test your dependencies, cache, and build workflow by the day.