Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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` 是同一笔
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的
Expand Down
65 changes: 55 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand All @@ -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 万行需要很长时间)
Expand All @@ -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<i64>,
}

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 {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
33 changes: 17 additions & 16 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ fn tx_rollup_tick(
db: &std::sync::Mutex<rusqlite::Connection>,
archive_dir: &std::path::Path,
batch: i64,
retain_days: i64,
retain_minutes: i64,
) -> (usize, usize) {
match db.try_lock() {
Ok(conn) => {
Expand All @@ -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}");
Expand All @@ -404,21 +404,22 @@ 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(),
);
tokio::spawn(async move {
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} 行");
}
Expand Down Expand Up @@ -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(
Expand All @@ -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"
);
Expand All @@ -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();
Expand All @@ -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),
"库正忙必须跳过"
);
Expand Down
2 changes: 1 addition & 1 deletion src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ pub async fn transactions(
// `transactions` 里 —— 聚合(summary/trend)答得出、本列表答不出的分界就是它。
// 折叠关掉时**没有**删除发生,也就没有这条边界 ⇒ 不发(`null`),页面不会去说一件没发生的事。
let detail_since: Option<String> = 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
};
Expand Down
36 changes: 21 additions & 15 deletions src/tx_retention_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` 里有两个区间
//! 没有深度可言(「全部时间」、自定义起点):它们能整段落在保留窗口之外,静态门禁**表达不了**
//! 那种深度。判据因此换成「要么不提供、要么披露」:服务端随响应发布保留边界
Expand Down Expand Up @@ -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]";
Expand Down Expand Up @@ -113,8 +116,8 @@ struct Reading {
presets_days: Vec<i64>,
/// 读不懂的 `MS(…)` 表达式(应 0:读不懂就等于那条预设**没有判据**)。
unreadable: Vec<String>,
/// 示例配置里的保留窗口(天);读不到 = `None`。
retain_days: Option<i64>,
/// 示例配置里的保留窗口(**分钟**);读不到 = `None`。
retain_minutes: Option<i64>,
/// 删除函数体里是否出现「只删明细表」。
deletes_details_only: bool,
/// 删除函数体里是否带上折叠水位。
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -435,8 +440,8 @@ fn disclosure(js: &str, field: &str) -> Disclosure {
}
}

/// 从示例配置的 `[rollup]` 段读保留窗口(天)。注释行不算(同段注释里提到过这个键名)。
fn retain_days(toml: &str) -> Option<i64> {
/// 从示例配置的 `[rollup]` 段读保留窗口(**分钟**)。注释行不算(同段注释里提到过这个键名)。
fn retain_minutes(toml: &str) -> Option<i64> {
let mut in_section = false;
for raw in toml.lines() {
let line = raw.trim();
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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<dyn Fn(&mut Reading)>,
),
(
Expand Down Expand Up @@ -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()
);
Expand Down
Loading
Loading