From 100a00c7371b88c8fe0f8014a05c77a6ea010e7f Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 15 Oct 2026 18:41:56 +0800 Subject: [PATCH] feat(rollup): keep ten minutes of transaction detail by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the transactions page reading the read model (#406), the detail window no longer decides how far back the page can answer — the folded minute roll-ups carry that history. So the window can now be set to what the record actually needs, and the host's ruling of 2026-10-15 is that no one queries individual records at all: ten minutes. `[rollup] retain_minutes` therefore defaults to 10 instead of 43200 (30 days), and the example config follows. The deletion itself is unchanged — rows are still only deleted once they are folded *and* archived, so nothing is lost: the folded part stays answerable as minute roll-ups and the original rows stay in the JSONL archive. The transactions note no longer converts the retention boundary into days. It used to round the boundary to whole days, which was already lossy and would now be wrong in a visible way — a ten-minute window rounds to "the last 0 days". The sentence names the boundary instant instead, which is unit-free and needs no second calculation on the client (the server computes the boundary from the same expression the deletion uses). `tx_retention_gate` R1 is rewritten, because its premise was exactly what #406 removed ("the list can always only answer from the detail table"). It now has two halves: - the window must be expressible in minutes — a config that has gone back to spelling it in days turns this red, because day granularity cannot express ten minutes at all: below one day there is only 0, and 0 already means "keep no detail", a different rule; - when the window is shallower than the deepest preset the list offers, the difference can only be carried by the list no longer reading the detail table at all — the ledger is that a page answering from the raw table would silently answer less, with nothing on screen saying so. The rule is not relaxed to nothing: putting the list back on the raw table makes it apply again. Both halves were verified by mutation on the real corpus (config back to days => only R1 goes red; list back to the raw table => only R1 goes red). Tests: `cargo test` 562 passed, 0 failed. `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` rc=0, `cargo build` rc=0. --- config/config.example.toml | 9 +-- src/config.rs | 20 +++--- src/tx_retention_gate.rs | 134 +++++++++++++++++++++++++++++++------ ui/README.md | 17 +++-- ui/index.html | 4 +- ui/js/app.js | 7 +- ui/js/i18n.js | 6 +- 7 files changed, 152 insertions(+), 45 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index de1302b..deb13e9 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -71,10 +71,11 @@ interval_secs = 60 # 归档扫描间隔(秒) enabled = true # 是否启用折叠(默认 true) batch = 2_000 # 每轮最多折叠多少条明细(越小 → 单次持库时间越短) interval_secs = 60 # 折叠扫描间隔(秒) -retain_minutes = 43_200 # 明细保留窗口(**分钟**,默认 43200 = 30 天):早于 - # now-retain_minutes 的已折叠明细每轮删一批。 - # 0 = 不留明细(只留汇总),交易页将只答得出最近一分钟。 - # 旧拼写 retain_days(天)仍认且优先 —— 新配置用本键。 +retain_minutes = 10 # 明细保留窗口(**分钟**,默认 10):早于 now-retain_minutes + # 的已折叠明细每轮删一批。窗口外的明细不丢 —— 交易页读读模型 + # tx_facts,它们以分钟汇总行呈现(一行代表 N 条)。 + # 0 = 不留明细(只留汇总),交易页上每一行都是汇总行。 + # 旧拼写 retain_days(天)仍认且**优先** —— 新配置用本键。 # ============================================================ # 0.5 用量明细保留(rant 2026-10-09T12:28:58 的独立余项):`usage_records` 是同一笔 diff --git a/src/config.rs b/src/config.rs index 3a9380e..78238f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -252,7 +252,7 @@ fn default_rollup_interval_secs() -> u64 { 60 } fn default_rollup_retain_minutes() -> i64 { - 43_200 + 10 } /// 交易明细汇总([rollup] 段,rant 2026-10-09T12:28:58 验收项 1)。 @@ -264,9 +264,12 @@ fn default_rollup_retain_minutes() -> i64 { /// 删除只删**已折叠且已归档**的 —— 于是删掉的行永远有原件(JSONL)与聚合(汇总表)两个落脚处; /// `[archive]` 关掉时水位 0 ⇒ 不折也不删(fail-closed),这是刻意的。 /// -/// 窗口是**明细**的保留窗口(不是汇总的):窗口内的明细仍逐条留在 `transactions` 里, -/// 交易页照常翻页;窗口外的只剩汇总行(与 JSONL 里的原件)。取 0 即「明细只留汇总」的极值 -/// —— 折叠追平后 `transactions` 会被清空,交易页只答得出最近一分钟,**这是刻意的取舍,不是缺陷**。 +/// 窗口是**明细**的保留窗口(不是汇总的)。默认 **10 分钟**:宿主 2026-10-15 的裁定是「根本没有 +/// 查询真实明细的需求」(rant 2026-10-15T16:25:04.530850+08:00),窗口内的明细仍逐条留下, +/// 窗口外的只剩汇总行(与 JSONL 里的原件)—— 而交易页**看得到**那段历史:它读读模型 +/// `tx_facts`,窗口外的那部分以**分钟级汇总行**呈现(一行代表 N 条,行自己用 `row_count` 交代)。 +/// 取 0 即「明细只留汇总」的极值 —— 折叠追平后 `transactions` 会被清空,交易页上每一行都是 +/// 汇总行,**这是刻意的取舍,不是缺陷**。 /// /// 单位是**分钟**(`retain_minutes`)。此前是**天**(`retain_days`,默认 30)—— 那个单位 /// 表达不出「只留 10 分钟」这样的保留策略,而已落地的删除本来就按 `datetime('now', ?)` 算偏移, @@ -283,8 +286,9 @@ pub struct Rollup { /// 折叠扫描间隔(秒,默认 60;最小 1) #[serde(default = "default_rollup_interval_secs")] pub interval_secs: u64, - /// 明细的保留窗口(**分钟**,默认 `43200` = 30 天):早于 `now - retain_minutes` 的 - /// **已折叠**明细每轮删一批。0 = 不留明细(只留汇总),负数按 0 计。 + /// 明细的保留窗口(**分钟**,默认 `10`):早于 `now - retain_minutes` 的**已折叠**明细每轮 + /// 删一批。0 = 不留明细(只留汇总),负数按 0 计。窗口外的明细在交易页上以分钟汇总行呈现 + /// (读模型 `tx_facts`),不丢信息、只是粒度变粗。 #[serde(default = "default_rollup_retain_minutes")] pub retain_minutes: i64, /// **已弃用**:保留窗口的旧拼写(**天**)。给出时**优先**于 `retain_minutes`(×1440), @@ -621,9 +625,9 @@ mod tests { assert!(d.enabled, "默认启用 —— 折叠是「明细只留汇总」的前置"); assert_eq!(d.batch, 2_000); assert_eq!(d.interval_secs, 60); - assert_eq!(d.retain_minutes, 43_200, "明细默认保留 30 天(43200 分钟)"); + assert_eq!(d.retain_minutes, 10, "明细默认只保留 10 分钟"); assert_eq!(d.retain_days, None, "旧拼写缺省不设 —— 新字段才是真源"); - assert_eq!(d.window_minutes(), 43_200); + assert_eq!(d.window_minutes(), 10); } /// 旧拼写 `retain_days`(**天**)仍然算数(×1440),而且**优先**于新键 —— 老配置原样可用。 diff --git a/src/tx_retention_gate.rs b/src/tx_retention_gate.rs index 561f5c0..2bc0d74 100644 --- a/src/tx_retention_gate.rs +++ b/src/tx_retention_gate.rs @@ -6,19 +6,29 @@ //! 删除只受一条约束 —— `transactions` 的**分页列表**要的是逐行身份(`id`/`counterpart`), //! 汇总行没有它,所以列表**永远**只能从明细表答。于是: //! -//! * **保留窗口必须盖得住列表自己提供的每一个预设区间**。少一天都不行:窗口是 -//! `time < datetime('now', '-N minutes')`,列表最深预设若比它还深,用户点那个预设会 -//! 看到「共 N 条」和实际能翻的行数一起缩水,而**没有任何提示**(`total` 与行同源, -//! 所以也不会自相矛盾 —— 它会安静地少答一段历史)。 +//! * **保留窗口必须能用分钟表达,且它在盖不住列表的预设时必须有「还债」的载体**。 +//! 窗口是 `time < datetime('now', '-N minutes')`,列表最深预设若比它还深、而列表又**只** +//! 答得出窗口内的明细,用户点那个预设就会看到「共 N 条」和实际能翻的行数一起缩水,而 +//! **没有任何提示**(`total` 与行同源,所以也不会自相矛盾 —— 它会安静地少答一段历史)。 +//! 本轴的原判据到此为止;rant `2026-10-15T16:25:04` 的验收项 2 把**后半句**改掉了:列表 +//! 改从读模型 `tx_facts` 取数之后,窗口外那段历史有载体(分钟汇总行),债还清了 —— +//! 于是判据写成「窗口盖得住 **或** 列表不再从明细表取数」,而不是把规则删掉:谁把列表搬回 +//! 明细表,这条立刻重新生效(这正是验收项 5 的变异之一)。单位那一半仍然照旧:**读不到 +//! 分钟键**(又退回按「天」写)即红 —— 天粒度表达不了「只留 10 分钟」,一天以下只剩 `0`, +//! 而 `0` 是「完全不留明细」这条**另一条**规则。 //! 判据两侧都**派生**:预设深度从 `ui/js/app.js::txRangeParams` 读,窗口从 -//! `config/config.example.toml` 的 `[rollup] retain_minutes`(分钟)读。 +//! `config/config.example.toml` 的 `[rollup] retain_minutes`(分钟)读,「列表搬没搬」从 +//! `src/routes/wallet.rs` 的列表端点体里读(不再出现 `FROM transactions` ⇒ 搬了)。 //! * **盖不住的那类区间 ——「无界」的 —— 必须把边界说给页面**。`#tx-range` 里有两个区间 //! 没有深度可言(「全部时间」、自定义起点):它们能整段落在保留窗口之外,静态门禁**表达不了** //! 那种深度。判据因此换成「要么不提供、要么披露」:服务端随响应发布保留边界 //! (`tx_rollup` 的 `DETAIL_SINCE_FIELD`,与删除同一算式),前端在**缺口**上消费它 //! (`summary.entries` 与 `total` 的差)。**披露必须长在缺口上,不许长在「列表空」上**: -//! 明细还在(`total > 0`)时同样会少列,而「只在一条明细都没有时才解释」正好让说明只在 -//! **最不需要它**的场合发射(R104)。两侧同样**全派生**:选项名册从 `ui/index.html` 的 +//! R104 轴的原形状是「只在一条明细都没有时才解释」,而那正好让说明只在**最不需要它**的 +//! 场合发射(零行态屏幕本来就写着「没有匹配记录」)。⚠️ 验收项 2 之后「缺口」的含义换了: +//! `entries > total` 不再说「有一段列不出来」,而说「这一屏里有行代表多条记录」(分钟汇总行)。 +//! 判据的形状(以缺口为准、不带空态闸门、被调用)不变 —— 那句话今天要说的正是「更早的行 +//! 是分钟级汇总」,所以它仍然必须长在**有行**的时候。两侧同样**全派生**:选项名册从 `ui/index.html` 的 //! `#tx-range` 读(哪些无界由「该分支有没有 `MS(…)` 深度」判定),字段名从 `tx_rollup.rs` //! 的常量读。 //! * **删除的边界是「已折叠 ∧ 已归档」**。折叠水位(`transactions_rollup.up_to_id`)与 @@ -83,9 +93,17 @@ const OPTION_VALUE: &str = "