limitsdb 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (33) hide show
  1. {limitsdb-0.6.0 → limitsdb-0.7.0}/PKG-INFO +86 -16
  2. {limitsdb-0.6.0 → limitsdb-0.7.0}/README.md +85 -15
  3. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/cli/ldb_impl.py +78 -23
  4. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_ddl.py +51 -28
  5. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_errors.py +8 -0
  6. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_ilm_config.py +6 -0
  7. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_params_config.py +11 -8
  8. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_runner.py +491 -120
  9. limitsdb-0.7.0/limitsdb/core/ldb_status.py +31 -0
  10. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/ldb_engines.py +164 -50
  11. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/oracle/ldb_engine_impl.py +679 -336
  12. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/resources/ilm.example.yml +4 -2
  13. {limitsdb-0.6.0 → limitsdb-0.7.0}/pyproject.toml +1 -1
  14. limitsdb-0.6.0/limitsdb/core/ldb_status.py +0 -8
  15. {limitsdb-0.6.0 → limitsdb-0.7.0}/LICENSE +0 -0
  16. {limitsdb-0.6.0 → limitsdb-0.7.0}/LICENSE-DUAL.md +0 -0
  17. {limitsdb-0.6.0 → limitsdb-0.7.0}/NOTICE +0 -0
  18. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/__init__.py +0 -0
  19. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/cli/__init__.py +0 -0
  20. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/cli/ldb_crypt.py +0 -0
  21. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/cli/ldb_init.py +0 -0
  22. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/cli/ldb_run.py +0 -0
  23. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/__init__.py +0 -0
  24. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_config_loader.py +0 -0
  25. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_crypto.py +0 -0
  26. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_logger.py +0 -0
  27. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_meta.py +0 -0
  28. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/core/ldb_utils.py +0 -0
  29. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/__init__.py +0 -0
  30. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/ldb_engine_loader.py +0 -0
  31. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/oracle/__init__.py +0 -0
  32. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/db/oracle/ldb_conf.sql +0 -0
  33. {limitsdb-0.6.0 → limitsdb-0.7.0}/limitsdb/resources/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limitsdb
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Configuration-driven ILM (Information Lifecycle Management) for production and history databases
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -420,15 +420,23 @@ The script:
420
420
 
421
421
  - stops at the first error (`whenever sqlerror exit failure`) and ends with a
422
422
  `LimitsDb: OK` banner on success;
423
- - contains no passwords: it prompts for them (hidden input);
423
+ - contains no passwords: it prompts for all of them (hidden input) at its start,
424
+ before it changes anything;
424
425
  - can be rerun: each object is created in a PL/SQL block that ignores only its
425
426
  own "already exists" error, and an existing database link is skipped;
426
- - creates the database link after reconnecting as the runtime user, who owns it
427
- (it asks for that user's password).
427
+ - creates the database link after reconnecting as the runtime user, who owns it.
428
+ It asks for that user's password once and reuses it to reconnect. An existing user keeps its password, so the one entered must
429
+ match it; the script says so before it reconnects.
428
430
 
429
431
  Run it in SQL*Plus connected as a user with administrative privileges:
430
432
  `sqlplus admin@<dsn> @bootstrap_<schema>_<env>_<timestamp>.sql`.
431
433
 
434
+ When `ldb-impl` has admin credentials and the runtime user already existed with
435
+ a different password, the reconnection as that user is rejected (ORA-01017).
436
+ `ldb-impl` then logs that the user rejected the configured password, naming the
437
+ user and the database, and exits 1; it never changes the password of an existing
438
+ user.
439
+
432
440
  ## Run ILM
433
441
 
434
442
  ```bash
@@ -441,7 +449,7 @@ ldb-run --schema <SCHEMA> [--profile <PROFILE>] --action SOURCE_ILM|HISTORY_ILM
441
449
  Common overrides:
442
450
 
443
451
  ```
444
- --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
452
+ --mode VALIDATE|PREPARE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup and privileges (read-only), PREPARE grants and objects, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
445
453
  --chunk-size <int> # Rows per chunk when processing large tables
446
454
  --parallel-max <int> # Maximum number of tables processed in parallel
447
455
  --ddl-pending-dir <path> # Where failed history DDL is written for a DBA (default: ldb_ddl_pending)
@@ -471,9 +479,25 @@ application role, and creating or altering history tables before a `PREVIEW` or
471
479
  `EXECUTE` run (`PREVIEW` needs the objects in place to validate and time the
472
480
  queries; it skips only the data movement). Both operations are idempotent.
473
481
 
474
- If admin credentials are absent from `secrets[.<PROFILE>].json`, the privilege
475
- grants are skipped silently — the assumption is that grants are already in
476
- place.
482
+ A `PREVIEW` writes its own statuses in `ldb_ctl`, the `EXECUTE` ones prefixed with `P` (`PTSTART`, `PCSTART`,
483
+ `PCEND`, `PLSTART`, `PLEND`, `PTEND`, `PERROR`), and finishes each table with `PTEND`, not `TEND`, so it never marks a
484
+ table as processed for the day: an `EXECUTE` run afterwards (same day) still moves the data. A `PREVIEW` is refused
485
+ (`ORA-20006`) over a table that has an `EXECUTE` status for the same process date. An `EXECUTE` continues an incomplete
486
+ `EXECUTE` of the same process date (finished tables stay finished and the rows already moved are kept); in any other case
487
+ the table starts over and the abandoned run is closed in `ldb_log` as "Incomplete process closed by a later process".
488
+
489
+ Only one `ldb-run` can work on a table at a time: the runner takes a lock per table in `LDB_RUN_LOCK` and a second runner
490
+ stops with exit code 1 before starting anything. After upgrading, rerun `ldb-impl` so the new table and the supporting
491
+ procedures (`check_save_status`, `check_referencing_tables`) are installed.
492
+
493
+ The generated `SCRIPT` takes the mode from SQL*Plus and runs without the runner lock:
494
+ `sqlplus user/password@dsn @script EXECUTE` (or `PREVIEW`). Its process date is the `ldb_process_date` variable.
495
+
496
+ If source admin credentials are absent from `secrets[.<PROFILE>].json`, the
497
+ source privilege grants are not applied. `ldb-run` checks with the runtime
498
+ connection which privileges are missing through the role and logs a warning
499
+ naming them; a table the runtime user cannot see is skipped with a warning.
500
+ Run `--mode PREPARE` to get the missing grants as a file for a DBA.
477
501
 
478
502
  History table management follows the same principle:
479
503
 
@@ -486,7 +510,7 @@ History table management follows the same principle:
486
510
  DBA pre-creates all history objects and admin credentials are not stored.
487
511
  - **Admin credentials absent, history schema cannot be inspected** (for example
488
512
  the runtime connection or dictionary query fails): `ldb-run` exits with code 1
489
- and logs the cause. It does not run ILM against a history schema whose state is
513
+ and tells the operator to run `ldb-impl` so a DBA completes the configuration. It does not run ILM against a history schema whose state is
490
514
  unknown.
491
515
  - **Admin credentials absent, DDL changes are required**: `ldb-run` applies the
492
516
  DDL directly over the runtime history connection, with no prior privilege
@@ -504,9 +528,46 @@ History table management follows the same principle:
504
528
  If the file cannot be written (for example the directory is not writable),
505
529
  `ldb-run` logs the error with the failed statements and exits with code 1.
506
530
 
531
+ ### Preparing grants and objects with `--mode PREPARE`
532
+
533
+ `--mode PREPARE` brings permissions and objects into place without running any
534
+ ILM work, so the problems show up before `SCRIPT`, `PREVIEW` or `EXECUTE`. It
535
+ is safe to rerun and meant to run first (`VALIDATE`, `PREPARE`, then the rest):
536
+
537
+ - With source admin credentials (`SOURCE_ILM`) it grants the source tables to
538
+ the source role. Without them it writes `source_grants_<schema>_<ts>.sql` under
539
+ `ddl_pending_dir` for a DBA, with only the privileges the runtime user does not
540
+ receive yet through the role, and warns. Once the grants are in place no file
541
+ is written.
542
+ - It checks, with the runtime connections alone, that the source and history
543
+ roles are enabled for the runtime users and give them `SELECT`, `INSERT`,
544
+ `UPDATE` and `DELETE` on every configured table. A role that is granted but
545
+ not a default role gives nothing.
546
+ The role must stay a default role of the runtime user (`ldb-run` warns
547
+ when it is not); `GRANT role` makes it default unless the user was altered.
548
+ - The default role names are `LDB_SOURCE_ROLE` and `LDB_HISTORY_ROLE`. When
549
+ several schemas (configuration sets) share the same SOURCE or HISTORY
550
+ database, those names collide; set `source_role_name` / `history_role_name`
551
+ per schema to keep the roles apart. Reusing one role across schemas also
552
+ works and is rarely a problem.
553
+ - It creates or alters the history tables and grants them to the history role,
554
+ with the same admin or runtime-connection rules as `EXECUTE`: statements that
555
+ fail go to the pending file.
556
+ - It exits with code 1 if the runtime user still cannot see a configured table
557
+ (it does not exist or `SELECT` is not granted). Other modes only skip such a
558
+ table with a warning.
559
+
560
+ `VALIDATE` runs the same privilege check without changing anything: a gap on
561
+ the action's own environment fails it, a gap on the other environment is a
562
+ warning, since `PREPARE` creates the history objects. `SCRIPT`, `PREVIEW` and
563
+ `EXECUTE` warn about the gaps they find.
564
+
565
+ `PREVIEW` and `EXECUTE` keep applying DDL and grants themselves, so a change in
566
+ source reaches history without failing the run.
567
+
507
568
  ### History DDL in `--mode SCRIPT`
508
569
 
509
- `--mode SCRIPT` applies nothing to the databases. The ILM SQL blocks, which
570
+ `--mode SCRIPT` applies no DDL and no history grants to the databases. The ILM SQL blocks, which
510
571
  run many times, go to stdout. The one-time statements go to files under
511
572
  `ddl_pending_dir`, so they stay out of the script that is rerun:
512
573
 
@@ -516,7 +577,14 @@ run many times, go to stdout. The one-time statements go to files under
516
577
  - `source_grants_<schema>_<YYYYMMDD_HHMMSS>.sql` (`SOURCE_ILM` only): the grants
517
578
  on the source tables for the source role. Run it against the source database.
518
579
 
519
- Both are plain SQL for SQL*Plus that stop at the first failing statement; the
580
+ The runtime user reads the column metadata of the source tables through their
581
+ grants, so when source admin credentials exist `SCRIPT` applies the source
582
+ grants itself and writes no `source_grants` file. Without them the file is
583
+ written and `ldb-run` logs a warning. In every mode, a table the runtime user
584
+ cannot see (it does not exist, or `SELECT` is not granted) is skipped with a
585
+ warning instead of failing the run, and so is a table that references it.
586
+
587
+ Both files are plain SQL for SQL*Plus that stop at the first failing statement; the
520
588
  grants are unconditional and safe to run again. The history DDL is always
521
589
  emitted, computed like this:
522
590
 
@@ -538,7 +606,7 @@ the same pending file as the failed DDL, so a DBA can run it.
538
606
  | Parameter | Purpose | Default / values |
539
607
  | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
540
608
  | `action` | ILM target (`SOURCE_ILM` moves/purges source, `HISTORY_ILM` cleans downstream history). | Default `SOURCE_ILM`; choices `SOURCE_ILM`, `HISTORY_ILM`. |
541
- | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
609
+ | `mode` | Execution mode: `VALIDATE` config, credentials and role privileges (read-only), `PREPARE` apply grants and history objects without running ILM, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
542
610
  | `chunk_size` | Rows per chunk when processing large tables. | Default `100000`. |
543
611
  | `use_added_columns` | Populate derived columns in history tables. | Default `True` (boolean toggle). |
544
612
  | `add_ldb_columns` | Add LimitsDb execution-date columns in history tables. | Default `True` (boolean toggle). |
@@ -551,10 +619,10 @@ the same pending file as the failed DDL, so a DBA can run it.
551
619
  | `profile` | Environment name (e.g., `dev`, `prod`). Differentiates configuration files for different environments within the same configuration set. | Optional; default `null`. |
552
620
  | `ilm_config_file` | YAML file with tables (bypass DB discovery). | Optional; default `null`. |
553
621
  | `source_dsn` | Source DSN / connection descriptor. | Default empty string. |
554
- | `source_username` | Source username. | Default empty string. |
622
+ | `source_username` | Source username, as named in the data dictionary: lowercase (`ldb`) is a quoted user created as `"ldb"`, uppercase (`LDB`) a regular one. | | Default empty string. |
555
623
  | `source_password` | Source password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
556
624
  | `history_dsn` | History DSN / connection descriptor. | Default empty string. |
557
- | `history_username` | History username. | Default empty string. |
625
+ | `history_username` | History username, with the same case rules as `source_username`. || Default empty string. |
558
626
  | `history_password` | History password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
559
627
  | `admin_source_username` | Admin source username. | Default empty string. |
560
628
  | `admin_source_password` | Admin source password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
@@ -599,7 +667,9 @@ Each entry in `ilm[.<PROFILE>].yml` merges table-level attributes with one or mo
599
667
  - **Retention windows:** `retain_months_source` is required whenever `purge_date_expr` is present. `retain_months_history` extends the history window but also depends on defining the source retention. During `HISTORY_ILM` runs, the engine sums source and history months to determine the cut-off date.
600
668
  - **Date expressions:** `purge_date_expr` identifies the date column (or expression) that anchors retention. Prefix column references with `@` (for example, `"@DSP_DATE"`); the runner swaps the prefix for the proper table alias at execution time.
601
669
  - **Additional filters:** Add optional filters through `additional_filter_expr` (source runs) and `history_addtl_filter_expr` (history runs). Both accept the same `@column` syntax, and the history expression falls back to the source expression when omitted.
602
- - **Referencing tables:** Use `referencing_tables` to pull parent conditions into child tables. List entries as `<TABLE> <ALIAS>` (optionally `<OWNER>.<TABLE> <ALIAS>`) and supply matching `join_expr` fragments. The runner inherits active conditions from each referenced table and rewrites the join fragments—`@` becomes `inner` joins by default or `left outer` joins when `source_orphan_purge: true` to find orphans.
670
+ - **Referencing tables:** Use `referencing_tables` to pull parent conditions into child tables. List entries as `<TABLE> <ALIAS>` (optionally `<OWNER>.<TABLE> <ALIAS>`) and supply matching `join_expr` fragments. The runner inherits the active conditions of each referenced table: the conditions of one table are alternatives (`or`), and a child row is purged only when its own conditions and those of every referenced table are met (`and` between tables). It rewrites the join fragments—`@` becomes `inner` joins by default or `left outer` joins when `source_orphan_purge: true` to find orphans.
671
+ - **Join owners in history:** `join_expr` names each referenced table by its source owner. In `HISTORY_ILM` runs with `use_added_columns: false` the runner joins the history copy instead, replacing `<source_owner>.<table>` with `<history_owner>.<table>` for every table listed in `referencing_tables`. With `use_added_columns: true` history runs do not join.
672
+ - **Extra tables:** Filters may contain subqueries over tables that ILM does not manage. List them in `extra_select_tables` (read by `additional_filter_expr`, so source runs) and `history_extra_select_tables` (read by `history_addtl_filter_expr`) as comma-separated `[OWNER.]TABLE` entries; an entry without owner belongs to the environment's own owner. The environment's role gets `SELECT` on them wherever it gets the other table grants (`ldb-run`, `--mode PREPARE`, `--mode SCRIPT`) and the privilege checks include them. In history, `history_extra_select_tables` falls back to `extra_select_tables`, just as `history_addtl_filter_expr` falls back to `additional_filter_expr`. A table also managed by ILM keeps its full privileges. For tables that are not in the history database write the access in `history_addtl_filter_expr` yourself (for example `TABLE@SRC`, or `TABLE UNION ALL TABLE@SRC` in a subquery); LimitsDb does not rewrite subqueries. `SRC` is only the default name of the history-to-source database link; it can be overridden with `history_to_source_dblink_name` (and `source_to_history_dblink_name`, default `HIST`, for the other direction). The link connects as the source runtime user and uses the grants of its source role, so a table read as `TABLE@SRC` must also be listed in `extra_select_tables`.
603
673
  - **Historical predicate snapshot:** With `use_added_columns` enabled, every archived row preserves the values needed to reevaluate all its predicates in history, including values sourced from related tables. Distinct cut-off dates are stored in helper columns such as `ldb_date_<suffix>`. Predicates that share a date reuse the same helper column while retaining their own filters and retention windows. Other values sourced from related tables are materialized as `<column>_<table_alias>`.
604
674
  - **Orphan handling:** Enable `source_orphan_purge` with `orphan_check_column` to archive source rows whose referenced parent is absent. The source query uses `left outer` joins and treats the row as an orphan when any comma-separated check column is `NULL`. `referencing_tables`, `join_expr`, `orphan_check_column`, `use_added_columns: true`, and a positive historical retention are required. Orphans follow the table's normal source-to-history flow rather than being deleted directly. LimitsDb snapshots this state in `LDB_IS_ORPHAN` and stores a fallback date so `HISTORY_ILM` can apply the configured retention without accessing the missing parent.
605
675
 
@@ -625,7 +695,7 @@ Every key is commented; the default value and short help appear inline. Example:
625
695
 
626
696
  ```yaml
627
697
  # action: "SOURCE_ILM" # ILM target: months_keep_history_max (SOURCE_ILM) or history (HISTORY_ILM)
628
- # mode: "PREVIEW" # VALIDATE | PLAN | PREVIEW | SCRIPT | EXECUTE (PREVIEW mirrors legacy DRY_RUN)
698
+ # mode: "PREVIEW" # VALIDATE | PREPARE | PLAN | PREVIEW | SCRIPT | EXECUTE
629
699
  # chunk_size: 100000 # Rows per chunk when processing large tables
630
700
  # use_added_columns: True # Populate derived columns in history tables
631
701
  # add_ldb_columns: True # Add LimitsDb execution-date columns in history tables
@@ -274,15 +274,23 @@ The script:
274
274
 
275
275
  - stops at the first error (`whenever sqlerror exit failure`) and ends with a
276
276
  `LimitsDb: OK` banner on success;
277
- - contains no passwords: it prompts for them (hidden input);
277
+ - contains no passwords: it prompts for all of them (hidden input) at its start,
278
+ before it changes anything;
278
279
  - can be rerun: each object is created in a PL/SQL block that ignores only its
279
280
  own "already exists" error, and an existing database link is skipped;
280
- - creates the database link after reconnecting as the runtime user, who owns it
281
- (it asks for that user's password).
281
+ - creates the database link after reconnecting as the runtime user, who owns it.
282
+ It asks for that user's password once and reuses it to reconnect. An existing user keeps its password, so the one entered must
283
+ match it; the script says so before it reconnects.
282
284
 
283
285
  Run it in SQL*Plus connected as a user with administrative privileges:
284
286
  `sqlplus admin@<dsn> @bootstrap_<schema>_<env>_<timestamp>.sql`.
285
287
 
288
+ When `ldb-impl` has admin credentials and the runtime user already existed with
289
+ a different password, the reconnection as that user is rejected (ORA-01017).
290
+ `ldb-impl` then logs that the user rejected the configured password, naming the
291
+ user and the database, and exits 1; it never changes the password of an existing
292
+ user.
293
+
286
294
  ## Run ILM
287
295
 
288
296
  ```bash
@@ -295,7 +303,7 @@ ldb-run --schema <SCHEMA> [--profile <PROFILE>] --action SOURCE_ILM|HISTORY_ILM
295
303
  Common overrides:
296
304
 
297
305
  ```
298
- --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
306
+ --mode VALIDATE|PREPARE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup and privileges (read-only), PREPARE grants and objects, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
299
307
  --chunk-size <int> # Rows per chunk when processing large tables
300
308
  --parallel-max <int> # Maximum number of tables processed in parallel
301
309
  --ddl-pending-dir <path> # Where failed history DDL is written for a DBA (default: ldb_ddl_pending)
@@ -325,9 +333,25 @@ application role, and creating or altering history tables before a `PREVIEW` or
325
333
  `EXECUTE` run (`PREVIEW` needs the objects in place to validate and time the
326
334
  queries; it skips only the data movement). Both operations are idempotent.
327
335
 
328
- If admin credentials are absent from `secrets[.<PROFILE>].json`, the privilege
329
- grants are skipped silently — the assumption is that grants are already in
330
- place.
336
+ A `PREVIEW` writes its own statuses in `ldb_ctl`, the `EXECUTE` ones prefixed with `P` (`PTSTART`, `PCSTART`,
337
+ `PCEND`, `PLSTART`, `PLEND`, `PTEND`, `PERROR`), and finishes each table with `PTEND`, not `TEND`, so it never marks a
338
+ table as processed for the day: an `EXECUTE` run afterwards (same day) still moves the data. A `PREVIEW` is refused
339
+ (`ORA-20006`) over a table that has an `EXECUTE` status for the same process date. An `EXECUTE` continues an incomplete
340
+ `EXECUTE` of the same process date (finished tables stay finished and the rows already moved are kept); in any other case
341
+ the table starts over and the abandoned run is closed in `ldb_log` as "Incomplete process closed by a later process".
342
+
343
+ Only one `ldb-run` can work on a table at a time: the runner takes a lock per table in `LDB_RUN_LOCK` and a second runner
344
+ stops with exit code 1 before starting anything. After upgrading, rerun `ldb-impl` so the new table and the supporting
345
+ procedures (`check_save_status`, `check_referencing_tables`) are installed.
346
+
347
+ The generated `SCRIPT` takes the mode from SQL*Plus and runs without the runner lock:
348
+ `sqlplus user/password@dsn @script EXECUTE` (or `PREVIEW`). Its process date is the `ldb_process_date` variable.
349
+
350
+ If source admin credentials are absent from `secrets[.<PROFILE>].json`, the
351
+ source privilege grants are not applied. `ldb-run` checks with the runtime
352
+ connection which privileges are missing through the role and logs a warning
353
+ naming them; a table the runtime user cannot see is skipped with a warning.
354
+ Run `--mode PREPARE` to get the missing grants as a file for a DBA.
331
355
 
332
356
  History table management follows the same principle:
333
357
 
@@ -340,7 +364,7 @@ History table management follows the same principle:
340
364
  DBA pre-creates all history objects and admin credentials are not stored.
341
365
  - **Admin credentials absent, history schema cannot be inspected** (for example
342
366
  the runtime connection or dictionary query fails): `ldb-run` exits with code 1
343
- and logs the cause. It does not run ILM against a history schema whose state is
367
+ and tells the operator to run `ldb-impl` so a DBA completes the configuration. It does not run ILM against a history schema whose state is
344
368
  unknown.
345
369
  - **Admin credentials absent, DDL changes are required**: `ldb-run` applies the
346
370
  DDL directly over the runtime history connection, with no prior privilege
@@ -358,9 +382,46 @@ History table management follows the same principle:
358
382
  If the file cannot be written (for example the directory is not writable),
359
383
  `ldb-run` logs the error with the failed statements and exits with code 1.
360
384
 
385
+ ### Preparing grants and objects with `--mode PREPARE`
386
+
387
+ `--mode PREPARE` brings permissions and objects into place without running any
388
+ ILM work, so the problems show up before `SCRIPT`, `PREVIEW` or `EXECUTE`. It
389
+ is safe to rerun and meant to run first (`VALIDATE`, `PREPARE`, then the rest):
390
+
391
+ - With source admin credentials (`SOURCE_ILM`) it grants the source tables to
392
+ the source role. Without them it writes `source_grants_<schema>_<ts>.sql` under
393
+ `ddl_pending_dir` for a DBA, with only the privileges the runtime user does not
394
+ receive yet through the role, and warns. Once the grants are in place no file
395
+ is written.
396
+ - It checks, with the runtime connections alone, that the source and history
397
+ roles are enabled for the runtime users and give them `SELECT`, `INSERT`,
398
+ `UPDATE` and `DELETE` on every configured table. A role that is granted but
399
+ not a default role gives nothing.
400
+ The role must stay a default role of the runtime user (`ldb-run` warns
401
+ when it is not); `GRANT role` makes it default unless the user was altered.
402
+ - The default role names are `LDB_SOURCE_ROLE` and `LDB_HISTORY_ROLE`. When
403
+ several schemas (configuration sets) share the same SOURCE or HISTORY
404
+ database, those names collide; set `source_role_name` / `history_role_name`
405
+ per schema to keep the roles apart. Reusing one role across schemas also
406
+ works and is rarely a problem.
407
+ - It creates or alters the history tables and grants them to the history role,
408
+ with the same admin or runtime-connection rules as `EXECUTE`: statements that
409
+ fail go to the pending file.
410
+ - It exits with code 1 if the runtime user still cannot see a configured table
411
+ (it does not exist or `SELECT` is not granted). Other modes only skip such a
412
+ table with a warning.
413
+
414
+ `VALIDATE` runs the same privilege check without changing anything: a gap on
415
+ the action's own environment fails it, a gap on the other environment is a
416
+ warning, since `PREPARE` creates the history objects. `SCRIPT`, `PREVIEW` and
417
+ `EXECUTE` warn about the gaps they find.
418
+
419
+ `PREVIEW` and `EXECUTE` keep applying DDL and grants themselves, so a change in
420
+ source reaches history without failing the run.
421
+
361
422
  ### History DDL in `--mode SCRIPT`
362
423
 
363
- `--mode SCRIPT` applies nothing to the databases. The ILM SQL blocks, which
424
+ `--mode SCRIPT` applies no DDL and no history grants to the databases. The ILM SQL blocks, which
364
425
  run many times, go to stdout. The one-time statements go to files under
365
426
  `ddl_pending_dir`, so they stay out of the script that is rerun:
366
427
 
@@ -370,7 +431,14 @@ run many times, go to stdout. The one-time statements go to files under
370
431
  - `source_grants_<schema>_<YYYYMMDD_HHMMSS>.sql` (`SOURCE_ILM` only): the grants
371
432
  on the source tables for the source role. Run it against the source database.
372
433
 
373
- Both are plain SQL for SQL*Plus that stop at the first failing statement; the
434
+ The runtime user reads the column metadata of the source tables through their
435
+ grants, so when source admin credentials exist `SCRIPT` applies the source
436
+ grants itself and writes no `source_grants` file. Without them the file is
437
+ written and `ldb-run` logs a warning. In every mode, a table the runtime user
438
+ cannot see (it does not exist, or `SELECT` is not granted) is skipped with a
439
+ warning instead of failing the run, and so is a table that references it.
440
+
441
+ Both files are plain SQL for SQL*Plus that stop at the first failing statement; the
374
442
  grants are unconditional and safe to run again. The history DDL is always
375
443
  emitted, computed like this:
376
444
 
@@ -392,7 +460,7 @@ the same pending file as the failed DDL, so a DBA can run it.
392
460
  | Parameter | Purpose | Default / values |
393
461
  | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
394
462
  | `action` | ILM target (`SOURCE_ILM` moves/purges source, `HISTORY_ILM` cleans downstream history). | Default `SOURCE_ILM`; choices `SOURCE_ILM`, `HISTORY_ILM`. |
395
- | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
463
+ | `mode` | Execution mode: `VALIDATE` config, credentials and role privileges (read-only), `PREPARE` apply grants and history objects without running ILM, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
396
464
  | `chunk_size` | Rows per chunk when processing large tables. | Default `100000`. |
397
465
  | `use_added_columns` | Populate derived columns in history tables. | Default `True` (boolean toggle). |
398
466
  | `add_ldb_columns` | Add LimitsDb execution-date columns in history tables. | Default `True` (boolean toggle). |
@@ -405,10 +473,10 @@ the same pending file as the failed DDL, so a DBA can run it.
405
473
  | `profile` | Environment name (e.g., `dev`, `prod`). Differentiates configuration files for different environments within the same configuration set. | Optional; default `null`. |
406
474
  | `ilm_config_file` | YAML file with tables (bypass DB discovery). | Optional; default `null`. |
407
475
  | `source_dsn` | Source DSN / connection descriptor. | Default empty string. |
408
- | `source_username` | Source username. | Default empty string. |
476
+ | `source_username` | Source username, as named in the data dictionary: lowercase (`ldb`) is a quoted user created as `"ldb"`, uppercase (`LDB`) a regular one. | | Default empty string. |
409
477
  | `source_password` | Source password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
410
478
  | `history_dsn` | History DSN / connection descriptor. | Default empty string. |
411
- | `history_username` | History username. | Default empty string. |
479
+ | `history_username` | History username, with the same case rules as `source_username`. || Default empty string. |
412
480
  | `history_password` | History password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
413
481
  | `admin_source_username` | Admin source username. | Default empty string. |
414
482
  | `admin_source_password` | Admin source password stored in secrets. | Default empty string; encrypted in `secrets.<PROFILE>.json`. |
@@ -453,7 +521,9 @@ Each entry in `ilm[.<PROFILE>].yml` merges table-level attributes with one or mo
453
521
  - **Retention windows:** `retain_months_source` is required whenever `purge_date_expr` is present. `retain_months_history` extends the history window but also depends on defining the source retention. During `HISTORY_ILM` runs, the engine sums source and history months to determine the cut-off date.
454
522
  - **Date expressions:** `purge_date_expr` identifies the date column (or expression) that anchors retention. Prefix column references with `@` (for example, `"@DSP_DATE"`); the runner swaps the prefix for the proper table alias at execution time.
455
523
  - **Additional filters:** Add optional filters through `additional_filter_expr` (source runs) and `history_addtl_filter_expr` (history runs). Both accept the same `@column` syntax, and the history expression falls back to the source expression when omitted.
456
- - **Referencing tables:** Use `referencing_tables` to pull parent conditions into child tables. List entries as `<TABLE> <ALIAS>` (optionally `<OWNER>.<TABLE> <ALIAS>`) and supply matching `join_expr` fragments. The runner inherits active conditions from each referenced table and rewrites the join fragments—`@` becomes `inner` joins by default or `left outer` joins when `source_orphan_purge: true` to find orphans.
524
+ - **Referencing tables:** Use `referencing_tables` to pull parent conditions into child tables. List entries as `<TABLE> <ALIAS>` (optionally `<OWNER>.<TABLE> <ALIAS>`) and supply matching `join_expr` fragments. The runner inherits the active conditions of each referenced table: the conditions of one table are alternatives (`or`), and a child row is purged only when its own conditions and those of every referenced table are met (`and` between tables). It rewrites the join fragments—`@` becomes `inner` joins by default or `left outer` joins when `source_orphan_purge: true` to find orphans.
525
+ - **Join owners in history:** `join_expr` names each referenced table by its source owner. In `HISTORY_ILM` runs with `use_added_columns: false` the runner joins the history copy instead, replacing `<source_owner>.<table>` with `<history_owner>.<table>` for every table listed in `referencing_tables`. With `use_added_columns: true` history runs do not join.
526
+ - **Extra tables:** Filters may contain subqueries over tables that ILM does not manage. List them in `extra_select_tables` (read by `additional_filter_expr`, so source runs) and `history_extra_select_tables` (read by `history_addtl_filter_expr`) as comma-separated `[OWNER.]TABLE` entries; an entry without owner belongs to the environment's own owner. The environment's role gets `SELECT` on them wherever it gets the other table grants (`ldb-run`, `--mode PREPARE`, `--mode SCRIPT`) and the privilege checks include them. In history, `history_extra_select_tables` falls back to `extra_select_tables`, just as `history_addtl_filter_expr` falls back to `additional_filter_expr`. A table also managed by ILM keeps its full privileges. For tables that are not in the history database write the access in `history_addtl_filter_expr` yourself (for example `TABLE@SRC`, or `TABLE UNION ALL TABLE@SRC` in a subquery); LimitsDb does not rewrite subqueries. `SRC` is only the default name of the history-to-source database link; it can be overridden with `history_to_source_dblink_name` (and `source_to_history_dblink_name`, default `HIST`, for the other direction). The link connects as the source runtime user and uses the grants of its source role, so a table read as `TABLE@SRC` must also be listed in `extra_select_tables`.
457
527
  - **Historical predicate snapshot:** With `use_added_columns` enabled, every archived row preserves the values needed to reevaluate all its predicates in history, including values sourced from related tables. Distinct cut-off dates are stored in helper columns such as `ldb_date_<suffix>`. Predicates that share a date reuse the same helper column while retaining their own filters and retention windows. Other values sourced from related tables are materialized as `<column>_<table_alias>`.
458
528
  - **Orphan handling:** Enable `source_orphan_purge` with `orphan_check_column` to archive source rows whose referenced parent is absent. The source query uses `left outer` joins and treats the row as an orphan when any comma-separated check column is `NULL`. `referencing_tables`, `join_expr`, `orphan_check_column`, `use_added_columns: true`, and a positive historical retention are required. Orphans follow the table's normal source-to-history flow rather than being deleted directly. LimitsDb snapshots this state in `LDB_IS_ORPHAN` and stores a fallback date so `HISTORY_ILM` can apply the configured retention without accessing the missing parent.
459
529
 
@@ -479,7 +549,7 @@ Every key is commented; the default value and short help appear inline. Example:
479
549
 
480
550
  ```yaml
481
551
  # action: "SOURCE_ILM" # ILM target: months_keep_history_max (SOURCE_ILM) or history (HISTORY_ILM)
482
- # mode: "PREVIEW" # VALIDATE | PLAN | PREVIEW | SCRIPT | EXECUTE (PREVIEW mirrors legacy DRY_RUN)
552
+ # mode: "PREVIEW" # VALIDATE | PREPARE | PLAN | PREVIEW | SCRIPT | EXECUTE
483
553
  # chunk_size: 100000 # Rows per chunk when processing large tables
484
554
  # use_added_columns: True # Populate derived columns in history tables
485
555
  # add_ldb_columns: True # Add LimitsDb execution-date columns in history tables
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ from collections.abc import Callable
6
7
  from datetime import datetime
7
8
  from pathlib import Path
8
- from typing import Any
9
+ from typing import Any, Literal
9
10
 
10
11
  from limitsdb.core.ldb_crypto import load_or_create_key
11
- from limitsdb.core.ldb_errors import LimitsDbError
12
+ from limitsdb.core.ldb_ddl import render_script
13
+ from limitsdb.core.ldb_errors import InvalidCredentialsError, LimitsDbError
12
14
  from limitsdb.core.ldb_logger import configure_logger, get_logger, reconfigure_logger
13
15
  from limitsdb.core.ldb_params_config import Config, build_config
14
16
  from limitsdb.core.ldb_utils import encrypt_secrets_in_place
@@ -16,6 +18,7 @@ from limitsdb.db.ldb_engine_loader import get_db_engine
16
18
  from limitsdb.db.ldb_engines import (
17
19
  ColumnDefinition,
18
20
  DatabaseLinkDefinition,
21
+ DdlSink,
19
22
  RoleDefinition,
20
23
  SequenceDefinition,
21
24
  TableDefinition,
@@ -60,6 +63,7 @@ def _build_control_tables(owner: str) -> list[TableDefinition]:
60
63
  ColumnDefinition("CTL_PROCESS_END", "date"),
61
64
  ColumnDefinition("CTL_ROWS_PROCESSED", "number", precision=18, scale=0),
62
65
  ColumnDefinition("CTL_PLSQL", "clob"),
66
+ ColumnDefinition("CTL_RUN_ID", "string", length=36),
63
67
  ),
64
68
  primary_key=("CTL_OWNER", "CTL_TABLE_NAME"),
65
69
  ),
@@ -81,6 +85,22 @@ def _build_control_tables(owner: str) -> list[TableDefinition]:
81
85
  ),
82
86
  primary_key=("LOG_ID",),
83
87
  ),
88
+ # Runner mutex: the 'L' row is locked by the live runner; the 'K' row publishes its run_id
89
+ TableDefinition(
90
+ owner=owner,
91
+ name="LDB_RUN_LOCK",
92
+ columns=(
93
+ ColumnDefinition("RL_OWNER", "string", length=50, nullable=False),
94
+ ColumnDefinition("RL_TABLE_NAME", "string", length=50, nullable=False),
95
+ ColumnDefinition("RL_KIND", "string", length=1, nullable=False),
96
+ ColumnDefinition("RL_RUN_ID", "string", length=36),
97
+ ColumnDefinition("RL_MODE", "string", length=10),
98
+ ColumnDefinition("RL_HOST", "string", length=255),
99
+ ColumnDefinition("RL_PID", "number", precision=18, scale=0),
100
+ ColumnDefinition("RL_START", "date"),
101
+ ),
102
+ primary_key=("RL_OWNER", "RL_TABLE_NAME", "RL_KIND"),
103
+ ),
84
104
  ]
85
105
 
86
106
 
@@ -164,13 +184,15 @@ def run_cli() -> None:
164
184
  source_has_admin = bool(administration.source.username and administration.source.password)
165
185
  history_has_admin = bool(administration.history.username and administration.history.password)
166
186
 
167
- def write_script(env: str, **definitions: Any) -> None:
168
- content = engine.generate_bootstrap_script(title=f"{env} database of schema {config.schema}", **definitions)
187
+ def write_script(env: str, steps: Callable[[DdlSink], None]) -> None:
188
+ fragments: list[str] = []
189
+ steps(fragments.append)
190
+ title = f"{env} database of schema {config.schema}"
169
191
  stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
170
192
  path = Path(config.execution.ddl_pending_dir) / f"bootstrap_{config.schema}_{env.lower()}_{stamp}.sql"
171
193
  try:
172
194
  path.parent.mkdir(parents=True, exist_ok=True)
173
- path.write_text(content, encoding="utf-8")
195
+ path.write_text(render_script(title, fragments), encoding="utf-8")
174
196
  except OSError as exc:
175
197
  logger.error(
176
198
  "Admin credentials for %s are absent and the script could not be written to %s: %s", env, path, exc
@@ -178,29 +200,48 @@ def run_cli() -> None:
178
200
  raise SystemExit(1) from exc
179
201
  script_files.append(path)
180
202
 
203
+ def bootstrap_steps(
204
+ roles: list[RoleDefinition],
205
+ user: UserDefinition,
206
+ tables: list[TableDefinition],
207
+ sequences: list[SequenceDefinition],
208
+ link: DatabaseLinkDefinition,
209
+ runtime: tuple[str, str],
210
+ ) -> Callable[[DdlSink], None]:
211
+ def steps(sink: DdlSink) -> None:
212
+ engine.script_password_prompts([user], [link], ddl_sink=sink)
213
+ engine.ensure_roles(None, roles, ddl_sink=sink)
214
+ engine.ensure_users(None, [user], ddl_sink=sink)
215
+ engine.ensure_tables(None, tables, ddl_sink=sink)
216
+ engine.ensure_sequences(None, sequences, ddl_sink=sink)
217
+ engine.ensure_supporting_objects(None, runtime[0], ddl_sink=sink)
218
+ engine.ensure_database_links(None, [link], ddl_sink=sink, connect_as=runtime)
219
+
220
+ return steps
221
+
181
222
  if not source_has_admin:
182
223
  write_script(
183
224
  "SOURCE",
184
- roles=source_roles,
185
- users=[source_user],
186
- tables=source_tables,
187
- sequences=source_sequences,
188
- supporting_owner=connections.source.username,
189
- links=[source_db_link],
190
- runtime_username=connections.source.username,
191
- runtime_dsn=connections.source.dsn,
225
+ bootstrap_steps(
226
+ source_roles,
227
+ source_user,
228
+ source_tables,
229
+ source_sequences,
230
+ source_db_link,
231
+ (connections.source.username, connections.source.dsn),
232
+ ),
192
233
  )
193
234
  if not history_has_admin:
194
235
  write_script(
195
236
  "HISTORY",
196
- roles=history_roles,
197
- users=[history_user],
198
- tables=history_tables,
199
- sequences=history_sequences,
200
- supporting_owner=connections.history.username,
201
- links=[history_db_link],
202
- runtime_username=connections.history.username,
203
- runtime_dsn=connections.history.dsn,
237
+ bootstrap_steps(
238
+ history_roles,
239
+ history_user,
240
+ history_tables,
241
+ history_sequences,
242
+ history_db_link,
243
+ (connections.history.username, connections.history.dsn),
244
+ ),
204
245
  )
205
246
 
206
247
  if source_has_admin:
@@ -229,15 +270,29 @@ def run_cli() -> None:
229
270
  finally:
230
271
  engine.close_connection(history_admin_conn)
231
272
 
273
+ def connect_as_runtime(env: Literal["SOURCE", "HISTORY"], username: str) -> Any:
274
+ # The user exists here (ensure_users ran and never changes an existing password), so a rejected
275
+ # login can only be a configured password that differs from the one the user already has.
276
+ try:
277
+ return engine.get_connection(config, admin=False, env=env)
278
+ except InvalidCredentialsError as exc:
279
+ logger.error(
280
+ "The runtime user %s exists in %s but rejected the configured password; ldb-impl does not "
281
+ "change the password of an existing user. Set the configured password to the user's current one.",
282
+ username,
283
+ env,
284
+ )
285
+ raise SystemExit(1) from exc
286
+
232
287
  if source_has_admin:
233
- source_user_conn = engine.get_connection(config, admin=False, env="SOURCE")
288
+ source_user_conn = connect_as_runtime("SOURCE", connections.source.username)
234
289
  try:
235
290
  summary["source_database_links"] = engine.ensure_database_links(source_user_conn, [source_db_link])
236
291
  finally:
237
292
  engine.close_connection(source_user_conn)
238
293
 
239
294
  if history_has_admin:
240
- history_user_conn = engine.get_connection(config, admin=False, env="HISTORY")
295
+ history_user_conn = connect_as_runtime("HISTORY", connections.history.username)
241
296
  try:
242
297
  summary["history_database_links"] = engine.ensure_database_links(history_user_conn, [history_db_link])
243
298
  finally: