sliftutils 1.7.24 → 1.7.27
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.
- package/index.d.ts +28 -0
- package/package.json +1 -1
- package/storage/IArchives.d.ts +1 -0
- package/storage/IArchives.ts +4 -0
- package/storage/dist/ArchivesDisk.ts.cache +3 -3
- package/storage/dist/IArchives.ts.cache +3 -3
- package/storage/dist/backblaze.ts.cache +2 -2
- package/storage/remoteStorage/blobStore.d.ts +17 -0
- package/storage/remoteStorage/blobStore.ts +167 -27
- package/storage/remoteStorage/createArchives.ts +22 -6
- package/storage/remoteStorage/deployTakeover.d.ts +5 -0
- package/storage/remoteStorage/deployTakeover.ts +12 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +3 -3
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
- package/storage/remoteStorage/dist/createArchives.ts.cache +3 -3
- package/storage/remoteStorage/dist/storageServerState.ts.cache +3 -3
- package/storage/remoteStorage/sourceWrapper.d.ts +4 -0
- package/storage/remoteStorage/sourceWrapper.ts +10 -0
- package/storage/remoteStorage/spec.md +6 -0
- package/storage/remoteStorage/storageServerState.d.ts +1 -0
- package/storage/remoteStorage/storageServerState.ts +162 -43
|
@@ -6,12 +6,12 @@ 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,
|
|
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";
|
|
14
|
-
import { applyDeployRemap, getTakeoverAltPort, onTakeoverEvent } from "./deployTakeover";
|
|
14
|
+
import { applyDeployRemap, getTakeoverAltPort, getOwnWindowEndClip, onTakeoverEvent } from "./deployTakeover";
|
|
15
15
|
import { createApiArchives } from "./createArchives";
|
|
16
16
|
import type { IStorage } from "../IStorage";
|
|
17
17
|
import type { AccessRequest, TrustRecord } from "./storageController";
|
|
@@ -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
|
-
|
|
381
|
-
|
|
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,93 @@ 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
|
+
// Internally every self entry is the SAME store - one process listening on one or more ports
|
|
449
|
+
// (a takeover's alternate-port middle window included). So the disk window merges all
|
|
450
|
+
// contiguous own windows: an options boundary or the port split must never force a pointless
|
|
451
|
+
// internal flush between two windows that are both us. Only the DYING process of a takeover
|
|
452
|
+
// clips its end - from the write handoff on, the data belongs to the successor process.
|
|
453
|
+
let diskWindow: [number, number] = [0, 0];
|
|
454
|
+
if (self) {
|
|
455
|
+
let [start, end] = self.validWindow;
|
|
456
|
+
let merged = true;
|
|
457
|
+
while (merged) {
|
|
458
|
+
merged = false;
|
|
459
|
+
for (let entry of selfEntries) {
|
|
460
|
+
let [entryStart, entryEnd] = entry.validWindow;
|
|
461
|
+
if (entryStart > end || entryEnd < start) continue;
|
|
462
|
+
if (entryStart < start || entryEnd > end) {
|
|
463
|
+
start = Math.min(start, entryStart);
|
|
464
|
+
end = Math.max(end, entryEnd);
|
|
465
|
+
merged = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
let clip = getOwnWindowEndClip();
|
|
470
|
+
if (clip !== undefined && clip < end) {
|
|
471
|
+
end = clip;
|
|
472
|
+
}
|
|
473
|
+
diskWindow = [start, end];
|
|
474
|
+
}
|
|
475
|
+
let ownIndexes = new Set(selfIndexes);
|
|
476
|
+
let sourceSpecs: StorePlan["sourceSpecs"] = [{
|
|
477
|
+
validWindow: diskWindow,
|
|
478
|
+
}];
|
|
479
|
+
if (selfIndex !== -1) {
|
|
480
|
+
for (let i = selfIndex + 1; i < effective.sources.length; i++) {
|
|
481
|
+
let source = effective.sources[i];
|
|
482
|
+
if (typeof source === "string" || ownIndexes.has(i)) continue;
|
|
483
|
+
// Disjoint-shard sources never talk to each other; a partial overlap syncs only
|
|
484
|
+
// the intersection (scans ignore the rest, writes only send matching keys)
|
|
485
|
+
let sharedRoute = routeIntersection(self?.route, source.route);
|
|
486
|
+
if (!sharedRoute) continue;
|
|
487
|
+
// A bounded-cache server (noFullSync on our own entry) must not full-sync from
|
|
488
|
+
// ANY source, or its disk would fill regardless of the limit
|
|
489
|
+
sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let rawDisk = !!self?.rawDisk;
|
|
493
|
+
// Only what the running store genuinely cannot change in place: the store TYPE (rawDisk) and
|
|
494
|
+
// whether the disk-limit poll runs. Source additions/removals and window/route changes are all
|
|
495
|
+
// applied live via updateSources - a config change must never destroy the running store.
|
|
496
|
+
let structureKey = JSON.stringify({
|
|
497
|
+
rawDisk,
|
|
498
|
+
readerDiskLimit: self?.readerDiskLimit,
|
|
499
|
+
});
|
|
500
|
+
return { effective, selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): LoadedBucket {
|
|
504
|
+
let folder = getBucketFolder(account, bucketName);
|
|
505
|
+
if (!plan) {
|
|
506
|
+
plan = computeStorePlan(account, bucketName, routing);
|
|
507
|
+
}
|
|
508
|
+
let { selfEntries, self } = plan;
|
|
394
509
|
let store: IBucketStore;
|
|
395
|
-
if (
|
|
510
|
+
if (plan.rawDisk) {
|
|
396
511
|
store = new ArchivesDisk(folder);
|
|
397
512
|
} else {
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
}
|
|
513
|
+
if (!self) {
|
|
514
|
+
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
515
|
}
|
|
516
|
+
let sources: ArchivesSource[] = plan.sourceSpecs.map(spec => ({
|
|
517
|
+
source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
|
|
518
|
+
validWindow: spec.validWindow,
|
|
519
|
+
route: spec.route,
|
|
520
|
+
noFullSync: spec.noFullSync,
|
|
521
|
+
identity: sourceIdentity(spec.sourceConfig),
|
|
522
|
+
}));
|
|
427
523
|
store = new BlobStore(folder, sources, {
|
|
428
524
|
onIndexChanged: key => {
|
|
429
525
|
// Fires for our own routing writes AND routing files pulled in via synchronization
|
|
@@ -431,10 +527,10 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig)
|
|
|
431
527
|
if (key !== ROUTING_FILE) return;
|
|
432
528
|
void scheduleRoutingReload(account, bucketName);
|
|
433
529
|
},
|
|
434
|
-
readerDiskLimit:
|
|
530
|
+
readerDiskLimit: plan.readerDiskLimit,
|
|
435
531
|
});
|
|
436
532
|
}
|
|
437
|
-
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store };
|
|
533
|
+
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
|
|
438
534
|
scheduleWindowBoundaryRebuild(loaded);
|
|
439
535
|
scheduleBoundaryScans(loaded);
|
|
440
536
|
return loaded;
|
|
@@ -489,15 +585,38 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
489
585
|
if (!data) return;
|
|
490
586
|
let routing = parseRoutingData(data);
|
|
491
587
|
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
588
|
+
let reason = config?.force && (config.reason || "forced") || "routing config changed";
|
|
589
|
+
let plan = computeStorePlan(account, bucketName, routing);
|
|
590
|
+
// Config changes are applied to the RUNNING store in place - windows/routes mutate, added
|
|
591
|
+
// sources start syncing, removed sources' slots go dead - so the index, pending fast writes
|
|
592
|
+
// (re-capped to the new deadline), and unaffected sync loops all survive. The store is only
|
|
593
|
+
// destroyed for what it structurally cannot express (a rawDisk flip).
|
|
594
|
+
if (plan.structureKey === loaded.structureKey && loaded.store instanceof BlobStore) {
|
|
595
|
+
console.log(`Applying the routing config to the running store for bucket ${key} (${reason})`);
|
|
596
|
+
loaded.store.updateSources(plan.sourceSpecs.map(spec => {
|
|
597
|
+
const sourceConfig = spec.sourceConfig;
|
|
598
|
+
if (!sourceConfig) {
|
|
599
|
+
return { identity: sourceIdentity(undefined), validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(getBucketFolder(account, bucketName)) };
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
identity: sourceIdentity(sourceConfig),
|
|
603
|
+
validWindow: spec.validWindow,
|
|
604
|
+
route: spec.route,
|
|
605
|
+
noFullSync: spec.noFullSync,
|
|
606
|
+
create: () => createApiArchives(sourceConfig),
|
|
607
|
+
};
|
|
608
|
+
}));
|
|
609
|
+
let updated: LoadedBucket = { ...loaded, routing, routingJSON: JSON.stringify(routing), selfEntries: plan.selfEntries, self: plan.self };
|
|
610
|
+
buckets.set(key, Promise.resolve(updated));
|
|
611
|
+
scheduleWindowBoundaryRebuild(updated);
|
|
612
|
+
scheduleBoundaryScans(updated);
|
|
613
|
+
return;
|
|
496
614
|
}
|
|
615
|
+
console.log(`Rebuilding the store for bucket ${key} (${reason})`);
|
|
497
616
|
if (loaded.store instanceof BlobStore) {
|
|
498
617
|
await loaded.store.dispose();
|
|
499
618
|
}
|
|
500
|
-
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing)));
|
|
619
|
+
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
|
|
501
620
|
}
|
|
502
621
|
|
|
503
622
|
// Per-write options are evaluated at the WRITE's time (and the key's route), not the current
|