From ab53919ae084f157cfbb3c279504a30ed4d97591 Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 15 Oct 2026 16:45:30 +0800 Subject: [PATCH] refactor(config): express the detail-retention window in minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rollup deletion window was spelled in days (`[rollup] retain_days`, default 30). That unit cannot express a sub-day policy: the only value below one day is 0, and 0 already means "keep no detail at all" — a different rule, not a shorter window. The deletion itself has always been computed as a `datetime('now', )` offset, so minutes are its natural resolution. `[rollup] retain_minutes` (default 43200 = 30 days) replaces it, and the new `Rollup::window_minutes()` becomes the single read point shared by the deletion loop, `tx_rollup::detail_since` (the boundary published to the page) and the startup log, so "which field wins" has one answer. Behaviour is unchanged: the default is the same 30 days, and the legacy `retain_days` key is still accepted (x1440) and takes precedence, so existing configs keep the window they had. `config.example.toml` moves to the new spelling. Tests: `delete_folded_resolves_the_window_in_minutes` pins the new resolution with minute-scale fixtures (a 5-minute-old row survives a 10-minute window and goes under a 3-minute one), and `rollup_legacy_retain_days_still_selects_the_window` pins the legacy key, its precedence, and the new unit's ability to express 10 minutes. --- config/config.example.toml | 4 ++- docs/architecture.md | 2 +- src/config.rs | 65 +++++++++++++++++++++++++++++------ src/main.rs | 33 +++++++++--------- src/routes/wallet.rs | 2 +- src/tx_retention_gate.rs | 36 ++++++++++++-------- src/tx_rollup.rs | 70 ++++++++++++++++++++++++++------------ 7 files changed, 147 insertions(+), 65 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index 8eb22bf..de1302b 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -71,8 +71,10 @@ interval_secs = 60 # 归档扫描间隔(秒) enabled = true # 是否启用折叠(默认 true) batch = 2_000 # 每轮最多折叠多少条明细(越小 → 单次持库时间越短) interval_secs = 60 # 折叠扫描间隔(秒) -retain_days = 30 # 明细保留窗口(天):早于 now-retain_days 的已折叠明细每轮删一批。 +retain_minutes = 43_200 # 明细保留窗口(**分钟**,默认 43200 = 30 天):早于 + # now-retain_minutes 的已折叠明细每轮删一批。 # 0 = 不留明细(只留汇总),交易页将只答得出最近一分钟。 + # 旧拼写 retain_days(天)仍认且优先 —— 新配置用本键。 # ============================================================ # 0.5 用量明细保留(rant 2026-10-09T12:28:58 的独立余项):`usage_records` 是同一笔 diff --git a/docs/architecture.md b/docs/architecture.md index 91a809d..963aa81 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ > **明细表的保留策略(v0.7.30 起,库不再无限增长)**:`transactions` 与 `usage_records` 只增不减, > 服务现在**先归档、再删除**。`transactions`:明细 → 归档成 `<数据目录>/archive/transactions-*.jsonl` > (`src/archive.rs`)→ 折叠成 `transactions_rollup`(`src/tx_rollup.rs`)→ **聚合改从视图 `tx_facts` -> (=汇总 ∪ 未折叠明细,`src/tx_facts.rs`)取数** → 删除已折叠且已归档、过了 `[rollup] retain_days` +> (=汇总 ∪ 未折叠明细,`src/tx_facts.rs`)取数** → 删除已折叠且已归档、过了 `[rollup] retain_minutes` > 的行。分页列表要的是逐行身份(汇总行没有),仍读明细 ⇒ 它与聚合会在保留窗口之外分叉, > `/api/transactions` 随响应发布这段边界。`usage_records`:读者全是写死的月/日窗口,**不需要折叠** —— > 归档后按同一套日历窗口删除(`src/usage_retention.rs`)。两表的开关与批次见 `config.example.toml` 的 diff --git a/src/config.rs b/src/config.rs index e514e09..3a9380e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -251,8 +251,8 @@ fn default_rollup_batch() -> i64 { fn default_rollup_interval_secs() -> u64 { 60 } -fn default_rollup_retain_days() -> i64 { - 30 +fn default_rollup_retain_minutes() -> i64 { + 43_200 } /// 交易明细汇总([rollup] 段,rant 2026-10-09T12:28:58 验收项 1)。 @@ -264,9 +264,14 @@ fn default_rollup_retain_days() -> i64 { /// 删除只删**已折叠且已归档**的 —— 于是删掉的行永远有原件(JSONL)与聚合(汇总表)两个落脚处; /// `[archive]` 关掉时水位 0 ⇒ 不折也不删(fail-closed),这是刻意的。 /// -/// `retain_days` 是**明细**的保留窗口(不是汇总的):窗口内的明细仍逐条留在 `transactions` 里, +/// 窗口是**明细**的保留窗口(不是汇总的):窗口内的明细仍逐条留在 `transactions` 里, /// 交易页照常翻页;窗口外的只剩汇总行(与 JSONL 里的原件)。取 0 即「明细只留汇总」的极值 /// —— 折叠追平后 `transactions` 会被清空,交易页只答得出最近一分钟,**这是刻意的取舍,不是缺陷**。 +/// +/// 单位是**分钟**(`retain_minutes`)。此前是**天**(`retain_days`,默认 30)—— 那个单位 +/// 表达不出「只留 10 分钟」这样的保留策略,而已落地的删除本来就按 `datetime('now', ?)` 算偏移, +/// 分钟是它天然的分辨率。旧键 `retain_days` 仍然**认**(见 [`Rollup::window_minutes`]), +/// 不静默忽略。 #[derive(Debug, Clone, Deserialize)] pub struct Rollup { /// 是否启用(默认 true;折叠是「明细只留汇总」的前置,回填 97 万行需要很长时间) @@ -278,10 +283,25 @@ pub struct Rollup { /// 折叠扫描间隔(秒,默认 60;最小 1) #[serde(default = "default_rollup_interval_secs")] pub interval_secs: u64, - /// 明细的保留窗口(天,默认 30):早于 `now - retain_days` 的**已折叠**明细每轮删一批。 - /// 0 = 不留明细(只留汇总),负数按 0 计。 - #[serde(default = "default_rollup_retain_days")] - pub retain_days: i64, + /// 明细的保留窗口(**分钟**,默认 `43200` = 30 天):早于 `now - retain_minutes` 的 + /// **已折叠**明细每轮删一批。0 = 不留明细(只留汇总),负数按 0 计。 + #[serde(default = "default_rollup_retain_minutes")] + pub retain_minutes: i64, + /// **已弃用**:保留窗口的旧拼写(**天**)。给出时**优先**于 `retain_minutes`(×1440), + /// 于是老配置原样可用;新配置请写 `retain_minutes`。 + #[serde(default)] + pub retain_days: Option, +} + +impl Rollup { + /// 明细保留窗口的**唯一取值点**(分钟)—— 删除、`detail_since`、启动日志都只经它, + /// 免得「读哪个字段」有两处答案。旧拼写 `retain_days` 给出时优先(×1440)。 + pub fn window_minutes(&self) -> i64 { + match self.retain_days { + Some(days) => days.max(0).saturating_mul(1440), + None => self.retain_minutes.max(0), + } + } } impl Default for Rollup { @@ -290,7 +310,8 @@ impl Default for Rollup { enabled: default_rollup_enabled(), batch: default_rollup_batch(), interval_secs: default_rollup_interval_secs(), - retain_days: default_rollup_retain_days(), + retain_minutes: default_rollup_retain_minutes(), + retain_days: None, } } } @@ -600,7 +621,30 @@ mod tests { assert!(d.enabled, "默认启用 —— 折叠是「明细只留汇总」的前置"); assert_eq!(d.batch, 2_000); assert_eq!(d.interval_secs, 60); - assert_eq!(d.retain_days, 30, "明细默认保留 30 天"); + assert_eq!(d.retain_minutes, 43_200, "明细默认保留 30 天(43200 分钟)"); + assert_eq!(d.retain_days, None, "旧拼写缺省不设 —— 新字段才是真源"); + assert_eq!(d.window_minutes(), 43_200); + } + + /// 旧拼写 `retain_days`(**天**)仍然算数(×1440),而且**优先**于新键 —— 老配置原样可用。 + /// 这条同时是「新单位真的能表达 10 分钟」的判据:旧的「天」写不出它。 + #[test] + fn rollup_legacy_retain_days_still_selects_the_window() { + let legacy: Rollup = toml::from_str("retain_days = 30").unwrap(); + assert_eq!(legacy.window_minutes(), 43_200, "旧拼写按天 ×1440"); + + // 两个键同时给出 ⇒ 旧拼写赢(升配置的人不必同时清掉旧行,也不会被静默改掉窗口) + let both: Rollup = toml::from_str("retain_minutes = 10\nretain_days = 30").unwrap(); + assert_eq!(both.window_minutes(), 43_200, "旧键优先"); + assert_eq!(both.retain_minutes, 10, "新键照旧解析出来(只是不生效)"); + + // 只给新键 ⇒ 就是它自己:10 分钟这个窗口在旧的「天」单位下**写不出来** + let new_only: Rollup = toml::from_str("retain_minutes = 10").unwrap(); + assert_eq!(new_only.window_minutes(), 10, "10 分钟现在真的表达得出来"); + + // 负值按 0 计(与 `delete_folded` 的 `max(0)` 同一条纪律) + let negative: Rollup = toml::from_str("retain_minutes = -5").unwrap(); + assert_eq!(negative.window_minutes(), 0, "负数按 0 计"); } #[test] @@ -611,7 +655,8 @@ mod tests { assert!(cfg.rollup.enabled); assert_eq!(cfg.rollup.batch, 2_000); assert_eq!(cfg.rollup.interval_secs, 60); - assert_eq!(cfg.rollup.retain_days, Rollup::default().retain_days); + assert_eq!(cfg.rollup.retain_minutes, Rollup::default().retain_minutes); + assert_eq!(cfg.rollup.retain_days, None, "示例配置用新拼写"); } #[test] diff --git a/src/main.rs b/src/main.rs index 5fe10a1..392a8b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -366,7 +366,7 @@ fn tx_rollup_tick( db: &std::sync::Mutex, archive_dir: &std::path::Path, batch: i64, - retain_days: i64, + retain_minutes: i64, ) -> (usize, usize) { match db.try_lock() { Ok(conn) => { @@ -380,7 +380,7 @@ fn tx_rollup_tick( }; // 删除排在折叠**之后**:本轮刚折出来的行也可能已过保留窗口(回填老库时几乎必然), // 先折后删让「已折叠」这个前提在同一个 tick 里就成立。 - let deleted = match tx_rollup::delete_folded(&conn, archived, retain_days, batch) { + let deleted = match tx_rollup::delete_folded(&conn, archived, retain_minutes, batch) { Ok(n) => n, Err(e) => { log::warn!("删除已折叠的明细失败: {e}"); @@ -404,10 +404,10 @@ fn spawn_tx_rollup( cfg: &config::Rollup, ) -> anyhow::Result<()> { let batch = cfg.batch; - let retain_days = cfg.retain_days; + let retain_minutes = cfg.window_minutes(); let every = std::time::Duration::from_secs(cfg.interval_secs.max(1)); log::info!( - "交易明细汇总已启用: {}(每 {}s 一批 ≤ {batch} 行,明细保留 {retain_days} 天)", + "交易明细汇总已启用: {}(每 {}s 一批 ≤ {batch} 行,明细保留 {retain_minutes} 分钟)", archive_dir.display(), every.as_secs(), ); @@ -415,10 +415,11 @@ fn spawn_tx_rollup( loop { // 同归档:同步 DB I/O 走 blocking 线程,不占 tokio worker(worker 是 `/healthz` 的命脉)。 let (db, dir) = (db.clone(), archive_dir.clone()); - let (folded, deleted) = - tokio::task::spawn_blocking(move || tx_rollup_tick(&db, &dir, batch, retain_days)) - .await - .unwrap_or((0, 0)); + let (folded, deleted) = tokio::task::spawn_blocking(move || { + tx_rollup_tick(&db, &dir, batch, retain_minutes) + }) + .await + .unwrap_or((0, 0)); if folded > 0 || deleted > 0 { log::debug!("交易明细汇总: 折叠 +{folded} 行、删除 {deleted} 行"); } @@ -712,7 +713,7 @@ mod tests { [], ) .unwrap(); - // 时间**相对 now** 取:保留窗口的判据是 `time < datetime('now', '-N days')`, + // 时间**相对 now** 取:保留窗口的判据是 `time < datetime('now', '-N minutes')`, // 写死一个字面量会让「现在是不是已经过了那个点」变成隐藏前提(UTC 与本地差 8 小时)。 for age_days in [40, 1] { conn.execute( @@ -728,19 +729,19 @@ mod tests { // 归档水位 0(归档还没跟到任何一行)⇒ 既不折也不删(fail-closed) assert_eq!( - tx_rollup_tick(&db, &archive_dir, 100, 30), + tx_rollup_tick(&db, &archive_dir, 100, 43_200), (0, 0), "未归档不得折叠、也不得删除" ); - // 归档追到第 2 行 ⇒ 折叠 2 条;40 天前那条已过 30 天窗口 ⇒ 同轮删掉,1 天前那条留下 + // 归档追到第 2 行 ⇒ 折叠 2 条;40 天前那条已过 43_200 分钟窗口 ⇒ 同轮删掉,1 天前那条留下 std::fs::write(archive_dir.join("watermark"), "2").unwrap(); assert_eq!( - tx_rollup_tick(&db, &archive_dir, 100, 30), + tx_rollup_tick(&db, &archive_dir, 100, 43_200), (2, 1), "窗口内的明细不得删" ); assert_eq!( - tx_rollup_tick(&db, &archive_dir, 100, 30), + tx_rollup_tick(&db, &archive_dir, 100, 43_200), (0, 0), "追平后为 0" ); @@ -759,11 +760,11 @@ mod tests { .unwrap(); assert_eq!(left, 1, "只应删掉窗口外的那一条"); } - // 窗口 0 天 ⇒ 剩下那条也过了窗口,一并删掉;汇总行**永远**不动 + // 窗口 0 分钟 ⇒ 剩下那条也过了窗口,一并删掉;汇总行**永远**不动 assert_eq!( tx_rollup_tick(&db, &archive_dir, 100, 0), (0, 1), - "窗口 0 天应放出全部已折叠明细" + "窗口 0 分钟应放出全部已折叠明细" ); { let conn = db.lock().unwrap(); @@ -779,7 +780,7 @@ mod tests { // 库被占住 ⇒ 本轮安静跳过((0,0)),而不是阻塞等在锁上 let guard = db.lock().unwrap(); assert_eq!( - tx_rollup_tick(&db, &archive_dir, 100, 30), + tx_rollup_tick(&db, &archive_dir, 100, 43_200), (0, 0), "库正忙必须跳过" ); diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 2b5d12f..80506b3 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -581,7 +581,7 @@ pub async fn transactions( // `transactions` 里 —— 聚合(summary/trend)答得出、本列表答不出的分界就是它。 // 折叠关掉时**没有**删除发生,也就没有这条边界 ⇒ 不发(`null`),页面不会去说一件没发生的事。 let detail_since: Option = if st.cfg.rollup.enabled { - Some(tx_rollup::detail_since(&conn, st.cfg.rollup.retain_days).map_err(internal)?) + Some(tx_rollup::detail_since(&conn, st.cfg.rollup.window_minutes()).map_err(internal)?) } else { None }; diff --git a/src/tx_retention_gate.rs b/src/tx_retention_gate.rs index 547ad59..561f5c0 100644 --- a/src/tx_retention_gate.rs +++ b/src/tx_retention_gate.rs @@ -7,11 +7,11 @@ //! 汇总行没有它,所以列表**永远**只能从明细表答。于是: //! //! * **保留窗口必须盖得住列表自己提供的每一个预设区间**。少一天都不行:窗口是 -//! `time < datetime('now', '-N days')`,列表最深预设若比 `N` 还深,用户点那个预设会 +//! `time < datetime('now', '-N minutes')`,列表最深预设若比它还深,用户点那个预设会 //! 看到「共 N 条」和实际能翻的行数一起缩水,而**没有任何提示**(`total` 与行同源, //! 所以也不会自相矛盾 —— 它会安静地少答一段历史)。 //! 判据两侧都**派生**:预设深度从 `ui/js/app.js::txRangeParams` 读,窗口从 -//! `config/config.example.toml` 的 `[rollup] retain_days` 读。 +//! `config/config.example.toml` 的 `[rollup] retain_minutes`(分钟)读。 //! * **盖不住的那类区间 ——「无界」的 —— 必须把边界说给页面**。`#tx-range` 里有两个区间 //! 没有深度可言(「全部时间」、自定义起点):它们能整段落在保留窗口之外,静态门禁**表达不了** //! 那种深度。判据因此换成「要么不提供、要么披露」:服务端随响应发布保留边界 @@ -53,8 +53,11 @@ const RANGE_FN: &str = "function txRangeParams("; /// 预设区间的表达形态:`MS(<整数乘积>)`,`MS(h)` 就是 `h` 小时。 const MS: &str = "MS("; -/// 保留窗口所在文件的键。 -const RETAIN_KEY: &str = "retain_days"; +/// 保留窗口所在文件的键(**分钟**)。 +const RETAIN_KEY: &str = "retain_minutes"; + +/// 一天的分钟数 —— 预设深度(天)与保留窗口(分钟)同单位比较用。 +const MINUTES_PER_DAY: i64 = 1440; /// 示例配置里窗口所在的段。 const RETAIN_SECTION: &str = "[rollup]"; @@ -113,8 +116,8 @@ struct Reading { presets_days: Vec, /// 读不懂的 `MS(…)` 表达式(应 0:读不懂就等于那条预设**没有判据**)。 unreadable: Vec, - /// 示例配置里的保留窗口(天);读不到 = `None`。 - retain_days: Option, + /// 示例配置里的保留窗口(**分钟**);读不到 = `None`。 + retain_minutes: Option, /// 删除函数体里是否出现「只删明细表」。 deletes_details_only: bool, /// 删除函数体里是否带上折叠水位。 @@ -144,8 +147,10 @@ struct Reading { impl Reading { /// R1:产品自己提供的每个预设区间都落在保留窗口内。 fn r1(&self) -> bool { - match (self.presets_days.last(), self.retain_days) { - (Some(deepest), Some(window)) => *deepest <= window, + match (self.presets_days.last(), self.retain_minutes) { + // 两侧**同单位**比较(预设是「天」、窗口是「分钟」)—— 直接比会把「10 分钟」 + // 当成 10 天,或(换算成天再取整)当成 0 天。 + (Some(deepest), Some(window)) => deepest.saturating_mul(MINUTES_PER_DAY) <= window, _ => false, } } @@ -171,13 +176,13 @@ impl Reading { fn blame(&self) -> String { format!( - "r1={} r2={} r3={} r4={} · 预设 {:?} 天 · 窗口 {:?} 天 · 读不懂 {:?} · 只删明细 {} · 折叠界 {} · 归档界 {} · 两界同句 {} · 时间窗 {} · 区间选项 {:?} · 无界 {:?} · 边界字段 {:?} · 已发布 {} · 披露生产函数 {:?}(以缺口为准 {} · 空态闸门 {} · 被调用 {})", + "r1={} r2={} r3={} r4={} · 预设 {:?} 天 · 窗口 {:?} 分钟 · 读不懂 {:?} · 只删明细 {} · 折叠界 {} · 归档界 {} · 两界同句 {} · 时间窗 {} · 区间选项 {:?} · 无界 {:?} · 边界字段 {:?} · 已发布 {} · 披露生产函数 {:?}(以缺口为准 {} · 空态闸门 {} · 被调用 {})", self.r1(), self.r2(), self.r3(), self.r4(), self.presets_days, - self.retain_days, + self.retain_minutes, self.unreadable, self.deletes_details_only, self.has_fold_bound, @@ -435,8 +440,8 @@ fn disclosure(js: &str, field: &str) -> Disclosure { } } -/// 从示例配置的 `[rollup]` 段读保留窗口(天)。注释行不算(同段注释里提到过这个键名)。 -fn retain_days(toml: &str) -> Option { +/// 从示例配置的 `[rollup]` 段读保留窗口(**分钟**)。注释行不算(同段注释里提到过这个键名)。 +fn retain_minutes(toml: &str) -> Option { let mut in_section = false; for raw in toml.lines() { let line = raw.trim(); @@ -504,7 +509,7 @@ fn read() -> Reading { Reading { presets_days: presets, unreadable, - retain_days: retain_days(include_str!("../config/config.example.toml")), + retain_minutes: retain_minutes(include_str!("../config/config.example.toml")), // 「只删明细表」= 出现 `DELETE FROM transactions` 而**不**出现汇总表的删除形态。 deletes_details_only: body.contains("DELETE FROM transactions") && !body.contains("DELETE FROM transactions_rollup"), @@ -561,7 +566,8 @@ fn the_retention_window_covers_every_preset_the_list_offers_negatives() { ( "窗口收到列表最深的预设之下", Box::new(|r: &mut Reading| { - r.retain_days = Some(r.presets_days.last().copied().unwrap_or(0) - 1); + r.retain_minutes = + Some(r.presets_days.last().copied().unwrap_or(0) * MINUTES_PER_DAY - 1); }) as Box, ), ( @@ -668,7 +674,7 @@ fn the_scanner_sees_both_sides() { ); assert!(r.unreadable.is_empty(), "有读不懂的预设: {}", r.blame()); assert!( - r.retain_days.is_some(), + r.retain_minutes.is_some(), "示例配置里读不到 {RETAIN_KEY}(段名或键名变了?):{}", r.blame() ); diff --git a/src/tx_rollup.rs b/src/tx_rollup.rs index 3570098..07f47b8 100644 --- a/src/tx_rollup.rs +++ b/src/tx_rollup.rs @@ -190,7 +190,7 @@ pub fn fold_pending(conn: &Connection, archive_watermark: i64, batch: i64) -> Re /// 1. **`id ≤ MIN(汇总水位, 归档水位)`** —— 只删汇总表已经算过、且原件已经进 JSONL 的行。 /// 汇总水位是 `fold_pending` 自己写下的 `up_to_id`,归档水位由调用方传入(与折叠**同源**); /// 取小 ⇒ 归档一旦落后(或归档目录丢失 ⇒ 水位 0)就一行都不删,与折叠同一条 fail-closed 纪律。 -/// 2. **`time < now - retain_days`** —— 保留窗口内的明细仍逐条留在库里(交易页翻页、共享页按行 +/// 2. **`time < now - retain_minutes`** —— 保留窗口内的明细仍逐条留在库里(交易页翻页、共享页按行 /// 读的都是 `transactions` 本身)。窗口是**明细**的保留期;**汇总行永远不删**。 /// 3. **`LIMIT batch`** —— 单次持库时间有界,后台维护绝不与请求抢锁(与折叠、归档同一条纪律)。 /// @@ -203,13 +203,13 @@ pub fn fold_pending(conn: &Connection, archive_watermark: i64, batch: i64) -> Re /// 明细保留边界(UTC,`YYYY-MM-DDTHH:MM:SSZ`):**早于它的明细不保证还在 `transactions` 里**。 /// /// 这是「列表答不出来的那段」的**唯一定义处** —— 与 [`delete_folded`] 同一个时钟、同一个算式 -/// (SQL 的 `datetime('now', '-N days')`,负数按 0 处理)⇒ 删的是它、页面说的是它,两者不可能 +/// (SQL 的 `datetime('now', '-N minutes')`,负数按 0 处理)⇒ 删的是它、页面说的是它,两者不可能 /// 各自算出一个边界。读侧只读**边界值**,不做删除判断(那是 `delete_folded` 的事)。 /// /// ⚠️ 边界早于它 ≠ 明细已经没了:删除还受「已折叠 ∧ 已归档」两道闸(见 `delete_folded`)。 /// 所以本值是**上界**——真正少答的那段只可能落在它之外。调用方据此说明口径,不据此断言缺失。 -pub fn detail_since(conn: &Connection, retain_days: i64) -> Result { - let window = format!("-{} days", retain_days.max(0)); +pub fn detail_since(conn: &Connection, retain_minutes: i64) -> Result { + let window = format!("-{} minutes", retain_minutes.max(0)); conn.query_row( "SELECT strftime('%Y-%m-%dT%H:%M:%SZ', datetime('now', ?1))", params![window], @@ -221,14 +221,14 @@ pub fn detail_since(conn: &Connection, retain_days: i64) -> Result { pub fn delete_folded( conn: &Connection, archive_watermark: i64, - retain_days: i64, + retain_minutes: i64, batch: i64, ) -> Result { let upto = watermark(conn)?.min(archive_watermark); if batch <= 0 || upto <= 0 { return Ok(0); } - let window = format!("-{} days", retain_days.max(0)); + let window = format!("-{} minutes", retain_minutes.max(0)); conn.execute( "DELETE FROM transactions WHERE id IN ( \ SELECT id FROM transactions \ @@ -691,9 +691,10 @@ mod tests { .unwrap() } - /// 种一批明细:`ages` 里每个元素是「多少天前」,用它算出 `time`。 - /// 窗口判据是 `time < datetime('now', '-N days')`,所以夹具必须相对 `now` 取, - /// 写死字面量会把「跑测试时已经过了那个点没有」变成隐藏前提。 + /// 种一批明细:`ages` 里每个元素是「多少天前」,用它算出 `time`(整天的夹具更好读, + /// 而 SQLite 的 `-N days` 修饰词照样吃)。 + /// 窗口判据是 `time < datetime('now', '-N minutes')`(窗口单位是**分钟**,见 [`delete_folded`]), + /// 所以夹具必须相对 `now` 取,写死字面量会把「跑测试时已经过了那个点没有」变成隐藏前提。 fn seed_aged(conn: &Connection, ages: &[i64]) { for age in ages { conn.execute( @@ -708,14 +709,14 @@ mod tests { #[test] fn delete_folded_only_releases_rows_older_than_the_retention_window() { let conn = migrated_db(); - // 40/20/1 天前各一条 —— 窗口 30 天 ⇒ 只应放出 40 天前那条。 + // 40/20/1 天前各一条 —— 窗口 43_200 分钟(30 天)⇒ 只应放出 40 天前那条。 seed_aged(&conn, &[40, 20, 1]); assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 3, "先全折"); - assert_eq!(delete_folded(&conn, 1_000, 30, 100).unwrap(), 1); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 100).unwrap(), 1); assert_eq!(tx_count(&conn), 2, "窗口内的明细必须留下"); // 已经删过 ⇒ 幂等(同窗口再跑不删任何行) - assert_eq!(delete_folded(&conn, 1_000, 30, 100).unwrap(), 0); - // 窗口收到 0 天 ⇒ 剩下的(20/1 天前)也都过了窗口 + assert_eq!(delete_folded(&conn, 1_000, 43_200, 100).unwrap(), 0); + // 窗口收到 0 分钟 ⇒ 剩下的(20/1 天前)也都过了窗口 assert_eq!(delete_folded(&conn, 1_000, 0, 100).unwrap(), 2); assert_eq!(tx_count(&conn), 0); // 汇总行一个不少:明细删光之后聚合仍然答得出(这是「明细只留汇总」的全部意义) @@ -724,21 +725,48 @@ mod tests { assert_eq!(rows.iter().map(|r| r.3).sum::(), 3, "行数守恒"); } + /// 窗口的**分辨率是分钟**:旧单位「天」把 10 分钟这种窗口变成无法表达的存在 + /// (`-0 days` 就是「全放」),而分钟级的窗口正是「真实明细只留 10 分钟」那句诉求的前提。 + /// 这条用分钟级的夹具把新单位真的按分钟算钉住 —— 只把 `N` 由 30 换成 43_200 的测试 + /// 两棵树都会绿,分不出单位有没有生效。 + #[test] + fn delete_folded_resolves_the_window_in_minutes() { + let conn = migrated_db(); + // 5 / 20 分钟前各一条(SQLite 的修饰词直接按分钟给) + for age in [5, 20] { + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status, time) \ + VALUES (1, '', NULL, 'm', 1, 1, 'consume', '成功', datetime('now', ?1))", + params![format!("-{age} minutes")], + ) + .unwrap(); + } + assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 2, "先全折"); + // 窗口 10 分钟 ⇒ 只放出 20 分钟前那条;5 分钟前那条是窗口内的 + assert_eq!(delete_folded(&conn, 1_000, 10, 100).unwrap(), 1); + assert_eq!(tx_count(&conn), 1, "窗口内的明细必须留下"); + // 窗口收到 3 分钟 ⇒ 剩下那条(5 分钟前)也过了窗口 + assert_eq!(delete_folded(&conn, 1_000, 3, 100).unwrap(), 1); + assert_eq!(tx_count(&conn), 0); + // 汇总行一个不少 ⇒ 「明细没了、账还在」 + assert_eq!(rollup_rows(&conn).len(), 2, "两个分钟桶应各一行"); + } + #[test] fn delete_folded_never_gets_ahead_of_the_fold_or_the_archive() { let conn = migrated_db(); seed_aged(&conn, &[60, 50]); // 一条都没折过 ⇒ 水位 0 ⇒ 一行都不删(即使它们都老得过了窗口) assert_eq!(watermark(&conn).unwrap(), 0); - assert_eq!(delete_folded(&conn, 1_000, 30, 100).unwrap(), 0); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 100).unwrap(), 0); assert_eq!(tx_count(&conn), 2, "没折过不许删"); // 折了,但归档水位只到第 1 行 ⇒ 最多删到 id 1(归档没跟上的那半不许动) assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 2); - assert_eq!(delete_folded(&conn, 1, 30, 100).unwrap(), 1); + assert_eq!(delete_folded(&conn, 1, 43_200, 100).unwrap(), 1); assert_eq!(tx_count(&conn), 1, "归档没覆盖到的行不许删"); // 归档追平 ⇒ 剩下的那条才可删 - assert_eq!(delete_folded(&conn, 1_000, 30, 100).unwrap(), 1); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 100).unwrap(), 1); assert_eq!(tx_count(&conn), 0); } @@ -748,15 +776,15 @@ mod tests { seed_aged(&conn, &[90, 80, 70]); assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 3); // 批量 2 ⇒ 每轮最多删 2 条,剩下的下一轮再删(后台维护不长时间持库) - assert_eq!(delete_folded(&conn, 1_000, 30, 2).unwrap(), 2); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 2).unwrap(), 2); assert_eq!(tx_count(&conn), 1); - assert_eq!(delete_folded(&conn, 1_000, 30, 2).unwrap(), 1); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 2).unwrap(), 1); assert_eq!(tx_count(&conn), 0); // 边界:批次 <= 0 或水位 <= 0 ⇒ 一行都不删(不无限删) seed_aged(&conn, &[90]); assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 1); - assert_eq!(delete_folded(&conn, 1_000, 30, 0).unwrap(), 0); - assert_eq!(delete_folded(&conn, 0, 30, 100).unwrap(), 0); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 0).unwrap(), 0); + assert_eq!(delete_folded(&conn, 0, 43_200, 100).unwrap(), 0); assert_eq!(tx_count(&conn), 1, "非法参数下明细必须还在"); } @@ -787,7 +815,7 @@ mod tests { }; let before = read(&conn); assert_eq!(fold_pending(&conn, 1_000, 100).unwrap(), 3); - assert_eq!(delete_folded(&conn, 1_000, 30, 100).unwrap(), 3); + assert_eq!(delete_folded(&conn, 1_000, 43_200, 100).unwrap(), 3); assert_eq!(tx_count(&conn), 0, "明细确实清空了"); let after = read(&conn); assert_eq!(