Skip to content

Commit f34b6eb

Browse files
Added snapshot manifest management
1 parent 427310e commit f34b6eb

File tree

5 files changed

+237
-13
lines changed

5 files changed

+237
-13
lines changed

Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct Identifier<T>: DatedIdentifier {
1616
}
1717
}
1818

19-
protocol DatedIdentifier: RawRepresentable, Codable, Equatable, Hashable {
19+
protocol DatedIdentifier: RawRepresentable, Codable, Equatable, Hashable, CustomStringConvertible {
2020
var rawValue: String { get }
2121
init(rawValue: String)
2222
}
@@ -45,6 +45,8 @@ extension DatedIdentifier {
4545
try DatedIdentifierComponents(self)
4646
}
4747
}
48+
49+
var description: String { rawValue }
4850
}
4951

5052
struct DatedIdentifierComponents {

Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,17 @@ extension DiskPersistence where AccessMode == ReadOnly {
100100
// MARK: - Common URL Accessors
101101
extension DiskPersistence {
102102
/// The URL that points to the Snapshots directory.
103-
var snapshotsURL: URL {
103+
nonisolated var snapshotsURL: URL {
104104
storeURL.appendingPathComponent("Snapshots", isDirectory: true)
105105
}
106106

107107
/// The URL that points to the Backups directory.
108-
var backupsURL: URL {
108+
nonisolated var backupsURL: URL {
109109
storeURL.appendingPathComponent("Backups", isDirectory: true)
110110
}
111111

112112
/// The URL that points to the Info.json file.
113-
var storeInfoURL: URL {
113+
nonisolated var storeInfoURL: URL {
114114
storeURL.appendingPathComponent("Info.json", isDirectory: false)
115115
}
116116
}
@@ -264,7 +264,7 @@ extension DiskPersistence {
264264
return snapshot
265265
}
266266

267-
let snapshot = Snapshot(identifier: snapshotID, persistence: self)
267+
let snapshot = Snapshot(id: snapshotID, persistence: self)
268268
snapshots[snapshotID] = snapshot
269269

270270
return snapshot
@@ -294,7 +294,7 @@ extension DiskPersistence {
294294
let returnValue = try await updater(snapshot)
295295

296296
/// Update the store info with snapshot info
297-
storeInfo.currentSnapshot = snapshot.identifier
297+
storeInfo.currentSnapshot = snapshot.id
298298
storeInfo.modificationDate = modificationDate
299299

300300
return returnValue

Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistenceError.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@ public enum DiskPersistenceInternalError: LocalizedError {
3636
/// A request to update store info failed as an update was made in an inconsistent state
3737
case nestedStoreWrite
3838

39+
/// A request to update snapshot manifest failed as an update was made in an inconsistent state
40+
case nestedSnapshotWrite
41+
3942
public var errorDescription: String? {
4043
switch self {
4144
case .nestedStoreWrite:
4245
return "An internal error caused the store to be modified while it was being modified. Please report reproduction steps if found!"
46+
case .nestedSnapshotWrite:
47+
return "An internal error caused a snapshot to be modified while it was being modified. Please report reproduction steps if found!"
4348
}
4449
}
4550
}

Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/Snapshot.swift

Lines changed: 218 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,226 @@ typealias SnapshotIdentifier = Identifier<Snapshot<ReadOnly>>
1212

1313
/// A type that manages access to a snapshot on disk.
1414
actor Snapshot<AccessMode: _AccessMode> {
15-
let identifier: SnapshotIdentifier
16-
weak var persistence: DiskPersistence<AccessMode>?
15+
/// The identifier of the snapshot.
16+
///
17+
/// This is used to determine where on disk the snapshot is stored.
18+
let id: SnapshotIdentifier
19+
20+
/// The persistence the stapshot is a part of.
21+
///
22+
/// Prefer to access ``Snapshot/persistence`` instead, which offers non-optional access to the same persistence.
23+
private weak var _persistence: DiskPersistence<AccessMode>?
24+
25+
/// A flag indicating if this is a backup snapshot.
26+
///
27+
/// This is used to determine where on disk the snapshot is stored.
28+
let isBackup: Bool
29+
30+
/// A cached instance of the manifest as last loaded from disk.
31+
var cachedManifest: SnapshotManifest?
32+
33+
/// A pointer to the last manifest updater, so updates can be serialized after the last request
34+
var lastUpdateManifestTask: Task<Any, Error>?
1735

1836
init(
19-
identifier: SnapshotIdentifier,
20-
persistence: DiskPersistence<AccessMode>
37+
id: SnapshotIdentifier,
38+
persistence: DiskPersistence<AccessMode>,
39+
isBackup: Bool = false
2140
) {
22-
self.identifier = identifier
23-
self.persistence = persistence
41+
self.id = id
42+
self._persistence = persistence
43+
self.isBackup = isBackup
44+
}
45+
}
46+
47+
extension Snapshot {
48+
@inlinable
49+
var persistence: DiskPersistence<AccessMode> {
50+
guard let persistence = _persistence else { preconditionFailure("Persistence is no longer allocated for this snapshot.") }
51+
return persistence
52+
}
53+
}
54+
55+
// MARK: - Common URL Accessors
56+
extension Snapshot {
57+
/// The URL that points to the Snapshot directory.
58+
var snapshotURL: URL {
59+
guard let components = try? id.components else { preconditionFailure("Components could not be determined for Snapshot.") }
60+
61+
let baseURL = isBackup ? persistence.backupsURL : persistence.snapshotsURL
62+
63+
return baseURL
64+
.appendingPathComponent(components.year, isDirectory: true)
65+
.appendingPathComponent(components.monthDay, isDirectory: true)
66+
.appendingPathComponent(components.hourMinute, isDirectory: true)
67+
.appendingPathComponent("\(id).snapshot", isDirectory: true)
68+
}
69+
70+
/// The URL that points to the Manifest.json file.
71+
var manifestURL: URL {
72+
snapshotURL.appendingPathComponent("Manifest.json", isDirectory: false)
73+
}
74+
75+
/// The URL that points to the Dirty file.
76+
var dirtyURL: URL {
77+
snapshotURL.appendingPathComponent("Dirty", isDirectory: false)
78+
}
79+
80+
/// The URL that points to the Datastores directory.
81+
var datastoresURL: URL {
82+
snapshotURL.appendingPathComponent("Datastores", isDirectory: true)
83+
}
84+
85+
/// The URL that points to the Inbox directory.
86+
var inboxURL: URL {
87+
snapshotURL.appendingPathComponent("Inbox", isDirectory: true)
88+
}
89+
}
90+
91+
// MARK: - Snapshot Manifest Management
92+
extension Snapshot {
93+
/// Load the manifest from disk, or create a suitable starting value if such a file does not exist.
94+
private func loadManifest() throws -> SnapshotManifest {
95+
do {
96+
let data = try Data(contentsOf: snapshotURL)
97+
98+
let manifestDecoder = JSONDecoder()
99+
manifestDecoder.dateDecodingStrategy = .iso8601WithMilliseconds
100+
let manifest = try manifestDecoder.decode(SnapshotManifest.self, from: data)
101+
102+
cachedManifest = manifest
103+
return manifest
104+
} catch URLError.fileDoesNotExist, CocoaError.fileReadNoSuchFile {
105+
return SnapshotManifest(id: id, modificationDate: Date())
106+
} catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == Int(POSIXError.ENOENT.rawValue) {
107+
return SnapshotManifest(id: id, modificationDate: Date())
108+
} catch {
109+
throw error
110+
}
111+
}
112+
113+
/// Write the specified manifest to the store, and cache the results in ``Snapshot/cachedManifest``.
114+
private func write(manifest: SnapshotManifest) throws where AccessMode == ReadWrite {
115+
/// Make sure the directories exists first.
116+
if cachedManifest == nil {
117+
try FileManager.default.createDirectory(at: snapshotURL, withIntermediateDirectories: true)
118+
try FileManager.default.createDirectory(at: datastoresURL, withIntermediateDirectories: true)
119+
try FileManager.default.createDirectory(at: inboxURL, withIntermediateDirectories: true)
120+
}
121+
122+
/// Encode the provided manifest, and write it to disk.
123+
let manifestEncoder = JSONEncoder()
124+
manifestEncoder.dateEncodingStrategy = .iso8601WithMilliseconds
125+
let data = try manifestEncoder.encode(manifest)
126+
try data.write(to: manifestURL, options: .atomic)
127+
128+
/// Update the cache since we know what it should be.
129+
cachedManifest = manifest
130+
}
131+
132+
/// Load and update the manifest in an updater, returning the task for the updater.
133+
///
134+
/// This method loads the ``SnapshotManifest`` from cache, offers it to be mutated, then writes it back to disk, if it changed. It is up to the caller to update the modification date of the store.
135+
///
136+
/// - Note: Calling this method when no manifest exists on disk will create it, even if no changes occur in the block.
137+
/// - Parameter updater: An updater that takes a mutable reference to a manifest, and will forward the returned value to the caller.
138+
/// - Returns: A ``/Swift/Task`` which contains the value of the updater upon completion.
139+
func updateManifest<T>(updater: @escaping (_ manifest: inout SnapshotManifest) async throws -> T) -> Task<T, Error> where AccessMode == ReadWrite {
140+
141+
if let manifest = SnapshotTaskLocals.manifest {
142+
return Task {
143+
var updatedManifest = manifest
144+
let returnValue = try await updater(&updatedManifest)
145+
146+
guard updatedManifest == manifest else {
147+
throw DiskPersistenceInternalError.nestedSnapshotWrite
148+
}
149+
150+
return returnValue
151+
}
152+
}
153+
154+
/// Grab the last task so we can chain off of it in a serial manner.
155+
let lastUpdaterTask = lastUpdateManifestTask
156+
let updaterTask = Task {
157+
/// We don't care if the last request throws an error or not, but we do want it to complete first.
158+
_ = try? await lastUpdaterTask?.value
159+
160+
/// Load the manifest so we have a fresh copy, unless we have a cached copy already.
161+
var manifest = try cachedManifest ?? self.loadManifest()
162+
163+
/// Let the updater do something with the manifest, storing the variable on the Task Local stack.
164+
let returnValue = try await SnapshotTaskLocals.$manifest.withValue(manifest) {
165+
try await updater(&manifest)
166+
}
167+
168+
/// Only write to the store if we changed the manifest for any reason
169+
if manifest != cachedManifest {
170+
try write(manifest: manifest)
171+
}
172+
return returnValue
173+
}
174+
/// Assign the task to our pointer so we can depend on it the next time. Also, re-wrap it so we can keep proper type information when returning from this method.
175+
lastUpdateManifestTask = Task { try await updaterTask.value }
176+
177+
return updaterTask
24178
}
179+
180+
/// Load the manifest in an accessor, returning the task for the updater.
181+
///
182+
/// This method loads the ``SnapshotManifest`` from cache.
183+
///
184+
/// - Parameter accessor: An accessor that takes an immutable reference to a manifest, and will forward the returned value to the caller.
185+
/// - Returns: A ``/Swift/Task`` which contains the value of the updater upon completion.
186+
func updateManifest<T>(accessor: @escaping (_ manifest: SnapshotManifest) async throws -> T) -> Task<T, Error> where AccessMode == ReadOnly {
187+
188+
if let manifest = SnapshotTaskLocals.manifest {
189+
return Task { try await accessor(manifest) }
190+
}
191+
192+
/// Grab the last task so we can chain off of it in a serial manner.
193+
let lastUpdaterTask = lastUpdateManifestTask
194+
let updaterTask = Task {
195+
/// We don't care if the last request throws an error or not, but we do want it to complete first.
196+
_ = try? await lastUpdaterTask?.value
197+
198+
/// Load the manifest so we have a fresh copy, unless we have a cached copy already.
199+
let manifest = try cachedManifest ?? self.loadManifest()
200+
201+
/// Let the accessor do something with the manifest, storing the variable on the Task Local stack.
202+
return try await SnapshotTaskLocals.$manifest.withValue(manifest) {
203+
try await accessor(manifest)
204+
}
205+
}
206+
/// Assign the task to our pointer so we can depend on it the next time. Also, re-wrap it so we can keep proper type information when returning from this method.
207+
lastUpdateManifestTask = Task { try await updaterTask.value }
208+
209+
return updaterTask
210+
}
211+
212+
/// Load and update the manifest in an updater.
213+
///
214+
/// This method loads the ``SnapshotManifest`` from cache, offers it to be mutated, then writes it back to disk, if it changed. It is up to the caller to update the modification date of the store.
215+
///
216+
/// - Note: Calling this method when no manifest exists on disk will create it, even if no changes occur in the block.
217+
/// - Parameter updater: An updater that takes a mutable reference to a manifest, and will forward the returned value to the caller.
218+
/// - Returns: The value returned from the `updater`.
219+
func withManifest<T>(updater: @escaping (_ manifest: inout SnapshotManifest) async throws -> T) async throws -> T where AccessMode == ReadWrite {
220+
try await updateManifest(updater: updater).value
221+
}
222+
223+
/// Load the manifest in an updater.
224+
///
225+
/// This method loads the ``SnapshotManifest`` from cache.
226+
///
227+
/// - Parameter accessor: An accessor that takes an immutable reference to a manifest, and will forward the returned value to the caller.
228+
/// - Returns: The value returned from the `accessor`.
229+
func withManifest<T>(accessor: @escaping (_ manifest: SnapshotManifest) async throws -> T) async throws -> T where AccessMode == ReadOnly {
230+
try await updateManifest(accessor: accessor).value
231+
}
232+
}
233+
234+
private enum SnapshotTaskLocals {
235+
@TaskLocal
236+
static var manifest: SnapshotManifest?
25237
}

Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotManifest.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@ enum SnapshotManifestVersion: String, Codable {
1616
}
1717

1818
/// A struct to store information about a ``DiskPersistence``'s snapshot on disk.
19-
struct SnapshotManifest: Codable, Equatable {
19+
struct SnapshotManifest: Codable, Equatable, Identifiable {
2020
/// The version of the snapshot, used when dealing with format changes at the library level.
2121
var version: SnapshotManifestVersion = .alpha
2222

23+
var id: SnapshotIdentifier
24+
25+
/// The last modification date of the snaphot.
26+
var modificationDate: Date
27+
2328
/// The known datastores for this snapshot, and their roots.
2429
var dataStores: [String] = []
2530
}

0 commit comments

Comments
 (0)