limitsdb 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,810 @@
1
+ from concurrent.futures import Future, ProcessPoolExecutor, as_completed
2
+ from operator import attrgetter
3
+ from typing import Any, Literal, TypedDict, cast
4
+
5
+ from limitsdb.core.ldb_errors import ConfigurationError
6
+ from limitsdb.core.ldb_ilm_config import IlmRule, load_rows_from_yaml
7
+ from limitsdb.core.ldb_logger import configure_logger, get_logger, reconfigure_logger
8
+ from limitsdb.core.ldb_params_config import Config
9
+ from limitsdb.core.ldb_status import Status
10
+ from limitsdb.core.ldb_utils import get_effective_credentials, max_ignore_none, nvl
11
+ from limitsdb.db.ldb_engine_loader import get_db_engine
12
+ from limitsdb.db.ldb_engines import ColumnDefinition, DatabaseEngine, TableDefinition
13
+
14
+ logger = get_logger("runner")
15
+
16
+
17
+ class _OtherColumn(TypedDict):
18
+ name: str
19
+ expr: str
20
+ metadata: ColumnDefinition
21
+
22
+
23
+ class ProcessedTableConfig(TypedDict, total=False):
24
+ """Mutable derived state for one table during planning and execution."""
25
+
26
+ conds: list[IlmRule]
27
+ referencing_tables: list[tuple[str, str]]
28
+ table_columns: list[str]
29
+ columns_metadata: dict[str, ColumnDefinition]
30
+ other_columns: list[_OtherColumn]
31
+ months_keep_history_max: int | None
32
+ skip: bool
33
+ query_expr: str
34
+ sql_block: str
35
+ metadata: Any
36
+
37
+
38
+ TablesConfig = dict[tuple[str, str], ProcessedTableConfig]
39
+
40
+
41
+ WorkerResult = tuple[str, str, str, int, int, int | None, str | None]
42
+
43
+
44
+ def _exception_details(exc: Exception) -> tuple[int | None, str]:
45
+ """Extract stable error details without assuming an Oracle exception shape."""
46
+ detail: Any = exc.args[0] if len(exc.args) == 1 else exc
47
+ code = getattr(detail, "code", None)
48
+ message = getattr(detail, "message", None)
49
+ if not isinstance(code, int):
50
+ code = None
51
+ if not isinstance(message, str) or not message:
52
+ message = str(exc) or type(exc).__name__
53
+ return code, message
54
+
55
+
56
+ def _credentials_present(config: Config, *, admin: bool, env: Literal["SOURCE", "HISTORY"] | None) -> bool:
57
+ try:
58
+ get_effective_credentials(config, admin=admin, env=env)
59
+ return True
60
+ except ValueError:
61
+ return False
62
+
63
+
64
+ def _open_connection(
65
+ config: Config, engine: DatabaseEngine, *, admin: bool, env: Literal["SOURCE", "HISTORY"] | None, description: str
66
+ ) -> Any | None:
67
+ try:
68
+ user, _, dsn = get_effective_credentials(config, admin=admin, env=env)
69
+ except ValueError as exc:
70
+ logger.error("%s: %s", description, exc)
71
+ return None
72
+ conn: Any | None = None
73
+ try:
74
+ conn = engine.get_connection(config, admin=admin, env=env)
75
+ engine.get_system_date(conn)
76
+ logger.info("%s: connected to %s as %s", description, dsn, user)
77
+ return conn
78
+ except Exception:
79
+ logger.error("%s: failed to connect to %s as %s", description, dsn, user, exc_info=True)
80
+ if conn:
81
+ engine.close_connection(conn)
82
+ return None
83
+
84
+
85
+ def _load_offline_rows(config: Config) -> list[IlmRule]:
86
+ if config.context.ilm_config_file:
87
+ return load_rows_from_yaml(config.context.ilm_config_file)
88
+ schema = config.context.schema
89
+ profile = config.context.profile
90
+ name = f"ilm.{profile}.yml" if profile else "ilm.yml"
91
+ raise ConfigurationError(
92
+ f"ILM configuration file '{name}' not found for schema '{schema}'. Provide the file or set LDB_ILM_CONFIG_FILE."
93
+ )
94
+
95
+
96
+ def _build_dependency_graph(rows: list[IlmRule]) -> dict[tuple[str, str], set[tuple[str, str]]]:
97
+ graph: dict[tuple[str, str], set[tuple[str, str]]] = {}
98
+ for row in rows:
99
+ key = (row["source_owner"], row["table_name"])
100
+ graph.setdefault(key, set())
101
+ for row in rows:
102
+ key = (row["source_owner"], row["table_name"])
103
+ refs = str(row.get("referencing_tables") or "").split(",")
104
+ for raw_ref in refs:
105
+ ref = raw_ref.strip()
106
+ if not ref:
107
+ continue
108
+ if "." in ref:
109
+ owner_part, remainder = ref.split(".", 1)
110
+ else:
111
+ owner_part, remainder = row["source_owner"], ref
112
+ parts = remainder.strip().split(" ", 1)
113
+ if len(parts) != 2:
114
+ raise ValueError(f"Invalid referencing_tables entry '{ref}' for {owner_part}")
115
+ table_part = parts[0]
116
+ ref_key = (owner_part.strip(), table_part.strip())
117
+ if ref_key not in graph:
118
+ raise ValueError(f"Referenced table {owner_part}.{table_part} not present in ILM configuration")
119
+ graph[ref_key].add(key)
120
+ return graph
121
+
122
+
123
+ def _compute_plan_layers(graph: dict[tuple[str, str], set[tuple[str, str]]]) -> list[list[tuple[str, str]]]:
124
+ remaining = set(graph.keys())
125
+ layers: list[list[tuple[str, str]]] = []
126
+ while remaining:
127
+ ready = sorted([key for key in remaining if not graph[key].intersection(remaining)])
128
+ if not ready:
129
+ cycle = ", ".join(f"{owner}.{table}" for owner, table in sorted(remaining))
130
+ raise ValueError(f"Cyclic dependency detected among tables: {cycle}")
131
+ layers.append(ready)
132
+ for key in ready:
133
+ remaining.remove(key)
134
+ return layers
135
+
136
+
137
+ def _collect_privilege_targets(
138
+ tables_conf_rows: list[IlmRule], owner_key: Literal["source_owner", "history_owner"], engine: DatabaseEngine
139
+ ) -> list[tuple[str, str]]:
140
+ tables: set[tuple[str, str]] = set(
141
+ [
142
+ (engine.get_identifier_str(r[owner_key]), engine.get_identifier_str(r["table_name"]))
143
+ for r in tables_conf_rows
144
+ ]
145
+ )
146
+ return sorted(tables)
147
+
148
+
149
+ def _runtime_owner(config: Config, rule: IlmRule) -> str:
150
+ """Return the schema whose rows are processed for the selected ILM action."""
151
+ return rule["source_owner"] if config.execution.action == "SOURCE_ILM" else rule["history_owner"]
152
+
153
+
154
+ def _runtime_join_expression(config: Config, rule: IlmRule) -> str:
155
+ """Render source joins, avoiding them when history predicates use stored derived columns."""
156
+ join_expr = rule["join_expr"]
157
+ if not join_expr or (config.execution.action == "HISTORY_ILM" and config.execution.use_added_columns):
158
+ return ""
159
+ join_type = "left outer" if rule["source_orphan_purge"] == "Y" else "inner"
160
+ return join_expr.replace("@", join_type)
161
+
162
+
163
+ def _orphan_predicate(rule: IlmRule) -> str:
164
+ """Return the predicate that identifies a missing referenced row."""
165
+ if rule["source_orphan_purge"] != "Y":
166
+ return ""
167
+ columns = [column.strip() for column in (rule["orphan_check_column"] or "").split(",") if column.strip()]
168
+ return " or ".join(f"{column} is null" for column in columns)
169
+
170
+
171
+ def _validate_orphan_configuration(config: Config, rule: IlmRule, key: tuple[str, str]) -> None:
172
+ """Validate the relationship data needed to archive orphan source rows."""
173
+ if rule["source_orphan_purge"] != "Y":
174
+ if rule["orphan_check_column"]:
175
+ raise ValueError(f"source_orphan_purge must be enabled when orphan_check_column is set for {key}")
176
+ return
177
+ missing = [
178
+ name
179
+ for name, value in (
180
+ ("referencing_tables", rule["referencing_tables"]),
181
+ ("join_expr", rule["join_expr"]),
182
+ ("orphan_check_column", rule["orphan_check_column"]),
183
+ )
184
+ if not value
185
+ ]
186
+ if missing:
187
+ raise ValueError(f"{', '.join(missing)} required when source_orphan_purge is enabled for {key}")
188
+ if not config.execution.use_added_columns:
189
+ raise ValueError(f"use_added_columns must be enabled when source_orphan_purge is enabled for {key}")
190
+
191
+
192
+ def _relationship_date_column(alias: str) -> str:
193
+ """Return the stable unquoted name used to persist a related table's retention date."""
194
+ return f"LDB_DATE_{alias.upper()}"
195
+
196
+
197
+ _ORPHAN_MARKER_COLUMN = "LDB_IS_ORPHAN"
198
+
199
+
200
+ def _plan_mode(config: Config) -> int:
201
+ rows = _load_offline_rows(config)
202
+ if not rows:
203
+ logger.info("No active ILM tables found for schema %s.", config.context.schema)
204
+ return 0
205
+ graph = _build_dependency_graph(rows)
206
+ layers = _compute_plan_layers(graph)
207
+ logger.info("ILM execution plan (PLAN mode):")
208
+ for idx, layer in enumerate(layers, start=1):
209
+ tables = ", ".join(f"{owner}.{table}" for owner, table in layer)
210
+ logger.info(" Stage %d (parallel=%d): %s", idx, len(layer), tables)
211
+ logger.info("Plan summary: %d stage(s), %d table(s).", len(layers), len(graph))
212
+ return 0
213
+
214
+
215
+ def _validate_environment(config: Config, engine: DatabaseEngine) -> int:
216
+ logger.info("VALIDATE mode: checking configuration and database connectivity.")
217
+ ok = True
218
+ connections: list[Any] = []
219
+ primary_conn: Any | None = None
220
+ required_specs: list[tuple[str, bool, Literal["SOURCE", "HISTORY"] | None]] = []
221
+ optional_specs: list[tuple[str, bool, Literal["SOURCE", "HISTORY"] | None]] = []
222
+ primary_env = "SOURCE" if config.execution.action == "SOURCE_ILM" else "HISTORY"
223
+ required_specs.append((f"{primary_env} runtime", False, None))
224
+ required_specs.append((f"{primary_env} admin", True, None))
225
+ if config.execution.action == "SOURCE_ILM":
226
+ required_specs.append(("HISTORY admin", True, "HISTORY"))
227
+ optional_specs.append(("HISTORY runtime", False, "HISTORY"))
228
+ else:
229
+ optional_specs.append(("SOURCE runtime", False, "SOURCE"))
230
+ optional_specs.append(("SOURCE admin", True, "SOURCE"))
231
+ for description, admin, env in required_specs:
232
+ desc_label = description.upper()
233
+ conn = _open_connection(config, engine, admin=admin, env=env, description=desc_label)
234
+ if conn is None:
235
+ ok = False
236
+ else:
237
+ connections.append(conn)
238
+ if description == f"{primary_env} runtime":
239
+ primary_conn = conn
240
+ for description, admin, env in optional_specs:
241
+ if not _credentials_present(config, admin=admin, env=env):
242
+ logger.info("%s: credentials not provided; skipping.", description.upper())
243
+ continue
244
+ conn = _open_connection(config, engine, admin=admin, env=env, description=description.upper())
245
+ if conn:
246
+ connections.append(conn)
247
+ try:
248
+ if primary_conn:
249
+ process_date = engine.get_system_date(primary_conn).strftime("%Y%m%d")
250
+ try:
251
+ tables_config = process_tables_cnf(primary_conn, config, engine, process_date)
252
+ logger.info("Configuration ready: %d table(s) evaluated.", len(tables_config))
253
+ _log_where_predicates(tables_config)
254
+ except Exception:
255
+ logger.error("Failed to process ILM configuration.", exc_info=True)
256
+ ok = False
257
+ else:
258
+ logger.error("Primary connection unavailable; skipping configuration validation.")
259
+ finally:
260
+ for conn in connections:
261
+ engine.close_connection(conn)
262
+ return 0 if ok else 1
263
+
264
+
265
+ def _clone_column_definition(name: str, template: ColumnDefinition) -> ColumnDefinition:
266
+ return ColumnDefinition(
267
+ name=name,
268
+ data_type=template.data_type,
269
+ length=template.length,
270
+ precision=template.precision,
271
+ scale=template.scale,
272
+ nullable=template.nullable,
273
+ default=template.default,
274
+ )
275
+
276
+
277
+ def _build_history_table_definition(
278
+ config: Config,
279
+ engine: DatabaseEngine,
280
+ source_connection: Any,
281
+ table_cnf: ProcessedTableConfig,
282
+ ) -> TableDefinition:
283
+ cnd0 = table_cnf["conds"][0]
284
+ history_owner, table_name = cnd0["history_owner"], cnd0["table_name"]
285
+ metadata_owner = _runtime_owner(config, cnd0)
286
+ required_columns: list[ColumnDefinition] = sorted(
287
+ table_cnf.get("columns_metadata", {}).values(), key=attrgetter("id")
288
+ )
289
+ for other_column in table_cnf.get("other_columns", []):
290
+ required_columns.append(other_column["metadata"])
291
+ pk_columns = list(engine.get_primary_key_columns(source_connection, metadata_owner, table_name, table_cnf))
292
+ if config.execution.add_ldb_columns:
293
+ if "LDB_PROCESS_DATE" not in pk_columns:
294
+ pk_columns.append("LDB_PROCESS_DATE")
295
+ primary_key: tuple[str, ...] | None = tuple(pk_columns) if pk_columns else None
296
+ return TableDefinition(
297
+ owner=history_owner,
298
+ name=table_name,
299
+ columns=tuple(required_columns),
300
+ primary_key=primary_key,
301
+ )
302
+
303
+
304
+ def _ensure_history_tables(
305
+ config: Config,
306
+ engine: DatabaseEngine,
307
+ source_admin_connection: Any,
308
+ tables_config: TablesConfig,
309
+ ) -> None:
310
+ history_admin_connection: Any | None = None
311
+ try:
312
+ history_admin_connection = engine.get_connection(config, admin=True, env="HISTORY")
313
+ for table_cnf in tables_config.values():
314
+ if table_cnf.get("skip"):
315
+ continue
316
+ table_def = _build_history_table_definition(config, engine, source_admin_connection, table_cnf)
317
+ engine.ensure_table_structure(history_admin_connection, table_def, table_cnf)
318
+ finally:
319
+ if history_admin_connection:
320
+ engine.close_connection(history_admin_connection)
321
+
322
+
323
+ def _initialize_worker_logger(log_level: str) -> None:
324
+ """Ensure background processes emit logs using the configured level."""
325
+ # ``ProcessPoolExecutor`` uses ``spawn`` on Windows, which re-imports the entry module and
326
+ # resets the logger configuration. Re-apply the desired level so worker logs reach stdout.
327
+ configure_logger(level=log_level)
328
+ reconfigure_logger(level=log_level)
329
+
330
+
331
+ def _log_where_predicates(tables_config: TablesConfig) -> None:
332
+ any_predicates = False
333
+ for table_info in tables_config.values():
334
+ query_expr = table_info.get("query_expr")
335
+ if not query_expr:
336
+ continue
337
+ if not any_predicates:
338
+ logger.info("WHERE predicates evaluated for ILM:")
339
+ any_predicates = True
340
+ logger.info("\n%s", query_expr)
341
+ if not any_predicates:
342
+ logger.info("There are no WHERE predicates calculated for ILM tables.")
343
+
344
+
345
+ def _append_unique_name(other_columns: list[_OtherColumn], other_column: _OtherColumn) -> None:
346
+ """Appends a column to the other_columns list if its name is not already present."""
347
+ if other_column["name"] not in {col["name"] for col in other_columns}:
348
+ other_columns.append(other_column)
349
+
350
+
351
+ def process_table(config: Config, owner: str, table_name: str, plsql_code: str, process_date: str) -> WorkerResult:
352
+ logger.info(f"Processing table {owner}.{table_name}...")
353
+ prev_rows_processed = 0
354
+ engine: DatabaseEngine | None = None
355
+ conn: Any = None
356
+ process_start: Any = None
357
+ try:
358
+ engine = get_db_engine(config.connections.db_engine)
359
+ conn = engine.get_connection(config)
360
+ user, _, dsn = get_effective_credentials(config, admin=False)
361
+ logger.info(f"Connected to {dsn} as {user}")
362
+ process_start = engine.get_system_date(conn)
363
+ logger.info(f"Executing ILM for {owner}.{table_name}...")
364
+ prev_rows_processed = engine.get_rows_processed(conn, owner, table_name, process_date)
365
+ engine.sql_block_run(conn, plsql_code)
366
+ rows_processed = engine.get_rows_processed(conn, owner, table_name, process_date)
367
+ return (owner, table_name, Status.TABLE_END, prev_rows_processed, rows_processed, None, None)
368
+ except Exception as exc:
369
+ sqlcode, message = _exception_details(exc)
370
+ rows_processed = prev_rows_processed
371
+ logger.error("Worker failed for %s.%s: %s", owner, table_name, message, exc_info=True)
372
+ if engine is not None and conn is not None and process_start is not None:
373
+ try:
374
+ rows_processed = engine.get_rows_processed(conn, owner, table_name, process_date)
375
+ engine.save_error_status(
376
+ conn, config, owner, table_name, process_date, process_start, message, plsql_code
377
+ )
378
+ except Exception:
379
+ logger.critical("Failed to persist recovery evidence for %s.%s", owner, table_name, exc_info=True)
380
+ return (owner, table_name, Status.ERROR, prev_rows_processed, rows_processed, sqlcode, message)
381
+ finally:
382
+ if engine is not None and conn is not None:
383
+ engine.close_connection(conn)
384
+
385
+
386
+ def generate_script_output(config: Config, tables_config: TablesConfig) -> int:
387
+ print(
388
+ f"""
389
+ whenever oserror exit 1
390
+ whenever sqlerror exit 1
391
+ set echo on ver off trimspool on
392
+ spool ldb_{config.context.schema}.log
393
+ COLUMN process_date NEW_VALUE process_date
394
+ SELECT TO_CHAR(SYSDATE, 'YYYYMMDD') process_date FROM DUAL;
395
+ """
396
+ )
397
+ while True:
398
+ cycle_printed = False
399
+ for (owner, table_name), table_info in tables_config.items():
400
+ if table_info["skip"]:
401
+ continue
402
+ cd = tables_config[(owner, table_name)]["conds"][0]
403
+ if cd["ctl_status"] in ["GENERATED"]:
404
+ continue
405
+ referencing_tables = table_info["referencing_tables"]
406
+ all_referencing_tables_ready = True
407
+ if referencing_tables:
408
+ for ref_owner, ref_table in referencing_tables:
409
+ cdr = tables_config[(ref_owner, ref_table)]["conds"][0]
410
+ if cdr["ctl_status"] != "GENERATED": # print end
411
+ all_referencing_tables_ready = False
412
+ break # referencing table not GENERATED status
413
+ if not all_referencing_tables_ready:
414
+ continue
415
+ print(f"rem table: {owner}.{table_name}")
416
+ print(table_info["sql_block"])
417
+ print("/")
418
+ cd["ctl_status"] = "GENERATED" # print end
419
+ cycle_printed = True
420
+ if not cycle_printed:
421
+ break # There are no processes to launch, so go to waiting some process to end
422
+ print("\nspool off\nexit 0\n")
423
+ return 0
424
+
425
+
426
+ def get_next_ready_table(
427
+ tables_config: TablesConfig, active_tables: set[tuple[str, str]]
428
+ ) -> tuple[str, str, ProcessedTableConfig] | None:
429
+ for (owner, table_name), table_cnf in tables_config.items():
430
+ if table_cnf["skip"]:
431
+ continue
432
+ cd = tables_config[(owner, table_name)]["conds"][0]
433
+ if cd["ctl_status"] in [Status.SKIPPED, Status.TABLE_END] or (owner, table_name) in active_tables:
434
+ continue
435
+ referencing_tables = table_cnf["referencing_tables"]
436
+ all_referencing_tables_ready = True
437
+ if referencing_tables:
438
+ for ref_owner, ref_table in referencing_tables:
439
+ cdr = tables_config[(ref_owner, ref_table)]["conds"][0]
440
+ if cdr["ctl_status"] != Status.TABLE_END:
441
+ all_referencing_tables_ready = False
442
+ break # referencing table not TEND status
443
+ if not all_referencing_tables_ready:
444
+ logger.debug(f"Table {owner}.{table_name} has to wait for referencing tables to finish...")
445
+ continue
446
+ return (owner, table_name, table_cnf) # Found a table ready to process
447
+ return None # No tables ready to process
448
+
449
+
450
+ def ldb_exec_ilm(
451
+ config: Config,
452
+ tables_config: TablesConfig,
453
+ process_date: str,
454
+ engine: DatabaseEngine,
455
+ connection: Any,
456
+ ) -> int:
457
+ processes: list[Future[WorkerResult]] = []
458
+ process_targets: dict[Future[WorkerResult], tuple[str, str]] = {}
459
+ active_tables: set[tuple[str, str]] = set()
460
+ with ProcessPoolExecutor(
461
+ max_workers=config.execution.parallel_max,
462
+ initializer=_initialize_worker_logger,
463
+ initargs=(config.execution.log_level,),
464
+ ) as executor:
465
+ process_launched = False
466
+ while True:
467
+ # While there is space in the pool, try to launch new processes
468
+ while len(processes) < config.execution.parallel_max:
469
+ cycle_launched = False
470
+ # Search for a process to launch
471
+ next_ready_table = get_next_ready_table(tables_config, active_tables)
472
+ if next_ready_table:
473
+ owner, table_name, table_info = next_ready_table
474
+ logger.info(f"Launching background process for {owner}.{table_name}...")
475
+ future = executor.submit(
476
+ process_table, config, owner, table_name, table_info["sql_block"], process_date
477
+ )
478
+ processes.append(future)
479
+ process_targets[future] = (owner, table_name)
480
+ active_tables.add((owner, table_name))
481
+ table_info["conds"][0]["ctl_status"] = Status.TABLE_START
482
+ cycle_launched = True
483
+ process_launched = True
484
+ if not cycle_launched:
485
+ break # There are no processes to launch, so go to waiting some process to end
486
+ logger.debug(f"Active processes: {len(processes)}")
487
+ if processes:
488
+ # Wait for some process to finish
489
+ for completed_future in as_completed(processes):
490
+ owner, table_name = process_targets[completed_future]
491
+ try:
492
+ owner, table_name, status, prev_rows_processed, rows_processed, sqlcode, message = (
493
+ completed_future.result()
494
+ )
495
+ except Exception as exc:
496
+ sqlcode, detail = _exception_details(exc)
497
+ status = Status.ERROR
498
+ prev_rows_processed = rows_processed = 0
499
+ message = f"Worker process failed: {detail}"
500
+ logger.error("Worker process failed for %s.%s", owner, table_name, exc_info=True)
501
+ logger.debug(f"Process for table {owner}.{table_name} completed with status {status}")
502
+ cdr = tables_config[(owner, table_name)]["conds"][0]
503
+ if status == Status.ERROR and rows_processed == prev_rows_processed:
504
+ status = Status.SKIPPED
505
+ logger.info(
506
+ f"Table {owner}.{table_name} finished with status {status} and {rows_processed - prev_rows_processed} rows processed."
507
+ )
508
+ cdr["ctl_status"] = status
509
+ if status == Status.ERROR:
510
+ logger.error(f" Error {sqlcode}: {message}")
511
+ elif status == Status.SKIPPED:
512
+ logger.error(f" Error {sqlcode}: {message}")
513
+ active_tables.remove((owner, table_name))
514
+ processes.remove(completed_future)
515
+ del process_targets[completed_future]
516
+ break
517
+ else:
518
+ break
519
+ if engine.all_status_tend(connection, tables_config, process_date):
520
+ if process_launched:
521
+ logger.info("All tables processed successfully.")
522
+ else:
523
+ logger.info("No tables to process.")
524
+ return 0
525
+ else:
526
+ logger.error("Some tables did not finish successfully.")
527
+ return 1
528
+
529
+
530
+ def process_tables_cnf(connection: Any, config: Config, engine: DatabaseEngine, process_date: str) -> TablesConfig:
531
+ logger.info("Processing table configuration...")
532
+ ldb_ctl_status_rows: list[dict[str, Any]] = []
533
+ ldb_conf_rows = _load_offline_rows(config)
534
+ if not config.execution.generate_script:
535
+ ldb_ctl_status_rows = engine.get_status(connection, process_date)
536
+ tables_config: TablesConfig = {}
537
+ # Populate tables_config with the raw rows and derive referencing tables from cnf_referencing_tables.
538
+ is_source_mode = config.execution.action == "SOURCE_ILM"
539
+ ldb_process_date_expr, ldb_insert_date_expr = engine.get_ldb_columns_expressions()
540
+ for ldb_cnf_row in ldb_conf_rows:
541
+ ldb_cnf_row["source_owner"] = engine.get_identifier_str(ldb_cnf_row["source_owner"])
542
+ ldb_cnf_row["table_name"] = engine.get_identifier_str(ldb_cnf_row["table_name"])
543
+ ldb_cnf_row["history_owner"] = engine.get_identifier_str(ldb_cnf_row["history_owner"])
544
+ key = (ldb_cnf_row["source_owner"], ldb_cnf_row["table_name"])
545
+ if key not in tables_config:
546
+ tables_config[key] = {"conds": [], "referencing_tables": []}
547
+ tables_config[key]["conds"].append(ldb_cnf_row)
548
+ for ldb_cnf_row in ldb_conf_rows:
549
+ key = (ldb_cnf_row["source_owner"], ldb_cnf_row["table_name"])
550
+ declared_references = ldb_cnf_row["referencing_tables"]
551
+ if declared_references:
552
+ for ref_table in declared_references.split(","):
553
+ ref_table = ref_table.strip()
554
+ if "." in ref_table:
555
+ ref_owner, ref_table = ref_table.split(".")
556
+ else:
557
+ ref_owner = ldb_cnf_row["source_owner"]
558
+ ref_table, alias = ref_table.split(" ")
559
+ ref_owner = engine.get_identifier_str(ref_owner)
560
+ ref_table = engine.get_identifier_str(ref_table)
561
+ ref_key = (ref_owner, ref_table)
562
+ if ref_key not in tables_config:
563
+ raise ValueError(f"Table {ref_owner}.{ref_table} not found in ldb_conf_rows")
564
+ tables_config[ref_key]["referencing_tables"].append(key)
565
+ if not config.execution.generate_script:
566
+ # Inject ctl_status values loaded from the database when resuming a run.
567
+ for ldb_ctl_status_row in ldb_ctl_status_rows:
568
+ key = (ldb_ctl_status_row["ctl_owner"], ldb_ctl_status_row["ctl_table_name"])
569
+ if key in tables_config:
570
+ for cond in tables_config[key]["conds"]:
571
+ cond["ctl_status"] = ldb_ctl_status_row["ctl_status"]
572
+ # Collect each table's active conditions along with inherited referencing-table conditions.
573
+ # Track every expression needed to build the WHERE clause for the combined predicate across all contributing conditions.
574
+ # Persist the resulting predicate in cond_expr so later stages can reuse it.
575
+ for key, table_cnf in tables_config.items():
576
+ cnd0 = table_cnf["conds"][0]
577
+ _validate_orphan_configuration(config, cnd0, key)
578
+ runtime_owner = _runtime_owner(config, cnd0)
579
+ inherited_references = cnd0["referencing_tables"]
580
+ added_conds = [("A", cond) for cond in table_cnf["conds"]]
581
+ orphan_history_date_predicates: list[str] = []
582
+ table_cnf["table_columns"], table_cnf["columns_metadata"] = engine.get_table_columns(
583
+ connection, runtime_owner, cnd0["table_name"]
584
+ )
585
+ if inherited_references:
586
+ for ref_table in inherited_references.split(","):
587
+ ref_table = ref_table.strip()
588
+ if "." in ref_table:
589
+ ref_owner, ref_table_name = ref_table.split(".")
590
+ else:
591
+ ref_owner, ref_table_name = ldb_cnf_row["source_owner"], ref_table
592
+ ref_table_name, alias = ref_table_name.split(" ")
593
+ ref_owner = engine.get_identifier_str(ref_owner)
594
+ ref_table_name = engine.get_identifier_str(ref_table_name)
595
+ if (ref_owner, ref_table_name) not in tables_config:
596
+ raise ValueError(f"Table {ref_owner}.{ref_table} not found in ldb_conf_rows")
597
+ ref_conds = tables_config[(ref_owner, ref_table_name)]["conds"]
598
+ added_conds.extend([(alias, cond) for cond in ref_conds])
599
+ # Build the combined WHERE expression from all contributing conditions.
600
+ for al, cd in added_conds:
601
+ cond_list: list[str] = []
602
+ purge_date_expr = nvl(cd["purge_date_expr"], "")
603
+ # Prefer the history-only filter during HISTORY_ILM runs; otherwise use the source filter.
604
+ additional_filter_expr = " ".join(nvl(cd["additional_filter_expr"], "").splitlines())
605
+ history_addtl_filter_expr = " ".join(
606
+ nvl(cd["history_addtl_filter_expr"], additional_filter_expr).splitlines()
607
+ )
608
+ addtl_expr = additional_filter_expr if is_source_mode else history_addtl_filter_expr
609
+ retain_months_source, retain_months_history = cd["retain_months_source"], cd["retain_months_history"]
610
+ if retain_months_source is not None and retain_months_history is not None:
611
+ retain_months_history += retain_months_source
612
+ retain_months = retain_months_source if is_source_mode else retain_months_history
613
+ if purge_date_expr and not retain_months_source:
614
+ raise ValueError(f"retain_months_source is required when cnf_purge_date_expr is set for {key}")
615
+ if retain_months_source and not purge_date_expr:
616
+ raise ValueError(f"purge_date_expr is required when cnf_retain_months_source is set for {key}")
617
+ if retain_months_history and not retain_months_source:
618
+ raise ValueError(f"retain_months_history is required when cnf_retain_months_source is set for {key}")
619
+ alias_prefix = al + "."
620
+ mod_alexp_uac = alias_prefix if is_source_mode or not config.execution.use_added_columns else "A."
621
+ if addtl_expr:
622
+ if not is_source_mode and config.execution.use_added_columns and al != "A":
623
+ related_identifiers = engine.get_identifiers_from_expression(history_addtl_filter_expr)
624
+ persisted_identifiers = {identifier: f"{identifier}_{al}" for identifier in related_identifiers}
625
+ cond_list.append(
626
+ engine.rewrite_expression_identifiers(
627
+ history_addtl_filter_expr,
628
+ persisted_identifiers,
629
+ qualifier="A",
630
+ )
631
+ )
632
+ else:
633
+ cond_list.append(addtl_expr.replace("@", mod_alexp_uac))
634
+ if purge_date_expr:
635
+ if is_source_mode or not config.execution.use_added_columns or al == "A":
636
+ cond_list.append(
637
+ engine.get_date_condition(purge_date_expr.replace("@", alias_prefix), cast(int, retain_months))
638
+ )
639
+ else:
640
+ relationship_date_predicate = engine.get_date_condition(
641
+ f"A.{_relationship_date_column(al)}", cast(int, retain_months_history)
642
+ )
643
+ cond_list.append(relationship_date_predicate)
644
+ orphan_history_date_predicates.append(relationship_date_predicate)
645
+ cd["cond_expr"] = " and ".join(cond_list)
646
+ # get the maximum cnf_retain_months_history from all added_conds
647
+ table_cnf["months_keep_history_max"] = max_ignore_none([cd["retain_months_history"] for _, cd in added_conds])
648
+ if cnd0["source_orphan_purge"] == "Y" and nvl(table_cnf["months_keep_history_max"], 0) <= 0:
649
+ raise ValueError(f"positive retain_months_history required when source_orphan_purge is enabled for {key}")
650
+ # if HISTORY_ILM and cnf_months_keep_history_max is None, skip the table
651
+ if not is_source_mode and table_cnf["months_keep_history_max"] is None:
652
+ table_cnf["skip"] = True
653
+ continue
654
+ table_cnf["skip"] = False
655
+ # get where expression from cond_expr of all added_conds joined by " or "
656
+ if config.execution.action == "SOURCE_ILM" and not any(cd["cond_expr"] for _, cd in added_conds):
657
+ raise ValueError(f"At least one condition must be specified for SOURCE_ILM on table {key}")
658
+ predicates = [f"({cd['cond_expr']})" for _, cd in added_conds if cd["cond_expr"]]
659
+ if is_source_mode:
660
+ orphan_predicate = _orphan_predicate(cnd0)
661
+ if orphan_predicate:
662
+ predicates.append(f"({orphan_predicate})")
663
+ elif cnd0["source_orphan_purge"] == "Y":
664
+ if not orphan_history_date_predicates:
665
+ raise ValueError(f"related retention date required when source_orphan_purge is enabled for {key}")
666
+ orphan_dates = "\n or ".join(f"({predicate})" for predicate in orphan_history_date_predicates)
667
+ predicates.append(f"(A.{_ORPHAN_MARKER_COLUMN} = 'Y' and ({orphan_dates}))")
668
+ where_expr = "\n or ".join(predicates)
669
+ other_columns: list[_OtherColumn] = []
670
+ table_cnf["other_columns"] = other_columns
671
+ if config.execution.use_added_columns and is_source_mode:
672
+ for al, cd in added_conds:
673
+ columns_metadata = tables_config[(cd["source_owner"], cd["table_name"])]["columns_metadata"]
674
+ if al != "A":
675
+ # Add the cnf_purge_date_expr to other_columns for referencing tables
676
+ if cd["purge_date_expr"]:
677
+ relationship_date_column = _relationship_date_column(al)
678
+ column_metadata = ColumnDefinition(
679
+ name=relationship_date_column, data_type="date", nullable=True
680
+ )
681
+ _append_unique_name(
682
+ other_columns,
683
+ {
684
+ "name": relationship_date_column,
685
+ "expr": engine.get_fallback_expression(
686
+ nvl(cd["purge_date_expr"], "").replace("@", al + "."),
687
+ ldb_process_date_expr,
688
+ )
689
+ if cnd0["source_orphan_purge"] == "Y"
690
+ else nvl(cd["purge_date_expr"], "").replace("@", al + "."),
691
+ "metadata": column_metadata,
692
+ },
693
+ )
694
+ # Add columns in cnf_history_addtl_filter_expr to other_cols_exprs and other_cols_alias for referencing tables
695
+ history_filter = " ".join(
696
+ nvl(cd["history_addtl_filter_expr"], cd["additional_filter_expr"] or "").splitlines()
697
+ )
698
+ for column_name in engine.get_identifiers_from_expression(history_filter):
699
+ column_metadata = _clone_column_definition(f"{column_name}_{al}", columns_metadata[column_name])
700
+ other_column: _OtherColumn = {
701
+ "name": f"{column_name}_{al}",
702
+ "expr": f"{al}.{column_name}",
703
+ "metadata": column_metadata,
704
+ }
705
+ _append_unique_name(other_columns, other_column)
706
+ orphan_predicate = _orphan_predicate(cnd0)
707
+ if orphan_predicate:
708
+ other_columns.append(
709
+ {
710
+ "name": _ORPHAN_MARKER_COLUMN,
711
+ "expr": f"case when {orphan_predicate} then 'Y' else 'N' end",
712
+ "metadata": ColumnDefinition(
713
+ name=_ORPHAN_MARKER_COLUMN,
714
+ data_type="string",
715
+ length=1,
716
+ nullable=True,
717
+ ),
718
+ }
719
+ )
720
+ if config.execution.add_ldb_columns and is_source_mode:
721
+ other_columns.append(
722
+ {
723
+ "name": "LDB_PROCESS_DATE",
724
+ "expr": ldb_process_date_expr,
725
+ "metadata": ColumnDefinition(name="LDB_PROCESS_DATE", data_type="date", nullable=False),
726
+ }
727
+ )
728
+ other_columns.append(
729
+ {
730
+ "name": "LDB_INSERT_DATE",
731
+ "expr": ldb_insert_date_expr,
732
+ "metadata": ColumnDefinition(name="LDB_INSERT_DATE", data_type="date", nullable=False),
733
+ }
734
+ )
735
+ _, cnf_table_name = key
736
+ join_expr = _runtime_join_expression(config, cnd0)
737
+ # Prepare query_expr with the cnf_table_name, join_expr and where_expr
738
+ base_from = f"from {runtime_owner.lower()}.{cnf_table_name.lower()} A"
739
+ join_clause = f"\n {join_expr}" if join_expr else ""
740
+ where_clause = f"\nwhere {where_expr}" if where_expr else ""
741
+ table_cnf["query_expr"] = base_from + join_clause + where_clause
742
+ table_columns = table_cnf["table_columns"]
743
+ # if table has lob columns and long type columns, remove long type columns from table_columns
744
+ if cnd0["long_columns"] and cnd0["has_lob_columns"] != "N":
745
+ long_columns = [c.strip().lower() for c in cnd0["long_columns"].split(",")]
746
+ table_columns = list(set([col.lower() for col in table_columns]) - set(long_columns))
747
+ table_cnf["table_columns"] = table_columns
748
+ # get sql_block and add it to tables_config
749
+ sql_block = engine.generate_sql_block(config, table_cnf, process_date)
750
+ table_cnf["sql_block"] = sql_block
751
+ return tables_config
752
+
753
+
754
+ def ldb_run(config: Config) -> None:
755
+ connection: Any = None
756
+ admin_connection: Any = None
757
+ engine: DatabaseEngine | None = None
758
+ try:
759
+ engine = get_db_engine(config.connections.db_engine)
760
+ if config.execution.mode == "PLAN":
761
+ rc = _plan_mode(config)
762
+ raise SystemExit(rc)
763
+ if config.execution.mode == "VALIDATE":
764
+ rc = _validate_environment(config, engine)
765
+ raise SystemExit(rc)
766
+ connection = engine.get_connection(config)
767
+ table_privileges = ("SELECT", "INSERT", "UPDATE", "DELETE")
768
+ tables_conf_rows = _load_offline_rows(config)
769
+ source_tables_for_privileges = _collect_privilege_targets(tables_conf_rows, "source_owner", engine)
770
+ if (
771
+ config.execution.action == "SOURCE_ILM"
772
+ and source_tables_for_privileges
773
+ and _credentials_present(config, admin=True, env="SOURCE")
774
+ ):
775
+ source_admin_priv_conn = engine.get_connection(config, admin=True, env="SOURCE")
776
+ try:
777
+ engine.ensure_table_privileges(
778
+ source_admin_priv_conn,
779
+ config.administration.source_role_name,
780
+ source_tables_for_privileges,
781
+ table_privileges,
782
+ )
783
+ finally:
784
+ engine.close_connection(source_admin_priv_conn)
785
+ process_date = engine.get_system_date(connection).strftime("%Y%m%d")
786
+ tables_config = process_tables_cnf(connection, config, engine, process_date)
787
+ if config.execution.generate_script:
788
+ rc = generate_script_output(config, tables_config)
789
+ else:
790
+ admin_connection = engine.get_connection(config, admin=True)
791
+ _ensure_history_tables(config, engine, admin_connection, tables_config)
792
+ history_tables_for_privileges = _collect_privilege_targets(tables_conf_rows, "history_owner", engine)
793
+ if history_tables_for_privileges and _credentials_present(config, admin=True, env="HISTORY"):
794
+ history_admin_priv_conn = engine.get_connection(config, admin=True, env="HISTORY")
795
+ try:
796
+ engine.ensure_table_privileges(
797
+ history_admin_priv_conn,
798
+ config.administration.history_role_name,
799
+ history_tables_for_privileges,
800
+ table_privileges,
801
+ )
802
+ finally:
803
+ engine.close_connection(history_admin_priv_conn)
804
+ rc = ldb_exec_ilm(config, tables_config, process_date, engine, connection)
805
+ raise SystemExit(rc)
806
+ finally:
807
+ if engine and connection:
808
+ engine.close_connection(connection)
809
+ if engine and admin_connection:
810
+ engine.close_connection(admin_connection)