blob: 3f830a0379e97df9d396f9e06a65923b1dfc9b29 [file]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import pg8000.dbapi
from extractor.config import AppConfig
from extractor.replica_lag_check import ensure_replica_safe
from extractor.watermark_manager import WatermarkManager
logger = logging.getLogger(__name__)
def as_utc_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
@dataclass(frozen=True)
class TableSpec:
source_table: str
raw_table: str
columns: tuple[str, ...]
primary_key: str
cursor_column: str = "last_modified_on_utc"
TABLE_SPECS: tuple[TableSpec, ...] = (
TableSpec(
source_table="m_office",
raw_table="raw_m_office",
primary_key="id",
columns=(
"id",
"parent_id",
"hierarchy",
"external_id",
"name",
"opening_date",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_currency",
raw_table="raw_m_currency",
primary_key="id",
columns=(
"id",
"code",
"decimal_places",
"currency_multiplesof",
"display_symbol",
"name",
"internationalized_name_code",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_client",
raw_table="raw_m_client",
primary_key="id",
columns=(
"id",
"account_no",
"external_id",
"status_enum",
"activation_date",
"office_joining_date",
"office_id",
"staff_id",
"gender_cv_id",
"date_of_birth",
"legal_form_enum",
"submittedon_date",
"updated_on",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_product_loan",
raw_table="raw_m_product_loan",
primary_key="id",
columns=(
"id",
"short_name",
"currency_code",
"currency_digits",
"currency_multiplesof",
"principal_amount",
"min_principal_amount",
"max_principal_amount",
"arrearstolerance_amount",
"name",
"description",
"nominal_interest_rate_per_period",
"annual_nominal_interest_rate",
"repay_every",
"repayment_period_frequency_enum",
"number_of_repayments",
"overdue_days_for_npa",
"start_date",
"close_date",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_loan",
raw_table="raw_m_loan",
primary_key="id",
columns=(
"id",
"account_no",
"external_id",
"client_id",
"product_id",
"loan_status_id",
"loan_type_enum",
"currency_code",
"currency_digits",
"currency_multiplesof",
"principal_amount_proposed",
"principal_amount",
"approved_principal",
"net_disbursal_amount",
"annual_nominal_interest_rate",
"nominal_interest_rate_per_period",
"interest_method_enum",
"interest_calculated_in_period_enum",
"term_frequency",
"term_period_frequency_enum",
"repay_every",
"repayment_period_frequency_enum",
"number_of_repayments",
"amortization_method_enum",
"submittedon_date",
"approvedon_date",
"expected_disbursedon_date",
"expected_firstrepaymenton_date",
"disbursedon_date",
"expected_maturedon_date",
"maturedon_date",
"principal_disbursed_derived",
"principal_repaid_derived",
"principal_writtenoff_derived",
"principal_outstanding_derived",
"interest_charged_derived",
"interest_repaid_derived",
"interest_writtenoff_derived",
"interest_outstanding_derived",
"fee_charges_outstanding_derived",
"penalty_charges_outstanding_derived",
"total_expected_repayment_derived",
"total_repayment_derived",
"total_writtenoff_derived",
"total_outstanding_derived",
"loan_counter",
"is_npa",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_loan_transaction",
raw_table="raw_m_loan_transaction",
primary_key="id",
columns=(
"id",
"loan_id",
"office_id",
"is_reversed",
"transaction_type_enum",
"transaction_date",
"amount",
"principal_portion_derived",
"interest_portion_derived",
"fee_charges_portion_derived",
"penalty_charges_portion_derived",
"outstanding_loan_balance_derived",
"submitted_on_date",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_delinquency_range",
raw_table="raw_m_delinquency_range",
primary_key="id",
columns=(
"id",
"classification",
"min_age_days",
"max_age_days",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_delinquency_bucket",
raw_table="raw_m_delinquency_bucket",
primary_key="id",
columns=(
"id",
"name",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_delinquency_bucket_mappings",
raw_table="raw_m_delinquency_bucket_mappings",
primary_key="id",
columns=(
"id",
"delinquency_range_id",
"delinquency_bucket_id",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="m_loan_delinquency_tag_history",
raw_table="raw_m_loan_delinquency_tag_history",
primary_key="id",
columns=(
"id",
"delinquency_range_id",
"loan_id",
"addedon_date",
"liftedon_date",
"created_on_utc",
"last_modified_on_utc",
),
),
TableSpec(
source_table="batch_job_execution",
raw_table="raw_batch_job_execution",
primary_key="job_execution_id",
columns=(
"job_execution_id",
"status",
"start_time",
"end_time",
"exit_code",
"exit_message",
"created_on_utc",
"last_modified_on_utc",
),
),
)
class FineractExtractor:
def __init__(self, config: AppConfig) -> None:
self.config = config
def _source_conn(self):
return pg8000.dbapi.connect(**self.config.source.connect_kwargs)
def _warehouse_conn(self):
return pg8000.dbapi.connect(**self.config.warehouse.connect_kwargs)
def run(self, mode: str) -> dict[str, int | str]:
if mode not in {"backfill", "incremental"}:
raise ValueError(f"Unsupported mode: {mode}")
run_id = uuid.uuid4()
rows_extracted = 0
rows_loaded = 0
source_conn = self._source_conn()
warehouse_conn = self._warehouse_conn()
try:
source_conn.autocommit = True
warehouse_conn.autocommit = False
logger.info("Starting %s extraction for tenant '%s'.", mode, self.config.tenant_id)
self._insert_pipeline_state(warehouse_conn, run_id, mode, "running")
watermark_manager = WatermarkManager(warehouse_conn, self.config.tenant_id)
try:
if mode == "backfill":
self._reset_backfill_state(warehouse_conn, watermark_manager)
lag_seconds = ensure_replica_safe(source_conn, self.config.replica_lag_threshold_seconds)
logger.info("Replica safety check passed with lag=%ss.", lag_seconds)
self._ensure_cob_completed(source_conn)
for spec in TABLE_SPECS:
logger.info("Extracting source table '%s' into raw.%s.", spec.source_table, spec.raw_table)
extracted_rows = self._extract_table(source_conn, warehouse_conn, watermark_manager, spec, mode)
rows_extracted += extracted_rows
rows_loaded += extracted_rows
logger.info(
"Completed table '%s': rows_extracted=%s rows_loaded=%s.",
spec.source_table,
extracted_rows,
extracted_rows,
)
self._update_pipeline_state(
warehouse_conn,
run_id,
status="success",
rows_extracted=rows_extracted,
rows_loaded=rows_loaded,
)
warehouse_conn.commit()
logger.info(
"Extraction run %s completed successfully: rows_extracted=%s rows_loaded=%s.",
run_id,
rows_extracted,
rows_loaded,
)
return {
"run_id": str(run_id),
"status": "success",
"rows_extracted": rows_extracted,
"rows_loaded": rows_loaded,
"replica_lag_seconds": lag_seconds,
}
except Exception as exc:
warehouse_conn.rollback()
logger.exception("Extraction run %s failed.", run_id)
self._update_pipeline_state(
warehouse_conn,
run_id,
status="failed",
rows_extracted=rows_extracted,
rows_loaded=rows_loaded,
error_message=str(exc),
)
warehouse_conn.commit()
raise
finally:
source_conn.close()
warehouse_conn.close()
def _reset_backfill_state(self, warehouse_conn, watermark_manager: WatermarkManager) -> None:
cursor = warehouse_conn.cursor()
for spec in TABLE_SPECS:
cursor.execute(
f"DELETE FROM raw.{_quote_identifier(spec.raw_table)} WHERE tenant_id = %s",
(self.config.tenant_id,),
)
cursor.execute(
"""
DELETE FROM meta.watermarks
WHERE tenant_id = %s
""",
(self.config.tenant_id,),
)
logger.info("Backfill reset completed for raw layer and watermarks.")
def _insert_pipeline_state(self, warehouse_conn, run_id: uuid.UUID, mode: str, status: str) -> None:
cursor = warehouse_conn.cursor()
cursor.execute(
"""
INSERT INTO meta.pipeline_state (
run_id,
tenant_id,
run_mode,
started_at,
status,
rows_extracted,
rows_loaded
)
VALUES (%s, %s, %s, %s, %s, 0, 0)
""",
(str(run_id), self.config.tenant_id, mode, datetime.now(timezone.utc), status),
)
warehouse_conn.commit()
def _update_pipeline_state(
self,
warehouse_conn,
run_id: uuid.UUID,
status: str,
rows_extracted: int,
rows_loaded: int,
error_message: str | None = None,
) -> None:
cursor = warehouse_conn.cursor()
cursor.execute(
"""
UPDATE meta.pipeline_state
SET completed_at = %s,
status = %s,
rows_extracted = %s,
rows_loaded = %s,
error_message = %s
WHERE run_id = %s
""",
(
datetime.now(timezone.utc),
status,
rows_extracted,
rows_loaded,
error_message,
str(run_id),
),
)
def _ensure_cob_completed(self, source_conn) -> None:
schema = _quote_identifier(self.config.source.schema)
cursor = source_conn.cursor()
cursor.execute(
f"""
SELECT MAX(end_time)
FROM {schema}.batch_job_execution
WHERE status = %s
""",
("COMPLETED",),
)
last_completed = as_utc_datetime(cursor.fetchone()[0])
if last_completed is None:
raise RuntimeError("No completed COB execution found in batch_job_execution.")
cutoff = datetime.now(timezone.utc) - timedelta(hours=self.config.cob_lookback_hours)
if last_completed < cutoff:
raise RuntimeError(
f"Latest COB completion {last_completed.isoformat()} is older than {self.config.cob_lookback_hours} hours."
)
logger.info("COB completion gate passed with latest completion at %s.", last_completed.isoformat())
def _extract_table(
self,
source_conn,
warehouse_conn,
watermark_manager: WatermarkManager,
spec: TableSpec,
mode: str,
) -> int:
previous_watermark = None if mode == "backfill" else watermark_manager.get(spec.source_table)
effective_watermark = previous_watermark
if effective_watermark is not None and self.config.extract_lookback_seconds > 0:
effective_watermark = effective_watermark - timedelta(seconds=self.config.extract_lookback_seconds)
logger.info(
"Applied lookback window of %ss for '%s': effective_watermark=%s.",
self.config.extract_lookback_seconds,
spec.source_table,
effective_watermark.isoformat(),
)
total_rows = 0
query, params = self._build_source_query(spec, effective_watermark)
cursor = source_conn.cursor()
cursor.execute(query, params)
while True:
batch = cursor.fetchmany(self.config.extract_batch_size)
if not batch:
break
total_rows += len(batch)
cursor_col_index = spec.columns.index(spec.cursor_column)
latest_watermark = max(row[cursor_col_index] for row in batch)
self._upsert_rows(warehouse_conn, spec, batch)
watermark_manager.update(spec.source_table, spec.cursor_column, latest_watermark)
logger.info(
"Loaded batch for '%s': batch_size=%s latest_watermark=%s.",
spec.source_table,
len(batch),
latest_watermark.isoformat() if latest_watermark else None,
)
return total_rows
def _build_source_query(self, spec: TableSpec, watermark: datetime | None) -> tuple[str, tuple]:
columns_sql = ", ".join(_quote_identifier(col) for col in spec.columns)
schema = _quote_identifier(self.config.source.schema)
table = _quote_identifier(spec.source_table)
cursor = _quote_identifier(spec.cursor_column)
pk = _quote_identifier(spec.primary_key)
base = f"SELECT {columns_sql} FROM {schema}.{table}"
order = f"ORDER BY {cursor}, {pk}"
if watermark is None:
return f"{base} {order}", ()
return f"{base} WHERE {cursor} >= %s {order}", (watermark,)
def _upsert_rows(self, warehouse_conn, spec: TableSpec, rows: list[tuple]) -> None:
raw_columns = ("tenant_id", *spec.columns)
insert_columns_sql = ", ".join(_quote_identifier(column) for column in raw_columns)
update_columns = [
column
for column in raw_columns
if column not in {"tenant_id", spec.primary_key}
]
update_assignments_sql = ", ".join(
f"{_quote_identifier(column)} = EXCLUDED.{_quote_identifier(column)}"
for column in update_columns
)
raw_table = _quote_identifier(spec.raw_table)
pk = _quote_identifier(spec.primary_key)
placeholders = []
values = []
for row in rows:
row_values = (self.config.tenant_id, *row)
ph = "(" + ", ".join("%s" for _ in row_values) + ")"
placeholders.append(ph)
values.extend(row_values)
values_sql = ", ".join(placeholders)
upsert_query = f"""
INSERT INTO raw.{raw_table} ({insert_columns_sql})
VALUES {values_sql}
ON CONFLICT ({_quote_identifier("tenant_id")}, {pk})
DO UPDATE SET {update_assignments_sql}
"""
cursor = warehouse_conn.cursor()
cursor.execute(upsert_query, values)