From 7beabe9d583bbaf76e32799d4b4feae71a214137 Mon Sep 17 00:00:00 2001 From: Alexander Cohen Date: Sat, 29 Nov 2025 18:53:08 -0500 Subject: [PATCH 1/4] additions --- .github/workflows/benchmarks.yml | 287 ++++++ .github/workflows/test.yml | 34 + .swift-version | 1 + Package.swift | 10 +- Sources/Arsenal/Arsenal.swift | 906 ++++++------------ Sources/Arsenal/ImageArsenal.swift | 97 ++ .../Arsenal/Implementations/DiskArsenal.swift | 339 +++++++ .../Implementations/MemoryArsenal.swift | 239 +++++ .../Implementations/SwiftDataArsenal.swift | 193 ++++ Tests/ArsenalTests/ArsenalBenchmarks.swift | 341 +++++++ Tests/ArsenalTests/ArsenalTests.swift | 291 +++++- 11 files changed, 2088 insertions(+), 650 deletions(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 .github/workflows/test.yml create mode 100644 .swift-version create mode 100644 Sources/Arsenal/ImageArsenal.swift create mode 100644 Sources/Arsenal/Implementations/DiskArsenal.swift create mode 100644 Sources/Arsenal/Implementations/MemoryArsenal.swift create mode 100644 Sources/Arsenal/Implementations/SwiftDataArsenal.swift create mode 100644 Tests/ArsenalTests/ArsenalBenchmarks.swift diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..ec53ec5 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,287 @@ +name: Benchmarks + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + benchmark: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0" + + - name: Run benchmarks + id: benchmark + run: | + echo "Running benchmarks..." + swift test --filter Benchmark 2>&1 | tee benchmark_output.txt + + - name: Parse and format results + run: | + python3 << 'EOF' + import re + from datetime import datetime + import subprocess + + # Get system info + try: + cpu_brand = subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().strip() + mem_bytes = int(subprocess.check_output(['sysctl', '-n', 'hw.memsize']).decode().strip()) + mem_gb = mem_bytes / (1024**3) + system_info = f"{cpu_brand}, {mem_gb:.0f} GB RAM" + except: + system_info = "macOS (GitHub Actions)" + + # Read benchmark output + with open('benchmark_output.txt', 'r') as f: + content = f.read() + + # Find all performance test results + pattern = r"testBenchmark(\w+).*?average: ([\d.]+)" + matches = re.findall(pattern, content) + + # Start markdown output + output = ["# πŸš€ Arsenal Cache Performance Benchmarks\n"] + output.append("*Multi-layer caching with LRU eviction and disk persistence*\n") + output.append(f"**Test Hardware:** {system_info}\n") + + # Memory cache benchmarks + output.append("## Memory Cache Operations\n") + output.append("| Operation | Items | Time | Per-Op | Status |") + output.append("|-----------|-------|------|--------|--------|") + + memory_ops = { + "MemorySet": ("Set", 1000), + "MemoryGet": ("Get", 1000), + "MemorySetWithPurge": ("Set (with purge)", 500), + } + + for test_name, avg_time in matches: + if test_name in memory_ops: + avg_time_f = float(avg_time) + op_name, ops = memory_ops[test_name] + + total_ms = avg_time_f * 1000 + per_op_us = (avg_time_f * 1_000_000) / ops + + if per_op_us < 1: + per_op = "<1 ΞΌs" + elif per_op_us < 1000: + per_op = f"{per_op_us:.1f} ΞΌs" + else: + per_op = f"{per_op_us/1000:.2f} ms" + + if total_ms < 50: + status = "βœ… Excellent" + elif total_ms < 100: + status = "βœ… Good" + elif total_ms < 500: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {op_name} | {ops} | {total_ms:.1f}ms | {per_op} | {status} |") + + # Disk cache benchmarks + output.append("\n## Disk Cache Operations\n") + output.append("| Operation | Items | Time | Per-Op | Status |") + output.append("|-----------|-------|------|--------|--------|") + + disk_ops = { + "DiskSet": ("Set", 100), + "DiskGet": ("Get", 100), + } + + for test_name, avg_time in matches: + if test_name in disk_ops: + avg_time_f = float(avg_time) + op_name, ops = disk_ops[test_name] + + total_ms = avg_time_f * 1000 + per_op_us = (avg_time_f * 1_000_000) / ops + + if per_op_us < 1000: + per_op = f"{per_op_us:.1f} ΞΌs" + else: + per_op = f"{per_op_us/1000:.2f} ms" + + # Disk ops have higher thresholds + if total_ms < 500: + status = "βœ… Excellent" + elif total_ms < 1000: + status = "βœ… Good" + elif total_ms < 3000: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {op_name} | {ops} | {total_ms:.1f}ms | {per_op} | {status} |") + + # Combined cache benchmarks + output.append("\n## Combined Cache (Memory + Disk)\n") + output.append("| Operation | Items | Time | Per-Op | Status |") + output.append("|-----------|-------|------|--------|--------|") + + combined_ops = { + "CombinedSetBoth": ("Set (both layers)", 100), + "CombinedGetWithPromotion": ("Get (diskβ†’memory promotion)", 100), + } + + for test_name, avg_time in matches: + if test_name in combined_ops: + avg_time_f = float(avg_time) + op_name, ops = combined_ops[test_name] + + total_ms = avg_time_f * 1000 + per_op_us = (avg_time_f * 1_000_000) / ops + + if per_op_us < 1000: + per_op = f"{per_op_us:.1f} ΞΌs" + else: + per_op = f"{per_op_us/1000:.2f} ms" + + if total_ms < 500: + status = "βœ… Excellent" + elif total_ms < 1000: + status = "βœ… Good" + elif total_ms < 3000: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {op_name} | {ops} | {total_ms:.1f}ms | {per_op} | {status} |") + + # Large item benchmarks + output.append("\n## Large Items (1 MB each)\n") + output.append("| Storage | Items | Time | Per-Item | Status |") + output.append("|---------|-------|------|----------|--------|") + + large_ops = { + "LargeItemMemory": ("Memory", 50), + "LargeItemDisk": ("Disk", 20), + } + + for test_name, avg_time in matches: + if test_name in large_ops: + avg_time_f = float(avg_time) + storage, ops = large_ops[test_name] + + total_ms = avg_time_f * 1000 + per_op_ms = total_ms / ops + + if per_op_ms < 10: + status = "βœ… Excellent" + elif per_op_ms < 50: + status = "βœ… Good" + elif per_op_ms < 100: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {storage} | {ops} | {total_ms:.0f}ms | {per_op_ms:.1f}ms | {status} |") + + # Throughput benchmark + output.append("\n## Throughput (Mixed Read/Write)\n") + output.append("| Operation | Ops | Time | Ops/sec | Status |") + output.append("|-----------|-----|------|---------|--------|") + + throughput_ops = { + "MemoryThroughput": ("Memory (33% write, 67% read)", 5000), + } + + for test_name, avg_time in matches: + if test_name in throughput_ops: + avg_time_f = float(avg_time) + op_name, ops = throughput_ops[test_name] + + total_ms = avg_time_f * 1000 + ops_per_sec = ops / avg_time_f + + if ops_per_sec > 100000: + status = "βœ… Excellent" + elif ops_per_sec > 50000: + status = "βœ… Good" + elif ops_per_sec > 10000: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {op_name} | {ops:,} | {total_ms:.0f}ms | {ops_per_sec:,.0f} | {status} |") + + # Clear benchmarks + output.append("\n## Clear Operations\n") + output.append("| Storage | Items | Time | Status |") + output.append("|---------|-------|------|--------|") + + clear_ops = { + "ClearMemory": ("Memory", 1000), + "ClearDisk": ("Disk", 100), + } + + for test_name, avg_time in matches: + if test_name in clear_ops: + avg_time_f = float(avg_time) + storage, ops = clear_ops[test_name] + + total_ms = avg_time_f * 1000 + + if total_ms < 100: + status = "βœ… Excellent" + elif total_ms < 500: + status = "βœ… Good" + elif total_ms < 1000: + status = "⚠️ OK" + else: + status = "❌ Review" + + output.append(f"| {storage} | {ops} | {total_ms:.0f}ms | {status} |") + + # Performance notes + output.append("\n## Performance Characteristics") + output.append("### Status Legend") + output.append("- βœ… **Excellent/Good**: Optimal performance") + output.append("- ⚠️ **OK**: Acceptable, monitor in production") + output.append("- ❌ **Review**: May need optimization") + output.append("\n### Architecture") + output.append("- **Memory cache**: LRU eviction with cost-based limits") + output.append("- **Disk cache**: File-based with staleness and cost eviction") + output.append("- **Combined**: Automatic diskβ†’memory promotion on read") + output.append("- **Thread safety**: `@globalActor` isolation via `ArsenalActor`") + + # Summary + total_tests = len(re.findall(r"Test Case.*passed", content)) + output.append(f"\n---\n**Total benchmarks:** {total_tests} passed | _Generated {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}_") + + # Write to file + with open('benchmark_results.md', 'w') as f: + f.write('\n'.join(output)) + + print('\n'.join(output)) + EOF + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: benchmark_results.md + comment_tag: benchmark-results + + - name: Comment commit with results + if: github.event_name == 'push' + uses: peter-evans/commit-comment@v3 + with: + body-path: benchmark_results.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bbbedc4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Run Tests + runs-on: macos-latest + strategy: + matrix: + sanitizer: ["address", "thread", ""] + + steps: + - uses: actions/checkout@v4 + timeout-minutes: 2 + + - name: Run Unit Tests, Sanitizer ${{ matrix.sanitizer }} + uses: mxcl/xcodebuild@v3 + timeout-minutes: 5 + with: + swift: 6.2 + action: test + platform: macOS + arch: arm64 + sanitizer: ${{ matrix.sanitizer }} \ No newline at end of file diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..0cda48a --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2 diff --git a/Package.swift b/Package.swift index 9080d8a..ba92631 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -17,12 +17,14 @@ let package = Package( .target( name: "Arsenal", swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - .enableUpcomingFeature("StrictConcurrency") + // Enable this if you want to play around with the SwiftData Storage + //.define("SWIFT_DATA_ARSENAL") ] ), .testTarget( name: "ArsenalTests", - dependencies: ["Arsenal"]), + dependencies: ["Arsenal"], + swiftSettings: [.define("SWIFT_DATA_ARSENAL")] + ), ] ) diff --git a/Sources/Arsenal/Arsenal.swift b/Sources/Arsenal/Arsenal.swift index fca837d..0ec7996 100644 --- a/Sources/Arsenal/Arsenal.swift +++ b/Sources/Arsenal/Arsenal.swift @@ -6,42 +6,193 @@ import Foundation -/// Arsenal is a simple implementation of a caching system that handles -/// in memory as well as on disk cache. +// MARK: - ArsenalItem Protocol + +/// A protocol that defines the requirements for items that can be stored in an ``Arsenal`` cache. /// -/// The memory cache has a weight limit in bytes which when exceeded will -/// start purging items by oldest access order (LRU) until the weight limit is not exceeded. +/// Types conforming to `ArsenalItem` must be reference types (`AnyObject`) and thread-safe (`Sendable`). +/// They must provide serialization capabilities and a cost metric for cache management. /// -/// The disk cache has a max staleness value which when exceeded will -/// purge items oldest first until no items exceeed the max staleness. +/// ## Conforming to ArsenalItem /// -/// Arsenal is observable in order to be able to pass it in the environment if needed. +/// To make a type cacheable, implement the required methods: /// -/// Cache is thread safe and isolated to the @ArsenalActor. +/// ```swift +/// final class MyItem: ArsenalItem { +/// let data: Data /// - +/// func toData() -> Data? { +/// return data +/// } +/// +/// static func from(data: Data?) -> ArsenalItem? { +/// guard let data else { return nil } +/// return MyItem(data: data) +/// } +/// +/// var cost: UInt64 { +/// return UInt64(data.count) +/// } +/// } +/// ``` @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 13.0, *) -public protocol ArsenalItem : AnyObject { +public protocol ArsenalItem: AnyObject, Sendable { + /// Serializes the item to `Data` for disk storage. + /// + /// - Returns: The serialized data representation, or `nil` if serialization fails. func toData() -> Data? + + /// Creates an item from serialized data. + /// + /// - Parameter data: The serialized data to deserialize. + /// - Returns: A new item instance, or `nil` if deserialization fails. static func from(data: Data?) -> ArsenalItem? + + /// The cost of storing this item, used for cache limit calculations. + /// + /// This value is typically the size in bytes, but can be any consistent unit + /// as long as all items use the same measurement. + var cost: UInt64 { get } +} + +// MARK: - ArsenalActor + +/// A global actor that provides thread-safe isolation for Arsenal cache operations. +/// +/// All cache operations are isolated to this actor to ensure thread safety. +/// Use the `@ArsenalActor` attribute to mark code that should run on this actor. +/// +/// ```swift +/// @ArsenalActor +/// func cacheOperation() async { +/// // This code runs on the ArsenalActor +/// } +/// ``` +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) +@globalActor public struct ArsenalActor { + /// The actor type used for isolation. + public actor ActorType {} + + /// The shared actor instance. + public static let shared: ActorType = .init() +} + +// MARK: - ArsenalImp Protocol + +/// A protocol defining the interface for cache storage implementations. +/// +/// Implement this protocol to create custom cache storage backends. +/// Built-in implementations include ``MemoryArsenal`` and ``DiskArsenal``. +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) +@ArsenalActor public protocol ArsenalImp: Sendable { + /// The type of items this implementation stores. + associatedtype T + + /// Stores or removes an item in the cache. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The unique key to associate with the item. + func set(_ value: T?, key: String) async + + /// Retrieves an item from the cache. + /// + /// - Parameter key: The key associated with the item. + /// - Returns: The cached item, or `nil` if not found. + func value(for key: String) async -> T? + + /// Updates the cost limit for this cache. + /// + /// - Parameter to: The new cost limit. + func update(costLimit to: UInt64) async + + /// Purges items from the cache based on the implementation's eviction policy. + func purge() async + + /// Purges items that are no longer referenced elsewhere in the application. + func purgeUnowned() async + + /// Removes all items from the cache. + func clear() async + + /// The maximum cost allowed before eviction occurs. + var costLimit: UInt64 { get } + + /// The current total cost of all cached items. var cost: UInt64 { get } } +// MARK: - Arsenal + +/// A dual-layer caching system with memory and disk storage. +/// +/// Arsenal provides a flexible caching solution that combines fast in-memory access +/// with persistent disk storage. It supports automatic eviction based on cost limits +/// and staleness, and is fully thread-safe through actor isolation. +/// +/// ## Overview +/// +/// Arsenal uses a two-tier caching strategy: +/// - **Memory cache**: Fast access with LRU (Least Recently Used) eviction +/// - **Disk cache**: Persistent storage with time-based staleness eviction +/// +/// When retrieving items, Arsenal checks memory first, then falls back to disk. +/// Items retrieved from disk are automatically promoted to memory for faster subsequent access. +/// +/// ## Usage +/// +/// ```swift +/// // Create a cache for images +/// let imageCache = Arsenal("com.myapp.images") +/// +/// // Store an item +/// await imageCache.set(image, key: "hero-image") +/// +/// // Retrieve an item +/// if let cached = await imageCache.value(for: "hero-image") { +/// // Use cached image +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Creating a Cache +/// - ``init(_:costLimit:maxStaleness:)`` +/// - ``init(_:resources:)`` +/// +/// ### Storing and Retrieving Items +/// - ``set(_:key:types:)-1lf3h`` +/// - ``set(_:key:types:)-20ori`` +/// - ``value(for:)-5nexh`` +/// - ``value(for:)-2gxqv`` +/// +/// ### Managing the Cache +/// - ``purge(_:)`` +/// - ``purgeUnowned(_:)`` +/// - ``clear(_:)`` +/// - ``update(costLimit:for:)`` @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@ArsenalActor @Observable final public class Arsenal : Sendable { - - public enum ResourceType { +@ArsenalActor @Observable public final class Arsenal: Sendable { + /// The type of cache resource. + public enum ResourceType: Sendable { + /// In-memory cache with LRU eviction. case memory + /// Disk-based cache with staleness eviction. case disk } - - /// String used to identify this Arsenal. - let identifier: String - - /// Creates a new cache with default limits for disk and memory. - /// 500 MB of memory limit. - /// 1 day of max staleness. - convenience init(_ identifier: String, costLimit: UInt64 = UInt64(5e+8), maxStaleness: TimeInterval = 86400) { + + /// The unique identifier for this cache instance. + /// + /// This identifier is used for disk storage paths and debugging. + public let identifier: String + + /// Creates a new cache with default memory and disk storage. + /// + /// - Parameters: + /// - identifier: A unique identifier for this cache instance. + /// - costLimit: The maximum cost for the memory cache. Defaults to 500 MB. + /// - maxStaleness: The maximum age for disk-cached items in seconds. Defaults to 1 day. + public convenience init(_ identifier: String, costLimit: UInt64 = UInt64(5e+8), maxStaleness: TimeInterval = 86400) { self.init( identifier, resources: [ @@ -56,667 +207,174 @@ public protocol ArsenalItem : AnyObject { ] ) } - - /// You create your own cache with whatever resources you like. - init(_ identifier: String, resources: [ResourceType: any ArsenalImp]) { + + /// Creates a new cache with custom resource implementations. + /// + /// Use this initializer to provide custom cache backends or to configure + /// only specific resource types. + /// + /// - Parameters: + /// - identifier: A unique identifier for this cache instance. + /// - resources: A dictionary mapping resource types to their implementations. + public init(_ identifier: String, resources: [ResourceType: any ArsenalImp]) { self.identifier = identifier self.resources = resources } - - /// Sets a cached item. - /// Passing nil as a value will remove the item from cache. + + /// Stores or removes an item in the cache. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The unique key to associate with the item. + /// - types: The resource types to update. Defaults to both memory and disk. public func set(_ value: T?, key: String, types: Set = [.memory, .disk]) async { - types.forEach { type in - (resources[type])?.set(value, key: key) + await forEachResource(of: types) { + await $0.set(value, key: key) } } - + + /// Stores or removes an item in the cache using a URL as the key. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The URL to use as the cache key. + /// - types: The resource types to update. Defaults to both memory and disk. public func set(_ value: T?, key: URL, types: Set = [.memory, .disk]) async { await set(value, key: key.absoluteString, types: types) } - - /// Retrieve a cache item. - /// The cache will check in memory first, then disk. + + /// Retrieves an item from the cache. + /// + /// This method checks the memory cache first for fast access. If the item + /// is not in memory but exists on disk, it's loaded and promoted to memory. + /// + /// - Parameter key: The key associated with the item. + /// - Returns: The cached item, or `nil` if not found. public func value(for key: String) async -> T? { - if let val = memoryResource?.value(for: key) { + if let val = await memoryResource?.value(for: key) { return val } - if let val: T = diskResource?.value(for: key) { - memoryResource?.set(val, key: key) + if let val: T = await diskResource?.value(for: key) { + await memoryResource?.set(val, key: key) return val } return nil } - + + /// Retrieves an item from the cache using a URL as the key. + /// + /// - Parameter key: The URL used as the cache key. + /// - Returns: The cached item, or `nil` if not found. public func value(for key: URL) async -> T? { await value(for: key.absoluteString) } - /// Updates the cost limits. - /// This can cause a purge if the new weight limit is below usage. + /// Updates the cost limit for specified resource types. + /// + /// Reducing the cost limit may trigger immediate eviction of items + /// to bring the cache within the new limit. + /// + /// - Parameters: + /// - costLimit: The new cost limit. + /// - types: The resource types to update. Defaults to memory only. public func update(costLimit: UInt64, for types: Set = [.memory]) async { - for type in types { - (resources[type])?.update(costLimit: costLimit) + await forEachResource(of: types) { + await $0.update(costLimit: costLimit) } } - /// Purges memory and disk caches based on their limits. + /// Purges items from the cache based on each resource's eviction policy. + /// + /// For memory caches, this evicts items using LRU until under the cost limit. + /// For disk caches, this removes stale items that exceed the maximum age. + /// + /// - Parameter types: The resource types to purge. Defaults to both memory and disk. public func purge(_ types: Set = [.memory, .disk]) async { - for type in types { - (resources[type])?.purge() + await forEachResource(of: types) { + await $0.purge() } } - - /// Purges in memory caches of unreferenced items. + + /// Purges items that are no longer referenced elsewhere in the application. + /// + /// This is useful for memory caches to release items that are only held by the cache. + /// + /// - Parameter types: The resource types to purge. Defaults to both memory and disk. public func purgeUnowned(_ types: Set = [.memory, .disk]) async { - for type in types { - (resources[type])?.purgeUnowned() + await forEachResource(of: types) { + await $0.purgeUnowned() } } - - /// Returns costs for resources + + /// Returns the total cost of all cached items for the specified resource types. + /// + /// - Parameter types: The resource types to include. Defaults to both memory and disk. + /// - Returns: The sum of costs across all specified resources. public func cost(_ types: Set = [.memory, .disk]) async -> UInt64 { types.reduce(into: 0) { - $0 += (resources[$1])?.cost ?? 0 + $0 += resources[$1]?.cost ?? 0 } } - + + /// Returns the total cost limit for the specified resource types. + /// + /// - Parameter types: The resource types to include. Defaults to both memory and disk. + /// - Returns: The sum of cost limits across all specified resources. public func costLimit(_ types: Set = [.memory, .disk]) async -> UInt64 { types.reduce(into: 0) { - $0 += (resources[$1])?.costLimit ?? 0 - } - } - - /// Removes all items from memory and disk cache. - public func clear(_ types: Set = [.memory, .disk]) async { - for type in types { - (resources[type])?.clear() - } - } - - /// Private resources + getters - private var memoryResource: (any ArsenalImp)? { - return resources[.memory] - } - private var diskResource: (any ArsenalImp)? { - return resources[.disk] - } - private var resources: [ResourceType: any ArsenalImp] -} - -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -extension Arsenal { - public var diskResourceCost: UInt64 { - diskResource?.cost ?? 0 - } - - public var diskResourceCostLimit: UInt64 { - diskResource?.costLimit ?? 0 - } - - public var memoryResourceCost: UInt64 { - memoryResource?.cost ?? 0 - } - - public var memoryResourceCostLimit: UInt64 { - memoryResource?.costLimit ?? 0 - } -} - -/// A global actor for the Arsenal, use as `@ArsenalActor`. -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@globalActor public struct ArsenalActor { - public actor ActorType { } - public static let shared: ActorType = ActorType() -} - -#if canImport(UIKit) -import UIKit - -/// An extension to UIImage that supports `ArsenalItem` -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -extension UIImage: ArsenalItem { - public func toData() -> Data? { - return jpegData(compressionQuality: 1) - } - public static func from(data: Data?) -> ArsenalItem? { - guard let data else { - return nil + $0 += resources[$1]?.costLimit ?? 0 } - return UIImage(data: data) - } - public var cost: UInt64 { - // I'm assuming any image we use is ARGB - return UInt64(size.width * size.height * 4) } -} - -#endif - -#if canImport(SwiftUI) && canImport(UIKit) -import SwiftUI -import UIKit - -/// Use as `@Environment(\.imageCache) var imageCache` -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -struct ImageArsenalKey: @preconcurrency EnvironmentKey { - @ArsenalActor static var defaultValue: Arsenal = Arsenal("com.bedroomcode.image.arsenal") -} - -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -extension EnvironmentValues { - public var imageArsenal: Arsenal { - get { self[ImageArsenalKey.self] } - } -} - -#endif - -// MARK: - -// MARK: Private -// Disk and Image internal Arsenal implementations from here on. - -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@ArsenalActor public protocol ArsenalImp : Sendable { - associatedtype T - - func set(_ value: T?, key: String) - func value(for key: String) -> T? - - func update(costLimit to: UInt64) - func purge() - func purgeUnowned() - func clear() - - var costLimit: UInt64 { get } - var cost: UInt64 { get } -} - -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -fileprivate class ArsenalURLProvider { - - let identifier: String - let prefix: String - let fileManager: FileManager - - init(_ identifier: String, prefix: String = "", fileManager: FileManager) { - self.identifier = identifier - self.prefix = prefix - self.fileManager = fileManager - } - - // Base folder for our caches. - // This can fail, if it does there's not much to do - // so we just return nil. - var cacheURL: URL? { - if let baseURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appending(component: sanitizedIdentifier) { - try? fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) - return baseURL - } - return nil - } - - lazy private var sanitizedIdentifier: String = sanitize(prefix.isEmpty ? identifier : prefix + "." + identifier) - lazy private var allowedCharacterSet: CharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - - // Ensure any key can be used as a name of a file on disk. - func sanitize(_ key: String) -> String { - return key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? key - } - - // Returns a full valid URL for a key. - // This is where the cache items appears on disk. - func url(for key: String) -> URL? { - return cacheURL?.appending(component: sanitize(key)) - } - -} - -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@ArsenalActor public class MemoryArsenal : ArsenalImp, @unchecked Sendable { - - init(costLimit: UInt64 = 0) { - self.costLimit = costLimit - } - - public func update(costLimit to: UInt64) { - ArsenalActor.assertIsolated() - - costLimit = to - purgeUnowned() - purge() - } - - public func set(_ value: T?, key: String) { - ArsenalActor.assertIsolated() - - if let img = cache[key] { - cost -= img.cost - cache[key] = nil - } - - if let img = value { - let item = MemoryItem(key: key, value: img) - cache[key] = item - cost += item.cost - } - purge() - } - - public func value(for key: String) -> T? { - ArsenalActor.assertIsolated() - - guard var item = cache[key] else { return nil } - - // update the last accessed date to ensure purges are correctly ordered - item.updateTimestamp() - return item.value - } - - public func contains(_ key: String) -> Bool { - ArsenalActor.assertIsolated() - - return cache[key] != nil - } - - public func purgeUnowned() { - ArsenalActor.assertIsolated() - - // this make all items weak - // by doing so, all items that aren't - // referenced anywhere will be nilled out - // then we recreate the cache with what - // is really owned. - - print("Purge unowned") - print("trying to purge \(cache.count) items using \(cost) in cost") - - let weakItems = cache.values.map { $0.weakify() } - cache.removeAll() - cost = 0 - let strongItems = weakItems.compactMap { $0.strongify() } - cost = strongItems.reduce(0) { $0 + $1.cost } - strongItems.forEach { cache[$0.key] = $0 } - - print("After purge we have \(cache.count) items using \(cost) in cost") - } - - public func purge() { - ArsenalActor.assertIsolated() - - // check our limits again in case we're - // good after removing non-referenced items. - guard costLimit > 0 && cost >= costLimit else { - return - } - - // least recently accessed first (LRU) - var sorted = cache.values.sorted { item1, item2 in - return item1.timestamp.compare(item2.timestamp) == .orderedAscending - } - - while !sorted.isEmpty && cost >= costLimit { - guard let item = sorted.first else { - break - } - - sorted.remove(at: 0) - cache[item.key] = nil - cost -= item.cost + /// Removes all items from the specified resource types. + /// + /// - Parameter types: The resource types to clear. Defaults to both memory and disk. + public func clear(_ types: Set = [.memory, .disk]) async { + await forEachResource(of: types) { + await $0.clear() } } - - public func clear() { - ArsenalActor.assertIsolated() - - cache = [:] - cost = 0 - } - - private struct MemoryItem { - let key: String - let value: T - let cost: UInt64 - var timestamp: Date = Date() - - init(key: String, value: T) { - self.key = key - self.value = value - self.cost = value.cost - } - - fileprivate init(key: String, value: T, cost: UInt64, timestamp: Date) { - self.key = key - self.value = value - self.cost = cost - self.timestamp = timestamp - } - - mutating func updateTimestamp() { - timestamp = Date() - } - func weakify() -> MemoryWeakItem { - MemoryWeakItem(key: key, value: value, cost: cost, timestamp: timestamp) - } - } - - private struct MemoryWeakItem { - let key: String - weak var value: T? - let cost: UInt64 - var timestamp: Date = Date() - - init(key: String, value: T, cost: UInt64, timestamp: Date) { - self.key = key - self.value = value - self.cost = cost - self.timestamp = timestamp - } - - func strongify() -> MemoryItem? { - guard let val = value else { - return nil + private func forEachResource(of types: Set, action: @Sendable @escaping (any ArsenalImp) async -> Void) async { + for type in types { + if let res = resources[type] { + await action(res) } - return MemoryItem(key: key, value: val, cost: cost, timestamp: timestamp) } } - - private var cache: [String : MemoryItem] = [:] - public private(set) var cost: UInt64 = 0 - public var costLimit: UInt64 -} -@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@ArsenalActor public class DiskArsenal : ArsenalImp, @unchecked Sendable { - - private let urlProvider: ArsenalURLProvider - private let maxStaleness: TimeInterval - private var usedCost: UInt64 = 0 - public var costLimit: UInt64 - public private(set) var cost: UInt64 - let identifier: String - - init(_ identifier: String, maxStaleness: TimeInterval = 0, costLimit: UInt64 = 0) { - self.identifier = identifier - self.urlProvider = ArsenalURLProvider(identifier, fileManager: FileManager()) - self.maxStaleness = maxStaleness - self.costLimit = costLimit - self.cost = 0 - Task { - self.cost = calculateCost() - } + private var memoryResource: (any ArsenalImp)? { + return resources[.memory] } - public func update(costLimit to: UInt64) { - ArsenalActor.assertIsolated() - - costLimit = to - purgeUnowned() - purge() - } - - public func set(_ value: T?, key: String) { - ArsenalActor.assertIsolated() - - guard let url = urlProvider.url(for: key) else { - return - } - if let data = value?.toData() { - do { - try data.write(to: url) - let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 - if size > 0 { - cost += UInt64(size) - } - purge() - } catch { - print("DiskArsenal error: \(error)") - } - } else { - deleteItem(at: url) - } - } - - public func value(for key: String) -> T? { - ArsenalActor.assertIsolated() - - guard let url = urlProvider.url(for: key), let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { - return nil - } - return T.from(data: data) as? T - } - - public func contains(for key: String) -> Bool { - ArsenalActor.assertIsolated() - - if let url = urlProvider.url(for: key) { - return urlProvider.fileManager.fileExists(atPath: url.path()) - } - return false - } - - public func purgeUnowned() {} - - public func purge() { - ArsenalActor.assertIsolated() - - // TODO: Implement purge based on cost as well - - guard maxStaleness > 0 || costLimit > 0 else { - return - } - - guard let baseURL = urlProvider.cacheURL else { - return - } - - guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], options: .skipsHiddenFiles) else { - return - } - - // sort URLs newest to oldest - var sortedUrls = urls.sorted { url1, url2 in - guard let date1 = try? url1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, - let date2 = try? url2.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { - return false - } - return date1.compare(date2) == .orderedDescending - } - - // Purge based on date - if maxStaleness > 0 { - let now = Date() - while let url = sortedUrls.popLast() { - - guard let date = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { - continue - } - - // Item is too young, since we're sorted we can bail - guard now.timeIntervalSince1970 - date.timeIntervalSince1970 > maxStaleness else { - break - } - - // We now know we need to delete the item - deleteItem(at: url) - } - } - - // Purge based on cost - if costLimit > 0 { - - while cost > costLimit, let url = sortedUrls.popLast() { - - guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0 else { - continue - } - - // We now know we need to delete the item - deleteItem(at: url) - } - - } - } - - public func clear() { - ArsenalActor.assertIsolated() - - guard let baseURL = urlProvider.cacheURL else { - return - } - if let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil) { - for url in urls { - deleteItem(at: url) - } - } - } - - private func deleteItem(at url: URL) { - ArsenalActor.assertIsolated() - - do { - let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 - try urlProvider.fileManager.removeItem(at: url) - if size > 0 { - cost -= UInt64(size) - } - } catch { - print("DiskArsenal error: \(error)") - } + private var diskResource: (any ArsenalImp)? { + return resources[.disk] } - - private func calculateCost() -> UInt64 { - ArsenalActor.assertIsolated() - - guard let baseURL = urlProvider.cacheURL else { - return 0 - } - guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles) else { - return 0 - } - - return urls.reduce(0) { cost, url in - if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0 { - return cost + UInt64(size) - } - return cost - } - } + private var resources: [ResourceType: any ArsenalImp] } -#if canImport(SwiftData) -import SwiftData +// MARK: - Arsenal Convenience Properties @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) -@ArsenalActor public class SwiftDataArsenal : ArsenalImp, @unchecked Sendable { - - @Model - fileprivate class ArsenalItemModel { - @Attribute(.unique) var key: String - @Attribute(.externalStorage) var data: Data? - var cost: UInt64 - var timestamp: Date = Date() - - @Transient lazy var value: T? = T.from(data: data) as? T - - init(key: String, value: T, cost: UInt64) { - self.key = key - self.data = value.toData() - self.cost = cost - self.value = value - } +public extension Arsenal { + /// The current cost of the disk cache. + var diskResourceCost: UInt64 { + diskResource?.cost ?? 0 } - - private let maxStaleness: TimeInterval - public var costLimit: UInt64 - let identifier: String - private let modelContainer: ModelContainer? - private let modelContext: ModelContext? - private let urlProvider: ArsenalURLProvider - - init(_ identifier: String, maxStaleness: TimeInterval = 0, costLimit: UInt64 = 0) { - self.identifier = identifier - self.urlProvider = ArsenalURLProvider(identifier, prefix: "sd", fileManager: FileManager()) - self.maxStaleness = maxStaleness - self.costLimit = 0 - - if let configURL = urlProvider.url(for: identifier)?.appendingPathExtension("sqlite") { - let config = ModelConfiguration(identifier, url: configURL) - self.modelContainer = try? ModelContainer(for: ArsenalItemModel.self, configurations: config) - } else { - self.modelContainer = try? ModelContainer(for: ArsenalItemModel.self) - } - if let container = self.modelContainer { - self.modelContext = ModelContext(container) - } else { - self.modelContext = nil - } - } - - public var cost: UInt64 { - 0 - } - - public func update(costLimit to: UInt64) { - ArsenalActor.assertIsolated() - // noop for now - } - - public func set(_ value: T?, key: String) { - ArsenalActor.assertIsolated() - - if let val = value { - let item = ArsenalItemModel(key: key, value: val, cost: val.cost) - modelContext?.insert(item) - } else { - - let fetchDescriptor = FetchDescriptor(predicate: #Predicate { item in - item.key == key - }) - do { - if let modelContext = modelContext, let item = try modelContext.fetch(fetchDescriptor).first { - modelContext.delete(item) - } - } catch { - // TODO: - } - } - - } - - public func value(for key: String) -> T? { - ArsenalActor.assertIsolated() - - let fetchDescriptor = FetchDescriptor(predicate: #Predicate { item in - item.key == key - }) - return try? modelContext?.fetch(fetchDescriptor).first?.value - } - - public func contains(for key: String) -> Bool { - ArsenalActor.assertIsolated() - - return value(for: key) != nil + /// The cost limit of the disk cache. + var diskResourceCostLimit: UInt64 { + diskResource?.costLimit ?? 0 } - - public func purgeUnowned() {} - - public func purge() { - ArsenalActor.assertIsolated() - // TODO: Implement purge + /// The current cost of the memory cache. + var memoryResourceCost: UInt64 { + memoryResource?.cost ?? 0 } - - public func clear() { - ArsenalActor.assertIsolated() - try? modelContext?.delete(model: ArsenalItemModel.self) + + /// The cost limit of the memory cache. + var memoryResourceCostLimit: UInt64 { + memoryResource?.costLimit ?? 0 } } - - -#endif - diff --git a/Sources/Arsenal/ImageArsenal.swift b/Sources/Arsenal/ImageArsenal.swift new file mode 100644 index 0000000..ff9ac5c --- /dev/null +++ b/Sources/Arsenal/ImageArsenal.swift @@ -0,0 +1,97 @@ +// +// ImageArsenal.swift +// +// Copyright (c) 2024 Alexander Cohen. All rights reserved. +// + +import Foundation + +#if canImport(SwiftUI) && canImport(UIKit) + import SwiftUI + import UIKit + + // MARK: - SwiftUI Environment + + /// An environment key for accessing a shared image cache. + /// + /// Use this to inject an ``Arsenal`` instance for `UIImage` caching + /// into the SwiftUI environment. + /// + /// ## Usage + /// + /// ```swift + /// struct ContentView: View { + /// @Environment(\.imageArsenal) var imageCache + /// + /// var body: some View { + /// // Use imageCache for caching images + /// } + /// } + /// ``` + @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) + struct ImageArsenalKey: @preconcurrency EnvironmentKey { + @ArsenalActor static var defaultValue: Arsenal = .init("com.bedroomcode.image.arsenal") + } + + @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) + public extension EnvironmentValues { + /// A shared image cache accessible through the SwiftUI environment. + /// + /// Access this property using the `@Environment` property wrapper: + /// ```swift + /// @Environment(\.imageArsenal) var imageCache + /// ``` + var imageArsenal: Arsenal { self[ImageArsenalKey.self] } + } + + // MARK: - UIImage + ArsenalItem + + /// Extends `UIImage` to conform to ``ArsenalItem`` for caching. + /// + /// Images are serialized as JPEG data with maximum quality for storage. + /// The cost is calculated as `width * height`, representing the relative + /// size of the image in pixels. + /// + /// ## Usage + /// + /// ```swift + /// let imageCache = Arsenal("com.myapp.images") + /// + /// // Cache an image + /// await imageCache.set(myImage, key: "profile-photo") + /// + /// // Retrieve from cache + /// if let cached = await imageCache.value(for: "profile-photo") { + /// imageView.image = cached + /// } + /// ``` + @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) + extension UIImage: ArsenalItem { + /// Serializes the image as JPEG data with maximum quality. + /// + /// - Returns: JPEG data representation of the image, or `nil` if encoding fails. + public func toData() -> Data? { + return jpegData(compressionQuality: 1) + } + + /// Creates a `UIImage` from serialized data. + /// + /// - Parameter data: The image data to decode. + /// - Returns: A `UIImage` instance, or `nil` if the data is invalid. + public static func from(data: Data?) -> ArsenalItem? { + guard let data else { + return nil + } + return UIImage(data: data) + } + + /// The relative cost of storing this image. + /// + /// Calculated as `width * height` in points. This provides a consistent + /// relative measure for comparing image sizes, regardless of scale factor. + public var cost: UInt64 { + return UInt64(size.width * size.height) + } + } + +#endif diff --git a/Sources/Arsenal/Implementations/DiskArsenal.swift b/Sources/Arsenal/Implementations/DiskArsenal.swift new file mode 100644 index 0000000..6d7c915 --- /dev/null +++ b/Sources/Arsenal/Implementations/DiskArsenal.swift @@ -0,0 +1,339 @@ +// +// DiskArsenal.swift +// +// Copyright (c) 2024 Alexander Cohen. All rights reserved. +// + +import Foundation +import os + +/// A disk-based cache implementation with staleness and cost-based eviction. +/// +/// `DiskArsenal` provides persistent caching by storing items as files on disk. +/// It supports two eviction strategies: +/// - **Staleness-based**: Removes items older than a specified time interval +/// - **Cost-based**: Removes oldest items when total size exceeds the limit +/// +/// ## Features +/// +/// - **Persistent Storage**: Items survive app restarts +/// - **Automatic Eviction**: Removes stale or excess items based on configuration +/// - **Lazy Cost Calculation**: Disk size is calculated asynchronously on initialization +/// - **Thread Safety**: All operations are isolated to ``ArsenalActor`` +/// +/// ## Storage Location +/// +/// Items are stored in the app's Caches directory under a folder named after +/// the cache identifier. File names are sanitized to be filesystem-safe. +/// +/// ## Usage +/// +/// ```swift +/// let diskCache = DiskArsenal( +/// "com.myapp.cache", +/// maxStaleness: 86400, // 1 day +/// costLimit: 1024 * 1024 * 500 // 500 MB +/// ) +/// +/// // Store an item +/// await diskCache.set(item, key: "my-key") +/// +/// // Retrieve an item +/// if let cached = await diskCache.value(for: "my-key") { +/// // Use cached item +/// } +/// ``` +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) +@ArsenalActor public class DiskArsenal: ArsenalImp { + private let logger = Logger(subsystem: "com.bedroomcode.arsenal", category: "DiskArsenal") + private let urlProvider: ArsenalURLProvider + private let maxStaleness: TimeInterval + private var costCalculationTask: Task? + + /// The maximum total cost allowed before eviction occurs. + public var costLimit: UInt64 + + /// The current total cost of all cached items on disk. + public private(set) var cost: UInt64 + + /// The unique identifier for this cache instance. + let identifier: String + + /// Creates a new disk cache with the specified configuration. + /// + /// - Parameters: + /// - identifier: A unique identifier used for the storage directory name. + /// - maxStaleness: The maximum age in seconds for cached items. Items older + /// than this are removed during purge. A value of `0` disables staleness-based eviction. + /// - costLimit: The maximum total size in bytes before eviction occurs. + /// A value of `0` disables cost-based eviction. + init(_ identifier: String, maxStaleness: TimeInterval = 0, costLimit: UInt64 = 0) { + self.identifier = identifier + urlProvider = ArsenalURLProvider(identifier, fileManager: FileManager()) + self.maxStaleness = maxStaleness + self.costLimit = costLimit + cost = 0 + costCalculationTask = Task { + self.calculateCost() + } + } + + /// Ensures the initial cost calculation has completed before proceeding. + private func ensureCostCalculated() async { + if let task = costCalculationTask { + cost = await task.value + costCalculationTask = nil + } + } + + /// Updates the cost limit and triggers eviction if necessary. + /// + /// - Parameter to: The new cost limit in bytes. + public func update(costLimit to: UInt64) async { + ArsenalActor.assertIsolated() + + costLimit = to + purgeUnowned() + await purge() + } + + /// Stores or removes an item on disk. + /// + /// When storing, the item is serialized using its `toData()` method and + /// written to a file. After storing, the cache may purge items if limits are exceeded. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The unique key to associate with the item. + public func set(_ value: T?, key: String) async { + ArsenalActor.assertIsolated() + await ensureCostCalculated() + + guard let url = urlProvider.url(for: key) else { + return + } + if let data = value?.toData() { + do { + try data.write(to: url) + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + if size > 0 { + cost += UInt64(size) + } + await purge() + } catch { + logger.error("Error writing to disk: \(error)") + } + } else { + deleteItem(at: url) + } + } + + /// Retrieves an item from disk. + /// + /// The item is deserialized using the type's `from(data:)` method. + /// + /// - Parameter key: The key associated with the item. + /// - Returns: The cached item, or `nil` if not found or deserialization fails. + public func value(for key: String) -> T? { + ArsenalActor.assertIsolated() + + guard let url = urlProvider.url(for: key), let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { + return nil + } + return T.from(data: data) as? T + } + + /// Checks if an item exists on disk. + /// + /// - Parameter key: The key to check. + /// - Returns: `true` if a file exists for the key, `false` otherwise. + public func contains(_ key: String) -> Bool { + ArsenalActor.assertIsolated() + + if let url = urlProvider.url(for: key) { + return urlProvider.fileManager.fileExists(atPath: url.path()) + } + return false + } + + /// No-op for disk cache. Disk items don't have weak reference semantics. + public func purgeUnowned() {} + + /// Purges items based on staleness and cost limits. + /// + /// This method: + /// 1. Removes items older than `maxStaleness` (if configured) + /// 2. Removes oldest items until under `costLimit` (if configured) + /// + /// Items are sorted by modification date, with oldest items removed first. + public func purge() async { + ArsenalActor.assertIsolated() + + guard maxStaleness > 0 || costLimit > 0 else { + return + } + + await ensureCostCalculated() + + guard let baseURL = urlProvider.cacheURL else { + return + } + + guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], options: .skipsHiddenFiles) else { + return + } + + // sort URLs newest to oldest + var sortedUrls = urls.sorted { url1, url2 in + guard let date1 = try? url1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, + let date2 = try? url2.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + else { + return false + } + return date1.compare(date2) == .orderedDescending + } + + // Purge based on date + if maxStaleness > 0 { + let now = Date() + while let url = sortedUrls.popLast() { + guard let date = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { + continue + } + + // Item is too young, since we're sorted we can bail + guard now.timeIntervalSince1970 - date.timeIntervalSince1970 > maxStaleness else { + break + } + + // We now know we need to delete the item + deleteItem(at: url) + } + } + + // Purge based on cost + if costLimit > 0 { + while cost > costLimit, let url = sortedUrls.popLast() { + guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0 else { + continue + } + + // We now know we need to delete the item + deleteItem(at: url) + } + } + } + + /// Removes all items from the disk cache. + public func clear() async { + ArsenalActor.assertIsolated() + await ensureCostCalculated() + + guard let baseURL = urlProvider.cacheURL else { + return + } + if let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil) { + for url in urls { + deleteItem(at: url) + } + } + } + + // MARK: - Private Methods + + private func deleteItem(at url: URL) { + ArsenalActor.assertIsolated() + + do { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + try urlProvider.fileManager.removeItem(at: url) + if size > 0 { + cost -= UInt64(size) + } + } catch { + logger.error("Error deleting from disk: \(error)") + } + } + + private func calculateCost() -> UInt64 { + ArsenalActor.assertIsolated() + + guard let baseURL = urlProvider.cacheURL else { + return 0 + } + + guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles) else { + return 0 + } + + return urls.reduce(0) { cost, url in + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0 { + return cost + UInt64(size) + } + return cost + } + } +} + +// MARK: - ArsenalURLProvider + +/// A helper class that manages file URLs for disk-based caching. +/// +/// This class handles: +/// - Creating the cache directory in the app's Caches folder +/// - Sanitizing keys to be valid file names +/// - Generating URLs for cache items +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) +class ArsenalURLProvider { + /// The identifier used for the cache directory. + let identifier: String + + /// An optional prefix added to the directory name. + let prefix: String + + /// The file manager used for file operations. + let fileManager: FileManager + + /// Creates a new URL provider. + /// + /// - Parameters: + /// - identifier: The identifier for the cache directory. + /// - prefix: An optional prefix for the directory name. Defaults to empty. + /// - fileManager: The file manager to use. Defaults to a new instance. + init(_ identifier: String, prefix: String = "", fileManager: FileManager) { + self.identifier = identifier + self.prefix = prefix + self.fileManager = fileManager + } + + /// The base URL for the cache directory. + /// + /// Creates the directory if it doesn't exist. Returns `nil` if the + /// Caches directory cannot be accessed. + var cacheURL: URL? { + if let baseURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appending(component: sanitizedIdentifier) { + try? fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) + return baseURL + } + return nil + } + + private lazy var sanitizedIdentifier: String = sanitize(prefix.isEmpty ? identifier : prefix + "." + identifier) + private lazy var allowedCharacterSet: CharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + + /// Sanitizes a string to be a valid file name. + /// + /// - Parameter key: The string to sanitize. + /// - Returns: A filesystem-safe version of the string. + func sanitize(_ key: String) -> String { + return key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? key + } + + /// Returns the file URL for a cache key. + /// + /// - Parameter key: The cache key. + /// - Returns: The URL where the item should be stored, or `nil` if unavailable. + func url(for key: String) -> URL? { + return cacheURL?.appending(component: sanitize(key)) + } +} diff --git a/Sources/Arsenal/Implementations/MemoryArsenal.swift b/Sources/Arsenal/Implementations/MemoryArsenal.swift new file mode 100644 index 0000000..f76eff5 --- /dev/null +++ b/Sources/Arsenal/Implementations/MemoryArsenal.swift @@ -0,0 +1,239 @@ +// +// MemoryArsenal.swift +// +// Copyright (c) 2024 Alexander Cohen. All rights reserved. +// + +import Foundation +import os + +/// An in-memory cache implementation with LRU (Least Recently Used) eviction. +/// +/// `MemoryArsenal` provides fast, in-memory caching with automatic eviction +/// when the cache exceeds its cost limit. Items are evicted based on their +/// last access time, with the least recently accessed items removed first. +/// +/// ## Features +/// +/// - **LRU Eviction**: Automatically removes least recently used items when cost limit is exceeded +/// - **Weak Reference Purging**: Can detect and remove items no longer referenced elsewhere +/// - **Thread Safety**: All operations are isolated to ``ArsenalActor`` +/// +/// ## Usage +/// +/// ```swift +/// let memoryCache = MemoryArsenal(costLimit: 1024 * 1024 * 100) // 100 MB +/// +/// // Store an item +/// await memoryCache.set(item, key: "my-key") +/// +/// // Retrieve an item (updates its access time) +/// if let cached = await memoryCache.value(for: "my-key") { +/// // Use cached item +/// } +/// ``` +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) +@ArsenalActor public class MemoryArsenal: ArsenalImp { + private let logger = Logger(subsystem: "com.bedroomcode.arsenal", category: "MemoryArsenal") + + /// Creates a new in-memory cache with the specified cost limit. + /// + /// - Parameter costLimit: The maximum total cost of items before eviction occurs. + /// A value of `0` means no limit. Defaults to `0`. + init(costLimit: UInt64 = 0) { + self.costLimit = costLimit + } + + /// Updates the cost limit and triggers eviction if necessary. + /// + /// If the new limit is lower than the current cost, items will be + /// evicted until the cache is within the new limit. + /// + /// - Parameter to: The new cost limit. + public func update(costLimit to: UInt64) { + ArsenalActor.assertIsolated() + + costLimit = to + purgeUnowned() + purge() + } + + /// Stores or removes an item in the cache. + /// + /// When storing an item, if an item with the same key exists, it is replaced. + /// After storing, the cache may purge items if the cost limit is exceeded. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The unique key to associate with the item. + public func set(_ value: T?, key: String) { + ArsenalActor.assertIsolated() + + if let img = cache[key] { + cost -= img.cost + cache[key] = nil + } + + if let img = value { + let item = MemoryItem(key: key, value: img) + cache[key] = item + cost += item.cost + } + purge() + } + + /// Retrieves an item from the cache and updates its access time. + /// + /// Accessing an item updates its timestamp, making it less likely to be + /// evicted during LRU purging. + /// + /// - Parameter key: The key associated with the item. + /// - Returns: The cached item, or `nil` if not found. + public func value(for key: String) -> T? { + ArsenalActor.assertIsolated() + + guard var item = cache[key] else { return nil } + + // update the last accessed date to ensure purges are correctly ordered + item.updateTimestamp() + cache[key] = item + return item.value + } + + /// Checks if an item exists in the cache. + /// + /// - Parameter key: The key to check. + /// - Returns: `true` if an item with the key exists, `false` otherwise. + public func contains(_ key: String) -> Bool { + ArsenalActor.assertIsolated() + + return cache[key] != nil + } + + /// Purges items that are no longer referenced elsewhere in the application. + /// + /// This method temporarily converts all cached items to weak references. + /// Items that are only held by the cache (and not referenced elsewhere) + /// will be deallocated and removed from the cache. + /// + /// This is useful for releasing memory when cached items are no longer + /// being used by the application. + public func purgeUnowned() { + ArsenalActor.assertIsolated() + + // this make all items weak + // by doing so, all items that aren't + // referenced anywhere will be nilled out + // then we recreate the cache with what + // is really owned. + + logger.debug("Purge unowned: trying to purge \(self.cache.count) items using \(self.cost) in cost") + + let weakItems = cache.values.map { $0.weakify() } + cache.removeAll() + cost = 0 + let strongItems = weakItems.compactMap { $0.strongify() } + cost = strongItems.reduce(0) { $0 + $1.cost } + strongItems.forEach { cache[$0.key] = $0 } + + logger.debug("After purge we have \(self.cache.count) items using \(self.cost) in cost") + } + + /// Purges items using LRU eviction until the cache is within its cost limit. + /// + /// Items are sorted by their last access time, and the least recently + /// accessed items are removed first until the total cost is below the limit. + public func purge() { + ArsenalActor.assertIsolated() + + // check our limits again in case we're + // good after removing non-referenced items. + guard costLimit > 0 && cost >= costLimit else { + return + } + + // least recently accessed first (LRU) + var sorted = cache.values.sorted { item1, item2 in + item1.timestamp.compare(item2.timestamp) == .orderedAscending + } + + while !sorted.isEmpty && cost >= costLimit { + guard let item = sorted.first else { + break + } + + sorted.remove(at: 0) + cache[item.key] = nil + cost -= item.cost + } + } + + /// Removes all items from the cache. + public func clear() { + ArsenalActor.assertIsolated() + + cache = [:] + cost = 0 + } + + // MARK: - Private Types + + private struct MemoryItem { + let key: String + let value: T + let cost: UInt64 + var timestamp: Date = .init() + + init(key: String, value: T) { + self.key = key + self.value = value + cost = value.cost + } + + fileprivate init(key: String, value: T, cost: UInt64, timestamp: Date) { + self.key = key + self.value = value + self.cost = cost + self.timestamp = timestamp + } + + mutating func updateTimestamp() { + timestamp = Date() + } + + func weakify() -> MemoryWeakItem { + MemoryWeakItem(key: key, value: value, cost: cost, timestamp: timestamp) + } + } + + private struct MemoryWeakItem { + let key: String + weak var value: T? + let cost: UInt64 + var timestamp: Date = .init() + + init(key: String, value: T, cost: UInt64, timestamp: Date) { + self.key = key + self.value = value + self.cost = cost + self.timestamp = timestamp + } + + func strongify() -> MemoryItem? { + guard let val = value else { + return nil + } + return MemoryItem(key: key, value: val, cost: cost, timestamp: timestamp) + } + } + + // MARK: - Properties + + private var cache: [String: MemoryItem] = [:] + + /// The current total cost of all cached items. + public private(set) var cost: UInt64 = 0 + + /// The maximum total cost allowed before eviction occurs. + public var costLimit: UInt64 +} diff --git a/Sources/Arsenal/Implementations/SwiftDataArsenal.swift b/Sources/Arsenal/Implementations/SwiftDataArsenal.swift new file mode 100644 index 0000000..6556c5c --- /dev/null +++ b/Sources/Arsenal/Implementations/SwiftDataArsenal.swift @@ -0,0 +1,193 @@ +// +// SwiftDataArsenal.swift +// +// Copyright (c) 2024 Alexander Cohen. All rights reserved. +// + +import Foundation + +#if SWIFT_DATA_ARSENAL && canImport(SwiftData) + import SwiftData + + /// A SwiftData-backed cache implementation for persistent storage. + /// + /// `SwiftDataArsenal` uses SwiftData and SQLite for persistent caching. + /// This is an experimental implementation and some features are not yet complete. + /// + /// - Warning: This implementation is experimental. Cost tracking and purging + /// are not yet implemented. + /// + /// ## Features + /// + /// - **SQLite Storage**: Uses SwiftData with SQLite backend + /// - **External Storage**: Large data is stored externally via `@Attribute(.externalStorage)` + /// - **Thread Safety**: All operations are isolated to ``ArsenalActor`` + /// + /// ## Enabling + /// + /// This class is only available when the `SWIFT_DATA_ARSENAL` compiler flag is defined. + /// + /// ## Usage + /// + /// ```swift + /// let cache = SwiftDataArsenal("com.myapp.cache") + /// + /// // Store an item + /// await cache.set(item, key: "my-key") + /// + /// // Retrieve an item + /// if let cached = await cache.value(for: "my-key") { + /// // Use cached item + /// } + /// ``` + @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, tvOS 17.0, *) + @ArsenalActor public class SwiftDataArsenal: ArsenalImp { + /// The SwiftData model for storing cached items. + @Model + fileprivate class ArsenalItemModel { + /// The unique key for this cached item. + @Attribute(.unique) var key: String + + /// The serialized item data, stored externally for large items. + @Attribute(.externalStorage) var data: Data? + + /// The cost of the cached item. + var cost: UInt64 + + /// When the item was cached. + var timestamp: Date = Date() + + /// The deserialized item value (computed lazily). + @Transient lazy var value: T? = T.from(data: data) as? T + + init(key: String, value: T, cost: UInt64) { + self.key = key + data = value.toData() + self.cost = cost + self.value = value + } + } + + private let maxStaleness: TimeInterval + private let modelContainer: ModelContainer? + private let modelContext: ModelContext? + private let urlProvider: ArsenalURLProvider + + /// The maximum total cost allowed before eviction occurs. + /// + /// - Note: Cost-based eviction is not yet implemented. + public var costLimit: UInt64 + + /// The unique identifier for this cache instance. + let identifier: String + + /// Creates a new SwiftData-backed cache. + /// + /// - Parameters: + /// - identifier: A unique identifier for the cache database. + /// - maxStaleness: The maximum age for cached items (not yet implemented). + /// - costLimit: The maximum cost before eviction (not yet implemented). + init(_ identifier: String, maxStaleness: TimeInterval = 0, costLimit _: UInt64 = 0) { + self.identifier = identifier + urlProvider = ArsenalURLProvider(identifier, prefix: "sd", fileManager: FileManager()) + self.maxStaleness = maxStaleness + costLimit = 0 + + if let configURL = urlProvider.url(for: identifier)?.appendingPathExtension("sqlite") { + let config = ModelConfiguration(identifier, url: configURL) + modelContainer = try? ModelContainer(for: ArsenalItemModel.self, configurations: config) + } else { + modelContainer = try? ModelContainer(for: ArsenalItemModel.self) + } + + if let container = modelContainer { + modelContext = ModelContext(container) + } else { + modelContext = nil + } + } + + /// The current total cost of cached items. + /// + /// - Note: Cost tracking is not yet implemented. Always returns `0`. + public var cost: UInt64 { + 0 + } + + /// Updates the cost limit. + /// + /// - Parameter to: The new cost limit. + /// - Note: Cost-based eviction is not yet implemented. + public func update(costLimit _: UInt64) { + ArsenalActor.assertIsolated() + // noop for now + } + + /// Stores or removes an item in the cache. + /// + /// - Parameters: + /// - value: The item to store, or `nil` to remove the existing item. + /// - key: The unique key to associate with the item. + public func set(_ value: T?, key: String) { + ArsenalActor.assertIsolated() + + if let val = value { + let item = ArsenalItemModel(key: key, value: val, cost: val.cost) + modelContext?.insert(item) + } else { + let fetchDescriptor = FetchDescriptor(predicate: #Predicate { item in + item.key == key + }) + do { + if let modelContext = modelContext, let item = try modelContext.fetch(fetchDescriptor).first { + modelContext.delete(item) + } + } catch { + // TODO: Handle error + } + } + } + + /// Retrieves an item from the cache. + /// + /// - Parameter key: The key associated with the item. + /// - Returns: The cached item, or `nil` if not found. + public func value(for key: String) -> T? { + ArsenalActor.assertIsolated() + + let fetchDescriptor = FetchDescriptor(predicate: #Predicate { item in + item.key == key + }) + return try? modelContext?.fetch(fetchDescriptor).first?.value + } + + /// Checks if an item exists in the cache. + /// + /// - Parameter key: The key to check. + /// - Returns: `true` if an item exists, `false` otherwise. + public func contains(_ key: String) -> Bool { + ArsenalActor.assertIsolated() + + return value(for: key) != nil + } + + /// No-op. Weak reference purging doesn't apply to SwiftData storage. + public func purgeUnowned() {} + + /// Purges items based on configured limits. + /// + /// - Note: Purging is not yet implemented. + public func purge() { + ArsenalActor.assertIsolated() + + // TODO: Implement purge based on staleness and cost + } + + /// Removes all items from the cache. + public func clear() { + ArsenalActor.assertIsolated() + try? modelContext?.delete(model: ArsenalItemModel.self) + } + } + +#endif diff --git a/Tests/ArsenalTests/ArsenalBenchmarks.swift b/Tests/ArsenalTests/ArsenalBenchmarks.swift new file mode 100644 index 0000000..c81efe6 --- /dev/null +++ b/Tests/ArsenalTests/ArsenalBenchmarks.swift @@ -0,0 +1,341 @@ +@testable import Arsenal +import XCTest + +/// Performance benchmarks for Arsenal caching operations. +/// +/// Run with: `swift test --filter Benchmark` +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, *) +class ArsenalBenchmarks: XCTestCase { + + // MARK: - Memory Cache Benchmarks + + func testBenchmarkMemorySet() async throws { + let cache = await Arsenal("benchMemorySet", resources: [ + .memory: MemoryArsenal(costLimit: 0), // No limit for benchmark + ]) + + let items = (0 ..< 1000).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + } + + measure { + let expectation = self.expectation(description: "Memory set") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "key\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + + await cache.clear([.memory]) + } + + func testBenchmarkMemoryGet() async throws { + let cache = await Arsenal("benchMemoryGet", resources: [ + .memory: MemoryArsenal(costLimit: 0), + ]) + + // Pre-populate cache + for i in 0 ..< 1000 { + let item = BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + await cache.set(item, key: "key\(i)") + } + + measure { + let expectation = self.expectation(description: "Memory get") + Task { + for i in 0 ..< 1000 { + _ = await cache.value(for: "key\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + + await cache.clear([.memory]) + } + + func testBenchmarkMemorySetWithPurge() async throws { + // Cache with limit that will trigger purges + let cache = await Arsenal("benchMemoryPurge", resources: [ + .memory: MemoryArsenal(costLimit: 100 * 1024), // 100 KB limit + ]) + + let items = (0 ..< 500).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + } + + measure { + let expectation = self.expectation(description: "Memory set with purge") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "purgeKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + + await cache.clear([.memory]) + } + + // MARK: - Disk Cache Benchmarks + + func testBenchmarkDiskSet() async throws { + let cache = await Arsenal("benchDiskSet", resources: [ + .disk: DiskArsenal("benchDiskSet", maxStaleness: 0, costLimit: 0), + ]) + + // Clear any existing data + await cache.clear([.disk]) + + let items = (0 ..< 100).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + } + + measure { + let expectation = self.expectation(description: "Disk set") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "diskKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + + await cache.clear([.disk]) + } + + func testBenchmarkDiskGet() async throws { + let cache = await Arsenal("benchDiskGet", resources: [ + .disk: DiskArsenal("benchDiskGet", maxStaleness: 0, costLimit: 0), + ]) + + // Pre-populate cache + for i in 0 ..< 100 { + let item = BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + await cache.set(item, key: "diskKey\(i)") + } + + measure { + let expectation = self.expectation(description: "Disk get") + Task { + for i in 0 ..< 100 { + _ = await cache.value(for: "diskKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + + await cache.clear([.disk]) + } + + // MARK: - Combined Cache Benchmarks + + func testBenchmarkCombinedSetBoth() async throws { + let cache = await Arsenal( + "benchCombined", + costLimit: 0, + maxStaleness: 86400 + ) + + await cache.clear([.memory, .disk]) + + let items = (0 ..< 100).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + } + + measure { + let expectation = self.expectation(description: "Combined set") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "combinedKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + + await cache.clear([.memory, .disk]) + } + + func testBenchmarkCombinedGetWithPromotion() async throws { + let cache = await Arsenal( + "benchPromotion", + costLimit: 0, + maxStaleness: 86400 + ) + + // Set items only to disk + for i in 0 ..< 100 { + let item = BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + await cache.set(item, key: "promoKey\(i)", types: [.disk]) + } + + measure { + let expectation = self.expectation(description: "Get with promotion") + Task { + for i in 0 ..< 100 { + // This will read from disk and promote to memory + _ = await cache.value(for: "promoKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + + await cache.clear([.memory, .disk]) + } + + // MARK: - Large Item Benchmarks + + func testBenchmarkLargeItemMemory() async throws { + let cache = await Arsenal("benchLargeMemory", resources: [ + .memory: MemoryArsenal(costLimit: 0), + ]) + + // 1 MB items + let items = (0 ..< 50).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024 * 1024), cost: UInt64(1024 * 1024)) + } + + measure { + let expectation = self.expectation(description: "Large item memory") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "largeKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + + await cache.clear([.memory]) + } + + func testBenchmarkLargeItemDisk() async throws { + let cache = await Arsenal("benchLargeDisk", resources: [ + .disk: DiskArsenal("benchLargeDisk", maxStaleness: 0, costLimit: 0), + ]) + + await cache.clear([.disk]) + + // 1 MB items + let items = (0 ..< 20).map { i in + BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024 * 1024), cost: UInt64(1024 * 1024)) + } + + measure { + let expectation = self.expectation(description: "Large item disk") + Task { + for (i, item) in items.enumerated() { + await cache.set(item, key: "largeDiskKey\(i)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + + await cache.clear([.disk]) + } + + // MARK: - Throughput Benchmarks + + func testBenchmarkMemoryThroughput() async throws { + let cache = await Arsenal("benchThroughput", resources: [ + .memory: MemoryArsenal(costLimit: 0), + ]) + + let item = BenchmarkItem(data: Data(repeating: 0, count: 512), cost: 512) + + measure { + let expectation = self.expectation(description: "Throughput") + Task { + // Mixed read/write workload + for i in 0 ..< 5000 { + if i % 3 == 0 { + await cache.set(item, key: "throughput\(i % 100)") + } else { + _ = await cache.value(for: "throughput\(i % 100)") + } + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + + await cache.clear([.memory]) + } + + // MARK: - Clear Benchmarks + + func testBenchmarkClearMemory() async throws { + let cache = await Arsenal("benchClearMemory", resources: [ + .memory: MemoryArsenal(costLimit: 0), + ]) + + measure { + let expectation = self.expectation(description: "Clear memory") + Task { + // Populate + for i in 0 ..< 1000 { + let item = BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + await cache.set(item, key: "clearKey\(i)") + } + // Clear + await cache.clear([.memory]) + expectation.fulfill() + } + wait(for: [expectation], timeout: 30) + } + } + + func testBenchmarkClearDisk() async throws { + let cache = await Arsenal("benchClearDisk", resources: [ + .disk: DiskArsenal("benchClearDisk", maxStaleness: 0, costLimit: 0), + ]) + + measure { + let expectation = self.expectation(description: "Clear disk") + Task { + // Populate + for i in 0 ..< 100 { + let item = BenchmarkItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) + await cache.set(item, key: "clearDiskKey\(i)") + } + // Clear + await cache.clear([.disk]) + expectation.fulfill() + } + wait(for: [expectation], timeout: 60) + } + } +} + +// MARK: - Benchmark Item + +@available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, *) +extension ArsenalBenchmarks { + final class BenchmarkItem: ArsenalItem { + let data: Data + let cost: UInt64 + + init(data: Data, cost: UInt64) { + self.data = data + self.cost = cost + } + + func toData() -> Data? { + return data + } + + static func from(data: Data?) -> ArsenalItem? { + guard let data = data else { return nil } + return BenchmarkItem(data: data, cost: UInt64(data.count)) + } + } +} diff --git a/Tests/ArsenalTests/ArsenalTests.swift b/Tests/ArsenalTests/ArsenalTests.swift index b5b1461..b61c761 100644 --- a/Tests/ArsenalTests/ArsenalTests.swift +++ b/Tests/ArsenalTests/ArsenalTests.swift @@ -1,87 +1,334 @@ -import XCTest @testable import Arsenal +import XCTest @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, *) class ArsenalTests: XCTestCase { var memoryCache: Arsenal! var diskCache: Arsenal! - + var combinedCache: Arsenal! + override func setUp() async throws { try await super.setUp() await memoryCache = Arsenal("testMemory", resources: [.memory: MemoryArsenal(costLimit: 1024 * 500)]) await diskCache = Arsenal("testDisk", resources: [.disk: DiskArsenal("testDisk", maxStaleness: 2)]) + await combinedCache = Arsenal("testCombined", costLimit: 1024 * 100, maxStaleness: 86400) } - + override func tearDown() async throws { await memoryCache.clear([.memory, .disk]) await diskCache.clear([.memory, .disk]) + await combinedCache.clear([.memory, .disk]) try await super.tearDown() } - + + // MARK: - Basic Operations + func testSetAndGetItem() async { let key = "testKey" let item = TestItem(data: Data(repeating: 0, count: 1024), cost: 1024) - + await memoryCache.set(item, key: key) let retrievedItem = await memoryCache.value(for: key) - + XCTAssertNotNil(retrievedItem, "Item should be retrievable from memory cache.") XCTAssertEqual(retrievedItem?.toData(), item.toData(), "Retrieved item data should match the original.") - + await diskCache.set(item, key: key) let diskItem = await diskCache.value(for: key) - + XCTAssertNotNil(diskItem, "Item should be retrievable from disk cache.") XCTAssertEqual(diskItem?.toData(), item.toData(), "Retrieved item data from disk should match the original.") } - + + func testRemoveItem() async { + let key = "removeKey" + let item = TestItem(data: Data(repeating: 1, count: 512), cost: 512) + + // Add item + await memoryCache.set(item, key: key) + let retrieved = await memoryCache.value(for: key) + XCTAssertNotNil(retrieved, "Item should exist after setting.") + + // Remove item by setting nil + await memoryCache.set(nil, key: key) + let removed = await memoryCache.value(for: key) + XCTAssertNil(removed, "Item should be nil after removal.") + } + + func testRemoveItemFromDisk() async { + let key = "diskRemoveKey" + let item = TestItem(data: Data(repeating: 2, count: 512), cost: 512) + + await diskCache.set(item, key: key) + let retrieved = await diskCache.value(for: key) + XCTAssertNotNil(retrieved, "Item should exist on disk after setting.") + + await diskCache.set(nil, key: key) + let removed = await diskCache.value(for: key) + XCTAssertNil(removed, "Item should be nil after removal from disk.") + } + + func testURLBasedKey() async { + let url = URL(string: "https://example.com/image.png")! + let item = TestItem(data: Data(repeating: 3, count: 256), cost: 256) + + await memoryCache.set(item, key: url) + let retrieved = await memoryCache.value(for: url) + + XCTAssertNotNil(retrieved, "Item should be retrievable using URL key.") + XCTAssertEqual(retrieved?.toData(), item.toData(), "Retrieved item should match original.") + } + + // MARK: - Memory Cache Tests + func testMemoryPurgeOnLimitExceed() async { // Setting items until the cache exceeds its limit - for i in 0..<500 { // Each item is 1024 bytes, limit is 500 MB + for i in 0 ..< 500 { // Each item is 1024 bytes, limit is 512 KB let item = TestItem(data: Data(repeating: UInt8(i % 256), count: 1024), cost: 1024) await memoryCache.set(item, key: "key\(i)") } - + // Trigger a manual purge or wait for the system to purge automatically await memoryCache.purge([.memory]) - + // Assuming the cache uses LRU, the earliest entries should be purged let firstItem = await memoryCache.value(for: "key0") XCTAssertNil(firstItem, "First item should be purged due to memory limit.") } - + + func testLRUOrdering() async { + // Create a small cache that can hold 2 items (costLimit 2500, each item 1000) + let smallCache = await Arsenal("testLRU", resources: [ + .memory: MemoryArsenal(costLimit: 2500), + ]) + + // Add 2 items (each 1000 cost, total 2000 = under limit) + let item0 = TestItem(data: Data(repeating: 0, count: 100), cost: 1000) + let item1 = TestItem(data: Data(repeating: 1, count: 100), cost: 1000) + await smallCache.set(item0, key: "item0") + await smallCache.set(item1, key: "item1") + + // Access item0 to make it recently used + _ = await smallCache.value(for: "item0") + + // Add a new item, forcing eviction (total would be 3000, limit is 2500) + let item2 = TestItem(data: Data(repeating: 2, count: 100), cost: 1000) + await smallCache.set(item2, key: "item2") + + // item1 should be evicted (oldest accessed), item0 should survive (recently accessed) + let retrieved0 = await smallCache.value(for: "item0") + let retrieved1 = await smallCache.value(for: "item1") + let retrieved2 = await smallCache.value(for: "item2") + + XCTAssertNotNil(retrieved0, "Recently accessed item should survive LRU eviction.") + XCTAssertNil(retrieved1, "Least recently accessed item should be evicted.") + XCTAssertNotNil(retrieved2, "Newly added item should exist.") + + await smallCache.clear([.memory]) + } + + func testMemoryCostTracking() async { + let item1 = TestItem(data: Data(repeating: 1, count: 100), cost: 1000) + let item2 = TestItem(data: Data(repeating: 2, count: 100), cost: 2000) + + await memoryCache.set(item1, key: "cost1") + let cost1 = await memoryCache.memoryResourceCost + XCTAssertEqual(cost1, 1000, "Cost should be 1000 after first item.") + + await memoryCache.set(item2, key: "cost2") + let cost2 = await memoryCache.memoryResourceCost + XCTAssertEqual(cost2, 3000, "Cost should be 3000 after second item.") + + await memoryCache.set(nil, key: "cost1") + let cost3 = await memoryCache.memoryResourceCost + XCTAssertEqual(cost3, 2000, "Cost should be 2000 after removing first item.") + } + + // MARK: - Disk Cache Tests + func testDiskCacheStalenessPurge() async { let oldItem = TestItem(data: Data(repeating: 1, count: 1024), cost: 1024) await diskCache.set(oldItem, key: "oldKey") - + // Simulating passage of time and forcing a purge try? await Task.sleep(for: .seconds(3)) await diskCache.purge([.disk]) - + let retrievedOldItem = await diskCache.value(for: "oldKey") XCTAssertNil(retrievedOldItem, "Old item should be purged based on staleness.") } + + func testDiskCostBasedPurge() async { + // Create disk cache with cost limit + let costLimitedDisk = await Arsenal("testDiskCost", resources: [ + .disk: DiskArsenal("testDiskCost", maxStaleness: 0, costLimit: 2000), + ]) + + // Add items exceeding cost limit + for i in 0 ..< 5 { + let item = TestItem(data: Data(repeating: UInt8(i), count: 1000), cost: 1000) + await costLimitedDisk.set(item, key: "diskItem\(i)") + } + + // Purge should remove oldest items + await costLimitedDisk.purge([.disk]) + + // First items should be gone, later items should remain + let item0 = await costLimitedDisk.value(for: "diskItem0") + let item4 = await costLimitedDisk.value(for: "diskItem4") + + XCTAssertNil(item0, "Oldest item should be purged due to cost limit.") + XCTAssertNotNil(item4, "Newest item should survive cost-based purge.") + + await costLimitedDisk.clear([.disk]) + } + + // MARK: - Combined Cache Tests + + func testDiskToMemoryPromotion() async { + let key = "promotionKey" + let item = TestItem(data: Data(repeating: 5, count: 512), cost: 512) + + // Set only to disk + await combinedCache.set(item, key: key, types: [.disk]) + + // Verify not in memory + let memoryCost = await combinedCache.memoryResourceCost + XCTAssertEqual(memoryCost, 0, "Memory should be empty before promotion.") + + // Retrieve (should promote to memory) + let retrieved = await combinedCache.value(for: key) + XCTAssertNotNil(retrieved, "Item should be retrievable from disk.") + + // Verify now in memory + let memoryCostAfter = await combinedCache.memoryResourceCost + XCTAssertGreaterThan(memoryCostAfter, 0, "Item should be promoted to memory after retrieval.") + } + + func testCombinedCacheSetsBoth() async { + let key = "bothKey" + let item = TestItem(data: Data(repeating: 6, count: 256), cost: 256) + + await combinedCache.set(item, key: key) + + let memoryCost = await combinedCache.memoryResourceCost + let diskCost = await combinedCache.diskResourceCost + + XCTAssertGreaterThan(memoryCost, 0, "Item should be in memory.") + XCTAssertGreaterThan(diskCost, 0, "Item should be on disk.") + } + + // MARK: - Clear Tests + + func testClearMemory() async { + let item = TestItem(data: Data(repeating: 7, count: 512), cost: 512) + + await memoryCache.set(item, key: "clearKey1") + await memoryCache.set(item, key: "clearKey2") + + let costBefore = await memoryCache.memoryResourceCost + XCTAssertGreaterThan(costBefore, 0, "Cache should have items before clear.") + + await memoryCache.clear([.memory]) + + let costAfter = await memoryCache.memoryResourceCost + XCTAssertEqual(costAfter, 0, "Cache should be empty after clear.") + + let item1 = await memoryCache.value(for: "clearKey1") + let item2 = await memoryCache.value(for: "clearKey2") + XCTAssertNil(item1, "Item 1 should be nil after clear.") + XCTAssertNil(item2, "Item 2 should be nil after clear.") + } + + func testClearDisk() async { + let item = TestItem(data: Data(repeating: 8, count: 512), cost: 512) + + await diskCache.set(item, key: "diskClear1") + await diskCache.set(item, key: "diskClear2") + + await diskCache.clear([.disk]) + + let item1 = await diskCache.value(for: "diskClear1") + let item2 = await diskCache.value(for: "diskClear2") + XCTAssertNil(item1, "Item 1 should be nil after disk clear.") + XCTAssertNil(item2, "Item 2 should be nil after disk clear.") + } + + // MARK: - Cost Limit Update Tests + + func testUpdateCostLimitTriggersPurge() async { + // Fill cache with items + for i in 0 ..< 10 { + let item = TestItem(data: Data(repeating: UInt8(i), count: 100), cost: 1000) + await memoryCache.set(item, key: "update\(i)") + } + + let costBefore = await memoryCache.memoryResourceCost + XCTAssertEqual(costBefore, 10000, "Should have 10 items totaling 10000 cost.") + + // Reduce limit to trigger purge + await memoryCache.update(costLimit: 5000, for: [.memory]) + + let costAfter = await memoryCache.memoryResourceCost + XCTAssertLessThanOrEqual(costAfter, 5000, "Cost should be at or below new limit after update.") + } + + // MARK: - Edge Cases + + func testGetNonexistentKey() async { + let result = await memoryCache.value(for: "nonexistent") + XCTAssertNil(result, "Getting nonexistent key should return nil.") + } + + func testOverwriteExistingKey() async { + let key = "overwriteKey" + let item1 = TestItem(data: Data(repeating: 1, count: 100), cost: 100) + let item2 = TestItem(data: Data(repeating: 2, count: 200), cost: 200) + + await memoryCache.set(item1, key: key) + let cost1 = await memoryCache.memoryResourceCost + XCTAssertEqual(cost1, 100, "Cost should reflect first item.") + + await memoryCache.set(item2, key: key) + let cost2 = await memoryCache.memoryResourceCost + XCTAssertEqual(cost2, 200, "Cost should reflect replaced item, not sum.") + + let retrieved = await memoryCache.value(for: key) + XCTAssertEqual(retrieved?.toData(), item2.toData(), "Retrieved item should be the replacement.") + } + + func testEmptyCachePurge() async { + // Purging empty cache should not crash + await memoryCache.purge([.memory]) + await diskCache.purge([.disk]) + + let memoryCost = await memoryCache.memoryResourceCost + let diskCost = await diskCache.diskResourceCost + XCTAssertEqual(memoryCost, 0, "Empty cache should have zero cost.") + XCTAssertEqual(diskCost, 0, "Empty disk cache should have zero cost.") + } } +// MARK: - Test Item + @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, *) extension ArsenalTests { - class TestItem: ArsenalItem { - var data: Data - var cost: UInt64 - + final class TestItem: ArsenalItem { + let data: Data + let cost: UInt64 + init(data: Data, cost: UInt64) { self.data = data self.cost = cost } - + func toData() -> Data? { return data } - + static func from(data: Data?) -> ArsenalItem? { guard let data = data else { return nil } return TestItem(data: data, cost: UInt64(data.count)) } } } - From 941b5f67d9f3d680b10ab392c5122179ec03f6c1 Mon Sep 17 00:00:00 2001 From: Alexander Cohen Date: Sat, 29 Nov 2025 18:59:09 -0500 Subject: [PATCH 2/4] update readme and swift version for benchmarks --- .github/workflows/benchmarks.yml | 3 +- README.md | 178 +++++++++++++++++++++++-------- 2 files changed, 135 insertions(+), 46 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ec53ec5..2e42144 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Swift uses: swift-actions/setup-swift@v2 with: - swift-version: "6.0" + swift-version: "6.2" - name: Run benchmarks id: benchmark @@ -31,6 +31,7 @@ jobs: swift test --filter Benchmark 2>&1 | tee benchmark_output.txt - name: Parse and format results + if: success() run: | python3 << 'EOF' import re diff --git a/README.md b/README.md index ae9c7f5..540bc12 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,170 @@ - -# Welcome to Arsenal! πŸš€ +# Arsenal [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT) -[![Swift](https://img.shields.io/badge/Swift-5.5-orange.svg)](https://swift.org/) +[![Swift](https://img.shields.io/badge/Swift-6.2-orange.svg)](https://swift.org/) [![Platform](https://img.shields.io/badge/platform-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20visionOS-lightgrey.svg)]() -Arsenal is your go-to caching solution for Swift applications, offering powerful memory and disk caching with a sprinkle of modern Swift concurrency magic. Designed with iOS, macOS, watchOS, and visionOS in mind, Arsenal ensures your caching is efficient and thread-safe. Whether you're building a dynamic mobile app or a feature-rich macOS application, Arsenal fits right in, keeping your data snappy and your users happy. +A multi-layer caching library for Swift with LRU memory eviction, disk persistence, and full Swift 6 concurrency support. + +## Features + +- **Dual-Layer Caching** - Memory and disk caches work together with automatic promotion +- **LRU Eviction** - Memory cache evicts least-recently-used items when cost limit is exceeded +- **Disk Persistence** - Items survive app restarts with staleness and cost-based eviction +- **Thread Safety** - All operations isolated via `@globalActor` for Swift 6 strict concurrency +- **SwiftUI Integration** - Environment key for shared image caching +- **Flexible Keys** - Use strings or URLs as cache keys -## 🌟 Features +## Requirements -- **Dual Caching:** Enjoy the flexibility of both memory and disk caching. -- **Smart Purging:** Automatic LRU (Least Recently Used) purging for memory and time-based purging for disk caches. -- **Concurrency Ready:** Leveraging Swift's latest concurrency features for top-notch performance and safety. -- **SwiftUI Friendly:** Drops seamlessly into SwiftUI projects, making it perfect for modern iOS development. -- **Observable:** Plug into your reactive setups easily, watching for changes as they happen. +- iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+ +- Swift 6.0+ -## πŸ“‹ Requirements +## Installation -- iOS 17.0+ -- macOS 14.0+ -- watchOS 10.0+ -- visionOS 1.0+ -- Swift 5.5+ +Add Arsenal to your project using Swift Package Manager: -## πŸ”§ Installation +```swift +dependencies: [ + .package(url: "https://github.com/naftaly/Arsenal.git", from: "1.0.0") +] +``` -To get started with Arsenal, integrate it directly into your project: +Or in Xcode: **File > Add Package Dependencies** and enter the repository URL. -1. In Xcode, select **File** > **Swift Packages** > **Add Package Dependency...** -2. Enter the repository URL `https://github.com/naftaly/arsenal.git`. -3. Specify the version or branch you want to use. -4. Follow the prompts to complete the integration. +## Usage -## πŸš€ Usage +### Define a Cacheable Type -### Set Up Your Cache +Conform your type to `ArsenalItem`: ```swift import Arsenal -// Use the image cache directly -@Environment(\.imageCache) var imageCache: Arsenal +struct CachedData: ArsenalItem { + let data: Data + + // Cost for cache eviction (e.g., byte size) + var cost: UInt64 { UInt64(data.count) } -// Make your own -var cache = Arsenal + // Serialize for disk storage + func toData() -> Data? { data } + + // Deserialize from disk + static func from(data: Data?) -> ArsenalItem? { + guard let data else { return nil } + return CachedData(data: data) + } +} +``` + +### Create a Cache + +```swift +// Combined memory + disk cache +let cache = await Arsenal( + "com.myapp.cache", + costLimit: 50 * 1024 * 1024, // 50 MB memory limit + maxStaleness: 86400 // 24 hour disk staleness +) + +// Memory-only cache +let memoryCache = await Arsenal("memoryOnly", resources: [ + .memory: MemoryArsenal(costLimit: 10 * 1024 * 1024) +]) + +// Disk-only cache +let diskCache = await Arsenal("diskOnly", resources: [ + .disk: DiskArsenal("diskOnly", maxStaleness: 3600, costLimit: 100 * 1024 * 1024) +]) ``` -### Managing Cache Entries +### Store and Retrieve ```swift -// Add an image to the cache -await imageCache.set(image, key: "uniqueKey") +// Store an item (writes to both memory and disk) +await cache.set(item, key: "my-key") + +// Store with URL key +await cache.set(item, key: URL(string: "https://example.com/data")!) -// Fetch an image from the cache -let cachedImage = await imageCache.value(for: "uniqueKey") +// Store to specific layer only +await cache.set(item, key: "disk-only", types: [.disk]) + +// Retrieve (checks memory first, promotes from disk if needed) +if let cached = await cache.value(for: "my-key") { + // Use cached item +} + +// Remove an item +await cache.set(nil, key: "my-key") ``` -### Fine-tuning Your Cache +### Cache Management ```swift -// Expand memory limit to 1 GB -await imageCache.update(costLimit: 1_000_000_000, for: [.memory]) +// Update cost limits at runtime +await cache.update(costLimit: 100 * 1024 * 1024, for: [.memory]) + +// Manually trigger eviction +await cache.purge([.memory, .disk]) + +// Clear all cached items +await cache.clear([.memory, .disk]) + +// Check current usage +let memoryCost = await cache.memoryResourceCost +let diskCost = await cache.diskResourceCost ``` -### Maintenance +### SwiftUI Image Caching + +Arsenal includes built-in `UIImage` support: ```swift -// Trigger a purge -await imageCache.purge() +struct ContentView: View { + @Environment(\.imageArsenal) var imageCache -// Clear all items from both memory and disk -await imageCache.clear() + var body: some View { + // Use imageCache for caching images + } +} ``` -## πŸ‘‹ Contributing +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Arsenal β”‚ +β”‚ (Cache Orchestrator) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ MemoryArsenal β”‚ β”‚ DiskArsenal β”‚ β”‚ +β”‚ β”‚ (LRU Cache) │◄───│ (File Storage) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Cost limit β”‚ β”‚ β€’ Staleness limit β”‚ β”‚ +β”‚ β”‚ β€’ LRU eviction β”‚ β”‚ β€’ Cost limit β”‚ β”‚ +β”‚ β”‚ β€’ O(1) access β”‚ β”‚ β€’ File-per-item β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ ArsenalActor β”‚ + β”‚ (@globalActor) β”‚ + β”‚ β”‚ + β”‚ Thread-safe access β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Read path**: Memory β†’ Disk (with automatic promotion to memory) +- **Write path**: Memory + Disk (configurable per-operation) +- **Eviction**: LRU for memory, staleness + cost for disk + +## Contributing -Got ideas on how to make Arsenal even better? We'd love to hear from you! Feel free to fork the repo, push your changes, and open a pull request. You can also open an issue if you run into bugs or have feature suggestions. +Contributions welcome! Fork the repo, make your changes, and open a pull request. -## πŸ“„ License +## License -Arsenal is proudly open-sourced under the MIT License. Dive into the LICENSE file for more details. +Arsenal is available under the MIT License. See the LICENSE file for details. From 66a050ac1e908546757f24451fc1a27499306df9 Mon Sep 17 00:00:00 2001 From: Alexander Cohen Date: Sat, 29 Nov 2025 19:00:04 -0500 Subject: [PATCH 3/4] format --- Package.swift | 5 +++-- Sources/Arsenal/Arsenal.swift | 4 ++-- Sources/Arsenal/ImageArsenal.swift | 4 ++-- Sources/Arsenal/Implementations/DiskArsenal.swift | 6 +++--- Sources/Arsenal/Implementations/MemoryArsenal.swift | 8 ++++---- Sources/Arsenal/Implementations/SwiftDataArsenal.swift | 2 +- Tests/ArsenalTests/ArsenalBenchmarks.swift | 5 ++--- Tests/ArsenalTests/ArsenalTests.swift | 4 ++-- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Package.swift b/Package.swift index ba92631..ca09421 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,8 @@ let package = Package( // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Arsenal", - targets: ["Arsenal"]), + targets: ["Arsenal"] + ), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -18,7 +19,7 @@ let package = Package( name: "Arsenal", swiftSettings: [ // Enable this if you want to play around with the SwiftData Storage - //.define("SWIFT_DATA_ARSENAL") + // .define("SWIFT_DATA_ARSENAL") ] ), .testTarget( diff --git a/Sources/Arsenal/Arsenal.swift b/Sources/Arsenal/Arsenal.swift index 0ec7996..b755ec6 100644 --- a/Sources/Arsenal/Arsenal.swift +++ b/Sources/Arsenal/Arsenal.swift @@ -344,11 +344,11 @@ public protocol ArsenalItem: AnyObject, Sendable { } private var memoryResource: (any ArsenalImp)? { - return resources[.memory] + resources[.memory] } private var diskResource: (any ArsenalImp)? { - return resources[.disk] + resources[.disk] } private var resources: [ResourceType: any ArsenalImp] diff --git a/Sources/Arsenal/ImageArsenal.swift b/Sources/Arsenal/ImageArsenal.swift index ff9ac5c..a47b195 100644 --- a/Sources/Arsenal/ImageArsenal.swift +++ b/Sources/Arsenal/ImageArsenal.swift @@ -71,7 +71,7 @@ import Foundation /// /// - Returns: JPEG data representation of the image, or `nil` if encoding fails. public func toData() -> Data? { - return jpegData(compressionQuality: 1) + jpegData(compressionQuality: 1) } /// Creates a `UIImage` from serialized data. @@ -90,7 +90,7 @@ import Foundation /// Calculated as `width * height` in points. This provides a consistent /// relative measure for comparing image sizes, regardless of scale factor. public var cost: UInt64 { - return UInt64(size.width * size.height) + UInt64(size.width * size.height) } } diff --git a/Sources/Arsenal/Implementations/DiskArsenal.swift b/Sources/Arsenal/Implementations/DiskArsenal.swift index 6d7c915..877a5ee 100644 --- a/Sources/Arsenal/Implementations/DiskArsenal.swift +++ b/Sources/Arsenal/Implementations/DiskArsenal.swift @@ -319,14 +319,14 @@ class ArsenalURLProvider { } private lazy var sanitizedIdentifier: String = sanitize(prefix.isEmpty ? identifier : prefix + "." + identifier) - private lazy var allowedCharacterSet: CharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + private lazy var allowedCharacterSet: CharacterSet = .alphanumerics.union(CharacterSet(charactersIn: "._-")) /// Sanitizes a string to be a valid file name. /// /// - Parameter key: The string to sanitize. /// - Returns: A filesystem-safe version of the string. func sanitize(_ key: String) -> String { - return key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? key + key.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? key } /// Returns the file URL for a cache key. @@ -334,6 +334,6 @@ class ArsenalURLProvider { /// - Parameter key: The cache key. /// - Returns: The URL where the item should be stored, or `nil` if unavailable. func url(for key: String) -> URL? { - return cacheURL?.appending(component: sanitize(key)) + cacheURL?.appending(component: sanitize(key)) } } diff --git a/Sources/Arsenal/Implementations/MemoryArsenal.swift b/Sources/Arsenal/Implementations/MemoryArsenal.swift index f76eff5..cffa0a9 100644 --- a/Sources/Arsenal/Implementations/MemoryArsenal.swift +++ b/Sources/Arsenal/Implementations/MemoryArsenal.swift @@ -127,7 +127,7 @@ import os // then we recreate the cache with what // is really owned. - logger.debug("Purge unowned: trying to purge \(self.cache.count) items using \(self.cost) in cost") + logger.debug("Purge unowned: trying to purge \(cache.count) items using \(cost) in cost") let weakItems = cache.values.map { $0.weakify() } cache.removeAll() @@ -136,7 +136,7 @@ import os cost = strongItems.reduce(0) { $0 + $1.cost } strongItems.forEach { cache[$0.key] = $0 } - logger.debug("After purge we have \(self.cache.count) items using \(self.cost) in cost") + logger.debug("After purge we have \(cache.count) items using \(cost) in cost") } /// Purges items using LRU eviction until the cache is within its cost limit. @@ -148,7 +148,7 @@ import os // check our limits again in case we're // good after removing non-referenced items. - guard costLimit > 0 && cost >= costLimit else { + guard costLimit > 0, cost >= costLimit else { return } @@ -157,7 +157,7 @@ import os item1.timestamp.compare(item2.timestamp) == .orderedAscending } - while !sorted.isEmpty && cost >= costLimit { + while !sorted.isEmpty, cost >= costLimit { guard let item = sorted.first else { break } diff --git a/Sources/Arsenal/Implementations/SwiftDataArsenal.swift b/Sources/Arsenal/Implementations/SwiftDataArsenal.swift index 6556c5c..62be3fc 100644 --- a/Sources/Arsenal/Implementations/SwiftDataArsenal.swift +++ b/Sources/Arsenal/Implementations/SwiftDataArsenal.swift @@ -139,7 +139,7 @@ import Foundation item.key == key }) do { - if let modelContext = modelContext, let item = try modelContext.fetch(fetchDescriptor).first { + if let modelContext, let item = try modelContext.fetch(fetchDescriptor).first { modelContext.delete(item) } } catch { diff --git a/Tests/ArsenalTests/ArsenalBenchmarks.swift b/Tests/ArsenalTests/ArsenalBenchmarks.swift index c81efe6..102edae 100644 --- a/Tests/ArsenalTests/ArsenalBenchmarks.swift +++ b/Tests/ArsenalTests/ArsenalBenchmarks.swift @@ -6,7 +6,6 @@ import XCTest /// Run with: `swift test --filter Benchmark` @available(iOS 17.0, macOS 14.0, macCatalyst 17.0, watchOS 10.0, visionOS 1.0, *) class ArsenalBenchmarks: XCTestCase { - // MARK: - Memory Cache Benchmarks func testBenchmarkMemorySet() async throws { @@ -330,11 +329,11 @@ extension ArsenalBenchmarks { } func toData() -> Data? { - return data + data } static func from(data: Data?) -> ArsenalItem? { - guard let data = data else { return nil } + guard let data else { return nil } return BenchmarkItem(data: data, cost: UInt64(data.count)) } } diff --git a/Tests/ArsenalTests/ArsenalTests.swift b/Tests/ArsenalTests/ArsenalTests.swift index b61c761..68c72e6 100644 --- a/Tests/ArsenalTests/ArsenalTests.swift +++ b/Tests/ArsenalTests/ArsenalTests.swift @@ -323,11 +323,11 @@ extension ArsenalTests { } func toData() -> Data? { - return data + data } static func from(data: Data?) -> ArsenalItem? { - guard let data = data else { return nil } + guard let data else { return nil } return TestItem(data: data, cost: UInt64(data.count)) } } From 8cb4d4ba8a11f221ea223b7c25e9c231e2f71452 Mon Sep 17 00:00:00 2001 From: Alexander Cohen Date: Sat, 29 Nov 2025 19:04:16 -0500 Subject: [PATCH 4/4] format --- Sources/Arsenal/Implementations/MemoryArsenal.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Arsenal/Implementations/MemoryArsenal.swift b/Sources/Arsenal/Implementations/MemoryArsenal.swift index cffa0a9..293e30e 100644 --- a/Sources/Arsenal/Implementations/MemoryArsenal.swift +++ b/Sources/Arsenal/Implementations/MemoryArsenal.swift @@ -127,7 +127,7 @@ import os // then we recreate the cache with what // is really owned. - logger.debug("Purge unowned: trying to purge \(cache.count) items using \(cost) in cost") + logger.debug("Purge unowned: trying to purge \(self.cache.count) items using \(self.cost) in cost") let weakItems = cache.values.map { $0.weakify() } cache.removeAll() @@ -136,7 +136,7 @@ import os cost = strongItems.reduce(0) { $0 + $1.cost } strongItems.forEach { cache[$0.key] = $0 } - logger.debug("After purge we have \(cache.count) items using \(cost) in cost") + logger.debug("After purge we have \(self.cache.count) items using \(self.cost) in cost") } /// Purges items using LRU eviction until the cache is within its cost limit.