Skip to content

Commit 94cb3ca

Browse files
committed
Big NoteArchive refactor
Moves to a model where the NoteArchive can cache modified page content, later update their properties, and explicitly create versions after accumulating a lot of cached changes. The goal is to enable faster propagation of changes beween views. In the previous architecture, changes didn't hit NoteArchive from NoteArchiveDocument until the autosave timer fired, which is way too slow.
1 parent 77ce3f4 commit 94cb3ca

6 files changed

Lines changed: 247 additions & 122 deletions

File tree

CommonplaceBookApp/NoteArchive.swift

Lines changed: 171 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,21 @@ public struct NoteArchive {
3838
/// Mapping of page UUID (constant across revisions) to the current page properties digest
3939
private var pagePropertyDigests: [String: String] = [:]
4040

41+
/// A mapping of page UUID to page contents loaded from the archive.
42+
private var pageContentsCache: [String: PageContents] = [:]
43+
4144
/// Returns the current mapping of page id to page properties
4245
public var pageProperties: [String: PageProperties] {
43-
return pagePropertyDigests.compactMapValues { propertyDigest -> PageProperties? in
46+
let archiveVersion = pagePropertyDigests.compactMapValues { propertyDigest -> PageProperties? in
4447
guard
4548
let snippet = archive.snippets[propertyDigest],
4649
let properties = try? PageProperties(snippet) else {
4750
return nil
4851
}
4952
return properties
5053
}
54+
let cacheVersion = pageContentsCache.compactMapValues { $0.pageProperties }
55+
return archiveVersion.merging(cacheVersion, uniquingKeysWith: { _, new in new })
5156
}
5257

5358
public enum SerializationError: Error {
@@ -84,6 +89,7 @@ public struct NoteArchive {
8489

8590
/// Text version of the archive, suitable for storing to disk.
8691
public func textSerialized() -> String {
92+
assert(pageContentsCache.allSatisfy({ !$0.value.dirty }))
8793
return archive.textSerialized()
8894
}
8995

@@ -93,27 +99,27 @@ public struct NoteArchive {
9399
@discardableResult
94100
public mutating func insertNote(
95101
_ text: String,
96-
contentChangeTime timestamp: Date,
97-
versionTimestamp: Date
102+
contentChangeTime timestamp: Date
98103
) throws -> String {
99-
let (propertiesSnippet, _) = try archivePageProperties(from: text, timestamp: timestamp)
104+
var pageContents = PageContents()
105+
pageContents.setText(text, modifiedTimestamp: timestamp)
100106
let key = UUID().uuidString
101-
pagePropertyDigests[key] = propertiesSnippet.sha1Digest
102-
try archivePageManifestVersion(timestamp: versionTimestamp)
107+
pageContentsCache[key] = pageContents
103108
return key
104109
}
105110

106111
/// Removes a note from the archive.
107112
/// - throws: `RetrievalError.noSuchPage` if the page does not exist.
108-
public mutating func removeNote(for pageIdentifier: String, versionTimestamp: Date) throws {
109-
guard pagePropertyDigests.removeValue(forKey: pageIdentifier) != nil else {
110-
throw RetrievalError.noSuchPage(pageIdentifier)
111-
}
112-
try archivePageManifestVersion(timestamp: versionTimestamp)
113+
public mutating func removeNote(for pageIdentifier: String) {
114+
pageContentsCache.removeValue(forKey: pageIdentifier)
115+
pagePropertyDigests.removeValue(forKey: pageIdentifier)
113116
}
114117

115118
/// Gets the current version of the text for a particular page.
116119
public func currentText(for pageIdentifier: String) throws -> String {
120+
if let cacheEntry = pageContentsCache[pageIdentifier] {
121+
return cacheEntry.text
122+
}
117123
let properties = try currentPageProperties(for: pageIdentifier).properties
118124
guard let noteSnippet = archive.snippets[properties.sha1Digest] else {
119125
throw RetrievalError.noSuchText(properties.sha1Digest)
@@ -141,36 +147,55 @@ public struct NoteArchive {
141147
/// - parameter pageIdentifier: The page identifier to update
142148
/// - parameter text: The new text of the page
143149
/// - parameter contentChangeTime: The *content change* timestamp of the text
144-
/// - parameter versionTimestamp: The time we are applying this change. If nil, the version
145-
/// will be recorded with `timestamp`. Importing an existing file is the expected
146-
/// case where the version timestamp is different from the content change timestamp.
147150
/// - note: If `text` is not different from the current value associated with `pageIdentifier`,
148151
/// this operation is a no-op. No new version gets created.
149152
public mutating func updateText(
150153
for pageIdentifier: String,
151154
to text: String,
152-
contentChangeTime timestamp: Date,
153-
versionTimestamp: Date
154-
) throws {
155-
let (existingSnippet, existingProperties) = try currentPageProperties(for: pageIdentifier)
156-
let (newSnippet, newProperties) = try archivePageProperties(from: text, timestamp: timestamp)
157-
// New content is the same as the old content
158-
if newProperties.sha1Digest == existingProperties.sha1Digest {
155+
contentChangeTime timestamp: Date
156+
) {
157+
if pageContentsCache[pageIdentifier] != nil {
158+
pageContentsCache[pageIdentifier]!.setText(text, modifiedTimestamp: timestamp)
159159
return
160+
} else {
161+
var contents = PageContents()
162+
contents.setText(text, modifiedTimestamp: timestamp)
163+
pageContentsCache[pageIdentifier] = contents
160164
}
161-
existingSnippet.encodeAsDiff(from: newSnippet)
162-
guard let existingTextSnippet = archive.snippets[existingProperties.sha1Digest] else {
163-
throw RetrievalError.noSuchPage(existingProperties.sha1Digest)
165+
}
166+
167+
/// Updates all page properties that are stale in the contents cache.
168+
/// - returns: How many page properties were updated.
169+
@discardableResult
170+
public mutating func batchUpdatePageProperties() -> Int {
171+
let updated = archive.updatePageProperties(
172+
in: pageContentsCache.filter({ $0.value.pagePropertiesStale }),
173+
parsingRules: parsingRules
174+
)
175+
pageContentsCache.merge(updated, uniquingKeysWith: { _, new in new })
176+
return updated.count
177+
}
178+
179+
/// Creates a new Version representing the current page manifest in the archive.
180+
/// - parameter timestamp: The version timestamp.
181+
/// - throws: Any errors creating the symbolic reference
182+
public mutating func archivePageManifestVersion(timestamp: Date) throws {
183+
try flushContentsCache()
184+
let version = Version(timestamp: timestamp, digest: archivePageManifest())
185+
if let existingVersion = pagePropertiesVersionHistory.last,
186+
existingVersion.digest == version.digest {
187+
// The new version is identical to the old version -- no-op.
188+
return
164189
}
165-
guard let newTextSnippet = archive.snippets[newProperties.sha1Digest] else {
166-
throw RetrievalError.noSuchPage(newProperties.sha1Digest)
190+
if let existingVersion = pagePropertiesVersionHistory.last,
191+
let oldManifestSnippet = archive.snippets[existingVersion.digest],
192+
let newManifestSnippet = archive.snippets[version.digest] {
193+
newManifestSnippet.encodeAsDiff(from: nil)
194+
oldManifestSnippet.encodeAsDiff(from: newManifestSnippet)
167195
}
168-
newTextSnippet.encodeAsDiff(from: nil)
169-
existingTextSnippet.encodeAsDiff(from: newTextSnippet)
170-
pagePropertyDigests[pageIdentifier] = newSnippet.sha1Digest
171-
try archivePageManifestVersion(timestamp: versionTimestamp)
196+
pagePropertiesVersionHistory.append(version)
197+
try archiveVersionHistory()
172198
}
173-
174199
}
175200

176201
// MARK: - Import
@@ -188,15 +213,13 @@ public extension NoteArchive {
188213
try updateText(
189214
for: importRecord.pageIdentifier,
190215
to: text,
191-
contentChangeTime: contentChangeDate,
192-
versionTimestamp: importDate
216+
contentChangeTime: contentChangeDate
193217
)
194218
}
195219
} else {
196220
let pageIdentifier = try insertNote(
197221
text,
198-
contentChangeTime: contentChangeDate,
199-
versionTimestamp: importDate
222+
contentChangeTime: contentChangeDate
200223
)
201224
importRecords[fileName] = FileImportRecord(
202225
pageIdentifier: pageIdentifier,
@@ -235,6 +258,39 @@ private extension NoteArchive {
235258
}
236259
}
237260

261+
/// An in-memory cache record of the contents of a page
262+
struct PageContents {
263+
var text: String
264+
var modifiedTimestamp: Date
265+
var dirty: Bool
266+
var pageProperties: PageProperties?
267+
var pagePropertiesStale: Bool
268+
269+
init() {
270+
self.text = ""
271+
self.modifiedTimestamp = Date.distantPast
272+
self.dirty = false
273+
self.pageProperties = nil
274+
self.pagePropertiesStale = false
275+
}
276+
277+
init(text: String, pageProperties: PageProperties) {
278+
self.text = text
279+
self.dirty = false
280+
self.pageProperties = pageProperties
281+
self.pagePropertiesStale = false
282+
self.modifiedTimestamp = pageProperties.timestamp
283+
}
284+
285+
/// Updates text.
286+
mutating func setText(_ text: String, modifiedTimestamp: Date) {
287+
self.text = text
288+
self.modifiedTimestamp = modifiedTimestamp
289+
self.dirty = true
290+
self.pagePropertiesStale = true
291+
}
292+
}
293+
238294
/// Represents a specific file that has been imported into the archive.
239295
struct FileImportRecord: Codable {
240296
/// The UUID representing the page that holds the file contents.
@@ -257,6 +313,53 @@ private extension NoteArchive {
257313
try archive.setSymbolicReference(key: "file-import", text: encoded)
258314
}
259315

316+
/// Writes any dirty content from `pageContentsCache` to `archive`
317+
/// - returns: How many modified pages were updated
318+
@discardableResult
319+
mutating func flushContentsCache() throws -> Int {
320+
var modifiedPageCount = 0
321+
// Make sure all properties are up to date
322+
batchUpdatePageProperties()
323+
for (pageIdentifier, contents) in pageContentsCache where contents.dirty {
324+
let newTextSnippet = archive.insert(contents.text)
325+
// Because we updated all page properties, safe to force-unwrap
326+
let newPropertiesSnippet = archive.insert(try contents.pageProperties!.makeSnippet())
327+
pageContentsCache[pageIdentifier]?.dirty = false
328+
modifiedPageCount += 1
329+
330+
// If there was already content for this page in the archive, delta-encode it.
331+
guard let (existingPropertiesSnippet, existingProperties) = try? currentPageProperties(for: pageIdentifier) else {
332+
pagePropertyDigests[pageIdentifier] = newPropertiesSnippet.sha1Digest
333+
continue
334+
}
335+
// New content is the same as the old content
336+
if newPropertiesSnippet.sha1Digest == existingPropertiesSnippet.sha1Digest {
337+
continue
338+
}
339+
newPropertiesSnippet.encodeAsDiff(from: nil)
340+
existingPropertiesSnippet.encodeAsDiff(from: newPropertiesSnippet)
341+
guard let existingTextSnippet = archive.snippets[existingProperties.sha1Digest] else {
342+
throw RetrievalError.noSuchPage(existingProperties.sha1Digest)
343+
}
344+
// Note the content can be the same but the properties can have different timestamps
345+
// So, check and make sure we didn't wind up with identical content before delta encoding.
346+
if newTextSnippet.sha1Digest != existingTextSnippet.sha1Digest {
347+
newTextSnippet.encodeAsDiff(from: nil)
348+
existingTextSnippet.encodeAsDiff(from: newTextSnippet)
349+
}
350+
pagePropertyDigests[pageIdentifier] = newPropertiesSnippet.sha1Digest
351+
}
352+
return modifiedPageCount
353+
}
354+
355+
/// Gets the page properties for a page identifier.
356+
///
357+
/// - note: We return both the snippet and the decoded properties so we have the option of adding delta encoding to the snippet
358+
/// if we are updating the contents of the page.
359+
///
360+
/// - parameter pageIdentifier: the page to retrieve properties for
361+
/// - returns: A tuple containing the TextSnippet of serialized properties and the deserialized version of the properties
362+
/// - throws: `RetrievalError.noSuchPage` if the page was not found in the archive.
260363
func currentPageProperties(
261364
for pageIdentifier: String
262365
) throws -> (snippet: TextSnippet, properties: PageProperties) {
@@ -267,21 +370,6 @@ private extension NoteArchive {
267370
return (propertiesSnippet, try PageProperties(propertiesSnippet))
268371
}
269372

270-
/// Creates a new Version representing the current page manifest in the archive.
271-
/// - parameter timestamp: The version timestamp.
272-
/// - throws:
273-
mutating func archivePageManifestVersion(timestamp: Date) throws {
274-
let version = Version(timestamp: timestamp, digest: archivePageManifest())
275-
if let existingVersion = pagePropertiesVersionHistory.last,
276-
let oldManifestSnippet = archive.snippets[existingVersion.digest],
277-
let newManifestSnippet = archive.snippets[version.digest] {
278-
newManifestSnippet.encodeAsDiff(from: nil)
279-
oldManifestSnippet.encodeAsDiff(from: newManifestSnippet)
280-
}
281-
pagePropertiesVersionHistory.append(version)
282-
try archiveVersionHistory()
283-
}
284-
285373
/// Writes the version history array into the archive.
286374
/// - note: We keep only one copy of the version array in the archive
287375
/// - throws: `TextSnippetArchive.Error` if there is a problem creating the symbolic reference to the version snippet
@@ -308,6 +396,8 @@ private extension NoteArchive {
308396
.compactMap(Version.init)
309397
}
310398

399+
/// Writes the current `pagePropertyDigests` into the archive.
400+
/// - returns: The sha1Digest of the snippet created to hold this version of the manifest.
311401
mutating func archivePageManifest() -> String {
312402
let manifest = pagePropertyDigests
313403
.map { "\($0.key) \($0.value)" }
@@ -317,6 +407,11 @@ private extension NoteArchive {
317407
return manifestSnippet.sha1Digest
318408
}
319409

410+
/// Loads a specific version of the page manifest from the archive.
411+
/// - parameter archive: The archive to load from.
412+
/// - parameter manifestIdentifier: The sha1Digest of a specific version of a manifest.
413+
/// - returns: a dictionary mapping pageIdentifiers to sha1Digests of specific versions of pages.
414+
/// - throws: `RetrievalError.noSuchPage` if the manifest is not in the archive.
320415
static func getPageManifest(
321416
from archive: TextSnippetArchive,
322417
manifestIdentifier: String
@@ -355,6 +450,32 @@ private extension NoteArchive {
355450
}
356451
}
357452

453+
private extension TextSnippetArchive {
454+
/// Given an array of pageContents, computes updated PageProperties for any that are stale.
455+
/// - note: This is mutating because we have to update any challenge templates in the archive
456+
/// - returns: An array where every entry has non-stale properties.
457+
mutating func updatePageProperties(
458+
in pageContents: [String: NoteArchive.PageContents],
459+
parsingRules: ParsingRules
460+
) -> [String: NoteArchive.PageContents] {
461+
pageContents.mapValues { pageContent in
462+
guard pageContent.pagePropertiesStale else { return pageContent }
463+
var pageContent = pageContent
464+
let nodes = parsingRules.parse(pageContent.text)
465+
let challengeTemplateKeys = nodes.archiveChallengeTemplates(to: &self)
466+
pageContent.pageProperties = PageProperties(
467+
sha1Digest: TextSnippet(pageContent.text).sha1Digest,
468+
timestamp: pageContent.modifiedTimestamp,
469+
hashtags: nodes.hashtags,
470+
title: String(nodes.title.split(separator: "\n").first ?? ""),
471+
cardTemplates: challengeTemplateKeys.map { $0.description }
472+
)
473+
pageContent.pagePropertiesStale = false
474+
return pageContent
475+
}
476+
}
477+
}
478+
358479
private extension Date {
359480
/// True if the receiver and `other` are "close enough"
360481
func closeEnough(to other: Date) -> Bool {

CommonplaceBookApp/NoteArchiveDocument+Importing.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public extension NoteArchiveDocument {
3131
)
3232
self.notifyObservers(of: self.noteArchive.pageProperties)
3333
self.invalidateSavedSnippets()
34+
DDLogInfo("Finished importing file \(fileName)")
3435
if let completion = completion {
3536
DispatchQueue.main.async {
3637
completion()
@@ -97,8 +98,11 @@ private extension NoteArchiveDocument {
9798
}
9899
}
99100
}
100-
group.notify(queue: .main) {
101-
completion?()
101+
group.notify(queue: noteArchiveQueue) {
102+
self.noteArchive.batchUpdatePageProperties()
103+
DispatchQueue.main.async {
104+
completion?()
105+
}
102106
}
103107
}
104108
}

CommonplaceBookApp/NoteArchiveDocument+TextEditViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ extension NoteArchiveDocument: TextEditViewControllerDelegate {
1414
let now = Date()
1515
do {
1616
let pageIdentifier = try noteArchiveQueue.sync {
17-
try noteArchive.insertNote(markdown, contentChangeTime: now, versionTimestamp: now)
17+
try noteArchive.insertNote(markdown, contentChangeTime: now)
1818
}
1919
viewController.pageIdentifier = pageIdentifier
2020
invalidateSavedSnippets()

0 commit comments

Comments
 (0)