feat(tradein/db): derived price-history trigger (avito/yandex) + last_seen_at deactivate index (#1559)
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 33s
Deploy Trade-In / build-backend (push) Successful in 27s
Deploy Trade-In / deploy (push) Successful in 41s
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 33s
Deploy Trade-In / build-backend (push) Successful in 27s
Deploy Trade-In / deploy (push) Successful in 41s
This commit is contained in:
parent
91f9a95f93
commit
e46867fa7f
2 changed files with 135 additions and 0 deletions
|
|
@ -0,0 +1,100 @@
|
|||
-- Migration 116: derived price-change trigger on listings → offer_price_history
|
||||
--
|
||||
-- Problem:
|
||||
-- The listings upsert in base.py::save_listings uses ON CONFLICT (dedup_hash)
|
||||
-- DO UPDATE SET price_rub = EXCLUDED.price_rub (and other fields). This
|
||||
-- silently overwrites the price with no history record. avito and yandex
|
||||
-- price changes are therefore lost on every re-scrape.
|
||||
--
|
||||
-- Only cian has offer_price_history data today (source-provided, scraped
|
||||
-- from cian's embedded priceChanges JSON by cian_detail.py and backfilled
|
||||
-- by cian_price_history.py).
|
||||
--
|
||||
-- Fix:
|
||||
-- An AFTER UPDATE OF price_rub trigger on listings that writes a derived
|
||||
-- history row when price_rub actually changes value.
|
||||
--
|
||||
-- Cian-exclusion rationale (CRITICAL):
|
||||
-- cian_price_history.py::backfill_cian_price_history selects cian listings
|
||||
-- to process via:
|
||||
-- LEFT JOIN offer_price_history oph ON oph.listing_id = l.id
|
||||
-- WHERE l.source = 'cian' AND oph.listing_id IS NULL
|
||||
-- i.e. it ONLY picks cian listings with zero existing history rows.
|
||||
-- If this trigger were to insert a NOW() row for a cian listing, that
|
||||
-- listing would be permanently excluded from the backfill, losing the full
|
||||
-- source-provided timeline with accurate historical timestamps. Therefore
|
||||
-- the trigger MUST fire only for non-cian sources.
|
||||
--
|
||||
-- Other no-ops:
|
||||
-- deactivate_stale_listings runs UPDATE listings SET is_active=false —
|
||||
-- it does not touch price_rub, so AFTER UPDATE OF price_rub means the
|
||||
-- trigger function is not even called for those updates.
|
||||
-- Geocode backfill (SET geom=...) similarly never touches price_rub.
|
||||
--
|
||||
-- Dependencies: listings table, offer_price_history table,
|
||||
-- offer_price_history_listing_change_uq unique constraint (listing_id, change_time)
|
||||
--
|
||||
-- Deploy order: standalone; no other migration must precede or follow.
|
||||
-- Idempotent: safe to run multiple times (CREATE OR REPLACE + DROP IF EXISTS).
|
||||
|
||||
BEGIN;
|
||||
|
||||
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 round(
|
||||
(NEW.price_rub::numeric - OLD.price_rub::numeric)
|
||||
/ OLD.price_rub::numeric * 100,
|
||||
2
|
||||
)
|
||||
ELSE NULL
|
||||
END,
|
||||
now()
|
||||
)
|
||||
ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq
|
||||
DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS listings_price_change_trg ON listings;
|
||||
|
||||
CREATE TRIGGER listings_price_change_trg
|
||||
AFTER UPDATE OF price_rub ON listings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION record_listing_price_change();
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- Migration 117: partial composite index to accelerate deactivate_stale_listings
|
||||
--
|
||||
-- Problem:
|
||||
-- deactivate_stale_listings executes:
|
||||
-- UPDATE listings SET is_active = false
|
||||
-- WHERE source = :x
|
||||
-- AND is_active = true
|
||||
-- AND last_seen_at < NOW() - interval '...'
|
||||
-- [AND listing_segment = ANY(...)]
|
||||
-- With ~39k rows and no index on last_seen_at or is_active, every call
|
||||
-- performs a sequential scan.
|
||||
--
|
||||
-- Fix:
|
||||
-- A partial B-tree index on (source, last_seen_at) WHERE is_active.
|
||||
-- The WHERE is_active partial predicate eliminates already-deactivated rows
|
||||
-- (typically the majority after steady-state), keeping the index small.
|
||||
-- The (source, last_seen_at) composite matches the equality+range predicates
|
||||
-- in the deactivate query so the planner can use an index scan.
|
||||
--
|
||||
-- Note on CONCURRENTLY:
|
||||
-- NOT used here. The deploy migration runner wraps each file in an explicit
|
||||
-- transaction (BEGIN/COMMIT). CREATE INDEX CONCURRENTLY cannot run inside
|
||||
-- a transaction block. Plain CREATE INDEX holds an ACCESS SHARE lock;
|
||||
-- at ~39k rows the lock duration is sub-second and acceptable.
|
||||
--
|
||||
-- Idempotent: IF NOT EXISTS makes repeated runs safe.
|
||||
-- Deploy order: standalone.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS listings_active_source_lastseen_idx
|
||||
ON listings (source, last_seen_at)
|
||||
WHERE is_active;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue