Skip to content

Commit d2690bf

Browse files
authored
sort snapshots to the front in loadDoc (#414)
1 parent a0aae79 commit d2690bf

File tree

3 files changed

+125
-15
lines changed

3 files changed

+125
-15
lines changed

packages/automerge-repo/src/storage/StorageSubsystem.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { type DocumentId } from "../types.js"
66
import { StorageAdapterInterface } from "./StorageAdapterInterface.js"
77
import { ChunkInfo, StorageKey, StorageId } from "./types.js"
88
import { keyHash, headsHash } from "./keyHash.js"
9-
import { chunkTypeFromKey } from "./chunkTypeFromKey.js"
109
import * as Uuid from "uuid"
1110
import { EventEmitter } from "eventemitter3"
1211
import { encodeHeads } from "../AutomergeUrl.js"
@@ -113,33 +112,63 @@ export class StorageSubsystem extends EventEmitter<StorageSubsystemEvents> {
113112
// AUTOMERGE DOCUMENT STORAGE
114113

115114
/**
116-
* Loads the Automerge document with the given ID from storage.
115+
* Loads and combines document chunks from storage, with snapshots first.
117116
*/
118-
async loadDoc<T>(documentId: DocumentId): Promise<A.Doc<T> | null> {
119-
// Load all the chunks for this document
120-
const chunks = await this.#storageAdapter.loadRange([documentId])
121-
const binaries = []
117+
async loadDocData(documentId: DocumentId): Promise<Uint8Array | null> {
118+
// Load snapshots first
119+
const snapshotChunks = await this.#storageAdapter.loadRange([
120+
documentId,
121+
"snapshot",
122+
])
123+
const incrementalChunks = await this.#storageAdapter.loadRange([
124+
documentId,
125+
"incremental",
126+
])
127+
128+
const binaries: Uint8Array[] = []
122129
const chunkInfos: ChunkInfo[] = []
123130

124-
for (const chunk of chunks) {
125-
// chunks might have been deleted in the interim
131+
// Process snapshots first
132+
for (const chunk of snapshotChunks) {
126133
if (chunk.data === undefined) continue
134+
chunkInfos.push({
135+
key: chunk.key,
136+
type: "snapshot",
137+
size: chunk.data.length,
138+
})
139+
binaries.push(chunk.data)
140+
}
127141

128-
const chunkType = chunkTypeFromKey(chunk.key)
129-
if (chunkType == null) continue
130-
142+
// Then process incrementals
143+
for (const chunk of incrementalChunks) {
144+
if (chunk.data === undefined) continue
131145
chunkInfos.push({
132146
key: chunk.key,
133-
type: chunkType,
147+
type: "incremental",
134148
size: chunk.data.length,
135149
})
136150
binaries.push(chunk.data)
137151
}
152+
153+
// Store chunk infos for future reference
138154
this.#chunkInfos.set(documentId, chunkInfos)
139155

156+
// If no chunks were found, return null
157+
if (binaries.length === 0) {
158+
return null
159+
}
160+
140161
// Merge the chunks into a single binary
141-
const binary = mergeArrays(binaries)
142-
if (binary.length === 0) return null
162+
return mergeArrays(binaries)
163+
}
164+
165+
/**
166+
* Loads the Automerge document with the given ID from storage.
167+
*/
168+
async loadDoc<T>(documentId: DocumentId): Promise<A.Doc<T> | null> {
169+
// Load and combine chunks
170+
const binary = await this.loadDocData(documentId)
171+
if (!binary) return null
143172

144173
// Load into an Automerge document
145174
const start = performance.now()
@@ -169,6 +198,7 @@ export class StorageSubsystem extends EventEmitter<StorageSubsystemEvents> {
169198
if (!this.#shouldSave(documentId, doc)) return
170199

171200
const sourceChunks = this.#chunkInfos.get(documentId) ?? []
201+
172202
if (this.#shouldCompact(sourceChunks)) {
173203
await this.#saveTotal(documentId, doc, sourceChunks)
174204
} else {

packages/automerge-repo/test/Repo.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { getRandomItem } from "./helpers/getRandomItem.js"
3434
import { TestDoc } from "./types.js"
3535
import { StorageId, StorageKey } from "../src/storage/types.js"
36+
import { chunkTypeFromKey } from "../src/storage/chunkTypeFromKey.js"
3637

3738
describe("Repo", () => {
3839
describe("constructor", () => {

packages/automerge-repo/test/StorageSubsystem.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import assert from "assert"
44
import fs from "fs"
55
import os from "os"
66
import path from "path"
7-
import { describe, it } from "vitest"
7+
import { describe, it, expect } from "vitest"
88
import { generateAutomergeUrl, parseAutomergeUrl } from "../src/AutomergeUrl.js"
99
import { PeerId, cbor } from "../src/index.js"
1010
import { StorageSubsystem } from "../src/storage/StorageSubsystem.js"
1111
import { StorageId } from "../src/storage/types.js"
1212
import { DummyStorageAdapter } from "../src/helpers/DummyStorageAdapter.js"
1313
import * as Uuid from "uuid"
14+
import { chunkTypeFromKey } from "../src/storage/chunkTypeFromKey.js"
15+
import { DocumentId } from "../src/types.js"
1416

1517
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "automerge-repo-tests"))
1618

@@ -243,6 +245,83 @@ describe("StorageSubsystem", () => {
243245
assert.strictEqual(id1, id2)
244246
})
245247
})
248+
249+
describe("loadDoc", () => {
250+
it("maintains correct document state when loading chunks in order", async () => {
251+
const storageAdapter = new DummyStorageAdapter()
252+
const storage = new StorageSubsystem(storageAdapter)
253+
254+
// Create a document with multiple changes
255+
const doc = A.init<{ foo: string }>()
256+
const doc1 = A.change(doc, d => {
257+
d.foo = "first"
258+
})
259+
const doc2 = A.change(doc1, d => {
260+
d.foo = "second"
261+
})
262+
const doc3 = A.change(doc2, d => {
263+
d.foo = "third"
264+
})
265+
266+
// Save the document with multiple changes
267+
const documentId = "test-doc" as DocumentId
268+
await storage.saveDoc(documentId, doc3)
269+
270+
// Load the document
271+
const loadedDoc = await storage.loadDoc<{ foo: string }>(documentId)
272+
273+
// Verify the document state is correct
274+
expect(loadedDoc?.foo).toBe("third")
275+
})
276+
277+
it("combines chunks with snapshot first", async () => {
278+
const storageAdapter = new DummyStorageAdapter()
279+
const storage = new StorageSubsystem(storageAdapter)
280+
281+
// Create a document with multiple changes
282+
const doc = A.init<{ foo: string }>()
283+
const doc1 = A.change(doc, d => {
284+
d.foo = "first"
285+
})
286+
const doc2 = A.change(doc1, d => {
287+
d.foo = Array(10000)
288+
.fill(0)
289+
.map(() =>
290+
String.fromCharCode(Math.floor(Math.random() * 26) + 97)
291+
)
292+
.join("")
293+
})
294+
295+
// Save the document with multiple changes
296+
const documentId = "test-doc" as DocumentId
297+
await storage.saveDoc(documentId, doc2)
298+
299+
const doc3 = A.change(doc2, d => {
300+
d.foo = "third"
301+
})
302+
await storage.saveDoc(documentId, doc3)
303+
304+
// Load the document
305+
const loadedDoc = await storage.loadDoc<{ foo: string }>(documentId)
306+
307+
// Verify the document state is correct
308+
expect(loadedDoc?.foo).toBe(doc3.foo)
309+
310+
// Get the raw binary data from storage
311+
const binary = await storage.loadDocData(documentId)
312+
expect(binary).not.toBeNull()
313+
if (!binary) return
314+
315+
// Verify the binary starts with the Automerge magic value
316+
expect(binary[0]).toBe(0x85)
317+
expect(binary[1]).toBe(0x6f)
318+
expect(binary[2]).toBe(0x4a)
319+
expect(binary[3]).toBe(0x83)
320+
321+
// Verify the chunk type is CHUNK_TYPE_DOCUMENT (0x00)
322+
expect(binary[8]).toBe(0x00)
323+
})
324+
})
246325
})
247326
}
248327
})

0 commit comments

Comments
 (0)