sliftutils 1.7.67 → 1.7.68

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.
@@ -2,6 +2,8 @@
2
2
  /// <reference types="node" />
3
3
  import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig } from "../IArchives";
4
4
  import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
5
+ import { AccessTotals, AccessSummaryState } from "./accessStats";
6
+ import type { SummaryEntry } from "../../treeSummary";
5
7
  export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
6
8
  export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
7
9
  export declare const STORAGE_NOT_AUTHENTICATED = "REMOTE_STORAGE_NOT_AUTHENTICATED_cf2f7b1e";
@@ -88,6 +90,12 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
88
90
  clearWriteStats: (account: string) => Promise<{
89
91
  clearedBuckets: number;
90
92
  }>;
93
+ getAccessStats: (account: string) => Promise<AccessTotals>;
94
+ getAccessSummaries: (account: string, config: {
95
+ operation: string;
96
+ maxCount: number;
97
+ weightBySize?: boolean;
98
+ }) => Promise<SummaryEntry<AccessSummaryState>[]>;
91
99
  getIndexInfo: (account: string, bucketName: string) => Promise<{
92
100
  fileCount: number;
93
101
  byteCount: number;
@@ -15,6 +15,8 @@ import {
15
15
  getActiveBucket, activateBucket, ActiveBucketInfo, getActiveBucketKeys,
16
16
  } from "./storageServerState";
17
17
  import { StorageClientController } from "./storageClientController";
18
+ import { trackAccess, getAccessTotals, readAccessSummaries, clearAccountAccessStats, AccessTotals, AccessSummaryState } from "./accessStats";
19
+ import type { SummaryEntry } from "../../treeSummary";
18
20
 
19
21
  export const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
20
22
  export const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
@@ -292,37 +294,57 @@ class RemoteStorageControllerBase {
292
294
  async get2(account: string, bucketName: string, path: string, range?: { start: number; end: number }, internal?: boolean): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
293
295
  assertValidPath(path);
294
296
  let bucket = await getBucket(account, bucketName);
295
- if (!bucket) return undefined;
296
- if (internal && bucket.store.getInternal2) {
297
- return await bucket.store.getInternal2(path, { range });
297
+ let result: { data: Buffer; writeTime: number; size: number } | undefined;
298
+ if (bucket) {
299
+ if (internal && bucket.store.getInternal2) {
300
+ result = await bucket.store.getInternal2(path, { range });
301
+ } else {
302
+ result = await bucket.store.get2(path, { range });
303
+ }
298
304
  }
299
- return await bucket.store.get2(path, { range });
305
+ trackAccess({ account, operation: "get", path: `${bucketName}/${path}`, size: result && result.data.length || 0 });
306
+ return result;
300
307
  }
301
308
  async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number, forceSetImmutable?: boolean, internal?: boolean): Promise<void> {
302
309
  assertValidName(bucketName, "bucket name");
303
310
  assertValidPath(path);
311
+ trackAccess({ account, operation: "set", path: `${bucketName}/${path}`, size: data.length });
304
312
  await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified, forceSetImmutable, internal });
305
313
  }
306
314
  async del(account: string, bucketName: string, path: string): Promise<void> {
307
315
  assertValidName(bucketName, "bucket name");
308
316
  assertValidPath(path);
317
+ trackAccess({ account, operation: "del", path: `${bucketName}/${path}` });
309
318
  await deleteBucketFile(account, bucketName, path);
310
319
  }
311
320
  async getInfo(account: string, bucketName: string, path: string, includeTombstones?: boolean): Promise<{ writeTime: number; size: number } | undefined> {
312
321
  assertValidPath(path);
322
+ trackAccess({ account, operation: "getInfo", path: `${bucketName}/${path}` });
313
323
  let bucket = await getBucket(account, bucketName);
314
324
  if (!bucket) return undefined;
315
325
  return await bucket.store.getInfo(path, { includeTombstones });
316
326
  }
317
327
  async findInfo(account: string, bucketName: string, prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
318
328
  let bucket = await getBucket(account, bucketName);
319
- if (!bucket) return [];
320
- return await bucket.store.findInfo(prefix, config);
329
+ let results = bucket && await bucket.store.findInfo(prefix, config) || [];
330
+ // The paths are only known once the results are in; an empty result still counts as one access at the prefix.
331
+ if (results.length) {
332
+ for (let info of results) {
333
+ trackAccess({ account, operation: "findInfo", path: `${bucketName}/${info.path}` });
334
+ }
335
+ } else {
336
+ trackAccess({ account, operation: "findInfo", path: `${bucketName}/${prefix}` });
337
+ }
338
+ return results;
321
339
  }
322
340
  async getChangesAfter2(account: string, bucketName: string, config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
323
341
  let bucket = await getBucket(account, bucketName);
324
342
  if (!bucket) return [];
325
- return await bucket.store.getChangesAfter2(config);
343
+ let results = await bucket.store.getChangesAfter2(config);
344
+ for (let info of results) {
345
+ trackAccess({ account, operation: "getChangesAfter", path: `${bucketName}/${info.path}` });
346
+ }
347
+ return results;
326
348
  }
327
349
  async getArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
328
350
  let bucket = await getBucket(account, bucketName);
@@ -348,10 +370,21 @@ class RemoteStorageControllerBase {
348
370
  assertValidName(bucketName, "bucket name");
349
371
  return await activateBucket(account, bucketName);
350
372
  }
351
- /** Zeroes the write statistics listBuckets reports, for every bucket in the account. */
373
+ /** Zeroes the write statistics listBuckets reports and the in-memory access statistics, for every bucket in the account. */
352
374
  async clearWriteStats(account: string): Promise<{ clearedBuckets: number }> {
353
375
  assertValidName(account, "account");
354
- return { clearedBuckets: await clearAccountWriteStats(account) };
376
+ clearAccountAccessStats(account);
377
+ return { clearedBuckets: clearAccountWriteStats(account) };
378
+ }
379
+ /** In-memory totals per operation type since startup (or the last clearWriteStats). */
380
+ async getAccessStats(account: string): Promise<AccessTotals> {
381
+ assertValidName(account, "account");
382
+ return getAccessTotals(account);
383
+ }
384
+ /** A path breakdown of one operation's accesses (operation names come from getAccessStats). maxCount is passed straight to TreeSummary.getSummaries. weightBySize is ignored for count-only operations, which return their count breakdown. */
385
+ async getAccessSummaries(account: string, config: { operation: string; maxCount: number; weightBySize?: boolean }): Promise<SummaryEntry<AccessSummaryState>[]> {
386
+ assertValidName(account, "account");
387
+ return readAccessSummaries({ account, ...config });
355
388
  }
356
389
  async getIndexInfo(account: string, bucketName: string): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
357
390
  let bucket = await getBucket(account, bucketName);
@@ -383,6 +416,7 @@ class RemoteStorageControllerBase {
383
416
  assertWritesAllowed();
384
417
  let info = largeUploadInfo.get(uploadId);
385
418
  if (!info) throw new Error(`Unknown large upload ${uploadId}`);
419
+ trackAccess({ account: info.account, operation: "uploadPart", path: `${info.bucketName}/${info.path}`, size: data.length });
386
420
  let bucket = await getBucket(info.account, info.bucketName);
387
421
  if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
388
422
  await bucket.store.appendLargeUpload(uploadId, Buffer.from(data));
@@ -437,6 +471,7 @@ class RemoteStorageControllerBase {
437
471
  }
438
472
  let info = await bucket.store.getInfo(filePath);
439
473
  if (!info || !info.size) {
474
+ trackAccess({ account, operation: "httpGet", path: `${bucketName}/${filePath}`, size: 0 });
440
475
  return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
441
476
  }
442
477
  let ext = filePath.split(".").pop() || "";
@@ -477,6 +512,7 @@ class RemoteStorageControllerBase {
477
512
  }
478
513
  }
479
514
  let result = await bucket.store.get2(filePath, { range });
515
+ trackAccess({ account, operation: "httpGet", path: `${bucketName}/${filePath}`, size: result && result.data.length || 0 });
480
516
  if (!result) {
481
517
  return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
482
518
  }
@@ -532,6 +568,8 @@ export const RemoteStorageController = SocketFunction.register(
532
568
  getActiveBucket: { hooks: [accountAccess] },
533
569
  activateBucket: { hooks: [accountAccess] },
534
570
  clearWriteStats: { hooks: [accountAccess] },
571
+ getAccessStats: { hooks: [accountAccess] },
572
+ getAccessSummaries: { hooks: [accountAccess] },
535
573
  getSyncStatus: { hooks: [accountAccess] },
536
574
  startLargeFile: { hooks: [accountAccess] },
537
575
  uploadPart: { hooks: [uploadAccess] },
@@ -28,8 +28,8 @@ export type BucketWriteStats = {
28
28
  flushedWrites: number;
29
29
  flushedBytes: number;
30
30
  };
31
- /** Zeroes the write statistics of every bucket in the account, including counts not yet flushed. */
32
- export declare function clearAccountWriteStats(account: string): Promise<number>;
31
+ /** Zeroes the write statistics of every bucket in the account. */
32
+ export declare function clearAccountWriteStats(account: string): number;
33
33
  export declare function setTrustedMachines(config: {
34
34
  account: string;
35
35
  machineIds: string[];
@@ -98,78 +98,36 @@ export type BucketWriteStats = {
98
98
  function emptyWriteStats(): BucketWriteStats {
99
99
  return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
100
100
  }
101
- function addWriteStats(a: BucketWriteStats, b: BucketWriteStats): BucketWriteStats {
102
- return {
103
- originalWrites: a.originalWrites + b.originalWrites,
104
- originalBytes: a.originalBytes + b.originalBytes,
105
- flushedWrites: a.flushedWrites + b.flushedWrites,
106
- flushedBytes: a.flushedBytes + b.flushedBytes,
107
- };
108
- }
109
- function getWriteStatsStorage(): Promise<IStorage<BucketWriteStats>> {
110
- return getSystemStorage<BucketWriteStats>("writeStats");
111
- }
112
101
 
113
- // Counted in memory as deltas since the last flush, so counting a write never touches the disk. The flush reads the stored totals and adds the delta, which also makes it correct across restarts.
114
- const writeStatDeltas = new Map<string, BucketWriteStats>();
115
-
116
- async function flushWriteStats(): Promise<void> {
117
- if (!writeStatDeltas.size) return;
118
- let pending = [...writeStatDeltas];
119
- writeStatDeltas.clear();
120
- let storage = await getWriteStatsStorage();
121
- for (let [key, delta] of pending) {
122
- try {
123
- await storage.set(key, addWriteStats(await storage.get(key) || emptyWriteStats(), delta));
124
- } catch (e) {
125
- // Put the delta back, so a failed flush loses nothing
126
- writeStatDeltas.set(key, addWriteStats(writeStatDeltas.get(key) || emptyWriteStats(), delta));
127
- console.error(`Flushing write stats for bucket ${key} failed: ${(e as Error).stack ?? e}`);
128
- }
129
- }
130
- }
131
-
132
- const startWriteStatsFlushing = lazy(() => {
133
- runInfinitePoll(WRITE_STATS_FLUSH_INTERVAL, flushWriteStats);
134
- });
102
+ // In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
103
+ const writeStats = new Map<string, BucketWriteStats>();
135
104
 
136
105
  function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
137
- let delta = writeStatDeltas.get(key);
138
- if (!delta) {
139
- delta = emptyWriteStats();
140
- writeStatDeltas.set(key, delta);
106
+ let stats = writeStats.get(key);
107
+ if (!stats) {
108
+ stats = emptyWriteStats();
109
+ writeStats.set(key, stats);
141
110
  }
142
111
  if (kind === "original") {
143
- delta.originalWrites++;
144
- delta.originalBytes += bytes;
112
+ stats.originalWrites++;
113
+ stats.originalBytes += bytes;
145
114
  } else {
146
- delta.flushedWrites++;
147
- delta.flushedBytes += bytes;
115
+ stats.flushedWrites++;
116
+ stats.flushedBytes += bytes;
148
117
  }
149
- startWriteStatsFlushing();
150
118
  }
151
119
 
152
- async function getBucketWriteStats(key: string): Promise<BucketWriteStats> {
153
- let storage = await getWriteStatsStorage();
154
- let stored = await storage.get(key) || emptyWriteStats();
155
- let delta = writeStatDeltas.get(key);
156
- return delta && addWriteStats(stored, delta) || stored;
120
+ function getBucketWriteStats(key: string): BucketWriteStats {
121
+ return writeStats.get(key) || emptyWriteStats();
157
122
  }
158
123
 
159
- /** Zeroes the write statistics of every bucket in the account, including counts not yet flushed. */
160
- export async function clearAccountWriteStats(account: string): Promise<number> {
161
- let storage = await getWriteStatsStorage();
124
+ /** Zeroes the write statistics of every bucket in the account. */
125
+ export function clearAccountWriteStats(account: string): number {
162
126
  let prefix = `${account}/`;
163
127
  let cleared = 0;
164
- for (let key of await storage.getKeys()) {
165
- if (!key.startsWith(prefix)) continue;
166
- writeStatDeltas.delete(key);
167
- await storage.remove(key);
168
- cleared++;
169
- }
170
- for (let key of [...writeStatDeltas.keys()]) {
128
+ for (let key of [...writeStats.keys()]) {
171
129
  if (!key.startsWith(prefix)) continue;
172
- writeStatDeltas.delete(key);
130
+ writeStats.delete(key);
173
131
  cleared++;
174
132
  }
175
133
  console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
@@ -211,7 +169,6 @@ const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
211
169
 
212
170
  const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
213
171
  const REBUILD_BOUNDARY_BUFFER = 1000;
214
- const WRITE_STATS_FLUSH_INTERVAL = 5 * 60 * 1000;
215
172
 
216
173
  function getBucketFolder(account: string, bucketName: string): string {
217
174
  return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
@@ -1070,15 +1027,11 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
1070
1027
  let key = `${account}/${bucketName}`;
1071
1028
  let folder = getBucketFolder(account, bucketName);
1072
1029
  let base: ServerBucketInfo = { bucketName, active: false, folder };
1073
- let [disk, writeStats] = await Promise.all([
1074
- timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
1075
- base.diskError = String(e.stack ?? e).slice(0, 500);
1076
- return undefined;
1077
- }),
1078
- timed("writeStats", () => getBucketWriteStats(key)),
1079
- ]);
1080
- base.disk = disk;
1081
- base.writeStats = writeStats;
1030
+ base.disk = await timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
1031
+ base.diskError = String(e.stack ?? e).slice(0, 500);
1032
+ return undefined;
1033
+ });
1034
+ base.writeStats = getBucketWriteStats(key);
1082
1035
  const loadedPromise = buckets.get(key);
1083
1036
  if (loadedPromise) {
1084
1037
  try {
package/test.ts CHANGED
@@ -57,7 +57,7 @@ function summarize(files: FileInfo[], weightBySize: boolean) {
57
57
  if (weightBySize) return summary.totalSize;
58
58
  return summary.count;
59
59
  },
60
- expectedOutputCount: SUMMARY_COUNT,
60
+ expectedOutputCount: 100,
61
61
  });
62
62
  let addStart = performance.now();
63
63
  for (let file of files) {
@@ -0,0 +1,31 @@
1
+ export type SummaryEntry<S> = {
2
+ path: string;
3
+ kind: "self" | "subtree" | "group" | "truncated";
4
+ summary: S;
5
+ weight: number;
6
+ };
7
+ export declare class TreeSummary<T, S> {
8
+ private config;
9
+ private root;
10
+ private nodeCount;
11
+ private maxTrackedNodes;
12
+ constructor(config: {
13
+ getPath: (value: T) => string;
14
+ createSummary: () => S;
15
+ addToSummary: (value: T, summary: S) => void;
16
+ mergeSummaries: (target: S, source: S) => void;
17
+ getWeight: (summary: S) => number;
18
+ expectedOutputCount?: number;
19
+ });
20
+ add(value: T): void;
21
+ private splitNode;
22
+ getTrackedNodeCount(): number;
23
+ getSummaries(maxCount: number): SummaryEntry<S>[];
24
+ private isRefinable;
25
+ private refine;
26
+ private splitParts;
27
+ private entryForParts;
28
+ private subtreeEntry;
29
+ private buildOutputSummary;
30
+ private prune;
31
+ }