sliftutils 1.7.24 → 1.7.26

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.
@@ -6,8 +6,8 @@ import { JSONStorage } from "../JSONStorage";
6
6
  import { ArchivesDisk } from "../ArchivesDisk";
7
7
  import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
8
8
  import {
9
- RemoteConfig, HostedConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
10
- ArchivesSyncStatus, FULL_VALID_WINDOW,
9
+ RemoteConfig, HostedConfig, BackblazeConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
10
+ ArchivesSyncStatus,
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";
@@ -145,6 +145,9 @@ export type LoadedBucket = {
145
145
  // time, see getWriteConfig/assertMutable. undefined when the config doesn't mention us.
146
146
  self: HostedConfig | undefined;
147
147
  store: IBucketStore;
148
+ // The store plan minus its valid windows (see StorePlan.structureKey): when only windows
149
+ // change, the store is updated in place instead of rebuilt
150
+ structureKey: string;
148
151
  };
149
152
 
150
153
  const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
@@ -241,6 +244,8 @@ const BOUNDARY_SCAN_OFFSETS = [-30 * 1000, 2 * 1000, 30 * 1000];
241
244
  // How far back a boundary scan asks the previous owner for changes: anything older has already
242
245
  // arrived through normal synchronization, and this covers the longest a write can sit unflushed
243
246
  const BOUNDARY_SCAN_LOOKBACK = DEFAULT_FAST_WRITE_DELAY + WINDOW_END_FLUSH_MARGIN;
247
+ // The catch-up scan for a window that was already active when we built the store
248
+ const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
244
249
 
245
250
  // Each (bucket, boundary, offset) is scheduled exactly once, surviving store rebuilds - the timer
246
251
  // re-resolves the bucket (and recomputes owners from the then-current routing) when it fires
@@ -257,6 +262,22 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
257
262
  starts.add(start);
258
263
  }
259
264
  }
265
+ // A window we are ALREADY inside when the store is built (startup, or a config that made us
266
+ // valid immediately) got none of the lead-up scans - so one runs shortly after, in case we
267
+ // came up just as the previous owner is realizing it must shut down. Only recent boundaries
268
+ // qualify: older trailing writes have long since propagated through normal synchronization.
269
+ for (let self of loaded.selfEntries) {
270
+ let [start, end] = self.validWindow;
271
+ if (!(start <= now && now < end) || start <= 0) continue;
272
+ if (now - start > BOUNDARY_SCAN_LOOKBACK) continue;
273
+ let scheduleKey = `${key}|${start}|startup`;
274
+ if (scheduledBoundaryScans.has(scheduleKey)) continue;
275
+ scheduledBoundaryScans.add(scheduleKey);
276
+ let timer = setTimeout(() => {
277
+ void runBoundaryScan(key, start, STARTUP_BOUNDARY_SCAN_DELAY, "startup+30s").catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
278
+ }, STARTUP_BOUNDARY_SCAN_DELAY);
279
+ (timer as { unref?: () => void }).unref?.();
280
+ }
260
281
  for (let start of starts) {
261
282
  for (let offset of BOUNDARY_SCAN_OFFSETS) {
262
283
  let at = start + offset;
@@ -281,8 +302,8 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
281
302
  }
282
303
  }
283
304
 
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`;
305
+ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
306
+ let label = `bucket ${bucketKey}, window starting ${new Date(windowStart).toISOString()}, offset ${offsetLabel || `${offset / 1000}s`}`;
286
307
  if (boundaryScansRunning.has(bucketKey)) {
287
308
  // Boundary scans never run in parallel; a slow one simply swallows later scheduled points
288
309
  console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
@@ -377,8 +398,29 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
377
398
  }
378
399
  }
379
400
 
380
- function buildBucket(account: string, bucketName: string, routing: RemoteConfig): LoadedBucket {
381
- let folder = getBucketFolder(account, bucketName);
401
+ type StorePlan = {
402
+ effective: RemoteConfig;
403
+ selfEntries: HostedConfig[];
404
+ self: HostedConfig | undefined;
405
+ rawDisk: boolean;
406
+ // The BlobStore sources this plan builds: [0] is the disk, the rest are downstream configs
407
+ sourceSpecs: { sourceConfig?: HostedConfig | BackblazeConfig; validWindow: [number, number]; route?: [number, number]; noFullSync?: boolean }[];
408
+ readerDiskLimit?: number;
409
+ // Everything about the plan EXCEPT the valid windows. Two plans with equal structure keys
410
+ // differ only in windows, which the running store applies in place - it must survive the
411
+ // routine config evolution of reducing the last forever-window and appending a new entry,
412
+ // instead of being disposed and rebuilt (losing sync state and rescanning everything).
413
+ structureKey: string;
414
+ };
415
+
416
+ // The stable identity of a source endpoint: its config with the in-place-updatable parts
417
+ // (windows, routes) stripped, so a source is recognized across config changes
418
+ function sourceIdentity(sourceConfig: HostedConfig | BackblazeConfig | undefined): string {
419
+ if (!sourceConfig) return "disk";
420
+ return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
421
+ }
422
+
423
+ function computeStorePlan(account: string, bucketName: string, routing: RemoteConfig): StorePlan {
382
424
  // The deploy-takeover remap is an INTERPRETATION overlay: everything behavioral (self entries,
383
425
  // windows, downstream sources) uses the remapped view, while loaded.routing/routingJSON stay
384
426
  // the STORED config - so version guards, change detection, and synchronization never see (or
@@ -391,39 +433,66 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig)
391
433
  if (self) {
392
434
  selfIndex = effective.sources.indexOf(self);
393
435
  }
436
+ // Our own disk is the base source; only the routing entries DOWNSTREAM from us (after our
437
+ // currently-valid entry) become synchronization sources. Upstream sources sync from us, so
438
+ // writing to or scanning them would just echo our own data back (and make our availability
439
+ // depend on theirs). A server NOT in the list has been removed from the bucket: it keeps
440
+ // its disk data (the config may re-add it), but stops all synchronization - no one
441
+ // contacts a removed source, and scanning/pushing as if we were still one would fight the
442
+ // real chain. The config-level validWindow rides on each ArchivesSource; the disk source
443
+ // shares our currently-valid entry's window (it holds our copy of the data).
444
+ // Our own disk gets no route filter: everything it holds is ours to index and serve.
445
+ // Being removed from the config entirely is a valid window of NOTHING ([0, 0]): fast
446
+ // writes flush immediately (nothing may sit in memory on a node that's been cut out),
447
+ // while the disk data and index stay served.
448
+ let ownIndexes = new Set(selfIndexes);
449
+ let sourceSpecs: StorePlan["sourceSpecs"] = [{
450
+ validWindow: self && self.validWindow || [0, 0],
451
+ }];
452
+ if (selfIndex !== -1) {
453
+ for (let i = selfIndex + 1; i < effective.sources.length; i++) {
454
+ let source = effective.sources[i];
455
+ if (typeof source === "string" || ownIndexes.has(i)) continue;
456
+ // Disjoint-shard sources never talk to each other; a partial overlap syncs only
457
+ // the intersection (scans ignore the rest, writes only send matching keys)
458
+ let sharedRoute = routeIntersection(self?.route, source.route);
459
+ if (!sharedRoute) continue;
460
+ // A bounded-cache server (noFullSync on our own entry) must not full-sync from
461
+ // ANY source, or its disk would fill regardless of the limit
462
+ sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
463
+ }
464
+ }
465
+ let rawDisk = !!self?.rawDisk;
466
+ // Only what the running store genuinely cannot change in place: the store TYPE (rawDisk) and
467
+ // whether the disk-limit poll runs. Source additions/removals and window/route changes are all
468
+ // applied live via updateSources - a config change must never destroy the running store.
469
+ let structureKey = JSON.stringify({
470
+ rawDisk,
471
+ readerDiskLimit: self?.readerDiskLimit,
472
+ });
473
+ return { effective, selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
474
+ }
475
+
476
+ function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): LoadedBucket {
477
+ let folder = getBucketFolder(account, bucketName);
478
+ if (!plan) {
479
+ plan = computeStorePlan(account, bucketName, routing);
480
+ }
481
+ let { selfEntries, self } = plan;
394
482
  let store: IBucketStore;
395
- if (self?.rawDisk) {
483
+ if (plan.rawDisk) {
396
484
  store = new ArchivesDisk(folder);
397
485
  } else {
398
- // Our own disk is the base source; only the routing entries DOWNSTREAM from us (after our
399
- // currently-valid entry) become synchronization sources. Upstream sources sync from us, so
400
- // writing to or scanning them would just echo our own data back (and make our availability
401
- // depend on theirs). A server NOT in the list has been removed from the bucket: it keeps
402
- // its disk data (the config may re-add it), but stops all synchronization - no one
403
- // contacts a removed source, and scanning/pushing as if we were still one would fight the
404
- // real chain. The config-level validWindow rides on each ArchivesSource; the disk source
405
- // shares our currently-valid entry's window (it holds our copy of the data).
406
- let ownIndexes = new Set(selfIndexes);
407
- // Our own disk gets no route filter: everything it holds is ours to index and serve
408
- let sources: ArchivesSource[] = [{
409
- source: new ArchivesDisk(folder),
410
- validWindow: self?.validWindow || FULL_VALID_WINDOW,
411
- }];
412
- if (selfIndex === -1) {
413
- console.log(`This server is not in the routing config for bucket ${account}/${bucketName}; keeping its data on disk but no longer synchronizing it`);
414
- } else {
415
- for (let i = selfIndex + 1; i < effective.sources.length; i++) {
416
- let source = effective.sources[i];
417
- if (typeof source === "string" || ownIndexes.has(i)) continue;
418
- // Disjoint-shard sources never talk to each other; a partial overlap syncs only
419
- // the intersection (scans ignore the rest, writes only send matching keys)
420
- let sharedRoute = routeIntersection(self?.route, source.route);
421
- if (!sharedRoute) continue;
422
- // A bounded-cache server (noFullSync on our own entry) must not full-sync from
423
- // ANY source, or its disk would fill regardless of the limit
424
- sources.push({ source: createApiArchives(source), validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
425
- }
486
+ if (!self) {
487
+ console.log(`This server is not in the routing config for bucket ${account}/${bucketName}: no longer synchronizing, valid window treated as [0, 0] (fast writes flush immediately), disk data kept and served`);
426
488
  }
489
+ let sources: ArchivesSource[] = plan.sourceSpecs.map(spec => ({
490
+ source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
491
+ validWindow: spec.validWindow,
492
+ route: spec.route,
493
+ noFullSync: spec.noFullSync,
494
+ identity: sourceIdentity(spec.sourceConfig),
495
+ }));
427
496
  store = new BlobStore(folder, sources, {
428
497
  onIndexChanged: key => {
429
498
  // Fires for our own routing writes AND routing files pulled in via synchronization
@@ -431,10 +500,10 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig)
431
500
  if (key !== ROUTING_FILE) return;
432
501
  void scheduleRoutingReload(account, bucketName);
433
502
  },
434
- readerDiskLimit: self?.readerDiskLimit,
503
+ readerDiskLimit: plan.readerDiskLimit,
435
504
  });
436
505
  }
437
- let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store };
506
+ let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
438
507
  scheduleWindowBoundaryRebuild(loaded);
439
508
  scheduleBoundaryScans(loaded);
440
509
  return loaded;
@@ -489,15 +558,38 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
489
558
  if (!data) return;
490
559
  let routing = parseRoutingData(data);
491
560
  if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
492
- if (config?.force) {
493
- console.log(`Rebuilding the store for bucket ${key} (${config.reason || "forced"})`);
494
- } else {
495
- console.log(`Routing config changed for bucket ${key}, rebuilding its store`);
561
+ let reason = config?.force && (config.reason || "forced") || "routing config changed";
562
+ let plan = computeStorePlan(account, bucketName, routing);
563
+ // Config changes are applied to the RUNNING store in place - windows/routes mutate, added
564
+ // sources start syncing, removed sources' slots go dead - so the index, pending fast writes
565
+ // (re-capped to the new deadline), and unaffected sync loops all survive. The store is only
566
+ // destroyed for what it structurally cannot express (a rawDisk flip).
567
+ if (plan.structureKey === loaded.structureKey && loaded.store instanceof BlobStore) {
568
+ console.log(`Applying the routing config to the running store for bucket ${key} (${reason})`);
569
+ loaded.store.updateSources(plan.sourceSpecs.map(spec => {
570
+ const sourceConfig = spec.sourceConfig;
571
+ if (!sourceConfig) {
572
+ return { identity: sourceIdentity(undefined), validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(getBucketFolder(account, bucketName)) };
573
+ }
574
+ return {
575
+ identity: sourceIdentity(sourceConfig),
576
+ validWindow: spec.validWindow,
577
+ route: spec.route,
578
+ noFullSync: spec.noFullSync,
579
+ create: () => createApiArchives(sourceConfig),
580
+ };
581
+ }));
582
+ let updated: LoadedBucket = { ...loaded, routing, routingJSON: JSON.stringify(routing), selfEntries: plan.selfEntries, self: plan.self };
583
+ buckets.set(key, Promise.resolve(updated));
584
+ scheduleWindowBoundaryRebuild(updated);
585
+ scheduleBoundaryScans(updated);
586
+ return;
496
587
  }
588
+ console.log(`Rebuilding the store for bucket ${key} (${reason})`);
497
589
  if (loaded.store instanceof BlobStore) {
498
590
  await loaded.store.dispose();
499
591
  }
500
- buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing)));
592
+ buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
501
593
  }
502
594
 
503
595
  // Per-write options are evaluated at the WRITE's time (and the key's route), not the current