Files
container/Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift
T
Kathryn Baldauf 16f2630126 Add initial native builder code (#399)
We're working on making a pure swift container image build system that
leverages containerization. This PR represents our initial design and
initial work towards this goal.

The native builder is still in active development and most of the
implementation has not been started or completed. We will be opening a
series of issues that represent various (but not necessarily all) pieces
of work that need to be done here.

There are docs included in this PR that describe the overall design of
each component and outline some of our goals. The easiest way to view
the docs by themselves (since this is a massive PR) is to look at the
docs commit in the `Commits` tab.

We'd love any feedback! 

@wlan0

---------

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
2025-07-31 13:13:20 -07:00

420 lines
17 KiB
Swift

//===----------------------------------------------------------------------===//
// 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 ContentAddressableCacheTests {
// MARK: - Basic Operations Tests
@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")
)
// 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()
// 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 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")
)
let cache = try await ContentAddressableCache(
contentStore: mockContentStore,
configuration: configuration
)
let key = TestDataFactory.createCacheKey()
let operation = TestDataFactory.createOperation()
let result = await cache.get(key, for: operation)
#expect(result == nil)
}
}
@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")
)
let cache = try await ContentAddressableCache(
contentStore: mockContentStore,
configuration: configuration
)
let operation = TestDataFactory.createOperation()
let key = TestDataFactory.createCacheKey(operation: operation)
let result = TestDataFactory.createCachedResult()
// 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)
}
}
@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")
)
let cache = try await ContentAddressableCache(
contentStore: mockContentStore,
configuration: configuration
)
let key = TestDataFactory.createCacheKey()
let result1 = TestDataFactory.createCachedResult(snapshotContent: "content1")
let result2 = TestDataFactory.createCachedResult(snapshotContent: "content2")
let operation = TestDataFactory.createOperation()
// 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)
}
}
@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")
)
let cache = try await ContentAddressableCache(
contentStore: mockContentStore,
configuration: configuration
)
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)
}
}
// MARK: - Eviction Tests
@Test func contentAddressableCacheEvictionBySize() async throws {
try await withCacheTestEnvironment { environment in
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
// 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
}
}
@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")
)
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")
}
}
}