pi-cache-optimizer 2.9.2 → 2.9.3
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.ts +19 -2
- package/package.json +1 -1
- package/src/stats-archive.ts +82 -5
- package/src/stats-store.ts +7 -1
package/index.ts
CHANGED
|
@@ -630,6 +630,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
630
630
|
* own shard was absorbed anyway (see adoptAbsorbedOwnShard).
|
|
631
631
|
*/
|
|
632
632
|
let persistedOwnShards: PersistedStatsShardV7[] = [];
|
|
633
|
+
/**
|
|
634
|
+
* Which lifetime of the in-memory counters a built shard snapshots: bumped
|
|
635
|
+
* whenever session_start clears them (see adoptAbsorbedOwnShard).
|
|
636
|
+
*/
|
|
637
|
+
let countersSession = 0;
|
|
638
|
+
const countersSessionOfShard = new WeakMap<PersistedStatsShardV7, number>();
|
|
633
639
|
function recordPersistedOwnShard(shard: PersistedStatsShardV7): void {
|
|
634
640
|
if (shard.instanceId !== instanceId) return;
|
|
635
641
|
const last = persistedOwnShards.at(-1);
|
|
@@ -653,9 +659,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
653
659
|
// Not identifiable (clock skew between the processes): assume the
|
|
654
660
|
// archive holds everything, so nothing is ever counted twice.
|
|
655
661
|
const absorbed = candidates.at(-1) ?? buildCurrentStatsShard();
|
|
662
|
+
// Session guard: subtract only a version built from the counters held
|
|
663
|
+
// now. session_start clears them (switching to another session, or the
|
|
664
|
+
// same one on reload) but keeps the id, so a version persisted before it
|
|
665
|
+
// counts usage these counters no longer hold: subtracting it would hide
|
|
666
|
+
// this session's usage. Its sessionHash alone cannot tell: a reload keeps
|
|
667
|
+
// it, and it changes without a reset when the session id first becomes
|
|
668
|
+
// known after usage (then the counters still hold that usage).
|
|
669
|
+
const sameSession = countersSessionOfShard.get(absorbed) === countersSession;
|
|
656
670
|
const remaining: Record<string, CacheStats> = {};
|
|
657
671
|
for (const [key, stats] of Object.entries(cacheStatsProcessByModel)) {
|
|
658
|
-
const entry = absorbed.globalEpoch === currentGlobalEpoch ? absorbed.models[key] : undefined;
|
|
672
|
+
const entry = sameSession && absorbed.globalEpoch === currentGlobalEpoch ? absorbed.models[key] : undefined;
|
|
659
673
|
const sameEpoch = entry && entry.modelEpoch === (modelEpochByKey.get(key) ?? initialEpoch(`model:${key}`));
|
|
660
674
|
const delta = cloneCacheStats(stats);
|
|
661
675
|
if (sameEpoch) {
|
|
@@ -773,7 +787,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
773
787
|
stats: cloneCacheStats(stats),
|
|
774
788
|
};
|
|
775
789
|
}
|
|
776
|
-
|
|
790
|
+
const shard: PersistedStatsShardV7 = {
|
|
777
791
|
version: 7,
|
|
778
792
|
kind: "pi-cache-optimizer-shard",
|
|
779
793
|
instanceId,
|
|
@@ -790,6 +804,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
790
804
|
models,
|
|
791
805
|
...(lastActualRoutedModel ? { lastRoutedModel: { ...lastActualRoutedModel } } : {}),
|
|
792
806
|
};
|
|
807
|
+
countersSessionOfShard.set(shard, countersSession);
|
|
808
|
+
return shard;
|
|
793
809
|
}
|
|
794
810
|
|
|
795
811
|
function scheduleShardRefresh(ctx?: ExtensionContext, model?: PiModel): void {
|
|
@@ -967,6 +983,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
967
983
|
syncSessionHash(ctx);
|
|
968
984
|
lastStatusText = undefined;
|
|
969
985
|
cacheStatsProcessByModel = {};
|
|
986
|
+
countersSession++;
|
|
970
987
|
cacheStatsLegacyFamily = emptyAllCacheStats();
|
|
971
988
|
modelEpochByKey.clear();
|
|
972
989
|
modelApiByKey.clear();
|
package/package.json
CHANGED
package/src/stats-archive.ts
CHANGED
|
@@ -185,8 +185,18 @@ export type StatsArchiveV2Document = {
|
|
|
185
185
|
|
|
186
186
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
187
187
|
const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
|
|
188
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7):
|
|
190
|
+
* `archive.migrating-<moved-at ms, 13 digits>-<uuid>.json`, or
|
|
191
|
+
* `archive.migrating-<uuid>.json` as 2.9.2 named it. Both match this pattern
|
|
192
|
+
* (2.9.2 reads both).
|
|
193
|
+
*/
|
|
189
194
|
const MIGRATION_PLACEHOLDER_PATTERN = /^archive\.migrating-[0-9a-f-]+\.json$/;
|
|
195
|
+
const MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN = /^archive\.migrating-(\d{13})-[0-9a-f]{8}-[0-9a-f-]+\.json$/;
|
|
196
|
+
|
|
197
|
+
function migrationPlaceholderPath(archivePath: string, movedAt = Date.now()): string {
|
|
198
|
+
return join(dirname(archivePath), `archive.migrating-${String(Math.max(0, Math.floor(movedAt))).padStart(13, "0")}-${randomUUID()}.json`);
|
|
199
|
+
}
|
|
190
200
|
|
|
191
201
|
export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
|
|
192
202
|
return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
|
|
@@ -792,6 +802,14 @@ export type StatsArchiveTestHooks = {
|
|
|
792
802
|
link?: (existingPath: string, newPath: string) => Promise<void>;
|
|
793
803
|
/** Runs right after the no-hard-link fallback created a generation file exclusively, before writing it. */
|
|
794
804
|
afterExclusiveCreate?: (target: string) => Promise<void>;
|
|
805
|
+
/**
|
|
806
|
+
* Runs between the steps of moving 2.9.0's archive.json aside (to land a
|
|
807
|
+
* concurrent 2.9.0 write there): "moved-aside" right after archive.json was
|
|
808
|
+
* renamed to its placeholder, before the tombstone directory is made;
|
|
809
|
+
* "undo-removed-tombstone" when undoing, right after the tombstone was
|
|
810
|
+
* removed, before the placeholder is put back.
|
|
811
|
+
*/
|
|
812
|
+
afterMigrationStep?: (step: "moved-aside" | "undo-removed-tombstone", archivePath: string, placeholder: string) => Promise<void>;
|
|
795
813
|
/** Overrides the maintenance heartbeat interval. */
|
|
796
814
|
heartbeatMs?: number;
|
|
797
815
|
};
|
|
@@ -820,6 +838,33 @@ async function listArchiveDirectory(archivePath: string): Promise<ArchiveListing
|
|
|
820
838
|
return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
|
|
821
839
|
}
|
|
822
840
|
|
|
841
|
+
/**
|
|
842
|
+
* The newest of several migration placeholders, chosen deterministically.
|
|
843
|
+
* Two exist only after a migration kept one for inspection (a 2.9.0 write
|
|
844
|
+
* landed between its move and its mkdir) and a later one was interrupted
|
|
845
|
+
* after its own move: the later one holds what 2.9.0 wrote after the first
|
|
846
|
+
* was moved, so it is the one to read and migrate from (the older one is set
|
|
847
|
+
* aside once a generation exists). Ordered by the time the file was moved
|
|
848
|
+
* aside (in its name), or for 2.9.2's uuid-only names by its mtime (rename
|
|
849
|
+
* keeps it; content 2.9.0 wrote after an earlier move is always newer than
|
|
850
|
+
* that move), then by mtime, then by name. A file that disappeared sorts
|
|
851
|
+
* first; the caller re-lists when it is gone.
|
|
852
|
+
*/
|
|
853
|
+
async function newestMigrationPlaceholder(placeholders: readonly string[]): Promise<string | undefined> {
|
|
854
|
+
if (placeholders.length <= 1) return placeholders[0];
|
|
855
|
+
const keyed = await Promise.all(placeholders.map(async (path) => {
|
|
856
|
+
const info = await lstat(path).catch((error) => {
|
|
857
|
+
if (getErrorCode(error) === "ENOENT") return undefined;
|
|
858
|
+
throw error;
|
|
859
|
+
});
|
|
860
|
+
const mtime = info?.mtimeMs ?? -Infinity;
|
|
861
|
+
const moved = MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN.exec(path.slice(dirname(path).length + 1));
|
|
862
|
+
return { path, time: info ? (moved ? Number(moved[1]) : mtime) : -Infinity, mtime };
|
|
863
|
+
}));
|
|
864
|
+
keyed.sort((a, b) => a.time - b.time || a.mtime - b.mtime || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
865
|
+
return keyed.at(-1)!.path;
|
|
866
|
+
}
|
|
867
|
+
|
|
823
868
|
const SOURCE_ATTEMPTS = 6;
|
|
824
869
|
/** Retries (with these delays) of a transient lstat/read failure before a reader gives up on this refresh. */
|
|
825
870
|
const TRANSIENT_RETRY_DELAYS_MS = [15, 60, 150];
|
|
@@ -883,7 +928,13 @@ function parseArchiveBytes(kind: "legacy" | "generation", generation: number, pa
|
|
|
883
928
|
|
|
884
929
|
class ArchiveRestart extends Error {}
|
|
885
930
|
|
|
886
|
-
|
|
931
|
+
/**
|
|
932
|
+
* A retry delay. Deliberately not unref'd: the read is awaited by a Pi
|
|
933
|
+
* handler (agent_settled, model_select, session_shutdown...), and in print
|
|
934
|
+
* mode an unref'd delay lets the event loop drain mid-handler, so Pi exits
|
|
935
|
+
* without session_shutdown (see renameWithRetry).
|
|
936
|
+
*/
|
|
937
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
887
938
|
|
|
888
939
|
export type ReadStatsArchiveOptions = {
|
|
889
940
|
/** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
|
|
@@ -1005,7 +1056,7 @@ async function locateAndReadArchive(archivePath: string, options: ReadStatsArchi
|
|
|
1005
1056
|
return legacyCorruptView(archivePath, parsed.parsed.sha256);
|
|
1006
1057
|
}
|
|
1007
1058
|
if (info && !info.isDirectory()) return legacyCorruptView(archivePath, undefined);
|
|
1008
|
-
const placeholder = listing.placeholders
|
|
1059
|
+
const placeholder = await newestMigrationPlaceholder(listing.placeholders);
|
|
1009
1060
|
if (placeholder) {
|
|
1010
1061
|
const parsed = await readArchiveFile(placeholder, "legacy", 0, options, restartIfNewer);
|
|
1011
1062
|
if (!parsed) throw new ArchiveRestart();
|
|
@@ -1349,12 +1400,13 @@ async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveVie
|
|
|
1349
1400
|
const expected = base.legacySourceSha256;
|
|
1350
1401
|
let placeholder = source;
|
|
1351
1402
|
if (source === archivePath) {
|
|
1352
|
-
placeholder =
|
|
1403
|
+
placeholder = migrationPlaceholderPath(archivePath);
|
|
1353
1404
|
try {
|
|
1354
1405
|
await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
|
|
1355
1406
|
} catch {
|
|
1356
1407
|
return false;
|
|
1357
1408
|
}
|
|
1409
|
+
await testHooks.afterMigrationStep?.("moved-aside", archivePath, placeholder);
|
|
1358
1410
|
}
|
|
1359
1411
|
try {
|
|
1360
1412
|
await createLegacyTombstone(archivePath);
|
|
@@ -1374,13 +1426,38 @@ async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveVie
|
|
|
1374
1426
|
// 2.9.0 republished between this pass's read and the move: undo, so the
|
|
1375
1427
|
// next pass migrates the newer file.
|
|
1376
1428
|
await rm(archivePath, { recursive: true, force: true }).catch(() => undefined);
|
|
1377
|
-
await
|
|
1429
|
+
await testHooks.afterMigrationStep?.("undo-removed-tombstone", archivePath, placeholder);
|
|
1430
|
+
await restoreLegacyArchive(placeholder, archivePath);
|
|
1378
1431
|
return false;
|
|
1379
1432
|
}
|
|
1380
1433
|
await fsyncDirectory(dirname(archivePath));
|
|
1381
1434
|
return true;
|
|
1382
1435
|
}
|
|
1383
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Undo a move aside: put the placeholder back as archive.json without ever
|
|
1439
|
+
* replacing a file there. Between removing the tombstone and this step a
|
|
1440
|
+
* 2.9.0 process (holding the lock after a stale takeover) may already have
|
|
1441
|
+
* published archive.json again; a rename would silently overwrite it, losing
|
|
1442
|
+
* what it absorbed (its raw shards are already deleted). link() is a
|
|
1443
|
+
* compare-and-swap: EEXIST keeps 2.9.0's newer file as the archive and keeps
|
|
1444
|
+
* the placeholder for inspection (set aside once a generation exists, never
|
|
1445
|
+
* counted, so nothing is counted twice). Without hard links the rename is
|
|
1446
|
+
* the only way back (the window stays open on such filesystems).
|
|
1447
|
+
*/
|
|
1448
|
+
async function restoreLegacyArchive(placeholder: string, archivePath: string): Promise<void> {
|
|
1449
|
+
try {
|
|
1450
|
+
await (testHooks.link ?? link)(placeholder, archivePath);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (getErrorCode(error) === "EEXIST") return;
|
|
1453
|
+
await renameWithRetry(placeholder, archivePath, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
// Both names are the same file now; drop the placeholder. If that fails it
|
|
1457
|
+
// is resolved later (its bytes are the ones the next migration reads).
|
|
1458
|
+
await unlink(placeholder).catch(() => undefined);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1384
1461
|
/** Instance ids of every shard-named file in `directory`, readable or not. */
|
|
1385
1462
|
async function listShardInstanceIds(directory: string): Promise<Set<string>> {
|
|
1386
1463
|
let names: string[];
|
package/src/stats-store.ts
CHANGED
|
@@ -891,7 +891,13 @@ export async function renameWithRetry(from: string, to: string, budgetMs = RENAM
|
|
|
891
891
|
const remaining = deadline - Date.now();
|
|
892
892
|
if (remaining <= 0 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
|
|
893
893
|
const delay = Math.min(20 * 2 ** attempt, RENAME_RETRY_MAX_DELAY_MS, remaining);
|
|
894
|
-
|
|
894
|
+
// Deliberately not unref'd: every caller awaits this rename (a Pi
|
|
895
|
+
// handler, or the restore that session_shutdown awaits), and Pi's
|
|
896
|
+
// print mode exits by letting the event loop drain. An unref'd delay
|
|
897
|
+
// would let the process exit in the middle of an awaited handler,
|
|
898
|
+
// skipping session_shutdown and its final flush. The delay is bounded
|
|
899
|
+
// by budgetMs, and interactive mode exits with process.exit anyway.
|
|
900
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
895
901
|
}
|
|
896
902
|
}
|
|
897
903
|
}
|