Skip to content

Commit a0d4c90

Browse files
author
Brick Green
committed
Merge branch 'master' into BFD-4225
# Conflicts: # apps/bfd-pipeline/bfd-pipeline-idr/bfd.sql # apps/bfd-pipeline/bfd-pipeline-idr/model.py # apps/bfd-pipeline/bfd-pipeline-idr/pipeline.py # apps/bfd-pipeline/bfd-pipeline-idr/test_pipeline.py # apps/bfd-pipeline/bfd-pipeline-idr/test_samples2/SYNTHETIC_CLM_LINE.csv
2 parents 8532859 + f26c422 commit a0d4c90

30 files changed

Lines changed: 696 additions & 557 deletions

.github/workflows/ci-python.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
uses: astral-sh/setup-uv@v5
5959
with:
6060
# Install a specific version of uv.
61-
version: "0.7.8"
61+
version: "0.8.10"
6262
- name: Run Python Tests per project
6363
id: run-pytest-set
6464
run: |

apps/bfd-pipeline/bfd-pipeline-idr/bfd.sql

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,10 @@ CREATE TABLE idr.claim (
210210
clm_nrln_ric_cd VARCHAR(1) NOT NULL,
211211
clm_srvc_prvdr_gnrc_id_num VARCHAR(20) NOT NULL,
212212
prvdr_prscrbng_prvdr_npi_num VARCHAR(10) NOT NULL,
213-
idr_insrt_ts TIMESTAMPTZ NOT NULL,
214-
idr_updt_ts TIMESTAMPTZ NOT NULL,
213+
idr_insrt_ts_clm TIMESTAMPTZ NOT NULL,
214+
idr_updt_ts_clm TIMESTAMPTZ NOT NULL,
215+
idr_insrt_ts_dcmtn TIMESTAMPTZ NOT NULL,
216+
idr_updt_ts_dcmtn TIMESTAMPTZ NOT NULL,
215217
bfd_created_ts TIMESTAMPTZ NOT NULL,
216218
bfd_updated_ts TIMESTAMPTZ NOT NULL
217219
);
@@ -276,35 +278,6 @@ CREATE TABLE idr.claim_institutional (
276278
bfd_updated_ts TIMESTAMPTZ NOT NULL
277279
);
278280

279-
CREATE TABLE idr.claim_value (
280-
clm_uniq_id BIGINT NOT NULL,
281-
clm_val_sqnc_num INT NOT NULL,
282-
clm_val_cd VARCHAR(2) NOT NULL,
283-
clm_val_amt NUMERIC NOT NULL,
284-
idr_insrt_ts TIMESTAMPTZ NOT NULL,
285-
idr_updt_ts TIMESTAMPTZ NOT NULL,
286-
bfd_created_ts TIMESTAMPTZ NOT NULL,
287-
bfd_updated_ts TIMESTAMPTZ NOT NULL,
288-
PRIMARY KEY(clm_uniq_id, clm_val_sqnc_num)
289-
);
290-
291-
CREATE TABLE idr.claim_procedure (
292-
clm_uniq_id BIGINT NOT NULL,
293-
clm_val_sqnc_num INT NOT NULL,
294-
clm_dgns_prcdr_icd_ind VARCHAR(1) NOT NULL,
295-
clm_dgns_cd VARCHAR(7) NOT NULL,
296-
clm_prcdr_cd VARCHAR(7) NOT NULL,
297-
clm_prod_type_cd VARCHAR(1) NOT NULL,
298-
clm_poa_ind VARCHAR(1) NOT NULL,
299-
clm_prcdr_prfrm_dt DATE NOT NULL,
300-
idr_insrt_ts TIMESTAMPTZ,
301-
idr_updt_ts TIMESTAMPTZ,
302-
bfd_row_num INT NOT NULL,
303-
bfd_created_ts TIMESTAMPTZ NOT NULL,
304-
bfd_updated_ts TIMESTAMPTZ NOT NULL,
305-
PRIMARY KEY(clm_uniq_id, clm_prod_type_cd, clm_val_sqnc_num)
306-
);
307-
308281
CREATE TABLE idr.claim_professional (
309282
clm_uniq_id BIGINT NOT NULL PRIMARY KEY,
310283
clm_carr_pmt_dnl_cd VARCHAR(2) NOT NULL,
@@ -345,8 +318,10 @@ CREATE TABLE idr.claim_line_professional (
345318
PRIMARY KEY(clm_uniq_id, clm_line_num)
346319
);
347320

348-
CREATE TABLE idr.claim_line (
321+
CREATE TABLE idr.claim_item (
349322
clm_uniq_id BIGINT NOT NULL,
323+
bfd_row_id INT NOT NULL,
324+
-- columns from V2_MDCR_CLM_LINE
350325
clm_line_num INT NOT NULL,
351326
clm_line_ansthsa_unit_cnt NUMERIC NOT NULL,
352327
clm_line_dgns_cd VARCHAR(7) NOT NULL,
@@ -377,11 +352,27 @@ CREATE TABLE idr.claim_line (
377352
hcpcs_3_mdfr_cd VARCHAR(2) NOT NULL,
378353
hcpcs_4_mdfr_cd VARCHAR(2) NOT NULL,
379354
hcpcs_5_mdfr_cd VARCHAR(2) NOT NULL,
380-
idr_insrt_ts TIMESTAMPTZ NOT NULL,
381-
idr_updt_ts TIMESTAMPTZ NOT NULL,
355+
idr_insrt_ts_line TIMESTAMPTZ NOT NULL,
356+
idr_updt_ts_line TIMESTAMPTZ NOT NULL,
357+
-- columns from V2_MDCR_CLM_PROD
358+
clm_val_sqnc_num_prod INT NOT NULL,
359+
clm_dgns_prcdr_icd_ind VARCHAR(1) NOT NULL,
360+
clm_dgns_cd VARCHAR(7) NOT NULL,
361+
clm_prcdr_cd VARCHAR(7) NOT NULL,
362+
clm_prod_type_cd VARCHAR(1) NOT NULL,
363+
clm_poa_ind VARCHAR(1) NOT NULL,
364+
clm_prcdr_prfrm_dt DATE NOT NULL,
365+
idr_insrt_ts_prod TIMESTAMPTZ NOT NULL,
366+
idr_updt_ts_prod TIMESTAMPTZ NOT NULL,
367+
-- columns from V2_MDCR_CLM_VAL
368+
clm_val_sqnc_num_val INT NOT NULL,
369+
clm_val_cd VARCHAR(2) NOT NULL,
370+
clm_val_amt NUMERIC NOT NULL,
371+
idr_insrt_ts_val TIMESTAMPTZ NOT NULL,
372+
idr_updt_ts_val TIMESTAMPTZ NOT NULL,
382373
bfd_created_ts TIMESTAMPTZ NOT NULL,
383374
bfd_updated_ts TIMESTAMPTZ NOT NULL,
384-
PRIMARY KEY(clm_uniq_id, clm_line_num)
375+
PRIMARY KEY(clm_uniq_id, bfd_row_id)
385376
);
386377

387378
CREATE TABLE idr.claim_line_institutional (
Lines changed: 64 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import logging
22
import os
3-
import time
43
from abc import ABC, abstractmethod
54
from collections.abc import Iterator, Mapping
6-
from datetime import date, datetime
5+
from datetime import UTC, date, datetime
76

87
import psycopg
98
import snowflake.connector
109
from cryptography.hazmat.backends import default_backend
1110
from cryptography.hazmat.primitives import serialization
1211
from psycopg.rows import class_row
13-
from snowflake.connector import DictCursor, ProgrammingError, SnowflakeConnection
14-
from snowflake.connector.network import ReauthenticationRequest, RetryRequest
12+
from snowflake.connector import DictCursor, SnowflakeConnection
1513

14+
from constants import DEFAULT_MIN_DATE
1615
from model import LoadProgress, T
1716
from timer import Timer
1817

@@ -33,18 +32,25 @@ def print_timers() -> None:
3332
transform_timer.print_results()
3433

3534

36-
def get_min_transaction_date() -> str:
35+
def get_min_transaction_date() -> datetime:
3736
min_date = os.environ.get("PIPELINE_MIN_TRANSACTION_DATE")
3837
if min_date is not None:
39-
return min_date
40-
return "0001-01-01"
38+
return datetime.strptime(min_date, "%Y-%m-%d").replace(tzinfo=UTC)
39+
return datetime.strptime("0001-01-01", "%Y-%m-%d").replace(tzinfo=UTC)
4140

4241

4342
class Extractor(ABC):
4443
@abstractmethod
4544
def extract_many(self, cls: type[T], sql: str, params: dict[str, DbType]) -> Iterator[list[T]]:
4645
pass
4746

47+
@abstractmethod
48+
def reconnect(self) -> None:
49+
pass
50+
51+
def _greatest_col(self, cols: list[str]) -> str:
52+
return f"GREATEST({','.join(cols)})"
53+
4854
def get_query(self, cls: type[T], is_historical: bool, start_time: datetime) -> str:
4955
query = cls.fetch_query(is_historical, start_time)
5056
columns = ",".join(cls.column_aliases())
@@ -56,9 +62,15 @@ def extract_idr_data(
5662
) -> Iterator[list[T]]:
5763
is_historical = progress is None or progress.is_historical()
5864
fetch_query = self.get_query(cls, is_historical, start_time)
59-
batch_timestamp_col = cls.batch_timestamp_col_alias(is_historical)
60-
update_timestamp_col = cls.update_timestamp_col_alias()
61-
65+
batch_timestamp_cols = cls.batch_timestamp_col_alias(is_historical)
66+
# GREATEST doesn't work with nulls so we need to coalesce here
67+
update_timestamp_cols = [
68+
f"COALESCE({col}, '{DEFAULT_MIN_DATE}')" for col in cls.update_timestamp_col_alias()
69+
]
70+
# We need to create batches using the most recent timestamp from all of the
71+
# insert/update timestamps
72+
batch_timestamp_clause = self._greatest_col([*batch_timestamp_cols, *update_timestamp_cols])
73+
min_transaction_date = get_min_transaction_date()
6274
logger.info("extracting %s", cls.table())
6375
if progress is None:
6476
idr_query_timer.start()
@@ -67,39 +79,31 @@ def extract_idr_data(
6779
cls,
6880
fetch_query.replace(
6981
"{WHERE_CLAUSE}",
70-
f"WHERE {batch_timestamp_col} >= '{get_min_transaction_date()}'",
71-
).replace("{ORDER_BY}", f"ORDER BY {batch_timestamp_col}"),
82+
f"WHERE {batch_timestamp_clause} >= '{min_transaction_date}'",
83+
).replace("{ORDER_BY}", f"ORDER BY {batch_timestamp_clause}"),
7284
{},
7385
)
7486
idr_query_timer.stop()
7587
return res
7688

7789
previous_batch_complete = progress.batch_complete_ts >= progress.batch_start_ts
78-
logger.info("previous batch complete: %s", previous_batch_complete)
79-
80-
compare_timestamp = progress.batch_start_ts if previous_batch_complete else progress.last_ts
81-
90+
# If we've completed the last batch, there shouldn't be any additional records
91+
# with the same timestamp
92+
op = ">" if previous_batch_complete else ">="
93+
# insertion timestamps aren't always representative of the time the data is available in
94+
# Snowflake, so we should always start loading from the most recent timestamp
95+
# that we've already fetched
96+
compare_timestamp = max(min_transaction_date, progress.last_ts)
8297
idr_query_timer.start()
8398
# Saved progress found, start processing from where we left off
84-
update_clause = (
85-
f"""AND ({update_timestamp_col} IS NULL
86-
OR {update_timestamp_col} >= %(timestamp)s)"""
87-
if update_timestamp_col is not None
88-
else ""
89-
)
9099
res = self.extract_many(
91100
cls,
92101
fetch_query.replace(
93102
"{WHERE_CLAUSE}",
94103
f"""
95-
WHERE
96-
(
97-
{batch_timestamp_col} >= %(timestamp)s
98-
{update_clause}
99-
)
100-
AND {batch_timestamp_col} >= '{get_min_transaction_date()}'
104+
WHERE {batch_timestamp_clause} {op} %(timestamp)s
101105
""",
102-
).replace("{ORDER_BY}", f"ORDER BY {batch_timestamp_col}"),
106+
).replace("{ORDER_BY}", f"ORDER BY {batch_timestamp_clause}"),
103107
{"timestamp": compare_timestamp},
104108
)
105109
idr_query_timer.stop()
@@ -109,9 +113,13 @@ def extract_idr_data(
109113
class PostgresExtractor(Extractor):
110114
def __init__(self, connection_string: str, batch_size: int) -> None:
111115
super().__init__()
116+
self.connection_string = connection_string
112117
self.conn = psycopg.connect(connection_string)
113118
self.batch_size = batch_size
114119

120+
def reconnect(self) -> None:
121+
self.conn = psycopg.connect(self.connection_string)
122+
115123
def extract_many(
116124
self, cls: type[T], sql: str, params: Mapping[str, DbType]
117125
) -> Iterator[list[T]]:
@@ -135,6 +143,9 @@ def __init__(self, batch_size: int) -> None:
135143
self.conn = SnowflakeExtractor._connect()
136144
self.batch_size = batch_size
137145

146+
def reconnect(self) -> None:
147+
SnowflakeExtractor._connect()
148+
138149
@staticmethod
139150
def _connect() -> SnowflakeConnection:
140151
private_key = serialization.load_pem_private_key(
@@ -156,42 +167,31 @@ def _connect() -> SnowflakeConnection:
156167

157168
def extract_many(self, cls: type[T], sql: str, params: dict[str, DbType]) -> Iterator[list[T]]:
158169
cur = None
159-
max_attempts = 5
160-
for attempt in range(max_attempts):
161-
try:
162-
cursor_execute_timer.start()
163-
cur = self.conn.cursor(DictCursor)
164-
cur.execute(sql, params)
165-
cursor_execute_timer.stop()
170+
171+
try:
172+
cursor_execute_timer.start()
173+
cur = self.conn.cursor(DictCursor)
174+
cur.execute(sql, params)
175+
cursor_execute_timer.stop()
176+
177+
cursor_fetch_timer.start()
178+
# fetchmany can return list[dict] or list[tuple] but we'll only use
179+
# queries that return dicts
180+
batch: list[dict[str, DbType]] = cur.fetchmany(self.batch_size) # type: ignore[assignment]
181+
cursor_fetch_timer.stop()
182+
183+
while len(batch) > 0: # type: ignore
184+
transform_timer.start()
185+
data = [cls(**{k.lower(): v for k, v in row.items()}) for row in batch]
186+
transform_timer.stop()
187+
188+
yield data
166189

167190
cursor_fetch_timer.start()
168-
# fetchmany can return list[dict] or list[tuple] but we'll only use
169-
# queries that return dicts
170-
batch: list[dict[str, DbType]] = cur.fetchmany(self.batch_size) # type: ignore[assignment]
191+
batch = cur.fetchmany(self.batch_size) # type: ignore[assignment]
171192
cursor_fetch_timer.stop()
193+
return
172194

173-
while len(batch) > 0: # type: ignore
174-
transform_timer.start()
175-
data = [cls(**{k.lower(): v for k, v in row.items()}) for row in batch]
176-
transform_timer.stop()
177-
178-
yield data
179-
180-
cursor_fetch_timer.start()
181-
batch = cur.fetchmany(self.batch_size) # type: ignore[assignment]
182-
cursor_fetch_timer.stop()
183-
return
184-
# Snowflake will throw a reauth error if the pipeline has been running for several hours
185-
# but it seems to be wrapped in a ProgrammingError.
186-
# Unclear the best way to handle this, it will require a bit more trial and error
187-
except (ReauthenticationRequest, RetryRequest, ProgrammingError) as ex:
188-
logger.warning("received transient error, retrying...", exc_info=ex)
189-
if attempt == max_attempts - 1:
190-
logger.error("max attempts exceeded")
191-
raise ex
192-
self.conn = SnowflakeExtractor._connect()
193-
time.sleep(1)
194-
195-
finally:
196-
if cur:
197-
cur.close()
195+
finally:
196+
if cur:
197+
cur.close()

apps/bfd-pipeline/bfd-pipeline-idr/loader.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import os
33
from collections.abc import Iterator
4-
from datetime import UTC, datetime
4+
from datetime import UTC, date, datetime
55

66
import psycopg
77

@@ -79,6 +79,7 @@ def load(
7979
)
8080
data_loaded = False
8181
num_rows = 0
82+
8283
# load each batch in a separate transaction
8384
for results in fetch_results:
8485
data_loaded = True
@@ -97,7 +98,7 @@ def load(
9798
cur.execute(
9899
f"CREATE TEMPORARY TABLE {temp_table} (LIKE {table}) ON COMMIT DROP" # type: ignore
99100
)
100-
immutable = model.update_timestamp_col() is None
101+
immutable = len(model.update_timestamp_col()) == 0
101102
meta_keys = (
102103
["bfd_created_ts"] if immutable else ["bfd_created_ts", "bfd_updated_ts"]
103104
)
@@ -149,11 +150,18 @@ def load(
149150
# Some tables that contain reference data (like contract info) may not have the
150151
# normal IDR timestamps.
151152
# For now we won't support incremental refreshes for those tables
152-
batch_timestamp_col = model.batch_timestamp_col(
153+
batch_timestamp_cols = model.batch_timestamp_col(
153154
progress is None or progress.is_historical()
154155
)
155-
if batch_timestamp_col:
156-
last_timestamp = last[batch_timestamp_col]
156+
update_cols = model.update_timestamp_col()
157+
if len(batch_timestamp_cols) > 0:
158+
max_timestamp = max(
159+
[
160+
_convert_date(last[col])
161+
for col in [*batch_timestamp_cols, *update_cols]
162+
if last[col] is not None
163+
]
164+
)
157165
cur.execute(
158166
"""
159167
UPDATE idr.load_progress
@@ -162,7 +170,7 @@ def load(
162170
""",
163171
{
164172
"table": table,
165-
"last_ts": last_timestamp,
173+
"last_ts": max_timestamp,
166174
},
167175
)
168176
commit_timer.start()
@@ -180,3 +188,9 @@ def load(
180188
self.conn.commit()
181189
logger.info("loaded %s rows", num_rows)
182190
return data_loaded
191+
192+
193+
def _convert_date(date_field: date | datetime) -> datetime:
194+
if type(date_field) is datetime:
195+
return date_field.replace(tzinfo=UTC)
196+
return datetime.combine(date_field, datetime.min.time()).replace(tzinfo=UTC)

0 commit comments

Comments
 (0)