diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml index 747a148..2b5a7a2 100644 --- a/.github/workflows/docc.yml +++ b/.github/workflows/docc.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Swift uses: swift-actions/setup-swift@v2 with: - swift-version: '6.1.0' + swift-version: '6.2.0' - name: Build and Export DocC run: | diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index a4f2fa8..cc36071 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -11,6 +11,9 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16.0 + - uses: swift-actions/setup-swift@v2 + with: + swift-version: '6.2.0' - uses: actions/checkout@v3 - name: Build run: swift build -v diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 65d8a33..785d3b6 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Swift uses: swift-actions/setup-swift@v2 with: - swift-version: '6.1.0' + swift-version: '6.2.0' - uses: actions/checkout@v3 - name: Build for release run: swift build -v -c release diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 45062d3..47699fd 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -11,11 +11,11 @@ jobs: - uses: actions/checkout@v4 # ① Install Swift for Windows - - name: Set up Swift 6.1 + - name: Set up Swift 6.2 uses: compnerd/gha-setup-swift@main with: - branch: swift-6.1-release # release branch - tag: 6.1-RELEASE # exact toolchain tag + branch: swift-6.2-release # release branch + tag: 6.2-RELEASE # exact toolchain tag # ② Build & test - run: swift --version # sanity-check diff --git a/Package.swift b/Package.swift index ecfddd8..4c42ca6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,11 +6,11 @@ import PackageDescription let package = Package( name: "Cache", platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), - .visionOS(.v1) + .macOS(.v15), + .iOS(.v18), + .watchOS(.v11), + .tvOS(.v18), + .visionOS(.v2) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/README.md b/README.md index 4755d97..5584a19 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,19 @@ *A simple, lightweight caching library for Swift.* -Requires **Swift 6.0** or later. +Requires **Swift 6.2** or later. + +### Platform Requirements + +| Platform | Minimum Version | +|----------|-----------------| +| macOS | 15.0+ | +| iOS | 18.0+ | +| watchOS | 11.0+ | +| tvOS | 18.0+ | +| visionOS | 2.0+ | + +> **Note:** For older platform support (macOS 10.15+, iOS 13+, etc.), use version 2.x.x. ## What is Cache? @@ -13,7 +25,7 @@ Cache is a Swift library for caching arbitrary data types in memory. It provides - Generic value type - Supports JSON serialization and deserialization - Flexible caching, allowing for multiple Cache objects with different configurations -- Thread-safe implementation +- Thread-safe implementation using Swift's `Mutex` from the Synchronization framework - Property Wrappers ## Installation @@ -22,6 +34,14 @@ Cache is a Swift library for caching arbitrary data types in memory. It provides Add the following line to your `Package.swift` file in the dependencies array: +```swift +dependencies: [ + .package(url: "https://github.com/0xLeif/Cache.git", from: "3.0.0") +] +``` + +For older platforms, use version 2.x: + ```swift dependencies: [ .package(url: "https://github.com/0xLeif/Cache.git", from: "2.0.0") diff --git a/Sources/Cache/Cache/AnyCacheable.swift b/Sources/Cache/Cache/AnyCacheable.swift index b6f3fbd..2f2c5ed 100644 --- a/Sources/Cache/Cache/AnyCacheable.swift +++ b/Sources/Cache/Cache/AnyCacheable.swift @@ -4,7 +4,7 @@ public class AnyCacheable: Cacheable, @unchecked Sendable { public typealias Key = AnyHashable public typealias Value = Any - private let lock = NSRecursiveLock() + private let lock = CacheLock() private var cache: any Cacheable private var cacheGet: ((AnyHashable) -> Any?)! @@ -95,68 +95,70 @@ public class AnyCacheable: Cacheable, @unchecked Sendable { _ key: AnyHashable, as: Output.Type = Output.self ) -> Output? { - lock.lock(); defer { lock.unlock() } - guard let value = cacheGet(key) else { - return nil + lock.withLock { + guard let value = cacheGet(key) else { + return nil + } + guard let output = value as? Output else { + return nil + } + return output } - - guard let output = value as? Output else { - return nil - } - - return output } public func resolve( _ key: AnyHashable, as: Output.Type = Output.self ) throws -> Output { - lock.lock(); defer { lock.unlock() } - let resolvedValue = try cacheResolve(key) - - guard let output = resolvedValue as? Output else { - throw InvalidTypeError( - expectedType: Output.self, - actualType: type(of: get(key, as: Any.self)) - ) + try lock.withLock { + let resolvedValue = try cacheResolve(key) + guard let output = resolvedValue as? Output else { + throw InvalidTypeError( + expectedType: Output.self, + actualType: Value.self + ) + } + return output } - - return output } public func set(value: Value, forKey key: AnyHashable) { - lock.lock(); defer { lock.unlock() } - cacheSet(value, key) + lock.withLock { + cacheSet(value, key) + } } public func remove(_ key: AnyHashable) { - lock.lock(); defer { lock.unlock() } - cacheRemove(key) + lock.withLock { + cacheRemove(key) + } } public func contains(_ key: AnyHashable) -> Bool { - lock.lock(); defer { lock.unlock() } - return cacheContains(key) + lock.withLock { + cacheContains(key) + } } public func require(keys: Set) throws -> Self { - lock.lock(); defer { lock.unlock() } - try cacheRequireKeys(keys) - - return self + try lock.withLock { + try cacheRequireKeys(keys) + return self + } } public func require(_ key: AnyHashable) throws -> Self { - lock.lock(); defer { lock.unlock() } - try cacheRequireKey(key) - - return self + try lock.withLock { + try cacheRequireKey(key) + return self + } } public func values(ofType: Output.Type) -> [AnyHashable: Output] { - lock.lock(); defer { lock.unlock() } - return cacheValues().compactMapValues { value in - value as? Output + lock.withLock { + cacheValues().compactMapValues { value in + value as? Output + } } } } diff --git a/Sources/Cache/Cache/Cache.swift b/Sources/Cache/Cache/Cache.swift index 2772260..81cfe1c 100644 --- a/Sources/Cache/Cache/Cache.swift +++ b/Sources/Cache/Cache/Cache.swift @@ -16,8 +16,8 @@ import Foundation open class Cache: Cacheable, @unchecked Sendable { /// Lock to synchronize the access to the cache dictionary. - /// Using NSRecursiveLock to prevent re-entrant lock deadlocks with @Published property wrapper - fileprivate var lock: NSRecursiveLock + /// Uses Mutex from the Synchronization framework (requires macOS 15+, iOS 18+). + let lock: CacheLock #if os(Linux) || os(Windows) fileprivate var cache: [Key: Value] = [:] @@ -32,7 +32,7 @@ open class Cache: Cacheable, @unchecked Sendable { - Parameter initialValues: An optional dictionary of initial key-value pairs. */ required public init(initialValues: [Key: Value] = [:]) { - lock = NSRecursiveLock() + lock = CacheLock() cache = initialValues } @@ -45,10 +45,9 @@ open class Cache: Cacheable, @unchecked Sendable { - Returns: The value stored in cache for the given key, or `nil` if it doesn't exist. */ open func get(_ key: Key, as: Output.Type = Output.self) -> Output? { - lock.lock() - defer { lock.unlock() } - - return cache.get(key, as: Output.self) + lock.withLock { + cache.get(key, as: Output.self) + } } /** @@ -61,18 +60,24 @@ open class Cache: Cacheable, @unchecked Sendable { - Throws: `MissingRequiredKeysError` if the key is missing, or `InvalidTypeError` if the value type is not compatible with the expected type. */ open func resolve(_ key: Key, as: Output.Type = Output.self) throws -> Output { - guard contains(key) else { - throw MissingRequiredKeysError(keys: [key]) - } - - guard let value: Output = get(key) else { - throw InvalidTypeError( - expectedType: Output.self, - actualType: type(of: get(key)) - ) + try lock.withLock { + guard unsafeContains(key) else { + throw MissingRequiredKeysError(keys: [key]) + } + + guard let rawValue = unsafeGet(key, as: Value.self) else { + throw MissingRequiredKeysError(keys: [key]) + } + + guard let value = rawValue as? Output else { + throw InvalidTypeError( + expectedType: Output.self, + actualType: Value.self + ) + } + + return value } - - return value } /** @@ -83,9 +88,9 @@ open class Cache: Cacheable, @unchecked Sendable { - key: The key the value should be stored under. */ open func set(value: Value, forKey key: Key) { - lock.lock() - cache.set(value: value, forKey: key) - lock.unlock() + lock.withLock { + cache.set(value: value, forKey: key) + } } /** @@ -95,9 +100,9 @@ open class Cache: Cacheable, @unchecked Sendable { - key: The key to remove the value for. */ open func remove(_ key: Key) { - lock.lock() - cache.remove(key) - lock.unlock() + lock.withLock { + cache.remove(key) + } } /** @@ -108,9 +113,9 @@ open class Cache: Cacheable, @unchecked Sendable { - Returns: `true` if the key is present in the cache, `false` otherwise. */ open func contains(_ key: Key) -> Bool { - lock.lock(); defer { lock.unlock() } - - return cache.contains(key) + lock.withLock { + cache.contains(key) + } } /** @@ -122,14 +127,15 @@ open class Cache: Cacheable, @unchecked Sendable { - Returns: The Cache instance. */ open func require(keys: Set) throws -> Self { - let missingKeys = keys - .filter { contains($0) == false } + try lock.withLock { + let missingKeys = keys.filter { unsafeContains($0) == false } - guard missingKeys.isEmpty else { - throw MissingRequiredKeysError(keys: missingKeys) - } + guard missingKeys.isEmpty else { + throw MissingRequiredKeysError(keys: missingKeys) + } - return self + return self + } } /** @@ -154,9 +160,9 @@ open class Cache: Cacheable, @unchecked Sendable { open func values( ofType: Output.Type = Output.self ) -> [Key: Output] { - lock.lock(); defer { lock.unlock() } - - return cache.values(ofType: Output.self) + lock.withLock { + cache.values(ofType: Output.self) + } } } @@ -188,3 +194,32 @@ extension Cache { try resolve(key, as: Value.self) } } + +// MARK: - Internal Unlocked Methods for Subclass Use + +extension Cache { + /// Internal unlocked get - caller must hold the lock. + func unsafeGet(_ key: Key, as: Output.Type = Output.self) -> Output? { + cache.get(key, as: Output.self) + } + + /// Internal unlocked set - caller must hold the lock. + func unsafeSet(value: Value, forKey key: Key) { + cache.set(value: value, forKey: key) + } + + /// Internal unlocked remove - caller must hold the lock. + func unsafeRemove(_ key: Key) { + cache.remove(key) + } + + /// Internal unlocked contains - caller must hold the lock. + func unsafeContains(_ key: Key) -> Bool { + cache.contains(key) + } + + /// Internal unlocked values - caller must hold the lock. + func unsafeValues(ofType: Output.Type = Output.self) -> [Key: Output] { + cache.values(ofType: Output.self) + } +} diff --git a/Sources/Cache/Cache/ComposableCache.swift b/Sources/Cache/Cache/ComposableCache.swift index 1b1f65c..212e93d 100644 --- a/Sources/Cache/Cache/ComposableCache.swift +++ b/Sources/Cache/Cache/ComposableCache.swift @@ -1,7 +1,7 @@ #if !os(Windows) import Foundation public struct ComposableCache: Cacheable, @unchecked Sendable { - private let lock = NSRecursiveLock() + private let lock = CacheLock() private let caches: [AnyCacheable] public init(caches: [any Cacheable]) { @@ -16,94 +16,88 @@ public struct ComposableCache: Cacheable, @unchecked Sendable { _ key: Key, as: Output.Type = Output.self ) -> Output? { - lock.lock(); defer { lock.unlock() } - for cache in caches { - guard - let output = cache.get(key, as: Output.self) - else { - continue + lock.withLock { + for cache in caches { + guard let output = cache.get(key, as: Output.self) else { + continue + } + return output } - - return output + return nil } - - return nil } public func resolve( _ key: Key, as: Output.Type = Output.self ) throws -> Output { - lock.lock(); defer { lock.unlock() } - for cache in caches { - guard - let output = try? cache.resolve(key, as: Output.self) - else { - continue + try lock.withLock { + for cache in caches { + guard let output = try? cache.resolve(key, as: Output.self) else { + continue + } + return output } - - return output + throw MissingRequiredKeysError(keys: [key]) } - - throw MissingRequiredKeysError(keys: [key]) } public func set(value: Any, forKey key: Key) { - lock.lock(); defer { lock.unlock() } - for cache in caches { - cache.set(value: value, forKey: key) + lock.withLock { + for cache in caches { + cache.set(value: value, forKey: key) + } } } public func remove(_ key: Key) { - lock.lock(); defer { lock.unlock() } - for cache in caches { - cache.remove(key) + lock.withLock { + for cache in caches { + cache.remove(key) + } } } public func contains(_ key: Key) -> Bool { - lock.lock(); defer { lock.unlock() } - for cache in caches { - if cache.contains(key) { - return true + lock.withLock { + for cache in caches { + if cache.contains(key) { + return true + } } + return false } - - return false } public func require(keys: Set) throws -> ComposableCache { - lock.lock(); defer { lock.unlock() } - for cache in caches { - _ = try cache.require(keys: keys) + try lock.withLock { + for cache in caches { + _ = try cache.require(keys: keys) + } + return self } - - return self } public func require(_ key: Key) throws -> ComposableCache { - lock.lock(); defer { lock.unlock() } - for cache in caches { - _ = try cache.require(key) + try lock.withLock { + for cache in caches { + _ = try cache.require(key) + } + return self } - - return self } public func values(ofType: Output.Type) -> [Key: Output] { - lock.lock(); defer { lock.unlock() } - for cache in caches { - let values = cache.values(ofType: Output.self).compactMapKeys { $0 as? Key } - - guard values.keys.count != 0 else { - continue + lock.withLock { + for cache in caches { + let values = cache.values(ofType: Output.self).compactMapKeys { $0 as? Key } + guard values.keys.count != 0 else { + continue + } + return values } - - return values + return [:] } - - return [:] } } #endif diff --git a/Sources/Cache/Cache/ExpiringCache.swift b/Sources/Cache/Cache/ExpiringCache.swift index be4b7e6..d6b855f 100644 --- a/Sources/Cache/Cache/ExpiringCache.swift +++ b/Sources/Cache/Cache/ExpiringCache.swift @@ -128,17 +128,18 @@ public class ExpiringCache: Cacheable, @unchecked Sendable - Returns: the value of the specified key casted to the output type (if possible). */ public func get(_ key: Key, as: Output.Type = Output.self) -> Output? { - guard let expiringValue = cache.get(key, as: ExpiringValue.self) else { - return nil - } + cache.lock.withLock { + guard let expiringValue = cache.unsafeGet(key, as: ExpiringValue.self) else { + return nil + } - if isExpired(value: expiringValue) { - cache.remove(key) + if isExpired(value: expiringValue) { + cache.unsafeRemove(key) + return nil + } - return nil + return expiringValue.value as? Output } - - return expiringValue.value as? Output } /** @@ -162,25 +163,28 @@ public class ExpiringCache: Cacheable, @unchecked Sendable - Returns: the value of the specified key casted to the output type. */ public func resolve(_ key: Key, as: Output.Type = Output.self) throws -> Output { - let expiringValue = try cache.resolve(key, as: ExpiringValue.self) + try cache.lock.withLock { + guard let expiringValue = cache.unsafeGet(key, as: ExpiringValue.self) else { + throw MissingRequiredKeysError(keys: [key]) + } - if isExpired(value: expiringValue) { - remove(key) + if isExpired(value: expiringValue) { + cache.unsafeRemove(key) + throw ExpiredValueError( + key: key, + expiration: expiringValue.expiration + ) + } - throw ExpiredValueError( - key: key, - expiration: expiringValue.expiration - ) - } + guard let value = expiringValue.value as? Output else { + throw InvalidTypeError( + expectedType: Output.self, + actualType: Value.self + ) + } - guard let value = expiringValue.value as? Output else { - throw InvalidTypeError( - expectedType: Output.self, - actualType: type(of: expiringValue.value) - ) + return value } - - return value } /** @@ -228,17 +232,18 @@ public class ExpiringCache: Cacheable, @unchecked Sendable - Returns: true if the cache contains the key, false otherwise. */ public func contains(_ key: Key) -> Bool { - guard let expiringValue = cache.get(key, as: ExpiringValue.self) else { - return false - } + cache.lock.withLock { + guard let expiringValue = cache.unsafeGet(key, as: ExpiringValue.self) else { + return false + } - if isExpired(value: expiringValue) { - remove(key) + if isExpired(value: expiringValue) { + cache.unsafeRemove(key) + return false + } - return false + return true } - - return cache.contains(key) } /** @@ -249,19 +254,26 @@ public class ExpiringCache: Cacheable, @unchecked Sendable - Returns: self (the Cache instance). */ public func require(keys: Set) throws -> Self { - var missingKeys: Set = [] + try cache.lock.withLock { + var missingKeys: Set = [] + + for key in keys { + if let expiringValue = cache.unsafeGet(key, as: ExpiringValue.self) { + if isExpired(value: expiringValue) { + cache.unsafeRemove(key) + missingKeys.insert(key) + } + } else { + missingKeys.insert(key) + } + } - for key in keys { - if contains(key) == false { - missingKeys.insert(key) + guard missingKeys.isEmpty else { + throw MissingRequiredKeysError(keys: missingKeys) } - } - guard missingKeys.isEmpty else { - throw MissingRequiredKeysError(keys: missingKeys) + return self } - - return self } /** @@ -282,20 +294,22 @@ public class ExpiringCache: Cacheable, @unchecked Sendable - Returns: a dictionary containing only the key-value pairs where the value is of the specified output type. */ public func values(ofType: Output.Type) -> [Key: Output] { - let values = cache.values(ofType: ExpiringValue.self) + cache.lock.withLock { + var nonExpiredValues: [Key: Output] = [:] + var expiredKeys: [Key] = [] + + cache.unsafeValues(ofType: ExpiringValue.self).forEach { key, expiringValue in + if isExpired(value: expiringValue) { + expiredKeys.append(key) + } else if let output = expiringValue.value as? Output { + nonExpiredValues[key] = output + } + } - var nonExpiredValues: [Key: Output] = [:] + expiredKeys.forEach { cache.unsafeRemove($0) } - values.forEach { key, expiringValue in - if - isExpired(value: expiringValue) == false, - let output = expiringValue.value as? Output - { - nonExpiredValues[key] = output - } + return nonExpiredValues } - - return nonExpiredValues } // MARK: - Private Helpers diff --git a/Sources/Cache/Cache/LRUCache.swift b/Sources/Cache/Cache/LRUCache.swift index 35e2afb..bf70f49 100644 --- a/Sources/Cache/Cache/LRUCache.swift +++ b/Sources/Cache/Cache/LRUCache.swift @@ -12,7 +12,6 @@ Error Handling: The set(value:forKey:) function does not throw any error. Instea The `LRUCache` class is a subclass of the `Cache` class. You can use its `capacity` property to specify the maximum number of key-value pairs that the cache can hold. */ public class LRUCache: Cache, @unchecked Sendable { - private let lock = NSRecursiveLock() private var keys: [Key] /// The maximum capacity of the cache. @@ -47,42 +46,40 @@ public class LRUCache: Cache, @unchecked Senda } public override func get(_ key: Key, as: Output.Type = Output.self) -> Output? { - lock.lock(); defer { lock.unlock() } - guard let value = super.get(key, as: Output.self) else { - return nil + lock.withLock { + guard let value = unsafeGet(key, as: Output.self) else { + return nil + } + updateKeys(recentlyUsed: key) + return value } - - updateKeys(recentlyUsed: key) - - return value } public override func set(value: Value, forKey key: Key) { - lock.lock(); defer { lock.unlock() } - super.set(value: value, forKey: key) - - updateKeys(recentlyUsed: key) - checkCapacity() + lock.withLock { + unsafeSet(value: value, forKey: key) + updateKeys(recentlyUsed: key) + checkCapacity() + } } public override func remove(_ key: Key) { - lock.lock(); defer { lock.unlock() } - super.remove(key) - - if let index = keys.firstIndex(of: key) { - keys.remove(at: index) + lock.withLock { + unsafeRemove(key) + if let index = keys.firstIndex(of: key) { + keys.remove(at: index) + } } } public override func contains(_ key: Key) -> Bool { - lock.lock(); defer { lock.unlock() } - guard super.contains(key) else { - return false + lock.withLock { + guard unsafeContains(key) else { + return false + } + updateKeys(recentlyUsed: key) + return true } - - updateKeys(recentlyUsed: key) - - return true } // MARK: - Private Helpers @@ -93,7 +90,8 @@ public class LRUCache: Cache, @unchecked Senda let keyToRemove = keys.first else { return } - remove(keyToRemove) + unsafeRemove(keyToRemove) + keys.removeFirst() } private func updateKeys(recentlyUsed: Key) { diff --git a/Sources/Cache/Cache/PersistableCache.swift b/Sources/Cache/Cache/PersistableCache.swift index db44804..26f9f3b 100644 --- a/Sources/Cache/Cache/PersistableCache.swift +++ b/Sources/Cache/Cache/PersistableCache.swift @@ -43,7 +43,6 @@ import Foundation public class PersistableCache< Key: RawRepresentable & Hashable, Value, PersistedValue >: Cache, @unchecked Sendable where Key.RawValue == String { - private let lock: NSLock = NSLock() /// The name of the cache. This will be used as the filename when saving to disk. public let name: String @@ -150,13 +149,12 @@ public class PersistableCache< - An error if the `data.write(to:)` call fails to write the JSON data to disk. */ public func save() throws { - lock.lock() - defer { lock.unlock() } - - let persistedValues = allValues.compactMapValues(persistedValueMap) - let json = JSON(initialValues: persistedValues) - let data = try json.data() - try data.write(to: url.fileURL(withName: name)) + try lock.withLock { + let persistedValues = unsafeValues(ofType: Value.self).compactMapValues(persistedValueMap) + let json = JSON(initialValues: persistedValues) + let data = try json.data() + try data.write(to: url.fileURL(withName: name)) + } } /** @@ -165,10 +163,9 @@ public class PersistableCache< - Throws: An error if the file manager fails to remove the cache file. */ public func delete() throws { - lock.lock() - defer { lock.unlock() } - - try FileManager.default.removeItem(at: url.fileURL(withName: name)) + try lock.withLock { + try FileManager.default.removeItem(at: url.fileURL(withName: name)) + } } } diff --git a/Sources/Cache/Cache/RequiredKeysCache.swift b/Sources/Cache/Cache/RequiredKeysCache.swift index 24d342f..8974b5f 100644 --- a/Sources/Cache/Cache/RequiredKeysCache.swift +++ b/Sources/Cache/Cache/RequiredKeysCache.swift @@ -1,18 +1,16 @@ -import Foundation /// The `RequiredKeysCache` class is a subclass of `Cache` that allows you to define a set of required keys. This cache ensures that the required keys are always present and throws an error if any of them are missing. public class RequiredKeysCache: Cache, @unchecked Sendable { - private let keysLock = NSLock() private var _requiredKeys: Set = [] /// The set of keys that must always be present in the cache. public var requiredKeys: Set { - get { keysLock.lock(); defer { keysLock.unlock() }; return _requiredKeys } + get { lock.withLock { _requiredKeys } } set { - keysLock.lock() - _requiredKeys = newValue - keysLock.unlock() - for key in newValue { - _ = resolve(requiredKey: key) + lock.withLock { + _requiredKeys = newValue + for key in newValue { + _validateRequiredKey(key) + } } } } @@ -56,10 +54,10 @@ public class RequiredKeysCache: Cache, @unchec - key: The key of the value to remove. */ public override func remove(_ key: Key) { - keysLock.lock(); let isRequired = _requiredKeys.contains(key); keysLock.unlock() - guard isRequired == false else { return } - - super.remove(key) + lock.withLock { + guard _requiredKeys.contains(key) == false else { return } + unsafeRemove(key) + } } /** @@ -73,15 +71,21 @@ public class RequiredKeysCache: Cache, @unchec - Throws: A runtime error if the required key is not present in the cache or if the expected value type is incorrect. */ public func resolve(requiredKey: Key, as: Output.Type = Output.self) -> Output { - keysLock.lock(); let isRequired = _requiredKeys.contains(requiredKey); keysLock.unlock() - precondition(isRequired, "The key '\(requiredKey)' is not a Required Key.") - precondition(contains(requiredKey), "Required Key Missing: '\(requiredKey)'") - - do { - return try resolve(requiredKey, as: Output.self) + lock.withLock { + precondition(_requiredKeys.contains(requiredKey), "The key '\(requiredKey)' is not a Required Key.") + precondition(unsafeContains(requiredKey), "Required Key Missing: '\(requiredKey)'") + + guard let value = unsafeGet(requiredKey, as: Output.self) else { + fatalError("Required Key Missing or Invalid Type: '\(requiredKey)'") + } + return value } + } - catch { fatalError(error.localizedDescription) } + /// Internal validation that assumes lock is already held by caller. + private func _validateRequiredKey(_ key: Key) { + precondition(_requiredKeys.contains(key), "The key '\(key)' is not a Required Key.") + precondition(unsafeContains(key), "Required Key Missing: '\(key)'") } /** @@ -111,11 +115,15 @@ public class RequiredKeysCache: Cache, @unchec as: CacheValue.Type = CacheValue.self, block: (CacheValue) -> Value ) -> Value { - let newValue = block(resolve(requiredKey: key, as: CacheValue.self)) - - set(value: newValue, forKey: key) - - return newValue + lock.withLock { + precondition(_requiredKeys.contains(key), "The key '\(key)' is not a Required Key.") + guard let currentValue = unsafeGet(key, as: CacheValue.self) else { + fatalError("Required Key Missing or Invalid Type: '\(key)'") + } + let newValue = block(currentValue) + unsafeSet(value: newValue, forKey: key) + return newValue + } } /** diff --git a/Sources/Cache/CacheLock.swift b/Sources/Cache/CacheLock.swift new file mode 100644 index 0000000..12102d8 --- /dev/null +++ b/Sources/Cache/CacheLock.swift @@ -0,0 +1,26 @@ +import Synchronization + +/// A thread-safe lock wrapper around Swift's `Mutex` type. +/// +/// Usage: +/// ```swift +/// let lock = CacheLock() +/// let result = lock.withLock { +/// // protected code +/// return value +/// } +/// ``` +final class CacheLock: Sendable { + private let mutex = Mutex(()) + + init() {} + + /// Executes the given closure while holding the lock. + /// + /// - Parameter body: The closure to execute. + /// - Returns: The value returned by the closure. + /// - Throws: Any error thrown by the closure. + func withLock(_ body: () throws -> T) rethrows -> T { + try mutex.withLock { _ in try body() } + } +} diff --git a/Sources/Cache/JSON/JSON.swift b/Sources/Cache/JSON/JSON.swift index e931714..5086139 100644 --- a/Sources/Cache/JSON/JSON.swift +++ b/Sources/Cache/JSON/JSON.swift @@ -1,7 +1,7 @@ import Foundation public struct JSON: Cacheable, @unchecked Sendable where Key.RawValue == String { - private let lock = NSLock() + private let lock = CacheLock() private var cache: [Key: Any] /** @@ -95,29 +95,29 @@ public struct JSON: Cacheable, @unchecked Send _ key: Key, keyed: JSONKey.Type = JSONKey.self ) -> JSON? { - lock.lock(); defer { lock.unlock() } - let value = cache[key] - var jsonDictionary: JSON? - - if let data = value as? Data { - jsonDictionary = JSON(data: data) - } else if let dictionary = value as? [String: Any] { - jsonDictionary = JSON( - initialValues: dictionary.compactMapKeys { key in - guard let key = JSONKey(rawValue: key) else { - return nil + lock.withLock { + let value = cache[key] + var jsonDictionary: JSON? + + if let data = value as? Data { + jsonDictionary = JSON(data: data) + } else if let dictionary = value as? [String: Any] { + jsonDictionary = JSON( + initialValues: dictionary.compactMapKeys { key in + guard let key = JSONKey(rawValue: key) else { + return nil + } + return key } + ) + } else if let dictionary = value as? [JSONKey: Any] { + jsonDictionary = JSON(initialValues: dictionary) + } else if let json = value as? JSON { + jsonDictionary = json + } - return key - } - ) - } else if let dictionary = value as? [JSONKey: Any] { - jsonDictionary = JSON(initialValues: dictionary) - } else if let json = value as? JSON { - jsonDictionary = json + return jsonDictionary } - - return jsonDictionary } /** @@ -132,29 +132,29 @@ public struct JSON: Cacheable, @unchecked Send _ key: Key, keyed: JSONKey.Type = JSONKey.self ) -> [JSON]? { - lock.lock(); defer { lock.unlock() } - let value = cache[key] - var jsonArray: [JSON]? - - if let data = value as? Data { - jsonArray = JSON.array(data: data) - } else if let array = value as? [[String: Any]] { - jsonArray = array.compactMap { json in - guard - let jsonData = try? JSONSerialization.data(withJSONObject: json) - else { return nil } - - return JSON(data: jsonData) - } - } else if let array = value as? [[JSONKey: Any]] { - jsonArray = array.map { json in - JSON(initialValues: json) + lock.withLock { + let value = cache[key] + var jsonArray: [JSON]? + + if let data = value as? Data { + jsonArray = JSON.array(data: data) + } else if let array = value as? [[String: Any]] { + jsonArray = array.compactMap { json in + guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else { + return nil + } + return JSON(data: jsonData) + } + } else if let array = value as? [[JSONKey: Any]] { + jsonArray = array.map { json in + JSON(initialValues: json) + } + } else if let json = value as? [JSON] { + jsonArray = json } - } else if let json = value as? [JSON] { - jsonArray = json - } - return jsonArray + return jsonArray + } } /** @@ -166,8 +166,9 @@ public struct JSON: Cacheable, @unchecked Send - Returns: The value for the given key, or `nil` if the key doesn't exist or the cast fails. */ public func get(_ key: Key, as: Value.Type = Value.self) -> Value? { - lock.lock(); defer { lock.unlock() } - return cache.get(key, as: Value.self) + lock.withLock { + cache.get(key, as: Value.self) + } } /** @@ -180,8 +181,9 @@ public struct JSON: Cacheable, @unchecked Send - Throws: A `MissingRequiredKeysError` if the key is missing, or an `InvalidTypeError` if the value couldn't be casted to the specified type. */ public func resolve(_ key: Key, as: Value.Type = Value.self) throws -> Value { - lock.lock(); defer { lock.unlock() } - return try cache.resolve(key, as: Value.self) + try lock.withLock { + try cache.resolve(key, as: Value.self) + } } /** @@ -192,9 +194,9 @@ public struct JSON: Cacheable, @unchecked Send - forKey: The key to associate with the value. */ public mutating func set(value: Value, forKey key: Key) { - lock.lock() - cache.set(value: value, forKey: key) - lock.unlock() + lock.withLock { + cache.set(value: value, forKey: key) + } } /** @@ -204,9 +206,9 @@ public struct JSON: Cacheable, @unchecked Send - key: The key to remove the value for. */ public mutating func remove(_ key: Key) { - lock.lock() - cache.remove(key) - lock.unlock() + lock.withLock { + cache.remove(key) + } } /** @@ -217,8 +219,9 @@ public struct JSON: Cacheable, @unchecked Send - Returns: `true` if the object contains the key, otherwise `false`. */ public func contains(_ key: Key) -> Bool { - lock.lock(); defer { lock.unlock() } - return cache.contains(key) + lock.withLock { + cache.contains(key) + } } /** @@ -231,10 +234,10 @@ public struct JSON: Cacheable, @unchecked Send */ @discardableResult public func require(keys: Set) throws -> Self { - lock.lock(); defer { lock.unlock() } - try cache.require(keys: keys) - - return self + try lock.withLock { + try cache.require(keys: keys) + return self + } } /** @@ -247,10 +250,10 @@ public struct JSON: Cacheable, @unchecked Send */ @discardableResult public func require(_ key: Key) throws -> Self { - lock.lock(); defer { lock.unlock() } - try cache.require(key) - - return self + try lock.withLock { + try cache.require(key) + return self + } } /** @@ -263,7 +266,8 @@ public struct JSON: Cacheable, @unchecked Send public func values( ofType type: Value.Type = Value.self ) -> [Key: Value] { - lock.lock(); defer { lock.unlock() } - return cache.values(ofType: type) + lock.withLock { + cache.values(ofType: type) + } } } diff --git a/Tests/CacheTests/ThreadSafetyTests.swift b/Tests/CacheTests/ThreadSafetyTests.swift index 13e0123..f53fbea 100644 --- a/Tests/CacheTests/ThreadSafetyTests.swift +++ b/Tests/CacheTests/ThreadSafetyTests.swift @@ -581,5 +581,120 @@ final class ThreadSafetyTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } - + + // MARK: - Atomic Operation Tests + + func testAtomicResolve() { + let cache = Cache() + let iterations = 500 + let expectation = XCTestExpectation(description: "Atomic resolve test") + expectation.expectedFulfillmentCount = iterations + + // Set up some initial values + for i in 0..<10 { + cache.set(value: i * 10, forKey: "key\(i)") + } + + DispatchQueue.concurrentPerform(iterations: iterations) { i in + let key = "key\(i % 10)" + do { + let value: Int = try cache.resolve(key) + XCTAssertEqual(value, (i % 10) * 10) + } catch { + XCTFail("Resolve should not fail for existing key: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testAtomicRequireKeys() { + let cache = Cache() + let iterations = 200 + let expectation = XCTestExpectation(description: "Atomic require keys test") + expectation.expectedFulfillmentCount = iterations + + // Set up initial values + for i in 0..<5 { + cache.set(value: i, forKey: "key\(i)") + } + + let requiredKeys: Set = ["key0", "key1", "key2"] + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + do { + _ = try cache.require(keys: requiredKeys) + } catch { + XCTFail("Require should not fail: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testExpiringCacheAtomicOperations() { + let cache = ExpiringCache(duration: .seconds(10)) + let iterations = 300 + let expectation = XCTestExpectation(description: "ExpiringCache atomic test") + expectation.expectedFulfillmentCount = iterations + + // Set up initial values + for i in 0..<50 { + cache.set(value: "value\(i)", forKey: i) + } + + DispatchQueue.concurrentPerform(iterations: iterations) { i in + let key = i % 50 + + // Mix of operations + switch i % 4 { + case 0: + _ = cache.get(key) + case 1: + _ = cache.contains(key) + case 2: + _ = try? cache.resolve(key, as: String.self) + default: + _ = cache.values(ofType: String.self) + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testConcurrentResolveAndModify() { + let cache = Cache() + let iterations = 500 + let expectation = XCTestExpectation(description: "Concurrent resolve and modify") + expectation.expectedFulfillmentCount = iterations + + // Set up initial values + for i in 0..<100 { + cache.set(value: i, forKey: i) + } + + DispatchQueue.concurrentPerform(iterations: iterations) { i in + let key = i % 100 + + if i % 3 == 0 { + // Resolve + _ = try? cache.resolve(key, as: Int.self) + } else if i % 3 == 1 { + // Modify + cache.set(value: i, forKey: key) + } else { + // Check contains + _ = cache.contains(key) + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + } \ No newline at end of file