Skip to content

Commit 7a5adac

Browse files
nganclaude
andcommitted
Reset PK sequences after mounting cached fixtures (#51)
ActiveRecordCoder#mount replays INSERTs that include explicit primary key values. Postgres sequences don't observe these inserts, so a later Model.create can call nextval and collide with an id we just inserted — intermittent PG::UniqueViolation in test runs that mount the cache onto a database whose sequence is at its initial value (e.g. parallel test workers with their own DB copies). Reset the sequence per connection after the batch executes: - Prefer connection.reset_column_sequences! when available (Rails main / 8.2+) — batches the reset in one round-trip per connection - Fall back to per-table connection.reset_pk_sequence! (Rails 8.0/8.1) - Skip silently on adapters that expose neither (MySQL, SQLite) Adds spec/integration/pk_sequence_repro_spec.rb that simulates the parallel-worker scenario by wiping the table and resetting the PK generator after the auto-mount, then re-mounting and asserting that a fresh Model.create succeeds. Without the fix this fails on Postgres with PG::UniqueViolation; passes naturally on MySQL/SQLite. Closes #51 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be65794 commit 7a5adac

3 files changed

Lines changed: 122 additions & 2 deletions

File tree

lib/fixture_kit/coders/active_record_coder.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ def mount(data)
4343
end
4444

4545
verify_foreign_keys!(connection)
46+
47+
# Replayed INSERTs use explicit PKs, which Postgres sequences do not
48+
# observe. Re-sync the sequence so subsequent Model.create calls don't
49+
# collide with an id we just inserted. No-op on adapters whose PK
50+
# generators advance from explicit-id INSERTs (MySQL, SQLite).
51+
reset_primary_key_sequences(connection, models.map(&:table_name))
4652
end
4753
end
4854
end
@@ -108,6 +114,16 @@ def verify_foreign_keys!(connection)
108114
end
109115
end
110116

117+
def reset_primary_key_sequences(connection, tables)
118+
# Rails main (>= 8.2) batches the reset in one round-trip per connection.
119+
# Older versions fall back to one query per table.
120+
if connection.respond_to?(:reset_column_sequences!)
121+
connection.reset_column_sequences!(tables.map { |t| [t] })
122+
elsif connection.respond_to?(:reset_pk_sequence!)
123+
tables.each { |t| connection.reset_pk_sequence!(t) }
124+
end
125+
end
126+
111127
def models_by_pool(data)
112128
seen = Set.new
113129

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
# Reproduces issue #51: when a cached fixture is mounted onto a database whose
6+
# PK generator has not seen the inserted ids (e.g. a parallel test worker with
7+
# its own DB copy), a subsequent Model.create can collide with one of the
8+
# explicit ids the cache replayed. Postgres exhibits this; MySQL/SQLite advance
9+
# their counters from explicit-id inserts and stay safe.
10+
RSpec.describe "Primary key sequence after fixture mount" do
11+
fixture do
12+
User.create!(name: "Alice PK Repro", email: "alice-pk-repro@example.com")
13+
User.create!(name: "Bob PK Repro", email: "bob-pk-repro@example.com")
14+
end
15+
16+
after do
17+
User.connection.disable_referential_integrity do
18+
User.connection.execute("DELETE FROM #{User.quoted_table_name}")
19+
end
20+
end
21+
22+
it "lets a new record be created without colliding with replayed explicit ids" do
23+
# Simulate a parallel-worker scenario: empty table, PK generator at its
24+
# initial value. The fixture's auto-mount already populated the table for
25+
# us, so wipe and reset before re-mounting.
26+
wipe_and_reset_pk!(User)
27+
28+
declaration = self.class.metadata[FixtureKit::RSpec::DECLARATION_METADATA_KEY]
29+
declaration.mount
30+
31+
expect {
32+
User.create!(name: "Charlie PK Repro", email: "charlie-pk-repro@example.com")
33+
}.not_to raise_error
34+
end
35+
36+
def wipe_and_reset_pk!(model)
37+
connection = model.connection
38+
connection.disable_referential_integrity do
39+
connection.execute("DELETE FROM #{model.quoted_table_name}")
40+
end
41+
42+
case connection.adapter_name.to_s.downcase
43+
when "postgresql"
44+
sequence = connection.pk_and_sequence_for(model.table_name)&.last
45+
connection.execute("ALTER SEQUENCE #{sequence} RESTART WITH 1") if sequence
46+
when "mysql", "mysql2", "trilogy"
47+
# MySQL advances AUTO_INCREMENT on explicit-id INSERTs, so the counter
48+
# already keeps up with the cached ids. ALTER TABLE ... AUTO_INCREMENT
49+
# implicitly commits, which would break the surrounding transactional
50+
# fixture, so we leave it alone. The test should still pass on MySQL.
51+
when "sqlite"
52+
if connection.data_source_exists?("sqlite_sequence")
53+
connection.execute("DELETE FROM sqlite_sequence WHERE name = #{connection.quote(model.table_name)}")
54+
end
55+
else
56+
raise "Unsupported adapter for PK reset: #{connection.adapter_name.inspect}"
57+
end
58+
end
59+
end

spec/unit/coders/active_record_coder_spec.rb

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,45 @@ def upsert_all_options(model)
156156
coder.mount(records)
157157
end
158158

159+
it "uses the batched reset_column_sequences! when the adapter exposes it" do
160+
records = {
161+
User => "INSERT INTO users (id, name) VALUES (1, 'Alice')",
162+
Project => "INSERT INTO projects (id, name, owner_id) VALUES (1, 'Website', 1)"
163+
}
164+
165+
fake_connection = stub_fake_connection
166+
allow(fake_connection).to receive(:respond_to?).with(:reset_column_sequences!).and_return(true)
167+
allow(fake_connection).to receive(:reset_column_sequences!)
168+
stub_shared_pool([User, Project], fake_connection)
169+
170+
coder.mount(records)
171+
172+
expect(fake_connection).to have_received(:reset_column_sequences!)
173+
.with([[User.table_name], [Project.table_name]]).once
174+
end
175+
176+
it "falls back to per-table reset_pk_sequence! when reset_column_sequences! is unavailable" do
177+
records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }
178+
179+
fake_connection = stub_fake_connection
180+
allow(fake_connection).to receive(:respond_to?).with(:reset_pk_sequence!).and_return(true)
181+
allow(fake_connection).to receive(:reset_pk_sequence!)
182+
stub_pool(User, fake_connection)
183+
184+
coder.mount(records)
185+
186+
expect(fake_connection).to have_received(:reset_pk_sequence!).with(User.table_name).once
187+
end
188+
189+
it "skips PK sequence reset on adapters that expose neither method" do
190+
records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }
191+
192+
fake_connection = stub_fake_connection
193+
stub_pool(User, fake_connection)
194+
195+
expect { coder.mount(records) }.not_to raise_error
196+
end
197+
159198
context "when ActiveRecord.verify_foreign_keys_for_fixtures is true" do
160199
around do |example|
161200
previous = ActiveRecord.verify_foreign_keys_for_fixtures
@@ -212,13 +251,19 @@ def stub_fake_connection
212251
allow(fake_connection).to receive(:execute_batch)
213252
allow(fake_connection).to receive(:quote_table_name) { |name| %("#{name}") }
214253
allow(fake_connection).to receive(:check_all_foreign_keys_valid!)
254+
allow(fake_connection).to receive(:respond_to?).with(:reset_column_sequences!).and_return(false)
255+
allow(fake_connection).to receive(:respond_to?).with(:reset_pk_sequence!).and_return(false)
215256
fake_connection
216257
end
217258

218259
def stub_pool(model, connection)
219-
pool = double("pool-for-#{model.name}")
260+
stub_shared_pool([model], connection)
261+
end
262+
263+
def stub_shared_pool(models, connection)
264+
pool = double("pool-for-#{models.map(&:name).join('-')}")
220265
allow(pool).to receive(:with_connection).and_yield(connection)
221-
allow(model).to receive(:connection_pool).and_return(pool)
266+
models.each { |m| allow(m).to receive(:connection_pool).and_return(pool) }
222267
end
223268
end
224269

0 commit comments

Comments
 (0)