Make the epoch store durable and fail loudly on a missing or unreadable one #36

Closed
opened 2026-10-09 13:25:04 +01:00 by vnprc-agent · 1 comment
Collaborator

Make the epoch store durable and make a missing or unreadable store fail loudly, so a crash or a deleted file cannot silently discard epoch history.

Primary goals:

  • Writes survive power loss — the record file is fsynced, so an interrupted write leaves either the old content or the new, never a truncated file.
  • A missing store is an error, not a fresh start — the mint refuses to re-genesis over an absent or unparseable store unless something explicitly says to.
  • Writes stay off the async mutex — blocking filesystem calls do not run while the rotation lock is held.

Current state and specifics

EpochStore in roles/mint/src/lib/epoch/store.rs persists the epoch records as JSON beside the mint database, written by atomic rename. Three gaps follow.

The rename is not fsynced, so a power loss can leave a truncated or zero-length epochs.json. On the next start the mint cannot parse it.

That failure mode is worse than it looks, because an absent or unreadable store is indistinguishable from a first boot: the mint opens a genesis epoch at the current height and carries on. Every earlier epoch is then forgotten, which means their outstanding quotes lose the processor registration that keeps them resolvable, and their holders can never issue. The same happens if someone deletes the file to "reset" something. A loud failure is the correct behaviour; the operator can then choose to re-genesis deliberately.

The store's write also happens inside the async rotation mutex, so a slow disk blocks rotation rather than just that write.

Scope

In scope: fsync the record file and its directory on write; distinguish "no store exists and the operator asked for a fresh start" from "the store is missing or unreadable", failing loudly on the latter; and move the blocking filesystem work off the async lock.

Decide and state how a deliberate fresh start is expressed, since just clean cashu already removes the store as the documented clean-slate step and must keep working.

Out of scope: moving epoch records into the mint database, which is a larger change and would want its own issue.

Validation

Truncate epochs.json to zero length and confirm the mint refuses to start rather than re-genesising. Remove it entirely and confirm the same, then confirm the documented clean-slate path still produces a working fresh mint. For durability, confirm the fsync is actually issued rather than asserting it from the code; a deliberate test that the file survives a simulated interrupted write is better than reading the call site.

Make the epoch store durable and make a missing or unreadable store fail loudly, so a crash or a deleted file cannot silently discard epoch history. Primary goals: - **Writes survive power loss** — the record file is fsynced, so an interrupted write leaves either the old content or the new, never a truncated file. - **A missing store is an error, not a fresh start** — the mint refuses to re-genesis over an absent or unparseable store unless something explicitly says to. - **Writes stay off the async mutex** — blocking filesystem calls do not run while the rotation lock is held. ### Current state and specifics `EpochStore` in `roles/mint/src/lib/epoch/store.rs` persists the epoch records as JSON beside the mint database, written by atomic rename. Three gaps follow. The rename is not fsynced, so a power loss can leave a truncated or zero-length `epochs.json`. On the next start the mint cannot parse it. That failure mode is worse than it looks, because an absent or unreadable store is indistinguishable from a first boot: the mint opens a genesis epoch at the current height and carries on. Every earlier epoch is then forgotten, which means their outstanding quotes lose the processor registration that keeps them resolvable, and their holders can never issue. The same happens if someone deletes the file to "reset" something. A loud failure is the correct behaviour; the operator can then choose to re-genesis deliberately. The store's write also happens inside the async rotation mutex, so a slow disk blocks rotation rather than just that write. ### Scope In scope: fsync the record file and its directory on write; distinguish "no store exists and the operator asked for a fresh start" from "the store is missing or unreadable", failing loudly on the latter; and move the blocking filesystem work off the async lock. Decide and state how a deliberate fresh start is expressed, since `just clean cashu` already removes the store as the documented clean-slate step and must keep working. Out of scope: moving epoch records into the mint database, which is a larger change and would want its own issue. ### Validation Truncate `epochs.json` to zero length and confirm the mint refuses to start rather than re-genesising. Remove it entirely and confirm the same, then confirm the documented clean-slate path still produces a working fresh mint. For durability, confirm the fsync is actually issued rather than asserting it from the code; a deliberate test that the file survives a simulated interrupted write is better than reading the call site.
Author
Collaborator

Superseded by a decision on vnprc/hashpool#37 (2026-10-09): the epoch store moves out of epochs.json and into the mint database's key-value store, written in the same transaction as the cdk state it describes. That meets this issue's three goals by construction rather than by code: durability is sqlite's (no fsync of our own), a missing or unreadable store cannot exist separately from the database (there is one file; just clean cashu removes it and that is the clean slate), and writes go through cdk's async database layer, off the rotation lock's executor thread. The fsync, fresh-start and mismatch work built on the earlier shape is dropped. #37 will carry the closing reference once the move is on its branch.

Superseded by a decision on vnprc/hashpool#37 (2026-10-09): the epoch store moves out of `epochs.json` and into the mint database's key-value store, written in the same transaction as the cdk state it describes. That meets this issue's three goals by construction rather than by code: durability is sqlite's (no fsync of our own), a missing or unreadable store cannot exist separately from the database (there is one file; `just clean cashu` removes it and that is the clean slate), and writes go through cdk's async database layer, off the rotation lock's executor thread. The fsync, fresh-start and mismatch work built on the earlier shape is dropped. #37 will carry the closing reference once the move is on its branch.
vnprc closed this issue 2026-10-11 03:27:13 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
vnprc/hashpool#36
No description provided.