Skip to content

Commit 1597f8c

Browse files
committed
test: Enable parallel execution for lightweight tests + ResourceArbiter
- Remove .serialized from 10 lightweight test suites (pgmicro, nginx, redis, busybox) - Keep .serialized on heavy tests (wordpress, mysql) to prevent memory pressure - Add --parallel --num-workers 2 to run-tests.sh for concurrent test execution - Create ResourceArbiter.swift: manages ExecutionMode based on memory/I/O pressure - Create ResourceGuard.swift: provides memory monitoring for tests - Update run-tests.sh with parallel execution configuration Performance improvement: Lightweight tests can now run concurrently instead of sequentially, reducing overall test time on 8GB M2
1 parent 303a46f commit 1597f8c

21 files changed

Lines changed: 2115 additions & 13 deletions

Package.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ let package = Package(
6060
path: "Tests/TestHelpers",
6161
exclude: ["test_helpers.sh"]
6262
),
63+
64+
// Container Testing utilities (Memory Governor Trait, etc.)
65+
// Note: Uses Swift Testing framework (built into Swift 6+)
66+
.target(
67+
name: "ContainerTesting",
68+
dependencies: [],
69+
path: "Sources/ContainerTesting"
70+
),
6371

6472
// Tests
6573
.testTarget(
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//===----------------------------------------------------------------------===//
2+
// ResourceArbiter.swift
3+
// Execution Mode Arbiter for Container-Compose Tests
4+
// Manages parallel vs serialized execution based on memory and I/O pressure
5+
//===----------------------------------------------------------------------===//
6+
7+
import Foundation
8+
9+
public enum TestWeight {
10+
case lightweight // pgmicro, nginx, redis, busybox
11+
case medium // single container, no disk
12+
case heavy // wordpress, mysql, multi-container
13+
case snapshotHeavy // tests that trigger Apple Container snapshots
14+
}
15+
16+
public enum ExecutionMode {
17+
case parallel
18+
case serial
19+
case blocked(reason: String)
20+
}
21+
22+
public actor ResourceArbiter {
23+
public static let shared = ResourceArbiter()
24+
25+
private var inFlightCount: Int = 0
26+
private var snapshotOpsInProgress: Bool = false
27+
private let maxInFlightLightweight = 3
28+
29+
private let memoryThresholdMB = 1024 // 1GB - force serial below this
30+
private let ioPressureThreshold = 5 // block new tests if too many I/O ops
31+
32+
private init() {}
33+
34+
public func requestExecutionSlot(for weight: TestWeight) -> ExecutionMode {
35+
let freeMemory = ResourceHelper.getLatestFreeMemory() ?? 8192
36+
37+
if weight == .snapshotHeavy || snapshotOpsInProgress {
38+
return .blocked(reason: "Snapshot operation in progress - preventing I/O pile-up")
39+
}
40+
41+
if freeMemory < memoryThresholdMB {
42+
return .serial
43+
}
44+
45+
if weight == .heavy {
46+
return .serial
47+
}
48+
49+
if weight == .lightweight {
50+
if inFlightCount >= maxInFlightLightweight {
51+
return .blocked(reason: "Max in-flight containers (\(maxInFlightLightweight)) reached")
52+
}
53+
inFlightCount += 1
54+
return .parallel
55+
}
56+
57+
return .parallel
58+
}
59+
60+
public func releaseExecutionSlot(for weight: TestWeight) {
61+
if weight == .lightweight && inFlightCount > 0 {
62+
inFlightCount -= 1
63+
}
64+
}
65+
66+
public func beginSnapshotOperation() {
67+
snapshotOpsInProgress = true
68+
}
69+
70+
public func endSnapshotOperation() {
71+
snapshotOpsInProgress = false
72+
}
73+
74+
public func getStatus() -> (inFlight: Int, snapshotting: Bool, memoryMB: Int?) {
75+
let freeMemory = ResourceHelper.getLatestFreeMemory()
76+
return (inFlightCount, snapshotOpsInProgress, freeMemory)
77+
}
78+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
//===----------------------------------------------------------------------===//
2+
// ResourceGuard.swift
3+
// Memory Governor Trait for Swift Testing
4+
// Prevents tests from launching when memory is constrained
5+
// Critical for 8GB M2 environments to avoid swap death
6+
//===----------------------------------------------------------------------===//
7+
8+
import Foundation
9+
import Testing
10+
11+
/// Provides real-time memory monitoring for Swift Testing
12+
public struct ResourceHelper {
13+
14+
/// Reads the latest free memory from telemetry CSV
15+
/// - Parameter logPath: Path to the resource monitor CSV
16+
/// - Returns: Free memory in MB, or nil if not available
17+
public static func getLatestFreeMemory(logPath: String? = nil) -> Int? {
18+
let path = logPath ?? ProcessInfo.processInfo.environment["RESOURCE_LOG_PATH"]
19+
?? "/tmp/resource_monitor.csv"
20+
21+
guard FileManager.default.fileExists(atPath: path),
22+
let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
23+
// Fallback to direct system query
24+
return getSystemFreeMemory()
25+
}
26+
27+
let lines = contents.components(separatedBy: .newlines).filter { !$0.isEmpty }
28+
guard lines.count > 1 else { return nil }
29+
30+
// Get last data line (skip header)
31+
let lastLine = lines.last!
32+
let columns = lastLine.components(separatedBy: ",")
33+
34+
// CSV format: timestamp,free_memory_mb,active_memory_mb,cpu_percent,container_count
35+
guard columns.count >= 2,
36+
let freeMemory = Int(columns[1]) else {
37+
return nil
38+
}
39+
40+
return freeMemory
41+
}
42+
43+
/// Direct system memory query (fallback when telemetry not available)
44+
private static func getSystemFreeMemory() -> Int? {
45+
// Use vm_stat for macOS
46+
let task = Process()
47+
task.launchPath = "/usr/bin/vm_stat"
48+
task.arguments = []
49+
50+
let pipe = Pipe()
51+
task.standardOutput = pipe
52+
task.launch()
53+
task.waitUntilExit()
54+
55+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
56+
guard let output = String(data: data, encoding: .utf8) else { return nil }
57+
58+
// Parse vm_stat output
59+
var freePages: Int = 0
60+
var speculativePages: Int = 0
61+
var inactivePages: Int = 0
62+
63+
for line in output.components(separatedBy: .newlines) {
64+
if line.contains("Pages free"),
65+
let value = line.components(separatedBy: ":").last?.trimmingCharacters(in: .whitespaces),
66+
let num = Int(value.trimmingCharacters(in: CharacterSet(charactersIn: "."))) {
67+
freePages = num
68+
}
69+
if line.contains("Pages speculative"),
70+
let value = line.components(separatedBy: ":").last?.trimmingCharacters(in: .whitespaces),
71+
let num = Int(value.trimmingCharacters(in: CharacterSet(charactersIn: "."))) {
72+
speculativePages = num
73+
}
74+
if line.contains("Pages inactive"),
75+
let value = line.components(separatedBy: ":").last?.trimmingCharacters(in: .whitespaces),
76+
let num = Int(value.trimmingCharacters(in: CharacterSet(charactersIn: "."))) {
77+
inactivePages = num
78+
}
79+
}
80+
81+
let totalAvailable = freePages + speculativePages + inactivePages
82+
return (totalAvailable * 4096) / (1024 * 1024)
83+
}
84+
85+
/// Gets total physical memory
86+
public static func getTotalMemory() -> Int {
87+
let task = Process()
88+
task.launchPath = "/usr/sbin/sysctl"
89+
task.arguments = ["-n", "hw.memsize"]
90+
91+
let pipe = Pipe()
92+
task.standardOutput = pipe
93+
task.launch()
94+
task.waitUntilExit()
95+
96+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
97+
guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespaces),
98+
let bytes = UInt64(output) else {
99+
return 8192 // Fallback: assume 8GB
100+
}
101+
102+
return Int(bytes / (1024 * 1024))
103+
}
104+
}
105+
106+
/// Custom trait that guards tests based on available memory
107+
/// Use this for heavy tests to prevent OOM crashes on constrained systems
108+
public struct MemoryGuardTrait: TestScoping, TestTrait, SuiteTrait {
109+
110+
/// Minimum free memory required (in MB)
111+
public let minRequiredMB: Int
112+
113+
/// Optional path to telemetry log
114+
public let telemetryPath: String?
115+
116+
/// Initialize with memory requirement
117+
/// - Parameters:
118+
/// - minRequiredMB: Minimum free memory in MB
119+
/// - telemetryPath: Optional path to resource telemetry CSV
120+
public init(minRequiredMB: Int, telemetryPath: String? = nil) {
121+
self.minRequiredMB = minRequiredMB
122+
self.telemetryPath = telemetryPath
123+
}
124+
125+
public func provideScope(
126+
for test: Testing.Test,
127+
testCase: Testing.Test.Case?,
128+
performing function: () async throws -> Void
129+
) async throws {
130+
let freeMemory = ResourceHelper.getLatestFreeMemory(logPath: telemetryPath)
131+
let totalMemory = ResourceHelper.getTotalMemory()
132+
133+
if let free = freeMemory {
134+
let enabled = free >= minRequiredMB
135+
136+
if !enabled {
137+
print("⛔ MEMORY GUARD: Skipping '\(test.name)'")
138+
print(" Required: \(minRequiredMB)MB free")
139+
print(" Available: \(free)MB free")
140+
print(" Total: \(totalMemory)MB")
141+
print(" Reason: Insufficient memory - test would cause swap pressure")
142+
return // Skip test
143+
} else {
144+
print("✓ Memory Guard: '\(test.name)' can run (\(free)MB >= \(minRequiredMB)MB)")
145+
}
146+
} else {
147+
// If we can't determine memory, be conservative for heavy tests
148+
print("⚠️ Memory Guard: Cannot determine available memory, allowing test")
149+
}
150+
151+
// Memory guard passed - run the test
152+
try await function()
153+
}
154+
}
155+
156+
/// Extension for convenient trait syntax
157+
extension Trait where Self == MemoryGuardTrait {
158+
/// Requires minimum free memory for test execution
159+
/// - Parameter mb: Minimum free memory in MB
160+
public static func minMemory(_ mb: Int) -> MemoryGuardTrait {
161+
MemoryGuardTrait(minRequiredMB: mb)
162+
}
163+
164+
/// Guards for heavy container tests (typically needs 800MB+)
165+
public static var heavyContainer: MemoryGuardTrait {
166+
minMemory(800)
167+
}
168+
169+
/// Guards for medium tests (typically needs 400MB+)
170+
public static var mediumContainer: MemoryGuardTrait {
171+
minMemory(400)
172+
}
173+
174+
/// Guards for lightweight tests (typically needs 200MB+)
175+
public static var lightweight: MemoryGuardTrait {
176+
minMemory(200)
177+
}
178+
}
179+
180+
/// Simple memory check trait for basic enable/disable
181+
public struct MemoryCheckTrait: TestTrait {
182+
public let minMemoryMB: Int
183+
184+
public init(minMemoryMB: Int) {
185+
self.minMemoryMB = minMemoryMB
186+
}
187+
}

0 commit comments

Comments
 (0)