Files
container/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift
T
2025-08-13 10:46:14 -07:00

290 lines
8.8 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 ContainerizationExtras
import ContainerizationOCI
import Foundation
// MARK: - Cache Index
/// Actor-based cache index that manages cache metadata with atomic updates
public actor CacheIndex: Sendable {
private let path: URL
/// Cache index state persisted to cache.json
struct State: Codable {
var entries: [String: CacheEntry]
var version: Int
var statistics: Statistics
struct Statistics: Codable {
var totalSize: UInt64
var entryCount: Int
var hitCount: Int64
var missCount: Int64
var evictionCount: Int64
var lastModified: Date
var lastGC: Date?
}
static let empty = State(
entries: [:],
version: 1,
statistics: Statistics(
totalSize: 0,
entryCount: 0,
hitCount: 0,
missCount: 0,
evictionCount: 0,
lastModified: Date(),
lastGC: nil
)
)
}
public init(path: URL) throws {
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
self.path = path
}
// MARK: - Public Methods
/// Add or update a cache entry
public func put(
key: String,
descriptor: Descriptor,
metadata: CacheMetadata
) async throws {
var state = try self.load()
// Update or create entry
let entry = CacheEntry(
descriptor: descriptor,
metadata: metadata
)
// Update statistics
if state.entries[key] == nil {
state.statistics.entryCount += 1
}
state.entries[key] = entry
state.statistics.totalSize = calculateTotalSize(state.entries)
state.statistics.lastModified = Date()
try self.save(state)
}
/// Get a cache entry and update access time
public func get(key: String) async throws -> CacheEntry? {
var state = try self.load()
guard var entry = state.entries[key] else {
state.statistics.missCount += 1
try self.save(state)
return nil
}
// Update access time
entry.metadata.accessedAt = Date()
state.entries[key] = entry
state.statistics.hitCount += 1
state.statistics.lastModified = Date()
try self.save(state)
return entry
}
/// Remove cache entries
public func remove(keys: [String]) async throws {
var state = try self.load()
for key in keys {
if state.entries.removeValue(forKey: key) != nil {
state.statistics.entryCount -= 1
state.statistics.evictionCount += 1
}
}
state.statistics.totalSize = calculateTotalSize(state.entries)
state.statistics.lastModified = Date()
try self.save(state)
}
/// Get all cache entries
public func allEntries() async throws -> [String: CacheEntry] {
let state = try self.load()
return state.entries
}
/// Get cache statistics
public func statistics() async throws -> CacheStatistics {
let state = try self.load()
// Calculate derived statistics
let hitRate =
state.statistics.hitCount + state.statistics.missCount > 0
? Double(state.statistics.hitCount) / Double(state.statistics.hitCount + state.statistics.missCount)
: 0.0
let ages = state.entries.values.map { entry in
Date().timeIntervalSince(entry.metadata.createdAt)
}.sorted()
let oldestAge = ages.last ?? 0
let newestAge = ages.first ?? 0
let avgSize =
state.statistics.entryCount > 0
? state.statistics.totalSize / UInt64(state.statistics.entryCount)
: 0
return CacheStatistics(
entryCount: state.statistics.entryCount,
totalSize: state.statistics.totalSize,
hitRate: hitRate,
oldestEntryAge: oldestAge,
mostRecentEntryAge: newestAge,
evictionPolicy: "lru",
averageEntrySize: avgSize,
operationMetrics: .empty,
errorCount: 0,
lastGCTime: state.statistics.lastGC,
shardInfo: nil
)
}
// MARK: - File Operations
/// Load cache index from cache.json
private func load() throws -> State {
let indexPath = self.path.appendingPathComponent("cache.json")
guard FileManager.default.fileExists(atPath: indexPath.path) else {
return .empty
}
do {
let data = try Data(contentsOf: indexPath)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(State.self, from: data)
} catch {
// Handle corrupted index by starting fresh
print("Warning: Cache index corrupted, starting with empty cache: \(error.localizedDescription)")
// Try to backup the corrupted file for debugging
let backupPath = indexPath.appendingPathExtension("corrupted")
try? FileManager.default.moveItem(at: indexPath, to: backupPath)
// Return empty state to start fresh
return .empty
}
}
/// Save cache index to cache.json atomically
private func save(_ state: State) throws {
let indexPath = self.path.appendingPathComponent("cache.json")
let tempPath = indexPath.appendingPathExtension("tmp")
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(state)
try data.write(to: tempPath, options: .atomic)
// Atomic rename
_ = try FileManager.default.replaceItem(at: indexPath, withItemAt: tempPath, backupItemName: nil, options: [], resultingItemURL: nil)
} catch {
// Clean up temp file if it exists
try? FileManager.default.removeItem(at: tempPath)
throw CacheError.storageFailed(path: tempPath.path, underlyingError: error)
}
}
// MARK: - Helper Methods
private func calculateTotalSize(_ entries: [String: CacheEntry]) -> UInt64 {
entries.values.reduce(0) { total, entry in
total + UInt64(entry.descriptor.size)
}
}
}
// MARK: - Cache Entry
/// Individual cache entry containing manifest descriptor and metadata
public struct CacheEntry: Codable, Sendable {
/// OCI descriptor for the cache manifest
public let descriptor: Descriptor
/// Cache metadata
public var metadata: CacheMetadata
public init(descriptor: Descriptor, metadata: CacheMetadata) {
self.descriptor = descriptor
self.metadata = metadata
}
}
// MARK: - Cache Metadata
/// Metadata associated with a cache entry
public struct CacheMetadata: Codable, Sendable {
/// When the entry was created
public let createdAt: Date
/// When the entry was last accessed
public var accessedAt: Date
/// Hash of the operation that created this cache entry
public let operationHash: String
/// Platform this cache entry is for
public let platform: Platform
/// Time-to-live in seconds (nil means no expiration)
public let ttl: TimeInterval?
/// Custom tags for filtering
public let tags: [String: String]
public init(
createdAt: Date = Date(),
accessedAt: Date = Date(),
operationHash: String,
platform: Platform,
ttl: TimeInterval? = nil,
tags: [String: String] = [:]
) {
self.createdAt = createdAt
self.accessedAt = accessedAt
self.operationHash = operationHash
self.platform = platform
self.ttl = ttl
self.tags = tags
}
/// Check if the entry has expired
public var isExpired: Bool {
guard let ttl = ttl else { return false }
return Date().timeIntervalSince(createdAt) > ttl
}
}