Draft: L.rosa/poc lfk table split
Table Split Analysis for LFK
The Problem
We need to know which loose_foreign_keys_deleted_records belong to an organization so we can migrate them to another cell. Right now, we have mixed data: some records have sharding keys, some don't. We also want to enforce that org-scoped records always have at least one sharding key.
What I Tried
Approach 1: Two Separate Tables (Draft: L.rosa/poc lfk table split approach 1 (!224812 - closed))
The idea was simple: split into loose_foreign_keys_deleted_records (org data) and loose_foreign_keys_deleted_records_cell_local (cell-local data).
The trigger would route inserts:
IF sharding_keys = '{}'::jsonb THEN
INSERT INTO loose_foreign_keys_deleted_records_cell_local ...
ELSE
INSERT INTO loose_foreign_keys_deleted_records ...
END IF;
But, it's messy:
The codebase calls LooseForeignKeys::DeletedRecord everywhere. We needed a facade pattern:
module LooseForeignKeys
class DeletedRecord
SHARDED_MODEL = ::LooseForeignKeys::DeletedRecordSharded
CELL_LOCAL_MODEL = ::LooseForeignKeys::DeletedRecordCellLocal
class << self
delegate_missing_to :default_model
def load_batch_for_table(table, batch_size)
model_for_table(table).load_batch_for_table(table, batch_size)
end
def mark_records_processed(records)
for_each_model_with_count(records) { |model, recs| model.mark_records_processed(recs) }
end
# ... more routing methods
end
end
end
This works, but:
- Need
SharedModelconcern to share partitioning logic between both models -
source_modelattribute on records to route updates back correctly -
delegate_missing_tofor scopes likestatus_pending - Explicit routing for
new,load_batch_for_table,mark_records_processed, and any other methods. - Adding a new method consists of altering the facade and the table concern classes
- Two tables to manage partitions for
- More test changes to handle both models
- We need to delegate one of the models (CellLocal or Clusterwide) to work as the default model in the facade
Approach 2: Single Table with clusterwide Column (this MR)
Keep one table. Add a boolean column. Use a constraint to enforce sharding keys.
ALTER TABLE loose_foreign_keys_deleted_records
ADD COLUMN clusterwide BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE loose_foreign_keys_deleted_records
ADD CONSTRAINT sharding_key_required_when_clusterwide
CHECK (
clusterwide = false
OR num_nonnulls(organization_id, namespace_id, project_id, user_id) >= 1
);
The trigger sets clusterwide based on whether sharding keys were passed:
DECLARE
sharding_keys JSONB := CASE WHEN TG_NARGS > 0 THEN TG_ARGV[0]::jsonb ELSE '{}'::jsonb END;
clusterwide BOOLEAN := sharding_keys != '{}'::jsonb;
BEGIN
...
No model changes, no facade, and no model routing.
| Two Tables | Single Table + Column | |
|---|---|---|
| Schema changes | New table + columns on existing | Columns + constraint on existing |
| Model changes | Facade + 2 models + concern | None |
| Trigger changes | Routes to different tables | Single table, sets column |
| Test changes | Need to handle both models | Minimal |
| Enforcement | Separate tables (implicit) |
CHECK constraint with num_nonnulls
|
| Partition management | Two tables | One table |
| Querying | Route to correct table | *Filter by clusterwide column |
*The filtering will happen only upon data migrating
Why two tables add complexity
The facade pattern works, but:
-
delegate_missing_tohas some limitations, likestatus_pendingonly querying to a default model - Records will need a
source_modelattribute set viaafter_initialize - Every method that creates or queries records needs routing logic
- Partition management for sliding list partitions runs separately for each table
- Specs need to handle both models and the facade
Why single table is simpler
- Existing model works unchanged
- Constraint enforces sharding key requirement at the database level
- Daily partitions mean table size isn't a concern
- Partitions detach once drained anyway
- No routing, no facade, no
source_modeltracking
My recommendation
Both approaches work. The two-table approach gives cleaner separation, while the single-table approach is simpler to implement and maintain.
If we need strict schema enforcement (separate tables), we can pick Approach 1, but we accept more complexity, maintenance overhead, and partition management.
Otherwise, Approach 2 (this MR) is a go. It's simpler, and the constraint enforces what we need, and we don't have to touch the services or workers.