sliftutils 1.7.32 → 1.7.33

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.
Files changed (116) hide show
  1. package/builders/extensionBuild.ts +1 -1
  2. package/bundler/bundleEntry.ts +1 -3
  3. package/bundler/bundler.ts +2 -4
  4. package/bundler/sourceMaps.ts +1 -2
  5. package/examplestorage/browser.tsx +1 -2
  6. package/examplestorage/exampleserver.ts +1 -2
  7. package/index.d.ts +105 -100
  8. package/misc/dist/environment.ts.cache +2 -2
  9. package/misc/dist/getSecret.ts.cache +2 -2
  10. package/misc/dist/strings.ts.cache +2 -2
  11. package/misc/dist/zip.ts.cache +2 -2
  12. package/misc/https/dist/certs.ts.cache +2 -2
  13. package/misc/https/dist/dns.ts.cache +7 -16
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/dns.d.ts +1 -4
  16. package/misc/https/dns.ts +5 -14
  17. package/misc/https/hostServer.d.ts +2 -0
  18. package/misc/https/hostServer.ts +6 -7
  19. package/misc/https/httpsCerts.ts +4 -10
  20. package/misc/openrouter.ts +1 -2
  21. package/package.json +1 -1
  22. package/render-utils/FullscreenModal.tsx +1 -4
  23. package/render-utils/GenericFormat.tsx +2 -8
  24. package/render-utils/Input.tsx +5 -13
  25. package/render-utils/InputLabel.tsx +4 -6
  26. package/render-utils/InputPicker.tsx +1 -2
  27. package/render-utils/URLParam.ts +2 -3
  28. package/render-utils/dist/observer.tsx.cache +2 -2
  29. package/render-utils/mobxTyped.ts +2 -3
  30. package/render-utils/niceStringify.ts +2 -4
  31. package/storage/ArchivesDisk.ts +4 -14
  32. package/storage/BulkDatabase2/BulkDatabase2.d.ts +1 -2
  33. package/storage/BulkDatabase2/BulkDatabase2.ts +4 -11
  34. package/storage/BulkDatabase2/BulkDatabaseBase.ts +42 -114
  35. package/storage/BulkDatabase2/BulkDatabaseFormat.ts +31 -91
  36. package/storage/BulkDatabase2/BulkDatabaseMerge.ts +19 -57
  37. package/storage/BulkDatabase2/BulkDatabaseReader.ts +4 -12
  38. package/storage/BulkDatabase2/LoadedIndex.ts +6 -18
  39. package/storage/BulkDatabase2/WriteOverlay.ts +3 -8
  40. package/storage/BulkDatabase2/blockCache.ts +12 -28
  41. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +40 -103
  42. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +19 -50
  43. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +18 -50
  44. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +7 -15
  45. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +9 -21
  46. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +6 -11
  47. package/storage/BulkDatabase2/dist/blockCache.ts.cache +15 -31
  48. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +10 -29
  49. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +10 -18
  50. package/storage/BulkDatabase2/dist/streamLog.ts.cache +17 -43
  51. package/storage/BulkDatabase2/dist/syncClient.ts.cache +11 -27
  52. package/storage/BulkDatabase2/mergeLock.ts +7 -26
  53. package/storage/BulkDatabase2/mergeMarkers.ts +7 -15
  54. package/storage/BulkDatabase2/streamLog.ts +14 -40
  55. package/storage/BulkDatabase2/syncClient.ts +8 -24
  56. package/storage/FileFolderAPI.tsx +28 -75
  57. package/storage/IArchives.d.ts +3 -3
  58. package/storage/IArchives.ts +21 -50
  59. package/storage/IStorage.ts +2 -4
  60. package/storage/PendingManager.tsx +2 -3
  61. package/storage/TransactionStorage.ts +11 -24
  62. package/storage/backblaze.ts +12 -43
  63. package/storage/dist/ArchivesDisk.ts.cache +7 -17
  64. package/storage/dist/FileFolderAPI.tsx.cache +25 -63
  65. package/storage/dist/IArchives.ts.cache +10 -20
  66. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  67. package/storage/dist/JSONStorage.ts.cache +2 -2
  68. package/storage/dist/PendingManager.tsx.cache +5 -6
  69. package/storage/dist/TransactionStorage.ts.cache +14 -27
  70. package/storage/dist/backblaze.ts.cache +15 -46
  71. package/storage/dist/embeddingFormats.ts.cache +11 -24
  72. package/storage/dist/fileSystemPointer.ts.cache +4 -7
  73. package/storage/dist/remoteFileStorage.ts.cache +17 -36
  74. package/storage/embeddingFormats.ts +11 -27
  75. package/storage/fileSystemPointer.ts +2 -7
  76. package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +8 -18
  77. package/storage/proxydatabase/dist/transactionSet.ts.cache +4 -7
  78. package/storage/proxydatabase/inMemoryDatabase.ts +1 -3
  79. package/storage/proxydatabase/ivfEmbeddingDatabase.ts +8 -23
  80. package/storage/proxydatabase/transactionSet.ts +2 -5
  81. package/storage/remoteFileServer.ts +20 -48
  82. package/storage/remoteFileStorage.ts +14 -33
  83. package/storage/remoteStorage/ArchivesRemote.d.ts +1 -4
  84. package/storage/remoteStorage/ArchivesRemote.ts +11 -26
  85. package/storage/remoteStorage/ArchivesUrl.ts +2 -4
  86. package/storage/remoteStorage/accessPage.tsx +1 -5
  87. package/storage/remoteStorage/blobStore.d.ts +5 -13
  88. package/storage/remoteStorage/blobStore.ts +52 -130
  89. package/storage/remoteStorage/createArchives.d.ts +9 -13
  90. package/storage/remoteStorage/createArchives.ts +43 -187
  91. package/storage/remoteStorage/deployTakeover.d.ts +18 -32
  92. package/storage/remoteStorage/deployTakeover.ts +70 -384
  93. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +12 -24
  94. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +5 -7
  95. package/storage/remoteStorage/dist/blobStore.ts.cache +47 -117
  96. package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
  97. package/storage/remoteStorage/dist/createArchives.ts.cache +48 -189
  98. package/storage/remoteStorage/dist/deployTakeover.ts.cache +81 -387
  99. package/storage/remoteStorage/dist/intermediateSources.ts.cache +119 -0
  100. package/storage/remoteStorage/dist/remoteConfig.ts.cache +36 -24
  101. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +20 -40
  102. package/storage/remoteStorage/dist/storageClientController.ts.cache +4 -6
  103. package/storage/remoteStorage/dist/storageController.ts.cache +11 -73
  104. package/storage/remoteStorage/dist/storageServerState.ts.cache +265 -178
  105. package/storage/remoteStorage/grantAccessCli.ts +1 -3
  106. package/storage/remoteStorage/intermediateSources.d.ts +21 -0
  107. package/storage/remoteStorage/intermediateSources.ts +117 -0
  108. package/storage/remoteStorage/remoteConfig.ts +33 -20
  109. package/storage/remoteStorage/sourceWrapper.d.ts +8 -19
  110. package/storage/remoteStorage/sourceWrapper.ts +19 -41
  111. package/storage/remoteStorage/storageClientController.ts +1 -3
  112. package/storage/remoteStorage/storageController.d.ts +4 -5
  113. package/storage/remoteStorage/storageController.ts +10 -81
  114. package/storage/remoteStorage/storageServer.ts +20 -34
  115. package/storage/remoteStorage/storageServerState.d.ts +28 -5
  116. package/storage/remoteStorage/storageServerState.ts +271 -207
@@ -1,438 +1,124 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { exec } from "child_process";
4
- import { RemoteConfig, RemoteConfigBase, HostedConfig } from "../IArchives";
5
- import { parseHostedUrl, replaceHostedUrlPort } from "./remoteConfig";
6
-
7
- // Deploy takeover: the deploy manager (querysub's parametersTimeline) writes <n>-parameters.json
8
- // files into our instance folder describing when each version runs and when it is shut down. Old
9
- // and new version overlap for the overlap time (O = oldEnd - newStart). We turn that overlap into
10
- // an atomic write handoff by REINTERPRETING every routing source that points at us: its valid
11
- // window is split so that the middle of the overlap ([S + 0.5*O, S + 1.5*O]) points at the new
12
- // process's temporary alternate port. The existing valid-window machinery then does everything -
13
- // the old process starts rejecting fresh writes at the first boundary, clients re-resolve, and the
14
- // new process (which recognizes the alternate port as itself) takes over. The remap is an
15
- // INTERPRETATION OVERLAY, applied where routing configs are used (see applyDeployRemap callers) -
16
- // it is never written to disk, never synchronized, and never part of any stored configuration.
17
- //
18
- // Everything here is recomputed idempotently from its inputs (timeline files, the port registry,
19
- // our ancestor pids, the clock) - files can appear or change at any moment, and we can start at
20
- // any point during a switchover.
3
+ import { startIntermediateMaintenance } from "./storageServerState";
21
4
 
22
5
  const PARAMETERS_TIMELINE_FILE_REGEX = /^(\d+)-parameters\.json$/;
23
- const TIMELINE_POLL_INTERVAL = 60 * 1000;
24
- // Processes listening on an alternate port register it here (sibling of the storage folder), keyed
25
- // by their actual pid; content includes their ancestor pids so the timeline entry pid (a shell
26
- // ancestor of the process) can be matched to a registry entry
27
- const PORT_REGISTRY_FOLDER_NAME = "storagePortRegistry";
28
- // Main-port acquisition attempts run this often, tightening near the predecessor's scheduled death
6
+ const DEPLOY_DETECT_RETRY_DELAY = 5 * 1000;
7
+ const DEPLOY_DETECT_ATTEMPTS = 6;
8
+ const DEPLOY_RELEASE_MATCH_WINDOW = 5 * 60 * 1000;
9
+ const ALT_PORT_LINGER = 30 * 60 * 1000;
29
10
  const ACQUIRE_SLOW_DELAY = 30 * 1000;
30
- const ACQUIRE_FAST_DELAY = 5 * 1000;
11
+ const ACQUIRE_FAST_DELAY = 1000;
31
12
  const ACQUIRE_FAST_WINDOW = 60 * 1000;
32
- const BOUNDARY_A_FRACTION = 0.5;
33
- const BOUNDARY_B_FRACTION = 1.5;
34
- const REMAP_EXPIRE_FRACTION = 2;
35
13
 
36
14
  type TimelineEntry = {
37
- pid?: number;
38
- // releaseTime + overlapTime define everything: the newer entry's releaseTime is when it
39
- // starts, and releaseTime + overlapTime is when the older one is killed (freeing the port).
40
- // The files also carry an aliveWindow, which is deliberately ignored.
41
15
  parameters: { releaseTime?: number; overlapTime?: number };
42
16
  };
43
17
 
44
- type TakeoverComputed = {
45
- // We are scheduled to die with a successor overlapping us (known from the files alone, before
46
- // the successor's port is discovered - the flush deadline applies immediately)
47
- dying?: { successorStart: number; overlap: number };
48
- // The window reinterpretation, once the middle window's port is known (the successor's
49
- // registered alternate port - or our own, when we ARE the successor)
50
- remap?: { boundaryA: number; boundaryB: number; expire: number; altPort: number };
51
- // When our predecessor dies and frees the main port (for acquisition pacing)
52
- predecessorEnd?: number;
18
+ type DeployTakeover = {
19
+ releaseTime: number;
20
+ overlapTime: number;
21
+ altPort?: number;
53
22
  };
54
23
 
55
- export type TakeoverEvent = "remapChanged";
56
-
57
- let initialized: { domain: string; mainPort: number; storageFolder: string } | undefined;
58
- let ancestorPids = new Set<number>();
59
- let timelineFolder: string | undefined;
60
- let ourAltPort: number | undefined;
61
- let current: TakeoverComputed = {};
62
- let currentKey = JSON.stringify(current);
63
- let eventListeners: ((event: TakeoverEvent) => void)[] = [];
64
- let eventTimers: ReturnType<typeof setTimeout>[] = [];
65
- let loggedNoMatch = false;
66
- let loggedIdentity = false;
24
+ let takeover: DeployTakeover | undefined;
67
25
 
68
- function execText(command: string): Promise<string> {
69
- return new Promise((resolve, reject) => {
70
- exec(command, (error, stdout) => {
71
- if (error) {
72
- reject(error);
73
- return;
74
- }
75
- resolve(stdout);
76
- });
77
- });
26
+ function iso(time: number): string {
27
+ return new Date(time).toISOString();
78
28
  }
79
29
 
80
- // The timeline entry pid is the screen shell that spawned us - an ancestor - so we walk our whole
81
- // ancestor chain to find which entry is us
82
- async function getAncestorPids(): Promise<Set<number>> {
83
- let pids = new Set<number>([process.pid]);
84
- let currentPid = process.pid;
85
- while (true) {
86
- let ppid: number;
87
- if (currentPid === process.pid && process.ppid) {
88
- ppid = process.ppid;
89
- } else {
90
- try {
91
- ppid = parseInt((await execText(`ps -o ppid= -p ${currentPid}`)).trim());
92
- } catch {
93
- break;
94
- }
95
- }
96
- if (!ppid || Number.isNaN(ppid) || ppid <= 1 || pids.has(ppid)) break;
97
- pids.add(ppid);
98
- currentPid = ppid;
99
- }
100
- return pids;
30
+ function logPrefix(): string {
31
+ return `[pid ${process.pid}]`;
101
32
  }
102
33
 
103
- // The instance folder is ALWAYS the parent of our cwd (services run in <instance>/git/) - never
104
- // search for it, a walk could latch onto the wrong folder
105
34
  function getTimelineFolder(): string {
106
35
  return path.dirname(process.cwd()).replaceAll("\\", "/") + "/";
107
36
  }
108
37
 
109
- function pidAlive(pid: number): boolean {
110
- try {
111
- process.kill(pid, 0);
112
- return true;
113
- } catch (e) {
114
- return (e as { code?: string }).code === "EPERM";
115
- }
116
- }
117
-
118
- function getRegistryFolder(): string {
119
- if (!initialized) {
120
- throw new Error(`Deploy takeover is not initialized`);
121
- }
122
- return path.join(path.dirname(path.resolve(initialized.storageFolder)), PORT_REGISTRY_FOLDER_NAME);
123
- }
124
-
125
- type RegistryEntry = { pid: number; port: number; ancestorPids: number[] };
126
-
127
- async function readPortRegistry(): Promise<RegistryEntry[]> {
128
- let folder = getRegistryFolder();
129
- let files: string[] = [];
38
+ async function readTimelineEntries(): Promise<TimelineEntry[]> {
39
+ let folder = getTimelineFolder();
40
+ let files: string[];
130
41
  try {
131
42
  files = await fs.promises.readdir(folder);
132
- } catch {
43
+ } catch (e) {
44
+ console.warn(`${logPrefix()} Could not read the deploy timeline folder ${folder}: ${(e as Error).stack ?? e}`);
133
45
  return [];
134
46
  }
135
- let entries: RegistryEntry[] = [];
136
- for (let file of files) {
137
- let match = /^(\d+)\.json$/.exec(file);
138
- if (!match) continue;
139
- let pid = parseInt(match[1]);
140
- if (!pidAlive(pid)) {
141
- try {
142
- await fs.promises.unlink(path.join(folder, file));
143
- } catch { }
144
- continue;
145
- }
146
- try {
147
- let entry = JSON.parse(await fs.promises.readFile(path.join(folder, file), "utf8")) as RegistryEntry;
148
- entries.push({ ...entry, pid });
149
- } catch { }
150
- }
151
- return entries;
152
- }
153
-
154
- async function readTimelineEntries(): Promise<TimelineEntry[]> {
155
- if (!timelineFolder) return [];
156
47
  let entries: TimelineEntry[] = [];
157
- for (let file of await fs.promises.readdir(timelineFolder)) {
48
+ for (let file of files) {
158
49
  if (!PARAMETERS_TIMELINE_FILE_REGEX.test(file)) continue;
159
50
  try {
160
- entries.push(JSON.parse(await fs.promises.readFile(timelineFolder + file, "utf8")) as TimelineEntry);
51
+ entries.push(JSON.parse(await fs.promises.readFile(folder + file, "utf8")) as TimelineEntry);
161
52
  } catch (e) {
162
- console.warn(`Could not read deploy timeline file ${timelineFolder + file}: ${(e as Error).stack ?? e}`);
53
+ console.warn(`${logPrefix()} Could not read deploy timeline file ${folder + file}: ${(e as Error).stack ?? e}`);
163
54
  }
164
55
  }
165
56
  return entries;
166
57
  }
167
58
 
168
- function makeRemap(successorStart: number, overlap: number, altPort: number): TakeoverComputed["remap"] {
169
- return {
170
- boundaryA: successorStart + overlap * BOUNDARY_A_FRACTION,
171
- boundaryB: successorStart + overlap * BOUNDARY_B_FRACTION,
172
- expire: successorStart + overlap * REMAP_EXPIRE_FRACTION,
173
- altPort,
174
- };
175
- }
176
-
177
- function emit(event: TakeoverEvent): void {
178
- for (let listener of eventListeners) {
179
- try {
180
- listener(event);
181
- } catch (e) {
182
- console.error(`Deploy takeover listener failed: ${(e as Error).stack ?? e}`);
183
- }
184
- }
185
- }
186
-
187
- function scheduleEvents(): void {
188
- for (let timer of eventTimers) {
189
- clearTimeout(timer);
190
- }
191
- eventTimers = [];
192
- let now = Date.now();
193
- let schedule = (time: number, fnc: () => void) => {
194
- if (time <= now) return;
195
- let timer = setTimeout(fnc, time - now);
196
- (timer as { unref?: () => void }).unref?.();
197
- eventTimers.push(timer);
198
- };
199
- const remap = current.remap;
200
- if (remap) {
201
- let altPort = remap.altPort;
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`));
204
- schedule(remap.expire, () => void recompute().catch((e: Error) => console.error(`Deploy takeover recompute failed: ${e.stack ?? e}`)));
205
- }
206
- }
207
-
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> {
225
- if (!initialized || !timelineFolder) return;
226
- let now = Date.now();
227
- let entries = await readTimelineEntries();
228
- let registry = await readPortRegistry();
229
- let us: TimelineEntry | undefined;
59
+ function findOurRelease(entries: TimelineEntry[], now: number): DeployTakeover | undefined {
60
+ let best: DeployTakeover | undefined;
230
61
  for (let entry of entries) {
231
- if (entry.pid && ancestorPids.has(entry.pid)) {
232
- us = entry;
62
+ let releaseTime = entry.parameters.releaseTime;
63
+ if (releaseTime === undefined) continue;
64
+ let overlapTime = entry.parameters.overlapTime || 0;
65
+ if (releaseTime > now + DEPLOY_RELEASE_MATCH_WINDOW) continue;
66
+ if (now > releaseTime + overlapTime + DEPLOY_RELEASE_MATCH_WINDOW) continue;
67
+ if (best && best.releaseTime >= releaseTime) continue;
68
+ best = { releaseTime, overlapTime };
69
+ }
70
+ return best;
71
+ }
72
+
73
+ /** Called when the main port is already in use, which on a healthy machine only happens while our predecessor is still running a deploy overlap. Confirms that against the deploy timeline; if no deploy is in progress we are in a bad state (someone else holds our port) and the process must not keep running. */
74
+ export async function detectDeployTakeover(): Promise<DeployTakeover> {
75
+ for (let attempt = 1; attempt <= DEPLOY_DETECT_ATTEMPTS; attempt++) {
76
+ let entries = await readTimelineEntries();
77
+ let found = findOurRelease(entries, Date.now());
78
+ if (found) {
79
+ takeover = found;
80
+ console.log(`${logPrefix()} Our main port is in use and the deploy timeline confirms a release at ${iso(found.releaseTime)} with an overlap of ${found.overlapTime}ms: we are the successor. Our predecessor is killed at ${iso(found.releaseTime + found.overlapTime)}.`);
81
+ return found;
233
82
  }
234
- }
235
- // Matching failures must be loud, or takeover detection fails silently forever
236
- if (!us && entries.length && !loggedNoMatch) {
237
- loggedNoMatch = true;
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(" | ")}`);
239
- }
240
- if (us && !loggedIdentity) {
241
- loggedIdentity = true;
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`);
243
- }
244
- let computed: TakeoverComputed = {};
245
- let usRelease = us?.parameters.releaseTime;
246
- if (us && usRelease !== undefined) {
247
- // The newer entry's releaseTime is when it starts; releaseTime + overlapTime is when the
248
- // older one is killed. That's the whole timeline.
249
- let next: { entry: TimelineEntry; release: number } | undefined;
250
- let prev: { entry: TimelineEntry; release: number } | undefined;
251
- for (let entry of entries) {
252
- if (entry === us) continue;
253
- let release = entry.parameters.releaseTime;
254
- if (release === undefined) continue;
255
- if (release > usRelease && (!next || release < next.release)) {
256
- next = { entry, release };
257
- }
258
- if (release < usRelease && (!prev || release > prev.release)) {
259
- prev = { entry, release };
260
- }
261
- }
262
- if (next) {
263
- let successorStart = next.release;
264
- let overlap = next.entry.parameters.overlapTime || us.parameters.overlapTime || 0;
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.
269
- computed.dying = { successorStart, overlap };
270
- if (overlap > 0 && now < successorStart + overlap * REMAP_EXPIRE_FRACTION) {
271
- const successorPid = next.entry.pid;
272
- if (successorPid) {
273
- let successorEntry = registry.find(x => x.ancestorPids.includes(successorPid));
274
- if (successorEntry) {
275
- computed.remap = makeRemap(successorStart, overlap, successorEntry.port);
276
- }
277
- }
278
- }
279
- }
280
- if (prev) {
281
- let overlap = us.parameters.overlapTime || prev.entry.parameters.overlapTime || 0;
282
- // The predecessor holds the main port until OUR release + the overlap
283
- computed.predecessorEnd = usRelease + overlap;
284
- if (overlap > 0 && ourAltPort && now < usRelease + overlap * REMAP_EXPIRE_FRACTION && !computed.remap) {
285
- computed.remap = makeRemap(usRelease, overlap, ourAltPort);
286
- }
83
+ console.log(`${logPrefix()} Our main port is in use but the deploy timeline in ${getTimelineFolder()} shows no release in progress (attempt ${attempt} of ${DEPLOY_DETECT_ATTEMPTS}); retrying in ${DEPLOY_DETECT_RETRY_DELAY}ms`);
84
+ if (attempt < DEPLOY_DETECT_ATTEMPTS) {
85
+ await new Promise(resolve => setTimeout(resolve, DEPLOY_DETECT_RETRY_DELAY));
287
86
  }
288
87
  }
289
- let key = JSON.stringify(computed);
290
- if (key === currentKey) return;
291
- logTransitions(current, computed);
292
- current = computed;
293
- currentKey = key;
294
- scheduleEvents();
295
- emit("remapChanged");
296
- }
297
-
298
- function iso(time: number): string {
299
- return new Date(time).toISOString();
300
- }
301
-
302
- // The switchover lifecycle is rare and important, so every stage gets a clear line
303
- function logTransitions(prev: TakeoverComputed, next: TakeoverComputed): void {
304
- if (!prev.dying && next.dying) {
305
- let { successorStart, overlap } = next.dying;
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)}`);
307
- }
308
- if (!prev.predecessorEnd && next.predecessorEnd) {
309
- console.log(`${logPrefix()} We are a deploy successor: our predecessor holds the main port until ${iso(next.predecessorEnd)}`);
310
- }
311
- if (!prev.remap && next.remap) {
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)})`);
313
- }
314
- if (prev.remap && !next.remap) {
315
- console.log(`${logPrefix()} Deploy switchover remap ended; running on the plain stored routing config`);
316
- }
317
- }
318
-
319
- /** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
320
- * works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
321
- * the tighter acquisition pacing) simply stay inert. */
322
- export async function initDeployTakeover(config: { domain: string; mainPort: number; storageFolder: string }): Promise<void> {
323
- initialized = config;
324
- ancestorPids = await getAncestorPids();
325
- timelineFolder = getTimelineFolder();
326
- let entries = await readTimelineEntries();
327
- if (!entries.length) {
328
- console.log(`${logPrefix()} No deploy timeline entries in ${timelineFolder}; deploy takeover inactive until they appear (port fallback still works)`);
329
- } else {
330
- console.log(`${logPrefix()} Deploy timeline found at ${timelineFolder} (${entries.length} entries); our pid chain: ${[...ancestorPids].join(" -> ")}`);
331
- }
332
- await recompute();
333
- let poll = setInterval(() => {
334
- void recompute().catch((e: Error) => console.error(`Deploy takeover recompute failed: ${e.stack ?? e}`));
335
- }, TIMELINE_POLL_INTERVAL);
336
- (poll as { unref?: () => void }).unref?.();
88
+ throw new Error(`Our main port is in use, but no deploy is in progress after ${DEPLOY_DETECT_ATTEMPTS * DEPLOY_DETECT_RETRY_DELAY}ms of checking the deploy timeline in ${getTimelineFolder()}. Something else is holding our port, so this process is in a bad state and is exiting.`);
337
89
  }
338
90
 
339
- /** Called when we had to listen on an alternate port (the main port was still held by our
340
- * predecessor): registers it so the predecessor can route the middle overlap window to us. */
341
- export async function registerAltPort(port: number): Promise<void> {
342
- ourAltPort = port;
343
- let folder = getRegistryFolder();
344
- await fs.promises.mkdir(folder, { recursive: true });
345
- let entry: RegistryEntry = { pid: process.pid, port, ancestorPids: [...ancestorPids] };
346
- await fs.promises.writeFile(path.join(folder, `${process.pid}.json`), JSON.stringify(entry));
347
- await recompute();
348
- }
349
-
350
- export function onTakeoverEvent(listener: (event: TakeoverEvent) => void): void {
351
- eventListeners.push(listener);
352
- }
353
-
354
- /** The interpretation overlay: splits every source pointing at our domain+main port so the middle
355
- * of the deploy overlap points at the alternate port. Pure, in-memory only - the stored routing
356
- * config is never modified, and this must never be applied to data that gets persisted. */
357
- export function applyDeployRemap(routing: RemoteConfig): RemoteConfig {
358
- let remap = current.remap;
359
- let init = initialized;
360
- if (!remap || !init) return routing;
361
- let sources: RemoteConfigBase[] = [];
362
- let slice = (source: HostedConfig, start: number, end: number, url?: string): HostedConfig => {
363
- return { ...source, url: url || source.url, validWindow: [start, end] };
364
- };
365
- for (let source of routing.sources) {
366
- if (typeof source === "string" || source.type !== "remote") {
367
- sources.push(source);
368
- continue;
369
- }
370
- let parsed: { address: string; port: number };
371
- try {
372
- parsed = parseHostedUrl(source.url);
373
- } catch {
374
- sources.push(source);
375
- continue;
376
- }
377
- if (parsed.address !== init.domain || parsed.port !== init.mainPort) {
378
- sources.push(source);
379
- continue;
380
- }
381
- let [start, end] = source.validWindow;
382
- let a = Math.max(start, Math.min(remap.boundaryA, end));
383
- let b = Math.max(start, Math.min(remap.boundaryB, end));
384
- let added = false;
385
- if (a > start) {
386
- sources.push(slice(source, start, a));
387
- added = true;
388
- }
389
- if (b > a) {
390
- sources.push(slice(source, a, b, replaceHostedUrlPort(source.url, remap.altPort)));
391
- added = true;
392
- }
393
- if (end > b) {
394
- sources.push(slice(source, b, end));
395
- added = true;
396
- }
397
- if (!added) {
398
- sources.push(source);
399
- }
91
+ export function setAltPort(port: number): void {
92
+ if (!takeover) {
93
+ throw new Error(`An alternate port (${port}) was taken without a detected deploy takeover - detectDeployTakeover must run first`);
400
94
  }
401
- return { version: routing.version, sources };
95
+ takeover.altPort = port;
96
+ console.log(`${logPrefix()} Listening on alternate port ${port}: writes route here until ${iso(getIntermediateEnd())}, and we keep listening until ${iso(getAltPortListenEnd())}`);
97
+ startIntermediateMaintenance();
402
98
  }
403
99
 
404
- /** A stamp of the current remap interpretation, advertised in ping responses - so every connected
405
- * client learns of a takeover within one ping interval, instead of waiting for its config poll
406
- * or a write rejection. */
407
- export function getTakeoverStamp(): string | undefined {
408
- let remap = current.remap;
409
- if (!remap) return undefined;
410
- return JSON.stringify(remap);
100
+ /** The window in which writes belong to our alternate port: from the release until our predecessor is killed and we take the main port. */
101
+ export function getTakeoverIntermediate(): { start: number; end: number; altPort: number } | undefined {
102
+ if (!takeover?.altPort) return undefined;
103
+ return { start: takeover.releaseTime, end: getIntermediateEnd(), altPort: takeover.altPort };
411
104
  }
412
105
 
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;
106
+ function getIntermediateEnd(): number {
107
+ if (!takeover) return 0;
108
+ return takeover.releaseTime + takeover.overlapTime;
417
109
  }
418
110
 
419
- /** For the dying process of a takeover: our own process's data ends at the write handoff - fast
420
- * writes must be on disk by then. Undefined for the successor and in normal operation. Both
421
- * processes share the config identity (all self windows look like "ours" to both), so this is
422
- * the only way the dying side knows the post-handoff windows belong to the other process. */
423
- export function getOwnWindowEndClip(): number | undefined {
424
- let dying = current.dying;
425
- if (!dying) return undefined;
426
- let remap = current.remap;
427
- if (remap) return remap.boundaryA;
428
- return dying.successorStart;
111
+ /** We never stop listening on the alternate port while its window is still valid, and hold it well past that for clients that have not caught up yet. */
112
+ export function getAltPortListenEnd(): number {
113
+ if (!takeover) return 0;
114
+ return Math.max(takeover.releaseTime + takeover.overlapTime * 2, getIntermediateEnd() + ALT_PORT_LINGER);
429
115
  }
430
116
 
431
- /** How long to wait between main-port acquisition attempts: tight around the predecessor's
432
- * scheduled death (when the port actually frees), relaxed otherwise. */
117
+ /** How long to wait between main-port acquisition attempts: tight around our predecessor's scheduled death (when the port actually frees), relaxed otherwise. */
433
118
  export function getMainPortAcquireDelay(): number {
434
- let end = current.predecessorEnd;
435
- if (end !== undefined && Math.abs(Date.now() - end) <= ACQUIRE_FAST_WINDOW) {
119
+ if (!takeover) return ACQUIRE_SLOW_DELAY;
120
+ let predecessorEnd = takeover.releaseTime + takeover.overlapTime;
121
+ if (Date.now() >= predecessorEnd - ACQUIRE_FAST_WINDOW) {
436
122
  return ACQUIRE_FAST_DELAY;
437
123
  }
438
124
  return ACQUIRE_SLOW_DELAY;