mirror of
https://github.com/apple/container.git
synced 2026-07-15 05:57:01 +00:00
401 lines
14 KiB
Swift
401 lines
14 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 ContainerizationOCI
|
|
import Crypto
|
|
import Foundation
|
|
|
|
/// Content-addressable cache implementation using ContentStore as backing storage.
|
|
///
|
|
/// This implementation stores cache entries as OCI artifacts with manifests
|
|
/// pointing to content layers. It provides atomic operations, deduplication,
|
|
/// and efficient eviction policies.
|
|
public actor ContentAddressableCache: BuildCache {
|
|
private let contentStore: ContentStore
|
|
private let index: CacheIndex
|
|
private let configuration: CacheConfiguration
|
|
private var evictionTask: Task<Void, Never>?
|
|
|
|
/// Initialize a new content-addressable cache.
|
|
///
|
|
/// - Parameters:
|
|
/// - contentStore: The content store for storage
|
|
/// - configuration: Cache configuration
|
|
public init(
|
|
contentStore: ContentStore,
|
|
configuration: CacheConfiguration = CacheConfiguration()
|
|
) async throws {
|
|
self.contentStore = contentStore
|
|
self.configuration = configuration
|
|
do {
|
|
self.index = try CacheIndex(path: configuration.indexPath)
|
|
} catch {
|
|
throw CacheError.manifestUnreadable(path: configuration.indexPath.path, underlyingError: error)
|
|
}
|
|
|
|
// Start background eviction task
|
|
self.evictionTask = Task { [weak self] in
|
|
await self?.runPeriodicEviction()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
evictionTask?.cancel()
|
|
}
|
|
|
|
// MARK: - BuildCache Protocol Implementation
|
|
|
|
public func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? {
|
|
// Generate cache digest
|
|
guard let digest = try? generateCacheDigest(from: key) else {
|
|
return nil
|
|
}
|
|
|
|
// Check index
|
|
guard let entry = try? await index.get(key: digest) else {
|
|
return nil
|
|
}
|
|
|
|
// Fetch manifest from content store using the manifest digest from the index
|
|
guard let manifest: CacheManifest = try? await contentStore.get(digest: entry.descriptor.digest) else {
|
|
// Clean up orphaned index entry
|
|
try? await index.remove(keys: [digest])
|
|
return nil
|
|
}
|
|
|
|
// Reconstruct result from manifest
|
|
guard let result = try? await reconstructResult(from: manifest) else {
|
|
return nil
|
|
}
|
|
|
|
// Update access time
|
|
var updatedMetadata = entry.metadata
|
|
updatedMetadata.accessedAt = Date()
|
|
try? await index.put(key: digest, descriptor: entry.descriptor, metadata: updatedMetadata)
|
|
|
|
return result
|
|
}
|
|
|
|
public func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async {
|
|
// Generate cache digest
|
|
guard let digest = try? generateCacheDigest(from: key) else {
|
|
return
|
|
}
|
|
|
|
// Check if already exists
|
|
if let _ = try? await index.get(key: digest) {
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Start new ingest session
|
|
let (sessionId, ingestDir) = try await contentStore.newIngestSession()
|
|
|
|
do {
|
|
// Create content writer for this session
|
|
let writer = try ContentWriter(for: ingestDir)
|
|
|
|
// Create manifest with embedded snapshot, environment and metadata
|
|
let manifest = createManifest(
|
|
key: key,
|
|
operation: operation,
|
|
snapshot: result.snapshot,
|
|
environmentChanges: result.environmentChanges,
|
|
metadataChanges: result.metadataChanges
|
|
)
|
|
|
|
// Write manifest
|
|
let (manifestSize, manifestDigest) = try writer.create(from: manifest)
|
|
|
|
// Complete ingest session
|
|
_ = try await contentStore.completeIngestSession(sessionId)
|
|
|
|
let descriptor = Descriptor(
|
|
mediaType: manifest.mediaType,
|
|
digest: manifestDigest.digestString,
|
|
size: manifestSize
|
|
)
|
|
|
|
let operationHash: String
|
|
do {
|
|
operationHash = try operation.contentDigest().stringValue
|
|
} catch {
|
|
// If we can't compute the operation hash, use a fallback
|
|
operationHash = "unknown"
|
|
}
|
|
|
|
let metadata = CacheMetadata(
|
|
createdAt: Date(),
|
|
accessedAt: Date(),
|
|
operationHash: operationHash,
|
|
platform: key.platform,
|
|
ttl: configuration.defaultTTL,
|
|
tags: [:]
|
|
)
|
|
|
|
try await index.put(key: digest, descriptor: descriptor, metadata: metadata)
|
|
|
|
// Trigger eviction if needed
|
|
Task { [weak self] in
|
|
await self?.checkAndEvict()
|
|
}
|
|
|
|
} catch {
|
|
// Cancel ingest session on error
|
|
try? await contentStore.cancelIngestSession(sessionId)
|
|
throw error
|
|
}
|
|
} catch {
|
|
// Log error but don't propagate - caching should not fail builds
|
|
print("Cache put failed: \(error)")
|
|
}
|
|
}
|
|
|
|
public func has(key: CacheKey) async -> Bool {
|
|
guard let digest = try? generateCacheDigest(from: key) else {
|
|
return false
|
|
}
|
|
return (try? await index.get(key: digest)) != nil
|
|
}
|
|
|
|
public func evict(keys: [CacheKey]) async {
|
|
for key in keys {
|
|
guard let digest = try? generateCacheDigest(from: key) else {
|
|
continue
|
|
}
|
|
|
|
if let entry = try? await index.get(key: digest) {
|
|
// 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])
|
|
}
|
|
}
|
|
}
|
|
|
|
public func statistics() async -> CacheStatistics {
|
|
let stats = (try? await index.statistics()) ?? CacheStatistics.empty
|
|
|
|
return CacheStatistics(
|
|
entryCount: stats.entryCount,
|
|
totalSize: stats.totalSize,
|
|
hitRate: stats.hitRate,
|
|
oldestEntryAge: stats.oldestEntryAge,
|
|
mostRecentEntryAge: stats.mostRecentEntryAge,
|
|
evictionPolicy: "lru",
|
|
averageEntrySize: stats.entryCount > 0 ? stats.totalSize / UInt64(stats.entryCount) : 0,
|
|
operationMetrics: .empty,
|
|
errorCount: 0,
|
|
lastGCTime: nil,
|
|
shardInfo: nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Generate a deterministic cache digest from the cache key.
|
|
/// - Throws: CacheError.encodingFailed if UTF-8 encoding fails
|
|
private func generateCacheDigest(from key: CacheKey) throws -> String {
|
|
var hasher = SHA256()
|
|
|
|
// Version prefix for cache invalidation
|
|
guard let versionData = configuration.cacheKeyVersion.data(using: .utf8) else {
|
|
// This should never happen as cacheKeyVersion is controlled internally
|
|
throw CacheError.encodingFailed("Failed to encode cache key version as UTF-8: \(configuration.cacheKeyVersion)")
|
|
}
|
|
hasher.update(data: versionData)
|
|
|
|
// Operation digest
|
|
hasher.update(data: key.operationDigest.bytes)
|
|
|
|
// Sorted input digests for determinism
|
|
for digest in key.inputDigests.sorted(by: { $0.stringValue < $1.stringValue }) {
|
|
hasher.update(data: digest.bytes)
|
|
}
|
|
|
|
// Platform data
|
|
let platformData = encodePlatform(key.platform)
|
|
hasher.update(data: platformData)
|
|
|
|
let digest = hasher.finalize()
|
|
let hexString = digest.map { String(format: "%02x", $0) }.joined()
|
|
return "sha256:\(hexString)"
|
|
}
|
|
|
|
/// Encode platform to canonical form for hashing.
|
|
private func encodePlatform(_ platform: Platform) -> Data {
|
|
let normalized = NormalizedPlatform(
|
|
os: platform.os,
|
|
architecture: platform.architecture,
|
|
variant: platform.variant,
|
|
osVersion: platform.osVersion,
|
|
osFeatures: platform.osFeatures?.sorted()
|
|
)
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
return (try? encoder.encode(normalized)) ?? Data()
|
|
}
|
|
|
|
/// 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: CacheManifest.currentSchemaVersion,
|
|
mediaType: CacheManifest.manifestMediaType,
|
|
config: CacheConfig(
|
|
cacheKey: SerializedCacheKey(from: key),
|
|
operationType: String(describing: type(of: operation)),
|
|
platform: key.platform,
|
|
buildVersion: "1.0.0"
|
|
),
|
|
annotations: [
|
|
"com.apple.container-build.created": ISO8601DateFormatter().string(from: Date()),
|
|
"com.apple.container-build.cache-version": configuration.cacheKeyVersion,
|
|
],
|
|
subject: nil,
|
|
snapshot: snapshot,
|
|
environmentChanges: environmentChanges,
|
|
metadataChanges: metadataChanges
|
|
)
|
|
}
|
|
|
|
/// 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"]))
|
|
}
|
|
|
|
// 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.
|
|
private func checkAndEvict() async {
|
|
let stats = try? await index.statistics()
|
|
|
|
guard let stats = stats else { return }
|
|
|
|
// Check if we need to evict based on size
|
|
if stats.totalSize > configuration.maxSize {
|
|
await performEviction(targetSize: UInt64(Double(configuration.maxSize) * 0.8))
|
|
}
|
|
}
|
|
|
|
/// Perform cache eviction to reach target size.
|
|
private func performEviction(targetSize: UInt64) async {
|
|
let stats = try? await index.statistics()
|
|
guard let currentSize = stats?.totalSize, currentSize > targetSize else { return }
|
|
|
|
let sizeToEvict = currentSize - targetSize
|
|
|
|
// Get all entries and sort by access time (LRU)
|
|
guard let allEntries = try? await index.allEntries() else { return }
|
|
let sortedEntries = allEntries.sorted { $0.value.metadata.accessedAt < $1.value.metadata.accessedAt }
|
|
|
|
var evictedSize: UInt64 = 0
|
|
var keysToEvict: [String] = []
|
|
var digestsToDelete: [String] = []
|
|
|
|
for (key, entry) in sortedEntries {
|
|
// Only need to delete the manifest itself (no separate layers)
|
|
digestsToDelete.append(entry.descriptor.digest)
|
|
|
|
keysToEvict.append(key)
|
|
evictedSize += UInt64(entry.descriptor.size)
|
|
|
|
if evictedSize >= sizeToEvict {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from index
|
|
try? await index.remove(keys: keysToEvict)
|
|
|
|
// Delete content from store
|
|
_ = try? await contentStore.delete(digests: digestsToDelete)
|
|
}
|
|
|
|
/// Run periodic eviction task.
|
|
private func runPeriodicEviction() async {
|
|
while !Task.isCancelled {
|
|
// Sleep for GC interval
|
|
try? await Task.sleep(nanoseconds: UInt64(configuration.gcInterval * 1_000_000_000))
|
|
|
|
// Remove expired entries based on TTL
|
|
guard let allEntries = try? await index.allEntries() else { return }
|
|
var keysToEvict: [String] = []
|
|
var digestsToDelete: [String] = []
|
|
|
|
for (key, entry) in allEntries {
|
|
// Check TTL
|
|
if let ttl = entry.metadata.ttl {
|
|
let expirationDate = entry.metadata.createdAt.addingTimeInterval(ttl)
|
|
if Date() > expirationDate {
|
|
keysToEvict.append(key)
|
|
|
|
// Only need to delete the manifest itself (no separate layers)
|
|
digestsToDelete.append(entry.descriptor.digest)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !keysToEvict.isEmpty {
|
|
try? await index.remove(keys: keysToEvict)
|
|
_ = try? await contentStore.delete(digests: digestsToDelete)
|
|
}
|
|
|
|
// Check size limits
|
|
await checkAndEvict()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Extensions
|
|
|
|
extension Platform {
|
|
var canonicalString: String {
|
|
var parts = ["\(os)/\(architecture)"]
|
|
if let variant = variant {
|
|
parts.append(variant)
|
|
}
|
|
return parts.joined(separator: "/")
|
|
}
|
|
|
|
/// - Throws: CacheError.encodingFailed if UTF-8 encoding fails
|
|
func canonicalRepresentation() throws -> Data {
|
|
guard let data = canonicalString.data(using: .utf8) else {
|
|
// This should never happen as canonicalString contains only valid UTF-8
|
|
throw CacheError.encodingFailed("Failed to encode canonical platform string as UTF-8: \(canonicalString)")
|
|
}
|
|
return data
|
|
}
|
|
}
|