pi-cache-optimizer 2.8.27 → 2.9.0
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/index.ts +23 -3
- package/package.json +1 -1
- package/src/stats-archive.ts +560 -0
- package/src/stats-store.ts +117 -14
package/README.md
CHANGED
|
@@ -137,6 +137,8 @@ The fixture verifier reports numeric tool-order changes and confirms cache-marke
|
|
|
137
137
|
|
|
138
138
|
Current versions store stats under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory. Each loaded extension instance owns one active UUID-named daily shard and writes it through temp-file + atomic rename; at local midnight it closes the old shard and starts a new UUID without erasing session history. This prevents parent/child/parallel Pi processes from overwriting one another. Upgrading from the old v6 single-file format deletes the old local stats files and starts footer counters from zero; upstream provider caches are not affected.
|
|
139
139
|
|
|
140
|
+
Since 2.9.0, maintenance (at most every six hours, under the shared maintenance lock, in the background) folds closed past-day shards, and active ones whose process has been gone for over 24 hours, into a single snapshot at `pi-cache-optimizer-stats.d/archive/archive.json`, then deletes the originals. The shard directory therefore only holds roughly today's shards, so startup and refresh cost no longer grows with the 60-day retention window. Archived counters keep their reset epochs, so `reset` hides them immediately, and they expire on the same 60-day window. The archive records which shards it absorbed, so a crash mid-compaction never double counts. Older versions ignore the archive: if you downgrade or run an older version alongside, its `session` footer shows only history that has not been archived yet; current-day `total` and `process` counters are unaffected.
|
|
141
|
+
|
|
140
142
|
Response stats stay with the dispatched catalog/request-local model even if you switch to another model on the same provider while waiting. Legacy response aliases use the request snapshot, not the new selection; the footer continues showing the selected direct model. Old corrupt JSON/schema shards expire by file modification time after two months; young corrupt files, valid current-day shards, live old instances, and symlink targets remain protected.
|
|
141
143
|
|
|
142
144
|
The footer defaults to `session`, which reflects the current Pi conversation rather than another parallel Pi terminal using the same provider/model. Use either the command or environment variable to select the scope:
|
package/README.zh-CN.md
CHANGED
|
@@ -137,6 +137,8 @@ bun .trellis/tasks/09-03-context-epoch-tool-ordering/verify.ts
|
|
|
137
137
|
|
|
138
138
|
当前版本把统计保存在 Pi agent 目录的 `pi-cache-optimizer-stats.d/shards/` 下。每个已加载的 extension instance 独占一个当天的活跃 UUID shard,并通过临时文件 + 原子 rename 写入;本地日期切换时关闭旧 shard、创建新的 UUID,保留 session 历史,因此父/子/并行 Pi 进程不会互相覆盖。旧 v6 单文件统计在升级时直接删除,本地 footer 计数从零开始;不会影响上游 provider 的实际缓存。
|
|
139
139
|
|
|
140
|
+
从 2.9.0 起,维护任务会把已关闭的往日 shard,以及进程已退出超过 24 小时的活跃 shard,合并进 `pi-cache-optimizer-stats.d/archive/archive.json` 这一份快照,再删除原文件。维护最多每 6 小时一次,在共享维护锁下后台运行。这样 shard 目录里基本只剩当天的 shard,启动和刷新的开销不再随 60 天保留期增长。归档计数保留 reset epoch,`reset` 立即生效,并按同样的 60 天窗口过期。归档会记录已吸收的 shard,所以合并中途崩溃也不会重复计数。旧版本不会读取归档:如果降级或与旧版本同时运行,旧版的 `session` footer 只显示尚未归档的历史;当天的 `total` 和 `process` 计数不受影响。
|
|
141
|
+
|
|
140
142
|
即使等待响应时切换到同一 provider 的另一个模型,响应统计仍归属于实际请求的 catalog/request-local 模型。旧 transport 的响应别名只根据请求快照处理,不使用新选择的模型;footer 继续显示当前选择的 direct 模型。损坏 JSON/schema 的旧 shard 按文件修改时间在两个月后清理;较新的损坏文件、有效的当天 shard、仍存活的旧 instance 和 symlink 目标均受保护。
|
|
141
143
|
|
|
142
144
|
Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 provider/model 时污染当前窗口。可以通过命令或环境变量切换显示范围:
|
package/index.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, ato
|
|
|
13
13
|
import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./src/model-detect.ts";
|
|
14
14
|
import { findLastExactModelDefinition, mergeCacheCompat, resolveEffectiveCompatFromConfig } from "./src/compat-config.ts";
|
|
15
15
|
import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion, hasExplicitLongRetentionOptInFromConfig, hasReceiptReasoningProtocolChange, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, parseModelsJsonFixReceipt, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, receiptBackupPath, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./src/models-json-fix.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { SHARD_ARCHIVE_PATH, compactStatsShardsV7, maybeRunStatsMaintenance, readStatsArchive } from "./src/stats-archive.ts";
|
|
17
|
+
import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, acquireStatsMaintenanceLock, holdsStatsMaintenanceLock, releaseStatsMaintenanceLock, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_RETENTION_MS, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, mapWithConcurrency, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
|
|
17
18
|
import { PI_VIRTUAL_MODEL_API, getCompat, isAnthropicMessagesApi, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isOpenAIFamilyToken, isPiBuiltInLlamaCppModel, isResponsesPromptRewriteBypassApi, isRoutedFallbackModel, isValidModelsConfigForEffectiveCompat, modelKey, readEffectiveCompatConfig } from "./src/model-identity.ts";
|
|
18
19
|
import { CONFIG_FILE_PATH, VIRTUAL_REWRITE_ENV, FOOTER_MODE_ENV, TOOL_ORDER_ENV, featureEnabled, footerStatsMode, isEnabledEnv, isToolOrderEnabled, parseFooterStatsMode, parsePersistedCacheOptimizerConfig, readPersistedCacheOptimizerConfig, resolveFooterStatsMode, runtimeOptimizerEnabled, setPersistedCacheOptimizerConfig, shouldInjectOpenAIPromptCacheKey, writePersistedCacheOptimizerConfig, writePersistedFeature, writePersistedFooterMode } from "./src/config.ts";
|
|
19
20
|
import { decideZeroPriceWarming, readCacheWarmingMode } from "./src/cache-warming.ts";
|
|
@@ -323,6 +324,14 @@ export const __internals_for_tests = {
|
|
|
323
324
|
aggregateStatsShardsV7,
|
|
324
325
|
loadStatsShardAggregateV7,
|
|
325
326
|
cleanupStatsShardsV7,
|
|
327
|
+
compactStatsShardsV7,
|
|
328
|
+
maybeRunStatsMaintenance,
|
|
329
|
+
readStatsArchive,
|
|
330
|
+
acquireStatsMaintenanceLock,
|
|
331
|
+
holdsStatsMaintenanceLock,
|
|
332
|
+
releaseStatsMaintenanceLock,
|
|
333
|
+
SHARD_ARCHIVE_PATH,
|
|
334
|
+
SHARD_RETENTION_MS,
|
|
326
335
|
mapWithConcurrency,
|
|
327
336
|
removeLegacyStatsFiles,
|
|
328
337
|
readGlobalStatsEpoch,
|
|
@@ -599,10 +608,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
599
608
|
}
|
|
600
609
|
|
|
601
610
|
async function refreshShardAggregate(): Promise<ShardAggregate> {
|
|
602
|
-
|
|
611
|
+
// Order matters: compaction publishes the archive before deleting the
|
|
612
|
+
// shards it absorbed, so reading the directory first and the archive
|
|
613
|
+
// second always sees each absorbed shard in at least one of them. Reading
|
|
614
|
+
// them concurrently could pair an old archive with a post-delete listing
|
|
615
|
+
// and drop those counters (and the last routed model) for this refresh.
|
|
616
|
+
const rawShards = await readValidStatsShardsV7();
|
|
617
|
+
const archive = await readStatsArchive();
|
|
618
|
+
// Shards folded into the archive are counted there. Ignore their raw file
|
|
619
|
+
// (a crash may leave it behind) and this process's in-memory retired copy.
|
|
620
|
+
for (const id of archive.absorbedIds) retiredShards.delete(id);
|
|
621
|
+
const persistedShards = rawShards.filter((shard) => !archive.absorbedIds.has(shard.instanceId));
|
|
603
622
|
for (const shard of persistedShards) retiredShards.delete(shard.instanceId);
|
|
604
623
|
const shards = persistedShards.filter((shard) => shard.instanceId !== instanceId);
|
|
605
624
|
shards.push(...retiredShards.values());
|
|
625
|
+
shards.push(...archive.shards);
|
|
606
626
|
if (currentSessionHashSet) shards.push(buildCurrentStatsShard());
|
|
607
627
|
// Keep lifetime session footer data separate from daily totals/commands.
|
|
608
628
|
// null explicitly means all retained days; undefined invokes the default day.
|
|
@@ -885,7 +905,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
885
905
|
// no second directory pass is needed. Cleanup also warms the parsed-shard
|
|
886
906
|
// cache, so the scan below only re-stats files.
|
|
887
907
|
try {
|
|
888
|
-
await
|
|
908
|
+
await maybeRunStatsMaintenance();
|
|
889
909
|
} catch (error) {
|
|
890
910
|
console.warn(`${LOG_PREFIX}: stats shard maintenance failed`, error);
|
|
891
911
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive snapshot for closed past-day stats shards (issue #28, phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Every Pi process writes one shard per local day. Shards are kept for
|
|
5
|
+
* SHARD_RETENTION_MS so session-scoped footer totals survive long
|
|
6
|
+
* conversations, which left ~1,500 files to stat and parse on every refresh.
|
|
7
|
+
*
|
|
8
|
+
* Past-day shards that are closed (or abandoned by a dead process) never
|
|
9
|
+
* change again, so maintenance folds them into one archive file and deletes
|
|
10
|
+
* the originals. The directory then only holds today's shards.
|
|
11
|
+
*
|
|
12
|
+
* Correctness rules:
|
|
13
|
+
* - Buckets keep the global and model epochs they were written under. Readers
|
|
14
|
+
* filter them with the same epoch checks as raw shards, so a reset hides
|
|
15
|
+
* archived counters at once, before the next compaction drops them.
|
|
16
|
+
* - The archive records every instanceId it absorbed. Readers ignore raw
|
|
17
|
+
* shards (and in-memory retired shards) with an absorbed id, so a crash
|
|
18
|
+
* between publishing the archive and deleting the originals, or two
|
|
19
|
+
* processes compacting at once, never double counts.
|
|
20
|
+
* - Archived data only feeds the all-days (session-scoped) aggregate. Its
|
|
21
|
+
* buckets keep their original day, so the current-day aggregate filters
|
|
22
|
+
* them out exactly as it filters the raw past-day shards they replace.
|
|
23
|
+
* Per-instance counters in the all-days aggregate count archive buckets,
|
|
24
|
+
* not processes; only `bySession` and `lastRoutedModelBySession` are read
|
|
25
|
+
* from it.
|
|
26
|
+
*/
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import { lstat, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
31
|
+
import {
|
|
32
|
+
type CacheStats,
|
|
33
|
+
type PersistedRoutedModelRef,
|
|
34
|
+
type PersistedStatsShardV7,
|
|
35
|
+
SHARD_CLEANUP_MARKER_PATH,
|
|
36
|
+
SHARD_FILES_DIR,
|
|
37
|
+
SHARD_IO_CONCURRENCY,
|
|
38
|
+
SHARD_MAINTENANCE_DIR,
|
|
39
|
+
SHARD_RETENTION_MS,
|
|
40
|
+
SHARD_STATE_DIR,
|
|
41
|
+
SHARD_TEMP_RETENTION_MS,
|
|
42
|
+
acquireStatsMaintenanceLock,
|
|
43
|
+
cleanupStatsShardsV7,
|
|
44
|
+
cloneCacheStats,
|
|
45
|
+
currentLocalDay,
|
|
46
|
+
isMaintenanceMarkerDue,
|
|
47
|
+
mapWithConcurrency,
|
|
48
|
+
mergeStatsAcrossDays,
|
|
49
|
+
parseCacheStats,
|
|
50
|
+
parsePersistedRoutedModelRef,
|
|
51
|
+
holdsStatsMaintenanceLock,
|
|
52
|
+
readGlobalStatsEpochStrict,
|
|
53
|
+
readModelStatsEpochStrict,
|
|
54
|
+
readValidStatsShardsV7,
|
|
55
|
+
releaseStatsMaintenanceLock,
|
|
56
|
+
renameWithRetry,
|
|
57
|
+
writeMaintenanceMarker,
|
|
58
|
+
} from "./stats-store.ts";
|
|
59
|
+
|
|
60
|
+
export const SHARD_ARCHIVE_DIR = join(SHARD_STATE_DIR, "archive");
|
|
61
|
+
export const SHARD_ARCHIVE_PATH = join(SHARD_ARCHIVE_DIR, "archive.json");
|
|
62
|
+
export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-compaction");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An active shard is only treated as abandoned once its process is gone and
|
|
66
|
+
* it has not been touched for this long, so a recycled Windows PID or a
|
|
67
|
+
* transient process-check failure cannot fold a live shard away.
|
|
68
|
+
*/
|
|
69
|
+
export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
export type StatsArchiveBucketV1 = {
|
|
72
|
+
globalEpoch: string;
|
|
73
|
+
sessionHash: string;
|
|
74
|
+
modelKey: string;
|
|
75
|
+
modelEpoch: string;
|
|
76
|
+
/** Day of the shards folded into this bucket. */
|
|
77
|
+
day: string;
|
|
78
|
+
provider: string;
|
|
79
|
+
modelId: string;
|
|
80
|
+
modelName?: string;
|
|
81
|
+
api?: string;
|
|
82
|
+
/** Latest lifecycle.updatedAt among the folded shards. */
|
|
83
|
+
updatedAt: number;
|
|
84
|
+
stats: CacheStats;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type StatsArchiveRoutedV1 = {
|
|
88
|
+
globalEpoch: string;
|
|
89
|
+
sessionHash: string;
|
|
90
|
+
day: string;
|
|
91
|
+
updatedAt: number;
|
|
92
|
+
model: PersistedRoutedModelRef;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type StatsArchiveV1 = {
|
|
96
|
+
version: 1;
|
|
97
|
+
kind: "pi-cache-optimizer-archive";
|
|
98
|
+
updatedAt: number;
|
|
99
|
+
/** instanceId -> time it was folded in. */
|
|
100
|
+
absorbed: Record<string, number>;
|
|
101
|
+
buckets: StatsArchiveBucketV1[];
|
|
102
|
+
routed: StatsArchiveRoutedV1[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
106
|
+
|
|
107
|
+
export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
|
|
108
|
+
return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
112
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Strict parse: returns undefined when the document is not a v1 archive. Invalid entries are dropped. */
|
|
116
|
+
export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
|
|
117
|
+
const record = asRecord(value);
|
|
118
|
+
if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-archive") return undefined;
|
|
119
|
+
const rawAbsorbed = asRecord(record.absorbed);
|
|
120
|
+
if (!rawAbsorbed || !Array.isArray(record.buckets) || !Array.isArray(record.routed)) return undefined;
|
|
121
|
+
|
|
122
|
+
const absorbed: Record<string, number> = {};
|
|
123
|
+
for (const [id, at] of Object.entries(rawAbsorbed)) {
|
|
124
|
+
if (isNonEmptyString(id) && isFiniteNumber(at)) absorbed[id] = at;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const buckets: StatsArchiveBucketV1[] = [];
|
|
128
|
+
for (const raw of record.buckets) {
|
|
129
|
+
const entry = asRecord(raw);
|
|
130
|
+
const stats = parseCacheStats(entry?.stats);
|
|
131
|
+
if (
|
|
132
|
+
!entry || !stats ||
|
|
133
|
+
!isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
|
|
134
|
+
!isNonEmptyString(entry.modelKey) || !isNonEmptyString(entry.modelEpoch) ||
|
|
135
|
+
!isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
|
|
136
|
+
typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
|
|
137
|
+
!isFiniteNumber(entry.updatedAt) ||
|
|
138
|
+
entry.modelKey !== `${entry.provider}/${entry.modelId}`
|
|
139
|
+
) continue;
|
|
140
|
+
buckets.push({
|
|
141
|
+
globalEpoch: entry.globalEpoch,
|
|
142
|
+
sessionHash: entry.sessionHash,
|
|
143
|
+
modelKey: entry.modelKey,
|
|
144
|
+
modelEpoch: entry.modelEpoch,
|
|
145
|
+
day: entry.day,
|
|
146
|
+
provider: entry.provider,
|
|
147
|
+
modelId: entry.modelId,
|
|
148
|
+
...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName } : {}),
|
|
149
|
+
...(isNonEmptyString(entry.api) ? { api: entry.api } : {}),
|
|
150
|
+
updatedAt: entry.updatedAt,
|
|
151
|
+
stats,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const routed: StatsArchiveRoutedV1[] = [];
|
|
156
|
+
for (const raw of record.routed) {
|
|
157
|
+
const entry = asRecord(raw);
|
|
158
|
+
const model = parsePersistedRoutedModelRef(entry?.model);
|
|
159
|
+
if (
|
|
160
|
+
!entry || !model ||
|
|
161
|
+
!isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
|
|
162
|
+
typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
|
|
163
|
+
!isFiniteNumber(entry.updatedAt)
|
|
164
|
+
) continue;
|
|
165
|
+
routed.push({ globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, day: entry.day, updatedAt: entry.updatedAt, model });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
version: 1,
|
|
170
|
+
kind: "pi-cache-optimizer-archive",
|
|
171
|
+
updatedAt: isFiniteNumber(record.updatedAt) ? record.updatedAt : 0,
|
|
172
|
+
absorbed,
|
|
173
|
+
buckets,
|
|
174
|
+
routed,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type LoadedStatsArchive = {
|
|
179
|
+
/** False when no archive file exists; true for a parsed archive. */
|
|
180
|
+
present: boolean;
|
|
181
|
+
/** True when a file exists but cannot be parsed; compaction then refuses to overwrite it. */
|
|
182
|
+
corrupt: boolean;
|
|
183
|
+
archive: StatsArchiveV1;
|
|
184
|
+
absorbedIds: ReadonlySet<string>;
|
|
185
|
+
/** Archive buckets/routed entries as synthetic shards for aggregateStatsShardsV7. Do not mutate. */
|
|
186
|
+
shards: readonly PersistedStatsShardV7[];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type CachedArchive = { ino: number | bigint; size: number; mtimeMs: number; loaded: LoadedStatsArchive };
|
|
190
|
+
const archiveCache = new Map<string, CachedArchive>();
|
|
191
|
+
|
|
192
|
+
export function clearStatsArchiveCache(): void {
|
|
193
|
+
archiveCache.clear();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Convert archive entries into synthetic closed shards so the existing aggregation applies unchanged. */
|
|
197
|
+
export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStatsShardV7[] {
|
|
198
|
+
const shards: PersistedStatsShardV7[] = [];
|
|
199
|
+
const base = (id: string, sessionHash: string, day: string, globalEpoch: string, updatedAt: number): PersistedStatsShardV7 => ({
|
|
200
|
+
version: 7,
|
|
201
|
+
kind: "pi-cache-optimizer-shard",
|
|
202
|
+
instanceId: id,
|
|
203
|
+
sessionHash,
|
|
204
|
+
process: { pid: 0, ppid: 0, instanceStartedAt: 0 },
|
|
205
|
+
lifecycle: { state: "closed", createdAt: updatedAt, updatedAt, closedAt: updatedAt },
|
|
206
|
+
day,
|
|
207
|
+
globalEpoch,
|
|
208
|
+
models: {},
|
|
209
|
+
});
|
|
210
|
+
archive.buckets.forEach((bucket, index) => {
|
|
211
|
+
const shard = base(`archive-bucket-${index}`, bucket.sessionHash, bucket.day, bucket.globalEpoch, bucket.updatedAt);
|
|
212
|
+
shard.models[bucket.modelKey] = {
|
|
213
|
+
modelEpoch: bucket.modelEpoch,
|
|
214
|
+
provider: bucket.provider,
|
|
215
|
+
modelId: bucket.modelId,
|
|
216
|
+
...(bucket.modelName ? { modelName: bucket.modelName } : {}),
|
|
217
|
+
...(bucket.api ? { api: bucket.api } : {}),
|
|
218
|
+
stats: cloneCacheStats(bucket.stats),
|
|
219
|
+
};
|
|
220
|
+
shards.push(shard);
|
|
221
|
+
});
|
|
222
|
+
archive.routed.forEach((entry, index) => {
|
|
223
|
+
const shard = base(`archive-routed-${index}`, entry.sessionHash, entry.day, entry.globalEpoch, entry.updatedAt);
|
|
224
|
+
shard.lastRoutedModel = { ...entry.model };
|
|
225
|
+
shards.push(shard);
|
|
226
|
+
});
|
|
227
|
+
return shards;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildLoaded(archive: StatsArchiveV1, present: boolean, corrupt: boolean): LoadedStatsArchive {
|
|
231
|
+
return {
|
|
232
|
+
present,
|
|
233
|
+
corrupt,
|
|
234
|
+
archive,
|
|
235
|
+
absorbedIds: new Set(Object.keys(archive.absorbed)),
|
|
236
|
+
shards: archiveToSyntheticShards(archive),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the archive, re-parsing only when inode/size/mtime changed. A missing
|
|
242
|
+
* file is an empty archive; an invalid one is reported as corrupt and treated
|
|
243
|
+
* as empty, so raw shards stay the source of truth. A transient I/O failure
|
|
244
|
+
* (Windows EPERM/EBUSY while another process or a scanner holds the file) is
|
|
245
|
+
* never cached: the last good parse is returned when there is one, otherwise
|
|
246
|
+
* an uncached corrupt result, so the next refresh retries.
|
|
247
|
+
*/
|
|
248
|
+
export async function readStatsArchive(path: string = SHARD_ARCHIVE_PATH): Promise<LoadedStatsArchive> {
|
|
249
|
+
const transientFailure = (): LoadedStatsArchive => {
|
|
250
|
+
const previous = archiveCache.get(path);
|
|
251
|
+
return previous && !previous.loaded.corrupt ? previous.loaded : buildLoaded(emptyStatsArchive(0), true, true);
|
|
252
|
+
};
|
|
253
|
+
let info: import("node:fs").Stats;
|
|
254
|
+
try {
|
|
255
|
+
info = await lstat(path);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (getErrorCode(error) === "ENOENT") {
|
|
258
|
+
archiveCache.delete(path);
|
|
259
|
+
return buildLoaded(emptyStatsArchive(0), false, false);
|
|
260
|
+
}
|
|
261
|
+
return transientFailure();
|
|
262
|
+
}
|
|
263
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
264
|
+
archiveCache.delete(path);
|
|
265
|
+
return buildLoaded(emptyStatsArchive(0), true, true);
|
|
266
|
+
}
|
|
267
|
+
const cached = archiveCache.get(path);
|
|
268
|
+
if (cached && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) return cached.loaded;
|
|
269
|
+
let text: string;
|
|
270
|
+
try {
|
|
271
|
+
text = await readFile(path, "utf8");
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// A file renamed away between lstat and read is simply gone.
|
|
274
|
+
if (getErrorCode(error) === "ENOENT") {
|
|
275
|
+
archiveCache.delete(path);
|
|
276
|
+
return buildLoaded(emptyStatsArchive(0), false, false);
|
|
277
|
+
}
|
|
278
|
+
return transientFailure();
|
|
279
|
+
}
|
|
280
|
+
let loaded: LoadedStatsArchive;
|
|
281
|
+
try {
|
|
282
|
+
const parsed = parseStatsArchive(JSON.parse(text));
|
|
283
|
+
loaded = parsed ? buildLoaded(parsed, true, false) : buildLoaded(emptyStatsArchive(0), true, true);
|
|
284
|
+
} catch {
|
|
285
|
+
loaded = buildLoaded(emptyStatsArchive(0), true, true);
|
|
286
|
+
}
|
|
287
|
+
archiveCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, loaded });
|
|
288
|
+
return loaded;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type ArchiveFileIdentity = { ino: number | bigint; size: number; mtimeMs: number } | undefined;
|
|
292
|
+
|
|
293
|
+
async function archiveFileIdentity(path: string): Promise<ArchiveFileIdentity> {
|
|
294
|
+
try {
|
|
295
|
+
const info = await lstat(path);
|
|
296
|
+
return { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (getErrorCode(error) === "ENOENT") return undefined;
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sameArchiveFile(a: ArchiveFileIdentity, b: ArchiveFileIdentity): boolean {
|
|
304
|
+
if (!a || !b) return a === b;
|
|
305
|
+
return a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Remove archive temp files a crash left behind (shard cleanup only covers shards/). */
|
|
309
|
+
async function cleanupStaleArchiveTemps(archivePath: string, now: number): Promise<void> {
|
|
310
|
+
const directory = dirname(archivePath);
|
|
311
|
+
let names: string[];
|
|
312
|
+
try {
|
|
313
|
+
names = await readdir(directory);
|
|
314
|
+
} catch {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
for (const name of names) {
|
|
318
|
+
if (!name.endsWith(".tmp")) continue;
|
|
319
|
+
const path = join(directory, name);
|
|
320
|
+
try {
|
|
321
|
+
const info = await lstat(path);
|
|
322
|
+
if (info.isFile() && !info.isSymbolicLink() && now - info.mtimeMs >= SHARD_TEMP_RETENTION_MS) await unlink(path);
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function writeStatsArchive(path: string, archive: StatsArchiveV1): Promise<void> {
|
|
328
|
+
await mkdir(dirname(path), { recursive: true });
|
|
329
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
330
|
+
try {
|
|
331
|
+
await writeFile(tempPath, JSON.stringify(archive) + "\n", "utf8");
|
|
332
|
+
await renameWithRetry(tempPath, path);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await unlink(tempPath).catch(() => undefined);
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
archiveCache.delete(path);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A shard maintenance may fold away: from a past day and no longer written to. */
|
|
341
|
+
export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
|
|
342
|
+
if (shard.day >= today) return false;
|
|
343
|
+
if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
|
|
344
|
+
if (shard.lifecycle.state === "closed") return true;
|
|
345
|
+
return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type CompactionResult = {
|
|
349
|
+
/** Raw shards newly folded into the archive. */
|
|
350
|
+
absorbed: number;
|
|
351
|
+
/** Raw shard files deleted (newly or previously absorbed). */
|
|
352
|
+
removed: number;
|
|
353
|
+
/** Archive entries dropped for an old epoch or past retention. */
|
|
354
|
+
dropped: number;
|
|
355
|
+
/** True when an unreadable archive blocked compaction. */
|
|
356
|
+
skippedCorrupt: boolean;
|
|
357
|
+
/**
|
|
358
|
+
* True when the archive, an epoch, or lock ownership changed while this
|
|
359
|
+
* pass ran (e.g. a stale-lock takeover or a concurrent reset). Nothing was
|
|
360
|
+
* published or deleted; the next pass retries.
|
|
361
|
+
*/
|
|
362
|
+
skippedConcurrent: boolean;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export type CompactionOptions = {
|
|
366
|
+
/** Owner token from acquireStatsMaintenanceLock; ownership is re-checked before publishing. */
|
|
367
|
+
lockToken?: string;
|
|
368
|
+
/** Test hook: runs after the plan is built, right before the publish checks. */
|
|
369
|
+
beforePublish?: () => Promise<void>;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const bucketId = (b: Pick<StatsArchiveBucketV1, "globalEpoch" | "sessionHash" | "modelKey" | "modelEpoch" | "day">): string =>
|
|
373
|
+
JSON.stringify([b.globalEpoch, b.sessionHash, b.modelKey, b.modelEpoch, b.day]);
|
|
374
|
+
const routedId = (r: Pick<StatsArchiveRoutedV1, "globalEpoch" | "sessionHash">): string =>
|
|
375
|
+
JSON.stringify([r.globalEpoch, r.sessionHash]);
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fold archivable shards into the archive, then delete them. Call only while
|
|
379
|
+
* holding the maintenance lock. Order matters for crash safety: the archive
|
|
380
|
+
* (with the absorbed ids) is published before any raw shard is deleted.
|
|
381
|
+
*/
|
|
382
|
+
export async function compactStatsShardsV7(
|
|
383
|
+
now = Date.now(),
|
|
384
|
+
directory: string = SHARD_FILES_DIR,
|
|
385
|
+
archivePath: string = SHARD_ARCHIVE_PATH,
|
|
386
|
+
options: CompactionOptions = {},
|
|
387
|
+
): Promise<CompactionResult> {
|
|
388
|
+
const result: CompactionResult = { absorbed: 0, removed: 0, dropped: 0, skippedCorrupt: false, skippedConcurrent: false };
|
|
389
|
+
await cleanupStaleArchiveTemps(archivePath, now);
|
|
390
|
+
clearStatsArchiveCache();
|
|
391
|
+
const identityAtRead = await archiveFileIdentity(archivePath);
|
|
392
|
+
const loaded = await readStatsArchive(archivePath);
|
|
393
|
+
if (loaded.corrupt) {
|
|
394
|
+
result.skippedCorrupt = true;
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
const previous = loaded.archive;
|
|
398
|
+
const today = currentLocalDay();
|
|
399
|
+
// Strict reads: compaction permanently drops counters whose epoch differs,
|
|
400
|
+
// so an unreadable epoch file must abort instead of falling back to the
|
|
401
|
+
// initial epoch (which would discard every archived and absorbed counter).
|
|
402
|
+
const globalEpoch = await readGlobalStatsEpochStrict();
|
|
403
|
+
const modelEpochs = new Map<string, string>();
|
|
404
|
+
const currentModelEpoch = async (key: string): Promise<string> => {
|
|
405
|
+
let epoch = modelEpochs.get(key);
|
|
406
|
+
if (!epoch) {
|
|
407
|
+
epoch = await readModelStatsEpochStrict(key);
|
|
408
|
+
modelEpochs.set(key, epoch);
|
|
409
|
+
}
|
|
410
|
+
return epoch;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Carry forward live entries; drop ones a reset or retention invalidated.
|
|
414
|
+
const buckets = new Map<string, StatsArchiveBucketV1>();
|
|
415
|
+
for (const bucket of previous.buckets) {
|
|
416
|
+
if (
|
|
417
|
+
bucket.globalEpoch !== globalEpoch ||
|
|
418
|
+
bucket.modelEpoch !== await currentModelEpoch(bucket.modelKey) ||
|
|
419
|
+
now - bucket.updatedAt >= SHARD_RETENTION_MS
|
|
420
|
+
) {
|
|
421
|
+
result.dropped++;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
buckets.set(bucketId(bucket), { ...bucket, stats: cloneCacheStats(bucket.stats) });
|
|
425
|
+
}
|
|
426
|
+
const routed = new Map<string, StatsArchiveRoutedV1>();
|
|
427
|
+
for (const entry of previous.routed) {
|
|
428
|
+
if (entry.globalEpoch !== globalEpoch || now - entry.updatedAt >= SHARD_RETENTION_MS) {
|
|
429
|
+
result.dropped++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
routed.set(routedId(entry), { ...entry, model: { ...entry.model } });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const shards = await readValidStatsShardsV7(directory);
|
|
436
|
+
const presentIds = new Set(shards.map((shard) => shard.instanceId));
|
|
437
|
+
const absorbed: Record<string, number> = {};
|
|
438
|
+
// Keep ids whose raw file still exists (a crash or a failed delete left it).
|
|
439
|
+
for (const [id, at] of Object.entries(previous.absorbed)) if (presentIds.has(id)) absorbed[id] = at;
|
|
440
|
+
|
|
441
|
+
for (const shard of shards) {
|
|
442
|
+
if (shard.instanceId in absorbed || !isArchivableShard(shard, now, today)) continue;
|
|
443
|
+
absorbed[shard.instanceId] = now;
|
|
444
|
+
result.absorbed++;
|
|
445
|
+
if (shard.globalEpoch !== globalEpoch) continue; // reset away: nothing to keep
|
|
446
|
+
if (shard.lastRoutedModel) {
|
|
447
|
+
const id = routedId({ globalEpoch, sessionHash: shard.sessionHash });
|
|
448
|
+
const existing = routed.get(id);
|
|
449
|
+
if (!existing || existing.updatedAt < shard.lifecycle.updatedAt) {
|
|
450
|
+
routed.set(id, { globalEpoch, sessionHash: shard.sessionHash, day: shard.day, updatedAt: shard.lifecycle.updatedAt, model: { ...shard.lastRoutedModel } });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
454
|
+
if (entry.modelEpoch !== await currentModelEpoch(key)) continue;
|
|
455
|
+
const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day });
|
|
456
|
+
const existing = buckets.get(id);
|
|
457
|
+
const newer = !existing || existing.updatedAt <= shard.lifecycle.updatedAt;
|
|
458
|
+
buckets.set(id, {
|
|
459
|
+
globalEpoch,
|
|
460
|
+
sessionHash: shard.sessionHash,
|
|
461
|
+
modelKey: key,
|
|
462
|
+
modelEpoch: entry.modelEpoch,
|
|
463
|
+
day: shard.day,
|
|
464
|
+
provider: newer ? entry.provider : existing.provider,
|
|
465
|
+
modelId: newer ? entry.modelId : existing.modelId,
|
|
466
|
+
...((newer ? entry.modelName : existing.modelName) ? { modelName: newer ? entry.modelName : existing.modelName } : {}),
|
|
467
|
+
...((newer ? entry.api : existing.api) ? { api: newer ? entry.api : existing.api } : {}),
|
|
468
|
+
updatedAt: Math.max(existing?.updatedAt ?? 0, shard.lifecycle.updatedAt),
|
|
469
|
+
stats: mergeStatsAcrossDays(existing?.stats, entry.stats, null),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const absorbedChanged =
|
|
475
|
+
Object.keys(absorbed).length !== Object.keys(previous.absorbed).length ||
|
|
476
|
+
Object.keys(absorbed).some((id) => !(id in previous.absorbed));
|
|
477
|
+
if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged) {
|
|
478
|
+
await options.beforePublish?.();
|
|
479
|
+
// Publish only if nothing this plan depends on moved underneath it: a
|
|
480
|
+
// reset during the pass (counters written under the new epoch would be
|
|
481
|
+
// dropped as stale), or another compactor after a stale-lock takeover
|
|
482
|
+
// (overwriting its archive would lose shards it already deleted).
|
|
483
|
+
let concurrent =
|
|
484
|
+
!(await holdsStatsMaintenanceLock(options.lockToken)) ||
|
|
485
|
+
!sameArchiveFile(identityAtRead, await archiveFileIdentity(archivePath)) ||
|
|
486
|
+
(await readGlobalStatsEpochStrict()) !== globalEpoch;
|
|
487
|
+
for (const [key, epoch] of modelEpochs) {
|
|
488
|
+
if (concurrent) break;
|
|
489
|
+
if ((await readModelStatsEpochStrict(key)) !== epoch) concurrent = true;
|
|
490
|
+
}
|
|
491
|
+
if (concurrent) {
|
|
492
|
+
result.skippedConcurrent = true;
|
|
493
|
+
result.absorbed = 0;
|
|
494
|
+
result.dropped = 0;
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
await writeStatsArchive(archivePath, {
|
|
498
|
+
version: 1,
|
|
499
|
+
kind: "pi-cache-optimizer-archive",
|
|
500
|
+
updatedAt: now,
|
|
501
|
+
absorbed,
|
|
502
|
+
buckets: Array.from(buckets.values()),
|
|
503
|
+
routed: Array.from(routed.values()),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Only now that the archive covering them is durable, delete the originals.
|
|
508
|
+
const toDelete = Object.keys(absorbed);
|
|
509
|
+
const outcomes = await mapWithConcurrency(toDelete, SHARD_IO_CONCURRENCY, async (id): Promise<number> => {
|
|
510
|
+
try {
|
|
511
|
+
await unlink(join(directory, `${id}.json`));
|
|
512
|
+
return 1;
|
|
513
|
+
} catch {
|
|
514
|
+
// ENOENT: already gone. Anything else: the id stays absorbed (so the
|
|
515
|
+
// file is ignored by readers) and the next compaction retries.
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
result.removed = outcomes.reduce((sum, value) => sum + value, 0);
|
|
520
|
+
return result;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export type StatsMaintenanceResult = { cleanupRemoved: number; compaction?: CompactionResult };
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Due retention cleanup and archive compaction under one lock. Each has its
|
|
527
|
+
* own marker so the first run after upgrading compacts immediately even when
|
|
528
|
+
* cleanup ran recently.
|
|
529
|
+
*/
|
|
530
|
+
export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsMaintenanceResult> {
|
|
531
|
+
const result: StatsMaintenanceResult = { cleanupRemoved: 0 };
|
|
532
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
533
|
+
const cleanupDue = await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now);
|
|
534
|
+
const compactionDue = await isMaintenanceMarkerDue(SHARD_COMPACTION_MARKER_PATH, now);
|
|
535
|
+
if (!cleanupDue && !compactionDue) return result;
|
|
536
|
+
const lockToken = await acquireStatsMaintenanceLock(now);
|
|
537
|
+
if (!lockToken) return result;
|
|
538
|
+
try {
|
|
539
|
+
if (cleanupDue) {
|
|
540
|
+
result.cleanupRemoved = await cleanupStatsShardsV7(now);
|
|
541
|
+
await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
|
|
542
|
+
}
|
|
543
|
+
if (compactionDue) {
|
|
544
|
+
try {
|
|
545
|
+
result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
|
|
546
|
+
if (result.compaction.skippedCorrupt) {
|
|
547
|
+
console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is unreadable; keeping raw shards`);
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
|
|
551
|
+
}
|
|
552
|
+
// A pass that lost a race published nothing; let the next startup retry
|
|
553
|
+
// instead of waiting out the full interval.
|
|
554
|
+
if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
|
|
555
|
+
}
|
|
556
|
+
} finally {
|
|
557
|
+
await releaseStatsMaintenanceLock(lockToken);
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
}
|
package/src/stats-store.ts
CHANGED
|
@@ -234,6 +234,25 @@ export async function readEpochFile(path: string, fallback: string): Promise<str
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Like readEpochFile, but only a missing file maps to the fallback. Any other
|
|
239
|
+
* read or parse failure throws, so destructive maintenance (archive
|
|
240
|
+
* compaction drops counters whose epoch differs) never mistakes a transient
|
|
241
|
+
* Windows EPERM/EBUSY or a damaged epoch file for "no reset ever happened".
|
|
242
|
+
*/
|
|
243
|
+
export async function readEpochFileStrict(path: string, fallback: string): Promise<string> {
|
|
244
|
+
let text: string;
|
|
245
|
+
try {
|
|
246
|
+
text = await readFile(path, "utf8");
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (getErrorCode(error) === "ENOENT") return fallback;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const epoch = parseEpochRecord(JSON.parse(text));
|
|
252
|
+
if (!epoch) throw new Error(`invalid stats epoch file ${path}`);
|
|
253
|
+
return epoch;
|
|
254
|
+
}
|
|
255
|
+
|
|
237
256
|
export async function readGlobalStatsEpoch(): Promise<string> {
|
|
238
257
|
return readEpochFile(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
|
|
239
258
|
}
|
|
@@ -242,6 +261,14 @@ export async function readModelStatsEpoch(modelKeyValue: string): Promise<string
|
|
|
242
261
|
return readEpochFile(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
|
|
243
262
|
}
|
|
244
263
|
|
|
264
|
+
export async function readGlobalStatsEpochStrict(): Promise<string> {
|
|
265
|
+
return readEpochFileStrict(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function readModelStatsEpochStrict(modelKeyValue: string): Promise<string> {
|
|
269
|
+
return readEpochFileStrict(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
|
|
270
|
+
}
|
|
271
|
+
|
|
245
272
|
export async function writeStatsEpoch(path: string, epoch: string): Promise<void> {
|
|
246
273
|
await mkdir(dirname(path), { recursive: true });
|
|
247
274
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
@@ -584,35 +611,111 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
|
|
|
584
611
|
return outcomes.reduce((sum, value) => sum + value, 0);
|
|
585
612
|
}
|
|
586
613
|
|
|
587
|
-
|
|
588
|
-
export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<number> {
|
|
589
|
-
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
590
|
-
try {
|
|
591
|
-
const marker = await stat(SHARD_CLEANUP_MARKER_PATH);
|
|
592
|
-
if (now - marker.mtimeMs < SHARD_CLEANUP_INTERVAL_MS) return 0;
|
|
593
|
-
} catch {}
|
|
614
|
+
const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
|
|
594
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Take the cross-process maintenance lock (a directory, so creation is atomic
|
|
618
|
+
* on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
|
|
619
|
+
* treated as abandoned by a crashed process and replaced. Resolves to an
|
|
620
|
+
* owner token (truthy) on success, undefined otherwise. The token lets a
|
|
621
|
+
* holder whose lock was taken over avoid releasing the new owner's lock.
|
|
622
|
+
*/
|
|
623
|
+
export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<string | undefined> {
|
|
624
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
625
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
626
|
+
const claim = async (): Promise<string> => {
|
|
627
|
+
// Best effort: an older version (or a failed write) leaves no owner file,
|
|
628
|
+
// which only disables the ownership check, never the lock itself.
|
|
629
|
+
await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8").catch(() => undefined);
|
|
630
|
+
return token;
|
|
631
|
+
};
|
|
595
632
|
try {
|
|
596
633
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
634
|
+
return await claim();
|
|
597
635
|
} catch (error) {
|
|
598
|
-
if (getErrorCode(error) !== "EEXIST") return
|
|
636
|
+
if (getErrorCode(error) !== "EEXIST") return undefined;
|
|
599
637
|
try {
|
|
600
638
|
const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
601
|
-
if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return
|
|
639
|
+
if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
|
|
602
640
|
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
603
641
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
642
|
+
return await claim();
|
|
604
643
|
} catch {
|
|
605
|
-
return
|
|
644
|
+
return undefined;
|
|
606
645
|
}
|
|
607
646
|
}
|
|
647
|
+
}
|
|
608
648
|
|
|
649
|
+
/** False only when the lock directory now records a different owner (or is gone). */
|
|
650
|
+
export async function holdsStatsMaintenanceLock(token: string | undefined): Promise<boolean> {
|
|
651
|
+
if (!token) return true;
|
|
652
|
+
try {
|
|
653
|
+
const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
|
|
654
|
+
return owner === token;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
// Lock directory gone: someone treated us as stale. Owner file missing
|
|
657
|
+
// inside an existing lock: written by a version without tokens; assume ours.
|
|
658
|
+
if (getErrorCode(error) !== "ENOENT") return true;
|
|
659
|
+
try {
|
|
660
|
+
await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
661
|
+
return true;
|
|
662
|
+
} catch {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function releaseStatsMaintenanceLock(token?: string): Promise<void> {
|
|
669
|
+
// Do not delete a lock another process took over after ours went stale.
|
|
670
|
+
if (token && !(await holdsStatsMaintenanceLock(token))) return;
|
|
671
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** True when the marker file is missing or older than `intervalMs`. */
|
|
675
|
+
export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
|
|
676
|
+
try {
|
|
677
|
+
const marker = await stat(path);
|
|
678
|
+
return now - marker.mtimeMs >= intervalMs;
|
|
679
|
+
} catch {
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export async function writeMaintenanceMarker(path: string, now = Date.now()): Promise<void> {
|
|
685
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
686
|
+
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
687
|
+
await renameWithRetry(tempPath, path);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* rename() that retries briefly on the transient errors Windows raises while
|
|
694
|
+
* another process (or an antivirus scanner) holds the target open.
|
|
695
|
+
*/
|
|
696
|
+
export async function renameWithRetry(from: string, to: string, attempts = 5): Promise<void> {
|
|
697
|
+
for (let attempt = 0; ; attempt++) {
|
|
698
|
+
try {
|
|
699
|
+
await rename(from, to);
|
|
700
|
+
return;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (attempt >= attempts - 1 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
|
|
703
|
+
await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Run due retention maintenance; resolves to the number of files removed (0 when skipped). */
|
|
709
|
+
export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<number> {
|
|
710
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
711
|
+
if (!(await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now))) return 0;
|
|
712
|
+
const lockToken = await acquireStatsMaintenanceLock(now);
|
|
713
|
+
if (!lockToken) return 0;
|
|
609
714
|
try {
|
|
610
715
|
const removed = await cleanupStatsShardsV7(now);
|
|
611
|
-
|
|
612
|
-
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
613
|
-
await rename(tempPath, SHARD_CLEANUP_MARKER_PATH);
|
|
716
|
+
await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
|
|
614
717
|
return removed;
|
|
615
718
|
} finally {
|
|
616
|
-
await
|
|
719
|
+
await releaseStatsMaintenanceLock(lockToken);
|
|
617
720
|
}
|
|
618
721
|
}
|