Skip to content

fix(billing): migrate the ProcessedStripeEvent TTL index to its dead-letter-preserving spec - #4007

Merged
PierreBrisorgueil merged 1 commit into
masterfrom
fix/processed-stripe-event-ttl-migration
Jul 29, 2026
Merged

fix(billing): migrate the ProcessedStripeEvent TTL index to its dead-letter-preserving spec#4007
PierreBrisorgueil merged 1 commit into
masterfrom
fix/processed-stripe-event-ttl-migration

Conversation

@PierreBrisorgueil

Copy link
Copy Markdown
Contributor

Summary

Scope

  • Module(s) impacted: billing (migrations only — no schema/model change, the schema already declares the target spec)
  • Cross-module impact: none
  • Risk level: medium (touches a live TTL index on a collection that may already hold data; mitigated by fast-path skip, ambiguity pre-check, and idempotent re-run — see below)

The defects

1. Schema/migration drift (the crash/silent-wrong-behavior bug). The schema's TTL index on processedAt gained partialFilterExpression: { deadLetter: { $eq: false } } with no accompanying migration. On an already-deployed database still holding the plain processedAt_1 TTL index, autoIndex hits IndexOptionsConflict (code 85 — same name, different options) on every boot. Left unrepaired, the live index keeps its old semantics forever: it goes on purging dead-letter documents (permanent rejections — Stripe was told to stop retrying) after 30 days, reopening the exact risk the partial filter exists to close — a later manual replay would re-process a purged event as brand new, causing double credit grants / double subscription resets.

2. Latent backfill gap. Documents written before the deadLetter field existed carry no deadLetter at all. Partial-filter matching uses query semantics, and { deadLetter: { $eq: false } } does not match a missing field — those pre-mechanism documents would fall outside the TTL's scope entirely and never expire. The migration backfills deadLetter: false onto them before recreating the index; the schema default already covers every write since the mechanism landed.

3. Ambiguity pre-check (no guessing). Before backfilling, the migration does a zero-write pre-check: a document missing deadLetter but carrying dead-letter-era evidence (attempts > 0 or a non-null lastError) is impossible via any code path (those fields shipped together with the mechanism, and the schema default has materialized deadLetter on every write since) — but if manual surgery or a partial restore ever produced one, silently defaulting it to false could mark a genuine permanent rejection as purgeable. The migration aborts loud instead, naming sample ids, and leaves classification to a human. This is the same "abort loud on the impossible state" posture as the sibling index migrations.

Why same-name swap, not the weekKey rename technique

20260728120000-fix-usage-weekkey-index-partial.js recreates its index under a new name to sidestep a conflict window. This migration deliberately does not do that: the schema already ships this index's target spec under its default name (processedAt_1), so renaming now would force every already-converged database to drop a correct index and install an identical twin under a different name — pure churn, just to spare legacy databases a conflict that PR #4004's boot tolerance already makes survivable. Ordering here is fast-path skip (already-exact spec + nothing to backfill → no-op) → ambiguity pre-check → backfill → drop same-key/same-name index(es) → recreate exact spec. No unique constraint is involved anywhere in this index, so there is no duplicate-key/E11000 window like the unique-index siblings — the only cost of the drop→recreate step is TTL expiry pausing briefly, which is harmless.

Merge-order dependency

#4005 (boot tolerance for same-name index conflicts, closing #4004) must merge before this PR reaches a legacy (already-deployed) database. On such a database the live index still differs from the schema declaration, so awaitIndexBuilds() hits IndexOptionsConflict at boot; only with #4005's tolerance does boot survive long enough for the migration runner — which runs right after — to reach and execute this migration. Fresh databases and databases where the index already autoIndex'd correctly are unaffected either way (this migration's fast path is a no-op for them).

Accepted residual

Same class of residual the sibling index migrations document: a narrow rolling-deploy TOCTOU window between the ambiguity pre-check (a read) and the backfill (a write) — a document could theoretically transition into the ambiguous shape between the two if multiple instances deploy concurrently mid-rollout. Not closed here, consistent with the siblings. Practically unreachable: the ambiguous shape requires dead-letter-era evidence, and Stripe's own retry window (3 days) is two orders of magnitude shorter than this index's 30-day TTL, leaving effectively no realistic scenario where that race matters before the window that would exploit it closes on its own.

Validation

  • npm run lint
  • npm test
  • Manual checks done (if applicable) — integration test file (7 cases) modeled on billing.usage.weekKeyIndexPartialFilter.migration.integration.tests.js: legacy plain-TTL convergence, missing-field backfill, dead-letter documents untouched, ambiguity-abort, idempotent re-run, fast-path skip, syncIndexes() twin-identity

Guardrails check

  • No secrets or credentials introduced (.env*, secrets/**, keys, tokens)
  • No risky rename/move of core stack paths
  • Changes remain merge-friendly for downstream projects
  • Tests added or updated when behavior changed

Notes for reviewers

  • Security considerations: the defect being fixed is itself a security-adjacent data-integrity gap (dead-letter documents expiring and becoming replayable → double-processing). The fix closes it without introducing new attack surface.
  • Mergeability considerations: merge fix(mongoose): tolerate same-name index conflicts at boot so migrations can repair them #4005 first (see Merge-order dependency above) — this migration is only reachable on a legacy database once boot tolerates the pre-existing index conflict.
  • Follow-up tasks (optional): none identified.

…letter-preserving spec

The schema's 30-day TTL index on processedAt gained
partialFilterExpression { deadLetter: { $eq: false } } with no
accompanying migration. An already-deployed database still holding the
plain processedAt_1 TTL index hits IndexOptionsConflict (code 85) at
every boot — tolerated since #4004, but the live index keeps purging
dead-letter documents (permanent rejections that must never re-enter
replay) until it is swapped.

The migration also backfills deadLetter: false onto documents written
before the field existed: a partial filter's $eq: false never matches a
missing field, so those documents fell outside the TTL entirely and
would never expire.

Same-name swap (fast-path no-op on converged databases), modeled on the
sibling index migrations. No unique constraint involved, so no
duplicate-key window exists; the drop/recreate window only pauses TTL
expiry briefly. Reachable on legacy databases only with the #4004 boot
tolerance deployed — that fix must merge first.

Closes #4006
@PierreBrisorgueil PierreBrisorgueil added the Fix A bug fix label Jul 29, 2026
@PierreBrisorgueil PierreBrisorgueil self-assigned this Jul 29, 2026
@PierreBrisorgueil PierreBrisorgueil added the Fix A bug fix label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PierreBrisorgueil, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e1c57053-bbf1-4c34-962e-996313002d04

📥 Commits

Reviewing files that changed from the base of the PR and between fb771a8 and 4daa1db.

📒 Files selected for processing (2)
  • modules/billing/migrations/20260729120000-fix-processed-stripe-event-ttl-index-partial-filter.js
  • modules/billing/tests/billing.processedStripeEvent.ttlIndexPartialFilter.migration.integration.tests.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.50%. Comparing base (fb771a8) to head (4daa1db).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4007   +/-   ##
=======================================
  Coverage   93.50%   93.50%           
=======================================
  Files         170      170           
  Lines        5727     5727           
  Branches     1839     1839           
=======================================
  Hits         5355     5355           
  Misses        302      302           
  Partials       70       70           
Flag Coverage Δ
integration 61.79% <ø> (ø)
unit 75.99% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update fb771a8...4daa1db. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@PierreBrisorgueil
PierreBrisorgueil marked this pull request as ready for review July 29, 2026 14:39
@PierreBrisorgueil
PierreBrisorgueil merged commit 5d41fe2 into master Jul 29, 2026
8 checks passed
@PierreBrisorgueil
PierreBrisorgueil deleted the fix/processed-stripe-event-ttl-migration branch July 29, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Billing: ProcessedStripeEvent TTL index gained a partial filter with no migration — legacy databases keep the wrong (dead-letter-purging) index

1 participant