Skip to content

feat(storage): EfMessageStore — SQL claim (UPDATE…RETURNING) + idempotent fan-out (S2 of #37)#59

Merged
williamdewitt merged 1 commit into
mainfrom
feat/m46-efmessagestore
Jun 27, 2026
Merged

feat(storage): EfMessageStore — SQL claim (UPDATE…RETURNING) + idempotent fan-out (S2 of #37)#59
williamdewitt merged 1 commit into
mainfrom
feat/m46-efmessagestore

Conversation

@williamdewitt

Copy link
Copy Markdown
Owner

What

Sub-task 2 of 3 for #37 (durable SQLite store), under M2 epic #8. Implements the internal IMessageStore on EF Core / SQLite, mirroring the in-memory store's semantics exactly.

The claim path (the interesting bit)

ClaimDueAsync pushes the claim into one atomic statement:

UPDATE messages SET owner = $owner, lease_until = $lease_until
 WHERE id IN (SELECT id FROM messages
               WHERE status = $pending AND next_attempt_at <= $now
                 AND (owner IS NULL OR lease_until < $now)
               ORDER BY next_attempt_at LIMIT $batch)
RETURNING ...;

SQLite serializes writers, so a competing dispatcher's identical statement runs against the already-claimed state and its WHERE excludes those rows — no double-claim, no SKIP LOCKED needed (that's the Postgres story, #38). Due = pending + next attempt arrived + (unowned or lease lapsed); lease_until < now is strict, so a lease is still held at the exact expiry instant.

The rest

  • AddAsync — idempotent on (event_id, endpoint_id) via INSERT … ON CONFLICT DO NOTHING; the rows-affected sum is the count actually inserted.
  • MarkDelivered / Reschedule / DeadLetter — EF ExecuteUpdateAsync (set-based, no load).
  • A fresh context + connection per operation (DbContext isn't thread-safe) so the store is safe under concurrent dispatchers; deps are IDbContextFactory + TimeProvider.
  • Core exposes its internal resource-access contracts to the provider via InternalsVisibleTo.

Verification

  • 175 tests green on net8.0 + net10.0, zero warnings. Tests mirror InMemoryMessageStoreTests against a real SQLite file: claim/lease/crash-recovery, idempotent fan-out, batch order, the exact-expiry boundary, no-double-claim across 8 concurrent workers over 200 messages, constructor guards, full-message round-trip.
  • Mutation: EfMessageStore.cs = 100% locally (dotnet stryker --project Caliber.Webhooks.EntityFrameworkCore).

Follow-up (called out, not in this PR)

The EF provider isn't in the CI Stryker scope yet (config is core-only). Wiring it in — together with covering the #45 config files (EndpointConfiguration/WebhookMessageConfiguration, currently 0%, exercised once #47's endpoint round-trip tests land) — is the next quality-gate step so the provider lands in the mutation gate with a green full score.

risk:core — human review + merge. Closes #46.

…tent fan-out

Sub-task 2 of 3 for #37 (durable SQLite store), under M2 epic #8. Implements the
internal IMessageStore on EF Core / SQLite, mirroring the in-memory store's semantics.

- ClaimDueAsync pushes the claim into one atomic UPDATE … WHERE id IN (SELECT … ORDER
  BY next_attempt_at LIMIT) … RETURNING. SQLite serializes writers, so a competing
  dispatcher's identical statement sees the already-claimed state and excludes those
  rows — no double-claim, no SKIP LOCKED needed. Due = pending, next attempt arrived,
  unowned or lease lapsed (lease_until < now is strict).
- AddAsync is idempotent on (event_id, endpoint_id) via INSERT … ON CONFLICT DO
  NOTHING; the rows-affected sum is the count actually inserted.
- MarkDelivered / Reschedule / DeadLetter via EF ExecuteUpdateAsync (set-based, no load).
- A fresh context + connection per operation (DbContext is not thread-safe), so the
  store is safe under concurrent dispatchers; deps are IDbContextFactory + TimeProvider.
- Core exposes its internal resource-access contracts to the provider via InternalsVisibleTo.

Tests mirror InMemoryMessageStoreTests against a real SQLite file — claim/lease/
crash-recovery, idempotent fan-out, batch order, the exact-expiry boundary, and
no-double-claim across 8 concurrent workers over 200 messages — plus constructor
guards and full-message round-trip. 175 tests green on net8.0 + net10.0, zero warnings.

Mutation: EfMessageStore.cs scores 100% locally (Stryker, --project EFCore). The EF
project is not yet in the CI Stryker scope; wiring it in (and covering the #45 config
files via #47's endpoint round-trip tests) is the follow-up that lands the provider in
the mutation gate.

Closes #46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the risk:core src/Caliber.Webhooks; code-owner review (Opus, high effort) label Jun 27, 2026
@williamdewitt williamdewitt merged commit f08d5fe into main Jun 27, 2026
9 checks passed
@williamdewitt williamdewitt deleted the feat/m46-efmessagestore branch June 27, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:core src/Caliber.Webhooks; code-owner review (Opus, high effort)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(storage): EfMessageStore — SQL claim (UPDATE … RETURNING) + idempotent fan-out

1 participant