@docstack/pouchdb-adapter-googledrive 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/LICENSE +10 -0
- package/README.md +52 -1
- package/lib/adapter.d.ts +0 -0
- package/lib/adapter.js +374 -178
- package/lib/cache.d.ts +0 -0
- package/lib/cache.js +0 -0
- package/lib/client.d.ts +2 -0
- package/lib/client.js +6 -4
- package/lib/drive.d.ts +136 -2
- package/lib/drive.js +670 -89
- package/lib/index.d.ts +0 -0
- package/lib/index.js +0 -0
- package/lib/types.d.ts +34 -4
- package/lib/types.js +0 -0
- package/package.json +7 -3
- package/.env.example +0 -9
package/lib/drive.js
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DriveHandler = void 0;
|
|
3
|
+
exports.DriveHandler = exports.SEQ_SLOTS = void 0;
|
|
4
|
+
exports.writerSlotFor = writerSlotFor;
|
|
5
|
+
exports.writerIdFromLogName = writerIdFromLogName;
|
|
4
6
|
const cache_1 = require("./cache");
|
|
5
7
|
const client_1 = require("./client");
|
|
6
8
|
const DEFAULT_COMPACTION_THRESHOLD = 100; // entries
|
|
7
9
|
const DEFAULT_SIZE_THRESHOLD = 1024 * 1024; // 1MB
|
|
8
10
|
const DEFAULT_CACHE_SIZE = 1000; // Number of docs
|
|
11
|
+
const META_COMMIT_RETRIES = 6; // read-modify-write attempts per _meta.json commit
|
|
12
|
+
const RETIRED_LOG_HISTORY = 500; // tombstones kept for change logs compaction deleted
|
|
13
|
+
/**
|
|
14
|
+
* Sequence numbers are `tick * SEQ_SLOTS + writerSlot`.
|
|
15
|
+
*
|
|
16
|
+
* Writers mint them blind to one another - there is no lock and no compare-and-swap -
|
|
17
|
+
* so two clients reading the same counter will always be able to derive the same next
|
|
18
|
+
* tick. Reserving the low digits for a per-writer slot means they can share a tick and
|
|
19
|
+
* still never share a *sequence number*, which is the part that matters: `_changes`
|
|
20
|
+
* filters on `seq > since`, so a second document sharing a checkpointed sequence
|
|
21
|
+
* number is never emitted to a replication target again.
|
|
22
|
+
*
|
|
23
|
+
* A million slots keeps the chance that two concurrent writers hash to the same one
|
|
24
|
+
* near 1 in 20,000 for a ten-client fleet, and leaves room for 9e9 ticks inside
|
|
25
|
+
* Number.MAX_SAFE_INTEGER.
|
|
26
|
+
*/
|
|
27
|
+
exports.SEQ_SLOTS = 1000000;
|
|
28
|
+
/** Stable slot for a writer id. FNV-1a, folded into the slot space. */
|
|
29
|
+
function writerSlotFor(writerId) {
|
|
30
|
+
let h = 2166136261;
|
|
31
|
+
for (let i = 0; i < writerId.length; i++) {
|
|
32
|
+
h ^= writerId.charCodeAt(i);
|
|
33
|
+
h = Math.imul(h, 16777619);
|
|
34
|
+
}
|
|
35
|
+
return Math.abs(h) % exports.SEQ_SLOTS;
|
|
36
|
+
}
|
|
37
|
+
function newWriterId() {
|
|
38
|
+
return Math.random().toString(36).substring(2, 10);
|
|
39
|
+
}
|
|
40
|
+
/** The writer id embedded in a change-log filename, if the name carries one.
|
|
41
|
+
* New format: changes-<seq>-<writerId>-<random>.ndjson (4 dash-parts).
|
|
42
|
+
* Old format: changes-<seq>-<random>.ndjson (3 parts) - no id to extract. */
|
|
43
|
+
function writerIdFromLogName(name) {
|
|
44
|
+
if (!name.startsWith('changes-') || !name.endsWith('.ndjson'))
|
|
45
|
+
return null;
|
|
46
|
+
const parts = name.slice(0, -'.ndjson'.length).split('-');
|
|
47
|
+
return parts.length === 4 ? parts[2] : null;
|
|
48
|
+
}
|
|
49
|
+
/** A trivial single-node pouchdb-merge tree for a bare rev string - used where a
|
|
50
|
+
* doc enters the index without going through _bulkDocs's real merge (local docs,
|
|
51
|
+
* legacy-snapshot migration, and the low-level DriveHandler.appendChange() API's
|
|
52
|
+
* raw callers). Correct as far as it goes (one leaf, no ancestry); these paths
|
|
53
|
+
* never participate in replication conflict resolution anyway. */
|
|
54
|
+
function synthesizeTree(rev, deleted) {
|
|
55
|
+
const dash = rev.indexOf('-');
|
|
56
|
+
const pos = dash >= 0 ? parseInt(rev.slice(0, dash), 10) : 0;
|
|
57
|
+
const hash = dash >= 0 ? rev.slice(dash + 1) : rev;
|
|
58
|
+
return JSON.stringify([{ pos, ids: [hash, { status: 'available', deleted: !!deleted }, []] }]);
|
|
59
|
+
}
|
|
9
60
|
/**
|
|
10
61
|
* DriveHandler - Lazy Loading Implementation
|
|
11
62
|
*
|
|
@@ -33,6 +84,27 @@ class DriveHandler {
|
|
|
33
84
|
this.metaMd5 = null;
|
|
34
85
|
this.metaModifiedTime = null;
|
|
35
86
|
this.localDocsEtag = null;
|
|
87
|
+
this.metaFileId = null;
|
|
88
|
+
/** Identifies this handler among the writers sharing a folder. Change-log
|
|
89
|
+
* filenames carry it, so two writers can never produce the same name and an
|
|
90
|
+
* orphaned log can be traced back to whoever wrote it. Not readonly: if the
|
|
91
|
+
* folder shows another writer whose id hashes to our sequence slot, we re-roll
|
|
92
|
+
* to a free one before minting anything (see rerollIfSlotContested). */
|
|
93
|
+
this.writerId = newWriterId();
|
|
94
|
+
/** Change-log file ids this handler wrote, minus any a compaction has retired.
|
|
95
|
+
* Drive API v3 has no compare-and-swap (ETags were dropped), so another
|
|
96
|
+
* writer's read-modify-write of _meta.json can drop a log id that landed in
|
|
97
|
+
* between. Anything still in here but missing from the remote changeLogIds was
|
|
98
|
+
* dropped that way and gets put back - see reconcileOwnLogs(). */
|
|
99
|
+
this.ownLogIds = new Set();
|
|
100
|
+
/** This writer's reservation in the low digits of every sequence number it mints.
|
|
101
|
+
* Derived from writerId; changes only when writerId is re-rolled off a
|
|
102
|
+
* contested slot. */
|
|
103
|
+
this.writerSlot = writerSlotFor(this.writerId);
|
|
104
|
+
/** Serializes this handler's own _meta.json read-modify-write cycles. Says
|
|
105
|
+
* nothing about other clients - that is what commitMeta's verify pass is for -
|
|
106
|
+
* but stops one handler racing itself when several writes are in flight. */
|
|
107
|
+
this.metaLock = Promise.resolve();
|
|
36
108
|
// In-Memory Index: ID -> Metadata/Pointer
|
|
37
109
|
this.index = {};
|
|
38
110
|
this.pendingChanges = [];
|
|
@@ -97,17 +169,21 @@ class DriveHandler {
|
|
|
97
169
|
this.folderId = await this.findOrCreateFolder();
|
|
98
170
|
this.log('Retrieved folder', { folderId: this.folderId });
|
|
99
171
|
}
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
this.log('Retrieved meta file', { fileId:
|
|
103
|
-
this.
|
|
104
|
-
this.metaEtag = metaFile.etag || null;
|
|
105
|
-
this.metaMd5 = metaFile.md5Checksum || null;
|
|
106
|
-
this.metaModifiedTime = metaFile.modifiedTime || null;
|
|
172
|
+
const current = await this.readRemoteMeta();
|
|
173
|
+
if (current) {
|
|
174
|
+
this.log('Retrieved meta file', { fileId: current.pointer.fileId });
|
|
175
|
+
this.adoptMeta(current.meta, current.pointer);
|
|
107
176
|
}
|
|
108
177
|
else {
|
|
109
178
|
this.log('Meta file not found, creating new');
|
|
110
|
-
await this.
|
|
179
|
+
await this.ensureMetaFile();
|
|
180
|
+
}
|
|
181
|
+
// A change log of ours that has fallen out of changeLogIds without a
|
|
182
|
+
// compaction retiring it was dropped by another writer's
|
|
183
|
+
// read-modify-write. Put it back before replaying, so this load sees
|
|
184
|
+
// its own writes - and so the next reader does too.
|
|
185
|
+
if (this.hasOrphanedOwnLogs(this.meta)) {
|
|
186
|
+
await this.commitMeta((latest, repaired) => repaired ? latest : null);
|
|
111
187
|
}
|
|
112
188
|
if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
|
|
113
189
|
this.log('Snapshot index changed, loading index', {
|
|
@@ -147,7 +223,31 @@ class DriveHandler {
|
|
|
147
223
|
}
|
|
148
224
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
149
225
|
this.log('Replaying change logs');
|
|
150
|
-
const
|
|
226
|
+
const retired = new Set(this.meta.retiredLogIds || []);
|
|
227
|
+
// Take the union of what the metadata references and what is actually
|
|
228
|
+
// in the folder. A log missing from changeLogIds but present in the
|
|
229
|
+
// folder was orphaned by a lost metadata update; a log referenced but
|
|
230
|
+
// absent was deleted after being referenced. Both are survivable if
|
|
231
|
+
// the folder gets the last word.
|
|
232
|
+
let discovered = [];
|
|
233
|
+
try {
|
|
234
|
+
const listed = await this.listChangeLogs();
|
|
235
|
+
// The same listing shows every writer's id - the moment to notice
|
|
236
|
+
// someone else is on our sequence slot and move off it.
|
|
237
|
+
this.rerollIfSlotContested(listed.map(f => f.name));
|
|
238
|
+
const referenced = new Set(this.meta.changeLogIds);
|
|
239
|
+
discovered = listed.map(f => f.id).filter(id => !referenced.has(id) && !retired.has(id));
|
|
240
|
+
if (discovered.length > 0) {
|
|
241
|
+
this.log('Found change logs the metadata does not reference', discovered);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
// Listing is an optimisation over the metadata, never a
|
|
246
|
+
// prerequisite - a failure here must not fail the load.
|
|
247
|
+
this.log('Failed to list change logs, falling back to metadata only', e);
|
|
248
|
+
}
|
|
249
|
+
const pendingLogs = [...this.meta.changeLogIds, ...discovered]
|
|
250
|
+
.filter(id => !this.processedLogIds.has(id) && !retired.has(id));
|
|
151
251
|
if (pendingLogs.length > 0) {
|
|
152
252
|
this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
|
|
153
253
|
const logResults = await Promise.all(pendingLogs.map(async (id) => {
|
|
@@ -160,6 +260,18 @@ class DriveHandler {
|
|
|
160
260
|
return { id, changes: null };
|
|
161
261
|
}
|
|
162
262
|
}));
|
|
263
|
+
// Replay in sequence order, not in the order the ids happened to
|
|
264
|
+
// be listed. Two clients can hold different changeLogIds orderings
|
|
265
|
+
// for the same folder once merges are in play, and updateIndex
|
|
266
|
+
// takes the last write for a document - so insertion order meant
|
|
267
|
+
// two readers of one folder could disagree about the winner.
|
|
268
|
+
logResults.sort((a, b) => {
|
|
269
|
+
const seqOf = (r) => r.changes && r.changes.length ? (Array.isArray(r.changes) ? r.changes[0].seq : r.changes.seq) : Number.MAX_SAFE_INTEGER;
|
|
270
|
+
const sa = seqOf(a), sb = seqOf(b);
|
|
271
|
+
if (sa !== sb)
|
|
272
|
+
return sa - sb;
|
|
273
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
274
|
+
});
|
|
163
275
|
const foundNew = {};
|
|
164
276
|
for (const { id, changes } of logResults) {
|
|
165
277
|
if (!changes) {
|
|
@@ -190,6 +302,19 @@ class DriveHandler {
|
|
|
190
302
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
191
303
|
// ... (previous logic for change logs)
|
|
192
304
|
// (Already updated in previous turn, keep it)
|
|
305
|
+
// Put what we found back into the metadata, so the next reader
|
|
306
|
+
// does not have to rediscover it and compaction can see it. Any
|
|
307
|
+
// client repairs this, not only the writer that lost the race.
|
|
308
|
+
if (discovered.length > 0) {
|
|
309
|
+
await this.commitMeta((latest) => {
|
|
310
|
+
const referenced = new Set(latest.changeLogIds);
|
|
311
|
+
const stillRetired = new Set(latest.retiredLogIds || []);
|
|
312
|
+
const missing = discovered.filter(id => !referenced.has(id) && !stillRetired.has(id));
|
|
313
|
+
if (missing.length === 0)
|
|
314
|
+
return null;
|
|
315
|
+
return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
193
318
|
// 2b. Load Local Documents Store (Pinned in meta)
|
|
194
319
|
if (this.meta.localDocsId) {
|
|
195
320
|
this.log('Loading local docs store', this.meta.localDocsId);
|
|
@@ -201,6 +326,7 @@ class DriveHandler {
|
|
|
201
326
|
for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
|
|
202
327
|
this.log('Merging local doc', id);
|
|
203
328
|
this.index[id] = {
|
|
329
|
+
tree: synthesizeTree(doc._rev),
|
|
204
330
|
rev: doc._rev,
|
|
205
331
|
seq: 0, // Local docs don't participate in shared sequences
|
|
206
332
|
location: { fileId: this.meta.localDocsId }
|
|
@@ -213,7 +339,13 @@ class DriveHandler {
|
|
|
213
339
|
this.log('Failed to load local docs store', e);
|
|
214
340
|
}
|
|
215
341
|
}
|
|
216
|
-
// 3. Start Polling
|
|
342
|
+
// 3. Start Polling (if enabled). Idempotent on purpose: load()
|
|
343
|
+
// runs again on every catch-up and retry, and restarting the
|
|
344
|
+
// interval each time would push the next tick further out for
|
|
345
|
+
// exactly as long as the client stays busy.
|
|
346
|
+
if (this.options.pollingIntervalMs) {
|
|
347
|
+
this.startPolling(Number(this.options.pollingIntervalMs));
|
|
348
|
+
}
|
|
217
349
|
}
|
|
218
350
|
catch (e) {
|
|
219
351
|
console.error('Failed to load database', e);
|
|
@@ -234,6 +366,7 @@ class DriveHandler {
|
|
|
234
366
|
// We will cache them ALL now (since we downloaded them) and index them.
|
|
235
367
|
for (const [id, doc] of Object.entries(snapshot.docs)) {
|
|
236
368
|
this.index[id] = {
|
|
369
|
+
tree: synthesizeTree(doc._rev),
|
|
237
370
|
rev: doc._rev,
|
|
238
371
|
seq: snapshot.seq, // Approximate
|
|
239
372
|
location: { fileId: 'LEGACY_MEMORY' } // Special validity marker
|
|
@@ -298,6 +431,40 @@ class DriveHandler {
|
|
|
298
431
|
}
|
|
299
432
|
return doc;
|
|
300
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Fetch a specific (possibly non-winning/conflicting) revision's body by its
|
|
436
|
+
* own tracked location - used by _get(opts.rev)/_bulkGet when the requested
|
|
437
|
+
* rev isn't the current winner. Deliberately doesn't touch docCache (keyed
|
|
438
|
+
* per-id, not per-rev - a conflict fetch is rare enough not to warrant a
|
|
439
|
+
* per-rev cache key) and doesn't force `_rev` to the index's winning rev the
|
|
440
|
+
* way get() does.
|
|
441
|
+
*/
|
|
442
|
+
async getRevisionBody(id, rev, location) {
|
|
443
|
+
if (location.fileId === 'LEGACY_MEMORY' || location.fileId === '__SELF__')
|
|
444
|
+
return null;
|
|
445
|
+
const content = await this.fetchFile(location.fileId);
|
|
446
|
+
let doc = null;
|
|
447
|
+
if (Array.isArray(content)) {
|
|
448
|
+
const match = content.find((c) => c.id === id && c.rev === rev);
|
|
449
|
+
doc = match ? match.doc : null;
|
|
450
|
+
}
|
|
451
|
+
else if (content && content.conflicts && content.conflicts[id]) {
|
|
452
|
+
doc = content.conflicts[id][rev] ?? null;
|
|
453
|
+
}
|
|
454
|
+
else if (content && content.docs && content.docs[id]) {
|
|
455
|
+
// Winning body happened to be requested through this path (e.g. after
|
|
456
|
+
// a merge where the "conflict" turned out to be the new winner).
|
|
457
|
+
const candidate = content.docs[id];
|
|
458
|
+
if (candidate && candidate._rev === rev)
|
|
459
|
+
doc = candidate;
|
|
460
|
+
}
|
|
461
|
+
else if (content && content.id === id && content.rev === rev && content.doc) {
|
|
462
|
+
doc = content.doc;
|
|
463
|
+
}
|
|
464
|
+
if (doc)
|
|
465
|
+
doc._rev = rev;
|
|
466
|
+
return doc;
|
|
467
|
+
}
|
|
301
468
|
/** Generic Download with Caching and Parsing */
|
|
302
469
|
async fetchFile(fileId, skipCache = false) {
|
|
303
470
|
if (!skipCache) {
|
|
@@ -463,18 +630,14 @@ class DriveHandler {
|
|
|
463
630
|
}
|
|
464
631
|
catch (err) {
|
|
465
632
|
if (err.status === 412 || err.status === 409) {
|
|
466
|
-
// Reload and RETRY
|
|
633
|
+
// Reload and RETRY. No resequencing here - tryAppendChanges
|
|
634
|
+
// stamps sequence numbers from a fresh read of _meta.json on
|
|
635
|
+
// every attempt of its own.
|
|
467
636
|
await this.load();
|
|
468
637
|
// Check conflicts against Index (Metadata sufficient)
|
|
469
638
|
this.checkConflicts(remote);
|
|
470
|
-
// Reseq
|
|
471
|
-
let currentSeq = this.meta.seq;
|
|
472
|
-
for (const change of remote) {
|
|
473
|
-
currentSeq++;
|
|
474
|
-
change.seq = currentSeq;
|
|
475
|
-
}
|
|
476
639
|
attemptNum++;
|
|
477
|
-
await
|
|
640
|
+
await this.backoff(attemptNum);
|
|
478
641
|
continue;
|
|
479
642
|
}
|
|
480
643
|
throw err;
|
|
@@ -519,7 +682,8 @@ class DriveHandler {
|
|
|
519
682
|
else {
|
|
520
683
|
res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
|
|
521
684
|
// Update Meta with new File ID
|
|
522
|
-
|
|
685
|
+
const localRes = res;
|
|
686
|
+
await this.commitMeta((latest) => ({ ...latest, localDocsId: localRes.id }));
|
|
523
687
|
}
|
|
524
688
|
this.localDocsEtag = res.etag;
|
|
525
689
|
// Update Index
|
|
@@ -551,17 +715,92 @@ class DriveHandler {
|
|
|
551
715
|
}
|
|
552
716
|
}
|
|
553
717
|
async tryAppendChanges(changes) {
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
718
|
+
// Catching up on another writer's logs and failing to publish are separate
|
|
719
|
+
// failures, so they get separate budgets - a busy folder should not be able
|
|
720
|
+
// to spend the publish retries on catch-ups alone.
|
|
721
|
+
let catchUps = 0;
|
|
722
|
+
for (let attempt = 0; attempt < META_COMMIT_RETRIES;) {
|
|
723
|
+
// 1. Take the sequence range from what Drive holds right now, not from
|
|
724
|
+
// this handler's cached copy - that copy is only refreshed on load, so
|
|
725
|
+
// two clients allocating from their own stale copies is exactly how two
|
|
726
|
+
// change logs end up claiming the same sequence number.
|
|
727
|
+
const current = await this.readRemoteMeta();
|
|
728
|
+
// Catch up before writing. Another writer having appended since our last
|
|
729
|
+
// load means our index - and so the revision this write is built on - is
|
|
730
|
+
// out of date; replaying their logs first is what lets checkConflicts see
|
|
731
|
+
// the collision instead of us silently writing over them. (Previously
|
|
732
|
+
// this only happened when the metadata ETag came back 412, which Drive
|
|
733
|
+
// itself never does.)
|
|
734
|
+
if (current && this.hasUnprocessedLogs(current.meta)) {
|
|
735
|
+
if (++catchUps > META_COMMIT_RETRIES) {
|
|
736
|
+
throw new Error('Could not catch up with concurrent writers');
|
|
737
|
+
}
|
|
738
|
+
await this.load();
|
|
739
|
+
// Only the low-level appendChange() callers are checked here.
|
|
740
|
+
// _bulkDocs has already resolved revisions through pouchdb-merge and
|
|
741
|
+
// expresses a collision as a conflict branch, not as a thrown error;
|
|
742
|
+
// failing its whole batch would be the wrong answer.
|
|
743
|
+
this.checkConflicts(changes.filter(c => !c.nextIndexEntry));
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// Take the tick from whatever counter is furthest along and stamp our own
|
|
747
|
+
// slot into it. Another writer working from the same counter lands on the
|
|
748
|
+
// same tick and a different sequence number, which is the point - there is
|
|
749
|
+
// no way to stop them sharing the tick, and no longer any need to.
|
|
750
|
+
const observedSeq = current ? current.meta.seq : this.meta.seq;
|
|
751
|
+
const baseTick = Math.floor(Math.max(observedSeq, this.meta.seq) / exports.SEQ_SLOTS);
|
|
752
|
+
changes.forEach((change, i) => {
|
|
753
|
+
change.seq = (baseTick + i + 1) * exports.SEQ_SLOTS + this.writerSlot;
|
|
754
|
+
});
|
|
755
|
+
const lastSeq = changes[changes.length - 1].seq;
|
|
756
|
+
// 2. Write Log File (Upload Data). `nextIndexEntry` (the merged tree, only
|
|
757
|
+
// needed transiently by updateIndex() below) is stripped first - it's
|
|
758
|
+
// already durably captured by the index/snapshot system, so writing it into
|
|
759
|
+
// every change-log line too would just redundantly bloat storage, growing
|
|
760
|
+
// with tree depth on every single write.
|
|
761
|
+
const fileId = await this.writeChangeFile(changes.map(({ nextIndexEntry, ...rest }) => rest));
|
|
762
|
+
// 3. Publish it. The modifier merges into whatever _meta.json holds at
|
|
763
|
+
// write time rather than replacing it, so a concurrent writer's logs
|
|
764
|
+
// survive; it abandons the commit if someone has taken the sequence
|
|
765
|
+
// range already stamped into our file, since those seqs would then
|
|
766
|
+
// collide with theirs.
|
|
767
|
+
let committed;
|
|
768
|
+
try {
|
|
769
|
+
committed = await this.commitMeta((latest) => ({
|
|
770
|
+
...latest,
|
|
771
|
+
// Idempotent: commitMeta may run this more than once.
|
|
772
|
+
changeLogIds: latest.changeLogIds.includes(fileId)
|
|
773
|
+
? latest.changeLogIds
|
|
774
|
+
: [...latest.changeLogIds, fileId],
|
|
775
|
+
// Never let the shared counter go backwards: another writer may
|
|
776
|
+
// have reached a higher tick while we were uploading, and
|
|
777
|
+
// rewinding it would hand our tick out a second time.
|
|
778
|
+
seq: Math.max(latest.seq, lastSeq)
|
|
779
|
+
}), { verify: (m) => m.changeLogIds.includes(fileId) });
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
this.discardLog(fileId);
|
|
783
|
+
throw err;
|
|
784
|
+
}
|
|
785
|
+
if (!committed) {
|
|
786
|
+
// commitMeta ran out of attempts. Nothing references the log we just
|
|
787
|
+
// uploaded, so drop it and start over against fresh metadata. This no
|
|
788
|
+
// longer fires for a sequence collision - slots make those impossible -
|
|
789
|
+
// only for a metadata write that would not stick.
|
|
790
|
+
this.log('Change log not published, retrying', { fileId, attempt });
|
|
791
|
+
this.discardLog(fileId);
|
|
792
|
+
await this.backoff(attempt++);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
// Published: from here on this log is ours to defend if another writer
|
|
796
|
+
// drops it from the metadata.
|
|
797
|
+
this.ownLogIds.add(fileId);
|
|
798
|
+
// 4. Update Local State. Marking our own log processed keeps a later
|
|
799
|
+
// load() from replaying it: the entries we apply here carry the merged
|
|
800
|
+
// rev tree computed by _bulkDocs, while the log lines on Drive have it
|
|
801
|
+
// stripped, so a replay would overwrite real ancestry with a synthesized
|
|
802
|
+
// single-node tree.
|
|
803
|
+
this.processedLogIds.add(fileId);
|
|
565
804
|
const changedDocs = {};
|
|
566
805
|
for (const change of changes) {
|
|
567
806
|
this.updateIndex(change, fileId);
|
|
@@ -586,16 +825,42 @@ class DriveHandler {
|
|
|
586
825
|
this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
|
|
587
826
|
this.compact().catch(e => console.error('Compaction failed', e));
|
|
588
827
|
}
|
|
828
|
+
return;
|
|
589
829
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
830
|
+
throw new Error(`Failed to publish change log after ${META_COMMIT_RETRIES} attempts`);
|
|
831
|
+
}
|
|
832
|
+
/** Forget a change log we uploaded but never managed to reference from
|
|
833
|
+
* _meta.json. Nothing points at it, so it is safe to remove. */
|
|
834
|
+
discardLog(fileId) {
|
|
835
|
+
this.ownLogIds.delete(fileId);
|
|
836
|
+
this.client.deleteFile(fileId).catch(e => this.log('Failed to clean up unreferenced log', fileId, e));
|
|
595
837
|
}
|
|
596
838
|
/** Update Index with a new change */
|
|
597
839
|
updateIndex(change, fileId) {
|
|
840
|
+
if (change.nextIndexEntry) {
|
|
841
|
+
// adapter.ts already computed the full merged tree/winner/conflicts via
|
|
842
|
+
// pouchdb-merge - just substitute the '__SELF__' placeholder(s) with the
|
|
843
|
+
// fileId this batch actually landed in (unknown until upload completed).
|
|
844
|
+
const entry = { ...change.nextIndexEntry, seq: change.seq };
|
|
845
|
+
if (entry.location.fileId === '__SELF__')
|
|
846
|
+
entry.location = { fileId };
|
|
847
|
+
if (entry.conflictLocations) {
|
|
848
|
+
const resolved = {};
|
|
849
|
+
for (const rev of Object.keys(entry.conflictLocations)) {
|
|
850
|
+
const loc = entry.conflictLocations[rev];
|
|
851
|
+
resolved[rev] = loc.fileId === '__SELF__' ? { fileId } : loc;
|
|
852
|
+
}
|
|
853
|
+
entry.conflictLocations = resolved;
|
|
854
|
+
}
|
|
855
|
+
this.index[change.id] = entry;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
// Legacy path: no computed tree (the low-level DriveHandler.appendChange()
|
|
859
|
+
// API's raw callers - e.g. concurrency tests - never set nextIndexEntry).
|
|
860
|
+
// Synthesize a trivial single-node tree so the index entry shape stays
|
|
861
|
+
// consistent for anything reading `.tree` (e.g. _getRevisionTree).
|
|
598
862
|
this.index[change.id] = {
|
|
863
|
+
tree: synthesizeTree(change.rev, change.deleted),
|
|
599
864
|
rev: change.rev,
|
|
600
865
|
seq: change.seq,
|
|
601
866
|
deleted: !!change.deleted,
|
|
@@ -628,6 +893,27 @@ class DriveHandler {
|
|
|
628
893
|
const snapshotSeq = this.meta.seq;
|
|
629
894
|
const oldLogIds = [...this.meta.changeLogIds];
|
|
630
895
|
const oldIndexId = this.meta.snapshotIndexId;
|
|
896
|
+
// Resolve which snapshot-data file(s) the OLD index points to, so we can
|
|
897
|
+
// delete them too once the new snapshot is safely in place. Without this,
|
|
898
|
+
// every compaction leaves its predecessor's data file behind as orphaned
|
|
899
|
+
// clutter (the index gets cleaned up, but the (often much larger) data
|
|
900
|
+
// file it pointed to never does).
|
|
901
|
+
let oldDataFileIds = [];
|
|
902
|
+
if (oldIndexId) {
|
|
903
|
+
try {
|
|
904
|
+
const oldIndex = await this.downloadJson(oldIndexId, true);
|
|
905
|
+
const ids = new Set();
|
|
906
|
+
for (const entry of Object.values(oldIndex.entries || {})) {
|
|
907
|
+
const fid = entry.location?.fileId;
|
|
908
|
+
if (fid && fid !== 'LEGACY_MEMORY')
|
|
909
|
+
ids.add(fid);
|
|
910
|
+
}
|
|
911
|
+
oldDataFileIds = [...ids];
|
|
912
|
+
}
|
|
913
|
+
catch (e) {
|
|
914
|
+
this.log('Failed to load old snapshot index for data-file cleanup', oldIndexId, e);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
631
917
|
// 1. Fetch ALL active documents
|
|
632
918
|
// We need them to build the new large snapshot-data file
|
|
633
919
|
// This is the one time we download everything if not cached.
|
|
@@ -649,6 +935,39 @@ class DriveHandler {
|
|
|
649
935
|
this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
|
|
650
936
|
throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
|
|
651
937
|
}
|
|
938
|
+
// 1b. Carry forward conflict-branch bodies too. Without this, the first
|
|
939
|
+
// compaction after a real conflict exists would silently drop the losing
|
|
940
|
+
// revision forever - snapshotData only ever had room for one body per id
|
|
941
|
+
// before conflict tracking existed. A body that fails to fetch here is
|
|
942
|
+
// logged and dropped rather than aborting the whole compaction (unlike
|
|
943
|
+
// missingDocs above): losing one stale conflict branch is far less bad
|
|
944
|
+
// than losing a doc's current data, and shouldn't block reclaiming space.
|
|
945
|
+
const conflictFetches = [];
|
|
946
|
+
for (const id of allIds) {
|
|
947
|
+
const conflicts = this.index[id].conflictLocations;
|
|
948
|
+
if (!conflicts)
|
|
949
|
+
continue;
|
|
950
|
+
for (const rev of Object.keys(conflicts)) {
|
|
951
|
+
conflictFetches.push({ id, rev, location: conflicts[rev] });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const carriedConflicts = {};
|
|
955
|
+
if (conflictFetches.length > 0) {
|
|
956
|
+
snapshotData.conflicts = {};
|
|
957
|
+
for (const { id, rev, location } of conflictFetches) {
|
|
958
|
+
const body = await this.getRevisionBody(id, rev, location);
|
|
959
|
+
if (!body) {
|
|
960
|
+
this.log('Compaction WARNING: could not fetch conflict body, dropping', id, rev);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (!snapshotData.conflicts[id])
|
|
964
|
+
snapshotData.conflicts[id] = {};
|
|
965
|
+
snapshotData.conflicts[id][rev] = body;
|
|
966
|
+
if (!carriedConflicts[id])
|
|
967
|
+
carriedConflicts[id] = {};
|
|
968
|
+
carriedConflicts[id][rev] = true;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
652
971
|
// 2. Upload Data File
|
|
653
972
|
const dataContent = JSON.stringify(snapshotData);
|
|
654
973
|
const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
|
|
@@ -656,11 +975,19 @@ class DriveHandler {
|
|
|
656
975
|
// 3. Create Index pointing to this Data File
|
|
657
976
|
const newIndexEntries = {};
|
|
658
977
|
for (const id of Object.keys(snapshotData.docs)) {
|
|
659
|
-
|
|
978
|
+
const entry = {
|
|
979
|
+
tree: this.index[id].tree,
|
|
660
980
|
rev: this.index[id].rev,
|
|
661
981
|
seq: this.index[id].seq,
|
|
662
982
|
location: { fileId: dataFileId }
|
|
663
983
|
};
|
|
984
|
+
if (carriedConflicts[id]) {
|
|
985
|
+
entry.conflictLocations = {};
|
|
986
|
+
for (const rev of Object.keys(carriedConflicts[id])) {
|
|
987
|
+
entry.conflictLocations[rev] = { fileId: dataFileId };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
newIndexEntries[id] = entry;
|
|
664
991
|
}
|
|
665
992
|
const snapshotIndex = {
|
|
666
993
|
entries: newIndexEntries,
|
|
@@ -670,51 +997,225 @@ class DriveHandler {
|
|
|
670
997
|
const indexContent = JSON.stringify(snapshotIndex);
|
|
671
998
|
const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
|
|
672
999
|
const newIndexId = indexRes.id;
|
|
673
|
-
// 4. Update Meta
|
|
1000
|
+
// 4. Update Meta. Only the logs THIS handler had already replayed are
|
|
1001
|
+
// retired; anything another writer appended in the meantime stays in
|
|
1002
|
+
// changeLogIds and gets replayed on top of the new snapshot.
|
|
674
1003
|
let filesToDelete = [];
|
|
675
|
-
await this.
|
|
1004
|
+
const committed = await this.commitMeta((latest) => {
|
|
676
1005
|
const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
|
|
677
1006
|
// Only delete files that were in oldLogIds but not in remainingLogs
|
|
678
1007
|
filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
|
|
1008
|
+
const retired = [...(latest.retiredLogIds || []), ...filesToDelete];
|
|
679
1009
|
return {
|
|
680
1010
|
...latest,
|
|
681
1011
|
snapshotIndexId: newIndexId,
|
|
682
1012
|
changeLogIds: remainingLogs,
|
|
1013
|
+
// Tombstones, so the writers of those logs don't put them back.
|
|
1014
|
+
retiredLogIds: [...new Set(retired)].slice(-RETIRED_LOG_HISTORY),
|
|
683
1015
|
lastCompaction: Date.now()
|
|
684
1016
|
};
|
|
1017
|
+
}, {
|
|
1018
|
+
verify: (m) => m.snapshotIndexId === newIndexId &&
|
|
1019
|
+
!filesToDelete.some(id => m.changeLogIds.includes(id))
|
|
685
1020
|
});
|
|
686
|
-
// 5. Cleanup -
|
|
687
|
-
|
|
1021
|
+
// 5. Cleanup - ONLY once we have read back the metadata that de-references
|
|
1022
|
+
// these files. Until that write is confirmed, the change logs are still
|
|
1023
|
+
// the only copy of everything in them, and deleting on the strength of an
|
|
1024
|
+
// unverified write is how a lost update turns into lost documents. If the
|
|
1025
|
+
// commit never stuck, the new snapshot files are left behind unreferenced
|
|
1026
|
+
// rather than deleted - a reader may already have picked them up.
|
|
1027
|
+
if (!committed) {
|
|
1028
|
+
this.log('Compaction: metadata never committed, keeping every change log', {
|
|
1029
|
+
snapshotIndexId: newIndexId,
|
|
1030
|
+
logs: oldLogIds.length
|
|
1031
|
+
});
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
for (const id of filesToDelete)
|
|
1035
|
+
this.ownLogIds.delete(id);
|
|
1036
|
+
const staleDataFileIds = oldDataFileIds.filter(id => id !== dataFileId);
|
|
1037
|
+
await this.cleanupOldFiles(oldIndexId, [...filesToDelete, ...staleDataFileIds]);
|
|
688
1038
|
this.currentLogSizeEstimate = 0;
|
|
689
1039
|
}
|
|
690
1040
|
finally {
|
|
691
1041
|
this.isCompacting = false;
|
|
692
1042
|
}
|
|
693
1043
|
}
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1044
|
+
// --- _meta.json: the one piece of shared mutable state --------------------
|
|
1045
|
+
//
|
|
1046
|
+
// Drive API v3 has no compare-and-swap. ETags are gone from the API, so the
|
|
1047
|
+
// If-Match header saveMeta() still sends is honoured by the emulated server and
|
|
1048
|
+
// silently ignored by Drive itself - which is what let two clients read the same
|
|
1049
|
+
// metadata, append their own change log, and each write back a changeLogIds list
|
|
1050
|
+
// that did not mention the other's. The losing log stayed in the folder,
|
|
1051
|
+
// referenced by nothing, invisible to every reader.
|
|
1052
|
+
//
|
|
1053
|
+
// Four things stand in for the missing CAS:
|
|
1054
|
+
//
|
|
1055
|
+
// 1. every commit builds on metadata read from Drive moments earlier, never on
|
|
1056
|
+
// this handler's cached copy, so the window in which a concurrent writer can
|
|
1057
|
+
// be clobbered is one round trip instead of the client's whole lifetime;
|
|
1058
|
+
// 2. modifiers merge into that copy rather than replacing it, so whatever
|
|
1059
|
+
// another writer added survives;
|
|
1060
|
+
// 3. commits that matter re-read afterwards and retry if what they wrote is not
|
|
1061
|
+
// there, which catches the writer who read just before we wrote;
|
|
1062
|
+
// 4. and a writer remembers the logs it wrote (ownLogIds), so anything dropped
|
|
1063
|
+
// despite all of the above is restored on its next load or commit.
|
|
1064
|
+
/** Run `fn` after every meta mutation this handler has already queued has
|
|
1065
|
+
* settled. Never call this from inside a commitMeta modifier - it would wait on
|
|
1066
|
+
* itself. */
|
|
1067
|
+
withMetaLock(fn) {
|
|
1068
|
+
const run = this.metaLock.then(fn, fn);
|
|
1069
|
+
this.metaLock = run.then(() => undefined, () => undefined);
|
|
1070
|
+
return run;
|
|
1071
|
+
}
|
|
1072
|
+
async backoff(attempt) {
|
|
1073
|
+
const ceiling = Math.min(100 * Math.pow(2, attempt), 2000);
|
|
1074
|
+
await new Promise(r => setTimeout(r, ceiling * (0.5 + Math.random())));
|
|
1075
|
+
}
|
|
1076
|
+
/** Locate _meta.json and read its body straight from Drive, past every cache. */
|
|
1077
|
+
async readRemoteMeta() {
|
|
1078
|
+
const pointer = await this.findFile('_meta.json');
|
|
1079
|
+
if (!pointer) {
|
|
1080
|
+
this.metaFileId = null;
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
this.metaFileId = pointer.fileId;
|
|
1084
|
+
const meta = await this.readMetaBody(pointer.fileId);
|
|
1085
|
+
return meta ? { meta, pointer } : null;
|
|
1086
|
+
}
|
|
1087
|
+
/** Read a known _meta.json by id - no folder listing, no cache. Used by the
|
|
1088
|
+
* post-write verification pass, which only needs the body. */
|
|
1089
|
+
async readMetaBody(fileId) {
|
|
1090
|
+
this.fileCache.remove(fileId);
|
|
1091
|
+
const raw = await this.client.getFile(fileId);
|
|
1092
|
+
if (typeof raw === 'string')
|
|
1093
|
+
return JSON.parse(raw);
|
|
1094
|
+
if (raw && typeof raw === 'object')
|
|
1095
|
+
return JSON.parse(JSON.stringify(raw));
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
adoptMeta(meta, pointer) {
|
|
1099
|
+
this.meta = meta;
|
|
1100
|
+
this.metaFileId = pointer.fileId;
|
|
1101
|
+
this.metaEtag = pointer.etag || null;
|
|
1102
|
+
this.metaMd5 = pointer.md5Checksum || null;
|
|
1103
|
+
this.metaModifiedTime = pointer.modifiedTime || null;
|
|
1104
|
+
}
|
|
1105
|
+
/** True when the folder holds changes this handler has not replayed - another
|
|
1106
|
+
* writer appended, or compacted, since our last load. */
|
|
1107
|
+
hasUnprocessedLogs(meta) {
|
|
1108
|
+
if (meta.snapshotIndexId !== this.currentSnapshotIndexId)
|
|
1109
|
+
return true;
|
|
1110
|
+
return meta.changeLogIds.some(id => !this.processedLogIds.has(id));
|
|
1111
|
+
}
|
|
1112
|
+
/** True when a change log we wrote has fallen out of `changeLogIds` without a
|
|
1113
|
+
* compaction retiring it - i.e. someone else's write dropped it. */
|
|
1114
|
+
hasOrphanedOwnLogs(meta) {
|
|
1115
|
+
if (this.ownLogIds.size === 0)
|
|
1116
|
+
return false;
|
|
1117
|
+
const present = new Set(meta.changeLogIds);
|
|
1118
|
+
const retired = new Set(meta.retiredLogIds || []);
|
|
1119
|
+
for (const id of this.ownLogIds) {
|
|
1120
|
+
if (!present.has(id) && !retired.has(id))
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
/** Put back any such log. Returns `latest` by identity when there is nothing to
|
|
1126
|
+
* repair, so callers can tell the two cases apart. */
|
|
1127
|
+
reconcileOwnLogs(latest) {
|
|
1128
|
+
if (this.ownLogIds.size === 0)
|
|
1129
|
+
return latest;
|
|
1130
|
+
const present = new Set(latest.changeLogIds);
|
|
1131
|
+
const retired = new Set(latest.retiredLogIds || []);
|
|
1132
|
+
const missing = [];
|
|
1133
|
+
for (const id of [...this.ownLogIds]) {
|
|
1134
|
+
if (retired.has(id)) {
|
|
1135
|
+
this.ownLogIds.delete(id); // folded into a snapshot - not ours to defend
|
|
1136
|
+
continue;
|
|
708
1137
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1138
|
+
if (!present.has(id))
|
|
1139
|
+
missing.push(id);
|
|
1140
|
+
}
|
|
1141
|
+
if (missing.length === 0)
|
|
1142
|
+
return latest;
|
|
1143
|
+
this.log('Restoring change logs dropped by another writer', missing);
|
|
1144
|
+
return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Read-modify-write `_meta.json`.
|
|
1148
|
+
*
|
|
1149
|
+
* `modify` receives the current remote metadata (with any of our dropped logs
|
|
1150
|
+
* already restored, which `repaired` reports) and returns the value to write, or
|
|
1151
|
+
* null to abandon the commit because its assumptions no longer hold. This method
|
|
1152
|
+
* also returns null when it runs out of attempts. Either way nothing durable has
|
|
1153
|
+
* changed and the caller must not act as though it had.
|
|
1154
|
+
*/
|
|
1155
|
+
commitMeta(modify, opts = {}) {
|
|
1156
|
+
return this.withMetaLock(async () => {
|
|
1157
|
+
for (let attempt = 0; attempt < META_COMMIT_RETRIES; attempt++) {
|
|
1158
|
+
const current = await this.readRemoteMeta();
|
|
1159
|
+
if (!current)
|
|
1160
|
+
throw new Error('Meta missing');
|
|
1161
|
+
const reconciled = this.reconcileOwnLogs(current.meta);
|
|
1162
|
+
const next = modify(reconciled, reconciled !== current.meta);
|
|
1163
|
+
if (!next)
|
|
1164
|
+
return null;
|
|
1165
|
+
try {
|
|
1166
|
+
await this.saveMeta(next, current.pointer);
|
|
714
1167
|
}
|
|
715
|
-
|
|
1168
|
+
catch (err) {
|
|
1169
|
+
if (err.status === 412 || err.status === 409) {
|
|
1170
|
+
await this.backoff(attempt);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
throw err;
|
|
1174
|
+
}
|
|
1175
|
+
if (!opts.verify) {
|
|
1176
|
+
this.meta = next;
|
|
1177
|
+
return next;
|
|
1178
|
+
}
|
|
1179
|
+
const after = await this.readMetaBody(current.pointer.fileId);
|
|
1180
|
+
if (after && opts.verify(after)) {
|
|
1181
|
+
// Adopt the remote view rather than our own - it carries whatever
|
|
1182
|
+
// else landed alongside us.
|
|
1183
|
+
this.meta = after;
|
|
1184
|
+
return after;
|
|
1185
|
+
}
|
|
1186
|
+
this.log('Meta commit did not survive, retrying', { attempt });
|
|
1187
|
+
await this.backoff(attempt);
|
|
716
1188
|
}
|
|
1189
|
+
this.log('Meta commit abandoned after', META_COMMIT_RETRIES, 'attempts');
|
|
1190
|
+
return null;
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
/** Create _meta.json for a folder that has none. Two clients opening the same
|
|
1194
|
+
* empty folder both end up here and Drive will happily keep two files with the
|
|
1195
|
+
* same name, after which every client picks between them at random. Settle it
|
|
1196
|
+
* deterministically instead: lowest file id wins, the loser deletes its own and
|
|
1197
|
+
* adopts the winner. */
|
|
1198
|
+
async ensureMetaFile() {
|
|
1199
|
+
await this.saveMeta(this.meta, null);
|
|
1200
|
+
const mine = this.metaFileId;
|
|
1201
|
+
const rivals = await this.findFiles('_meta.json');
|
|
1202
|
+
if (rivals.length < 2 || !mine)
|
|
1203
|
+
return;
|
|
1204
|
+
const winner = rivals.map(f => f.fileId).sort()[0];
|
|
1205
|
+
if (winner === mine) {
|
|
1206
|
+
this.log('Won the _meta.json creation race', { mine, rivals: rivals.length });
|
|
1207
|
+
return;
|
|
717
1208
|
}
|
|
1209
|
+
this.log('Lost the _meta.json creation race, adopting', winner);
|
|
1210
|
+
try {
|
|
1211
|
+
await this.client.deleteFile(mine);
|
|
1212
|
+
}
|
|
1213
|
+
catch (e) {
|
|
1214
|
+
this.log('Failed to remove duplicate _meta.json', mine, e);
|
|
1215
|
+
}
|
|
1216
|
+
const adopted = await this.readRemoteMeta();
|
|
1217
|
+
if (adopted)
|
|
1218
|
+
this.adoptMeta(adopted.meta, adopted.pointer);
|
|
718
1219
|
}
|
|
719
1220
|
// Reused helpers
|
|
720
1221
|
async findOrCreateFolder() {
|
|
@@ -726,6 +1227,72 @@ class DriveHandler {
|
|
|
726
1227
|
const createRes = await this.client.createFile(this.folderName, this.parents.length ? this.parents : undefined, 'application/vnd.google-apps.folder', '');
|
|
727
1228
|
return createRes.id;
|
|
728
1229
|
}
|
|
1230
|
+
/** Every change log in the folder, whatever _meta.json has to say about them.
|
|
1231
|
+
*
|
|
1232
|
+
* The folder is the authority on which change logs exist; _meta.json is only a
|
|
1233
|
+
* cache of that, and a lossy one - it is a whole-file read-modify-write with no
|
|
1234
|
+
* compare-and-swap behind it, so a writer whose metadata lands and is then
|
|
1235
|
+
* overwritten by a slower writer loses its reference. The file is still right
|
|
1236
|
+
* here. Listing for it is what stops a lost update from becoming a lost
|
|
1237
|
+
* document. */
|
|
1238
|
+
async listChangeLogs() {
|
|
1239
|
+
const q = `name contains 'changes-' and '${this.folderId}' in parents and trashed = false`;
|
|
1240
|
+
const files = await this.client.listFiles(q);
|
|
1241
|
+
return files.filter(f => f.name.startsWith('changes-')).map(f => ({ id: f.id, name: f.name }));
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Give up a sequence slot another writer is already using.
|
|
1245
|
+
*
|
|
1246
|
+
* Slots make sequence collisions structurally impossible only between writers on
|
|
1247
|
+
* *different* slots; two ids hashing to the same slot are back to the dense
|
|
1248
|
+
* allocation this scheme replaced. The filenames the folder listing hands us
|
|
1249
|
+
* carry every writer's id, so a contested slot is visible - and since this
|
|
1250
|
+
* handler re-rolls before minting anything against what it just saw, the
|
|
1251
|
+
* exposure shrinks from "the whole session" to "rival's first log not yet
|
|
1252
|
+
* visible in a listing".
|
|
1253
|
+
*
|
|
1254
|
+
* Logs already written keep their old name and numbers; ownLogIds tracks file
|
|
1255
|
+
* ids, not names, so nothing else cares.
|
|
1256
|
+
*/
|
|
1257
|
+
rerollIfSlotContested(logNames) {
|
|
1258
|
+
const rivalSlots = new Set();
|
|
1259
|
+
for (const name of logNames) {
|
|
1260
|
+
const id = writerIdFromLogName(name);
|
|
1261
|
+
if (id && id !== this.writerId)
|
|
1262
|
+
rivalSlots.add(writerSlotFor(id));
|
|
1263
|
+
}
|
|
1264
|
+
if (!rivalSlots.has(this.writerSlot))
|
|
1265
|
+
return;
|
|
1266
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
1267
|
+
const candidateId = newWriterId();
|
|
1268
|
+
const candidateSlot = writerSlotFor(candidateId);
|
|
1269
|
+
if (!rivalSlots.has(candidateSlot)) {
|
|
1270
|
+
this.log('Sequence slot contested, re-rolling writer id', {
|
|
1271
|
+
from: { writerId: this.writerId, slot: this.writerSlot },
|
|
1272
|
+
to: { writerId: candidateId, slot: candidateSlot }
|
|
1273
|
+
});
|
|
1274
|
+
this.writerId = candidateId;
|
|
1275
|
+
this.writerSlot = candidateSlot;
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// ~50 rivals colliding with 50 fresh rolls in a million-slot space does not
|
|
1280
|
+
// happen by chance; leave the slot alone rather than loop forever.
|
|
1281
|
+
this.log('Could not find a free sequence slot, keeping', this.writerSlot);
|
|
1282
|
+
}
|
|
1283
|
+
/** Every file in the folder with this name. Drive allows duplicates, so this is
|
|
1284
|
+
* how the callers that care (see ensureMetaFile) find out there are any. */
|
|
1285
|
+
async findFiles(name) {
|
|
1286
|
+
const safeName = this.escapeQuery(name);
|
|
1287
|
+
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
1288
|
+
const files = await this.client.listFiles(q);
|
|
1289
|
+
return files.map(file => ({
|
|
1290
|
+
fileId: file.id,
|
|
1291
|
+
etag: file.etag,
|
|
1292
|
+
md5Checksum: file.md5Checksum,
|
|
1293
|
+
modifiedTime: file.modifiedTime
|
|
1294
|
+
}));
|
|
1295
|
+
}
|
|
729
1296
|
async findFile(name) {
|
|
730
1297
|
const safeName = this.escapeQuery(name);
|
|
731
1298
|
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
@@ -761,16 +1328,25 @@ class DriveHandler {
|
|
|
761
1328
|
async writeChangeFile(changes) {
|
|
762
1329
|
const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
|
|
763
1330
|
const startSeq = changes[0].seq;
|
|
764
|
-
|
|
1331
|
+
// The writer id makes the name unique even when two clients do manage to
|
|
1332
|
+
// stamp the same starting sequence number, and says who wrote it.
|
|
1333
|
+
const name = `changes-${startSeq}-${this.writerId}-${Math.random().toString(36).substring(7)}.ndjson`;
|
|
765
1334
|
const res = await this.client.createFile(name, [this.folderId], 'application/x-ndjson', lines);
|
|
766
1335
|
this.currentLogSizeEstimate += new Blob([lines]).size;
|
|
767
1336
|
return res.id;
|
|
768
1337
|
}
|
|
769
|
-
|
|
1338
|
+
/** Write `meta` to _meta.json. `target` is the file to write, as already
|
|
1339
|
+
* located by the caller; pass null to force creation, or omit it to look the
|
|
1340
|
+
* file up. */
|
|
1341
|
+
async saveMeta(meta, target) {
|
|
770
1342
|
const content = JSON.stringify(meta);
|
|
771
|
-
const metaFile = await this.findFile('_meta.json');
|
|
1343
|
+
const metaFile = target !== undefined ? target : await this.findFile('_meta.json');
|
|
772
1344
|
if (metaFile) {
|
|
773
|
-
|
|
1345
|
+
// If-Match is a no-op against Drive v3, which dropped ETags - it still
|
|
1346
|
+
// guards the emulated server and costs nothing, but nothing here may
|
|
1347
|
+
// assume it was enforced. See the commitMeta block above.
|
|
1348
|
+
const res = await this.client.updateFile(metaFile.fileId, content, metaFile.etag || undefined);
|
|
1349
|
+
this.metaFileId = metaFile.fileId;
|
|
774
1350
|
this.metaEtag = res.etag;
|
|
775
1351
|
this.metaMd5 = res.md5Checksum || null;
|
|
776
1352
|
this.metaModifiedTime = res.modifiedTime;
|
|
@@ -778,16 +1354,18 @@ class DriveHandler {
|
|
|
778
1354
|
}
|
|
779
1355
|
else {
|
|
780
1356
|
const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
|
|
1357
|
+
this.metaFileId = res.id;
|
|
781
1358
|
this.metaEtag = res.etag;
|
|
782
1359
|
this.metaMd5 = res.md5Checksum || null;
|
|
783
1360
|
this.metaModifiedTime = res.modifiedTime;
|
|
784
1361
|
}
|
|
785
1362
|
}
|
|
786
1363
|
async countTotalChanges() {
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1364
|
+
// Count change-log files, not changes. This used to return meta.seq when there
|
|
1365
|
+
// was no snapshot yet, on the reasoning that the counter and the change count
|
|
1366
|
+
// were the same number. Sequence numbers now carry a writer slot in their low
|
|
1367
|
+
// digits (see SEQ_SLOTS), so meta.seq is about a million times the tick and
|
|
1368
|
+
// would trigger a compaction on the very first write.
|
|
791
1369
|
// Each log file ID in changeLogIds represents some number of changes.
|
|
792
1370
|
// For simplicity and to trigger compaction based on file count (which is what matters for Drive),
|
|
793
1371
|
// we can return the number of log files.
|
|
@@ -820,14 +1398,28 @@ class DriveHandler {
|
|
|
820
1398
|
await deleteFile(id);
|
|
821
1399
|
}
|
|
822
1400
|
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Watch _meta.json for writes by other clients.
|
|
1403
|
+
*
|
|
1404
|
+
* This is the only thing that makes `db.changes({ live: true })` fire for a
|
|
1405
|
+
* *remote* write: on a change it calls load(), which replays the newly
|
|
1406
|
+
* referenced logs and emits exactly those through notifyListeners. Without it a
|
|
1407
|
+
* client only ever hears about what it wrote itself, so connect-and-read works
|
|
1408
|
+
* and continuous sync between two connected clients does not.
|
|
1409
|
+
*
|
|
1410
|
+
* Change is detected by md5Checksum, falling back to modifiedTime. There is
|
|
1411
|
+
* deliberately no ETag comparison: Drive API v3 has none (see
|
|
1412
|
+
* docs/adr/0001-metadata-writes-without-compare-and-swap.md), so that branch
|
|
1413
|
+
* could only ever compare '' against '' - and sitting first in the chain, it
|
|
1414
|
+
* shadowed the two comparisons that do work.
|
|
1415
|
+
*/
|
|
823
1416
|
startPolling(intervalMs) {
|
|
824
|
-
this.log('Starting polling with interval', { intervalMs });
|
|
825
1417
|
if (isNaN(intervalMs) || intervalMs <= 0)
|
|
826
1418
|
return;
|
|
827
1419
|
if (this.pollingInterval)
|
|
828
|
-
|
|
1420
|
+
return; // already watching
|
|
1421
|
+
this.log('Starting polling with interval', { intervalMs });
|
|
829
1422
|
this.pollingInterval = setInterval(async () => {
|
|
830
|
-
this.log('Polling tick...');
|
|
831
1423
|
if (this.isPollingActive) {
|
|
832
1424
|
this.log('Polling already in progress, skipping tick');
|
|
833
1425
|
return;
|
|
@@ -839,27 +1431,16 @@ class DriveHandler {
|
|
|
839
1431
|
this.log('Polling: _meta.json not found');
|
|
840
1432
|
return;
|
|
841
1433
|
}
|
|
842
|
-
// Compare etags, falling back to md5Checksum or modifiedTime
|
|
843
|
-
const remoteEtag = metaFile.etag;
|
|
844
1434
|
const remoteMd5 = metaFile.md5Checksum;
|
|
845
1435
|
const remoteModified = metaFile.modifiedTime;
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
if (remoteEtag !== this.metaEtag)
|
|
850
|
-
changed = true;
|
|
851
|
-
}
|
|
852
|
-
else if (remoteMd5 && this.metaMd5) {
|
|
853
|
-
if (remoteMd5 !== this.metaMd5)
|
|
854
|
-
changed = true;
|
|
855
|
-
}
|
|
856
|
-
else if (remoteModified !== this.metaModifiedTime) {
|
|
857
|
-
changed = true;
|
|
858
|
-
}
|
|
1436
|
+
const changed = remoteMd5 && this.metaMd5
|
|
1437
|
+
? remoteMd5 !== this.metaMd5
|
|
1438
|
+
: remoteModified !== this.metaModifiedTime;
|
|
859
1439
|
if (changed) {
|
|
860
|
-
this.log('Polling detected change
|
|
1440
|
+
this.log('Polling detected change', { remoteMd5, remoteModified });
|
|
1441
|
+
// load() emits precisely what it replayed. Announcing the whole
|
|
1442
|
+
// index instead is what used to send PouchDB sync in circles.
|
|
861
1443
|
await this.load();
|
|
862
|
-
this.notifyListeners();
|
|
863
1444
|
}
|
|
864
1445
|
}
|
|
865
1446
|
catch (err) {
|