Skip to content

Commit 4f0c166

Browse files
ikesyoclaude
andcommitted
Add automatic cache sharing for restored frameworks
Enhance FrameworkProducer to automatically share restored frameworks to other producer cache storages, avoiding duplicate caching by checking existsValidCache before storing. This improves cache efficiency by ensuring restored frameworks are available across multiple storage backends. Changes: - Add shareRestoredCachesToProducers method to distribute restored caches - Add existsValidCache check to prevent duplicate caching - Add comprehensive Swift Testing test suite for cache sharing functionality - Add logging for cache sharing operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ff81585 commit 4f0c166

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed

Sources/ScipioKit/Producer/FrameworkProducer.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ struct FrameworkProducer {
105105
to: storagesWithConsumer,
106106
cacheSystem: cacheSystem
107107
)
108+
109+
if !restored.isEmpty {
110+
await shareRestoredCachesToProducers(restored, cacheSystem: cacheSystem)
111+
}
112+
108113
let skipTargets = valid.union(restored)
109114
targetGraph.remove(skipTargets)
110115
}
@@ -369,6 +374,44 @@ struct FrameworkProducer {
369374
logger.warning("⚠️ Could not create VersionFile. This framework will not be cached.", metadata: .color(.yellow))
370375
}
371376
}
377+
378+
internal func shareRestoredCachesToProducers(_ restoredTargets: Set<CacheSystem.CacheTarget>, cacheSystem: CacheSystem) async {
379+
let storagesWithProducer = cachePolicies.storages(for: .producer)
380+
guard !storagesWithProducer.isEmpty else { return }
381+
382+
logger.info("🔄 Sharing \(restoredTargets.count) restored framework(s) to other cache storages", metadata: .color(.blue))
383+
384+
for storage in storagesWithProducer {
385+
await shareCachesToStorage(restoredTargets, to: storage, cacheSystem: cacheSystem)
386+
}
387+
}
388+
389+
private func shareCachesToStorage(_ targets: Set<CacheSystem.CacheTarget>, to storage: any CacheStorage, cacheSystem: CacheSystem) async {
390+
let chunked = targets.chunks(ofCount: storage.parallelNumber ?? CacheSystem.defaultParalellNumber)
391+
392+
for chunk in chunked {
393+
await withTaskGroup(of: Void.self) { group in
394+
for target in chunk {
395+
group.addTask {
396+
do {
397+
let cacheKey = try await cacheSystem.calculateCacheKey(of: target)
398+
let hasCache = try await storage.existsValidCache(for: cacheKey)
399+
guard !hasCache else { return }
400+
401+
let frameworkName = target.buildProduct.frameworkName
402+
let frameworkPath = outputDir.appendingPathComponent(frameworkName)
403+
404+
logger.info("🔄 Share \(frameworkName) to cache storage: \(storage.displayName)", metadata: .color(.blue))
405+
try await storage.cacheFramework(frameworkPath, for: cacheKey)
406+
} catch {
407+
logger.warning("⚠️ Failed to share cache to \(storage.displayName): \(error.localizedDescription)", metadata: .color(.yellow))
408+
}
409+
}
410+
}
411+
await group.waitForAll()
412+
}
413+
}
414+
}
372415
}
373416

374417
extension [Runner.Options.CachePolicy] {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import Foundation
2+
import Testing
3+
@testable @_spi(Internals) import ScipioKit
4+
import ScipioStorage
5+
import Logging
6+
7+
struct FrameworkProducerTests {
8+
9+
init() {
10+
LoggingSystem.bootstrap { _ in SwiftLogNoOpLogHandler() }
11+
}
12+
13+
@Test func cacheSharing() async throws {
14+
let tempDir = FileManager.default.temporaryDirectory
15+
let outputDir = tempDir.appendingPathComponent("test-output-\(UUID().uuidString)")
16+
17+
try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
18+
19+
defer {
20+
try? FileManager.default.removeItem(at: outputDir)
21+
}
22+
23+
// Create mock cache storages
24+
let consumerStorage = MockCacheStorage(name: "consumer")
25+
let producer1Storage = MockCacheStorage(name: "producer1")
26+
let producer2Storage = MockCacheStorage(name: "producer2")
27+
28+
// Create cache policies
29+
let consumerPolicy = Runner.Options.CachePolicy(
30+
storage: consumerStorage,
31+
actors: [.consumer]
32+
)
33+
let producerPolicy1 = Runner.Options.CachePolicy(
34+
storage: producer1Storage,
35+
actors: [.producer]
36+
)
37+
let producerPolicy2 = Runner.Options.CachePolicy(
38+
storage: producer2Storage,
39+
actors: [.producer]
40+
)
41+
42+
let cachePolicies = [consumerPolicy, producerPolicy1, producerPolicy2]
43+
44+
// Use CacheKeyTests/AsRemotePackage fixture to avoid revision detection issues
45+
let testPackagePath = URL(fileURLWithPath: #filePath)
46+
.deletingLastPathComponent()
47+
.appendingPathComponent("Resources")
48+
.appendingPathComponent("Fixtures")
49+
.appendingPathComponent("CacheKeyTests")
50+
.appendingPathComponent("AsRemotePackage")
51+
52+
let descriptionPackage = try await DescriptionPackage(
53+
packageDirectory: testPackagePath,
54+
mode: .prepareDependencies,
55+
onlyUseVersionsFromResolvedFile: false
56+
)
57+
58+
let frameworkProducer = FrameworkProducer(
59+
descriptionPackage: descriptionPackage,
60+
buildOptions: BuildOptions(
61+
buildConfiguration: .debug,
62+
isDebugSymbolsEmbedded: false,
63+
frameworkType: .dynamic,
64+
sdks: [.iOS],
65+
extraFlags: nil,
66+
extraBuildParameters: nil,
67+
enableLibraryEvolution: false,
68+
keepPublicHeadersStructure: false,
69+
customFrameworkModuleMapContents: nil,
70+
stripStaticDWARFSymbols: false
71+
),
72+
buildOptionsMatrix: [:],
73+
cachePolicies: cachePolicies,
74+
overwrite: false,
75+
outputDir: outputDir
76+
)
77+
78+
let cacheSystem = CacheSystem(outputDirectory: outputDir)
79+
80+
// Create a mock cache target using the real package info
81+
let package = try #require(
82+
descriptionPackage
83+
.graph
84+
.allPackages
85+
.values
86+
.first { $0.name == "scipio-testing" }
87+
)
88+
let target = try #require(package.targets.first { $0.name == "ScipioTesting" })
89+
let buildProduct = BuildProduct(package: package, target: target)
90+
let mockTarget = CacheSystem.CacheTarget(
91+
buildProduct: buildProduct,
92+
buildOptions: BuildOptions(
93+
buildConfiguration: .debug,
94+
isDebugSymbolsEmbedded: false,
95+
frameworkType: .dynamic,
96+
sdks: [.iOS],
97+
extraFlags: nil,
98+
extraBuildParameters: nil,
99+
enableLibraryEvolution: false,
100+
keepPublicHeadersStructure: false,
101+
customFrameworkModuleMapContents: nil,
102+
stripStaticDWARFSymbols: false
103+
)
104+
)
105+
106+
let restoredTargets: Set<CacheSystem.CacheTarget> = [mockTarget]
107+
let mockCacheKey = try await cacheSystem.calculateCacheKey(of: mockTarget)
108+
109+
// Setup initial state: producer1 has cache, producer2 doesn't
110+
try await producer1Storage.setHasCache(for: mockCacheKey, value: true)
111+
try await producer2Storage.setHasCache(for: mockCacheKey, value: false)
112+
113+
// Test FrameworkProducer's cache sharing functionality
114+
await frameworkProducer.shareRestoredCachesToProducers(restoredTargets, cacheSystem: cacheSystem)
115+
116+
let producer1CacheCalls = await producer1Storage.getCacheFrameworkCalls()
117+
let producer2CacheCalls = await producer2Storage.getCacheFrameworkCalls()
118+
119+
// Verify behavior: producer1 already has cache so no call, producer2 doesn't have cache so gets a call
120+
#expect(producer1CacheCalls.count == 0, "Producer1 already has cache, so cacheFramework should not be called")
121+
try #require(producer2CacheCalls.count == 1, "Producer2 doesn't have cache, so cacheFramework should be called once")
122+
123+
// Verify the cache call was made with correct parameters
124+
let actualFrameworkPath = producer2CacheCalls[0].frameworkPath
125+
let expectedFrameworkPath = outputDir.appendingPathComponent(buildProduct.frameworkName)
126+
#expect(actualFrameworkPath == expectedFrameworkPath, "Framework path should match")
127+
128+
// Verify the cache call was made with the correct cache key
129+
let expectedCacheKey = try mockCacheKey.calculateChecksum()
130+
#expect(producer2CacheCalls[0].cacheKey == expectedCacheKey, "Cache key should match the actual cache key used")
131+
}
132+
}
133+
134+
// MARK: - Mock Classes
135+
136+
private struct MockCacheKey: CacheKey {
137+
let targetName: String
138+
139+
func calculateChecksum() throws -> String {
140+
return "mock-checksum-\(targetName)"
141+
}
142+
}
143+
144+
// MARK: - Mock Cache Storage
145+
146+
private actor MockCacheStorage: CacheStorage {
147+
let displayName: String
148+
let parallelNumber: Int? = 1
149+
150+
private var hasCacheMap: [String: Bool] = [:]
151+
private var cacheFrameworkCalls: [(frameworkPath: URL, cacheKey: String)] = []
152+
153+
init(name: String) {
154+
self.displayName = name
155+
}
156+
157+
func existsValidCache(for cacheKey: some CacheKey) async throws -> Bool {
158+
let keyString = try cacheKey.calculateChecksum()
159+
return hasCacheMap[keyString] ?? false
160+
}
161+
162+
func fetchArtifacts(for cacheKey: some CacheKey, to destinationDir: URL) async throws {
163+
// Mock implementation - no-op
164+
}
165+
166+
func cacheFramework(_ frameworkPath: URL, for cacheKey: some CacheKey) async throws {
167+
let keyString = try cacheKey.calculateChecksum()
168+
let call = (frameworkPath: frameworkPath, cacheKey: keyString)
169+
cacheFrameworkCalls.append(call)
170+
}
171+
172+
// Test helper methods
173+
func setHasCache(for cacheKey: some CacheKey, value: Bool) async throws {
174+
let keyString = try cacheKey.calculateChecksum()
175+
hasCacheMap[keyString] = value
176+
}
177+
178+
func getCacheFrameworkCalls() -> [(frameworkPath: URL, cacheKey: String)] {
179+
return cacheFrameworkCalls
180+
}
181+
}

0 commit comments

Comments
 (0)