Fix race condition in slug generation#3
Conversation
When two processes create records with the same base slug simultaneously, the second process now retries with a new random suffix instead of crashing. Changes: - Add around_create retry for NOT NULL slug columns (pre-INSERT collision) - Add with_slug_retry helper with PostgreSQL-safe savepoints (requires_new: true) - Add slug_column_not_null? optimization to skip overhead for nullable columns - Add slug_unique_violation? detection for SQLite/PostgreSQL/MySQL - Update set_slug to retry with savepoints on RecordNotUnique - Use non-bang save in update_slug_if_nil (after_find path) Closes #2 - simplified implementation replacing over-engineered PR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverall this is a focused, well-structured fix for the TOCTOU race condition. The dual-path approach (around_create for NOT NULL, after_create for nullable) is clean, the CHANGELOG/README are thorough, and the savepoint handling is necessary and correct. A few issues worth addressing before merging: Issues1. The NOT NULL retry path isn't actually tested (medium)
2.
|
Changes from Codex review: - Remove compute_slug_for_retry in favor of direct compute_slug calls - Add for_insert: parameter to with_slug_retry for path distinction - Make with_slug_retry yield attempt count for caller decision-making - Simplify set_slug to use shared with_slug_retry helper - Fix boundary: == to >= for MAX_SLUG_GENERATION_ATTEMPTS check - Update tests to be more behavioral and less source-inspection - Add additional README warnings about retry behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Slug is already regenerated at the call site in retry_create_on_slug_unique_violation when attempts.positive?, so recomputing it again in the helper was redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Fix race condition in slug generationOverall this is a solid, well-scoped fix. The approach—retry on Below are the issues I found, ordered by severity. Bugs / Correctness1. # Before
def set_slug
return unless slug_persisted?
self.slug = compute_slug if id_changed? || slug.blank?
self.save
end
# After
def set_slug
return unless slug_persisted?
return unless slug.blank?
# ...
endFor NOT NULL columns, 2. Before, Design Concerns3. Reliance on def retry_create_on_slug_unique_violation
with_slug_retry(for_insert: true) do |attempts|
self.slug = compute_slug if attempts.positive?
yield # <-- re-invoked on retry
end
endCalling 4. # Before: non-bang, failure swallowed
self.save
# After: bang, raises ActiveRecord::RecordInvalid on other validation failures
save!If a model has other validations that can fail after create (e.g., cross-model validations that depend on state), this will now raise where before it was silent. Probably the right call—failing loudly is better—but it's a behavior change for anyone relying on 5. included do
around_create :retry_create_on_slug_unique_violation # runs for ALL models
# ...
endThe method returns immediately for nullable columns ( Test Coverage6. # test_helper.rb - defined but never called
def self.reset_strict_slug_model!
StrictSlugModel.delete_all
endThe 7. assert transaction_depths.any? { |depth| depth >= 1 }
8. Internal method testing via Tests like Minor Nits
Summary
The two things I'd fix before merging: the weak savepoint assertion (7) and the dead helper (6). The |
Code Review – PR #3: Fix race condition in slug generationOverall this is a well-scoped, focused fix. The approach (DB-level retry with savepoints) is the correct solution for this class of race condition. The documentation and test coverage are good. Below are my findings organized by severity. Bugs / Correctness1. # Before
def set_slug
self.slug = compute_slug if id_changed? || slug.blank?
self.save
end
# After
with_slug_retry do |_attempts|
self.slug = compute_slug
save!
endThe switch from 2. The after_find legacy repair path now uses plain def update_slug_if_nil
return unless slug_persisted? && slug.nil?
self.slug = compute_slug
save
endIf two processes simultaneously load a legacy record with 3. record.define_singleton_method(:save) { false }
record.send(:update_slug_if_nil)
assert record.slug.present?The assertion passes because Design Concerns4. included do
around_create :retry_create_on_slug_unique_violation # runs for all models
after_create :set_slug
...
endFor nullable slug models (the common case), 5. The NOT NULL setup requires manual user boilerplate The gem now has two distinct setup paths, and the NOT NULL path requires users to define their own private 6. Even for models with no realistic collision risk (e.g., ID-based hex string slugs), every with_slug_retry do |_attempts|
self.slug = compute_slug
save!
endFor ID-based slug strategies, Minor Issues7. Magic string comments ( The inline comments reference a design document by code ( # S1: around_create retry for NOT NULL slug columns (pre-INSERT collision)
# S2: Uses savepoint (requires_new: true) for PostgreSQL compatibilityThis notation implies there's a specification document somewhere, but none is linked. These appear to have been design-phase labels that weren't cleaned up. Consider replacing them with self-explanatory prose comments, or linking to the PR/issue if they're meant for archeological purposes. 8. message.match?(/\bslug\b|_on_slug\b/)The pattern 9.
Test CoverageThe test suite is good. Particular strengths:
Missing tests:
Summary
The implementation is sound. The main thing to resolve before merging is the |
Code Review — PR #3: Fix race condition in slug generationOverall this is a solid, well-scoped fix. The core design is sound: savepoint-based retry catches the actual DB constraint violation rather than relying solely on the optimistic Bug / Correctness
The CHANGELOG and README warn that Inconsistency
The CHANGELOG says:
But if attempts >= MAX_SLUG_GENERATION_ATTEMPTS
slug_candidate = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
endThe comment on Test quality
record.define_singleton_method(:save) { false }
record.define_singleton_method(:save!) { raise "save! should not be called from update_slug_if_nil" }
record.send(:update_slug_if_nil)
assert record.slug.present?This test confirms No actual concurrent write test for the new retry paths The new tests simulate race conditions by injecting synthetic Code styleNumbered review tags ( # S1: around_create retry for NOT NULL slug columns (pre-INSERT collision)
# S2: Uses savepoint (requires_new: true) for PostgreSQL compatibility
# S4: Skips overhead when slug column is nullable ...These look like cross-reference markers from a design document, not long-lived documentation. The numbers are non-obvious out of context (e.g. Minor
Summary
The core logic is correct and the test coverage is substantially better than the status quo. Happy to see this merged once the changelog wording is clarified. |
Summary
Fixes the race condition that occurs when two processes create records with the same base slug simultaneously. This is a minimal, focused fix that replaces the over-engineered PR #2.
The Problem
When two processes race:
RecordNotUniqueThe Fix
RecordNotUniqueis raised for a slug column, regenerate with a new random suffix and retrytransaction(requires_new: true)to prevent transaction abort on retryChanges
Files Changed
lib/slugifiable/model.rb- Add retry logic with savepointstest/slugifiable/race_condition_test.rb- New focused test filetest/test_helper.rb- Add StrictSlugModel fixtureCHANGELOG.md- Document changes and known limitationsREADME.md- Add NOT NULL slug column setup guideKnown Limitations
Documented in CHANGELOG:
before_createcallbacks re-execute on retry (design callbacks to be idempotent)RecordNotUniquetriggers retry, not validation-level errorsCloses #2
🤖 Generated with Claude Code