Skip to content

fix(webhooks): always emit _bucket so cold-start lambdas don't strand the card - #41

Merged
yatesjalex merged 4 commits into
mainfrom
yatesclaude/busy-cray-a96e6c
Apr 23, 2026
Merged

fix(webhooks): always emit _bucket so cold-start lambdas don't strand the card#41
yatesjalex merged 4 commits into
mainfrom
yatesclaude/busy-cray-a96e6c

Conversation

@yatesjalex

@yatesjalex yatesjalex commented Apr 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Partner Simulator fires of attribution_failed required two clicks before the card moved into Needs Attention. Root cause: translatePartnerWebhook skipped _bucket in the emitted offer_updated patch whenever the server's local snapshot already sat in the target bucket.
  • On Vercel Fluid Compute the webhook and the SSE/polling channel can land on different lambda instances with divergent in-memory state — so "server already sits in pending" ≠ "client already sits in pending". When they disagree, the status-only patch stranded the card in its old bucket, and deriveActivityStatus short-circuits on bucket === 'in_progress' before it ever looks at status === 'RETRY'.
  • Fix: always emit the canonical _bucket in the three status-changing partner events (attribution_verified, attribution_failed, reward_paid). No-op when server and client agree; self-heals when they don't.

Evidence

Captured in docs/investigations/2026-04-23-partner-webhook-double-fire.md with the full HAR alongside. From the admin-reported session:

# event from → to patch _bucket?
1 attribution_failed RETRY → RETRY missing (stuck)
2 attribution_failed IN_PROGRESS → RETRY pending (moved)

Webhook #1 hit a lambda whose seed snapshot was already pending, so the old code elided _bucket; the client (on a different lambda) was in in_progress and stayed there.

Test plan

  • pnpm test src/server/webhooks/translate.test.ts — 14/14 pass, including a new end-to-end cross-instance drift regression suite that applies the emitted events to a drifted client view and asserts the offer lands in pending with RETRY.
  • pnpm test — full suite 330/330 green.
  • pnpm typecheck — clean.
  • Smoke: fire attribution_failed once from the Partner Simulator on a preview deploy after a cold start and confirm the card moves on the first click.

🤖 Generated with Claude Code


Note

Medium Risk
Changes partner webhook translation for status-changing events to always patch _bucket, which affects how offer state is reconciled across realtime clients and could move cards between buckets more aggressively if assumptions are wrong.

Overview
Fixes a production issue where partner webhook updates could require multiple fires to move an offer card, by making translatePartnerWebhook always include the canonical _bucket for attribution_verified, attribution_failed, and reward_paid (instead of emitting it only when the local snapshot differed).

Expands translate.test.ts to assert unconditional _bucket emission and adds an end-to-end regression test simulating cross-instance drift by applying translated events to a drifted client view.

Separately hardens AnimatedNumber and ProgressRing animations by sampling performance.now() inside each RAF tick (and clamping t) to avoid jsdom clock-origin issues in tests, and adds an investigation write-up documenting the webhook drift root cause and fix.

Reviewed by Cursor Bugbot for commit 5859c1a. Bugbot is set up for automated code reviews on this repo. Configure here.

… the card

Partner webhooks on Vercel Fluid Compute sometimes land on a lambda whose
in-memory snapshot hasn't seen prior traffic. The old patch-diff optimization
in translatePartnerWebhook skipped _bucket when the server's local snapshot
already sat in the target bucket — but the client, hydrating from a
different instance, may have the offer in a different bucket. The status-
only patch then silently stranded the card (deriveActivityStatus short-
circuits on bucket='in_progress' before checking the RETRY status), and
admins had to fire attribution_failed twice before Needs Attention updated.

Fix: always include the canonical _bucket for attribution_{verified,failed}
and reward_paid. No-op when server and client agree; self-heals when they
don't. HAR + investigation notes captured alongside the regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
super Ready Ready Preview, Comment Apr 23, 2026 9:03am

The 5MB HAR added noise to the PR diff. The key request/response rows are
already captured in the investigation markdown, which is the load-bearing
artifact for future readers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProgressRing and AnimatedNumber capture `start = performance.now()` and then
drive cubic easing from the `now` argument handed back by
`requestAnimationFrame`. Under jsdom + GH Actions jitter, the two clocks
sometimes disagree enough that the first frame fires with `now < start` —
negative `t`, `(1-t)^3` blows up, and the UI briefly renders `-2370%` /
`-6554%` before catching up. Locally the offsets stay in sync and tests
pass 8/8, but CI failed the ProgressRing suite on PR #41 (unrelated to the
webhook fix it was scoped to).

Clamping `t` to [0,1] makes a negative delta render `animated = 0` on that
frame; the animation converges on its target from zero regardless of the
RAF/performance-clock origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stuck

Previous attempt clamped `t` to [0,1], which stopped the wild negative
renders but left the CI ring/number stuck at 0 — subsequent CI log showed
stroke-dashoffset=circumference (animated=0) for the full 1s waitFor window.

Root cause: jsdom's requestAnimationFrame timestamp and the synchronously-
captured `performance.now()` come from different clock origins. With the
clamp, negative deltas pin t to 0; the delta only turns positive when
RAF's `now` finally crosses `performance.now()`'s origin, which on GH
Actions runners can take longer than the waitFor timeout. Before the clamp,
the negative delta just rendered garbage (-2370%, -6554%).

Fix: ignore the RAF-provided timestamp entirely and read `performance.now()`
inside the tick. Both anchor and delta now come from the same monotonic
clock, so the animation progresses on the first frame regardless of the
host's RAF timestamp semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yatesjalex
yatesjalex merged commit b4650e5 into main Apr 23, 2026
7 checks passed
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.

1 participant