From bba4162a39b649d6c85fba46e0bfc23fd077508e Mon Sep 17 00:00:00 2001 From: OrtegaMatias <212189024+OrtegaMatias@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:37:22 -0400 Subject: [PATCH] feat: add dependencyLevels scheduler for parallel `up` (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups services into launch WAVES by their depends_on graph: every service in a wave has all of its in-set dependencies in an earlier wave, so a whole wave can be launched concurrently. This is the pure, daemon-free core of parallelizing `up` (#128), where independent services currently start sequentially (~23s for 11 independent services vs ~7s in parallel). - depends_on entries naming a service outside the selected set (e.g. profile-excluded) are ignored, matching topoSortConfiguredServices. - Throws on a dependency cycle (including a self-dependency) before returning, so a cyclic graph can never deadlock a concurrent launch. Pure static function mirroring the existing graph helpers, with 6 static tests: linear chain, diamond, independent set, cycle, self-dependency, and out-of-set dependency. No behavior change yet — `up` still launches serially; this lands the schedulable core first. --- .../Codable Structs/Service.swift | 50 ++++++++++ .../ParallelSchedulerTests.swift | 98 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 Tests/Container-Compose-StaticTests/ParallelSchedulerTests.swift diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 78fcd73..b0950ef 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -422,6 +422,56 @@ public struct Service: Codable, Hashable { return sorted } + /// Groups the selected services into launch WAVES for a parallel `up`. Every + /// service in `levels[k]` has all of its in-set `depends_on` dependencies in + /// some earlier wave, so every service in one wave can be `container run` + /// launched concurrently (issue #128). Level 0 (no in-set dependencies) + /// comes first. + /// + /// - `depends_on` entries naming a service that is not in `services` are + /// ignored — mirroring `topoSortConfiguredServices` and the way + /// profile-excluded dependencies are tolerated. + /// - Throws (same `NSError`/"ComposeError" shape as `topoSortConfiguredServices`) + /// on a dependency cycle, including a self-dependency, *before* returning — + /// so a cyclic graph can never deadlock the concurrent launch. + /// - Order within a wave follows the caller's service order but is otherwise + /// unspecified (the services in a wave are meant to start concurrently). + static func dependencyLevels( + _ services: [(serviceName: String, service: Service)] + ) throws -> [[String]] { + let byName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) + + var level: [String: Int] = [:] + var visiting: Set = [] + + func computeLevel(_ name: String) throws -> Int { + if let cached = level[name] { return cached } + guard let service = byName[name] else { return 0 } // out-of-set dep → already satisfied + if visiting.contains(name) { + throw NSError(domain: "ComposeError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Cyclic dependency detected involving '\(name)'" + ]) + } + visiting.insert(name) + var maxDepLevel = -1 + for dep in service.depends_on ?? [] where byName[dep] != nil { + maxDepLevel = max(maxDepLevel, try computeLevel(dep)) + } + visiting.remove(name) + let resolved = maxDepLevel + 1 + level[name] = resolved + return resolved + } + + var waves: [[String]] = [] + for (name, _) in services { + let lvl = try computeLevel(name) + while waves.count <= lvl { waves.append([]) } + waves[lvl].append(name) + } + return waves + } + /// Validates that every explicitly requested service name exists among the /// services defined in the compose file. Mirrors `docker compose up `, /// which fails with "no such service: " rather than silently selecting diff --git a/Tests/Container-Compose-StaticTests/ParallelSchedulerTests.swift b/Tests/Container-Compose-StaticTests/ParallelSchedulerTests.swift new file mode 100644 index 0000000..4baf9cd --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ParallelSchedulerTests.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +import Foundation +@testable import ContainerComposeCore + +/// `dependencyLevels` groups services into launch WAVES: every service in +/// wave k has all of its in-set dependencies in an earlier wave, so a whole +/// wave can be `container run` launched concurrently. This is the pure, +/// daemon-free core of the parallel `up` (issue #128). +@Suite("Parallel Scheduler Levels") +struct ParallelSchedulerTests { + + @Test("Linear chain is not parallelized: web -> app -> db") + func linearChainOrdersByLevel() throws { + let web = Service(image: "nginx", depends_on: ["app"]) + let app = Service(image: "myapp", depends_on: ["db"]) + let db = Service(image: "postgres", depends_on: nil) + + let levels = try Service.dependencyLevels([("web", web), ("app", app), ("db", db)]) + + #expect(levels == [["db"], ["app"], ["web"]]) + } + + @Test("Independent services form a single wave (the #128 case)") + func independentServicesFormOneWave() throws { + let a = Service(image: "a", depends_on: nil) + let b = Service(image: "b", depends_on: nil) + let c = Service(image: "c", depends_on: nil) + + let levels = try Service.dependencyLevels([("a", a), ("b", b), ("c", c)]) + + #expect(levels.count == 1) + #expect(Set(levels[0]) == ["a", "b", "c"]) + } + + @Test("Diamond graph groups siblings into the same wave") + func diamondGraphGroupsSiblings() throws { + // front depends on web+api; web+api both depend on db. + let front = Service(image: "front", depends_on: ["web", "api"]) + let web = Service(image: "web", depends_on: ["db"]) + let api = Service(image: "api", depends_on: ["db"]) + let db = Service(image: "db", depends_on: nil) + + let levels = try Service.dependencyLevels( + [("front", front), ("web", web), ("api", api), ("db", db)]) + + #expect(levels.count == 3) + #expect(levels[0] == ["db"]) + #expect(Set(levels[1]) == ["web", "api"]) + #expect(levels[2] == ["front"]) + } + + @Test("A dependency cycle throws instead of deadlocking") + func cycleThrows() { + let web = Service(image: "web", depends_on: ["app"]) + let app = Service(image: "app", depends_on: ["web"]) + + #expect(throws: (any Error).self) { + try Service.dependencyLevels([("web", web), ("app", app)]) + } + } + + @Test("A self-dependency throws") + func selfDependencyThrows() { + let a = Service(image: "a", depends_on: ["a"]) + + #expect(throws: (any Error).self) { + try Service.dependencyLevels([("a", a)]) + } + } + + @Test("depends_on naming a service outside the set is ignored") + func dependsOnAbsentServiceIsIgnored() throws { + // `web` depends on `db`, but `db` is not in the selected set + // (e.g. excluded by a profile). It must not hang or throw — `web` + // is treated as having no in-set dependency, so it lands in wave 0. + let web = Service(image: "nginx", depends_on: ["db"]) + + let levels = try Service.dependencyLevels([("web", web)]) + + #expect(levels == [["web"]]) + } +}