sliftutils 1.7.102 → 1.7.103

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 (29) hide show
  1. package/index.d.ts +223 -7
  2. package/package.json +1 -1
  3. package/storage/IArchives.d.ts +18 -0
  4. package/storage/IArchives.ts +14 -0
  5. package/storage/StreamingLogs.d.ts +77 -0
  6. package/storage/StreamingLogs.ts +412 -0
  7. package/storage/TransactionFile.d.ts +12 -6
  8. package/storage/TransactionFile.ts +44 -12
  9. package/storage/dist/IArchives.ts.cache +2 -2
  10. package/storage/dist/TransactionFile.ts.cache +41 -9
  11. package/storage/remoteStorage/ArchivesRemote.ts +3 -3
  12. package/storage/remoteStorage/blobStore.d.ts +28 -0
  13. package/storage/remoteStorage/blobStore.ts +117 -11
  14. package/storage/remoteStorage/createArchives.d.ts +40 -1
  15. package/storage/remoteStorage/createArchives.ts +84 -0
  16. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  17. package/storage/remoteStorage/dist/blobStore.ts.cache +122 -12
  18. package/storage/remoteStorage/dist/createArchives.ts.cache +52 -3
  19. package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
  20. package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -4
  21. package/storage/remoteStorage/dist/storeSync.ts.cache +43 -2
  22. package/storage/remoteStorage/spec.md +4 -0
  23. package/storage/remoteStorage/storageController.d.ts +10 -0
  24. package/storage/remoteStorage/storageController.ts +37 -3
  25. package/storage/remoteStorage/storageLogs.d.ts +29 -0
  26. package/storage/remoteStorage/storageLogs.ts +96 -0
  27. package/storage/remoteStorage/storageServerState.ts +16 -2
  28. package/storage/remoteStorage/storeSync.d.ts +1 -0
  29. package/storage/remoteStorage/storeSync.ts +38 -0
@@ -22,6 +22,16 @@ export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
22
22
  const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
23
23
  // Marks an upload id that never reached the disk, so it can never collide with one ArchivesDisk handed out
24
24
  const DISCARDED_UPLOAD_PREFIX = "discarded_";
25
+ // set refuses empty buffers, and an undelete carries no data - this single ignored byte satisfies the wire
26
+ const UNDELETE_PLACEHOLDER = Buffer.from([1]);
27
+
28
+ // Deletion history: deleted files keep their bytes on disk (marked in the index) until the history outgrows max(HISTORY_MIN_BYTES, live bytes * history factor) - see StoreSync.enforceHistoryLimit
29
+ export const HISTORY_MIN_BYTES = 10 * 1024 * 1024 * 1024;
30
+ const HISTORY_FACTOR = 1;
31
+ /** The multiple of a store's live bytes its deletion history may grow to. Async so it can later become dynamic and user-configurable; for now it is a constant. */
32
+ export async function getHistoryFactor(): Promise<number> {
33
+ return HISTORY_FACTOR;
34
+ }
25
35
 
26
36
 
27
37
  /** What we store about a file. Its times are not in here: the index keeps those for every key, deleted ones included (see TransactionFile). */
@@ -274,7 +284,7 @@ export class BlobStore {
274
284
  await this.index.flush();
275
285
  }
276
286
 
277
- public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
287
+ public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean; includeMarked?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
278
288
  if (config.internal) {
279
289
  return await this.getInternal2(config);
280
290
  }
@@ -283,6 +293,12 @@ export class BlobStore {
283
293
  let range = config.range;
284
294
  // Not in the index means it does not exist here: the index IS the answer, and scanning heals it on its own schedule. An entry whose holder is no longer in the source list is still valid - getEntryHolder resolves the persisted URL directly, and get2's fallback loop covers a holder that is gone entirely.
285
295
  let entry = this.getIndexEntry(key);
296
+ // A marked deletion still has its bytes, and the kept value says where - so an includeMarked read is a normal read through the marked entry (it just must not RE-INDEX anything: see the markedRead guards below)
297
+ let markedRead = false;
298
+ if (!entry && config.includeMarked) {
299
+ entry = this.getMarkedEntry(key);
300
+ markedRead = !!entry;
301
+ }
286
302
  if (!entry) {
287
303
  if (!config.includeTombstones) return undefined;
288
304
  // A deletion has no bytes - the tombstone IS the answer, so a flag-caller gets its time with empty data
@@ -310,8 +326,8 @@ export class BlobStore {
310
326
  }
311
327
  }
312
328
  if (result) {
313
- // Ranged reads can't populate a cache (they're partial)
314
- if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
329
+ // Ranged reads can't populate a cache (they're partial), and a marked read must not re-index anything - caching would resurrect the deleted key as live
330
+ if (!markedRead && this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
315
331
  await this.cacheRead(key, result);
316
332
  }
317
333
  return result;
@@ -336,7 +352,10 @@ export class BlobStore {
336
352
  continue;
337
353
  }
338
354
  if (!fallback) continue;
339
- await this.cacheRead(key, fallback);
355
+ // A marked read must not re-index anything - caching would resurrect the deleted key as live
356
+ if (!markedRead) {
357
+ await this.cacheRead(key, fallback);
358
+ }
340
359
  let data = fallback.data;
341
360
  if (range) {
342
361
  data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
@@ -344,12 +363,14 @@ export class BlobStore {
344
363
  return { data, writeTime: fallback.writeTime, size: fallback.size };
345
364
  }
346
365
  if (holderError) throw holderError;
347
- // The holder answered "not there" and no other source has it either: the entry was stale. Forgotten rather than deleted - nothing happened to this file, we were simply wrong about holding it, and saying otherwise would push a deletion out to everyone else.
348
- this.purgeIndexEntry(key);
366
+ // The holder answered "not there" and no other source has it either: the entry was stale. Forgotten rather than deleted - nothing happened to this file, we were simply wrong about holding it, and saying otherwise would push a deletion out to everyone else. (Never for a marked read: purging would erase the deletion's tombstone.)
367
+ if (!markedRead) {
368
+ this.purgeIndexEntry(key);
369
+ }
349
370
  return undefined;
350
371
  }
351
372
 
352
- public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
373
+ public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean; undelete?: boolean }): Promise<void> {
353
374
  let { path: key, data } = config;
354
375
  if (!data.length) {
355
376
  throw new Error(`set was called with an empty buffer for ${JSON.stringify(key)} (store ${this.folder}): an empty file IS a deletion in this system and would read back as missing - call del instead`);
@@ -363,6 +384,13 @@ export class BlobStore {
363
384
  } else {
364
385
  this.assertWriteTarget(key, route, config.lastModified);
365
386
  }
387
+ if (config.undelete) {
388
+ if (key === ROUTING_FILE) {
389
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be undeleted (it cannot be deleted in the first place)`);
390
+ }
391
+ await this.undeleteKey(key, writeTime, config.internal);
392
+ return;
393
+ }
366
394
  if (key !== ROUTING_FILE && this.storeConfig.all().length) {
367
395
  if (config.forceSetImmutable) {
368
396
  if (!config.lastModified) {
@@ -447,6 +475,13 @@ export class BlobStore {
447
475
  if (!key.startsWith(prefix)) continue;
448
476
  infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
449
477
  }
478
+ if (config.includeMarked) {
479
+ // A key is live OR marked, never both, so this cannot collide with the loop above
480
+ for (let [key, entry] of this.markedEntries()) {
481
+ if (!key.startsWith(prefix)) continue;
482
+ infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
483
+ }
484
+ }
450
485
  let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
451
486
  sort(files, x => x.path);
452
487
  return files;
@@ -480,6 +515,7 @@ export class BlobStore {
480
515
  /** The index's totals plus any in-progress background synchronization. */
481
516
  public getSyncProgress(): {
482
517
  index: { fileCount: number; byteCount: number };
518
+ marked: { fileCount: number; byteCount: number; oldestDeleteTime?: number };
483
519
  sources: { debugName: string; fileCount: number; byteCount: number }[];
484
520
  readerDiskLimit?: number;
485
521
  syncing: SyncActivity[];
@@ -487,6 +523,7 @@ export class BlobStore {
487
523
  let totals = this.namedIndexTotals();
488
524
  return {
489
525
  index: { fileCount: totals.fileCount, byteCount: totals.byteCount },
526
+ marked: this.markedTotals(),
490
527
  sources: totals.sources,
491
528
  readerDiskLimit: this.readerDiskLimit,
492
529
  syncing: this.sync.getActivities(),
@@ -762,6 +799,71 @@ export class BlobStore {
762
799
  }
763
800
  }
764
801
 
802
+ /** A file MARKED for deletion: its kept index value plus when it was deleted. Undefined when the key is live, never existed, or its history was already dropped. */
803
+ public getMarkedEntry(key: string): (IndexEntry & { deleteTime: number }) | undefined {
804
+ let tombstone = this.index.getDeleted(key);
805
+ if (!tombstone || tombstone.value === undefined) return undefined;
806
+ return { ...tombstone.value, writeTime: tombstone.valueTime || tombstone.time, changedAt: tombstone.changedAt, deleteTime: tombstone.time };
807
+ }
808
+
809
+ /** Every file marked for deletion - the deletion history, walked by retention and by includeMarked listings. */
810
+ public *markedEntries(): IterableIterator<[string, IndexEntry & { deleteTime: number }]> {
811
+ for (let [key, tombstone] of this.index.deletedEntries()) {
812
+ if (tombstone.value === undefined) continue;
813
+ yield [key, { ...tombstone.value, writeTime: tombstone.valueTime || tombstone.time, changedAt: tombstone.changedAt, deleteTime: tombstone.time }];
814
+ }
815
+ }
816
+
817
+ /** The deletion history's totals: how many marked files, their bytes, and the delete time of the OLDEST one - which is how far back the history reaches. */
818
+ public markedTotals(): { fileCount: number; byteCount: number; oldestDeleteTime?: number } {
819
+ let fileCount = 0;
820
+ let byteCount = 0;
821
+ let oldestDeleteTime: number | undefined;
822
+ for (let [, entry] of this.markedEntries()) {
823
+ fileCount++;
824
+ byteCount += entry.size;
825
+ if (oldestDeleteTime === undefined || entry.deleteTime < oldestDeleteTime) {
826
+ oldestDeleteTime = entry.deleteTime;
827
+ }
828
+ }
829
+ return { fileCount, byteCount, oldestDeleteTime };
830
+ }
831
+
832
+ /** Physically removes a marked file's bytes from our disk and drops its kept value, leaving a plain tombstone that ages out normally - retention calling time on the oldest history. */
833
+ public async dropMarkedHistory(key: string): Promise<void> {
834
+ await this.ownDisk.del(key);
835
+ this.index.dropValue(key);
836
+ }
837
+
838
+ /** See SetConfig.undelete: flips a marked deletion back to live (fresh write time, so the restore outranks the deletion everywhere it propagated) - the bytes never left the disk, so reads just work again. Internal restores are a peer's propagation and tolerate having nothing to restore (this node may never have held the file); a caller's restore throws instead. */
839
+ private async undeleteKey(key: string, writeTime: number, internal: boolean | undefined): Promise<void> {
840
+ let marked = this.getMarkedEntry(key);
841
+ if (!this.index.unmark(key, writeTime)) {
842
+ // Already live: undelete is idempotent (retries and peer propagation both re-send it)
843
+ if (this.getIndexEntry(key)) return;
844
+ if (internal) {
845
+ console.log(`Undelete of ${JSON.stringify(key)} (store ${this.folder}) has nothing to restore here - this node may never have held the file`);
846
+ return;
847
+ }
848
+ throw new Error(`Cannot undelete ${JSON.stringify(key)} (store ${this.folder}): it has no marked deletion to restore - it was never deleted here${this.getDeletedEntry(key) && ", or its deletion history was already dropped (a plain tombstone remains, but the bytes are gone)" || ""}`);
849
+ }
850
+ console.log(`Undeleted ${JSON.stringify(key)} (store ${this.folder}): the index entry (${marked?.size} bytes, deleted ${marked && new Date(marked.deleteTime).toISOString()}) is live again as of ${new Date(writeTime).toISOString()} - the bytes never left the disk`);
851
+ this.config?.onIndexChanged?.(key);
852
+ if (internal) return;
853
+ // Peers marked their own copies when the deletion propagated, so the restore propagates the same way. Straight past any write delay - a buffered undelete could be read back as its placeholder byte.
854
+ let route = getRoute(key);
855
+ for (let i of this.getWritableSources()) {
856
+ if (i === 0) continue;
857
+ if (!routeContains(this.sources[i].route, route)) continue;
858
+ // Only our own servers understand the flag - a raw source (backblaze) would store the placeholder byte as the file. Their older copy is re-pushed by reconciliation instead.
859
+ if (this.sources[i].sourceConfig?.type !== "remote") continue;
860
+ let push = unwrapDelayed(this.sources[i].source).set(key, UNDELETE_PLACEHOLDER, { lastModified: writeTime, undelete: true, noChecks: true, internal: true });
861
+ void push.catch((e: Error) => {
862
+ console.error(`Background undelete of ${key} on sync source ${this.sources[i].source.getDebugName()} failed (its scan of us re-finds the file anyway): ${e.stack ?? e}`);
863
+ });
864
+ }
865
+ }
866
+
765
867
  /** How many files we hold, deletions excluded. */
766
868
  public indexSize(): number {
767
869
  return this.index.size;
@@ -922,8 +1024,7 @@ export class BlobStore {
922
1024
  assertValidLastModified(config.lastModified);
923
1025
  if (config.lastModified < await this.currentWriteTime(key)) return;
924
1026
  if (data.length === 0) {
925
- // A deletion stores nothing on our own source - the tombstone is the whole of it
926
- await this.sources[0].source.del(key);
1027
+ // The bytes stay on our disk as deletion history (see writeToSources) - the marked index entry is the whole of the deletion
927
1028
  this.setIndexDeleted(key, config.lastModified);
928
1029
  return;
929
1030
  }
@@ -935,6 +1036,8 @@ export class BlobStore {
935
1036
  private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
936
1037
  await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime, forceSetImmutable: true, noChecks: true });
937
1038
  this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
1039
+ // A down-cache pulls the bytes off a source exactly like synchronization does, so it counts (and logs) the same way
1040
+ this.noteSyncTransfer("sync get", key, result.data.length);
938
1041
  }
939
1042
 
940
1043
  // The shared engine of set and del: an empty buffer is exactly a deletion here, which is why the empty-buffer rejection lives in set (the public API), not in this machinery
@@ -985,8 +1088,7 @@ export class BlobStore {
985
1088
  }
986
1089
  // Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconciliation re-sends anything they missed once they come back.
987
1090
  if (data.length === 0) {
988
- // A deletion stores nothing on our own source - the tombstone is the whole of it
989
- await this.sources[first].source.del(key);
1091
+ // The bytes STAY on our disk, as deletion history: the index marks the key deleted (keeping its value - see TransactionFile.delete), reads stop finding it, and the retention pass physically removes the oldest history once it outgrows its budget (see StoreSync.enforceHistoryLimit). The marked index entry is the whole of the local deletion.
990
1092
  this.setIndexDeleted(key, writeTime);
991
1093
  } else {
992
1094
  await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
@@ -994,6 +1096,10 @@ export class BlobStore {
994
1096
  }
995
1097
  if (isRouting) return;
996
1098
  let route = getRoute(key);
1099
+ // Deletions did not consume the first slot (nothing is written anywhere locally), so it receives the propagated deletion like the rest - except slot 0, our own disk, whose bytes ARE the history being kept
1100
+ if (data.length === 0 && first !== 0) {
1101
+ writable.unshift(first);
1102
+ }
997
1103
  for (let i of writable) {
998
1104
  if (!routeContains(this.sources[i].route, route)) continue;
999
1105
  // Deletions travel as del carrying the original write time (never as empty sets - set rejects empty buffers). Backblaze materializes such dels as real empty files, so its listings still show the deletion for other stores to scan in as a tombstone.
@@ -1,7 +1,8 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IArchives, RemoteConfig, RemoteConfigBase, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig } from "../IArchives";
3
+ import { IArchives, RemoteConfig, RemoteConfigBase, SourceConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig } from "../IArchives";
4
4
  import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
5
+ import { LogFileInfo } from "../StreamingLogs";
5
6
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
6
7
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
7
8
  /** A client for ONE source - see storeSources.ts. Re-exported here because a chain is built out of them. */
@@ -26,6 +27,9 @@ export declare class ArchivesChain implements IArchives {
26
27
  machineId: string;
27
28
  ip: string;
28
29
  } | undefined>;
30
+ /** The sources that can serve a file right now, in dispatch order - the first is the write node, the one a plain read asks first. Each entry's url is what GetConfig.sourceUrl / GetInfoConfig.sourceUrl accept, so listing these and then reading with sourceUrl compares the copies the sources actually hold. */
31
+ getFileSources(fileName: string): Promise<SourceConfig[]>;
32
+ private runOnSource;
29
33
  get(fileName: string, config?: GetConfig): Promise<Buffer | undefined>;
30
34
  /** get2, but trying sources in latency order (fastest first) instead of config order. While this is much faster, it might miss immediate writes: the write node is no longer tried first, so a lagging replica may answer with a slightly older value. Exclusive with noFallbacks (which only considers one source - the write node - so there is no order to speed up); passing both throws. */
31
35
  getFast(fileName: string, config?: GetConfig): Promise<{
@@ -67,6 +71,8 @@ export declare class ArchivesChain implements IArchives {
67
71
  set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
68
72
  private setRoutingConfig;
69
73
  del(fileName: string, config?: DelConfig): Promise<void>;
74
+ /** See IArchives.undelete: restores a file marked for deletion, dispatched to the write node as SetConfig.undelete (the write node propagates the restore to its peers itself). */
75
+ undelete(fileName: string): Promise<void>;
70
76
  /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
71
77
  move(config: MoveFileConfig): Promise<void>;
72
78
  private getVariableShardTargets;
@@ -117,6 +123,39 @@ export declare function clearServerWriteStats(config: {
117
123
  }): Promise<{
118
124
  clearedBuckets: number;
119
125
  }>;
126
+ /** The operation-log files ONE storage server holds (every server logs only its own operations - see listAllServerLogFiles for the whole fleet). The names carry pid/thread/time-range/entry-count metadata; see LogFileInfo. */
127
+ export declare function listServerLogFiles(config: {
128
+ url: string;
129
+ account: string;
130
+ }): Promise<LogFileInfo[]>;
131
+ /** Every server's log files at once, one entry per url - a server that cannot answer reports its error instead of failing the rest. */
132
+ export declare function listAllServerLogFiles(config: {
133
+ urls: string[];
134
+ account: string;
135
+ }): Promise<{
136
+ url: string;
137
+ files?: LogFileInfo[];
138
+ error?: string;
139
+ }[]>;
140
+ /** Downloads the named log files off one server and decodes them into the logged objects (the wire always carries them LZ4-compressed - live files are compressed in memory server-side). */
141
+ export declare function getServerLogs(config: {
142
+ url: string;
143
+ account: string;
144
+ names: string[];
145
+ }): Promise<{
146
+ name: string;
147
+ entries: unknown[];
148
+ }[]>;
149
+ /** getServerLogs, but SEARCHED instead of fully decoded: the raw JSON text is substring-matched (every search string must appear in a statement - see createLogSearcher), and only the matching statements are decoded into objects. Far cheaper than decoding whole files to look for one path or caller. */
150
+ export declare function searchServerLogs(config: {
151
+ url: string;
152
+ account: string;
153
+ names: string[];
154
+ searches: string[];
155
+ }): Promise<{
156
+ name: string;
157
+ entries: unknown[];
158
+ }[]>;
120
159
  export declare function getBucketInfo(config: {
121
160
  url: string;
122
161
  }): Promise<ArchivesConfig>;
@@ -18,6 +18,7 @@ import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
18
18
  import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
19
19
  import { SourceWrapper } from "./sourceWrapper";
20
20
  import { ChainState, ChainStateManager } from "./chainStartup";
21
+ import { LogFileInfo, decodeLogFile, createLogSearcher } from "../StreamingLogs";
21
22
  import { formatTime } from "socket-function/src/formatting/format";
22
23
 
23
24
  // How many extra full passes over the sources fallback dispatch makes when EVERY source in a pass failed, before the operation throws (see GetConfig.retries) - most requests shouldn't fail at all, so a couple of blanket retries buys availability for almost nothing
@@ -331,12 +332,33 @@ export class ArchivesChain implements IArchives {
331
332
  return undefined;
332
333
  }
333
334
 
335
+ /** The sources that can serve a file right now, in dispatch order - the first is the write node, the one a plain read asks first. Each entry's url is what GetConfig.sourceUrl / GetInfoConfig.sourceUrl accept, so listing these and then reading with sourceUrl compares the copies the sources actually hold. */
336
+ public async getFileSources(fileName: string): Promise<SourceConfig[]> {
337
+ let state = await this.state.getState();
338
+ let route = getRoute(fileName);
339
+ return state.sources.filter(x => routeContains(x.config.route, route) && configWindowCurrent(x.config)).map(x => x.config);
340
+ }
341
+
342
+ // The one exact source a sourceUrl read means, no fallback of any kind - asking for a SPECIFIC source's copy and getting another's would defeat the point
343
+ private async runOnSource<T>(sourceUrl: string, run: (archives: IArchives) => Promise<T>): Promise<T> {
344
+ let state = await this.state.getState();
345
+ let source = state.sources.find(x => x.config.url === sourceUrl);
346
+ if (!source) {
347
+ throw new Error(`No configured source has the url ${JSON.stringify(sourceUrl)} for ${this.getDebugName()} (sources: ${state.sources.map(x => x.config.url).join(", ")})`);
348
+ }
349
+ return await source.read(run);
350
+ }
351
+
334
352
  public async get(fileName: string, config?: GetConfig): Promise<Buffer | undefined> {
335
353
  let result = await this.get2(fileName, config);
336
354
  return result && result.data || undefined;
337
355
  }
338
356
  /** get2, but trying sources in latency order (fastest first) instead of config order. While this is much faster, it might miss immediate writes: the write node is no longer tried first, so a lagging replica may answer with a slightly older value. Exclusive with noFallbacks (which only considers one source - the write node - so there is no order to speed up); passing both throws. */
339
357
  public async getFast(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
358
+ if (config?.sourceUrl) {
359
+ // A specific source leaves nothing for the latency ordering to decide
360
+ return await this.get2(fileName, config);
361
+ }
340
362
  return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries, fast: true, timeout: { path: fileName } }, async (archives, url) => {
341
363
  let result = await archives.get2(fileName, config);
342
364
  // Empty data is a tombstone, not content - see get2
@@ -346,6 +368,14 @@ export class ArchivesChain implements IArchives {
346
368
  }
347
369
  /** Always resolves with a url - the authority that answered. A value that doesn't exist is still an answer FROM a server, so it comes back as { url } with no data (never plain undefined); errors from every source throw instead. */
348
370
  public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
371
+ const sourceUrl = config?.sourceUrl;
372
+ if (sourceUrl) {
373
+ return await this.runOnSource(sourceUrl, async archives => {
374
+ let result = await archives.get2(fileName, config);
375
+ if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url: sourceUrl };
376
+ return { data: result.data, writeTime: result.writeTime, size: result.size, url: sourceUrl };
377
+ });
378
+ }
349
379
  return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries, timeout: { path: fileName } }, async (archives, url) => {
350
380
  let result = await archives.get2(fileName, config);
351
381
  // Empty data is a tombstone, not content (unless the caller asked for tombstones) - a ranged read of a REAL file can legitimately be empty though (range past EOF), which the total size distinguishes
@@ -354,6 +384,13 @@ export class ArchivesChain implements IArchives {
354
384
  });
355
385
  }
356
386
  public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number; url: string } | undefined> {
387
+ const sourceUrl = config?.sourceUrl;
388
+ if (sourceUrl) {
389
+ return await this.runOnSource(sourceUrl, async archives => {
390
+ let result = await archives.getInfo(fileName, config);
391
+ return result && { ...result, url: sourceUrl } || undefined;
392
+ });
393
+ }
357
394
  return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries }, async (archives, url) => {
358
395
  let result = await archives.getInfo(fileName, config);
359
396
  return result && { ...result, url } || undefined;
@@ -561,6 +598,13 @@ export class ArchivesChain implements IArchives {
561
598
  await this.request({ write: true, fallbacks: config?.fallbacks, retries: config?.retries, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
562
599
  }
563
600
 
601
+ /** See IArchives.undelete: restores a file marked for deletion, dispatched to the write node as SetConfig.undelete (the write node propagates the restore to its peers itself). */
602
+ public async undelete(fileName: string): Promise<void> {
603
+ // set refuses empty buffers, and an undelete carries no data - the byte is ignored
604
+ let placeholder = Buffer.from([1]);
605
+ await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: placeholder.length, label: `Undelete of ${JSON.stringify(fileName)}` } }, archives => archives.set(fileName, placeholder, { undelete: true }));
606
+ }
607
+
564
608
  /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
565
609
  public async move(config: MoveFileConfig): Promise<void> {
566
610
  if (config.fromPath === config.toPath) return;
@@ -809,6 +853,46 @@ export async function clearServerWriteStats(config: { url: string; account: stri
809
853
  return await callServer(config.url, controller => controller.clearWriteStats({ account: config.account }));
810
854
  }
811
855
 
856
+ /** The operation-log files ONE storage server holds (every server logs only its own operations - see listAllServerLogFiles for the whole fleet). The names carry pid/thread/time-range/entry-count metadata; see LogFileInfo. */
857
+ export async function listServerLogFiles(config: { url: string; account: string }): Promise<LogFileInfo[]> {
858
+ return await callServer(config.url, controller => controller.listLogFiles({ account: config.account }));
859
+ }
860
+
861
+ /** Every server's log files at once, one entry per url - a server that cannot answer reports its error instead of failing the rest. */
862
+ export async function listAllServerLogFiles(config: { urls: string[]; account: string }): Promise<{ url: string; files?: LogFileInfo[]; error?: string }[]> {
863
+ return await Promise.all(config.urls.map(async url => {
864
+ try {
865
+ return { url, files: await listServerLogFiles({ url, account: config.account }) };
866
+ } catch (e) {
867
+ return { url, error: String((e as Error).stack ?? e) };
868
+ }
869
+ }));
870
+ }
871
+
872
+ /** Downloads the named log files off one server and decodes them into the logged objects (the wire always carries them LZ4-compressed - live files are compressed in memory server-side). */
873
+ export async function getServerLogs(config: { url: string; account: string; names: string[] }): Promise<{ name: string; entries: unknown[] }[]> {
874
+ return await callServer(config.url, async controller => {
875
+ let results: { name: string; entries: unknown[] }[] = [];
876
+ for (let name of config.names) {
877
+ let data = await controller.getLogFile({ account: config.account, name });
878
+ results.push({ name, entries: decodeLogFile(Buffer.from(data)) });
879
+ }
880
+ return results;
881
+ });
882
+ }
883
+
884
+ /** getServerLogs, but SEARCHED instead of fully decoded: the raw JSON text is substring-matched (every search string must appear in a statement - see createLogSearcher), and only the matching statements are decoded into objects. Far cheaper than decoding whole files to look for one path or caller. */
885
+ export async function searchServerLogs(config: { url: string; account: string; names: string[]; searches: string[] }): Promise<{ name: string; entries: unknown[] }[]> {
886
+ return await callServer(config.url, async controller => {
887
+ let results: { name: string; entries: unknown[] }[] = [];
888
+ for (let name of config.names) {
889
+ let data = await controller.getLogFile({ account: config.account, name });
890
+ results.push({ name, entries: createLogSearcher(Buffer.from(data))(config.searches) });
891
+ }
892
+ return results;
893
+ });
894
+ }
895
+
812
896
  export async function getBucketInfo(config: { url: string }): Promise<ArchivesConfig> {
813
897
  // getConfig is bucket-level (no store selection), so the fabricated sourceConfig never has to match anything
814
898
  let remote = new ArchivesRemote({ url: config.url, waitForAccess: false, sourceConfig: normalizeSource(config.url) });