Betriebssicherheit: Sperre gegen parallele Läufe, Upsert, Batch-Schreiben für Postgres, Zeichensatz für CSV

This commit is contained in:
wkh
2026-10-09 21:38:54 +02:00
parent a86caf5364
commit 48c900558b
15 changed files with 467 additions and 21 deletions
+44 -7
View File
@@ -1,6 +1,6 @@
# DataPipeline – generisches ETL-Framework in Ruby
Stand: 2026-10-08 · Gem `datapipeline` 2.0.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4
Stand: 2026-10-09 · Gem `datapipeline` 2.1.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4
---
@@ -574,6 +574,7 @@ flowchart LR
| [lib/datapipeline.rb](lib/datapipeline.rb) | DSL und Ausführung (`run`), `Stats`, `Context` |
| [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/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 |
@@ -611,6 +612,7 @@ flowchart LR
| `on_error(:abort \| :skip \| :collect, to: storage)` | Umgang mit Fehlern in Datenschritten (siehe 2.7) |
| `on_finish { \|stats\| ... }` | nach jedem Lauf, auch nach Abbruch |
| `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 |
| `dry_run?` | in Schritten mit Nebenwirkungen (API-Aufrufe): `true` im Dry-Run |
| `logger(logger)` | eigener Ruby-`Logger`; Default: Warnungen auf `$stderr` |
@@ -664,7 +666,7 @@ mit 30 MB statt 614 MB im Batch). Die Datenzeilen sind dieselben wie im Batch, m
| `header` | kennt `read` und `written` | wird zuerst geschrieben, `read` und `written` sind `nil` |
| `ctx.read` in `map`/`filter` | Anzahl Quellzeilen | `nil` |
| mehrere `transform` im Parallelmodus | erlaubt | `ArgumentError`, dafür `chained` setzen |
| Postgres als Ziel | eine Transaktion für alles | je Zeile ein `INSERT` ohne gemeinsame Transaktion |
| Postgres als Ziel | eine Transaktion für alles | eine Transaktion je `batch_size` Zeilen (Default 1000); `batch_size: :all` eine für alles |
Postgres als Quelle liest im Streaming über einen serverseitigen Cursor (`fetch_size`, Default 1000). Abfragen und
Lookups auf derselben Verbindung bleiben währenddessen möglich.
@@ -689,21 +691,31 @@ Alle Plugins liefern Zeilen mit **String-Schlüsseln**. Die mitgelieferten Plugi
### 2.5 Mitgelieferte Plugins
**DataPipeline::PostgresStorage** – Optionen `host`, `port` (Default 5432), `database`, `username`, `password`, `query`, `params`,
`table`, `typed`, `fetch_size`, `truncate`.
`table`, `typed`, `fetch_size`, `truncate`, `upsert`, `batch_size`.
- `read` / `query` mit Platzhaltern `$1, $2, …` und `params: [...]`; Ergebnis als `Hash<String, String>`
(`NULL` = `nil`), mit `typed: true` als Ruby-Typen (Integer, Float, Date, …).
- `read { |row| }` liest über einen Cursor in einer Transaktion, je `fetch_size` Zeilen (Default 1000).
- `write(rows)` schreibt in `table:` (oder alte Form `write(table, rows)`), alle Zeilen in **einer Transaktion**,
Werte als Bind-Parameter, `nil` = `NULL`, Tabellen-/Spaltennamen gequotet (`schema.tabelle` erlaubt).
- `truncate: true` leert die Tabelle vor dem ersten Schreiben (bei `write` in derselben Transaktion).
- `upsert: %w[belnr]` schreibt `insert … on conflict (belnr) do update set …` für alle übrigen Spalten der Zeile (ohne
übrige Spalten `do nothing`). Die Tabelle braucht einen Primärschlüssel oder Unique-Index auf genau diesen Spalten.
Damit kann ein Lauf nach einem Abbruch wiederholt werden, ohne doppelte Zeilen.
- `write_row` (Streaming) sammelt die Zeilen und schreibt je `batch_size` Zeilen (Default 1000) in einer Transaktion;
`finalize` und `close` schreiben den Rest. Mit `batch_size: :all` läuft der ganze Stream in **einer** Transaktion:
`finalize` am Ende eines erfolgreichen Laufs bestätigt sie, nach einem Abbruch rollt `close` sie zurück, auch ein
`truncate`. Dafür braucht das Ziel eine eigene Verbindung (nicht dieselbe wie die Quelle).
```ruby
pg = container :postgres, host: 'localhost', database: 'crm', username: 'etl', table: 'kunde_import'
read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today - 7]
# wiederholbar: vorhandene Belege werden aktualisiert, im Streaming alles oder nichts
write container(:postgres, **db, table: 'beleg', upsert: %w[belnr], batch_size: :all)
```
**DataPipeline::CsvStorage** – Optionen `file` (Pflicht), `csv:` (an Rubys `CSV`, gemergt mit den Defaults `headers: true`,
`col_sep: ','`), `columns`, `counter`, `verbose`.
`col_sep: ','`), `columns`, `encoding`, `replace`, `bom`, `counter`, `verbose`.
- `columns: %w[a b c]` legt die Spaltenreihenfolge fest: fehlende Felder bleiben leer, String- und Symbol-Schlüssel
werden gefunden, die Kopfzeile kommt aus `columns`. Ohne `columns` gilt die Hash-Reihenfolge (nötig für Dateien mit
mehreren Satzarten wie CUDO).
@@ -711,13 +723,22 @@ read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today
- Leere Daten: ohne `columns` leere Datei, mit `columns` nur die Kopfzeile.
- `layout: DataPipeline::Layout` schreibt Dateien mit mehreren Satzarten in der Feldreihenfolge je Satzart, ohne
Kopfzeile; ein Feld, das nicht im Layout steht, bricht ab (siehe 2.9).
- `encoding: 'ISO-8859-1'` (oder `'Windows-1252'`, …) ist der Zeichensatz der Datei, Default UTF-8. Gelesen wird als
UTF-8, geschrieben wird von UTF-8 umkodiert. Ein Zeichen, das der Zeichensatz nicht kennt (z. B. `€` in ISO-8859-1),
bricht mit `Encoding::UndefinedConversionError` ab; mit `replace: '?'` wird es ersetzt. Ein UTF-8-BOM am
Dateianfang wird beim Lesen übersprungen, `bom: true` schreibt eines (für Excel). Ein `encoding` in `csv:` wird wie
bisher unverändert an Ruby weitergegeben.
```ruby
write container(:csv, file: 'out/cudo.txt', encoding: 'Windows-1252', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
```
Übersicht der übrigen Plugins (alle mit `read`, `read { }`, `write`, `write_row`, `close`):
| `container` | Klasse | Gem (bei Nutzung geladen) | Wichtige Optionen |
| --- | --- | --- | --- |
| `:json` | `DataPipeline::JsonStorage` | – | `file`, `format: :array \| :lines` (Default aus Endung: `.ndjson`/`.jsonl` → Zeilen), `root: 'data.items'`, `pretty`; ein einzelnes Objekt wird als eine Zeile gelesen |
| `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate` |
| `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate`, `upsert` |
| `:excel`, `:xlsx` | `DataPipeline::ExcelStorage` | `roo` (lesen), `caxlsx` (schreiben) | `file`, `sheet` (Name/Index), `headers`, `columns`, `skip` (Titelzeilen) |
| `:rest` | `DataPipeline::RestStorage` | `http` | `url`, `auth`, `headers`, `params`, `root`, Paging `page_param` oder `next: ->(body) { … }`, `retries`, `timeout`, `batch_size`, `write_method`, `write_url` (auch als Lambda je Zeile), `ssl_verify` |
@@ -727,7 +748,8 @@ read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today
**DataPipeline::SqliteStorage** – Verhält sich wie Postgres: Bind-Parameter, Schreiben in einer Transaktion, Tabellen- und
Spaltennamen gequotet, `nil` = `NULL`, Datum als ISO-Text. Werte kommen mit SQLite-Typen zurück (Integer, Float, String).
`create: true` legt die Tabelle beim ersten Schreiben aus der ersten Zeile an (Integer → `integer`, Float → `real`,
sonst `text`), `truncate: true` leert sie vorher. Die Datei wird erst bei der ersten Nutzung geöffnet; ein Dry-Run
sonst `text`), `truncate: true` leert sie vorher. `upsert: %w[belnr]` wie bei Postgres; mit `create: true` wird der
nötige Unique-Index gleich mit angelegt. Die Datei wird erst bei der ersten Nutzung geöffnet; ein Dry-Run
legt keine an.
**DataPipeline::ExcelStorage** – Lesen von xlsx zeilenweise (`each_row_streaming`), leere Zeilen werden übersprungen. Beim
@@ -911,6 +933,20 @@ 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]
```
**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
und ruft wie jeder Abbruch `on_finish` auf. Das Betriebssystem gibt die Sperre beim Ende des Prozesses frei, auch nach
einem Absturz; es bleiben also keine verwaisten Sperren. Die Datei bleibt liegen und enthält die PID des letzten Laufs.
```ruby
lock 'data/.cudo.lock' # in der Pipeline: von run bis zum Ende des Laufs
```
```sh
bin/etl run cudo.rb --lock=data/.cudo.lock # für das ganze Skript, z. B. mehrere Pipelines nacheinander
```
**Dry-Run und Limit** – Im Dry-Run läuft alles, aber nichts wird geschrieben: kein Ziel, keine Fehlerdatei, kein
Wasserzeichen; eine CSV-Datei wird nicht angelegt. Am Ende stehen die Zusammenfassung und die ersten Zeilen
(`preview`, Default 10) als JSON auf `$stdout`. `limit n` verarbeitet nur die ersten n Quellzeilen, im Streaming
@@ -929,10 +965,11 @@ bin/etl run pipeline.rb --param land=DE # im Skript: params[:land]
| `--from`, `--till` | in `params[:from]`/`params[:till]` und unverändert an das Skript weitergereicht (eigener `OptionParser` der Importer) |
| `--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` |
| `--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` |
Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen. Das `Rakefile` ruft `bin/etl` auf:
Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen, `3` ein anderer Lauf hält die Sperre. Das `Rakefile` ruft `bin/etl` auf:
`rake process:posting[2025-11-01,2025-11-30]`, `rake process:all[2025-11-01,2025-11-30,dry]`. Ein fehlgeschlagener Lauf stoppt Rake.
### 2.8 Tests