299 lines
12 KiB
PL/PgSQL
299 lines
12 KiB
PL/PgSQL
--
|
|
-- Audit Log Partitioning Setup for LeoCRM
|
|
-- =========================================
|
|
--
|
|
-- This script creates a partitioned audit_log table with monthly partitions.
|
|
-- It includes functions for automatic partition creation and maintenance.
|
|
--
|
|
-- Usage:
|
|
-- psql -h localhost -U leocrm -d crm_db -f scripts/setup_audit_partitioning.sql
|
|
--
|
|
-- The script is idempotent — safe to run multiple times.
|
|
--
|
|
|
|
BEGIN;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 1. Create the partitioned audit_log table
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
-- Check if the partitioned table already exists
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_class WHERE relname = 'audit_log' AND relkind = 'p'
|
|
) THEN
|
|
-- Check if there's an existing non-partitioned table
|
|
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'audit_log' AND relkind = 'r') THEN
|
|
-- Rename existing table
|
|
ALTER TABLE audit_log RENAME TO audit_log_old;
|
|
RAISE NOTICE 'Renamed existing audit_log to audit_log_old';
|
|
END IF;
|
|
|
|
-- Create the partitioned table
|
|
CREATE TABLE audit_log (
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
tenant_id UUID,
|
|
user_id UUID,
|
|
action VARCHAR(100) NOT NULL,
|
|
entity_type VARCHAR(50),
|
|
entity_id UUID,
|
|
changes JSONB,
|
|
ip_address VARCHAR(45),
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (id, created_at)
|
|
) PARTITION BY RANGE (created_at);
|
|
|
|
RAISE NOTICE 'Created partitioned audit_log table';
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 2. Function: Create a single monthly partition
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
CREATE OR REPLACE FUNCTION create_monthly_audit_partition(
|
|
partition_date DATE DEFAULT CURRENT_DATE
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
year_str TEXT;
|
|
month_str TEXT;
|
|
partition_name TEXT;
|
|
start_date TEXT;
|
|
end_date TEXT;
|
|
BEGIN
|
|
year_str := TO_CHAR(partition_date, 'YYYY');
|
|
month_str := TO_CHAR(partition_date, 'MM');
|
|
partition_name := 'audit_log_' || year_str || '_' || month_str;
|
|
start_date := year_str || '-' || month_str || '-01';
|
|
end_date := TO_CHAR(partition_date + INTERVAL '1 month', 'YYYY-MM-DD');
|
|
|
|
-- Check if partition already exists
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_class WHERE relname = partition_name
|
|
) THEN
|
|
EXECUTE format('
|
|
CREATE TABLE %I PARTITION OF audit_log
|
|
FOR VALUES FROM (%L) TO (%L)',
|
|
partition_name, start_date, end_date
|
|
);
|
|
|
|
-- Create indexes on the new partition
|
|
EXECUTE format('
|
|
CREATE INDEX %I ON %I (tenant_id)',
|
|
'idx_' || partition_name || '_tenant', partition_name
|
|
);
|
|
EXECUTE format('
|
|
CREATE INDEX %I ON %I (action)',
|
|
'idx_' || partition_name || '_action', partition_name
|
|
);
|
|
EXECUTE format('
|
|
CREATE INDEX %I ON %I (entity_type, entity_id)',
|
|
'idx_' || partition_name || '_entity', partition_name
|
|
);
|
|
EXECUTE format('
|
|
CREATE INDEX %I ON %I (created_at DESC)',
|
|
'idx_' || partition_name || '_created', partition_name
|
|
);
|
|
|
|
RAISE NOTICE 'Created partition: %', partition_name;
|
|
ELSE
|
|
RAISE NOTICE 'Partition already exists: %', partition_name;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 3. Function: Create partitions for the next N months
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
CREATE OR REPLACE FUNCTION create_future_audit_partitions(
|
|
months_ahead INT DEFAULT 3
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
i INT;
|
|
partition_date DATE;
|
|
BEGIN
|
|
-- Create current month partition first
|
|
partition_date := DATE_TRUNC('month', CURRENT_DATE)::DATE;
|
|
PERFORM create_monthly_audit_partition(partition_date);
|
|
|
|
-- Create future month partitions
|
|
FOR i IN 1..months_ahead LOOP
|
|
partition_date := (DATE_TRUNC('month', CURRENT_DATE) + (i || ' months')::INTERVAL)::DATE;
|
|
PERFORM create_monthly_audit_partition(partition_date);
|
|
END LOOP;
|
|
|
|
RAISE NOTICE 'Created % future partitions (current + % months)', months_ahead + 1, months_ahead;
|
|
END;
|
|
$$;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 4. Function: Drop old partitions beyond retention period
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
CREATE OR REPLACE FUNCTION drop_old_audit_partitions(
|
|
retention_months INT DEFAULT 12
|
|
)
|
|
RETURNS INT
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
partition_record RECORD;
|
|
drop_count INT := 0;
|
|
cutoff_date DATE;
|
|
BEGIN
|
|
cutoff_date := DATE_TRUNC('month', CURRENT_DATE - (retention_months || ' months')::INTERVAL)::DATE;
|
|
|
|
FOR partition_record IN
|
|
SELECT
|
|
inhrelid::regclass AS partition_name,
|
|
pg_get_expr(relpartbound, relid) AS partition_bound
|
|
FROM pg_catalog.pg_inherits
|
|
JOIN pg_class ON pg_class.oid = inhrelid
|
|
WHERE inhparent = 'audit_log'::regclass
|
|
LOOP
|
|
-- Extract the upper bound date from the partition bound expression
|
|
-- Format: FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
|
|
IF partition_record.partition_bound ~ 'TO \(''([0-9]{4}-[0-9]{2}-[0-9]{2})' THEN
|
|
DECLARE
|
|
upper_date DATE;
|
|
BEGIN
|
|
upper_date := SUBSTRING(
|
|
partition_record.partition_bound
|
|
FROM 'TO \(''([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
|
)::DATE;
|
|
|
|
IF upper_date <= cutoff_date THEN
|
|
EXECUTE format('DROP TABLE IF EXISTS %I', partition_record.partition_name);
|
|
drop_count := drop_count + 1;
|
|
RAISE NOTICE 'Dropped old partition: %', partition_record.partition_name;
|
|
END IF;
|
|
END;
|
|
END IF;
|
|
END LOOP;
|
|
|
|
RETURN drop_count;
|
|
END;
|
|
$$;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 5. Create initial partitions (current + next 3 months)
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
SELECT create_future_audit_partitions(3);
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 6. Migrate existing data (if any)
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
DO $$
|
|
DECLARE
|
|
old_count BIGINT;
|
|
new_count BIGINT;
|
|
BEGIN
|
|
-- Check if old table exists and has data
|
|
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'audit_log_old') THEN
|
|
EXECUTE 'SELECT COUNT(*) FROM audit_log_old' INTO old_count;
|
|
|
|
IF old_count > 0 THEN
|
|
RAISE NOTICE 'Migrating % rows from audit_log_old...', old_count;
|
|
|
|
-- Insert data into partitioned table
|
|
EXECUTE '
|
|
INSERT INTO audit_log (
|
|
id, tenant_id, user_id, action, entity_type,
|
|
entity_id, changes, ip_address, user_agent, created_at
|
|
)
|
|
SELECT
|
|
id, tenant_id, user_id, action, entity_type,
|
|
entity_id, changes, ip_address, user_agent, created_at
|
|
FROM audit_log_old
|
|
';
|
|
|
|
EXECUTE 'SELECT COUNT(*) FROM audit_log' INTO new_count;
|
|
RAISE NOTICE 'Migration complete: % rows migrated', new_count;
|
|
|
|
-- Verify data integrity
|
|
IF old_count = new_count THEN
|
|
RAISE NOTICE 'Data integrity verified: % rows match', old_count;
|
|
ELSE
|
|
RAISE WARNING 'Data mismatch: old=% rows, new=% rows', old_count, new_count;
|
|
END IF;
|
|
ELSE
|
|
RAISE NOTICE 'No data to migrate in audit_log_old';
|
|
END IF;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- 7. Verify setup
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
|
|
DO $$
|
|
DECLARE
|
|
partition_count INT;
|
|
table_type TEXT;
|
|
BEGIN
|
|
-- Check table type
|
|
SELECT relkind INTO table_type FROM pg_class WHERE relname = 'audit_log';
|
|
|
|
IF table_type = 'p' THEN
|
|
RAISE NOTICE 'audit_log is correctly set up as a partitioned table';
|
|
ELSE
|
|
RAISE WARNING 'audit_log is NOT a partitioned table (relkind=%)', table_type;
|
|
END IF;
|
|
|
|
-- Count partitions
|
|
SELECT COUNT(*) INTO partition_count
|
|
FROM pg_catalog.pg_inherits
|
|
WHERE inhparent = 'audit_log'::regclass;
|
|
|
|
RAISE NOTICE 'Number of partitions: %', partition_count;
|
|
END;
|
|
$$;
|
|
|
|
COMMIT;
|
|
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
-- Usage Examples
|
|
-- ═══════════════════════════════════════════════════════════════
|
|
--
|
|
-- Create partitions for the next 6 months:
|
|
-- SELECT create_future_audit_partitions(6);
|
|
--
|
|
-- Drop partitions older than 12 months:
|
|
-- SELECT drop_old_audit_partitions(12);
|
|
--
|
|
-- Create a specific month partition:
|
|
-- SELECT create_monthly_audit_partition('2026-07-01'::DATE);
|
|
--
|
|
-- Check partition sizes:
|
|
-- SELECT
|
|
-- relname AS partition_name,
|
|
-- pg_size_pretty(pg_total_relation_size(relid)) AS total_size
|
|
-- FROM pg_catalog.pg_statio_user_tables
|
|
-- WHERE relname LIKE 'audit_log_%'
|
|
-- ORDER BY relname;
|
|
--
|
|
-- Check row counts per partition:
|
|
-- SELECT
|
|
-- relname AS partition_name,
|
|
-- n_live_tup AS row_count
|
|
-- FROM pg_catalog.pg_stat_user_tables
|
|
-- WHERE relname LIKE 'audit_log_%'
|
|
-- ORDER BY relname;
|
|
--
|
|
-- Cron job (run on 1st of each month at 2 AM):
|
|
-- 0 2 1 * * /usr/bin/psql -h localhost -U leocrm -d crm_db -c "SELECT create_future_audit_partitions(3);"
|
|
--
|