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.
- package/index.d.ts +14 -10
- package/package.json +1 -1
- package/storage/ArchivesDisk.ts +1 -1
- package/storage/IArchives.d.ts +1 -0
- package/storage/IArchives.ts +4 -0
- package/storage/backblaze.ts +1 -1
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +4 -4
- package/storage/remoteStorage/ArchivesRemote.ts +1 -1
- package/storage/remoteStorage/ArchivesUrl.ts +1 -1
- package/storage/remoteStorage/blobStore.d.ts +11 -4
- package/storage/remoteStorage/blobStore.ts +159 -46
- package/storage/remoteStorage/createArchives.ts +13 -3
- package/storage/remoteStorage/deployTakeover.d.ts +2 -5
- package/storage/remoteStorage/deployTakeover.ts +7 -38
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +7 -7
- package/storage/remoteStorage/dist/blobStore.ts.cache +171 -56
- package/storage/remoteStorage/dist/createArchives.ts.cache +15 -4
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +40 -35
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +4 -4
- package/storage/remoteStorage/dist/storageController.ts.cache +18 -9
- package/storage/remoteStorage/dist/storageServerState.ts.cache +178 -32
- package/storage/remoteStorage/sourceWrapper.ts +2 -2
- package/storage/remoteStorage/spec.md +19 -1
- package/storage/remoteStorage/storageServerState.d.ts +0 -1
- package/storage/remoteStorage/storageServerState.ts +153 -25
|
@@ -53,7 +53,7 @@ export class SourceWrapper {
|
|
|
53
53
|
let wrapper = new SourceWrapper(config, options?.background !== false);
|
|
54
54
|
if (config.type === "backblaze") {
|
|
55
55
|
if (await hasBackblazeCreds()) {
|
|
56
|
-
wrapper.api = new ArchivesBackblaze({ bucketName: parseBackblazeUrl(config.url).bucketName, public: config.public, immutable: config.immutable });
|
|
56
|
+
wrapper.api = new ArchivesBackblaze({ bucketName: parseBackblazeUrl(config.url).bucketName, public: config.public, immutable: config.immutable, allowedOrigins: config.allowedOrigins });
|
|
57
57
|
} else if (isNode()) {
|
|
58
58
|
wrapper.writeBlocked = `No backblaze credentials for ${config.url}. Provide backblaze.json.applicationKeyId and backblaze.json.applicationKey via getSecret to enable API access.`;
|
|
59
59
|
} else {
|
|
@@ -93,7 +93,7 @@ export class SourceWrapper {
|
|
|
93
93
|
let endText = end === Number.MAX_SAFE_INTEGER && "forever" || new Date(end).toISOString();
|
|
94
94
|
parts.push(`validWindow [${startText}, ${endText}]`);
|
|
95
95
|
}
|
|
96
|
-
return `source
|
|
96
|
+
return `source ${this.config.url}${parts.length && ` (${parts.join(", ")})` || ""}`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
public isConnected(): boolean {
|
|
@@ -10,10 +10,12 @@ A client adopts a routing config once, during initialization, and then runs on i
|
|
|
10
10
|
|
|
11
11
|
Every store fully rescans its sources' metadata (on startup and periodically), and every write — including deletions — is ordered by last-write time. Deletions are tombstones: an empty file IS a missing file, kept as a size-0 index entry (for a week) so the deletion itself propagates and reconciles like any other write. Because everything is a timestamped write and scans are bidirectional (pull what they have, push what they're missing), any two sources can be merged in any order and converge to the same state: newest write time wins, per file. There is no operation whose loss corrupts the system — a failed background write, a missed delete, a source that was down for a day — the next scan reconciles it.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## storage/storagerouting.json Has the routing config and is duplicated on every node.
|
|
14
14
|
|
|
15
15
|
Each bucket stores its complete routing config (the full redundancy list) inside itself, at storage/storagerouting.json, on every source. Discovery is therefore trivial: as long as ONE node is up, a client reading it gets the full overview of the intended sources. And because clients re-discover on every startup (and re-read every 5 minutes), a developer can change the configuration in one place and clients rapidly accept it — no redeploy, no coordinated restart of the fleet.
|
|
16
16
|
|
|
17
|
+
If the client tries to do a write where the valid state is far enough away or the sharding is wrong, it will re-download the routing config throttled, so it only does this at most once every 30 seconds, and retry the request.
|
|
18
|
+
|
|
17
19
|
## BulkDatabase2 index + our own disk as just another source
|
|
18
20
|
|
|
19
21
|
Each bucket's store keeps a BulkDatabase2 index of every file (path, write time, size, holding source), served from memory — existence checks and listings are extremely fast, never touching a source. The trick that keeps the index accurate: our own disk is not special-cased, it is simply the first synchronization source. The same scan/reconcile code that synchronizes remote sources also synchronizes the disk with the index, so the index self-heals from the same machinery instead of needing separate consistency logic.
|
|
@@ -29,3 +31,19 @@ Clients always write to the same node — the first source whose valid window is
|
|
|
29
31
|
## Trust instead of API keys
|
|
30
32
|
|
|
31
33
|
Machines authenticate with their certs.ts identity (proving ownership of their machine key with a signed, server-bound token), and access is granted per account to specific machineIds. No API keys are minted, copied into configs, or passed around — granting a machine access is one command on the storage machine, and revoking it is removing the trust record. The only API keys left in the system are the ones third parties force on us: backblaze and cloudflare, both resolved through getSecret.
|
|
34
|
+
|
|
35
|
+
## storage/storagerouting.json is a special file
|
|
36
|
+
|
|
37
|
+
This is our special file that stores the routing information. We write it directly to each node. The client side tries to keep an updated version of these (that's mentioned earlier And is how the client can keep up to date even if the client's code isn't up to date, As long as at least one of the sources is still alive).
|
|
38
|
+
|
|
39
|
+
IMPORTANTLY! This special file is never synchronized between different storage nodes. It's only directly written to, and it's only read off of our disk. It can be written to on any node. We don't take into account the valid state window or the shard. We still do take into account the write time, though, the latest write time wins. We only allow writes if version >= the previous version.
|
|
40
|
+
|
|
41
|
+
## fast writes
|
|
42
|
+
|
|
43
|
+
We support a flag that does fast writes, which will cause us to batch all the writes in memory, Returning from the set call immediately. This allows you to do many writes to the same file with very little disk I/O. This uses a configurable delay amount. You could set it to zero and then we just won't delay it at all, and we'll flush everything to the disk immediately away.
|
|
44
|
+
|
|
45
|
+
The deploy system will tell us if it's intending to switch over a source, which we use to create a virtual valid state window in the middle that uses a different port.
|
|
46
|
+
|
|
47
|
+
We look at the valid state windows and we make sure we never delay past the valid state window. In fact, if we are within five minutes of being invalid due to the valid state window, we flush the writes immediately. That way, when the next valid window starts running, the writes will be already on disk.
|
|
48
|
+
|
|
49
|
+
We also do scans when we are coming up to the valid state window. If we are going to be the new valid state window, both before, a little bit after, and farther after. This helps the switch over be smoother so we get all of the trailing writes. These scans use our ability to ask for the changes since a certain time. We do the scan on the right write node. The write node is always the first node in order (with a valid state window and matching the route hash). Which might result in us having to scan multiple nodes if we require multiple nodes to fill the full route hash window.
|
|
@@ -42,7 +42,6 @@ export declare function writeBucketFile(account: string, bucketName: string, fil
|
|
|
42
42
|
}): Promise<void>;
|
|
43
43
|
export declare function getBucketConfig(bucket: LoadedBucket): ArchivesConfig;
|
|
44
44
|
export declare function rebuildAllLoadedBuckets(): Promise<void>;
|
|
45
|
-
export declare function rescanAllLoadedBucketDisks(): Promise<void>;
|
|
46
45
|
export type ServerBucketInfo = {
|
|
47
46
|
bucketName: string;
|
|
48
47
|
active: boolean;
|
|
@@ -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,
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
let data = await
|
|
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 = {
|
|
@@ -564,7 +692,7 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
564
692
|
}
|
|
565
693
|
|
|
566
694
|
public getDebugName() {
|
|
567
|
-
return `localBucket
|
|
695
|
+
return `localBucket account ${this.account} bucket ${this.bucketName}`;
|
|
568
696
|
}
|
|
569
697
|
public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
570
698
|
let result = await this.get2(fileName, config);
|