sliftutils 1.7.22 → 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.
@@ -4,14 +4,14 @@ import { getFileStorageNested2 } from "../FileFolderAPI";
4
4
  import { TransactionStorage } from "../TransactionStorage";
5
5
  import { JSONStorage } from "../JSONStorage";
6
6
  import { ArchivesDisk } from "../ArchivesDisk";
7
- import { BlobStore, IBucketStore } from "./blobStore";
7
+ import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
8
8
  import {
9
9
  RemoteConfig, HostedConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
10
10
  ArchivesSyncStatus, FULL_VALID_WINDOW,
11
11
  WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
12
12
  } from "../IArchives";
13
13
  import { ROUTING_FILE, parseRoutingData, parseHostedUrl, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection } from "./remoteConfig";
14
- import { applyDeployRemap, getFlushDeadline, getTakeoverAltPort, onTakeoverEvent } from "./deployTakeover";
14
+ import { applyDeployRemap, getTakeoverAltPort, onTakeoverEvent } from "./deployTakeover";
15
15
  import { createApiArchives } from "./createArchives";
16
16
  import type { IStorage } from "../IStorage";
17
17
  import type { AccessRequest, TrustRecord } from "./storageController";
@@ -233,6 +233,150 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
233
233
  (timer as { unref?: () => void }).unref?.();
234
234
  }
235
235
 
236
+ // Extra scans around each of OUR valid-window starts (a deploy switchover is just this too - its
237
+ // remap adds valid windows), catching last-moment writes the previous window's owner accepted near
238
+ // the boundary. By the final scan the previous owner has flushed everything (fast writes never
239
+ // delay past a window's end), so nothing is missed.
240
+ const BOUNDARY_SCAN_OFFSETS = [-30 * 1000, 2 * 1000, 30 * 1000];
241
+ // How far back a boundary scan asks the previous owner for changes: anything older has already
242
+ // arrived through normal synchronization, and this covers the longest a write can sit unflushed
243
+ const BOUNDARY_SCAN_LOOKBACK = DEFAULT_FAST_WRITE_DELAY + WINDOW_END_FLUSH_MARGIN;
244
+
245
+ // Each (bucket, boundary, offset) is scheduled exactly once, surviving store rebuilds - the timer
246
+ // re-resolves the bucket (and recomputes owners from the then-current routing) when it fires
247
+ const scheduledBoundaryScans = new Set<string>();
248
+ const boundaryScansRunning = new Set<string>();
249
+
250
+ function scheduleBoundaryScans(loaded: LoadedBucket): void {
251
+ let key = `${loaded.account}/${loaded.bucketName}`;
252
+ let now = Date.now();
253
+ let starts = new Set<number>();
254
+ for (let self of loaded.selfEntries) {
255
+ let start = self.validWindow[0];
256
+ if (start > now && start < Number.MAX_SAFE_INTEGER) {
257
+ starts.add(start);
258
+ }
259
+ }
260
+ for (let start of starts) {
261
+ for (let offset of BOUNDARY_SCAN_OFFSETS) {
262
+ let at = start + offset;
263
+ if (at <= now) continue;
264
+ let scheduleKey = `${key}|${start}|${offset}`;
265
+ if (scheduledBoundaryScans.has(scheduleKey)) continue;
266
+ scheduledBoundaryScans.add(scheduleKey);
267
+ let arm = () => {
268
+ let timer = setTimeout(() => {
269
+ // Timer length is capped (far-future boundaries), so re-arm until the time
270
+ // actually arrives
271
+ if (Date.now() < at) {
272
+ arm();
273
+ return;
274
+ }
275
+ void runBoundaryScan(key, start, offset).catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
276
+ }, Math.min(at - Date.now(), MAX_REBUILD_TIMER_DELAY));
277
+ (timer as { unref?: () => void }).unref?.();
278
+ };
279
+ arm();
280
+ }
281
+ }
282
+ }
283
+
284
+ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: number): Promise<void> {
285
+ let label = `bucket ${bucketKey}, window starting ${new Date(windowStart).toISOString()}, offset ${offset / 1000}s`;
286
+ if (boundaryScansRunning.has(bucketKey)) {
287
+ // Boundary scans never run in parallel; a slow one simply swallows later scheduled points
288
+ console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
289
+ return;
290
+ }
291
+ boundaryScansRunning.add(bucketKey);
292
+ try {
293
+ let loaded = await buckets.get(bucketKey);
294
+ if (!loaded) return;
295
+ let store = loaded.store;
296
+ if (!(store instanceof BlobStore)) return;
297
+ // Owners are recomputed at fire time - the routing (or the takeover remap) may have
298
+ // changed since this scan was scheduled
299
+ let effective = applyDeployRemap(loaded.routing);
300
+ let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
301
+ let selfIndexSet = new Set(selfIndexes);
302
+ let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
303
+ if (!selves.length) return;
304
+ let prevTime = windowStart - 1;
305
+ let needDiskScan = false;
306
+ // sourceIndex -> the slice of our route that source owned in the previous window
307
+ let remoteOwners = new Map<number, [number, number]>();
308
+ for (let self of selves) {
309
+ let selfRoute = self.route || FULL_ROUTE;
310
+ let idx = effective.sources.indexOf(self);
311
+ // These scans only run on the write node - the first source valid at the new window's
312
+ // start for our route. An earlier entry fully covering our route means writes go there,
313
+ // and IT does the boundary scans.
314
+ let shadowed = false;
315
+ for (let j = 0; j < idx; j++) {
316
+ let other = effective.sources[j];
317
+ if (typeof other === "string") continue;
318
+ let [ws, we] = other.validWindow;
319
+ if (!(ws <= windowStart && windowStart < we)) continue;
320
+ let r = other.route || FULL_ROUTE;
321
+ if (r[0] <= selfRoute[0] && selfRoute[1] <= r[1]) {
322
+ shadowed = true;
323
+ break;
324
+ }
325
+ }
326
+ if (shadowed) continue;
327
+ // The previous window's owners: walking the config in order (write-target order),
328
+ // each source claims whatever slice of our route no earlier source already owns
329
+ let uncovered: [number, number][] = [[selfRoute[0], selfRoute[1]]];
330
+ for (let j = 0; j < effective.sources.length && uncovered.length; j++) {
331
+ let other = effective.sources[j];
332
+ if (typeof other === "string") continue;
333
+ let [ws, we] = other.validWindow;
334
+ if (!(ws <= prevTime && prevTime < we)) continue;
335
+ let r = other.route || FULL_ROUTE;
336
+ let remaining: [number, number][] = [];
337
+ let claimed: [number, number] | undefined;
338
+ for (let u of uncovered) {
339
+ let inter = routeIntersection(u, r);
340
+ if (!inter) {
341
+ remaining.push(u);
342
+ continue;
343
+ }
344
+ claimed = claimed && [Math.min(claimed[0], inter[0]), Math.max(claimed[1], inter[1])] as [number, number] || inter;
345
+ if (u[0] < inter[0]) remaining.push([u[0], inter[0]]);
346
+ if (inter[1] < u[1]) remaining.push([inter[1], u[1]]);
347
+ }
348
+ uncovered = remaining;
349
+ if (!claimed) continue;
350
+ if (selfIndexSet.has(j)) {
351
+ // The previous owner shares our storage (same machine + bucket, e.g. the other
352
+ // process of a deploy switchover), so a disk rescan sees its writes
353
+ needDiskScan = true;
354
+ } else {
355
+ let existing = remoteOwners.get(j);
356
+ remoteOwners.set(j, existing && [Math.min(existing[0], claimed[0]), Math.max(existing[1], claimed[1])] as [number, number] || claimed);
357
+ }
358
+ }
359
+ }
360
+ if (!needDiskScan && !remoteOwners.size) return;
361
+ console.log(`Boundary scan (${label}): diskScan=${needDiskScan}, remote previous-window owners: ${remoteOwners.size}`);
362
+ if (needDiskScan) {
363
+ await store.rescanBase();
364
+ }
365
+ let since = windowStart - BOUNDARY_SCAN_LOOKBACK;
366
+ for (let [sourceIndex, route] of remoteOwners) {
367
+ let ownerSource = effective.sources[sourceIndex];
368
+ if (typeof ownerSource === "string") continue;
369
+ try {
370
+ await store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
371
+ } catch (e) {
372
+ console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
373
+ }
374
+ }
375
+ } finally {
376
+ boundaryScansRunning.delete(bucketKey);
377
+ }
378
+ }
379
+
236
380
  function buildBucket(account: string, bucketName: string, routing: RemoteConfig): LoadedBucket {
237
381
  let folder = getBucketFolder(account, bucketName);
238
382
  // The deploy-takeover remap is an INTERPRETATION overlay: everything behavioral (self entries,
@@ -288,11 +432,11 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig)
288
432
  void scheduleRoutingReload(account, bucketName);
289
433
  },
290
434
  readerDiskLimit: self?.readerDiskLimit,
291
- getFlushDeadline,
292
435
  });
293
436
  }
294
437
  let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store };
295
438
  scheduleWindowBoundaryRebuild(loaded);
439
+ scheduleBoundaryScans(loaded);
296
440
  return loaded;
297
441
  }
298
442
 
@@ -339,9 +483,9 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
339
483
  let key = `${account}/${bucketName}`;
340
484
  let loaded = await buckets.get(key);
341
485
  if (!loaded) return;
342
- // get() cache-reads the file onto our disk when a remote source holds it, so the bucket still
343
- // exists after a restart
344
- let data = await loaded.store.get(ROUTING_FILE);
486
+ // The routing config is only ever read off our own disk - it is never pulled from another
487
+ // source (it reaches us exclusively as a version-validated write, which lands on disk first)
488
+ let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
345
489
  if (!data) return;
346
490
  let routing = parseRoutingData(data);
347
491
  if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
@@ -468,28 +612,12 @@ export async function rebuildAllLoadedBuckets(): Promise<void> {
468
612
  }
469
613
  }
470
614
 
471
- export async function rescanAllLoadedBucketDisks(): Promise<void> {
472
- console.log(`Deploy switchover disk rescan starting (${buckets.size} loaded buckets) - catching files the other process wrote`);
473
- for (let loadedPromise of [...buckets.values()]) {
474
- let loaded = await loadedPromise.catch(() => undefined);
475
- if (!loaded) continue;
476
- let store = loaded.store;
477
- if (!(store instanceof BlobStore)) continue;
478
- try {
479
- await store.rescanBase();
480
- } catch (e) {
481
- console.error(`Deploy switchover disk rescan failed for bucket ${loaded.account}/${loaded.bucketName}: ${(e as Error).stack ?? e}`);
482
- }
483
- }
484
- }
485
-
486
615
  onTakeoverEvent(event => {
487
616
  if (event === "remapChanged") {
617
+ // The rebuild applies the remapped windows, and scheduleBoundaryScans (part of the
618
+ // rebuild) schedules the handoff scans around the new windows' starts
488
619
  void rebuildAllLoadedBuckets();
489
620
  }
490
- if (event === "diskScan") {
491
- void rescanAllLoadedBucketDisks();
492
- }
493
621
  });
494
622
 
495
623
  export type ServerBucketInfo = {