Skip to content

fix(persistence): bound local write retries and set a busy timeout - #734

Merged
d-oit merged 8 commits into
mainfrom
fix/persistence-bounded-lock-retry
Sep 17, 2026
Merged

d-oit merged 8 commits into
mainfrom
fix/persistence-bounded-lock-retry

Conversation

@d-oit

@d-oit d-oit commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the ADR-0095 persistence acceptance criterion that the new scale evidence showed failing: bounded retries/timeouts for concurrent local writes.

The measured problem

From plans/evidence/scale_2026_09_17/persistence_scale.json (produced by #733): 8 concurrent writers × 25 round-trips on one local database →

variant ops/s p50 p95 p99 retries/op error rate
raw (no retry) 1 616 2.94 ms 10.19 ms 13.64 ms 0.00 0.91
caller-side bounded retry (5 × 2 ms) 381 23.08 ms 23.08 ms 35.21 ms 3.43 0.47

csm-persistence configured neither a busy timeout nor a retry, so writers failed with database is locked immediately. The evidence harness (and benches/persistence_benchmark.rs) had to hand-roll a retry loop.

The change

  • persistence_retry (new module): LOCAL_BUSY_TIMEOUT_MS = 5_000, WRITE_RETRY_LIMIT = 5, textual classification of the transient SQLITE_BUSY/SQLITE_LOCKED families (the driver exposes no extended result code), and a deterministic 2–32 ms exponential backoff.
  • connect() issues PRAGMA busy_timeout = 5000 on local connections, so a writer waits for a competing writer instead of failing; bounded at 5 s.
  • Idempotent write paths (save_concept, save_concepts, save_association) re-run their upsert transaction within the retry budget. Non-idempotent statements are deliberately not retried.

Both write entry points keep their existing signatures and error types; a failure that exhausts the budget still returns MemoryError::database.

Verification

  • New tests/persistence_concurrency.rs: 8 writers × 25 single saves and 8 writers × (5-concept batch + 5 associations) must all succeed and remain readable — passes in 0.70 s (the same workload that failed 90 % of operations before).
  • cargo clippy -p csm-persistence --all-features clean; cargo fmt --all -- --check clean; validate-changelog.sh passes.
  • CHANGELOG: new Fixed entry recording the before/after error rate and pointing at the evidence artifact.

Follow-up

Once #733 is merged this branch gets a re-run of the persistence artifact (scripts/scale-evidence.sh persistence --tasks 8 --ops 25) so the committed evidence shows the post-fix numbers; the pre-fix JSON stays in the same directory for comparison.

The 2026-09-17 scale evidence recorded a 90 % error rate for eight concurrent
writers on one local database, and 47 % even with a caller-side retry loop
(plans/evidence/scale_2026_09_17/persistence_scale.json), because
csm-persistence configured neither a lock timeout nor a retry. ADR-0095
requires persistence concurrency to have bounded retries/timeouts and to
report them.

- `persistence_retry`: `LOCAL_BUSY_TIMEOUT_MS = 5000`,
  `WRITE_RETRY_LIMIT = 5`, transient-error classification
  (`SQLITE_BUSY`/`SQLITE_LOCKED` families) and a deterministic 2-32 ms
  exponential backoff.
- `connect()` sets `PRAGMA busy_timeout` on local connections, so a writer
  waits for a competing writer instead of failing immediately.
- The idempotent write paths (`save_concept`, `save_concepts`,
  `save_association`) retry a transient failure within that budget by
  re-running their upsert transaction; non-idempotent statements are not
  retried.

`tests/persistence_concurrency.rs` guards it: 8 writers x 25 single saves and
8 writers x (5-concept batch + 5 associations) must all succeed and stay
readable.
@d-oit
d-oit requested a review from d-o-hub as a code owner September 17, 2026 20:29
@codacy-production

codacy-production Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 67 complexity

Metric Results
Complexity 67

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

CI/CD Tester added 7 commits September 17, 2026 22:41
…racking

The inline retry loops pushed persistence.rs to 518 lines, over the 500-LOC
gate that tests/arch_fitness.rs enforces for crates/*/src (the gate failed
in both the lint and test jobs).

- persistence_retry gains with_retry(): the three write paths call it
  instead of repeating the loop.
- The AbsenceStore impl and its row helper move to persistence_absence.rs
  (132 lines), taking persistence.rs to 387.
The extracted module took the file-level csm_traits imports with it, so the
in-file test module (and the orphaned async_trait attribute) needed repair.
…rison

The 2026-09-17 artifact recorded the state before bounded retries existed.
Rename it persistence_scale_pre_fix.json (+ its manifest) so the post-fix run
in this PR can sit beside it and the before/after is visible.
…fest

The manifest's dirty flag described the evidence files the run itself writes.
Exclude plans/evidence from the git status probe so the flag answers the
question that matters: was the measured code committed?
…ies in place

Produced on commit 7bf03c6 (clean tree): error rate 0.905 -> 0.000 without
caller retries and 0.470 -> 0.000 with them; writers wait instead of failing.
The pre-fix artifact and manifest stay beside it for the comparison.
SonarCloud flagged the new renderer: two path-traversal vulnerabilities for
taking a filesystem path from argv, and cognitive complexity 36 in render().

- Resolve the argument as a *name* under plans/evidence/ (validated against
  [A-Za-z0-9_-]+), so no caller-supplied path reaches a file operation.
- Split render() into render_header/render_ann/render_persistence/
  render_memory/render_contention.
@sonarqubecloud

Copy link
Copy Markdown

@d-oit
d-oit merged commit 1433ae8 into main Sep 17, 2026
34 checks passed
@d-oit
d-oit deleted the fix/persistence-bounded-lock-retry branch September 17, 2026 21:39
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