feat(webhook): honor retry_after as a pending not-before time - #999
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends the webhook inbox semantics so a caller-provided WebhookEvent.RetryAfter is durably stored on pending rows and treated as a not-before time, keeping the row invisible to dispatch (and the claimable backlog metric) until the timestamp is reached.
Changes:
- Persist
WebhookEvent.RetryAfteron insert for pending webhook deliveries. - Update the shared “claimable” predicate so pending rows are only claimable when
retry_afteris unset or has elapsed (aligning pending with retryable behavior). - Add an integration test verifying deferred pending rows are not claimable (and not counted as claimable backlog) until the not-before time passes.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| pkg/storage/storage.go | Updates interface docs to describe RetryAfter as a not-before time for pending deliveries. |
| pkg/storage/internal/sqlstore/webhook_events.go | Persists retry_after on insert and gates pending claimability on (retry_after IS NULL OR retry_after <= now). |
| pkg/storage/internal/sqlstore/webhook_events_test.go | Adds coverage for deferred pending events (dispatch invisibility + InboxStats alignment). |
| pkg/storage/internal/sqlstore/sql_helpers.go | Adds nullTimePtr helper to bind nullable *time.Time values in SQL statements. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
a7b30cb to
b3629c8
Compare
morgo
left a comment
There was a problem hiding this comment.
🤖 Approving on Morgan's behalf (automated review, escalation rules apply).
Verified the mechanics: Create's column/placeholder/arg lists all line up at 13 with retry_after in position; the pending-arm not-before check lives in the shared claimable predicate, so FindNext and the backlog gauge cannot drift; the claim consumes the persisted retry_after (integration test asserts the row, not just the returned struct); and MarkFailed's nullTimePtr wrap is behavior-equivalent. Effective risk is low because nothing in production sets RetryAfter on pending rows yet — the producer (#1002) is still draft — so the only live change is the metrics basis (age/lag measured from due time), which is intentional, documented, and covered by both integration and metrics tests.
Two non-blocking notes:
- This edits the same InboxStats query that #1007 rewrote for dialect portability (TIMESTAMPDIFF → Go-side subtraction). Whichever merges second needs a small semantic rebase, and the merged expression should keep #1007's portable form — GREATEST/COALESCE port fine, TIMESTAMPDIFF doesn't.
- The FIFO caveat (a deferred row re-enters at its original insertion position, ahead of rows created during its deferral) is documented honestly; fine for second-scale grace delays.
A caller-set RetryAfter is now persisted on Create and pending rows stay invisible to FindNext (and the backlog gauge, via the shared claimable predicate) until it passes. This is the deferred-dispatch primitive for the check_suite.requested convergence trigger, which must lose the race to the organic pull_request delivery. Nil RetryAfter is unchanged, and a terminal-redelivery reopen still clears it.
A row created with a not-before time waits out its grace period by design; measuring dispatch lag and backlog age from receipt reports that deferral as backlog, saturating the lag histogram's upper percentiles and spiking the age gauge the instant a deferral comes due. Both now measure from the later of receipt and the consumed retry_after. Also pins the reopen-discards-incoming-not-before contract with a test and refreshes stale claimability docs.
The dialect-portability refactor split sqlstore's New into NewMySQL/NewPostgres; the retry_after inbox tests predate that split.
b3629c8 to
3a4b3c1
Compare
|
Review response from Kiran's (@Kiran01bm) AI code review assessment agent (Amp / Claude Opus 4.5) Summary: Both non-blocking notes are resolved at head
|
Pending inbox rows now honor a caller-set
retry_afteras a not-before time: the delivery is durable immediately but invisible to dispatch — and not counted as backlog — until the time passes.Why
An upcoming redundant convergence trigger (
check_suite.requested) must be enqueued with a grace delay so the organicpull_requestdelivery normally wins the race — the redundant signal should only be processed if the primary delivery never arrived. That requires a deferred-dispatch primitive in the inbox itself, so the deferral is as durable as the row. Theretry_aftercolumn already exists (used by retryable rows); this extends its meaning to pending rows.What
CreatepersistsWebhookEvent.RetryAfter(previously ignored on insert).retry_after IS NULL OR retry_after <= nowfor pending rows, matching the existing retryable-row condition. BecauseFindNextandInboxStatsderive from the same predicate, a deferred row is not counted as claimable backlog.ClaimableSince(derived byFindNextfrom the consumedretry_after), and the oldest-claimable-age gauge measures fromGREATEST(received_at, retry_after).RetryAfteris byte-for-byte the previous behavior, so all existing producers are unaffected. A terminal-redelivery reopen still clearsretry_after— and discards a not-before time on the incoming duplicate — keeping GitHub's "Redeliver" button an immediate recovery lever.retry_afteralready exists.Before / after for a pending row created with a future
retry_after: