sliftutils 1.7.22 → 1.7.24

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,7 +424,7 @@ 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
430
  let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
@@ -375,24 +441,43 @@ export class BlobStore implements IBucketStore {
375
441
  clearInterval(progressTimer);
376
442
  this.syncActivities.delete(activity);
377
443
  }
378
- console.log(`Metadata scan of ${source.getDebugName()} finished: ${files.length} files in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder})`);
444
+ let indexSizeBefore = this.mem.size;
379
445
  let seen = new Map<string, number>();
446
+ let tally = newScanTally();
447
+ let newPaths = 0;
380
448
  for (let file of files) {
381
449
  seen.set(file.path, file.createTime);
382
- this.applyScanned(sourceIndex, file);
450
+ if (!this.mem.has(file.path)) {
451
+ newPaths++;
452
+ }
453
+ tally[this.applyScanned(sourceIndex, file)]++;
383
454
  }
384
455
  state.scannedCount = files.length;
385
456
  // Index entries this source was the holder of, but that vanished from it (e.g. deleted
386
457
  // while we were offline), come out of the index. Entries changed after the scan started
387
458
  // are kept — the scan listing may simply predate them. Tombstones have no physical file
388
459
  // for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
460
+ let removedFromIndex = 0;
461
+ let missingOnSource = 0;
389
462
  for (let [key, entry] of this.mem) {
390
- if (entry.source !== sourceIndex) continue;
391
- if (entry.size === 0) continue;
392
463
  if (seen.has(key)) continue;
393
- if (entry.changedAt >= scanStart) continue;
394
- 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++;
395
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)})`);
396
481
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
397
482
  return seen;
398
483
  }
@@ -408,12 +493,11 @@ export class BlobStore implements IBucketStore {
408
493
  for (let [key, entry] of this.mem) {
409
494
  if (this.stopped.stop) return;
410
495
  if (entry.source === sourceIndex) continue;
411
- // Past-window sources only receive the routing file (see writeToSources), and
412
- // sharded sources only the keys routing into them
413
- if (key !== ROUTING_FILE) {
414
- if (!acceptsWrites) continue;
415
- if (!routeContains(route, getRoute(key))) continue;
416
- }
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;
417
501
  let theirTime = listing.get(key);
418
502
  if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
419
503
  if (entry.size === 0) {
@@ -432,16 +516,28 @@ export class BlobStore implements IBucketStore {
432
516
  }
433
517
  }
434
518
 
435
- private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
436
- let { validWindow, route } = this.sources[sourceIndex];
437
- let [windowStart, windowEnd] = validWindow;
438
- if (file.createTime < windowStart || file.createTime > windowEnd) return;
439
- // A partially-overlapping shard's listing includes keys outside our route; ignore them
440
- 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
+ }
441
534
  let existing = this.mem.get(file.path);
442
535
  // The highest write time wins across all sources (ties keep the existing entry)
443
- if (existing && file.createTime <= existing.writeTime) return;
536
+ if (existing && file.createTime <= existing.writeTime) return "unchanged";
444
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";
445
541
  }
446
542
 
447
543
  private async pollChanges(sourceIndex: number): Promise<void> {
@@ -450,8 +546,13 @@ export class BlobStore implements IBucketStore {
450
546
  let state = this.sourceStates[sourceIndex];
451
547
  let pollStart = Date.now();
452
548
  let changes = await source.getChangesAfter(state.changesAfterTime);
549
+ let tally = newScanTally();
453
550
  for (let file of changes) {
454
- 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)}`);
455
556
  }
456
557
  state.scannedCount += changes.length;
457
558
  state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
@@ -622,6 +723,12 @@ export class BlobStore implements IBucketStore {
622
723
  }
623
724
  return result;
624
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
+ }
625
732
  // The holder is down or lost the file. ANY other source's copy beats no value - even an
626
733
  // OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
627
734
  for (let i = 0; i < this.sources.length; i++) {
@@ -666,20 +773,24 @@ export class BlobStore implements IBucketStore {
666
773
  }
667
774
  let writeTime = lastModified || Date.now();
668
775
  if (config?.fast) {
669
- let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
670
- let flushAt = Date.now() + writeDelay;
671
- let deadline = this.config?.getFlushDeadline?.();
672
- if (deadline !== undefined) {
673
- // Past the deadline fast writes write through (deployTakeover logs the transition
674
- // once, with the times - a per-store log here would repeat on every store rebuild)
675
- if (Date.now() >= deadline) {
676
- this.overlay.delete(key);
677
- await this.writeToSources(key, data, writeTime);
678
- return key;
679
- }
680
- 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;
681
791
  }
682
- this.overlay.set(key, { data, t: writeTime, flushAt });
792
+ this.overlay.delete(key);
793
+ await this.writeToSources(key, data, writeTime);
683
794
  return key;
684
795
  }
685
796
  this.overlay.delete(key);
@@ -704,9 +815,10 @@ export class BlobStore implements IBucketStore {
704
815
  }
705
816
 
706
817
  private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
707
- // The routing file is exempt from the valid-window and route write filters: the CONFIG
708
- // must keep flowing to every source, or a client probing one of them first would adopt a
709
- // 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.
710
822
  let isRouting = key === ROUTING_FILE;
711
823
  let writable = this.getWritableSources({ ignoreWindow: isRouting });
712
824
  let first = writable.shift();
@@ -723,9 +835,10 @@ export class BlobStore implements IBucketStore {
723
835
  await this.sources[first].source.set(key, data, { lastModified: writeTime });
724
836
  }
725
837
  this.setIndexEntry(key, { writeTime, size: data.length, source: first });
726
- let route = !isRouting && getRoute(key) || 0;
838
+ if (isRouting) return;
839
+ let route = getRoute(key);
727
840
  for (let i of writable) {
728
- if (!isRouting && !routeContains(this.sources[i].route, route)) continue;
841
+ if (!routeContains(this.sources[i].route, route)) continue;
729
842
  // Downstream sources receive tombstones as actual empty writes, so their listings show
730
843
  // the deletion (size 0) and other stores scan it in as a tombstone
731
844
  void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
@@ -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;
@@ -92,7 +92,7 @@ export class ArchivesChain implements IArchives {
92
92
 
93
93
  public getDebugName() {
94
94
  let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
95
- return `chain/${urls.join(",")}`;
95
+ return `chain ${urls.join(", ")}`;
96
96
  }
97
97
 
98
98
  // Lazy init that rethrows its error to every caller, while a background timer resets and
@@ -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;
@@ -23,9 +23,6 @@ export declare function getTakeoverStamp(): string | undefined;
23
23
  /** The middle-window alternate port of an active remap. The OTHER process of the takeover lives on
24
24
  * this port on OUR machine (same disk!), so sources pointing at it are self, never sync targets. */
25
25
  export declare function getTakeoverAltPort(): number | undefined;
26
- /** For the dying process: fast-write flush delays must never extend past this time, and after it
27
- * fast writes flush immediately - so nothing is left in memory when the write window transfers. */
28
- export declare function getFlushDeadline(): number | undefined;
29
26
  /** How long to wait between main-port acquisition attempts: tight around the predecessor's
30
27
  * scheduled death (when the port actually frees), relaxed otherwise. */
31
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>();
@@ -205,27 +199,10 @@ function scheduleEvents(): void {
205
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
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})`));
210
- schedule(remap.boundaryA + SWITCH_SCAN_LEAD, () => emit("diskScan"));
211
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
- let dying = current.dying;
215
- if (dying) {
216
- let deadline = dying.successorStart + dying.overlap * FLUSH_DEADLINE_FRACTION;
217
- let killTime = dying.successorStart + dying.overlap;
218
- let logDeadline = () => {
219
- if (loggedFlushDeadlineFor === deadline) return;
220
- loggedFlushDeadlineFor = deadline;
221
- console.log(`${logPrefix()} Deploy switchover flush deadline ${iso(deadline)} reached (now ${iso(Date.now())}): fast writes write through immediately until we are killed at ${iso(killTime)}`);
222
- };
223
- if (deadline <= now) {
224
- logDeadline();
225
- } else {
226
- schedule(deadline, logDeadline);
227
- }
228
- }
229
206
  }
230
207
 
231
208
  // Multiple processes (predecessor + successor) often share one journal during a switchover, so
@@ -233,7 +210,6 @@ function scheduleEvents(): void {
233
210
  function logPrefix(): string {
234
211
  return `[pid ${process.pid}]`;
235
212
  }
236
- let loggedFlushDeadlineFor: number | undefined;
237
213
 
238
214
  // Recompute is invoked from init, registerAltPort, the poll, and the expiry timer - possibly
239
215
  // concurrently. Runs are serialized, or two in-flight runs would both pass the changed-state check
@@ -286,9 +262,10 @@ async function recomputeNow(): Promise<void> {
286
262
  if (next) {
287
263
  let successorStart = next.release;
288
264
  let overlap = next.entry.parameters.overlapTime || us.parameters.overlapTime || 0;
289
- // Even a ZERO-overlap deploy needs the dying behaviors - draining fast-write flushes
290
- // by the release and writing through after it, or acknowledged data dies with us.
291
- // 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.
292
269
  computed.dying = { successorStart, overlap };
293
270
  if (overlap > 0 && now < successorStart + overlap * REMAP_EXPIRE_FRACTION) {
294
271
  const successorPid = next.entry.pid;
@@ -326,7 +303,7 @@ function iso(time: number): string {
326
303
  function logTransitions(prev: TakeoverComputed, next: TakeoverComputed): void {
327
304
  if (!prev.dying && next.dying) {
328
305
  let { successorStart, overlap } = next.dying;
329
- console.log(`${logPrefix()} 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)}`);
330
307
  }
331
308
  if (!prev.predecessorEnd && next.predecessorEnd) {
332
309
  console.log(`${logPrefix()} We are a deploy successor: our predecessor holds the main port until ${iso(next.predecessorEnd)}`);
@@ -341,7 +318,7 @@ function logTransitions(prev: TakeoverComputed, next: TakeoverComputed): void {
341
318
 
342
319
  /** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
343
320
  * works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
344
- * the flush deadline, the tighter acquisition pacing) simply stay inert. */
321
+ * the tighter acquisition pacing) simply stay inert. */
345
322
  export async function initDeployTakeover(config: { domain: string; mainPort: number; storageFolder: string }): Promise<void> {
346
323
  initialized = config;
347
324
  ancestorPids = await getAncestorPids();
@@ -439,14 +416,6 @@ export function getTakeoverAltPort(): number | undefined {
439
416
  return current.remap?.altPort;
440
417
  }
441
418
 
442
- /** For the dying process: fast-write flush delays must never extend past this time, and after it
443
- * fast writes flush immediately - so nothing is left in memory when the write window transfers. */
444
- export function getFlushDeadline(): number | undefined {
445
- let dying = current.dying;
446
- if (!dying) return undefined;
447
- return dying.successorStart + dying.overlap * FLUSH_DEADLINE_FRACTION;
448
- }
449
-
450
419
  /** How long to wait between main-port acquisition attempts: tight around the predecessor's
451
420
  * scheduled death (when the port actually frees), relaxed otherwise. */
452
421
  export function getMainPortAcquireDelay(): number {