sliftutils 1.7.84 → 1.7.86

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.
@@ -4,8 +4,8 @@ import { runInfinitePoll, delay } from "socket-function/src/batching";
4
4
  import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
5
5
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
6
  import {
7
- IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, assertValidLastModified,
8
- windowAcceptsWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
7
+ IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, SourceConfig, assertValidLastModified,
8
+ windowAcceptsWrites, windowsAcceptWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
9
9
  } from "../IArchives";
10
10
  import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
11
  import { ArchivesBackblaze } from "../backblaze";
@@ -18,7 +18,10 @@ import { SourcesList } from "./sourcesList";
18
18
  // The storage engine of the remote storage server. Data lives in synchronization sources (at minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last modified time, size, and which source currently holds the data) in a BulkDatabase2, and synchronizes the index from all sources (see ArchivesSource in IArchives.ts). Every startup fully rescans each source's metadata, so the index self-heals; the file with the highest write time wins across all sources, so multiple sources need no stacking order. The store also holds its own routing entries (the self entries of the ONE route it serves), so it validates writes itself: valid windows, routes, immutability, and internal-push acceptance.
19
19
 
20
20
  export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
21
- const FAST_FLUSH_POLL = 1000 * 15;
21
+ // The most a fast write is buffered before reaching our OWN storage servers (type-"remote" downstream sources) and our own disk - short, so cross-node redundancy and read-your-writes across nodes don't wait the full writeDelay. The writeDelay (default 5 min) is reserved for the non-"remote" sources (backblaze etc). A fast write with a writeDelay shorter than this uses the shorter one for both.
22
+ const MAX_REMOTE_FAST_BUFFER = 1000 * 5;
23
+ // The overlay is polled this often for due flushes - fine enough that the fast (remote) flush lands close to MAX_REMOTE_FAST_BUFFER, not one coarse tick later
24
+ const FAST_FLUSH_POLL = 1000 * 2;
22
25
  // Fast writes are never delayed past our own valid window, and within this margin of the window's end they write through immediately - so when the next window's source takes over, the writes are already on disk
23
26
  export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
24
27
  // Index changes are buffered in memory and written to the BulkDatabase2 in batches
@@ -116,7 +119,12 @@ type OverlayEntry = {
116
119
  // A zero-length buffer is a pending delete (tombstone)
117
120
  data: Buffer;
118
121
  t: number;
119
- flushAt: number;
122
+ // When to flush to our own disk + type-"remote" downstream sources (our own storage servers): short, so cross-node reads and redundancy don't wait the full writeDelay
123
+ fastFlushAt: number;
124
+ // When to flush to the remaining (non-"remote") downstream sources, e.g. backblaze: the full writeDelay, coalescing to keep expensive external writes rare
125
+ slowFlushAt: number;
126
+ // Set once the fast flush has run (own disk + remote peers written); the slow flush then only needs the non-remote sources
127
+ fastFlushed?: boolean;
120
128
  };
121
129
 
122
130
  // One row of the BulkDatabase2 index (key is the file path)
@@ -158,11 +166,13 @@ export type BlobSourceSpec = {
158
166
  identity: string;
159
167
  // See ArchivesSource.url
160
168
  url: string;
161
- validWindow: [number, number];
169
+ validWindows: [number, number][];
162
170
  route?: [number, number];
163
171
  noFullSync?: boolean;
164
172
  // See ArchivesSource.intermediate
165
173
  intermediate?: boolean;
174
+ // See ArchivesSource.sourceConfig (absent for the base disk source)
175
+ sourceConfig?: SourceConfig;
166
176
  // Only called for sources that don't match an existing live slot
167
177
  create: () => IArchives;
168
178
  };
@@ -460,7 +470,7 @@ export class BlobStore implements IBucketStore {
460
470
  indexSize: this.mem.size,
461
471
  sources: this.sources.map((x, i) => ({
462
472
  debugName: x.source.getDebugName(),
463
- validWindow: x.validWindow,
473
+ validWindows: x.validWindows,
464
474
  route: x.route,
465
475
  noFullSync: x.noFullSync,
466
476
  supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
@@ -520,14 +530,14 @@ export class BlobStore implements IBucketStore {
520
530
  if (entries) {
521
531
  this.entries = entries;
522
532
  }
523
- let setWindow = (i: number, window: [number, number]) => {
524
- let old = this.sources[i].validWindow;
525
- if (old[0] === window[0] && old[1] === window[1]) return;
526
- console.log(`Valid window changed for ${this.sources[i].source.getDebugName()} (store ${this.folder}): [${old.join(", ")}] -> [${window.join(", ")}]`);
527
- this.sources[i].validWindow = window;
533
+ let setWindows = (i: number, windows: [number, number][]) => {
534
+ let old = this.sources[i].validWindows;
535
+ if (JSON.stringify(old) === JSON.stringify(windows)) return;
536
+ console.log(`Valid windows changed for ${this.sources[i].source.getDebugName()} (store ${this.folder}): ${JSON.stringify(old)} -> ${JSON.stringify(windows)}`);
537
+ this.sources[i].validWindows = windows;
528
538
  };
529
- setWindow(0, specs[0].validWindow);
530
- // Live slots pair with specs by identity, in order (the same endpoint can appear several times, e.g. one entry per window)
539
+ setWindows(0, specs[0].validWindows);
540
+ // Live slots pair with specs by identity. Each endpoint is now ONE spec carrying all its windows, so this is a 1:1 pairing; any leftover duplicate slots from before (an endpoint that used to be split into several slots) go unmatched below and are collapsed away.
531
541
  let liveByIdentity = new Map<string, number[]>();
532
542
  let originalLength = this.sources.length;
533
543
  for (let i = 1; i < originalLength; i++) {
@@ -546,17 +556,18 @@ export class BlobStore implements IBucketStore {
546
556
  let slot = liveByIdentity.get(spec.identity)?.shift();
547
557
  if (slot !== undefined) {
548
558
  matched.add(slot);
549
- setWindow(slot, spec.validWindow);
559
+ setWindows(slot, spec.validWindows);
550
560
  let existing = this.sources[slot];
551
561
  if (JSON.stringify(existing.route) !== JSON.stringify(spec.route)) {
552
562
  console.log(`Route changed for ${existing.source.getDebugName()} (store ${this.folder}): ${JSON.stringify(existing.route)} -> ${JSON.stringify(spec.route)}`);
553
563
  existing.route = spec.route;
554
564
  }
555
565
  existing.noFullSync = spec.noFullSync;
566
+ existing.sourceConfig = spec.sourceConfig;
556
567
  continue;
557
568
  }
558
569
  let source = spec.create();
559
- this.sources.push({ source, url: spec.url, validWindow: spec.validWindow, route: spec.route, noFullSync: spec.noFullSync, intermediate: spec.intermediate, identity: spec.identity });
570
+ this.sources.push({ source, url: spec.url, validWindows: spec.validWindows, route: spec.route, noFullSync: spec.noFullSync, intermediate: spec.intermediate, sourceConfig: spec.sourceConfig, identity: spec.identity });
560
571
  this.sourceStates.push(newSourceState());
561
572
  this.sourceFileCounts.push(0);
562
573
  this.sourceByteCounts.push(0);
@@ -569,11 +580,12 @@ export class BlobStore implements IBucketStore {
569
580
  if (!this.isLive(i) || matched.has(i)) continue;
570
581
  this.removeSource(i);
571
582
  }
572
- let deadline = this.sources[0].validWindow[1] - WINDOW_END_FLUSH_MARGIN;
583
+ let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
573
584
  let recapped = 0;
574
585
  for (let entry of this.overlay.values()) {
575
- if (entry.flushAt <= deadline) continue;
576
- entry.flushAt = deadline;
586
+ if (entry.fastFlushAt <= deadline && entry.slowFlushAt <= deadline) continue;
587
+ entry.fastFlushAt = Math.min(entry.fastFlushAt, deadline);
588
+ entry.slowFlushAt = Math.min(entry.slowFlushAt, deadline);
577
589
  recapped++;
578
590
  }
579
591
  if (recapped) {
@@ -909,10 +921,10 @@ export class BlobStore implements IBucketStore {
909
921
  }
910
922
  }
911
923
 
912
- // An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever
924
+ // An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever. (Dead only once EVERY window has ended - an intermediate normally has just one.)
913
925
  private isDeadIntermediate(sourceIndex: number): boolean {
914
- let { intermediate, validWindow } = this.sources[sourceIndex];
915
- return !!intermediate && validWindow[1] <= Date.now();
926
+ let { intermediate, validWindows } = this.sources[sourceIndex];
927
+ return !!intermediate && validWindows.every(w => w[1] <= Date.now());
916
928
  }
917
929
 
918
930
  // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the source's listing (path -> write time), which reconcileSource uses for the push direction.
@@ -975,9 +987,9 @@ export class BlobStore implements IBucketStore {
975
987
 
976
988
  // The push direction of synchronization: everything we know that the source is missing (or holds an older copy of) is written to it — including deletions, as tombstone writes. This is what heals a source whose background writes failed (e.g. it was down): the next scan sees what's missing and re-sends it. A failing file is skipped, not fatal (immutable targets are handled by forceSetImmutable, and one unreadable value must not stop the rest of the pass) - only a run of consecutive failures (the source itself is down) aborts until the next scan cycle.
977
989
  private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
978
- let { source, validWindow, route } = this.sources[sourceIndex];
990
+ let { source, validWindows, route } = this.sources[sourceIndex];
979
991
  let state = this.sourceStates[sourceIndex];
980
- let acceptsWrites = windowAcceptsWrites(validWindow);
992
+ let acceptsWrites = windowsAcceptWrites(validWindows);
981
993
  let targetSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
982
994
  let pushed = 0;
983
995
  let failed = 0;
@@ -1241,10 +1253,12 @@ export class BlobStore implements IBucketStore {
1241
1253
  writeDelay = DEFAULT_FAST_WRITE_DELAY;
1242
1254
  }
1243
1255
  // The delay never extends past our own valid window's end (minus the margin, so the writes are on disk before the next window's source takes over - a deploy switchover is just this too, since its remap ends our window). Past that point fast writes write through immediately.
1244
- let deadline = this.sources[0].validWindow[1] - WINDOW_END_FLUSH_MARGIN;
1256
+ let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
1245
1257
  if (writeDelay > 0 && Date.now() < deadline) {
1246
- let flushAt = Math.min(Date.now() + writeDelay, deadline);
1247
- this.overlay.set(key, { data, t: writeTime, flushAt });
1258
+ // Own disk + our own storage servers (type-"remote") flush within MAX_REMOTE_FAST_BUFFER; the non-"remote" sources (backblaze) wait the full writeDelay
1259
+ let fastFlushAt = Math.min(Date.now() + Math.min(writeDelay, MAX_REMOTE_FAST_BUFFER), deadline);
1260
+ let slowFlushAt = Math.min(Date.now() + writeDelay, deadline);
1261
+ this.overlay.set(key, { data, t: writeTime, fastFlushAt, slowFlushAt });
1248
1262
  return;
1249
1263
  }
1250
1264
  }
@@ -1252,17 +1266,28 @@ export class BlobStore implements IBucketStore {
1252
1266
  await this.writeToSources(key, data, writeTime);
1253
1267
  }
1254
1268
 
1269
+ // The end of our own (base disk) write window that CONTAINS now - the deadline fast writes must flush before, so the next window's source has the data on handoff. The LATEST end among covering windows (overlapping windows hand off at the last one). No window contains now (an inert store, or a moment between our windows) -> 0, i.e. flush through immediately.
1270
+ private baseWriteWindowEnd(): number {
1271
+ let now = Date.now();
1272
+ let end = 0;
1273
+ for (let w of this.sources[0].validWindows) {
1274
+ if (w[0] <= now && now < w[1]) end = Math.max(end, w[1]);
1275
+ }
1276
+ return end;
1277
+ }
1278
+
1255
1279
  private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
1256
1280
  let writable: number[] = [];
1257
1281
  for (let i = 0; i < this.sources.length; i++) {
1258
1282
  if (!this.isLive(i)) continue;
1259
- if (!config?.ignoreWindow && !windowAcceptsWrites(this.sources[i].validWindow)) continue;
1283
+ if (!config?.ignoreWindow && !windowsAcceptWrites(this.sources[i].validWindows)) continue;
1260
1284
  writable.push(i);
1261
1285
  }
1262
1286
  return writable;
1263
1287
  }
1264
1288
 
1265
- private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
1289
+ // config lets a staged fast flush write only PART of the sources: writeBase (our own disk + index) defaults on, and downstream filters which non-base peers get the fan-out (the fast flush writes only type-"remote" peers, the slow flush only the rest). A full write (no config) does base + every route-matching peer, as before.
1290
+ private async writeToSources(key: string, data: Buffer, writeTime: number, config?: { writeBase?: boolean; downstream?: (source: ArchivesSource) => boolean }): Promise<void> {
1266
1291
  // The routing file is NEVER synchronized between storage nodes: the writer writes it directly to each node, so we store it on our own disk only (no valid-window filter - routing/valid windows can't possibly apply to the file defining them) and never forward it to other sources.
1267
1292
  this.config?.onWriteCounted?.("flushed", data.length);
1268
1293
  let isRouting = key === ROUTING_FILE;
@@ -1271,18 +1296,21 @@ export class BlobStore implements IBucketStore {
1271
1296
  if (first === undefined) {
1272
1297
  throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
1273
1298
  }
1274
- // Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconcileSource re-sends anything they missed once they come back.
1275
- if (data.length === 0) {
1276
- // A tombstone stores nothing on our own source - the index entry alone records it
1277
- await this.sources[first].source.del(key);
1278
- } else {
1279
- await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
1299
+ if (config?.writeBase ?? true) {
1300
+ // Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconcileSource re-sends anything they missed once they come back.
1301
+ if (data.length === 0) {
1302
+ // A tombstone stores nothing on our own source - the index entry alone records it
1303
+ await this.sources[first].source.del(key);
1304
+ } else {
1305
+ await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
1306
+ }
1307
+ this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
1280
1308
  }
1281
- this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
1282
1309
  if (isRouting) return;
1283
1310
  let route = getRoute(key);
1284
1311
  for (let i of writable) {
1285
1312
  if (!routeContains(this.sources[i].route, route)) continue;
1313
+ if (config?.downstream && !config.downstream(this.sources[i])) continue;
1286
1314
  // Deletions travel as del carrying the original write time (never as empty sets - set rejects empty buffers). Backblaze materializes such dels as real empty files, so its listings still show the deletion for other stores to scan in as a tombstone.
1287
1315
  let push: Promise<unknown>;
1288
1316
  if (data.length === 0) {
@@ -1304,15 +1332,28 @@ export class BlobStore implements IBucketStore {
1304
1332
  throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
1305
1333
  }
1306
1334
 
1335
+ private isRemoteSource(source: ArchivesSource): boolean {
1336
+ return source.sourceConfig?.type === "remote";
1337
+ }
1338
+
1307
1339
  private async flushOverlay(force?: boolean): Promise<void> {
1308
1340
  let now = Date.now();
1309
1341
  for (let [key, entry] of this.overlay) {
1310
- if (!force && entry.flushAt > now) continue;
1311
- await this.writeToSources(key, entry.data, entry.t);
1312
- // Only remove if it wasn't overwritten while we were flushing
1313
- if (this.overlay.get(key) === entry) {
1314
- this.overlay.delete(key);
1342
+ let doFast = !entry.fastFlushed && (force || entry.fastFlushAt <= now);
1343
+ let doSlow = force || entry.slowFlushAt <= now;
1344
+ if (!doFast && !doSlow) continue;
1345
+ if (doSlow) {
1346
+ // The remaining sources: if the fast flush already ran, only the non-"remote" ones (own disk + remote peers are done); otherwise everything at once (writeDelay was <= MAX_REMOTE_FAST_BUFFER, or a forced/window-boundary flush)
1347
+ await this.writeToSources(key, entry.data, entry.t, entry.fastFlushed ? { writeBase: false, downstream: s => !this.isRemoteSource(s) } : undefined);
1348
+ // Only remove if it wasn't overwritten while we were flushing
1349
+ if (this.overlay.get(key) === entry) {
1350
+ this.overlay.delete(key);
1351
+ }
1352
+ continue;
1315
1353
  }
1354
+ // Fast stage: own disk + our own storage servers only; the entry stays in the overlay for its slow flush
1355
+ await this.writeToSources(key, entry.data, entry.t, { downstream: s => this.isRemoteSource(s) });
1356
+ entry.fastFlushed = true;
1316
1357
  }
1317
1358
  }
1318
1359
 
@@ -1375,7 +1416,7 @@ export class BlobStore implements IBucketStore {
1375
1416
  for (let i = 0; i < this.sources.length; i++) {
1376
1417
  if (!this.isLive(i)) continue;
1377
1418
  let sourceEntry = this.sources[i];
1378
- if (!windowAcceptsWrites(sourceEntry.validWindow)) continue;
1419
+ if (!windowsAcceptWrites(sourceEntry.validWindows)) continue;
1379
1420
  let source = sourceEntry.source;
1380
1421
  if (!(source instanceof ArchivesBackblaze)) continue;
1381
1422
  void source.del(key).catch((e: Error) => {