Skip to content

Commit a18f248

Browse files
authored
Merge pull request #16 from code0-tech/15-fix-foreign-key-violations-during-partition-detachments
Fix foreign key violations during partition detachments
2 parents cfb96d6 + cd0eac7 commit a18f248

5 files changed

Lines changed: 214 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ When tables have foreign key relationships, registration order and retention con
189189
- A child table's `retain_for` must be less than or equal to the parent table's `retain_for`.
190190
If a child retains partitions longer than its parent, dropping the parent partition will
191191
fail because the child's foreign key still references it.
192+
- Foreign keys between the partitioned tables must be declared with `drop_foreign_keys_on_detach`
193+
on the model. Otherwise, the foreign key of a detached partition will block detaching partitions
194+
of the referenced table.
192195

193196
#### Schema Cleaner Integration
194197

‎lib/code0/zero_track/database/partitioning/partition_manager.rb‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def detach_partitions!
6464
model.partitioning_strategy.partitions_to_detach.each do |partition|
6565
detach_partition(partition, connection)
6666
end
67+
68+
drop_declared_foreign_keys_from_detached_partitions(connection)
6769
end
6870
end
6971

@@ -109,6 +111,73 @@ def detach_partition(partition, connection)
109111
)
110112
end
111113

114+
def drop_declared_foreign_keys_from_detached_partitions(connection)
115+
constraint_names = model.foreign_keys_to_drop_on_detach || []
116+
return if constraint_names.empty?
117+
118+
warn_about_unknown_foreign_keys(constraint_names, connection)
119+
120+
detached_foreign_keys(constraint_names, connection).each do |qualified_table, constraint_name|
121+
connection.execute(
122+
"ALTER TABLE #{qualified_table} " \
123+
"DROP CONSTRAINT #{connection.quote_column_name(constraint_name)}"
124+
)
125+
126+
logger.info(
127+
message: 'Dropped foreign key from detached partition',
128+
table_name: model.table_name,
129+
partition_name: qualified_table,
130+
constraint_name: constraint_name
131+
)
132+
end
133+
end
134+
135+
# We check the parent table because we drop the FK from the partition
136+
# when detaching. A missing FK on the detached partition is an expected state
137+
def warn_about_unknown_foreign_keys(constraint_names, connection)
138+
existing = parent_foreign_key_names(connection)
139+
unknown = constraint_names - existing
140+
return if unknown.empty?
141+
142+
logger.warn(
143+
message: 'Configured foreign keys to drop on detach do not exist on the partitioned table',
144+
table_name: model.table_name,
145+
constraint_names: unknown
146+
)
147+
end
148+
149+
def parent_foreign_key_names(connection)
150+
connection.select_values(<<~SQL.squish)
151+
SELECT con.conname
152+
FROM pg_catalog.pg_constraint con
153+
WHERE con.contype = 'f'
154+
AND con.conrelid = #{connection.quote(model.table_name)}::regclass
155+
SQL
156+
end
157+
158+
def detached_foreign_keys(constraint_names, connection)
159+
schema = Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
160+
quoted_names = constraint_names.map { |name| connection.quote(name) }.join(', ')
161+
162+
rows = connection.select_rows(<<~SQL.squish)
163+
SELECT (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) AS qualified_table,
164+
con.conname AS constraint_name
165+
FROM pg_catalog.pg_constraint con
166+
JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
167+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
168+
WHERE con.contype = 'f'
169+
AND con.conname IN (#{quoted_names})
170+
AND n.nspname = #{connection.quote(schema)}
171+
AND c.relkind = 'r'
172+
AND NOT c.relispartition
173+
AND NOT EXISTS (
174+
SELECT 1 FROM pg_catalog.pg_inherits i WHERE i.inhrelid = c.oid
175+
)
176+
SQL
177+
178+
rows.map { |qualified_table, constraint_name| [qualified_table, constraint_name] }
179+
end
180+
112181
def drop_partition(detached_partition, connection)
113182
schema_name = connection.quote_table_name(detached_partition.schema)
114183
partition_name = connection.quote_table_name(detached_partition.name)

‎lib/code0/zero_track/database/partitioning/partitioned_table.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ def partition_by(column, strategy:, **kwargs)
2525

2626
@partitioning_strategy = strategy_class.new(self, column, **kwargs)
2727
end
28+
29+
def drop_foreign_keys_on_detach(*constraint_names)
30+
@foreign_keys_to_drop_on_detach ||= []
31+
@foreign_keys_to_drop_on_detach.concat(constraint_names.flatten.map(&:to_s))
32+
@foreign_keys_to_drop_on_detach
33+
end
34+
35+
def foreign_keys_to_drop_on_detach
36+
@foreign_keys_to_drop_on_detach || []
37+
end
2838
end
2939
end
3040
end

‎spec/code0/zero_track/database/partitioning/partition_manager_spec.rb‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,26 @@
99
let(:model) do
1010
model = double('Model', table_name: 'events') # rubocop:disable RSpec/VerifiedDoubles
1111
allow(model).to receive(:try).with(:partitioning_strategy).and_return(partitioning_strategy)
12-
allow(model).to receive(:partitioning_strategy).and_return(partitioning_strategy)
12+
allow(model).to receive_messages(
13+
partitioning_strategy: partitioning_strategy,
14+
foreign_keys_to_drop_on_detach: foreign_keys_to_drop_on_detach
15+
)
1316
allow(model).to receive(:with_connection).and_yield(connection)
1417
model
1518
end
1619

1720
let(:partitioning_strategy) { double('Strategy') }
1821

22+
let(:foreign_keys_to_drop_on_detach) { [] }
23+
1924
let(:connection) do
2025
connection = double('Connection')
2126
allow(connection).to receive(:execute)
2227
allow(connection).to receive(:transaction).and_yield
2328
allow(connection).to receive(:quote_table_name) { |name| "\"#{name}\"" }
29+
allow(connection).to receive(:quote_column_name) { |name| "\"#{name}\"" }
2430
allow(connection).to receive(:quote) { |value| "'#{value}'" }
31+
allow(connection).to receive_messages(select_rows: [], select_values: [])
2532
connection
2633
end
2734

@@ -172,6 +179,105 @@
172179

173180
expect(connection).not_to have_received(:execute).with(a_string_matching(/DETACH/))
174181
end
182+
183+
context 'when the model declares foreign keys to drop on detach' do
184+
let(:foreign_keys_to_drop_on_detach) { %w[fk_events_parent fk_events_other] }
185+
186+
let(:partition) do
187+
Code0::ZeroTrack::Database::Partitioning::TimePartition.new(
188+
model, '2023-01-01', '2023-02-01', partition_name: 'events_202301'
189+
)
190+
end
191+
192+
before do
193+
allow(partitioning_strategy).to receive(:partitions_to_detach).and_return([partition])
194+
end
195+
196+
it 'drops the declared foreign keys found on detached partitions' do
197+
allow(connection).to receive(:select_rows).and_return(
198+
[['"partitions_dynamic"."events_202301"', 'fk_events_parent']]
199+
)
200+
201+
described_class.new(model).detach_partitions!
202+
203+
expect(connection).to have_received(:execute).with(
204+
a_string_matching(
205+
/ALTER TABLE "partitions_dynamic"."events_202301" DROP CONSTRAINT "fk_events_parent"/
206+
)
207+
)
208+
end
209+
210+
it 'reconciles every detached partition, not only the one detached in this run' do
211+
# Two already-detached partitions still carry the inherited foreign key.
212+
allow(connection).to receive(:select_rows).and_return(
213+
[
214+
['"partitions_dynamic"."events_202301"', 'fk_events_parent'],
215+
['"partitions_dynamic"."events_202212"', 'fk_events_parent']
216+
]
217+
)
218+
219+
described_class.new(model).detach_partitions!
220+
221+
expect(connection).to have_received(:execute).with(
222+
a_string_matching(/"partitions_dynamic"."events_202301" DROP CONSTRAINT "fk_events_parent"/)
223+
)
224+
expect(connection).to have_received(:execute).with(
225+
a_string_matching(/"partitions_dynamic"."events_202212" DROP CONSTRAINT "fk_events_parent"/)
226+
)
227+
end
228+
229+
it 'scopes the catalog lookup to the declared constraint names and dynamic schema' do
230+
described_class.new(model).detach_partitions!
231+
232+
expect(connection).to have_received(:select_rows).with(
233+
a_string_matching(/pg_catalog\.pg_constraint.*contype = 'f'/m)
234+
)
235+
expect(connection).to have_received(:select_rows).with(
236+
a_string_matching(/'fk_events_parent', 'fk_events_other'/)
237+
)
238+
expect(connection).to have_received(:select_rows).with(
239+
a_string_matching(/'partitions_dynamic'/)
240+
)
241+
end
242+
243+
it 'does not attempt to drop anything when the catalog reports no matching foreign keys' do
244+
allow(connection).to receive(:select_rows).and_return([])
245+
246+
described_class.new(model).detach_partitions!
247+
248+
expect(connection).not_to have_received(:execute).with(a_string_matching(/DROP CONSTRAINT/))
249+
end
250+
251+
it 'skips the catalog lookup entirely when no foreign keys are declared' do
252+
allow(model).to receive(:foreign_keys_to_drop_on_detach).and_return([])
253+
254+
described_class.new(model).detach_partitions!
255+
256+
expect(connection).not_to have_received(:select_rows)
257+
end
258+
259+
it 'warns about declared foreign keys missing from the partitioned parent table' do
260+
allow(connection).to receive(:select_values).and_return(%w[fk_events_parent])
261+
262+
described_class.new(model).detach_partitions!
263+
264+
expect(rails_logger).to have_received(:warn).with(
265+
hash_including(
266+
message: /do not exist on the partitioned table/,
267+
table_name: 'events',
268+
constraint_names: %w[fk_events_other]
269+
)
270+
)
271+
end
272+
273+
it 'does not warn when all declared foreign keys exist on the partitioned parent table' do
274+
allow(connection).to receive(:select_values).and_return(%w[fk_events_parent fk_events_other])
275+
276+
described_class.new(model).detach_partitions!
277+
278+
expect(rails_logger).not_to have_received(:warn)
279+
end
280+
end
175281
end
176282

177283
describe '#drop_partitions' do

‎spec/code0/zero_track/database/partitioning/partitioned_table_spec.rb‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,30 @@
6161
end.to raise_error(ArgumentError, /already partitioned/)
6262
end
6363
end
64+
65+
describe '.drop_foreign_keys_on_detach' do
66+
it 'defaults to an empty list' do
67+
expect(test_class.foreign_keys_to_drop_on_detach).to eq([])
68+
end
69+
70+
it 'records declared constraint names as strings' do
71+
test_class.drop_foreign_keys_on_detach(:fk_a, 'fk_b')
72+
73+
expect(test_class.foreign_keys_to_drop_on_detach).to eq(%w[fk_a fk_b])
74+
end
75+
76+
it 'accumulates across multiple declarations' do
77+
test_class.drop_foreign_keys_on_detach(:fk_a)
78+
test_class.drop_foreign_keys_on_detach(:fk_b, :fk_c)
79+
80+
expect(test_class.foreign_keys_to_drop_on_detach).to eq(%w[fk_a fk_b fk_c])
81+
end
82+
83+
it 'flattens an array argument' do
84+
test_class.drop_foreign_keys_on_detach(%i[fk_a fk_b])
85+
86+
expect(test_class.foreign_keys_to_drop_on_detach).to eq(%w[fk_a fk_b])
87+
end
88+
end
6489
end
6590
# rubocop:enable RSpec/VerifiedDoubles

0 commit comments

Comments
 (0)