-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathApiCache.swift
More file actions
181 lines (161 loc) · 7.36 KB
/
Copy pathApiCache.swift
File metadata and controls
181 lines (161 loc) · 7.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache
import WordPressShared
extension WordPressApiCache {
/// The one cache instance shared by every `WordPressClient`.
///
/// A single SQLite database backs the cache for the whole app, so every
/// `WordPressApiCache` instance opens and migrates the same file.
/// Bootstrapping more than one concurrently can fail and destroy a
/// database still in use by another; sharing one instance keeps that to a
/// single bootstrap. Swift's once-only static initialization guarantees
/// it happens once per process.
public static let shared = WordPressApiCache.bootstrap()
/// A failure encountered while opening the on-disk cache. It carries the
/// point of failure and the underlying error so the two can be reported
/// together for Sentry classification.
struct OnDiskCacheFailure: Error {
enum Kind: Equatable {
case couldNotOpenDatabase
case migrationFailed
case couldNotRemoveOrphanedSiblings
case couldNotDeleteDatabase
case couldNotRemoveSiblings
}
let kind: Kind
let underlyingError: Error
}
/// The result of opening the on-disk cache.
enum OnDiskCacheOutcome {
/// Opened an existing or freshly created cache with nothing to report.
case opened(WordPressApiCache)
/// Opened only after recovering from a broken database. The failure
/// that triggered recovery is surfaced for Sentry classification.
case recovered(WordPressApiCache, from: OnDiskCacheFailure)
/// Could not open an on-disk cache; the caller falls back to memory.
case failed(OnDiskCacheFailure)
}
private static func bootstrap() -> WordPressApiCache {
let cacheURL = URL.libraryDirectory.appending(path: "app.sqlite")
let cache: WordPressApiCache
switch onDiskCache(at: cacheURL) {
case .opened(let opened):
cache = opened
case .recovered(let recovered, let failure):
report(failure)
cache = recovered
case .failed(let failure):
report(failure)
cache = memoryCache()
}
cache.startListeningForUpdates()
return cache
}
private static func report(_ failure: OnDiskCacheFailure) {
// Report each kind from its own call site. `wpAssertionFailure` derives
// its analytics identity and 7-day suppression key from #file/#line
// (see AssertionLogger), so a shared call site would let one kind's
// report suppress the others and collapse their Sentry grouping.
let userInfo = ["error": "\(failure.underlyingError)"]
switch failure.kind {
case .couldNotOpenDatabase:
wpAssertionFailure("Failed to create an instance", userInfo: userInfo)
case .migrationFailed:
wpAssertionFailure("Failed to migrate database", userInfo: userInfo)
case .couldNotRemoveOrphanedSiblings:
wpAssertionFailure("Failed to remove orphaned sqlite sibling files", userInfo: userInfo)
case .couldNotDeleteDatabase:
wpAssertionFailure("Failed to delete sqlite database", userInfo: userInfo)
case .couldNotRemoveSiblings:
wpAssertionFailure("Failed to remove sqlite sibling files", userInfo: userInfo)
}
}
/// Opens (or creates) the on-disk cache at the given URL, recovering from a
/// broken database by deleting it and starting over. Internal so tests can
/// exercise the recovery path against a temporary location.
static func onDiskCache(at cacheURL: URL) -> OnDiskCacheOutcome {
let fileManager = FileManager.default
// A previous app version may have deleted the database file without its
// sibling files. An orphaned journal left next to a freshly created
// database is a documented SQLite corruption vector, so remove any
// leftovers first and refuse to continue if that cleanup fails.
if !fileManager.fileExists(at: cacheURL) {
do {
try removeSiblingFiles(of: cacheURL)
} catch {
return .failed(OnDiskCacheFailure(kind: .couldNotRemoveOrphanedSiblings, underlyingError: error))
}
}
switch openCache(file: cacheURL) {
case .success(let cache):
return .opened(cache)
case .failure(let failure):
// Only an existing database can be recovered by recreating it.
guard fileManager.fileExists(at: cacheURL) else {
return .failed(failure)
}
do {
try fileManager.removeItem(at: cacheURL)
} catch {
return .failed(OnDiskCacheFailure(kind: .couldNotDeleteDatabase, underlyingError: error))
}
do {
// Deleting the database but not its journal is a documented
// SQLite corruption vector; always remove them together.
try removeSiblingFiles(of: cacheURL)
} catch {
return .failed(OnDiskCacheFailure(kind: .couldNotRemoveSiblings, underlyingError: error))
}
switch openCache(file: cacheURL) {
case .success(let cache):
return .recovered(cache, from: failure)
case .failure(let retryFailure):
return .failed(retryFailure)
}
}
}
private static func openCache(file: URL) -> Result<WordPressApiCache, OnDiskCacheFailure> {
let cache: WordPressApiCache
do {
cache = try WordPressApiCache(url: file)
} catch {
return .failure(OnDiskCacheFailure(kind: .couldNotOpenDatabase, underlyingError: error))
}
do {
_ = try cache.performMigrations()
} catch {
return .failure(OnDiskCacheFailure(kind: .migrationFailed, underlyingError: error))
}
// Best-effort: keep the database out of iCloud backups. This can only
// fail in exotic filesystem states, and the cache is usable regardless,
// so it is not treated as a bootstrap failure.
var url = file
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? url.setResourceValues(values)
return .success(cache)
}
private static func removeSiblingFiles(of cacheURL: URL) throws {
let fileManager = FileManager.default
for suffix in ["-journal", "-wal", "-shm"] {
let sibling = URL(fileURLWithPath: cacheURL.path + suffix)
// Absent siblings are fine; only a genuine failure to remove one
// that exists is a corruption risk worth surfacing to the caller.
if fileManager.fileExists(at: sibling) {
try fileManager.removeItem(at: sibling)
}
}
}
private static func memoryCache() -> WordPressApiCache {
// Fallback when the on-disk cache cannot be opened. Because the cache
// is a process-wide singleton, this degradation lasts the whole
// session by design; the cache is refetchable, so the cost is a cold
// cache until relaunch.
// Creating an in-memory database should always succeed.
let cache = try! WordPressApiCache()
_ = try! cache.performMigrations()
return cache
}
}