Betrieb: Verbindungsdatei, Summen mit Abgleich vor dem Schreiben, Laufhistorie

This commit is contained in:
wkh
2026-10-09 21:46:50 +02:00
parent fffb2e4961
commit baf09d3947
7 changed files with 390 additions and 11 deletions
+67 -3
View File
@@ -575,6 +575,7 @@ flowchart LR
| [lib/datapipeline/validation.rb](lib/datapipeline/validation.rb) | Regeln für `validate` |
| [lib/datapipeline/watermark.rb](lib/datapipeline/watermark.rb) | Wasserzeichen für inkrementelle Läufe |
| [lib/datapipeline/lock.rb](lib/datapipeline/lock.rb) | Sperre gegen parallele Läufe |
| [lib/datapipeline/connections.rb](lib/datapipeline/connections.rb) | Verbindungsdatei `config/connections.yml` |
| [lib/datapipeline/layout.rb](lib/datapipeline/layout.rb) | Satzarten-Layouts (Feldliste je Satzart, siehe 2.9) |
| [exe/etl](exe/etl) | Kommando `etl` des Gems mit Exit-Codes (siehe 2.7); [bin/etl](bin/etl) startet es im Repository mit dem Bundle |
| [datapipeline.gemspec](datapipeline.gemspec), [CHANGELOG.md](CHANGELOG.md) | Gem-Beschreibung, Versionsverlauf |
@@ -616,6 +617,10 @@ flowchart LR
| `validate(rules \| layout, warn: false, ignore: []) { \|row\| ... }` | Prüfschritt (siehe 2.7, 2.9); `warn: true` nur Warnung, Zeile bleibt; schaltet Verkettung ein |
| `on_error(:abort \| :skip \| :collect, to: storage)` | Umgang mit Fehlern in Datenschritten (siehe 2.7) |
| `on_finish { \|stats\| ... }` | nach jedem Lauf, auch nach Abbruch |
| `totals 'dmbtr', …` | Summen dieser Spalten über die Quellzeilen und über die Zeilen nach allen Schritten (siehe 2.7) |
| `reconcile { \|stats\| ... }` | Abgleich nach allen Schritten, vor dem Schreiben; eine Meldung bricht mit `DataPipeline::ReconcileError` ab |
| `name(str)`, `history(file)` | Name des Laufs; eine JSON-Zeile je Lauf in `file` (siehe 2.7) |
| `connection(:name, **opts)` | Storage aus der Verbindungsdatei (siehe 2.7); `container(:typ, connection: :name)` ebenso |
| `watermark(name, column:, file:, initial:)` | letzter verarbeiteter Wert für inkrementelle Läufe (siehe 2.7) |
| `lock(file)` | Sperre während `run`; läuft schon ein Lauf mit derselben Datei, bricht dieser mit `DataPipeline::LockError` ab (siehe 2.7) |
| `dry_run(flag = true)`, `limit(n)` | nichts schreiben bzw. nur die ersten n Quellzeilen |
@@ -859,8 +864,11 @@ validate { |row| 'Betrag 0' if row['dmbtr'].to_f.zero? }
| `warnings` | Zeilen mit Warnung aus `validate …, warn: true` |
| `duration`, `started_at`, `finished_at` | Laufzeit |
| `error` | Exception eines abgebrochenen Laufs |
| `name` | Name aus `name`, über `etl` Default der Skriptname ohne `.rb` |
| `totals`, `total(spalte, :read \| :written)` | Summen aus `totals` als `Rational` (exakt) |
| `to_h` | alles als Hash, wie in der Laufhistorie |
| `ok?` | kein Abbruch und nichts verworfen |
| `to_s` | `read=9 written=4 rejected=4 0.097s` |
| `to_s` | `read=9 written=4 rejected=4 0.097s`, mit `totals` zusätzlich `dmbtr=1500.25/1500.25` (Quelle/Ergebnis) |
Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung) und einen Abbruch (Fehler).
`on_finish` läuft nach jedem Lauf, auch nach einem Abbruch; ein Fehler im Hook wird nur geloggt.
@@ -955,6 +963,61 @@ wm = watermark :rechnungen, column: 'rechnung_id', file: 'data/.state.json', ini
read pg, query: 'select * from rechnung where rechnung_id > $1 order by rechnung_id', params: [wm.value]
```
**Verbindungen** – Zugangsdaten stehen nicht in den Skripten, sondern einmal in `config/connections.yml` (oder der
Datei aus `DataPipeline.connections_file = …` bzw. `ENV['DATAPIPELINE_CONNECTIONS']`). Die Datei wird mit ERB
gelesen, Passwörter kommen so aus der Umgebung und die Datei kann ins Repository. `type` ist der Plugin-Typ, alle
anderen Schlüssel sind dessen Optionen; Optionen beim Aufruf haben Vorrang.
```yaml
# config/connections.yml
pds:
type: postgres
host: <%= ENV.fetch('PDS_HOST', 'localhost') %>
database: pds
username: etl
password: <%= ENV['PDS_PASSWORD'] %>
```
```ruby
read connection(:pds, query: 'select * from rechnung where datum >= $1', params: [params[:from]])
write container(:postgres, connection: :pds, table: 'export_log') # Typ muss zur Verbindung passen
```
Eine unbekannte Verbindung, eine fehlende Datei oder ein abweichender Typ bricht mit `ArgumentError` ab. Die Datei
wird je Prozess einmal gelesen.
**Summen und Abgleich** – Bei einer Buchhaltungsschnittstelle reicht es nicht, dass der Lauf durchläuft: Die Summen
müssen stimmen. `totals` summiert Spalten über die Quellzeilen (`:read`) und über die Zeilen nach allen Schritten
(`:written`, ohne Kopf- und Schlusssätze), exakt als `Rational`. Leere und nicht numerische Werte zählen nicht mit.
`reconcile` läuft nach allen Schritten und **vor dem Schreiben**; gibt der Block eine Meldung zurück, bricht der Lauf
mit `DataPipeline::ReconcileError` ab und schreibt nichts (Exit-Code `1`, `on_finish` läuft). Im Streaming läuft
`reconcile` nach der letzten Zeile, die Zeilen sind dann schon geschrieben; mit Postgres und `batch_size: :all` werden
sie beim Abbruch zurückgerollt (2.5).
```ruby
totals 'dmbtr'
reconcile do |stats|
quelle, ziel = stats.total('dmbtr', :read), stats.total('dmbtr')
"Summe dmbtr: Quelle #{DataPipeline.decimal(quelle)}, Ergebnis #{DataPipeline.decimal(ziel)}" if quelle != ziel
end
reconcile { |stats| 'keine Buchungen' if stats.written.zero? }
```
`DataPipeline.decimal(wert)` gibt eine Summe als exakten Dezimaltext aus (`1500.25` statt `6001/4`).
**Laufhistorie** – `history 'data/runs.ndjson'` (oder `etl … --history=data/runs.ndjson` für alle Pipelines eines
Skripts) hängt nach jedem Lauf eine JSON-Zeile an, auch nach einem Abbruch und im Dry-Run. Damit lässt sich später
beantworten, wann der letzte erfolgreiche Lauf war und wie viele Zeilen er hatte. Ein Fehler beim Schreiben der
Historie wird nur geloggt.
```json
{"name":"cudo","version":"1.0","started_at":"2026-10-09T06:00:00.123+02:00","finished_at":"2026-10-09T06:00:04.336+02:00","duration":4.213,"read":1250,"written":1243,"output":1245,"rejected":7,"warnings":0,"dry_run":false,"ok":false,"error":null,"totals":{"dmbtr":{"read":"98231.4","written":"97810.1"}}}
```
```ruby
letzter = DataPipeline.history('data/runs.ndjson', name: 'cudo').reverse.find { |run| run['ok'] }
```
**Sperre gegen parallele Läufe** – Startet ein Cronjob, während der vorige Lauf noch arbeitet, lesen beide dasselbe
Wasserzeichen und exportieren doppelt. `lock` verhindert das mit einer exklusiven Sperre (`flock`) auf eine Datei:
Ein zweiter Lauf wartet nicht, sondern bricht sofort mit `DataPipeline::LockError` ab (Exit-Code `3`), schreibt nichts
@@ -988,6 +1051,7 @@ bin/etl run pipeline.rb --param land=DE # im Skript: params[:land]
| `--param KEY=VALUE` | `params[:key]`, mehrfach möglich |
| `--dry-run`, `--limit=N` | für alle Pipelines des Skripts |
| `--lock=FILE` | Sperre für das ganze Skript; ist sie belegt, endet `etl` sofort mit Exit-Code `3` |
| `--history=FILE` | Laufhistorie für alle Pipelines des Skripts; als `name` gilt der Skriptname |
| `--verbose`, `--quiet` | Log-Level Debug bzw. Warnung (Default über `bin/etl`: Info) |
| jede andere `--name=wert` | `params[:name]` und unverändert an das Skript, z. B. `--out=data`, `--incremental=1` |
@@ -996,7 +1060,7 @@ Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen, `3` ein
### 2.8 Tests
`rake test` (oder `bundle exec rake`) startet die Minitest-Suite in [test/](test/): rund 165 Fälle für Plugins, Kern,
`rake test` (oder `bundle exec rake`) startet die Minitest-Suite in [test/](test/): rund 205 Fälle für Plugins, Kern,
Streaming, Betrieb, Kommandozeile und die Beispiele.
| Ordner | Inhalt |
@@ -1008,7 +1072,7 @@ Streaming, Betrieb, Kommandozeile und die Beispiele.
Die `.env` wird in Tests nicht geladen; kein Test erreicht die Produktivdatenbank. Postgres-Tests legen je Test eine
Wegwerf-Datenbank auf einem lokalen Server an (`ETL_TEST_PG_HOST`, `_PORT`, `_USER`, `_PASSWORD`; Default
`localhost`, `$USER`). Ohne erreichbaren Server werden sie übersprungen (21 Tests). Ein eigenes Plugin lässt sich mit
`localhost`, `$USER`). Ohne erreichbaren Server werden sie übersprungen (23 Tests). Ein eigenes Plugin lässt sich mit
`MemoryStorage` aus `test/test_helper.rb` als Quelle oder Ziel testen; mit `include StorageContract` und einer
Methode `contract_storage(env, :write | :read)` bekommt es die gemeinsamen Tests.