|
| 1 | +# Durable task store and delivery idempotency — design (issue #2) |
| 2 | + |
| 3 | +Status: **proposed** — this document is the review surface for the design; |
| 4 | +implementation follows in phased PRs once it is agreed. |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +The adapter accepts GitHub webhooks, maps them to tasks, and pushes them into |
| 9 | +an in-process `tokio::mpsc` channel consumed by the worker pool |
| 10 | +(`crates/webhook/src/routes.rs` → `crates/worker/src/lib.rs`). Three failure |
| 11 | +modes make this unacceptable for a hosted App that promises reliable work: |
| 12 | + |
| 13 | +1. **Silent drops.** A full channel logs `task queue full — dropping task` |
| 14 | + and still returns `200 OK`. GitHub treats 200 as delivered and never |
| 15 | + retries; the user sees nothing. |
| 16 | +2. **Restart amnesia.** Queued and running tasks, task history, and the |
| 17 | + supersession registry all live in process memory |
| 18 | + (`crates/github/src/tasks.rs`). A deploy or crash loses everything, and |
| 19 | + Cave cannot reconstruct state. |
| 20 | +3. **Duplicate deliveries.** GitHub redelivers webhooks (manual redelivery, |
| 21 | + timeouts, retries). Nothing deduplicates by `X-GitHub-Delivery`, so a |
| 22 | + redelivered event re-runs the task: duplicate sessions, comments, PRs. |
| 23 | + |
| 24 | +## Goals |
| 25 | + |
| 26 | +Mapped from issue #2's acceptance criteria: |
| 27 | + |
| 28 | +- Every accepted webhook has a durable record before GitHub sees success. |
| 29 | +- Replaying a delivery id never creates a duplicate task. |
| 30 | +- Process restart loses no queued task and re-queues interrupted running |
| 31 | + tasks. |
| 32 | +- A saturated worker pool delays work instead of dropping it. |
| 33 | +- Task lifecycle states are explicit and queryable: |
| 34 | + `received`, `queued`, `running`, `completed`, `failed`, `ignored` |
| 35 | + (plus the existing `superseded` terminal state from #8/#10). |
| 36 | +- Cave's `/api/github/tasks` survives restarts. |
| 37 | + |
| 38 | +## Non-goals |
| 39 | + |
| 40 | +- Multi-node worker fleets and distributed queues. One process owns the |
| 41 | + store; the hosted fleet architecture arrives with #5/#15 and can graduate |
| 42 | + the trait implementation without changing call sites. |
| 43 | +- Tenant auth on the task API (#3), audit/retention policy (#12), and |
| 44 | + installation-scoped routing (#7) — they build on this store but are |
| 45 | + separate issues. |
| 46 | +- Payload archival. We persist routing coordinates and a payload hash, not |
| 47 | + full webhook bodies (see `docs/security.md`; bodies can embed user |
| 48 | + content we don't want retained by default). |
| 49 | + |
| 50 | +## Storage choice: embedded SQLite via `rusqlite` |
| 51 | + |
| 52 | +- **Zero-infra self-hosting.** The compose stack stays single-container; |
| 53 | + self-hosters get durability for free with a mounted volume. This matches |
| 54 | + the repo's "honest self-hosted adapter" posture. |
| 55 | +- **WAL mode** gives concurrent readers with a single writer — exactly the |
| 56 | + adapter's shape (webhook writes, worker claims, Cave reads). |
| 57 | +- **`rusqlite` over `sqlx`:** the adapter's query surface is small and |
| 58 | + hand-written; rusqlite is synchronous (wrapped in `spawn_blocking`), |
| 59 | + adds no proc-macro build cost, and avoids an async pool we don't need |
| 60 | + at this concurrency. |
| 61 | +- **Graduation path.** All access goes through one `TaskStore` type in a |
| 62 | + new `crates/store` crate. If hosted scale demands Postgres, the type |
| 63 | + becomes a trait with a second backend; call sites don't change. |
| 64 | + |
| 65 | +Connection discipline: one writer connection guarded by a mutex, WAL, |
| 66 | +`busy_timeout=5s`, `synchronous=NORMAL`, foreign keys on. Reads may share |
| 67 | +the writer connection initially — the volume is tiny; optimizing read |
| 68 | +concurrency is premature until Cave polling proves otherwise. |
| 69 | + |
| 70 | +Schema versioning via `PRAGMA user_version` and in-crate forward-only |
| 71 | +migrations run at startup. |
| 72 | + |
| 73 | +## Config surface |
| 74 | + |
| 75 | +```toml |
| 76 | +[storage] |
| 77 | +# Directory for durable adapter state (SQLite database + WAL files). |
| 78 | +# The doctor command checks it is creatable/writable. |
| 79 | +path = "data/coven-github.db" |
| 80 | +``` |
| 81 | + |
| 82 | +Default `data/coven-github.db` relative to the working directory; compose |
| 83 | +gains a named volume. `doctor` validates the parent directory exists or is |
| 84 | +creatable and warns when the path sits on tmpfs. |
| 85 | + |
| 86 | +## Schema |
| 87 | + |
| 88 | +```sql |
| 89 | +CREATE TABLE webhook_deliveries ( |
| 90 | + delivery_id TEXT PRIMARY KEY, -- X-GitHub-Delivery |
| 91 | + event TEXT NOT NULL, -- X-GitHub-Event |
| 92 | + action TEXT, -- payload action, if any |
| 93 | + installation_id INTEGER, |
| 94 | + repo TEXT, -- owner/name when parseable |
| 95 | + payload_hash TEXT NOT NULL, -- sha256 of raw body |
| 96 | + routing TEXT NOT NULL, -- 'task:<id>' | 'ignored:<reason>' |
| 97 | + received_at TEXT NOT NULL -- RFC 3339 |
| 98 | +); |
| 99 | + |
| 100 | +CREATE TABLE tasks ( |
| 101 | + id TEXT PRIMARY KEY, -- uuid (also the session id) |
| 102 | + delivery_id TEXT REFERENCES webhook_deliveries(delivery_id), |
| 103 | + installation_id INTEGER NOT NULL, |
| 104 | + repo TEXT NOT NULL, -- owner/name |
| 105 | + familiar_id TEXT NOT NULL, |
| 106 | + kind TEXT NOT NULL, -- serde_json of TaskKind |
| 107 | + commander TEXT, -- issue #13 permission gate |
| 108 | + state TEXT NOT NULL, -- queued|running|completed|failed|ignored|superseded |
| 109 | + supersede_key TEXT, -- 'owner/repo#pr' for PR reviews |
| 110 | + attempts INTEGER NOT NULL DEFAULT 0, |
| 111 | + -- Result surface for Cave (nullable until terminal): |
| 112 | + branch TEXT, |
| 113 | + pr_number INTEGER, |
| 114 | + check_run_url TEXT, |
| 115 | + summary TEXT, |
| 116 | + created_at TEXT NOT NULL, |
| 117 | + updated_at TEXT NOT NULL |
| 118 | +); |
| 119 | +CREATE INDEX tasks_state_created ON tasks(state, created_at); |
| 120 | +CREATE INDEX tasks_supersede ON tasks(supersede_key) |
| 121 | + WHERE supersede_key IS NOT NULL; |
| 122 | + |
| 123 | +CREATE TABLE task_attempts ( |
| 124 | + task_id TEXT NOT NULL REFERENCES tasks(id), |
| 125 | + attempt INTEGER NOT NULL, |
| 126 | + started_at TEXT NOT NULL, |
| 127 | + ended_at TEXT, |
| 128 | + outcome TEXT, -- 'completed' | failure category |
| 129 | + detail TEXT, -- redacted error text |
| 130 | + PRIMARY KEY (task_id, attempt) |
| 131 | +); |
| 132 | +``` |
| 133 | + |
| 134 | +Notes: |
| 135 | + |
| 136 | +- `tasks.kind` stores the existing `TaskKind` serde JSON — no parallel type |
| 137 | + hierarchy, and the wire shape is already versioned by the enum tags. |
| 138 | +- `supersede_key` replaces the in-memory `review_heads` map: the newest |
| 139 | + review task for a PR supersedes older *queued* rows in the same |
| 140 | + transaction that inserts it (mid-flight staleness stays with the #8 |
| 141 | + re-fetch gate). |
| 142 | +- The Cave list (`TaskListItem`) becomes a straight query over `tasks`; |
| 143 | + the in-memory `TaskStore` in `crates/github/src/tasks.rs` is retired. |
| 144 | +- `task_attempts.detail` passes through the existing `redact` scrubbing |
| 145 | + before persistence. |
| 146 | + |
| 147 | +## Webhook path |
| 148 | + |
| 149 | +``` |
| 150 | +1. Validate HMAC (unchanged) |
| 151 | +2. Read X-GitHub-Delivery. |
| 152 | + Missing → 400 {"error":"missing delivery id"}. |
| 153 | + GitHub always sends it; a caller that doesn't is not GitHub. |
| 154 | +3. BEGIN IMMEDIATE |
| 155 | + INSERT webhook_deliveries ... ON CONFLICT(delivery_id) DO NOTHING |
| 156 | + If the row already existed → COMMIT, return 200 {"ok":true, |
| 157 | + "duplicate":true}. Nothing else happens: idempotency. |
| 158 | +4. Route the event (existing event_to_task logic). |
| 159 | + Not actionable → record routing='ignored:<reason>', COMMIT, 200. |
| 160 | +5. INSERT tasks (state='queued', supersede_key when review) |
| 161 | + + tombstone older queued reviews with the same supersede_key |
| 162 | + (state='superseded'). |
| 163 | + Record routing='task:<id>'. COMMIT. |
| 164 | +6. Notify the worker pool (tokio::sync::Notify — a wake-up, not a queue). |
| 165 | +7. Return 200. Durable state exists before GitHub hears success. |
| 166 | +``` |
| 167 | + |
| 168 | +The `mpsc` channel disappears. There is no "queue full": the queue is the |
| 169 | +`tasks` table, and backpressure is worker-side (semaphore), not |
| 170 | +acceptance-side. |
| 171 | + |
| 172 | +Failure mode: if SQLite is unavailable the route returns **500**, GitHub |
| 173 | +retries the delivery later, and the operator sees it in logs — strictly |
| 174 | +better than acknowledging work we can't hold. |
| 175 | + |
| 176 | +## Worker path |
| 177 | + |
| 178 | +``` |
| 179 | +loop: |
| 180 | + claim = UPDATE tasks |
| 181 | + SET state='running', attempts=attempts+1, updated_at=now |
| 182 | + WHERE id = (SELECT id FROM tasks |
| 183 | + WHERE state='queued' ORDER BY created_at LIMIT 1) |
| 184 | + RETURNING *; |
| 185 | + none → wait on Notify with a 5s timeout (poll fallback), continue. |
| 186 | + some → INSERT task_attempts row; execute (existing execute_task body); |
| 187 | + terminal update: state=completed|failed|superseded (+ result |
| 188 | + columns), close the attempt row. |
| 189 | +``` |
| 190 | + |
| 191 | +Concurrency stays a semaphore around claims. Claims are atomic under |
| 192 | +SQLite's writer lock; `RETURNING` (SQLite ≥ 3.35, bundled) keeps it one |
| 193 | +statement. |
| 194 | + |
| 195 | +**Restart recovery:** on startup, before serving — |
| 196 | + |
| 197 | +```sql |
| 198 | +UPDATE tasks SET state='queued' |
| 199 | +WHERE state='running'; |
| 200 | +``` |
| 201 | + |
| 202 | +One process owns the store, so any `running` row at boot is an orphan of a |
| 203 | +dead process. The attempt row stays closed as `interrupted`; the task gets |
| 204 | +a fresh attempt when re-claimed. Tasks whose `attempts` already exceed |
| 205 | +`worker.max_retries + 1` go to `failed` instead, so a crash-looping task |
| 206 | +cannot poison the queue. (A re-run task re-uses its marker-backed status |
| 207 | +comment (#13), so the user surface stays deduplicated even across a |
| 208 | +restart.) |
| 209 | + |
| 210 | +## What changes where |
| 211 | + |
| 212 | +| Component | Change | |
| 213 | +|---|---| |
| 214 | +| new `crates/store` | `Store`: open/migrate, delivery insert-or-dup, task enqueue+tombstone, claim, terminal updates, attempt records, Cave list query, startup recovery | |
| 215 | +| `crates/webhook/routes.rs` | Delivery-id gate; persist-then-ack; drop `task_tx`; `list_tasks` reads the store | |
| 216 | +| `crates/worker/lib.rs` | Claim loop replaces `mpsc` recv; terminal states write to the store; supersession check reads `state='superseded'` instead of the in-memory registry | |
| 217 | +| `crates/github/tasks.rs` | In-memory `TaskStore` retired; `TaskListItem`/`TaskListStatus` stay as the API projection | |
| 218 | +| `crates/config` | `[storage] path` + doctor checks | |
| 219 | +| `crates/server/main.rs` | Open store, run recovery, wire Notify | |
| 220 | +| `compose.yaml` | Named volume for `/data` | |
| 221 | +| `README.md` | Durable queue row: planned → implemented (only once true) | |
| 222 | + |
| 223 | +## Test plan (maps to the issue's criteria) |
| 224 | + |
| 225 | +- **Duplicate delivery:** same delivery id twice → one `tasks` row; second |
| 226 | + response flags `duplicate`; no second worker execution (wiremock: no |
| 227 | + second Check Run POST). |
| 228 | +- **Missing delivery id:** 400, nothing persisted. |
| 229 | +- **Queue-full behavior:** N tasks with concurrency 1 → all N eventually |
| 230 | + complete; none dropped (the old `try_send` drop test inverts). |
| 231 | +- **Restart recovery:** enqueue, claim, drop the store handle mid-run, |
| 232 | + reopen → task is `queued` again with a closed `interrupted` attempt; |
| 233 | + exhausted-attempts variant lands in `failed`. |
| 234 | +- **Supersession:** two review events for the same PR → older queued row |
| 235 | + `superseded`, only the newer claims. |
| 236 | +- **Ignored routing:** unsupported event → delivery recorded with |
| 237 | + `ignored:` routing, no task row. |
| 238 | +- **Cave continuity:** list query returns pre-restart terminal tasks. |
| 239 | +- Demo (`examples/demo/run-demo.sh`) keeps passing — it exercises the full |
| 240 | + loop through the real binary. |
| 241 | + |
| 242 | +## Phased PRs |
| 243 | + |
| 244 | +1. **`crates/store` + delivery idempotency.** Store crate, migrations, |
| 245 | + config/doctor, webhook persist-then-ack with dedup. The mpsc stays for |
| 246 | + dispatch in this PR (tasks additionally recorded durably). |
| 247 | +2. **Durable claims + recovery.** Replace mpsc with claim loop + Notify; |
| 248 | + startup recovery; supersession moves into the store; retire the |
| 249 | + in-memory `TaskStore`; Cave list reads SQLite. |
| 250 | +3. **Truth pass.** README/HOSTED status updates, compose volume, demo |
| 251 | + assertion that a redelivered webhook does not duplicate comments. |
| 252 | + |
| 253 | +Each PR keeps `cargo check/clippy/test` green and lands separately |
| 254 | +mergeable. |
0 commit comments