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.
Files changed (53) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/index.d.ts +95 -39
  3. package/misc/dist/yaml.ts.cache +2 -2
  4. package/misc/dist/yamlBase.ts.cache +2 -2
  5. package/misc/https/hostServer.d.ts +7 -0
  6. package/misc/https/hostServer.ts +89 -20
  7. package/misc/openrouter.ts +17 -14
  8. package/package.json +2 -2
  9. package/storage/ArchivesDisk.d.ts +1 -1
  10. package/storage/ArchivesDisk.ts +2 -1
  11. package/storage/IArchives.d.ts +5 -1
  12. package/storage/IArchives.ts +5 -1
  13. package/storage/backblaze.d.ts +1 -1
  14. package/storage/backblaze.ts +9 -6
  15. package/storage/dist/embeddingFormats.ts.cache +2 -2
  16. package/storage/proxydatabase/dist/Database.ts.cache +2 -2
  17. package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +123 -61
  18. package/storage/proxydatabase/dist/transactionSet.ts.cache +16 -4
  19. package/storage/remoteStorage/ArchivesRemote.d.ts +1 -1
  20. package/storage/remoteStorage/ArchivesRemote.ts +2 -1
  21. package/storage/remoteStorage/ArchivesUrl.d.ts +1 -1
  22. package/storage/remoteStorage/ArchivesUrl.ts +13 -9
  23. package/storage/remoteStorage/blobStore.d.ts +6 -2
  24. package/storage/remoteStorage/blobStore.ts +46 -6
  25. package/storage/remoteStorage/createArchives.d.ts +26 -11
  26. package/storage/remoteStorage/createArchives.ts +104 -28
  27. package/storage/remoteStorage/deployTakeover.d.ts +24 -0
  28. package/storage/remoteStorage/deployTakeover.ts +373 -0
  29. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +16 -11
  30. package/storage/remoteStorage/dist/blobStore.ts.cache +39 -4
  31. package/storage/remoteStorage/dist/createArchives.ts.cache +91 -9
  32. package/storage/remoteStorage/dist/deployTakeover.ts.cache +371 -0
  33. package/storage/remoteStorage/dist/remoteConfig.ts.cache +9 -3
  34. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +3 -3
  35. package/storage/remoteStorage/dist/storageController.ts.cache +11 -12
  36. package/storage/remoteStorage/dist/storageServerState.ts.cache +120 -19
  37. package/storage/remoteStorage/remoteConfig.d.ts +1 -0
  38. package/storage/remoteStorage/remoteConfig.ts +6 -0
  39. package/storage/remoteStorage/storageController.d.ts +2 -0
  40. package/storage/remoteStorage/storageController.ts +9 -9
  41. package/storage/remoteStorage/storageServer.ts +14 -1
  42. package/storage/remoteStorage/storageServerState.d.ts +16 -1
  43. package/storage/remoteStorage/storageServerState.ts +118 -16
  44. package/yarn.lock +4 -4
  45. package/storage/dist/faceFramesData.ts.cache +0 -55
  46. package/storage/embeddingBench.d.ts +0 -1
  47. package/storage/embeddingBench.ts +0 -152
  48. package/storage/faceFramesData.d.ts +0 -1
  49. package/storage/faceFramesData.ts +0 -49
  50. package/storage/faceFramesServer.d.ts +0 -1
  51. package/storage/faceFramesServer.ts +0 -56
  52. package/storage/proxydatabase/ivfDbCheck.d.ts +0 -1
  53. 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 = await fetch(url, { headers });
34
- if (response.status === 404) return undefined;
35
- if (!response.ok) {
36
- throw new Error(`Read of ${url} failed: ${response.status} ${response.statusText}`);
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.get("content-range");
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.status === 200) {
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.get("last-modified");
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<void> {
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<void>;
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<void>;
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<void>;
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<void> {
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
- this.overlay.set(key, { data, t: writeTime, flushAt: Date.now() + writeDelay });
645
- return;
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
- private assertNotBareVariableShard;
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 { ArchivesRemote } from "./ArchivesRemote";
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.checkForNewConfig().catch((e: Error) => {
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 data = await this.run(state, {}, archives => archives.get(ROUTING_FILE));
259
- if (!data) return;
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(state, config.route, run);
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>(state: ChainState, route: number | undefined, run: (archives: IArchives) => Promise<T>): Promise<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
- private assertNotBareVariableShard(fileName: string): void {
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
- throw new Error(`Keys containing VARIABLE_SHARD must be written with setVariableShard, which materializes the shard value and returns the full key. Key: ${JSON.stringify(fileName)}`);
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
- /** Writes a key containing the VARIABLE_SHARD sentinel: picks the lowest-latency up write
512
- * shard, materializes the key with a random value inside that shard's route, writes it, and
513
- * returns the FULL key actually written (the caller needs it to ever read the value back).
514
- * Unlike normal writes this CAN move to another shard when the preferred one is down (error +
515
- * socket down, same rule as reads) - each shard receives a different key, so write
516
- * consistency is preserved. */
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
- this.assertNotBareVariableShard(config.path);
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;