@hank-warren/pi-stats 0.4.0 → 0.4.1
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/CHANGELOG.md +10 -0
- package/cache.ts +113 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @hank-warren/pi-stats
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c079c51: Scan sessions through a bounded worker pool instead of one file at a time.
|
|
8
|
+
|
|
9
|
+
The stats scan now runs an ordered pool capped at eight concurrent readers, reduced by the discovery index, which shortens a cold scan over a large session history. Results stay in discovery order regardless of completion order, and progress is reported monotonically.
|
|
10
|
+
|
|
11
|
+
Cancellation is settled rather than abandoned: an abort stops further work being assigned and publishes nothing afterwards, but waits for reads already in flight, so the caller's `catch` never runs while file reads are still landing in the result array or progress is still being reported into a dashboard that is being torn down.
|
|
12
|
+
|
|
3
13
|
## 0.4.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/cache.ts
CHANGED
|
@@ -240,6 +240,64 @@ export function statsCachePath(agentDir: string): string {
|
|
|
240
240
|
return join(agentDir, "pi-stats", "cache.json");
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
export async function mapConcurrentOrdered<T, R>(
|
|
244
|
+
items: readonly T[],
|
|
245
|
+
limit: number,
|
|
246
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
247
|
+
onProgress?: (completed: number, total: number) => void,
|
|
248
|
+
signal?: AbortSignal,
|
|
249
|
+
): Promise<R[]> {
|
|
250
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("concurrency limit must be positive");
|
|
251
|
+
const results = new Array<R>(items.length);
|
|
252
|
+
let nextIndex = 0;
|
|
253
|
+
let completed = 0;
|
|
254
|
+
// The first failure (an abort, a worker throw, or a progress callback that
|
|
255
|
+
// throws) stops work being assigned and stops anything else being published,
|
|
256
|
+
// but never abandons work already in flight: the caller's `catch` must not
|
|
257
|
+
// run while file reads are still landing in `results` and progress is still
|
|
258
|
+
// being reported into a widget that is about to be torn down.
|
|
259
|
+
let failure: unknown;
|
|
260
|
+
const failed = () => failure !== undefined;
|
|
261
|
+
const run = async () => {
|
|
262
|
+
while (!failed()) {
|
|
263
|
+
try {
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
failure ??= error;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const index = nextIndex;
|
|
270
|
+
if (index >= items.length) return;
|
|
271
|
+
nextIndex += 1;
|
|
272
|
+
try {
|
|
273
|
+
const value = await worker(items[index]!, index);
|
|
274
|
+
// Re-check *after* the await: an abort during the read must not still
|
|
275
|
+
// publish a result or a progress tick into a dashboard that is being
|
|
276
|
+
// torn down. A sibling runner may not have noticed the abort yet.
|
|
277
|
+
signal?.throwIfAborted();
|
|
278
|
+
if (failed()) return;
|
|
279
|
+
results[index] = value;
|
|
280
|
+
completed += 1;
|
|
281
|
+
onProgress?.(completed, items.length);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
failure ??= error;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
289
|
+
if (failed()) throw failure;
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type ScannedFile = {
|
|
294
|
+
file: string;
|
|
295
|
+
record?: CachedFileRecord;
|
|
296
|
+
reused: boolean;
|
|
297
|
+
unreadable: boolean;
|
|
298
|
+
changed: boolean;
|
|
299
|
+
};
|
|
300
|
+
|
|
243
301
|
export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
244
302
|
const roots = await resolveSessionRoots(options);
|
|
245
303
|
const sessionFiles = await discoverJsonlFiles(roots, options.signal);
|
|
@@ -260,45 +318,65 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
260
318
|
};
|
|
261
319
|
let changed = options.force === true || !loaded.valid;
|
|
262
320
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
321
|
+
const scanned = await mapConcurrentOrdered(
|
|
322
|
+
files,
|
|
323
|
+
8,
|
|
324
|
+
async (file): Promise<ScannedFile> => {
|
|
325
|
+
options.signal?.throwIfAborted();
|
|
326
|
+
try {
|
|
327
|
+
const metadata = await stat(file);
|
|
328
|
+
const previous = loaded.cache.files[file];
|
|
329
|
+
if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
|
|
330
|
+
return { file, record: previous!, reused: true, unreadable: false, changed: false };
|
|
331
|
+
}
|
|
332
|
+
if (sidecars.has(file)) {
|
|
333
|
+
const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
|
|
334
|
+
return {
|
|
335
|
+
file,
|
|
336
|
+
record: {
|
|
337
|
+
size: metadata.size,
|
|
338
|
+
mtimeMs: metadata.mtimeMs,
|
|
339
|
+
sidecar: parsed.records,
|
|
340
|
+
malformedLines: parsed.malformedLines,
|
|
341
|
+
},
|
|
342
|
+
reused: false,
|
|
343
|
+
unreadable: false,
|
|
344
|
+
changed: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
283
347
|
const parsed = parseSessionText(file, await readFile(file, "utf8"));
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
348
|
+
return {
|
|
349
|
+
file,
|
|
350
|
+
record: {
|
|
351
|
+
size: metadata.size,
|
|
352
|
+
mtimeMs: metadata.mtimeMs,
|
|
353
|
+
...(parsed.ignored ? { ignored: true as const } : {}),
|
|
354
|
+
...(parsed.session ? { session: parsed.session } : {}),
|
|
355
|
+
malformedLines: parsed.malformedLines,
|
|
356
|
+
},
|
|
357
|
+
reused: false,
|
|
358
|
+
unreadable: false,
|
|
359
|
+
changed: true,
|
|
290
360
|
};
|
|
291
|
-
|
|
292
|
-
|
|
361
|
+
} catch {
|
|
362
|
+
options.signal?.throwIfAborted();
|
|
363
|
+
return { file, reused: false, unreadable: true, changed: true };
|
|
293
364
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
365
|
+
},
|
|
366
|
+
options.onProgress,
|
|
367
|
+
options.signal,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (const result of scanned) {
|
|
371
|
+
if (result.record) {
|
|
372
|
+
next.files[result.file] = result.record;
|
|
373
|
+
if (result.reused) diagnostics.reusedFiles++;
|
|
374
|
+
else diagnostics.parsedFiles++;
|
|
375
|
+
if (result.record.ignored) diagnostics.ignoredFiles++;
|
|
376
|
+
diagnostics.malformedLines += result.record.malformedLines;
|
|
300
377
|
}
|
|
301
|
-
|
|
378
|
+
if (result.unreadable) diagnostics.unreadableFiles++;
|
|
379
|
+
if (result.changed) changed = true;
|
|
302
380
|
}
|
|
303
381
|
|
|
304
382
|
if (Object.keys(loaded.cache.files).some((file) => !(file in next.files))) changed = true;
|