Migration helper to sync trigger partitioned tables
What does this MR do?
Related issue: #218424 (closed)
Overview
This change builds on the work started in #32440 (closed) to implement a set of migration helpers that can be used to leverage table partitioning. Since PG does not support partitioning existing tables, the migration logic has to create a partitioned copy of the original table, and copy the data over. Like most migrations, this has requirement that it can be performed without downtime or impact to the application.
In the first MR, the migration helpers were added with the functionality to create a copy of the existing table, and create the partitions by date range (one partition per month).
This MR builds on that by implementing the next piece of the migration helper, which is a trigger that fires for all writes on the source table, and performs the equivalent write on the partitioned table. This ensures that once the migration is in place, any future writes will be synced to the partitioned table.
The final MR will implement the last piece of the migration, which will copy historic data to the partitioned table using a BackgroundMigration.
Example Function:
BEGIN
IF (TG_OP = 'DELETE') THEN
DELETE FROM audit_events_part_5fc467ac26 where id = OLD.id;
ELSIF (TG_OP = 'UPDATE') THEN
UPDATE audit_events_part_5fc467ac26
SET author_id = NEW.author_id,
type = NEW.type,
entity_id = NEW.entity_id,
entity_type = NEW.entity_type,
details = NEW.details,
updated_at = NEW.updated_at,
created_at = NEW.created_at
WHERE audit_events_part_5fc467ac26.id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO audit_events_part_5fc467ac26 (id,
author_id,
type,
entity_id,
entity_type,
details,
updated_at,
created_at)
VALUES (NEW.id,
NEW.author_id,
NEW.type,
NEW.entity_id,
NEW.entity_type,
NEW.details,
NEW.updated_at,
NEW.created_at);
END IF;
RETURN NULL;
END
Transactions
The MR as implemented now expects the entire migration not to be run transactionally. This is primarily because adding a trigger to a table requires a ShareRowExclusiveLock
and dropping a trigger from a table requires an AccessExclusiveLock
, so we need to minimize the time that lock is held, as well as allow for lock retries.
For an up
migration, we don't take the lock for the trigger until the final step, so the lock would be held for a minimal duration. For a down
migration we first drop the trigger, then drop the table/partitions. This would hold the lock on the table with the trigger until the partitioned table is cleaned up. While the risk of removing the partitioned table seems low, I felt it was safer to break this up.
The helper checks for existence of objects before executing operations, so if a non-transactional migration were to fail, it should be seamless to rerun the set of migrations without intervention.
Example Use:
class PartitionAuditEvents < ActiveRecord::Migration[6.0]
include Gitlab::Database::PartitioningMigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
partition_table_by_date :audit_events, :created_at, min_date: Date.new(2020, 1, 1), max_date: Date.new(2021, 1, 1)
end
def down
drop_partitioned_table_for :audit_events
end
end
up
Output of rails db:migrate:up VERSION=20200602144438
== 20200602144438 PartitionAuditEvents: migrating =============================
-- transaction_open?()
-> 0.0000s
-- table_exists?("audit_events_part_5fc467ac26")
-> 0.0003s
-- transaction()
-- execute("CREATE TABLE audit_events_part_5fc467ac26 (\n LIKE audit_events INCLUDING ALL EXCLUDING INDEXES,\n partition_key_bfab5f7bb7 timestamp without time zone NOT NULL,\n PRIMARY KEY (id, partition_key_bfab5f7bb7)\n) PARTITION BY RANGE (partition_key_bfab5f7bb7)\n")
-> 0.0019s
-- remove_column("audit_events_part_5fc467ac26", "created_at")
-> 0.0003s
-- rename_column("audit_events_part_5fc467ac26", "partition_key_bfab5f7bb7", "created_at")
-> 0.0012s
-- change_column_default("audit_events_part_5fc467ac26", "id", nil)
-> 0.0011s
-> 0.0054s
-- table_exists?("audit_events_part_5fc467ac26_000000")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_000000 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM (MINVALUE) TO ('2020-01-01')\n")
-> 0.0022s
-- table_exists?("audit_events_part_5fc467ac26_202001")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202001 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-01-01') TO ('2020-02-01')\n")
-> 0.0019s
-- table_exists?("audit_events_part_5fc467ac26_202002")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202002 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-02-01') TO ('2020-03-01')\n")
-> 0.0031s
-- table_exists?("audit_events_part_5fc467ac26_202003")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202003 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-03-01') TO ('2020-04-01')\n")
-> 0.0025s
-- table_exists?("audit_events_part_5fc467ac26_202004")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202004 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-04-01') TO ('2020-05-01')\n")
-> 0.0028s
-- table_exists?("audit_events_part_5fc467ac26_202005")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202005 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-05-01') TO ('2020-06-01')\n")
-> 0.0027s
-- table_exists?("audit_events_part_5fc467ac26_202006")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202006 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-06-01') TO ('2020-07-01')\n")
-> 0.0027s
-- table_exists?("audit_events_part_5fc467ac26_202007")
-> 0.0006s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202007 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-07-01') TO ('2020-08-01')\n")
-> 0.0022s
-- table_exists?("audit_events_part_5fc467ac26_202008")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202008 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-08-01') TO ('2020-09-01')\n")
-> 0.0021s
-- table_exists?("audit_events_part_5fc467ac26_202009")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202009 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-09-01') TO ('2020-10-01')\n")
-> 0.0025s
-- table_exists?("audit_events_part_5fc467ac26_202010")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202010 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-10-01') TO ('2020-11-01')\n")
-> 0.0029s
-- table_exists?("audit_events_part_5fc467ac26_202011")
-> 0.0004s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202011 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-11-01') TO ('2020-12-01')\n")
-> 0.0022s
-- table_exists?("audit_events_part_5fc467ac26_202012")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202012 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2020-12-01') TO ('2021-01-01')\n")
-> 0.0022s
-- table_exists?("audit_events_part_5fc467ac26_202101")
-> 0.0003s
-- execute("CREATE TABLE audit_events_part_5fc467ac26_202101 PARTITION OF audit_events_part_5fc467ac26\nFOR VALUES FROM ('2021-01-01') TO ('2021-02-01')\n")
-> 0.0030s
-- execute("CREATE FUNCTION table_sync_function_2be879775d()\nRETURNS TRIGGER AS\n$$\nBEGIN\nIF (TG_OP = 'DELETE') THEN\n DELETE FROM audit_events_part_5fc467ac26 where id = OLD.id;\nELSIF (TG_OP = 'UPDATE') THEN\n UPDATE audit_events_part_5fc467ac26\n SET author_id = NEW.author_id,\n type = NEW.type,\n entity_id = NEW.entity_id,\n entity_type = NEW.entity_type,\n details = NEW.details,\n updated_at = NEW.updated_at,\n created_at = NEW.created_at\n WHERE audit_events_part_5fc467ac26.id = NEW.id;\nELSIF (TG_OP = 'INSERT') THEN\n INSERT INTO audit_events_part_5fc467ac26 (id,\n author_id,\n type,\n entity_id,\n entity_type,\n details,\n updated_at,\n created_at)\n VALUES (NEW.id,\n NEW.author_id,\n NEW.type,\n NEW.entity_id,\n NEW.entity_type,\n NEW.details,\n NEW.updated_at,\n NEW.created_at);\nEND IF;\nRETURN NULL;\n\nEND\n$$ LANGUAGE PLPGSQL\n")
-> 0.0013s
-- execute("CREATE TRIGGER table_sync_trigger_ee39a25f9d\nAFTER INSERT OR UPDATE OR DELETE ON audit_events\nFOR EACH ROW\nEXECUTE PROCEDURE table_sync_function_2be879775d()\n")
-> 0.0003s
== 20200602144438 PartitionAuditEvents: migrated (0.0615s) ====================
down
Output of rails db:migrate:down VERSION=20200602144438
== 20200602144438 PartitionAuditEvents: reverting =============================
-- transaction_open?()
-> 0.0000s
-- execute("DROP TRIGGER IF EXISTS table_sync_trigger_ee39a25f9d ON audit_events")
-> 0.0006s
-- execute("DROP FUNCTION IF EXISTS table_sync_function_2be879775d()")
-> 0.0004s
-- drop_table("audit_events_part_5fc467ac26")
-> 0.0099s
== 20200602144438 PartitionAuditEvents: reverted (0.0145s) ====================
Does this MR meet the acceptance criteria?
Conformity
- [-] Changelog entry
- [-] Documentation (if required)
-
Code review guidelines -
Merge request performance guidelines -
Style guides - [-] Database guides
- [-] Separation of EE specific content
Availability and Testing
- [-] Review and add/update tests for this feature/bug. Consider all test levels. See the Test Planning Process.
- [-] Informed Infrastructure department of a default or new setting change, if applicable per definition of done