| 1 | import Foundation |
| 2 | |
| 3 | /// Headless cache-budget check: `sequencer --cachetest <file.sq> [capGB]`. |
| 4 | /// |
| 5 | /// Loads the real project into the headless context, jumps the playhead to |
| 6 | /// several spots spread across the timeline, and lets demand builds + |
| 7 | /// evictions run at each stop while continuously asserting the invariant |
| 8 | /// that makes the cap a hard ceiling: |
| 9 | /// |
| 10 | /// ledger + reserved ≤ cap (checked every second) |
| 11 | /// bytes on disk ≤ cap + slack (checked every few seconds) |
| 12 | /// |
| 13 | /// At each stop it also waits for the chunk under the playhead to become |
| 14 | /// covered — proving the system optimizes around where the user is even when |
| 15 | /// the whole project can't fit (the "Deltarune problem"). |
| 16 | func runCacheTest(path: String, capGB: Int) { |
| 17 | func spin(_ seconds: Double) { |
| 18 | RunLoop.main.run(until: Date().addingTimeInterval(seconds)) |
| 19 | } |
| 20 | |
| 21 | print("== Sequencer cachetest ==") |
| 22 | if capGB > 0 { UserDefaults.standard.set(capGB, forKey: "maxCacheGB") } |
| 23 | let pipeline = MediaPipeline.shared |
| 24 | let cap = pipeline.maxCacheBytes |
| 25 | print("cache: \(pipeline.cacheRoot.path)") |
| 26 | print(String(format: "cap: %.1f GB", Double(cap) / 1e9)) |
| 27 | |
| 28 | // Load the project (package or legacy flat file) into the headless context. |
| 29 | let url = URL(fileURLWithPath: path) |
| 30 | var jsonURL = url |
| 31 | var isDir: ObjCBool = false |
| 32 | if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { |
| 33 | jsonURL = url.appendingPathComponent("project.json") |
| 34 | } |
| 35 | guard let data = try? Data(contentsOf: jsonURL), |
| 36 | let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else { |
| 37 | print("FAIL: cannot read project at \(path)") |
| 38 | return |
| 39 | } |
| 40 | let ctx = DocumentContext.headless |
| 41 | ctx.store.adopt(doc.project) |
| 42 | let project = ctx.store.project |
| 43 | let span = project.clips.map(\.end).max() ?? 0 |
| 44 | print(String(format: "project: %d clips, %d media, %.0f s timeline", |
| 45 | project.clips.count, project.media.count, span)) |
| 46 | |
| 47 | // Wait for the launch reconcile so admission is open. |
| 48 | let bootDeadline = Date().addingTimeInterval(60) |
| 49 | while !pipeline.budgetReady && Date() < bootDeadline { spin(0.1) } |
| 50 | guard pipeline.budgetReady else { print("FAIL: ledger reconcile timed out"); return } |
| 51 | |
| 52 | // Playhead stops: spread across the timeline, snapped to the midpoint of a |
| 53 | // real video clip so there is always something to cover. |
| 54 | var stops: [Double] = [] |
| 55 | for frac in [0.05, 0.35, 0.65, 0.9] { |
| 56 | let t = span * frac |
| 57 | if let clip = project.clips |
| 58 | .filter({ $0.kind == .video }) |
| 59 | .min(by: { abs(($0.start + $0.end) / 2 - t) < abs(($1.start + $1.end) / 2 - t) }) { |
| 60 | let mid = (clip.start + clip.end) / 2 |
| 61 | if !stops.contains(where: { abs($0 - mid) < 60 }) { stops.append(mid) } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | var failures = 0 |
| 66 | var walkTick = 0 |
| 67 | func checkInvariant(_ label: String) { |
| 68 | let held = pipeline.ledgerBytes <= cap |
| 69 | if !held { |
| 70 | failures += 1 |
| 71 | print(String(format: "FAIL: ledger %.2f GB over cap (%@)", |
| 72 | Double(pipeline.ledgerBytes) / 1e9, label)) |
| 73 | } |
| 74 | // The disk walk is the ground truth the ledger approximates. Slack |
| 75 | // covers unbudgeted small writes (filmstrips) between reconciles. |
| 76 | walkTick += 1 |
| 77 | if walkTick % 5 == 0 { |
| 78 | let disk = MediaPipeline.directorySize(pipeline.cacheRoot) |
| 79 | if disk > cap + 500_000_000 { |
| 80 | failures += 1 |
| 81 | print(String(format: "FAIL: %.2f GB on disk exceeds cap (%@)", |
| 82 | Double(disk) / 1e9, label)) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | for (i, t) in stops.enumerated() { |
| 88 | print(String(format: "\n-- stop %d: playhead %.0f s", i + 1, t)) |
| 89 | ctx.playback.seek(to: t) |
| 90 | ctx.chunks.ensure(for: project) |
| 91 | ctx.chunks.updateDemand(force: true) |
| 92 | let clipsHere = project.clips.filter { |
| 93 | $0.kind == .video && $0.start <= t && t < $0.end |
| 94 | } |
| 95 | let deadline = Date().addingTimeInterval(150) |
| 96 | var covered = false |
| 97 | var announced = false |
| 98 | while Date() < deadline { |
| 99 | spin(1.0) |
| 100 | checkInvariant("stop \(i + 1)") |
| 101 | var missing: [String] = [] |
| 102 | for clip in clipsHere { |
| 103 | guard let media = project.media(clip.mediaId) else { continue } |
| 104 | let src = clip.srcIn + (t - clip.start) * clip.speed |
| 105 | if !ctx.chunks.isCovered(media: media, sourceTime: src) |
| 106 | && !ctx.chunks.buildFailed(media: media, sourceTime: src) { |
| 107 | missing.append("\(media.cacheKey.prefix(8))#\(ChunkManager.chunkIndex(forSource: src))") |
| 108 | } |
| 109 | } |
| 110 | covered = missing.isEmpty |
| 111 | if covered { break } |
| 112 | if !announced { |
| 113 | announced = true |
| 114 | NSLog("[cachetest] stop %d waiting on: %@", i + 1, |
| 115 | missing.joined(separator: " ")) |
| 116 | } |
| 117 | } |
| 118 | print(String(format: "coverage at playhead: %@ (ledger %.2f GB)", |
| 119 | covered ? "OK" : "FAIL (not covered in 150s)", |
| 120 | Double(pipeline.ledgerBytes) / 1e9)) |
| 121 | if !covered { failures += 1 } |
| 122 | } |
| 123 | |
| 124 | // Let any in-flight builds settle, then final ground-truth comparison. |
| 125 | spin(5) |
| 126 | let disk = MediaPipeline.directorySize(pipeline.cacheRoot) |
| 127 | print(String(format: "\nfinal: ledger %.2f GB, disk %.2f GB, cap %.1f GB", |
| 128 | Double(pipeline.ledgerBytes) / 1e9, Double(disk) / 1e9, |
| 129 | Double(cap) / 1e9)) |
| 130 | if disk > cap + 500_000_000 { failures += 1; print("FAIL: final disk size over cap") } |
| 131 | print(failures == 0 ? "\n== cachetest PASS ==" : "\n== cachetest FAIL (\(failures)) ==") |
| 132 | } |