feat(history): crash recovery scanner and operation inspection cli #48
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-6-recovery"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
docs/technical/cli-api.mdmapslife operation get IDtoGET /v1/operations/{id}, but nothing implemented either half:src/cli.rsroutedCommands::Operation(_)intonot_implemented()and no operations route existed. Meanwhile the mutation routes already reporterror.operation_idon transport failures andpartialenvelopes for unconfirmed outcomes, so the recovery loop they point at was dead.Crash recovery was missing too: an operation recorded
preparedis potentially dispatched after a restart, so a service that restarts mid-write has no way to reconcile what the remote actually holds. This lands both halves — a startup scanner that reconciles unresolved operations from evidence, and the read side an operator or agent uses to inspect one.Changes
src/history/recovery.rs(new): startup scanner overoperations WHERE state IN ('prepared','unknown'). For each operation it reads the stored target live (href/uid/representationfromintent_json) and classifies the evidence perdocs/technical/timeline-consistency.md#crash-recovery: a live state matching the intended representation appends alife_eventsobservation withevidence_class='observed_external'and no invented actor or time (attribution stays uncertain); matching baseline, differing from both, and live absence reconcile without an observation. Every case moves the operation toneeds_reconciliation— nothing is ever reportedappliedorfailed, and no remote write is ever issued, because a readback establishes neither.RecoverySummaryand logged with structuredtracingfields (operation_id, error) — never payloads — and never take the daemon down.src/api/operations.rs(new) plusGET /v1/operations/{id}: returnsoperation_id,state,actor,prepared_at,updated_at,intent,baseline,resultandrecovery_requirement(present forunknown/needs_reconciliation). Unknown ID is HTTP 404not_found; an unreadable journal isdependency_unavailable. The envelope ispartialwhile the outcome is not established — the documented pairing of exit 6 with a partial response — andokonce it is.src/api/mod.rs: the route, andstartup_recovery()wired intoserve()before the listener accepts a request; the pass is skipped entirely when there is no journal worker or no calendar backend.src/cli.rs:life operation get <ID> [--json]dispatches to that route; exit 0 forapplied/failed, 6 forprepared/unknown/needs_reconciliation, 2 fornot_found, and transport failures keep the read-command codes (5 unreachable, 6 timeout).src/history/journal.rs: one shared decoder foroperationsrows (replacing three copies) plusJournalService::unresolved_operations(), the scanner's work list.src/history/recovery.rs: the pass is bounded —MAX_RECOVERY_OPERATIONS(25) andRECOVERY_BUDGET(30s), both public and checked between operations. The remainder is left exactly as it was and reported asRecoverySummary::deferred(plus a log line and thedeferredfield on the startup summary), so a crash loop's backlog against a slow or unreachable backend cannot hold readiness for one client timeout per operation; the next start continues where this one stopped.Verification
nix develop --command cargo test— 182 tests pass across 14 suites, including the newtests/crash_recovery_tests.rs(18 tests).tests/crash_recovery_tests.rs, asserted against real HTTP behaviour and the SQLite journal:needs_reconciliation, one evidence readback, zero remote writes.needs_reconciliationwith anobserved_externalobservation carrying no actor and nooccurred_atand provenanceattribution: uncertain; the observed write is never repeated.life_eventsdedupe key) → the response ispartialwithoperation_state: preparedandoperation_unknown, the operation ID survives, the durable row staysprepared, and recovery then reconciles it with no secondary write.appliedresult (changed: false) with no second write, and recovery leaves the operation alone.life operation getexit codes 0/6/2 and 5/6 through the real binary, the 404 path, the missing-journal path, and recovery skipped without a journal worker.life serveover a journal holding apreparedoperation reconciles it at startup (log line plus journal row) and keeps serving/health/live;life operation getagainst that daemon exits 6 with the full record and 2 for an unknown ID.MAX_RECOVERY_OPERATIONS + 2prepared operations examines exactly the cap, reports the remainder deferred and stillprepared, and a second pass reconciles them all; an unreachable backend withMAX_RECOVERY_OPERATIONS + 5unresolved operations returns insideRECOVERY_BUDGETwith nothing touched. Manually confirmed through the real daemon: a 30-operation backlog against a dead backend answers/health/liveafter 0.06s, logsscanned=25 deferred=5, and leaves all 30 operations unresolved.partial+error.operation_idenvelopes,deny_unknown_fieldson create payloads and theIf-Matchpreflight all still pass unchanged.Related Issues
Closes #6
Coordinator review — CHANGES REQUESTED (one item)
Verified
e54f959by reading the diff and running it myself. The evidence discipline is exactly right:PUTanywhere insrc/history/recovery.rs; the only journal write iscommit_operation_result(.., needs_reconciliation, ..)plus theobserved_externalobservation.Matching remote state proves an observation, not unique attribution…) and neverapplied.needs_reconciliation;unreadableoperations stay unresolved for the next start rather than being guessed.journal_error) and the daemon keeps serving.GET /v1/operations/{id}returns the full record withrecovery_requirementonly forunknown/needs_reconciliation, 404not_found, 503journal_unavailable.life operation getexit codes: 0 settled, 6 unresolved, 2 unknown id, 5/6 transport.Independent verification on
e54f959:cargo test182 passed / 0 failed across 14 suites includingcrash_recovery_tests(18);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.Change needed: bound the startup pass.
recover_pending_operationsiterates every unresolved operation with no cap and no total deadline. Each readback is bounded only by the CalDAV client timeout (defaultrequest_timeout_seconds = 20), andstartup_recoveryruns before the listener accepts requests — so N unresolved operations against a slow or unreachable backend delay readiness by up to N × 20s, on precisely the path (crash + backend outage) recovery exists to handle. A crash loop accumulates exactly those rows.Please add a bounded pass and report it:
deferred: usizeinRecoverySummary, logged) so the next start continues;This matches the repo's existing discipline (
MAX_QUERY_SPAN_DAYS,MAX_EXPANDED_INSTANCES,MAX_HISTORY_ROWS, boundedreconcile_attempts) and keeps the daemon's startup time a function of a constant rather than of journal size.Tests: (a) more unresolved operations than the cap → exactly the cap examined, the remainder reported as deferred and still unresolved, and the next pass picks them up; (b) an unreachable backend with many unresolved operations still returns within the pass bound. Keep everything else as-is and keep test/clippy/fmt green.
Observation (not a change for this PR)
not_foundmaps to exit 2 here but exit 1 forlife item get(#9).docs/technical/cli-api.mddefines exit 2 as "Invalid input/configuration" and exit 1 as "other failure", so exit 2 is the defensible reading and #9 is the outlier — the two issue specs disagreed. I'm filing a follow-up to alignlife item get's 404 to exit 2 rather than changing it here.Coordinator re-review — APPROVED
Verified
89395d0myself (read the diff, ran the suite):MAX_RECOVERY_OPERATIONS = 25andRECOVERY_BUDGET = 30sare checked before each operation, so startup time is now a function of constants rather than of journal size. Worst case is bounded by the budget plus one client timeout — acceptable and a large improvement on N × 20s.prepared/unknown) and reported asRecoverySummary::deferredplus a log line, so the next start continues rather than the evidence being guessed.Independent verification on
89395d0:cargo test184 passed / 0 failed across 14 suites includingcrash_recovery_tests(20);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.Merging. The
not_foundexit-code divergence is tracked separately in #49 and correctly left alone here.feat(history): crash recovery scanner and operation inspection cli (#6)to feat(history): crash recovery scanner and operation inspection cli