pi-cache-optimizer 2.9.0 → 2.9.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/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/index.ts +24 -8
- package/package.json +1 -1
- package/src/stats-archive.ts +889 -177
- package/src/stats-store.ts +188 -53
package/src/stats-store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { LOG_PREFIX, asRecord, getErrorCode, getNonNegativeNumber, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
5
5
|
import { STATE_DIR } from "./paths.ts";
|
|
@@ -486,10 +486,48 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
|
|
|
486
486
|
return shards;
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
489
|
+
/**
|
|
490
|
+
* Pre-reduced counters from many shards (the stats archive), already filtered
|
|
491
|
+
* to the current global and model epochs. Merging one into an aggregator
|
|
492
|
+
* gives the same result as feeding it the shards it was reduced from, at that
|
|
493
|
+
* position of the shard sequence. Only valid for the all-days (`day === null`)
|
|
494
|
+
* aggregate. Entries are listed in the order their first shard contributed,
|
|
495
|
+
* so key order in the result matches too. Treat as read-only.
|
|
496
|
+
*/
|
|
497
|
+
export type ShardContribution = {
|
|
498
|
+
sessions: ReadonlyArray<{
|
|
499
|
+
sessionHash: string;
|
|
500
|
+
/** Contributing shards of this session (each counts once in instancesBySession). */
|
|
501
|
+
instances: number;
|
|
502
|
+
models: ReadonlyArray<{ key: string; stats: CacheStats; instances: number }>;
|
|
503
|
+
}>;
|
|
504
|
+
models: ReadonlyArray<{
|
|
505
|
+
key: string;
|
|
506
|
+
stats: CacheStats;
|
|
507
|
+
instances: number;
|
|
508
|
+
sessions: readonly string[];
|
|
509
|
+
}>;
|
|
510
|
+
/**
|
|
511
|
+
* Per model, the first shard with the newest lifecycle.updatedAt that
|
|
512
|
+
* carried it, in the order an aggregation would first record each one.
|
|
513
|
+
*/
|
|
514
|
+
refs: ReadonlyArray<{ key: string; updatedAt: number; ref: PersistedRoutedModelRef }>;
|
|
515
|
+
/** Per session, the first entry with the newest updatedAt. */
|
|
516
|
+
routed: ReadonlyArray<{ sessionHash: string; updatedAt: number; model: PersistedRoutedModelRef }>;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
export type ShardAggregator = {
|
|
520
|
+
readonly day: string | null;
|
|
521
|
+
readonly globalEpoch: string;
|
|
522
|
+
/** The model epoch this aggregation filters `key` with (read once per aggregation). */
|
|
523
|
+
modelEpoch(key: string): Promise<string>;
|
|
524
|
+
add(shard: PersistedStatsShardV7): Promise<void>;
|
|
525
|
+
addContribution(contribution: ShardContribution): void;
|
|
526
|
+
finish(): ShardAggregate;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** Incremental form of aggregateStatsShardsV7: add shards (and pre-reduced contributions) in order, then finish. */
|
|
530
|
+
export async function createShardAggregatorV7(day: string | null = currentLocalDay()): Promise<ShardAggregator> {
|
|
493
531
|
const globalEpoch = await readGlobalStatsEpoch();
|
|
494
532
|
const modelEpochs = new Map<string, string>();
|
|
495
533
|
const result: ShardAggregate = {
|
|
@@ -505,46 +543,99 @@ export async function aggregateStatsShardsV7(
|
|
|
505
543
|
const routedUpdatedAt = new Map<string, number>();
|
|
506
544
|
const modelRefUpdatedAt = new Map<string, number>();
|
|
507
545
|
const modelSessions = new Map<string, Set<string>>();
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
514
|
-
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
546
|
+
const modelEpoch = async (key: string): Promise<string> => {
|
|
547
|
+
let epoch = modelEpochs.get(key);
|
|
548
|
+
if (!epoch) {
|
|
549
|
+
epoch = await readModelStatsEpoch(key);
|
|
550
|
+
modelEpochs.set(key, epoch);
|
|
515
551
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
552
|
+
return epoch;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
day,
|
|
557
|
+
globalEpoch,
|
|
558
|
+
modelEpoch,
|
|
559
|
+
async add(shard) {
|
|
560
|
+
if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) return;
|
|
561
|
+
let contributed = false;
|
|
562
|
+
if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
|
|
563
|
+
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
564
|
+
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
521
565
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
566
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
567
|
+
const epoch = modelEpochs.get(key) ?? await modelEpoch(key);
|
|
568
|
+
if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
|
|
569
|
+
contributed = true;
|
|
570
|
+
const sessionModels = result.bySession[shard.sessionHash] ??= {};
|
|
571
|
+
sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
|
|
572
|
+
result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
|
|
573
|
+
result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
|
|
574
|
+
if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
|
|
575
|
+
modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
|
|
576
|
+
result.modelRefsByKey[key] = {
|
|
577
|
+
provider: entry.provider,
|
|
578
|
+
id: entry.modelId,
|
|
579
|
+
name: entry.modelName ?? entry.modelId,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
|
|
583
|
+
sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
|
|
584
|
+
const sessions = modelSessions.get(key) ?? new Set<string>();
|
|
585
|
+
sessions.add(shard.sessionHash);
|
|
586
|
+
modelSessions.set(key, sessions);
|
|
535
587
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
588
|
+
if (contributed) {
|
|
589
|
+
result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
addContribution(contribution) {
|
|
593
|
+
if (day !== null) throw new Error("shard contributions only apply to the all-days aggregate");
|
|
594
|
+
for (const entry of contribution.routed) {
|
|
595
|
+
if ((routedUpdatedAt.get(entry.sessionHash) ?? -1) < entry.updatedAt) {
|
|
596
|
+
routedUpdatedAt.set(entry.sessionHash, entry.updatedAt);
|
|
597
|
+
result.lastRoutedModelBySession[entry.sessionHash] = { ...entry.model };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
for (const model of contribution.models) {
|
|
601
|
+
// mergeStatsAcrossDays clones on first insert, so the shared
|
|
602
|
+
// contribution is never handed out (callers mutate the aggregate).
|
|
603
|
+
result.totalsByModel[model.key] = mergeStatsAcrossDays(result.totalsByModel[model.key], model.stats, null);
|
|
604
|
+
result.instancesByModel[model.key] = (result.instancesByModel[model.key] ?? 0) + model.instances;
|
|
605
|
+
const sessions = modelSessions.get(model.key) ?? new Set<string>();
|
|
606
|
+
for (const sessionHash of model.sessions) sessions.add(sessionHash);
|
|
607
|
+
modelSessions.set(model.key, sessions);
|
|
608
|
+
}
|
|
609
|
+
for (const entry of contribution.refs) {
|
|
610
|
+
if ((modelRefUpdatedAt.get(entry.key) ?? -1) < entry.updatedAt) {
|
|
611
|
+
modelRefUpdatedAt.set(entry.key, entry.updatedAt);
|
|
612
|
+
result.modelRefsByKey[entry.key] = { ...entry.ref };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const session of contribution.sessions) {
|
|
616
|
+
const sessionModels = result.bySession[session.sessionHash] ??= {};
|
|
617
|
+
const sessionInstances = result.instancesBySessionModel[session.sessionHash] ??= {};
|
|
618
|
+
for (const model of session.models) {
|
|
619
|
+
sessionModels[model.key] = mergeStatsAcrossDays(sessionModels[model.key], model.stats, null);
|
|
620
|
+
sessionInstances[model.key] = (sessionInstances[model.key] ?? 0) + model.instances;
|
|
621
|
+
}
|
|
622
|
+
result.instancesBySession[session.sessionHash] = (result.instancesBySession[session.sessionHash] ?? 0) + session.instances;
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
finish() {
|
|
626
|
+
for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
|
|
627
|
+
return result;
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function aggregateStatsShardsV7(
|
|
633
|
+
shards: PersistedStatsShardV7[],
|
|
634
|
+
day: string | null = currentLocalDay(),
|
|
635
|
+
): Promise<ShardAggregate> {
|
|
636
|
+
const aggregator = await createShardAggregatorV7(day);
|
|
637
|
+
for (const shard of shards) await aggregator.add(shard);
|
|
638
|
+
return aggregator.finish();
|
|
548
639
|
}
|
|
549
640
|
|
|
550
641
|
export async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
|
|
@@ -613,6 +704,14 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
|
|
|
613
704
|
|
|
614
705
|
const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
|
|
615
706
|
|
|
707
|
+
/**
|
|
708
|
+
* Tokens whose owner file this process actually wrote. For those, a lock
|
|
709
|
+
* directory without an owner file can only mean the directory was replaced
|
|
710
|
+
* (a 2.8.x stale-lock takeover writes none, and a 2.9 takeover is caught
|
|
711
|
+
* between its mkdir and its owner write), so ownership is lost.
|
|
712
|
+
*/
|
|
713
|
+
const writtenLockTokens = new Set<string>();
|
|
714
|
+
|
|
616
715
|
/**
|
|
617
716
|
* Take the cross-process maintenance lock (a directory, so creation is atomic
|
|
618
717
|
* on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
|
|
@@ -626,7 +725,10 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
626
725
|
const claim = async (): Promise<string> => {
|
|
627
726
|
// Best effort: an older version (or a failed write) leaves no owner file,
|
|
628
727
|
// which only disables the ownership check, never the lock itself.
|
|
629
|
-
|
|
728
|
+
try {
|
|
729
|
+
await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8");
|
|
730
|
+
writtenLockTokens.add(token);
|
|
731
|
+
} catch {}
|
|
630
732
|
return token;
|
|
631
733
|
};
|
|
632
734
|
try {
|
|
@@ -636,7 +738,11 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
636
738
|
if (getErrorCode(error) !== "EEXIST") return undefined;
|
|
637
739
|
try {
|
|
638
740
|
const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
639
|
-
|
|
741
|
+
// A lock dated more than the stale window in the future was left by a
|
|
742
|
+
// crash while the clock ran ahead; without this it would block
|
|
743
|
+
// maintenance until the wall clock caught up (possibly for months).
|
|
744
|
+
const age = now - lock.mtimeMs;
|
|
745
|
+
if (age <= SHARD_CLEANUP_LOCK_STALE_MS && age >= -SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
|
|
640
746
|
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
641
747
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
642
748
|
return await claim();
|
|
@@ -646,19 +752,23 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
646
752
|
}
|
|
647
753
|
}
|
|
648
754
|
|
|
649
|
-
/**
|
|
650
|
-
|
|
755
|
+
/**
|
|
756
|
+
* False when the lock directory records a different owner, is gone, or lost
|
|
757
|
+
* the owner file this holder wrote. An unreadable owner file fails closed
|
|
758
|
+
* (destructive publishes are skipped and retried) unless `failOpen` is set.
|
|
759
|
+
*/
|
|
760
|
+
export async function holdsStatsMaintenanceLock(token: string | undefined, { failOpen = false }: { failOpen?: boolean } = {}): Promise<boolean> {
|
|
651
761
|
if (!token) return true;
|
|
652
762
|
try {
|
|
653
763
|
const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
|
|
654
764
|
return owner === token;
|
|
655
765
|
} catch (error) {
|
|
766
|
+
if (getErrorCode(error) !== "ENOENT") return failOpen;
|
|
656
767
|
// Lock directory gone: someone treated us as stale. Owner file missing
|
|
657
|
-
// inside an existing lock:
|
|
658
|
-
if (getErrorCode(error) !== "ENOENT") return true;
|
|
768
|
+
// inside an existing lock: ours only if we never managed to write one.
|
|
659
769
|
try {
|
|
660
770
|
await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
661
|
-
return
|
|
771
|
+
return !writtenLockTokens.has(token);
|
|
662
772
|
} catch {
|
|
663
773
|
return false;
|
|
664
774
|
}
|
|
@@ -666,16 +776,41 @@ export async function holdsStatsMaintenanceLock(token: string | undefined): Prom
|
|
|
666
776
|
}
|
|
667
777
|
|
|
668
778
|
export async function releaseStatsMaintenanceLock(token?: string): Promise<void> {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
779
|
+
try {
|
|
780
|
+
// Do not delete a lock another process took over after ours went stale.
|
|
781
|
+
if (token && !(await holdsStatsMaintenanceLock(token, { failOpen: true }))) return;
|
|
782
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
783
|
+
} finally {
|
|
784
|
+
if (token) writtenLockTokens.delete(token);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Heartbeat: refresh the lock directory's mtime so a long maintenance pass is
|
|
790
|
+
* not mistaken for a crashed holder and taken over. A no-op (false) unless
|
|
791
|
+
* `token` still owns the lock. It cannot help while the process is suspended
|
|
792
|
+
* (laptop sleep); archive publishing is a compare-and-swap for that case.
|
|
793
|
+
*/
|
|
794
|
+
export async function touchStatsMaintenanceLock(token: string | undefined): Promise<boolean> {
|
|
795
|
+
if (!token) return false;
|
|
796
|
+
try {
|
|
797
|
+
if (!(await holdsStatsMaintenanceLock(token))) return false;
|
|
798
|
+
const now = new Date();
|
|
799
|
+
await utimes(SHARD_CLEANUP_LOCK_PATH, now, now);
|
|
800
|
+
return true;
|
|
801
|
+
} catch {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
672
804
|
}
|
|
673
805
|
|
|
674
806
|
/** True when the marker file is missing or older than `intervalMs`. */
|
|
675
807
|
export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
|
|
676
808
|
try {
|
|
677
809
|
const marker = await stat(path);
|
|
678
|
-
|
|
810
|
+
const age = now - marker.mtimeMs;
|
|
811
|
+
// A marker from the future (the clock was set back) must not suspend
|
|
812
|
+
// maintenance until the wall clock reaches it again.
|
|
813
|
+
return age >= intervalMs || age < -intervalMs;
|
|
679
814
|
} catch {
|
|
680
815
|
return true;
|
|
681
816
|
}
|