Skip to content

fix: cap organization history undo receipts (settings.json bloat hotfix) - #393

Merged
Tianyu199509 merged 6 commits into
mainfrom
fix/organization-history-receipt-cap
Sep 18, 2026
Merged

Tianyu199509 merged 6 commits into
mainfrom
fix/organization-history-receipt-cap

Conversation

@Tianyu199509

@Tianyu199509 Tianyu199509 commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

问题

RecentOrganizationHistory 只限制历史条目数(24),从不限制单条 entry 内的 receipt 数量。多文件大批量整理会无上界膨胀 settings.json——实测一个 1.5.3 真实档案达到 19.8MB(24 条 × 约 520KB 的完整 source/destination/identity receipt),放大两条链路:

  • 每次启动反序列化 19.8MB JSON → 数百 MB 常驻对象图(新实例 3 分钟即 782MB 私有内存,重启无法恢复,因为数据在盘上);
  • 1.5.3 每次防抖保存全量序列化 → 每次分配 ~20MB LOH buffer(perf: stream settings serialization into the store temp file #390 已在 main 修掉分配侧,但数据本身的膨胀仍需治理)。

关键设计约束(两轮深审收敛)

OrganizationHistoryEntry.Items 在 Desktop Organization 体系里四重角色:undo receipt、retry merge state、forward recovery 的 durable commit evidence(RecoverPendingAsync 靠它识别已提交的移动)、结果页 UI 数据。retention 策略只能在 transaction boundary 之后动它。

修复结构(6 commits,经五轮代码审阅迭代)

1. 预算与摘要(b98243b)

  • OrganizationHistoryPolicy:单条 MaxUndoReceiptItemsPerEntry = 500 + 2500 条历史 receipt 预算(MaxUndoReceiptItemBudget——注意:active undo 与 journal 保护的 receipts 不计入预算,生命周期结束后由下一轮 compact 回收,极端情况总量可临时超过 2500);超限降级为摘要(持久化 TotalItemCount 记真实数量、清 Items、CanUndo=false)。Receipt 全有或全无:截断的 receipt 列表绝不保持可撤销。
  • 条目 cap(24)的唯一来源是 policy:keep 集合先收 journal 引用 entry 和所有 active undo(宁超不删),再用最新普通历史填满剩余名额;DesktopOrganizationTransaction 与 settings load 不再各自裁剪。

2. transaction boundary(ea4bbd8)

  • Commit 时序:完整 receipts 落盘(commit point)→ 清 recovery journal → compact → 再 Save。任何窗口崩溃最多留下一个较大的 settings.json,绝不会把已整理的文件移回桌面。
  • Load 不做 destructive 迁移:settings 加载阶段完全不碰 receipts;compact 只在 RecoverPendingAsync 确认 journal 清除/不存在后执行(启动必经,老档案升级后首次启动自动瘦身)。
  • 中断 undo 保护 IsUndoLifecycleActive:undo 进行中/中断的 entry(CanUndo && !IsUndone && (UndoStarted || 任何 IsRestored))一律不降级——ManagedDrop 的中断 undo 没有 journal,receipts 是唯一续命状态。
  • 持久化 UndoReceiptsDiscarded:retry merge 继承标记、TotalItemCount 累加真实总数、永不重获 CanUndo。
  • 运行结果与持久化解耦(第一步):DesktopOrganizationExecutionResult.CompletedItems 快照供结果页。

3. 全链路解耦与 commit 做实(c73c538)

  • OrganizerOperationResult:OrganizeDropAsync / MoveItemsBackToDesktopAsync 返回 { History, CompletedItems }——普通文件格子 501+ 文件导入/移回的 UI 后处理(格子 upsert、排序插入、返回计数)不再读可能已被摘要化的 History.Items;VM 两处 + 自动整理 watcher 已跟随。
  • commit point 做实:所有"删 journal 依赖 settings 已 durable"的保存点全部改 SaveCheckedAsync——ExecuteAsync commit 失败即 throw(journal 保留,下次启动 recovery);undo 对账与 forward 恢复完成点失败保留 journal 重试(幂等);两个 abandon 流程失败 throw(调用方均有失败 UI)。静默保存失败不再可能删掉事务的唯一持久化记录。
  • Abandon = undo 生命周期终点:CanUndo=false + UndoStarted=false + journal 清后立刻 retention,被放弃的 entry 永远不会卡在"active"状态逃过 compact。
  • journal 保护的显式参数:ApplyRetentionPolicy(history, protectedTransactionId)——pending journal 引用的 entry 保留 receipts 且永不成为 24 条裁剪的牺牲品;其余历史正常执行 500/2500/24 策略。
  • 长会话预算:AddHistoryEntryAsync(普通导入路径)现在每次执行全量策略(查共享 journal 获取保护 id),连续操作不再能在两次 compact 之间无限累积。

4. durable 终态与保护顺序(731df77)

  • Abandon 崩溃安全:DesktopOrganizationRecoveryJournal.IsAbandoned 持久终态标记——abandon 先把标记落盘再动 settings;RecoverPendingAsync 拒绝执行 abandoned journal(forward 方向不会把用户选择保留的文件恢复回去),undo 对账分支拒绝终态 entry(!CanUndo || IsUndone,不会把已放弃/已完成的 undo 复活),UndoAsync 防御性拒绝 abandoned journal。
  • cap 服从保护契约:条目 cap 在保护判断之后执行,active undo / journal 引用 entry 永不成为裁剪牺牲品;三处独立 cap 收敛为 policy 单一来源(Transaction 的 RemoveRange 与 load 的 Take(24) 已删)。
  • 普通导入容错:journal 读取失败(替换竞态/损坏)时只 cap 新 entry、跳过 destructive retention——已完成的文件操作绝不因 retention 失败。

5. Abandon WAL finalize(20e08bc)

  • IsAbandoned 从"跳过标记"补成完整 WAL:启动 recovery 跑 TryFinalizeAbandonedJournalAsync——应用 settings 终态(undo 双 false / forward 的 widget/rule/目录 cleanup)→ SaveCheckedAsync → 失败 WAL 留存下次续做 → 成功才清 journal。ApplyAbandonedTerminalState/MarkUndoAbandoned 被 Recover/AbandonPending/AbandonUndo 三入口共用。abandon 事务=写 WAL→应用终态→durable save→删 WAL,任何窗口崩溃下次从 WAL 续做。

6. 终态 durable 确认与 commit 边界(a0fcd5b)

  • RecoverPendingAsync 终态分支在清 journal 前先 SaveCheckedAsync 确认——undo 的 checked save 失败后内存终态不能冒充持久化终态(同进程点恢复也不再可能删掉 journal 留下旧磁盘态);已持久化场景幂等重存。
  • ExecuteAsync 加 committed 标志:durable commit(settings save + journal clear)之后任何 post-commit 失败永不回滚(compaction/目录清理为 best-effort 维护段),回滚严格限定在 commit 前窗口。

真实数据验证

膨胀现场的 19.8MB settings.json 副本(env 门控测试 RealSettingsCopy_MigrationShrinksBloat,DESKBOX_HISTORY_MIGRATION_FIXTURE 指向副本,不碰现场):

19,785,160 bytes -> 91,858 bytes(约 215 倍瘦身)
entries=24(全部保留),retainedReceipts=1(仅 1 条小额历史保留完整撤销)

测试(29 个新增/重写,全套 3722/3722 绿,x64)

  • 单条边界 499/500/501 + 幂等;全局预算真实时间序 newest-first;正常 24×20 小历史完全不动
  • crash window 真重启模拟:完整 receipts + completed journal 落盘 → LoadAsync 从磁盘重载 → recovery 返回 0、文件原地不动、之后才 compact
  • settings 保存失败:settings.json 只读 → ExecuteAsync 抛 IOException → recovery journal 仍存在
  • undo checked-save 失败的完整 durable 矩阵:文件全部恢复+内存终态+保存失败(journal 留存)→ 同进程 RecoverPendingAsync 确认终态后才清 journal → 全新 SettingsService 从磁盘重载断言 IsUndone=true/CanUndo=false
  • 501 文件普通导入 / 501 文件移回桌面:CompletedItems 完整计数 + 持久化 entry 摘要化
  • pending undo journal:启动对账后 receipts/CanUndo/UndoStarted 保留(中断 undo 可继续)
  • discarded retry:600+10=610 累计、永不重获撤销、compact 后 Items=0;正常 retry 仍可撤销
  • abandon 后 compact;journal 引用 entry 的预算/裁剪保护;长会话预算 invariant
  • 601 文件 Desktop Organization:CompletedItems.Count==601 + 持久化摘要 + journal 清后 compact
  • 超长路径不触发降级(数量预算刻意不做字节测量);ItemCount fallback;旧膨胀档案 load 不动 receipts / recovery 后瘦身 >10 倍

不碰 / 后续

… budget

RecentOrganizationHistory capped only the entry count (24), never the
receipt count inside an entry, so repeated multi-thousand-file batches
grew settings.json without bound: a real 1.5.3 profile hit 19.8 MB
(24 entries x ~520 KB of full source/destination/identity receipts),
inflating the startup object graph and every debounced save.

OrganizationHistoryPolicy now bounds retention two ways:

- MaxUndoReceiptItemsPerEntry (500): larger batches keep their history
  entry as a summary - the new persisted TotalItemCount, no receipts,
  CanUndo=false. Undo receipts are all-or-nothing, so a truncated
  receipt list can never stay undoable.
- MaxUndoReceiptItemBudget (2500): when the whole history exceeds the
  budget the oldest entries are downgraded to summaries first; entries
  are never deleted for budget reasons.

The policy runs at both append sites (OrganizerService.AddHistoryEntryAsync
and the desktop organization transaction, including the retry merge) and
in the load normalizer, so an existing bloated profile shrinks on the
first save after upgrade: the real 19.8 MB profile migrates to 90,984
bytes with all 24 entries preserved. Legacy entries without
TotalItemCount fall back to Items.Count for display; retained entries
keep their full DestinationIdentity receipts untouched.

The real-profile migration is reproducible through the env-gated
RealSettingsCopy_MigrationShrinksBloat test (DESKBOX_HISTORY_MIGRATION_FIXTURE).
Review of the first cut found the retention policy had crossed a
transaction boundary: in the desktop organization flow,
OrganizationHistory.Items is also the durable commit evidence that
tells RecoverPendingAsync which moves already committed, the resume
state of an interrupted undo, the retry merge base, and the execution
result page data. Compacting it blindly broke three of those roles.

- Commit order is now: save full receipts (commit point) -> clear the
  recovery journal -> compact -> save again. A crash anywhere in
  between costs at most a larger settings.json, never a wrongly
  restored file. Load-time normalization no longer drops receipts at
  all; compaction runs in RecoverPendingAsync once no journal is
  outstanding, which startup always reaches.
- A discarded transaction is now marked with a persisted
  UndoReceiptsDiscarded flag. Retry merges inherit the marker, carry
  the real TotalItemCount forward, and can never regain CanUndo.
- Entries with an interrupted undo (UndoStarted or any IsRestored
  receipt, not yet IsUndone) are never compacted: their receipts are
  resume state, and an interrupted ManagedDrop undo has no recovery
  journal to consult at all.
- DesktopOrganizationExecutionResult.CompletedItems snapshots this
  run's receipts so the result page renders real counts even after the
  persisted entry becomes a summary; the page no longer reads the
  persisted Items.
- ManagedDrop appends only cap the new entry; the global budget runs
  in the post-recovery compaction so it cannot touch entries that
  still back a pending journal.

New integration tests cover the crash window (committed moves stay
put while full receipts are on disk), the pending-undo journal case,
the discarded-retry case, and a 601-file execution reporting full
results with a compacted entry; the oldest-first budget test now uses
real descending timestamps.
@Tianyu199509

Copy link
Copy Markdown
Owner Author

四个发现全部修复(ea4bbd8,3707/3707 绿),逐条对账:

🔴 F1 — commit 时序重排

采纳你的建议顺序:完整 receipts 落盘(commit point)→ 清 recovery journal → compact → 再 Save。ExecuteAsync 中 ApplyRetentionPolicy 已移到 _recoveryStore.Clear() 之后;三个窗口的后果如你分析:最坏只是没瘦身。回归测试 RecoverPendingAsync_CommittedMovesStayPutWithFullReceiptsOnDisk 直接构造了 crash 窗口的磁盘态(settings 全量 receipts + journal 全 Completed),断言 recovery 返回 0、文件原地不动、journal 正常清理。

🔴 F2 — load 不再 destructive + 中断 undo 保护

NormalizeOrganizerSettings 里的 policy 调用已整体移除——load 阶段完全不碰 receipts。compact 的唯一执行点变为 RecoverPendingAsync 内部的 CompactHistoryAfterJournalResolutionAsync()(journal 确认清除/不存在后),启动必经此路径,老档案升级后首次启动仍会自动瘦身(测试 RecoverPendingAsync_CompactsBloatWhenNoJournalOutstanding:30 条 × 2000 → 24 条 summary、>10 倍瘦身)。

关于"只查 UndoStarted 不够稳":完全同意,而且光查 journal 也不够——ManagedDrop 的中断 undo 根本没有 journal,靠 IsRestored receipts 续命。所以保护规则比 journal 检查更宽:IsUndoLifecycleActive = !IsUndone && (UndoStarted || Items.Any(IsRestored)) 的 entry 一律不降级,等用户完成(IsUndone)或放弃(CanUndo=false)后的下一轮 compact 再收。测试 RecoverPendingAsync_PendingUndoJournalKeepsActiveUndoReceipts 覆盖你描述的对账场景:600 条 receipts、UndoStarted、部分恢复 → 对账后 journal 清了但 receipts/CanUndo/UndoStarted 全保留,空集合 All() 把 IsUndone 误标为 true 的路径不再可达。

🔴 F3 — 显式持久化 UndoReceiptsDiscarded

新增 UndoReceiptsDiscarded(WhenWritingDefault 序列化),不再靠 Items.Count == 0 猜。retry merge 抽成可单测的 OrganizationHistoryPolicy.MergeRetryHistory:previous 已 discarded → 继承标记、CanUndo=false、TotalItemCount = previous.TotalItemCount + 本次数(600+10=610);本次 receipts 保留到 journal 清除后的 compact 统一清掉。policy 对 discarded entry 加了防御:任何后续 Items 一律清空、强制 CanUndo=false,永不复活。测试 MergeRetryHistory_DiscardedTransactionNeverRegainsUndo 锁住 610/不可撤销/compact 后 Items=0,反向用例确认正常 retry 合并后仍可撤销。

🟠 F4 — 执行结果与持久化 History 解耦

DesktopOrganizationExecutionResult 新增 CompletedItems(本次运行完成 receipts 的快照,在 compact 之前拍摄)。结果页三处消费点全部改读快照:RenderExecutionResult(result.CompletedItems)、BuildSourceResult(result.CompletedItems, scope)、PartialResult 计数;undo 部分失败后的重渲染路径仍读 settings 里的真实 entry(那里 receipts 未被 compact,语义不变)。integration 测试:601 文件整理 → CompletedItems.Count == 601 且持久化 entry 已是 summary(Items=0、TotalItemCount=601)。

🟡 F5 — 测试时间方向

GlobalBudget_DowngradesOldestFirst 改为 AddMinutes(-i)(index 0 = now,index 5 = 5 分钟前),并加了 TimestampUtc == Min 断言真正锁 newest-first 契约。

两个 Limitation

均按你的判断保持现状:首次升级仍要完整反序列化一次 19.8MB(迁移后落盘 91,858 字节,下次启动彻底恢复);数量预算不做字节测量。真实档案迁移复测通过:19,785,160 -> 91,858 bytes, entries=24, retainedReceipts=1。

另外两处主动收紧

  • ManagedDrop 追加点只 cap 新 entry,不再跑全列表预算——全列表会碰到可能还在给 pending undo journal 背书的 Desktop Organization entry;全局预算改由 post-recovery compact 执行。
  • RecoverPendingAsync 里"journal 仍有剩余未恢复项"的早退分支不跑 compact(journal 还在)。

journal 拆分仍留后续架构 PR,本 PR 未动。

Second review round found the retention policy still leaked into two
callers, and the commit point was not actually durable.

- OrganizerService.OrganizeDropAsync / MoveItemsBackToDesktopAsync now
  return OrganizerOperationResult: CompletedItems carries this run's
  receipts for callers, History is the persisted entry (possibly a
  receipt-less summary). A 501-file import or move-back no longer
  reports zero completed items; WidgetViewModel and the auto
  organization watcher follow the new shape.
- Every settings save that gates a recovery journal clear is now
  SaveCheckedAsync: ExecuteAsync's commit point throws on failure so
  the journal survives, the undo reconcile and forward recovery
  completion keep the journal on failure, and both abandon flows
  throw. A silent save failure can no longer delete the only durable
  record of a transaction.
- Abandon is now an undo lifecycle endpoint: CanUndo=false plus
  UndoStarted=false, followed by the post-clear retention pass, so an
  abandoned entry can never stay protected from compaction forever.
  IsUndoLifecycleActive additionally requires CanUndo, making every
  non-undoable entry compactable.
- ApplyRetentionPolicy takes a protected transaction id: the entry a
  pending recovery journal still references keeps its receipts and
  survives the entry cap, while the global 2500 budget and the
  24-entry cap now also run on ordinary imports (AddHistoryEntryAsync
  consults the shared journal), so a long session cannot grow history
  without bound between compaction passes.
- The crash-window test now reloads settings from disk as a real
  restart; new tests cover 501-file import/move-back result counts,
  save-failure journal survival, abandon-time compaction, and the
  protected-entry budget. The abandon contract test follows the new
  endpoint semantics.

Real-profile migration unchanged: 19,785,160 -> 91,858 bytes.
@Tianyu199509

Copy link
Copy Markdown
Owner Author

第二轮 6 个发现全部处理(c73c538,3714/3714 绿),PR body 已同步重写。逐条对账:

🔴 B1 — 普通格子的运行结果与持久化 history 彻底解耦

采纳你的建议做了统一:新增 OrganizerOperationResult { History, CompletedItems },OrganizeDropAsync / MoveItemBackToDesktopAsync / MoveItemsBackToDesktopAsync 三个公共方法全部改返回它。快照在 AddHistoryEntryAsync 之前拍摄,调用方全部改读 CompletedItems:

  • WidgetViewModel.ImportPathsAsync:ApplyImportedTransferResultsAsync 与返回数组都改用快照;
  • WidgetViewModel.MoveItemsBackToDesktopAsync / 单文件 MoveItemBackToDesktopAsync:moved 计数改快照;
  • DesktopAutoOrganizationWatcher:改用 operation.History.Id(单文件路径本就不受影响,跟随新签名)。

回归测试:OrganizeDropAsync_LargeBatchReturnsFullCompletedItemsAndSummarizesEntry(501 文件:CompletedItems.Count==501 + 持久化 entry 摘要化)和 MoveItemsBackToDesktopAsync_LargeBatch... 同构。

🔴 B2 — commit point 做实

原则按你说的统一了:凡"下一步删 journal、且删除依赖 settings 已 durable"的保存点,一律 SaveCheckedAsync。五个点:

  • ExecuteAsync commit:失败 throw new IOException(进原有 catch 回滚内存态并 rethrow,journal 保留,下次启动 recovery);
  • UndoAsync(DesktopOrg undo 完成):失败 throw,journal 保留;
  • RecoverPendingAsync undo 对账 / forward 恢复完成:失败记日志并保留 journal 直接 return(对账幂等,下次启动重试);
  • 两个 abandon 流程:失败 throw(TaskView/SettingsSection 两个调用方都有失败 UI,已核实)。

compact 之后的第二笔 save 保持 best effort(失败最坏=文件大一点)。测试 ExecuteAsync_SettingsSaveFailureThrowsAndKeepsRecoveryJournal:settings.json 置只读 → ExecuteAsync 抛 IOException → journal 仍存在。

🟠 H1 — Abandon = 生命周期终点

按你倾向的第二种:AbandonUndoAsync / AbandonPendingRecoveryAsync 现在同时置 CanUndo=false + UndoStarted=false,journal 清除后立刻跑 retention——被放弃的 entry 马上可 compact。另外 IsUndoLifecycleActive 加了 entry.CanUndo && 前置(你的方案一作为防御双保险):任何不可撤销的 entry 都不再被误判为 active。测试 AbandonUndoAsync_CompactsAbandonedEntryReceipts(600 条 receipts → abandon 后清空、TotalItemCount 保留)。#346 的既有契约测试 AbandonUndo_StopsRetriesKeepsReceiptsAndClearsItsJournal 按新语义更新(UndoStarted 断言翻转,注释说明 endpoint 语义;其 1 条 receipt ≤500 本就不降级)。

🟠 H2 — 2500 恢复为实时 invariant

ApplyRetentionPolicy(history, protectedTransactionId):journal 引用的 entry 跳过降级与预算、且永不成为 24 条裁剪的牺牲品(裁剪时 protected 先锁定保留名额,其余按时间新到旧保留)。AddHistoryEntryAsync 每次执行全量策略——查共享默认路径 journal 拿保护 id(HasPendingJournal 时才读文件,低频操作可接受)。顺手把 24 条 cap 也收进 policy(原来只在 Transaction 和 load normalize 执行)。测试三个:protected entry 在 7×500 预算下完好(次老替降)、protected 永不被 cap 裁掉、长会话集成(预置 6×500 后 drop 1 文件 → 总 receipts ≤2500)。

🟡 M1 — 真 restart 模拟

RecoverPendingAsync_CommittedMovesStayPutWithFullReceiptsOnDisk 重写:settings.json 序列化落盘 + journal 落盘 → new SettingsService(...).LoadAsync() 从磁盘重载(顺带锁住 receipts 的持久化 round-trip,含 TotalItemCount/UndoReceiptsDiscarded 字段)→ 新 Transaction + store → recovery 返回 0、文件原地不动、journal 清后 entry 才 compact。save failure 测试如上。

🟡 M2 — Retry 统计口径

PartialResult 文案改用 result.History.ItemCount(= TotalItemCount fallback Items.Count):compact 后 TotalItemCount 是 transaction 累计,retry 后显示 605/610 而非本轮 5;BuildSourceResult/完成卡片保持本轮明细口径(累计的 per-scope 分布在摘要后拿不到,属后续 PR 范围)。

🔵 L — PR body 已重写

按三段 commit 结构(预算与摘要 / transaction boundary / 全链路解耦与 commit 做实)重写,含设计约束、测试清单、已知 Limitation,与代码一致。

真实档案迁移复测不变:19,785,160 -> 91,858 bytes, entries=24, retainedReceipts=1。

Third review round: two crash windows and a cap ordering bug around the
retain/protect contract.

- Abandon is now crash-safe via a durable journal terminal marker:
  DesktopOrganizationRecoveryJournal.IsAbandoned is persisted BEFORE the
  abandon touches settings, and RecoverPendingAsync refuses to act on
  an abandoned journal (a crash between marking and clearing can never
  re-execute what the user gave up). The undo-reconcile branch also
  refuses terminal entries (!CanUndo || IsUndone), so a crash between a
  finished undo and the journal clear cannot flip CanUndo back on, and
  UndoAsync rejects abandoned journals defensively.
- The entry cap now runs after the protection contract, not before it:
  EnforceEntryCap keeps every protected entry (journal-referenced AND
  active undos) first and fills the remaining slots with newest normal
  history — if protected entries alone exceed the cap, the list grows
  past it rather than dropping resume state. DesktopOrganizationTransaction's
  own RemoveRange(24) is gone and the settings load no longer trims the
  count: the retention policy is the single source of the cap.
- Ordinary imports no longer hard-depend on a journal parse:
  TryRunRetentionPolicyAsync swallows read failures and the caller caps
  only the brand-new entry (CapEntryReceipts), so a corrupt or
  mid-replacement journal can never fail an import whose files already
  moved; the next safe compaction pass picks up the rest. The shared
  store's journal path is injectable so tests never touch the real
  data directory.

New tests: abandoned-undo journal must not revive, abandoned marker must
not restore files, active undo survives an over-cap list, corrupt
journal leaves an import successful. Real-profile migration unchanged:
19,785,160 -> 91,858 bytes.
@Tianyu199509

Copy link
Copy Markdown
Owner Author

第三轮 3 个发现全部修复(731df77,3718/3718 绿),PR body 预算表述已按 Medium 1 修正。逐条对账:

🔴 B1 — Abandon 的 durable 终态

两个方向都按你的建议做了:

Undo 方向(复活窗口):RecoverPendingAsync 的 undo 对账分支在 ApplyUndoReceipts 之前加了终态判断——if (!history.CanUndo || history.IsUndone) { Clear journal; compact; return 0; }。同时涵盖"abandon 已持久化但 journal 未清"和"undo 已完成但 crash 在 clear 前"两个窗口,ApplyUndoReceipts 不可能再把 CanUndo 翻回来。测试 RecoverPendingAsync_AbandonedUndoJournalIsNotRevived 锁住。

Forward 方向(反向恢复):DesktopOrganizationRecoveryJournal 新增持久化 IsAbandoned 标记。两个 abandon 流程都改为先把 journal 标记 IsAbandoned=true 并 SaveAsync 落盘,再动 settings——标记是 durable 的,之后任何窗口崩溃,启动恢复看到标记直接清 journal 返回,绝不执行 restore。测试 RecoverPendingAsync_AbandonedMarkerNeverRestores(标记落盘 + settings 还是旧态 + destination 文件在 → recovery 返回 0、文件不动)。另外 UndoAsync 的 pending 分支也加了 IsAbandoned 防御(拒绝把 abandoned journal 当对账素材)。

🔴 B2 — cap 优先于保护的顺序错误

认账:EnforceEntryCap 在 IsProtected 判断之前跑,只认 protectedTransactionId 不认 active undo——和你引用的注释自相矛盾。三套独立 cap 也确实存在。修复:

  1. EnforceEntryCap 的 keep 集合改为先收所有 IsProtected 的 entry(journal 引用 + 所有 active undo),再用普通历史按新到旧填满 24 - keep.Count;protected 自身超 24 时宁超不删(Take(Math.Max(0, cap - keep.Count)))。
  2. DesktopOrganizationTransaction.ExecuteAsync 的独立 RemoveRange(24) 已删除(Insert 后交给 commit 后的 policy compact 统一收口)。
  3. SettingsService load 的 Take(24) 也已移除(只保留排序)——load 发生在 startup recovery 之前,这里裁剪会在不知道 journal 的情况下丢 forward crash window 的 commit evidence。cap 的唯一来源现在是 OrganizationHistoryPolicy,只在确认 journal 状态的安全窗口执行。

测试 ApplyRetentionPolicy_EntryCapNeverTrimsActiveUndo(25 条含最老 active partial undo → cap 后 active 完整保留、恰 24 条);LoadAsync_DoesNotDowngradeBloatWithoutRecoveryPass 断言更新为 30 条 load 后全保留(cap 等 recovery 后的 compact)。

🟠 High — 普通导入不再硬依赖 journal parse

按你的语义做了 TryRunRetentionPolicyAsync:journal 不存在或可靠读到 → 跑全量 policy(带保护 id);读失败(替换竞态/损坏/File.Exists 后消失/Deserialize 异常)→ 吞掉并只对新 entry 跑 CapEntryReceipts(新 entry 刚创建、绝不被 journal 引用,cap 它永远安全),其余 entry 等下一次安全 compact——宁可暂时不瘦身,绝不让已完成的文件操作报失败。测试 OrganizeDropAsync_CorruptJournalDoesNotFailTheImport(垃圾 JSON journal → 单文件导入正常返回 1 个完成项)。顺手把共享 store 的 journal 路径改成可注入(internal ctor 第 5 参),测试不再碰真实数据目录。共享锁没加——原子替换 + 读失败容错已覆盖竞态后果,加锁收益有限(如你所说它是"更完整"选项,留后续)。

🟡 两个 Medium

  • 预算表述:PR body 已改为 "2500 historical receipt budget——active undo 与 journal 保护的 receipts 不计入预算,生命周期结束后由下一轮 compact 回收",并注明极端情况总量可临时超过 2500。
  • 单一来源:随 B2 完成,policy 是 cap 的唯一执行点(Transaction 与 load 的两套已删)。

真实档案迁移复测不变:19,785,160 -> 91,858 bytes, entries=24, retainedReceipts=1。

The IsAbandoned marker made recovery refuse to execute an abandoned
journal, but it cleared the WAL without completing the abandon's
settings-side half: a crash between the WAL write and the settings save
left the abandon permanently unfinished (an undo entry the user gave up
on stayed resumable; a forward abandon's widget/rule/directory cleanup
never ran).

Recovery now runs TryFinalizeAbandonedJournalAsync: apply the terminal
state (undo: CanUndo/UndoStarted flip; forward: RemoveUncommittedWidgets),
persist with a checked save, and only then clear the journal. When the
save fails the WAL survives and the next startup retries - the abandon
eventually completes instead of being dropped. The finalize core is
shared with AbandonPendingRecoveryAsync (and the terminal-state flip
with AbandonUndoAsync) so the semantics cannot drift apart.

Tests: abandoned undo WAL with pre-abandon settings finalizes the
terminal state; abandoned forward WAL completes the created-widget/rule/
directory cleanup; a failed finalize keeps the WAL and a second recovery
pass finishes it.
@Tianyu199509

Copy link
Copy Markdown
Owner Author

最后的 Blocker 已修(20e08bc,3721/3721 绿)——完全按你给的结构把 IsAbandoned 从"跳过标记"补成完整 WAL finalize。

实现

RecoverPendingAsync 的 IsAbandoned 分支改为调用 TryFinalizeAbandonedJournalAsync(journal):

应用 settings 终态(undo: CanUndo/UndoStarted 双 false;forward: RemoveUncommittedWidgets)
→ SaveCheckedAsync
→ 失败:WAL 留存,return false(下次启动继续 finalize)
→ 成功:Clear journal → compact

语义复用按你的建议做了单一来源:

  • ApplyAbandonedTerminalState(journal, history):终态应用核心(undo 双 false / forward cleanup),被 TryFinalizeAbandonedJournalAsync 和 AbandonPendingRecoveryAsync 共用;
  • MarkUndoAbandoned(history):双 false 翻转,AbandonUndoAsync 的无 journal 路径也走它;
  • AbandonPendingRecoveryAsync 重构为"WAL 写标记 → TryFinalizeAbandonedJournalAsync → 失败 throw",交互路径和启动 finalize 不再是两套逻辑。

整个 abandon 事务现在是:写 WAL → 应用 settings 终态 → durable SaveChecked → 删 WAL——任何一步崩溃,下次启动从 WAL 继续 2~4 步,abandon 最终一定完成。

三个测试(对应你点的 crash matrix)

  1. RecoverPendingAsync_AbandonedUndoWALFinalizesTerminalState——正是你指出的上一版没覆盖的窗口:WAL 落盘成功 + settings 仍是 CanUndo=true/UndoStarted=true(crash 在 settings save 之前)→ RecoverPending → 双 false 落地 + journal 清。
  2. RecoverPendingAsync_AbandonedForwardWALCompletesCleanup——forward WAL + pre-abandon settings + 应清理的空 created widget/rule/目录 → RecoverPending → widget、rule、目录全部清掉 + journal 清。
  3. RecoverPendingAsync_AbandonedFinalizeRetriesAfterSaveFailure——settings.json 只读 → finalize 失败 → WAL 留存 → 恢复写权限后第二次 recovery 补完 finalize(journal 清 + 终态落地)。

真实档案迁移复测不变(19,785,160 → 91,858 bytes)。等你下一轮最终 regression sweep。

Two transaction boundary findings from the full regression sweep.

- RecoverPendingAsync's terminal-undo branch cleared the journal on the
  strength of the in-memory entry state alone. After an undo whose
  checked save failed earlier in the same process (files restored,
  receipts reconciled in memory, disk still pre-undo), that branch
  would delete the journal and leave the stale disk state as the only
  record — a restart would then show a resumable undo for files that
  are already back on the desktop. The branch now runs a checked save
  first: durable confirmation before the journal clear, idempotent for
  the crash-after-save case, journal kept on failure.
- ExecuteAsync kept its rollback catch active after the durable commit
  (settings save + journal clear). Any post-commit failure would
  restore the original in-memory graphs while the files are already
  moved and no journal remains to reconcile them — strictly worse than
  the failure itself. A committed flag now scopes the rollback to the
  pre-commit window; compaction and directory cleanup after the flag
  are best-effort post-commit maintenance.

The new test walks the full durability matrix: undo with a failing
checked save keeps the journal, a same-process recovery confirms the
terminal state before clearing it, and a fresh settings reload proves
the disk state is terminal.
@Tianyu199509

Copy link
Copy Markdown
Owner Author

两个事务边界都已修(a0fcd5b,3722/3722 绿),PR body 已同步到 6 commits / 3722 测试。逐条对账:

🔴 Blocker — 终态的 durable 确认

完全按你给的修法:RecoverPendingAsync 的终态分支(!history.CanUndo || history.IsUndone)在 Clear journal 之前先 SaveCheckedAsync——成功才清 + compact;失败记日志、journal 留存、返回(下次重试)。对"终态原本已持久化、crash 在 save 与 clear 之间"的场景是幂等重存,无副作用。

新增测试 UndoSaveFailure_ThenSameProcessRecoverKeepsDiskTerminalStateDurable 完整走了你的 12 步矩阵:

  1. 正常 ExecuteAsync(2 文件整理);
  2. UndoAsync 前置 settings.json 只读 → 文件全部物理恢复、receipts 在内存对账为终态 → Assert.ThrowsAsync<IOException>;
  3. journal 仍存在;内存 entry IsUndone=true / CanUndo=false;
  4. 恢复可写;
  5. 同一进程同一 SettingsService 实例调 RecoverPendingAsync → journal 被清(此前的洞就在这一步会只凭内存终态清 journal);
  6. 全新 SettingsService(dataDir) + LoadAsync 从磁盘重载 → 断言磁盘 entry IsUndone=true / CanUndo=false——终态真正 durable,不是内存残留。

🟠 High — commit 后永不回滚

ExecuteAsync 加了 committed 标志:SaveCheckedAsync 成功 + _recoveryStore.Clear() 之后置 true;catch 里 if (committed) throw; 直接穿透——durable commit 之后的任何 post-commit 失败不再恢复 originalWidgets/Rules/History(那会在文件已移动、journal 已删的状态下制造比异常本身更严重的不一致)。compact save 与 RemoveEmptyCreatedDirectories 明确标注为 best-effort post-commit 维护段,语义与你引用的注释("journal gone 后最多留下较大的 settings")一致。回滚严格限定在 pre-commit 窗口。

🟡 Low — PR body

已同步:6 commits 结构(新增第 5 段 WAL finalize、第 6 段终态确认与 commit 边界)、测试数 3722/3722、undo durable 矩阵测试入清单。

五轮收敛下来的事务不变量现在完整闭环:receipts 是 transaction 状态时永不可动;删 journal 前对应 settings 状态必须 checked-durable(commit、终态、abandon finalize 三处同规);durable commit 之后永不回滚;WAL 标记配套 finalize 重放;宁不瘦身不让文件操作失败。等你下一轮最终合并审计。

@Tianyu199509
Tianyu199509 merged commit 81983ec into main Sep 18, 2026
1 check passed
@Tianyu199509
Tianyu199509 deleted the fix/organization-history-receipt-cap branch September 18, 2026 00:15
Tianyu199509 added a commit that referenced this pull request Sep 18, 2026
Version bump (4 files), changelog, both READMEs, and the v1.5.4
release notes with installer and store-package hashes. Ships the
batch-operation memory work (#386/#388/#390/#391/#393) plus the
RenderWindow verbose-log downgrade.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant