From 89cbddf3185ec003b80aab80983856f1d455d8e3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 11:43:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/db):=20widen=20offer=5Fprice=5Fhist?= =?UTF-8?q?ory.diff=5Fpercent=20numeric(5,2)=E2=86=92(8,2)=20+=20clamp=20?= =?UTF-8?q?=D0=B2=20=D1=82=D1=80=D0=B8=D0=B3=D0=B3=D0=B5=D1=80=D0=B5=20(?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D1=88=20full=5Fload)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/131_fix_diff_percent_overflow.sql | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/131_fix_diff_percent_overflow.sql diff --git a/tradein-mvp/backend/data/sql/131_fix_diff_percent_overflow.sql b/tradein-mvp/backend/data/sql/131_fix_diff_percent_overflow.sql new file mode 100644 index 00000000..8aa5eb73 --- /dev/null +++ b/tradein-mvp/backend/data/sql/131_fix_diff_percent_overflow.sql @@ -0,0 +1,103 @@ +-- Migration 131: fix NumericValueOutOfRange crash in record_listing_price_change() +-- +-- Incident: prod run_id=260, 2026-06-20 +-- A listing scrubbed by avito_full_load had an OLD.price_rub that was +-- garbage-small (data entry error or placeholder value). The computed +-- diff_percent = round((NEW-OLD)/OLD*100, 2) exceeded ±999.99, which +-- overflows numeric(5,2) (precision=5, scale=2, max value=±999.99). +-- PostgreSQL raised DataError: "numeric field overflow" inside the +-- trigger, which caused the INSERT into listings to abort, which caused +-- the entire avito_full_load batch to crash. +-- The trigger is shared across all non-cian sources → same crash would +-- occur for yandex/domclick full_load batches on similar data. +-- +-- Fix (two parts): +-- 1. Widen diff_percent column to numeric(8,2) (max ±999999.99%). +-- Widening precision is safe and lossless for existing data. +-- 2. Replace the trigger function to clamp the computed value into +-- [−999999.99, +999999.99] so even future extreme deltas cannot +-- overflow the column. All other logic is preserved exactly as in +-- migration 116. +-- +-- Dependencies: offer_price_history table (023), listings table, +-- offer_price_history_listing_change_uq unique constraint, +-- trigger listings_price_change_trg (116). +-- +-- Idempotent: ALTER TYPE widening is a no-op if already numeric(8,2); +-- CREATE OR REPLACE FUNCTION is always safe to re-run. +-- Trigger is NOT recreated — the existing trigger already points to +-- record_listing_price_change(); replacing the function body is enough. +-- +-- Deploy order: standalone; auto-applied by deploy pipeline via +-- _schema_migrations. Do NOT apply manually on prod — causes drift. + +BEGIN; + +-- Part 1: widen the column. +-- numeric(5,2) → numeric(8,2): raises max from ±999.99 to ±999999.99. +-- PostgreSQL widens precision without rewriting rows (catalogue-only change +-- for heap tables when scale does not decrease and precision does not decrease). +ALTER TABLE offer_price_history + ALTER COLUMN diff_percent TYPE numeric(8,2); + +-- Part 2: replace trigger function with clamped diff_percent computation. +-- All guards from migration 116 are preserved verbatim: +-- • same-value skip (IS NOT DISTINCT FROM) +-- • NULL new-price skip +-- • cian source skip (backfill exclusion — see 116 for rationale) +-- • OLD.price_rub > 0 gate (avoid division by zero / nonsensical pct) +-- • ON CONFLICT DO NOTHING (idempotent re-scrape) +CREATE OR REPLACE FUNCTION record_listing_price_change() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + -- Skip if price did not actually change (same-value upsert write). + IF NEW.price_rub IS NOT DISTINCT FROM OLD.price_rub THEN + RETURN NEW; + END IF; + -- Skip if the new price is NULL (defensive; price_rub is NOT NULL in practice). + IF NEW.price_rub IS NULL THEN + RETURN NEW; + END IF; + -- Skip cian: its history is source-provided via cian_price_history.py backfill. + -- That service selects listings WHERE oph.listing_id IS NULL (zero rows in + -- offer_price_history). Inserting a trigger row here would permanently + -- exclude those listings from the backfill, losing accurate historical timestamps. + IF NEW.source = 'cian' THEN + RETURN NEW; + END IF; + + INSERT INTO offer_price_history ( + listing_id, + change_time, + price_rub, + source, + diff_percent, + recorded_at + ) VALUES ( + NEW.id, + now(), + NEW.price_rub, + NEW.source, + CASE + WHEN OLD.price_rub IS NOT NULL AND OLD.price_rub > 0 + THEN LEAST(GREATEST( + round( + (NEW.price_rub::numeric - OLD.price_rub::numeric) + / OLD.price_rub::numeric * 100, + 2 + ), + -999999.99), 999999.99) + ELSE NULL + END, + now() + ) + ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq + DO NOTHING; + + RETURN NEW; +END; +$$; + +COMMIT;