Skip to content

Fix persistent 500 by resyncing cards_count after failed card insert#10

Merged
eirvandelden merged 3 commits into
mainfrom
fix/atomic-card-number-assignment
Jun 25, 2026
Merged

Fix persistent 500 by resyncing cards_count after failed card insert#10
eirvandelden merged 3 commits into
mainfrom
fix/atomic-card-number-assignment

Conversation

@eirvandelden

Copy link
Copy Markdown
Owner

Root cause of the persistent 500

The previous fixes missed a second failure mode. When card.update! raises RecordNotUnique:

  1. The entire Card.transaction rolls back — including the accounts.cards_count += 1 that happened inside assign_number.
  2. The counter is now stuck behind the actual max card number (a card with that number was created during a previous race and never cleaned up).
  3. Every retry calls increment! from the same stale counter value, gets the same already-taken number, rolls back again, and exhausts all three retries → persistent 500.

This explains why only a single request was failing (no concurrency needed) and why the retries didn't help.

Fix

In the RecordNotUnique rescue block, before retrying, reload the account and advance accounts.cards_count to MAX(cards.number) if it has fallen behind. This update runs outside the rolled-back transaction so it persists. The next retry then increments past the conflicting number and succeeds.

The Account.transaction wrapping in assign_number is kept — it serialises concurrent writers so two threads can't get the same counter value in the first place.

Test plan

  • bin/rails test test/models/card_test.rb test/controllers/cards/ — 107 tests, all pass
  • Deploy and create cards — no 500s

🤖 Generated with Claude Code

eirvandelden and others added 3 commits June 24, 2026 13:55
…action

The original code called account.increment!(:cards_count) which does an
atomic UPDATE followed by a separate SELECT (reload_attribute!). Under
concurrent load two threads could each run the UPDATE before either ran
the SELECT, causing both to read the same final counter value and then
both try to INSERT a card with that number, hitting the unique index on
(account_id, number).

Wrapping increment! in Account.transaction ensures the UPDATE and the
subsequent SELECT happen within the same SQLite write transaction. SQLite
serialises writers, so no other thread can increment the counter between
our UPDATE and our SELECT — each card creation gets a distinct number.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
When a card INSERT fails with RecordNotUnique, the entire Card.transaction
rolls back — including the accounts.cards_count increment inside it. This
leaves the counter behind the actual max card number. Every retry then
tries the same (already-taken) number, rolls back again, and all three
retries are exhausted, causing a persistent 500.

On RecordNotUnique, before retrying, reload the account and advance
cards_count to MAX(cards.number) if it has fallen behind. This update
runs outside the failed transaction so it persists. The next retry then
increments past the conflicting number and succeeds.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@eirvandelden eirvandelden merged commit a5f5a88 into main Jun 25, 2026
2 of 9 checks passed
@eirvandelden eirvandelden deleted the fix/atomic-card-number-assignment branch June 25, 2026 11:29
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