Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .swift-version

This file was deleted.

23 changes: 23 additions & 0 deletions .swiftformat
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SwiftFormat configuration

# Format options
--indent 4
--indentcase false
--trimwhitespace always
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--maxwidth 120
--linebreaks lf

# Enabled rules
--enable isEmpty
--enable sortImports

# Disabled rules
--disable andOperator
--disable blankLinesBetweenScopes
--disable redundantSelf

# Swift version
--swiftversion 6.2
29 changes: 21 additions & 8 deletions Sources/Arsenal/Arsenal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ public protocol ArsenalItem: AnyObject, Sendable {
/// - 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) {
public convenience init(
_ identifier: String, costLimit: UInt64 = UInt64(5e+8), maxStaleness: TimeInterval = 86400
) {
self.init(
identifier,
resources: [
Expand Down Expand Up @@ -310,7 +312,7 @@ public protocol ArsenalItem: AnyObject, Sendable {
///
/// - 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<ResourceType> = [.memory, .disk]) async -> UInt64 {
public func cost(_ types: Set<ResourceType> = [.memory, .disk]) -> UInt64 {
types.reduce(into: 0) {
$0 += resources[$1]?.cost ?? 0
}
Expand All @@ -320,7 +322,7 @@ public protocol ArsenalItem: AnyObject, Sendable {
///
/// - 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<ResourceType> = [.memory, .disk]) async -> UInt64 {
public func costLimit(_ types: Set<ResourceType> = [.memory, .disk]) -> UInt64 {
types.reduce(into: 0) {
$0 += resources[$1]?.costLimit ?? 0
}
Expand All @@ -335,11 +337,22 @@ public protocol ArsenalItem: AnyObject, Sendable {
}
}

private func forEachResource(of types: Set<ResourceType>, action: @Sendable @escaping (any ArsenalImp<T>) async -> Void) async {
for type in types {
if let res = resources[type] {
await action(res)
}
// Note: Using explicit async let instead of TaskGroup because Swift 6's
// region-based isolation checker doesn't support TaskGroup in this actor context.
private func forEachResource(
of types: Set<ResourceType>, action: @Sendable @escaping (any ArsenalImp<T>) async -> Void
) async {
let memRes = types.contains(.memory) ? memoryResource : nil
let diskRes = types.contains(.disk) ? diskResource : nil

if let memRes, let diskRes {
async let memTask: Void = action(memRes)
async let diskTask: Void = action(diskRes)
_ = await (memTask, diskTask)
} else if let memRes {
await action(memRes)
} else if let diskRes {
await action(diskRes)
}
}

Expand Down
45 changes: 35 additions & 10 deletions Sources/Arsenal/Implementations/DiskArsenal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ import os
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 {
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
Expand Down Expand Up @@ -189,14 +191,22 @@ import os
return
}

guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], options: .skipsHiddenFiles) else {
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
guard
let date1 = try? url1.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate,
let date2 = try? url2.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
else {
return false
}
Expand All @@ -208,7 +218,10 @@ import os
let now = Date()
var itemsWithoutDates: [URL] = []
while let url = sortedUrls.popLast() {
guard let date = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else {
guard
let date = try? url.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
else {
// Keep items without valid dates for cost-based purge
itemsWithoutDates.append(url)
continue
Expand Down Expand Up @@ -249,7 +262,9 @@ import os
guard let baseURL = urlProvider.cacheURL else {
return
}
if let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil) {
if let urls = try? urlProvider.fileManager.contentsOfDirectory(
at: baseURL, includingPropertiesForKeys: nil
) {
for url in urls {
deleteItem(at: url)
}
Expand Down Expand Up @@ -280,7 +295,11 @@ import os
return 0
}

guard let urls = try? urlProvider.fileManager.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles) else {
guard
let urls = try? urlProvider.fileManager.contentsOfDirectory(
at: baseURL, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles
)
else {
return 0
}

Expand Down Expand Up @@ -329,15 +348,21 @@ class ArsenalURLProvider {
/// 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) {
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 = .alphanumerics.union(CharacterSet(charactersIn: "._-"))
private lazy var sanitizedIdentifier: String = sanitize(
prefix.isEmpty ? identifier : prefix + "." + identifier
)
private lazy var allowedCharacterSet: CharacterSet = .alphanumerics.union(
CharacterSet(charactersIn: "._-")
)

/// Sanitizes a string to be a valid file name.
///
Expand Down
63 changes: 32 additions & 31 deletions Sources/Arsenal/Implementations/MemoryArsenal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// Copyright (c) 2024 Alexander Cohen. All rights reserved.
//

import Darwin
import Foundation
import os

Expand Down Expand Up @@ -69,15 +70,16 @@ import os
public func set(_ value: T?, key: String) {
ArsenalActor.assertIsolated()

if let img = cache[key] {
cost -= img.cost
cache[key] = nil
}
// Get old cost if exists (single lookup)
let oldCost = cache[key]?.cost ?? 0

if let img = value {
let item = MemoryItem(key: key, value: img)
cache[key] = item
cost += item.cost
cost = cost - oldCost + item.cost
} else {
cache[key] = nil
cost -= oldCost
}
purge()
}
Expand All @@ -94,7 +96,6 @@ import os

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
Expand All @@ -121,20 +122,26 @@ import os
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.
// Convert items to weak references - items not referenced
// elsewhere will be deallocated when we clear the cache.
// Then rebuild with only the surviving items.

logger.debug("Purge unowned: trying to purge \(self.cache.count) items using \(self.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()
cost = 0
let strongItems = weakItems.compactMap { $0.strongify() }
cost = strongItems.reduce(0) { $0 + $1.cost }
strongItems.forEach { cache[$0.key] = $0 }

// Single pass: filter valid items, rebuild cache, and sum cost
var newCost: UInt64 = 0
for weakItem in weakItems {
if let strongItem = weakItem.strongify() {
cache[strongItem.key] = strongItem
newCost += strongItem.cost
}
}
cost = newCost

logger.debug("After purge we have \(self.cache.count) items using \(self.cost) in cost")
}
Expand All @@ -152,17 +159,10 @@ import os
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
}
// Sort by timestamp descending (most recent first), so popLast() gives oldest
var sorted = cache.values.sorted { $0.timestamp > $1.timestamp }

sorted.remove(at: 0)
while cost > costLimit, let item = sorted.popLast() {
cache[item.key] = nil
cost -= item.cost
}
Expand All @@ -182,23 +182,24 @@ import os
let key: String
let value: T
let cost: UInt64
var timestamp: Date = .init()
var timestamp: UInt64

init(key: String, value: T) {
self.key = key
self.value = value
cost = value.cost
timestamp = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
}

fileprivate init(key: String, value: T, cost: UInt64, timestamp: Date) {
init(key: String, value: T, cost: UInt64, timestamp: UInt64) {
self.key = key
self.value = value
self.cost = cost
self.timestamp = timestamp
}

mutating func updateTimestamp() {
timestamp = Date()
timestamp = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
}

func weakify() -> MemoryWeakItem {
Expand All @@ -210,9 +211,9 @@ import os
let key: String
weak var value: T?
let cost: UInt64
var timestamp: Date = .init()
let timestamp: UInt64

init(key: String, value: T, cost: UInt64, timestamp: Date) {
init(key: String, value: T, cost: UInt64, timestamp: UInt64) {
self.key = key
self.value = value
self.cost = cost
Expand Down
16 changes: 10 additions & 6 deletions Sources/Arsenal/Implementations/SwiftDataArsenal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ import Foundation
let item = ArsenalItemModel(key: key, value: val, cost: val.cost)
modelContext?.insert(item)
} else {
let fetchDescriptor = FetchDescriptor<ArsenalItemModel>(predicate: #Predicate { item in
item.key == key
})
let fetchDescriptor = FetchDescriptor<ArsenalItemModel>(
predicate: #Predicate { item in
item.key == key
}
)
do {
if let modelContext, let item = try modelContext.fetch(fetchDescriptor).first {
modelContext.delete(item)
Expand All @@ -155,9 +157,11 @@ import Foundation
public func value(for key: String) -> T? {
ArsenalActor.assertIsolated()

let fetchDescriptor = FetchDescriptor<ArsenalItemModel>(predicate: #Predicate { item in
item.key == key
})
let fetchDescriptor = FetchDescriptor<ArsenalItemModel>(
predicate: #Predicate { item in
item.key == key
}
)
return try? modelContext?.fetch(fetchDescriptor).first?.value
}

Expand Down
Loading
Loading