Skip to content

feat(yield): persist a daily snapshot ledger per active plan - #1210

Open
bilhokista wants to merge 4 commits into
Fracverse:masterfrom
bilhokista:feat/1130-yield-snapshot-ledger
Open

feat(yield): persist a daily snapshot ledger per active plan#1210
bilhokista wants to merge 4 commits into
Fracverse:masterfrom
bilhokista:feat/1130-yield-snapshot-ledger

Conversation

@bilhokista

Copy link
Copy Markdown

Closes #1130.

What this adds

A daily_yield_snapshots table and a worker that writes one row per active, yield-earning plan each day. Accrued yield was only ever computed on read from plans.last_ping, so there was no record of what a plan was worth on any past day — and none could be reconstructed after a rate change.

The snapshot copies principal and yield_rate_bps as they stood on the day, rather than referencing the live columns, so a later rate change cannot silently rewrite history.

The constraint that matters most

CONSTRAINT daily_yield_snapshots_plan_date_unique UNIQUE (plan_id, snapshot_date)

This is the ledger's integrity guarantee, not a nicety. A daily worker runs twice more often than one might expect: a restart, a redeploy, or a second replica. Without the constraint each of those silently doubles a plan's history, and an audit ledger that double-counts is worse than none.

Writes use ON CONFLICT ... DO NOTHING, so the first observation of a day stands and a repeat run is a no-op. snapshot_all returns the number of rows actually inserted, which is how a re-run makes itself visible in the logs rather than looking identical to a first run.

I chose DO NOTHING over DO UPDATE deliberately — for an audit record, first-write-wins is easier to reason about than a value that can change after the fact. Say the word if you would rather the latest run win.

Two edge cases handled explicitly

seconds_until_next_run never returns zero. Landing exactly on the configured hour returns a full day, not 0. A zero delay would spin the worker in a tight loop, re-running the snapshot as fast as the database could answer it. There is a test for exactly that, plus a sweep asserting the wait is positive and at most a day across all 24 hours.

elapsed_since_ping is saturating. last_ping is stored as epoch seconds and can sit in the future after a clock correction. A plain subtraction cast to u64 would wrap into an enormous positive number and write an absurd figure into the ledger. Tested with i64::MAX.

Also: one plan failing to insert logs a warning and the run continues, rather than abandoning the rest of the ledger; and a failed run logs and keeps the worker alive, which is safe precisely because the unique constraint makes the retry idempotent.

Configuration

YIELD_SNAPSHOT_HOUR_UTC picks the run hour, defaulting to midnight UTC. Values outside 0–23 fall back to the default rather than being trusted into the schedule calculation.

Verification

I could execute the scheduling and elapsed-time logic. Both helpers and all 7 tests were lifted into a scratch crate with real chrono and run with cargo test — all pass, including the 96-combination sweep.

Honest note: I could not run the crate's own cargo test (it needs the full dependency graph and sqlx's database or offline metadata), so snapshot_all itself — the SQL, the Decimal conversions and the rows_affected accounting — is unverified beyond review and needs CI with a database.

One conversion worth a reviewer's eye: principal goes through Decimal → String → f64 to reach the existing calculate_yield, which takes f64. For NUMERIC(78, 0) values that is lossy at the extreme end. I kept it rather than changing calculate_yield's signature, since that function is used elsewhere and widening it is a separate decision — but if you would prefer the whole path in Decimal, I am happy to do that here.

Overlap note: main.rs is also touched by my open PR #1209 (graceful shutdown), which changes how background workers are started. This PR deliberately follows the current master pattern so it stands alone. If #1209 lands first, this worker should return its JoinHandle too — a one-line follow-up I am glad to make.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CrfEY1tvXrbeMDAUzxfuk7

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.

Backend: Implement Yield Accrual Audit Ledger & Daily Balance Snapshot

1 participant