sliftutils 1.7.21 → 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.
- package/index.d.ts +23 -12
- package/package.json +1 -1
- package/storage/IArchives.d.ts +1 -0
- package/storage/IArchives.ts +4 -0
- package/storage/dist/IArchives.ts.cache +3 -3
- package/storage/remoteStorage/ArchivesRemote.ts +5 -5
- package/storage/remoteStorage/blobStore.d.ts +11 -5
- package/storage/remoteStorage/blobStore.ts +160 -53
- package/storage/remoteStorage/createArchives.ts +12 -2
- package/storage/remoteStorage/deployTakeover.d.ts +5 -5
- package/storage/remoteStorage/deployTakeover.ts +38 -31
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +8 -8
- package/storage/remoteStorage/dist/blobStore.ts.cache +6 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +40 -9
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +64 -15
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +11 -4
- package/storage/remoteStorage/dist/storageController.ts.cache +25 -11
- package/storage/remoteStorage/dist/storageServerState.ts.cache +11 -5
- package/storage/remoteStorage/sourceWrapper.ts +2 -2
- package/storage/remoteStorage/spec.md +20 -2
- package/storage/remoteStorage/storageController.d.ts +6 -1
- package/storage/remoteStorage/storageController.ts +24 -8
- package/storage/remoteStorage/storageServerState.d.ts +0 -1
- package/storage/remoteStorage/storageServerState.ts +160 -28
|
@@ -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";
|
|
@@ -164,15 +164,19 @@ const extraListenPorts = new Set<number>();
|
|
|
164
164
|
export function addExtraListenPort(port: number): void {
|
|
165
165
|
extraListenPorts.add(port);
|
|
166
166
|
}
|
|
167
|
-
|
|
168
167
|
function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
|
|
169
168
|
let { domain, port } = getStorageServerConfig();
|
|
170
169
|
let indexes: number[] = [];
|
|
170
|
+
// The takeover's alternate port is the OTHER process of the switchover, on OUR machine with
|
|
171
|
+
// OUR disk - self for sync purposes on both sides (the old node must not scan/push its own
|
|
172
|
+
// successor; the shared folder plus the switchover disk scans reconcile them)
|
|
173
|
+
let takeoverAltPort = getTakeoverAltPort();
|
|
171
174
|
for (let i = 0; i < routing.sources.length; i++) {
|
|
172
175
|
let source = routing.sources[i];
|
|
173
176
|
if (typeof source === "string" || source.type !== "remote") continue;
|
|
174
177
|
let parsed = parseHostedUrl(source.url);
|
|
175
|
-
if (parsed.address
|
|
178
|
+
if (parsed.address !== domain || parsed.account !== account || parsed.bucketName !== bucketName) continue;
|
|
179
|
+
if (parsed.port === port || extraListenPorts.has(parsed.port) || parsed.port === takeoverAltPort) {
|
|
176
180
|
indexes.push(i);
|
|
177
181
|
}
|
|
178
182
|
}
|
|
@@ -229,6 +233,150 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
|
229
233
|
(timer as { unref?: () => void }).unref?.();
|
|
230
234
|
}
|
|
231
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
|
+
|
|
232
380
|
function buildBucket(account: string, bucketName: string, routing: RemoteConfig): LoadedBucket {
|
|
233
381
|
let folder = getBucketFolder(account, bucketName);
|
|
234
382
|
// The deploy-takeover remap is an INTERPRETATION overlay: everything behavioral (self entries,
|
|
@@ -284,11 +432,11 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig)
|
|
|
284
432
|
void scheduleRoutingReload(account, bucketName);
|
|
285
433
|
},
|
|
286
434
|
readerDiskLimit: self?.readerDiskLimit,
|
|
287
|
-
getFlushDeadline,
|
|
288
435
|
});
|
|
289
436
|
}
|
|
290
437
|
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store };
|
|
291
438
|
scheduleWindowBoundaryRebuild(loaded);
|
|
439
|
+
scheduleBoundaryScans(loaded);
|
|
292
440
|
return loaded;
|
|
293
441
|
}
|
|
294
442
|
|
|
@@ -335,9 +483,9 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
335
483
|
let key = `${account}/${bucketName}`;
|
|
336
484
|
let loaded = await buckets.get(key);
|
|
337
485
|
if (!loaded) return;
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
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);
|
|
341
489
|
if (!data) return;
|
|
342
490
|
let routing = parseRoutingData(data);
|
|
343
491
|
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
@@ -431,11 +579,11 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
431
579
|
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
432
580
|
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] - WRITE_PAST_WINDOW_GRACE && writeTime <= x.validWindow[1] + WRITE_PAST_WINDOW_GRACE);
|
|
433
581
|
if (!timeValid.length) {
|
|
434
|
-
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: outside our valid windows (a switchover moved the write target)`);
|
|
582
|
+
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))} even with the ${WRITE_PAST_WINDOW_GRACE}ms grace (a switchover moved the write target)`);
|
|
435
583
|
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not a valid write target at ${writeTime} for bucket ${account}/${bucketName} (our valid windows: ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))}). Re-resolve the currently valid source and retry.`);
|
|
436
584
|
}
|
|
437
585
|
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
438
|
-
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: route ${route} is
|
|
586
|
+
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
|
|
439
587
|
throw new Error(`${STORAGE_WRONG_ROUTE} This server does not handle route ${route} (key ${JSON.stringify(filePath)}) for bucket ${account}/${bucketName} (our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}). Re-resolve the source for this key and retry.`);
|
|
440
588
|
}
|
|
441
589
|
}
|
|
@@ -464,28 +612,12 @@ export async function rebuildAllLoadedBuckets(): Promise<void> {
|
|
|
464
612
|
}
|
|
465
613
|
}
|
|
466
614
|
|
|
467
|
-
export async function rescanAllLoadedBucketDisks(): Promise<void> {
|
|
468
|
-
console.log(`Deploy switchover disk rescan starting (${buckets.size} loaded buckets) - catching files the other process wrote`);
|
|
469
|
-
for (let loadedPromise of [...buckets.values()]) {
|
|
470
|
-
let loaded = await loadedPromise.catch(() => undefined);
|
|
471
|
-
if (!loaded) continue;
|
|
472
|
-
let store = loaded.store;
|
|
473
|
-
if (!(store instanceof BlobStore)) continue;
|
|
474
|
-
try {
|
|
475
|
-
await store.rescanBase();
|
|
476
|
-
} catch (e) {
|
|
477
|
-
console.error(`Deploy switchover disk rescan failed for bucket ${loaded.account}/${loaded.bucketName}: ${(e as Error).stack ?? e}`);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
615
|
onTakeoverEvent(event => {
|
|
483
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
|
|
484
619
|
void rebuildAllLoadedBuckets();
|
|
485
620
|
}
|
|
486
|
-
if (event === "diskScan") {
|
|
487
|
-
void rescanAllLoadedBucketDisks();
|
|
488
|
-
}
|
|
489
621
|
});
|
|
490
622
|
|
|
491
623
|
export type ServerBucketInfo = {
|