sliftutils 1.7.21 → 1.7.23

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.
@@ -5,7 +5,7 @@ import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
5
5
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
6
  import {
7
7
  IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
8
- windowAcceptsWrites, SyncActivity,
8
+ windowAcceptsWrites, SyncActivity, FULL_ROUTE,
9
9
  } from "../IArchives";
10
10
  import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
11
  import { ArchivesBackblaze } from "../backblaze";
@@ -22,6 +22,10 @@ import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../F
22
22
 
23
23
  export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
24
24
  const FAST_FLUSH_POLL = 1000 * 15;
25
+ // Fast writes are never delayed past our own valid window, and within this margin of the window's
26
+ // end they write through immediately - so when the next window's source takes over, the writes are
27
+ // already on disk
28
+ export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
25
29
  // Index changes are buffered in memory and written to the BulkDatabase2 in batches
26
30
  const INDEX_FLUSH_INTERVAL = 1000 * 30;
27
31
  // Sources that support getChangesAfter are polled this often
@@ -120,6 +124,17 @@ type SourceState = {
120
124
  lastMissCheck: number;
121
125
  };
122
126
 
127
+ // What a scanned listing entry meant when compared against our index
128
+ type ScanOutcome = "filtered" | "new" | "updated" | "tombstone" | "unchanged";
129
+ type ScanTally = Record<ScanOutcome, number>;
130
+ function newScanTally(): ScanTally {
131
+ return { filtered: 0, new: 0, updated: 0, tombstone: 0, unchanged: 0 };
132
+ }
133
+ function formatScanTally(tally: ScanTally, total: number): string {
134
+ let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
135
+ return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside window/route (${pct(tally.filtered)})`;
136
+ }
137
+
123
138
  export class BlobStore implements IBucketStore {
124
139
  constructor(
125
140
  private folder: string,
@@ -130,10 +145,6 @@ export class BlobStore implements IBucketStore {
130
145
  onIndexChanged?: (key: string) => void;
131
146
  // LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
132
147
  readerDiskLimit?: number;
133
- // Deploy takeover: fast-write flush delays never extend past this time, and after it
134
- // fast writes flush immediately (nothing may sit in memory when the write window
135
- // transfers to the successor process)
136
- getFlushDeadline?: () => number | undefined;
137
148
  }
138
149
  ) { }
139
150
 
@@ -205,6 +216,11 @@ export class BlobStore implements IBucketStore {
205
216
  let source = sourceMap.get(entry.key);
206
217
  // Explicit checks, as 0 is a valid size and a valid source number
207
218
  if (size === undefined || source === undefined) continue;
219
+ // The routing config is only ever read off our own disk (see applyScanned), and a
220
+ // loaded bucket always has it there - a persisted entry pointing elsewhere is stale
221
+ if (entry.key === ROUTING_FILE) {
222
+ source = 0;
223
+ }
208
224
  let full: IndexEntry = { writeTime: entry.value, size, source, changedAt: entry.time, lastAccess: entry.time };
209
225
  this.mem.set(entry.key, full);
210
226
  this.countEntry(full, 1);
@@ -238,13 +254,63 @@ export class BlobStore implements IBucketStore {
238
254
  this.dirty.set(key, undefined);
239
255
  }
240
256
 
241
- /** Rescans our own disk's metadata into the index - used around deploy switchovers, where the
242
- * other process wrote files to the shared folder that our index hasn't seen. */
257
+ /** Rescans our own disk's metadata into the index - used around valid window handoffs, where
258
+ * another process wrote files to the shared folder that our index hasn't seen. */
243
259
  public async rescanBase(): Promise<void> {
244
260
  await this.init();
245
261
  await this.scanSource(0);
246
262
  }
247
263
 
264
+ /** A boundary scan of the node that owned (part of) our route in the valid window before ours,
265
+ * when that node is different storage (a disk rescan can't see its writes): just its changes
266
+ * since the boundary neighborhood, with matching values pulled onto our own disk. */
267
+ public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
268
+ await this.init();
269
+ let scanStart = Date.now();
270
+ console.log(`Boundary scan of ${source.getDebugName()} starting: changes since ${new Date(config.since).toISOString()}, route ${JSON.stringify(config.route || FULL_ROUTE)} (store ${this.folder})`);
271
+ let changes: ArchiveFileInfo[];
272
+ if (source.getChangesAfter) {
273
+ changes = await source.getChangesAfter(config.since);
274
+ } else {
275
+ changes = (await source.findInfo("")).filter(x => x.createTime > config.since);
276
+ }
277
+ let tally = newScanTally();
278
+ for (let file of changes) {
279
+ if (file.path === ROUTING_FILE || !routeContains(config.route, getRoute(file.path))) {
280
+ tally.filtered++;
281
+ continue;
282
+ }
283
+ let overlayEntry = this.overlay.get(file.path);
284
+ let entry = this.mem.get(file.path);
285
+ let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
286
+ if (file.createTime <= currentTime) {
287
+ tally.unchanged++;
288
+ continue;
289
+ }
290
+ if (file.size === 0) {
291
+ // A tombstone stores nothing on our own source - the index entry alone records it
292
+ this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, source: 0 });
293
+ tally.tombstone++;
294
+ continue;
295
+ }
296
+ let result = await source.get2(file.path);
297
+ if (!result) continue;
298
+ if (result.data.length === 0) {
299
+ this.setIndexEntry(file.path, { writeTime: result.writeTime, size: 0, source: 0 });
300
+ tally.tombstone++;
301
+ continue;
302
+ }
303
+ await this.sources[0].source.set(file.path, result.data, { lastModified: result.writeTime });
304
+ this.setIndexEntry(file.path, { writeTime: result.writeTime, size: result.data.length, source: 0 });
305
+ if (entry || overlayEntry) {
306
+ tally.updated++;
307
+ } else {
308
+ tally.new++;
309
+ }
310
+ }
311
+ console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
312
+ }
313
+
248
314
  /** The cheap always-current totals plus any in-progress background synchronization. */
249
315
  public getSyncProgress(): {
250
316
  index: { fileCount: number; byteCount: number };
@@ -358,14 +424,13 @@ export class BlobStore implements IBucketStore {
358
424
  // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the
359
425
  // source's listing (path -> write time), which reconcileSource uses for the push direction.
360
426
  private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
361
- let { source } = this.sources[sourceIndex];
427
+ let { source, validWindow, route } = this.sources[sourceIndex];
362
428
  let state = this.sourceStates[sourceIndex];
363
429
  let scanStart = Date.now();
364
- let scanLogged = false;
365
430
  let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
366
431
  this.syncActivities.add(activity);
432
+ console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.folder})`);
367
433
  let progressTimer = setInterval(() => {
368
- scanLogged = true;
369
434
  console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
370
435
  }, SYNC_PROGRESS_LOG_INTERVAL);
371
436
  (progressTimer as { unref?: () => void }).unref?.();
@@ -376,26 +441,43 @@ export class BlobStore implements IBucketStore {
376
441
  clearInterval(progressTimer);
377
442
  this.syncActivities.delete(activity);
378
443
  }
379
- if (scanLogged) {
380
- console.log(`Metadata scan of ${source.getDebugName()} finished: ${files.length} files in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder})`);
381
- }
444
+ let indexSizeBefore = this.mem.size;
382
445
  let seen = new Map<string, number>();
446
+ let tally = newScanTally();
447
+ let newPaths = 0;
383
448
  for (let file of files) {
384
449
  seen.set(file.path, file.createTime);
385
- this.applyScanned(sourceIndex, file);
450
+ if (!this.mem.has(file.path)) {
451
+ newPaths++;
452
+ }
453
+ tally[this.applyScanned(sourceIndex, file)]++;
386
454
  }
387
455
  state.scannedCount = files.length;
388
456
  // Index entries this source was the holder of, but that vanished from it (e.g. deleted
389
457
  // while we were offline), come out of the index. Entries changed after the scan started
390
458
  // are kept — the scan listing may simply predate them. Tombstones have no physical file
391
459
  // for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
460
+ let removedFromIndex = 0;
461
+ let missingOnSource = 0;
392
462
  for (let [key, entry] of this.mem) {
393
- if (entry.source !== sourceIndex) continue;
394
- if (entry.size === 0) continue;
395
463
  if (seen.has(key)) continue;
396
- if (entry.changedAt >= scanStart) continue;
397
- this.deleteIndexEntry(key);
464
+ if (entry.source === sourceIndex && entry.size !== 0 && entry.changedAt < scanStart) {
465
+ this.deleteIndexEntry(key);
466
+ removedFromIndex++;
467
+ continue;
468
+ }
469
+ // Counted only when the source SHOULD hold the entry (its window/route match) - these
470
+ // are what the reconcile pass pushes to it
471
+ if (entry.size === 0 || key === ROUTING_FILE) continue;
472
+ if (entry.writeTime < validWindow[0] || entry.writeTime > validWindow[1]) continue;
473
+ if (!routeContains(route, getRoute(key))) continue;
474
+ missingOnSource++;
398
475
  }
476
+ // Percentages are of the union of both sides (our index + their listing), so every count
477
+ // has a stable denominator
478
+ let union = indexSizeBefore + newPaths;
479
+ let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
480
+ console.log(`Metadata scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${files.length} listed vs ${indexSizeBefore} indexed (union ${union}): ${formatScanTally(tally, union)}, ${missingOnSource} in index but missing on source (${pct(missingOnSource)}), ${removedFromIndex} removed from index (${pct(removedFromIndex)})`);
399
481
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
400
482
  return seen;
401
483
  }
@@ -411,12 +493,11 @@ export class BlobStore implements IBucketStore {
411
493
  for (let [key, entry] of this.mem) {
412
494
  if (this.stopped.stop) return;
413
495
  if (entry.source === sourceIndex) continue;
414
- // Past-window sources only receive the routing file (see writeToSources), and
415
- // sharded sources only the keys routing into them
416
- if (key !== ROUTING_FILE) {
417
- if (!acceptsWrites) continue;
418
- if (!routeContains(route, getRoute(key))) continue;
419
- }
496
+ // The routing file is NEVER synchronized between storage nodes - it is only ever
497
+ // written directly to each node, and only ever read off our own disk
498
+ if (key === ROUTING_FILE) continue;
499
+ if (!acceptsWrites) continue;
500
+ if (!routeContains(route, getRoute(key))) continue;
420
501
  let theirTime = listing.get(key);
421
502
  if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
422
503
  if (entry.size === 0) {
@@ -435,16 +516,28 @@ export class BlobStore implements IBucketStore {
435
516
  }
436
517
  }
437
518
 
438
- private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
439
- let { validWindow, route } = this.sources[sourceIndex];
440
- let [windowStart, windowEnd] = validWindow;
441
- if (file.createTime < windowStart || file.createTime > windowEnd) return;
442
- // A partially-overlapping shard's listing includes keys outside our route; ignore them
443
- if (file.path !== ROUTING_FILE && !routeContains(route, getRoute(file.path))) return;
519
+ private applyScanned(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
520
+ if (file.path === ROUTING_FILE) {
521
+ // The routing config is NEVER pulled from other sources - it only ever arrives as an
522
+ // explicit, version-validated write, and is only ever read off our own disk. Route and
523
+ // valid-window filters can't possibly apply to it either: it is the file DEFINING
524
+ // them, so filtering it would mean certain sources could never have their routing
525
+ // config updated, ever.
526
+ if (sourceIndex !== 0) return "filtered";
527
+ } else {
528
+ let { validWindow, route } = this.sources[sourceIndex];
529
+ let [windowStart, windowEnd] = validWindow;
530
+ if (file.createTime < windowStart || file.createTime > windowEnd) return "filtered";
531
+ // A partially-overlapping shard's listing includes keys outside our route; ignore them
532
+ if (!routeContains(route, getRoute(file.path))) return "filtered";
533
+ }
444
534
  let existing = this.mem.get(file.path);
445
535
  // The highest write time wins across all sources (ties keep the existing entry)
446
- if (existing && file.createTime <= existing.writeTime) return;
536
+ if (existing && file.createTime <= existing.writeTime) return "unchanged";
447
537
  this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, source: sourceIndex });
538
+ if (file.size === 0) return "tombstone";
539
+ if (existing) return "updated";
540
+ return "new";
448
541
  }
449
542
 
450
543
  private async pollChanges(sourceIndex: number): Promise<void> {
@@ -453,8 +546,13 @@ export class BlobStore implements IBucketStore {
453
546
  let state = this.sourceStates[sourceIndex];
454
547
  let pollStart = Date.now();
455
548
  let changes = await source.getChangesAfter(state.changesAfterTime);
549
+ let tally = newScanTally();
456
550
  for (let file of changes) {
457
- this.applyScanned(sourceIndex, file);
551
+ tally[this.applyScanned(sourceIndex, file)]++;
552
+ }
553
+ // Polls run constantly, so only the ones that actually changed the index get a line
554
+ if (tally.new || tally.updated || tally.tombstone) {
555
+ console.log(`Changes poll of ${source.getDebugName()} (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
458
556
  }
459
557
  state.scannedCount += changes.length;
460
558
  state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
@@ -625,6 +723,12 @@ export class BlobStore implements IBucketStore {
625
723
  }
626
724
  return result;
627
725
  }
726
+ // The routing file is only ever read off our own disk - falling back to another source's
727
+ // copy would synchronize it between nodes through the read path, which it never is
728
+ if (key === ROUTING_FILE) {
729
+ if (holderError) throw holderError;
730
+ return undefined;
731
+ }
628
732
  // The holder is down or lost the file. ANY other source's copy beats no value - even an
629
733
  // OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
630
734
  for (let i = 0; i < this.sources.length; i++) {
@@ -669,22 +773,24 @@ export class BlobStore implements IBucketStore {
669
773
  }
670
774
  let writeTime = lastModified || Date.now();
671
775
  if (config?.fast) {
672
- let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
673
- let flushAt = Date.now() + writeDelay;
674
- let deadline = this.config?.getFlushDeadline?.();
675
- if (deadline !== undefined) {
676
- if (Date.now() >= deadline) {
677
- if (!this.loggedFlushDeadline) {
678
- this.loggedFlushDeadline = true;
679
- console.log(`Deploy switchover flush deadline passed (store ${this.folder}): fast writes now write through immediately`);
680
- }
681
- this.overlay.delete(key);
682
- await this.writeToSources(key, data, writeTime);
683
- return key;
684
- }
685
- flushAt = Math.min(flushAt, deadline);
776
+ // A writeDelay of zero is a real choice (no delay at all), so only an omitted delay
777
+ // gets the default
778
+ let writeDelay = config.writeDelay;
779
+ if (writeDelay === undefined) {
780
+ writeDelay = DEFAULT_FAST_WRITE_DELAY;
781
+ }
782
+ // The delay never extends past our own valid window's end (minus the margin, so the
783
+ // writes are on disk before the next window's source takes over - a deploy switchover
784
+ // is just this too, since its remap ends our window). Past that point fast writes
785
+ // write through immediately.
786
+ let deadline = this.sources[0].validWindow[1] - WINDOW_END_FLUSH_MARGIN;
787
+ if (writeDelay > 0 && Date.now() < deadline) {
788
+ let flushAt = Math.min(Date.now() + writeDelay, deadline);
789
+ this.overlay.set(key, { data, t: writeTime, flushAt });
790
+ return key;
686
791
  }
687
- this.overlay.set(key, { data, t: writeTime, flushAt });
792
+ this.overlay.delete(key);
793
+ await this.writeToSources(key, data, writeTime);
688
794
  return key;
689
795
  }
690
796
  this.overlay.delete(key);
@@ -709,9 +815,10 @@ export class BlobStore implements IBucketStore {
709
815
  }
710
816
 
711
817
  private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
712
- // The routing file is exempt from the valid-window and route write filters: the CONFIG
713
- // must keep flowing to every source, or a client probing one of them first would adopt a
714
- // stale config. Only data writes stop.
818
+ // The routing file is NEVER synchronized between storage nodes: the writer writes it
819
+ // directly to each node, so we store it on our own disk only (no valid-window filter -
820
+ // routing/valid windows can't possibly apply to the file defining them) and never forward
821
+ // it to other sources.
715
822
  let isRouting = key === ROUTING_FILE;
716
823
  let writable = this.getWritableSources({ ignoreWindow: isRouting });
717
824
  let first = writable.shift();
@@ -728,9 +835,10 @@ export class BlobStore implements IBucketStore {
728
835
  await this.sources[first].source.set(key, data, { lastModified: writeTime });
729
836
  }
730
837
  this.setIndexEntry(key, { writeTime, size: data.length, source: first });
731
- let route = !isRouting && getRoute(key) || 0;
838
+ if (isRouting) return;
839
+ let route = getRoute(key);
732
840
  for (let i of writable) {
733
- if (!isRouting && !routeContains(this.sources[i].route, route)) continue;
841
+ if (!routeContains(this.sources[i].route, route)) continue;
734
842
  // Downstream sources receive tombstones as actual empty writes, so their listings show
735
843
  // the deletion (size 0) and other stores scan it in as a tombstone
736
844
  void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
@@ -858,7 +966,6 @@ export class BlobStore implements IBucketStore {
858
966
  // holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry
859
967
  // repoints to that source so reads keep working (re-caching on the next read).
860
968
  private evicting = false;
861
- private loggedFlushDeadline = false;
862
969
  private async enforceDiskLimit(): Promise<void> {
863
970
  let limit = this.config?.readerDiskLimit;
864
971
  if (!limit || this.evicting) return;
@@ -46,7 +46,7 @@ const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
46
46
  // explicit writes surface the denial (with grant instructions) to the caller instead of hanging.
47
47
  export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
48
48
  if (source.type === "backblaze") {
49
- return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable });
49
+ return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable, allowedOrigins: source.allowedOrigins });
50
50
  }
51
51
  let parsed = parseHostedUrl(source.url);
52
52
  let server = isNode() && getStorageServerConfigOptional() || undefined;
@@ -214,7 +214,17 @@ export class ArchivesChain implements IArchives {
214
214
  // A rejected write fails init, which retries from scratch - re-reading the
215
215
  // routing, so losing a create race to another client just adopts their
216
216
  // config on the next attempt.
217
- await found.probe.write(archives => archives.set(ROUTING_FILE, serializeRemoteConfig(this.configured)));
217
+ // The routing file is NEVER synchronized between storage nodes, so it is
218
+ // written directly to EVERY node, with one shared write time (the latest
219
+ // write time wins on each node independently)
220
+ let routingData = serializeRemoteConfig(this.configured);
221
+ let routingWriteTime = Date.now();
222
+ let writtenUrls = new Set<string>();
223
+ for (let source of sources) {
224
+ if (writtenUrls.has(source.config.url)) continue;
225
+ writtenUrls.add(source.config.url);
226
+ await source.write(archives => archives.set(ROUTING_FILE, routingData, { lastModified: routingWriteTime }));
227
+ }
218
228
  } catch (e) {
219
229
  for (let source of sources) {
220
230
  source.dispose();
@@ -1,8 +1,8 @@
1
1
  import { RemoteConfig } from "../IArchives";
2
- export type TakeoverEvent = "remapChanged" | "diskScan";
2
+ export type TakeoverEvent = "remapChanged";
3
3
  /** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
4
4
  * works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
5
- * the flush deadline, the tighter acquisition pacing) simply stay inert. */
5
+ * the tighter acquisition pacing) simply stay inert. */
6
6
  export declare function initDeployTakeover(config: {
7
7
  domain: string;
8
8
  mainPort: number;
@@ -20,9 +20,9 @@ export declare function applyDeployRemap(routing: RemoteConfig): RemoteConfig;
20
20
  * client learns of a takeover within one ping interval, instead of waiting for its config poll
21
21
  * or a write rejection. */
22
22
  export declare function getTakeoverStamp(): string | undefined;
23
- /** For the dying process: fast-write flush delays must never extend past this time, and after it
24
- * fast writes flush immediately - so nothing is left in memory when the write window transfers. */
25
- export declare function getFlushDeadline(): number | undefined;
23
+ /** The middle-window alternate port of an active remap. The OTHER process of the takeover lives on
24
+ * this port on OUR machine (same disk!), so sources pointing at it are self, never sync targets. */
25
+ export declare function getTakeoverAltPort(): number | undefined;
26
26
  /** How long to wait between main-port acquisition attempts: tight around the predecessor's
27
27
  * scheduled death (when the port actually frees), relaxed otherwise. */
28
28
  export declare function getMainPortAcquireDelay(): number;
@@ -29,12 +29,6 @@ const PORT_REGISTRY_FOLDER_NAME = "storagePortRegistry";
29
29
  const ACQUIRE_SLOW_DELAY = 30 * 1000;
30
30
  const ACQUIRE_FAST_DELAY = 5 * 1000;
31
31
  const ACQUIRE_FAST_WINDOW = 60 * 1000;
32
- // Disk rescans fire this far before and after the write handoff, catching files the other process
33
- // wrote (the BulkDatabase2 index is atomic but its entries land at write time, not file time)
34
- const SWITCH_SCAN_LEAD = 10 * 1000;
35
- // The dying process must have flushed everything before the handoff: fast-write delays never
36
- // extend past S + overlap * this fraction, and after that point fast writes flush immediately
37
- const FLUSH_DEADLINE_FRACTION = 0.25;
38
32
  const BOUNDARY_A_FRACTION = 0.5;
39
33
  const BOUNDARY_B_FRACTION = 1.5;
40
34
  const REMAP_EXPIRE_FRACTION = 2;
@@ -58,7 +52,7 @@ type TakeoverComputed = {
58
52
  predecessorEnd?: number;
59
53
  };
60
54
 
61
- export type TakeoverEvent = "remapChanged" | "diskScan";
55
+ export type TakeoverEvent = "remapChanged";
62
56
 
63
57
  let initialized: { domain: string; mainPort: number; storageFolder: string } | undefined;
64
58
  let ancestorPids = new Set<number>();
@@ -202,18 +196,32 @@ function scheduleEvents(): void {
202
196
  (timer as { unref?: () => void }).unref?.();
203
197
  eventTimers.push(timer);
204
198
  };
205
- let remap = current.remap;
199
+ const remap = current.remap;
206
200
  if (remap) {
207
201
  let altPort = remap.altPort;
208
- schedule(remap.boundaryA - SWITCH_SCAN_LEAD, () => emit("diskScan"));
209
- schedule(remap.boundaryA, () => console.log(`Deploy switchover write handoff boundary passed: fresh writes now belong to the alternate-port side (port ${altPort})`));
210
- schedule(remap.boundaryA + SWITCH_SCAN_LEAD, () => emit("diskScan"));
211
- schedule(remap.boundaryB, () => console.log(`Deploy switchover second boundary passed: fresh writes return to the main port`));
202
+ schedule(remap.boundaryA, () => console.log(`${logPrefix()} Deploy switchover write handoff boundary ${iso(remap.boundaryA)} passed: fresh writes now belong to the alternate-port side (port ${altPort})`));
203
+ schedule(remap.boundaryB, () => console.log(`${logPrefix()} Deploy switchover second boundary ${iso(remap.boundaryB)} passed: fresh writes return to the main port`));
212
204
  schedule(remap.expire, () => void recompute().catch((e: Error) => console.error(`Deploy takeover recompute failed: ${e.stack ?? e}`)));
213
205
  }
214
206
  }
215
207
 
216
- async function recompute(): Promise<void> {
208
+ // Multiple processes (predecessor + successor) often share one journal during a switchover, so
209
+ // every lifecycle line identifies its process
210
+ function logPrefix(): string {
211
+ return `[pid ${process.pid}]`;
212
+ }
213
+
214
+ // Recompute is invoked from init, registerAltPort, the poll, and the expiry timer - possibly
215
+ // concurrently. Runs are serialized, or two in-flight runs would both pass the changed-state check
216
+ // and double-fire every transition (logs, rebuild events, timers).
217
+ let recomputeChain = Promise.resolve();
218
+ function recompute(): Promise<void> {
219
+ let next = recomputeChain.then(() => recomputeNow());
220
+ recomputeChain = next.then(() => { }, () => { });
221
+ return next;
222
+ }
223
+
224
+ async function recomputeNow(): Promise<void> {
217
225
  if (!initialized || !timelineFolder) return;
218
226
  let now = Date.now();
219
227
  let entries = await readTimelineEntries();
@@ -227,11 +235,11 @@ async function recompute(): Promise<void> {
227
235
  // Matching failures must be loud, or takeover detection fails silently forever
228
236
  if (!us && entries.length && !loggedNoMatch) {
229
237
  loggedNoMatch = true;
230
- console.warn(`No deploy timeline entry matches our pid chain (${[...ancestorPids].join(", ")}) - takeover detection cannot work. Entries: ${entries.map(x => `pid=${x.pid ?? "none"} releaseTime=${x.parameters.releaseTime}`).join(" | ")}`);
238
+ console.warn(`${logPrefix()} No deploy timeline entry matches our pid chain (${[...ancestorPids].join(", ")}) - takeover detection cannot work. Entries: ${entries.map(x => `pid=${x.pid ?? "none"} releaseTime=${x.parameters.releaseTime}`).join(" | ")}`);
231
239
  }
232
240
  if (us && !loggedIdentity) {
233
241
  loggedIdentity = true;
234
- console.log(`Deploy timeline entry matched: we are pid=${us.pid}, releaseTime=${us.parameters.releaseTime !== undefined && iso(us.parameters.releaseTime) || "unknown"}, ${entries.length} entries total`);
242
+ console.log(`${logPrefix()} Deploy timeline entry matched: we are pid=${us.pid}, releaseTime=${us.parameters.releaseTime !== undefined && iso(us.parameters.releaseTime) || "unknown"}, ${entries.length} entries total`);
235
243
  }
236
244
  let computed: TakeoverComputed = {};
237
245
  let usRelease = us?.parameters.releaseTime;
@@ -254,9 +262,10 @@ async function recompute(): Promise<void> {
254
262
  if (next) {
255
263
  let successorStart = next.release;
256
264
  let overlap = next.entry.parameters.overlapTime || us.parameters.overlapTime || 0;
257
- // Even a ZERO-overlap deploy needs the dying behaviors - draining fast-write flushes
258
- // by the release and writing through after it, or acknowledged data dies with us.
259
- // Only the remap (the alternate-port middle window) needs an actual overlap.
265
+ // Tracked even for a ZERO-overlap deploy (the scheduled-switchover log must still
266
+ // fire); only the remap (the alternate-port middle window) needs an actual overlap.
267
+ // Flushing before the handoff needs nothing special here: the remap ends our valid
268
+ // window, and fast writes always flush ahead of their window's end.
260
269
  computed.dying = { successorStart, overlap };
261
270
  if (overlap > 0 && now < successorStart + overlap * REMAP_EXPIRE_FRACTION) {
262
271
  const successorPid = next.entry.pid;
@@ -294,31 +303,31 @@ function iso(time: number): string {
294
303
  function logTransitions(prev: TakeoverComputed, next: TakeoverComputed): void {
295
304
  if (!prev.dying && next.dying) {
296
305
  let { successorStart, overlap } = next.dying;
297
- console.log(`Deploy switchover scheduled: our successor starts at ${iso(successorStart)}; fast-write flushes drain by ${iso(successorStart + overlap * FLUSH_DEADLINE_FRACTION)}, fresh writes hand off at ${iso(successorStart + overlap * BOUNDARY_A_FRACTION)}, and we are killed at ${iso(successorStart + overlap)}`);
306
+ console.log(`${logPrefix()} Deploy switchover scheduled: our successor starts at ${iso(successorStart)}; fresh writes hand off at ${iso(successorStart + overlap * BOUNDARY_A_FRACTION)}, and we are killed at ${iso(successorStart + overlap)}`);
298
307
  }
299
308
  if (!prev.predecessorEnd && next.predecessorEnd) {
300
- console.log(`We are a deploy successor: our predecessor holds the main port until ${iso(next.predecessorEnd)}`);
309
+ console.log(`${logPrefix()} We are a deploy successor: our predecessor holds the main port until ${iso(next.predecessorEnd)}`);
301
310
  }
302
311
  if (!prev.remap && next.remap) {
303
- console.log(`Deploy switchover remap active: sources pointing at us are split so [${iso(next.remap.boundaryA)} .. ${iso(next.remap.boundaryB)}] routes to alternate port ${next.remap.altPort} (remap expires at ${iso(next.remap.expire)})`);
312
+ console.log(`${logPrefix()} Deploy switchover remap active: sources pointing at us are split so [${iso(next.remap.boundaryA)} .. ${iso(next.remap.boundaryB)}] routes to alternate port ${next.remap.altPort} (remap expires at ${iso(next.remap.expire)})`);
304
313
  }
305
314
  if (prev.remap && !next.remap) {
306
- console.log(`Deploy switchover remap ended; running on the plain stored routing config`);
315
+ console.log(`${logPrefix()} Deploy switchover remap ended; running on the plain stored routing config`);
307
316
  }
308
317
  }
309
318
 
310
319
  /** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
311
320
  * works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
312
- * the flush deadline, the tighter acquisition pacing) simply stay inert. */
321
+ * the tighter acquisition pacing) simply stay inert. */
313
322
  export async function initDeployTakeover(config: { domain: string; mainPort: number; storageFolder: string }): Promise<void> {
314
323
  initialized = config;
315
324
  ancestorPids = await getAncestorPids();
316
325
  timelineFolder = getTimelineFolder();
317
326
  let entries = await readTimelineEntries();
318
327
  if (!entries.length) {
319
- console.log(`No deploy timeline entries in ${timelineFolder}; deploy takeover inactive until they appear (port fallback still works)`);
328
+ console.log(`${logPrefix()} No deploy timeline entries in ${timelineFolder}; deploy takeover inactive until they appear (port fallback still works)`);
320
329
  } else {
321
- console.log(`Deploy timeline found at ${timelineFolder} (${entries.length} entries); our pid chain: ${[...ancestorPids].join(" -> ")}`);
330
+ console.log(`${logPrefix()} Deploy timeline found at ${timelineFolder} (${entries.length} entries); our pid chain: ${[...ancestorPids].join(" -> ")}`);
322
331
  }
323
332
  await recompute();
324
333
  let poll = setInterval(() => {
@@ -401,12 +410,10 @@ export function getTakeoverStamp(): string | undefined {
401
410
  return JSON.stringify(remap);
402
411
  }
403
412
 
404
- /** For the dying process: fast-write flush delays must never extend past this time, and after it
405
- * fast writes flush immediately - so nothing is left in memory when the write window transfers. */
406
- export function getFlushDeadline(): number | undefined {
407
- let dying = current.dying;
408
- if (!dying) return undefined;
409
- return dying.successorStart + dying.overlap * FLUSH_DEADLINE_FRACTION;
413
+ /** The middle-window alternate port of an active remap. The OTHER process of the takeover lives on
414
+ * this port on OUR machine (same disk!), so sources pointing at it are self, never sync targets. */
415
+ export function getTakeoverAltPort(): number | undefined {
416
+ return current.remap?.altPort;
410
417
  }
411
418
 
412
419
  /** How long to wait between main-port acquisition attempts: tight around the predecessor's