sliftutils 1.7.90 → 1.7.91

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 (34) hide show
  1. package/index.d.ts +34 -79
  2. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  3. package/misc/https/dist/dns.ts.cache +2 -2
  4. package/package.json +1 -1
  5. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  6. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  7. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  8. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  9. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  10. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  11. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  12. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  13. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  14. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  15. package/storage/remoteStorage/accessStats.d.ts +12 -0
  16. package/storage/remoteStorage/accessStats.ts +47 -0
  17. package/storage/remoteStorage/blobStore.d.ts +3 -1
  18. package/storage/remoteStorage/blobStore.ts +5 -0
  19. package/storage/remoteStorage/dist/accessStats.ts.cache +42 -3
  20. package/storage/remoteStorage/dist/blobStore.ts.cache +6 -3
  21. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +56 -50
  22. package/storage/remoteStorage/dist/storageController.ts.cache +36 -29
  23. package/storage/remoteStorage/dist/storageServerState.ts.cache +110 -233
  24. package/storage/remoteStorage/dist/storeConfig.ts.cache +2 -2
  25. package/storage/remoteStorage/dist/storePlan.ts.cache +4 -56
  26. package/storage/remoteStorage/intermediateManagement.d.ts +2 -3
  27. package/storage/remoteStorage/intermediateManagement.ts +53 -46
  28. package/storage/remoteStorage/storageController.ts +22 -14
  29. package/storage/remoteStorage/storageServerState.d.ts +15 -55
  30. package/storage/remoteStorage/storageServerState.ts +106 -268
  31. package/storage/remoteStorage/storeConfig.d.ts +2 -0
  32. package/storage/remoteStorage/storeConfig.ts +2 -0
  33. package/storage/remoteStorage/storePlan.d.ts +0 -20
  34. package/storage/remoteStorage/storePlan.ts +1 -69
@@ -1,38 +1,17 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
3
  import { BlobStore } from "./blobStore";
4
- import { RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
4
+ import { RemoteConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
5
5
  import { BucketDiskInfo } from "./bucketDisk";
6
- import { SelfSummary } from "./storePlan";
7
- /**
8
- * What this server HAS, as opposed to what any of it does. Four things, and nothing else:
9
- *
10
- * - the stores, by folder (getStore) - made once, and self-configuring from there
11
- * - which of them a request means (findBucketStore), which is only ever a name
12
- * - what exists on disk, for listings and for the pages that report it
13
- *
14
- * It decides nothing about routes, windows or peers - a store reads its own config and works that
15
- * out itself (BlobStore.applyRoutingConfig) - and it schedules nothing: switchovers and the handovers
16
- * they cause live in intermediateManagement.
17
- */
18
- export type LoadedStore = {
19
- name: string;
20
- entries: HostedConfig[];
21
- folder: string;
22
- store: BlobStore;
23
- };
24
- export type BucketState = {
25
- account: string;
26
- bucketName: string;
27
- routing?: RemoteConfig;
28
- selfEntries: HostedConfig[];
29
- self: SelfSummary | undefined;
30
- stores: LoadedStore[];
31
- };
32
- /** The loaded bucket, loading it (which instantiates its stores and starts their synchronization) if needed. A bucket that does not exist on this server throws - callers never see undefined buckets. */
33
- export declare function requireBucket(account: string, bucketName: string): Promise<BucketState>;
6
+ import { BucketWriteStats } from "./accessStats";
7
+ export declare function getStore(account: string, bucketName: string, name: string): BlobStore;
34
8
  /** The store serving a request: the one the client's selected entry NAMES. Account, name, and bucket ARE the folder, so this is a direct lookup, never a search of what exists - and a name this server has never seen is CREATED, never rejected, because asking for a name is the instruction to have that store (one name, one folder, one index; it configures itself once the routing config lands in it). Nothing else about the request is compared - not the window, not the route, not the flags - which is the whole point of naming it: a client a config version behind on some flag still reaches the right store. */
35
9
  export declare function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): BlobStore;
10
+ /** The stores of a bucket as the DISK records them (a bucket is nothing more than the store folders sharing its name), opened - so the ones that weren't running yet start synchronizing. Empty when the bucket does not exist here. */
11
+ export declare function getBucketStores(account: string, bucketName: string): Promise<{
12
+ name: string;
13
+ store: BlobStore;
14
+ }[]>;
36
15
  /** Internal (store-to-store) reads skip store selection entirely: the caller is another store whose index says this MACHINE holds the bytes - the persisted holder identity is just a URL, which cannot name a store. Whichever store's folder has the newest copy answers. */
37
16
  export declare function readBucketInternal(account: string, bucketName: string, config: {
38
17
  path: string;
@@ -46,9 +25,9 @@ export declare function readBucketInternal(account: string, bucketName: string,
46
25
  writeTime: number;
47
26
  size: number;
48
27
  } | undefined>;
49
- export declare function getBucketConfig(bucket: BucketState): ArchivesConfig;
50
- export declare function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus>;
51
- export declare function debugBucketIndexTotals(bucket: BucketState): Promise<{
28
+ export declare function getBucketArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig>;
29
+ export declare function bucketSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus>;
30
+ export declare function debugBucketIndexTotals(account: string, bucketName: string): Promise<{
52
31
  fileCount: number;
53
32
  byteCount: number;
54
33
  sources: {
@@ -57,14 +36,8 @@ export declare function debugBucketIndexTotals(bucket: BucketState): Promise<{
57
36
  byteCount: number;
58
37
  }[];
59
38
  }>;
60
- /** The bucket states currently in memory - what the switchover machinery walks. Only what is loaded: loading a bucket starts its synchronization, and a bucket nothing has touched has no in-flight writes to hand over anyway. */
61
- export declare function getBucketStates(): BucketState[];
62
39
  /** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
63
40
  export declare function resolveSourceArchives(url: string): IArchives;
64
- export declare function getStore(account: string, bucketName: string, name: string): BlobStore;
65
- export declare function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined>;
66
- /** Re-reads the bucket off the disk, replacing the cached snapshot: what a routing-config write calls, because that write can CREATE a store (a new name gets a new folder) and the snapshot would keep answering with the stores it saw at load time. Existing stores are reused (getStore caches by folder), so this only picks up folder changes - it never restarts a store. */
67
- export declare function reloadBucket(account: string, bucketName: string): Promise<BucketState | undefined>;
68
41
  /**
69
42
  * Writing the routing config is a write like any other: it goes into a store, and the store applies
70
43
  * it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
@@ -77,7 +50,7 @@ export declare function reloadBucket(account: string, bucketName: string): Promi
77
50
  export declare function writeRoutingConfig(account: string, bucketName: string, name: string, data: Buffer, config?: {
78
51
  lastModified?: number;
79
52
  }): Promise<void>;
80
- /** Which buckets this process currently has loaded - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
53
+ /** Which buckets this process currently has active (some store of theirs was opened) - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
81
54
  export declare function getActiveBucketKeys(): {
82
55
  account: string;
83
56
  bucketName: string;
@@ -96,25 +69,12 @@ export type ServerBucketInfo = {
96
69
  };
97
70
  export type ActiveBucketInfo = {
98
71
  folder: string;
99
- /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded. Absent when none of its stores has one yet. */
72
+ /** The bucket's routing config, the newest copy among its stores. Absent when none of them has one yet. */
100
73
  routing?: RemoteConfig;
101
- /** Our own entries in that config, and their summarized current role (routes union + flags) */
102
- selfEntries: HostedConfig[];
103
- self?: SelfSummary;
104
74
  config: ArchivesConfig;
105
75
  };
106
- /** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
76
+ /** The state of ONE active bucket. Returns an error string when the bucket is not active here, which is the normal state for a bucket nothing has accessed since startup. */
107
77
  export declare function debugGetActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
108
- /** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
78
+ /** Loads every store of a bucket that exists on this server's disk into memory, which starts their synchronization and window timers, and returns the bucket's state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-active buckets just return their state. */
109
79
  export declare function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
110
80
  export declare function debugListAccountBuckets(account: string): Promise<ServerBucketInfo[]>;
111
- export type BucketWriteStats = {
112
- /** Every set call the bucket accepted */
113
- originalWrites: number;
114
- originalBytes: number;
115
- /** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
116
- flushedWrites: number;
117
- flushedBytes: number;
118
- };
119
- /** Zeroes the write statistics of every bucket in the account. */
120
- export declare function debugClearAccountWriteStats(account: string): number;
@@ -1,57 +1,61 @@
1
- import path from "path";
2
- import fs from "fs";
3
1
  import { BlobStore } from "./blobStore";
4
2
  import {
5
- RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus, SyncActivity,
3
+ RemoteConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus, SyncActivity,
6
4
  } from "../IArchives";
7
5
  import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, normalizeSource } from "./remoteConfig";
8
6
  import { createStoreSource, applySourceConfig } from "./storeSources";
9
7
  import { broadcastRoutingChanged } from "./storageController";
10
- import { getStorageServerConfig, assertWritesAllowed } from "./serverConfig";
8
+ import { assertWritesAllowed } from "./serverConfig";
11
9
  import { assertValidSourceName } from "./validation";
12
10
  import { getBucketFolder, listAccountStoreFolders, listBucketStoreFolders, readRoutingFromDisk, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
13
11
  import { scheduleBoundaryWork, reinjectIntermediates } from "./intermediateManagement";
14
- import { planSelfStores, isSelfSource, SelfSummary } from "./storePlan";
12
+ import { isSelfSource } from "./storePlan";
13
+ import { countBucketWrite, getBucketWriteStats, BucketWriteStats } from "./accessStats";
15
14
 
16
15
  /**
17
- * What this server HAS, as opposed to what any of it does. Four things, and nothing else:
18
- *
19
- * - the stores, by folder (getStore) - made once, and self-configuring from there
20
- * - which of them a request means (findBucketStore), which is only ever a name
21
- * - what exists on disk, for listings and for the pages that report it
22
- *
23
- * It decides nothing about routes, windows or peers - a store reads its own config and works that
24
- * out itself (BlobStore.applyRoutingConfig) - and it schedules nothing: switchovers and the handovers
25
- * they cause live in intermediateManagement.
16
+ * What this server HAS: the stores, by name (getStore) - made once, self-configuring from there -
17
+ * and the three entry points a request resolves through (findBucketStore, readBucketInternal,
18
+ * writeRoutingConfig). There is no cached bucket state: a bucket is nothing but the stores whose
19
+ * folders share its name, listed off the disk when something genuinely needs all of them.
26
20
  */
27
21
 
28
- export type LoadedStore = {
29
- // The name its entries gave it (see CommonConfig.name) - its identity here, its folder on disk, and how a request finds it
30
- name: string;
31
- // The exact self entries this store serves (same route, different valid windows). Empty when we are not in the config but still serve our disk data.
32
- entries: HostedConfig[];
33
- folder: string;
34
- store: BlobStore;
35
- };
36
-
37
- // A bucket loaded on this server: the stores it has here, and what the newest of their configs says about us. Pure data - the stores themselves do the work, including configuring themselves.
38
- export type BucketState = {
39
- account: string;
40
- bucketName: string;
41
- // The newest routing config among its stores, or absent when none of them has one yet
42
- routing?: RemoteConfig;
43
- selfEntries: HostedConfig[];
44
- self: SelfSummary | undefined;
45
- stores: LoadedStore[];
46
- };
22
+ /**
23
+ * Every store this process runs, by folder - which IS the store's identity: account, name, bucket.
24
+ * A store is made once, configures itself from the routing config it holds, and stays; nothing here
25
+ * decides what it is, only that it exists.
26
+ */
27
+ const stores = new Map<string, BlobStore>();
28
+ // The opened stores of each `${account}/${bucketName}`, by name - which buckets are ACTIVE (something touched them), for deploy handoffs and the debug pages. Purely a registry of what getStore already made.
29
+ const activeStores = new Map<string, Map<string, BlobStore>>();
47
30
 
48
- /** The loaded bucket, loading it (which instantiates its stores and starts their synchronization) if needed. A bucket that does not exist on this server throws - callers never see undefined buckets. */
49
- export async function requireBucket(account: string, bucketName: string): Promise<BucketState> {
50
- let bucket = await getLoadedBucket(account, bucketName);
51
- if (!bucket) {
52
- throw new Error(`Bucket ${account}/${bucketName} does not exist on this server. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
31
+ export function getStore(account: string, bucketName: string, name: string): BlobStore {
32
+ // The one place a name becomes a folder, so the one place it is validated (it can come straight off the wire)
33
+ assertValidSourceName(name);
34
+ let folder = getBucketFolder(name, account, bucketName);
35
+ let existing = stores.get(folder);
36
+ if (existing) return existing;
37
+ console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder}) - created on this first use if it never existed`);
38
+ let store = new BlobStore(folder, name, {
39
+ // Which entries in ITS routing config are this server's copy of it
40
+ isSelf: source => isSelfSource(source, account, bucketName),
41
+ createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
42
+ applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
43
+ onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
44
+ resolveSourceUrl: resolveSourceArchives,
45
+ // The store is the one that knows when a config landed, and a config with upcoming windows is what boundary scans are armed from
46
+ onRoutingApplied: routing => scheduleBoundaryWork(account, bucketName, routing),
47
+ });
48
+ stores.set(folder, store);
49
+ let key = `${account}/${bucketName}`;
50
+ let byName = activeStores.get(key);
51
+ if (!byName) {
52
+ byName = new Map();
53
+ activeStores.set(key, byName);
53
54
  }
54
- return bucket;
55
+ byName.set(name, store);
56
+ // Lazy init would leave a store nothing has touched sitting inert - no index loaded, no config applied, nothing synchronizing - while its folder is full of files
57
+ void store.init().catch((e: Error) => console.error(`Initializing store ${JSON.stringify(name)} of bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
58
+ return store;
55
59
  }
56
60
 
57
61
  /** The store serving a request: the one the client's selected entry NAMES. Account, name, and bucket ARE the folder, so this is a direct lookup, never a search of what exists - and a name this server has never seen is CREATED, never rejected, because asking for a name is the instruction to have that store (one name, one folder, one index; it configures itself once the routing config lands in it). Nothing else about the request is compared - not the window, not the route, not the flags - which is the whole point of naming it: a client a config version behind on some flag still reaches the right store. */
@@ -59,15 +63,27 @@ export function findBucketStore(account: string, bucketName: string, sourceConfi
59
63
  if (!sourceConfig) {
60
64
  throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected`);
61
65
  }
62
- // Not part of resolving the request (the name already says everything): loading the bucket is what schedules its switchover work and marks it active for deploy handoffs, and a bucket serving requests must be loaded
63
- void getLoadedBucket(account, bucketName).catch((e: Error) => console.error(`Loading bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
64
66
  return getStore(account, bucketName, sourceConfig.name);
65
67
  }
66
68
 
69
+ /** The stores of a bucket as the DISK records them (a bucket is nothing more than the store folders sharing its name), opened - so the ones that weren't running yet start synchronizing. Empty when the bucket does not exist here. */
70
+ export async function getBucketStores(account: string, bucketName: string): Promise<{ name: string; store: BlobStore }[]> {
71
+ let folders = await listBucketStoreFolders(account, bucketName);
72
+ return folders.map(x => ({ name: x.name, store: getStore(account, bucketName, x.name) }));
73
+ }
74
+
75
+ async function requireBucketStores(account: string, bucketName: string): Promise<{ name: string; store: BlobStore }[]> {
76
+ let list = await getBucketStores(account, bucketName);
77
+ if (!list.length) {
78
+ throw new Error(`Bucket ${account}/${bucketName} does not exist on this server. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
79
+ }
80
+ return list;
81
+ }
82
+
67
83
  /** Internal (store-to-store) reads skip store selection entirely: the caller is another store whose index says this MACHINE holds the bytes - the persisted holder identity is just a URL, which cannot name a store. Whichever store's folder has the newest copy answers. */
68
84
  export async function readBucketInternal(account: string, bucketName: string, config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
69
- let bucket = await requireBucket(account, bucketName);
70
- let results = await Promise.all(bucket.stores.map(s => s.store.get2({ path: config.path, range: config.range, includeTombstones: config.includeTombstones, internal: true })));
85
+ let bucketStores = await requireBucketStores(account, bucketName);
86
+ let results = await Promise.all(bucketStores.map(s => s.store.get2({ path: config.path, range: config.range, includeTombstones: config.includeTombstones, internal: true })));
71
87
  let best: { data: Buffer; writeTime: number; size: number } | undefined;
72
88
  for (let result of results) {
73
89
  if (result && (!best || result.writeTime > best.writeTime)) {
@@ -77,13 +93,13 @@ export async function readBucketInternal(account: string, bucketName: string, co
77
93
  return best;
78
94
  }
79
95
 
80
- export function getBucketConfig(bucket: BucketState): ArchivesConfig {
96
+ function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfig | undefined): ArchivesConfig {
81
97
  let index = { fileCount: 0, byteCount: 0 };
82
98
  let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
83
99
  let syncing: SyncActivity[] = [];
84
100
  let readerDiskLimit: number | undefined;
85
- for (let s of bucket.stores) {
86
- let progress = s.store.getSyncProgress();
101
+ for (let store of bucketStores) {
102
+ let progress = store.getSyncProgress();
87
103
  index.fileCount += progress.index.fileCount;
88
104
  index.byteCount += progress.index.byteCount;
89
105
  indexSources.push(...progress.sources);
@@ -93,7 +109,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
93
109
  return {
94
110
  // Every store is index-backed, so the change feed is always native
95
111
  supportsChangesAfter: true,
96
- remoteConfig: bucket.routing,
112
+ remoteConfig: routing,
97
113
  index,
98
114
  indexSources,
99
115
  readerDiskLimit,
@@ -101,8 +117,15 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
101
117
  };
102
118
  }
103
119
 
104
- export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus> {
105
- let statuses = await Promise.all(bucket.stores.map(s => s.store.getSyncStatus()));
120
+ export async function getBucketArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
121
+ let bucketStores = await requireBucketStores(account, bucketName);
122
+ let routing = await readRoutingFromDisk(account, bucketName);
123
+ return aggregateArchivesConfig(bucketStores.map(x => x.store), routing);
124
+ }
125
+
126
+ export async function bucketSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus> {
127
+ let bucketStores = await requireBucketStores(account, bucketName);
128
+ let statuses = await Promise.all(bucketStores.map(s => s.store.getSyncStatus()));
106
129
  return {
107
130
  allScansComplete: statuses.every(x => x.allScansComplete),
108
131
  indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
@@ -110,8 +133,9 @@ export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyn
110
133
  };
111
134
  }
112
135
 
113
- export async function debugBucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }> {
114
- let totals = await Promise.all(bucket.stores.map(s => s.store.computeIndexTotals()));
136
+ export async function debugBucketIndexTotals(account: string, bucketName: string): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }> {
137
+ let bucketStores = await requireBucketStores(account, bucketName);
138
+ let totals = await Promise.all(bucketStores.map(s => s.store.computeIndexTotals()));
115
139
  return {
116
140
  fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
117
141
  byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
@@ -119,15 +143,6 @@ export async function debugBucketIndexTotals(bucket: BucketState): Promise<{ fil
119
143
  };
120
144
  }
121
145
 
122
- const buckets = new Map<string, Promise<BucketState | undefined>>();
123
- // The resolved ones, for the code that walks buckets rather than asking for one (see getBucketStates)
124
- const loadedBuckets = new Map<string, BucketState>();
125
-
126
- /** The bucket states currently in memory - what the switchover machinery walks. Only what is loaded: loading a bucket starts its synchronization, and a bucket nothing has touched has no in-flight writes to hand over anyway. */
127
- export function getBucketStates(): BucketState[] {
128
- return [...loadedBuckets.values()];
129
- }
130
-
131
146
  const resolvedSourceArchives = new Map<string, IArchives>();
132
147
  /** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
133
148
  export function resolveSourceArchives(url: string): IArchives {
@@ -143,91 +158,6 @@ export function resolveSourceArchives(url: string): IArchives {
143
158
  return archives;
144
159
  }
145
160
 
146
- /** What an index entry records as the holder of its bytes (see ArchivesSource.url), so it must name the endpoint FOREVER. An intermediate is a switchover's temporary alternate port onto another source, and that port is gone for good once its window passes - so it is recorded as the source it was split out of, which holds the same bucket and outlives it. */
147
- function sourcePersistentUrl(sourceConfig: SourceConfig | undefined, folder: string): string {
148
- if (!sourceConfig) return folder;
149
- return sourceConfig.intermediate || sourceConfig.url;
150
- }
151
-
152
- /** The fast-write policy this store writes under: its own configuration decides it (fast/writeDelay are OUR settings, not the peer's), and each source then holds writes for as long as sourceWriteDelay says. */
153
- /**
154
- * Every store this process runs, by folder - which IS the store's identity: account, name, bucket.
155
- * A store is made once, configures itself from the routing config it holds, and stays; nothing here
156
- * decides what it is, only that it exists.
157
- */
158
- const stores = new Map<string, BlobStore>();
159
-
160
- export function getStore(account: string, bucketName: string, name: string): BlobStore {
161
- // The one place a name becomes a folder, so the one place it is validated (it can come straight off the wire)
162
- assertValidSourceName(name);
163
- let folder = getBucketFolder(name, account, bucketName);
164
- let existing = stores.get(folder);
165
- if (existing) return existing;
166
- console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder}) - created on this first use if it never existed`);
167
- let store = new BlobStore(folder, name, {
168
- // Which entries in ITS routing config are this server's copy of it
169
- isSelf: source => isSelfSource(source, account, bucketName),
170
- createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
171
- applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
172
- onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
173
- resolveSourceUrl: resolveSourceArchives,
174
- });
175
- stores.set(folder, store);
176
- // Lazy init would leave a store nothing has touched sitting inert - no index loaded, no config applied, nothing synchronizing - while its folder is full of files
177
- void store.init().catch((e: Error) => console.error(`Initializing store ${JSON.stringify(name)} of bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
178
- return store;
179
- }
180
-
181
- /** The bucket as this server has it: the stores whose folders exist, plus whatever the newest of their configs says about us. Names come from the DISK, not from the config - a store we hold keeps being served whether or not the current config still mentions it. */
182
- function buildBucket(account: string, bucketName: string, names: string[], routing: RemoteConfig | undefined): BucketState {
183
- let plan = routing && planSelfStores(account, bucketName, routing) || undefined;
184
- let entriesByName = new Map<string, HostedConfig[]>();
185
- for (let selfStore of plan?.stores || []) {
186
- entriesByName.set(selfStore.name, selfStore.entries);
187
- }
188
- // Each store reads its own copy of the config and decides what it is - see BlobStore.applyRoutingConfig. Nothing here configures anything.
189
- let stores: LoadedStore[] = names.map(name => ({
190
- name,
191
- entries: entriesByName.get(name) || [],
192
- folder: getBucketFolder(name, account, bucketName),
193
- store: getStore(account, bucketName, name),
194
- }));
195
- let loaded: BucketState = { account, bucketName, routing, selfEntries: plan?.selfEntries || [], self: plan?.self, stores };
196
- loadedBuckets.set(`${account}/${bucketName}`, loaded);
197
- scheduleBoundaryWork(loaded);
198
- return loaded;
199
- }
200
-
201
- // A bucket exists here when it has at least one store folder - the disk IS the record. Its config is the newest copy among those stores; with none at all it has no config yet, which is a perfectly good state for a store nobody has configured.
202
- async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
203
- let folders = await listBucketStoreFolders(account, bucketName);
204
- if (!folders.length) return undefined;
205
- let routing = await readRoutingFromDisk(account, bucketName);
206
- return buildBucket(account, bucketName, folders.map(x => x.name), routing);
207
- }
208
-
209
- export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
210
- let key = `${account}/${bucketName}`;
211
- return buckets.get(key) || reloadBucket(account, bucketName);
212
- }
213
-
214
- /** Re-reads the bucket off the disk, replacing the cached snapshot: what a routing-config write calls, because that write can CREATE a store (a new name gets a new folder) and the snapshot would keep answering with the stores it saw at load time. Existing stores are reused (getStore caches by folder), so this only picks up folder changes - it never restarts a store. */
215
- export async function reloadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
216
- let key = `${account}/${bucketName}`;
217
- let loaded = loadBucket(account, bucketName);
218
- buckets.set(key, loaded);
219
- void loaded.then(bucket => {
220
- if (!bucket && buckets.get(key) === loaded) {
221
- buckets.delete(key);
222
- }
223
- }, () => {
224
- if (buckets.get(key) === loaded) {
225
- buckets.delete(key);
226
- }
227
- });
228
- return await loaded;
229
- }
230
-
231
161
  /**
232
162
  * Writing the routing config is a write like any other: it goes into a store, and the store applies
233
163
  * it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
@@ -241,17 +171,15 @@ export async function writeRoutingConfig(account: string, bucketName: string, na
241
171
  assertWritesAllowed();
242
172
  let store = getStore(account, bucketName, name);
243
173
  let incoming = parseRoutingData(data);
244
- let current = (await getLoadedBucket(account, bucketName))?.routing;
174
+ let current = await readRoutingFromDisk(account, bucketName);
245
175
  let stored = reinjectIntermediates(current, incoming);
246
176
  await store.set({ path: ROUTING_FILE, data: Buffer.from(serializeRemoteConfig(stored)), lastModified: config?.lastModified });
247
- // The write may have CREATED the store (a new name gets a new folder), and the loaded bucket is a snapshot of the disk - so it is re-read, or every data write naming the new store is rejected until a restart
248
- await reloadBucket(account, bucketName);
249
177
  broadcastRoutingChanged();
250
178
  }
251
179
 
252
- /** Which buckets this process currently has loaded - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
180
+ /** Which buckets this process currently has active (some store of theirs was opened) - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
253
181
  export function getActiveBucketKeys(): { account: string; bucketName: string }[] {
254
- return [...buckets.keys()].map(key => {
182
+ return [...activeStores.keys()].map(key => {
255
183
  let slash = key.indexOf("/");
256
184
  return { account: key.slice(0, slash), bucketName: key.slice(slash + 1) };
257
185
  });
@@ -272,80 +200,46 @@ export type ServerBucketInfo = {
272
200
 
273
201
  export type ActiveBucketInfo = {
274
202
  folder: string;
275
- /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded. Absent when none of its stores has one yet. */
203
+ /** The bucket's routing config, the newest copy among its stores. Absent when none of them has one yet. */
276
204
  routing?: RemoteConfig;
277
- /** Our own entries in that config, and their summarized current role (routes union + flags) */
278
- selfEntries: HostedConfig[];
279
- self?: SelfSummary;
280
205
  config: ArchivesConfig;
281
206
  };
282
207
 
283
- function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
284
- return {
285
- // One of its stores' folders - a bucket no longer has a folder of its own, it has one per store
286
- folder: loaded.stores[0]?.folder || "",
287
- routing: loaded.routing,
288
- selfEntries: loaded.selfEntries,
289
- self: loaded.self,
290
- config: getBucketConfig(loaded),
291
- };
292
- }
293
-
294
- /** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
208
+ /** The state of ONE active bucket. Returns an error string when the bucket is not active here, which is the normal state for a bucket nothing has accessed since startup. */
295
209
  export async function debugGetActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
296
210
  let key = `${account}/${bucketName}`;
297
- let loadedPromise = buckets.get(key);
298
- if (!loadedPromise) {
299
- return `Bucket ${key} is not loaded on this server, so it has no live state (it loads on first access)`;
300
- }
301
- let loaded: BucketState | undefined;
302
- try {
303
- loaded = await loadedPromise;
304
- } catch (e) {
305
- return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
306
- }
307
- if (!loaded) {
308
- return `Bucket ${key} does not exist on this server`;
211
+ let opened = activeStores.get(key);
212
+ if (!opened || !opened.size) {
213
+ return `Bucket ${key} is not active on this server (a bucket activates on first access)`;
309
214
  }
310
- return toActiveBucketInfo(loaded);
215
+ let bucketStores = [...opened.values()];
216
+ let routing = await readRoutingFromDisk(account, bucketName);
217
+ return { folder: bucketStores[0].folder, routing, config: aggregateArchivesConfig(bucketStores, routing) };
311
218
  }
312
219
 
313
- /** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
220
+ /** Loads every store of a bucket that exists on this server's disk into memory, which starts their synchronization and window timers, and returns the bucket's state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-active buckets just return their state. */
314
221
  export async function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
315
222
  let key = `${account}/${bucketName}`;
316
- let wasLoaded = buckets.has(key);
317
- let loaded: BucketState | undefined;
318
- try {
319
- loaded = await getLoadedBucket(account, bucketName);
320
- } catch (e) {
321
- return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
322
- }
323
- if (!loaded) {
223
+ let wasActive = activeStores.has(key);
224
+ let bucketStores = await getBucketStores(account, bucketName);
225
+ if (!bucketStores.length) {
324
226
  return `Bucket ${key} does not exist on this server (it has no store folders under ${getBucketFolder("<name>", account, bucketName)})`;
325
227
  }
326
228
  // Wait for the indexes to load, so the totals we return are the real ones rather than zeroes from stores that have not read their index yet. The source scans keep running in the background.
327
- for (let s of loaded.stores) {
229
+ for (let s of bucketStores) {
328
230
  await s.store.init();
329
231
  }
330
- if (!wasLoaded) {
331
- console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
232
+ if (!wasActive) {
233
+ console.log(`Activated bucket ${key} on request: its ${bucketStores.length} store(s) are now loaded and synchronizing`);
332
234
  }
333
- return toActiveBucketInfo(loaded);
235
+ let routing = await readRoutingFromDisk(account, bucketName);
236
+ return { folder: bucketStores[0].store.folder, routing, config: aggregateArchivesConfig(bucketStores.map(x => x.store), routing) };
334
237
  }
335
238
 
336
239
  export async function debugListAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
337
240
  let start = Date.now();
338
- let timings = new Map<string, number>();
339
- async function timed<T>(name: string, run: () => Promise<T>): Promise<T> {
340
- let began = Date.now();
341
- try {
342
- return await run();
343
- } finally {
344
- timings.set(name, (timings.get(name) || 0) + (Date.now() - began));
345
- }
346
- }
347
241
  // The disk is the record: a bucket exists here when it has at least one store folder, and several names for one bucket are still one bucket
348
- let folders = await timed("readdir", () => listAccountStoreFolders(account));
242
+ let folders = await listAccountStoreFolders(account);
349
243
  let byBucket = new Map<string, string>();
350
244
  for (let store of folders) {
351
245
  if (byBucket.has(store.bucketName)) continue;
@@ -356,83 +250,27 @@ export async function debugListAccountBuckets(account: string): Promise<ServerBu
356
250
  return await Promise.all(names.map(async bucketName => {
357
251
  let key = `${account}/${bucketName}`;
358
252
  let folder = byBucket.get(bucketName) || "";
359
- let base: ServerBucketInfo = { bucketName, active: false, folder };
360
- base.disk = await timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
253
+ let base: ServerBucketInfo = { bucketName, active: activeStores.has(key), folder };
254
+ base.disk = await getDiskInfo(folder).catch((e: Error) => {
361
255
  base.diskError = String(e.stack ?? e).slice(0, 500);
362
256
  return undefined;
363
257
  });
364
258
  base.writeStats = getBucketWriteStats(key);
365
- const loadedPromise = buckets.get(key);
366
- if (loadedPromise) {
367
- try {
368
- let loaded = await timed("awaitLoaded", () => loadedPromise);
369
- if (loaded) {
370
- return { ...base, active: true, config: getBucketConfig(loaded) };
371
- }
372
- } catch (e) {
373
- return { ...base, active: true, error: String((e as Error).stack ?? e).slice(0, 500) };
374
- }
375
- }
376
259
  try {
377
- const parsed = await timed("readRoutingFile", () => readRoutingFromDisk(account, bucketName));
378
- if (!parsed) {
260
+ let routing = await readRoutingFromDisk(account, bucketName);
261
+ if (base.active) {
262
+ let opened = [...(activeStores.get(key)?.values() || [])];
263
+ return { ...base, config: aggregateArchivesConfig(opened, routing) };
264
+ }
265
+ if (!routing) {
379
266
  return { ...base, error: `No routing file (${ROUTING_FILE}) in any of its stores` };
380
267
  }
381
- return { ...base, config: { remoteConfig: parsed } };
268
+ return { ...base, config: { remoteConfig: routing } };
382
269
  } catch (e) {
383
270
  return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
384
271
  }
385
272
  }));
386
273
  } finally {
387
- // The parts run concurrently, so these are summed wall times per part, not a breakdown of the total
388
- let parts = [...timings].map(([name, ms]) => `${name} ${ms}ms`).join(", ");
389
- console.log(`debugListAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets: ${parts}`);
390
- }
391
- }
392
-
393
- export type BucketWriteStats = {
394
- /** Every set call the bucket accepted */
395
- originalWrites: number;
396
- originalBytes: number;
397
- /** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
398
- flushedWrites: number;
399
- flushedBytes: number;
400
- };
401
- function emptyWriteStats(): BucketWriteStats {
402
- return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
403
- }
404
-
405
- // In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
406
- const writeStats = new Map<string, BucketWriteStats>();
407
-
408
- function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
409
- let stats = writeStats.get(key);
410
- if (!stats) {
411
- stats = emptyWriteStats();
412
- writeStats.set(key, stats);
413
- }
414
- if (kind === "original") {
415
- stats.originalWrites++;
416
- stats.originalBytes += bytes;
417
- } else {
418
- stats.flushedWrites++;
419
- stats.flushedBytes += bytes;
420
- }
421
- }
422
-
423
- function getBucketWriteStats(key: string): BucketWriteStats {
424
- return writeStats.get(key) || emptyWriteStats();
425
- }
426
-
427
- /** Zeroes the write statistics of every bucket in the account. */
428
- export function debugClearAccountWriteStats(account: string): number {
429
- let prefix = `${account}/`;
430
- let cleared = 0;
431
- for (let key of [...writeStats.keys()]) {
432
- if (!key.startsWith(prefix)) continue;
433
- writeStats.delete(key);
434
- cleared++;
274
+ console.log(`debugListAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets`);
435
275
  }
436
- console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
437
- return cleared;
438
276
  }
@@ -23,6 +23,8 @@ export declare class StoreConfig {
23
23
  export type StorePolicy = {
24
24
  validWindow: [number, number];
25
25
  route?: [number, number];
26
+ public?: boolean;
27
+ immutable?: boolean;
26
28
  fast?: boolean;
27
29
  writeDelay?: number;
28
30
  noFullSync?: boolean;