sliftutils 1.7.12 → 1.7.14
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/.claude/settings.local.json +2 -1
- package/index.d.ts +95 -39
- package/misc/dist/yaml.ts.cache +2 -2
- package/misc/dist/yamlBase.ts.cache +2 -2
- package/misc/https/hostServer.d.ts +7 -0
- package/misc/https/hostServer.ts +89 -20
- package/misc/openrouter.ts +17 -14
- package/package.json +2 -2
- package/storage/ArchivesDisk.d.ts +1 -1
- package/storage/ArchivesDisk.ts +2 -1
- package/storage/IArchives.d.ts +5 -1
- package/storage/IArchives.ts +5 -1
- package/storage/backblaze.d.ts +1 -1
- package/storage/backblaze.ts +9 -6
- package/storage/dist/embeddingFormats.ts.cache +2 -2
- package/storage/proxydatabase/dist/Database.ts.cache +2 -2
- package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +123 -61
- package/storage/proxydatabase/dist/transactionSet.ts.cache +16 -4
- package/storage/remoteStorage/ArchivesRemote.d.ts +1 -1
- package/storage/remoteStorage/ArchivesRemote.ts +2 -1
- package/storage/remoteStorage/ArchivesUrl.d.ts +1 -1
- package/storage/remoteStorage/ArchivesUrl.ts +13 -9
- package/storage/remoteStorage/blobStore.d.ts +6 -2
- package/storage/remoteStorage/blobStore.ts +46 -6
- package/storage/remoteStorage/createArchives.d.ts +26 -11
- package/storage/remoteStorage/createArchives.ts +104 -28
- package/storage/remoteStorage/deployTakeover.d.ts +24 -0
- package/storage/remoteStorage/deployTakeover.ts +373 -0
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +16 -11
- package/storage/remoteStorage/dist/blobStore.ts.cache +39 -4
- package/storage/remoteStorage/dist/createArchives.ts.cache +91 -9
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +371 -0
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +9 -3
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +3 -3
- package/storage/remoteStorage/dist/storageController.ts.cache +11 -12
- package/storage/remoteStorage/dist/storageServerState.ts.cache +120 -19
- package/storage/remoteStorage/remoteConfig.d.ts +1 -0
- package/storage/remoteStorage/remoteConfig.ts +6 -0
- package/storage/remoteStorage/storageController.d.ts +2 -0
- package/storage/remoteStorage/storageController.ts +9 -9
- package/storage/remoteStorage/storageServer.ts +14 -1
- package/storage/remoteStorage/storageServerState.d.ts +16 -1
- package/storage/remoteStorage/storageServerState.ts +118 -16
- package/yarn.lock +4 -4
- package/storage/dist/faceFramesData.ts.cache +0 -55
- package/storage/embeddingBench.d.ts +0 -1
- package/storage/embeddingBench.ts +0 -152
- package/storage/faceFramesData.d.ts +0 -1
- package/storage/faceFramesData.ts +0 -49
- package/storage/faceFramesServer.d.ts +0 -1
- package/storage/faceFramesServer.ts +0 -56
- package/storage/proxydatabase/ivfDbCheck.d.ts +0 -1
- package/storage/proxydatabase/ivfDbCheck.ts +0 -85
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module.allowclient = true;
|
|
2
2
|
|
|
3
|
+
import { httpsRequest, HttpsResponseInfo } from "socket-function/src/https";
|
|
3
4
|
import { IArchives, ArchiveFileInfo, ArchivesConfig } from "../IArchives";
|
|
4
5
|
import { buildFileUrl } from "./remoteConfig";
|
|
5
6
|
|
|
@@ -30,25 +31,28 @@ export class ArchivesUrl implements IArchives {
|
|
|
30
31
|
if (range) {
|
|
31
32
|
headers["Range"] = `bytes=${range.start}-${range.end - 1}`;
|
|
32
33
|
}
|
|
33
|
-
let response =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
let response: HttpsResponseInfo = { headers: {} };
|
|
35
|
+
let data: Buffer;
|
|
36
|
+
try {
|
|
37
|
+
data = await httpsRequest(url, undefined, "GET", false, { headers, outResponse: response });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// httpsRequest throws on any non-2xx; a missing file is a normal absent result, not an error.
|
|
40
|
+
if (response.statusCode === 404) return undefined;
|
|
41
|
+
throw e;
|
|
37
42
|
}
|
|
38
|
-
let data = Buffer.from(await response.arrayBuffer());
|
|
39
43
|
// A real 206 only returns the requested slice, so the full size lives in Content-Range's total.
|
|
40
44
|
let size = data.length;
|
|
41
|
-
let contentRange = response.headers
|
|
45
|
+
let contentRange = response.headers["content-range"];
|
|
42
46
|
let total = contentRange && Number(contentRange.split("/")[1]);
|
|
43
47
|
if (total && Number.isFinite(total)) {
|
|
44
48
|
size = total;
|
|
45
49
|
}
|
|
46
50
|
// Servers that don't support ranges return the full file with a 200 (ours serves real
|
|
47
51
|
// 206s, but backblaze friendly URLs and proxies may not)
|
|
48
|
-
if (range && response.
|
|
52
|
+
if (range && response.statusCode === 200) {
|
|
49
53
|
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
50
54
|
}
|
|
51
|
-
let lastModified = response.headers
|
|
55
|
+
let lastModified = response.headers["last-modified"];
|
|
52
56
|
let writeTime = lastModified && new Date(lastModified).getTime() || 0;
|
|
53
57
|
return { data, writeTime, size };
|
|
54
58
|
}
|
|
@@ -57,7 +61,7 @@ export class ArchivesUrl implements IArchives {
|
|
|
57
61
|
return result && { writeTime: result.writeTime, size: result.size } || undefined;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<
|
|
64
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
|
|
61
65
|
throw this.readOnlyError("set");
|
|
62
66
|
}
|
|
63
67
|
public async del(fileName: string): Promise<void> {
|
|
@@ -24,7 +24,7 @@ export type IBucketStore = {
|
|
|
24
24
|
writeTime: number;
|
|
25
25
|
size: number;
|
|
26
26
|
} | undefined>;
|
|
27
|
-
set(fileName: string, data: Buffer, config?: WriteConfig): Promise<
|
|
27
|
+
set(fileName: string, data: Buffer, config?: WriteConfig): Promise<string>;
|
|
28
28
|
del(fileName: string, config?: WriteConfig): Promise<void>;
|
|
29
29
|
getInfo(fileName: string): Promise<{
|
|
30
30
|
writeTime: number;
|
|
@@ -70,6 +70,7 @@ export declare class BlobStore implements IBucketStore {
|
|
|
70
70
|
constructor(folder: string, sources: ArchivesSource[], config?: {
|
|
71
71
|
onIndexChanged?: ((key: string) => void) | undefined;
|
|
72
72
|
readerDiskLimit?: number | undefined;
|
|
73
|
+
getFlushDeadline?: (() => number | undefined) | undefined;
|
|
73
74
|
} | undefined);
|
|
74
75
|
private stopped;
|
|
75
76
|
private index;
|
|
@@ -92,6 +93,9 @@ export declare class BlobStore implements IBucketStore {
|
|
|
92
93
|
private countEntry;
|
|
93
94
|
private setIndexEntry;
|
|
94
95
|
private deleteIndexEntry;
|
|
96
|
+
/** Rescans our own disk's metadata into the index - used around deploy switchovers, where the
|
|
97
|
+
* other process wrote files to the shared folder that our index hasn't seen. */
|
|
98
|
+
rescanBase(): Promise<void>;
|
|
95
99
|
/** The cheap always-current totals plus any in-progress background synchronization. */
|
|
96
100
|
getSyncProgress(): {
|
|
97
101
|
index: {
|
|
@@ -144,7 +148,7 @@ export declare class BlobStore implements IBucketStore {
|
|
|
144
148
|
size: number;
|
|
145
149
|
} | undefined>;
|
|
146
150
|
private cacheRead;
|
|
147
|
-
set(key: string, data: Buffer, config?: WriteConfig): Promise<
|
|
151
|
+
set(key: string, data: Buffer, config?: WriteConfig): Promise<string>;
|
|
148
152
|
del(key: string, config?: WriteConfig): Promise<void>;
|
|
149
153
|
private getWritableSources;
|
|
150
154
|
private writeToSources;
|
|
@@ -2,7 +2,7 @@ import path from "path";
|
|
|
2
2
|
import { lazy } from "socket-function/src/caching";
|
|
3
3
|
import { runInfinitePoll, delay } from "socket-function/src/batching";
|
|
4
4
|
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
5
|
-
import { formatNumber } from "socket-function/src/formatting/format";
|
|
5
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
6
|
import {
|
|
7
7
|
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
|
|
8
8
|
windowAcceptsWrites, SyncActivity,
|
|
@@ -44,6 +44,9 @@ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
|
|
|
44
44
|
// Full syncs download this many files concurrently (high-latency sources like backblaze would
|
|
45
45
|
// otherwise crawl one round-trip at a time)
|
|
46
46
|
const FULL_SYNC_PARALLEL = 8;
|
|
47
|
+
// A full sync running longer than this is console.errored (and again every interval after), so a
|
|
48
|
+
// sync that will take days is loud instead of a quiet console.log every minute
|
|
49
|
+
const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
|
|
47
50
|
|
|
48
51
|
export type WriteConfig = {
|
|
49
52
|
// Resolve once the write is in memory; flush to the sources after writeDelay, coalescing
|
|
@@ -60,7 +63,7 @@ export type WriteConfig = {
|
|
|
60
63
|
export type IBucketStore = {
|
|
61
64
|
get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
|
|
62
65
|
get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
|
|
63
|
-
set(fileName: string, data: Buffer, config?: WriteConfig): Promise<
|
|
66
|
+
set(fileName: string, data: Buffer, config?: WriteConfig): Promise<string>;
|
|
64
67
|
del(fileName: string, config?: WriteConfig): Promise<void>;
|
|
65
68
|
getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
|
|
66
69
|
findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
|
|
@@ -127,6 +130,10 @@ export class BlobStore implements IBucketStore {
|
|
|
127
130
|
onIndexChanged?: (key: string) => void;
|
|
128
131
|
// LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
|
|
129
132
|
readerDiskLimit?: number;
|
|
133
|
+
// Deploy takeover: fast-write flush delays never extend past this time, and after it
|
|
134
|
+
// fast writes flush immediately (nothing may sit in memory when the write window
|
|
135
|
+
// transfers to the successor process)
|
|
136
|
+
getFlushDeadline?: () => number | undefined;
|
|
130
137
|
}
|
|
131
138
|
) { }
|
|
132
139
|
|
|
@@ -231,6 +238,13 @@ export class BlobStore implements IBucketStore {
|
|
|
231
238
|
this.dirty.set(key, undefined);
|
|
232
239
|
}
|
|
233
240
|
|
|
241
|
+
/** Rescans our own disk's metadata into the index - used around deploy switchovers, where the
|
|
242
|
+
* other process wrote files to the shared folder that our index hasn't seen. */
|
|
243
|
+
public async rescanBase(): Promise<void> {
|
|
244
|
+
await this.init();
|
|
245
|
+
await this.scanSource(0);
|
|
246
|
+
}
|
|
247
|
+
|
|
234
248
|
/** The cheap always-current totals plus any in-progress background synchronization. */
|
|
235
249
|
public getSyncProgress(): {
|
|
236
250
|
index: { fileCount: number; byteCount: number };
|
|
@@ -478,6 +492,20 @@ export class BlobStore implements IBucketStore {
|
|
|
478
492
|
};
|
|
479
493
|
let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
|
|
480
494
|
(progressTimer as { unref?: () => void }).unref?.();
|
|
495
|
+
let slowErrorTimer = setInterval(() => {
|
|
496
|
+
let elapsed = Date.now() - activity.startTime;
|
|
497
|
+
let doneFiles = activity.doneFiles || 0;
|
|
498
|
+
let doneBytes = activity.doneBytes || 0;
|
|
499
|
+
let bytesPerSecond = doneBytes / (elapsed / 1000);
|
|
500
|
+
let remainingBytes = totalBytes - doneBytes;
|
|
501
|
+
let etaText = "unknown (no bytes transferred yet)";
|
|
502
|
+
if (bytesPerSecond > 0) {
|
|
503
|
+
let remainingMs = remainingBytes / bytesPerSecond * 1000;
|
|
504
|
+
etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
|
|
505
|
+
}
|
|
506
|
+
console.error(`Full sync from ${source.getDebugName()} (store ${this.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
|
|
507
|
+
}, FULL_SYNC_SLOW_ERROR_INTERVAL);
|
|
508
|
+
(slowErrorTimer as { unref?: () => void }).unref?.();
|
|
481
509
|
try {
|
|
482
510
|
let nextIndex = 0;
|
|
483
511
|
let failed = false;
|
|
@@ -509,6 +537,7 @@ export class BlobStore implements IBucketStore {
|
|
|
509
537
|
await Promise.all(workers);
|
|
510
538
|
} finally {
|
|
511
539
|
clearInterval(progressTimer);
|
|
540
|
+
clearInterval(slowErrorTimer);
|
|
512
541
|
this.syncActivities.delete(activity);
|
|
513
542
|
// A sync slow enough to have logged progress also logs its completion
|
|
514
543
|
if (progressLogged) {
|
|
@@ -627,7 +656,7 @@ export class BlobStore implements IBucketStore {
|
|
|
627
656
|
this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
|
|
628
657
|
}
|
|
629
658
|
|
|
630
|
-
public async set(key: string, data: Buffer, config?: WriteConfig): Promise<
|
|
659
|
+
public async set(key: string, data: Buffer, config?: WriteConfig): Promise<string> {
|
|
631
660
|
await this.init();
|
|
632
661
|
let lastModified = config?.lastModified;
|
|
633
662
|
if (lastModified) {
|
|
@@ -636,16 +665,27 @@ export class BlobStore implements IBucketStore {
|
|
|
636
665
|
let entry = this.mem.get(key);
|
|
637
666
|
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
638
667
|
// An older write never overwrites a newer one (see IArchives.set)
|
|
639
|
-
if (lastModified < currentTime) return;
|
|
668
|
+
if (lastModified < currentTime) return key;
|
|
640
669
|
}
|
|
641
670
|
let writeTime = lastModified || Date.now();
|
|
642
671
|
if (config?.fast) {
|
|
643
672
|
let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
|
|
644
|
-
|
|
645
|
-
|
|
673
|
+
let flushAt = Date.now() + writeDelay;
|
|
674
|
+
let deadline = this.config?.getFlushDeadline?.();
|
|
675
|
+
if (deadline !== undefined) {
|
|
676
|
+
if (Date.now() >= deadline) {
|
|
677
|
+
this.overlay.delete(key);
|
|
678
|
+
await this.writeToSources(key, data, writeTime);
|
|
679
|
+
return key;
|
|
680
|
+
}
|
|
681
|
+
flushAt = Math.min(flushAt, deadline);
|
|
682
|
+
}
|
|
683
|
+
this.overlay.set(key, { data, t: writeTime, flushAt });
|
|
684
|
+
return key;
|
|
646
685
|
}
|
|
647
686
|
this.overlay.delete(key);
|
|
648
687
|
await this.writeToSources(key, data, writeTime);
|
|
688
|
+
return key;
|
|
649
689
|
}
|
|
650
690
|
|
|
651
691
|
public async del(key: string, config?: WriteConfig): Promise<void> {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
import { IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
|
|
4
|
+
import { ServerBucketInfo } from "./storageServerState";
|
|
4
5
|
export declare function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives;
|
|
5
6
|
export declare class ArchivesChain implements IArchives {
|
|
6
7
|
private configured;
|
|
@@ -16,9 +17,14 @@ export declare class ArchivesChain implements IArchives {
|
|
|
16
17
|
private init;
|
|
17
18
|
private buildSources;
|
|
18
19
|
private startConfigPoll;
|
|
20
|
+
private configRefreshInFlight;
|
|
21
|
+
private refreshActiveConfig;
|
|
22
|
+
private fetchLatestConfig;
|
|
19
23
|
private checkForNewConfig;
|
|
20
24
|
private run;
|
|
21
25
|
private runWrite;
|
|
26
|
+
private lastConfigRefresh;
|
|
27
|
+
private prepareWrongTargetRetry;
|
|
22
28
|
private request;
|
|
23
29
|
waitingForAccess(): Promise<{
|
|
24
30
|
link: string;
|
|
@@ -61,20 +67,14 @@ export declare class ArchivesChain implements IArchives {
|
|
|
61
67
|
/** True only when EVERY write-receiving source would accept our writes (partial write access
|
|
62
68
|
* desynchronizes sources, so it counts as no access). */
|
|
63
69
|
hasWriteAccess(): Promise<boolean>;
|
|
64
|
-
|
|
70
|
+
/** Returns the full key written. Plain keys come back unchanged; keys containing VARIABLE_SHARD
|
|
71
|
+
* are automatically materialized (a shard value is picked and embedded, see setVariableShard)
|
|
72
|
+
* and the caller needs the returned key to ever read the value back. */
|
|
65
73
|
set(fileName: string, data: Buffer, config?: {
|
|
66
74
|
lastModified?: number;
|
|
67
|
-
}): Promise<void>;
|
|
68
|
-
del(fileName: string): Promise<void>;
|
|
69
|
-
/** Writes a key containing the VARIABLE_SHARD sentinel: picks the lowest-latency up write
|
|
70
|
-
* shard, materializes the key with a random value inside that shard's route, writes it, and
|
|
71
|
-
* returns the FULL key actually written (the caller needs it to ever read the value back).
|
|
72
|
-
* Unlike normal writes this CAN move to another shard when the preferred one is down (error +
|
|
73
|
-
* socket down, same rule as reads) - each shard receives a different key, so write
|
|
74
|
-
* consistency is preserved. */
|
|
75
|
-
setVariableShard(key: string, data: Buffer, config?: {
|
|
76
|
-
lastModified?: number;
|
|
77
75
|
}): Promise<string>;
|
|
76
|
+
del(fileName: string): Promise<void>;
|
|
77
|
+
private setVariableShard;
|
|
78
78
|
setLargeFile(config: {
|
|
79
79
|
path: string;
|
|
80
80
|
getNextData(): Promise<Buffer | undefined>;
|
|
@@ -83,3 +83,18 @@ export declare class ArchivesChain implements IArchives {
|
|
|
83
83
|
dispose(): void;
|
|
84
84
|
}
|
|
85
85
|
export declare function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain;
|
|
86
|
+
/** Every bucket an account has on one storage server - active and inactive - with each bucket's
|
|
87
|
+
* configuration. One authenticated call (the normal trust system applies): no ArchivesChain, no
|
|
88
|
+
* synchronization, and inactive buckets on the server stay inactive. Any URL addressing the
|
|
89
|
+
* server works (a bucket routing URL, or just https://host:port). */
|
|
90
|
+
export declare function listServerBuckets(config: {
|
|
91
|
+
url: string;
|
|
92
|
+
account: string;
|
|
93
|
+
}): Promise<ServerBucketInfo[]>;
|
|
94
|
+
/** Live info for one bucket given its routing URL (getConfig: routing config, index totals, disk
|
|
95
|
+
* limit, in-progress synchronization). One authenticated call to that server - a light, safe
|
|
96
|
+
* alternative to instantiating an ArchivesChain, which would start synchronization machinery. */
|
|
97
|
+
export declare function getBucketInfo(config: {
|
|
98
|
+
url: string;
|
|
99
|
+
accountName?: string;
|
|
100
|
+
}): Promise<ArchivesConfig>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module.allowclient = true;
|
|
2
2
|
|
|
3
3
|
import { isNode, sort } from "socket-function/src/misc";
|
|
4
|
+
import { delay } from "socket-function/src/batching";
|
|
4
5
|
import {
|
|
5
6
|
IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
|
|
6
7
|
ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW,
|
|
@@ -11,9 +12,11 @@ import {
|
|
|
11
12
|
normalizeRemoteConfig, normalizeSource, parseRoutingData, serializeRemoteConfig,
|
|
12
13
|
getRoute, routeContains, parseVariableRoute,
|
|
13
14
|
} from "./remoteConfig";
|
|
14
|
-
import {
|
|
15
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
16
|
+
import { ArchivesRemote, parseStorageUrl, authenticateStorage } from "./ArchivesRemote";
|
|
15
17
|
import { ArchivesBackblaze } from "../backblaze";
|
|
16
|
-
import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
|
|
18
|
+
import { getStorageServerConfigOptional, getLocalArchives, ServerBucketInfo } from "./storageServerState";
|
|
19
|
+
import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
|
|
17
20
|
import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
|
|
18
21
|
|
|
19
22
|
// Turns a RemoteConfig into a usable IArchives (createArchives). Initialization is lazy: on the
|
|
@@ -26,6 +29,13 @@ import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from
|
|
|
26
29
|
// rebuild the source list when it changed (reusing wrappers for unchanged sources).
|
|
27
30
|
|
|
28
31
|
const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
|
|
32
|
+
// Wrong-valid-window rejections within this distance of a known window boundary are just us racing
|
|
33
|
+
// the boundary itself - waiting fixes them, no new config needed
|
|
34
|
+
const WRONG_TARGET_BOUNDARY_WINDOW = 30 * 1000;
|
|
35
|
+
const WRONG_TARGET_BOUNDARY_RETRY_DELAY = 15 * 1000;
|
|
36
|
+
// Otherwise our config is stale; re-fetch it from the server, at most this often (when throttled,
|
|
37
|
+
// retry with what we have)
|
|
38
|
+
const CONFIG_REFRESH_THROTTLE = 30 * 1000;
|
|
29
39
|
|
|
30
40
|
// The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
|
|
31
41
|
// storage server for its synchronization sources. Denied calls throw immediately (registering the
|
|
@@ -239,13 +249,39 @@ export class ArchivesChain implements IArchives {
|
|
|
239
249
|
private startConfigPoll(): void {
|
|
240
250
|
if (this.pollTimer || this.disposed) return;
|
|
241
251
|
this.pollTimer = setInterval(() => {
|
|
242
|
-
void this.
|
|
252
|
+
void this.refreshActiveConfig().catch((e: Error) => {
|
|
243
253
|
console.error(`Checking for a new storage routing config failed for ${this.getDebugName()}: ${e.stack ?? e}`);
|
|
244
254
|
});
|
|
245
255
|
}, CONFIG_POLL_INTERVAL);
|
|
246
256
|
(this.pollTimer as { unref?: () => void }).unref?.();
|
|
247
257
|
}
|
|
248
258
|
|
|
259
|
+
// Deduplicates concurrent refreshes (the poll timer and wrong-target write retries share this)
|
|
260
|
+
private configRefreshInFlight: Promise<void> | undefined;
|
|
261
|
+
private refreshActiveConfig(): Promise<void> {
|
|
262
|
+
if (!this.configRefreshInFlight) {
|
|
263
|
+
this.configRefreshInFlight = this.checkForNewConfig().finally(() => {
|
|
264
|
+
this.configRefreshInFlight = undefined;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return this.configRefreshInFlight;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The latest config, as the server INTERPRETS it: getConfig carries in-memory overlays (deploy
|
|
271
|
+
// takeover remaps) that are deliberately never written into the routing file, so it's
|
|
272
|
+
// preferred over reading the raw file. URL-only chains fall back to the raw file.
|
|
273
|
+
private async fetchLatestConfig(state: ChainState): Promise<RemoteConfig | undefined> {
|
|
274
|
+
try {
|
|
275
|
+
let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
|
|
276
|
+
if (config.remoteConfig) {
|
|
277
|
+
return normalizeRemoteConfig(config.remoteConfig);
|
|
278
|
+
}
|
|
279
|
+
} catch { }
|
|
280
|
+
let data = await this.run(state, {}, archives => archives.get(ROUTING_FILE));
|
|
281
|
+
if (!data) return undefined;
|
|
282
|
+
return parseRoutingData(data);
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
private async checkForNewConfig(): Promise<void> {
|
|
250
286
|
if (this.disposed || !this.statePromise) return;
|
|
251
287
|
let state: ChainState;
|
|
@@ -255,9 +291,8 @@ export class ArchivesChain implements IArchives {
|
|
|
255
291
|
// Init is failing; its own retry loop handles that
|
|
256
292
|
return;
|
|
257
293
|
}
|
|
258
|
-
let
|
|
259
|
-
if (!
|
|
260
|
-
let latest = parseRoutingData(data);
|
|
294
|
+
let latest = await this.fetchLatestConfig(state);
|
|
295
|
+
if (!latest) return;
|
|
261
296
|
if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
|
|
262
297
|
console.log(`Storage routing config changed for ${this.getDebugName()}, rebuilding sources`);
|
|
263
298
|
let oldByConfig = new Map(state.sources.map(source => [JSON.stringify(source.config), source]));
|
|
@@ -287,7 +322,7 @@ export class ArchivesChain implements IArchives {
|
|
|
287
322
|
// an application error and throws as-is. A down source gets its background reconnect kicked.
|
|
288
323
|
private async run<T>(state: ChainState, config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
289
324
|
if (config.write) {
|
|
290
|
-
return await this.runWrite(
|
|
325
|
+
return await this.runWrite(config.route, run);
|
|
291
326
|
}
|
|
292
327
|
let errors: string[] = [];
|
|
293
328
|
for (let source of state.sources) {
|
|
@@ -322,10 +357,11 @@ export class ArchivesChain implements IArchives {
|
|
|
322
357
|
// scatter its writes across the chain. (Reads fail over freely above: sources are synchronized
|
|
323
358
|
// copies, so reading from any of them is safe.) The one retry is for a window boundary passing
|
|
324
359
|
// (or a route disagreement) mid-write, which re-resolves the target rather than falling back.
|
|
325
|
-
private async runWrite<T>(
|
|
360
|
+
private async runWrite<T>(route: number | undefined, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
326
361
|
let retriedWrongWindow = false;
|
|
327
362
|
let retriedWrongRoute = false;
|
|
328
363
|
while (true) {
|
|
364
|
+
let state = await this.getState();
|
|
329
365
|
let target = state.sources.find(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route)));
|
|
330
366
|
if (!target) {
|
|
331
367
|
throw new Error(`No source accepts writes for ${this.getDebugName()}${route !== undefined && ` (route ${route})` || ""} (every source is outside its valid window or outside the key's route)`);
|
|
@@ -340,10 +376,12 @@ export class ArchivesChain implements IArchives {
|
|
|
340
376
|
let message = String((e as Error).stack ?? e);
|
|
341
377
|
if (message.includes(STORAGE_WRONG_VALID_WINDOW) && !retriedWrongWindow) {
|
|
342
378
|
retriedWrongWindow = true;
|
|
379
|
+
await this.prepareWrongTargetRetry(state, "window");
|
|
343
380
|
continue;
|
|
344
381
|
}
|
|
345
382
|
if (message.includes(STORAGE_WRONG_ROUTE) && !retriedWrongRoute) {
|
|
346
383
|
retriedWrongRoute = true;
|
|
384
|
+
await this.prepareWrongTargetRetry(state, "route");
|
|
347
385
|
continue;
|
|
348
386
|
}
|
|
349
387
|
if (!target.isConnected()) {
|
|
@@ -354,6 +392,23 @@ export class ArchivesChain implements IArchives {
|
|
|
354
392
|
}
|
|
355
393
|
}
|
|
356
394
|
|
|
395
|
+
private lastConfigRefresh = 0;
|
|
396
|
+
// A wrong-target rejection means either we raced a window boundary (time fixes it) or our
|
|
397
|
+
// config is stale - e.g. a deploy-takeover remap that only exists in the server's memory
|
|
398
|
+
private async prepareWrongTargetRetry(state: ChainState, kind: "window" | "route"): Promise<void> {
|
|
399
|
+
if (kind === "window") {
|
|
400
|
+
let now = Date.now();
|
|
401
|
+
let nearBoundary = state.sources.some(source => source.config.validWindow.some(t => t > 0 && t < Number.MAX_SAFE_INTEGER && Math.abs(t - now) <= WRONG_TARGET_BOUNDARY_WINDOW));
|
|
402
|
+
if (nearBoundary) {
|
|
403
|
+
await delay(WRONG_TARGET_BOUNDARY_RETRY_DELAY);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (Date.now() - this.lastConfigRefresh < CONFIG_REFRESH_THROTTLE) return;
|
|
408
|
+
this.lastConfigRefresh = Date.now();
|
|
409
|
+
await this.refreshActiveConfig();
|
|
410
|
+
}
|
|
411
|
+
|
|
357
412
|
private async request<T>(config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
358
413
|
let state = await this.getState();
|
|
359
414
|
return await this.run(state, config, run);
|
|
@@ -494,33 +549,26 @@ export class ArchivesChain implements IArchives {
|
|
|
494
549
|
return true;
|
|
495
550
|
}
|
|
496
551
|
|
|
497
|
-
|
|
552
|
+
/** Returns the full key written. Plain keys come back unchanged; keys containing VARIABLE_SHARD
|
|
553
|
+
* are automatically materialized (a shard value is picked and embedded, see setVariableShard)
|
|
554
|
+
* and the caller needs the returned key to ever read the value back. */
|
|
555
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
|
|
498
556
|
if (fileName.includes(VARIABLE_SHARD) && parseVariableRoute(fileName) === undefined) {
|
|
499
|
-
|
|
557
|
+
return await this.setVariableShard(fileName, data, config);
|
|
500
558
|
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
504
|
-
this.assertNotBareVariableShard(fileName);
|
|
505
559
|
await this.request({ write: true, route: getRoute(fileName) }, archives => archives.set(fileName, data, config));
|
|
560
|
+
return fileName;
|
|
506
561
|
}
|
|
507
562
|
public async del(fileName: string): Promise<void> {
|
|
508
563
|
await this.request({ write: true, route: getRoute(fileName) }, archives => archives.del(fileName));
|
|
509
564
|
}
|
|
510
565
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
public async setVariableShard(key: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
|
|
518
|
-
if (!key.includes(VARIABLE_SHARD)) {
|
|
519
|
-
throw new Error(`Expected the key to contain the VARIABLE_SHARD sentinel, was ${JSON.stringify(key)}`);
|
|
520
|
-
}
|
|
521
|
-
if (parseVariableRoute(key) !== undefined) {
|
|
522
|
-
throw new Error(`The key already has a materialized shard value; write it with set instead. Key: ${JSON.stringify(key)}`);
|
|
523
|
-
}
|
|
566
|
+
// Writes a bare variable-shard key: picks the lowest-latency up write shard, materializes the
|
|
567
|
+
// key with a random value inside that shard's route, writes it, and returns the FULL key
|
|
568
|
+
// actually written. Unlike normal writes this CAN move to another shard when the preferred one
|
|
569
|
+
// is down (error + socket down, same rule as reads) - each shard receives a different key, so
|
|
570
|
+
// write consistency is preserved.
|
|
571
|
+
private async setVariableShard(key: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
|
|
524
572
|
let state = await this.getState();
|
|
525
573
|
// Per-shard write consistency still holds: within a route, only the FIRST source may take
|
|
526
574
|
// writes - latency only picks WHICH shard the key materializes into
|
|
@@ -558,7 +606,9 @@ export class ArchivesChain implements IArchives {
|
|
|
558
606
|
}
|
|
559
607
|
|
|
560
608
|
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
561
|
-
|
|
609
|
+
if (config.path.includes(VARIABLE_SHARD) && parseVariableRoute(config.path) === undefined) {
|
|
610
|
+
throw new Error(`setLargeFile does not support VARIABLE_SHARD keys (there is no way to return the materialized key); write the file with set, or materialize the key yourself. Key: ${JSON.stringify(config.path)}`);
|
|
611
|
+
}
|
|
562
612
|
let state = await this.getState();
|
|
563
613
|
// Same consistency rule as runWrite: the first currently-valid source for the route, or nothing
|
|
564
614
|
let route = getRoute(config.path);
|
|
@@ -610,3 +660,29 @@ export class ArchivesChain implements IArchives {
|
|
|
610
660
|
export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
|
|
611
661
|
return new ArchivesChain(config);
|
|
612
662
|
}
|
|
663
|
+
|
|
664
|
+
/** Every bucket an account has on one storage server - active and inactive - with each bucket's
|
|
665
|
+
* configuration. One authenticated call (the normal trust system applies): no ArchivesChain, no
|
|
666
|
+
* synchronization, and inactive buckets on the server stay inactive. Any URL addressing the
|
|
667
|
+
* server works (a bucket routing URL, or just https://host:port). */
|
|
668
|
+
export async function listServerBuckets(config: { url: string; account: string }): Promise<ServerBucketInfo[]> {
|
|
669
|
+
SocketFunction.ENABLE_CLIENT_MODE = true;
|
|
670
|
+
let parsed = parseStorageUrl(config.url);
|
|
671
|
+
let nodeId = SocketFunction.connect({ address: parsed.address, port: parsed.port });
|
|
672
|
+
let controller = RemoteStorageController.nodes[nodeId];
|
|
673
|
+
try {
|
|
674
|
+
return await controller.listBuckets(config.account);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
if (!String((e as Error).stack ?? e).includes(STORAGE_NOT_AUTHENTICATED)) throw e;
|
|
677
|
+
await authenticateStorage({ address: parsed.address, port: parsed.port, nodeId });
|
|
678
|
+
return await controller.listBuckets(config.account);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Live info for one bucket given its routing URL (getConfig: routing config, index totals, disk
|
|
683
|
+
* limit, in-progress synchronization). One authenticated call to that server - a light, safe
|
|
684
|
+
* alternative to instantiating an ArchivesChain, which would start synchronization machinery. */
|
|
685
|
+
export async function getBucketInfo(config: { url: string; accountName?: string }): Promise<ArchivesConfig> {
|
|
686
|
+
let remote = new ArchivesRemote({ url: config.url, accountName: config.accountName, waitForAccess: false });
|
|
687
|
+
return await remote.getConfig();
|
|
688
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { RemoteConfig } from "../IArchives";
|
|
2
|
+
export type TakeoverEvent = "remapChanged" | "diskScan";
|
|
3
|
+
/** Starts the takeover machinery. Returns whether a deploy timeline exists - when it doesn't,
|
|
4
|
+
* everything here stays inert (and the caller must NOT fall back to alternate ports, since a busy
|
|
5
|
+
* port then means a real misconfiguration, not a deploy). */
|
|
6
|
+
export declare function initDeployTakeover(config: {
|
|
7
|
+
domain: string;
|
|
8
|
+
mainPort: number;
|
|
9
|
+
storageFolder: string;
|
|
10
|
+
}): Promise<boolean>;
|
|
11
|
+
/** Called when we had to listen on an alternate port (the main port was still held by our
|
|
12
|
+
* predecessor): registers it so the predecessor can route the middle overlap window to us. */
|
|
13
|
+
export declare function registerAltPort(port: number): Promise<void>;
|
|
14
|
+
export declare function onTakeoverEvent(listener: (event: TakeoverEvent) => void): void;
|
|
15
|
+
/** The interpretation overlay: splits every source pointing at our domain+main port so the middle
|
|
16
|
+
* of the deploy overlap points at the alternate port. Pure, in-memory only - the stored routing
|
|
17
|
+
* config is never modified, and this must never be applied to data that gets persisted. */
|
|
18
|
+
export declare function applyDeployRemap(routing: RemoteConfig): RemoteConfig;
|
|
19
|
+
/** For the dying process: fast-write flush delays must never extend past this time, and after it
|
|
20
|
+
* fast writes flush immediately - so nothing is left in memory when the write window transfers. */
|
|
21
|
+
export declare function getFlushDeadline(): number | undefined;
|
|
22
|
+
/** How long to wait between main-port acquisition attempts: tight around the predecessor's
|
|
23
|
+
* scheduled death (when the port actually frees), relaxed otherwise. */
|
|
24
|
+
export declare function getMainPortAcquireDelay(): number;
|