Skip to content

Commit 32eb8df

Browse files
authored
Merge pull request #2001 from shashank03-dev/fix/issue-1966-ja-price-bulk-rpc
fix(etl): bulk-update Jan Aushadhi prices via atomic RPC
2 parents 7af47d7 + cf72d23 commit 32eb8df

3 files changed

Lines changed: 260 additions & 10 deletions

File tree

apps/etl/src/loaders/supabase_loader.py

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444

4545
BATCH_SIZE = 100
4646
DELAY_SEC = 0.5
47+
48+
# Postgres RPC (supabase/migrations) for atomic Jan Aushadhi price back-fill.
49+
# Updates medicines.jan_aushadhi_price in place by id without an INSERT, so the
50+
# table's NOT NULL columns (e.g. generic_name) are never validated against the
51+
# {id, jan_aushadhi_price}-only payload.
52+
JA_PRICE_BULK_RPC = "bulk_update_jan_aushadhi_price"
4753
BATCH_UPSERT_MAX_ATTEMPTS = 4
4854
BATCH_UPSERT_INITIAL_BACKOFF_SEC = 2.0
4955
BATCH_UPSERT_MAX_BACKOFF_SEC = 8.0
@@ -790,6 +796,12 @@ def _upsert_ja_price_update_batches(
790796
updates: list[dict],
791797
table: str,
792798
) -> tuple[int, int]:
799+
# The bulk RPC updates public.medicines.jan_aushadhi_price in place by id.
800+
# Any other table (none today) must not reach it — fall back to per-row
801+
# updates so behaviour stays correct if a caller passes a different table.
802+
if table != "medicines":
803+
return self._update_ja_price_rows_one_by_one(updates, table)
804+
793805
updated = failed = 0
794806
total = len(updates)
795807
total_batches = (total + BATCH_SIZE - 1) // BATCH_SIZE
@@ -800,21 +812,44 @@ def _upsert_ja_price_update_batches(
800812
batch_end = batch_start + len(batch)
801813

802814
try:
803-
self._run_upsert_with_transient_retries(
804-
lambda: self.client.table(table).upsert(batch).execute(),
805-
f"merge_jan_aushadhi_price batch {batch_number}/{total_batches} upsert",
806-
)
807-
updated += len(batch)
815+
response = self._bulk_update_ja_price(batch, batch_number, total_batches)
816+
rpc_count = self._coerce_rpc_updated_count(response)
817+
if rpc_count is None:
818+
# RPC committed (no exception → no INSERT, NOT NULL never hit)
819+
# but the row count came back in an unexpected shape. Assume the
820+
# whole batch landed and log loudly so a response-shape change is
821+
# visible rather than silently miscounted.
822+
logger.error(
823+
f"[Loader] merge_jan_aushadhi_price: batch "
824+
f"{batch_number}/{total_batches} bulk RPC returned an "
825+
f"unrecognized count shape {getattr(response, 'data', None)!r}; "
826+
f"assuming all {len(batch)} rows updated"
827+
)
828+
rpc_count = len(batch)
829+
updated += rpc_count
830+
# The selection guarantees every id was a real, still-NULL row, so a
831+
# short count means rows vanished between the page scan and the UPDATE
832+
# (e.g. deleted concurrently). Account for them as failed so the
833+
# caller's checked == updated + skipped + failed invariant holds.
834+
shortfall = len(batch) - rpc_count
835+
if shortfall > 0:
836+
failed += shortfall
837+
logger.warning(
838+
f"[Loader] merge_jan_aushadhi_price: batch "
839+
f"{batch_number}/{total_batches} updated {rpc_count}/{len(batch)} "
840+
f"rows; {shortfall} unaccounted (rows missing at UPDATE time) "
841+
f"— counted as failed"
842+
)
808843
logger.info(
809844
f"[Loader] merge_jan_aushadhi_price: batch "
810-
f"{batch_number}/{total_batches} upserted {len(batch)} rows "
845+
f"{batch_number}/{total_batches} updated {rpc_count} rows "
811846
f"({updated}/{total} page matches)"
812847
)
813848
except Exception as e:
814-
logger.warning(
849+
logger.error(
815850
f"[Loader] merge_jan_aushadhi_price: batch "
816851
f"{batch_number}/{total_batches} rows {batch_start}-{batch_end} "
817-
f"failed: {e} - retrying row-by-row"
852+
f"bulk RPC failed: {e} - retrying row-by-row"
818853
)
819854
batch_updated, batch_failed = self._update_ja_price_rows_one_by_one(
820855
batch,
@@ -825,6 +860,44 @@ def _upsert_ja_price_update_batches(
825860

826861
return updated, failed
827862

863+
def _bulk_update_ja_price(
864+
self,
865+
batch: list[dict],
866+
batch_number: int,
867+
total_batches: int,
868+
) -> object:
869+
"""Atomically update one batch via the bulk RPC, with transient retries."""
870+
captured: dict = {}
871+
872+
def _call() -> None:
873+
captured["response"] = self.client.rpc(
874+
JA_PRICE_BULK_RPC,
875+
{"p_updates": batch},
876+
).execute()
877+
878+
self._run_upsert_with_transient_retries(
879+
_call,
880+
f"merge_jan_aushadhi_price batch {batch_number}/{total_batches} bulk RPC",
881+
)
882+
return captured.get("response")
883+
884+
@staticmethod
885+
def _coerce_rpc_updated_count(response: object) -> "int | None":
886+
"""Read the integer row count returned by the bulk RPC.
887+
888+
PostgREST returns a scalar function result as the raw value, but tolerate
889+
a single-element list too. Returns ``None`` when the shape is unrecognized
890+
so the caller can decide how to account for it rather than guessing here.
891+
"""
892+
data = getattr(response, "data", None)
893+
if isinstance(data, bool): # bool is an int subclass — exclude it
894+
return None
895+
if isinstance(data, int):
896+
return data
897+
if isinstance(data, list) and len(data) == 1 and isinstance(data[0], int):
898+
return data[0]
899+
return None
900+
828901
def _update_ja_price_rows_one_by_one(
829902
self,
830903
updates: list[dict],

apps/etl/tests/test_loader.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,8 +575,47 @@ def __init__(self, medicines=None, transient_batch_failures=0):
575575
self.medicines = medicines or []
576576
self.update_calls = []
577577
self.upsert_calls = []
578+
self.rpc_calls = []
578579
self.transient_batch_failures = transient_batch_failures
579580
self.transient_batch_attempts = 0
581+
# When set, the RPC returns this as response.data instead of the real
582+
# changed-row count — used to exercise short-count / unrecognized shapes.
583+
self.rpc_data_override = None
584+
self.rpc_override_set = False
585+
586+
def rpc(self, name, params):
587+
"""Fake the bulk_update_jan_aushadhi_price RPC: atomic UPDATE by id."""
588+
client = self
589+
590+
class _FakeRpc:
591+
def execute(self_inner):
592+
client.rpc_calls.append((name, params))
593+
updates = params.get("p_updates") or []
594+
595+
# Mirror the real loader's batch retry surface: a multi-row batch
596+
# can hit a transient error before succeeding on a later attempt.
597+
if len(updates) > 1:
598+
client.transient_batch_attempts += 1
599+
if client.transient_batch_attempts <= client.transient_batch_failures:
600+
raise TimeoutError(
601+
"connection timed out during Jan Aushadhi price bulk RPC"
602+
)
603+
604+
changed = 0
605+
for update in updates:
606+
row_id = update.get("id")
607+
new_price = update.get("jan_aushadhi_price")
608+
if row_id is None or new_price is None:
609+
continue
610+
for med in client.medicines:
611+
if med.get("id") == row_id:
612+
med["jan_aushadhi_price"] = new_price
613+
changed += 1
614+
if client.rpc_override_set:
615+
return FakeExecuteResponse(client.rpc_data_override)
616+
return FakeExecuteResponse(changed)
617+
618+
return _FakeRpc()
580619

581620
def table(self, name):
582621
t = MergeFakeTable(name, self)
@@ -644,7 +683,78 @@ def test_ja_backfill_updates_null_jan_aushadhi_price_rows(tmp_path):
644683
assert medicines[1]["jan_aushadhi_price"] == 25.00
645684

646685

647-
def test_ja_backfill_retries_transient_batch_upsert_before_fallback(tmp_path, monkeypatch):
686+
def test_ja_backfill_uses_bulk_rpc_with_id_and_price_only(tmp_path):
687+
"""Regression for #1966: back-fill goes through the bulk_update RPC with a
688+
{id, jan_aushadhi_price}-only payload, never a PostgREST upsert (which would
689+
fail the medicines.generic_name NOT NULL constraint and fall back to slow
690+
row-by-row PATCHes)."""
691+
medicines = [
692+
{"id": "m1", "generic_name": "Paracetamol", "strength": "500mg",
693+
"source": "commercial", "jan_aushadhi_price": None},
694+
]
695+
nppa_csv = _write_nppa_csv(tmp_path, [
696+
{"generic_name": "paracetamol", "strength": "500mg", "mrp": "18.50"},
697+
])
698+
client = MergeFakeSupabaseClient(medicines=medicines)
699+
loader = make_merge_loader(client, tmp_path)
700+
701+
stats = loader.merge_jan_aushadhi_price(nppa_csv=nppa_csv)
702+
703+
assert stats["updated"] == 1
704+
assert stats["failed"] == 0
705+
# No upsert and no row-by-row fallback were used.
706+
assert client.upsert_calls == []
707+
assert client.update_calls == []
708+
# Exactly one bulk RPC call, carrying only id + jan_aushadhi_price.
709+
assert len(client.rpc_calls) == 1
710+
name, params = client.rpc_calls[0]
711+
assert name == "bulk_update_jan_aushadhi_price"
712+
assert params["p_updates"] == [{"id": "m1", "jan_aushadhi_price": 18.50}]
713+
714+
715+
def test_ja_backfill_counts_short_rpc_result_as_failed(tmp_path):
716+
"""If the bulk RPC updates fewer rows than the batch (a row vanished between
717+
the page scan and the UPDATE), the shortfall is counted as failed so the
718+
checked == updated + skipped + failed invariant holds — not silently dropped."""
719+
medicines = [
720+
{"id": "m1", "generic_name": "Paracetamol", "jan_aushadhi_price": None},
721+
]
722+
client = MergeFakeSupabaseClient(medicines=medicines)
723+
loader = make_merge_loader(client, tmp_path)
724+
725+
# m2 has no matching medicine row, so the RPC reports 1 updated, not 2.
726+
batch = [
727+
{"id": "m1", "jan_aushadhi_price": 18.50},
728+
{"id": "m2", "jan_aushadhi_price": 25.00},
729+
]
730+
updated, failed = loader._upsert_ja_price_update_batches(batch, "medicines")
731+
732+
assert updated == 1
733+
assert failed == 1
734+
assert medicines[0]["jan_aushadhi_price"] == 18.50
735+
assert client.update_calls == [] # no row-by-row fallback was triggered
736+
737+
738+
def test_ja_backfill_assumes_full_batch_on_unrecognized_rpc_shape(tmp_path):
739+
"""An unrecognized RPC count shape is assumed to be a full success (the RPC
740+
committed without raising) rather than miscounted as failed."""
741+
medicines = [
742+
{"id": "m1", "generic_name": "Paracetamol", "jan_aushadhi_price": None},
743+
]
744+
client = MergeFakeSupabaseClient(medicines=medicines)
745+
client.rpc_override_set = True
746+
client.rpc_data_override = {"unexpected": "shape"}
747+
loader = make_merge_loader(client, tmp_path)
748+
749+
batch = [{"id": "m1", "jan_aushadhi_price": 18.50}]
750+
updated, failed = loader._upsert_ja_price_update_batches(batch, "medicines")
751+
752+
assert updated == 1
753+
assert failed == 0
754+
assert client.update_calls == []
755+
756+
757+
def test_ja_backfill_retries_transient_batch_rpc_before_fallback(tmp_path, monkeypatch):
648758
medicines = [
649759
{"id": "m1", "generic_name": "Paracetamol", "strength": "500mg",
650760
"source": "commercial", "jan_aushadhi_price": None},
@@ -671,7 +781,7 @@ def test_ja_backfill_retries_transient_batch_upsert_before_fallback(tmp_path, mo
671781
assert stats["updated"] == 2
672782
assert stats["failed"] == 0
673783
assert client.transient_batch_attempts == 3
674-
assert len(client.upsert_calls) == 3
784+
assert len(client.rpc_calls) == 3
675785
assert client.update_calls == []
676786
assert len(sleep_calls) == 2
677787

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
-- Bulk Jan Aushadhi price back-fill RPC
2+
--
3+
-- WHY THIS EXISTS
4+
-- ---------------
5+
-- The ETL loader (apps/etl/src/loaders/supabase_loader.py :: merge_jan_aushadhi_price)
6+
-- back-fills medicines.jan_aushadhi_price in batches. It previously used PostgREST
7+
-- .upsert(), which compiles to INSERT ... ON CONFLICT. Even for rows that already
8+
-- exist, PostgREST sends a full INSERT, so every payload was validated against the
9+
-- table's NOT NULL columns (notably medicines.generic_name). The batch payload only
10+
-- carries {id, jan_aushadhi_price}, so the INSERT path raised
11+
-- "null value in column generic_name violates not-null constraint"
12+
-- which forced a row-by-row PATCH fallback (~100x slower).
13+
--
14+
-- This function performs an atomic, set-based UPDATE of jan_aushadhi_price only,
15+
-- keyed by id, in a single statement. It never inserts, so NOT NULL columns are
16+
-- never touched and no other field can be corrupted.
17+
--
18+
-- Payload shape (single jsonb array argument):
19+
-- [{"id": "<uuid>", "jan_aushadhi_price": 10.5}, ...]
20+
--
21+
-- Returns the number of rows actually updated.
22+
23+
CREATE OR REPLACE FUNCTION public.bulk_update_jan_aushadhi_price(p_updates jsonb)
24+
RETURNS integer
25+
LANGUAGE plpgsql
26+
-- SECURITY INVOKER (the default): this function WRITES to medicines, so it must
27+
-- run with the caller's privileges and stay subject to RLS. The ETL connects as
28+
-- service_role (which the medicines_service_write policy in
29+
-- 20260529000000_add_rls_policies.sql allows), so the back-fill works; any other
30+
-- role's UPDATE is filtered out by RLS. Unlike the read-only RPCs in this repo,
31+
-- exposing a writer with definer rights would let anon/authenticated callers edit
32+
-- prices via PostgREST.
33+
SET search_path = public, pg_temp
34+
AS $$
35+
DECLARE
36+
v_updated integer;
37+
BEGIN
38+
IF p_updates IS NULL OR jsonb_typeof(p_updates) <> 'array' THEN
39+
RAISE EXCEPTION 'p_updates must be a JSON array of {id, jan_aushadhi_price} objects, got %',
40+
COALESCE(jsonb_typeof(p_updates), 'null');
41+
END IF;
42+
43+
WITH payload AS (
44+
SELECT id, jan_aushadhi_price
45+
FROM jsonb_to_recordset(p_updates)
46+
AS x(id uuid, jan_aushadhi_price numeric)
47+
WHERE id IS NOT NULL
48+
AND jan_aushadhi_price IS NOT NULL
49+
),
50+
changed AS (
51+
UPDATE public.medicines m
52+
SET jan_aushadhi_price = p.jan_aushadhi_price
53+
FROM payload p
54+
WHERE m.id = p.id
55+
RETURNING m.id
56+
)
57+
SELECT count(*) INTO v_updated FROM changed;
58+
59+
RETURN v_updated;
60+
END;
61+
$$;
62+
63+
-- Postgres grants EXECUTE on new functions to PUBLIC by default, which would make
64+
-- this writer callable by anon/authenticated through PostgREST. Lock it down to
65+
-- the ETL's service_role so prices can only be back-filled by the loader.
66+
REVOKE EXECUTE ON FUNCTION public.bulk_update_jan_aushadhi_price(jsonb) FROM PUBLIC;
67+
GRANT EXECUTE ON FUNCTION public.bulk_update_jan_aushadhi_price(jsonb) TO service_role;

0 commit comments

Comments
 (0)