Skip to content

Commit 86caf0b

Browse files
committed
Address several concerns:
- Less bookkeeping and looping in `evictFromMemory…` - Added a helper for getting a snapshot array of disk entries - Added the ability for the URL creation method to return the identifier it used to construct the URL - Fix several smaller issues in the logic and feedback - Do evict an existing entry before storage (to keep peak disk usage down), but then perform a cleanup pass wherein all but the latest cache entry for a specific identifier are deleted. This avoids leaving orphaned cache entries if two concurrent processes or threads race in writing the same identifier to disk.
1 parent 07f1ebe commit 86caf0b

File tree

3 files changed

+125
-83
lines changed

3 files changed

+125
-83
lines changed

Foundation/URLCache.swift

Lines changed: 94 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ class StoredCachedURLResponse: NSObject, NSSecureCoding {
5353
func encode(with aCoder: NSCoder) {
5454
aCoder.encode(cachedURLResponse.response, forKey: "response")
5555
aCoder.encode(cachedURLResponse.data as NSData, forKey: "data")
56-
aCoder.encode(cachedURLResponse.storagePolicy.rawValue, forKey: "storagePolicy")
56+
aCoder.encode(Int(bitPattern: cachedURLResponse.storagePolicy.rawValue), forKey: "storagePolicy")
5757
aCoder.encode(cachedURLResponse.userInfo as NSDictionary?, forKey: "userInfo")
5858
aCoder.encode(cachedURLResponse.date as NSDate, forKey: "date")
5959
}
6060

6161
required init?(coder aDecoder: NSCoder) {
6262
guard let response = aDecoder.decodeObject(of: URLResponse.self, forKey: "response"),
6363
let data = aDecoder.decodeObject(of: NSData.self, forKey: "data"),
64-
let storagePolicy = URLCache.StoragePolicy(rawValue: UInt(aDecoder.decodeInt64(forKey: "storagePolicy"))),
64+
let storagePolicy = URLCache.StoragePolicy(rawValue: UInt(bitPattern: aDecoder.decodeInteger(forKey: "storagePolicy"))),
6565
let date = aDecoder.decodeObject(of: NSDate.self, forKey: "date") else {
6666
return nil
6767
}
@@ -196,7 +196,7 @@ open class CachedURLResponse : NSObject, NSCopying {
196196
open class URLCache : NSObject {
197197

198198
private static let sharedLock = NSLock()
199-
private static var _shared = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024, diskPath: nil)
199+
private static var _shared: URLCache?
200200

201201
/*!
202202
@method sharedURLCache
@@ -218,7 +218,13 @@ open class URLCache : NSObject {
218218
open class var shared: URLCache {
219219
get {
220220
return sharedLock.performLocked {
221-
return _shared
221+
if let shared = _shared {
222+
return shared
223+
}
224+
225+
let shared = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024, diskPath: nil)
226+
_shared = shared
227+
return shared
222228
}
223229
}
224230
set {
@@ -258,59 +264,39 @@ open class URLCache : NSObject {
258264
private var inMemoryCacheContents: [String: CacheEntry] = [:]
259265

260266
func evictFromMemoryCacheAssumingLockHeld(maximumSize: Int) {
261-
let sizes: [Int] = inMemoryCacheOrder.map {
262-
inMemoryCacheContents[$0]!.cost
263-
}
264-
265-
var totalSize = sizes.reduce(0, +)
266-
267-
guard totalSize > maximumSize else { return }
268-
269-
var identifiersToRemove: Set<String> = []
270-
for (index, identifier) in inMemoryCacheOrder.enumerated() {
271-
identifiersToRemove.insert(identifier)
272-
totalSize -= sizes[index]
273-
if totalSize < maximumSize {
267+
var totalSize = inMemoryCacheContents.values.reduce(0) { $0 + $1.cost }
268+
269+
var countEvicted = 0
270+
for identifier in inMemoryCacheOrder {
271+
if totalSize > maximumSize {
272+
countEvicted += 1
273+
let entry = inMemoryCacheContents.removeValue(forKey: identifier)!
274+
totalSize -= entry.cost
275+
} else {
274276
break
275277
}
276278
}
277279

278-
for identifier in identifiersToRemove {
279-
inMemoryCacheContents.removeValue(forKey: identifier)
280-
}
281-
inMemoryCacheOrder.removeAll(where: { identifiersToRemove.contains($0) })
280+
inMemoryCacheOrder.removeSubrange(0 ..< countEvicted)
282281
}
283282

284283
func evictFromDiskCache(maximumSize: Int) {
285-
var entries: [DiskEntry] = []
286-
enumerateDiskEntries(includingPropertiesForKeys: [.fileSizeKey]) { (entry, stop) in
287-
entries.append(entry)
288-
}
289-
290-
entries.sort { (a, b) -> Bool in
291-
a.date < b.date
284+
let entries = diskEntries(includingPropertiesForKeys: [.fileSizeKey]).sorted {
285+
$0.date < $1.date
292286
}
293287

294-
let sizes: [Int] = entries.map {
295-
return (try? $0.url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
288+
let sizes = entries.map { (entry) in
289+
(try? entry.url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
296290
}
297-
291+
298292
var totalSize = sizes.reduce(0, +)
299293

300-
guard totalSize > maximumSize else { return }
301-
302-
var urlsToRemove: [URL] = []
303294
for (index, entry) in entries.enumerated() {
304-
urlsToRemove.append(entry.url)
305-
totalSize -= sizes[index]
306-
if totalSize < maximumSize {
307-
break
295+
if totalSize > maximumSize {
296+
try? FileManager.default.removeItem(at: entry.url)
297+
totalSize -= sizes[index]
308298
}
309299
}
310-
311-
for url in urlsToRemove {
312-
try? FileManager.default.removeItem(at: url)
313-
}
314300
}
315301

316302
/*!
@@ -330,8 +316,10 @@ open class URLCache : NSObject {
330316
self.memoryCapacity = memoryCapacity
331317
self.diskCapacity = diskCapacity
332318

319+
let url: URL?
320+
333321
if let path = path {
334-
cacheDirectory = URL(fileURLWithPath: path)
322+
url = URL(fileURLWithPath: path)
335323
} else {
336324
do {
337325
let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
@@ -342,15 +330,23 @@ open class URLCache : NSObject {
342330

343331
// We append a Swift Foundation identifier to avoid clobbering a Darwin cache that may exist at the same path;
344332
// the two on-disk cache formats aren't compatible.
345-
let url = caches
346-
.appendingPathComponent("org.swift.Foundation.URLCache", isDirectory: true)
333+
url = caches
334+
.appendingPathComponent("org.swift.foundation.URLCache", isDirectory: true)
347335
.appendingPathComponent(directoryName, isDirectory: true)
336+
} catch {
337+
url = nil
338+
}
339+
}
340+
341+
if let url = url {
342+
do {
348343
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
349-
350344
cacheDirectory = url
351345
} catch {
352346
cacheDirectory = nil
353347
}
348+
} else {
349+
cacheDirectory = nil
354350
}
355351
}
356352

@@ -381,7 +377,7 @@ open class URLCache : NSObject {
381377
var identifier: String
382378

383379
init?(_ url: URL) {
384-
if url.pathExtension.localizedCompare(DiskEntry.pathExtension) != .orderedSame {
380+
if url.pathExtension.caseInsensitiveCompare(DiskEntry.pathExtension) != .orderedSame {
385381
return nil
386382
}
387383

@@ -410,35 +406,42 @@ open class URLCache : NSObject {
410406
}
411407
}
412408

413-
private func diskContentsURL(for request: URLRequest, forCreationAt date: Date? = nil) -> URL? {
414-
guard let identifier = self.identifier(for: request) else { return nil }
415-
guard let directory = cacheDirectory else { return nil }
416-
417-
var foundURL: URL?
418-
419-
enumerateDiskEntries { (entry, stop) in
420-
if entry.identifier == identifier {
421-
foundURL = entry.url
422-
stop = true
423-
}
409+
private func diskEntries(includingPropertiesForKeys keys: [URLResourceKey] = []) -> [DiskEntry] {
410+
var entries: [DiskEntry] = []
411+
enumerateDiskEntries(includingPropertiesForKeys: keys) { (entry, stop) in
412+
entries.append(entry)
424413
}
414+
return entries
415+
}
416+
417+
private func diskContentLocators(for request: URLRequest, forCreationAt date: Date? = nil) -> (identifier: String, url: URL)? {
418+
guard let directory = cacheDirectory else { return nil }
419+
guard let identifier = self.identifier(for: request) else { return nil }
425420

426421
if let date = date {
427-
// If we're trying to _create_ an entry and it already exists, then we can't -- we should evict the old one first.
428-
if foundURL != nil {
429-
return nil
430-
}
431-
432-
// Create the new URL
422+
// Create a new URL, which may or may not exist on disk.
433423
let interval = Int64(date.timeIntervalSinceReferenceDate)
434-
return directory.appendingPathComponent("\(interval).\(identifier).\(DiskEntry.pathExtension)")
424+
return (identifier, directory.appendingPathComponent("\(interval).\(identifier).\(DiskEntry.pathExtension)"))
435425
} else {
436-
return foundURL
426+
var foundURL: URL?
427+
428+
enumerateDiskEntries { (entry, stop) in
429+
if entry.identifier == identifier {
430+
foundURL = entry.url
431+
stop = true
432+
}
433+
}
434+
435+
if let foundURL = foundURL {
436+
return (identifier, foundURL)
437+
}
437438
}
439+
440+
return nil
438441
}
439442

440443
private func diskContents(for request: URLRequest) throws -> StoredCachedURLResponse? {
441-
guard let url = diskContentsURL(for: request) else { return nil }
444+
guard let url = diskContentLocators(for: request)?.url else { return nil }
442445

443446
let data = try Data(contentsOf: url)
444447
return try NSKeyedUnarchiver.unarchivedObject(ofClasses: [StoredCachedURLResponse.self], from: data) as? StoredCachedURLResponse
@@ -505,13 +508,28 @@ open class URLCache : NSObject {
505508
do {
506509
evictFromDiskCache(maximumSize: diskCapacity - entry.cost)
507510

508-
if let oldURL = diskContentsURL(for: request) {
509-
try FileManager.default.removeItem(at: oldURL)
511+
let locators = diskContentLocators(for: request, forCreationAt: Date())
512+
if let newURL = locators?.url {
513+
try serialized.write(to: newURL, options: .atomic)
510514
}
511515

512-
if let newURL = diskContentsURL(for: request, forCreationAt: Date()) {
513-
try serialized.write(to: newURL, options: .atomic)
516+
if let identifier = locators?.identifier {
517+
// Multiple threads and/or processes may be writing the same key at the same time. If writing the contents race for the exact same timestamp, we can't do much about that. (One of the two will exist, due to the .atomic; the other will error out.) But if the timestamps differ, we may end up with duplicate keys on disk.
518+
// If so, best-effort clear all entries except the one with the highest date.
519+
520+
// Refetch a snapshot of the directory contents from disk; do not trust prior state:
521+
let entriesToRemove = diskEntries().filter {
522+
$0.identifier == identifier
523+
}.sorted {
524+
$1.date < $0.date
525+
}.dropFirst() // Keep the one with the latest date.
526+
527+
for entry in entriesToRemove {
528+
// Do not interrupt cleanup if one fails.
529+
try? FileManager.default.removeItem(at: entry.url)
530+
}
514531
}
532+
515533
} catch { /* Best effort -- do not store on error. */ }
516534
}
517535
}
@@ -534,7 +552,7 @@ open class URLCache : NSObject {
534552
}
535553
}
536554

537-
if let oldURL = diskContentsURL(for: request) {
555+
if let oldURL = diskContentLocators(for: request)?.url {
538556
try? FileManager.default.removeItem(at: oldURL)
539557
}
540558
}
@@ -573,15 +591,12 @@ open class URLCache : NSObject {
573591
}
574592

575593
do { // Disk cache:
576-
var urlsToRemove: [URL] = []
577-
enumerateDiskEntries { (entry, stop) in
578-
if entry.date > date {
579-
urlsToRemove.append(entry.url)
580-
}
594+
let entriesToRemove = diskEntries().filter {
595+
$0.date > date
581596
}
582597

583-
for url in urlsToRemove {
584-
try? FileManager.default.removeItem(at: url)
598+
for entry in entriesToRemove {
599+
try? FileManager.default.removeItem(at: entry.url)
585600
}
586601
}
587602
}

Foundation/URLResponse.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ open class URLResponse : NSObject, NSSecureCoding, NSCopying {
3636
self.url = nsurl as URL
3737

3838

39-
let nsmimetype = aDecoder.decodeObject(of: NSString.self, forKey: "NS.mimeType")
40-
self.mimeType = nsmimetype as String?
39+
if let mimetype = aDecoder.decodeObject(of: NSString.self, forKey: "NS.mimeType") {
40+
self.mimeType = mimetype as String
41+
}
4142

4243
self.expectedContentLength = aDecoder.decodeInt64(forKey: "NS.expectedContentLength")
4344

TestFoundation/TestURLCache.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,25 @@ class TestURLCache : XCTestCase {
216216
}
217217
}
218218

219+
func testStoringTwiceOnlyHasOneEntry() throws {
220+
let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots)
221+
222+
let url = "https://apple.com/"
223+
let (requestA, responseA) = try cachePair(for: url, ofSize: aBit, startingWith: 1)
224+
cache.storeCachedResponse(responseA, for: requestA)
225+
226+
Thread.sleep(forTimeInterval: 3.0) // Enough to make the timestamp move forward.
227+
228+
let (requestB, responseB) = try cachePair(for: url, ofSize: aBit, startingWith: 2)
229+
cache.storeCachedResponse(responseB, for: requestB)
230+
231+
XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 1)
232+
233+
let response = cache.cachedResponse(for: requestB)
234+
XCTAssertNotNil(response)
235+
XCTAssertEqual((try response.unwrapped()).data, responseB.data)
236+
}
237+
219238
// -----
220239

221240
static var allTests: [(String, (TestURLCache) -> () throws -> Void)] {
@@ -229,6 +248,7 @@ class TestURLCache : XCTestCase {
229248
("testRemovingOne", testRemovingOne),
230249
("testRemovingAll", testRemovingAll),
231250
("testRemovingSince", testRemovingSince),
251+
("testStoringTwiceOnlyHasOneEntry", testStoringTwiceOnlyHasOneEntry),
232252
]
233253
}
234254

@@ -239,11 +259,17 @@ class TestURLCache : XCTestCase {
239259
return URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: writableTestDirectoryURL.path)
240260
}
241261

242-
func cachePair(for urlString: String, ofSize size: Int, storagePolicy: URLCache.StoragePolicy = .allowed) throws -> (URLRequest, CachedURLResponse) {
262+
func cachePair(for urlString: String, ofSize size: Int, storagePolicy: URLCache.StoragePolicy = .allowed, startingWith: UInt8 = 0) throws -> (URLRequest, CachedURLResponse) {
243263
let url = try URL(string: urlString).unwrapped()
244264
let request = URLRequest(url: url)
245265
let response = try HTTPURLResponse(url: url, statusCode: 200, httpVersion: "1.1", headerFields: [:]).unwrapped()
246-
return (request, CachedURLResponse(response: response, data: Data(count: size), storagePolicy: storagePolicy))
266+
267+
var data = Data(count: size)
268+
if data.count > 0 {
269+
data[0] = startingWith
270+
}
271+
272+
return (request, CachedURLResponse(response: response, data: data, storagePolicy: storagePolicy))
247273
}
248274

249275
var writableTestDirectoryURL: URL!

0 commit comments

Comments
 (0)