sliftutils 1.7.101 → 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 +227 -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 +32 -0
- package/storage/remoteStorage/blobStore.ts +124 -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 +127 -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 -3
- package/storage/remoteStorage/dist/storeSync.ts.cache +47 -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 +17 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -0
- package/storage/remoteStorage/storeSync.ts +42 -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). */
|
|
@@ -106,6 +116,8 @@ export class BlobStore {
|
|
|
106
116
|
requestRoutingConfig?: () => Promise<RemoteConfig | undefined>;
|
|
107
117
|
// Every accepted write ("original") and every write that actually reached the sources ("flushed"). Fast writes coalesce, so the two counts differ.
|
|
108
118
|
onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
|
|
119
|
+
/** A synchronization transfer: "sync get" is bytes pulled off a source (the backblaze download bill), "sync set" is bytes pushed to one. Injected because sync traffic never passes through the API controller, so nothing else can count it. */
|
|
120
|
+
onSyncTransfer?: (operation: "sync get" | "sync set", path: string, bytes: number) => void;
|
|
109
121
|
// Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
|
|
110
122
|
resolveSourceUrl?: (url: string) => IArchives;
|
|
111
123
|
}
|
|
@@ -272,7 +284,7 @@ export class BlobStore {
|
|
|
272
284
|
await this.index.flush();
|
|
273
285
|
}
|
|
274
286
|
|
|
275
|
-
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> {
|
|
276
288
|
if (config.internal) {
|
|
277
289
|
return await this.getInternal2(config);
|
|
278
290
|
}
|
|
@@ -281,6 +293,12 @@ export class BlobStore {
|
|
|
281
293
|
let range = config.range;
|
|
282
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.
|
|
283
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
|
+
}
|
|
284
302
|
if (!entry) {
|
|
285
303
|
if (!config.includeTombstones) return undefined;
|
|
286
304
|
// A deletion has no bytes - the tombstone IS the answer, so a flag-caller gets its time with empty data
|
|
@@ -308,8 +326,8 @@ export class BlobStore {
|
|
|
308
326
|
}
|
|
309
327
|
}
|
|
310
328
|
if (result) {
|
|
311
|
-
// Ranged reads can't populate a cache (they're partial)
|
|
312
|
-
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) {
|
|
313
331
|
await this.cacheRead(key, result);
|
|
314
332
|
}
|
|
315
333
|
return result;
|
|
@@ -334,7 +352,10 @@ export class BlobStore {
|
|
|
334
352
|
continue;
|
|
335
353
|
}
|
|
336
354
|
if (!fallback) continue;
|
|
337
|
-
|
|
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
|
+
}
|
|
338
359
|
let data = fallback.data;
|
|
339
360
|
if (range) {
|
|
340
361
|
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
@@ -342,12 +363,14 @@ export class BlobStore {
|
|
|
342
363
|
return { data, writeTime: fallback.writeTime, size: fallback.size };
|
|
343
364
|
}
|
|
344
365
|
if (holderError) throw holderError;
|
|
345
|
-
// 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.
|
|
346
|
-
|
|
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
|
+
}
|
|
347
370
|
return undefined;
|
|
348
371
|
}
|
|
349
372
|
|
|
350
|
-
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> {
|
|
351
374
|
let { path: key, data } = config;
|
|
352
375
|
if (!data.length) {
|
|
353
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`);
|
|
@@ -361,6 +384,13 @@ export class BlobStore {
|
|
|
361
384
|
} else {
|
|
362
385
|
this.assertWriteTarget(key, route, config.lastModified);
|
|
363
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
|
+
}
|
|
364
394
|
if (key !== ROUTING_FILE && this.storeConfig.all().length) {
|
|
365
395
|
if (config.forceSetImmutable) {
|
|
366
396
|
if (!config.lastModified) {
|
|
@@ -445,6 +475,13 @@ export class BlobStore {
|
|
|
445
475
|
if (!key.startsWith(prefix)) continue;
|
|
446
476
|
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
447
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
|
+
}
|
|
448
485
|
let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
|
|
449
486
|
sort(files, x => x.path);
|
|
450
487
|
return files;
|
|
@@ -478,6 +515,7 @@ export class BlobStore {
|
|
|
478
515
|
/** The index's totals plus any in-progress background synchronization. */
|
|
479
516
|
public getSyncProgress(): {
|
|
480
517
|
index: { fileCount: number; byteCount: number };
|
|
518
|
+
marked: { fileCount: number; byteCount: number; oldestDeleteTime?: number };
|
|
481
519
|
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
482
520
|
readerDiskLimit?: number;
|
|
483
521
|
syncing: SyncActivity[];
|
|
@@ -485,6 +523,7 @@ export class BlobStore {
|
|
|
485
523
|
let totals = this.namedIndexTotals();
|
|
486
524
|
return {
|
|
487
525
|
index: { fileCount: totals.fileCount, byteCount: totals.byteCount },
|
|
526
|
+
marked: this.markedTotals(),
|
|
488
527
|
sources: totals.sources,
|
|
489
528
|
readerDiskLimit: this.readerDiskLimit,
|
|
490
529
|
syncing: this.sync.getActivities(),
|
|
@@ -760,6 +799,71 @@ export class BlobStore {
|
|
|
760
799
|
}
|
|
761
800
|
}
|
|
762
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
|
+
|
|
763
867
|
/** How many files we hold, deletions excluded. */
|
|
764
868
|
public indexSize(): number {
|
|
765
869
|
return this.index.size;
|
|
@@ -804,6 +908,11 @@ export class BlobStore {
|
|
|
804
908
|
this.index.purge(key);
|
|
805
909
|
}
|
|
806
910
|
|
|
911
|
+
/** Counts a synchronization transfer in the server's access statistics (see getStore's wiring): "sync get" for bytes pulled off a source, "sync set" for bytes pushed to one. */
|
|
912
|
+
public noteSyncTransfer(operation: "sync get" | "sync set", path: string, bytes: number): void {
|
|
913
|
+
this.config?.onSyncTransfer?.(operation, path, bytes);
|
|
914
|
+
}
|
|
915
|
+
|
|
807
916
|
// ── validation (from this store's own routing entries) ──
|
|
808
917
|
|
|
809
918
|
/**
|
|
@@ -915,8 +1024,7 @@ export class BlobStore {
|
|
|
915
1024
|
assertValidLastModified(config.lastModified);
|
|
916
1025
|
if (config.lastModified < await this.currentWriteTime(key)) return;
|
|
917
1026
|
if (data.length === 0) {
|
|
918
|
-
//
|
|
919
|
-
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
|
|
920
1028
|
this.setIndexDeleted(key, config.lastModified);
|
|
921
1029
|
return;
|
|
922
1030
|
}
|
|
@@ -928,6 +1036,8 @@ export class BlobStore {
|
|
|
928
1036
|
private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
|
|
929
1037
|
await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime, forceSetImmutable: true, noChecks: true });
|
|
930
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);
|
|
931
1041
|
}
|
|
932
1042
|
|
|
933
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
|
|
@@ -978,8 +1088,7 @@ export class BlobStore {
|
|
|
978
1088
|
}
|
|
979
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.
|
|
980
1090
|
if (data.length === 0) {
|
|
981
|
-
//
|
|
982
|
-
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.
|
|
983
1092
|
this.setIndexDeleted(key, writeTime);
|
|
984
1093
|
} else {
|
|
985
1094
|
await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
|
|
@@ -987,6 +1096,10 @@ export class BlobStore {
|
|
|
987
1096
|
}
|
|
988
1097
|
if (isRouting) return;
|
|
989
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
|
+
}
|
|
990
1103
|
for (let i of writable) {
|
|
991
1104
|
if (!routeContains(this.sources[i].route, route)) continue;
|
|
992
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) });
|