@docstack/pouchdb-adapter-googledrive 0.1.5 → 0.1.7
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 +212 -0
- package/LICENSE +10 -0
- package/README.md +52 -1
- package/lib/adapter.js +339 -133
- package/lib/client.d.ts +2 -0
- package/lib/client.js +6 -4
- package/lib/drive.d.ts +139 -2
- package/lib/drive.js +739 -98
- package/lib/types.d.ts +34 -4
- package/package.json +7 -4
- package/.env.example +0 -9
package/lib/drive.js
CHANGED
|
@@ -1,11 +1,65 @@
|
|
|
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");
|
|
8
|
+
const pouchdb_merge_1 = require("pouchdb-merge");
|
|
6
9
|
const DEFAULT_COMPACTION_THRESHOLD = 100; // entries
|
|
7
10
|
const DEFAULT_SIZE_THRESHOLD = 1024 * 1024; // 1MB
|
|
8
11
|
const DEFAULT_CACHE_SIZE = 1000; // Number of docs
|
|
12
|
+
const META_COMMIT_RETRIES = 6; // read-modify-write attempts per _meta.json commit
|
|
13
|
+
const RETIRED_LOG_HISTORY = 500; // tombstones kept for change logs compaction deleted
|
|
14
|
+
const LOG_DOWNLOAD_CONCURRENCY = 8; // parallel change-log GETs during load()
|
|
15
|
+
const MERGE_DEPTH = 1000; // revs_limit for index-side tree merges
|
|
16
|
+
/**
|
|
17
|
+
* Sequence numbers are `tick * SEQ_SLOTS + writerSlot`.
|
|
18
|
+
*
|
|
19
|
+
* Writers mint them blind to one another - there is no lock and no compare-and-swap -
|
|
20
|
+
* so two clients reading the same counter will always be able to derive the same next
|
|
21
|
+
* tick. Reserving the low digits for a per-writer slot means they can share a tick and
|
|
22
|
+
* still never share a *sequence number*, which is the part that matters: `_changes`
|
|
23
|
+
* filters on `seq > since`, so a second document sharing a checkpointed sequence
|
|
24
|
+
* number is never emitted to a replication target again.
|
|
25
|
+
*
|
|
26
|
+
* A million slots keeps the chance that two concurrent writers hash to the same one
|
|
27
|
+
* near 1 in 20,000 for a ten-client fleet, and leaves room for 9e9 ticks inside
|
|
28
|
+
* Number.MAX_SAFE_INTEGER.
|
|
29
|
+
*/
|
|
30
|
+
exports.SEQ_SLOTS = 1000000;
|
|
31
|
+
/** Stable slot for a writer id. FNV-1a, folded into the slot space. */
|
|
32
|
+
function writerSlotFor(writerId) {
|
|
33
|
+
let h = 2166136261;
|
|
34
|
+
for (let i = 0; i < writerId.length; i++) {
|
|
35
|
+
h ^= writerId.charCodeAt(i);
|
|
36
|
+
h = Math.imul(h, 16777619);
|
|
37
|
+
}
|
|
38
|
+
return Math.abs(h) % exports.SEQ_SLOTS;
|
|
39
|
+
}
|
|
40
|
+
function newWriterId() {
|
|
41
|
+
return Math.random().toString(36).substring(2, 10);
|
|
42
|
+
}
|
|
43
|
+
/** The writer id embedded in a change-log filename, if the name carries one.
|
|
44
|
+
* New format: changes-<seq>-<writerId>-<random>.ndjson (4 dash-parts).
|
|
45
|
+
* Old format: changes-<seq>-<random>.ndjson (3 parts) - no id to extract. */
|
|
46
|
+
function writerIdFromLogName(name) {
|
|
47
|
+
if (!name.startsWith('changes-') || !name.endsWith('.ndjson'))
|
|
48
|
+
return null;
|
|
49
|
+
const parts = name.slice(0, -'.ndjson'.length).split('-');
|
|
50
|
+
return parts.length === 4 ? parts[2] : null;
|
|
51
|
+
}
|
|
52
|
+
/** A trivial single-node pouchdb-merge tree for a bare rev string - used where a
|
|
53
|
+
* doc enters the index without going through _bulkDocs's real merge (local docs,
|
|
54
|
+
* legacy-snapshot migration, and the low-level DriveHandler.appendChange() API's
|
|
55
|
+
* raw callers). Correct as far as it goes (one leaf, no ancestry); these paths
|
|
56
|
+
* never participate in replication conflict resolution anyway. */
|
|
57
|
+
function synthesizeTree(rev, deleted) {
|
|
58
|
+
const dash = rev.indexOf('-');
|
|
59
|
+
const pos = dash >= 0 ? parseInt(rev.slice(0, dash), 10) : 0;
|
|
60
|
+
const hash = dash >= 0 ? rev.slice(dash + 1) : rev;
|
|
61
|
+
return JSON.stringify([{ pos, ids: [hash, { status: 'available', deleted: !!deleted }, []] }]);
|
|
62
|
+
}
|
|
9
63
|
/**
|
|
10
64
|
* DriveHandler - Lazy Loading Implementation
|
|
11
65
|
*
|
|
@@ -33,6 +87,27 @@ class DriveHandler {
|
|
|
33
87
|
this.metaMd5 = null;
|
|
34
88
|
this.metaModifiedTime = null;
|
|
35
89
|
this.localDocsEtag = null;
|
|
90
|
+
this.metaFileId = null;
|
|
91
|
+
/** Identifies this handler among the writers sharing a folder. Change-log
|
|
92
|
+
* filenames carry it, so two writers can never produce the same name and an
|
|
93
|
+
* orphaned log can be traced back to whoever wrote it. Not readonly: if the
|
|
94
|
+
* folder shows another writer whose id hashes to our sequence slot, we re-roll
|
|
95
|
+
* to a free one before minting anything (see rerollIfSlotContested). */
|
|
96
|
+
this.writerId = newWriterId();
|
|
97
|
+
/** Change-log file ids this handler wrote, minus any a compaction has retired.
|
|
98
|
+
* Drive API v3 has no compare-and-swap (ETags were dropped), so another
|
|
99
|
+
* writer's read-modify-write of _meta.json can drop a log id that landed in
|
|
100
|
+
* between. Anything still in here but missing from the remote changeLogIds was
|
|
101
|
+
* dropped that way and gets put back - see reconcileOwnLogs(). */
|
|
102
|
+
this.ownLogIds = new Set();
|
|
103
|
+
/** This writer's reservation in the low digits of every sequence number it mints.
|
|
104
|
+
* Derived from writerId; changes only when writerId is re-rolled off a
|
|
105
|
+
* contested slot. */
|
|
106
|
+
this.writerSlot = writerSlotFor(this.writerId);
|
|
107
|
+
/** Serializes this handler's own _meta.json read-modify-write cycles. Says
|
|
108
|
+
* nothing about other clients - that is what commitMeta's verify pass is for -
|
|
109
|
+
* but stops one handler racing itself when several writes are in flight. */
|
|
110
|
+
this.metaLock = Promise.resolve();
|
|
36
111
|
// In-Memory Index: ID -> Metadata/Pointer
|
|
37
112
|
this.index = {};
|
|
38
113
|
this.pendingChanges = [];
|
|
@@ -97,17 +172,21 @@ class DriveHandler {
|
|
|
97
172
|
this.folderId = await this.findOrCreateFolder();
|
|
98
173
|
this.log('Retrieved folder', { folderId: this.folderId });
|
|
99
174
|
}
|
|
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;
|
|
175
|
+
const current = await this.readRemoteMeta();
|
|
176
|
+
if (current) {
|
|
177
|
+
this.log('Retrieved meta file', { fileId: current.pointer.fileId });
|
|
178
|
+
this.adoptMeta(current.meta, current.pointer);
|
|
107
179
|
}
|
|
108
180
|
else {
|
|
109
181
|
this.log('Meta file not found, creating new');
|
|
110
|
-
await this.
|
|
182
|
+
await this.ensureMetaFile();
|
|
183
|
+
}
|
|
184
|
+
// A change log of ours that has fallen out of changeLogIds without a
|
|
185
|
+
// compaction retiring it was dropped by another writer's
|
|
186
|
+
// read-modify-write. Put it back before replaying, so this load sees
|
|
187
|
+
// its own writes - and so the next reader does too.
|
|
188
|
+
if (this.hasOrphanedOwnLogs(this.meta)) {
|
|
189
|
+
await this.commitMeta((latest, repaired) => repaired ? latest : null);
|
|
111
190
|
}
|
|
112
191
|
if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
|
|
113
192
|
this.log('Snapshot index changed, loading index', {
|
|
@@ -147,10 +226,40 @@ class DriveHandler {
|
|
|
147
226
|
}
|
|
148
227
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
149
228
|
this.log('Replaying change logs');
|
|
150
|
-
const
|
|
229
|
+
const retired = new Set(this.meta.retiredLogIds || []);
|
|
230
|
+
// Take the union of what the metadata references and what is actually
|
|
231
|
+
// in the folder. A log missing from changeLogIds but present in the
|
|
232
|
+
// folder was orphaned by a lost metadata update; a log referenced but
|
|
233
|
+
// absent was deleted after being referenced. Both are survivable if
|
|
234
|
+
// the folder gets the last word.
|
|
235
|
+
let discovered = [];
|
|
236
|
+
try {
|
|
237
|
+
const listed = await this.listChangeLogs();
|
|
238
|
+
// The same listing shows every writer's id - the moment to notice
|
|
239
|
+
// someone else is on our sequence slot and move off it.
|
|
240
|
+
this.rerollIfSlotContested(listed.map(f => f.name));
|
|
241
|
+
const referenced = new Set(this.meta.changeLogIds);
|
|
242
|
+
discovered = listed.map(f => f.id).filter(id => !referenced.has(id) && !retired.has(id));
|
|
243
|
+
if (discovered.length > 0) {
|
|
244
|
+
this.log('Found change logs the metadata does not reference', discovered);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
// Listing is an optimisation over the metadata, never a
|
|
249
|
+
// prerequisite - a failure here must not fail the load.
|
|
250
|
+
this.log('Failed to list change logs, falling back to metadata only', e);
|
|
251
|
+
}
|
|
252
|
+
const pendingLogs = [...this.meta.changeLogIds, ...discovered]
|
|
253
|
+
.filter(id => !this.processedLogIds.has(id) && !retired.has(id));
|
|
151
254
|
if (pendingLogs.length > 0) {
|
|
152
|
-
this.log(`Downloading ${pendingLogs.length} change logs
|
|
153
|
-
|
|
255
|
+
this.log(`Downloading ${pendingLogs.length} change logs, ${LOG_DOWNLOAD_CONCURRENCY} at a time`);
|
|
256
|
+
// Bounded, not unbounded: a cold boot can hold dozens of pending
|
|
257
|
+
// logs, and firing them all at once is exactly the burst Drive's
|
|
258
|
+
// rate limiter clips. A clipped download is skipped and retried on
|
|
259
|
+
// a later load - out of order, which used to regress the index
|
|
260
|
+
// (see updateIndex); the guard there is the fix, this is the
|
|
261
|
+
// prevention.
|
|
262
|
+
const logResults = await this.mapBounded(pendingLogs, LOG_DOWNLOAD_CONCURRENCY, async (id) => {
|
|
154
263
|
try {
|
|
155
264
|
const changes = await this.downloadNdjson(id);
|
|
156
265
|
return { id, changes };
|
|
@@ -159,7 +268,19 @@ class DriveHandler {
|
|
|
159
268
|
this.log(`Failed to download change log ${id}`, e);
|
|
160
269
|
return { id, changes: null };
|
|
161
270
|
}
|
|
162
|
-
})
|
|
271
|
+
});
|
|
272
|
+
// Replay in sequence order, not in the order the ids happened to
|
|
273
|
+
// be listed. Two clients can hold different changeLogIds orderings
|
|
274
|
+
// for the same folder once merges are in play, and updateIndex
|
|
275
|
+
// takes the last write for a document - so insertion order meant
|
|
276
|
+
// two readers of one folder could disagree about the winner.
|
|
277
|
+
logResults.sort((a, b) => {
|
|
278
|
+
const seqOf = (r) => r.changes && r.changes.length ? (Array.isArray(r.changes) ? r.changes[0].seq : r.changes.seq) : Number.MAX_SAFE_INTEGER;
|
|
279
|
+
const sa = seqOf(a), sb = seqOf(b);
|
|
280
|
+
if (sa !== sb)
|
|
281
|
+
return sa - sb;
|
|
282
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
283
|
+
});
|
|
163
284
|
const foundNew = {};
|
|
164
285
|
for (const { id, changes } of logResults) {
|
|
165
286
|
if (!changes) {
|
|
@@ -190,6 +311,19 @@ class DriveHandler {
|
|
|
190
311
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
191
312
|
// ... (previous logic for change logs)
|
|
192
313
|
// (Already updated in previous turn, keep it)
|
|
314
|
+
// Put what we found back into the metadata, so the next reader
|
|
315
|
+
// does not have to rediscover it and compaction can see it. Any
|
|
316
|
+
// client repairs this, not only the writer that lost the race.
|
|
317
|
+
if (discovered.length > 0) {
|
|
318
|
+
await this.commitMeta((latest) => {
|
|
319
|
+
const referenced = new Set(latest.changeLogIds);
|
|
320
|
+
const stillRetired = new Set(latest.retiredLogIds || []);
|
|
321
|
+
const missing = discovered.filter(id => !referenced.has(id) && !stillRetired.has(id));
|
|
322
|
+
if (missing.length === 0)
|
|
323
|
+
return null;
|
|
324
|
+
return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
193
327
|
// 2b. Load Local Documents Store (Pinned in meta)
|
|
194
328
|
if (this.meta.localDocsId) {
|
|
195
329
|
this.log('Loading local docs store', this.meta.localDocsId);
|
|
@@ -201,6 +335,7 @@ class DriveHandler {
|
|
|
201
335
|
for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
|
|
202
336
|
this.log('Merging local doc', id);
|
|
203
337
|
this.index[id] = {
|
|
338
|
+
tree: synthesizeTree(doc._rev),
|
|
204
339
|
rev: doc._rev,
|
|
205
340
|
seq: 0, // Local docs don't participate in shared sequences
|
|
206
341
|
location: { fileId: this.meta.localDocsId }
|
|
@@ -213,7 +348,13 @@ class DriveHandler {
|
|
|
213
348
|
this.log('Failed to load local docs store', e);
|
|
214
349
|
}
|
|
215
350
|
}
|
|
216
|
-
// 3. Start Polling
|
|
351
|
+
// 3. Start Polling (if enabled). Idempotent on purpose: load()
|
|
352
|
+
// runs again on every catch-up and retry, and restarting the
|
|
353
|
+
// interval each time would push the next tick further out for
|
|
354
|
+
// exactly as long as the client stays busy.
|
|
355
|
+
if (this.options.pollingIntervalMs) {
|
|
356
|
+
this.startPolling(Number(this.options.pollingIntervalMs));
|
|
357
|
+
}
|
|
217
358
|
}
|
|
218
359
|
catch (e) {
|
|
219
360
|
console.error('Failed to load database', e);
|
|
@@ -234,6 +375,7 @@ class DriveHandler {
|
|
|
234
375
|
// We will cache them ALL now (since we downloaded them) and index them.
|
|
235
376
|
for (const [id, doc] of Object.entries(snapshot.docs)) {
|
|
236
377
|
this.index[id] = {
|
|
378
|
+
tree: synthesizeTree(doc._rev),
|
|
237
379
|
rev: doc._rev,
|
|
238
380
|
seq: snapshot.seq, // Approximate
|
|
239
381
|
location: { fileId: 'LEGACY_MEMORY' } // Special validity marker
|
|
@@ -298,6 +440,40 @@ class DriveHandler {
|
|
|
298
440
|
}
|
|
299
441
|
return doc;
|
|
300
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Fetch a specific (possibly non-winning/conflicting) revision's body by its
|
|
445
|
+
* own tracked location - used by _get(opts.rev)/_bulkGet when the requested
|
|
446
|
+
* rev isn't the current winner. Deliberately doesn't touch docCache (keyed
|
|
447
|
+
* per-id, not per-rev - a conflict fetch is rare enough not to warrant a
|
|
448
|
+
* per-rev cache key) and doesn't force `_rev` to the index's winning rev the
|
|
449
|
+
* way get() does.
|
|
450
|
+
*/
|
|
451
|
+
async getRevisionBody(id, rev, location) {
|
|
452
|
+
if (location.fileId === 'LEGACY_MEMORY' || location.fileId === '__SELF__')
|
|
453
|
+
return null;
|
|
454
|
+
const content = await this.fetchFile(location.fileId);
|
|
455
|
+
let doc = null;
|
|
456
|
+
if (Array.isArray(content)) {
|
|
457
|
+
const match = content.find((c) => c.id === id && c.rev === rev);
|
|
458
|
+
doc = match ? match.doc : null;
|
|
459
|
+
}
|
|
460
|
+
else if (content && content.conflicts && content.conflicts[id]) {
|
|
461
|
+
doc = content.conflicts[id][rev] ?? null;
|
|
462
|
+
}
|
|
463
|
+
else if (content && content.docs && content.docs[id]) {
|
|
464
|
+
// Winning body happened to be requested through this path (e.g. after
|
|
465
|
+
// a merge where the "conflict" turned out to be the new winner).
|
|
466
|
+
const candidate = content.docs[id];
|
|
467
|
+
if (candidate && candidate._rev === rev)
|
|
468
|
+
doc = candidate;
|
|
469
|
+
}
|
|
470
|
+
else if (content && content.id === id && content.rev === rev && content.doc) {
|
|
471
|
+
doc = content.doc;
|
|
472
|
+
}
|
|
473
|
+
if (doc)
|
|
474
|
+
doc._rev = rev;
|
|
475
|
+
return doc;
|
|
476
|
+
}
|
|
301
477
|
/** Generic Download with Caching and Parsing */
|
|
302
478
|
async fetchFile(fileId, skipCache = false) {
|
|
303
479
|
if (!skipCache) {
|
|
@@ -463,18 +639,14 @@ class DriveHandler {
|
|
|
463
639
|
}
|
|
464
640
|
catch (err) {
|
|
465
641
|
if (err.status === 412 || err.status === 409) {
|
|
466
|
-
// Reload and RETRY
|
|
642
|
+
// Reload and RETRY. No resequencing here - tryAppendChanges
|
|
643
|
+
// stamps sequence numbers from a fresh read of _meta.json on
|
|
644
|
+
// every attempt of its own.
|
|
467
645
|
await this.load();
|
|
468
646
|
// Check conflicts against Index (Metadata sufficient)
|
|
469
647
|
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
648
|
attemptNum++;
|
|
477
|
-
await
|
|
649
|
+
await this.backoff(attemptNum);
|
|
478
650
|
continue;
|
|
479
651
|
}
|
|
480
652
|
throw err;
|
|
@@ -519,7 +691,8 @@ class DriveHandler {
|
|
|
519
691
|
else {
|
|
520
692
|
res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
|
|
521
693
|
// Update Meta with new File ID
|
|
522
|
-
|
|
694
|
+
const localRes = res;
|
|
695
|
+
await this.commitMeta((latest) => ({ ...latest, localDocsId: localRes.id }));
|
|
523
696
|
}
|
|
524
697
|
this.localDocsEtag = res.etag;
|
|
525
698
|
// Update Index
|
|
@@ -551,17 +724,92 @@ class DriveHandler {
|
|
|
551
724
|
}
|
|
552
725
|
}
|
|
553
726
|
async tryAppendChanges(changes) {
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
727
|
+
// Catching up on another writer's logs and failing to publish are separate
|
|
728
|
+
// failures, so they get separate budgets - a busy folder should not be able
|
|
729
|
+
// to spend the publish retries on catch-ups alone.
|
|
730
|
+
let catchUps = 0;
|
|
731
|
+
for (let attempt = 0; attempt < META_COMMIT_RETRIES;) {
|
|
732
|
+
// 1. Take the sequence range from what Drive holds right now, not from
|
|
733
|
+
// this handler's cached copy - that copy is only refreshed on load, so
|
|
734
|
+
// two clients allocating from their own stale copies is exactly how two
|
|
735
|
+
// change logs end up claiming the same sequence number.
|
|
736
|
+
const current = await this.readRemoteMeta();
|
|
737
|
+
// Catch up before writing. Another writer having appended since our last
|
|
738
|
+
// load means our index - and so the revision this write is built on - is
|
|
739
|
+
// out of date; replaying their logs first is what lets checkConflicts see
|
|
740
|
+
// the collision instead of us silently writing over them. (Previously
|
|
741
|
+
// this only happened when the metadata ETag came back 412, which Drive
|
|
742
|
+
// itself never does.)
|
|
743
|
+
if (current && this.hasUnprocessedLogs(current.meta)) {
|
|
744
|
+
if (++catchUps > META_COMMIT_RETRIES) {
|
|
745
|
+
throw new Error('Could not catch up with concurrent writers');
|
|
746
|
+
}
|
|
747
|
+
await this.load();
|
|
748
|
+
// Only the low-level appendChange() callers are checked here.
|
|
749
|
+
// _bulkDocs has already resolved revisions through pouchdb-merge and
|
|
750
|
+
// expresses a collision as a conflict branch, not as a thrown error;
|
|
751
|
+
// failing its whole batch would be the wrong answer.
|
|
752
|
+
this.checkConflicts(changes.filter(c => !c.nextIndexEntry));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// Take the tick from whatever counter is furthest along and stamp our own
|
|
756
|
+
// slot into it. Another writer working from the same counter lands on the
|
|
757
|
+
// same tick and a different sequence number, which is the point - there is
|
|
758
|
+
// no way to stop them sharing the tick, and no longer any need to.
|
|
759
|
+
const observedSeq = current ? current.meta.seq : this.meta.seq;
|
|
760
|
+
const baseTick = Math.floor(Math.max(observedSeq, this.meta.seq) / exports.SEQ_SLOTS);
|
|
761
|
+
changes.forEach((change, i) => {
|
|
762
|
+
change.seq = (baseTick + i + 1) * exports.SEQ_SLOTS + this.writerSlot;
|
|
763
|
+
});
|
|
764
|
+
const lastSeq = changes[changes.length - 1].seq;
|
|
765
|
+
// 2. Write Log File (Upload Data). `nextIndexEntry` (the merged tree, only
|
|
766
|
+
// needed transiently by updateIndex() below) is stripped first - it's
|
|
767
|
+
// already durably captured by the index/snapshot system, so writing it into
|
|
768
|
+
// every change-log line too would just redundantly bloat storage, growing
|
|
769
|
+
// with tree depth on every single write.
|
|
770
|
+
const fileId = await this.writeChangeFile(changes.map(({ nextIndexEntry, ...rest }) => rest));
|
|
771
|
+
// 3. Publish it. The modifier merges into whatever _meta.json holds at
|
|
772
|
+
// write time rather than replacing it, so a concurrent writer's logs
|
|
773
|
+
// survive; it abandons the commit if someone has taken the sequence
|
|
774
|
+
// range already stamped into our file, since those seqs would then
|
|
775
|
+
// collide with theirs.
|
|
776
|
+
let committed;
|
|
777
|
+
try {
|
|
778
|
+
committed = await this.commitMeta((latest) => ({
|
|
779
|
+
...latest,
|
|
780
|
+
// Idempotent: commitMeta may run this more than once.
|
|
781
|
+
changeLogIds: latest.changeLogIds.includes(fileId)
|
|
782
|
+
? latest.changeLogIds
|
|
783
|
+
: [...latest.changeLogIds, fileId],
|
|
784
|
+
// Never let the shared counter go backwards: another writer may
|
|
785
|
+
// have reached a higher tick while we were uploading, and
|
|
786
|
+
// rewinding it would hand our tick out a second time.
|
|
787
|
+
seq: Math.max(latest.seq, lastSeq)
|
|
788
|
+
}), { verify: (m) => m.changeLogIds.includes(fileId) });
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
this.discardLog(fileId);
|
|
792
|
+
throw err;
|
|
793
|
+
}
|
|
794
|
+
if (!committed) {
|
|
795
|
+
// commitMeta ran out of attempts. Nothing references the log we just
|
|
796
|
+
// uploaded, so drop it and start over against fresh metadata. This no
|
|
797
|
+
// longer fires for a sequence collision - slots make those impossible -
|
|
798
|
+
// only for a metadata write that would not stick.
|
|
799
|
+
this.log('Change log not published, retrying', { fileId, attempt });
|
|
800
|
+
this.discardLog(fileId);
|
|
801
|
+
await this.backoff(attempt++);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
// Published: from here on this log is ours to defend if another writer
|
|
805
|
+
// drops it from the metadata.
|
|
806
|
+
this.ownLogIds.add(fileId);
|
|
807
|
+
// 4. Update Local State. Marking our own log processed keeps a later
|
|
808
|
+
// load() from replaying it: the entries we apply here carry the merged
|
|
809
|
+
// rev tree computed by _bulkDocs, while the log lines on Drive have it
|
|
810
|
+
// stripped, so a replay would overwrite real ancestry with a synthesized
|
|
811
|
+
// single-node tree.
|
|
812
|
+
this.processedLogIds.add(fileId);
|
|
565
813
|
const changedDocs = {};
|
|
566
814
|
for (const change of changes) {
|
|
567
815
|
this.updateIndex(change, fileId);
|
|
@@ -586,21 +834,122 @@ class DriveHandler {
|
|
|
586
834
|
this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
|
|
587
835
|
this.compact().catch(e => console.error('Compaction failed', e));
|
|
588
836
|
}
|
|
837
|
+
return;
|
|
589
838
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
839
|
+
throw new Error(`Failed to publish change log after ${META_COMMIT_RETRIES} attempts`);
|
|
840
|
+
}
|
|
841
|
+
/** Forget a change log we uploaded but never managed to reference from
|
|
842
|
+
* _meta.json. Nothing points at it, so it is safe to remove. */
|
|
843
|
+
discardLog(fileId) {
|
|
844
|
+
this.ownLogIds.delete(fileId);
|
|
845
|
+
this.client.deleteFile(fileId).catch(e => this.log('Failed to clean up unreferenced log', fileId, e));
|
|
595
846
|
}
|
|
596
847
|
/** Update Index with a new change */
|
|
597
848
|
updateIndex(change, fileId) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
849
|
+
if (change.nextIndexEntry) {
|
|
850
|
+
// adapter.ts already computed the full merged tree/winner/conflicts via
|
|
851
|
+
// pouchdb-merge - just substitute the '__SELF__' placeholder(s) with the
|
|
852
|
+
// fileId this batch actually landed in (unknown until upload completed).
|
|
853
|
+
const entry = { ...change.nextIndexEntry, seq: change.seq };
|
|
854
|
+
if (entry.location.fileId === '__SELF__')
|
|
855
|
+
entry.location = { fileId };
|
|
856
|
+
if (entry.conflictLocations) {
|
|
857
|
+
const resolved = {};
|
|
858
|
+
for (const rev of Object.keys(entry.conflictLocations)) {
|
|
859
|
+
const loc = entry.conflictLocations[rev];
|
|
860
|
+
resolved[rev] = loc.fileId === '__SELF__' ? { fileId } : loc;
|
|
861
|
+
}
|
|
862
|
+
entry.conflictLocations = resolved;
|
|
863
|
+
}
|
|
864
|
+
this.index[change.id] = entry;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// Legacy path: no computed tree. Every row a reader replays lands here,
|
|
868
|
+
// because `nextIndexEntry` is stripped before upload - which makes this the
|
|
869
|
+
// path that decides what a replica believes.
|
|
870
|
+
//
|
|
871
|
+
// It used to replace the entry blindly, and a row replayed out of order
|
|
872
|
+
// rewrote rev, seq and location to an older state. Out-of-order replay is
|
|
873
|
+
// routine, not exotic: a change-log download clipped by the rate limiter is
|
|
874
|
+
// skipped and retried on a later load, after higher logs have applied. The
|
|
875
|
+
// regressed entry then fails the changes feed's `seq > since` gate, so the
|
|
876
|
+
// newer revision is never emitted, and the puller's checkpoint advances past
|
|
877
|
+
// it - silent, permanent loss on the reading side while the folder holds
|
|
878
|
+
// everything. A writer echoing a stale revision at a fresh seq regressed the
|
|
879
|
+
// winner the same way, no retry needed.
|
|
880
|
+
//
|
|
881
|
+
// So: merge instead of replace, and never let a replay move a document
|
|
882
|
+
// backwards. The winner is decided by revision generation (hash tie-break),
|
|
883
|
+
// not pouchdb-merge's winningRev - synthesized nodes carry no ancestry, so
|
|
884
|
+
// every rev is a leaf to winningRev, and its live-leaf preference would let
|
|
885
|
+
// an old live rev beat a genuine deletion at a higher generation.
|
|
886
|
+
const existing = this.index[change.id];
|
|
887
|
+
if (!existing) {
|
|
888
|
+
this.index[change.id] = {
|
|
889
|
+
tree: synthesizeTree(change.rev, change.deleted),
|
|
890
|
+
rev: change.rev,
|
|
891
|
+
seq: change.seq,
|
|
892
|
+
deleted: !!change.deleted,
|
|
893
|
+
location: { fileId }
|
|
894
|
+
};
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
// Never regress the seq, whatever else happens - the feed gates on it.
|
|
898
|
+
const seq = Math.max(existing.seq, change.seq);
|
|
899
|
+
let existingTree;
|
|
900
|
+
try {
|
|
901
|
+
existingTree = JSON.parse(existing.tree);
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
existingTree = JSON.parse(synthesizeTree(existing.rev, existing.deleted));
|
|
905
|
+
}
|
|
906
|
+
if (change.rev === existing.rev || (0, pouchdb_merge_1.revExists)(existingTree, change.rev)) {
|
|
907
|
+
// A re-replay of something already known: nothing to change but the seq.
|
|
908
|
+
if (seq !== existing.seq)
|
|
909
|
+
this.index[change.id] = { ...existing, seq };
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const incomingPath = JSON.parse(synthesizeTree(change.rev, change.deleted))[0];
|
|
913
|
+
const mergedTree = (0, pouchdb_merge_1.merge)(existingTree, incomingPath, MERGE_DEPTH).tree;
|
|
914
|
+
const genOf = (rev) => parseInt(rev.split('-')[0], 10) || 0;
|
|
915
|
+
const hashOf = (rev) => rev.slice(rev.indexOf('-') + 1);
|
|
916
|
+
const incomingWins = genOf(change.rev) !== genOf(existing.rev)
|
|
917
|
+
? genOf(change.rev) > genOf(existing.rev)
|
|
918
|
+
: hashOf(change.rev) > hashOf(existing.rev); // CouchDB's deterministic tie-break
|
|
919
|
+
const entry = {
|
|
920
|
+
tree: JSON.stringify(mergedTree),
|
|
921
|
+
rev: incomingWins ? change.rev : existing.rev,
|
|
922
|
+
seq,
|
|
923
|
+
deleted: incomingWins ? !!change.deleted : !!existing.deleted,
|
|
924
|
+
location: incomingWins ? { fileId } : existing.location
|
|
603
925
|
};
|
|
926
|
+
// The losing revision stays reachable as a conflict, matching what the
|
|
927
|
+
// nextIndexEntry path does for real merges.
|
|
928
|
+
const conflicts = { ...(existing.conflictLocations || {}) };
|
|
929
|
+
if (incomingWins) {
|
|
930
|
+
conflicts[existing.rev] = existing.location;
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
conflicts[change.rev] = { fileId };
|
|
934
|
+
}
|
|
935
|
+
delete conflicts[entry.rev];
|
|
936
|
+
if (Object.keys(conflicts).length > 0)
|
|
937
|
+
entry.conflictLocations = conflicts;
|
|
938
|
+
this.index[change.id] = entry;
|
|
939
|
+
}
|
|
940
|
+
/** Run `fn` over `items` with at most `limit` in flight at once, preserving
|
|
941
|
+
* result order. */
|
|
942
|
+
async mapBounded(items, limit, fn) {
|
|
943
|
+
const results = new Array(items.length);
|
|
944
|
+
let next = 0;
|
|
945
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
946
|
+
while (next < items.length) {
|
|
947
|
+
const i = next++;
|
|
948
|
+
results[i] = await fn(items[i]);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
await Promise.all(workers);
|
|
952
|
+
return results;
|
|
604
953
|
}
|
|
605
954
|
checkConflicts(changes) {
|
|
606
955
|
for (const change of changes) {
|
|
@@ -670,6 +1019,39 @@ class DriveHandler {
|
|
|
670
1019
|
this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
|
|
671
1020
|
throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
|
|
672
1021
|
}
|
|
1022
|
+
// 1b. Carry forward conflict-branch bodies too. Without this, the first
|
|
1023
|
+
// compaction after a real conflict exists would silently drop the losing
|
|
1024
|
+
// revision forever - snapshotData only ever had room for one body per id
|
|
1025
|
+
// before conflict tracking existed. A body that fails to fetch here is
|
|
1026
|
+
// logged and dropped rather than aborting the whole compaction (unlike
|
|
1027
|
+
// missingDocs above): losing one stale conflict branch is far less bad
|
|
1028
|
+
// than losing a doc's current data, and shouldn't block reclaiming space.
|
|
1029
|
+
const conflictFetches = [];
|
|
1030
|
+
for (const id of allIds) {
|
|
1031
|
+
const conflicts = this.index[id].conflictLocations;
|
|
1032
|
+
if (!conflicts)
|
|
1033
|
+
continue;
|
|
1034
|
+
for (const rev of Object.keys(conflicts)) {
|
|
1035
|
+
conflictFetches.push({ id, rev, location: conflicts[rev] });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const carriedConflicts = {};
|
|
1039
|
+
if (conflictFetches.length > 0) {
|
|
1040
|
+
snapshotData.conflicts = {};
|
|
1041
|
+
for (const { id, rev, location } of conflictFetches) {
|
|
1042
|
+
const body = await this.getRevisionBody(id, rev, location);
|
|
1043
|
+
if (!body) {
|
|
1044
|
+
this.log('Compaction WARNING: could not fetch conflict body, dropping', id, rev);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (!snapshotData.conflicts[id])
|
|
1048
|
+
snapshotData.conflicts[id] = {};
|
|
1049
|
+
snapshotData.conflicts[id][rev] = body;
|
|
1050
|
+
if (!carriedConflicts[id])
|
|
1051
|
+
carriedConflicts[id] = {};
|
|
1052
|
+
carriedConflicts[id][rev] = true;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
673
1055
|
// 2. Upload Data File
|
|
674
1056
|
const dataContent = JSON.stringify(snapshotData);
|
|
675
1057
|
const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
|
|
@@ -677,11 +1059,19 @@ class DriveHandler {
|
|
|
677
1059
|
// 3. Create Index pointing to this Data File
|
|
678
1060
|
const newIndexEntries = {};
|
|
679
1061
|
for (const id of Object.keys(snapshotData.docs)) {
|
|
680
|
-
|
|
1062
|
+
const entry = {
|
|
1063
|
+
tree: this.index[id].tree,
|
|
681
1064
|
rev: this.index[id].rev,
|
|
682
1065
|
seq: this.index[id].seq,
|
|
683
1066
|
location: { fileId: dataFileId }
|
|
684
1067
|
};
|
|
1068
|
+
if (carriedConflicts[id]) {
|
|
1069
|
+
entry.conflictLocations = {};
|
|
1070
|
+
for (const rev of Object.keys(carriedConflicts[id])) {
|
|
1071
|
+
entry.conflictLocations[rev] = { fileId: dataFileId };
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
newIndexEntries[id] = entry;
|
|
685
1075
|
}
|
|
686
1076
|
const snapshotIndex = {
|
|
687
1077
|
entries: newIndexEntries,
|
|
@@ -691,22 +1081,42 @@ class DriveHandler {
|
|
|
691
1081
|
const indexContent = JSON.stringify(snapshotIndex);
|
|
692
1082
|
const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
|
|
693
1083
|
const newIndexId = indexRes.id;
|
|
694
|
-
// 4. Update Meta
|
|
1084
|
+
// 4. Update Meta. Only the logs THIS handler had already replayed are
|
|
1085
|
+
// retired; anything another writer appended in the meantime stays in
|
|
1086
|
+
// changeLogIds and gets replayed on top of the new snapshot.
|
|
695
1087
|
let filesToDelete = [];
|
|
696
|
-
await this.
|
|
1088
|
+
const committed = await this.commitMeta((latest) => {
|
|
697
1089
|
const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
|
|
698
1090
|
// Only delete files that were in oldLogIds but not in remainingLogs
|
|
699
1091
|
filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
|
|
1092
|
+
const retired = [...(latest.retiredLogIds || []), ...filesToDelete];
|
|
700
1093
|
return {
|
|
701
1094
|
...latest,
|
|
702
1095
|
snapshotIndexId: newIndexId,
|
|
703
1096
|
changeLogIds: remainingLogs,
|
|
1097
|
+
// Tombstones, so the writers of those logs don't put them back.
|
|
1098
|
+
retiredLogIds: [...new Set(retired)].slice(-RETIRED_LOG_HISTORY),
|
|
704
1099
|
lastCompaction: Date.now()
|
|
705
1100
|
};
|
|
1101
|
+
}, {
|
|
1102
|
+
verify: (m) => m.snapshotIndexId === newIndexId &&
|
|
1103
|
+
!filesToDelete.some(id => m.changeLogIds.includes(id))
|
|
706
1104
|
});
|
|
707
|
-
// 5. Cleanup -
|
|
708
|
-
//
|
|
709
|
-
// the
|
|
1105
|
+
// 5. Cleanup - ONLY once we have read back the metadata that de-references
|
|
1106
|
+
// these files. Until that write is confirmed, the change logs are still
|
|
1107
|
+
// the only copy of everything in them, and deleting on the strength of an
|
|
1108
|
+
// unverified write is how a lost update turns into lost documents. If the
|
|
1109
|
+
// commit never stuck, the new snapshot files are left behind unreferenced
|
|
1110
|
+
// rather than deleted - a reader may already have picked them up.
|
|
1111
|
+
if (!committed) {
|
|
1112
|
+
this.log('Compaction: metadata never committed, keeping every change log', {
|
|
1113
|
+
snapshotIndexId: newIndexId,
|
|
1114
|
+
logs: oldLogIds.length
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
for (const id of filesToDelete)
|
|
1119
|
+
this.ownLogIds.delete(id);
|
|
710
1120
|
const staleDataFileIds = oldDataFileIds.filter(id => id !== dataFileId);
|
|
711
1121
|
await this.cleanupOldFiles(oldIndexId, [...filesToDelete, ...staleDataFileIds]);
|
|
712
1122
|
this.currentLogSizeEstimate = 0;
|
|
@@ -715,30 +1125,181 @@ class DriveHandler {
|
|
|
715
1125
|
this.isCompacting = false;
|
|
716
1126
|
}
|
|
717
1127
|
}
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1128
|
+
// --- _meta.json: the one piece of shared mutable state --------------------
|
|
1129
|
+
//
|
|
1130
|
+
// Drive API v3 has no compare-and-swap. ETags are gone from the API, so the
|
|
1131
|
+
// If-Match header saveMeta() still sends is honoured by the emulated server and
|
|
1132
|
+
// silently ignored by Drive itself - which is what let two clients read the same
|
|
1133
|
+
// metadata, append their own change log, and each write back a changeLogIds list
|
|
1134
|
+
// that did not mention the other's. The losing log stayed in the folder,
|
|
1135
|
+
// referenced by nothing, invisible to every reader.
|
|
1136
|
+
//
|
|
1137
|
+
// Four things stand in for the missing CAS:
|
|
1138
|
+
//
|
|
1139
|
+
// 1. every commit builds on metadata read from Drive moments earlier, never on
|
|
1140
|
+
// this handler's cached copy, so the window in which a concurrent writer can
|
|
1141
|
+
// be clobbered is one round trip instead of the client's whole lifetime;
|
|
1142
|
+
// 2. modifiers merge into that copy rather than replacing it, so whatever
|
|
1143
|
+
// another writer added survives;
|
|
1144
|
+
// 3. commits that matter re-read afterwards and retry if what they wrote is not
|
|
1145
|
+
// there, which catches the writer who read just before we wrote;
|
|
1146
|
+
// 4. and a writer remembers the logs it wrote (ownLogIds), so anything dropped
|
|
1147
|
+
// despite all of the above is restored on its next load or commit.
|
|
1148
|
+
/** Run `fn` after every meta mutation this handler has already queued has
|
|
1149
|
+
* settled. Never call this from inside a commitMeta modifier - it would wait on
|
|
1150
|
+
* itself. */
|
|
1151
|
+
withMetaLock(fn) {
|
|
1152
|
+
const run = this.metaLock.then(fn, fn);
|
|
1153
|
+
this.metaLock = run.then(() => undefined, () => undefined);
|
|
1154
|
+
return run;
|
|
1155
|
+
}
|
|
1156
|
+
async backoff(attempt) {
|
|
1157
|
+
const ceiling = Math.min(100 * Math.pow(2, attempt), 2000);
|
|
1158
|
+
await new Promise(r => setTimeout(r, ceiling * (0.5 + Math.random())));
|
|
1159
|
+
}
|
|
1160
|
+
/** Locate _meta.json and read its body straight from Drive, past every cache. */
|
|
1161
|
+
async readRemoteMeta() {
|
|
1162
|
+
const pointer = await this.findFile('_meta.json');
|
|
1163
|
+
if (!pointer) {
|
|
1164
|
+
this.metaFileId = null;
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
this.metaFileId = pointer.fileId;
|
|
1168
|
+
const meta = await this.readMetaBody(pointer.fileId);
|
|
1169
|
+
return meta ? { meta, pointer } : null;
|
|
1170
|
+
}
|
|
1171
|
+
/** Read a known _meta.json by id - no folder listing, no cache. Used by the
|
|
1172
|
+
* post-write verification pass, which only needs the body. */
|
|
1173
|
+
async readMetaBody(fileId) {
|
|
1174
|
+
this.fileCache.remove(fileId);
|
|
1175
|
+
const raw = await this.client.getFile(fileId);
|
|
1176
|
+
if (typeof raw === 'string')
|
|
1177
|
+
return JSON.parse(raw);
|
|
1178
|
+
if (raw && typeof raw === 'object')
|
|
1179
|
+
return JSON.parse(JSON.stringify(raw));
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
adoptMeta(meta, pointer) {
|
|
1183
|
+
this.meta = meta;
|
|
1184
|
+
this.metaFileId = pointer.fileId;
|
|
1185
|
+
this.metaEtag = pointer.etag || null;
|
|
1186
|
+
this.metaMd5 = pointer.md5Checksum || null;
|
|
1187
|
+
this.metaModifiedTime = pointer.modifiedTime || null;
|
|
1188
|
+
}
|
|
1189
|
+
/** True when the folder holds changes this handler has not replayed - another
|
|
1190
|
+
* writer appended, or compacted, since our last load. */
|
|
1191
|
+
hasUnprocessedLogs(meta) {
|
|
1192
|
+
if (meta.snapshotIndexId !== this.currentSnapshotIndexId)
|
|
1193
|
+
return true;
|
|
1194
|
+
return meta.changeLogIds.some(id => !this.processedLogIds.has(id));
|
|
1195
|
+
}
|
|
1196
|
+
/** True when a change log we wrote has fallen out of `changeLogIds` without a
|
|
1197
|
+
* compaction retiring it - i.e. someone else's write dropped it. */
|
|
1198
|
+
hasOrphanedOwnLogs(meta) {
|
|
1199
|
+
if (this.ownLogIds.size === 0)
|
|
1200
|
+
return false;
|
|
1201
|
+
const present = new Set(meta.changeLogIds);
|
|
1202
|
+
const retired = new Set(meta.retiredLogIds || []);
|
|
1203
|
+
for (const id of this.ownLogIds) {
|
|
1204
|
+
if (!present.has(id) && !retired.has(id))
|
|
1205
|
+
return true;
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
/** Put back any such log. Returns `latest` by identity when there is nothing to
|
|
1210
|
+
* repair, so callers can tell the two cases apart. */
|
|
1211
|
+
reconcileOwnLogs(latest) {
|
|
1212
|
+
if (this.ownLogIds.size === 0)
|
|
1213
|
+
return latest;
|
|
1214
|
+
const present = new Set(latest.changeLogIds);
|
|
1215
|
+
const retired = new Set(latest.retiredLogIds || []);
|
|
1216
|
+
const missing = [];
|
|
1217
|
+
for (const id of [...this.ownLogIds]) {
|
|
1218
|
+
if (retired.has(id)) {
|
|
1219
|
+
this.ownLogIds.delete(id); // folded into a snapshot - not ours to defend
|
|
1220
|
+
continue;
|
|
732
1221
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1222
|
+
if (!present.has(id))
|
|
1223
|
+
missing.push(id);
|
|
1224
|
+
}
|
|
1225
|
+
if (missing.length === 0)
|
|
1226
|
+
return latest;
|
|
1227
|
+
this.log('Restoring change logs dropped by another writer', missing);
|
|
1228
|
+
return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Read-modify-write `_meta.json`.
|
|
1232
|
+
*
|
|
1233
|
+
* `modify` receives the current remote metadata (with any of our dropped logs
|
|
1234
|
+
* already restored, which `repaired` reports) and returns the value to write, or
|
|
1235
|
+
* null to abandon the commit because its assumptions no longer hold. This method
|
|
1236
|
+
* also returns null when it runs out of attempts. Either way nothing durable has
|
|
1237
|
+
* changed and the caller must not act as though it had.
|
|
1238
|
+
*/
|
|
1239
|
+
commitMeta(modify, opts = {}) {
|
|
1240
|
+
return this.withMetaLock(async () => {
|
|
1241
|
+
for (let attempt = 0; attempt < META_COMMIT_RETRIES; attempt++) {
|
|
1242
|
+
const current = await this.readRemoteMeta();
|
|
1243
|
+
if (!current)
|
|
1244
|
+
throw new Error('Meta missing');
|
|
1245
|
+
const reconciled = this.reconcileOwnLogs(current.meta);
|
|
1246
|
+
const next = modify(reconciled, reconciled !== current.meta);
|
|
1247
|
+
if (!next)
|
|
1248
|
+
return null;
|
|
1249
|
+
try {
|
|
1250
|
+
await this.saveMeta(next, current.pointer);
|
|
738
1251
|
}
|
|
739
|
-
|
|
1252
|
+
catch (err) {
|
|
1253
|
+
if (err.status === 412 || err.status === 409) {
|
|
1254
|
+
await this.backoff(attempt);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
throw err;
|
|
1258
|
+
}
|
|
1259
|
+
if (!opts.verify) {
|
|
1260
|
+
this.meta = next;
|
|
1261
|
+
return next;
|
|
1262
|
+
}
|
|
1263
|
+
const after = await this.readMetaBody(current.pointer.fileId);
|
|
1264
|
+
if (after && opts.verify(after)) {
|
|
1265
|
+
// Adopt the remote view rather than our own - it carries whatever
|
|
1266
|
+
// else landed alongside us.
|
|
1267
|
+
this.meta = after;
|
|
1268
|
+
return after;
|
|
1269
|
+
}
|
|
1270
|
+
this.log('Meta commit did not survive, retrying', { attempt });
|
|
1271
|
+
await this.backoff(attempt);
|
|
740
1272
|
}
|
|
1273
|
+
this.log('Meta commit abandoned after', META_COMMIT_RETRIES, 'attempts');
|
|
1274
|
+
return null;
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
/** Create _meta.json for a folder that has none. Two clients opening the same
|
|
1278
|
+
* empty folder both end up here and Drive will happily keep two files with the
|
|
1279
|
+
* same name, after which every client picks between them at random. Settle it
|
|
1280
|
+
* deterministically instead: lowest file id wins, the loser deletes its own and
|
|
1281
|
+
* adopts the winner. */
|
|
1282
|
+
async ensureMetaFile() {
|
|
1283
|
+
await this.saveMeta(this.meta, null);
|
|
1284
|
+
const mine = this.metaFileId;
|
|
1285
|
+
const rivals = await this.findFiles('_meta.json');
|
|
1286
|
+
if (rivals.length < 2 || !mine)
|
|
1287
|
+
return;
|
|
1288
|
+
const winner = rivals.map(f => f.fileId).sort()[0];
|
|
1289
|
+
if (winner === mine) {
|
|
1290
|
+
this.log('Won the _meta.json creation race', { mine, rivals: rivals.length });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
this.log('Lost the _meta.json creation race, adopting', winner);
|
|
1294
|
+
try {
|
|
1295
|
+
await this.client.deleteFile(mine);
|
|
1296
|
+
}
|
|
1297
|
+
catch (e) {
|
|
1298
|
+
this.log('Failed to remove duplicate _meta.json', mine, e);
|
|
741
1299
|
}
|
|
1300
|
+
const adopted = await this.readRemoteMeta();
|
|
1301
|
+
if (adopted)
|
|
1302
|
+
this.adoptMeta(adopted.meta, adopted.pointer);
|
|
742
1303
|
}
|
|
743
1304
|
// Reused helpers
|
|
744
1305
|
async findOrCreateFolder() {
|
|
@@ -750,6 +1311,72 @@ class DriveHandler {
|
|
|
750
1311
|
const createRes = await this.client.createFile(this.folderName, this.parents.length ? this.parents : undefined, 'application/vnd.google-apps.folder', '');
|
|
751
1312
|
return createRes.id;
|
|
752
1313
|
}
|
|
1314
|
+
/** Every change log in the folder, whatever _meta.json has to say about them.
|
|
1315
|
+
*
|
|
1316
|
+
* The folder is the authority on which change logs exist; _meta.json is only a
|
|
1317
|
+
* cache of that, and a lossy one - it is a whole-file read-modify-write with no
|
|
1318
|
+
* compare-and-swap behind it, so a writer whose metadata lands and is then
|
|
1319
|
+
* overwritten by a slower writer loses its reference. The file is still right
|
|
1320
|
+
* here. Listing for it is what stops a lost update from becoming a lost
|
|
1321
|
+
* document. */
|
|
1322
|
+
async listChangeLogs() {
|
|
1323
|
+
const q = `name contains 'changes-' and '${this.folderId}' in parents and trashed = false`;
|
|
1324
|
+
const files = await this.client.listFiles(q);
|
|
1325
|
+
return files.filter(f => f.name.startsWith('changes-')).map(f => ({ id: f.id, name: f.name }));
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Give up a sequence slot another writer is already using.
|
|
1329
|
+
*
|
|
1330
|
+
* Slots make sequence collisions structurally impossible only between writers on
|
|
1331
|
+
* *different* slots; two ids hashing to the same slot are back to the dense
|
|
1332
|
+
* allocation this scheme replaced. The filenames the folder listing hands us
|
|
1333
|
+
* carry every writer's id, so a contested slot is visible - and since this
|
|
1334
|
+
* handler re-rolls before minting anything against what it just saw, the
|
|
1335
|
+
* exposure shrinks from "the whole session" to "rival's first log not yet
|
|
1336
|
+
* visible in a listing".
|
|
1337
|
+
*
|
|
1338
|
+
* Logs already written keep their old name and numbers; ownLogIds tracks file
|
|
1339
|
+
* ids, not names, so nothing else cares.
|
|
1340
|
+
*/
|
|
1341
|
+
rerollIfSlotContested(logNames) {
|
|
1342
|
+
const rivalSlots = new Set();
|
|
1343
|
+
for (const name of logNames) {
|
|
1344
|
+
const id = writerIdFromLogName(name);
|
|
1345
|
+
if (id && id !== this.writerId)
|
|
1346
|
+
rivalSlots.add(writerSlotFor(id));
|
|
1347
|
+
}
|
|
1348
|
+
if (!rivalSlots.has(this.writerSlot))
|
|
1349
|
+
return;
|
|
1350
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
1351
|
+
const candidateId = newWriterId();
|
|
1352
|
+
const candidateSlot = writerSlotFor(candidateId);
|
|
1353
|
+
if (!rivalSlots.has(candidateSlot)) {
|
|
1354
|
+
this.log('Sequence slot contested, re-rolling writer id', {
|
|
1355
|
+
from: { writerId: this.writerId, slot: this.writerSlot },
|
|
1356
|
+
to: { writerId: candidateId, slot: candidateSlot }
|
|
1357
|
+
});
|
|
1358
|
+
this.writerId = candidateId;
|
|
1359
|
+
this.writerSlot = candidateSlot;
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// ~50 rivals colliding with 50 fresh rolls in a million-slot space does not
|
|
1364
|
+
// happen by chance; leave the slot alone rather than loop forever.
|
|
1365
|
+
this.log('Could not find a free sequence slot, keeping', this.writerSlot);
|
|
1366
|
+
}
|
|
1367
|
+
/** Every file in the folder with this name. Drive allows duplicates, so this is
|
|
1368
|
+
* how the callers that care (see ensureMetaFile) find out there are any. */
|
|
1369
|
+
async findFiles(name) {
|
|
1370
|
+
const safeName = this.escapeQuery(name);
|
|
1371
|
+
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
1372
|
+
const files = await this.client.listFiles(q);
|
|
1373
|
+
return files.map(file => ({
|
|
1374
|
+
fileId: file.id,
|
|
1375
|
+
etag: file.etag,
|
|
1376
|
+
md5Checksum: file.md5Checksum,
|
|
1377
|
+
modifiedTime: file.modifiedTime
|
|
1378
|
+
}));
|
|
1379
|
+
}
|
|
753
1380
|
async findFile(name) {
|
|
754
1381
|
const safeName = this.escapeQuery(name);
|
|
755
1382
|
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
@@ -785,16 +1412,25 @@ class DriveHandler {
|
|
|
785
1412
|
async writeChangeFile(changes) {
|
|
786
1413
|
const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
|
|
787
1414
|
const startSeq = changes[0].seq;
|
|
788
|
-
|
|
1415
|
+
// The writer id makes the name unique even when two clients do manage to
|
|
1416
|
+
// stamp the same starting sequence number, and says who wrote it.
|
|
1417
|
+
const name = `changes-${startSeq}-${this.writerId}-${Math.random().toString(36).substring(7)}.ndjson`;
|
|
789
1418
|
const res = await this.client.createFile(name, [this.folderId], 'application/x-ndjson', lines);
|
|
790
1419
|
this.currentLogSizeEstimate += new Blob([lines]).size;
|
|
791
1420
|
return res.id;
|
|
792
1421
|
}
|
|
793
|
-
|
|
1422
|
+
/** Write `meta` to _meta.json. `target` is the file to write, as already
|
|
1423
|
+
* located by the caller; pass null to force creation, or omit it to look the
|
|
1424
|
+
* file up. */
|
|
1425
|
+
async saveMeta(meta, target) {
|
|
794
1426
|
const content = JSON.stringify(meta);
|
|
795
|
-
const metaFile = await this.findFile('_meta.json');
|
|
1427
|
+
const metaFile = target !== undefined ? target : await this.findFile('_meta.json');
|
|
796
1428
|
if (metaFile) {
|
|
797
|
-
|
|
1429
|
+
// If-Match is a no-op against Drive v3, which dropped ETags - it still
|
|
1430
|
+
// guards the emulated server and costs nothing, but nothing here may
|
|
1431
|
+
// assume it was enforced. See the commitMeta block above.
|
|
1432
|
+
const res = await this.client.updateFile(metaFile.fileId, content, metaFile.etag || undefined);
|
|
1433
|
+
this.metaFileId = metaFile.fileId;
|
|
798
1434
|
this.metaEtag = res.etag;
|
|
799
1435
|
this.metaMd5 = res.md5Checksum || null;
|
|
800
1436
|
this.metaModifiedTime = res.modifiedTime;
|
|
@@ -802,16 +1438,18 @@ class DriveHandler {
|
|
|
802
1438
|
}
|
|
803
1439
|
else {
|
|
804
1440
|
const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
|
|
1441
|
+
this.metaFileId = res.id;
|
|
805
1442
|
this.metaEtag = res.etag;
|
|
806
1443
|
this.metaMd5 = res.md5Checksum || null;
|
|
807
1444
|
this.metaModifiedTime = res.modifiedTime;
|
|
808
1445
|
}
|
|
809
1446
|
}
|
|
810
1447
|
async countTotalChanges() {
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1448
|
+
// Count change-log files, not changes. This used to return meta.seq when there
|
|
1449
|
+
// was no snapshot yet, on the reasoning that the counter and the change count
|
|
1450
|
+
// were the same number. Sequence numbers now carry a writer slot in their low
|
|
1451
|
+
// digits (see SEQ_SLOTS), so meta.seq is about a million times the tick and
|
|
1452
|
+
// would trigger a compaction on the very first write.
|
|
815
1453
|
// Each log file ID in changeLogIds represents some number of changes.
|
|
816
1454
|
// For simplicity and to trigger compaction based on file count (which is what matters for Drive),
|
|
817
1455
|
// we can return the number of log files.
|
|
@@ -844,14 +1482,28 @@ class DriveHandler {
|
|
|
844
1482
|
await deleteFile(id);
|
|
845
1483
|
}
|
|
846
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Watch _meta.json for writes by other clients.
|
|
1487
|
+
*
|
|
1488
|
+
* This is the only thing that makes `db.changes({ live: true })` fire for a
|
|
1489
|
+
* *remote* write: on a change it calls load(), which replays the newly
|
|
1490
|
+
* referenced logs and emits exactly those through notifyListeners. Without it a
|
|
1491
|
+
* client only ever hears about what it wrote itself, so connect-and-read works
|
|
1492
|
+
* and continuous sync between two connected clients does not.
|
|
1493
|
+
*
|
|
1494
|
+
* Change is detected by md5Checksum, falling back to modifiedTime. There is
|
|
1495
|
+
* deliberately no ETag comparison: Drive API v3 has none (see
|
|
1496
|
+
* docs/adr/0001-metadata-writes-without-compare-and-swap.md), so that branch
|
|
1497
|
+
* could only ever compare '' against '' - and sitting first in the chain, it
|
|
1498
|
+
* shadowed the two comparisons that do work.
|
|
1499
|
+
*/
|
|
847
1500
|
startPolling(intervalMs) {
|
|
848
|
-
this.log('Starting polling with interval', { intervalMs });
|
|
849
1501
|
if (isNaN(intervalMs) || intervalMs <= 0)
|
|
850
1502
|
return;
|
|
851
1503
|
if (this.pollingInterval)
|
|
852
|
-
|
|
1504
|
+
return; // already watching
|
|
1505
|
+
this.log('Starting polling with interval', { intervalMs });
|
|
853
1506
|
this.pollingInterval = setInterval(async () => {
|
|
854
|
-
this.log('Polling tick...');
|
|
855
1507
|
if (this.isPollingActive) {
|
|
856
1508
|
this.log('Polling already in progress, skipping tick');
|
|
857
1509
|
return;
|
|
@@ -863,27 +1515,16 @@ class DriveHandler {
|
|
|
863
1515
|
this.log('Polling: _meta.json not found');
|
|
864
1516
|
return;
|
|
865
1517
|
}
|
|
866
|
-
// Compare etags, falling back to md5Checksum or modifiedTime
|
|
867
|
-
const remoteEtag = metaFile.etag;
|
|
868
1518
|
const remoteMd5 = metaFile.md5Checksum;
|
|
869
1519
|
const remoteModified = metaFile.modifiedTime;
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
if (remoteEtag !== this.metaEtag)
|
|
874
|
-
changed = true;
|
|
875
|
-
}
|
|
876
|
-
else if (remoteMd5 && this.metaMd5) {
|
|
877
|
-
if (remoteMd5 !== this.metaMd5)
|
|
878
|
-
changed = true;
|
|
879
|
-
}
|
|
880
|
-
else if (remoteModified !== this.metaModifiedTime) {
|
|
881
|
-
changed = true;
|
|
882
|
-
}
|
|
1520
|
+
const changed = remoteMd5 && this.metaMd5
|
|
1521
|
+
? remoteMd5 !== this.metaMd5
|
|
1522
|
+
: remoteModified !== this.metaModifiedTime;
|
|
883
1523
|
if (changed) {
|
|
884
|
-
this.log('Polling detected change
|
|
1524
|
+
this.log('Polling detected change', { remoteMd5, remoteModified });
|
|
1525
|
+
// load() emits precisely what it replayed. Announcing the whole
|
|
1526
|
+
// index instead is what used to send PouchDB sync in circles.
|
|
885
1527
|
await this.load();
|
|
886
|
-
this.notifyListeners();
|
|
887
1528
|
}
|
|
888
1529
|
}
|
|
889
1530
|
catch (err) {
|