Skip to content

Commit c926842

Browse files
committed
fix(webhooks): claim a repair attempt before requesting redelivery
`repair_delivery` read the repair row, checked the attempt cap and the backoff, sent `POST /app/hook/deliveries/{id}/attempts`, and only then wrote the attempt. Two watchdogs reading the same open row — an engine overlapping its own restart, or two nodes on one store — could both pass those checks and both ask GitHub to redeliver the same delivery, charge it two attempts, and so reach the cap in half the real retries. The retry pass added in 4d670b3 drives repairs from the table on every poll, which is what made that window routine rather than incidental. The attempt is now taken in one conditional store write before the request goes out: `claim_webhook_redelivery` upserts with `attempts = attempts + 1, last_attempt_at = .., resolved_at = NULL` but only where the stored row still has the attempt count and the resolution state the caller read. Exactly one claimant matches; the others get `None` and send nothing. A close committed since the read also fails the compare, so a pass can no longer resurrect a repair whose delivery has landed. The per-GUID backoff ladder and the cap stay in the watchdog, where the scan and the retry pass share them. Both backends implement the claim as a single statement (`ON CONFLICT .. DO UPDATE .. WHERE .. RETURNING`), which is the shape the webhook inbox already uses to lease deliveries. New tests: the claim's compare on both backends (attempt charged once, loser charges nothing, close stands, resolved row reopens under the read resolution), and a two-node race on one Postgres database proving one winner. Verified the SQL directly against Postgres 18 and SQLite as well as through the crate's suites.
1 parent b86137a commit c926842

9 files changed

Lines changed: 413 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ Releases before v0.27.0 predate the changelog.
4747
delivery watchdog treated every delivery in GitHub's history as a phantom
4848
ack when the store had no watchdog cursor, and asked GitHub to redeliver
4949
all of them. After a store cutover this re-ran CI for merged PRs and
50-
superseded pushes — 2,435 redeliveries and hundreds of runs on one host —
51-
and the replays queued ahead of live webhooks in the inbox. A store's
52-
first poll now adopts the history before it (the watermark starts at the
53-
grace boundary, drawn and persisted at the first attempt, so a history
54-
outage right after startup cannot swallow the deliveries that failed while
55-
polling was down); later passes repair phantom acks and edge failures as
56-
before, and a restored snapshot keeps its cursor and still repairs
57-
everything since. Recover a known gap by redelivering it from GitHub.
50+
superseded pushes, and the replays queued ahead of live webhooks in the
51+
inbox. A store's first poll now adopts the history before it (the
52+
watermark starts at the grace boundary, drawn and persisted at the first
53+
attempt, so a history outage right after startup cannot swallow the
54+
deliveries that failed while polling was down); later passes repair
55+
phantom acks and edge failures as before, and a restored snapshot keeps
56+
its cursor and still repairs everything since. Recover a known gap by
57+
redelivering it from GitHub.
5858

5959
- **Judged-missing webhooks are no longer stranded below the watchdog
6060
watermark**: the watermark advances past everything a pass examines, so a
@@ -67,7 +67,10 @@ Releases before v0.27.0 predate the changelog.
6767
backoff and attempt cap as the scan, so untracked history is still never
6868
replayed. The retry scan reads this App's rows that are still below the
6969
attempt cap, so another App's backlog — or rows at the cap — cannot fill
70-
the bounded window and starve newer repairs out of it.
70+
the bounded window and starve newer repairs out of it. An attempt is
71+
claimed before the request, in one conditional store write, so two
72+
watchdogs overlapping a restart cannot both request or both charge the
73+
same delivery.
7174

7275
- **The conformance campaign survives a second run against the same
7376
Postgres database**: `conformance-5repos.sh` wiped the campaign home,

‎crates/preloop-runner-server/src/control/backend.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,20 @@ pub(crate) trait ControlBackend: Send + Sync {
10241024
attempt_cap: u32,
10251025
limit: usize,
10261026
) -> Result<Vec<WebhookRedeliveryRecord>, ControlError>;
1027+
/// Atomically take one redelivery attempt for a repair row.
1028+
///
1029+
/// Succeeds only when the stored row still looks exactly like `observed`
1030+
/// — same attempt count, same resolution state — and charges the attempt
1031+
/// and the last-attempt clock in the same statement, returning the
1032+
/// claimed row. A second claimant (a watchdog overlapping a restart, a
1033+
/// second server on the store) reads a row that no longer matches what it
1034+
/// read, gets `None`, and must not send the request: the winner's attempt
1035+
/// already covers the delivery.
1036+
async fn claim_webhook_redelivery(
1037+
&self,
1038+
observed: &WebhookRedeliveryRecord,
1039+
claimed_at_us: i64,
1040+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError>;
10271041
async fn resolve_webhook_redelivery(
10281042
&self,
10291043
delivery_guid: &str,
@@ -2874,6 +2888,16 @@ impl ControlBackend for Backend {
28742888
}
28752889
}
28762890
}
2891+
async fn claim_webhook_redelivery(
2892+
&self,
2893+
observed: &WebhookRedeliveryRecord,
2894+
claimed_at_us: i64,
2895+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError> {
2896+
match self {
2897+
Self::Sqlite(b) => b.claim_webhook_redelivery(observed, claimed_at_us).await,
2898+
Self::Postgres(b) => b.claim_webhook_redelivery(observed, claimed_at_us).await,
2899+
}
2900+
}
28772901
async fn resolve_webhook_redelivery(
28782902
&self,
28792903
delivery_guid: &str,

‎crates/preloop-runner-server/src/control/lite/impls.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,13 @@ impl ControlBackend for LiteBackend {
732732
self.retryable_webhook_redeliveries(app_id, attempt_cap, limit)
733733
.await
734734
}
735+
async fn claim_webhook_redelivery(
736+
&self,
737+
observed: &WebhookRedeliveryRecord,
738+
claimed_at_us: i64,
739+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError> {
740+
self.claim_webhook_redelivery(observed, claimed_at_us).await
741+
}
735742
async fn resolve_webhook_redelivery(
736743
&self,
737744
delivery_guid: &str,

‎crates/preloop-runner-server/src/control/lite/webhooks.rs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,58 @@ impl LiteBackend {
760760
})
761761
}
762762

763+
/// Take one redelivery attempt for a repair row, or report that someone
764+
/// else already did.
765+
///
766+
/// The `WHERE` on the conflict path is the compare of a compare-and-swap
767+
/// over the row the caller read: an attempt committed since that read
768+
/// (another watchdog, a restart overlap, a second server on the store)
769+
/// no longer matches, so the update is skipped, nothing is returned, and
770+
/// the caller sends no request. The charge and the last-attempt clock
771+
/// move in the same statement, so a claimed attempt is exactly one
772+
/// request. `IS` is null-safe equality, so an open row (`resolved_at`
773+
/// NULL) matches a NULL `resolved_at` and a closed row only reopens
774+
/// under the resolution the caller read.
775+
pub(crate) async fn claim_webhook_redelivery(
776+
&self,
777+
observed: &WebhookRedeliveryRecord,
778+
claimed_at_us: i64,
779+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError> {
780+
self.write(|tx| {
781+
tx.query_row(
782+
&format!(
783+
"INSERT INTO webhook_redeliveries (delivery_guid, github_delivery_id, app_id, \
784+
reason, attempts, first_seen_at, last_attempt_at, resolved_at, last_error) \
785+
VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, NULL, NULL) \
786+
ON CONFLICT (delivery_guid) DO UPDATE SET \
787+
github_delivery_id = excluded.github_delivery_id, \
788+
app_id = excluded.app_id, reason = excluded.reason, \
789+
attempts = webhook_redeliveries.attempts + 1, \
790+
last_attempt_at = excluded.last_attempt_at, \
791+
resolved_at = NULL, last_error = NULL \
792+
WHERE webhook_redeliveries.attempts = ?7 \
793+
AND webhook_redeliveries.resolved_at IS ?8 \
794+
RETURNING {REDELIVERY_COLUMNS}"
795+
),
796+
params![
797+
observed.delivery_guid,
798+
observed.github_delivery_id,
799+
observed.app_id,
800+
observed.reason.as_str(),
801+
observed.first_seen_at_us,
802+
claimed_at_us,
803+
observed.attempts as i64,
804+
observed.resolved_at_us,
805+
],
806+
redelivery_row,
807+
)
808+
.optional()
809+
.map_err(db)?
810+
.map(finish_redelivery)
811+
.transpose()
812+
})
813+
}
814+
763815
/// Close an open repair; `false` when unknown or already resolved.
764816
pub(crate) async fn resolve_webhook_redelivery(
765817
&self,

‎crates/preloop-runner-server/src/control/pg/trait_impl.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,13 @@ impl ControlBackend for PgBackend {
734734
self.retryable_webhook_redeliveries(app_id, attempt_cap, limit)
735735
.await
736736
}
737+
async fn claim_webhook_redelivery(
738+
&self,
739+
observed: &WebhookRedeliveryRecord,
740+
claimed_at_us: i64,
741+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError> {
742+
self.claim_webhook_redelivery(observed, claimed_at_us).await
743+
}
737744
async fn resolve_webhook_redelivery(
738745
&self,
739746
delivery_guid: &str,

‎crates/preloop-runner-server/src/control/pg/webhooks.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,66 @@ impl PgBackend {
759759
.collect()
760760
}
761761

762+
/// Take one redelivery attempt for a repair row, or report that someone
763+
/// else already did.
764+
///
765+
/// Statement: `INSERT .. ON CONFLICT (delivery_guid) DO UPDATE SET
766+
/// attempts = r.attempts + 1, .. WHERE r.attempts = $7 AND r.resolved_at
767+
/// IS NOT DISTINCT FROM $8 RETURNING ..`.
768+
///
769+
/// The `WHERE` is the compare of a compare-and-swap over the row the
770+
/// caller read: an attempt committed since that read (another watchdog,
771+
/// a restart overlap, a second server) no longer matches, so the update
772+
/// is skipped, nothing is returned, and the caller sends no request. The
773+
/// charge and the last-attempt clock move in the same statement, so a
774+
/// claimed attempt is exactly one request. Under `READ COMMITTED` the
775+
/// conflict path re-checks the condition against the row's latest
776+
/// committed version, which is what makes the race safe.
777+
pub(super) async fn claim_webhook_redelivery(
778+
&self,
779+
observed: &WebhookRedeliveryRecord,
780+
claimed_at_us: i64,
781+
) -> Result<Option<WebhookRedeliveryRecord>, ControlError> {
782+
let client = self.writer().await?;
783+
let sql = format!(
784+
"INSERT INTO webhook_redeliveries AS r (delivery_guid, github_delivery_id, app_id, \
785+
reason, attempts, first_seen_at, last_attempt_at, resolved_at, last_error) \
786+
VALUES ($1, $2, $3, $4, 1, {}, {}, NULL, NULL) \
787+
ON CONFLICT (delivery_guid) DO UPDATE SET \
788+
github_delivery_id = EXCLUDED.github_delivery_id, \
789+
app_id = EXCLUDED.app_id, reason = EXCLUDED.reason, \
790+
attempts = r.attempts + 1, last_attempt_at = EXCLUDED.last_attempt_at, \
791+
resolved_at = NULL, last_error = NULL \
792+
WHERE r.attempts = $7 AND r.resolved_at IS NOT DISTINCT FROM {} \
793+
RETURNING {REDELIVERY_COLUMNS}",
794+
// The two timestamps are microseconds on the wire, so the
795+
// comparison against `resolved_at` needs the same epoch
796+
// conversion the other timestamp columns use.
797+
ts!("$5"),
798+
ts!("$6"),
799+
ts!("$8")
800+
);
801+
client
802+
.query_opt(
803+
&sql,
804+
&[
805+
&observed.delivery_guid,
806+
&observed.github_delivery_id,
807+
&observed.app_id,
808+
&observed.reason.as_str(),
809+
&observed.first_seen_at_us,
810+
&claimed_at_us,
811+
&(observed.attempts.min(i32::MAX as u32) as i32),
812+
&observed.resolved_at_us,
813+
],
814+
)
815+
.await
816+
.map_err(db)?
817+
.as_ref()
818+
.map(redelivery_from_row)
819+
.transpose()
820+
}
821+
762822
/// Mark a repair row resolved (first resolution wins).
763823
///
764824
/// Statement: `UPDATE webhook_redeliveries SET resolved_at = $2 WHERE

0 commit comments

Comments
 (0)