modern-swift-dev/swift-stash
SwiftStash is a small, concurrency-safe cache for Swift. It provides an actor-isolated API, configurable FIFO, LIFO, and LRU eviction, and interchangeable memory or disk storage engines.
Requirements
- Swift 6.0+
- macOS 14+
- iOS and tvOS 17+
- watchOS 10+
- visionOS 1+
- Linux with Swift 6.0+
Installation
Add SwiftStash to your package dependencies:
dependencies: [
.package(
url: "https://github.com/modern-swift-dev/swift-stash.git",
from: "2.0.0"
)
]Then add SwiftStash to the dependencies of your target:
.target(
name: "MyTarget",
dependencies: ["SwiftStash"]
)To use network time for expiration, also add the SwiftStashNetworkTime library, which depends on Kronos:
.product(name: "SwiftStashNetworkTime", package: "swift-stash")Quick start
import SwiftStash
let storage = MemoryStorageEngine<String, String>()
let cache = Cache(
policy: .lru(threshold: 5 * 60),
storageEngine: storage
)
await cache.add("Ada", for: "current-user")
if let name = await cache["current-user"] {
print(name)
}
await cache.evictUntil(maxCount: 100)Cache is an actor, so calls from outside its isolation domain use await. Values stored in a cache must be Sendable. Each cache runs on its own serial dispatch queue, so blocking disk I/O doesn't hold up the threads Swift concurrency uses for other tasks. Creating a cache doesn't touch storage; it loads its entries when first used.
Each cache has one key type. Use a string-backed enum when the set of keys is known:
enum ProfileKey: String, CacheKey {
case current
case selected
}
let storage = MemoryStorageEngine<ProfileKey, Profile>()
let cache = Cache(storageEngine: storage)
await cache.add(profile, for: .current)
let currentProfile = await cache[.current]String also conforms to CacheKey. A custom key can conform by providing a lossless stringValue representation and init?(stringValue:).
To fetch a value only when the cache doesn't hold it, use value(for:expiresAfter:orFetch:). Concurrent calls for the same key share one fetch, and a fetch that throws caches nothing:
let profile = try await cache.value(for: .current, expiresAfter: 600) {
try await api.currentProfile()
}Storage engines
MemoryStorageEngine gives the cache no persistent backing. Values remain in the Cache actor until they are removed or the cache is released.
DiskStorageEngine stores each entry in the app's caches directory, in a folder named after the bundle identifier. Processes without a bundle identifier, such as command-line tools, use their process name instead. SwiftStash includes serializers for Codable values (JsonDiskStorageSerializer) and strings (TextDiskStorageSerializer):
let storage = DiskStorageEngine(
directory: "profiles",
keyType: ProfileKey.self,
serializer: JsonDiskStorageSerializer<Profile>()
)
let cache = Cache(storageEngine: storage)The engine creates the named subdirectory when it is initialized, and recreates it if it's removed. To store entries elsewhere, such as in Application Support, which the system doesn't purge, pass a directory URL instead:
let storage = DiskStorageEngine(
directory: URL.applicationSupportDirectory.appendingPathComponent("profiles"),
keyType: ProfileKey.self,
serializer: JsonDiskStorageSerializer<Profile>()
)Give each engine its own directory: creating an engine for a directory that a live engine with another key type, value type, serializer, or encryption key uses stops the process. Disk writes report failure by returning false from the storage engine; cache mutation APIs do not throw when persistence fails.
Each entry file holds a small metadata header followed by the serialized value as is. Files written by earlier releases remain readable and are rewritten in the current format when loaded, but earlier releases can't read the current format.
A cache backed by DiskStorageEngine reads only each entry's metadata when it's created, and reads a value from disk the first time it's needed. Loaded values stay in memory for fast reads; call unloadValues() to release them. On Apple platforms, the cache also releases them whenever the system reports memory pressure. The entries stay on disk and are read again on demand.
You can support another persistence mechanism by conforming a type to StorageEngine, or support another disk representation by conforming to DiskStorageSerializer.
Eviction
Choose a policy when creating a cache:
fifo(threshold:)removes the oldest entries first.lifo(threshold:)removes the newest entries first when reducing by count.lru(threshold:)removes the least recently accessed entries first.
The threshold is measured in seconds; pass .infinity for entries that never expire by age, such as in a cache bounded only by maxCount or a cost limit. An entry can also have its own lifetime; it then expires at the end of that lifetime or at the policy's threshold, whichever comes first:
await cache.add(token, for: "session", expiresAfter: 60)Reads never return an expired entry: reading one removes it and returns nil. With lru, reads are also persisted so access times survive a reload: an entry's access is written at most once every 1% of the threshold, and at most once a minute, so an entry reloaded from storage expires at most 1% of the threshold early. The other policies don't write on reads. Use contains(_:) to check for an entry, or values() to list them, without counting as an access. Call evictExpired() to remove all expired entries, or evictUntil(maxCount:) to remove expired entries and enforce a maximum count. To enforce a maximum count automatically, pass maxCount when creating the cache; the cache then calls evictUntil(maxCount:) after each addition.
To bound the cache by size instead, pass a CostLimit with a maximum total cost and a function that computes each value's cost. After each addition, the cache removes expired entries and then evicts entries in policy order until the total is within the limit:
let images = Cache(
policy: .lru(threshold: 24 * 60 * 60),
storageEngine: MemoryStorageEngine<URL, Data>(),
costLimit: CostLimit(maxCost: 50_000_000) { $0.count }
)Time
A cache reads the time from its clock, which defaults to SystemClock, the system date. So that expiration doesn't depend on the device's date setting, pass NetworkTimeClock.shared from the SwiftStashNetworkTime library, and synchronize it once early in your app's lifetime:
import SwiftStashNetworkTime
NetworkTimeClock.shared.synchronize()
let cache = Cache(
storageEngine: MemoryStorageEngine<String, String>(),
clock: NetworkTimeClock.shared
)Synchronization uses Kronos to query NTP servers from the main run loop and finishes in the background; the clock keeps using the system date until it succeeds. On watchOS, visionOS, and Linux, the clock always uses the system date.
Testing
Pass a ManualClock to test expiration and eviction without waiting for real time to pass:
let clock = ManualClock()
let cache = Cache(
policy: .fifo(threshold: 60),
storageEngine: MemoryStorageEngine<String, String>(),
clock: clock
)
await cache.add("Ada", for: "current-user")
clock.advance(by: 60)
await cache.evictExpired()Each ManualClock is independent, so tests that use separate clocks can run in parallel.
Examples
The examples are independent, executable Swift packages:
- Memory cache - basic reads, writes, identifiable values, and count-based eviction.
- Disk cache - persistent
Codablevalues with the JSON serializer. - Encrypted disk cache - encrypted entry files, with swift-crypto on non-Apple platforms.
- Custom serializer - storing a custom value with a binary representation.
The examples guide explains each program before showing its complete source.
Run one from the repository root:
swift run --package-path Examples/memory-cacheContributing
Guides and examples live in Documentation/Site. The central documentation repository owns the shared Astro theme, builds the guides and DocC API reference, and publishes them daily. For local builds and previews, follow the docs README.
See CONTRIBUTING.md for development setup, common commands, and documentation publishing instructions.
License
SwiftStash is available under the MIT License. See LICENSE.
Package Metadata
Repository: modern-swift-dev/swift-stash
Default branch: main
README: README.md