HH-441: harden durable storage and recovery (#92)

* HH-441: harden durable storage and recovery

* HH-441: clear recovery review blockers

* HH-441: enforce offsite backup failure domain

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-22 02:16:02 +08:00
committed by GitHub
co-authored by rogee
parent 77c91662d2
commit 6d6d80dd86
20 changed files with 966 additions and 202 deletions
@@ -1,22 +1,5 @@
-- Revert reporting_events_rollups to the original (incorrect) init schema structure.
-- WARNING: this restores the broken column names (dimension, dimension_value, metric_name,
-- value, value_in_business_hours, period) that do not match the GORM model.
DROP TABLE IF EXISTS reporting_events_rollups;
CREATE TABLE IF NOT EXISTS reporting_events_rollups (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL,
dimension VARCHAR(50) NOT NULL,
dimension_value VARCHAR(255) NOT NULL,
metric_name VARCHAR(50) NOT NULL,
value DOUBLE PRECISION NOT NULL,
value_in_business_hours DOUBLE PRECISION DEFAULT 0,
period VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE(account_id, dimension, dimension_value, metric_name, period)
);
CREATE INDEX idx_reporting_events_rollups_deleted_at ON reporting_events_rollups(deleted_at);
DO $$
BEGIN
RAISE EXCEPTION 'migration 000048 is irreversible; restore the pre-upgrade backup instead of destroying rollup data';
END
$$;
@@ -1,18 +1,67 @@
-- Fix reporting_events_rollups table structure to match GORM model
-- The init schema (000001) created columns: dimension, dimension_value, metric_name,
-- value, value_in_business_hours, period — but the GORM model and all Go code use:
-- date, dimension_type, dimension_id, metric, count, sum_value, sum_value_business_hours.
-- This migration drops and recreates the table with the correct schema.
-- The table is empty in practice (rollups are computed on-demand), so data loss is negligible.
-- Convert the legacy rollup schema without dropping its data. The explicit
-- transaction makes lock/statement timeout failures leave the old table intact.
BEGIN;
LOCK TABLE reporting_events_rollups IN ACCESS EXCLUSIVE MODE;
DROP TABLE IF EXISTS reporting_events_rollups;
DO $$
DECLARE
invalid_periods BIGINT;
invalid_dimensions BIGINT;
invalid_counts BIGINT;
oversized_dimensions BIGINT;
collision_groups BIGINT;
BEGIN
SELECT count(*) INTO invalid_periods
FROM reporting_events_rollups
WHERE NOT pg_input_is_valid(period, 'date');
IF invalid_periods > 0 THEN
RAISE EXCEPTION 'migration 000048: % rollup period value(s) cannot convert to date', invalid_periods
USING HINT = 'Repair or remove the reported legacy rows explicitly, then retry the migration.';
END IF;
CREATE TABLE reporting_events_rollups (
SELECT count(*) INTO invalid_dimensions
FROM reporting_events_rollups
WHERE NOT pg_input_is_valid(dimension_value, 'bigint');
IF invalid_dimensions > 0 THEN
RAISE EXCEPTION 'migration 000048: % rollup dimension_value(s) cannot convert to bigint', invalid_dimensions
USING HINT = 'Map non-numeric legacy dimensions explicitly, then retry the migration.';
END IF;
SELECT count(*) INTO invalid_counts
FROM reporting_events_rollups
WHERE metric_name LIKE '%count%' AND NOT pg_input_is_valid(value::text, 'bigint');
IF invalid_counts > 0 THEN
RAISE EXCEPTION 'migration 000048: % count value(s) cannot convert losslessly to bigint', invalid_counts
USING HINT = 'Repair fractional, non-finite, or out-of-range count values, then retry the migration.';
END IF;
SELECT count(*) INTO oversized_dimensions
FROM reporting_events_rollups
WHERE length(dimension) > 20;
IF oversized_dimensions > 0 THEN
RAISE EXCEPTION 'migration 000048: % dimension value(s) exceed the new 20-character limit', oversized_dimensions
USING HINT = 'Shorten or explicitly map oversized dimensions, then retry the migration.';
END IF;
SELECT count(*) INTO collision_groups
FROM (
SELECT 1
FROM reporting_events_rollups
GROUP BY account_id, period::date, dimension, dimension_value::BIGINT, metric_name
HAVING count(*) > 1
) collisions;
IF collision_groups > 0 THEN
RAISE EXCEPTION 'migration 000048: % unique-key collision group(s) appear after type conversion', collision_groups
USING HINT = 'Merge or choose one legacy row in each collision group explicitly, then retry the migration.';
END IF;
END $$;
CREATE TABLE reporting_events_rollups_v2 (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL,
date DATE NOT NULL,
dimension_type VARCHAR(20) NOT NULL,
dimension_id INTEGER NOT NULL,
dimension_id BIGINT NOT NULL,
metric VARCHAR(50) NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
sum_value DOUBLE PRECISION NOT NULL DEFAULT 0,
@@ -23,7 +72,36 @@ CREATE TABLE reporting_events_rollups (
UNIQUE(account_id, date, dimension_type, dimension_id, metric)
);
INSERT INTO reporting_events_rollups_v2 (
id, account_id, date, dimension_type, dimension_id, metric, count,
sum_value, sum_value_business_hours, created_at, updated_at, deleted_at
)
SELECT
id,
account_id,
period::date,
dimension,
dimension_value::BIGINT,
metric_name,
CASE WHEN metric_name LIKE '%count%' THEN value::BIGINT ELSE 1 END,
value,
COALESCE(value_in_business_hours, 0),
created_at,
updated_at,
deleted_at
FROM reporting_events_rollups;
SELECT setval(
pg_get_serial_sequence('reporting_events_rollups_v2', 'id'),
COALESCE((SELECT MAX(id) FROM reporting_events_rollups_v2), 1),
EXISTS (SELECT 1 FROM reporting_events_rollups_v2)
);
DROP TABLE reporting_events_rollups;
ALTER TABLE reporting_events_rollups_v2 RENAME TO reporting_events_rollups;
ALTER INDEX reporting_events_rollups_v2_pkey RENAME TO reporting_events_rollups_pkey;
CREATE INDEX idx_reporting_events_rollups_deleted_at ON reporting_events_rollups(deleted_at);
CREATE INDEX idx_reporting_events_rollups_account_id ON reporting_events_rollups(account_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_reporting_events_rollups_date ON reporting_events_rollups(date) WHERE deleted_at IS NULL;
CREATE INDEX idx_reporting_events_rollups_dimension ON reporting_events_rollups(dimension_type, dimension_id) WHERE deleted_at IS NULL;
COMMIT;
@@ -1,52 +1,4 @@
DELETE FROM custom_attribute_definitions
WHERE attribute_model = 'conversation_attribute'
AND attribute_name IN (
'swt_source_url',
'swt_source_search_term',
'swt_source_purchase_term',
'swt_source_keyword_id',
'swt_source_channel',
'swt_source_realtime_location',
'swt_source_region',
'swt_source_ad_account_id',
'swt_source_wakeable',
'swt_baidu_conversation_type',
'swt_baidu_agent_name',
'swt_baidu_ssid'
);
UPDATE conversations
SET
custom_attributes = COALESCE(custom_attributes, '{}'::jsonb)
- 'swt_source_url'
- 'swt_source_description'
- 'swt_source_search_term'
- 'swt_source_purchase_term'
- 'swt_source_keyword_id'
- 'swt_source_channel'
- 'swt_source_realtime_location'
- 'swt_source_region'
- 'swt_source_ad_account_id'
- 'swt_source_wakeable'
- 'swt_baidu_conversation_type'
- 'swt_baidu_agent_name'
- 'swt_baidu_ssid',
updated_at = CURRENT_TIMESTAMP
WHERE COALESCE(custom_attributes, '{}'::jsonb) ?| ARRAY[
'swt_source_url',
'swt_source_description',
'swt_source_search_term',
'swt_source_purchase_term',
'swt_source_keyword_id',
'swt_source_channel',
'swt_source_realtime_location',
'swt_source_region',
'swt_source_ad_account_id',
'swt_source_wakeable',
'swt_baidu_conversation_type',
'swt_baidu_agent_name',
'swt_baidu_ssid'
];
-- Historical kind=8 system messages are backfilled from the Connector SQLite
-- store because PostgreSQL intentionally does not retain the raw encoded payload.
-- Keep the legacy source attributes during the expand/contract window. New
-- connector versions emit source information as system messages, while an old
-- application digest still needs these definitions and values after rollback.
SELECT 1;