Skip to content

Commit 9c22d1e

Browse files
BFD-4167: Initial IDR pipeline terraservice and fixes for prod data (#2771)
Co-authored-by: Mitch Alessio <mitch.alessio@forpeople.us>
1 parent b0261e9 commit 9c22d1e

29 files changed

Lines changed: 971 additions & 293 deletions

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ CREATE TABLE idr.beneficiary(
3535
);
3636

3737
CREATE INDEX ON idr.beneficiary(bene_mbi_id);
38+
CREATE INDEX ON idr.beneficiary(bene_xref_efctv_sk_computed);
3839

3940
CREATE TABLE idr.beneficiary_mbi_id (
4041
bene_mbi_id VARCHAR(11) NOT NULL,
@@ -134,10 +135,10 @@ CREATE TABLE idr.beneficiary_xref (
134135
bene_kill_cred_cd VARCHAR(1) NOT NULL,
135136
idr_insrt_ts TIMESTAMPTZ NOT NULL,
136137
idr_updt_ts TIMESTAMPTZ NOT NULL,
137-
src_rec_ctre_ts TIMESTAMPTZ NOT NULL,
138+
src_rec_crte_ts TIMESTAMPTZ NOT NULL,
138139
bfd_created_ts TIMESTAMPTZ NOT NULL,
139140
bfd_updated_ts TIMESTAMPTZ NOT NULL,
140-
PRIMARY KEY(bene_sk, bene_hicn_num, src_rec_ctre_ts)
141+
PRIMARY KEY(bene_sk, bene_hicn_num, src_rec_crte_ts)
141142
);
142143

143144
CREATE TABLE idr.contract_pbp_number (
@@ -152,7 +153,8 @@ CREATE TABLE idr.load_progress(
152153
id INT GENERATED ALWAYS AS IDENTITY,
153154
table_name TEXT NOT NULL UNIQUE,
154155
last_ts TIMESTAMPTZ NOT NULL,
155-
batch_completion_ts TIMESTAMPTZ NOT NULL
156+
batch_start_ts TIMESTAMPTZ NOT NULL,
157+
batch_complete_ts TIMESTAMPTZ NOT NULL
156158
);
157159

158160
CREATE TABLE idr.claim (
@@ -191,7 +193,9 @@ CREATE TABLE idr.claim (
191193
clm_rndrg_prvdr_npi_num VARCHAR(10) NOT NULL,
192194
clm_rndrg_prvdr_last_name VARCHAR(60) NOT NULL,
193195
prvdr_blg_prvdr_npi_num VARCHAR(10) NOT NULL,
196+
prvdr_rfrg_prvdr_npi_num VARCHAR(10) NOT NULL,
194197
clm_disp_cd VARCHAR(2) NOT NULL,
198+
clm_ric_cd VARCHAR(1) NOT NULL,
195199
clm_sbmt_chrg_amt NUMERIC NOT NULL,
196200
clm_blood_pt_frnsh_qty INT NOT NULL,
197201
clm_nch_prmry_pyr_cd VARCHAR(1) NOT NULL,
@@ -367,3 +371,32 @@ HAVING COUNT(DISTINCT bene_xref_efctv_sk) > 1;
367371

368372
-- required to refresh view with CONCURRENTLY
369373
CREATE UNIQUE INDEX ON idr.overshare_mbis (bene_mbi_id);
374+
375+
CREATE OR REPLACE FUNCTION idr.refresh_overshare_mbis()
376+
RETURNS VOID AS $$
377+
DECLARE comment_sql TEXT;
378+
BEGIN
379+
-- Using "concurrently" will make the refresh slower, but it will not block any reads
380+
-- on the view while the refresh is in progress
381+
REFRESH MATERIALIZED VIEW CONCURRENTLY idr.overshare_mbis;
382+
-- There's no implicit way to know when a materialized view was last updated
383+
-- add a comment on the object in case we need to verify that it's being updated as expected
384+
comment_sql := 'COMMENT ON MATERIALIZED VIEW idr.overshare_mbis is '
385+
|| quote_literal('{"last_refreshed": "' || now() || '"}');
386+
EXECUTE comment_sql;
387+
END;
388+
$$
389+
LANGUAGE plpgsql
390+
391+
-- Only the owner of the view may refresh it, we need to set "security definer" so the function
392+
-- can execute in the context of the creator
393+
SECURITY DEFINER;
394+
-- search_path is the order in which schemas are searched when a name is referenced with no schema specified
395+
-- Postgres recommends setting this on functions marked as "security definer" to prevent malicious users from
396+
-- creating an object that shadows an existing one on a globally writable schema
397+
ALTER FUNCTION idr.refresh_overshare_mbis() SET search_path = idr;
398+
-- Execute privilege is granted to PUBLIC by default
399+
REVOKE ALL ON FUNCTION idr.refresh_overshare_mbis() FROM PUBLIC;
400+
401+
-- This only needs to be executed by the pipeline
402+
-- GRANT EXECUTE ON FUNCTION idr.refresh_overshare_mbis() TO api_pipeline_svcs;

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

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import logging
12
import os
3+
import time
24
from abc import ABC, abstractmethod
35
from collections.abc import Iterator, Mapping
46
from datetime import date, datetime
57

68
import psycopg
79
import snowflake.connector
10+
from cryptography.hazmat.backends import default_backend
11+
from cryptography.hazmat.primitives import serialization
812
from psycopg.rows import class_row
9-
from snowflake.connector import DictCursor
13+
from snowflake.connector import DictCursor, ProgrammingError, SnowflakeConnection
14+
from snowflake.connector.network import ReauthenticationRequest, RetryRequest
1015

11-
from constants import DEFAULT_MAX_DATE
1216
from model import LoadProgress, T
1317
from timer import Timer
1418

@@ -17,6 +21,8 @@
1721
cursor_fetch_timer = Timer("cursor_fetch")
1822
transform_timer = Timer("transform")
1923

24+
logger = logging.getLogger(__name__)
25+
2026
type DbType = str | float | int | bool | date | datetime
2127

2228

@@ -39,17 +45,21 @@ class Extractor(ABC):
3945
def extract_many(self, cls: type[T], sql: str, params: dict[str, DbType]) -> Iterator[list[T]]:
4046
pass
4147

42-
def get_query(self, cls: type[T], is_historical: bool) -> str:
43-
query = cls.fetch_query(is_historical)
48+
def get_query(self, cls: type[T], is_historical: bool, start_time: datetime) -> str:
49+
query = cls.fetch_query(is_historical, start_time)
4450
columns = ",".join(cls.column_aliases())
4551
columns_raw = ",".join(cls.columns_raw())
4652
return query.replace("{COLUMNS}", columns).replace("{COLUMNS_NO_ALIAS}", columns_raw)
4753

48-
def extract_idr_data(self, cls: type[T], progress: LoadProgress | None) -> Iterator[list[T]]:
54+
def extract_idr_data(
55+
self, cls: type[T], progress: LoadProgress | None, start_time: datetime
56+
) -> Iterator[list[T]]:
4957
is_historical = progress is None or progress.is_historical()
50-
fetch_query = self.get_query(cls, is_historical)
58+
fetch_query = self.get_query(cls, is_historical, start_time)
5159
batch_timestamp_col = cls.batch_timestamp_col_alias(is_historical)
5260
update_timestamp_col = cls.update_timestamp_col_alias()
61+
62+
logger.info("extracting %s", cls.table())
5363
if progress is None:
5464
idr_query_timer.start()
5565
# No saved progress, process the whole table from the beginning
@@ -64,12 +74,16 @@ def extract_idr_data(self, cls: type[T], progress: LoadProgress | None) -> Itera
6474
idr_query_timer.stop()
6575
return res
6676

67-
previous_batch_complete = progress.batch_completion_ts != DEFAULT_MAX_DATE
68-
op = ">" if previous_batch_complete else ">="
77+
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+
6982
idr_query_timer.start()
70-
# Saved progress found, start processing from where we left
83+
# Saved progress found, start processing from where we left off
7184
update_clause = (
72-
f"OR {update_timestamp_col} IS NOT NULL AND {update_timestamp_col} {op} %(timestamp)s"
85+
f"""AND ({update_timestamp_col} IS NULL
86+
OR {update_timestamp_col} >= %(timestamp)s)"""
7387
if update_timestamp_col is not None
7488
else ""
7589
)
@@ -78,13 +92,15 @@ def extract_idr_data(self, cls: type[T], progress: LoadProgress | None) -> Itera
7892
fetch_query.replace(
7993
"{WHERE_CLAUSE}",
8094
f"""
81-
WHERE
82-
({update_timestamp_col} IS NOT NULL
83-
AND {batch_timestamp_col} {op} %(timestamp)s {update_clause})
84-
AND {batch_timestamp_col} {op} '{get_min_transaction_date()}'
95+
WHERE
96+
(
97+
{batch_timestamp_col} >= %(timestamp)s
98+
{update_clause}
99+
)
100+
AND {batch_timestamp_col} >= '{get_min_transaction_date()}'
85101
""",
86102
).replace("{ORDER_BY}", f"ORDER BY {batch_timestamp_col}"),
87-
{"timestamp": progress.last_ts},
103+
{"timestamp": compare_timestamp},
88104
)
89105
idr_query_timer.stop()
90106
return res
@@ -115,40 +131,67 @@ def extract_single(self, cls: type[T], sql: str, params: dict[str, DbType]) -> T
115131
class SnowflakeExtractor(Extractor):
116132
def __init__(self, batch_size: int) -> None:
117133
super().__init__()
118-
self.conn = snowflake.connector.connect( # type: ignore
134+
135+
self.conn = SnowflakeExtractor._connect()
136+
self.batch_size = batch_size
137+
138+
@staticmethod
139+
def _connect() -> SnowflakeConnection:
140+
private_key = serialization.load_pem_private_key(
141+
os.environ["IDR_PRIVATE_KEY"].encode(), password=None, backend=default_backend()
142+
)
143+
private_key_bytes = private_key.private_bytes(
144+
encoding=serialization.Encoding.DER,
145+
format=serialization.PrivateFormat.PKCS8,
146+
encryption_algorithm=serialization.NoEncryption(),
147+
)
148+
return snowflake.connector.connect( # type: ignore
119149
user=os.environ["IDR_USERNAME"],
120-
password=os.environ["IDR_PASSWORD"],
150+
private_key=private_key_bytes,
121151
account=os.environ["IDR_ACCOUNT"],
122152
warehouse=os.environ["IDR_WAREHOUSE"],
123153
database=os.environ["IDR_DATABASE"],
124154
schema=os.environ["IDR_SCHEMA"],
125155
)
126-
self.batch_size = batch_size
127156

128157
def extract_many(self, cls: type[T], sql: str, params: dict[str, DbType]) -> Iterator[list[T]]:
129158
cur = None
130-
try:
131-
cursor_execute_timer.start()
132-
cur = self.conn.cursor(DictCursor)
133-
cur.execute(sql, params)
134-
cursor_execute_timer.stop()
135-
136-
cursor_fetch_timer.start()
137-
# fetchmany can return list[dict] or list[tuple] but we'll only use
138-
# queries that return dicts
139-
batch: list[dict[str, DbType]] = cur.fetchmany(self.batch_size) # type: ignore[assignment]
140-
cursor_fetch_timer.stop()
141-
142-
while len(batch) > 0: # type: ignore
143-
transform_timer.start()
144-
data = [cls(**{k.lower(): v for k, v in row.items()}) for row in batch]
145-
transform_timer.stop()
146-
147-
yield data
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()
148166

149167
cursor_fetch_timer.start()
150-
batch = cur.fetchmany(self.batch_size) # type: ignore[assignment]
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]
151171
cursor_fetch_timer.stop()
152-
finally:
153-
if cur:
154-
cur.close()
172+
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()
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
#!/usr/bin/env bash
22

3-
IDR_USERNAME="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_username --with-decryption --query "Parameter.Value" --output text)"
3+
IDR_USERNAME="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_username --with-decryption --query "Parameter.Value" --output text)"
44
export IDR_USERNAME
5-
IDR_PASSWORD="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_password --with-decryption --query "Parameter.Value" --output text)"
6-
export IDR_PASSWORD
7-
IDR_ACCOUNT="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_account --with-decryption --query "Parameter.Value" --output text)"
5+
IDR_PRIVATE_KEY="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_private_key --with-decryption --query "Parameter.Value" --output text)"
6+
export IDR_PRIVATE_KEY
7+
IDR_ACCOUNT="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_account --with-decryption --query "Parameter.Value" --output text)"
88
export IDR_ACCOUNT
9-
IDR_WAREHOUSE="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_warehouse --with-decryption --query "Parameter.Value" --output text)"
9+
IDR_WAREHOUSE="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_warehouse --with-decryption --query "Parameter.Value" --output text)"
1010
export IDR_WAREHOUSE
11-
IDR_DATABASE="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_database --with-decryption --query "Parameter.Value" --output text)"
11+
IDR_DATABASE="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_database --with-decryption --query "Parameter.Value" --output text)"
1212
export IDR_DATABASE
13-
IDR_SCHEMA="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/idr_schema --with-decryption --query "Parameter.Value" --output text)"
13+
IDR_SCHEMA="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/idr_schema --with-decryption --query "Parameter.Value" --output text)"
1414
export IDR_SCHEMA
1515

16-
BFD_DB_USERNAME="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/db/username --with-decryption --query "Parameter.Value" --output text)"
16+
BFD_DB_USERNAME="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/db/username --with-decryption --query "Parameter.Value" --output text)"
1717
export BFD_DB_USERNAME
18-
BFD_DB_PASSWORD="$(aws ssm get-parameter --name /bfd/3913-prod/pipeline/sensitive/db/password --with-decryption --query "Parameter.Value" --output text)"
18+
BFD_DB_PASSWORD="$(aws ssm get-parameter --name /bfd/${BFD_ENV}/idr-pipeline/sensitive/db/password --with-decryption --query "Parameter.Value" --output text)"
1919
export BFD_DB_PASSWORD
2020

21-
db_cluster="$(aws ssm get-parameter --name /bfd/3913-prod/common/nonsensitive/rds_cluster_identifier --with-decryption --query "Parameter.Value" --output text)"
21+
db_cluster="bfd-${BFD_ENV}-aurora-cluster"
2222
BFD_DB_ENDPOINT="$(aws rds describe-db-clusters --db-cluster-identifier $db_cluster --query "DBClusters[0].Endpoint" --output text)"
2323
export BFD_DB_ENDPOINT

0 commit comments

Comments
 (0)