/** * Author: sthomas@peak6.com * * Several tables can be spread across several data storage tiers. Tiers can * consist of tablespaces with varying performance characteristics, or foreign * servers meant to be accessible, but not generally by the platform. These * types of tiers need a system to manage migration between data layers that * will not adversely affect platform performance. * * This library handles all maintenance in creating new tier partitions and * data ushering between partition segments, if applicable. */ \echo Use "CREATE EXTENSION tab_tier;" to load this file. \quit SET client_min_messages = warning; -------------------------------------------------------------------------------- -- CREATE EXTENSION USER -------------------------------------------------------------------------------- DO $$ BEGIN PERFORM 1 FROM pg_roles WHERE rolname = 'tab_tier_role'; IF NOT FOUND THEN EXECUTE 'CREATE ROLE tab_tier_role'; EXECUTE 'GRANT USAGE ON SCHEMA @extschema@ TO tab_tier_role'; END IF; END; $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- -- CREATE TABLES -------------------------------------------------------------------------------- CREATE TABLE tier_config ( config_id SERIAL NOT NULL PRIMARY KEY, config_name VARCHAR NOT NULL UNIQUE, setting VARCHAR NOT NULL, is_default BOOLEAN NOT NULL DEFAULT False, created_dt TIMESTAMP NOT NULL DEFAULT now(), modified_dt TIMESTAMP NOT NULL DEFAULT now() ); SELECT pg_catalog.pg_extension_config_dump('tier_config', 'WHERE NOT is_default'); CREATE TABLE tier_root ( tier_root_id SERIAL NOT NULL PRIMARY KEY, root_schema VARCHAR NOT NULL, root_table VARCHAR NOT NULL, date_column VARCHAR NULL, part_period INTERVAL NOT NULL, tier_proc VARCHAR NULL, part_tablespace VARCHAR NOT NULL, root_retain INTERVAL NOT NULL, lts_target VARCHAR NULL, lts_threshold INTERVAL NULL, is_default BOOLEAN NOT NULL DEFAULT False, created_dt TIMESTAMP NOT NULL DEFAULT now(), modified_dt TIMESTAMP NOT NULL DEFAULT now() ); SELECT pg_catalog.pg_extension_config_dump('tier_root', 'WHERE NOT is_default'); CREATE TABLE tier_part ( tier_part_id SERIAL NOT NULL PRIMARY KEY, tier_root_id INT NOT NULL, part_schema VARCHAR NOT NULL, part_table VARCHAR NOT NULL, check_start TIMESTAMP NOT NULL, check_stop TIMESTAMP NOT NULL, is_default BOOLEAN NOT NULL DEFAULT False, is_archived BOOLEAN NOT NULL DEFAULT False, created_dt TIMESTAMP NOT NULL, modified_dt TIMESTAMP NOT NULL ); SELECT pg_catalog.pg_extension_config_dump('tier_part', 'WHERE NOT is_default'); ALTER TABLE tier_part ADD CONSTRAINT fk_tier_part_root FOREIGN KEY (tier_root_id) REFERENCES tier_root (tier_root_id) ON DELETE CASCADE; -------------------------------------------------------------------------------- -- CREATE FUNCTIONS -------------------------------------------------------------------------------- /** * Create all necessary partitions for a new tier root * * When selecting new tables to partition, registration is only the first step. * The table is very likely to have existing data that needs to be relocated. * The hardest part of relocating this old data is creating all of the * tier partitions, which may number in the dozens. This library has functions * to handle all this, so this function utilizes them to create partitions * to accommodate the oldest data in the root table, to the edge of the * retention window. * * @param string Schema name of root table to bootstrap. * @param string Table Name of root table to bootstrap. * @param boolean Create partitions even for future dates. */ CREATE OR REPLACE FUNCTION bootstrap_tier_parts( sSchema VARCHAR, sTable VARCHAR, bFuture BOOLEAN ) RETURNS VOID AS $$ DECLARE rRoot @extschema@.tier_root%ROWTYPE; dStart DATE; dCurrent DATE; dFinal DATE := CURRENT_DATE; BEGIN -- Retrieve the root definition. That will define all of our crazy work -- in creating potentially dozens of partitions. SELECT INTO rRoot * FROM @extschema@.tier_root WHERE root_schema = sSchema AND root_table = sTable; IF NOT FOUND THEN RAISE EXCEPTION 'Could not bootstrap %. Not found!', quote_ident(sTable); END IF; -- Get the smallest known value from our source table for the date_column. -- This will determine the date of our earliest partition. If the root -- table would have no partitions, just skip it. EXECUTE 'SELECT min(' || quote_ident(rRoot.date_column) || ') FROM ' || quote_ident(sSchema) || '.' || quote_ident(sTable) INTO dStart; IF dStart IS NULL THEN RETURN; END IF; -- If we're asked to create future partitions, replace the stopping date -- with the maximum value found in the root table. IF bFuture THEN EXECUTE 'SELECT max(' || quote_ident(rRoot.date_column) || ') FROM ' || quote_ident(sSchema) || '.' || quote_ident(sTable) INTO dFinal; dFinal = dFinal - rRoot.part_period; END IF; -- Insert a "dummy" row into the tier partition tracking table, one -- part_period older than the oldest known date in the source. This record -- will create an artificial time gap that we can use extend_tier_root -- to fill in the "missing" partitions. INSERT INTO @extschema@.tier_part (tier_root_id, part_schema, part_table, check_start, check_stop) VALUES (rRoot.tier_root_id, 'strap', 'strap', dStart - rRoot.part_period, dStart); -- Loop creating partitions until we have enough to satisfy an insert -- only one day older than root_retain, minus the width of one period. -- This ensures at least one extra partition exists if the date rolls -- over, and is what cap_tier_partitions would do in any case. dCurrent = dStart; WHILE dCurrent <= dFinal - rRoot.root_retain + rRoot.part_period LOOP PERFORM @extschema@.extend_tier_root(sSchema, sTable); dCurrent = dCurrent + rRoot.part_period; END LOOP; -- Finally, delete the bootstrap row we created in the partition tracker. DELETE FROM @extschema@.tier_part WHERE tier_root_id = rRoot.tier_root_id AND part_table = 'strap'; END; $$ LANGUAGE plpgsql VOLATILE; /** * Ensures all root tables have at least one extra partition target * * Because of the nature of the tier system, we want to ensure at least one * target tier partition exists at all times for the desired date ranges. * Doing otherwise requires a lot of extra manual work to do data shifting. * This procedure checks all registered root tables in the tier system and * will create new partitions to match the granularity in part_period. * * Calling this function several times in a row will do nothing on subsequent * runs due to its design. It will only function when new partitions are needed. * * If any root can not be extended, it is skipped and a warning is raised. */ CREATE OR REPLACE FUNCTION cap_tier_partitions() RETURNS VOID AS $$ DECLARE sSchema VARCHAR; sTable VARCHAR; BEGIN -- This query should identify any root which is missing at least one -- partition for data older than root_retain. The gap between the current -- date and the most recent partition determines how many more partitions -- are generated. The minimum granularity for this function to work is -- 1 day. FOR sSchema, sTable IN SELECT r.root_schema, r.root_table, generate_series(1, floor(extract(epoch from ( coalesce(CURRENT_DATE - max(p.check_stop), r.root_retain))) / extract(epoch from r.root_retain))::int + 1) AS gap FROM @extschema@.tier_root r LEFT JOIN @extschema@.tier_part p ON ( p.tier_root_id = r.tier_root_id AND NOT p.is_archived ) GROUP BY r.root_schema, r.root_table, r.root_retain, r.part_period HAVING coalesce(CURRENT_DATE - max(p.check_stop), r.root_retain) > r.root_retain - r.part_period - INTERVAL '1 day' LOOP BEGIN PERFORM @extschema@.extend_tier_root(sSchema, sTable, FALSE); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered extending %! Skipping.', sTable; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Copy all indexes from one table to another. * * When making a copy of a table, much is lost. Among them are indexes, * which is actually partually beneficial. This allows us to fill a table * copy and apply the indexes last to optimize creation time. * * @param string Name of Schema where source objects can be found. * @param string Name of the source table for indexes. * @param string Name of schema containing the target table for the indexes. * @param string Name of target table for indexes. */ CREATE OR REPLACE FUNCTION _copy_indexes(VARCHAR, VARCHAR, VARCHAR, VARCHAR) RETURNS VOID AS $$ DECLARE sSchema ALIAS FOR $1; sSource ALIAS FOR $2; sNSPTarget ALIAS FOR $3; sTarget ALIAS FOR $4; rIndex RECORD; sIndex VARCHAR; nCounter INT := 1; BEGIN -- Cascade every known index from the source table to the target. -- This should not include primary keys because we may have manually added -- such a beast with a constraint cascade. The only exception is the -- partition column. FOR rIndex IN SELECT pg_get_indexdef(i.oid) AS indexdef, x.indisprimary, CASE WHEN x.indisunique = True THEN 'u' ELSE 'i' END AS indtype FROM pg_index x JOIN pg_class c ON (c.oid = x.indrelid) JOIN pg_class i ON (i.oid = x.indexrelid) JOIN pg_namespace n ON (n.oid = c.relnamespace) WHERE n.nspname = sSchema AND c.relname = sSource GROUP BY 1, 2, 3 LOOP -- Generate an index name that isn't *quite* as long, since all child -- tables will have a bunch of extra cruft added that might get -- truncated. sIndex = rIndex.indtype || 'dx_' || regexp_replace(sTarget, '([a-z]{1,4})[a-z]*?_?', '\1_', 'ig') || nCounter; rIndex.indexdef = regexp_replace(rIndex.indexdef, 'INDEX [\w\.]+ ', 'INDEX ' || sIndex || ' '); rIndex.indexdef = regexp_replace(rIndex.indexdef, ' ON [\w\.]+ ', ' ON ' || sNSPTarget || '.' || sTarget || ' '); EXECUTE rIndex.indexdef; nCounter := nCounter + 1; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Copy all object permissions from one table to another. * * When the tier system copies a table, the original owner is retained, * but he may not be the only user. This function is provided to copy grants * primarily for the partition system, but it can be used as a general grant * copying system. * * This should be considered an internal function used only by the library. * * @param string Name of Schema where source objects can be found. * @param string Name of the source table of permissions. * @param string Name of Schema where target objects can be found. * @param string Name of target table for copied permissions. */ CREATE OR REPLACE FUNCTION _copy_grants( sSchema VARCHAR, sSource VARCHAR, sNSPTarget VARCHAR, sTarget VARCHAR ) RETURNS VOID AS $$ DECLARE rPerms RECORD; BEGIN -- We'll have to requisition the information schema for this bit of info, -- as the EDB catalog is sufficiently complicated to dissuade direct -- inquiries. FOR rPerms IN SELECT grantee, string_agg(privilege_type, ', ') AS perms FROM information_schema.role_table_grants WHERE table_schema = sSchema AND table_name = sSource GROUP BY grantee LOOP EXECUTE 'GRANT ' || rPerms.perms || ' ON ' || quote_ident(sNSPTarget) || '.' || quote_ident(sTarget) || ' TO ' || rPerms.grantee; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Move data from the oldest partition(s) to long term storage * * The lts_* columns in tab_root denote Long Term Storage of data for this * partition set. As such, this function provides the ability to move data * that has exceeded lts_threshold in age to a table identified by * lts_target. This can be a local archive table or an FDW equivalent. The * implication in either case, is that a separate partitioning entry or remote * archival process is in place to manage data moved to lts_target. * * Any partitions in this partition set that are older than lts_threshold * will be processed. This is done in case the archival process fell behind * for whatever reason. It's always safer to retain data that failed to * archive and try again later when issues have been resolved. * * @param string Schema name of root table having data migrated. * @param string Table Name of root table having data migrated. */ CREATE OR REPLACE FUNCTION archive_tier( sSchema VARCHAR, sTable VARCHAR ) RETURNS VOID AS $$ DECLARE rRoot @extschema@.tier_root%ROWTYPE; rPart @extschema@.tier_part%ROWTYPE; BEGIN RAISE NOTICE 'Migrating Older % Data to LTS', sTable; -- Retrieve the root definition SELECT INTO rRoot * FROM @extschema@.tier_root WHERE root_schema = sSchema AND root_table = sTable; IF NOT FOUND THEN RAISE EXCEPTION 'Could not migrate (%). Not found!', quote_ident(sTable); END IF; -- Fetch the oldest partition definitions where the lts_retain root date -- falls outside the check ranges. We'll need these to identify data being -- copied to long term storage, and require a successful migration before -- dropping the archived partition. This is looped in case the process -- needs to process multiple pending partition archivals. FOR rPart IN SELECT * FROM @extschema@.tier_part WHERE tier_root_id = rRoot.tier_root_id AND check_stop < CURRENT_DATE - rRoot.lts_threshold AND NOT is_archived ORDER BY check_stop LOOP RAISE NOTICE ' * Archiving %', quote_ident(rPart.part_table); -- The assumption here is that the lts_target is a fully qualified -- FDW-based table so we don't have to concern ourselves with connection -- management. If unset, it means the data isn't meant to be retained, -- or that there is some other archival process in place. This is also -- wrapped in an exception block so one bad insert doesn't derail the whole -- archival process. IF rRoot.lts_target IS NOT NULL THEN RAISE NOTICE ' - Moving data to LTS.'; BEGIN EXECUTE 'INSERT INTO ' || rRoot.lts_target || ' SELECT * FROM ' || quote_ident(rPart.part_schema) || '.' || quote_ident(rPart.part_table); EXCEPTION WHEN OTHERS THEN RAISE WARNING ' - Insertion failed: %.', SQLERRM; CONTINUE; END; END IF; RAISE NOTICE ' - Marking partition as archived.'; UPDATE @extschema@.tier_part SET is_archived = True WHERE tier_part_id = rPart.tier_part_id; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Archive data in all registered tables to long term storage * * This function is basically just a wrapper for archive_tier by walking * through all registered tables and archive data to long term storage * and/or drop expired partitions. */ CREATE OR REPLACE FUNCTION archive_all_tiers() RETURNS VOID AS $$ DECLARE sSchema VARCHAR; sTable VARCHAR; BEGIN -- Simply loop through all known root tables. In all cases, we're just -- passing the buck to archive_tier in an exception block to prevent -- failed archivals from affecting others. FOR sSchema, sTable IN SELECT DISTINCT r.root_schema, r.root_table FROM @extschema@.tier_root r JOIN @extschema@.tier_part p USING (tier_root_id) WHERE p.check_stop < CURRENT_DATE - r.lts_threshold AND NOT p.is_archived LOOP BEGIN PERFORM @extschema@.archive_tier(sSchema, sTable); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered with %! Skipping.', sTable; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Drop any tiers that were successfully moved to LTS * * Once a partition/tier has been moved into long term storage, we don't need * the copy locally anymore. This is a separate maintenance function so it * can run as object owners or superusers without being part of the archival * process itself. This way, the account doing the archival does not have to * own the tables themselves, or be a superuser. */ CREATE OR REPLACE FUNCTION drop_archived_tiers() RETURNS VOID AS $$ DECLARE sSchema VARCHAR; sTable VARCHAR; BEGIN -- Simply loop through all archived partitions and invoke a drop command. FOR sSchema, sTable IN SELECT part_schema, part_table FROM @extschema@.tier_part WHERE is_archived LOOP BEGIN RAISE NOTICE 'Dropping archived partition: %...', sTable; EXECUTE 'DROP TABLE ' || quote_ident(sSchema) || '.' || quote_ident(sTable); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Could not drop %! Skipping.', sTable; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Creates a new partition extension based on root table. * * This procedure will add one extent of part_period length to an existing * registered root table. Each new partition will always be one period higher * than the last registered partition. If none are found, the current date * is used as a base. * * All partition ranges are truncated down to no more than a day of granularity. * In the case of granularity greater or equal to one month, ranges are cut * down to start or stop on the first day of the months indicated. * * This function may be called in rapid succession by maintenance processes * that are attempting to ensure migrated data always has a valid target. * * @param string Schema name of root table being extended. * @param string Table Name of root table being extended. * @param boolean Should we *only* create partitions without moving data? */ CREATE OR REPLACE FUNCTION extend_tier_root( sSchema VARCHAR, sTable VARCHAR, bFast BOOLEAN DEFAULT TRUE ) RETURNS VOID AS $$ DECLARE rRoot @extschema@.tier_root%ROWTYPE; dLast DATE; dStart DATE; sPartName VARCHAR; sIndex VARCHAR; sMask VARCHAR; sOwner VARCHAR; BEGIN -- Retrieve the root definition. We'll need that to define certain -- elements of the child partition we're creating. SELECT INTO rRoot * FROM @extschema@.tier_root WHERE root_schema = sSchema AND root_table = sTable; IF NOT FOUND THEN RAISE EXCEPTION 'Could not extend (%). Not found!', quote_ident(sTable); END IF; -- Given the root partition exists, try to find the most recent extension. -- The next extension should be named after that one, plus the partition -- interval. If not found, base it on the current date plus the root -- interval. Never go below day granularity, and if we're at a month or -- above, reset it to always fall on the first of each month in the range. -- This prevents embarrassing off-by-one errors and makes contents more -- predictable. SELECT INTO dLast check_stop FROM @extschema@.tier_part WHERE tier_root_id = rRoot.tier_root_id AND NOT is_archived ORDER BY check_stop DESC LIMIT 1; dStart = coalesce(dLast, CURRENT_DATE - rRoot.root_retain); sMask = 'YYYYMMDD'; IF rRoot.part_period >= INTERVAL '1 month' THEN sMask = 'YYYYMM'; dStart = date_trunc('month', dStart); END IF; sPartName = sTable || '_part_' || to_char(dStart, sMask); -- Create an entry into our configuration / tracking table for this -- partition. If there is a problem, this will be rolled back in the -- transaction. INSERT INTO @extschema@.tier_part (tier_root_id, part_schema, part_table, check_start, check_stop) VALUES (rRoot.tier_root_id, sSchema, sPartName, dStart, dStart + rRoot.part_period); -- Go ahead and create the table. It'll be based on the root table so we -- can copy indexes and table/column comments. We won't include any -- constraints, as those were presumably checked in the base table when -- the data was originally created. If this becomes a problem, they can -- be re-added. Otherwise, this is just a check-constraint table inheritance -- pattern on our root interval. -- There's an interesting optimization we can perform if the partition -- period as at the minimum possible level. When a new partition is created, -- we can take advantage of the fact the day is new, and copy data without -- indexes, which is much faster than otherwise. If the partition already -- exists, copies will work normally. This might not invoke often, but it can -- be helpful when it does. IF NOT bFast AND rRoot.part_period <= INTERVAL '1 day' THEN EXECUTE ' CREATE TABLE ' || quote_ident(sSchema) || '.' || quote_ident(sPartName) || ' ( snapshot_dt TIMESTAMP WITHOUT TIME ZONE NOT NULL, LIKE ' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ' INCLUDING COMMENTS ) INHERITS (' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ') TABLESPACE ' || rRoot.part_tablespace; PERFORM @extschema@.migrate_tier_data( sSchema, sTable, to_char(dStart, sMask) ); EXECUTE ' ALTER TABLE ' || quote_ident(sSchema) || '.' || quote_ident(sPartName) || ' ADD CONSTRAINT ' || quote_ident(sPartName) || '_' || quote_ident(rRoot.date_column) || '_check ' || ' CHECK (' || quote_ident(rRoot.date_column) || ' >= ' || quote_literal(dStart::text) || ' AND ' || quote_ident(rRoot.date_column) || ' < ' || quote_literal((dStart + rRoot.part_period)::text) || ')'; PERFORM @extschema@._copy_indexes( sSchema, sTable, sSchema, sPartName ); -- If we can't do the "optimized" extension, the standard partition creation -- will work just fine. There's nothing wrong with it, it's just not as fast -- as the minimal granularity level makes possible. ELSE EXECUTE ' CREATE TABLE ' || quote_ident(sSchema) || '.' || quote_ident(sPartName) || ' ( snapshot_dt TIMESTAMP WITHOUT TIME ZONE NOT NULL, CHECK (' || quote_ident(rRoot.date_column) || ' >= ' || quote_literal(dStart::text) || ' AND ' || quote_ident(rRoot.date_column) || ' < ' || quote_literal((dStart + rRoot.part_period)::text) || '), LIKE ' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ' INCLUDING INDEXES INCLUDING COMMENTS ) INHERITS (' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ') TABLESPACE ' || rRoot.part_tablespace; -- Next, we should move the indexes to the proper tablespace as well, as -- that's one thing that doesn't get inherited. FOR sIndex IN SELECT indexname FROM pg_indexes WHERE schemaname = sSchema AND tablename = sPartName LOOP EXECUTE 'ALTER INDEX ' || quote_ident(sSchema) || '.' || quote_ident(sIndex) || ' SET TABLESPACE ' || quote_ident(rRoot.part_tablespace); END LOOP; END IF; -- Now we have to move the table ownership. Doing otherwise would be -- incredibly inconsistent. SELECT INTO sOwner tableowner FROM pg_tables WHERE schemaname = sSchema AND tablename = sTable; EXECUTE ' ALTER TABLE ' || sSchema || '.' || sPartName || ' OWNER TO ' || sOwner; -- Last but not least, copy the grants of our parent table. PERFORM @extschema@._copy_grants(sSchema, sTable, sSchema, sPartName); END; $$ LANGUAGE plpgsql VOLATILE; /** * Migrate data in all registered tier partitions at once. * * Instead of flushing a single root table into all existing partitions, * flush all known tables. This is a time-consuming and potentially * dangerious action, and we suggest not using this function unless * necessary and with a very stable database. * * This function is primarily useful if data in the root table somehow * got left behind, either by missing runs of the migration function, or * past failures. */ CREATE OR REPLACE FUNCTION flush_all_tiers() RETURNS VOID AS $$ DECLARE rPart @extschema@.tier_root%ROWTYPE; BEGIN -- Simply loop through all known root tables. -- In all cases, call the flush routine. That routine will push data to -- all existing partitions from the root table. FOR rPart IN SELECT * FROM @extschema@.tier_root LOOP BEGIN PERFORM @extschema@.flush_tier_data(rPart.root_schema, rPart.root_table); -- If one tier barfs, there's no reason *all* of them should. EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered with %! Skipping.', rPart.root_table; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Flush data to partitions for a specified root table. * * Given a root table, identify all non-archived partitions and invoke a * data migration. Not only can this be used as a followup to the bootstrap * routine, but can work as a full flush of the root table. * * This function is primarily useful if data in the root table somehow * got left behind, either by missing runs of the migration function, or * past failures. * * @param string Schema name of root table having data flushed. * @param string Table Name of root table having data flushed. */ CREATE OR REPLACE FUNCTION flush_tier_data( sSchema VARCHAR, sTable VARCHAR ) RETURNS VOID AS $$ DECLARE sPart VARCHAR; BEGIN -- Given the root table, snag all non-archived partitions. We can simply -- call the basic migration function and let it do all of the heavy lifting. FOR sPart IN SELECT replace(p.part_table, sTable || '_part_', '') FROM tab_tier.tier_root r JOIN tab_tier.tier_part p USING (tier_root_id) WHERE root_schema = sSchema AND root_table = sTable AND NOT is_archived LOOP BEGIN PERFORM @extschema@.migrate_tier_data(sSchema, sTable, sPart, FALSE, TRUE); -- If one partition barfs, there's no reason *all* of them should. EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered with %! Skipping.', rPart.root_table; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Retrieve a Configuration Setting from tier_config. * * @param config_key Name of the configuration setting to retrieve. * * @return TEXT Value for the requested configuration setting. */ CREATE OR REPLACE FUNCTION get_tier_config( config_key VARCHAR ) RETURNS TEXT AS $$ BEGIN RETURN (SELECT setting FROM @extschema@.tier_config WHERE config_name = config_key); END; $$ LANGUAGE PLPGSQL SECURITY DEFINER; /** * Copy all data from root for named partition in fastest way possible * * When partitions are created, they are empty. This function will copy data * from the root table to the specified partition just like * migrate_tier_data. However, it disables indexes before invoking the * data movement and then reenables them, which is much faster when inserting * large amounts of data. * * @param string Schema name of root table owning partition to initialize. * @param string Table Name of root table owning partition table to initialize. * @param string Specific partition to target, root table and partition * prefix removed. Ex: 201304 */ CREATE OR REPLACE FUNCTION initialize_tier_part( sSchema VARCHAR, sTable VARCHAR, sPart VARCHAR ) RETURNS VOID AS $$ DECLARE oTarget REGCLASS; BEGIN -- Data sanitize the supplied partition and turn it into a table name. SELECT INTO oTarget (part_schema || '.' || part_table)::regclass FROM @extschema@.tier_part WHERE part_table = sTable || '_part_' || regexp_replace(sPart, '\D', '', 'g') AND NOT is_archived; IF NOT FOUND THEN RAISE EXCEPTION 'Could not initialize %. Partition missing.', quote_ident(oTarget::text); END IF; -- To do this as quickly as possible, we need to disable all indexes on the -- target partition. We will reenable this later. UPDATE pg_index SET indisready = FALSE, indisvalid = FALSE WHERE indrelid = oTarget; -- Call the migration function which does all the actual heavy lifting. RAISE NOTICE 'Initializing partition %.', oTarget::text; PERFORM @extschema@.migrate_tier_data(sSchema, sTable, sPart); -- Reenable the indexes and inheritance we disabled earlier, and force a -- reindex of the table. This will take a while, but potentially inserting -- several million rows with them enabled would have been much worse. UPDATE pg_index SET indisready = TRUE, indisvalid = TRUE WHERE indrelid = oTarget; RAISE NOTICE ' * Rebuilding partition indexes.'; EXECUTE 'REINDEX TABLE ' || oTarget::text; END; $$ LANGUAGE plpgsql VOLATILE; /** * Migrate data in all registered tier root tables at once. * * Several tables are probably registered with the tier system already. * These tables should regularly have data moved into the low-priority * tier. This routine will ensure all old root data exists in the proper * tier partitions for the entirety of their retention window by calling * the update process for every known tier root. * * In cases where a procedure (tier_proc) is defined, it will call that * instead of the generic (migrate_tier_data) procedure designed to * handle simple partition schemes. */ CREATE OR REPLACE FUNCTION migrate_all_tiers() RETURNS VOID AS $$ DECLARE rPart @extschema@.tier_root%ROWTYPE; BEGIN -- Simply loop through all known root tables. -- In all cases, call the appropriate (date-based or user-defined) procedure -- that migrates data from the root table(s) to the target partition. FOR rPart IN SELECT * FROM @extschema@.tier_root LOOP BEGIN IF rPart.tier_proc IS NULL THEN PERFORM @extschema@.migrate_tier_data(rPart.root_schema, rPart.root_table); ELSE EXECUTE 'SELECT @extschema@.' || rPart.tier_proc || '()'; END IF; -- If one tier barfs, there's no reason *all* of them should. EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered with %! Skipping.', rPart.root_table; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Move data from root table to current tier partition * * Given a root table we've already registered and at least one active * partition, it should be possible to move data older than root_retain * to a partition where the data being archived falls within that partition. * Other stored procedures exist to make sure there's always at least one * of these, but the worst that can happen in the case of a missing partition * is that data doesn't get relocated to the slower device. * * This procedure always proceeds in a copy -> delete order to ensure data * is safe in the partition before being removed from the root. Any query that * targets the root will also retrieve all partitions, so availability should * not change unless the ONLY keyword is specified. * * @param string Schema name of root table having data migrated. * @param string Table Name of root table having data migrated. * @param string Optional specific partition to target, root table and * partition prefix removed. Ex: 201304 * @param boolean Should we analyze the parent and partition after movement? * This is provided to optimize mass migrations. Default True. * @param boolean Should we include all data within the partition's boundaries? * Normally, we don't include data within the root retention * window, so it stays in the root table. Default False. */ CREATE OR REPLACE FUNCTION @extschema@.migrate_tier_data( sSchema VARCHAR, sTable VARCHAR, sPart VARCHAR DEFAULT NULL, bAnalyze BOOLEAN DEFAULT TRUE, bAll BOOLEAN DEFAULT FALSE ) RETURNS VOID AS $$ DECLARE rRoot @extschema@.tier_root%ROWTYPE; rPart @extschema@.tier_part%ROWTYPE; sColList VARCHAR; sSQL VARCHAR; nCount BIGINT; BEGIN RAISE NOTICE 'Migrating Older % Data', sTable; -- Retrieve the root definition and the most recent partition definition -- where the root_retain root date falls between the check ranges. -- We'll need these to identify data being copied from the parent table -- to the correct partition. SELECT INTO rRoot * FROM @extschema@.tier_root WHERE root_schema = sSchema AND root_table = sTable; IF NOT FOUND THEN RAISE EXCEPTION 'Could not migrate (%). Not found!', quote_ident(sTable); END IF; -- If we were not passed a specific partition, get the boundaries from the -- partition that's just slightly older than root_retain. Otherwise, we -- were asked to target a specific partition, and we want its information. IF sPart IS NULL THEN SELECT INTO rPart * FROM @extschema@.tier_part WHERE tier_root_id = rRoot.tier_root_id AND check_start <= CURRENT_DATE - rRoot.root_retain AND NOT is_archived ORDER BY check_start DESC LIMIT 1; ELSE SELECT INTO rPart * FROM @extschema@.tier_part WHERE tier_root_id = rRoot.tier_root_id AND part_table = sTable || '_part_' || regexp_replace(sPart, '\D', '', 'g') AND NOT is_archived; END IF; IF NOT FOUND THEN RAISE EXCEPTION 'Could not data shift (%). Partition missing.', quote_ident(sTable); END IF; -- Now we just insert all rows that fall between the check constraints of the -- target partition we identified earlier. If rows fall outside these bounds, -- it's probably better that they be moved manually anyway, since they were -- likely added to the table for a date before the active partition. -- We can also set the snapshot date now, so we know when the data was moved. -- Again, we're avoiding a trigger to set this to reduce overhead. RAISE NOTICE ' * Copying data to new tier.'; SELECT INTO sColList string_agg(a.attname::varchar, ', ') FROM pg_attribute a WHERE a.attrelid = (sSchema || '.' || sTable)::regclass AND a.attnum > 0; sSQL = 'INSERT INTO ' || quote_ident(rPart.part_schema) || '.' || quote_ident(rPart.part_table) || ' ( ' || sColList || ', snapshot_dt) SELECT ' || sColList || ', now() FROM ONLY ' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ' WHERE ' || quote_ident(rRoot.date_column) || ' >= ' || quote_literal(rPart.check_start::text) || ' AND ' || quote_ident(rRoot.date_column) || ' < ' || quote_literal(rPart.check_stop::text); IF NOT bALL THEN sSQL = sSQL || ' AND ' || quote_ident(rRoot.date_column) || ' < CURRENT_DATE - ' || quote_literal(rRoot.root_retain::text) || '::interval'; END IF; EXECUTE sSQL; -- Here is where we'll insert an optimization shortcut. If all the rows -- we copied are the *only* rows to move, we can truncate the root table -- immediately and skip the slow delete operation. EXECUTE 'SELECT count(*) FROM ONLY ' || quote_ident(sSchema) || '.' || quote_ident(sTable) INTO nCount; IF nCount < 1 THEN RAISE NOTICE ' * Truncating data from old tier.'; EXECUTE 'TRUNCATE TABLE ONLY ' || quote_ident(sSchema) || '.' || quote_ident(sTable); -- Or, once the rows are copied, it should be safe to delete them from the -- source. Since all children inherit from the main table, we want to -- *ensure* to use the ONLY keyword so we don't delete from all of the -- other partitions as well. ELSE RAISE NOTICE ' * Deleting data from old tier.'; sSQL = 'DELETE FROM ONLY ' || quote_ident(sSchema) || '.' || quote_ident(sTable) || ' WHERE ' || quote_ident(rRoot.date_column) || ' >= ' || quote_literal(rPart.check_start::text) || ' AND ' || quote_ident(rRoot.date_column) || ' < ' || quote_literal(rPart.check_stop::text); IF NOT bALL THEN sSQL = sSQL || ' AND ' || quote_ident(rRoot.date_column) || ' < CURRENT_DATE - ' || quote_literal(rRoot.root_retain::text) || '::interval'; END IF; EXECUTE sSQL; END IF; -- Last but not least, analyze our source table because we probably -- invalidated the last collected statistics. IF bAnalyze THEN RAISE NOTICE ' * Updating statistics.'; EXECUTE 'ANALYZE ' || quote_ident(sSchema) || '.' || quote_ident(sTable); EXECUTE 'ANALYZE ' || quote_ident(rPart.part_schema) || '.' || quote_ident(rPart.part_table); END IF; END; $$ LANGUAGE plpgsql VOLATILE; /** * Registers a table with the tier management system * * This procedure registers a table with the tier management system in such * a way that only the non-default columns are specified. To override a default, * alter the table entry in tier_root. * * @param string Name of Schema where root resides. * @param string Name of the table to register. * @param string Date-based column to use as partition pivot. */ CREATE OR REPLACE FUNCTION register_tier_root( sSchema VARCHAR, sTable VARCHAR, sColumn VARCHAR ) RETURNS VOID AS $$ DECLARE dRetain INTERVAL; dPeriod INTERVAL; dThresh INTERVAL; sStorage TEXT; BEGIN dRetain = @extschema@.get_tier_config('root_retain'); dPeriod = @extschema@.get_tier_config('part_period'); dThresh = @extschema@.get_tier_config('lts_threshold'); sStorage = @extschema@.get_tier_config('part_tablespace'); -- Check for this schema/table/column combination, to save us from having to -- trap an exception. PERFORM 1 FROM @extschema@.tier_root WHERE root_schema = sSchema AND root_table = sTable; IF NOT FOUND THEN INSERT INTO @extschema@.tier_root (root_schema, root_table, date_column, part_tablespace, root_retain, part_period, lts_threshold) VALUES (sSchema, sTable, sColumn, sStorage, dRetain, dPeriod, dThresh); END IF; END; $$ LANGUAGE plpgsql VOLATILE; /** * Set a Configuration Setting from tab_tier. * * This function doesn't just set values. It also acts as an API for * checking setting validity. These settings are specifically adjusted: * * - root_retain : Must be able to convert to a PostgreSQL INTERVAL type. * - lts_threshold : Must be able to convert to a PostgreSQL INTERVAL type. * - part_period : Must be able to convert to a PostgreSQL INTERVAL type. * * All settings will be folded to lower case for consistency. * * @param config_key Name of the configuration setting to retrieve. * @param config_val full value to use for the specified setting. * * @return TEXT Value for the created/modified configuration setting. */ CREATE OR REPLACE FUNCTION set_tier_config( config_key VARCHAR, config_val VARCHAR ) RETURNS TEXT AS $$ DECLARE new_val VARCHAR := config_val; low_key VARCHAR := lower(config_key); info_msg VARCHAR; BEGIN -- If this is a new setting we don't control, just set it and ignore it. -- The admin may be storing personal notes. Any settings required by the -- extension should already exist by this point. PERFORM 1 FROM @extschema@.tier_config WHERE config_name = low_key; IF NOT FOUND THEN INSERT INTO @extschema@.tier_config (config_name, setting) VALUES (low_key, new_val); RETURN new_val; END IF; -- Don't let the user choose a tablespace that doesn't exist. IF low_key = 'part_tablespace' THEN PERFORM 1 FROM pg_tablespace WHERE spcname = config_val; IF NOT FOUND THEN RAISE EXCEPTION '% is not a valid tablespace!', config_val; RETURN NULL; END IF; END IF; -- Make sure all of the INTERVAL types are actually intervals. IF low_key IN ('root_retain', 'lts_threshold', 'part_period') THEN BEGIN PERFORM config_val::INTERVAL; EXCEPTION WHEN OTHERS THEN RAISE EXCEPTION '% is not an interval!', config_val; RETURN NULL; END; END IF; -- With the data filtered, it's now safe to modify the config table. -- Also set the default to false so non-default settings are retained -- in dumps. UPDATE @extschema@.tier_config SET setting = new_val, is_default = False WHERE config_name = low_key; -- Finally, return the value of the setting, indicating it was accepted. RETURN new_val; END; $$ LANGUAGE PLPGSQL SECURITY DEFINER; /** * Detaches or Attaches all partitions from named tier root * * When root tiers are first bootstrapped, the new partitions are all empty * yet moving data into them will cause locking issues should the partition * itself undergo index manipulation. Since this is done by certain helper * functions to speed initial data population, it's best to detach our new * partitions. Otherwise, the parent table would be effectively unusable * during the data migration. * * In turn, it makes sense to later enable these same partitions when they're * safely full of data. We initially built this functionality into the * initialization function, but since functions get their own transaction * context, detached tables were still attached in the context of any other * transaction that started before we committed, making the action effectively * worthless. This procedure is supplied as a workaround so the detachment * and attachment processes get their own transaction context. * * @param string Schema name of root table to toggle partitions. * @param string Table Name of root table to toggle partitions. * @param boolean TRUE to enable partitions, FALSE to disable. */ CREATE OR REPLACE FUNCTION toggle_tier_partitions( sSchema VARCHAR, sTable VARCHAR, bEnabled BOOLEAN ) RETURNS VOID AS $$ DECLARE sPartName VARCHAR; BEGIN -- Just loop through all known partitions for the supplied root table. -- Enable or disable based on the passed boolean flag. FOR sPartName IN SELECT p.part_schema || '.' || p.part_table FROM @extschema@.tier_root r JOIN @extschema@.tier_part p USING (tier_root_id) WHERE r.root_schema = sSchema AND r.root_table = sTable AND NOT p.is_archived LOOP BEGIN EXECUTE 'ALTER TABLE ' || sPartName || CASE WHEN bEnabled THEN '' ELSE ' NO' END || ' INHERIT ' || quote_ident(sSchema) || '.' || quote_ident(stable); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Problem encountered detaching %! Skipping.', sPartName; CONTINUE; END; END LOOP; END; $$ LANGUAGE plpgsql VOLATILE; /** * Remove a table/schema pair from the tab_tier system. * * This function simplifies removing tables from tab_tier. Tables removed * with this function will no longer have data moved by any functions * used by this extension. Otherwise, no modifications will be made to * the tables themselves. * * @param table_schema String name of the schema for this table. * @param target_table String name of the *base* table to remove from tab_tier; * all child tables will also be unregistered. */ CREATE OR REPLACE FUNCTION unregister_table( table_schema VARCHAR, target_table VARCHAR ) RETURNS VOID AS $$ BEGIN DELETE FROM @extschema@.tier_root WHERE root_schema = table_schema AND root_table = target_table; END; $$ LANGUAGE PLPGSQL SECURITY DEFINER; /** * Update created/modified timestamp automatically * * This function maintains two metadata columns on any table that uses * it in a trigger. These columns include: * * - created_dt : Set to when the row first enters the table. * - modified_at : Set to when the row is ever changed in the table. * * @return object NEW */ CREATE OR REPLACE FUNCTION update_audit_stamps() RETURNS TRIGGER AS $$ BEGIN -- All inserts get a new timestamp to mark their creation. Any updates should -- inherit the timestamp of the old version. In either case, a modified -- timestamp is applied to track the last time the row was changed. IF TG_OP = 'INSERT' THEN NEW.created_dt = now(); ELSE NEW.created_dt = OLD.created_dt; END IF; NEW.modified_dt = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- -- CREATE TRIGGERS -------------------------------------------------------------------------------- CREATE TRIGGER t_tier_root_timestamp_b_iu BEFORE INSERT OR UPDATE ON tier_root FOR EACH ROW EXECUTE PROCEDURE update_audit_stamps(); CREATE TRIGGER t_tier_part_timestamp_b_iu BEFORE INSERT OR UPDATE ON tier_part FOR EACH ROW EXECUTE PROCEDURE update_audit_stamps(); -------------------------------------------------------------------------------- -- GRANT USAGE -------------------------------------------------------------------------------- REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA @extschema@ FROM PUBLIC; GRANT ALL ON ALL TABLES IN SCHEMA @extschema@ TO tab_tier_role; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA @extschema@ TO tab_tier_role; GRANT USAGE ON ALL SEQUENCES IN SCHEMA @extschema@ TO tab_tier_role; -------------------------------------------------------------------------------- -- CONFIGURE EXTENSION -------------------------------------------------------------------------------- INSERT INTO tier_config (config_name, setting, is_default) VALUES ('root_retain', '3 Months', True), ('lts_threshold', '2 years', True), ('part_period', '1 Month', True), ('part_tablespace', 'pg_default', TRUE);