dsh-usage-badge 0.1.11 → 0.1.13
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 +1 -1
- package/docs/design-notes.md +46 -1
- package/lib/buckets.js +9 -3
- package/lib/cache-store.js +13 -4
- package/lib/client.js +10 -0
- package/lib/fold.js +286 -76
- package/lib/index.js +180 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
83
83
|
|
|
84
84
|
## 已知限制
|
|
85
85
|
|
|
86
|
-
-
|
|
86
|
+
- **首次启动要冷扫一遍全部会话日志**(本机 338 份 / 348 MB 实测约 19 秒,随机器负载浮动;按解码字节数让出事件循环,最长一次阻塞约 0.4 秒,宿主启动与界面都不会被卡住,徽标先显示「…」再填上数字)。**之后每次启动只读增量**:只解压新追加的那几帧 —— 同一台机器实测「1 个日志、2 KB、57 ms」,没有任何变化时「0 个、16 ms」。**挂载插件本身不碰磁盘**(只登记一条路由,实测 0.75 ms),打开缓存库、读历史、折日志都发生在界面第一次请求时 —— 那时宿主早已启动完成。启动的每一段都有数字:日志里的 `ready in …`、面板上的**「上次启动」**一行会写明打开库 / 载入 / 列日志 / 折叠各用了多久,觉得慢时看这一行就知道慢在哪。
|
|
87
87
|
- **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
|
|
88
88
|
- **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
|
|
89
89
|
- **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
|
package/docs/design-notes.md
CHANGED
|
@@ -327,6 +327,50 @@ dsh-usage-badge
|
|
|
327
327
|
|
|
328
328
|
删掉 `cache.sqlite` 与删掉 `cache.json` 的后果完全一样:下次启动冷扫一遍日志重建;只有「日志已被删除」的那段历史会随之消失。
|
|
329
329
|
|
|
330
|
+
### 折叠的代价:只解码新增的那几个字节
|
|
331
|
+
|
|
332
|
+
「DSH 启动很慢」查下来是这个插件自己:**每启动一次就把整份日志重新解码一遍**。本机实测(373 个日志 / 348 MB):一次启动折 25 个日志要 **3.6 秒**,冷启动(没有缓存)**20 秒**,而耗时**八成在 zstd 解压**——12.7 MB 的日志解压 775 ms,解析 JSON 只要 133 ms。两个原因叠在一起:
|
|
333
|
+
|
|
334
|
+
1. **游标取错了地方**:会话的 `fileSize`/`fileMtimeMs` 原来取自它**最早那一天的行走**(每天的行记的是写它的那次折叠的戳,而折叠只重写 payload 真的变了的天),所以长寿会话的最早一天带着几个月前的戳 —— 于是**每次启动都判定成「变了」**,多天的会话全部重折。现在 `resolveRows` 取会话行本身的游标(`sessions.file_size`),那才是「这份日志我折到哪儿了」。
|
|
335
|
+
2. **变了就从头折**:游标的 `fileSize` 本来**就是**上次折到哪儿的字节偏移,所以「新增了 1 KB」不需要从第 0 字节重新解压。现在 `foldSession(file, previous, { fromByte })` 只读 `[fromByte, size)`:按 zstd **帧**切(一帧要么整个折进去,要么留给下一趟)、纯文本按**最后一个换行**切,末尾那半行只有在它自身是完整 JSON 时才算数(日志最后一行常常没有换行符,漏掉它就正好漏掉最新一次请求)。
|
|
336
|
+
|
|
337
|
+
规则与代价:
|
|
338
|
+
|
|
339
|
+
| | 改之前 | 改之后 |
|
|
340
|
+
|---|---|---|
|
|
341
|
+
| 启动(25 个日志有增量时) | 折 25 个、读 45 MB、**3.6 s** | 折 1 个、读 **2 KB**、**57 ms** |
|
|
342
|
+
| 启动(无变化) | 每个多天会话都重折 | **0 个、16 ms** |
|
|
343
|
+
| 冷启动(无缓存,338 个日志) | 19.6 s,单次阻塞可到 ~0.8 s | 18.6 s,**最长阻塞 ~0.4 s** |
|
|
344
|
+
| 使用中(每次轮询) | 活动会话整份重解压 | 只解压新追加的帧 |
|
|
345
|
+
|
|
346
|
+
三条不变量:
|
|
347
|
+
|
|
348
|
+
- **折不全就重折**:只有「同一路径、比游标更长」才走增量;变短、换路径、无游标、无旧记录一律从头折 —— 尾部读法假设前缀没变。
|
|
349
|
+
- **不删自己没看过的天**:增量折出来的记录只覆盖窗口内的天,所以 `put(record, { keepOtherDays: true })` 跳过「记录里没有的天就删掉」那一步。否则折一次今天就会把窗口外的历史删光。
|
|
350
|
+
- **不许伪造新日期**:尾部里的某天若不在旧记录里(时钟跳了、日志被原地重写),这次折叠作废、改为从头折。`test/cache.verify.mjs` 里 8 条断言钉住这些(只读增量字节数、两天相加不翻倍、无变化读 0、游标是日志大小而不是旧天戳、半行不算/补全后只算一次)。
|
|
351
|
+
|
|
352
|
+
剩下的一份成本是**冷启动必须把 348 MB 读一遍**(一次性的,之后每次启动都只读增量)。它现在是**按解码字节数让出事件循环**的(每 1 MB 让一次 —— 按帧让会适得其反:一个 12 MB 日志有 34 000 帧,`setImmediate` 往返比解码本身还贵,实测 21 s 变 52 s):18.6 秒的总时长里最长阻塞 ~0.4 秒,宿主照常启动、界面照常响应,徽标先显示「…」再填上数字。
|
|
353
|
+
|
|
354
|
+
**等价性核对(真机)**:同一份 334 MB / 338 个日志,「读快照 + 只折尾部」与「清空缓存全量重折」在**共同可见的 68 天上逐日完全一致**(input、请求数逐项相等);库里另外多的 3 天来自**日志已被删除**的会话 —— 那正是缓存有意保留的历史,全量重折本来就看不回来。
|
|
355
|
+
|
|
356
|
+
**挂载时插件什么都不做,是真的什么都不做**。DSH 的 Loader 在 `boot()` 里挂载整棵插件树,所以「晚点再加载这个插件」不是插件能选的 —— 能选的是**挂载之后干什么**。`apply()` 现在只做一件事:往 `ctx.webServer` 登记一条路由(实测 **0.75 ms**,不开库、不读价格表、不碰日志、连 `sessions` 目录都不列)。真正的活全部落在**界面第一次请求**上:那条请求不可能早于宿主启动完成。
|
|
357
|
+
|
|
358
|
+
启动的每一段都有数字,不再靠感觉:
|
|
359
|
+
|
|
360
|
+
| 段 | 什么时候做 | 含义 | 变大意味着 |
|
|
361
|
+
|---|---|---|---|
|
|
362
|
+
| 挂载 | DSH 启动时 | 登记路由 | 不该长大 |
|
|
363
|
+
| 打开库 `storeMs` | 第一次请求 | 打开 `cache.sqlite`、设 pragma、查 schema | 磁盘慢 / 杀毒软件在扫那个文件 |
|
|
364
|
+
| 载入 `loadMs` | 第一次请求 | 把窗口内的历史读进内存 | 缓存库大,或磁盘慢 |
|
|
365
|
+
| 列日志 `listMs` | 每次折叠 | 递归遍历 `sessions` 目录 | 会话特别多,或磁盘慢 |
|
|
366
|
+
| 折叠 `foldMs` | 每次折叠 | 解码新追加的字节 + 重建合并视图 | 增量确实很大(会同时报读了多少 KB / 折了几个日志) |
|
|
367
|
+
|
|
368
|
+
`ready in …`(日志)与面板上的「上次启动」一行报的就是这条链路;`waitedMs` 单独记「从挂载到界面来问」等了多久 —— 那是宿主的启动时间,不是插件的成本。
|
|
369
|
+
|
|
370
|
+
本机实测(348 MB / 341 个日志,已是最坏的那类安装):`apply()` **0.75 ms**;第一次请求里打开库 22 ms、载入 46 ms、列日志 16 ms、折叠 41 ms(1 个日志、8 KB),**合计 ~103 ms**;之后每轮 18 ms 左右,没有变化时就只有列日志 + stat。
|
|
371
|
+
|
|
372
|
+
**兜底计时器**:如果宿主压根没人打开界面(没人请求数据),`DSH_USAGE_BADGE_WARM_MS`(默认 3000 ms)之后也会自己热身一次,纯粹为了让日志里有那条 `ready` 行;两条路径都会走同一个 `warmOnce()`,所以只会热身一次。
|
|
373
|
+
|
|
330
374
|
### `按年` 视图:年份列表属于数据,不属于代码
|
|
331
375
|
|
|
332
376
|
历史不删,就迟早要看更早的年。这一年视图有三条决定:
|
|
@@ -373,7 +417,7 @@ npm test
|
|
|
373
417
|
| `test/pricing.verify.mjs` | 用临时 `DSH_HOME` + 合成会话日志跑真实宿主半,断言精确金额。第一组用例**逐字移植自桌面壳的 `pricing-key-case.verify.mjs`**(同样的 fixture、同样的期望值),用来证明移植后的定价与原实现一致;另覆盖上下文档位(严格大于、`"128K"` 紧凑写法、缓存 token 计入上下文)、峰谷 `days` 规则、跨币种换算、前缀行的四条优先级规则(精确行优先、长前缀优先、星号不是分隔符、裸 `*` 仍是死配置)、隐式前缀回退(官方行兜住自己的变体、分隔符边界让 `gpt-4` 吞不掉 `gpt-4o`、开关经 PUT 路由后在**同一个进程内**立刻重算),以及未匹配模型不计价的语义(哪些行算「匹配」、token 仍计入、快照列出被跳过的路由)、逐行/表级的 `honorHolidays`(既有「节假日盖过行内规则」也有「行内拒绝日历后照样收峰价」)、行编辑器的写入路径(整份 `overrides` 经 PUT 落盘、其它字段与两个开关不受影响)、**`default` 行是整体替换而非字段合并**(清空的字段真的消失、`{}` 被当成「没有这一行」)、以及**一行 `null` 覆盖行**被跳过而不是让 `/summary` 500 |
|
|
374
418
|
| `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、同一个月内不同日子的分类互不干扰,以及**2027 年的单日端午/中秋**(同一天 10:30,节当天 x1、旁边的周三 x2)与 2027-02-14 调休周日 |
|
|
375
419
|
| `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、已有的 `default` 行不动、没有 `default` 行时按官方第一条补一个并报告 `defaultFrom`、页面第一条确实是最便宜那档);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费(含未列出模型按生成的 default 行计价),以及兜底行的完整生命周期:删除 → 回到内置模板并把 `defaultSource` 报成 `template` → 再次应用从官方第一条生成回来 → 手改过的兜底行不会被应用覆盖 |
|
|
376
|
-
| `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated`
|
|
420
|
+
| `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量、**增量折叠**(重启只读新追加的字节且读的字节数正好等于追加量、两天相加不翻倍、无变化时读 0、游标是日志大小而不是最早那天的戳、半行不算而补全后只算一次),**启动让出宿主**(还没折叠的挂载如实说「没折过」而不是看起来没数据、等待与工作分开计时),以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
|
|
377
421
|
| `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**没有用量的那天金额仍贴右**(token 槽位渲染但为空)、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
|
|
378
422
|
| `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
|
|
379
423
|
| `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
|
|
@@ -824,6 +868,7 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
|
|
|
824
868
|
- **首次启动要冷扫一遍全部日志**(实测 209 份日志约 10 秒,分片让出事件循环,不会卡住宿主)。之后走增量:实测热启动 237ms、折叠 0 份。缓存文件丢失才会再次冷扫。
|
|
825
869
|
- **历史依赖日志仍在盘上,但「变短」不再吃掉历史**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。日志**变短或还原成更早的备份**时不再删缓存里的天:折叠结果变小只会写进 `fallback` 槽位,`current`(读的时候认的那份)仍是更完整的那份。只有日志**更全**、却不再包含某一天时,那天才会被丢掉(section 8 有正反两个方向的断言)。**例外是改名**:会话身份是日志所在目录名,改名会让同一份用量各算一份。
|
|
826
870
|
- **缓存只把最近 430 天装进内存**(`CACHE_WINDOW_DAYS`,比快照要的 400 天宽):更早的行留在库里,`按年` 视图能直接查它们,但**日粒度**的区间(`近 N 日`)与热力图仍只覆盖最近一年。
|
|
871
|
+
- **第一次折叠(或缓存被删掉之后)仍要把全部会话日志读一遍**:本机 348 MB / 338 个日志约 21 秒。它是按帧让出事件循环的(实测最长一次阻塞 203 ms),所以不会拖住宿主启动,但徽标会先显示「…」再填上数字;之后每次启动只读增量(同一台机器实测:1 个日志、2 KB、57 ms)。
|
|
827
872
|
- **没有 `node:sqlite` 的运行时会退回旧的 JSON 缓存**:功能一样,但保存代价与常驻内存随历史长度增长(就是换 SQLite 之前的实测数字)。面板的 ⓘ 会写明当前用的是哪一种。
|
|
828
873
|
- **云同步是手动触发的**:面板上的「立即同步」+「测试连接」是目前唯一的入口(价格表变更后的自动推送、启动后延迟一次都还没接)。数据仍然只在一台机器上直到你点它 —— 换机器、重装、或删掉缓存目录,都会丢掉「日志里已经没有」的那段历史(日志还在的部分能重新折出来)。设计见上面「云同步」一节。
|
|
829
874
|
- **折叠结果是日志所在目录 × 日期**:缓存按 `(会话目录, 日期)` 存,所以把某个会话目录**改名**会让同一份用量出现两个会话,**各算一份**(实测 `¥1.5 → ¥3.0`)。行模型只能处理「同一个会话的多个副本」,管不了会话 id 本身漂移 —— 要修需要从日志 header 取稳定的会话 id。删日志、截断日志、还原旧备份这三种情况现在都不会丢历史。
|
package/lib/buckets.js
CHANGED
|
@@ -297,12 +297,18 @@ export function resolveRows(records) {
|
|
|
297
297
|
const byDate = [...entry.winners.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
298
298
|
for (const row of byDate) days.set(row.date, deserializeDay(row.routes))
|
|
299
299
|
const winner = byDate[0] ?? null
|
|
300
|
+
// The session's stamp is its **cursor** — the log version this device last folded it from —
|
|
301
|
+
// not the stamp of one of its days. A day row keeps the stamp of the fold that wrote it, and a
|
|
302
|
+
// fold rewrites only the days whose buckets changed, so the oldest day of a long-lived session
|
|
303
|
+
// carries a months-old stamp. Reading the cursor off that day made every multi-day session look
|
|
304
|
+
// changed on every start, which re-folded the whole log (measured: 3.6 s per start, 20 s cold).
|
|
305
|
+
// The day stamp is only a fallback for a record that arrived without a cursor.
|
|
300
306
|
resolved.push({
|
|
301
307
|
sessionId: entry.sessionId,
|
|
302
308
|
path: entry.path,
|
|
303
|
-
fileSize:
|
|
304
|
-
fileMtimeMs:
|
|
305
|
-
seq:
|
|
309
|
+
fileSize: entry.fileSize ?? winner?.fileSize ?? 0,
|
|
310
|
+
fileMtimeMs: entry.fileMtimeMs ?? winner?.fileMtimeMs ?? 0,
|
|
311
|
+
seq: entry.seq ?? winner?.seq ?? -1,
|
|
306
312
|
provider: entry.provider,
|
|
307
313
|
model: entry.model,
|
|
308
314
|
days,
|
package/lib/cache-store.js
CHANGED
|
@@ -351,6 +351,9 @@ function createJsonStore({ path, log }) {
|
|
|
351
351
|
put(record, options = {}) {
|
|
352
352
|
const doc = ensureDocument()
|
|
353
353
|
const at = options.at ?? Date.now()
|
|
354
|
+
// A tail fold carries only the days the appended bytes touched, so the days it does not
|
|
355
|
+
// mention are not rows the log lost — they are rows this fold never looked at.
|
|
356
|
+
const keepOtherDays = options.keepOtherDays === true
|
|
354
357
|
const previous = documentToRecord(record.sessionId, doc.sessions[record.sessionId])
|
|
355
358
|
const incoming = rowsOf(record, { source: LOCAL_SOURCE, at })
|
|
356
359
|
const incomingKeys = new Set(incoming.map((row) => `${row.date}\u0000${row.source}`))
|
|
@@ -366,7 +369,7 @@ function createJsonStore({ path, log }) {
|
|
|
366
369
|
// reaches back to it. A row older than the incoming cursor was folded from a fuller log
|
|
367
370
|
// — a shorter one now, or a restored backup — and it is all that is left of those days.
|
|
368
371
|
const theirs = rowSource(row) !== LOCAL_SOURCE || rowKind(row) === FALLBACK
|
|
369
|
-
const keep = theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
|
|
372
|
+
const keep = keepOtherDays || theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
|
|
370
373
|
if (keep) kept.push(row)
|
|
371
374
|
}
|
|
372
375
|
// Grouped by `(date, source, kind)`: the `current` and `fallback` copies of one date share a
|
|
@@ -793,7 +796,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
793
796
|
* - when the fold is shorter *and* drops a day, the day is rolled back rather than deleted —
|
|
794
797
|
* this is what a restored backup looks like, and it is the case the whole row model exists for.
|
|
795
798
|
*/
|
|
796
|
-
const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now() } = {}) => {
|
|
799
|
+
const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now(), keepOtherDays = false } = {}) => {
|
|
797
800
|
writeCursor(record)
|
|
798
801
|
const cursor = readCursor(record.sessionId)
|
|
799
802
|
const incoming = rowsOf(record, { source, at })
|
|
@@ -817,6 +820,12 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
817
820
|
// log. Its `current` row goes with it — unless this log version is itself weaker than the copy
|
|
818
821
|
// already stored, in which case a shortened log is treated as a rollback rather than as a
|
|
819
822
|
// deletion, and the day is kept.
|
|
823
|
+
//
|
|
824
|
+
// `keepOtherDays` skips this whole step: a **tail fold** only ever looked at the appended
|
|
825
|
+
// bytes, so a day it does not mention is a day it never read, not one the log dropped. Without
|
|
826
|
+
// it, folding the last five minutes of a growing log would delete every day outside the loading
|
|
827
|
+
// window the record was built from.
|
|
828
|
+
if (keepOtherDays) return
|
|
820
829
|
for (const row of stored.values()) {
|
|
821
830
|
if (cursor && !rowIsNewerOrEqual(cursor, row)) continue
|
|
822
831
|
deleteRow.run(record.sessionId, row.date, source, CURRENT)
|
|
@@ -1036,11 +1045,11 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
|
|
|
1036
1045
|
* short fold it just produced. Returning the resolved record is what makes "the cache kept it"
|
|
1037
1046
|
* true in memory as well as on disk.
|
|
1038
1047
|
*/
|
|
1039
|
-
put(record, { at = Date.now() } = {}) {
|
|
1048
|
+
put(record, { at = Date.now(), keepOtherDays = false } = {}) {
|
|
1040
1049
|
if (!writable) return null
|
|
1041
1050
|
try {
|
|
1042
1051
|
db.exec('BEGIN IMMEDIATE')
|
|
1043
|
-
writeSession(record, { source: LOCAL_SOURCE, at })
|
|
1052
|
+
writeSession(record, { source: LOCAL_SOURCE, at, keepOtherDays })
|
|
1044
1053
|
db.exec('COMMIT')
|
|
1045
1054
|
} catch (error) {
|
|
1046
1055
|
try {
|
package/lib/client.js
CHANGED
|
@@ -1937,6 +1937,16 @@ html[data-theme="dark"] .dub-mask{
|
|
|
1937
1937
|
'新增一行',
|
|
1938
1938
|
),
|
|
1939
1939
|
),
|
|
1940
|
+
// How the last start spent its time. A slow start used to be a feeling; these are the
|
|
1941
|
+
// numbers behind it, so "it is slow again" can say *which* phase grew.
|
|
1942
|
+
config?.boot?.readyAt
|
|
1943
|
+
? h(
|
|
1944
|
+
'div',
|
|
1945
|
+
{ className: 'dub-note' },
|
|
1946
|
+
`上次启动:共 ${config.boot.totalMs} ms —— 打开库 ${config.boot.storeMs} ms · 载入 ${config.boot.loadMs} ms · 列日志 ${config.boot.listMs} ms · 折叠 ${config.boot.foldMs} ms` +
|
|
1947
|
+
`(${config.boot.folded}/${config.boot.files} 个日志,读 ${Math.round((config.boot.bytesRead ?? 0) / 1024)} KB;共 ${config.boot.sessions} 个会话)`,
|
|
1948
|
+
)
|
|
1949
|
+
: null,
|
|
1940
1950
|
!effective
|
|
1941
1951
|
? h('div', { className: 'dub-note' }, '正在读取当前配置…')
|
|
1942
1952
|
: h(
|
package/lib/fold.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @module dsh-usage-badge/fold
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
20
|
+
import { closeSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
import { join, resolve } from 'node:path'
|
|
23
23
|
import { zstdDecompressSync } from 'node:zlib'
|
|
@@ -57,34 +57,6 @@ export function resolveDshHome(configured, env = process.env) {
|
|
|
57
57
|
return resolve(expandHome(chosen))
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
* Decode a log body that may hold one or more concatenated zstd frames.
|
|
62
|
-
*
|
|
63
|
-
* DSH appends frames as a session grows, so a `.zstd` log is a frame sequence
|
|
64
|
-
* rather than one stream; `zstdDecompressSync` handles a single frame, so the
|
|
65
|
-
* buffer is split on the frame magic first. A body with no frame magic is
|
|
66
|
-
* returned as UTF-8, which keeps plain `.jsonl` and any partially written file
|
|
67
|
-
* readable instead of throwing.
|
|
68
|
-
*/
|
|
69
|
-
export function decodeLogBody(buf) {
|
|
70
|
-
const starts = []
|
|
71
|
-
let i = 0
|
|
72
|
-
while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
|
|
73
|
-
starts.push(i)
|
|
74
|
-
i += 1
|
|
75
|
-
}
|
|
76
|
-
if (starts.length === 0) return buf.toString('utf8')
|
|
77
|
-
if (typeof zstdDecompressSync !== 'function') {
|
|
78
|
-
throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
|
|
79
|
-
}
|
|
80
|
-
let out = ''
|
|
81
|
-
for (let k = 0; k < starts.length; k++) {
|
|
82
|
-
const end = k + 1 < starts.length ? starts[k + 1] : buf.length
|
|
83
|
-
out += zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
|
|
84
|
-
}
|
|
85
|
-
return out
|
|
86
|
-
}
|
|
87
|
-
|
|
88
60
|
function logParent(file) {
|
|
89
61
|
const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
|
|
90
62
|
return index >= 0 ? file.slice(0, index) : ''
|
|
@@ -139,45 +111,129 @@ export function sessionIdFromLogPath(file) {
|
|
|
139
111
|
}
|
|
140
112
|
|
|
141
113
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* Route attribution follows the log's own control events: `request/header` (and
|
|
145
|
-
* `request/context`) carry the provider and model, and each `assistant/message`
|
|
146
|
-
* carries the provider-reported `usage` for one completed request. Requests are
|
|
147
|
-
* stamped with the hour they were *sent* where the log allows it, because a
|
|
148
|
-
* streaming call can cross a peak/valley boundary and filing it by completion
|
|
149
|
-
* would price it in the wrong band.
|
|
114
|
+
* Read a log's tail without reading the whole file.
|
|
150
115
|
*
|
|
151
|
-
*
|
|
116
|
+
* The fold's cost is dominated by zstd decompression (measured on a real log: 12.7 MB took 775 ms
|
|
117
|
+
* to decode against 133 ms of `JSON.parse`), so re-decoding a log whose only change is an appended
|
|
118
|
+
* frame is the one thing worth avoiding. `from` is the byte offset the last fold consumed — which
|
|
119
|
+
* is exactly the `fileSize` the cursor already stores, so this needs no new field.
|
|
152
120
|
*/
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
121
|
+
function readTail(file, from, size) {
|
|
122
|
+
if (!(from > 0)) return readFileSync(file)
|
|
123
|
+
const length = size - from
|
|
124
|
+
if (!(length > 0)) return Buffer.alloc(0)
|
|
125
|
+
const fd = openSync(file, 'r')
|
|
156
126
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
127
|
+
const buf = Buffer.allocUnsafe(length)
|
|
128
|
+
let read = 0
|
|
129
|
+
while (read < length) {
|
|
130
|
+
const n = readSync(fd, buf, read, length - read, from + read)
|
|
131
|
+
if (n <= 0) break
|
|
132
|
+
read += n
|
|
133
|
+
}
|
|
134
|
+
return read === length ? buf : buf.subarray(0, read)
|
|
135
|
+
} finally {
|
|
136
|
+
closeSync(fd)
|
|
160
137
|
}
|
|
161
|
-
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether a fragment is exactly one complete JSON value — a torn append is not. */
|
|
141
|
+
function isCompleteJsonLine(fragment) {
|
|
142
|
+
if (!fragment || !fragment.trim()) return false
|
|
162
143
|
try {
|
|
163
|
-
|
|
144
|
+
JSON.parse(fragment)
|
|
145
|
+
return true
|
|
164
146
|
} catch {
|
|
165
|
-
return
|
|
147
|
+
return false
|
|
166
148
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Split a plain-text log into newline-aligned slices.
|
|
153
|
+
*
|
|
154
|
+
* Sliced rather than handed over whole so a large `.jsonl` log cannot hold the event loop for a
|
|
155
|
+
* second while the rest of the host waits. The final line may have no trailing newline yet — that
|
|
156
|
+
* is how the last request of an idle log looks, and dropping it would under-count the badge
|
|
157
|
+
* exactly where eyes are — so it counts when it parses, and a torn append is left for next time.
|
|
158
|
+
*/
|
|
159
|
+
function* plainChunks(text) {
|
|
160
|
+
const SLICE = 1 << 20
|
|
161
|
+
let offset = 0
|
|
162
|
+
while (offset < text.length) {
|
|
163
|
+
let end = Math.min(offset + SLICE, text.length)
|
|
164
|
+
if (end < text.length) {
|
|
165
|
+
const nl = text.lastIndexOf('\n', end - 1)
|
|
166
|
+
end = nl >= offset ? nl + 1 : text.length
|
|
167
|
+
}
|
|
168
|
+
let piece = text.slice(offset, end)
|
|
169
|
+
if (end >= text.length) {
|
|
170
|
+
const nl = piece.lastIndexOf('\n')
|
|
171
|
+
piece = piece.slice(0, nl + 1) + (isCompleteJsonLine(piece.slice(nl + 1)) ? piece.slice(nl + 1) : '')
|
|
172
|
+
if (!piece) return
|
|
173
|
+
}
|
|
174
|
+
yield { bytes: Buffer.byteLength(piece, 'utf8'), text: piece }
|
|
175
|
+
offset = end
|
|
174
176
|
}
|
|
177
|
+
}
|
|
175
178
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The chunks of a body that may be folded, each carrying how many bytes it accounts for.
|
|
181
|
+
*
|
|
182
|
+
* A `.zstd` log is a sequence of independently decodable frames that DSH appends to, so "how much
|
|
183
|
+
* can I fold" is a question about frames, not bytes: a frame that does not decode (the appender is
|
|
184
|
+
* mid-write) or whose text stops mid-line is left for the next pass rather than folded half way.
|
|
185
|
+
* Anything that does not begin with a frame magic is plain text, consumed up to its last newline.
|
|
186
|
+
*/
|
|
187
|
+
function* foldableChunks(buf) {
|
|
188
|
+
const starts = []
|
|
189
|
+
let i = 0
|
|
190
|
+
while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
|
|
191
|
+
starts.push(i)
|
|
192
|
+
i += 1
|
|
193
|
+
}
|
|
194
|
+
if (starts.length === 0 || starts[0] !== 0) {
|
|
195
|
+
yield* plainChunks(buf.toString('utf8'))
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (typeof zstdDecompressSync !== 'function') {
|
|
199
|
+
throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
|
|
200
|
+
}
|
|
201
|
+
for (let k = 0; k < starts.length; k++) {
|
|
202
|
+
const end = k + 1 < starts.length ? starts[k + 1] : buf.length
|
|
203
|
+
let piece
|
|
204
|
+
try {
|
|
205
|
+
piece = zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
|
|
206
|
+
} catch {
|
|
207
|
+
return // an unfinished frame: the next pass picks it up
|
|
208
|
+
}
|
|
209
|
+
if (!piece.endsWith('\n')) {
|
|
210
|
+
// Only the last frame can be the end of a log, and then only when its final line is itself
|
|
211
|
+
// complete JSON; anything else is a torn append.
|
|
212
|
+
if (end === buf.length && isCompleteJsonLine(piece.slice(piece.lastIndexOf('\n') + 1))) {
|
|
213
|
+
yield { bytes: end - starts[k], text: piece }
|
|
214
|
+
}
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
yield { bytes: end - starts[k], text: piece }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A bucket copied deep enough that a fold can add to it without touching the stored record. */
|
|
222
|
+
function cloneBucket(bucket) {
|
|
223
|
+
return {
|
|
224
|
+
...bucket,
|
|
225
|
+
hourly: Array.isArray(bucket.hourly) ? bucket.hourly.map((hour) => ({ ...hour })) : bucket.hourly,
|
|
226
|
+
usageRecords: Array.isArray(bucket.usageRecords) ? bucket.usageRecords.map((usage) => ({ ...usage })) : bucket.usageRecords,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
180
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Add one decoded chunk's events to the fold state.
|
|
232
|
+
*
|
|
233
|
+
* Events land in `state.fresh` rather than in the day maps the previous record handed over, so a
|
|
234
|
+
* tail fold that has to be abandoned leaves that record exactly as it was.
|
|
235
|
+
*/
|
|
236
|
+
function foldText(text, state) {
|
|
181
237
|
for (const line of text.split('\n')) {
|
|
182
238
|
if (!line) continue
|
|
183
239
|
let ev
|
|
@@ -187,25 +243,26 @@ export function foldSession(file, previous) {
|
|
|
187
243
|
continue // a torn tail line is expected on a log being appended
|
|
188
244
|
}
|
|
189
245
|
const seq = typeof ev?.seq === 'number' ? ev.seq : -1
|
|
190
|
-
if (seq >
|
|
246
|
+
if (seq > state.seq) state.seq = seq
|
|
191
247
|
const data = ev?.data ?? {}
|
|
192
248
|
if (ev?.type === 'request/header') {
|
|
193
249
|
const config = data.header?.config ?? {}
|
|
194
|
-
provider = config.provider ?? data.provider ?? null
|
|
195
|
-
model = config.model ?? data.model ?? null
|
|
250
|
+
state.provider = config.provider ?? data.provider ?? null
|
|
251
|
+
state.model = config.model ?? data.model ?? null
|
|
196
252
|
} else if (ev?.type === 'request/context') {
|
|
197
|
-
provider = data.provider ?? provider
|
|
198
|
-
model = data.model ?? model
|
|
253
|
+
state.provider = data.provider ?? state.provider
|
|
254
|
+
state.model = data.model ?? state.model
|
|
199
255
|
} else if (ev?.type === 'assistant/message') {
|
|
200
256
|
const usage = data.usage
|
|
201
257
|
if (!usage || typeof usage !== 'object') continue
|
|
202
|
-
const pm = provider ?? UNKNOWN
|
|
203
|
-
const mm = model ?? UNKNOWN
|
|
258
|
+
const pm = state.provider ?? UNKNOWN
|
|
259
|
+
const mm = state.model ?? UNKNOWN
|
|
204
260
|
const date = dayKey(ev.time ?? Date.now())
|
|
205
|
-
|
|
261
|
+
state.dates.add(date)
|
|
262
|
+
let dayObj = state.fresh.get(date)
|
|
206
263
|
if (!dayObj) {
|
|
207
264
|
dayObj = new Map()
|
|
208
|
-
|
|
265
|
+
state.fresh.set(date, dayObj)
|
|
209
266
|
}
|
|
210
267
|
const key = `${pm}|${mm}`
|
|
211
268
|
const bucket = dayObj.get(key) ?? emptyBucket(pm, mm)
|
|
@@ -231,16 +288,115 @@ export function foldSession(file, previous) {
|
|
|
231
288
|
dayObj.set(key, bucket)
|
|
232
289
|
}
|
|
233
290
|
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Commit this pass's own buckets into the record's days, cloning what it shares with the old one. */
|
|
294
|
+
function mergeFresh(state) {
|
|
295
|
+
for (const [date, dayObj] of state.fresh) {
|
|
296
|
+
const existing = state.days.get(date)
|
|
297
|
+
const target = existing ? new Map(existing) : new Map()
|
|
298
|
+
for (const [key, bucket] of dayObj) {
|
|
299
|
+
const known = target.get(key)
|
|
300
|
+
if (!known) {
|
|
301
|
+
target.set(key, bucket)
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
const merged = cloneBucket(known)
|
|
305
|
+
addBucket(merged, bucket)
|
|
306
|
+
target.set(key, merged)
|
|
307
|
+
}
|
|
308
|
+
state.days.set(date, target)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Fold one session log into per-day, per-route buckets.
|
|
314
|
+
*
|
|
315
|
+
* Route attribution follows the log's own control events: `request/header` (and
|
|
316
|
+
* `request/context`) carry the provider and model, and each `assistant/message`
|
|
317
|
+
* carries the provider-reported `usage` for one completed request. Requests are
|
|
318
|
+
* stamped with the hour they were *sent* where the log allows it, because a
|
|
319
|
+
* streaming call can cross a peak/valley boundary and filing it by completion
|
|
320
|
+
* would price it in the wrong band.
|
|
321
|
+
*
|
|
322
|
+
* **Only the new bytes are decoded.** `fromByte` is where the previous fold stopped (the cursor's
|
|
323
|
+
* `fileSize`), and the tail is decoded on its own and merged into `previous`: re-decoding the whole
|
|
324
|
+
* log for one appended frame is what made a start with 25 grown logs cost 3.6 s and a cold cache
|
|
325
|
+
* 20 s. The returned `fileSize` is the offset this pass actually reached — a torn tail stays
|
|
326
|
+
* unfolded and is retried next time for the price of the few bytes it holds.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} file
|
|
329
|
+
* @param {object|null} previous - the record this session already has; required for a tail fold.
|
|
330
|
+
* @param {{fromByte?:number, yieldBytes?:number}} [options]
|
|
331
|
+
* @returns {Promise<object|null>} the session record, or null when the file is unreadable.
|
|
332
|
+
*/
|
|
333
|
+
export async function foldSession(file, previous = null, { fromByte = 0, yieldBytes = 1 << 20 } = {}) {
|
|
334
|
+
const sessionId = sessionIdFromLogPath(file)
|
|
335
|
+
let stat
|
|
336
|
+
try {
|
|
337
|
+
stat = statSync(file)
|
|
338
|
+
} catch {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
const tail = fromByte > 0 && fromByte <= stat.size
|
|
342
|
+
let body
|
|
343
|
+
try {
|
|
344
|
+
body = readTail(file, tail ? fromByte : 0, stat.size)
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const state = {
|
|
350
|
+
// A copy of the map, one level deep: `mergeFresh` replaces whole day entries and cloned
|
|
351
|
+
// buckets, so an abandoned tail fold cannot leave a mark on the record this session already has.
|
|
352
|
+
days: tail && previous?.days ? new Map(previous.days) : new Map(),
|
|
353
|
+
fresh: new Map(),
|
|
354
|
+
dates: new Set(),
|
|
355
|
+
provider: tail ? previous?.provider ?? null : null,
|
|
356
|
+
model: tail ? previous?.model ?? null : null,
|
|
357
|
+
seq: tail ? previous?.seq ?? -1 : -1,
|
|
358
|
+
}
|
|
359
|
+
// `body` holds the tail alone, so offsets inside it are buffer-relative; `consumed` is reported
|
|
360
|
+
// in file coordinates, which is what the cursor stores.
|
|
361
|
+
const shift = tail ? fromByte : 0
|
|
362
|
+
let consumed = shift
|
|
363
|
+
let sinceYield = 0
|
|
364
|
+
try {
|
|
365
|
+
for (const chunk of foldableChunks(body)) {
|
|
366
|
+
foldText(chunk.text, state)
|
|
367
|
+
consumed += chunk.bytes
|
|
368
|
+
// Yield on **bytes decoded**, not on a count of frames: a 12 MB log is ~34 000 frames, so
|
|
369
|
+
// yielding per few frames cost more in `setImmediate` round-trips than the decoding did
|
|
370
|
+
// (measured: a 338-log cold fold went from 21 s to 52 s). One megabyte is ~30 ms of zstd
|
|
371
|
+
// work — small enough that the host keeps breathing, rare enough to be free.
|
|
372
|
+
sinceYield += chunk.bytes
|
|
373
|
+
if (sinceYield >= yieldBytes) {
|
|
374
|
+
sinceYield = 0
|
|
375
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// An unreadable body keeps whatever this session contributed before, so a
|
|
380
|
+
// transient decompression failure cannot erase history.
|
|
381
|
+
return previous ?? null
|
|
382
|
+
}
|
|
383
|
+
mergeFresh(state)
|
|
234
384
|
|
|
235
385
|
return {
|
|
236
386
|
sessionId,
|
|
237
387
|
path: file,
|
|
238
|
-
fileSize:
|
|
388
|
+
fileSize: consumed,
|
|
239
389
|
fileMtimeMs: stat.mtimeMs || 0,
|
|
240
|
-
seq:
|
|
241
|
-
provider,
|
|
242
|
-
model,
|
|
243
|
-
days:
|
|
390
|
+
seq: state.seq,
|
|
391
|
+
provider: state.provider,
|
|
392
|
+
model: state.model,
|
|
393
|
+
days: state.days,
|
|
394
|
+
// Which days this pass itself added. A tail fold may add a day (a session running past
|
|
395
|
+
// midnight does exactly that), but it may never add one *older* than the newest day the record
|
|
396
|
+
// already holds: an append only moves forward, so an older day means the log was rewritten
|
|
397
|
+
// rather than appended to, and the caller re-folds the session from the start.
|
|
398
|
+
touchedDates: [...state.dates],
|
|
399
|
+
foldedBytes: consumed,
|
|
244
400
|
}
|
|
245
401
|
}
|
|
246
402
|
|
|
@@ -288,6 +444,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
288
444
|
const cursors = new Map()
|
|
289
445
|
let days = new Map()
|
|
290
446
|
let lastRefresh = null
|
|
447
|
+
/**
|
|
448
|
+
* Whether the stored history has been read into this process yet.
|
|
449
|
+
*
|
|
450
|
+
* `loadCache()` is deliberately **not** called when the plugin mounts: mounting happens inside DSH's
|
|
451
|
+
* own startup, and reading 300+ sessions' payloads there put forty-odd milliseconds (more on a cold
|
|
452
|
+
* disk, or under a virus scanner) on the host's boot path for numbers nothing has asked for yet.
|
|
453
|
+
* The first `refresh()` — which is what the badge's first request runs — does it instead.
|
|
454
|
+
*/
|
|
455
|
+
let loadedOnce = false
|
|
291
456
|
|
|
292
457
|
function mergeDays() {
|
|
293
458
|
days = mergeRecords(records.values())
|
|
@@ -331,6 +496,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
331
496
|
}
|
|
332
497
|
mergeDays()
|
|
333
498
|
if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
|
|
499
|
+
loadedOnce = true
|
|
334
500
|
return records.size > 0
|
|
335
501
|
}
|
|
336
502
|
|
|
@@ -351,12 +517,23 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
351
517
|
* reason the store is not a single document, and it means there is no save timer to lose
|
|
352
518
|
* work in and no moment where a burst of edits has to be batched.
|
|
353
519
|
*
|
|
354
|
-
* @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
|
|
520
|
+
* @returns {Promise<{files:number, folded:number, tailed:number, bytesRead:number, loadMs:number, listMs:number, ms:number}>} what this pass did.
|
|
355
521
|
*/
|
|
356
522
|
async function refresh() {
|
|
357
523
|
const started = Date.now()
|
|
524
|
+
let loadMs = 0
|
|
525
|
+
if (!loadedOnce) {
|
|
526
|
+
// The first pass of a process is where the stored history comes in — see `loadedOnce`.
|
|
527
|
+
const beforeLoad = Date.now()
|
|
528
|
+
loadCache()
|
|
529
|
+
loadMs = Date.now() - beforeLoad
|
|
530
|
+
}
|
|
531
|
+
const beforeList = Date.now()
|
|
358
532
|
const files = listLogFiles(sessionsRoot)
|
|
533
|
+
const listMs = Date.now() - beforeList
|
|
359
534
|
let folded = 0
|
|
535
|
+
let tailed = 0
|
|
536
|
+
let bytesRead = 0
|
|
360
537
|
let sinceYield = 0
|
|
361
538
|
for (const file of files) {
|
|
362
539
|
const sessionId = sessionIdFromLogPath(file)
|
|
@@ -371,7 +548,35 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
371
548
|
continue
|
|
372
549
|
}
|
|
373
550
|
const previous = records.get(sessionId)
|
|
374
|
-
|
|
551
|
+
// The cursor's `fileSize` is the byte offset the last fold consumed, so a log that only grew
|
|
552
|
+
// is folded from there. Everything else — a shorter log, a different path, no cursor, no
|
|
553
|
+
// record to fold into — is a full re-fold, because a tail read assumes the prefix is intact.
|
|
554
|
+
const tailFrom =
|
|
555
|
+
previous && cursor && cursor.path === file && cursor.fileSize > 0 && stat.size > cursor.fileSize
|
|
556
|
+
? cursor.fileSize
|
|
557
|
+
: 0
|
|
558
|
+
let record = await foldSession(file, tailFrom > 0 ? previous : null, { fromByte: tailFrom })
|
|
559
|
+
let tail = tailFrom > 0 && Boolean(record)
|
|
560
|
+
if (tail && record !== previous) {
|
|
561
|
+
// A tail fold may add a day — a session running past midnight does exactly that — but the
|
|
562
|
+
// days it adds cannot be *older* than the newest one already folded: an append only moves
|
|
563
|
+
// forward. An older day means the file was rewritten in place, so the prefix this pass did
|
|
564
|
+
// not look at is not the prefix the record was built from; fold that session from the start.
|
|
565
|
+
let newest = ''
|
|
566
|
+
for (const date of previous.days?.keys() ?? []) if (date > newest) newest = date
|
|
567
|
+
const stale = newest !== '' && record.touchedDates?.some((date) => date < newest)
|
|
568
|
+
if (stale) {
|
|
569
|
+
record = await foldSession(file, previous, {})
|
|
570
|
+
tail = false
|
|
571
|
+
}
|
|
572
|
+
} else if (!record) {
|
|
573
|
+
tail = false
|
|
574
|
+
}
|
|
575
|
+
if (record) {
|
|
576
|
+
bytesRead += tail && tailFrom > 0 ? Math.max(0, (record.fileSize ?? 0) - tailFrom) : (record.fileSize ?? 0)
|
|
577
|
+
delete record.touchedDates
|
|
578
|
+
delete record.foldedBytes
|
|
579
|
+
}
|
|
375
580
|
// `foldSession` hands the previous record straight back when it could not read the log, and
|
|
376
581
|
// that record is already in the store — writing it again would replace the session's rows
|
|
377
582
|
// with only the days this process happens to hold (the loading window), deleting the older
|
|
@@ -379,6 +584,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
379
584
|
// fold, which is what a transient read failure wants.
|
|
380
585
|
if (record && record !== previous) {
|
|
381
586
|
folded++
|
|
587
|
+
if (tail) tailed++
|
|
382
588
|
// Written back one session at a time, right here: a save costs the changed session's
|
|
383
589
|
// days, not the whole history, so there is nothing to batch and no timer to lose it in.
|
|
384
590
|
//
|
|
@@ -386,9 +592,13 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
386
592
|
// the fold just produced, but when the log got shorter the store keeps the fuller copy
|
|
387
593
|
// it already had — and then *that* is the truth, because the whole point of the row
|
|
388
594
|
// model is that a shortened log cannot make history disappear.
|
|
595
|
+
//
|
|
596
|
+
// `keepOtherDays` is what makes a tail fold safe to write: the record it carries covers
|
|
597
|
+
// the loading window, not the session's whole history, so the days outside the window are
|
|
598
|
+
// this process's blind spot rather than rows the log no longer has.
|
|
389
599
|
let effective = record
|
|
390
600
|
try {
|
|
391
|
-
effective = store.put(record, { source: LOCAL_SOURCE }) ?? record
|
|
601
|
+
effective = store.put(record, { source: LOCAL_SOURCE, keepOtherDays: tail }) ?? record
|
|
392
602
|
} catch (error) {
|
|
393
603
|
log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
|
|
394
604
|
}
|
|
@@ -411,7 +621,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
411
621
|
// being refreshed — and, because nothing writes it again, that history stays in the store
|
|
412
622
|
// for good rather than being erased by the log's absence.
|
|
413
623
|
if (folded > 0) mergeDays()
|
|
414
|
-
lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
|
|
624
|
+
lastRefresh = { files: files.length, folded, tailed, bytesRead, loadMs, listMs, ms: Date.now() - started, at: Date.now() }
|
|
415
625
|
return lastRefresh
|
|
416
626
|
}
|
|
417
627
|
|
package/lib/index.js
CHANGED
|
@@ -87,6 +87,16 @@ const SNAPSHOT_TTL_MS = 2000
|
|
|
87
87
|
*/
|
|
88
88
|
export const PRICING_PUSH_DELAY_MS = 3000
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* How long the plugin waits before warming itself up when nobody has asked for numbers.
|
|
92
|
+
*
|
|
93
|
+
* The **first request** is what really starts the work (see `warmOnce`), and that request cannot
|
|
94
|
+
* arrive before the harness is up — so this timer is only the fallback for a host nobody is looking
|
|
95
|
+
* at. It is deliberately generous: mounting happens inside DSH's own startup, and nothing the plugin
|
|
96
|
+
* does belongs on that path.
|
|
97
|
+
*/
|
|
98
|
+
export const STARTUP_WARM_DELAY_MS = 3000
|
|
99
|
+
|
|
90
100
|
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
91
101
|
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
92
102
|
|
|
@@ -586,9 +596,46 @@ export function apply(ctx) {
|
|
|
586
596
|
// The folded history lives in whichever store this runtime can offer — SQLite when
|
|
587
597
|
// `node:sqlite` is there, the original single-document JSON otherwise. Either way it is one
|
|
588
598
|
// file inside the plugin's own directory.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
599
|
+
//
|
|
600
|
+
// **Nothing is opened here.** `apply()` runs inside DSH's own startup, and a plugin must not put
|
|
601
|
+
// its I/O on that path: opening the database, reading the price table and parsing the stored
|
|
602
|
+
// history all happen on first use — which in practice is the interface's first request, long after
|
|
603
|
+
// the harness is up. `boot` records how long each phase took, so a slow start names its culprit.
|
|
604
|
+
let opened = null
|
|
605
|
+
function ensureOpen() {
|
|
606
|
+
if (opened) return opened
|
|
607
|
+
const storeOpenStarted = Date.now()
|
|
608
|
+
const store = createCacheStore({ dir: dataDir, log })
|
|
609
|
+
const storeMs = Date.now() - storeOpenStarted
|
|
610
|
+
const aggregator = createAggregator({ sessionsRoot, store, log })
|
|
611
|
+
// The price table is read here rather than at mount for the same reason; `refreshOnce()` re-reads
|
|
612
|
+
// it on every pass, so an edit still applies without a restart.
|
|
613
|
+
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
614
|
+
if (pricingSourcePath === legacyPricingPath) {
|
|
615
|
+
log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
|
|
616
|
+
}
|
|
617
|
+
pricing = readPricing(pricingPaths)
|
|
618
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
619
|
+
opened = { store, aggregator }
|
|
620
|
+
boot.storeMs = storeMs
|
|
621
|
+
return opened
|
|
622
|
+
}
|
|
623
|
+
const boot = {
|
|
624
|
+
mountedAt: Date.now(),
|
|
625
|
+
at: 0,
|
|
626
|
+
waitedMs: 0,
|
|
627
|
+
storeMs: 0,
|
|
628
|
+
loadMs: 0,
|
|
629
|
+
listMs: 0,
|
|
630
|
+
foldMs: 0,
|
|
631
|
+
totalMs: 0,
|
|
632
|
+
files: 0,
|
|
633
|
+
sessions: 0,
|
|
634
|
+
folded: 0,
|
|
635
|
+
tailed: 0,
|
|
636
|
+
bytesRead: 0,
|
|
637
|
+
readyAt: 0,
|
|
638
|
+
}
|
|
592
639
|
|
|
593
640
|
/**
|
|
594
641
|
* The sync system, created **on first use** rather than when the plugin mounts.
|
|
@@ -611,7 +658,7 @@ export function apply(ctx) {
|
|
|
611
658
|
if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
|
|
612
659
|
const engine = new SyncEngine({
|
|
613
660
|
dataDir,
|
|
614
|
-
store: lock.acquired ? store : null,
|
|
661
|
+
store: lock.acquired ? ensureOpen().store : null,
|
|
615
662
|
log,
|
|
616
663
|
config: document,
|
|
617
664
|
state: document.state,
|
|
@@ -641,19 +688,20 @@ export function apply(ctx) {
|
|
|
641
688
|
return system
|
|
642
689
|
}
|
|
643
690
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
let pricing = readPricing(pricingPaths)
|
|
649
|
-
let rules = resolveHolidayRules(pricing.holidays)
|
|
691
|
+
let pricing = null
|
|
692
|
+
// A "nothing on" default so anything that reads `rules` before the first load (a log line, say)
|
|
693
|
+
// cannot trip over it; `ensureOpen()` replaces it with the real rules.
|
|
694
|
+
let rules = resolveHolidayRules(undefined)
|
|
650
695
|
let snapshot = null
|
|
651
696
|
let inFlight = null
|
|
652
697
|
let official = null
|
|
698
|
+
/** The status of the warm-up pass, until the first snapshot has reported it. */
|
|
699
|
+
let firstStatus = null
|
|
653
700
|
|
|
654
701
|
/** Serialize concurrent refreshes so a burst of polls folds the logs once. */
|
|
655
702
|
function refreshOnce() {
|
|
656
703
|
if (!inFlight) {
|
|
704
|
+
const { aggregator } = ensureOpen()
|
|
657
705
|
inFlight = (async () => {
|
|
658
706
|
const status = await aggregator.refresh()
|
|
659
707
|
// Re-read pricing every pass so an edit applies without a restart.
|
|
@@ -871,7 +919,7 @@ export function apply(ctx) {
|
|
|
871
919
|
// this reload is for) and the log says why, but the run is still what it was. `loadCache`
|
|
872
920
|
// already swallows a store it cannot read; this covers everything else.
|
|
873
921
|
try {
|
|
874
|
-
aggregator.loadCache({ quiet: true })
|
|
922
|
+
ensureOpen().aggregator.loadCache({ quiet: true })
|
|
875
923
|
} catch (error) {
|
|
876
924
|
log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
|
|
877
925
|
}
|
|
@@ -906,7 +954,11 @@ export function apply(ctx) {
|
|
|
906
954
|
}
|
|
907
955
|
|
|
908
956
|
async function getSnapshot() {
|
|
909
|
-
const
|
|
957
|
+
const { store, aggregator } = ensureOpen()
|
|
958
|
+
// The first request reports the fold it caused, rather than immediately re-checking and reporting
|
|
959
|
+
// "nothing changed" — the numbers the caller is about to see came from that pass.
|
|
960
|
+
const status = firstStatus ?? (await refreshOnce())
|
|
961
|
+
firstStatus = null
|
|
910
962
|
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
911
963
|
// than re-costing every day on each poll.
|
|
912
964
|
if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
|
|
@@ -1060,23 +1112,64 @@ export function apply(ctx) {
|
|
|
1060
1112
|
log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
|
|
1061
1113
|
}
|
|
1062
1114
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
log(`initial fold failed: ${error?.message ?? error}`)
|
|
1115
|
+
/**
|
|
1116
|
+
* The one warm-up pass: open, load the stored history, fold what changed, record the phases.
|
|
1117
|
+
*
|
|
1118
|
+
* It runs on the **first request**, not at mount. Mounting happens inside DSH's own startup and the
|
|
1119
|
+
* plugin has no business doing I/O there — so nothing is opened until something actually asks for
|
|
1120
|
+
* numbers, which is the interface's first poll, long after the harness is up. A fallback timer
|
|
1121
|
+
* (`DSH_USAGE_BADGE_WARM_MS`) covers a host nobody is looking at; whichever happens first wins, and
|
|
1122
|
+
* this is memoised so a burst of polls still only warms once.
|
|
1123
|
+
*/
|
|
1124
|
+
let warmPromise = null
|
|
1125
|
+
function warmOnce() {
|
|
1126
|
+
if (warmPromise) return warmPromise
|
|
1127
|
+
if (!boot.at) {
|
|
1128
|
+
boot.at = Date.now()
|
|
1129
|
+
boot.waitedMs = boot.at - boot.mountedAt
|
|
1079
1130
|
}
|
|
1131
|
+
warmPromise = (async () => {
|
|
1132
|
+
const { store, aggregator } = ensureOpen()
|
|
1133
|
+
try {
|
|
1134
|
+
const status = await refreshOnce()
|
|
1135
|
+
firstStatus = status
|
|
1136
|
+
// The SQLite store has already committed every session it folded; the JSON fallback
|
|
1137
|
+
// batches, and a short-lived host would otherwise pay the full cold scan on every start.
|
|
1138
|
+
aggregator.flush()
|
|
1139
|
+
const passMs = status.ms ?? 0
|
|
1140
|
+
const loadMs = status.loadMs ?? 0
|
|
1141
|
+
const listMs = status.listMs ?? 0
|
|
1142
|
+
Object.assign(boot, {
|
|
1143
|
+
loadMs,
|
|
1144
|
+
listMs,
|
|
1145
|
+
passMs,
|
|
1146
|
+
// What is left of the pass once loading and listing are accounted for: the folding itself.
|
|
1147
|
+
foldMs: Math.max(0, passMs - loadMs - listMs),
|
|
1148
|
+
totalMs: Date.now() - boot.at,
|
|
1149
|
+
files: status.files ?? 0,
|
|
1150
|
+
sessions: aggregator.sessionCount,
|
|
1151
|
+
folded: status.folded ?? 0,
|
|
1152
|
+
tailed: status.tailed ?? 0,
|
|
1153
|
+
bytesRead: status.bytesRead ?? 0,
|
|
1154
|
+
readyAt: Date.now(),
|
|
1155
|
+
})
|
|
1156
|
+
log(
|
|
1157
|
+
`ready in ${boot.totalMs}ms (${boot.waitedMs}ms after mount) — open ${boot.storeMs}ms, load ${boot.loadMs}ms,` +
|
|
1158
|
+
` list ${boot.listMs}ms, fold ${boot.foldMs}ms;` +
|
|
1159
|
+
` ${boot.sessions} session(s) over ${boot.files} log(s), ${boot.folded} folded` +
|
|
1160
|
+
` (${boot.tailed} from the tail only, ${Math.round(boot.bytesRead / 1024)} KB read)` +
|
|
1161
|
+
`; cache: ${store.kind}` +
|
|
1162
|
+
(rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
|
|
1163
|
+
)
|
|
1164
|
+
if (rules.missingYears?.length) {
|
|
1165
|
+
log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
|
|
1166
|
+
}
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
log(`initial fold failed: ${error?.message ?? error}`)
|
|
1169
|
+
}
|
|
1170
|
+
return boot
|
|
1171
|
+
})()
|
|
1172
|
+
return warmPromise
|
|
1080
1173
|
}
|
|
1081
1174
|
|
|
1082
1175
|
async function handle(req, res) {
|
|
@@ -1084,7 +1177,12 @@ export function apply(ctx) {
|
|
|
1084
1177
|
const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
1085
1178
|
|
|
1086
1179
|
try {
|
|
1180
|
+
// Every route needs the store and the price table; only the data routes additionally need the
|
|
1181
|
+
// logs folded, so the fold waits for those (see `warmOnce`).
|
|
1182
|
+
ensureOpen()
|
|
1087
1183
|
if (route === '/summary' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
1184
|
+
// The interface's first poll is what starts the plugin's work: see `warmOnce`.
|
|
1185
|
+
await warmOnce()
|
|
1088
1186
|
sendJson(res, 200, await getSnapshot())
|
|
1089
1187
|
return
|
|
1090
1188
|
}
|
|
@@ -1098,6 +1196,13 @@ export function apply(ctx) {
|
|
|
1098
1196
|
sendJson(res, 400, { error: `year must be a four-digit calendar year, got ${JSON.stringify(year)}` })
|
|
1099
1197
|
return
|
|
1100
1198
|
}
|
|
1199
|
+
// A year older than the loading window is only in the store, so this route needs the logs
|
|
1200
|
+
// folded first — same warm-up the badge's own first request runs.
|
|
1201
|
+
await warmOnce()
|
|
1202
|
+
// This route never reports the warm pass, so do not let it be consumed by the next /summary:
|
|
1203
|
+
// that one should judge the logs as they are when it asks.
|
|
1204
|
+
firstStatus = null
|
|
1205
|
+
const { store } = ensureOpen()
|
|
1101
1206
|
const records = store.load({ since: `${year}-01-01`, until: `${year}-12-31` })
|
|
1102
1207
|
const dayRows = mergeRecords(records)
|
|
1103
1208
|
sendJson(res, 200, summarizeYear(dayRows, pricing, rules, year))
|
|
@@ -1105,6 +1210,7 @@ export function apply(ctx) {
|
|
|
1105
1210
|
}
|
|
1106
1211
|
|
|
1107
1212
|
if (route === '/config' && req.method === 'GET') {
|
|
1213
|
+
const { store } = ensureOpen()
|
|
1108
1214
|
const calendar = describeCalendar(rules)
|
|
1109
1215
|
// The bundled table is reported separately from the resolved rules: a
|
|
1110
1216
|
// deployment with the calendar off still needs to see what data is
|
|
@@ -1160,6 +1266,9 @@ export function apply(ctx) {
|
|
|
1160
1266
|
// neither exists, so the built-in template is in force.
|
|
1161
1267
|
pricingSource: raw.path,
|
|
1162
1268
|
},
|
|
1269
|
+
// How the last start spent its time. A slow start is then attributable instead of a
|
|
1270
|
+
// feeling: the panel shows this line, and the same numbers go to the log.
|
|
1271
|
+
boot: { ...boot },
|
|
1163
1272
|
})
|
|
1164
1273
|
return
|
|
1165
1274
|
}
|
|
@@ -1367,16 +1476,40 @@ export function apply(ctx) {
|
|
|
1367
1476
|
// asynchronous, and a value read after it would be whatever the environment holds by then — which a
|
|
1368
1477
|
// test that sets an override for exactly this mount would already have taken away.
|
|
1369
1478
|
const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
|
|
1479
|
+
// Same reasoning for the fallback warm-up: read the knob now, not inside the timer callback.
|
|
1480
|
+
const warmDelay = delayFor('DSH_USAGE_BADGE_WARM_MS', STARTUP_WARM_DELAY_MS)
|
|
1370
1481
|
// Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
|
|
1371
1482
|
// callback ran anyway, took the lock back, and synced through a store that had already been closed
|
|
1372
1483
|
// (`statement has been finalized`), leaving a lock file behind for a host that was gone.
|
|
1373
1484
|
let disposed = false
|
|
1374
|
-
|
|
1485
|
+
let warmTimer = null
|
|
1486
|
+
/**
|
|
1487
|
+
* Everything the plugin does automatically, in one place: warm the fold, then start the automatic
|
|
1488
|
+
* sync and the remote-device read.
|
|
1489
|
+
*
|
|
1490
|
+
* The fold itself is **not** what this timer is for — the interface's first request does that
|
|
1491
|
+
* (`warmOnce`), and that request cannot arrive before the harness is up. This is only the
|
|
1492
|
+
* fallback for a host nobody is looking at, which is why it waits a moment: nothing here belongs
|
|
1493
|
+
* in DSH's boot path. A zero delay means "warm on mount", which is what a test wants.
|
|
1494
|
+
*/
|
|
1495
|
+
let scheduled = false
|
|
1496
|
+
const begin = () => {
|
|
1497
|
+
warmTimer = null
|
|
1375
1498
|
if (disposed) return
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1499
|
+
void warmOnce().then(() => {
|
|
1500
|
+
if (disposed) return
|
|
1501
|
+
if (!scheduled) {
|
|
1502
|
+
scheduled = true
|
|
1503
|
+
scheduleStartupSync(startupDelay)
|
|
1504
|
+
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1505
|
+
void refreshRemoteDevices()
|
|
1506
|
+
}
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
if (warmDelay > 0) {
|
|
1510
|
+
warmTimer = setTimeout(begin, warmDelay)
|
|
1511
|
+
warmTimer.unref?.()
|
|
1512
|
+
} else begin()
|
|
1380
1513
|
// The disposer is **async on purpose**, and everything below depends on the order:
|
|
1381
1514
|
//
|
|
1382
1515
|
// 1. stop automatic work that would fire into a process that is going away;
|
|
@@ -1390,13 +1523,18 @@ export function apply(ctx) {
|
|
|
1390
1523
|
disposed = true
|
|
1391
1524
|
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1392
1525
|
// would write state for a process that no longer exists.
|
|
1526
|
+
if (warmTimer) clearTimeout(warmTimer)
|
|
1393
1527
|
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1394
1528
|
if (startupSyncTimer) clearTimeout(startupSyncTimer)
|
|
1395
1529
|
if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1530
|
+
// A host that was never asked for numbers never opened anything: there is no store to close, no
|
|
1531
|
+
// lock to hand back, and nothing worth publishing on the way out.
|
|
1532
|
+
if (opened) {
|
|
1533
|
+
try {
|
|
1534
|
+
await syncOnExit()
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
log(`exit sync failed: ${error?.message ?? error}`)
|
|
1537
|
+
}
|
|
1400
1538
|
}
|
|
1401
1539
|
// **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
|
|
1402
1540
|
// re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
|
|
@@ -1412,10 +1550,13 @@ export function apply(ctx) {
|
|
|
1412
1550
|
system.engine.abort()
|
|
1413
1551
|
system.engine.release()
|
|
1414
1552
|
}
|
|
1553
|
+
// Unconditional, and `releaseLock` itself only removes a lock whose pid is this process: a lock left
|
|
1554
|
+
// behind by an earlier mount of this same process (a hot reload) is ours to clean up even when this
|
|
1555
|
+
// mount never created a sync system.
|
|
1415
1556
|
releaseLock(dataDir)
|
|
1416
|
-
aggregator.close()
|
|
1557
|
+
if (opened) opened.aggregator.close()
|
|
1417
1558
|
}
|
|
1418
|
-
}, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
|
|
1559
|
+
}, 'usage-badge: warm the fold on first use, schedule the first sync, and close the cache on unload')
|
|
1419
1560
|
|
|
1420
1561
|
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
1421
1562
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-usage-badge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|