Skip to content

Commit c383665

Browse files
authored
BFD-4686: IDR Pipeline incremental performance improvements (#3125)
1 parent 5d39eab commit c383665

3 files changed

Lines changed: 125 additions & 72 deletions

File tree

apps/bfd-pipeline-idr/loader.py

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import psycopg
66
from psycopg.abc import Params, QueryNoTemplate
7+
from psycopg.errors import DeadlockDetected, LockNotAvailable, QueryCanceled
78

89
from constants import DEFAULT_MIN_DATE
910
from load_partition import LoadPartition, LoadType
@@ -95,10 +96,14 @@ def __init__(
9596
self.meta_keys = (
9697
["bfd_created_ts"] if self.immutable else ["bfd_created_ts", "bfd_updated_ts"]
9798
)
99+
self.progress_start_timer = Timer("progress_start", model, partition)
98100
self.idr_query_timer = Timer("idr_query", model, partition)
99101
self.temp_table_timer = Timer("temp_table", model, partition)
100102
self.copy_timer = Timer("copy", model, partition)
101-
self.insert_timer = Timer("insert", model, partition)
103+
self.upsert_timer = Timer("upsert", model, partition)
104+
self.last_updated_timer = Timer("last_updated", model, partition)
105+
self.total_insert_timer = Timer("total_insert", model, partition)
106+
self.update_progress_timer = Timer("update_progress", model, partition)
102107
self.commit_timer = Timer("commit", model, partition)
103108
self.load_type = load_type
104109
self.enable_load_progress = should_track_load_progress(load_mode)
@@ -111,8 +116,10 @@ def load(
111116
# (temp tables can't be created with an explicit schema set)
112117

113118
with self.conn.cursor() as cur:
119+
self.progress_start_timer.start()
114120
self._insert_batch_start(cur)
115121
self.conn.commit()
122+
self.progress_start_timer.stop()
116123
data_loaded = False
117124
num_rows = 0
118125

@@ -140,11 +147,13 @@ def load(
140147

141148
if results:
142149
# Upsert into the main table
143-
self.insert_timer.start()
150+
self.total_insert_timer.start()
144151
self._merge(cur, timestamp)
145-
self.insert_timer.stop()
152+
self.total_insert_timer.stop()
146153

154+
self.update_progress_timer.start()
147155
self._calculate_load_progress(cur, results)
156+
self.update_progress_timer.stop()
148157

149158
self.commit_timer.start()
150159
self.conn.commit()
@@ -211,11 +220,13 @@ def _setup_temp_table(self, cur: psycopg.Cursor) -> None:
211220
# For simplicity's sake, we'll create our temp tables using the existing schema and
212221
# just drop the columns we need to ignore.
213222
cur.execute(
214-
f"CREATE TEMPORARY TABLE {self.temp_table} (LIKE {self.table}) ON COMMIT DROP" # type: ignore
223+
f"CREATE TEMPORARY TABLE IF NOT EXISTS {self.temp_table} (LIKE {self.table}) "
224+
"ON COMMIT PRESERVE ROWS" # type: ignore
215225
)
226+
cur.execute(f"TRUNCATE TABLE {self.temp_table}") # type: ignore
216227
# Created/updated columns don't need to be loaded from the source.
217228
for col in self.meta_keys:
218-
cur.execute(f"ALTER TABLE {self.temp_table} DROP COLUMN {col}") # type: ignore
229+
cur.execute(f"ALTER TABLE {self.temp_table} DROP COLUMN IF EXISTS {col}") # type: ignore
219230

220231
def _calculate_load_progress(self, cur: psycopg.Cursor, results: Sequence[T]) -> None:
221232
last = results[len(results) - 1].model_dump()
@@ -260,6 +271,7 @@ def _update_load_progress(
260271
) -> None:
261272
if self.enable_load_progress:
262273
cur.execute(query, params) # type: ignore
274+
self.conn.commit()
263275

264276
def _merge(self, cur: psycopg.Cursor, timestamp: datetime) -> None:
265277
unique_key = self.model.unique_key()
@@ -274,11 +286,15 @@ def _merge(self, cur: psycopg.Cursor, timestamp: datetime) -> None:
274286
on_conflict = (
275287
"DO NOTHING"
276288
if self.immutable or not update_set
277-
else f"DO UPDATE SET {update_set}, bfd_updated_ts=%(timestamp)s"
289+
else (
290+
f"DO UPDATE SET {update_set}, bfd_updated_ts=%(timestamp)s "
291+
"WHERE (t.*) IS DISTINCT FROM (EXCLUDED.*)"
292+
)
278293
)
279-
timestamp_placeholders = ",".join("%(timestamp)s" for _ in self.meta_keys)
294+
timestamp_placeholders = ", ".join("%(timestamp)s" for _ in self.meta_keys)
280295

281296
# Upsert into the main table
297+
self.upsert_timer.start()
282298
if self.model.should_replace():
283299
# Delete before inserting since we've specified that the data should be
284300
# replaced rather than merged.
@@ -287,42 +303,54 @@ def _merge(self, cur: psycopg.Cursor, timestamp: datetime) -> None:
287303
cur.execute(f"DELETE FROM {self.table}") # type: ignore
288304
cur.execute(
289305
f"""
290-
INSERT INTO {self.table}({self.cols_str}, {",".join(self.meta_keys)})
291-
SELECT {self.cols_str},{timestamp_placeholders} FROM {self.temp_table}
292-
ON CONFLICT ({",".join(unique_key)}) {on_conflict}
306+
INSERT INTO {self.table} AS t ({self.cols_str}, {", ".join(self.meta_keys)})
307+
SELECT {self.cols_str}, {timestamp_placeholders} FROM {self.temp_table}
308+
ON CONFLICT ({", ".join(unique_key)}) {on_conflict}
293309
""", # type: ignore
294310
{"timestamp": timestamp},
311+
binary=True,
295312
)
313+
self.conn.commit()
314+
self.upsert_timer.stop()
296315

297316
if self.load_type == LoadType.INCREMENTAL and self.model.last_updated_date_table():
298317
key = self.model.last_updated_timestamp_col()
299318
last_updated_cols = self.model.last_updated_date_column()
300319
set_clause = ", ".join(f"{col} = %(timestamp)s" for col in last_updated_cols)
301320

302-
# We require multi-step transactions since we're dealing with temp tables, so there
303-
# is a chance of a deadlock here.
304-
# However, it's safe to ignore these because if the timestamp for this row is being
305-
# updated concurrently then it's going to have the same end result anyway.
306-
# If a deadlock occurs, the CTE returns no rows and this is a no-op.
307-
308-
cur.execute(
309-
f"""
310-
WITH current_ts AS (
311-
SELECT {key}
312-
FROM {self.model.last_updated_date_table()}
313-
WHERE {key} IN (
314-
SELECT {key} FROM {self.temp_table}
321+
self.last_updated_timer.start()
322+
try:
323+
# We want to immediately terminate the transaction if there is already a lock on
324+
# the table so that we avoid extraneous waits because if there is a lock this table
325+
# is being updated concurrently and that existing update will have the same result
326+
cur.execute("SAVEPOINT pre_last_updated")
327+
cur.execute("SET LOCAL lock_timeout=1")
328+
cur.execute("SET LOCAL statement_timeout=3000")
329+
cur.execute(
330+
f"""
331+
WITH current_ts AS (
332+
SELECT {key}
333+
FROM {self.model.last_updated_date_table()}
334+
WHERE {key} IN (
335+
SELECT {key} FROM {self.temp_table}
336+
)
337+
ORDER BY {key}
338+
FOR UPDATE SKIP LOCKED
315339
)
316-
ORDER BY {key}
317-
FOR UPDATE SKIP LOCKED
340+
UPDATE {self.model.last_updated_date_table()} u
341+
SET {set_clause}
342+
FROM current_ts t
343+
WHERE u.{key} = t.{key};
344+
""", # type: ignore
345+
{"timestamp": timestamp},
318346
)
319-
UPDATE {self.model.last_updated_date_table()} u
320-
SET {set_clause}
321-
FROM current_ts t
322-
WHERE u.{key} = t.{key};
323-
""", # type: ignore
324-
{"timestamp": timestamp},
325-
)
347+
self.conn.commit()
348+
except (DeadlockDetected, LockNotAvailable, QueryCanceled) as ex:
349+
logger.warning(
350+
"deadlock/lock/statement timeout updating update timestamp, ignoring: %s", ex
351+
)
352+
cur.execute("ROLLBACK TO SAVEPOINT pre_last_updated")
353+
self.last_updated_timer.stop()
326354

327355
def _copy_data(self, cur: psycopg.Cursor, results: Sequence[T]) -> None:
328356
# Use COPY to load the batch into Postgres.

apps/bfd-pipeline-idr/test_pipeline.py

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import os
22
import shutil
33
import subprocess
4-
from collections.abc import Generator
54
from datetime import datetime, timedelta
65
from pathlib import Path
76
from typing import cast
87
from uuid import uuid4
98

109
import psycopg
11-
import pytest
12-
from psycopg import sql
10+
from psycopg import Connection, sql
1311
from psycopg.rows import DictRow, dict_row
1412
from testcontainers.core.config import testcontainers_config # type: ignore
1513

@@ -45,7 +43,7 @@ def _run_migrator(postgres: PostgresContainer) -> None:
4543
f"-Dflyway.user={postgres.username} "
4644
f"-Dflyway.password={postgres.password} "
4745
"-Duser.timezone=UTC",
48-
cwd="../bfd-db-migrator-ng",
46+
cwd=Path(__file__).parent.joinpath("../bfd-db-migrator-ng"),
4947
shell=True,
5048
capture_output=True,
5149
check=True,
@@ -55,41 +53,7 @@ def _run_migrator(postgres: PostgresContainer) -> None:
5553
raise
5654

5755

58-
@pytest.fixture(scope="module")
59-
def setup_db() -> Generator[PostgresContainer]:
60-
with PostgresContainer("postgres:16", driver="") as postgres:
61-
with psycopg.connect(postgres.get_connection_url()) as conn:
62-
with Path("./mock-idr.sql").open() as f:
63-
conn.execute(f.read()) # type: ignore
64-
conn.commit()
65-
66-
_run_migrator(postgres)
67-
load_from_csv(PostgresExecutor(conn), "./test_samples1") # type: ignore
68-
69-
info = conn.info
70-
# Info level logs obscure the error output when running tests
71-
# so we want to override this unless the calling process has set this explicitly
72-
os.environ.setdefault("IDR_LOG_LEVEL", "warning")
73-
os.environ["BFD_DB_ENDPOINT"] = info.host
74-
os.environ["BFD_DB_PORT"] = str(info.port)
75-
os.environ["BFD_DB_NAME"] = info.dbname
76-
os.environ["BFD_DB_USERNAME"] = info.user
77-
os.environ["BFD_DB_PASSWORD"] = info.password
78-
os.environ["IDR_BATCH_SIZE"] = "100000"
79-
os.environ["IDR_FORCE_LOAD_PROGRESS"] = "1"
80-
os.environ["BFD_TEST_DATE"] = "2023-04-02"
81-
yield postgres
82-
83-
84-
def test_pipeline(setup_db: PostgresContainer) -> None:
85-
load_type = LoadType.INCREMENTAL
86-
conn = cast(
87-
psycopg.Connection[DictRow],
88-
psycopg.connect(setup_db.get_connection_url(), row_factory=dict_row), # type: ignore
89-
)
90-
91-
conn.commit()
92-
56+
def _do_test_pipeline(conn: Connection[DictRow], load_type: LoadType) -> None:
9357
run(Source.POSTGRES, LoadMode.SYNTHETIC, load_type)
9458

9559
cur = conn.execute("select * from idr.beneficiary order by bene_sk")
@@ -521,3 +485,62 @@ def test_pipeline(setup_db: PostgresContainer) -> None:
521485
def advance_time(timestamp: datetime) -> None:
522486
new_time = timestamp + timedelta(minutes=1)
523487
os.environ["BFD_TEST_DATE"] = new_time.isoformat()
488+
489+
490+
def test_pipeline() -> None:
491+
# This is REALLY dumb. Typically we would use a parameterized test plus a fixture to setup the
492+
# database and run the same test with both load types, but GitHub Actions Runners seem to have
493+
# some issue with testcontainers where only a single test case succeeds and all others fail to
494+
# connect. This forces sequential execution of each test case and ensures only a single
495+
# container is ever started, avoiding the issue in CI
496+
# TODO: Don't do this, find a way to make parameterized tests work in CI
497+
with (
498+
PostgresContainer("postgres:16", driver="") as postgres,
499+
# No idea why pyright is upset about "row_factory", but we need to tell it to ignore the
500+
# argument type here.
501+
psycopg.connect(conninfo=postgres.get_connection_url(), row_factory=dict_row) as conn, # pyright: ignore[reportArgumentType]
502+
):
503+
for load_type in [LoadType.INCREMENTAL, LoadType.INITIAL]:
504+
# Truncate all tables first. See above for justification
505+
conn.execute(
506+
"""
507+
DO $$ DECLARE
508+
r RECORD;
509+
BEGIN
510+
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'idr') LOOP
511+
EXECUTE 'DROP TABLE idr.' || quote_ident(r.tablename) || ' CASCADE';
512+
END LOOP;
513+
514+
FOR r IN (
515+
SELECT tablename FROM pg_tables WHERE schemaname = 'cms_vdm_view_mdcr_prd'
516+
) LOOP
517+
EXECUTE 'DROP TABLE cms_vdm_view_mdcr_prd.'
518+
|| quote_ident(r.tablename)
519+
|| ' CASCADE';
520+
END LOOP;
521+
END $$;
522+
"""
523+
)
524+
conn.commit()
525+
526+
with Path(__file__).parent.joinpath("./mock-idr.sql").open() as f:
527+
conn.execute(f.read()) # type: ignore
528+
conn.commit()
529+
530+
_run_migrator(postgres)
531+
load_from_csv(PostgresExecutor(conn), Path(__file__).parent.joinpath("./test_samples1")) # type: ignore
532+
533+
info = conn.info
534+
# Info level logs obscure the error output when running tests
535+
# so we want to override this unless the calling process has set this explicitly
536+
os.environ.setdefault("IDR_LOG_LEVEL", "warning")
537+
os.environ["BFD_DB_ENDPOINT"] = info.host
538+
os.environ["BFD_DB_PORT"] = str(info.port)
539+
os.environ["BFD_DB_NAME"] = info.dbname
540+
os.environ["BFD_DB_USERNAME"] = info.user
541+
os.environ["BFD_DB_PASSWORD"] = info.password
542+
os.environ["IDR_BATCH_SIZE"] = "100000"
543+
os.environ["IDR_FORCE_LOAD_PROGRESS"] = "1"
544+
os.environ["BFD_TEST_DATE"] = "2023-04-02"
545+
546+
_do_test_pipeline(cast(Connection[DictRow], conn), load_type)

ops/services/04-idr-pipeline/lambda_src/run-idr-pipeline/app/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ class IdrContainerOverrides:
9595
@classmethod
9696
def from_invoke(cls, invoke_model: InvokeModel) -> IdrContainerOverrides:
9797
env_kvs = (
98-
[EnvKeyValue(name=k, value=v) for k, v in invoke_model.env] if invoke_model.env else []
98+
[EnvKeyValue(name=k, value=v) for k, v in invoke_model.env.items()]
99+
if invoke_model.env
100+
else []
99101
)
100102
command_seq = invoke_model.command.split() if invoke_model.command else None
101103

0 commit comments

Comments
 (0)