diff --git a/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift b/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift index 0dff1256..51edcf83 100644 --- a/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift +++ b/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift @@ -118,45 +118,6 @@ public struct CachedResult: Sendable { } } -/// A memory-based cache implementation for development/testing. -public actor MemoryBuildCache: BuildCache { - private var storage: [CacheKey: CachedResult] = [:] - private var hits: Int = 0 - private var misses: Int = 0 - - public init() {} - - public func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? { - guard let result = storage[key] else { - misses += 1 - return nil - } - hits += 1 - return result - } - - public func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async { - storage[key] = result - } - - public func statistics() async -> CacheStatistics { - CacheStatistics( - entryCount: storage.count, - totalSize: UInt64(storage.count * 1024), // Rough estimate - hitRate: hits + misses > 0 ? Double(hits) / Double(hits + misses) : 0, - oldestEntryAge: 0, - mostRecentEntryAge: 0, - evictionPolicy: "none", - compressionRatio: 1.0, - averageEntrySize: 1024, - operationMetrics: .empty, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) - } -} - /// A no-op cache implementation that never caches. public struct NoOpBuildCache: BuildCache { public init() {} @@ -177,7 +138,6 @@ public struct NoOpBuildCache: BuildCache { oldestEntryAge: 0, mostRecentEntryAge: 0, evictionPolicy: "none", - compressionRatio: 1.0, averageEntrySize: 0, operationMetrics: .empty, errorCount: 0, diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift index ceeeff04..f772c230 100644 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift +++ b/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift @@ -160,7 +160,6 @@ public actor CacheIndex: Sendable { oldestEntryAge: oldestAge, mostRecentEntryAge: newestAge, evictionPolicy: "lru", - compressionRatio: 1.0, // TODO: Calculate actual compression ratio averageEntrySize: avgSize, operationMetrics: .empty, errorCount: 0, diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift index 81f4ac84..4e3ca640 100644 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift +++ b/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerBuildIR +import ContainerBuildSnapshotter import ContainerizationOCI import Foundation @@ -25,31 +26,39 @@ struct CacheManifest: Codable, Sendable { let schemaVersion: Int let mediaType: String let config: CacheConfig - let layers: [CacheLayer] let annotations: [String: String] let subject: Descriptor? - static let currentSchemaVersion = 2 - static let manifestMediaType = "application/vnd.container-build.cache.manifest.v2+json" + /// Snapshot embedded directly in manifest + let snapshot: Snapshot? + + /// Environment changes embedded directly in manifest + let environmentChanges: [String: EnvironmentValue] + + /// Metadata changes embedded directly in manifest + let metadataChanges: [String: String] + + static let currentSchemaVersion = 5 // Incremented for direct Snapshot storage + static let manifestMediaType = "application/vnd.container-build.cache.manifest.v5+json" init( schemaVersion: Int = CacheManifest.currentSchemaVersion, mediaType: String = CacheManifest.manifestMediaType, config: CacheConfig, - layers: [CacheLayer], annotations: [String: String] = [:], - subject: Descriptor? = nil + subject: Descriptor? = nil, + snapshot: Snapshot? = nil, + environmentChanges: [String: EnvironmentValue] = [:], + metadataChanges: [String: String] = [:] ) { self.schemaVersion = schemaVersion self.mediaType = mediaType self.config = config - self.layers = layers self.annotations = annotations self.subject = subject - } - - func allContentDigests() -> [String] { - layers.map { $0.descriptor.digest } + self.snapshot = snapshot + self.environmentChanges = environmentChanges + self.metadataChanges = metadataChanges } } @@ -76,18 +85,6 @@ struct CacheConfig: Codable, Sendable { } } -/// Cache layer representing a component of the cached result -struct CacheLayer: Codable, Sendable { - let descriptor: Descriptor - let type: LayerType - - enum LayerType: String, Codable, Sendable { - case snapshot = "snapshot" - case environment = "environment" - case metadata = "metadata" - } -} - /// Serializable version of CacheKey for storage struct SerializedCacheKey: Codable, Sendable { let operationDigest: String @@ -124,9 +121,11 @@ extension CacheManifest { schemaVersion: schemaVersion, mediaType: mediaType, config: config, - layers: layers, annotations: annotations, - subject: subject + subject: subject, + snapshot: snapshot, + environmentChanges: environmentChanges, + metadataChanges: metadataChanges ) } @@ -139,22 +138,108 @@ extension CacheManifest { schemaVersion: schemaVersion, mediaType: mediaType, config: config, - layers: layers, annotations: newAnnotations, - subject: subject + subject: subject, + snapshot: snapshot, + environmentChanges: environmentChanges, + metadataChanges: metadataChanges ) } - /// Get total size of all layers - var totalSize: Int64 { - layers.reduce(0) { $0 + $1.descriptor.size } + /// Add or update environment changes + func withEnvironmentChanges(_ changes: [String: EnvironmentValue]) -> CacheManifest { + var newEnvironmentChanges = environmentChanges + for (key, value) in changes { + newEnvironmentChanges[key] = value + } + + return CacheManifest( + schemaVersion: schemaVersion, + mediaType: mediaType, + config: config, + annotations: annotations, + subject: subject, + snapshot: snapshot, + environmentChanges: newEnvironmentChanges, + metadataChanges: metadataChanges + ) } - /// Check if manifest is compressed - var isCompressed: Bool { - layers.contains { layer in - layer.descriptor.mediaType.contains("gzip") || layer.descriptor.mediaType.contains("zstd") || layer.descriptor.mediaType.contains("lz4") + /// Add or update metadata changes + func withMetadataChanges(_ changes: [String: String]) -> CacheManifest { + var newMetadataChanges = metadataChanges + for (key, value) in changes { + newMetadataChanges[key] = value } + + return CacheManifest( + schemaVersion: schemaVersion, + mediaType: mediaType, + config: config, + annotations: annotations, + subject: subject, + snapshot: snapshot, + environmentChanges: environmentChanges, + metadataChanges: newMetadataChanges + ) + } + + /// Check if manifest has a snapshot + var hasSnapshot: Bool { + snapshot != nil + } + + /// Check if manifest has environment changes + var hasEnvironmentChanges: Bool { + !environmentChanges.isEmpty + } + + /// Check if manifest has metadata changes + var hasMetadataChanges: Bool { + !metadataChanges.isEmpty + } + + /// Set or update the snapshot + func withSnapshot(_ snapshot: Snapshot) -> CacheManifest { + CacheManifest( + schemaVersion: schemaVersion, + mediaType: mediaType, + config: config, + annotations: annotations, + subject: subject, + snapshot: snapshot, + environmentChanges: environmentChanges, + metadataChanges: metadataChanges + ) + } + + /// Create a manifest with combined snapshot, environment and metadata changes + func withChanges( + snapshot: Snapshot? = nil, + environment: [String: EnvironmentValue] = [:], + metadata: [String: String] = [:] + ) -> CacheManifest { + var newEnvironmentChanges = environmentChanges + var newMetadataChanges = metadataChanges + + for (key, value) in environment { + newEnvironmentChanges[key] = value + } + + for (key, value) in metadata { + newMetadataChanges[key] = value + } + + return CacheManifest( + schemaVersion: schemaVersion, + mediaType: mediaType, + config: config, + annotations: annotations, + subject: subject, + snapshot: snapshot ?? self.snapshot, + environmentChanges: newEnvironmentChanges, + metadataChanges: newMetadataChanges + ) } } diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift index 05c4d647..dcffdba4 100644 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift +++ b/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift @@ -28,9 +28,6 @@ public struct CacheConfiguration: Sendable { /// Maximum age for cache entries public let maxAge: TimeInterval - /// Compression configuration - public let compression: CompressionConfiguration - /// Index database path public let indexPath: URL @@ -58,7 +55,6 @@ public struct CacheConfiguration: Sendable { public init( maxSize: UInt64 = 10 * 1024 * 1024 * 1024, // 10GB default maxAge: TimeInterval = 7 * 24 * 60 * 60, // 7 days default - compression: CompressionConfiguration = .default, indexPath: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! .appendingPathComponent("com.apple.container-build.cache.db"), evictionPolicy: EvictionPolicy = .lru, @@ -71,7 +67,6 @@ public struct CacheConfiguration: Sendable { ) { self.maxSize = maxSize self.maxAge = maxAge - self.compression = compression self.indexPath = indexPath self.evictionPolicy = evictionPolicy self.concurrency = concurrency @@ -83,31 +78,6 @@ public struct CacheConfiguration: Sendable { } } -public struct CompressionConfiguration: Sendable { - public let algorithm: CompressionAlgorithm - public let level: Int - public let minSize: Int // Minimum size to compress - - public enum CompressionAlgorithm: String, Sendable { - case zstd = "zstd" - case lz4 = "lz4" - case gzip = "gzip" - case none = "none" - } - - public static let `default` = CompressionConfiguration( - algorithm: .zstd, - level: 3, - minSize: 1024 // 1KB - ) - - public init(algorithm: CompressionAlgorithm, level: Int, minSize: Int) { - self.algorithm = algorithm - self.level = level - self.minSize = minSize - } -} - public enum EvictionPolicy: String, Sendable { case lru = "lru" // Least Recently Used case lfu = "lfu" // Least Frequently Used @@ -159,7 +129,6 @@ struct CacheIndexEntry: Sendable, Codable { let platform: Platform let operationType: String let contentDigests: [String] - let compression: String let transaction: UUID? var age: TimeInterval { @@ -186,7 +155,6 @@ public struct CacheStatistics: Sendable { public let oldestEntryAge: TimeInterval public let mostRecentEntryAge: TimeInterval public let evictionPolicy: String - public let compressionRatio: Double public let averageEntrySize: UInt64 public let operationMetrics: OperationMetrics public let errorCount: Int @@ -200,7 +168,6 @@ public struct CacheStatistics: Sendable { oldestEntryAge: 0, mostRecentEntryAge: 0, evictionPolicy: "none", - compressionRatio: 1.0, averageEntrySize: 0, operationMetrics: .empty, errorCount: 0, diff --git a/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift b/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift index 2fa69693..cf434688 100644 --- a/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift +++ b/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift @@ -110,16 +110,13 @@ public actor ContentAddressableCache: BuildCache { // Create content writer for this session let writer = try ContentWriter(for: ingestDir) - // Store components - let snapshotLayer = try await storeSnapshot(result.snapshot, using: writer) - let environmentLayer = try await storeEnvironment(result.environmentChanges, using: writer) - let metadataLayer = try await storeMetadata(result.metadataChanges, using: writer) - - // Create manifest + // Create manifest with embedded snapshot, environment and metadata let manifest = createManifest( key: key, operation: operation, - layers: [snapshotLayer, environmentLayer, metadataLayer].compactMap { $0 } + snapshot: result.snapshot, + environmentChanges: result.environmentChanges, + metadataChanges: result.metadataChanges ) // Write manifest @@ -128,14 +125,6 @@ public actor ContentAddressableCache: BuildCache { // Complete ingest session _ = try await contentStore.completeIngestSession(sessionId) - // Calculate total size - let _ = - manifestSize - + [snapshotLayer, environmentLayer, metadataLayer] - .compactMap { $0 } - .reduce(0) { $0 + $1.descriptor.size } - - // Record in index let descriptor = Descriptor( mediaType: manifest.mediaType, digest: manifestDigest.digestString, @@ -191,11 +180,8 @@ public actor ContentAddressableCache: BuildCache { } if let entry = try? await index.get(key: digest) { - // Get manifest to find content digests - if let manifest: CacheManifest = try? await contentStore.get(digest: entry.descriptor.digest) { - let digests = [entry.descriptor.digest] + manifest.allContentDigests() - _ = try? await contentStore.delete(digests: digests) - } + // Only need to delete the manifest itself now (no separate layers) + _ = try? await contentStore.delete(digests: [entry.descriptor.digest]) // Remove from index try? await index.remove(keys: [digest]) @@ -213,7 +199,6 @@ public actor ContentAddressableCache: BuildCache { oldestEntryAge: stats.oldestEntryAge, mostRecentEntryAge: stats.mostRecentEntryAge, evictionPolicy: "lru", - compressionRatio: 1.0, averageEntrySize: stats.entryCount > 0 ? stats.totalSize / UInt64(stats.entryCount) : 0, operationMetrics: .empty, errorCount: 0, @@ -268,72 +253,16 @@ public actor ContentAddressableCache: BuildCache { return (try? encoder.encode(normalized)) ?? Data() } - /// Store snapshot data and return layer descriptor. - private func storeSnapshot(_ snapshot: Snapshot, using writer: ContentWriter) async throws -> CacheLayer? { - let data = try JSONEncoder().encode(snapshot) - let compressed = try compress(data) - let (size, digest) = try writer.write(compressed) - - let descriptor = Descriptor.forCacheContent( - mediaType: "application/vnd.container-build.snapshot.v1+json", - digest: digest.digestString, - size: size, - compressed: true, - annotations: [ - "com.apple.container-build.compression": configuration.compression.algorithm.rawValue, - "com.apple.container-build.uncompressed-size": String(data.count), - ] - ) - - return CacheLayer(descriptor: descriptor, type: .snapshot) - } - - /// Store environment changes and return layer descriptor. - private func storeEnvironment(_ changes: [String: EnvironmentValue], using writer: ContentWriter) async throws -> CacheLayer? { - guard !changes.isEmpty else { return nil } - - let data = try JSONEncoder().encode(changes) - let compressed = try compress(data) - let (size, digest) = try writer.write(compressed) - - let descriptor = Descriptor.forCacheContent( - mediaType: "application/vnd.container-build.environment.v1+json", - digest: digest.digestString, - size: size, - compressed: true, - annotations: [ - "com.apple.container-build.compression": configuration.compression.algorithm.rawValue - ] - ) - - return CacheLayer(descriptor: descriptor, type: .environment) - } - - /// Store metadata changes and return layer descriptor. - private func storeMetadata(_ changes: [String: String], using writer: ContentWriter) async throws -> CacheLayer? { - guard !changes.isEmpty else { return nil } - - let data = try JSONEncoder().encode(changes) - let compressed = try compress(data) - let (size, digest) = try writer.write(compressed) - - let descriptor = Descriptor.forCacheContent( - mediaType: "application/vnd.container-build.metadata.v1+json", - digest: digest.digestString, - size: size, - compressed: true, - annotations: [ - "com.apple.container-build.compression": configuration.compression.algorithm.rawValue - ] - ) - - return CacheLayer(descriptor: descriptor, type: .metadata) - } - - /// Create cache manifest. - private func createManifest(key: CacheKey, operation: ContainerBuildIR.Operation, layers: [CacheLayer]) -> CacheManifest { + /// Create cache manifest with embedded snapshot, environment and metadata. + private func createManifest( + key: CacheKey, + operation: ContainerBuildIR.Operation, + snapshot: Snapshot? = nil, + environmentChanges: [String: EnvironmentValue] = [:], + metadataChanges: [String: String] = [:] + ) -> CacheManifest { CacheManifest( - schemaVersion: 2, + schemaVersion: CacheManifest.currentSchemaVersion, mediaType: CacheManifest.manifestMediaType, config: CacheConfig( cacheKey: SerializedCacheKey(from: key), @@ -341,80 +270,31 @@ public actor ContentAddressableCache: BuildCache { platform: key.platform, buildVersion: "1.0.0" ), - layers: layers, annotations: [ "com.apple.container-build.created": ISO8601DateFormatter().string(from: Date()), "com.apple.container-build.cache-version": configuration.cacheKeyVersion, ], - subject: nil - ) - } - - /// Reconstruct cached result from manifest. - private func reconstructResult(from manifest: CacheManifest) async throws -> CachedResult { - var snapshot: Snapshot? - var environmentChanges: [String: EnvironmentValue] = [:] - var metadataChanges: [String: String] = [:] - - for layer in manifest.layers { - guard let content = try await contentStore.get(digest: layer.descriptor.digest) else { - throw CacheError.storageFailed( - path: layer.descriptor.digest, underlyingError: NSError(domain: "Cache", code: 404, userInfo: [NSLocalizedDescriptionKey: "Missing content"])) - } - - let data = try content.data() - let decompressed = try decompress(data) - - switch layer.type { - case .snapshot: - snapshot = try JSONDecoder().decode(Snapshot.self, from: decompressed) - case .environment: - environmentChanges = try JSONDecoder().decode([String: EnvironmentValue].self, from: decompressed) - case .metadata: - metadataChanges = try JSONDecoder().decode([String: String].self, from: decompressed) - } - } - - guard let snapshot = snapshot else { - throw CacheError.storageFailed( - path: "Missing snapshot layer", underlyingError: NSError(domain: "Cache", code: 500, userInfo: [NSLocalizedDescriptionKey: "Invalid Manifest"])) - } - - return CachedResult( + subject: nil, snapshot: snapshot, environmentChanges: environmentChanges, metadataChanges: metadataChanges ) } - /// Compress data based on configuration. - private func compress(_ data: Data) throws -> Data { - guard data.count >= configuration.compression.minSize else { - return data + /// Reconstruct cached result from manifest. + private func reconstructResult(from manifest: CacheManifest) async throws -> CachedResult { + // Snapshot is embedded directly in the manifest + guard let snapshot = manifest.snapshot else { + throw CacheError.storageFailed( + path: "Missing snapshot", underlyingError: NSError(domain: "Cache", code: 500, userInfo: [NSLocalizedDescriptionKey: "No snapshot found in manifest"])) } - switch configuration.compression.algorithm { - case .none: - return data - case .zstd: - // Use zstd compression (would need actual implementation) - return data // Placeholder - case .lz4: - // Use lz4 compression (would need actual implementation) - return data // Placeholder - case .gzip: - // Use gzip compression - return try (data as NSData).compressed(using: .zlib) as Data - } - } - - /// Decompress data based on manifest metadata. - private func decompress(_ data: Data) throws -> Data { - // Check if data is compressed by trying to decompress - if let decompressed = try? (data as NSData).decompressed(using: .zlib) as Data { - return decompressed - } - return data + // Environment and metadata are also embedded directly in the manifest + return CachedResult( + snapshot: snapshot, + environmentChanges: manifest.environmentChanges, + metadataChanges: manifest.metadataChanges + ) } /// Check if eviction is needed and trigger it. @@ -445,11 +325,8 @@ public actor ContentAddressableCache: BuildCache { var digestsToDelete: [String] = [] for (key, entry) in sortedEntries { - // Get manifest to find all content digests - if let manifest: CacheManifest = try? await contentStore.get(digest: entry.descriptor.digest) { - digestsToDelete.append(entry.descriptor.digest) - digestsToDelete.append(contentsOf: manifest.allContentDigests()) - } + // Only need to delete the manifest itself (no separate layers) + digestsToDelete.append(entry.descriptor.digest) keysToEvict.append(key) evictedSize += UInt64(entry.descriptor.size) @@ -484,11 +361,8 @@ public actor ContentAddressableCache: BuildCache { if Date() > expirationDate { keysToEvict.append(key) - // Get manifest to find all content digests - if let manifest: CacheManifest = try? await contentStore.get(digest: entry.descriptor.digest) { - digestsToDelete.append(entry.descriptor.digest) - digestsToDelete.append(contentsOf: manifest.allContentDigests()) - } + // Only need to delete the manifest itself (no separate layers) + digestsToDelete.append(entry.descriptor.digest) } } } diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/BuildCacheProtocolTests.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/BuildCacheProtocolTests.swift deleted file mode 100644 index d10deef7..00000000 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/BuildCacheProtocolTests.swift +++ /dev/null @@ -1,220 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Foundation -import Testing - -@testable import ContainerBuildCache - -struct BuildCacheProtocolTests { - - // MARK: - MemoryBuildCache Tests - - @Test func memoryBuildCacheBasicOperations() async throws { - let cache = MemoryBuildCache() - - // Create test data - let key = createTestCacheKey() - let result = createTestCachedResult() - let operation = createTestOperation() - - // Test cache miss - let initialResult = await cache.get(key, for: operation) - #expect(initialResult == nil) - - // Test put - await cache.put(result, key: key, for: operation) - - // Test cache hit - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil) - #expect(cachedResult?.snapshot.id == result.snapshot.id) - #expect(cachedResult?.environmentChanges.count == result.environmentChanges.count) - #expect(cachedResult?.metadataChanges == result.metadataChanges) - } - - @Test func memoryBuildCacheStatistics() async throws { - let cache = MemoryBuildCache() - - // Initial statistics - let initialStats = await cache.statistics() - #expect(initialStats.entryCount == 0) - #expect(initialStats.totalSize == 0) - #expect(initialStats.hitRate == 0) - - // Add some entries - let key1 = createTestCacheKey(operationDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111") - let key2 = createTestCacheKey(operationDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222") - let result = createTestCachedResult() - let operation = createTestOperation() - - await cache.put(result, key: key1, for: operation) - await cache.put(result, key: key2, for: operation) - - // Test statistics after adding entries - let statsAfterPut = await cache.statistics() - #expect(statsAfterPut.entryCount == 2) - #expect(statsAfterPut.totalSize > 0) - - // Test hit rate calculation - _ = await cache.get(key1, for: operation) // Hit - _ = await cache.get(key1, for: operation) // Hit - let nonExistentKey = createTestCacheKey(operationDigest: "sha256:9999999999999999999999999999999999999999999999999999999999999999") - _ = await cache.get(nonExistentKey, for: operation) // Miss - - let finalStats = await cache.statistics() - #expect(abs(finalStats.hitRate - 2.0 / 3.0) < 0.01) // 2 hits out of 3 attempts - } - - @Test func memoryBuildCacheConcurrentAccess() async throws { - let cache = MemoryBuildCache() - let operation = createTestOperation() - - // Test concurrent puts and gets - await withTaskGroup(of: Void.self) { group in - // Concurrent puts - for i in 0..<10 { - let index = i - let key = createTestCacheKey(operationDigest: "sha256:\(String(format: "%064d", index))") - let result = createTestCachedResult() - group.addTask { @Sendable in - await cache.put(result, key: key, for: operation) - } - } - - // Concurrent gets - for i in 0..<10 { - let index = i - let key = createTestCacheKey(operationDigest: "sha256:\(String(format: "%064d", index))") - group.addTask { @Sendable in - _ = await cache.get(key, for: operation) - } - } - } - - let stats = await cache.statistics() - #expect(stats.entryCount == 10) - } - - // MARK: - NoOpBuildCache Tests - - @Test func noOpBuildCacheAlwaysReturnsNil() async throws { - let cache = NoOpBuildCache() - let key = createTestCacheKey() - let result = createTestCachedResult() - let operation = createTestOperation() - - // Test that get always returns nil - let initialResult = await cache.get(key, for: operation) - #expect(initialResult == nil) - - // Test that put doesn't store anything - await cache.put(result, key: key, for: operation) - - // Test that get still returns nil after put - let afterPutResult = await cache.get(key, for: operation) - #expect(afterPutResult == nil) - } - - @Test func noOpBuildCacheStatistics() async throws { - let cache = NoOpBuildCache() - - let stats = await cache.statistics() - #expect(stats.entryCount == 0) - #expect(stats.totalSize == 0) - #expect(stats.hitRate == 0) - #expect(stats.oldestEntryAge == 0) - #expect(stats.mostRecentEntryAge == 0) - } - - // MARK: - CacheKey Tests - - @Test func cacheKeyEquality() throws { - let digest1 = try Digest(parsing: "sha256:1111111111111111111111111111111111111111111111111111111111111111") - let digest2 = try Digest(parsing: "sha256:2222222222222222222222222222222222222222222222222222222222222222") - let platform = Platform.linuxAMD64 - - let key1 = CacheKey(operationDigest: digest1, inputDigests: [digest2], platform: platform) - let key2 = CacheKey(operationDigest: digest1, inputDigests: [digest2], platform: platform) - let key3 = CacheKey(operationDigest: digest2, inputDigests: [digest1], platform: platform) - - #expect(key1 == key2) - #expect(key1 != key3) - } - - @Test func cacheKeyHashing() throws { - let digest1 = try Digest(parsing: "sha256:1111111111111111111111111111111111111111111111111111111111111111") - let digest2 = try Digest(parsing: "sha256:2222222222222222222222222222222222222222222222222222222222222222") - let platform = Platform.linuxAMD64 - - let key1 = CacheKey(operationDigest: digest1, inputDigests: [digest2], platform: platform) - let key2 = CacheKey(operationDigest: digest1, inputDigests: [digest2], platform: platform) - - #expect(key1.hashValue == key2.hashValue) - - // Test that keys can be used in sets - let keySet: Set = [key1, key2] - #expect(keySet.count == 1) // Should deduplicate - } - - // MARK: - CachedResult Tests - - @Test func cachedResultInitialization() throws { - let snapshot = createTestSnapshot() - let environmentChanges: [String: EnvironmentValue] = [ - "PATH": .literal("/usr/bin"), - "HOME": .literal("/home/user"), - ] - let metadataChanges = ["build.time": "2024-01-01T12:00:00Z"] - - let result = CachedResult( - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - - #expect(result.snapshot.id == snapshot.id) - #expect(result.environmentChanges.count == 2) - #expect(result.metadataChanges["build.time"] == "2024-01-01T12:00:00Z") - } - - // MARK: - Helper Methods - - private func createTestCacheKey(operationDigest: String = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") -> ContainerBuildCache.CacheKey { - let digest = try! Digest(parsing: operationDigest) - let inputDigest = try! Digest(parsing: "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321") - return ContainerBuildCache.CacheKey( - operationDigest: digest, - inputDigests: [inputDigest], - platform: Platform.linuxAMD64 - ) - } - - private func createTestSnapshot() -> Snapshot { - TestDataFactory.createSnapshot() - } - - private func createTestCachedResult() -> CachedResult { - TestDataFactory.createCachedResult() - } - - private func createTestOperation() -> MockOperation { - TestDataFactory.createOperation() - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheConfigurationTests.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheConfigurationTests.swift deleted file mode 100644 index 0ab111bd..00000000 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheConfigurationTests.swift +++ /dev/null @@ -1,332 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerBuildIR -import Foundation -import Testing - -@testable import ContainerBuildCache - -struct CacheConfigurationTests { - - // MARK: - CacheConfiguration Tests - - @Test func cacheConfigurationDefaultValues() throws { - let config = CacheConfiguration() - - #expect(config.maxSize == 10 * 1024 * 1024 * 1024) // 10GB - #expect(config.maxAge == 7 * 24 * 60 * 60) // 7 days - #expect(config.evictionPolicy == .lru) - #expect(config.compression.algorithm == .zstd) - #expect(config.compression.level == 3) - #expect(config.compression.minSize == 1024) - #expect(config.verifyIntegrity == true) - #expect(config.sharding == nil) - #expect(config.gcInterval == 3600) // 1 hour - #expect(config.cacheKeyVersion == "v1") - #expect(config.defaultTTL == nil) - } - - @Test func cacheConfigurationCustomValues() throws { - let customIndexPath = FileManager.default.temporaryDirectory - .appendingPathComponent("custom-cache.db") - - let customCompression = CompressionConfiguration( - algorithm: .gzip, - level: 6, - minSize: 2048 - ) - - let customConcurrency = ConcurrencyConfiguration( - maxConcurrentReads: 50, - maxConcurrentWrites: 5, - maxConcurrentEvictions: 1 - ) - - let config = CacheConfiguration( - maxSize: 5 * 1024 * 1024 * 1024, // 5GB - maxAge: 3 * 24 * 60 * 60, // 3 days - compression: customCompression, - indexPath: customIndexPath, - evictionPolicy: .fifo, - concurrency: customConcurrency, - verifyIntegrity: false, - sharding: nil, - gcInterval: 1800, // 30 minutes - cacheKeyVersion: "v2", - defaultTTL: 86400 // 1 day - ) - - #expect(config.maxSize == 5 * 1024 * 1024 * 1024) - #expect(config.maxAge == 3 * 24 * 60 * 60) - #expect(config.compression.algorithm == .gzip) - #expect(config.compression.level == 6) - #expect(config.compression.minSize == 2048) - #expect(config.indexPath == customIndexPath) - #expect(config.evictionPolicy == .fifo) - #expect(config.concurrency.maxConcurrentReads == 50) - #expect(config.concurrency.maxConcurrentWrites == 5) - #expect(config.concurrency.maxConcurrentEvictions == 1) - #expect(config.verifyIntegrity == false) - #expect(config.sharding == nil) - #expect(config.gcInterval == 1800) - #expect(config.cacheKeyVersion == "v2") - #expect(config.defaultTTL == 86400) - } - - @Test func cacheConfigurationValidationLimits() throws { - // Test that configuration accepts reasonable limits - let config = CacheConfiguration( - maxSize: 1024, // 1KB minimum - maxAge: 60, // 1 minute minimum - gcInterval: 30 // 30 seconds minimum - ) - - #expect(config.maxSize == 1024) - #expect(config.maxAge == 60) - #expect(config.gcInterval == 30) - } - - // MARK: - EvictionPolicy Tests - - @Test func evictionPolicyLRU() throws { - let policy = EvictionPolicy.lru - #expect(policy == .lru) - - // Test that LRU is the default - let config = CacheConfiguration() - #expect(config.evictionPolicy == .lru) - } - - @Test func evictionPolicyFIFO() throws { - let policy = EvictionPolicy.fifo - #expect(policy == .fifo) - - let config = CacheConfiguration(evictionPolicy: .fifo) - #expect(config.evictionPolicy == .fifo) - } - - @Test func evictionPolicyARC() throws { - let policy = EvictionPolicy.arc - #expect(policy == .arc) - - let config = CacheConfiguration(evictionPolicy: .arc) - #expect(config.evictionPolicy == .arc) - } - - // MARK: - CompressionConfiguration Tests - - @Test func compressionConfigurationDefault() throws { - let compression = CompressionConfiguration.default - - #expect(compression.algorithm == .zstd) - #expect(compression.level == 3) - #expect(compression.minSize == 1024) - } - - @Test func compressionConfigurationCustomAlgorithms() throws { - let zstdConfig = CompressionConfiguration(algorithm: .zstd, level: 5, minSize: 512) - #expect(zstdConfig.algorithm == .zstd) - #expect(zstdConfig.level == 5) - #expect(zstdConfig.minSize == 512) - - let lz4Config = CompressionConfiguration(algorithm: .lz4, level: 1, minSize: 256) - #expect(lz4Config.algorithm == .lz4) - #expect(lz4Config.level == 1) - #expect(lz4Config.minSize == 256) - - let gzipConfig = CompressionConfiguration(algorithm: .gzip, level: 9, minSize: 2048) - #expect(gzipConfig.algorithm == .gzip) - #expect(gzipConfig.level == 9) - #expect(gzipConfig.minSize == 2048) - - let noneConfig = CompressionConfiguration(algorithm: .none, level: 0, minSize: 0) - #expect(noneConfig.algorithm == .none) - #expect(noneConfig.level == 0) - #expect(noneConfig.minSize == 0) - } - - @Test func compressionConfigurationAlgorithmRawValues() throws { - #expect(CompressionConfiguration.CompressionAlgorithm.zstd.rawValue == "zstd") - #expect(CompressionConfiguration.CompressionAlgorithm.lz4.rawValue == "lz4") - #expect(CompressionConfiguration.CompressionAlgorithm.gzip.rawValue == "gzip") - #expect(CompressionConfiguration.CompressionAlgorithm.none.rawValue == "none") - } - - // MARK: - ConcurrencyConfiguration Tests - - @Test func concurrencyConfigurationDefault() throws { - let concurrency = ConcurrencyConfiguration.default - - #expect(concurrency.maxConcurrentReads == 100) - #expect(concurrency.maxConcurrentWrites == 10) - #expect(concurrency.maxConcurrentEvictions == 2) - } - - @Test func concurrencyConfigurationCustom() throws { - let concurrency = ConcurrencyConfiguration( - maxConcurrentReads: 200, - maxConcurrentWrites: 20, - maxConcurrentEvictions: 5 - ) - - #expect(concurrency.maxConcurrentReads == 200) - #expect(concurrency.maxConcurrentWrites == 20) - #expect(concurrency.maxConcurrentEvictions == 5) - } - - @Test func concurrencyConfigurationMinimalValues() throws { - let concurrency = ConcurrencyConfiguration( - maxConcurrentReads: 1, - maxConcurrentWrites: 1, - maxConcurrentEvictions: 1 - ) - - #expect(concurrency.maxConcurrentReads == 1) - #expect(concurrency.maxConcurrentWrites == 1) - #expect(concurrency.maxConcurrentEvictions == 1) - } - - // MARK: - CacheStatistics Tests - - @Test func cacheStatisticsInitialization() throws { - let operationMetrics = OperationMetrics( - totalOperations: 100, - averageGetDuration: 0.05, - averagePutDuration: 0.1, - p95GetDuration: 0.08, - p95PutDuration: 0.15 - ) - let stats = CacheStatistics( - entryCount: 100, - totalSize: 1024 * 1024, - hitRate: 0.85, - oldestEntryAge: 3600, - mostRecentEntryAge: 60, - evictionPolicy: "lru", - compressionRatio: 0.7, - averageEntrySize: 10240, - operationMetrics: operationMetrics, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) - - #expect(stats.entryCount == 100) - #expect(stats.totalSize == 1024 * 1024) - #expect(abs(stats.hitRate - 0.85) < 0.001) - #expect(stats.oldestEntryAge == 3600) - #expect(stats.mostRecentEntryAge == 60) - } - - @Test func cacheStatisticsEmptyCache() throws { - let operationMetrics = OperationMetrics( - totalOperations: 0, - averageGetDuration: 0.0, - averagePutDuration: 0.0, - p95GetDuration: 0.0, - p95PutDuration: 0.0 - ) - let stats = CacheStatistics( - entryCount: 0, - totalSize: 0, - hitRate: 0.0, - oldestEntryAge: 0, - mostRecentEntryAge: 0, - evictionPolicy: "lru", - compressionRatio: 1.0, - averageEntrySize: 0, - operationMetrics: operationMetrics, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) - - #expect(stats.entryCount == 0) - #expect(stats.totalSize == 0) - #expect(stats.hitRate == 0.0) - #expect(stats.oldestEntryAge == 0) - #expect(stats.mostRecentEntryAge == 0) - } - - // MARK: - Integration Tests - - @Test func cacheConfigurationIntegration() throws { - // Test that all configuration components work together - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - - let config = CacheConfiguration( - maxSize: 512 * 1024 * 1024, // 512MB - maxAge: 24 * 60 * 60, // 1 day - compression: CompressionConfiguration(algorithm: .lz4, level: 1, minSize: 512), - indexPath: tempDir.appendingPathComponent("test-cache.db"), - evictionPolicy: .lru, - concurrency: ConcurrencyConfiguration( - maxConcurrentReads: 50, - maxConcurrentWrites: 5, - maxConcurrentEvictions: 1 - ), - verifyIntegrity: true, - sharding: nil, - gcInterval: 900, // 15 minutes - cacheKeyVersion: "test-v1", - defaultTTL: 7200 // 2 hours - ) - - // Verify all settings are preserved - #expect(config.maxSize == 512 * 1024 * 1024) - #expect(config.maxAge == 24 * 60 * 60) - #expect(config.compression.algorithm == .lz4) - #expect(config.compression.level == 1) - #expect(config.compression.minSize == 512) - #expect(config.evictionPolicy == .lru) - #expect(config.concurrency.maxConcurrentReads == 50) - #expect(config.concurrency.maxConcurrentWrites == 5) - #expect(config.concurrency.maxConcurrentEvictions == 1) - #expect(config.verifyIntegrity == true) - #expect(config.sharding == nil) - #expect(config.gcInterval == 900) - #expect(config.cacheKeyVersion == "test-v1") - #expect(config.defaultTTL == 7200) - } - - // MARK: - Edge Cases - - @Test func cacheConfigurationEdgeCases() throws { - // Test with very large values - let largeConfig = CacheConfiguration( - maxSize: UInt64.max, - maxAge: TimeInterval.greatestFiniteMagnitude, - gcInterval: TimeInterval.greatestFiniteMagnitude - ) - - #expect(largeConfig.maxSize == UInt64.max) - #expect(largeConfig.maxAge == TimeInterval.greatestFiniteMagnitude) - #expect(largeConfig.gcInterval == TimeInterval.greatestFiniteMagnitude) - - // Test with minimal values - let minimalConfig = CacheConfiguration( - maxSize: 0, - maxAge: 0, - gcInterval: 0 - ) - - #expect(minimalConfig.maxSize == 0) - #expect(minimalConfig.maxAge == 0) - #expect(minimalConfig.gcInterval == 0) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIntegrationTests.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIntegrationTests.swift deleted file mode 100644 index 80b72a51..00000000 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIntegrationTests.swift +++ /dev/null @@ -1,446 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation -import Testing - -@testable import ContainerBuildCache - -struct CacheIntegrationTests { - - // MARK: - Complete Workflow Tests - - @Test func cacheIntegrationCompleteWorkflow() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let tempDir = testEnv.tempDir - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - indexPath: tempDir.appendingPathComponent("workflow-cache") - ) - - // Initialize cache - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - // Create test data - let operation = TestDataFactory.createOperation(kind: "build", content: "compile-app") - let key = TestDataFactory.createCacheKey(operationContent: "build-operation") - let result = TestDataFactory.createCachedResult( - snapshotContent: "compiled-app", - environmentChanges: [ - "PATH": .literal("/usr/local/bin:/usr/bin"), - "CC": .literal("clang"), - ], - metadataChanges: [ - "build.time": "2024-01-01T12:00:00Z", - "build.version": "1.0.0", - ] - ) - - // Step 1: Cache miss - let initialResult = await cache.get(key, for: operation) - #expect(initialResult == nil, "Initial cache lookup should miss") - - // Step 2: Store result - await cache.put(result, key: key, for: operation) - - // Step 3: Cache hit - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil, "Cache lookup should hit after storing") - #expect(cachedResult?.snapshot.digest == result.snapshot.digest) - #expect(cachedResult?.environmentChanges.count == result.environmentChanges.count) - #expect(cachedResult?.metadataChanges == result.metadataChanges) - - // Step 4: Verify statistics - let stats = await cache.statistics() - #expect(stats.entryCount == 1) - #expect(stats.totalSize > 0) - #expect(stats.hitRate > 0) - } - } - - @Test func cacheIntegrationMultipleOperations() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let tempDir = testEnv.tempDir - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - indexPath: tempDir.appendingPathComponent("multi-op-cache") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - // Simulate a multi-stage build process - let operations = [ - ("download", "Download dependencies"), - ("compile", "Compile source code"), - ("test", "Run unit tests"), - ("package", "Create distribution package"), - ] - - var cachedResults: [CachedResult] = [] - - // Store results for each operation - for (i, (opType, opDescription)) in operations.enumerated() { - let operation = TestDataFactory.createOperation(kind: opType, content: opDescription) - let key = TestDataFactory.createCacheKey( - operationContent: "\(opType)-\(i)", - inputContents: i > 0 ? ["previous-stage-\(i-1)"] : [] - ) - let result = TestDataFactory.createCachedResult( - snapshotContent: "\(opType)-result-\(i)", - environmentChanges: ["STAGE": .literal(opType)], - metadataChanges: ["stage.name": opType, "stage.index": "\(i)"] - ) - - await cache.put(result, key: key, for: operation) - cachedResults.append(result) - } - - // Verify all operations are cached - for (i, (opType, opDescription)) in operations.enumerated() { - let operation = TestDataFactory.createOperation(kind: opType, content: opDescription) - let key = TestDataFactory.createCacheKey( - operationContent: "\(opType)-\(i)", - inputContents: i > 0 ? ["previous-stage-\(i-1)"] : [] - ) - - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil, "Operation \(opType) should be cached") - #expect(cachedResult?.snapshot.digest == cachedResults[i].snapshot.digest) - } - - // Verify final statistics - let stats = await cache.statistics() - #expect(stats.entryCount == operations.count) - #expect(stats.totalSize > 0) - } - } - - @Test func cacheIntegrationPersistenceAcrossRestarts() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let indexPath = testEnv.tempDir.appendingPathComponent("persistent-cache") - let config = TestDataFactory.createCacheConfiguration(indexPath: indexPath) - - let operation = TestDataFactory.createOperation() - let key = TestDataFactory.createCacheKey() - let result = TestDataFactory.createCachedResult() - - // First cache instance - do { - let cache1 = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - await cache1.put(result, key: key, for: operation) - - let cachedResult1 = await cache1.get(key, for: operation) - #expect(cachedResult1 != nil) - } - - // Second cache instance (simulating restart) - do { - let cache2 = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - // Should still find the cached result - let cachedResult2 = await cache2.get(key, for: operation) - #expect(cachedResult2 != nil, "Cache should persist across restarts") - #expect(cachedResult2?.snapshot.digest == result.snapshot.digest) - } - } - } - - @Test func cacheIntegrationLargeDataSets() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - maxSize: 10 * 1024 * 1024, // 10MB - indexPath: testEnv.tempDir.appendingPathComponent("large-data-cache") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - let operation = TestDataFactory.createOperation() - let entryCount = 100 - - // Store many entries - for i in 0.. 0, "Should have at least some cached entries") - - let stats = await cache.statistics() - #expect(stats.totalSize <= config.maxSize * 2) // Allow some overhead - } - } - - @Test func cacheIntegrationErrorRecovery() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - indexPath: testEnv.tempDir.appendingPathComponent("error-recovery-cache") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - let operation = TestDataFactory.createOperation() - - // Store some valid entries - for i in 0..<5 { - let key = TestDataFactory.createCacheKey(operationContent: "valid-entry-\(i)") - let result = TestDataFactory.createCachedResult(snapshotContent: "valid-content-\(i)") - await cache.put(result, key: key, for: operation) - } - - // Verify cache is working - let stats1 = await cache.statistics() - #expect(stats1.entryCount == 5) - - // Simulate error condition by clearing content store but keeping index - await mockContentStore.clear() - - // Cache should handle missing content gracefully - let key = TestDataFactory.createCacheKey(operationContent: "valid-entry-0") - let _ = await cache.get(key, for: operation) - // Result should be nil due to missing content, but cache shouldn't crash - - // Cache should still be functional for new entries - let newKey = TestDataFactory.createCacheKey(operationContent: "new-entry") - let newResult = TestDataFactory.createCachedResult(snapshotContent: "new-content") - await cache.put(newResult, key: newKey, for: operation) - - let cachedNewResult = await cache.get(newKey, for: operation) - #expect(cachedNewResult != nil, "Cache should recover and work for new entries") - } - } - - // MARK: - Cross-Cache Type Integration - - @Test func cacheIntegrationMemoryCacheComparison() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let memoryCache = MemoryBuildCache() - - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - indexPath: testEnv.tempDir.appendingPathComponent("comparison-cache") - ) - let persistentCache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - let operation = TestDataFactory.createOperation() - let key = TestDataFactory.createCacheKey() - let result = TestDataFactory.createCachedResult() - - // Store in both caches - await memoryCache.put(result, key: key, for: operation) - await persistentCache.put(result, key: key, for: operation) - - // Retrieve from both caches - let memoryResult = await memoryCache.get(key, for: operation) - let persistentResult = await persistentCache.get(key, for: operation) - - // Both should return the same logical result - #expect(memoryResult != nil) - #expect(persistentResult != nil) - #expect(memoryResult?.snapshot.digest == persistentResult?.snapshot.digest) - #expect(memoryResult?.environmentChanges.count == persistentResult?.environmentChanges.count) - #expect(memoryResult?.metadataChanges == persistentResult?.metadataChanges) - - // Compare statistics - let memoryStats = await memoryCache.statistics() - let persistentStats = await persistentCache.statistics() - - #expect(memoryStats.entryCount == persistentStats.entryCount) - // Note: totalSize might differ due to different storage mechanisms - } - } - - // MARK: - Performance Integration Tests - - @Test func cacheIntegrationPerformanceUnderLoad() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - maxSize: 50 * 1024 * 1024, // 50MB - indexPath: testEnv.tempDir.appendingPathComponent("performance-cache") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - let operation = TestDataFactory.createOperation() - let operationCount = 50 - - // Measure performance under concurrent load - let (_, duration) = await PerformanceMeasurement.measure { - // First, perform concurrent writes - await withTaskGroup(of: Void.self) { group in - for i in 0.. 0) - #expect(stats.hitRate > 0) - } - } - - // MARK: - Real-World Scenario Tests - - @Test func cacheIntegrationBuildPipelineScenario() async throws { - try await withCacheTestEnvironment { (testEnv: CacheTestEnvironment) in - let mockContentStore = MockContentStore(baseDir: testEnv.tempDir) - let config = TestDataFactory.createCacheConfiguration( - indexPath: testEnv.tempDir.appendingPathComponent("pipeline-cache") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: config - ) - - // Simulate a typical build pipeline - let pipeline = [ - ("fetch-sources", ["Dockerfile", "src/"]), - ("install-deps", ["package.json", "yarn.lock"]), - ("compile", ["src/main.ts", "src/utils.ts"]), - ("test", ["test/unit.test.ts"]), - ("build-image", ["dist/", "Dockerfile"]), - ] - - var previousOutputs: [String] = [] - - for (stageName, inputs) in pipeline { - let operation = TestDataFactory.createOperation(kind: stageName, content: "Pipeline stage: \(stageName)") - let key = TestDataFactory.createCacheKey( - operationContent: stageName, - inputContents: inputs + previousOutputs - ) - - // Check cache first - let cachedResult = await cache.get(key, for: operation) - - if cachedResult != nil { - // Cache hit - use cached result - previousOutputs.append("cached-\(stageName)") - print("Cache hit for stage: \(stageName)") - } else { - // Cache miss - simulate work and store result - let result = TestDataFactory.createCachedResult( - snapshotContent: "output-of-\(stageName)", - environmentChanges: ["STAGE": .literal(stageName)], - metadataChanges: [ - "stage": stageName, - "timestamp": ISO8601DateFormatter().string(from: Date()), - ] - ) - - await cache.put(result, key: key, for: operation) - previousOutputs.append("fresh-\(stageName)") - print("Cache miss for stage: \(stageName)") - } - } - - // Verify all stages were processed - #expect(previousOutputs.count == pipeline.count) - - // Run pipeline again - should have more cache hits - var secondRunOutputs: [String] = [] - previousOutputs = [] // Reset for second run - - for (stageName, inputs) in pipeline { - let operation = TestDataFactory.createOperation(kind: stageName, content: "Pipeline stage: \(stageName)") - let key = TestDataFactory.createCacheKey( - operationContent: stageName, - inputContents: inputs + previousOutputs - ) - - let cachedResult = await cache.get(key, for: operation) - if cachedResult != nil { - secondRunOutputs.append("cached-\(stageName)") - previousOutputs.append("cached-\(stageName)") - } else { - secondRunOutputs.append("fresh-\(stageName)") - previousOutputs.append("fresh-\(stageName)") - } - } - - // Second run should have at least some cache hits - let cacheHits = secondRunOutputs.filter { $0.hasPrefix("cached-") }.count - #expect(cacheHits > 0, "Second pipeline run should have cache hits") - - let finalStats = await cache.statistics() - #expect(finalStats.hitRate > 0, "Overall hit rate should be positive") - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift index ac9d8adf..67850464 100644 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift +++ b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift @@ -327,14 +327,14 @@ public enum TestDataFactory { id: UUID = UUID(), content: String = "test-snapshot", size: Int64 = 1024, - parent: UUID? = nil + parent: Snapshot? = nil ) -> Snapshot { let digest = createDigest(from: content) return Snapshot( id: id, digest: digest, size: size, - parent: nil, + parent: parent, state: .prepared(mountpoint: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) ) } @@ -380,27 +380,6 @@ public enum TestDataFactory { MockOperation(kind: kind, content: content) } - public static func createCacheConfiguration( - maxSize: UInt64 = 1024 * 1024, // 1MB for tests - maxAge: TimeInterval = 3600, // 1 hour for tests - indexPath: URL? = nil - ) -> CacheConfiguration { - let testIndexPath = - indexPath - ?? FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathComponent("test-cache.db") - - return CacheConfiguration( - maxSize: maxSize, - maxAge: maxAge, - indexPath: testIndexPath, - evictionPolicy: .lru, - verifyIntegrity: false, // Disable for faster tests - gcInterval: 60 // Short interval for tests - ) - } - public static func createCacheMetadata( operationHash: String = "test-hash", platform: Platform = .linuxAMD64, diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift index 4f722a1c..9049642d 100644 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift +++ b/Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift @@ -16,6 +16,7 @@ import ContainerBuildIR import ContainerBuildSnapshotter +import ContainerizationOCI import Foundation import Testing @@ -23,397 +24,212 @@ import Testing struct ContentAddressableCacheTests { - // MARK: - Basic Operations Tests + // Helper to create a cache with a mock store and isolated index directory + private func makeCache( + tempDir: URL, + store: MockContentStore? = nil, + ttl: TimeInterval? = nil, + gcInterval: TimeInterval = 10.0 + ) async throws -> (ContentAddressableCache, MockContentStore) { + let indexPath = tempDir.appendingPathComponent("index", isDirectory: true) + try FileManager.default.createDirectory(at: indexPath, withIntermediateDirectories: true) - @Test func contentAddressableCacheBasicGetPut() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") + let contentStore = store ?? MockContentStore(baseDir: tempDir.appendingPathComponent("store", isDirectory: true)) + let config = CacheConfiguration( + maxSize: 1024 * 1024 * 1024, + maxAge: 7 * 24 * 60 * 60, + indexPath: indexPath, + evictionPolicy: .lru, + concurrency: .default, + verifyIntegrity: true, + sharding: nil, + gcInterval: gcInterval, + cacheKeyVersion: "test-v1", + defaultTTL: ttl + ) + + let cache = try await ContentAddressableCache(contentStore: contentStore, configuration: config) + return (cache, contentStore) + } + + @Test func putAndGetRoundTrip() async throws { + try await withCacheTestEnvironment { env in + let (cache, store) = try await makeCache(tempDir: env.tempDir) + + let op = TestDataFactory.createOperation(kind: "run", content: "echo hello") + let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["in1", "in2"], platform: .linuxAMD64) + let result = TestDataFactory.createCachedResult( + snapshotContent: "snap-A", + environmentChanges: ["PATH": .literal("/usr/bin:/bin")], + metadataChanges: ["build.time": "2024-08-01T00:00:00Z"] ) - // Note: This test assumes ContentAddressableCache can accept our protocol - // In a real implementation, we might need to create an adapter - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - defer { - // Cleanup handled by withCacheTestEnvironment - } - let operation = TestDataFactory.createOperation() - let key = TestDataFactory.createCacheKey(operation: operation) - let result = TestDataFactory.createCachedResult() + await cache.put(result, key: key, for: op) - // Test cache miss - let initialResult = await cache.get(key, for: operation) - #expect(initialResult == nil) + // Stored once in content store + #expect(await store.contentCount() == 1) - // Test put - await cache.put(result, key: key, for: operation) + let fetched = await cache.get(key, for: op) + let fr = try #require(fetched) - // Test cache hit - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil) - #expect(cachedResult?.snapshot.id == result.snapshot.id) - #expect(cachedResult?.environmentChanges.count == result.environmentChanges.count) - #expect(cachedResult?.metadataChanges == result.metadataChanges) + // Verify snapshot and metadata round-trip + #expect(fr.snapshot.digest == result.snapshot.digest) + #expect(fr.snapshot.size == result.snapshot.size) + #expect(fr.environmentChanges == result.environmentChanges) + #expect(fr.metadataChanges == result.metadataChanges) } } - @Test func contentAddressableCacheMiss() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) + @Test func idempotentPutDoesNotDuplicateStorage() async throws { + try await withCacheTestEnvironment { env in + let (cache, store) = try await makeCache(tempDir: env.tempDir) - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) + let op = TestDataFactory.createOperation(kind: "run", content: "op-idem") + let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["a", "b"], platform: .linuxAMD64) + let result = TestDataFactory.createCachedResult(snapshotContent: "idem-snap") - let key = TestDataFactory.createCacheKey() - let operation = TestDataFactory.createOperation() + await cache.put(result, key: key, for: op) + await cache.put(result, key: key, for: op) // second put should be a no-op - let result = await cache.get(key, for: operation) - #expect(result == nil) + #expect(await store.contentCount() == 1) } } - @Test func contentAddressableCacheHit() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) + @Test func hasKeyAndMiss() async throws { + try await withCacheTestEnvironment { env in + let (cache, _) = try await makeCache(tempDir: env.tempDir) - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) + let op = TestDataFactory.createOperation(kind: "cmd", content: "A") + let keyHit = TestDataFactory.createCacheKey(operation: op, inputContents: ["x"], platform: .linuxAMD64) + let keyMiss = TestDataFactory.createCacheKey(operation: op, inputContents: ["different"], platform: .linuxAMD64) - let operation = TestDataFactory.createOperation() - let key = TestDataFactory.createCacheKey(operation: operation) - let result = TestDataFactory.createCachedResult() + let result = TestDataFactory.createCachedResult(snapshotContent: "rt") + await cache.put(result, key: keyHit, for: op) - // Store the result - await cache.put(result, key: key, for: operation) - - // Retrieve it - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil) - #expect(cachedResult?.snapshot.digest == result.snapshot.digest) + #expect(await cache.has(key: keyHit)) + #expect(!(await cache.has(key: keyMiss))) } } - @Test func contentAddressableCacheDuplicatePut() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) + @Test func deterministicKeyOrderInvariance() async throws { + try await withCacheTestEnvironment { env in + let (cache, store) = try await makeCache(tempDir: env.tempDir) - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) + let op = TestDataFactory.createOperation(kind: "run", content: "det") + // Same set of inputs but different order + let key1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["i1", "i2", "i3"], platform: .linuxAMD64) + let key2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["i3", "i2", "i1"], platform: .linuxAMD64) + let result = TestDataFactory.createCachedResult(snapshotContent: "det-snap") - let key = TestDataFactory.createCacheKey() - let result1 = TestDataFactory.createCachedResult(snapshotContent: "content1") - let result2 = TestDataFactory.createCachedResult(snapshotContent: "content2") - let operation = TestDataFactory.createOperation() + await cache.put(result, key: key1, for: op) - // Store first result - await cache.put(result1, key: key, for: operation) - - // Store second result with same key (should be ignored) - await cache.put(result2, key: key, for: operation) - - // Should still get the first result - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil) - #expect(cachedResult?.snapshot.digest == result1.snapshot.digest) + // Expect that the permuted key hits the same entry (no additional storage) + #expect(await cache.has(key: key2)) + let fetched = await cache.get(key2, for: op) + #expect(fetched?.snapshot.digest == result.snapshot.digest) + #expect(await store.contentCount() == 1) } } - @Test func contentAddressableCacheStatistics() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) + @Test func evictRemovesIndexAndContent() async throws { + try await withCacheTestEnvironment { env in + let (cache, store) = try await makeCache(tempDir: env.tempDir) - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) + let op = TestDataFactory.createOperation(kind: "run", content: "evict-op") + let key1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["k1"], platform: .linuxAMD64) + let key2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["k2"], platform: .linuxAMD64) + let r1 = TestDataFactory.createCachedResult(snapshotContent: "S1") + let r2 = TestDataFactory.createCachedResult(snapshotContent: "S2") + + await cache.put(r1, key: key1, for: op) + await cache.put(r2, key: key2, for: op) + #expect(await store.contentCount() == 2) + + await cache.evict(keys: [key1]) + + #expect(!(await cache.has(key: key1))) + #expect(await cache.has(key: key2)) + #expect(await store.contentCount() == 1) + } + } + + @Test func statisticsReflectEntries() async throws { + try await withCacheTestEnvironment { env in + let (cache, _) = try await makeCache(tempDir: env.tempDir) + + let op = TestDataFactory.createOperation() + let k1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["a"], platform: .linuxAMD64) + let k2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["b"], platform: .linuxAMD64) + let r = TestDataFactory.createCachedResult() + + await cache.put(r, key: k1, for: op) + await cache.put(r, key: k2, for: op) + + _ = await cache.get(k1, for: op) // one hit + _ = await cache.get(k2, for: op) // another hit + _ = await cache.get(TestDataFactory.createCacheKey(operation: op, inputContents: ["c"], platform: .linuxAMD64), for: op) // miss let stats = await cache.statistics() - #expect(stats.entryCount == 0) - #expect(stats.totalSize == 0) - - // Add some entries - let key1 = TestDataFactory.createCacheKey(operationContent: "op1") - let key2 = TestDataFactory.createCacheKey(operationContent: "op2") - let result = TestDataFactory.createCachedResult() - let operation = TestDataFactory.createOperation() - - await cache.put(result, key: key1, for: operation) - await cache.put(result, key: key2, for: operation) - - let updatedStats = await cache.statistics() - #expect(updatedStats.entryCount == 2) - #expect(updatedStats.totalSize > 0) + #expect(stats.entryCount == 2) + #expect(stats.totalSize > 0) + #expect(stats.averageEntrySize > 0) + #expect(stats.hitRate > 0) + #expect(stats.evictionPolicy == "lru") } } - // MARK: - Eviction Tests + @Test func ttlEvictionViaBackgroundGC() async throws { + try await withCacheTestEnvironment { env in + // Short TTL and GC interval to exercise background cleanup + let (cache, _) = try await makeCache(tempDir: env.tempDir, ttl: 0.05, gcInterval: 0.02) - @Test func contentAddressableCacheEvictionBySize() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) + let op = TestDataFactory.createOperation() + let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["exp"], platform: .linuxAMD64) + let r = TestDataFactory.createCachedResult() + await cache.put(r, key: key, for: op) - // Create a small cache - let smallConfig = TestDataFactory.createCacheConfiguration( - maxSize: 2048, // Very small cache - indexPath: environment.tempDir.appendingPathComponent("small-cache-index") - ) - - let smallCache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: smallConfig - ) - - // Fill the cache beyond capacity - let operation = TestDataFactory.createOperation() - for i in 0..<10 { - let key = TestDataFactory.createCacheKey(operationContent: "operation-\(i)") - let result = TestDataFactory.createCachedResult(snapshotContent: "large-content-\(i)") - await smallCache.put(result, key: key, for: operation) - } - - // Give eviction time to run - try await Task.sleep(nanoseconds: 100_000_000 * 5) // 0.1 seconds - - let stats = await smallCache.statistics() - #expect(stats.totalSize < 2048 * 2) // Should have evicted some entries + #expect(await cache.has(key: key)) + // Wait long enough for TTL to expire and GC to run + try await Task.sleep(nanoseconds: 200_000_000) // 0.2s + #expect(!(await cache.has(key: key))) } } - @Test func contentAddressableCacheEvictionByAge() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") + @Test func orphanedIndexEntryIsCleanedOnMiss() async throws { + try await withCacheTestEnvironment { env in + // Seed a valid entry, then corrupt the stored manifest to simulate orphan/invalid content + let (cache, store) = try await makeCache(tempDir: env.tempDir) + let index = try CacheIndex(path: env.tempDir.appendingPathComponent("index", isDirectory: true)) + + let op = TestDataFactory.createOperation(kind: "run", content: "corrupt") + let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["x", "y"], platform: .linuxAMD64) + let r = TestDataFactory.createCachedResult(snapshotContent: "S") + await cache.put(r, key: key, for: op) + + // Find the stored index entry + let entries = try await index.allEntries() + #expect(entries.count == 1) + let (_, entry) = try #require(entries.first) + + // Overwrite manifest content with an invalid one (missing snapshot) + let bad = CacheManifest( + config: CacheConfig( + cacheKey: SerializedCacheKey(from: key), + operationType: String(describing: type(of: op)), + platform: key.platform, + buildVersion: "1.0" + ), + annotations: [:], + subject: nil, + snapshot: nil, // <-- corrupt + environmentChanges: [:], + metadataChanges: [:] ) + try await store.put(bad, digest: entry.descriptor.digest) - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - - // This test would require manipulating time or using a mock clock - // For now, we'll test the TTL-based eviction logic - - let key = TestDataFactory.createCacheKey() - let result = TestDataFactory.createCachedResult() - let operation = TestDataFactory.createOperation() - - await cache.put(result, key: key, for: operation) - - // Verify it's there - let cachedResult = await cache.get(key, for: operation) - #expect(cachedResult != nil) - - // In a real test, we'd advance time and check eviction - // For now, just verify the entry exists - let stats = await cache.statistics() - #expect(stats.entryCount == 1) - } - } - - @Test func contentAddressableCacheEvictionByTTL() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - - // Create configuration with short TTL - let shortTTLConfig = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, - maxAge: 1, // 1 second TTL - indexPath: environment.tempDir.appendingPathComponent("ttl-cache-index") - ) - - let ttlCache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: shortTTLConfig - ) - - let key = TestDataFactory.createCacheKey() - let result = TestDataFactory.createCachedResult() - let operation = TestDataFactory.createOperation() - - await ttlCache.put(result, key: key, for: operation) - - // Verify it's there initially - let initialResult = await ttlCache.get(key, for: operation) - #expect(initialResult != nil) - - // Wait for TTL to expire - try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds - - // Entry should be evicted (this depends on the implementation running periodic cleanup) - // Note: This test might be flaky depending on the eviction implementation - } - } - - // MARK: - Concurrency Tests - - @Test func contentAddressableCacheConcurrentOperations() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - - let operation = TestDataFactory.createOperation() - - // Test concurrent puts and gets - await withTaskGroup(of: Void.self) { group in - // Concurrent puts - for i in 0..<5 { - group.addTask { - let key = TestDataFactory.createCacheKey(operationContent: "concurrent-op-\(i)") - let result = TestDataFactory.createCachedResult(snapshotContent: "content-\(i)") - await cache.put(result, key: key, for: operation) - } - } - - // Concurrent gets - for i in 0..<5 { - group.addTask { - let key = TestDataFactory.createCacheKey(operationContent: "concurrent-op-\(i)") - _ = await cache.get(key, for: operation) - } - } - } - - let stats = await cache.statistics() - #expect(stats.entryCount > 0) - } - } - - // MARK: - Error Handling Tests - - @Test func contentAddressableCacheCorruptedData() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - - // This test would require injecting corrupted data into the content store - // For now, we'll test basic error resilience - - let key = TestDataFactory.createCacheKey() - let operation = TestDataFactory.createOperation() - - // Try to get from empty cache (should handle gracefully) - let result = await cache.get(key, for: operation) - #expect(result == nil) - - // Cache should still be functional - let stats = await cache.statistics() - #expect(stats.entryCount == 0) - } - } - - @Test func contentAddressableCacheMissingContentStore() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - - // Test behavior when content store operations fail - // This would require a mock that can simulate failures - - let key = TestDataFactory.createCacheKey() - let result = TestDataFactory.createCachedResult() - let operation = TestDataFactory.createOperation() - - // Put should not crash even if content store has issues - await cache.put(result, key: key, for: operation) - - // Get should handle missing content gracefully - let _ = await cache.get(key, for: operation) - // Result depends on whether the put succeeded despite content store issues - } - } - - // MARK: - Performance Tests - - @Test func contentAddressableCachePerformance() async throws { - try await withCacheTestEnvironment { environment in - let mockContentStore = MockContentStore(baseDir: environment.tempDir) - let configuration = TestDataFactory.createCacheConfiguration( - maxSize: 1024 * 1024, // 1MB for tests - maxAge: 3600, // 1 hour for tests - indexPath: environment.tempDir.appendingPathComponent("cache-index") - ) - - let cache = try await ContentAddressableCache( - contentStore: mockContentStore, - configuration: configuration - ) - - let operation = TestDataFactory.createOperation() - - let (_, duration) = await PerformanceMeasurement.measure { - // Perform multiple cache operations - for i in 0..<100 { - let key = TestDataFactory.createCacheKey(operationContent: "perf-test-\(i)") - let result = TestDataFactory.createCachedResult(snapshotContent: "content-\(i)") - await cache.put(result, key: key, for: operation) - _ = await cache.get(key, for: operation) - } - } - - // Assert reasonable performance (adjust threshold as needed) - #expect(duration < 5.0, "Cache operations took too long: \(duration)s") + // Now a get should fail and effectively behave like a miss + let got = await cache.get(key, for: op) + #expect(got == nil) } } }