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"]]) + } +}