Skip to content

feat: durable store + delivery idempotency — #2 Phase 1#45

Closed
BunsDev wants to merge 4 commits into
mainfrom
feat/issue-2-durable-queue
Closed

feat: durable store + delivery idempotency — #2 Phase 1#45
BunsDev wants to merge 4 commits into
mainfrom
feat/issue-2-durable-queue

Conversation

@BunsDev

@BunsDev BunsDev commented Jul 7, 2026

Copy link
Copy Markdown
Member

Phase 1 of the merged design (docs/durable-task-store.md, #43). Advances #2; does not close it — Phases 2 (durable claim loop + restart recovery + retire the in-memory TaskStore) and 3 (compose volume + README/HOSTED status flip + demo assertion) follow.

What this delivers

  • crates/store — new crate on rusqlite (bundled SQLite, WAL, synchronous=NORMAL, busy_timeout=5s, foreign keys on). PRAGMA user_version forward-only migrations create the full webhook_deliveries / tasks / task_attempts schema from the design doc. Sync connection guarded by a mutex, bridged to async via spawn_blocking. Exposes what Phase 1 uses: delivery insert-or-dedup, routing updates, queued-task persistence, and a compensating delete.
  • Delivery idempotencyhandle_webhook now requires X-GitHub-Delivery (400 if absent), records the delivery as a claim before any work, and returns 200 {duplicate:true} on redelivery without re-running. This is the direct fix for GitHub redeliveries creating duplicate sessions/comments/PRs.
  • Persist-then-ack — accepted tasks are written durably (state='queued') and their routing recorded before dispatch. If task persistence fails, the delivery claim is released and the handler returns 500 so GitHub retries rather than the work being deduped away and lost. The mpsc channel stays for dispatch in this phase (per the doc); a full channel now logs against a durable row instead of silently dropping.
  • [storage] path config (default data/coven-github.db, serde-default so existing configs keep working) with a doctor check that errors on a non-directory parent and warns when it's absent.
  • Server opens the store at startup, failing fast on a bad path.

Scope boundaries (per the phased design)

Phase 1 deliberately does not yet: replace the mpsc with a durable claim loop, reload queued/running tasks on restart, move supersession into the store, retire the in-memory TaskStore, flip the README status, or add the compose volume. Those are Phases 2–3.

Tests (map to the issue's criteria)

  • Store: first-delivery-New / replay-Duplicate; routing recorded + updatable; queued-task persistence; schema + dedup survive reopening the same DB file.
  • Config: storage default path; doctor flags a non-directory parent.
  • Webhook route (real HMAC signing): missing delivery id → 400 with nothing persisted; accepted delivery persists one queued task + task:<id> routing; replayed delivery id does not create a second task (duplicate:true); unactionable event records ignored: routing with no task.

Local gates: cargo check --all-targets + cargo clippy --all-targets -- -D warnings + cargo test --all (145 passed, 0 failed).

🤖 Generated with Claude Code

BunsDev added 4 commits July 6, 2026 22:47
Phase 1 of docs/durable-task-store.md: new crates/store on rusqlite (bundled,
WAL) with user_version migrations for the webhook_deliveries / tasks /
task_attempts schema. Exposes delivery insert-or-dedup, routing updates, and
queued-task persistence — the durable claim loop and recovery follow in
Phase 2.

Signed-off-by: Val Alexander <bunsthedev@gmail.com>
New StorageConfig.path (default data/coven-github.db) locates the durable
SQLite state; the section is serde-default so existing configs keep working.
Doctor errors when the path's parent exists as a non-directory and warns when
it is absent (created on first run).

Signed-off-by: Val Alexander <bunsthedev@gmail.com>
handle_webhook now requires X-GitHub-Delivery (400 if absent), records the
delivery as an idempotency claim before any work, and returns 200 duplicate:true
on redelivery without re-running. Accepted tasks are persisted queued and their
routing recorded before the mpsc dispatch (kept for Phase 1); a failed task
insert releases the delivery claim and returns 500 so GitHub retries rather than
losing the work.

Signed-off-by: Val Alexander <bunsthedev@gmail.com>
Opens the SQLite store from storage.path at startup (failing fast on a bad
path) and passes it into AppState so the webhook can persist deliveries and
tasks.

Signed-off-by: Val Alexander <bunsthedev@gmail.com>
Copilot AI review requested due to automatic review settings July 7, 2026 03:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Phase 1 implementation of issue #2’s durable state plan: introduces an embedded SQLite-backed store to persist webhook delivery records (for idempotency) and accepted tasks (queued) before acknowledging GitHub, and wires the server/webhook/config plumbing to use it.

Changes:

  • Added coven-github-store crate (rusqlite + migrations) to persist webhook_deliveries and tasks for delivery dedup + queued task durability.
  • Updated webhook route to require X-GitHub-Delivery, claim/dedup deliveries, persist tasks before dispatch, and record routing outcomes.
  • Added [storage] path config with defaults + doctor diagnostics; server opens the store at startup and passes it into webhook state.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/worker/src/lib.rs Updates test config builders to include the new storage config field.
crates/webhook/src/routes.rs Enforces delivery id, adds delivery idempotency + persist-then-ack behavior, records routing, and adds route-level tests.
crates/webhook/Cargo.toml Adds dependency on the new store crate.
crates/store/src/lib.rs Implements SQLite store, migrations, delivery recording, task persistence, and related tests/helpers.
crates/store/Cargo.toml Defines new store crate dependencies/dev-dependencies.
crates/server/src/main.rs Opens durable store at startup and injects it into webhook state.
crates/server/Cargo.toml Adds dependency on the new store crate.
crates/config/src/lib.rs Adds [storage] config with default path plus doctor validation/diagnostics and tests.
config/example.toml Documents and demonstrates [storage] path configuration.
Cargo.toml Adds crates/store workspace member and workspace rusqlite dependency.
Cargo.lock Locks new dependencies introduced by rusqlite / store crate.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/store/src/lib.rs
Comment on lines +224 to +226
let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if version < 1 {
conn.execute_batch(
Comment thread crates/store/src/lib.rs
Comment on lines +89 to +92
tokio::task::spawn_blocking(move || {
let guard = conn.lock().expect("store connection mutex poisoned");
f(&guard)
})
Comment thread crates/store/src/lib.rs
Comment on lines +132 to +136
conn.execute(
"UPDATE webhook_deliveries SET routing = ?2 WHERE delivery_id = ?1",
rusqlite::params![delivery_id, routing],
)?;
Ok(())
Comment on lines +170 to +173
if let Err(e) = state.store.insert_task(&task, &delivery_id, supersede_key).await {
warn!(task_id = %task.id, "failed to persist task: {e:#}");
state.store.delete_delivery(&delivery_id).await.ok();
return (
Comment on lines +127 to +132
match state.store.record_delivery(record).await {
Ok(DeliveryOutcome::New) => {}
Ok(DeliveryOutcome::Duplicate) => {
info!(delivery_id, "duplicate webhook delivery — skipping");
return (StatusCode::OK, Json(json!({"ok": true, "duplicate": true}))).into_response();
}
Comment thread crates/config/src/lib.rs
Comment on lines +333 to +340
Err(_) => out.push(Diagnostic::warning(
"storage.path",
format!(
"storage directory '{}' does not exist yet — it will be created on first run.",
parent.display()
),
)),
},
@BunsDev

BunsDev commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Closing as a duplicate: PR #44 landed an equivalent (and more atomic) Phase 1 of #2 — same crates/store SQLite crate and X-GitHub-Delivery idempotency — while this branch was in flight. #44 is the canonical Phase 1; standing this down to avoid competing implementations. Nothing here supersedes #44.

@BunsDev BunsDev closed this Jul 7, 2026
@BunsDev BunsDev deleted the feat/issue-2-durable-queue branch July 7, 2026 04:13
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.

2 participants