sliftutils 1.7.121 → 1.7.122
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/dist/treeSummary.ts.cache +2 -2
- package/index.d.ts +74 -7
- package/misc/dist/environment.ts.cache +2 -2
- package/misc/dist/getSecret.ts.cache +2 -2
- package/misc/dist/strings.ts.cache +2 -2
- package/misc/dist/zip.ts.cache +2 -2
- package/misc/https/dist/certs.ts.cache +2 -2
- package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
- package/package.json +2 -2
- package/render-utils/dist/observer.tsx.cache +2 -2
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +14 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +15 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +57 -5
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +288 -137
- package/storage/BulkDatabase2/LoadedIndex.d.ts +1 -0
- package/storage/BulkDatabase2/LoadedIndex.ts +2 -1
- package/storage/BulkDatabase2/blockCache.ts +6 -1
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +257 -162
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +8 -3
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +30 -4
- package/storage/BulkDatabase2/syncClient.d.ts +2 -0
- package/storage/BulkDatabase2/syncClient.ts +27 -2
- package/storage/PrivateFileSystemStorage.ts +43 -6
- package/storage/dist/ArchivesDisk.ts.cache +2 -2
- package/storage/dist/FileFolderAPI.tsx.cache +2 -2
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
- package/storage/dist/JSONStorage.ts.cache +2 -2
- package/storage/dist/PendingManager.tsx.cache +2 -2
- package/storage/dist/PrivateFileSystemStorage.ts.cache +43 -8
- package/storage/dist/StreamingLogs.ts.cache +2 -2
- package/storage/dist/TransactionFile.ts.cache +2 -2
- package/storage/dist/TransactionStorage.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +2 -2
- package/storage/dist/fileSystemPointer.ts.cache +2 -2
- package/storage/dist/remoteFileStorage.ts.cache +2 -2
- package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +2 -2
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +2 -2
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +2 -2
- package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +2 -2
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +2 -2
- package/storage/remoteStorage/dist/certTrustModal.tsx.cache +2 -2
- package/storage/remoteStorage/dist/chainStartup.ts.cache +2 -2
- package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
- package/storage/remoteStorage/dist/createArchives.ts.cache +2 -2
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +2 -2
- package/storage/remoteStorage/dist/intermediateManagement.ts.cache +2 -2
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +2 -2
- package/storage/remoteStorage/dist/serverConfig.ts.cache +2 -2
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +2 -2
- package/storage/remoteStorage/dist/sourcesList.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageLogs.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageServerState.ts.cache +2 -2
- package/storage/remoteStorage/dist/storeConfig.ts.cache +2 -2
- package/storage/remoteStorage/dist/storePlan.ts.cache +2 -2
- package/storage/remoteStorage/dist/storeSources.ts.cache +2 -2
- package/storage/remoteStorage/dist/storeSync.ts.cache +2 -2
- package/storage/remoteStorage/dist/validation.ts.cache +2 -2
- package/yarn.lock +4 -4
|
@@ -16,7 +16,7 @@ import { blockCache, encodeCompressedBlocks } from "./blockCache";
|
|
|
16
16
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
17
17
|
import { blue, magenta } from "socket-function/src/formatting/logColors";
|
|
18
18
|
import { STREAM_EXTENSION, frameDeletes, frameRows, streamReaderFromEntries } from "./streamLog";
|
|
19
|
-
import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
19
|
+
import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, queryLiveWriters, registerWriterId, RemoteWrite } from "./syncClient";
|
|
20
20
|
import { DELETED } from "./WriteOverlay";
|
|
21
21
|
import { MergeLockInfo, peekMergeFileLock, peekMergeLock, releaseMergeFileLock, releaseMergeLock, startMergeFileLockHeartbeat, tryAcquireMergeFileLock, tryAcquireMergeLock } from "./mergeLock";
|
|
22
22
|
import { markerExclusions, processDeleteMarkers, readDeleteMarkers, writeDeleteMarker } from "./mergeMarkers";
|
|
@@ -40,10 +40,9 @@ const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
|
40
40
|
const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
|
|
41
41
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
42
42
|
const MAX_INDEX_RELOAD_ATTEMPTS = 3;
|
|
43
|
-
const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
|
|
44
43
|
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
45
44
|
const DUP_THRESHOLD = 0.4;
|
|
46
|
-
// Whole-tier dedup short-circuit (
|
|
45
|
+
// Whole-tier dedup short-circuit (start of phase 3): when the combined tier is over this size AND the overall key duplication fraction is over this threshold, fold every combined file in one merge instead of the per-key-group walk (which spaces merges 5 min apart — 16 h for 200 groups).
|
|
47
46
|
const DEDUP_TRIGGER_BYTES = 512 * 1024 * 1024;
|
|
48
47
|
const DEDUP_TRIGGER_FRACTION = 0.5;
|
|
49
48
|
const WRITE_FLUSH_FIRST_STEP_MS = 250;
|
|
@@ -55,11 +54,13 @@ export const bulkDatabase2Timing = {
|
|
|
55
54
|
// Wait this long after the tab becomes visible before the first merge check, then check this often afterwards. Tab being hidden cancels the timers (no merges while in background). Browser-only; Node compactors (e.g. remoteFileServer) drive their own polling.
|
|
56
55
|
visibleMergeIntervalMs: 5 * 60 * 1000,
|
|
57
56
|
mergeSpacingMs: 5 * 60 * 1000,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// Phase 2 rolls the loose bulk files up once they pass either of these. Bytes because read cost grows with them; count because each loose file holds its whole key list in memory no matter how few bytes it has, so a trickle of tiny folds has to be collapsed long before it reaches the byte trigger.
|
|
58
|
+
looseBulkTriggerBytes: 1024 * 1024 * 1024,
|
|
59
|
+
looseBulkTriggerFiles: 20,
|
|
61
60
|
streamFoldTriggerBytes: 64 * 1024 * 1024,
|
|
62
61
|
streamFileMaxBytes: 50 * 1024 * 1024,
|
|
62
|
+
// How long to wait for peers to answer a stream-owner liveness probe. Runs inside a merge pass, which already waits 15s for the file lock, so a second here costs nothing.
|
|
63
|
+
liveWriterProbeMs: 1000,
|
|
63
64
|
streamFoldHardLimitBytes: 768 * 1024 * 1024,
|
|
64
65
|
// 0 = flush every write (Node — append is real and cheap); browser ramps to 15s to avoid rewriting the whole stream file per write.
|
|
65
66
|
writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
|
|
@@ -120,8 +121,11 @@ export type MergeAttemptResult = {
|
|
|
120
121
|
let networkCompactionEnabled = false;
|
|
121
122
|
|
|
122
123
|
let fileNameCounter = 0;
|
|
123
|
-
// Per-process ID so two writers picking the same timestamp + counter never collide on a name.
|
|
124
|
+
// Per-process ID so two writers picking the same timestamp + counter never collide on a name. Also stamped into our stream file names and answered over the sync channel, so a peer can tell whether the writer of a given stream file is still running.
|
|
124
125
|
const writerId = Math.random().toString(36).slice(2, 10);
|
|
126
|
+
registerWriterId(writerId);
|
|
127
|
+
// Reserved owner for the tombstone-carry file a merge emits. Nothing ever appends to it, but it must NOT read as abandoned: folding it would emit another carry file, which would read as abandoned in turn, and the pass would fold forever.
|
|
128
|
+
const MERGE_OUTPUT_OWNER = "merged";
|
|
125
129
|
function nextCounter(): number { return ++fileNameCounter; }
|
|
126
130
|
|
|
127
131
|
let lastFileTime = 0;
|
|
@@ -135,13 +139,19 @@ function newFileName(timestamp: number): string {
|
|
|
135
139
|
return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
// Accept old 3-part (stream_timestamp_random) and new 4-part (stream_timestamp_ownerId_counter) shapes. A 3-part name carries no owner, so it can never be matched to a live writer — which is what we want: no build that produces those names is still appending to them.
|
|
138
143
|
function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
|
|
139
144
|
if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
|
|
140
145
|
const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
|
|
141
|
-
if (parts
|
|
146
|
+
if (parts[0] !== "stream") return undefined;
|
|
147
|
+
if (parts.length !== 3 && parts.length !== 4) return undefined;
|
|
142
148
|
const timestamp = parseInt(parts[1], 10);
|
|
143
149
|
if (!Number.isFinite(timestamp)) return undefined;
|
|
144
|
-
return { fileName, timestamp };
|
|
150
|
+
return { fileName, timestamp, ownerId: parts.length === 4 && parts[2] || undefined };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function newStreamFileName(ownerId: string): string {
|
|
154
|
+
return `stream_${Date.now()}_${ownerId}_${nextCounter()}${STREAM_EXTENSION}`;
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
// Accept old 3-part (level_timestamp_counter) and new 4-part (level_timestamp_writerId_counter) shapes.
|
|
@@ -155,6 +165,57 @@ function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
|
155
165
|
return { fileName, level, timestamp };
|
|
156
166
|
}
|
|
157
167
|
|
|
168
|
+
/** One threshold a compaction step is measured against. `value` is where the collection stands now and `threshold` is what sets the step off, so `fraction` (value/threshold) reads as how close it is - 1 or more means met. Deliberately not clamped, so an overdue step reads as how far past due it is. */
|
|
169
|
+
export type CompactionTrigger = {
|
|
170
|
+
name: string;
|
|
171
|
+
value: number;
|
|
172
|
+
threshold: number;
|
|
173
|
+
fraction: number;
|
|
174
|
+
met: boolean;
|
|
175
|
+
/** How to render value/threshold. "fraction" values are 0..1. */
|
|
176
|
+
unit: "bytes" | "count" | "fraction";
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** streamHardLimit and streamFold are phase 1 (stream -> bulk), looseCombine is phase 2 (loose bulk -> combined bulk), dedupAll and dedupKeyGroup are phase 3. */
|
|
180
|
+
export type CompactionStepKind = "streamHardLimit" | "streamFold" | "looseCombine" | "dedupAll" | "dedupKeyGroup";
|
|
181
|
+
|
|
182
|
+
export type CompactionStep = {
|
|
183
|
+
phase: 1 | 2 | 3;
|
|
184
|
+
kind: CompactionStepKind;
|
|
185
|
+
/** Whether this step runs on the next pass. Authoritative: on top of `requires` it accounts for inputs the step needs beyond its thresholds (e.g. two files to combine), so it can be false even with every trigger met. */
|
|
186
|
+
ready: boolean;
|
|
187
|
+
/** Whether every trigger has to be met for this step, or just one of them. */
|
|
188
|
+
requires: "any" | "all";
|
|
189
|
+
triggers: CompactionTrigger[];
|
|
190
|
+
/** When this step's merge starts, given merges are spaced mergeSpacingMs apart. Only set when ready. */
|
|
191
|
+
startTime?: number;
|
|
192
|
+
/** The files this step consumes, as of when the plan was made. */
|
|
193
|
+
bulkFiles: BulkFileInfo[];
|
|
194
|
+
streamFiles: StreamFileInfo[];
|
|
195
|
+
/** Total size of those inputs. */
|
|
196
|
+
bytes: number;
|
|
197
|
+
/** dedupKeyGroup only - the key range the step rewrites. */
|
|
198
|
+
keyRange?: { lo: string; hi: string };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Every compaction the current file set calls for, in the order a merge pass runs them, plus how close each not-yet-ready one is to its thresholds. */
|
|
202
|
+
export type CompactionPlan = {
|
|
203
|
+
collection: string;
|
|
204
|
+
/** When the plan was computed; every startTime is measured from here. */
|
|
205
|
+
time: number;
|
|
206
|
+
steps: CompactionStep[];
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function makeTrigger(config: { name: string; value: number; threshold: number; unit: CompactionTrigger["unit"] }): CompactionTrigger {
|
|
210
|
+
return { ...config, fraction: config.value / config.threshold, met: config.value >= config.threshold };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function fmtTriggerValue(value: number, unit: CompactionTrigger["unit"]): string {
|
|
214
|
+
if (unit === "bytes") return fmtBytes(value);
|
|
215
|
+
if (unit === "fraction") return `${Math.round(value * 100)}%`;
|
|
216
|
+
return formatNumber(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
158
219
|
export class BulkDatabaseBase<T extends { key: string }> {
|
|
159
220
|
constructor(
|
|
160
221
|
public readonly name: string,
|
|
@@ -229,7 +290,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
229
290
|
|
|
230
291
|
private subCaches: SubReaderCaches = { bulk: new Map(), stream: new Map() };
|
|
231
292
|
|
|
232
|
-
private pendingAppends:
|
|
293
|
+
private pendingAppends: Buffer[] = [];
|
|
233
294
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
234
295
|
private flushChain: Promise<void> = Promise.resolve();
|
|
235
296
|
private currentFlushDelay = 0;
|
|
@@ -241,8 +302,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
241
302
|
// In-process re-entry guard: if a merge is running, additional triggers (visibility timer, writes, tryMergeNow calls) return immediately instead of queueing. Keeps the visibility-timer firings from stacking behind a long merge.
|
|
242
303
|
private mergeInFlight = false;
|
|
243
304
|
private lastMergeSkipLogMs = 0;
|
|
244
|
-
// Running
|
|
245
|
-
private streamRowsOnDisk = 0;
|
|
305
|
+
// Running counter of stream-tier bytes on disk. Seeded from each LoadedIndex build, then incremented per flush so the fold-trigger checks current data without an extra directory listing.
|
|
246
306
|
private streamBytesOnDisk = 0;
|
|
247
307
|
|
|
248
308
|
private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -298,9 +358,36 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
298
358
|
return !!(await this.storage()).isRemote;
|
|
299
359
|
}
|
|
300
360
|
|
|
361
|
+
// Uncompacted stream bytes have to be read and decoded in full by every reader on every index build, so size alone decides — a fold is worth it even if those bytes are one enormous row.
|
|
301
362
|
private streamNeedsFold(): boolean {
|
|
302
|
-
return this.
|
|
303
|
-
|
|
363
|
+
return this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Stream files nobody can still be appending to, split by who owned them:
|
|
367
|
+
// retirable - a fold may delete these the moment it has consumed them, instead of waiting out streamSealAgeMs. Includes our own sealed files and merge-carry output.
|
|
368
|
+
// ownerGone - nobody is left to fold these (a closed tab's leftovers, or a legacy name with no owner stamp), and until one is folded every reader has to decode all of it just to build the index. Worth folding at any size. Our own files are deliberately NOT in here: every merge pass seals ours, so counting them would fold on every tick; the streamFileMaxBytes rollover and the streamFoldTriggerBytes gate cover ours instead.
|
|
369
|
+
//
|
|
370
|
+
// Foreign owners are probed over the sync channel. In Node there is no channel, so we cannot know - every foreign owner is assumed alive and the streamSealAgeMs rule stands.
|
|
371
|
+
//
|
|
372
|
+
// assumeSealed is for planning: a merge pass broadcasts a seal before it starts, so by the time it merges our current file IS final. The planner passes isSyncSupported() to predict that; anything deciding a real deletion passes false and goes by streamFileName as it actually stands.
|
|
373
|
+
private async findAbandonedStreams(streamFiles: StreamFileInfo[], assumeSealed: boolean): Promise<{ retirable: Set<string>; ownerGone: Set<string> }> {
|
|
374
|
+
const retirable = new Set<string>();
|
|
375
|
+
const ownerGone = new Set<string>();
|
|
376
|
+
const hasForeignOwner = streamFiles.some(f => f.ownerId && f.ownerId !== writerId && f.ownerId !== MERGE_OUTPUT_OWNER);
|
|
377
|
+
const live = hasForeignOwner && await queryLiveWriters(this.name, bulkDatabase2Timing.liveWriterProbeMs) || undefined;
|
|
378
|
+
for (const f of streamFiles) {
|
|
379
|
+
if (f.ownerId === MERGE_OUTPUT_OWNER) { retirable.add(f.fileName); continue; }
|
|
380
|
+
// streamFileName is the only "we will append here again" signal - once it moves on, getStreamFileName opens a fresh file and this one is final.
|
|
381
|
+
if (f.ownerId === writerId) {
|
|
382
|
+
if (assumeSealed || f.fileName !== this.streamFileName) retirable.add(f.fileName);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!f.ownerId || live && !live.has(f.ownerId)) {
|
|
386
|
+
retirable.add(f.fileName);
|
|
387
|
+
ownerGone.add(f.fileName);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { retirable, ownerGone };
|
|
304
391
|
}
|
|
305
392
|
|
|
306
393
|
private async automaticCompactionAllowed(): Promise<boolean> {
|
|
@@ -356,7 +443,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
356
443
|
});
|
|
357
444
|
const oldIndex = this.reader.index;
|
|
358
445
|
this.reader.setIndex(newIndex, { dropStaleFallback: this.rebuildOptions.dropStaleFallback });
|
|
359
|
-
this.streamRowsOnDisk = newIndex.streamRowsOnDisk;
|
|
360
446
|
this.streamBytesOnDisk = newIndex.streamBytesOnDisk;
|
|
361
447
|
if (oldIndex) {
|
|
362
448
|
for (const f of oldIndex.fileSet) {
|
|
@@ -437,7 +523,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
437
523
|
for (const { time, row } of stamped) this.reader.applyWrite(row.key as string, row, time);
|
|
438
524
|
});
|
|
439
525
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
440
|
-
await this.streamAppend(framed
|
|
526
|
+
await this.streamAppend(framed);
|
|
441
527
|
void this.maybeMerge({ onlyIfStreamHeavy: true });
|
|
442
528
|
}
|
|
443
529
|
|
|
@@ -453,13 +539,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
453
539
|
for (const { time, key } of stamped) this.reader.applyDelete(key, time);
|
|
454
540
|
});
|
|
455
541
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
456
|
-
await this.streamAppend(frameDeletes(stamped)
|
|
542
|
+
await this.streamAppend(frameDeletes(stamped));
|
|
457
543
|
void this.maybeMerge({ onlyIfStreamHeavy: true });
|
|
458
544
|
}
|
|
459
545
|
|
|
460
546
|
// Coalesce stream appends on a ramping per-collection schedule (the browser rewrites the whole file per append). The first write after a lull flushes immediately so a single edit-then-close is saved at once; sustained writes ramp toward writeFlushMaxDelayMs.
|
|
461
|
-
private async streamAppend(framed: Buffer
|
|
462
|
-
this.pendingAppends.push(
|
|
547
|
+
private async streamAppend(framed: Buffer): Promise<void> {
|
|
548
|
+
this.pendingAppends.push(framed);
|
|
463
549
|
const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
|
|
464
550
|
const now = Date.now();
|
|
465
551
|
if (max <= 0 || this.currentFlushDelay <= 0 || now - this.lastWriteTime > max) {
|
|
@@ -490,7 +576,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
490
576
|
private async doFlush(): Promise<void> {
|
|
491
577
|
if (!this.pendingAppends.length) return;
|
|
492
578
|
const batch = this.pendingAppends.slice();
|
|
493
|
-
const combined = Buffer.concat(batch
|
|
579
|
+
const combined = Buffer.concat(batch);
|
|
494
580
|
const storage = await this.storage();
|
|
495
581
|
const fileName = this.getStreamFileName();
|
|
496
582
|
if (fileName !== this.currentStreamFileName) {
|
|
@@ -502,7 +588,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
502
588
|
// New entries appended during the await are after `batch` — removing the front is exactly the flushed set.
|
|
503
589
|
this.pendingAppends.splice(0, batch.length);
|
|
504
590
|
this.streamBytesOnDisk += combined.length;
|
|
505
|
-
for (const p of batch) this.streamRowsOnDisk += p.rows;
|
|
506
591
|
this.currentStreamFileBytes += combined.length;
|
|
507
592
|
if (this.currentStreamFileBytes >= bulkDatabase2Timing.streamFileMaxBytes) {
|
|
508
593
|
this.streamFileName = undefined;
|
|
@@ -519,7 +604,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
519
604
|
if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
|
|
520
605
|
}
|
|
521
606
|
if (!this.streamFileName) {
|
|
522
|
-
this.streamFileName =
|
|
607
|
+
this.streamFileName = newStreamFileName(writerId);
|
|
523
608
|
}
|
|
524
609
|
return this.streamFileName;
|
|
525
610
|
}
|
|
@@ -772,9 +857,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
772
857
|
const ordered = orderStreamEntries(streamData.entries);
|
|
773
858
|
const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
|
|
774
859
|
|
|
860
|
+
// An abandoned stream that yielded no entries (zero bytes, or nothing but torn bytes) holds no data and has no writer left. Retire it here: the merge below never lists it as a used source, so the normal retirement path would skip it and it would re-trigger the abandoned-stream fold on every pass. replacedBy is empty because nothing supersedes it - the marker hides it from reads immediately and processMarkers deletes it once aged.
|
|
861
|
+
const { retirable: retirableStreams } = await this.findAbandonedStreams(streamFiles, false);
|
|
862
|
+
const contributingStreams = new Set(streamData.entries.map(e => e.fileName));
|
|
863
|
+
const emptyAbandoned = streamFiles.filter(f => retirableStreams.has(f.fileName) && !contributingStreams.has(f.fileName)).map(f => f.fileName);
|
|
864
|
+
if (emptyAbandoned.length) await writeDeleteMarker(storage, { deleteFiles: emptyAbandoned, replacedBy: [] });
|
|
865
|
+
|
|
775
866
|
const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
|
|
776
867
|
const readerNames = streamReader ? ["(streams)", ...consumedBulk.map(f => f.fileName)] : consumedBulk.map(f => f.fileName);
|
|
777
|
-
if (!readers.length)
|
|
868
|
+
if (!readers.length) {
|
|
869
|
+
if (emptyAbandoned.length) await this.triggerRebuild();
|
|
870
|
+
return emptyAbandoned.length > 0;
|
|
871
|
+
}
|
|
778
872
|
|
|
779
873
|
const inputs = [
|
|
780
874
|
...await Promise.all(consumedBulk.map(async f => ({ name: f.fileName, size: (await storage.getInfo(f.fileName).catch(() => undefined))?.size ?? 0 }))),
|
|
@@ -816,7 +910,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
816
910
|
const carriedDeletes = includesOldest ? 0 : mergeResult.carriedDeletes.size;
|
|
817
911
|
const outNames = [...newNames];
|
|
818
912
|
if (carriedDeletes) {
|
|
819
|
-
const carryName =
|
|
913
|
+
const carryName = newStreamFileName(MERGE_OUTPUT_OWNER);
|
|
820
914
|
await storage.set(carryName, frameDeletes([...mergeResult.carriedDeletes].map(([key, time]) => ({ time, key }))));
|
|
821
915
|
outNames.push(carryName);
|
|
822
916
|
}
|
|
@@ -835,6 +929,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
835
929
|
// A stream may still be appended to after we read it (the writer hasn't sealed/moved on); only retire streams canDeleteStream clears — the rest stay live and a later merge re-folds them once sealed. Bulk files are immutable, so a used one is always safe to retire.
|
|
836
930
|
const deletableStreams: string[] = [];
|
|
837
931
|
for (const f of usedStreamFiles) {
|
|
932
|
+
if (retirableStreams.has(f.fileName)) { deletableStreams.push(f.fileName); continue; }
|
|
838
933
|
if (await this.canDeleteStream(f, Date.now(), streamData.sizes, forceDeleteStreams)) deletableStreams.push(f.fileName);
|
|
839
934
|
}
|
|
840
935
|
const deleteFiles = [...usedConsumedBulk.map(f => f.fileName), ...deletableStreams];
|
|
@@ -871,118 +966,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
871
966
|
return true;
|
|
872
967
|
}
|
|
873
968
|
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
private async
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if (streamFiles.length) {
|
|
890
|
-
const storage = await this.storage();
|
|
891
|
-
const sizes = await Promise.all(streamFiles.map(async f => { try { return (await storage.getInfo(f.fileName))?.size ?? 0; } catch { return 0; } }));
|
|
892
|
-
const totalStreamBytes = sizes.reduce((a, b) => a + b, 0);
|
|
893
|
-
if (totalStreamBytes > bulkDatabase2Timing.streamFoldHardLimitBytes) {
|
|
894
|
-
console.log(`${blue(this.name)} ${magenta("fold")} stream tier ${fmtBytes(totalStreamBytes)} over hard limit ${fmtBytes(bulkDatabase2Timing.streamFoldHardLimitBytes)} - folding all streams now`);
|
|
895
|
-
if (await this.mergeFileSet([], streamFiles, false, true)) merged = true;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Pass 1: consolidate recent. Only seal when cross-tab sync can fold recent streams — in Node canDeleteStream needs them aged anyway, so sealing would just fragment streams every pass.
|
|
901
|
-
const foldRecentStreams = isSyncSupported();
|
|
902
|
-
if (foldRecentStreams) {
|
|
903
|
-
syncBroadcastSeal(this.name);
|
|
904
|
-
this.streamFileName = undefined;
|
|
905
|
-
}
|
|
906
|
-
{
|
|
907
|
-
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
908
|
-
const bulkMeta = await Promise.all(bulkFiles.map(async f => {
|
|
909
|
-
const [size, header] = await Promise.all([this.fileLogicalSize(f.fileName), this.readBulkHeader(f.fileName)]);
|
|
910
|
-
return { kind: "bulk" as const, file: f, bytes: size ?? 0, time: header?.maxTime || f.timestamp };
|
|
911
|
-
}));
|
|
912
|
-
const streamMeta: { kind: "stream"; file: StreamFileInfo; bytes: number; time: number }[] = [];
|
|
913
|
-
for (const f of streamFiles) {
|
|
914
|
-
const aged = Date.now() - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs;
|
|
915
|
-
if (!foldRecentStreams && !aged) continue;
|
|
916
|
-
let bytes = 0;
|
|
917
|
-
try { const info = await (await this.storage()).getInfo(f.fileName); bytes = info?.size ?? 0; } catch { bytes = 0; }
|
|
918
|
-
streamMeta.push({ kind: "stream", file: f, bytes, time: f.timestamp });
|
|
919
|
-
}
|
|
920
|
-
const items = [...bulkMeta, ...streamMeta].sort((a, b) => b.time - a.time);
|
|
921
|
-
const recent: typeof items = [];
|
|
922
|
-
let recentBytes = 0;
|
|
923
|
-
for (const it of items) {
|
|
924
|
-
recent.push(it);
|
|
925
|
-
recentBytes += it.bytes;
|
|
926
|
-
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
927
|
-
}
|
|
928
|
-
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
929
|
-
const recentStreamBytes = recent.reduce((a, it) => a + (it.kind === "stream" ? it.bytes : 0), 0);
|
|
930
|
-
const heavyStream = recentStreamBytes > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
931
|
-
const triggered =
|
|
932
|
-
recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
|
|
933
|
-
|| heavyStream;
|
|
934
|
-
if (triggered) {
|
|
935
|
-
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
936
|
-
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
937
|
-
if (!await runMerge(rb, rs)) return merged;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
// Whole-tier dedup short-circuit (between Pass 1 and the per-group Pass 2): when the bulk tier is big enough AND mostly duplicates, fold every bulk file in one merge. Avoids the per-group walk's 5-min spacing × N-groups latency.
|
|
942
|
-
{
|
|
943
|
-
const { bulkFiles } = await this.listFiles();
|
|
944
|
-
if (bulkFiles.length >= 2) {
|
|
945
|
-
const storage = await this.storage();
|
|
946
|
-
let totalBytes = 0;
|
|
947
|
-
let totalSlots = 0;
|
|
948
|
-
const uniqueKeys = new Set<string>();
|
|
949
|
-
for (const f of bulkFiles) {
|
|
950
|
-
try {
|
|
951
|
-
const reader = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
|
|
952
|
-
totalBytes += reader.totalBytes;
|
|
953
|
-
totalSlots += reader.keys.length;
|
|
954
|
-
for (const k of reader.keys) uniqueKeys.add(k);
|
|
955
|
-
} catch { /* skip unreadable */ }
|
|
956
|
-
}
|
|
957
|
-
const dupFraction = totalSlots ? (totalSlots - uniqueKeys.size) / totalSlots : 0;
|
|
958
|
-
if (totalBytes > DEDUP_TRIGGER_BYTES && dupFraction > DEDUP_TRIGGER_FRACTION) {
|
|
959
|
-
console.log(`${blue(this.name)} ${magenta("dedup")}: ${fmtBytes(totalBytes)} across ${bulkFiles.length} bulk file(s), ${Math.round(dupFraction * 100)}% duplicate key-slots - folding all at once`);
|
|
960
|
-
if (!await runMerge(bulkFiles, [])) return merged;
|
|
961
|
-
return merged;
|
|
962
|
-
}
|
|
969
|
+
// Splits the bulk tier the way the merge writer does. A merge cuts an output file once it reaches TARGET_FILE_BYTES, so only its LAST output can be under that — meaning a sub-target file is either a single stream fold or the tail of an earlier merge, and is still waiting to be rolled up ("loose"). Anything at or over the target is done growing ("combined") and only phase 3 touches it again.
|
|
970
|
+
//
|
|
971
|
+
// A file whose size won't read is reported as combined: phase 2 can't consume one (its reader won't load, so the merge won't retire it), and calling it loose would re-trigger phase 2 on every pass until handleUnreadableFile finally deletes it.
|
|
972
|
+
private async splitBulkTier(bulkFiles: BulkFileInfo[]): Promise<{ loose: BulkFileInfo[]; looseBytes: number; combined: BulkFileInfo[]; sizes: Map<string, number> }> {
|
|
973
|
+
const logicalSizes = await Promise.all(bulkFiles.map(f => this.fileLogicalSize(f.fileName)));
|
|
974
|
+
const loose: BulkFileInfo[] = [];
|
|
975
|
+
const combined: BulkFileInfo[] = [];
|
|
976
|
+
const sizes = new Map<string, number>();
|
|
977
|
+
let looseBytes = 0;
|
|
978
|
+
for (let i = 0; i < bulkFiles.length; i++) {
|
|
979
|
+
const bytes = logicalSizes[i];
|
|
980
|
+
sizes.set(bulkFiles[i].fileName, bytes ?? 0);
|
|
981
|
+
if (bytes === undefined || bytes >= TARGET_FILE_BYTES) {
|
|
982
|
+
combined.push(bulkFiles[i]);
|
|
983
|
+
continue;
|
|
963
984
|
}
|
|
985
|
+
loose.push(bulkFiles[i]);
|
|
986
|
+
looseBytes += bytes;
|
|
964
987
|
}
|
|
965
|
-
|
|
966
|
-
// Pass 2: key-stratified deduplication. Disjoint key ranges → one group's merge doesn't change another's duplication; re-select each group's files at merge time (set shifts).
|
|
967
|
-
const groups = await this.findDuplicateGroups();
|
|
968
|
-
for (const g of groups) {
|
|
969
|
-
const { bulkFiles } = await this.listFiles();
|
|
970
|
-
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
971
|
-
const groupFiles = bulkFiles.filter((f, i) => {
|
|
972
|
-
const h = headers[i];
|
|
973
|
-
if (!h) return false;
|
|
974
|
-
if (h.minKey === undefined || h.maxKey === undefined) return true;
|
|
975
|
-
return h.minKey <= g.hi && h.maxKey >= g.lo;
|
|
976
|
-
});
|
|
977
|
-
if (groupFiles.length >= 2) { if (!await runMerge(groupFiles, [])) return merged; }
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
return merged;
|
|
988
|
+
return { loose, looseBytes, combined, sizes };
|
|
981
989
|
}
|
|
982
990
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
if (bulkFiles.length < 2) return [];
|
|
991
|
+
// One walk of the combined tier's key lists, producing both of phase 3's inputs: the whole-tier duplicate fraction, and the per-key-range groups. Combined into one pass because walking every file's keys is the most expensive thing a merge pass does that isn't a merge, and both answers come from the same key counts.
|
|
992
|
+
private async analyzeDuplicates(bulkFiles: BulkFileInfo[]): Promise<{ dupFraction: number; groups: { lo: string; hi: string; dup: number }[] }> {
|
|
986
993
|
const storage = await this.storage();
|
|
987
994
|
const infos = await Promise.all(bulkFiles.map(async f => {
|
|
988
995
|
try {
|
|
@@ -998,7 +1005,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
998
1005
|
totalBytes += i.bytes;
|
|
999
1006
|
for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
|
|
1000
1007
|
}
|
|
1001
|
-
if (totalSlots
|
|
1008
|
+
if (!totalSlots) return { dupFraction: 0, groups: [] };
|
|
1002
1009
|
const bytesPerSlot = totalBytes / totalSlots;
|
|
1003
1010
|
const sortedKeys = [...keyCount.keys()].sort();
|
|
1004
1011
|
const groups: { lo: string; hi: string; dup: number }[] = [];
|
|
@@ -1007,13 +1014,157 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1007
1014
|
const c = keyCount.get(sortedKeys[i]) ?? 0;
|
|
1008
1015
|
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
1009
1016
|
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
1010
|
-
|
|
1011
|
-
if (dup > DUP_THRESHOLD) groups.push({ lo: sortedKeys[gStart], hi: sortedKeys[i], dup });
|
|
1017
|
+
groups.push({ lo: sortedKeys[gStart], hi: sortedKeys[i], dup: (gSlots - gUnique) / gSlots });
|
|
1012
1018
|
gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
|
|
1013
1019
|
}
|
|
1014
1020
|
}
|
|
1015
|
-
|
|
1016
|
-
return groups;
|
|
1021
|
+
sort(groups, g => -g.dup);
|
|
1022
|
+
return { dupFraction: (totalSlots - keyCount.size) / totalSlots, groups };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Which files a key-group step rewrites. Re-run at merge time as well as at plan time: groups are disjoint by KEY range, not by file, so an earlier step in the same pass can consume a file this range still lists.
|
|
1026
|
+
private async filesForKeyRange(bulkFiles: BulkFileInfo[], keyRange: { lo: string; hi: string }): Promise<BulkFileInfo[]> {
|
|
1027
|
+
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
1028
|
+
return bulkFiles.filter((f, i) => {
|
|
1029
|
+
const h = headers[i];
|
|
1030
|
+
if (!h) return false;
|
|
1031
|
+
if (h.minKey === undefined || h.maxKey === undefined) return true;
|
|
1032
|
+
return h.minKey <= keyRange.hi && h.maxKey >= keyRange.lo;
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Every compaction the files on disk currently call for, without performing any of them. A merge pass
|
|
1038
|
+
* builds exactly this and then runs the steps whose `ready` is true, so the plan is precisely what the
|
|
1039
|
+
* database is about to do — and a step that isn't ready still reports its `triggers`, so a caller can
|
|
1040
|
+
* see how close it is (50MB of stream data out of the 64MB that would fold it, and so on).
|
|
1041
|
+
*
|
|
1042
|
+
* O(total keys): the phase 3 steps need every combined file's key list walked.
|
|
1043
|
+
*/
|
|
1044
|
+
public async planCompaction(): Promise<CompactionPlan> {
|
|
1045
|
+
const time = Date.now();
|
|
1046
|
+
const steps: CompactionStep[] = [];
|
|
1047
|
+
const storage = await this.storage();
|
|
1048
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
1049
|
+
|
|
1050
|
+
const streamSizes = new Map<string, number>();
|
|
1051
|
+
await Promise.all(streamFiles.map(async f => {
|
|
1052
|
+
try { streamSizes.set(f.fileName, (await storage.getInfo(f.fileName))?.size ?? 0); } catch { streamSizes.set(f.fileName, 0); }
|
|
1053
|
+
}));
|
|
1054
|
+
const streamBytes = (files: StreamFileInfo[]) => files.reduce((a, f) => a + (streamSizes.get(f.fileName) ?? 0), 0);
|
|
1055
|
+
|
|
1056
|
+
// ── Phase 1: stream -> bulk ──────────────────────────────────────────────────────────────────
|
|
1057
|
+
// The ENTIRE stream tier is parsed into memory and held there (subCaches.stream keeps every decoded entry) just to build the index, so it is the biggest single lever on our heap - a fold turns it into a bulk file we only read an index of. Folds streams and nothing else: no bulk file is dragged in, so the work is proportional to the memory reclaimed.
|
|
1058
|
+
const hardLimit = makeTrigger({ name: "streamBytes", value: streamBytes(streamFiles), threshold: bulkDatabase2Timing.streamFoldHardLimitBytes, unit: "bytes" });
|
|
1059
|
+
// Past the hard limit every read pulls an enormous file, so fold the whole tier regardless of who owns what. mergeFileSet force-deletes for this one; canDeleteStream still requires size-stability, so an active writer never loses data.
|
|
1060
|
+
steps.push({
|
|
1061
|
+
phase: 1, kind: "streamHardLimit", requires: "all", triggers: [hardLimit],
|
|
1062
|
+
ready: hardLimit.met && streamFiles.length > 0,
|
|
1063
|
+
bulkFiles: [], streamFiles, bytes: hardLimit.value,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// A pass seals before it merges, so by then our own current file is final too - predict that rather than reporting it as still-open.
|
|
1067
|
+
const { retirable, ownerGone } = await this.findAbandonedStreams(streamFiles, isSyncSupported());
|
|
1068
|
+
// Only fold what we can also retire. Folding a stream a live foreign owner may still append to would copy it into bulk without removing it, so the bytes would stay in memory and just get re-folded next pass; that owner rolls its own file over at streamFileMaxBytes instead.
|
|
1069
|
+
// Aged past streamSealAgeMs counts as retirable too (canDeleteStream's own first rule): no writer appends past the seal age, and this is the only thing that frees the tier in Node, where liveness cannot be probed at all.
|
|
1070
|
+
const foldable = streamFiles.filter(f => f.ownerId !== MERGE_OUTPUT_OWNER
|
|
1071
|
+
&& (retirable.has(f.fileName) || time - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs));
|
|
1072
|
+
// Merge-carry files hold nothing but tombstones and are never a REASON to fold - folding one alone would just rewrite it into another carry file, forever. They ride along whenever something else folds, which collapses however many have piled up into one.
|
|
1073
|
+
const carry = streamFiles.filter(f => f.ownerId === MERGE_OUTPUT_OWNER);
|
|
1074
|
+
// A stream whose writer is gone will never grow and nothing else will ever fold it, so fold it at any size - even one row. Zero-byte ones don't count: they hold no data to fold, and mergeFileSetInner retires them directly.
|
|
1075
|
+
const abandoned = foldable.filter(f => ownerGone.has(f.fileName) && streamSizes.get(f.fileName));
|
|
1076
|
+
const foldTriggers = [
|
|
1077
|
+
makeTrigger({ name: "foldableBytes", value: streamBytes(foldable), threshold: bulkDatabase2Timing.streamFoldTriggerBytes, unit: "bytes" }),
|
|
1078
|
+
makeTrigger({ name: "abandonedFiles", value: abandoned.length, threshold: 1, unit: "count" }),
|
|
1079
|
+
];
|
|
1080
|
+
steps.push({
|
|
1081
|
+
phase: 1, kind: "streamFold", requires: "any", triggers: foldTriggers,
|
|
1082
|
+
// Skipped when the hard limit already folds everything this would have.
|
|
1083
|
+
ready: !hardLimit.met && foldTriggers.some(t => t.met),
|
|
1084
|
+
bulkFiles: [], streamFiles: [...foldable, ...carry], bytes: streamBytes([...foldable, ...carry]),
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
// ── Phase 2: loose bulk -> combined bulk ─────────────────────────────────────────────────────
|
|
1088
|
+
// Phase 1 emits one small bulk file per fold. Each is cheap to read (index only) but holds its whole key list in memory and joins into every read, so they have to be rolled up. Merging just the loose ones also dedupes them for free - a rewrite-heavy workload collapses a gigabyte of near-identical folds into almost nothing - and it always terminates, because a merge cuts every output but the last at TARGET_FILE_BYTES, so one pass can leave at most one loose file behind.
|
|
1089
|
+
const { loose, looseBytes, combined, sizes } = await this.splitBulkTier(bulkFiles);
|
|
1090
|
+
const looseTriggers = [
|
|
1091
|
+
makeTrigger({ name: "looseBytes", value: looseBytes, threshold: bulkDatabase2Timing.looseBulkTriggerBytes, unit: "bytes" }),
|
|
1092
|
+
makeTrigger({ name: "looseFiles", value: loose.length, threshold: bulkDatabase2Timing.looseBulkTriggerFiles, unit: "count" }),
|
|
1093
|
+
];
|
|
1094
|
+
steps.push({
|
|
1095
|
+
phase: 2, kind: "looseCombine", requires: "any", triggers: looseTriggers,
|
|
1096
|
+
// Under two files there is nothing to combine, and rewriting one file into an identical one would re-trigger forever.
|
|
1097
|
+
ready: loose.length >= 2 && looseTriggers.some(t => t.met),
|
|
1098
|
+
bulkFiles: loose, streamFiles: [], bytes: looseBytes,
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
// ── Phase 3: dedup the combined files ────────────────────────────────────────────────────────
|
|
1102
|
+
// Loose files are excluded throughout: phase 2 rewrites them anyway, and that rewrite already dedupes them, so pulling one in here would do the same work twice at key-group scale.
|
|
1103
|
+
const combinedBytes = combined.reduce((a, f) => a + (sizes.get(f.fileName) ?? 0), 0);
|
|
1104
|
+
const { dupFraction, groups } = await this.analyzeDuplicates(combined);
|
|
1105
|
+
// Whole-tier short-circuit: when the combined tier is big enough AND mostly duplicates, fold all of it in one merge rather than paying the per-group walk's 5-min spacing x N groups.
|
|
1106
|
+
const dedupAllTriggers = [
|
|
1107
|
+
makeTrigger({ name: "combinedBytes", value: combinedBytes, threshold: DEDUP_TRIGGER_BYTES, unit: "bytes" }),
|
|
1108
|
+
makeTrigger({ name: "duplicateFraction", value: dupFraction, threshold: DEDUP_TRIGGER_FRACTION, unit: "fraction" }),
|
|
1109
|
+
];
|
|
1110
|
+
const dedupAllReady = combined.length >= 2 && dedupAllTriggers.every(t => t.met);
|
|
1111
|
+
steps.push({
|
|
1112
|
+
phase: 3, kind: "dedupAll", requires: "all", triggers: dedupAllTriggers,
|
|
1113
|
+
ready: dedupAllReady,
|
|
1114
|
+
bulkFiles: combined, streamFiles: [], bytes: combinedBytes,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Then key-stratified: disjoint key ranges, so one group's merge doesn't change another's duplication. Only groups over the threshold are worth a step; the best one below it is listed anyway so a caller can see how close the tier is.
|
|
1118
|
+
const groupSteps = groups.filter(g => g.dup >= DUP_THRESHOLD);
|
|
1119
|
+
if (!groupSteps.length && groups.length) groupSteps.push(groups[0]);
|
|
1120
|
+
for (const g of groupSteps) {
|
|
1121
|
+
const groupFiles = await this.filesForKeyRange(combined, g);
|
|
1122
|
+
const dup = makeTrigger({ name: "duplicateFraction", value: g.dup, threshold: DUP_THRESHOLD, unit: "fraction" });
|
|
1123
|
+
steps.push({
|
|
1124
|
+
phase: 3, kind: "dedupKeyGroup", requires: "all", triggers: [dup],
|
|
1125
|
+
// Moot if the whole tier is about to be folded in one go.
|
|
1126
|
+
ready: !dedupAllReady && dup.met && groupFiles.length >= 2,
|
|
1127
|
+
bulkFiles: groupFiles, streamFiles: [], bytes: groupFiles.reduce((a, f) => a + (sizes.get(f.fileName) ?? 0), 0),
|
|
1128
|
+
keyRange: { lo: g.lo, hi: g.hi },
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Merges are spaced mergeSpacingMs apart, and the first one runs immediately.
|
|
1133
|
+
let readyCount = 0;
|
|
1134
|
+
for (const step of steps) {
|
|
1135
|
+
if (!step.ready) continue;
|
|
1136
|
+
step.startTime = time + readyCount * bulkDatabase2Timing.mergeSpacingMs;
|
|
1137
|
+
readyCount++;
|
|
1138
|
+
}
|
|
1139
|
+
return { collection: this.name, time, steps };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Runs the compaction plan: builds it, then performs every step it marks ready, in order. All the deciding lives in planCompaction - this only executes, so what the database does and what planCompaction reports can never drift apart.
|
|
1143
|
+
private async testMergeINTERNAL_DO_NOT_CALL(): Promise<boolean> {
|
|
1144
|
+
let merged = false;
|
|
1145
|
+
await this.flushPending();
|
|
1146
|
+
|
|
1147
|
+
// Seal before planning, so every writer's current stream file is final and the plan can count it as foldable. Only when cross-tab sync is there to carry the seal - in Node canDeleteStream needs streams aged anyway, so sealing would just fragment them every pass.
|
|
1148
|
+
if (isSyncSupported()) {
|
|
1149
|
+
syncBroadcastSeal(this.name);
|
|
1150
|
+
this.streamFileName = undefined;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const plan = await this.planCompaction();
|
|
1154
|
+
for (const step of plan.steps) {
|
|
1155
|
+
if (!step.ready) continue;
|
|
1156
|
+
let bulkFiles = step.bulkFiles;
|
|
1157
|
+
if (step.keyRange) {
|
|
1158
|
+
bulkFiles = await this.filesForKeyRange(bulkFiles, step.keyRange);
|
|
1159
|
+
if (bulkFiles.length < 2) continue;
|
|
1160
|
+
}
|
|
1161
|
+
const why = step.triggers.filter(t => t.met)
|
|
1162
|
+
.map(t => `${t.name} ${fmtTriggerValue(t.value, t.unit)} of ${fmtTriggerValue(t.threshold, t.unit)}`).join(", ");
|
|
1163
|
+
console.log(`${blue(this.name)} ${magenta(step.kind)} phase ${step.phase}: ${bulkFiles.length} bulk + ${step.streamFiles.length} stream file(s), ${fmtBytes(step.bytes)} - ${why}`);
|
|
1164
|
+
if (merged && !await this.mergeSpacingDelay()) return merged;
|
|
1165
|
+
if (await this.mergeFileSet(bulkFiles, step.streamFiles, false, step.kind === "streamHardLimit")) merged = true;
|
|
1166
|
+
}
|
|
1167
|
+
return merged;
|
|
1017
1168
|
}
|
|
1018
1169
|
|
|
1019
1170
|
// ── reads — forwarded to BulkDatabaseReader, with rebuild-on-missing retry ───────────────────────
|
|
@@ -9,7 +9,8 @@ import { blue, red } from "socket-function/src/formatting/logColors";
|
|
|
9
9
|
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
10
10
|
|
|
11
11
|
export type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
12
|
-
|
|
12
|
+
// ownerId is the writer that may still be appending to this file; undefined for legacy names that predate the stamp (nothing appends to those anymore).
|
|
13
|
+
export type StreamFileInfo = { fileName: string; timestamp: number; ownerId?: string };
|
|
13
14
|
export type StreamReaderCacheEntry = { readSize: number; parsedPos: number; entries: StreamEntry[] };
|
|
14
15
|
|
|
15
16
|
export class MissingFileError extends Error { }
|
|
@@ -11,7 +11,12 @@ import { LZ4 } from "socket-function/src/lz4/LZ4";
|
|
|
11
11
|
// Per-block compression mainly helps slow storage (HDD): fewer bytes off disk per block, at the cost of a fast in-memory LZ4 decompress.
|
|
12
12
|
|
|
13
13
|
const BLOCK_SIZE = 256 * 1024;
|
|
14
|
-
|
|
14
|
+
// 128MB per context. This cache exists per browsing context (each tab, each
|
|
15
|
+
// worker), so a generous cap multiplies fast — at 512MB, three same-process
|
|
16
|
+
// tabs could pin 1.5GB of block data, and the resulting GC pressure visibly
|
|
17
|
+
// janks video playback. 128MB still holds ~500 hot blocks per context; misses
|
|
18
|
+
// just re-read from local disk, which is cheap.
|
|
19
|
+
const MAX_BLOCKS = Math.floor((128 * 1024 * 1024) / BLOCK_SIZE);
|
|
15
20
|
const MIN_COMPRESSION_RATIO = 2;
|
|
16
21
|
|
|
17
22
|
const EMPTY = Buffer.alloc(0) as Buffer;
|