Skip to content

Commit af825bd

Browse files
committed
Make stacked content pruning reference-aware
1 parent 4ce8a11 commit af825bd

12 files changed

Lines changed: 1135 additions & 153 deletions

Sources/tart/Commands/Prune.swift

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,34 @@ struct Prune: AsyncParsableCommand {
8181
}
8282

8383
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
84-
let prunables: [Prunable] = try prunableStorages
85-
.flatMap { try $0.prunables() }
86-
.sorted { try $0.accessDate() > $1.accessDate() }
87-
88-
var spaceBudgetBytes = spaceBudgetBytes
89-
var prunablesToDelete: [Prunable] = []
90-
91-
for prunable in prunables {
92-
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
93-
94-
if prunableSizeBytes <= spaceBudgetBytes {
95-
// Don't mark for deletion as
96-
// there's a budget available
97-
spaceBudgetBytes -= prunableSizeBytes
98-
} else {
99-
// Mark for deletion
100-
prunablesToDelete.append(prunable)
84+
while true {
85+
let prunables: [Prunable] = try prunableStorages
86+
.flatMap { try $0.prunables() }
87+
.sorted { try $0.accessDate() > $1.accessDate() }
88+
89+
var remainingBudgetBytes = spaceBudgetBytes
90+
var prunableToDelete: Prunable?
91+
92+
for prunable in prunables {
93+
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
94+
95+
if prunableSizeBytes <= remainingBudgetBytes {
96+
// Don't mark for deletion as there is budget available
97+
remainingBudgetBytes -= prunableSizeBytes
98+
} else {
99+
prunableToDelete = prunable
100+
break
101+
}
102+
}
103+
104+
guard let prunableToDelete else {
105+
return
101106
}
102-
}
103107

104-
try prunablesToDelete.forEach { try $0.delete() }
108+
// Deleting one cached stacked image can change which remaining image
109+
// owns shared immutable content. Rebuild before choosing another.
110+
try prunableToDelete.delete()
111+
}
105112
}
106113

107114
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
@@ -145,46 +152,51 @@ struct Prune: AsyncParsableCommand {
145152
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
146153
}
147154

148-
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
155+
static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
149156
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
150157
defer { span.end() }
151158

152159
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
153-
let prunables: [Prunable] = try prunableStorages
154-
.flatMap { try $0.prunables() }
155-
.sorted { try $0.accessDate() < $1.accessDate() }
160+
let prunables = {
161+
try prunableStorages
162+
.flatMap { try $0.prunables() }
163+
.sorted { try $0.accessDate() < $1.accessDate() }
164+
}
156165

157166
// Does it even make sense to start?
158-
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
159-
if cacheUsedBytes < reclaimBytes {
167+
let initialPrunables = try prunables()
168+
let initialCacheUsedBytes = try initialPrunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
169+
guard let reclaimBytes = Int(exactly: reclaimBytes), initialCacheUsedBytes >= reclaimBytes else {
160170
return
161171
}
162172

163-
var cacheReclaimedBytes: Int = 0
164-
165-
var it = prunables.makeIterator()
173+
let targetCacheUsedBytes = initialCacheUsedBytes - reclaimBytes
174+
var currentCacheUsedBytes = initialCacheUsedBytes
175+
let initiatorPath = initiator.map {
176+
$0.url.resolvingSymlinksInPath().standardizedFileURL.path
177+
}
166178

167-
while cacheReclaimedBytes <= reclaimBytes {
168-
guard let prunable = it.next() else {
179+
while currentCacheUsedBytes > targetCacheUsedBytes {
180+
// Deleting one cached stacked image can transfer ownership of shared
181+
// immutable content to another record without reclaiming those bytes.
182+
// Rebuild the candidates after every deletion so automatic pruning
183+
// measures the cache that remains rather than a stale ownership snapshot.
184+
guard let prunable = try prunables().first(where: {
185+
$0.url.resolvingSymlinksInPath().standardizedFileURL.path != initiatorPath
186+
}) else {
169187
break
170188
}
171189

172-
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
173-
// do not prune the initiator
174-
continue
175-
}
176-
177190
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
178191

179192
OpenTelemetry.instance.contextProvider.activeSpan?
180193
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
181194

182-
cacheReclaimedBytes += allocatedSizeBytes
183-
184195
try prunable.delete()
196+
currentCacheUsedBytes = try prunables().map { try $0.allocatedSizeBytes() }.reduce(0, +)
185197
}
186198

187199
OpenTelemetry.instance.contextProvider.activeSpan?
188-
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
200+
.addEvent(name: "Reclaimed \(initialCacheUsedBytes - currentCacheUsedBytes) bytes")
189201
}
190202
}

Sources/tart/Commands/Run.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,20 +1039,24 @@ struct AdditionalDisk {
10391039
// A cached stacked image has no writable top overlay. Create one in a
10401040
// disposable directory for this additional-disk attachment.
10411041
let temporaryVMDir = try VMDirectory.temporary()
1042-
try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL)
1043-
try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL)
1044-
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
1045-
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
1046-
try lock.lock()
1042+
let temporaryVMDirLock = try FileLock(lockURL: temporaryVMDir.baseURL)
1043+
try temporaryVMDirLock.lock()
1044+
try vmDir.cloneStacked(
1045+
to: temporaryVMDir,
1046+
copyWritableOverlay: false,
1047+
generateMAC: false
1048+
)
10471049
let stack = try temporaryVMDir.diskImageStack()
1048-
try stack.createWritableOverlay()
10491050
let attachment = try stack.makeAttachment(
10501051
readOnly: diskReadOnly,
10511052
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
10521053
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
10531054
)
10541055

1055-
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment), temporaryDiskLock: lock)
1056+
return AdditionalDisk(
1057+
configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment),
1058+
temporaryDiskLock: temporaryVMDirLock
1059+
)
10561060
}
10571061

10581062
// Unfortunately, VZDiskImageStorageDeviceAttachment does not support

Sources/tart/Config.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct Config {
3333
continue
3434
}
3535

36-
try FileManager.default.removeItem(at: entry)
36+
try VMDirectory(baseURL: entry).removeFromDisk()
3737

3838
try lock.unlock()
3939
}

Sources/tart/ContentStore.swift

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct ContentStore {
1616

1717
let baseURL: URL
1818
private let digestDirectoryURL: URL
19+
private let pruneLockURL: URL
1920

2021
init() throws {
2122
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
@@ -24,13 +25,41 @@ struct ContentStore {
2425
init(baseURL: URL) throws {
2526
self.baseURL = baseURL
2627
self.digestDirectoryURL = baseURL.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
28+
self.pruneLockURL = baseURL.appendingPathComponent(".gc.lock")
2729
try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true)
30+
if !FileManager.default.fileExists(atPath: pruneLockURL.path) {
31+
_ = FileManager.default.createFile(atPath: pruneLockURL.path, contents: Data())
32+
}
33+
}
34+
35+
/// Serializes reference publication with the final reference check and
36+
/// deletion of immutable cache entries across Tart processes.
37+
func withPruneLock<T>(_ body: () throws -> T) throws -> T {
38+
let lock = try FileLock(lockURL: pruneLockURL)
39+
try lock.lock()
40+
defer { try? lock.unlock() }
41+
42+
return try body()
43+
}
44+
45+
/// Waits for any prune already scanning references to finish. After this
46+
/// returns, later prune runs can see a reference the caller already wrote.
47+
func synchronizePublishedReferences() throws {
48+
try withPruneLock {}
2849
}
2950

3051
func contentURL(for contentDigest: String) throws -> URL {
52+
try contentURL(for: contentDigest, under: baseURL)
53+
}
54+
55+
/// Returns the canonical path for a digest under an arbitrary content-store
56+
/// root without creating directories or lock files.
57+
func contentURL(for contentDigest: String, under baseURL: URL) throws -> URL {
3158
let digestHex = try validatedDigestHex(contentDigest)
3259

33-
return digestDirectoryURL.appendingPathComponent(digestHex)
60+
return baseURL
61+
.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
62+
.appendingPathComponent(digestHex)
3463
}
3564

3665
func temporaryContentURL(for contentDigest: String) throws -> URL {
@@ -61,16 +90,18 @@ struct ContentStore {
6190
return lockURL
6291
}
6392

64-
/// Returns a digest-addressed entry without rereading it. Pull verifies
65-
/// content hashes before accepting a cache hit; clone only needs a cheap
66-
/// structural check, like Tart's existing disk.img path.
93+
/// Returns an immutable digest-addressed entry without rereading it. Files
94+
/// are verified when installed and when deciding whether a pull is a cache
95+
/// hit; normal clone/run/push paths trust the store like Tart's disk.img.
6796
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
6897
let url = try contentURL(for: contentDigest)
6998

7099
guard FileManager.default.fileExists(atPath: url.path) else {
71100
return nil
72101
}
73102

103+
try url.updateAccessDate()
104+
74105
return url
75106
}
76107

@@ -88,6 +119,33 @@ struct ContentStore {
88119
return url
89120
}
90121

122+
/// Returns immutable content files that no retained cached image or local VM
123+
/// references. Callers may prune these like other cache entries.
124+
func prunables(excluding referencedContentDigests: Swift.Set<String>) throws -> [URL] {
125+
guard let enumerator = FileManager.default.enumerator(
126+
at: digestDirectoryURL,
127+
includingPropertiesForKeys: [.isRegularFileKey],
128+
options: [.skipsSubdirectoryDescendants]
129+
) else {
130+
return []
131+
}
132+
133+
return try enumerator.compactMap { element in
134+
guard let url = element as? URL,
135+
try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else {
136+
return nil
137+
}
138+
139+
let contentDigest = "\(Self.digestPrefix)\(url.lastPathComponent)"
140+
guard (try? validatedDigestHex(contentDigest)) != nil,
141+
!referencedContentDigests.contains(contentDigest) else {
142+
return nil
143+
}
144+
145+
return url
146+
}
147+
}
148+
91149
/// Move a fully reconstructed temporary file into the cache after verifying
92150
/// its semantic identity. The caller should create the temporary file with
93151
/// temporaryContentURL(for:) or resumableContentURL(for:) so rename stays on

Sources/tart/OCI/Manifest.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ struct TartDiskFileGroup: Equatable {
3737
/// Whole reconstructed-file digest. Existing flat manifests do not have
3838
/// this until a macOS 27 clone normalizes its local manifest copy.
3939
var contentDigest: String?
40+
41+
/// Expected size of the complete disk file reconstructed from these chunks.
42+
func uncompressedSize() -> UInt64? {
43+
var result: UInt64 = 0
44+
for chunk in chunks {
45+
guard let size = chunk.uncompressedSize() else {
46+
return nil
47+
}
48+
49+
let addition = result.addingReportingOverflow(size)
50+
guard !addition.overflow else {
51+
return nil
52+
}
53+
result = addition.partialValue
54+
}
55+
56+
return result
57+
}
4058
}
4159

4260
enum TartDiskRepresentation: Equatable {
@@ -172,6 +190,16 @@ struct OCIManifest: Codable, Equatable {
172190
return .stacked(base: base, overlays: overlays)
173191
}
174192

193+
/// Returns content-store digests needed to reconstruct this disk stack.
194+
func diskContentDigests() throws -> [String] {
195+
switch try tartDiskRepresentation() {
196+
case .flat(let base):
197+
return base.contentDigest.map { [$0] } ?? []
198+
case .stacked(let base, let overlays):
199+
return ([base] + overlays).compactMap(\.contentDigest)
200+
}
201+
}
202+
175203
private func validateChunkMetadata(_ chunks: [OCIManifestLayer]) throws {
176204
guard chunks.allSatisfy({ $0.uncompressedSize() != nil && $0.uncompressedContentDigest() != nil }) else {
177205
throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest")

Sources/tart/VMDirectory+DiskImageStack.swift

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import Foundation
22

33
extension VMDirectory {
4+
/// Returns content-store digests needed to reconstruct this VM's disk stack.
5+
func diskContentDigests() throws -> [String] {
6+
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
7+
return try manifest.diskContentDigests()
8+
}
9+
410
func diskImageStack(contentStore providedStore: ContentStore? = nil) throws -> DiskImageStack {
511
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
612
let base: TartDiskFileGroup
@@ -42,13 +48,18 @@ extension VMDirectory {
4248
generateMAC: Bool,
4349
contentStore: ContentStore? = nil
4450
) throws {
45-
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
46-
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
47-
try FileManager.default.copyItem(at: manifestURL, to: destination.manifestURL)
51+
let contentStore = try contentStore ?? ContentStore()
52+
try contentStore.withPruneLock {
53+
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
54+
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
55+
try FileManager.default.copyItem(at: manifestURL, to: destination.manifestURL)
56+
57+
if copyWritableOverlay {
58+
try FileManager.default.copyItem(at: overlayURL, to: destination.overlayURL)
59+
}
60+
}
4861

49-
if copyWritableOverlay {
50-
try FileManager.default.copyItem(at: overlayURL, to: destination.overlayURL)
51-
} else {
62+
if !copyWritableOverlay {
5263
try destination.diskImageStack(contentStore: contentStore).createWritableOverlay()
5364
}
5465

@@ -67,17 +78,6 @@ extension VMDirectory {
6778
let contentDigest = try Digest.hash(diskURL)
6879
let contentStore = try providedStore ?? ContentStore()
6980

70-
if try contentStore.existingContentURL(for: contentDigest) == nil {
71-
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
72-
do {
73-
try FileManager.default.copyItem(at: diskURL, to: temporaryURL)
74-
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
75-
} catch {
76-
try? FileManager.default.removeItem(at: temporaryURL)
77-
throw error
78-
}
79-
}
80-
8181
var manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
8282
guard case .flat = try manifest.tartDiskRepresentation() else {
8383
throw RuntimeError.VMConfigurationError("--stacked cannot use an image that already has a stacked disk")
@@ -101,7 +101,24 @@ extension VMDirectory {
101101

102102
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
103103
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
104-
try manifest.toJSON().write(to: destination.manifestURL)
104+
try contentStore.withPruneLock {
105+
try manifest.toJSON().write(to: destination.manifestURL)
106+
}
107+
108+
// Publish the temporary VM's manifest before installing the shared base.
109+
// Reference-aware pruning includes in-progress manifests, so the content
110+
// cannot be collected in the window before this VM is moved into place.
111+
if try contentStore.contentURLIfPresent(for: contentDigest) == nil {
112+
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
113+
do {
114+
try FileManager.default.copyItem(at: diskURL, to: temporaryURL)
115+
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
116+
} catch {
117+
try? FileManager.default.removeItem(at: temporaryURL)
118+
throw error
119+
}
120+
}
121+
105122
try destination.diskImageStack(contentStore: contentStore).createWritableOverlay()
106123

107124
if generateMAC {

0 commit comments

Comments
 (0)