mirror of
https://github.com/apple/container.git
synced 2026-07-14 21:47:01 +00:00
d2f48982c1
This PR defines the snapshotter protocol
```swift
///Mount a snapshot and all its previous layers
func prepare(_ snapshot: Snapshot) async throws -> Snapshot
/// Commit a snapshot, making it permanent.
func commit(_ snapshot: Snapshot) async throws -> Snapshot
/// Remove a snapshot from snapshot store
func remove(_ snapshot: Snapshot) async throws
```
It updates executors to work with this new protocol
1478 lines
49 KiB
Swift
1478 lines
49 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the container 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 ContainerBuildCache
|
|
import ContainerBuildIR
|
|
import ContainerBuildReporting
|
|
import ContainerBuildSnapshotter
|
|
import ContainerizationOCI
|
|
import Crypto
|
|
import Foundation
|
|
|
|
// Import specific type to avoid ambiguity with ContainerBuildIR.CacheKey
|
|
import struct ContainerBuildCache.CacheKey
|
|
import struct ContainerBuildCache.CacheStatistics
|
|
import struct ContainerBuildCache.CachedResult
|
|
|
|
/// A production-ready, highly parallel scheduler that minimizes build time through
|
|
/// intelligent scheduling and maximum parallelization.
|
|
///
|
|
/// Key features:
|
|
/// - Parallel execution of independent operations
|
|
/// - Dynamic work stealing for load balancing
|
|
/// - Resource-aware scheduling with throttling
|
|
/// - Priority-based execution ordering
|
|
/// - Real-time performance monitoring
|
|
/// - Integrated progress reporting via event streams
|
|
public final class Scheduler: BuildExecutor {
|
|
/// Atomic storage for reporter to maintain Sendable conformance
|
|
private let reporterStorage = AtomicStorage<Reporter?>()
|
|
/// The reporter for this scheduler instance (if progress reporting is enabled)
|
|
public var reporter: Reporter? { reporterStorage.value }
|
|
/// Completion handler to wait for all consumers
|
|
private let completionHandlers = AtomicStorage<[@Sendable () async -> Void]>(initialValue: [])
|
|
private let dispatcher: ExecutionDispatcher
|
|
private let snapshotter: any Snapshotter
|
|
private let cache: any BuildCache
|
|
private let configuration: Configuration
|
|
|
|
/// Scheduler configuration
|
|
public struct Configuration: Sendable {
|
|
/// Maximum number of concurrent operations
|
|
public let maxConcurrency: Int
|
|
|
|
/// Maximum memory usage in bytes
|
|
public let maxMemoryUsage: Int64
|
|
|
|
/// Enable work stealing between queues
|
|
public let enableWorkStealing: Bool
|
|
|
|
/// Enable priority scheduling
|
|
public let enablePriorityScheduling: Bool
|
|
|
|
/// Resource monitoring interval
|
|
public let monitoringInterval: TimeInterval
|
|
|
|
/// Fail fast on first error
|
|
public let failFast: Bool
|
|
|
|
/// Enable progress reporting
|
|
public let enableProgressReporting: Bool
|
|
|
|
public init(
|
|
maxConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount * 2,
|
|
maxMemoryUsage: Int64 = 8 * 1024 * 1024 * 1024, // 8GB default
|
|
enableWorkStealing: Bool = true,
|
|
enablePriorityScheduling: Bool = true,
|
|
monitoringInterval: TimeInterval = 0.5,
|
|
failFast: Bool = true,
|
|
enableProgressReporting: Bool = true
|
|
) {
|
|
self.maxConcurrency = maxConcurrency
|
|
self.maxMemoryUsage = maxMemoryUsage
|
|
self.enableWorkStealing = enableWorkStealing
|
|
self.enablePriorityScheduling = enablePriorityScheduling
|
|
self.monitoringInterval = monitoringInterval
|
|
self.failFast = failFast
|
|
self.enableProgressReporting = enableProgressReporting
|
|
}
|
|
}
|
|
|
|
/// Execution state tracking
|
|
private let executionState = ExecutionState()
|
|
|
|
/// Work queues for parallel execution
|
|
private let workQueues: WorkQueueManager
|
|
|
|
/// Resource monitor
|
|
private let resourceMonitor: ResourceMonitor
|
|
|
|
/// Metrics collector
|
|
private let metricsCollector = MetricsCollector()
|
|
|
|
public init(
|
|
executors: [any OperationExecutor],
|
|
snapshotter: any Snapshotter,
|
|
cache: any BuildCache,
|
|
reporter: Reporter? = nil,
|
|
configuration: Configuration = Configuration()
|
|
) {
|
|
self.dispatcher = ExecutionDispatcher(executors: executors)
|
|
self.snapshotter = snapshotter
|
|
self.cache = cache
|
|
self.configuration = configuration
|
|
self.workQueues = WorkQueueManager(
|
|
concurrency: configuration.maxConcurrency,
|
|
enableWorkStealing: configuration.enableWorkStealing
|
|
)
|
|
self.resourceMonitor = ResourceMonitor(
|
|
maxMemory: configuration.maxMemoryUsage,
|
|
interval: configuration.monitoringInterval
|
|
)
|
|
|
|
// Initialize reporter based on configuration
|
|
if let reporter = reporter {
|
|
self.reporterStorage.value = reporter
|
|
} else if configuration.enableProgressReporting {
|
|
self.reporterStorage.value = Reporter()
|
|
}
|
|
}
|
|
|
|
/// Convenience initializer with default executors
|
|
public convenience init(
|
|
snapshotter: any Snapshotter,
|
|
cache: any BuildCache,
|
|
reporter: Reporter? = nil,
|
|
configuration: Configuration = Configuration()
|
|
) {
|
|
let defaultExecutors: [any OperationExecutor] = [
|
|
ImageOperationExecutor(),
|
|
ExecOperationExecutor(),
|
|
FilesystemOperationExecutor(),
|
|
MetadataOperationExecutor(),
|
|
UnknownOperationExecutor(),
|
|
]
|
|
|
|
self.init(
|
|
executors: defaultExecutors,
|
|
snapshotter: snapshotter,
|
|
cache: cache,
|
|
reporter: reporter,
|
|
configuration: configuration
|
|
)
|
|
}
|
|
|
|
/// Cancel all in-flight operations and prevent new ones from starting
|
|
public func cancel() async {
|
|
await executionState.cancel()
|
|
await workQueues.cancelAll()
|
|
// Note: Individual ExecutionContext instances will clean up their own
|
|
// active snapshots when operations are cancelled and throw errors
|
|
}
|
|
|
|
public func execute(_ graph: BuildGraph) async throws -> BuildResult {
|
|
let startTime = Date()
|
|
|
|
// Reset state
|
|
await executionState.reset()
|
|
await metricsCollector.reset()
|
|
|
|
// Report build started if we have a reporter
|
|
if let reporter = reporter {
|
|
let totalOperations = graph.stages.reduce(0) { $0 + $1.nodes.count + 1 } // +1 for base image
|
|
await reporter.report(.buildStarted(totalOperations: totalOperations, stages: graph.stages.count, timestamp: Date()))
|
|
}
|
|
|
|
// Start resource monitoring
|
|
let monitoringTask = Task {
|
|
await resourceMonitor.startMonitoring(executionState: executionState)
|
|
}
|
|
|
|
defer {
|
|
monitoringTask.cancel()
|
|
}
|
|
|
|
// Analyze graph for parallelization opportunities
|
|
let parallelizationPlan = try analyzeGraph(graph)
|
|
|
|
// Execute platforms in parallel when possible
|
|
let platformResults: [Platform: ImageManifest]
|
|
do {
|
|
platformResults = try await withThrowingTaskGroup(of: PlatformResult.self) { group in
|
|
for platform in graph.targetPlatforms {
|
|
group.addTask {
|
|
try await self.executePlatform(
|
|
graph: graph,
|
|
platform: platform,
|
|
plan: parallelizationPlan
|
|
)
|
|
}
|
|
}
|
|
|
|
var results: [Platform: ImageManifest] = [:]
|
|
do {
|
|
for try await result in group {
|
|
results[result.platform] = result.manifest
|
|
}
|
|
} catch {
|
|
// Cancel all remaining tasks on error
|
|
group.cancelAll()
|
|
// Signal cancellation to execution state
|
|
await executionState.cancel()
|
|
throw error
|
|
}
|
|
return results
|
|
}
|
|
} catch {
|
|
// Report build failure
|
|
await reporter?.report(.buildCompleted(success: false, timestamp: Date()))
|
|
await reporter?.finish()
|
|
|
|
// Run completion handlers before throwing
|
|
for handler in completionHandlers.value {
|
|
await handler()
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
// Report build success
|
|
await reporter?.report(.buildCompleted(success: true, timestamp: Date()))
|
|
await reporter?.finish()
|
|
|
|
// Run all completion handlers to ensure consumers finish
|
|
for handler in completionHandlers.value {
|
|
await handler()
|
|
}
|
|
|
|
// Collect final metrics
|
|
let totalDuration = Date().timeIntervalSince(startTime)
|
|
let (metrics, logs) = await metricsCollector.finalizeMetrics(totalDuration: totalDuration, executionState: executionState)
|
|
let cacheStats = await cache.statistics()
|
|
|
|
return BuildResult(
|
|
manifests: platformResults,
|
|
metrics: metrics,
|
|
cacheStats: cacheStats,
|
|
logs: logs
|
|
)
|
|
}
|
|
|
|
/// Register a completion handler that will be called after the build completes
|
|
/// but before execute() returns. This is useful for ensuring progress consumers
|
|
/// finish processing all events.
|
|
public func onCompletion(_ handler: @escaping @Sendable () async -> Void) {
|
|
completionHandlers.value.append(handler)
|
|
}
|
|
|
|
// MARK: - Graph Analysis
|
|
|
|
internal func analyzeGraph(_ graph: BuildGraph) throws -> ParallelizationPlan {
|
|
var plan = ParallelizationPlan()
|
|
|
|
for stage in graph.stages {
|
|
// Analyze dependencies within stage
|
|
let analysis = try analyzeStage(stage)
|
|
plan.stageAnalyses[stage.id] = analysis
|
|
}
|
|
|
|
return plan
|
|
}
|
|
|
|
private func analyzeStage(_ stage: BuildStage) throws -> StageAnalysis {
|
|
let dependencyGraph = try buildDependencyGraph(stage)
|
|
let parallelizableGroups = findParallelizableGroups(dependencyGraph)
|
|
|
|
return StageAnalysis(
|
|
dependencyGraph: dependencyGraph,
|
|
parallelizableGroups: parallelizableGroups
|
|
)
|
|
}
|
|
|
|
private func buildDependencyGraph(_ stage: BuildStage) throws -> DependencyGraph {
|
|
var graph = DependencyGraph()
|
|
|
|
// Add all nodes
|
|
for node in stage.nodes {
|
|
graph.addNode(node)
|
|
}
|
|
|
|
// Add edges based on dependencies
|
|
for node in stage.nodes {
|
|
for dep in node.dependencies {
|
|
if let depNode = stage.nodes.first(where: { $0.id == dep }) {
|
|
graph.addEdge(from: depNode, to: node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify no cycles
|
|
if graph.hasCycle() {
|
|
throw BuildExecutorError.cyclicDependency
|
|
}
|
|
|
|
return graph
|
|
}
|
|
|
|
private func findParallelizableGroups(_ graph: DependencyGraph) -> [[BuildNode]] {
|
|
var groups: [[BuildNode]] = []
|
|
var processed = Set<UUID>()
|
|
|
|
// Use Kahn's algorithm to find nodes that can execute in parallel
|
|
while processed.count < graph.nodeCount {
|
|
var currentGroup: [BuildNode] = []
|
|
|
|
// Find all nodes with no unprocessed dependencies
|
|
for node in graph.allNodes {
|
|
if !processed.contains(node.id) {
|
|
let deps = graph.dependencies(of: node)
|
|
if deps.allSatisfy({ processed.contains($0.id) }) {
|
|
currentGroup.append(node)
|
|
}
|
|
}
|
|
}
|
|
|
|
if currentGroup.isEmpty {
|
|
break // Shouldn't happen if no cycles
|
|
}
|
|
|
|
groups.append(currentGroup)
|
|
currentGroup.forEach { processed.insert($0.id) }
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
// MARK: - Platform Execution
|
|
|
|
private func executePlatform(
|
|
graph: BuildGraph,
|
|
platform: Platform,
|
|
plan: ParallelizationPlan
|
|
) async throws -> PlatformResult {
|
|
let stages = try graph.stagesForExecution(targetStage: graph.targetStage)
|
|
var stageSnapshots: [String: Snapshot] = [:]
|
|
var finalSnapshot: Snapshot?
|
|
|
|
// Build stage dependency graph to find parallelizable stages
|
|
var stageDependencies: [UUID: Set<UUID>] = [:]
|
|
for stage in stages {
|
|
stageDependencies[stage.id] = Set()
|
|
|
|
// Check for COPY --from dependencies
|
|
for node in stage.nodes {
|
|
if let copyOp = node.operation as? FilesystemOperation,
|
|
case .stage(let stageRef, _) = copyOp.source
|
|
{
|
|
if let depStage = resolveStageReference(stageRef, in: stages, currentStage: stage) {
|
|
stageDependencies[stage.id]?.insert(depStage.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute all base images in parallel first
|
|
var baseImageSnapshots: [UUID: Snapshot] = [:]
|
|
let sharedContext = SharedStageContext()
|
|
|
|
// Start all base image operations in parallel
|
|
try await withThrowingTaskGroup(of: (UUID, String?, Snapshot?).self) { group in
|
|
for stage in stages {
|
|
group.addTask {
|
|
if await self.executionState.isCancelled {
|
|
throw BuildExecutorError.cancelled
|
|
}
|
|
|
|
let context = ExecutionContext(
|
|
stage: stage,
|
|
graph: graph,
|
|
platform: platform,
|
|
reporter: self.reporter ?? Reporter(),
|
|
snapshotter: self.snapshotter
|
|
)
|
|
|
|
let stageName = stage.name ?? "stage-\(stage.id.uuidString.prefix(8))"
|
|
let baseNodeId = UUID()
|
|
let baseReportContext = ReportContext(
|
|
nodeId: baseNodeId,
|
|
stageId: stageName,
|
|
description: ReportContext.describeOperation(stage.base),
|
|
timestamp: Date(),
|
|
sourceMap: nil
|
|
)
|
|
|
|
await self.reporter?.report(.operationStarted(context: baseReportContext))
|
|
|
|
let baseSnapshot = try await self.executeBaseImage(stage.base, context: context)
|
|
|
|
await self.reporter?.report(.operationFinished(context: baseReportContext, duration: 0))
|
|
|
|
return (stage.id, stage.name, baseSnapshot)
|
|
}
|
|
}
|
|
|
|
// Collect base image results and store in shared context
|
|
for try await (stageId, stageName, snapshot) in group {
|
|
if let snapshot = snapshot {
|
|
baseImageSnapshots[stageId] = snapshot
|
|
// Store in shared context so stages can access via COPY --from
|
|
if let name = stageName {
|
|
await sharedContext.setSnapshot(name, snapshot: snapshot)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute stages in parallel when possible
|
|
var completedStages = Set<UUID>()
|
|
var stageResults: [UUID: Snapshot] = [:]
|
|
|
|
while completedStages.count < stages.count {
|
|
if await executionState.isCancelled {
|
|
throw BuildExecutorError.cancelled
|
|
}
|
|
|
|
// Find stages that can run now (all dependencies completed)
|
|
var stagesToRun: [BuildStage] = []
|
|
for stage in stages {
|
|
if !completedStages.contains(stage.id) {
|
|
let deps = stageDependencies[stage.id] ?? []
|
|
if deps.isSubset(of: completedStages) {
|
|
stagesToRun.append(stage)
|
|
}
|
|
}
|
|
}
|
|
|
|
if stagesToRun.isEmpty {
|
|
throw BuildExecutorError.cyclicDependency
|
|
}
|
|
|
|
// Execute all ready stages in parallel
|
|
try await withThrowingTaskGroup(of: (UUID, String?, Snapshot).self) { group in
|
|
for stage in stagesToRun {
|
|
let baseSnapshot = baseImageSnapshots[stage.id]
|
|
group.addTask {
|
|
// Check for cancellation before starting
|
|
if await self.executionState.isCancelled {
|
|
throw BuildExecutorError.cancelled
|
|
}
|
|
|
|
let context = ExecutionContext(
|
|
stage: stage,
|
|
graph: graph,
|
|
platform: platform,
|
|
reporter: self.reporter ?? Reporter(),
|
|
snapshotter: self.snapshotter
|
|
)
|
|
|
|
let stageName = stage.name ?? "stage-\(stage.id.uuidString.prefix(8))"
|
|
await self.reporter?.report(.stageStarted(stageName: stageName, timestamp: Date()))
|
|
|
|
guard let stageAnalysis = plan.stageAnalyses[stage.id] else {
|
|
throw BuildExecutorError.internalError("Stage analysis not found for stage \(stage.id)")
|
|
}
|
|
|
|
// Set the base image snapshot in the context if available
|
|
if let baseSnapshot = baseSnapshot {
|
|
context.setSnapshot(baseSnapshot, for: UUID())
|
|
await self.executionState.markNodeCompleted(UUID())
|
|
}
|
|
|
|
let snapshot = try await self.executeStageParallel(
|
|
stage,
|
|
context: context,
|
|
sharedContext: sharedContext,
|
|
plan: stageAnalysis,
|
|
skipBaseImage: true
|
|
)
|
|
|
|
await self.reporter?.report(.stageCompleted(stageName: stageName, timestamp: Date()))
|
|
|
|
return (stage.id, stage.name, snapshot)
|
|
}
|
|
}
|
|
|
|
// Collect results
|
|
for try await (stageId, stageName, snapshot) in group {
|
|
completedStages.insert(stageId)
|
|
stageResults[stageId] = snapshot
|
|
if let name = stageName {
|
|
await sharedContext.setSnapshot(name, snapshot: snapshot)
|
|
stageSnapshots[name] = snapshot
|
|
}
|
|
finalSnapshot = snapshot
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let snapshot = finalSnapshot else {
|
|
throw BuildExecutorError.stageNotFound("No stages executed")
|
|
}
|
|
|
|
let configDigest = try Digest(algorithm: .sha256, bytes: Data(count: 32))
|
|
let manifest = ImageManifest(
|
|
digest: snapshot.digest,
|
|
size: snapshot.size,
|
|
configDigest: configDigest,
|
|
layers: [LayerDescriptor(digest: snapshot.digest, size: snapshot.size)]
|
|
)
|
|
|
|
return PlatformResult(platform: platform, manifest: manifest)
|
|
}
|
|
|
|
private func resolveStageReference(_ ref: StageReference, in stages: [BuildStage], currentStage: BuildStage) -> BuildStage? {
|
|
switch ref {
|
|
case .named(let name):
|
|
return stages.first { $0.name == name }
|
|
case .index(let idx):
|
|
return idx < stages.count ? stages[idx] : nil
|
|
case .previous:
|
|
if let currentIndex = stages.firstIndex(where: { $0.id == currentStage.id }), currentIndex > 0 {
|
|
return stages[currentIndex - 1]
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func executeStageParallel(
|
|
_ stage: BuildStage,
|
|
context: ExecutionContext,
|
|
sharedContext: SharedStageContext,
|
|
plan: StageAnalysis,
|
|
skipBaseImage: Bool = false
|
|
) async throws -> Snapshot {
|
|
let stageStart = Date()
|
|
defer {
|
|
let duration = Date().timeIntervalSince(stageStart)
|
|
Task {
|
|
await metricsCollector.recordStageDuration(stage.name ?? "unnamed", duration: duration)
|
|
}
|
|
}
|
|
|
|
// Execute base image (unless it was already executed)
|
|
if !skipBaseImage {
|
|
let baseNodeId = UUID()
|
|
let baseReportContext = ReportContext(
|
|
nodeId: baseNodeId,
|
|
stageId: stage.name ?? "stage-\(stage.id.uuidString.prefix(8))",
|
|
description: ReportContext.describeOperation(stage.base),
|
|
timestamp: Date(),
|
|
sourceMap: nil
|
|
)
|
|
|
|
// Report base image operation started
|
|
await context.reporter.report(.operationStarted(context: baseReportContext))
|
|
|
|
if let baseSnapshot = try await executeBaseImage(stage.base, context: context) {
|
|
context.setSnapshot(baseSnapshot, for: baseNodeId)
|
|
|
|
// Report base image operation finished
|
|
await context.reporter.report(.operationFinished(context: baseReportContext, duration: 0))
|
|
|
|
// Mark base operation as completed so nodes can depend on it
|
|
await executionState.markNodeCompleted(baseNodeId)
|
|
}
|
|
}
|
|
|
|
// Execute nodes in parallel groups
|
|
for (groupIndex, group) in plan.parallelizableGroups.enumerated() {
|
|
do {
|
|
try await executeNodeGroup(group, context: context, stage: stage)
|
|
} catch {
|
|
// Handle errors based on configuration
|
|
if configuration.failFast {
|
|
throw BuildExecutorError.operationFailed(
|
|
group.first?.operation ?? UnknownOperation(metadata: OperationMetadata()),
|
|
underlying: error
|
|
)
|
|
} else {
|
|
// Log error and continue if possible
|
|
print("Warning: Group \(groupIndex) in stage '\(stage.name ?? "unnamed")' failed: \(error)")
|
|
// Mark failed nodes to skip dependents
|
|
for node in group {
|
|
await executionState.markNodeFailed(node.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let finalSnapshot = context.headSnapshot else {
|
|
throw BuildExecutorError.stageNotFound("No operations in stage")
|
|
}
|
|
|
|
return finalSnapshot
|
|
}
|
|
|
|
private func executeNodeGroup(
|
|
_ nodes: [BuildNode],
|
|
context: ExecutionContext,
|
|
stage: BuildStage
|
|
) async throws {
|
|
// Wait for available execution slots
|
|
await resourceMonitor.waitForResources(count: nodes.count)
|
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for node in nodes {
|
|
group.addTask {
|
|
try await self.executeNodeWithTracking(node, context: context, stage: stage)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try await group.waitForAll()
|
|
} catch {
|
|
// On error, cancel all remaining tasks
|
|
group.cancelAll()
|
|
|
|
// Signal cancellation if configured for fail-fast
|
|
if self.configuration.failFast {
|
|
await self.executionState.cancel()
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
private func executeNodeWithTracking(
|
|
_ node: BuildNode,
|
|
context: ExecutionContext,
|
|
stage: BuildStage
|
|
) async throws {
|
|
// Wait for ALL dependencies (including cross-stage) to complete
|
|
for depId in node.dependencies {
|
|
do {
|
|
try await executionState.waitForNode(depId)
|
|
} catch {
|
|
await executionState.markNodeFailed(node.id)
|
|
throw BuildExecutorError.operationFailed(
|
|
node.operation,
|
|
underlying: error
|
|
)
|
|
}
|
|
}
|
|
|
|
let nodeStart = Date()
|
|
await executionState.incrementOperationCount()
|
|
|
|
// Create report context
|
|
let reportContext = ReportContext(node: node, stage: stage, operation: node.operation)
|
|
|
|
// Report operation started
|
|
await context.reporter.report(.operationStarted(context: reportContext))
|
|
|
|
defer {
|
|
let duration = Date().timeIntervalSince(nodeStart)
|
|
Task {
|
|
await metricsCollector.recordOperationDuration(node.id, duration: duration)
|
|
await resourceMonitor.releaseResource()
|
|
}
|
|
}
|
|
|
|
// Check cache
|
|
let cacheKey: CacheKey
|
|
do {
|
|
cacheKey = try computeCacheKey(node: node, context: context)
|
|
} catch {
|
|
// If we can't compute cache key, skip caching and execute directly
|
|
cacheKey = CacheKey(
|
|
operationDigest: try Digest(algorithm: .sha256, bytes: Data(count: 32)),
|
|
inputDigests: [],
|
|
platform: context.platform
|
|
)
|
|
}
|
|
|
|
if let cached = await cache.get(cacheKey, for: node.operation) {
|
|
await executionState.incrementCacheHits()
|
|
context.setSnapshot(cached.snapshot, for: node.id)
|
|
|
|
// Report cache hit
|
|
await context.reporter.report(.operationCacheHit(context: reportContext))
|
|
|
|
// Mark node as completed
|
|
await executionState.markNodeCompleted(node.id)
|
|
return
|
|
}
|
|
|
|
// Execute operation with retry logic
|
|
var lastError: Error?
|
|
var retryCount = 0
|
|
let retryPolicy = node.operation.metadata.retryPolicy
|
|
let maxRetries = retryPolicy.maxRetries
|
|
|
|
while retryCount <= maxRetries {
|
|
// Check for cancellation before each attempt
|
|
if await executionState.isCancelled {
|
|
throw BuildExecutorError.cancelled
|
|
}
|
|
|
|
do {
|
|
let result = try await self.executeNode(node, context: context)
|
|
|
|
if let output = result.output {
|
|
if !output.stdout.isEmpty {
|
|
await metricsCollector.recordLog("[\(node.id)] \(output.stdout)")
|
|
// Report operation log
|
|
await context.reporter.report(.operationLog(context: reportContext, message: output.stdout))
|
|
}
|
|
if !output.stderr.isEmpty {
|
|
await metricsCollector.recordLog("[\(node.id)] [STDERR] \(output.stderr)")
|
|
// Report operation log for stderr
|
|
await context.reporter.report(.operationLog(context: reportContext, message: "[STDERR] \(output.stderr)"))
|
|
}
|
|
}
|
|
|
|
context.setSnapshot(result.snapshot, for: node.id)
|
|
|
|
// Store in cache
|
|
let cachedResult = CachedResult(
|
|
snapshot: result.snapshot,
|
|
environmentChanges: result.environmentChanges,
|
|
metadataChanges: result.metadataChanges
|
|
)
|
|
await cache.put(cachedResult, key: cacheKey, for: node.operation)
|
|
|
|
// Report operation finished
|
|
await context.reporter.report(.operationFinished(context: reportContext, duration: result.duration))
|
|
|
|
// Mark node as completed
|
|
await executionState.markNodeCompleted(node.id)
|
|
return // Success
|
|
|
|
} catch {
|
|
lastError = error
|
|
retryCount = await executionState.incrementRetryCount(for: node.id)
|
|
|
|
if retryCount <= maxRetries {
|
|
// Calculate backoff delay
|
|
let delay = min(
|
|
retryPolicy.initialDelay * pow(retryPolicy.backoffMultiplier, Double(retryCount - 1)),
|
|
retryPolicy.maxDelay
|
|
)
|
|
|
|
print("Retrying operation \(node.id) after \(delay)s (attempt \(retryCount)/\(maxRetries))")
|
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
await executionState.markNodeFailed(node.id)
|
|
|
|
// Report operation failed
|
|
let finalError =
|
|
lastError
|
|
?? BuildExecutorError.operationFailed(
|
|
node.operation,
|
|
underlying: UnknownFailureError()
|
|
)
|
|
let eventError = BuildEventError(
|
|
type: .executionFailed,
|
|
description: finalError.localizedDescription,
|
|
diagnostics: nil
|
|
)
|
|
await context.reporter.report(.operationFailed(context: reportContext, error: eventError))
|
|
|
|
throw BuildExecutorError.operationFailed(node.operation, underlying: finalError)
|
|
}
|
|
|
|
// MARK: - Node Execution
|
|
|
|
private func executeBaseImage(
|
|
_ operation: ImageOperation,
|
|
context: ExecutionContext
|
|
) async throws -> Snapshot? {
|
|
let result = try await dispatcher.dispatch(operation, context: context)
|
|
return result.snapshot
|
|
}
|
|
|
|
private func executeNode(
|
|
_ node: BuildNode,
|
|
context: ExecutionContext
|
|
) async throws -> ExecutionResult {
|
|
let constraints = buildNodeConstraints(node)
|
|
|
|
return try await dispatcher.dispatch(
|
|
node.operation,
|
|
context: context,
|
|
constraints: constraints
|
|
)
|
|
}
|
|
|
|
private func buildNodeConstraints(_ node: BuildNode) -> NodeConstraints? {
|
|
guard !node.constraints.isEmpty else { return nil }
|
|
|
|
var requiresPrivileged = false
|
|
var minMemory: Int64?
|
|
var cpuArchitecture: String?
|
|
|
|
for constraint in node.constraints {
|
|
switch constraint {
|
|
case .requiresPrivileged:
|
|
requiresPrivileged = true
|
|
case .memoryLimit(let limit):
|
|
minMemory = Int64(limit)
|
|
case .requiresPlatform(let platform):
|
|
cpuArchitecture = platform.architecture
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
return NodeConstraints(
|
|
requiresPrivileged: requiresPrivileged,
|
|
minMemory: minMemory,
|
|
cpuArchitecture: cpuArchitecture
|
|
)
|
|
}
|
|
|
|
// MARK: - Cache Key Generation
|
|
|
|
private func computeCacheKey(
|
|
node: BuildNode,
|
|
context: ExecutionContext
|
|
) throws -> CacheKey {
|
|
let operationDigest = try node.operation.contentDigest()
|
|
|
|
var inputDigests: [ContainerBuildIR.Digest] = []
|
|
|
|
// Add parent snapshot digest
|
|
if let parentSnapshot = context.headSnapshot {
|
|
inputDigests.append(parentSnapshot.digest)
|
|
}
|
|
|
|
// Add dependency snapshots
|
|
for depId in node.dependencies {
|
|
if let depSnapshot = context.snapshot(for: depId) {
|
|
inputDigests.append(depSnapshot.digest)
|
|
}
|
|
}
|
|
|
|
return CacheKey(
|
|
operationDigest: operationDigest,
|
|
inputDigests: inputDigests.sorted(by: { $0.stringValue < $1.stringValue }),
|
|
platform: context.platform
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
/// Shared context for stages running in parallel
|
|
private actor SharedStageContext {
|
|
private var snapshots: [String: Snapshot] = [:]
|
|
|
|
func setSnapshot(_ name: String, snapshot: Snapshot) {
|
|
snapshots[name] = snapshot
|
|
}
|
|
|
|
func getSnapshot(_ name: String) -> Snapshot? {
|
|
snapshots[name]
|
|
}
|
|
|
|
func getAllSnapshots() -> [String: Snapshot] {
|
|
snapshots
|
|
}
|
|
}
|
|
|
|
/// Tracks execution state across the scheduler
|
|
private actor ExecutionState {
|
|
private var cancelled = false
|
|
private var operationCount = 0
|
|
private var cacheHits = 0
|
|
private var failedNodes: Set<UUID> = []
|
|
private var nodeRetries: [UUID: Int] = [:]
|
|
private var completedNodes: Set<UUID> = []
|
|
private var nodeCompletionWaiters: [UUID: [CheckedContinuation<Void, Error>]] = [:]
|
|
|
|
var isCancelled: Bool { cancelled }
|
|
|
|
func cancel() {
|
|
cancelled = true
|
|
// Wake up any waiters with cancellation error
|
|
for (_, waiters) in nodeCompletionWaiters {
|
|
for waiter in waiters {
|
|
waiter.resume(throwing: BuildExecutorError.cancelled)
|
|
}
|
|
}
|
|
nodeCompletionWaiters.removeAll()
|
|
}
|
|
|
|
func reset() {
|
|
cancelled = false
|
|
operationCount = 0
|
|
cacheHits = 0
|
|
failedNodes.removeAll()
|
|
nodeRetries.removeAll()
|
|
completedNodes.removeAll()
|
|
nodeCompletionWaiters.removeAll()
|
|
}
|
|
|
|
func incrementOperationCount() {
|
|
operationCount += 1
|
|
}
|
|
|
|
func incrementCacheHits() {
|
|
cacheHits += 1
|
|
}
|
|
|
|
func markNodeCompleted(_ nodeId: UUID) {
|
|
completedNodes.insert(nodeId)
|
|
// Wake up any waiters for this node
|
|
if let waiters = nodeCompletionWaiters.removeValue(forKey: nodeId) {
|
|
for waiter in waiters {
|
|
waiter.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func markNodeFailed(_ nodeId: UUID) {
|
|
failedNodes.insert(nodeId)
|
|
// Wake up any waiters with failure
|
|
if let waiters = nodeCompletionWaiters.removeValue(forKey: nodeId) {
|
|
for waiter in waiters {
|
|
waiter.resume(throwing: DependencyFailedError(dependencyId: nodeId))
|
|
}
|
|
}
|
|
}
|
|
|
|
func isNodeCompleted(_ nodeId: UUID) -> Bool {
|
|
completedNodes.contains(nodeId)
|
|
}
|
|
|
|
func isNodeFailed(_ nodeId: UUID) -> Bool {
|
|
failedNodes.contains(nodeId)
|
|
}
|
|
|
|
func waitForNode(_ nodeId: UUID) async throws {
|
|
if completedNodes.contains(nodeId) {
|
|
return // Already completed
|
|
}
|
|
|
|
if failedNodes.contains(nodeId) {
|
|
throw DependencyFailedError(dependencyId: nodeId)
|
|
}
|
|
|
|
if cancelled {
|
|
throw BuildExecutorError.cancelled
|
|
}
|
|
|
|
// Wait for the node to complete
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
// Check again in case state changed
|
|
if completedNodes.contains(nodeId) {
|
|
continuation.resume()
|
|
return
|
|
}
|
|
|
|
if failedNodes.contains(nodeId) {
|
|
continuation.resume(throwing: DependencyFailedError(dependencyId: nodeId))
|
|
return
|
|
}
|
|
|
|
if cancelled {
|
|
continuation.resume(throwing: BuildExecutorError.cancelled)
|
|
return
|
|
}
|
|
|
|
var waiters = nodeCompletionWaiters[nodeId] ?? []
|
|
waiters.append(continuation)
|
|
nodeCompletionWaiters[nodeId] = waiters
|
|
}
|
|
}
|
|
|
|
func incrementRetryCount(for nodeId: UUID) -> Int {
|
|
let count = (nodeRetries[nodeId] ?? 0) + 1
|
|
nodeRetries[nodeId] = count
|
|
return count
|
|
}
|
|
|
|
func getStats() -> (operations: Int, hits: Int, failures: Int) {
|
|
(operationCount, cacheHits, failedNodes.count)
|
|
}
|
|
}
|
|
|
|
/// Manages work queues for parallel execution with work stealing
|
|
private actor WorkQueueManager {
|
|
private let queues: [WorkQueue]
|
|
private let enableWorkStealing: Bool
|
|
private var nextQueueIndex = 0
|
|
|
|
init(concurrency: Int, enableWorkStealing: Bool) {
|
|
self.queues = (0..<concurrency).map { WorkQueue(id: $0) }
|
|
self.enableWorkStealing = enableWorkStealing
|
|
|
|
// Set up work stealing relationships
|
|
if enableWorkStealing {
|
|
for i in 0..<concurrency {
|
|
let stealTargets = (0..<concurrency).filter { $0 != i }.map { queues[$0] }
|
|
queues[i].setStealTargets(stealTargets)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Submit a task to the least loaded queue
|
|
func submit(_ task: @escaping () async throws -> Void) {
|
|
// Round-robin with load balancing
|
|
let startIndex = nextQueueIndex
|
|
var minLoad = Int.max
|
|
var targetQueue = queues[startIndex]
|
|
|
|
for i in 0..<queues.count {
|
|
let index = (startIndex + i) % queues.count
|
|
let load = queues[index].currentLoad
|
|
if load < minLoad {
|
|
minLoad = load
|
|
targetQueue = queues[index]
|
|
}
|
|
}
|
|
|
|
targetQueue.enqueue(task)
|
|
nextQueueIndex = (nextQueueIndex + 1) % queues.count
|
|
}
|
|
|
|
/// Start all worker threads
|
|
func start() async {
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for queue in queues {
|
|
group.addTask {
|
|
await queue.processLoop()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelAll() {
|
|
for queue in queues {
|
|
queue.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Individual work queue with work stealing capability
|
|
private final class WorkQueue: @unchecked Sendable {
|
|
private let id: Int
|
|
private var tasks: [() async throws -> Void] = []
|
|
private let lock = NSLock()
|
|
private var cancelled = false
|
|
private var isProcessing = false
|
|
private var stealTargets: [WorkQueue] = []
|
|
private let workAvailable = NSCondition()
|
|
|
|
var currentLoad: Int {
|
|
lock.withLock { tasks.count }
|
|
}
|
|
|
|
init(id: Int) {
|
|
self.id = id
|
|
}
|
|
|
|
func setStealTargets(_ targets: [WorkQueue]) {
|
|
lock.withLock {
|
|
self.stealTargets = targets
|
|
}
|
|
}
|
|
|
|
func enqueue(_ task: @escaping () async throws -> Void) {
|
|
lock.withLock {
|
|
guard !cancelled else { return }
|
|
tasks.append(task)
|
|
workAvailable.signal()
|
|
}
|
|
}
|
|
|
|
func processLoop() async {
|
|
while !cancelled {
|
|
if let task = dequeueOrSteal() {
|
|
do {
|
|
try await task()
|
|
} catch {
|
|
// Log error but continue processing
|
|
print("Task failed: \(error)")
|
|
}
|
|
} else {
|
|
// No work available, wait
|
|
lock.withLock {
|
|
guard !cancelled && tasks.isEmpty else { return }
|
|
workAvailable.wait()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dequeueOrSteal() -> (() async throws -> Void)? {
|
|
// Try to get from own queue first
|
|
if let task = dequeue() {
|
|
return task
|
|
}
|
|
|
|
// If work stealing is enabled, try to steal from others
|
|
if !stealTargets.isEmpty {
|
|
// Randomize steal order to avoid contention
|
|
let shuffled = stealTargets.shuffled()
|
|
for target in shuffled {
|
|
if let task = target.steal() {
|
|
return task
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func dequeue() -> (() async throws -> Void)? {
|
|
lock.withLock {
|
|
guard !tasks.isEmpty else { return nil }
|
|
return tasks.removeFirst()
|
|
}
|
|
}
|
|
|
|
private func steal() -> (() async throws -> Void)? {
|
|
lock.withLock {
|
|
// Steal from the back to minimize contention
|
|
guard tasks.count > 1 else { return nil }
|
|
return tasks.removeLast()
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
lock.withLock {
|
|
cancelled = true
|
|
tasks.removeAll()
|
|
workAvailable.broadcast()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Monitors resource usage
|
|
private actor ResourceMonitor {
|
|
private let maxMemory: Int64
|
|
private let interval: TimeInterval
|
|
private var availableSlots: Int
|
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(maxMemory: Int64, interval: TimeInterval) {
|
|
self.maxMemory = maxMemory
|
|
self.interval = interval
|
|
self.availableSlots = ProcessInfo.processInfo.activeProcessorCount * 2
|
|
}
|
|
|
|
func startMonitoring(executionState: ExecutionState) async {
|
|
while await !executionState.isCancelled {
|
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
|
// Monitor memory and CPU usage
|
|
// Adjust available slots based on system load
|
|
}
|
|
}
|
|
|
|
func waitForResources(count: Int) async {
|
|
while availableSlots < count {
|
|
await withCheckedContinuation { continuation in
|
|
waiters.append(continuation)
|
|
}
|
|
}
|
|
availableSlots -= count
|
|
}
|
|
|
|
func releaseResource() {
|
|
availableSlots += 1
|
|
if let waiter = waiters.first {
|
|
waiters.removeFirst()
|
|
waiter.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collects execution metrics
|
|
private actor MetricsCollector {
|
|
private var operationDurations: [UUID: TimeInterval] = [:]
|
|
private var stageDurations: [String: TimeInterval] = [:]
|
|
private var logs: [String] = []
|
|
private var startTime = Date()
|
|
|
|
func reset() {
|
|
operationDurations.removeAll()
|
|
stageDurations.removeAll()
|
|
logs.removeAll()
|
|
startTime = Date()
|
|
}
|
|
|
|
func recordOperationDuration(_ id: UUID, duration: TimeInterval) {
|
|
operationDurations[id] = duration
|
|
}
|
|
|
|
func recordStageDuration(_ name: String, duration: TimeInterval) {
|
|
stageDurations[name] = duration
|
|
}
|
|
|
|
func recordLog(_ log: String) {
|
|
logs.append(log)
|
|
}
|
|
|
|
func finalizeMetrics(totalDuration: TimeInterval, executionState: ExecutionState) async -> (ExecutionMetrics, [String]) {
|
|
let stats = await executionState.getStats()
|
|
let metrics = ExecutionMetrics(
|
|
totalDuration: totalDuration,
|
|
stageDurations: stageDurations,
|
|
operationCount: operationDurations.count,
|
|
cachedOperationCount: stats.hits,
|
|
bytesTransferred: 0 // TODO: Track this
|
|
)
|
|
return (metrics, logs)
|
|
}
|
|
}
|
|
|
|
/// Result for a single platform build
|
|
private struct PlatformResult {
|
|
let platform: Platform
|
|
let manifest: ImageManifest
|
|
}
|
|
|
|
/// Parallelization analysis results
|
|
internal struct ParallelizationPlan {
|
|
var stageAnalyses: [UUID: StageAnalysis] = [:]
|
|
}
|
|
|
|
/// Analysis results for a single stage
|
|
internal struct StageAnalysis {
|
|
let dependencyGraph: DependencyGraph
|
|
let parallelizableGroups: [[BuildNode]]
|
|
}
|
|
|
|
/// Dependency graph for analysis
|
|
internal struct DependencyGraph {
|
|
private var adjacencyList: [UUID: Set<UUID>] = [:]
|
|
private var nodesList: [BuildNode] = []
|
|
|
|
var nodeCount: Int { nodesList.count }
|
|
var allNodes: [BuildNode] { nodesList }
|
|
|
|
mutating func addNode(_ node: BuildNode) {
|
|
nodesList.append(node)
|
|
adjacencyList[node.id] = []
|
|
}
|
|
|
|
mutating func addEdge(from: BuildNode, to: BuildNode) {
|
|
adjacencyList[from.id]?.insert(to.id)
|
|
}
|
|
|
|
func dependencies(of node: BuildNode) -> [BuildNode] {
|
|
nodesList.filter { node.dependencies.contains($0.id) }
|
|
}
|
|
|
|
func hasCycle() -> Bool {
|
|
var visited = Set<UUID>()
|
|
var recursionStack = Set<UUID>()
|
|
|
|
func dfs(_ nodeId: UUID) -> Bool {
|
|
visited.insert(nodeId)
|
|
recursionStack.insert(nodeId)
|
|
|
|
if let neighbors = adjacencyList[nodeId] {
|
|
for neighbor in neighbors {
|
|
if !visited.contains(neighbor) {
|
|
if dfs(neighbor) {
|
|
return true
|
|
}
|
|
} else if recursionStack.contains(neighbor) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
recursionStack.remove(nodeId)
|
|
return false
|
|
}
|
|
|
|
for node in nodesList {
|
|
if !visited.contains(node.id) {
|
|
if dfs(node.id) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Types
|
|
|
|
/// Error when a dependency failed
|
|
struct DependencyFailedError: LocalizedError {
|
|
let dependencyId: UUID
|
|
|
|
var errorDescription: String? {
|
|
"Dependency \(dependencyId) failed"
|
|
}
|
|
}
|
|
|
|
/// Error when operation fails for unknown reasons
|
|
struct UnknownFailureError: LocalizedError {
|
|
var errorDescription: String? {
|
|
"Operation failed for unknown reasons"
|
|
}
|
|
}
|
|
|
|
/// Placeholder for unknown operations
|
|
struct UnknownOperation: ContainerBuildIR.Operation {
|
|
var metadata: OperationMetadata
|
|
static let operationKind = OperationKind(rawValue: "unknown")
|
|
var operationKind: OperationKind { Self.operationKind }
|
|
|
|
func accept<V>(_ visitor: V) throws -> V.Result where V: OperationVisitor {
|
|
throw BuildExecutorError.unsupportedOperation(self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Extensions
|
|
|
|
extension BuildGraph {
|
|
/// Get stages in execution order for a target stage, resolving all dependencies.
|
|
func stagesForExecution(targetStage: BuildStage?) throws -> [BuildStage] {
|
|
let target = targetStage ?? stages.last
|
|
guard let target = target else {
|
|
return []
|
|
}
|
|
|
|
// Build dependency graph for stages
|
|
var stageDependencies: [UUID: Set<String>] = [:]
|
|
var stagesByName: [String: BuildStage] = [:]
|
|
var stagesByID: [UUID: BuildStage] = [:]
|
|
|
|
// Index stages
|
|
for stage in stages {
|
|
stagesByID[stage.id] = stage
|
|
if let name = stage.name {
|
|
stagesByName[name] = stage
|
|
}
|
|
stageDependencies[stage.id] = []
|
|
}
|
|
|
|
// Resolve FROM dependencies
|
|
// Note: In the current IR, stage dependencies are handled differently
|
|
// The base is always an ImageOperation, not a stage reference
|
|
// Stage-to-stage dependencies are handled through COPY --from operations
|
|
|
|
// Resolve COPY --from dependencies
|
|
for stage in stages {
|
|
for node in stage.nodes {
|
|
if let copyOp = node.operation as? FilesystemOperation,
|
|
case .stage(let stageRef, _) = copyOp.source
|
|
{
|
|
let stageName: String
|
|
switch stageRef {
|
|
case .named(let name):
|
|
stageName = name
|
|
case .index(let idx):
|
|
// Find stage by index
|
|
guard idx < stages.count else {
|
|
throw BuildExecutorError.stageNotFound("Stage index \(idx) out of bounds")
|
|
}
|
|
stageName = stages[idx].name ?? "stage-\(idx)"
|
|
case .previous:
|
|
// Find the previous stage
|
|
guard let currentIndex = stages.firstIndex(where: { $0.id == stage.id }),
|
|
currentIndex > 0
|
|
else {
|
|
throw BuildExecutorError.stageNotFound("No previous stage available")
|
|
}
|
|
stageName = stages[currentIndex - 1].name ?? "stage-\(currentIndex - 1)"
|
|
}
|
|
|
|
guard let sourceStage = stagesByName[stageName] else {
|
|
throw BuildExecutorError.stageNotFound("Stage '\(stageName)' referenced in COPY --from not found")
|
|
}
|
|
stageDependencies[stage.id]?.insert(sourceStage.id.uuidString)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Topological sort to find execution order
|
|
var visited = Set<UUID>()
|
|
var recursionStack = Set<UUID>()
|
|
var executionOrder: [BuildStage] = []
|
|
|
|
func visit(_ stageId: UUID) throws {
|
|
if recursionStack.contains(stageId) {
|
|
throw BuildExecutorError.cyclicDependency
|
|
}
|
|
|
|
if visited.contains(stageId) {
|
|
return
|
|
}
|
|
|
|
recursionStack.insert(stageId)
|
|
|
|
// Visit dependencies first
|
|
if let deps = stageDependencies[stageId] {
|
|
for depIdString in deps {
|
|
if let depId = UUID(uuidString: depIdString),
|
|
let _ = stagesByID[depId]
|
|
{
|
|
try visit(depId)
|
|
}
|
|
}
|
|
}
|
|
|
|
recursionStack.remove(stageId)
|
|
visited.insert(stageId)
|
|
|
|
if let stage = stagesByID[stageId] {
|
|
executionOrder.append(stage)
|
|
}
|
|
}
|
|
|
|
// Start from target and work backwards
|
|
try visit(target.id)
|
|
|
|
// Also visit any stages that the target transitively depends on
|
|
var targetDependencies = Set<UUID>()
|
|
func collectDependencies(_ stageId: UUID) {
|
|
if let deps = stageDependencies[stageId] {
|
|
for depIdString in deps {
|
|
if let depId = UUID(uuidString: depIdString) {
|
|
if !targetDependencies.contains(depId) {
|
|
targetDependencies.insert(depId)
|
|
collectDependencies(depId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
collectDependencies(target.id)
|
|
|
|
// Include all required stages
|
|
for stage in stages {
|
|
if targetDependencies.contains(stage.id) || stage.id == target.id {
|
|
if !visited.contains(stage.id) {
|
|
try visit(stage.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return executionOrder
|
|
}
|
|
}
|
|
|
|
// MARK: - Thread-Safe Storage
|
|
|
|
/// Thread-safe storage for reference types
|
|
private final class AtomicStorage<T>: @unchecked Sendable {
|
|
private var _value: T
|
|
private let lock = NSLock()
|
|
|
|
var value: T {
|
|
get {
|
|
lock.withLock { _value }
|
|
}
|
|
set {
|
|
lock.withLock { _value = newValue }
|
|
}
|
|
}
|
|
|
|
init(initialValue: T) {
|
|
self._value = initialValue
|
|
}
|
|
}
|
|
|
|
extension AtomicStorage {
|
|
convenience init() where T: ExpressibleByNilLiteral {
|
|
self.init(initialValue: nil)
|
|
}
|
|
}
|