mindoodb 0.0.47 → 0.0.49
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/README-server.md +39 -3
- package/dist/appendonlystores/network/HttpTransport.d.ts +14 -0
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +58 -39
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/RequestScheduler.d.ts +65 -0
- package/dist/appendonlystores/network/RequestScheduler.d.ts.map +1 -0
- package/dist/appendonlystores/network/RequestScheduler.js +147 -0
- package/dist/appendonlystores/network/RequestScheduler.js.map +1 -0
- package/dist/appendonlystores/network/index.d.ts +1 -0
- package/dist/appendonlystores/network/index.d.ts.map +1 -1
- package/dist/appendonlystores/network/index.js +6 -1
- package/dist/appendonlystores/network/index.js.map +1 -1
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +15 -6
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -1
- package/dist/browser/cache/IndexedDBLocalCacheStore.js +85 -26
- package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +3 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/index.mjs +2 -0
- package/dist/core/BaseMindooDB.d.ts +127 -0
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +514 -51
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +8 -1
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts +10 -0
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
- package/dist/core/expressions/builder.d.ts +1 -0
- package/dist/core/expressions/builder.d.ts.map +1 -1
- package/dist/core/expressions/builder.js +3 -0
- package/dist/core/expressions/builder.js.map +1 -1
- package/dist/core/expressions/evaluateExpression.d.ts +2 -0
- package/dist/core/expressions/evaluateExpression.d.ts.map +1 -1
- package/dist/core/expressions/evaluateExpression.js +2 -0
- package/dist/core/expressions/evaluateExpression.js.map +1 -1
- package/dist/core/expressions/formulaSource.d.ts.map +1 -1
- package/dist/core/expressions/formulaSource.js +1 -0
- package/dist/core/expressions/formulaSource.js.map +1 -1
- package/dist/core/expressions/metadata.d.ts +1 -1
- package/dist/core/expressions/metadata.d.ts.map +1 -1
- package/dist/core/expressions/metadata.js +1 -0
- package/dist/core/expressions/metadata.js.map +1 -1
- package/dist/core/expressions/types.d.ts +1 -1
- package/dist/core/expressions/types.d.ts.map +1 -1
- package/dist/core/indexing/createViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/createViewDataProvider.js +1 -0
- package/dist/core/indexing/createViewDataProvider.js.map +1 -1
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js +1 -0
- package/dist/core/indexing/summary/SummaryVirtualViewDataProvider.js.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +1 -0
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
- package/dist/core/query/executeQuery.d.ts.map +1 -1
- package/dist/core/query/executeQuery.js +2 -0
- package/dist/core/query/executeQuery.js.map +1 -1
- package/dist/core/types.d.ts +19 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/node/server/MindooDBServer.d.ts +51 -5
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +164 -40
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/node/server/config.d.ts.map +1 -1
- package/dist/node/server/config.js +4 -0
- package/dist/node/server/config.js.map +1 -1
- package/dist/node/server/rateLimits.d.ts +46 -0
- package/dist/node/server/rateLimits.d.ts.map +1 -0
- package/dist/node/server/rateLimits.js +50 -0
- package/dist/node/server/rateLimits.js.map +1 -0
- package/dist/node/server/types.d.ts +15 -0
- package/dist/node/server/types.d.ts.map +1 -1
- package/dist/node/server/types.js +9 -0
- package/dist/node/server/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -10,6 +10,7 @@ const uuid_1 = require("uuid");
|
|
|
10
10
|
const types_1 = require("./types");
|
|
11
11
|
const types_2 = require("./appendonlystores/types");
|
|
12
12
|
const bloom_1 = require("./appendonlystores/bloom");
|
|
13
|
+
const types_3 = require("./appendonlystores/network/types");
|
|
13
14
|
const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
|
|
14
15
|
const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
|
|
15
16
|
const DocumentConflictAnalysis_1 = require("./DocumentConflictAnalysis");
|
|
@@ -26,10 +27,10 @@ const AccessDeniedError_1 = require("./accesscontrol/AccessDeniedError");
|
|
|
26
27
|
const logging_1 = require("./logging");
|
|
27
28
|
const databaseIdValidation_1 = require("./databaseIdValidation");
|
|
28
29
|
const DocumentSummaryStore_1 = require("./indexing/summary/DocumentSummaryStore");
|
|
29
|
-
const
|
|
30
|
+
const types_4 = require("./indexing/summary/types");
|
|
30
31
|
const DocumentFullTextIndex_1 = require("./indexing/fulltext/DocumentFullTextIndex");
|
|
31
|
-
const
|
|
32
|
-
const
|
|
32
|
+
const types_5 = require("./indexing/fulltext/types");
|
|
33
|
+
const types_6 = require("./extraction/types");
|
|
33
34
|
const executeQuery_1 = require("./query/executeQuery");
|
|
34
35
|
const queryView_1 = require("./query/queryView");
|
|
35
36
|
const queryLive_1 = require("./query/queryLive");
|
|
@@ -43,7 +44,12 @@ const DEFAULT_ATTACHMENT_STREAM_BATCH_SIZE = 4;
|
|
|
43
44
|
const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
|
|
44
45
|
const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
|
|
45
46
|
const DEFAULT_MAX_CACHED_DOCS = 128;
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Documents materialized ahead during changefeed iteration. Kept well below
|
|
49
|
+
* {@link DEFAULT_MAX_CACHED_DOCS} so a window cannot evict itself out of L1
|
|
50
|
+
* before the iteration reaches it.
|
|
51
|
+
*/
|
|
52
|
+
const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 32;
|
|
47
53
|
const DEFAULT_WARMER_BATCH_SIZE = 50;
|
|
48
54
|
/**
|
|
49
55
|
* Default number of cached documents fetched per `getMany` batch during
|
|
@@ -54,6 +60,62 @@ const DEFAULT_WARMER_BATCH_SIZE = 50;
|
|
|
54
60
|
* Tunable via {@link DocumentCacheConfig.restoreBatchSize}.
|
|
55
61
|
*/
|
|
56
62
|
const DEFAULT_RESTORE_BATCH_SIZE = 256;
|
|
63
|
+
/**
|
|
64
|
+
* Cap for {@link BaseMindooDB.scannedEntryMetadata}, counted in retained
|
|
65
|
+
* metadata entries (not documents). Metadata is small (~300 bytes), so this
|
|
66
|
+
* is a few tens of MB at the ceiling; past it, materialization falls back to
|
|
67
|
+
* the per-document scan it would have done anyway.
|
|
68
|
+
*/
|
|
69
|
+
const MAX_SCANNED_ENTRY_METADATA_ENTRIES = 200000;
|
|
70
|
+
/**
|
|
71
|
+
* Encrypted payload bytes per batched entry fetch
|
|
72
|
+
* ({@link BaseMindooDB.prefetchEntriesForDocuments}). Bounds response size
|
|
73
|
+
* and peak memory; the count limit below stays far under the server's
|
|
74
|
+
* per-request id cap, so bytes are the binding constraint in practice.
|
|
75
|
+
*/
|
|
76
|
+
const MATERIALIZATION_PREFETCH_PAGE_BYTES = 8 * 1024 * 1024;
|
|
77
|
+
const MATERIALIZATION_PREFETCH_MAX_IDS = 5000;
|
|
78
|
+
/**
|
|
79
|
+
* Documents in a prefetch window without retained metadata that justify one
|
|
80
|
+
* bulk metadata scan of the whole store
|
|
81
|
+
* ({@link BaseMindooDB.warmScannedEntryMetadataFromStore}). A store-wide scan
|
|
82
|
+
* costs one request per 1000 entries, so it repays itself after a handful of
|
|
83
|
+
* per-document scans.
|
|
84
|
+
*/
|
|
85
|
+
const METADATA_WARMUP_MIN_UNCOVERED_DOCS = 8;
|
|
86
|
+
/**
|
|
87
|
+
* Backoff before retrying a rate-limited prefetch request when the server
|
|
88
|
+
* sends no `Retry-After`. The number of entries also caps the retry count.
|
|
89
|
+
*/
|
|
90
|
+
const PREFETCH_RATE_LIMIT_DELAYS_MS = [250, 1000, 3000];
|
|
91
|
+
/** Upper bound for an honoured `Retry-After`, so a prefetch cannot hang. */
|
|
92
|
+
const PREFETCH_MAX_RATE_LIMIT_DELAY_MS = 10000;
|
|
93
|
+
/**
|
|
94
|
+
* Read the type off a transport error by shape rather than `instanceof`:
|
|
95
|
+
* the error crosses module and bundle boundaries, where prototype identity is
|
|
96
|
+
* not reliable. Returns null for anything that is not a `NetworkError`.
|
|
97
|
+
*/
|
|
98
|
+
function networkErrorType(error) {
|
|
99
|
+
const candidate = error;
|
|
100
|
+
if (!candidate || candidate.name !== "NetworkError" || typeof candidate.type !== "string") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return candidate.type;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* How long to wait before retrying a rate-limited request, or null when the
|
|
107
|
+
* error is not a rate limit or the retries are exhausted. Honours the
|
|
108
|
+
* server's `Retry-After` when it asks for a longer wait than our backoff.
|
|
109
|
+
*/
|
|
110
|
+
function rateLimitRetryDelayMs(error, attempt) {
|
|
111
|
+
if (networkErrorType(error) !== types_3.NetworkErrorType.RATE_LIMITED ||
|
|
112
|
+
attempt >= PREFETCH_RATE_LIMIT_DELAYS_MS.length) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const retryAfterMs = error.retryAfterMs;
|
|
116
|
+
const requested = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) ? retryAfterMs : 0;
|
|
117
|
+
return Math.min(Math.max(requested, PREFETCH_RATE_LIMIT_DELAYS_MS[attempt]), PREFETCH_MAX_RATE_LIMIT_DELAY_MS);
|
|
118
|
+
}
|
|
57
119
|
/**
|
|
58
120
|
* Default {@link WarmerScheduler}: a simple `setTimeout(0)` yield that
|
|
59
121
|
* works in Node, browsers, and React Native without pulling in any
|
|
@@ -190,6 +252,39 @@ class BaseMindooDB {
|
|
|
190
252
|
* below {@link snapshotMinChanges}. Absent key = unknown (scan once).
|
|
191
253
|
*/
|
|
192
254
|
this.writesSinceSnapshotCheck = new Map();
|
|
255
|
+
/**
|
|
256
|
+
* Full entry metadata per document, kept from the sync walk that already
|
|
257
|
+
* read it, so the following materialization does not scan the store for
|
|
258
|
+
* the same document again. On a network-backed store that scan is an HTTP
|
|
259
|
+
* request per document — the dominant cost of a cold open.
|
|
260
|
+
*
|
|
261
|
+
* Only ever holds a document's *complete* history: the sync path fills it
|
|
262
|
+
* from the unfiltered cold-sync batch or from an explicit per-doc scan,
|
|
263
|
+
* both of which are complete by construction. A partial history here would
|
|
264
|
+
* materialize a document from a truncated DAG.
|
|
265
|
+
*
|
|
266
|
+
* Invalidated by {@link updateIndex} (every path that writes entries for a
|
|
267
|
+
* document goes through it) and consumed on first read, since the
|
|
268
|
+
* materialized document then lives in L1/L2.
|
|
269
|
+
*/
|
|
270
|
+
this.scannedEntryMetadata = new Map();
|
|
271
|
+
/** Retained entry count across {@link scannedEntryMetadata}. */
|
|
272
|
+
this.scannedEntryMetadataSize = 0;
|
|
273
|
+
/**
|
|
274
|
+
* One bulk metadata scan per instance
|
|
275
|
+
* ({@link warmScannedEntryMetadataFromStore}); set before the scan runs so
|
|
276
|
+
* a failed or oversized attempt is not repeated on every window.
|
|
277
|
+
*/
|
|
278
|
+
this.scannedEntryMetadataWarmed = false;
|
|
279
|
+
/**
|
|
280
|
+
* Entries fetched ahead for a batch of documents about to be materialized
|
|
281
|
+
* (see {@link prefetchEntriesForDocuments}). Read through
|
|
282
|
+
* {@link fetchEntries}, which falls back to the store for anything the
|
|
283
|
+
* buffer does not hold — a partial buffer is always safe.
|
|
284
|
+
*
|
|
285
|
+
* Non-null only for the duration of one prefetch batch.
|
|
286
|
+
*/
|
|
287
|
+
this.materializationEntryBuffer = null;
|
|
193
288
|
// Track which entry IDs we've already processed
|
|
194
289
|
this.processedEntryIds = [];
|
|
195
290
|
this.processedEntryCursor = null;
|
|
@@ -278,6 +373,15 @@ class BaseMindooDB {
|
|
|
278
373
|
// common choke point of all mutation paths, so it only records pending
|
|
279
374
|
// docIds; emission is coalesced per sync batch / event-loop turn.
|
|
280
375
|
// ---------------------------------------------------------------------------
|
|
376
|
+
/**
|
|
377
|
+
* Single-flight for {@link syncStoreChanges}. `processedEntryCursor`
|
|
378
|
+
* only advances once a run finishes, so overlapping callers would
|
|
379
|
+
* otherwise walk the identical cursor sequence — on a remote store that
|
|
380
|
+
* is one duplicated `scanEntriesSince` request per caller and page.
|
|
381
|
+
*/
|
|
382
|
+
this.syncStoreChangesInFlight = null;
|
|
383
|
+
/** Shared follow-up run for callers that arrive mid-sync. */
|
|
384
|
+
this.syncStoreChangesQueued = null;
|
|
281
385
|
this.changeListeners = new Set();
|
|
282
386
|
this.pendingChangeNotifications = new Map();
|
|
283
387
|
this.changeNotifyTimer = null;
|
|
@@ -373,8 +477,16 @@ class BaseMindooDB {
|
|
|
373
477
|
this._isAdminOnlyDb = adminOnlyDb;
|
|
374
478
|
this.timeTravelDate = timeTravelDate;
|
|
375
479
|
this.maxCachedDocs = Math.max(1, Math.floor(documentCacheConfig?.maxEntries ?? DEFAULT_MAX_CACHED_DOCS));
|
|
376
|
-
|
|
480
|
+
const requestedPrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
|
|
377
481
|
DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
|
|
482
|
+
// A window refills with up to half a window of runway left, so it can
|
|
483
|
+
// reach 1.5x its size ahead of the cursor. Keep that under the L1 cache,
|
|
484
|
+
// or the prefetch evicts the documents it just materialized and iteration
|
|
485
|
+
// fetches them a second time.
|
|
486
|
+
this.iteratePrefetchWindowDocs =
|
|
487
|
+
requestedPrefetchWindowDocs > 0
|
|
488
|
+
? Math.max(1, Math.min(requestedPrefetchWindowDocs, Math.floor(this.maxCachedDocs / 2)))
|
|
489
|
+
: 0;
|
|
378
490
|
this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
|
|
379
491
|
this.cacheRestoreBatchSize = Math.max(1, Math.floor(documentCacheConfig?.restoreBatchSize ?? DEFAULT_RESTORE_BATCH_SIZE));
|
|
380
492
|
this.reconcileRestoredIndexOnInit = documentCacheConfig?.reconcileRestoredIndexOnInit ?? false;
|
|
@@ -535,8 +647,8 @@ class BaseMindooDB {
|
|
|
535
647
|
*/
|
|
536
648
|
async getSummarySetup() {
|
|
537
649
|
try {
|
|
538
|
-
const doc = await this.getDocument(
|
|
539
|
-
return (0,
|
|
650
|
+
const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
|
|
651
|
+
return (0, types_4.sanitizeSummaryConfig)(doc.getData()[types_4.SUMMARY_SETUP_FIELD]) ?? null;
|
|
540
652
|
}
|
|
541
653
|
catch (error) {
|
|
542
654
|
if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
|
|
@@ -557,14 +669,14 @@ class BaseMindooDB {
|
|
|
557
669
|
* backfill — locally and, after sync, on every other replica.
|
|
558
670
|
*/
|
|
559
671
|
async setSummarySetup(config) {
|
|
560
|
-
const doc = await this.createDocument({ id:
|
|
672
|
+
const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
|
|
561
673
|
await this.changeDoc(doc, (d) => {
|
|
562
674
|
const data = d.getData();
|
|
563
675
|
if (config === null) {
|
|
564
|
-
delete data[
|
|
676
|
+
delete data[types_4.SUMMARY_SETUP_FIELD];
|
|
565
677
|
}
|
|
566
678
|
else {
|
|
567
|
-
data[
|
|
679
|
+
data[types_4.SUMMARY_SETUP_FIELD] = (0, types_4.sanitizeSummaryConfig)(config) ?? {};
|
|
568
680
|
}
|
|
569
681
|
});
|
|
570
682
|
}
|
|
@@ -606,7 +718,7 @@ class BaseMindooDB {
|
|
|
606
718
|
if (this._isAdminOnlyDb || this.store.getId() === "directory") {
|
|
607
719
|
return;
|
|
608
720
|
}
|
|
609
|
-
const hasSetupChange = changes.some((change) => change.docId ===
|
|
721
|
+
const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
|
|
610
722
|
if (!hasSetupChange && this.summarySetupProbed) {
|
|
611
723
|
return;
|
|
612
724
|
}
|
|
@@ -693,8 +805,8 @@ class BaseMindooDB {
|
|
|
693
805
|
*/
|
|
694
806
|
async getFulltextSetup() {
|
|
695
807
|
try {
|
|
696
|
-
const doc = await this.getDocument(
|
|
697
|
-
return (0,
|
|
808
|
+
const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
|
|
809
|
+
return (0, types_5.sanitizeFulltextConfig)(doc.getData()[types_5.FULLTEXT_SETUP_FIELD]) ?? null;
|
|
698
810
|
}
|
|
699
811
|
catch (error) {
|
|
700
812
|
if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
|
|
@@ -717,19 +829,19 @@ class BaseMindooDB {
|
|
|
717
829
|
* resumable backfill — locally and, after sync, on every other replica.
|
|
718
830
|
*/
|
|
719
831
|
async setFulltextSetup(config) {
|
|
720
|
-
const sanitized = config === null ? null : (0,
|
|
832
|
+
const sanitized = config === null ? null : (0, types_5.sanitizeFulltextConfig)(config) ?? {};
|
|
721
833
|
const current = await this.getFulltextSetup();
|
|
722
834
|
if (JSON.stringify(current) === JSON.stringify(sanitized)) {
|
|
723
835
|
return;
|
|
724
836
|
}
|
|
725
|
-
const doc = await this.createDocument({ id:
|
|
837
|
+
const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
|
|
726
838
|
await this.changeDoc(doc, (d) => {
|
|
727
839
|
const data = d.getData();
|
|
728
840
|
if (sanitized === null) {
|
|
729
|
-
delete data[
|
|
841
|
+
delete data[types_5.FULLTEXT_SETUP_FIELD];
|
|
730
842
|
}
|
|
731
843
|
else {
|
|
732
|
-
data[
|
|
844
|
+
data[types_5.FULLTEXT_SETUP_FIELD] = sanitized;
|
|
733
845
|
}
|
|
734
846
|
});
|
|
735
847
|
}
|
|
@@ -743,8 +855,8 @@ class BaseMindooDB {
|
|
|
743
855
|
*/
|
|
744
856
|
async getExtractionSetup() {
|
|
745
857
|
try {
|
|
746
|
-
const doc = await this.getDocument(
|
|
747
|
-
return (0,
|
|
858
|
+
const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
|
|
859
|
+
return (0, types_6.sanitizeExtractionConfig)(doc.getData()[types_6.EXTRACTION_SETUP_FIELD]) ?? null;
|
|
748
860
|
}
|
|
749
861
|
catch (error) {
|
|
750
862
|
if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
|
|
@@ -763,19 +875,19 @@ class BaseMindooDB {
|
|
|
763
875
|
* without producing changefeed/sync churn.
|
|
764
876
|
*/
|
|
765
877
|
async setExtractionSetup(config) {
|
|
766
|
-
const sanitized = config === null ? null : (0,
|
|
878
|
+
const sanitized = config === null ? null : (0, types_6.sanitizeExtractionConfig)(config) ?? {};
|
|
767
879
|
const current = await this.getExtractionSetup();
|
|
768
880
|
if (JSON.stringify(current) === JSON.stringify(sanitized)) {
|
|
769
881
|
return;
|
|
770
882
|
}
|
|
771
|
-
const doc = await this.createDocument({ id:
|
|
883
|
+
const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
|
|
772
884
|
await this.changeDoc(doc, (d) => {
|
|
773
885
|
const data = d.getData();
|
|
774
886
|
if (sanitized === null) {
|
|
775
|
-
delete data[
|
|
887
|
+
delete data[types_6.EXTRACTION_SETUP_FIELD];
|
|
776
888
|
}
|
|
777
889
|
else {
|
|
778
|
-
data[
|
|
890
|
+
data[types_6.EXTRACTION_SETUP_FIELD] = sanitized;
|
|
779
891
|
}
|
|
780
892
|
});
|
|
781
893
|
}
|
|
@@ -841,7 +953,7 @@ class BaseMindooDB {
|
|
|
841
953
|
if (this._isAdminOnlyDb || this.store.getId() === "directory") {
|
|
842
954
|
return;
|
|
843
955
|
}
|
|
844
|
-
const hasSetupChange = changes.some((change) => change.docId ===
|
|
956
|
+
const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
|
|
845
957
|
if (!hasSetupChange && this.fulltextSetupProbed) {
|
|
846
958
|
return;
|
|
847
959
|
}
|
|
@@ -1621,6 +1733,9 @@ class BaseMindooDB {
|
|
|
1621
1733
|
*/
|
|
1622
1734
|
updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false, forceChangeSeqBump = false) {
|
|
1623
1735
|
const startedAt = Date.now();
|
|
1736
|
+
// Every path that writes entries for a document lands here, so this is
|
|
1737
|
+
// where retained metadata for it goes stale (see scannedEntryMetadata).
|
|
1738
|
+
this.forgetScannedEntryMetadata(docId);
|
|
1624
1739
|
const assignedSeq = this.nextChangeSeq;
|
|
1625
1740
|
const newEntry = {
|
|
1626
1741
|
docId,
|
|
@@ -2065,6 +2180,7 @@ class BaseMindooDB {
|
|
|
2065
2180
|
this.dirtyDocIds.delete(docId);
|
|
2066
2181
|
this.lastFlushedDocState.delete(docId);
|
|
2067
2182
|
this.automergeHashToEntryId.delete(docId);
|
|
2183
|
+
this.forgetScannedEntryMetadata(docId);
|
|
2068
2184
|
// Plaintext summary values must not outlive the purge either.
|
|
2069
2185
|
this.summaryStore?.removeDocument(docId);
|
|
2070
2186
|
// Nor extracted plaintext tokens in the full-text index.
|
|
@@ -2384,6 +2500,7 @@ class BaseMindooDB {
|
|
|
2384
2500
|
this.docCache.clear();
|
|
2385
2501
|
this.lastFlushedDocState.clear();
|
|
2386
2502
|
this.automergeHashToEntryId.clear();
|
|
2503
|
+
this.clearScannedEntryMetadata();
|
|
2387
2504
|
this.processedEntryIds = [];
|
|
2388
2505
|
this.processedEntryCursor = null;
|
|
2389
2506
|
this.nextChangeSeq = 1;
|
|
@@ -2403,22 +2520,50 @@ class BaseMindooDB {
|
|
|
2403
2520
|
* sync page) must call {@link startBackgroundWarmer} explicitly so
|
|
2404
2521
|
* casual sync calls stay cheap and the warmer cost is paid only when
|
|
2405
2522
|
* the workload is about to benefit from it.
|
|
2523
|
+
*
|
|
2524
|
+
* Overlapping callers are coalesced: at most one run is in flight and
|
|
2525
|
+
* at most one follow-up run is queued behind it (see
|
|
2526
|
+
* {@link syncStoreChangesInFlight}).
|
|
2406
2527
|
*/
|
|
2407
2528
|
async syncStoreChanges() {
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2529
|
+
if (this.syncStoreChangesInFlight) {
|
|
2530
|
+
if (!this.syncStoreChangesQueued) {
|
|
2531
|
+
// The running scan may have started before the entries this caller
|
|
2532
|
+
// is waiting for were written, so it gets a follow-up run rather
|
|
2533
|
+
// than the in-flight one. The follow-up starts regardless of how
|
|
2534
|
+
// the current run ended, so a failed run does not strand it.
|
|
2535
|
+
this.syncStoreChangesQueued = this.syncStoreChangesInFlight
|
|
2536
|
+
.then(() => undefined, () => undefined)
|
|
2537
|
+
.then(() => {
|
|
2538
|
+
this.syncStoreChangesQueued = null;
|
|
2539
|
+
return this.syncStoreChanges();
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
return this.syncStoreChangesQueued;
|
|
2416
2543
|
}
|
|
2544
|
+
const run = (async () => {
|
|
2545
|
+
// Hold change-notification emission for the whole batch so listeners
|
|
2546
|
+
// receive one coalesced event per sync run instead of one per document.
|
|
2547
|
+
this.beginChangeNotificationHold();
|
|
2548
|
+
try {
|
|
2549
|
+
await this.syncStoreChangesInternal();
|
|
2550
|
+
}
|
|
2551
|
+
finally {
|
|
2552
|
+
this.endChangeNotificationHold();
|
|
2553
|
+
}
|
|
2554
|
+
})();
|
|
2555
|
+
this.syncStoreChangesInFlight = run.finally(() => {
|
|
2556
|
+
this.syncStoreChangesInFlight = null;
|
|
2557
|
+
});
|
|
2558
|
+
return this.syncStoreChangesInFlight;
|
|
2417
2559
|
}
|
|
2418
2560
|
async syncStoreChangesInternal() {
|
|
2419
2561
|
const syncStartedAt = Date.now();
|
|
2420
2562
|
this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
|
|
2421
2563
|
this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
|
|
2564
|
+
// Cold sync already fetched every entry via the unfiltered scan; reusing that
|
|
2565
|
+
// per-doc batch avoids N× scanEntriesSince({ docId }) (HTTP storms on remote).
|
|
2566
|
+
const coldSync = this.processedEntryCursor == null && this.processedEntryIds.length === 0;
|
|
2422
2567
|
// Find new entries that we haven't processed yet
|
|
2423
2568
|
const { entries: newEntryMetadata, nextCursor } = await this.getNewEntryMetadataForSync();
|
|
2424
2569
|
this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
|
|
@@ -2506,7 +2651,12 @@ class BaseMindooDB {
|
|
|
2506
2651
|
this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
|
|
2507
2652
|
}
|
|
2508
2653
|
else {
|
|
2509
|
-
|
|
2654
|
+
// Cold sync: entryMetadataList is already the full doc history from
|
|
2655
|
+
// the unfiltered scan. Incremental sync of an uncached doc may only
|
|
2656
|
+
// have recent entries, so re-scan that doc for prior lifecycle state.
|
|
2657
|
+
const allDocMetadata = coldSync
|
|
2658
|
+
? entryMetadataList
|
|
2659
|
+
: await this.scanAllMetadata(this.store, { docId });
|
|
2510
2660
|
const mutationEntries = allDocMetadata.filter((e) => this.isDocumentReplayEntry(e));
|
|
2511
2661
|
// Snapshots compact replay history but should not change the user-visible
|
|
2512
2662
|
// modification time when the original create/change/delete/undelete entries still exist.
|
|
@@ -2519,6 +2669,12 @@ class BaseMindooDB {
|
|
|
2519
2669
|
// changes nothing observable and may keep the idempotent skip.
|
|
2520
2670
|
const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e));
|
|
2521
2671
|
this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed, batchHasReplayEntries);
|
|
2672
|
+
// `allDocMetadata` is this document's complete history in both
|
|
2673
|
+
// branches above. Keeping it lets the materialization that
|
|
2674
|
+
// follows (summary, full-text, a UI read) skip its own per-doc
|
|
2675
|
+
// scan — one HTTP request each on a network-backed store.
|
|
2676
|
+
// Must come after updateIndex, which invalidates the entry.
|
|
2677
|
+
this.rememberScannedEntryMetadata(docId, allDocMetadata);
|
|
2522
2678
|
this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted}, awaitingWitness: ${awaitingWitness})`);
|
|
2523
2679
|
}
|
|
2524
2680
|
}
|
|
@@ -2636,6 +2792,143 @@ class BaseMindooDB {
|
|
|
2636
2792
|
supportsCursorScan(store) {
|
|
2637
2793
|
return typeof store.scanEntriesSince === "function";
|
|
2638
2794
|
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Retain a document's complete entry metadata for the materialization that
|
|
2797
|
+
* usually follows. Callers must pass the full history — see
|
|
2798
|
+
* {@link scannedEntryMetadata}.
|
|
2799
|
+
*
|
|
2800
|
+
* Skipped for time-travel instances: their scans are cutoff-scoped, and an
|
|
2801
|
+
* ad-hoc historical open does not repay the extra invariant.
|
|
2802
|
+
*/
|
|
2803
|
+
rememberScannedEntryMetadata(docId, metadata) {
|
|
2804
|
+
if (this.timeTravelDate != null || metadata.length === 0) {
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
// A complete history starts at a create entry, or at a snapshot that
|
|
2808
|
+
// compacted one away. Anything else means the scan this came from was
|
|
2809
|
+
// truncated, and materializing a document from a truncated DAG yields a
|
|
2810
|
+
// wrong document rather than a slow one — drop it and let the
|
|
2811
|
+
// per-document scan do the work.
|
|
2812
|
+
const hasHistoryBase = metadata.some((entry) => entry.entryType === "doc_create" || entry.entryType === "doc_snapshot");
|
|
2813
|
+
if (!hasHistoryBase) {
|
|
2814
|
+
this.logger.warn(`Not retaining entry metadata for ${docId}: no doc_create or doc_snapshot among ${metadata.length} entries`);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
this.forgetScannedEntryMetadata(docId);
|
|
2818
|
+
if (this.scannedEntryMetadataSize + metadata.length >
|
|
2819
|
+
MAX_SCANNED_ENTRY_METADATA_ENTRIES) {
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
this.scannedEntryMetadata.set(docId, metadata);
|
|
2823
|
+
this.scannedEntryMetadataSize += metadata.length;
|
|
2824
|
+
}
|
|
2825
|
+
/** Read the retained metadata without consuming it (batch planning). */
|
|
2826
|
+
peekScannedEntryMetadata(docId) {
|
|
2827
|
+
return this.scannedEntryMetadata.get(docId) ?? null;
|
|
2828
|
+
}
|
|
2829
|
+
/** Consume the retained metadata for a document, if any is held. */
|
|
2830
|
+
takeScannedEntryMetadata(docId) {
|
|
2831
|
+
const metadata = this.scannedEntryMetadata.get(docId);
|
|
2832
|
+
if (!metadata) {
|
|
2833
|
+
return null;
|
|
2834
|
+
}
|
|
2835
|
+
this.forgetScannedEntryMetadata(docId);
|
|
2836
|
+
return metadata;
|
|
2837
|
+
}
|
|
2838
|
+
forgetScannedEntryMetadata(docId) {
|
|
2839
|
+
const existing = this.scannedEntryMetadata.get(docId);
|
|
2840
|
+
if (!existing) {
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
this.scannedEntryMetadata.delete(docId);
|
|
2844
|
+
this.scannedEntryMetadataSize -= existing.length;
|
|
2845
|
+
}
|
|
2846
|
+
clearScannedEntryMetadata() {
|
|
2847
|
+
this.scannedEntryMetadata.clear();
|
|
2848
|
+
this.scannedEntryMetadataSize = 0;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Fill {@link scannedEntryMetadata} for the whole database with a single
|
|
2852
|
+
* paged scan.
|
|
2853
|
+
*
|
|
2854
|
+
* The sync walk only retains metadata for the documents it processed, so an
|
|
2855
|
+
* open that restored its index from the cache checkpoint starts with an
|
|
2856
|
+
* empty map — and a pass over the changefeed then scans once per document.
|
|
2857
|
+
* One store-wide scan replaces all of those: it costs a request per 1000
|
|
2858
|
+
* entries instead of a request per document.
|
|
2859
|
+
*
|
|
2860
|
+
* All-or-nothing by necessity. The scan is ordered by receipt order, not by
|
|
2861
|
+
* document, so a run stopped halfway would leave arbitrary documents with a
|
|
2862
|
+
* partial history — and a partial history materializes a wrong document.
|
|
2863
|
+
* If the store holds more entries than the cap allows, nothing is retained
|
|
2864
|
+
* and the per-document path stays in charge.
|
|
2865
|
+
*/
|
|
2866
|
+
async warmScannedEntryMetadataFromStore() {
|
|
2867
|
+
if (this.scannedEntryMetadataWarmed ||
|
|
2868
|
+
this.timeTravelDate != null ||
|
|
2869
|
+
!this.supportsCursorScan(this.store)) {
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
this.scannedEntryMetadataWarmed = true;
|
|
2873
|
+
const startedAt = Date.now();
|
|
2874
|
+
const byDoc = new Map();
|
|
2875
|
+
let scannedEntries = 0;
|
|
2876
|
+
let cursor = null;
|
|
2877
|
+
const filters = this.mergeTimeTravelScanFilters(undefined);
|
|
2878
|
+
while (true) {
|
|
2879
|
+
const page = await this.withRateLimitBackoff("metadata warm-up scan", () => this.store.scanEntriesSince(cursor, 1000, filters));
|
|
2880
|
+
for (const entry of page.entries) {
|
|
2881
|
+
const existing = byDoc.get(entry.docId);
|
|
2882
|
+
if (existing) {
|
|
2883
|
+
existing.push(entry);
|
|
2884
|
+
}
|
|
2885
|
+
else {
|
|
2886
|
+
byDoc.set(entry.docId, [entry]);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
scannedEntries += page.entries.length;
|
|
2890
|
+
if (scannedEntries > MAX_SCANNED_ENTRY_METADATA_ENTRIES) {
|
|
2891
|
+
this.logger.debug(`Metadata warm-up skipped: store holds more than ${MAX_SCANNED_ENTRY_METADATA_ENTRIES} entries`);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
if (!page.hasMore || !page.nextCursor) {
|
|
2895
|
+
break;
|
|
2896
|
+
}
|
|
2897
|
+
cursor = page.nextCursor;
|
|
2898
|
+
}
|
|
2899
|
+
for (const [docId, metadata] of byDoc) {
|
|
2900
|
+
// Cached documents will not be materialized from this metadata.
|
|
2901
|
+
if (this.docCache.has(docId)) {
|
|
2902
|
+
continue;
|
|
2903
|
+
}
|
|
2904
|
+
this.rememberScannedEntryMetadata(docId, metadata);
|
|
2905
|
+
}
|
|
2906
|
+
// Logged at info: the entries-per-document ratio here is the quickest
|
|
2907
|
+
// answer to "does this database have the history I expect?".
|
|
2908
|
+
this.logger.info(`Metadata warm-up scanned ${scannedEntries} entries and retained ` +
|
|
2909
|
+
`${this.scannedEntryMetadataSize} for ${this.scannedEntryMetadata.size} ` +
|
|
2910
|
+
`document(s) in ${Date.now() - startedAt}ms`);
|
|
2911
|
+
}
|
|
2912
|
+
/**
|
|
2913
|
+
* Run a prefetch request, waiting out a rate limit instead of giving up on
|
|
2914
|
+
* it. Every other failure propagates to the caller, which treats a failed
|
|
2915
|
+
* prefetch as a miss and falls back to the per-document path.
|
|
2916
|
+
*/
|
|
2917
|
+
async withRateLimitBackoff(description, run) {
|
|
2918
|
+
for (let attempt = 0;; attempt++) {
|
|
2919
|
+
try {
|
|
2920
|
+
return await run();
|
|
2921
|
+
}
|
|
2922
|
+
catch (error) {
|
|
2923
|
+
const delayMs = rateLimitRetryDelayMs(error, attempt);
|
|
2924
|
+
if (delayMs == null) {
|
|
2925
|
+
throw error;
|
|
2926
|
+
}
|
|
2927
|
+
this.logger.debug(`${description} rate limited; retrying in ${delayMs}ms (attempt ${attempt + 1})`);
|
|
2928
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2639
2932
|
async scanAllMetadata(store, filters) {
|
|
2640
2933
|
const effectiveFilters = this.mergeTimeTravelScanFilters(filters);
|
|
2641
2934
|
if (!this.supportsCursorScan(store)) {
|
|
@@ -7975,15 +8268,146 @@ class BaseMindooDB {
|
|
|
7975
8268
|
this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
|
|
7976
8269
|
}
|
|
7977
8270
|
}
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
8271
|
+
/**
|
|
8272
|
+
* Load entries for a materialization, serving whatever
|
|
8273
|
+
* {@link materializationEntryBuffer} already holds and fetching the rest.
|
|
8274
|
+
* Results keep the requested id order.
|
|
8275
|
+
*/
|
|
8276
|
+
async fetchEntries(ids) {
|
|
8277
|
+
const buffer = this.materializationEntryBuffer;
|
|
8278
|
+
if (!buffer || buffer.size === 0 || ids.length === 0) {
|
|
8279
|
+
return this.store.getEntries(ids);
|
|
8280
|
+
}
|
|
8281
|
+
const missingIds = ids.filter((id) => !buffer.has(id));
|
|
8282
|
+
const fetched = missingIds.length > 0 ? await this.store.getEntries(missingIds) : [];
|
|
8283
|
+
const fetchedById = new Map(fetched.map((entry) => [entry.id, entry]));
|
|
8284
|
+
const result = [];
|
|
8285
|
+
for (const id of ids) {
|
|
8286
|
+
const entry = buffer.get(id) ?? fetchedById.get(id);
|
|
8287
|
+
if (entry) {
|
|
8288
|
+
result.push(entry);
|
|
8289
|
+
}
|
|
8290
|
+
}
|
|
8291
|
+
return result;
|
|
8292
|
+
}
|
|
8293
|
+
/**
|
|
8294
|
+
* Fetch one page of a batched prefetch: waits out a rate limit, and splits
|
|
8295
|
+
* the page when the server rejects it as too large. The estimate that built
|
|
8296
|
+
* the page comes from `encryptedSize` metadata, so it can be wrong about
|
|
8297
|
+
* the framing overhead — halving is cheaper than guessing conservatively.
|
|
8298
|
+
*/
|
|
8299
|
+
async fetchEntryPage(ids) {
|
|
8300
|
+
try {
|
|
8301
|
+
return await this.withRateLimitBackoff("entry prefetch", () => this.store.getEntries(ids));
|
|
8302
|
+
}
|
|
8303
|
+
catch (error) {
|
|
8304
|
+
if (networkErrorType(error) !== types_3.NetworkErrorType.PAYLOAD_TOO_LARGE ||
|
|
8305
|
+
ids.length <= 1) {
|
|
8306
|
+
throw error;
|
|
8307
|
+
}
|
|
8308
|
+
const half = Math.ceil(ids.length / 2);
|
|
8309
|
+
this.logger.debug(`Entry prefetch page of ${ids.length} rejected as too large; splitting`);
|
|
8310
|
+
return [
|
|
8311
|
+
...(await this.fetchEntryPage(ids.slice(0, half))),
|
|
8312
|
+
...(await this.fetchEntryPage(ids.slice(half))),
|
|
8313
|
+
];
|
|
8314
|
+
}
|
|
8315
|
+
}
|
|
8316
|
+
/**
|
|
8317
|
+
* Fetch the entries a window of documents will need in a few large calls
|
|
8318
|
+
* instead of two or three per document. On a network-backed store that is
|
|
8319
|
+
* the difference between tens of requests and tens of thousands for a cold
|
|
8320
|
+
* pass over the changefeed.
|
|
8321
|
+
*
|
|
8322
|
+
* Plans locally from the metadata the sync walk retained
|
|
8323
|
+
* ({@link scannedEntryMetadata}); documents without retained metadata are
|
|
8324
|
+
* left out of the batch and materialize on their own. Pages are bounded by
|
|
8325
|
+
* encrypted payload bytes so peak memory stays predictable.
|
|
8326
|
+
*
|
|
8327
|
+
* Returns the buffer instead of installing it, so the caller controls its
|
|
8328
|
+
* lifetime.
|
|
8329
|
+
*/
|
|
8330
|
+
async prefetchEntriesForDocuments(docIds) {
|
|
8331
|
+
const wantedSizes = new Map();
|
|
8332
|
+
for (const docId of docIds) {
|
|
8333
|
+
const metadata = this.peekScannedEntryMetadata(docId);
|
|
8334
|
+
if (!metadata) {
|
|
8335
|
+
continue;
|
|
8336
|
+
}
|
|
8337
|
+
const plan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, metadata);
|
|
8338
|
+
const sizeById = new Map(metadata.map((entry) => [entry.id, entry.encryptedSize ?? 0]));
|
|
8339
|
+
const planned = plan.snapshotEntryId
|
|
8340
|
+
? [plan.snapshotEntryId, ...plan.entryIdsToApply]
|
|
8341
|
+
: plan.entryIdsToApply;
|
|
8342
|
+
for (const id of planned) {
|
|
8343
|
+
if (!wantedSizes.has(id)) {
|
|
8344
|
+
wantedSizes.set(id, sizeById.get(id) ?? 0);
|
|
8345
|
+
}
|
|
8346
|
+
}
|
|
8347
|
+
}
|
|
8348
|
+
if (wantedSizes.size === 0) {
|
|
8349
|
+
return null;
|
|
8350
|
+
}
|
|
8351
|
+
const buffer = new Map();
|
|
8352
|
+
let page = [];
|
|
8353
|
+
let pageBytes = 0;
|
|
8354
|
+
const flushPage = async () => {
|
|
8355
|
+
if (page.length === 0) {
|
|
8356
|
+
return;
|
|
8357
|
+
}
|
|
8358
|
+
for (const entry of await this.fetchEntryPage(page)) {
|
|
8359
|
+
buffer.set(entry.id, entry);
|
|
8360
|
+
}
|
|
8361
|
+
page = [];
|
|
8362
|
+
pageBytes = 0;
|
|
8363
|
+
};
|
|
8364
|
+
for (const [id, size] of wantedSizes) {
|
|
8365
|
+
// A single oversized entry still gets its own page: the budget bounds
|
|
8366
|
+
// a batch, never one document.
|
|
8367
|
+
if (page.length > 0 &&
|
|
8368
|
+
(pageBytes + size > MATERIALIZATION_PREFETCH_PAGE_BYTES ||
|
|
8369
|
+
page.length >= MATERIALIZATION_PREFETCH_MAX_IDS)) {
|
|
8370
|
+
await flushPage();
|
|
8371
|
+
}
|
|
8372
|
+
page.push(id);
|
|
8373
|
+
pageBytes += size;
|
|
8374
|
+
}
|
|
8375
|
+
await flushPage();
|
|
8376
|
+
return buffer;
|
|
8377
|
+
}
|
|
8378
|
+
/**
|
|
8379
|
+
* Materialize a window of upcoming documents so iteration reads them from
|
|
8380
|
+
* the cache instead of the store.
|
|
8381
|
+
*
|
|
8382
|
+
* Called after every yielded document, so it must be cheap when the runway
|
|
8383
|
+
* ahead is still full: `prefetchedThrough` is the index position the
|
|
8384
|
+
* previous window reached, and a call that still has half a window of
|
|
8385
|
+
* runway left returns without touching the store. Without that check the
|
|
8386
|
+
* pass issues a fetch per yielded document, and — because each call would
|
|
8387
|
+
* scan past the cached documents to find a full window of uncached ones —
|
|
8388
|
+
* it also runs unboundedly ahead of the cursor, materializing the whole
|
|
8389
|
+
* changefeed tail and evicting it from L1 before iteration arrives.
|
|
8390
|
+
*
|
|
8391
|
+
* Returns the position prefetched through, which the caller threads back in.
|
|
8392
|
+
*/
|
|
8393
|
+
async prefetchIterationWindow(indexSnapshot, startIndex, prefetchedThrough, idPrefix) {
|
|
8394
|
+
const windowSize = this.iteratePrefetchWindowDocs;
|
|
8395
|
+
if (windowSize <= 0) {
|
|
8396
|
+
return { prefetchedThrough: startIndex, prefetchedDocuments: 0 };
|
|
8397
|
+
}
|
|
8398
|
+
const from = Math.max(startIndex, prefetchedThrough);
|
|
8399
|
+
if (from - startIndex >= Math.ceil(windowSize / 2)) {
|
|
8400
|
+
// Enough runway left; refilling now would trade one batched fetch per
|
|
8401
|
+
// window for one per document.
|
|
8402
|
+
return { prefetchedThrough, prefetchedDocuments: 0 };
|
|
7981
8403
|
}
|
|
7982
8404
|
const docIds = [];
|
|
7983
8405
|
const seen = new Set();
|
|
7984
|
-
//
|
|
8406
|
+
// Look ahead a bounded number of uncached docs so iteration does not
|
|
7985
8407
|
// materialize the entire remaining changefeed tail up front.
|
|
7986
|
-
|
|
8408
|
+
let scannedThrough = from;
|
|
8409
|
+
for (let i = from; i < indexSnapshot.length && docIds.length < windowSize; i++) {
|
|
8410
|
+
scannedThrough = i + 1;
|
|
7987
8411
|
const entry = indexSnapshot[i];
|
|
7988
8412
|
// Skip docs that iterateChangesSince will emit as lightweight
|
|
7989
8413
|
// tombstones anyway: inaccessible docs are never materialized, and
|
|
@@ -8003,15 +8427,42 @@ class BaseMindooDB {
|
|
|
8003
8427
|
docIds.push(docId);
|
|
8004
8428
|
}
|
|
8005
8429
|
if (docIds.length === 0) {
|
|
8006
|
-
return 0;
|
|
8430
|
+
return { prefetchedThrough: scannedThrough, prefetchedDocuments: 0 };
|
|
8007
8431
|
}
|
|
8008
8432
|
// Prefetch is opportunistic: iteration can still fall back to on-demand
|
|
8009
8433
|
// loads, so individual prefetch failures are logged and ignored here.
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8434
|
+
// The same holds for the batched entry fetch — a failure there just means
|
|
8435
|
+
// the loads below go to the store themselves.
|
|
8436
|
+
try {
|
|
8437
|
+
// Documents the sync walk did not process have no retained metadata, so
|
|
8438
|
+
// they would each scan the store on their own and the batch below would
|
|
8439
|
+
// have nothing to plan from. A window full of them means a bulk pass is
|
|
8440
|
+
// under way (a warm open with a cold document cache); one store-wide
|
|
8441
|
+
// scan covers all of it.
|
|
8442
|
+
const uncoveredDocs = docIds.filter((docId) => !this.peekScannedEntryMetadata(docId)).length;
|
|
8443
|
+
if (uncoveredDocs >= METADATA_WARMUP_MIN_UNCOVERED_DOCS) {
|
|
8444
|
+
await this.warmScannedEntryMetadataFromStore();
|
|
8445
|
+
}
|
|
8446
|
+
this.materializationEntryBuffer =
|
|
8447
|
+
await this.prefetchEntriesForDocuments(docIds);
|
|
8448
|
+
}
|
|
8449
|
+
catch (err) {
|
|
8450
|
+
this.logger.warn(`Failed to batch-fetch entries for prefetch window:`, err);
|
|
8451
|
+
this.materializationEntryBuffer = null;
|
|
8452
|
+
}
|
|
8453
|
+
try {
|
|
8454
|
+
await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
|
|
8455
|
+
this.logger.warn(`Failed to prefetch document ${docId}:`, err);
|
|
8456
|
+
return null;
|
|
8457
|
+
})));
|
|
8458
|
+
}
|
|
8459
|
+
finally {
|
|
8460
|
+
this.materializationEntryBuffer = null;
|
|
8461
|
+
}
|
|
8462
|
+
return {
|
|
8463
|
+
prefetchedThrough: scannedThrough,
|
|
8464
|
+
prefetchedDocuments: docIds.length,
|
|
8465
|
+
};
|
|
8015
8466
|
}
|
|
8016
8467
|
getStartIndexForCursor(indexSnapshot, actualCursor) {
|
|
8017
8468
|
let startIndex = 0;
|
|
@@ -8403,7 +8854,16 @@ class BaseMindooDB {
|
|
|
8403
8854
|
// Find starting position using binary search
|
|
8404
8855
|
// We want to find the first entry that is greater than the cursor.
|
|
8405
8856
|
const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
|
|
8406
|
-
let prefetchedDocuments =
|
|
8857
|
+
let prefetchedDocuments = 0;
|
|
8858
|
+
// How far ahead the prefetch windows have reached. Kept per generator run
|
|
8859
|
+
// so concurrent iterations do not share a runway.
|
|
8860
|
+
let prefetchedThrough = startIndex;
|
|
8861
|
+
const prefetchFrom = async (from) => {
|
|
8862
|
+
const result = await this.prefetchIterationWindow(indexSnapshot, from, prefetchedThrough, idPrefix);
|
|
8863
|
+
prefetchedThrough = result.prefetchedThrough;
|
|
8864
|
+
prefetchedDocuments += result.prefetchedDocuments;
|
|
8865
|
+
};
|
|
8866
|
+
await prefetchFrom(startIndex);
|
|
8407
8867
|
let yieldedDocuments = 0;
|
|
8408
8868
|
let loadedDocuments = 0;
|
|
8409
8869
|
try {
|
|
@@ -8428,7 +8888,7 @@ class BaseMindooDB {
|
|
|
8428
8888
|
};
|
|
8429
8889
|
yieldedDocuments++;
|
|
8430
8890
|
yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
|
|
8431
|
-
|
|
8891
|
+
await prefetchFrom(i + 1);
|
|
8432
8892
|
continue;
|
|
8433
8893
|
}
|
|
8434
8894
|
if (entry.isDeleted) {
|
|
@@ -8445,7 +8905,7 @@ class BaseMindooDB {
|
|
|
8445
8905
|
};
|
|
8446
8906
|
yieldedDocuments++;
|
|
8447
8907
|
yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
|
|
8448
|
-
|
|
8908
|
+
await prefetchFrom(i + 1);
|
|
8449
8909
|
continue;
|
|
8450
8910
|
}
|
|
8451
8911
|
try {
|
|
@@ -8475,7 +8935,7 @@ class BaseMindooDB {
|
|
|
8475
8935
|
// Deleted documents are included so external indexes can handle deletions
|
|
8476
8936
|
yieldedDocuments++;
|
|
8477
8937
|
yield { doc, cursor: currentCursor };
|
|
8478
|
-
|
|
8938
|
+
await prefetchFrom(i + 1);
|
|
8479
8939
|
}
|
|
8480
8940
|
catch (error) {
|
|
8481
8941
|
this.logger.error(`Error processing document ${entry.docId}:`, error);
|
|
@@ -9567,7 +10027,10 @@ class BaseMindooDB {
|
|
|
9567
10027
|
// TODO: Implement loading from last snapshot if available
|
|
9568
10028
|
this.logger.debug(`Getting all entry hashes for document ${docId}`);
|
|
9569
10029
|
const storeQueryStartedAt = Date.now();
|
|
9570
|
-
|
|
10030
|
+
// The sync walk may already have read this document's full metadata; on a
|
|
10031
|
+
// network-backed store that saves one request per document.
|
|
10032
|
+
const allEntryMetadata = this.takeScannedEntryMetadata(docId) ??
|
|
10033
|
+
(await this.scanAllMetadata(this.store, { docId }));
|
|
9571
10034
|
storeQueryTime += Date.now() - storeQueryStartedAt;
|
|
9572
10035
|
this.logger.debug(`Found ${allEntryMetadata.length} total entry hashes for document ${docId}`);
|
|
9573
10036
|
if (allEntryMetadata.length === 0) {
|
|
@@ -9661,7 +10124,7 @@ class BaseMindooDB {
|
|
|
9661
10124
|
if (startFromSnapshot && snapshotMeta) {
|
|
9662
10125
|
this.logger.debug(`Loading snapshot for document ${docId}`);
|
|
9663
10126
|
const snapshotLoadStartedAt = Date.now();
|
|
9664
|
-
const snapshotEntries = await this.
|
|
10127
|
+
const snapshotEntries = await this.fetchEntries([snapshotMeta.id]);
|
|
9665
10128
|
entryLoadTime += Date.now() - snapshotLoadStartedAt;
|
|
9666
10129
|
this.logger.debug(`Retrieved ${snapshotEntries.length} snapshot entry(s) from store`);
|
|
9667
10130
|
if (snapshotEntries.length > 0) {
|
|
@@ -9729,7 +10192,7 @@ class BaseMindooDB {
|
|
|
9729
10192
|
// Load and apply all entries
|
|
9730
10193
|
this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
|
|
9731
10194
|
const entryLoadStartedAt = Date.now();
|
|
9732
|
-
const entries = await this.
|
|
10195
|
+
const entries = await this.fetchEntries(entriesToLoad.map(em => em.id));
|
|
9733
10196
|
entryLoadTime += Date.now() - entryLoadStartedAt;
|
|
9734
10197
|
this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
|
|
9735
10198
|
this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);
|