Исправлена дедупликация задач синхронизации контакта
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
"""Deduplicate contact mapping and skip no-op identity updates.
|
||||||
|
|
||||||
|
Revision ID: 0010_contact_map_dedup
|
||||||
|
Revises: 0009_chat_message_max
|
||||||
|
Create Date: 2026-08-05
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0010_contact_map_dedup"
|
||||||
|
down_revision: str | None = "0009_chat_message_max"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = han_app, pg_temp
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_entity_id uuid;
|
||||||
|
v_task_type text;
|
||||||
|
v_dedup text;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||||
|
IF TG_OP = 'UPDATE'
|
||||||
|
AND NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number
|
||||||
|
AND NEW.record_status IS NOT DISTINCT FROM OLD.record_status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
v_entity_id := NEW.id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
ELSE
|
||||||
|
v_entity_id := NEW.user_id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_task_type = 'contact.map_or_create' THEN
|
||||||
|
-- Identity and profile are inserted during the same bootstrap.
|
||||||
|
-- A stable key collapses both triggers into one logical task.
|
||||||
|
v_dedup := v_task_type || ':' || v_entity_id::text;
|
||||||
|
ELSE
|
||||||
|
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||||
|
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO han_app.sync_queue
|
||||||
|
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||||
|
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||||
|
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||||
|
now(), now(), now())
|
||||||
|
ON CONFLICT (dedup_key) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
raise RuntimeError("Contact map-or-create deduplication migration is forward-only")
|
||||||
@@ -175,7 +175,7 @@ async def lifespan(app: FastAPI):
|
|||||||
telemetry.shutdown()
|
telemetry.shutdown()
|
||||||
|
|
||||||
|
|
||||||
EXPECTED_API_DB_REVISION = "0009_chat_message_max"
|
EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -270,18 +270,32 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8")
|
sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8")
|
||||||
self.assertNotIn("server_settings", sms_db)
|
self.assertNotIn("server_settings", sms_db)
|
||||||
|
|
||||||
def test_contact_sync_qualifies_pgcrypto_digest(self) -> None:
|
def test_contact_sync_qualifies_digest_and_deduplicates_initial_map(self) -> None:
|
||||||
initial = (
|
initial = (
|
||||||
ROOT / "api-backend/alembic/versions/0001_initial_han_app.py"
|
ROOT / "api-backend/alembic/versions/0001_initial_han_app.py"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
fix = (
|
fix = (
|
||||||
ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py"
|
ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
dedup_fix = (
|
||||||
|
ROOT / "api-backend/alembic/versions/0010_contact_map_dedup.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8")
|
main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8")
|
||||||
self.assertIn("public.digest(", initial)
|
self.assertIn("public.digest(", initial)
|
||||||
self.assertIn("public.digest(", fix)
|
self.assertIn("public.digest(", fix)
|
||||||
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
||||||
self.assertIn('EXPECTED_API_DB_REVISION = "0009_chat_message_max"', main)
|
self.assertIn("IF v_task_type = 'contact.map_or_create' THEN", dedup_fix)
|
||||||
|
self.assertIn("v_dedup := v_task_type || ':' || v_entity_id::text;", dedup_fix)
|
||||||
|
self.assertIn(
|
||||||
|
"NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number",
|
||||||
|
dedup_fix,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"NEW.record_status IS NOT DISTINCT FROM OLD.record_status",
|
||||||
|
dedup_fix,
|
||||||
|
)
|
||||||
|
self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix)
|
||||||
|
self.assertIn('EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"', main)
|
||||||
|
|
||||||
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
||||||
migration = (
|
migration = (
|
||||||
|
|||||||
Reference in New Issue
Block a user