@lqc123qwe/car-runtime 1.1.0 → 1.3.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/.github/workflows/release.yml +28 -3
- package/README.md +16 -1
- package/package.json +1 -1
- package/scripts/release-artifacts.ts +111 -0
- package/scripts/release-pipeline.ts +27 -26
- package/src/cli.ts +41 -6
- package/src/runtime-core/telemetry.ts +113 -23
- package/src/session/indexStore.ts +178 -3
- package/test/s25-m8-runtime-core.spec.ts +2 -1
- package/test/s28-session-index-followup.spec.ts +236 -0
- package/test/s29-otel-producer.spec.ts +252 -0
- package/test/s30-release-artifacts.spec.ts +127 -0
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
* 口径(§3.2.M8.5 关键约束,逐条落地):
|
|
5
5
|
* - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
|
|
6
6
|
* - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
|
|
7
|
-
* - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s
|
|
8
|
-
*
|
|
7
|
+
* - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s;
|
|
8
|
+
* 1.2-S3 生产级策略(D-12b 口径修订,2026-09-27):**0 重试 → 有界重试 ≤2**(仅 429/5xx/
|
|
9
|
+
* 网络错误,退避 1s/2s;4xx 业务错不重试)——重试耗尽静默丢弃 + droppedExports 计数,
|
|
10
|
+
* 永不抛错(BD-05「不抛错、不落审计」收紧保持);隐私三原则不变(数据仍只发用户自配端点);
|
|
11
|
+
* - 采样(1.2-S3 加法):head 采样于 span.end() 决策——always_on(缺省,全量,1.1 行为不变)/
|
|
12
|
+
* always_off / { ratio }(概率);被采出计入 spansSampledOut;采样只减少出站数据(隐私正向);
|
|
13
|
+
* - 批处理上限(1.2-S3 加法):maxBatchSize 单请求分批(512)+ maxQueueSize buffer 上限
|
|
14
|
+
* (2048,溢出丢最旧 + queueOverflows 计数)——内存有界;
|
|
15
|
+
* - env 通道(1.2-S3 加法):telemetryConfigFromEnv——CAR_OTEL_ENDPOINT(唯一开关)/
|
|
16
|
+
* CAR_OTEL_SAMPLING(always_on|always_off|0..1,非法值保持缺省——遥测面禁 fail-hard)/
|
|
17
|
+
* CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME;
|
|
9
18
|
* - 严格解耦:本文件零 import 自 session/*(遥测与审计日志禁止混流)——测试做静态断言;
|
|
10
19
|
* - S17 零内容计数器(telemetry/metrics.ts)是登记表兜底通道,与本门面并存互补:
|
|
11
20
|
* 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
|
|
@@ -14,6 +23,8 @@
|
|
|
14
23
|
*/
|
|
15
24
|
import { randomBytes } from 'node:crypto'
|
|
16
25
|
|
|
26
|
+
export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
|
|
27
|
+
|
|
17
28
|
export interface TelemetryConfig {
|
|
18
29
|
/** 用户自配 OTLP/HTTP 端点(如 https://otel.example.internal)——无值 = 默认关 */
|
|
19
30
|
endpoint?: string
|
|
@@ -23,6 +34,19 @@ export interface TelemetryConfig {
|
|
|
23
34
|
exportTimeoutMs?: number
|
|
24
35
|
/** 自动导出间隔 ms;0(缺省)= 仅手动 flush(测试/收口时点调用) */
|
|
25
36
|
intervalMs?: number
|
|
37
|
+
// —— 1.2-S3 生产级策略(加法;缺省值保持 1.1 行为)——
|
|
38
|
+
/** 有界重试上限(仅 429/5xx/网络错误;§3.5.4 基线 ≤2;D-12b 口径修订) */
|
|
39
|
+
retryLimit?: number
|
|
40
|
+
/** 重试退避序列 ms(缺省 [1000, 2000];测试可注入) */
|
|
41
|
+
retryBackoffMs?: number[]
|
|
42
|
+
/** 采样(缺省 always_on = 全量);head 决策于 span.end() */
|
|
43
|
+
sampling?: SamplingSpec
|
|
44
|
+
/** 单请求 spans 上限(超限分批;缺省 512) */
|
|
45
|
+
maxBatchSize?: number
|
|
46
|
+
/** span buffer 上限(溢出丢最旧 + queueOverflows 计数;缺省 2048) */
|
|
47
|
+
maxQueueSize?: number
|
|
48
|
+
/** 测试注入睡眠(退避可观测;缺省真实定时器) */
|
|
49
|
+
sleep?: (ms: number) => Promise<void>
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
export interface TelemetrySpan {
|
|
@@ -47,9 +71,14 @@ export interface TelemetryMeter {
|
|
|
47
71
|
|
|
48
72
|
export interface TelemetryStats {
|
|
49
73
|
spansEnded: number
|
|
74
|
+
/** 1.2-S3 加法:head 采样被采出的 span 数(缺省 always_on 下恒 0) */
|
|
75
|
+
spansSampledOut: number
|
|
76
|
+
/** 成功出站的 OTLP 请求计数(traces/metrics 分批后按请求计) */
|
|
50
77
|
tracesExported: number
|
|
51
78
|
metricsExported: number
|
|
52
79
|
droppedExports: number
|
|
80
|
+
/** 1.2-S3 加法:buffer 溢出丢最旧计数(maxQueueSize 上限) */
|
|
81
|
+
queueOverflows: number
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
export interface TelemetryFacade {
|
|
@@ -76,6 +105,33 @@ const noopSpan = (): TelemetrySpan => ({
|
|
|
76
105
|
})
|
|
77
106
|
const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
|
|
78
107
|
const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
|
|
108
|
+
const NOOP_STATS: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 1.2-S3 env 配置通道:CAR_OTEL_ENDPOINT(唯一开关)/ CAR_OTEL_SAMPLING(always_on|always_off|0..1,
|
|
112
|
+
* 非法值保持缺省 always_on——遥测面禁 fail-hard)/ CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME。
|
|
113
|
+
* 无 endpoint = null(默认关,noop 门面)。
|
|
114
|
+
*/
|
|
115
|
+
export function telemetryConfigFromEnv(env: NodeJS.ProcessEnv = process.env): TelemetryConfig | null {
|
|
116
|
+
const endpoint = env.CAR_OTEL_ENDPOINT
|
|
117
|
+
if (!endpoint) return null
|
|
118
|
+
const cfg: TelemetryConfig = { endpoint }
|
|
119
|
+
if (env.CAR_OTEL_SERVICE_NAME) cfg.serviceName = env.CAR_OTEL_SERVICE_NAME
|
|
120
|
+
if (env.CAR_OTEL_INTERVAL_MS) {
|
|
121
|
+
const n = Number(env.CAR_OTEL_INTERVAL_MS)
|
|
122
|
+
if (Number.isFinite(n) && n >= 0) cfg.intervalMs = n
|
|
123
|
+
}
|
|
124
|
+
if (env.CAR_OTEL_SAMPLING) {
|
|
125
|
+
const s = env.CAR_OTEL_SAMPLING
|
|
126
|
+
if (s === 'always_on' || s === 'always_off') cfg.sampling = s
|
|
127
|
+
else {
|
|
128
|
+
const r = Number(s)
|
|
129
|
+
if (Number.isFinite(r) && r >= 0 && r <= 1) cfg.sampling = { ratio: r }
|
|
130
|
+
// 非法采样值:保持缺省 always_on(登记口径,禁 fail-hard)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return cfg
|
|
134
|
+
}
|
|
79
135
|
|
|
80
136
|
// ==================== 活动门面(显式开路径) ====================
|
|
81
137
|
|
|
@@ -104,36 +160,61 @@ export function createTelemetryFacade(
|
|
|
104
160
|
getMeter: () => NOOP_METER,
|
|
105
161
|
flush: async () => {},
|
|
106
162
|
shutdown: async () => {},
|
|
107
|
-
stats: () => ({
|
|
163
|
+
stats: () => ({ ...NOOP_STATS }),
|
|
108
164
|
}
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
112
168
|
const now = opts.now ?? Date.now
|
|
169
|
+
const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
113
170
|
const timeoutMs = config?.exportTimeoutMs ?? 5_000
|
|
171
|
+
const retryLimit = config?.retryLimit ?? 2
|
|
172
|
+
const backoff = config?.retryBackoffMs ?? [1_000, 2_000]
|
|
173
|
+
const sampling = config?.sampling ?? 'always_on'
|
|
174
|
+
const maxBatchSize = config?.maxBatchSize ?? 512
|
|
175
|
+
const maxQueueSize = config?.maxQueueSize ?? 2_048
|
|
114
176
|
const baseHeaders = { 'content-type': 'application/json', ...(config?.headers ?? {}) }
|
|
115
177
|
const serviceName = config?.serviceName ?? 'car-runtime'
|
|
116
178
|
|
|
117
179
|
const spans: SpanRecord[] = []
|
|
118
|
-
|
|
119
|
-
|
|
180
|
+
// 计数器键 = name+labels JSON(聚合去重);记录体分离保存 name/labels——
|
|
181
|
+
// OTLP metric 名必须是纯 name(1.2-BUG-3:键直当名会把 labels JSON 拼进 metric 名,
|
|
182
|
+
// M8 最小实现即存在,meter 零生产调用点掩盖至今,1.2-S3 meter 桥接入时暴露)
|
|
183
|
+
const counters = new Map<string, { name: string; labels: Record<string, string>; value: number }>()
|
|
184
|
+
const stats: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
|
|
120
185
|
let tracer: TelemetryTracer | null = null
|
|
121
186
|
let meter: TelemetryMeter | null = null
|
|
122
187
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
123
188
|
let flushing = false
|
|
124
189
|
|
|
190
|
+
/**
|
|
191
|
+
* OTLP 出站(D-12b 口径:有界重试)——ok / 429/5xx/网络错误退避重试 ≤retryLimit /
|
|
192
|
+
* 4xx 业务错不重试(端点配置类问题,重试无益);重试耗尽返回 false 由调用方计入
|
|
193
|
+
* droppedExports(静默丢弃,永不抛错——BD-05「不抛错」收紧保持)。
|
|
194
|
+
*/
|
|
125
195
|
const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
196
|
+
for (let attempt = 0; ; attempt++) {
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetchImpl(endpoint + path, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: baseHeaders,
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
203
|
+
})
|
|
204
|
+
if (res.ok) return true
|
|
205
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retryLimit) {
|
|
206
|
+
await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
return false
|
|
210
|
+
} catch {
|
|
211
|
+
// 网络错误/超时:可重试类
|
|
212
|
+
if (attempt < retryLimit) {
|
|
213
|
+
await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
}
|
|
139
220
|
|
|
@@ -141,13 +222,15 @@ export function createTelemetryFacade(
|
|
|
141
222
|
if (flushing) return
|
|
142
223
|
flushing = true
|
|
143
224
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
225
|
+
const spanQueue = spans.splice(0, spans.length)
|
|
226
|
+
// maxBatchSize 分批(1.2-S3):tracesExported 按成功出站请求计数
|
|
227
|
+
for (let i = 0; i < spanQueue.length; i += maxBatchSize) {
|
|
228
|
+
const batch = spanQueue.slice(i, i + maxBatchSize)
|
|
146
229
|
const body = {
|
|
147
230
|
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
148
231
|
scopeSpans: [{
|
|
149
232
|
scope: { name: 'car-runtime' },
|
|
150
|
-
spans:
|
|
233
|
+
spans: batch.map(s => ({
|
|
151
234
|
traceId: s.traceId,
|
|
152
235
|
spanId: s.spanId,
|
|
153
236
|
...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
|
|
@@ -170,8 +253,8 @@ export function createTelemetryFacade(
|
|
|
170
253
|
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
171
254
|
scopeMetrics: [{
|
|
172
255
|
scope: { name: 'car-runtime' },
|
|
173
|
-
metrics: batch.map(([
|
|
174
|
-
name,
|
|
256
|
+
metrics: batch.map(([, c]) => ({
|
|
257
|
+
name: c.name,
|
|
175
258
|
sum: {
|
|
176
259
|
aggregationTemporality: 'AGGREGATION_TEMPORALITY_CUMULATIVE',
|
|
177
260
|
dataPoints: Object.entries(c.labels).length
|
|
@@ -209,8 +292,15 @@ export function createTelemetryFacade(
|
|
|
209
292
|
recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
|
|
210
293
|
end() {
|
|
211
294
|
record.endMs = now()
|
|
212
|
-
spans.push(record)
|
|
213
295
|
stats.spansEnded++
|
|
296
|
+
// head 采样于 end() 决策(1.2-S3):缺省 always_on 全量(1.1 行为不变);采样只减少出站
|
|
297
|
+
const keep = sampling === 'always_on' ? true
|
|
298
|
+
: sampling === 'always_off' ? false
|
|
299
|
+
: Math.random() < sampling.ratio
|
|
300
|
+
if (!keep) { stats.spansSampledOut++; return }
|
|
301
|
+
// buffer 上限:溢出丢最旧 + 计数(内存有界)
|
|
302
|
+
if (spans.length >= maxQueueSize) { spans.shift(); stats.queueOverflows++ }
|
|
303
|
+
spans.push(record)
|
|
214
304
|
},
|
|
215
305
|
}
|
|
216
306
|
},
|
|
@@ -221,7 +311,7 @@ export function createTelemetryFacade(
|
|
|
221
311
|
return {
|
|
222
312
|
add(value, labels = {}) {
|
|
223
313
|
const key = name + JSON.stringify(labels)
|
|
224
|
-
const cur = counters.get(key) ?? { labels, value: 0 }
|
|
314
|
+
const cur = counters.get(key) ?? { name, labels, value: 0 }
|
|
225
315
|
cur.value += value
|
|
226
316
|
counters.set(key, cur)
|
|
227
317
|
},
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* 索引只收录链完整会话(审计面口径:可索引 = 可验证);
|
|
9
9
|
* - 物理编码 layout-blind:plain / zstd 统一经 format.ts 装载,索引记录 encoding 供检索。
|
|
10
10
|
*/
|
|
11
|
-
import { readdirSync } from 'node:fs'
|
|
12
|
-
import { join } from 'node:path'
|
|
11
|
+
import { readdirSync, existsSync, realpathSync } from 'node:fs'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
13
|
import { loadSessionLog, type SessionEvent } from './log.ts'
|
|
14
14
|
|
|
15
15
|
export interface SessionIndexRow {
|
|
@@ -31,14 +31,189 @@ export interface RebuildResult {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type SqliteValue = string | number | null
|
|
34
|
-
interface SqliteStmt { run(...v: SqliteValue[]): unknown }
|
|
34
|
+
interface SqliteStmt { run(...v: SqliteValue[]): unknown; get(...v: SqliteValue[]): unknown }
|
|
35
35
|
interface SqliteDb { exec(sql: string): void; prepare(sql: string): SqliteStmt; close(): void }
|
|
36
36
|
|
|
37
|
+
const CAR_E_SQLITE = 'CAR-E-SQLITE: 当前 Node 无 node:sqlite 能力(22.5+ 需 --experimental-sqlite,23.4+ 原生)——显式拒绝,不静默降级'
|
|
38
|
+
|
|
37
39
|
/** node:sqlite 能力探测(模块存在性;静态 import 在无能力 Node 上会崩模块加载,故动态) */
|
|
38
40
|
export async function sqliteAvailable(): Promise<boolean> {
|
|
39
41
|
try { await import('node:sqlite'); return true } catch { return false }
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/** 打开索引库(db 文件必须已存在——lookup 不产生建文件副作用;open 失败 = 索引文件无效显式报错) */
|
|
45
|
+
async function openExistingDb(dbPath: string): Promise<SqliteDb> {
|
|
46
|
+
if (!existsSync(dbPath)) {
|
|
47
|
+
throw new Error(`CAR-E-INDEX: 会话索引不存在:${dbPath}——先执行 car session rebuild-index 建立索引`)
|
|
48
|
+
}
|
|
49
|
+
const mod = await import('node:sqlite').catch(() => null)
|
|
50
|
+
if (!mod) throw new Error(CAR_E_SQLITE)
|
|
51
|
+
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
|
|
52
|
+
try {
|
|
53
|
+
return new DatabaseSync(dbPath)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new Error(`CAR-E-INDEX: 索引文件无效(打开失败:${(e as Error).message}):${dbPath}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 单会话检索(1.2-S1 W3-1):--session <id> 经索引解析 sessionId → 文件;未收录 = null(「可索引 = 可验证」口径——断链会话不入索引) */
|
|
60
|
+
export async function lookupSession(dbPath: string, sessionId: string): Promise<SessionIndexRow | null> {
|
|
61
|
+
const db = await openExistingDb(dbPath)
|
|
62
|
+
try {
|
|
63
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get()
|
|
64
|
+
if (!table) throw new Error(`CAR-E-INDEX: 索引文件无效(无 sessions 表):${dbPath}`)
|
|
65
|
+
const row = db.prepare('SELECT * FROM sessions WHERE sessionId = ?').get(sessionId) as Record<string, SqliteValue> | undefined
|
|
66
|
+
if (!row) return null
|
|
67
|
+
return {
|
|
68
|
+
sessionId: String(row.sessionId),
|
|
69
|
+
file: String(row.file),
|
|
70
|
+
eventCount: Number(row.eventCount),
|
|
71
|
+
chainHead: row.chainHead == null ? null : String(row.chainHead),
|
|
72
|
+
chainTail: row.chainTail == null ? null : String(row.chainTail),
|
|
73
|
+
firstTs: row.firstTs == null ? null : Number(row.firstTs),
|
|
74
|
+
lastTs: row.lastTs == null ? null : Number(row.lastTs),
|
|
75
|
+
tornTail: Number(row.tornTail) === 1,
|
|
76
|
+
encoding: row.encoding === 'zstd' ? 'zstd' : 'plain',
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const msg = (e as Error).message
|
|
80
|
+
if (msg.startsWith('CAR-E-')) throw e // 本层显式错误原样透传
|
|
81
|
+
// SQLite 惰性校验:非索引文件 open 不报错、query 才报(file is not a database)——统一收口为显式口径
|
|
82
|
+
throw new Error(`CAR-E-INDEX: 索引文件无效(${msg}):${dbPath}`)
|
|
83
|
+
} finally {
|
|
84
|
+
db.close()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CREATE_SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions (
|
|
89
|
+
sessionId TEXT PRIMARY KEY,
|
|
90
|
+
file TEXT NOT NULL,
|
|
91
|
+
eventCount INTEGER NOT NULL,
|
|
92
|
+
chainHead TEXT,
|
|
93
|
+
chainTail TEXT,
|
|
94
|
+
firstTs INTEGER,
|
|
95
|
+
lastTs INTEGER,
|
|
96
|
+
tornTail INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
encoding TEXT NOT NULL DEFAULT 'plain',
|
|
98
|
+
rebuiltAt TEXT NOT NULL
|
|
99
|
+
)`
|
|
100
|
+
|
|
101
|
+
/** 单行增量 upsert(1.2-S2 W3-2「更新半边」):库/表不存在则自举创建(活会话无需先 rebuild 即可入索引) */
|
|
102
|
+
export async function upsertSessionRow(dbPath: string, row: SessionIndexRow): Promise<void> {
|
|
103
|
+
const mod = await import('node:sqlite').catch(() => null)
|
|
104
|
+
if (!mod) throw new Error(CAR_E_SQLITE)
|
|
105
|
+
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
|
|
106
|
+
try {
|
|
107
|
+
const db = new DatabaseSync(dbPath)
|
|
108
|
+
try {
|
|
109
|
+
db.exec(CREATE_SESSIONS_SQL)
|
|
110
|
+
db.prepare(
|
|
111
|
+
'INSERT OR REPLACE INTO sessions (sessionId, file, eventCount, chainHead, chainTail, firstTs, lastTs, tornTail, encoding, rebuiltAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
112
|
+
).run(row.sessionId, row.file, row.eventCount, row.chainHead, row.chainTail, row.firstTs, row.lastTs, row.tornTail ? 1 : 0, row.encoding, new Date().toISOString())
|
|
113
|
+
} finally {
|
|
114
|
+
db.close()
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
const msg = (e as Error).message
|
|
118
|
+
if (msg.startsWith('CAR-E-')) throw e
|
|
119
|
+
throw new Error(`CAR-E-INDEX: 索引更新失败(${msg}):${dbPath}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 运行时索引更新器(1.2-S2 W3-2):file-backed 会话 append 后的异步增量通道。
|
|
125
|
+
*
|
|
126
|
+
* 口径(M7 §4.2 登记 + 1.2 规划 R-2):
|
|
127
|
+
* - 按行增量:SessionLog.append 的 sink 先于事件入内存调用(fail-fast 语义),故 record
|
|
128
|
+
* 直接收行 JSON——seq/ts/hash 取自行数据(seq 权威:eventCount = seq+1),不读内存快照;
|
|
129
|
+
* - debounce 合并:窗口内多次 append 只落一次 upsert(行状态由最新 append 覆盖);
|
|
130
|
+
* - 失败非致命:upsert 任何错误经 onError 留痕并计数,绝不向 append 路径传播——
|
|
131
|
+
* 索引是派生缓存,丢失由 rebuild-index 兜底(语义自洽,M7 已登记);
|
|
132
|
+
* - SQLite 能力缺席:首次 flush 显式登记一次后停用(非静默,后续 record 不再调度);
|
|
133
|
+
* - 活会话行口径:tornTail=false(按构造完整)、encoding='plain'(zstd 定位归档/导出,M7 W3)。
|
|
134
|
+
*/
|
|
135
|
+
interface PendingState {
|
|
136
|
+
sessionId: string
|
|
137
|
+
file: string
|
|
138
|
+
eventCount: number
|
|
139
|
+
chainHead: string
|
|
140
|
+
chainTail: string
|
|
141
|
+
firstTs: number
|
|
142
|
+
lastTs: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class IndexUpdater {
|
|
146
|
+
readonly #dbPath: string
|
|
147
|
+
readonly #debounceMs: number
|
|
148
|
+
readonly #onError: (e: Error) => void
|
|
149
|
+
#state: PendingState | null = null
|
|
150
|
+
#timer: ReturnType<typeof setTimeout> | null = null
|
|
151
|
+
#capabilityMissing = false
|
|
152
|
+
#updates = 0
|
|
153
|
+
#failures = 0
|
|
154
|
+
|
|
155
|
+
constructor(opts: { dbPath: string; debounceMs?: number; onError?: (e: Error) => void }) {
|
|
156
|
+
this.#dbPath = opts.dbPath
|
|
157
|
+
this.#debounceMs = opts.debounceMs ?? 2_000
|
|
158
|
+
this.#onError = opts.onError ?? (e => { try { console.error(`[index-updater] ${e.message}`) } catch { /* 收口期 stderr 不可用 */ } })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get stats(): { updates: number; failures: number } {
|
|
162
|
+
return { updates: this.#updates, failures: this.#failures }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** append sink 链上调用(行 JSON):seq 权威增量;非预期行防御性忽略 */
|
|
166
|
+
record(sessionId: string, line: string, file: string): void {
|
|
167
|
+
if (this.#capabilityMissing) return
|
|
168
|
+
let e: { seq?: unknown; ts?: unknown; hash?: unknown }
|
|
169
|
+
try { e = JSON.parse(line) as { seq?: unknown; ts?: unknown; hash?: unknown } } catch { return }
|
|
170
|
+
const seq = Number(e.seq)
|
|
171
|
+
const ts = Number(e.ts)
|
|
172
|
+
const hash = typeof e.hash === 'string' ? e.hash : ''
|
|
173
|
+
if (!Number.isFinite(seq) || !Number.isFinite(ts) || !hash) return
|
|
174
|
+
// realpath 规范化(1.2-BUG-4):macOS tmpdir 为 symlink(/var → /private/var),子进程 cwd 由
|
|
175
|
+
// OS 返回物理路径——resolve() 不解析符号链接,父子进程会产出同一文件的两个别名形态;
|
|
176
|
+
// 入库统一为 realpath(文件刚被 append 过必存在;异常回退 resolve)
|
|
177
|
+
const canonical = (() => {
|
|
178
|
+
try { return realpathSync(file) } catch { return resolve(file) }
|
|
179
|
+
})()
|
|
180
|
+
const s = this.#state
|
|
181
|
+
if (!s || s.sessionId !== sessionId || s.file !== canonical) {
|
|
182
|
+
this.#state = { sessionId, file: canonical, eventCount: seq + 1, chainHead: hash, chainTail: hash, firstTs: ts, lastTs: ts }
|
|
183
|
+
} else {
|
|
184
|
+
s.eventCount = Math.max(s.eventCount, seq + 1)
|
|
185
|
+
s.chainTail = hash
|
|
186
|
+
s.lastTs = ts
|
|
187
|
+
}
|
|
188
|
+
this.#timer ??= setTimeout(() => { void this.flush() }, this.#debounceMs)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 立即落 pending(close/exit 时点调用;幂等——无 pending 即空转) */
|
|
192
|
+
async flush(): Promise<void> {
|
|
193
|
+
if (this.#timer) { clearTimeout(this.#timer); this.#timer = null }
|
|
194
|
+
if (!this.#state) return
|
|
195
|
+
const s = this.#state
|
|
196
|
+
const row: SessionIndexRow = { ...s, tornTail: false, encoding: 'plain' }
|
|
197
|
+
try {
|
|
198
|
+
await upsertSessionRow(this.#dbPath, row)
|
|
199
|
+
this.#updates++
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.#failures++
|
|
202
|
+
const msg = (e as Error).message
|
|
203
|
+
if (msg.startsWith('CAR-E-SQLITE')) {
|
|
204
|
+
// 能力缺席:登记一次后停用(后续 record 不再调度——禁每窗口重复刷屏)
|
|
205
|
+
this.#capabilityMissing = true
|
|
206
|
+
}
|
|
207
|
+
this.#onError(e as Error)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 收口:取消未决 timer + 落 pending(car run 关句柄前调用) */
|
|
212
|
+
async close(): Promise<void> {
|
|
213
|
+
await this.flush()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
42
217
|
function scanLogFiles(root: string): string[] {
|
|
43
218
|
const rels = readdirSync(root, { recursive: true }) as string[]
|
|
44
219
|
return rels
|
|
@@ -198,7 +198,8 @@ test('M8: 遥测默认关——noop 句柄 + 零出站 + 零积累(可机器
|
|
|
198
198
|
t.getMeter().createCounter('c').add(1)
|
|
199
199
|
await t.flush()
|
|
200
200
|
await t.shutdown()
|
|
201
|
-
|
|
201
|
+
// 1.2-S3 stats 加法字段(spansSampledOut/queueOverflows)——noop 全零意图不变
|
|
202
|
+
assert.deepEqual(t.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
202
203
|
})
|
|
203
204
|
|
|
204
205
|
test('M8: 遥测显式开——OTLP/HTTP 形状出站 + stats + 端点不可达静默丢弃(BD-05)', async () => {
|