sliftutils 1.7.27 → 1.7.29
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 +12 -1
- package/package.json +1 -1
- package/storage/IArchives.d.ts +0 -1
- package/storage/IArchives.ts +4 -6
- package/storage/dist/IArchives.ts.cache +7 -8
- package/storage/remoteStorage/createArchives.d.ts +2 -0
- package/storage/remoteStorage/createArchives.ts +56 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +162 -29
- package/storage/remoteStorage/dist/createArchives.ts.cache +74 -9
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +17 -3
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +12 -2
- package/storage/remoteStorage/dist/storageServerState.ts.cache +160 -49
- package/storage/remoteStorage/spec.md +10 -4
- package/storage/remoteStorage/storageClientController.d.ts +5 -0
- package/storage/remoteStorage/storageClientController.ts +35 -0
- package/storage/remoteStorage/storageController.ts +30 -2
- package/storage/remoteStorage/storageServerState.d.ts +1 -0
- package/storage/remoteStorage/storageServerState.ts +18 -3
|
@@ -2,10 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
This is the reasoning behind the remote storage system (createArchives / storageServer / BlobStore). The code documents the mechanics; this documents the ideas that must survive refactors.
|
|
4
4
|
|
|
5
|
-
## Configuration updates happen only on startup
|
|
6
|
-
|
|
7
|
-
A client adopts a routing config once, during initialization, and then runs on it. Configs never automatically upgrade mid-run: the 5-minute poll picks up whatever the sources currently hold (so a developer's change spreads), but the version-gated *upgrade* — writing a newer configured version over an older stored one — happens only at startup. This is deliberate: config changes are made by a developer running code, and that developer is present at startup. It also prevents reversion loops — if a server briefly held a newer version and then went away, running clients don't cling to that phantom version; they follow what the surviving sources actually hold, and only a fresh startup with an explicitly newer configured version moves things forward again.
|
|
8
|
-
|
|
9
5
|
## Scanning + tombstones + versions make any two sources mergeable
|
|
10
6
|
|
|
11
7
|
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.
|
|
@@ -16,6 +12,16 @@ Each bucket stores its complete routing config (the full redundancy list) inside
|
|
|
16
12
|
|
|
17
13
|
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
14
|
|
|
15
|
+
Storage routing JSON is only written to if we have write access and it's only written to on startup, it doesn't propagate. that way things don't revert without the developer intentionally rerunning it.
|
|
16
|
+
|
|
17
|
+
## Redundancy, sharding, and deployment
|
|
18
|
+
|
|
19
|
+
For redundancy, we can just have multiple different configurations that will satisfy the same request. The first one is the one that we write to. If that one's down, we don't do writes.
|
|
20
|
+
|
|
21
|
+
However, there's also sharding, where we have different route ranges that values can hash to. Values can explicitly try to write to a specific value via a special like hash override key. This allows us to sometimes make our rights hit a server which has less latency. In the case that the client writer will accept the fact that we might change the key.
|
|
22
|
+
|
|
23
|
+
The valid windows allow us to schedule deployments, so the nodes can switch over gracefully.
|
|
24
|
+
|
|
19
25
|
## BulkDatabase2 index + our own disk as just another source
|
|
20
26
|
|
|
21
27
|
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.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Subscribe to server-pushed routing change notifications. Returns the unsubscribe function. */
|
|
2
|
+
export declare function onServerRoutingChanged(listener: () => void): () => void;
|
|
3
|
+
export declare const StorageClientController: import("socket-function/SocketFunctionTypes").SocketRegistered<{
|
|
4
|
+
routingConfigChanged: () => Promise<void>;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
|
|
3
|
+
// The client half of routing-change broadcasts: the storage server tracks its connected clients
|
|
4
|
+
// (see trackCaller in storageController) and calls routingConfigChanged on every one of them the
|
|
5
|
+
// moment a routing config changes - clients react immediately instead of waiting for a poll.
|
|
6
|
+
|
|
7
|
+
const listeners = new Set<() => void>();
|
|
8
|
+
|
|
9
|
+
/** Subscribe to server-pushed routing change notifications. Returns the unsubscribe function. */
|
|
10
|
+
export function onServerRoutingChanged(listener: () => void): () => void {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class StorageClientControllerBase {
|
|
18
|
+
async routingConfigChanged(): Promise<void> {
|
|
19
|
+
for (let listener of [...listeners]) {
|
|
20
|
+
try {
|
|
21
|
+
listener();
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error(`Routing change listener failed: ${(e as Error).stack ?? e}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const StorageClientController = SocketFunction.register(
|
|
30
|
+
"StorageClientController-remoteStorage-2b8f1a6d",
|
|
31
|
+
new StorageClientControllerBase(),
|
|
32
|
+
() => ({
|
|
33
|
+
routingConfigChanged: {},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
@@ -14,8 +14,9 @@ import { getTakeoverStamp } from "./deployTakeover";
|
|
|
14
14
|
import {
|
|
15
15
|
getStorageServerConfig, getTrust, getRequests, getLoadedBucket, writeBucketFile,
|
|
16
16
|
deleteBucketFile, assertWritesAllowed, assertMutable, LoadedBucket,
|
|
17
|
-
getBucketConfig, listAccountBuckets, ServerBucketInfo,
|
|
17
|
+
getBucketConfig, listAccountBuckets, ServerBucketInfo, setRoutingChangedBroadcaster,
|
|
18
18
|
} from "./storageServerState";
|
|
19
|
+
import { StorageClientController } from "./storageClientController";
|
|
19
20
|
|
|
20
21
|
// The remote storage server's API. Authentication uses certs.ts machine identities: a client
|
|
21
22
|
// proves it owns its machine key by signing a timestamped token (bound to this server, so tokens
|
|
@@ -105,6 +106,31 @@ function assertValidPath(path: string) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// Every client pings us (immediately on connect, then continuously), so pings tell us exactly who
|
|
110
|
+
// our currently-connected clients are. Client nodeIds never reconnect (a reconnect is a NEW
|
|
111
|
+
// nodeId), so the disconnect callback fully cleans an entry up.
|
|
112
|
+
const connectedClients = new Set<string>();
|
|
113
|
+
function trackCaller(): void {
|
|
114
|
+
let nodeId = SocketFunction.getCaller().nodeId;
|
|
115
|
+
if (connectedClients.has(nodeId)) return;
|
|
116
|
+
connectedClients.add(nodeId);
|
|
117
|
+
SocketFunction.onNextDisconnect(nodeId, () => {
|
|
118
|
+
connectedClients.delete(nodeId);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The moment any routing config changes on this server, every connected client is told - clients
|
|
123
|
+
// must never have to wait for a poll to learn the topology changed
|
|
124
|
+
setRoutingChangedBroadcaster(() => {
|
|
125
|
+
console.log(`Broadcasting routing config change to ${connectedClients.size} connected clients`);
|
|
126
|
+
for (let nodeId of [...connectedClients]) {
|
|
127
|
+
void StorageClientController.nodes[nodeId].routingConfigChanged().catch(() => {
|
|
128
|
+
// The client is gone (or too old to know the controller); the disconnect callback
|
|
129
|
+
// cleans the registry, nothing to do here
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
108
134
|
function getCallerMachineId(): string {
|
|
109
135
|
let caller = SocketFunction.getCaller();
|
|
110
136
|
let machineId = sessions.get(caller.nodeId);
|
|
@@ -155,8 +181,10 @@ async function getBucket(account: string, bucketName: string): Promise<LoadedBuc
|
|
|
155
181
|
class RemoteStorageControllerBase {
|
|
156
182
|
// Latency measurement (see SourceWrapper's pinging); no auth, it measures the transport. The
|
|
157
183
|
// takeover stamp piggybacks on it so every connected client learns of a deploy takeover
|
|
158
|
-
// within one ping interval.
|
|
184
|
+
// within one ping interval. Pings also tell us who our clients ARE - the trackCaller
|
|
185
|
+
// registry is what routing-change broadcasts push to.
|
|
159
186
|
async ping(): Promise<{ takeover?: string }> {
|
|
187
|
+
trackCaller();
|
|
160
188
|
return { takeover: getTakeoverStamp() };
|
|
161
189
|
}
|
|
162
190
|
|
|
@@ -37,6 +37,7 @@ export type LoadedBucket = {
|
|
|
37
37
|
};
|
|
38
38
|
export declare function addExtraListenPort(port: number): void;
|
|
39
39
|
export declare function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined>;
|
|
40
|
+
export declare function setRoutingChangedBroadcaster(broadcaster: () => void): void;
|
|
40
41
|
export declare function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void>;
|
|
41
42
|
export declare function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: {
|
|
42
43
|
lastModified?: number;
|
|
@@ -8,7 +8,7 @@ import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MAR
|
|
|
8
8
|
import {
|
|
9
9
|
RemoteConfig, HostedConfig, BackblazeConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
|
|
10
10
|
ArchivesSyncStatus,
|
|
11
|
-
|
|
11
|
+
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
14
|
import { applyDeployRemap, getTakeoverAltPort, getOwnWindowEndClip, onTakeoverEvent } from "./deployTakeover";
|
|
@@ -563,6 +563,18 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
563
563
|
return loaded;
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// Called whenever any bucket's routing config is applied (created, updated in place, or rebuilt).
|
|
567
|
+
// storageController wires this to its connected-client broadcast, so every client is pushed the
|
|
568
|
+
// change immediately - never waiting for a poll. (A callback, not an import - storageController
|
|
569
|
+
// imports us, so importing it back would be a cycle.)
|
|
570
|
+
let routingChangedBroadcaster: (() => void) | undefined;
|
|
571
|
+
export function setRoutingChangedBroadcaster(broadcaster: () => void): void {
|
|
572
|
+
routingChangedBroadcaster = broadcaster;
|
|
573
|
+
}
|
|
574
|
+
function notifyRoutingChanged(): void {
|
|
575
|
+
routingChangedBroadcaster?.();
|
|
576
|
+
}
|
|
577
|
+
|
|
566
578
|
// Routing reloads are serialized per bucket, so concurrent writes/syncs can't rebuild the same
|
|
567
579
|
// store twice in parallel
|
|
568
580
|
const routingReloads = new Map<string, Promise<void>>();
|
|
@@ -610,6 +622,7 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
610
622
|
buckets.set(key, Promise.resolve(updated));
|
|
611
623
|
scheduleWindowBoundaryRebuild(updated);
|
|
612
624
|
scheduleBoundaryScans(updated);
|
|
625
|
+
notifyRoutingChanged();
|
|
613
626
|
return;
|
|
614
627
|
}
|
|
615
628
|
console.log(`Rebuilding the store for bucket ${key} (${reason})`);
|
|
@@ -617,6 +630,7 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
617
630
|
await loaded.store.dispose();
|
|
618
631
|
}
|
|
619
632
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
|
|
633
|
+
notifyRoutingChanged();
|
|
620
634
|
}
|
|
621
635
|
|
|
622
636
|
// Per-write options are evaluated at the WRITE's time (and the key's route), not the current
|
|
@@ -657,6 +671,7 @@ async function writeRoutingConfig(account: string, bucketName: string, data: Buf
|
|
|
657
671
|
if (!loaded) {
|
|
658
672
|
await new ArchivesDisk(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
|
|
659
673
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
|
|
674
|
+
notifyRoutingChanged();
|
|
660
675
|
console.log(`Created bucket ${key}`);
|
|
661
676
|
return;
|
|
662
677
|
}
|
|
@@ -696,9 +711,9 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
696
711
|
// target from a stale view - it must re-resolve and retry at the correct source.
|
|
697
712
|
let route = getRoute(filePath);
|
|
698
713
|
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
699
|
-
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0]
|
|
714
|
+
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
700
715
|
if (!timeValid.length) {
|
|
701
|
-
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))}
|
|
716
|
+
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))} (a switchover moved the write target)`);
|
|
702
717
|
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.`);
|
|
703
718
|
}
|
|
704
719
|
if (!timeValid.some(x => routeContains(x.route, route))) {
|