-
-
Notifications
You must be signed in to change notification settings - Fork 134
fix(db): skip updated_at and stats bookkeeping in audit_logs #2905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
377 changes: 377 additions & 0 deletions
377
supabase/migrations/20260807093220_skip_updated_at_audit_noise.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,377 @@ | ||
| -- Skip audit_logs for pure updated_at bumps on every audited table, and keep | ||
| -- apps/orgs stats refresh bookkeeping silent. | ||
| -- | ||
| -- Execution model (audit_log_trigger): | ||
| -- - Where: AFTER INSERT/UPDATE/DELETE row trigger on orgs, apps, channels, | ||
| -- app_versions, org_users. | ||
| -- - Frequency: once per mutating row statement on those tables (not per RLS | ||
| -- candidate row). Hot-path plugin endpoints do not write these tables. | ||
| -- - Roles: runs as SECURITY DEFINER for authenticated, anon (API key), and | ||
| -- service_role writers that mutate audited rows. | ||
| -- - Cardinality: bounded by the mutating statement row count; identifier | ||
| -- lookups use primary keys (orgs.id, apps.app_id, channels.id, | ||
| -- app_versions.id, org_users.id). | ||
| -- - Indexes used by cleanup: idx_audit_logs_created_at, idx_audit_logs_operation, | ||
| -- idx_audit_logs_table_name for batched ctid deletes ordered by created_at. | ||
| -- | ||
| -- Historical noise is removed via a runtime-budgeted function. Migration applies | ||
| -- a small deployment budget; cron_tasks drains the remainder. | ||
|
|
||
| -- Skip audit_logs for pure updated_at bumps on every audited table, and keep | ||
| -- apps/orgs stats refresh bookkeeping silent. Delete matching historical rows | ||
| -- in bounded batches so Capgo-EU audit_logs cleanup stays resumable. | ||
|
|
||
| CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" | ||
| LANGUAGE "plpgsql" SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| DECLARE | ||
| v_old_record jsonb; | ||
| v_new_record jsonb; | ||
| v_changed_fields text[]; | ||
| v_org_id uuid; | ||
| v_record_id text; | ||
| v_user_id uuid; | ||
| v_key text; | ||
| v_api_key_text text; | ||
| v_api_key public.apikeys%ROWTYPE; | ||
| v_actor_type text := 'system'; | ||
| v_actor_user_id uuid; | ||
| v_actor_user_email text; | ||
| v_actor_apikey_id bigint; | ||
| v_actor_apikey_name text; | ||
| v_stats_refresh_fields constant text[] := ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']; | ||
| v_background_counter_fields constant text[] := ARRAY['channel_device_count', 'manifest_bundle_count', 'updated_at']; | ||
| v_fat_app_version_fields constant text[] := ARRAY['manifest', 'native_packages']; | ||
| BEGIN | ||
| SELECT auth.uid() INTO v_actor_user_id; | ||
|
|
||
| IF v_actor_user_id IS NOT NULL THEN | ||
| v_actor_type := 'user'; | ||
| ELSE | ||
| SELECT public.get_apikey_header() INTO v_api_key_text; | ||
|
|
||
| IF v_api_key_text IS NOT NULL THEN | ||
| SELECT * | ||
| INTO v_api_key | ||
| FROM public.find_apikey_by_value(v_api_key_text) | ||
| LIMIT 1; | ||
|
|
||
| -- Attribute only valid, write-capable API keys; a read-only key present on | ||
| -- a request must not be recorded as the actor of a mutation. | ||
| IF v_api_key.id IS NOT NULL | ||
| AND NOT public.is_apikey_expired(v_api_key.expires_at) | ||
| AND ( | ||
| public.is_allowed_capgkey(v_api_key_text, '{upload}'::text[]) | ||
| OR public.is_allowed_capgkey(v_api_key_text, '{write}'::text[]) | ||
| OR public.is_allowed_capgkey(v_api_key_text, '{all}'::text[]) | ||
| ) THEN | ||
| v_actor_type := 'apikey'; | ||
| v_actor_user_id := v_api_key.user_id; | ||
| v_actor_apikey_id := v_api_key.id; | ||
| v_actor_apikey_name := v_api_key.name; | ||
| END IF; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| IF v_actor_user_id IS NOT NULL THEN | ||
| SELECT users.email | ||
| INTO v_actor_user_email | ||
| FROM public.users AS users | ||
| WHERE users.id = v_actor_user_id; | ||
| END IF; | ||
|
|
||
| v_user_id := v_actor_user_id; | ||
|
|
||
| -- Skip internal app_versions upload/migrate bookkeeping before the generic | ||
| -- changed_fields walk. Compare via to_jsonb only (this trigger is shared across | ||
| -- tables; never touch NEW.column names that only exist on app_versions). | ||
| IF TG_OP = 'UPDATE' AND TG_TABLE_NAME = 'app_versions' THEN | ||
| v_old_record := pg_catalog.to_jsonb(OLD); | ||
| v_new_record := pg_catalog.to_jsonb(NEW); | ||
| IF ( | ||
| v_old_record | ||
| - 'manifest' | ||
| - 'updated_at' | ||
| - 'manifest_count' | ||
| - 'storage_provider' | ||
| - 'r2_path' | ||
| ) IS NOT DISTINCT FROM ( | ||
| v_new_record | ||
| - 'manifest' | ||
| - 'updated_at' | ||
| - 'manifest_count' | ||
| - 'storage_provider' | ||
| - 'r2_path' | ||
| ) THEN | ||
| RETURN NEW; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| IF TG_OP = 'DELETE' THEN | ||
| v_old_record := pg_catalog.to_jsonb(OLD); | ||
| v_new_record := NULL; | ||
| ELSIF TG_OP = 'INSERT' THEN | ||
| v_old_record := NULL; | ||
| v_new_record := pg_catalog.to_jsonb(NEW); | ||
| ELSE | ||
| v_old_record := pg_catalog.to_jsonb(OLD); | ||
| v_new_record := pg_catalog.to_jsonb(NEW); | ||
|
|
||
| FOR v_key IN SELECT pg_catalog.jsonb_object_keys(v_new_record) | ||
| LOOP | ||
| IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN | ||
| v_changed_fields := pg_catalog.array_append(v_changed_fields, v_key); | ||
| END IF; | ||
| END LOOP; | ||
|
|
||
| -- Pure updated_at bumps are bookkeeping, not user-facing audit events. | ||
| IF v_changed_fields IS NOT NULL | ||
| AND NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) | ||
| WHERE changed_field.field_name IS DISTINCT FROM 'updated_at' | ||
| ) THEN | ||
| RETURN NEW; | ||
| END IF; | ||
|
|
||
| IF TG_TABLE_NAME = ANY(ARRAY['apps', 'orgs']) | ||
| AND v_changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] | ||
| AND NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) | ||
| WHERE changed_field.field_name <> ALL(v_stats_refresh_fields) | ||
| ) THEN | ||
| RETURN NEW; | ||
| END IF; | ||
|
|
||
| IF v_actor_type = 'system' | ||
| AND TG_TABLE_NAME = 'apps' | ||
| AND v_changed_fields && ARRAY['channel_device_count', 'manifest_bundle_count'] | ||
| AND NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) | ||
| WHERE changed_field.field_name <> ALL(v_background_counter_fields) | ||
| ) THEN | ||
| RETURN NEW; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| -- Never persist multi-MB array/json columns in audit TOAST. | ||
| -- Keep fat field names in changed_fields when co-occurring with real user edits. | ||
| IF TG_TABLE_NAME = 'app_versions' THEN | ||
| IF v_old_record IS NOT NULL THEN | ||
| v_old_record := v_old_record - v_fat_app_version_fields; | ||
| END IF; | ||
| IF v_new_record IS NOT NULL THEN | ||
| v_new_record := v_new_record - v_fat_app_version_fields; | ||
| END IF; | ||
|
|
||
| END IF; | ||
|
|
||
| IF TG_OP = 'DELETE' THEN | ||
| CASE TG_TABLE_NAME | ||
| WHEN 'orgs' THEN | ||
| v_org_id := OLD.id; | ||
| v_record_id := OLD.id::text; | ||
| WHEN 'apps' THEN | ||
| v_org_id := OLD.owner_org; | ||
| v_record_id := OLD.app_id::text; | ||
| WHEN 'channels' THEN | ||
| v_org_id := OLD.owner_org; | ||
| v_record_id := OLD.id::text; | ||
| WHEN 'app_versions' THEN | ||
| v_org_id := OLD.owner_org; | ||
| v_record_id := OLD.id::text; | ||
| WHEN 'org_users' THEN | ||
| v_org_id := OLD.org_id; | ||
| v_record_id := OLD.id::text; | ||
| ELSE | ||
| v_org_id := NULL; | ||
| v_record_id := NULL; | ||
| END CASE; | ||
| ELSE | ||
| CASE TG_TABLE_NAME | ||
| WHEN 'orgs' THEN | ||
| v_org_id := NEW.id; | ||
| v_record_id := NEW.id::text; | ||
| WHEN 'apps' THEN | ||
| v_org_id := NEW.owner_org; | ||
| v_record_id := NEW.app_id::text; | ||
| WHEN 'channels' THEN | ||
| v_org_id := NEW.owner_org; | ||
| v_record_id := NEW.id::text; | ||
| WHEN 'app_versions' THEN | ||
| v_org_id := NEW.owner_org; | ||
| v_record_id := NEW.id::text; | ||
| WHEN 'org_users' THEN | ||
| v_org_id := NEW.org_id; | ||
| v_record_id := NEW.id::text; | ||
| ELSE | ||
| v_org_id := NULL; | ||
| v_record_id := NULL; | ||
| END CASE; | ||
| END IF; | ||
|
|
||
| IF v_org_id IS NOT NULL THEN | ||
| INSERT INTO public.audit_logs ( | ||
| table_name, | ||
| record_id, | ||
| operation, | ||
| user_id, | ||
| org_id, | ||
| old_record, | ||
| new_record, | ||
| changed_fields, | ||
| actor_type, | ||
| actor_user_id, | ||
| actor_user_email, | ||
| actor_apikey_id, | ||
| actor_apikey_name | ||
| ) VALUES ( | ||
| TG_TABLE_NAME, | ||
| v_record_id, | ||
| TG_OP, | ||
| v_user_id, | ||
| v_org_id, | ||
| v_old_record, | ||
| v_new_record, | ||
| v_changed_fields, | ||
| v_actor_type, | ||
| v_actor_user_id, | ||
| v_actor_user_email, | ||
| v_actor_apikey_id, | ||
| v_actor_apikey_name | ||
| ); | ||
| END IF; | ||
|
|
||
| IF TG_OP = 'DELETE' THEN | ||
| RETURN OLD; | ||
| END IF; | ||
|
|
||
| RETURN NEW; | ||
| END; | ||
| $$; | ||
|
|
||
|
|
||
| ALTER FUNCTION "public"."audit_log_trigger"() OWNER TO "postgres"; | ||
|
|
||
| CREATE OR REPLACE FUNCTION "public"."cleanup_audit_logs_bookkeeping_noise"( | ||
| "max_batches" integer DEFAULT 100, | ||
| "batch_size" integer DEFAULT 1000, | ||
| "max_runtime_ms" integer DEFAULT 15000 | ||
| ) RETURNS bigint | ||
| LANGUAGE "plpgsql" SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| DECLARE | ||
| batch_no integer := 0; | ||
| deleted_batch integer; | ||
| deleted_total bigint := 0; | ||
| v_max_batches integer := GREATEST(1, COALESCE(max_batches, 100)); | ||
| v_batch_size integer := GREATEST(1, COALESCE(batch_size, 1000)); | ||
| v_max_runtime_ms integer := GREATEST(1000, COALESCE(max_runtime_ms, 15000)); | ||
| started_at timestamptz := pg_catalog.clock_timestamp(); | ||
| BEGIN | ||
| PERFORM pg_catalog.set_config('statement_timeout', '0', true); | ||
|
|
||
| LOOP | ||
| batch_no := batch_no + 1; | ||
| EXIT WHEN batch_no > v_max_batches; | ||
| EXIT WHEN (EXTRACT(EPOCH FROM (pg_catalog.clock_timestamp() - started_at)) * 1000) >= v_max_runtime_ms; | ||
|
|
||
| DELETE FROM public.audit_logs | ||
| WHERE ctid IN ( | ||
| SELECT al.ctid | ||
| FROM public.audit_logs AS al | ||
| WHERE al.operation = 'UPDATE' | ||
| AND al.changed_fields IS NOT NULL | ||
| AND ( | ||
| NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_catalog.unnest(al.changed_fields) AS changed_field(field_name) | ||
| WHERE changed_field.field_name IS DISTINCT FROM 'updated_at' | ||
| ) | ||
| OR ( | ||
| al.table_name = ANY (ARRAY['apps', 'orgs']::text[]) | ||
| AND al.changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at']::text[] | ||
| AND NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM pg_catalog.unnest(al.changed_fields) AS changed_field(field_name) | ||
| WHERE changed_field.field_name <> ALL ( | ||
| ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']::text[] | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| ORDER BY al.created_at | ||
| LIMIT v_batch_size | ||
| ); | ||
|
|
||
| GET DIAGNOSTICS deleted_batch = ROW_COUNT; | ||
| deleted_total := deleted_total + deleted_batch; | ||
| EXIT WHEN deleted_batch = 0; | ||
| END LOOP; | ||
|
|
||
| RAISE NOTICE | ||
| 'cleanup_audit_logs_bookkeeping_noise: deleted=% batches=%/% batch_size=% runtime_ms=% budget_ms=%', | ||
| deleted_total, | ||
| LEAST(batch_no, v_max_batches), | ||
| v_max_batches, | ||
| v_batch_size, | ||
| (EXTRACT(EPOCH FROM (pg_catalog.clock_timestamp() - started_at)) * 1000)::bigint, | ||
| v_max_runtime_ms; | ||
|
|
||
| RETURN deleted_total; | ||
| END; | ||
| $$; | ||
|
|
||
| ALTER FUNCTION "public"."cleanup_audit_logs_bookkeeping_noise"("max_batches" integer, "batch_size" integer, "max_runtime_ms" integer) OWNER TO "postgres"; | ||
| REVOKE ALL ON FUNCTION "public"."cleanup_audit_logs_bookkeeping_noise"("max_batches" integer, "batch_size" integer, "max_runtime_ms" integer) FROM PUBLIC; | ||
| GRANT ALL ON FUNCTION "public"."cleanup_audit_logs_bookkeeping_noise"("max_batches" integer, "batch_size" integer, "max_runtime_ms" integer) TO "service_role"; | ||
|
|
||
| -- Small deployment-budget pass only; remaining rows drain via cron_tasks. | ||
| SELECT public.cleanup_audit_logs_bookkeeping_noise(10, 1000, 5000); | ||
|
|
||
| INSERT INTO public.cron_tasks ( | ||
| name, | ||
| description, | ||
| task_type, | ||
| target, | ||
| batch_size, | ||
| payload, | ||
| second_interval, | ||
| minute_interval, | ||
| hour_interval, | ||
| run_at_hour, | ||
| run_at_minute, | ||
| run_at_second, | ||
| run_on_dow, | ||
| run_on_day, | ||
| enabled | ||
| ) VALUES ( | ||
| 'cleanup_audit_logs_bookkeeping_noise', | ||
| 'Delete updated_at-only and apps/orgs stats-refresh audit_logs noise in bounded batches', | ||
| 'function', | ||
| 'public.cleanup_audit_logs_bookkeeping_noise()', | ||
| NULL, | ||
| NULL, | ||
| NULL, | ||
| NULL, | ||
| 1, | ||
| NULL, | ||
| 15, | ||
| NULL, | ||
| NULL, | ||
| NULL, | ||
| true | ||
| ) | ||
| ON CONFLICT (name) DO UPDATE | ||
| SET | ||
| description = EXCLUDED.description, | ||
| task_type = EXCLUDED.task_type, | ||
| target = EXCLUDED.target, | ||
| hour_interval = EXCLUDED.hour_interval, | ||
| run_at_minute = EXCLUDED.run_at_minute, | ||
| enabled = true, | ||
| updated_at = pg_catalog.now(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.