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.
- package/index.d.ts +223 -7
- package/package.json +1 -1
- package/storage/IArchives.d.ts +18 -0
- package/storage/IArchives.ts +14 -0
- package/storage/StreamingLogs.d.ts +77 -0
- package/storage/StreamingLogs.ts +412 -0
- package/storage/TransactionFile.d.ts +12 -6
- package/storage/TransactionFile.ts +44 -12
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/TransactionFile.ts.cache +41 -9
- package/storage/remoteStorage/ArchivesRemote.ts +3 -3
- package/storage/remoteStorage/blobStore.d.ts +28 -0
- package/storage/remoteStorage/blobStore.ts +117 -11
- package/storage/remoteStorage/createArchives.d.ts +40 -1
- package/storage/remoteStorage/createArchives.ts +84 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
- package/storage/remoteStorage/dist/blobStore.ts.cache +122 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +52 -3
- package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
- package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -4
- package/storage/remoteStorage/dist/storeSync.ts.cache +43 -2
- package/storage/remoteStorage/spec.md +4 -0
- package/storage/remoteStorage/storageController.d.ts +10 -0
- package/storage/remoteStorage/storageController.ts +37 -3
- package/storage/remoteStorage/storageLogs.d.ts +29 -0
- package/storage/remoteStorage/storageLogs.ts +96 -0
- package/storage/remoteStorage/storageServerState.ts +16 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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) });
|