Files
datapipeline/README.md
T

80 KiB
Raw Blame History

DataPipeline – ETL-Framework in Ruby

Stand: 2026-10-09 · Gem datapipeline 3.0.0 (CHANGELOG.md) · Ruby 3.4

DataPipeline liest Daten aus einer Quelle, schickt jede Zeile durch eine Kette von Schritten (prüfen, filtern, umbauen, anreichern, abgleichen) und schreibt das Ergebnis in ein oder mehrere Ziele. Eine Pipeline ist ein kurzes Ruby-Skript; Quellen und Ziele sind austauschbare Plugins für PostgreSQL, SQLite, CSV, JSON/NDJSON, Excel, Festbreiten-Dateien und REST-APIs.

Inhalt

  1. Überblick – was das Gem kann, Schnellstart, Grundbegriffe
  2. Beispiele – sieben lauffähige Pipelines, vom Import bis zum produktiven Export
  3. Pipelines schreiben – DSL, Schritte, Prüfen, Fehler, Ergebnis
  4. Plugins – mitgelieferte Quellen und Ziele, eigene Plugins, Abhängigkeiten
  5. Satzarten-Layouts – Dateien mit Kopf-, Positions- und Schlusssätzen
  6. Betrieb – Kommandozeile, Verbindungen, Sperre, Abgleich, Historie, Benachrichtigung, Fehlersuche
  7. Umstieg – von 2.x auf 3.0 (transform entfernt), von 2.0 auf 2.1
  8. Entwicklung – Aufbau, Tests, Gem bauen
  9. Ausblick

1. Überblick

1.1 Was das Gem kann

Bereich Möglichkeiten
Lesen und Schreiben PostgreSQL, SQLite, CSV, JSON/NDJSON, Excel, Festbreiten-Dateien, REST-APIs; eigene Plugins mit wenigen Methoden; mehrere Ziele je Pipeline, auch mit Auswahl je Ziel
Umbauen map (1:1, 1:n, 1:0), filter, Kurzschritte rename, select, cast, default; Nachschlagetabellen mit lookup; Kopf- und Schlusssätze mit Zählern
Prüfen Regeln je Spalte (Pflicht, Länge, Format, Werteliste, Typ), eigene Regeln als Block, Satzarten-Layouts; als Fehler oder nur als Warnung
Fehler Abbruch, Zeile überspringen oder mit Grund in eine Fehlerdatei schreiben
Datenmengen Batch im Speicher oder Streaming Zeile für Zeile mit konstantem Speicherbedarf; Postgres-Cursor und Schreiben in Paketen
Betrieb Kommandozeile etl mit Exit-Codes, Dry-Run, Limit, inkrementelle Läufe (Wasserzeichen), Sperre gegen parallele Läufe, Upsert für wiederholbare Läufe, Summenabgleich vor dem Schreiben, Laufhistorie, Verbindungsdatei, Slack
Dateiformate Zeichensatz (z. B. Windows-1252) für CSV und Festbreite, BOM für CSV; Satzarten-Dateien schreiben, prüfen und lesen

Anwendungen: Das Projekt etl nutzt das Gem für die PDS → SAP-Schnittstelle (CUDO) und für das Anlegen von Rechnungen über die pds API; dort liegen auch deren Doku und Tests.

1.2 Schnellstart

Im Repository:

mise install                                          # Ruby 3.4.9 aus mise.toml
bundle install
bundle exec rake test                                 # Tests; Postgres-Tests nur mit lokalem Server (8.2)
bin/etl run examples/kunden_import.rb                 # Beispiel 2.1, Ergebnis in tmp/
bin/etl run examples/kunden_import.rb --dry-run       # alles prüfen, nichts schreiben

In einer anderen Anwendung:

# Gemfile
gem 'datapipeline', path: '../datapipeline'        # oder git: '…'
gem 'pg'                                            # nur die Gems der benötigten Plugins (4.10)
bundle install
bundle exec etl pipeline.rb --dry-run               # das Kommando etl kommt mit dem Gem

Die kleinste sinnvolle Pipeline kopiert eine CSV-Datei nach JSON:

require 'datapipeline'

DataPipeline.new do
  read  container(:csv, file: 'kunden.csv')
  write container(:json, file: 'kunden.json')
end.run

1.3 Grundbegriffe

Begriff Bedeutung
Pipeline DataPipeline.new do … end: eine Quelle, Schritte, ein oder mehrere Ziele; run führt sie aus
Zeile ein Hash mit String-Schlüsseln, z. B. { 'kundennr' => '100001', 'name' => 'Müller GmbH' }
Plugin, Storage liest oder schreibt Zeilen (container(:csv, …)); dieselbe Klasse dient als Quelle und als Ziel
Schritt validate, filter, map und die Kurzschritte; jede Zeile läuft der Reihe nach durch alle Schritte
Kopf-, Schlusssatz header, trailer: Zeilen vor bzw. nach den Daten, z. B. mit der Anzahl Datensätze
Batch, Streaming Batch lädt die Quelle ganz in den Speicher, Streaming (streamed) verarbeitet Zeile für Zeile
Lauf, Stats ein Aufruf von run; das Ergebnis DataPipeline::Stats enthält Zähler, Fehler, Summen und Laufzeit
Satzart, Layout Dateien mit verschiedenen Zeilenarten (Kopf, Position, Ende) und fester Feldliste je Art (Kapitel 5)
Wasserzeichen der höchste verarbeitete Wert einer Spalte, damit der nächste Lauf nur Neues liest (6.5)

2. Beispiele

Alle Beispiele liegen in examples/, lesen die Daten in examples/data/ und schreiben nach tmp/ (--out=verzeichnis ändert das). test/examples/doc_examples_test.rb führt jedes aus, prüft das Ergebnis und dass der Code hier genau der Datei entspricht.

Beispiel Zeigt
2.1 Kundenimport Prüfen, Nachschlagen, Fehlerdatei, zwei Ziele
2.2 Datei mit Satzarten 1:n-Umbau, Kopf- und Schlusssatz, Layout
2.3 Inkrementell und gestreamt Wasserzeichen, Streaming
2.4 Eigenes Plugin eine Quelle selbst schreiben
2.5 REST-APIs Paging, Wiederholung, Pakete, eine URL je Datensatz
2.6 Produktiver Lauf Verbindungsdatei, Sperre, Kurzschritte, Summenabgleich, Upsert, Zeichensatz, Historie, Slack

2.1 Kundenimport

Eine Kundenliste (examples/data/kunden.csv, 9 Zeilen) soll geprüft, mit Ländernamen angereichert und in eine SQLite-Datenbank geschrieben werden; EU-Kunden kommen zusätzlich in eine Excel-Liste. Fehlerhafte Zeilen sollen den Lauf nicht abbrechen, sondern mit Grund in einer eigenen Datei landen.

flowchart LR
  CSV[kunden.csv] --> V1[validate<br/>Pflicht, Format, Typ]
  LJ[laender.json] -. lookup .-> V2
  V1 --> V2[validate<br/>Land bekannt]
  V2 --> F[filter<br/>nur aktiv]
  F --> M[map<br/>anreichern, umbauen]
  LJ -. lookup .-> M
  M --> DB[(kunden.sqlite3<br/>alle)]
  M -->|only: EU| XL[eu_kunden.xlsx]
  V1 -. on_error .-> ERR[kunden_fehler.csv]
  V2 -. on_error .-> ERR
#
# Kundenliste aus CSV prüfen, mit Ländern anreichern, nach SQLite und Excel schreiben
#
#   bin/etl run examples/kunden_import.rb [--out=tmp] [--dry-run]
#
require 'fileutils'
require 'datapipeline'

DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

DataPipeline.new do
  version 'kunden_import 1.0'

  # Nachschlagetabelle, einmal geladen: { 'DE' => { 'code' => 'DE', 'name' => 'Deutschland', 'eu' => true }, ... }
  laender = lookup container(:json, file: "#{DATA}/laender.json"), key: 'code', unique: true

  read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' })

  # fehlerhafte Zeilen mit Grund in eine eigene Datei, der Lauf geht weiter
  on_error :collect, to: container(:csv, file: "#{out}/kunden_fehler.csv")

  # 1. prüfen
  validate 'kundennr' => { required: true, format: /\A\d{6}\z/ },
           'name' => { required: true, max: 40 },
           'umsatz' => { type: :decimal }
  validate { |row| "Land #{row['land']} unbekannt" unless laender.key?(row['land']) }

  # 2. nur aktive Kunden
  filter { |row| row['status'] == 'aktiv' }

  # 3. anreichern und umbauen
  map do |row|
    land = laender.fetch(row['land'])
    {
      'kundennr' => row['kundennr'],
      'name' => row['name'].strip,
      'land' => land['name'],
      'eu' => land['eu'] ? 'ja' : 'nein',
      'umsatz' => row['umsatz'].to_f.round(2)
    }
  end

  # 4. schreiben: alle Kunden nach SQLite, EU-Kunden zusätzlich als Excel-Liste
  write container(:sqlite, database: "#{out}/kunden.sqlite3", table: 'kunden', create: true, truncate: true)
  write container(:excel, file: "#{out}/eu_kunden.xlsx", sheet: 'EU-Kunden'), only: ->(row) { row['eu'] == 'ja' }

  on_finish { |stats| puts "kunden_import: #{stats}" }
end.run
Zeile Erklärung
DataPipeline.params.fetch(:out, 'tmp') Parameter von der Kommandozeile: --out=verzeichnis
lookup …, key: 'code', unique: true lädt die Länder einmal als Hash nach Ländercode; kein Zugriff je Zeile
on_error :collect, to: … jede Zeile, die in einem Schritt scheitert, kommt mit _step und _error in die Fehlerdatei
validate 'kundennr' => { … } Regeln je Spalte (3.5)
validate { |row| … } eigene Regel: ein Text (oder ein Array) ist ein Fehler, nil heisst in Ordnung
filter inaktive Kunden fallen weg, ohne Fehler
map baut die Zielzeile; laender.fetch kann nicht scheitern, weil validate das Land schon geprüft hat
write … zweimal jedes Ziel bekommt die Zeilen; only: wählt die Zeilen für die Excel-Liste
create: true, truncate: true SQLite legt die Tabelle beim ersten Schreiben an und leert sie; im Dry-Run passiert nichts
on_finish läuft nach jedem Lauf, auch nach einem Abbruch; stats siehe 3.7

Ergebnis von bin/etl run examples/kunden_import.rb:

INFO start kunden_import 1.0
WARN rejected by validate#1: name: missing
WARN rejected by validate#1: kundennr: format /\A\d{6}\z/
WARN rejected by validate#2: Land XX unbekannt
WARN rejected by validate#1: umsatz: not decimal
INFO finish read=9 written=4 rejected=4 0.097s
kunden_import: read=9 written=4 rejected=4 0.097s
Datei Inhalt
tmp/kunden.sqlite3, Tabelle kunden 4 aktive, gültige Kunden (DE, AT, CH, IT) mit Ländername, eu, umsatz als Zahl
tmp/eu_kunden.xlsx, Blatt EU-Kunden 3 Kunden (ohne CH)
tmp/kunden_fehler.csv 4 Zeilen: leerer Name, Kundennummer mit 5 Stellen, Land XX, Umsatz abc – je mit _step und _error

Der Exit-Code ist 2, weil Zeilen verworfen wurden; ein Abbruch ergäbe 1, ein fehlerfreier Lauf 0. Mit --dry-run läuft alles gleich, es entstehen aber weder Datenbank noch Excel- oder Fehlerdatei; stattdessen stehen die ersten Zeilen auf dem Bildschirm.

2.2 Datei mit Satzarten

Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei- und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest (Kapitel 5). Ohne format: true formatiert das Layout nicht, deshalb bringt das map hier Datum und Beträge selbst ins Zielformat (delete('-'), format('%.2f', …)).

#
# Rechnungen aus verschachteltem JSON in eine Datei mit Satzarten (Datei-, Kopf-, Positions-, Schlusssatz)
#
#   bin/etl run examples/rechnungen_satzarten.rb [--out=tmp]
#
require 'fileutils'
require 'datapipeline'

DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

# Feld: [Name, Typ, Länge, Pflicht]; Typ AN Text, N Zahl (11.2 = 9 Vor-, 2 Nachkommastellen), DAT YYYYMMDD
LAYOUT = DataPipeline::Layout.new do
  record 'DATEI', [['SATYP', 'AN', 5, true], ['ERSTELLT', 'DAT', 8, true], ['ANZAHL', 'N', 6, true]]
  record 'KOPF',  [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true],
                   ['DATUM', 'DAT', 8, true], ['KUNNR', 'AN', 10, true], ['SUMME', 'N', '11.2', true]]
  record 'POS',   [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true],
                   ['POSNR', 'N', 3, true], ['TEXT', 'AN', 30], ['BETRAG', 'N', '11.2', true]]
  record 'ENDE',  [['SATYP', 'AN', 5, true], ['ANZAHL', 'N', 6, true]]
end

DataPipeline.new do
  read container(:json, file: "#{DATA}/rechnungen.json")

  # 1. eine Rechnung -> Kopfsatz + n Positionssätze
  map do |rechnung|
    positionen = rechnung['positionen'].each_with_index.map do |pos, i|
      { 'satyp' => 'POS', 'belnr' => rechnung['nr'], 'posnr' => format('%03d', i + 1),
        'text' => pos['text'], 'betrag' => format('%.2f', pos['menge'] * pos['preis']) }
    end
    kopf = { 'satyp' => 'KOPF', 'belnr' => rechnung['nr'], 'datum' => rechnung['datum'].delete('-'),
             'kunnr' => rechnung['kunde'], 'summe' => format('%.2f', positionen.sum { |p| p['betrag'].to_f }) }
    [kopf, *positionen]
  end

  # 2. laufende Nummer über alle Kopf- und Positionssätze
  map { |row, ctx| row.merge('lfdnr' => format('%06d', ctx.index + 1)) }

  # 3. gegen das Layout prüfen: zu langer Text wird gemeldet, die Zeile bleibt
  validate LAYOUT, warn: true

  # Datei- und Schlusssatz kennen die Anzahl der Datensätze
  header  { |ctx| { 'satyp' => 'DATEI', 'erstellt' => Date.today.strftime('%Y%m%d'), 'anzahl' => format('%06d', ctx.written) } }
  trailer { |ctx| { 'satyp' => 'ENDE', 'anzahl' => format('%06d', ctx.written) } }

  # Felder in der Reihenfolge des Layouts; Positionen zusätzlich als NDJSON
  write container(:csv, file: "#{out}/rechnungen.txt", layout: LAYOUT, csv: { headers: false, col_sep: '|' })
  write container(:json, file: "#{out}/positionen.ndjson"), only: ->(row) { row['satyp'] == 'POS' }
end.run

Ergebnis tmp/rechnungen.txt (die Warnung meldet den Positionstext mit mehr als 30 Zeichen):

DATEI|20261008|000005
KOPF|000001|R-1001|20251103|100001|380.50
POS|000002|R-1001|001|Beratung|300.00
POS|000003|R-1001|002|Reisekosten|80.50
KOPF|000004|R-1002|20251105|100002|1200.00
POS|000005|R-1002|001|Jahreslizenz Software mit Wartung und Support|1200.00
ENDE|000005

2.3 Inkrementell und gestreamt

Nur Bewegungen, die seit dem letzten erfolgreichen Lauf dazugekommen sind; jede Zeile wird sofort geschrieben, der Speicherbedarf bleibt auch bei Millionen Zeilen klein.

#
# Nur neue Buchungsbewegungen exportieren, Zeile für Zeile (Streaming)
# Ein zweiter Lauf ohne neue Daten schreibt nichts.
#
#   bin/etl run examples/bewegungen_inkrementell.rb [--out=tmp]
#
require 'fileutils'
require 'datapipeline'

DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

DataPipeline.new do
  streamed

  # höchste id des letzten erfolgreichen Laufs, gespeichert in tmp/.state.json
  stand = watermark :bewegungen, column: 'id', file: "#{out}/.state.json", initial: 0

  # bei einer Datenbank gehört der Filter ins SQL: where id > $1, params: [stand.value]
  read container(:json, file: "#{DATA}/bewegungen.ndjson")
  filter { |row| row['id'] > stand.value.to_i }
  map { |row| row.merge('soll_haben' => row['betrag'].negative? ? 'H' : 'S', 'betrag' => row['betrag'].abs) }

  write container(:json,
                  file: "#{out}/bewegungen_#{Time.now.strftime('%Y%m%d_%H%M%S')}.ndjson"
                 )

  on_finish { |stats| puts "neu: #{stats.written}, Stand jetzt: #{stand.current}" }
end.run

Der erste Lauf schreibt 4 Bewegungen und merkt sich 4; der zweite schreibt nichts (neu: 0) und legt auch keine Datei an. Bricht ein Lauf ab, bleibt der alte Stand stehen.

2.4 Eigenes Plugin

Eine Quelle, die alle CSV-Dateien eines Verzeichnisses liest. Ein Plugin braucht nur die Methoden, die es anbietet (hier read); die Schnittstelle steht in 4.1.

#
# Eigenes Plugin: liest alle CSV-Dateien eines Verzeichnisses als eine Quelle
#
#   bin/etl run examples/eigenes_plugin.rb [--out=tmp]
#
require 'fileutils'
require 'datapipeline'

DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

class VerzeichnisStorage < DataPipeline::StoragePlugin
  def initialize dir:, pattern: '*.csv', **csv_options
    @dir = dir
    @pattern = pattern
    @csv_options = csv_options
  end

  # jede Zeile bekommt den Dateinamen dazu; mit Block zeilenweise (für streamed)
  def read(&block)
    rows = Dir.glob(File.join(@dir, @pattern)).sort.flat_map do |file|
      DataPipeline::CsvStorage.new(file:, **@csv_options).read.map { |row| row.merge('datei' => File.basename(file, '.*')) }
    end
    return rows unless block

    rows.each(&block)
    nil
  end
end

DataPipeline.register :verzeichnis, VerzeichnisStorage

DataPipeline.new do
  read container(:verzeichnis, dir: "#{DATA}/filialen")
  map { |row| row.merge('menge' => row['menge'].to_i) }
  write container(:csv, file: "#{out}/alle_filialen.csv", columns: %w[datei artikel menge])
end.run

Ergebnis tmp/alle_filialen.csv:

datei,artikel,menge
vaduz,A-100,5
vaduz,B-200,2
zuerich,A-100,7
zuerich,C-300,1

2.5 REST-APIs

Die drei Beispiele laufen gegen eine kleine Demo-API (examples/demo_api.rb), die ohne Internet und ohne weitere Gems auskommt. Sie verlangt ein Token, liefert Seiten und Folge-Links, nimmt Daten per POST und PUT an und antwortet einmal absichtlich mit 503, damit die Wiederholung sichtbar wird.

ruby examples/demo_api.rb &                  # http://127.0.0.1:4567, Token demo-token
bin/etl run examples/rest_lesen.rb
bin/etl run examples/rest_schreiben.rb
bin/etl run examples/rest_zu_rest.rb

Gegen eine echte API ändern sich nur --url=… und das Token (DEMO_API_TOKEN, in der Praxis aus der Umgebung).

Endpunkt der Demo-API Antwort
GET /api/kunden?page=1&per_page=3 {"data": [...], "page": 1}; Seite 2 antwortet einmal mit 503
GET /api/laender [{"code": "DE", "name": "Deutschland"}, ...]
GET /api/bestellungen?cursor=n {"items": [...], "next": "/api/bestellungen?cursor=n+2"}, auf der letzten Seite "next": null
POST /api/import ein Objekt oder Array; 422, wenn kundennr oder name fehlt
PUT /api/archiv/<id> legt das Objekt unter der id ab

Lesen: seitenweise, mit Wiederholung und Nachschlagen – page_param holt Seite für Seite, bis eine leer ist; root zeigt auf das Array in der Antwort. Ein zweiter Container auf denselben Server dient mit query als Lookup.

#
# Kunden seitenweise aus einer REST-API lesen, mit Ländern aus einem zweiten Endpunkt anreichern,
# nach CSV und JSON schreiben
#
#   ruby examples/demo_api.rb &                         # Demo-API starten
#   bin/etl run examples/rest_lesen.rb [--url=http://127.0.0.1:4567] [--out=tmp]
#
require 'fileutils'
require 'datapipeline'

API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

DataPipeline.new do
  version 'rest_lesen 1.0'

  # GET /api/kunden?page=1&per_page=3, dann page=2, ... bis eine Seite leer ist
  # die Zeilen stehen in der Antwort unter "data"; 429/5xx und Netzfehler werden bis zu 3-mal wiederholt
  kunden = container :rest, url: "#{API}/api/kunden", auth: AUTH,
                            root: 'data', page_param: 'page', params: { 'per_page' => 3 },
                            retries: 3, retry_wait: 0.2

  # derselbe Server, anderer Endpunkt: query hängt den Pfad an die url an (GET /api/laender)
  stamm = container :rest, url: "#{API}/api", auth: AUTH
  laender = lookup stamm, query: 'laender', key: 'code', unique: true

  read kunden

  on_error :collect, to: container(:csv, file: "#{out}/api_fehler.csv")
  validate 'id' => { required: true }, 'umsatz' => { type: :decimal }

  map do |kunde|
    {
      'id' => kunde['id'],
      'name' => kunde['name'],
      'land' => laender.dig(kunde['land'], 'name') || kunde['land'],
      'umsatz' => kunde['umsatz'].to_f
    }
  end

  write container(:csv, file: "#{out}/api_kunden.csv", columns: %w[id name land umsatz])
  write container(:json, file: "#{out}/api_kunden.json", pretty: true)

  on_finish { |stats| puts "rest_lesen: #{stats}" }
end.run
INFO start rest_lesen 1.0
WARN rejected by validate#1: umsatz: not decimal
INFO finish read=7 written=6 rejected=1 0.213s

Gelesen werden 7 Kunden in 3 Seiten (plus eine leere Seite als Ende); die 503 auf Seite 2 bemerkt man nur an der Laufzeit. Kunde 5 hat den Umsatz n/a und steht in tmp/api_fehler.csv. Ergebnis tmp/api_kunden.csv:

id,name,land,umsatz
1,Müller GmbH,Deutschland,12500.5
2,Huber & Partner,Österreich,8300.0
3,Swiss Trade AG,Schweiz,22000.75
4,Rossi Srl,Italien,4100.1
6,Nordic AB,SE,990.0
7,Vaduz Treuhand,Liechtenstein,15000.0

Schweden fehlt in /api/laender, deshalb bleibt bei Kunde 6 der Code SE stehen.

Schreiben: prüfen, dann in Paketen senden – batch_size: 2 schickt je 2 Zeilen als JSON-Array. Was die API ablehnen würde, wird vorher geprüft: Lehnt die API ein Paket trotzdem ab (4xx), bricht der Lauf ab, weil ein Fehler beim Schreiben nicht einer einzelnen Zeile zugeordnet werden kann.

#
# Kunden aus CSV prüfen und in Paketen per POST an eine REST-API senden
#
#   ruby examples/demo_api.rb &
#   bin/etl run examples/rest_schreiben.rb [--url=http://127.0.0.1:4567] [--dry-run]
#
require 'fileutils'
require 'datapipeline'

API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

DataPipeline.new do
  version 'rest_schreiben 1.0'

  read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' })

  # vor dem Senden prüfen: was die API ablehnen würde, landet hier in der Fehlerdatei
  on_error :collect, to: container(:csv, file: "#{out}/import_fehler.csv")
  validate 'kundennr' => { required: true, format: /\A\d{6}\z/ }, 'name' => { required: true }

  filter { |row| row['status'] == 'aktiv' }
  map do |row|
    { 'kundennr' => row['kundennr'], 'name' => row['name'].strip, 'land' => row['land'], 'umsatz' => row['umsatz'].to_f }
  end

  # POST /api/import mit je 2 Kunden als JSON-Array; im Dry-Run wird nichts gesendet
  write container(:rest, url: "#{API}/api/import", auth: AUTH, batch_size: 2)

  on_finish do |stats|
    puts stats.dry_run ? 'Dry-Run: nichts gesendet' : "gesendet: #{stats.written} Kunden in #{(stats.written / 2.0).ceil} Paketen"
  end
end.run

Ergebnis: 6 aktive Kunden in 3 Anfragen an POST /api/import, 2 Kunden in tmp/import_fehler.csv (leerer Name, Kundennummer mit 5 Stellen). Mit --dry-run wird nichts gesendet; auf dem Bildschirm stehen die Daten, die gesendet würden:

DRY RUN – nothing written: read=9 written=6 rejected=2 0.0s (dry run)
{"kundennr":"100001","name":"Müller GmbH","land":"DE","umsatz":12500.5}
...

Von API zu API: Folge-Links, Streaming, eine URL je Datensatz – Die Antwort nennt die nächste Seite selbst; das Lambda in next: macht daraus eine vollständige URL. write_url als Lambda schickt jede Bestellung an ihre eigene Adresse. Mit dem Wasserzeichen archiviert ein zweiter Lauf nur, was neu dazugekommen ist.

#
# Von API zu API: neue Bestellungen über Folge-Links lesen und einzeln per PUT archivieren
# Ein zweiter Lauf ohne neue Bestellungen sendet nichts.
#
#   ruby examples/demo_api.rb &
#   bin/etl run examples/rest_zu_rest.rb [--url=http://127.0.0.1:4567] [--out=tmp]
#
require 'fileutils'
require 'datapipeline'

API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

DataPipeline.new do
  streamed

  # höchste Bestellnummer des letzten erfolgreichen Laufs
  stand = watermark :bestellungen, column: 'id', file: "#{out}/.state.json", initial: 0

  # die Antwort nennt die nächste Seite selbst: {"items": [...], "next": "/api/bestellungen?cursor=2"}
  read container(:rest, url: "#{API}/api/bestellungen", auth: AUTH, root: 'items',
                        next: ->(body) { body['next'] && "#{API}#{body['next']}" })

  filter { |row| row['id'] > stand.value.to_i }
  map { |row| row.merge('archiviert' => Date.today.iso8601) }

  # eine url je Bestellung: PUT /api/archiv/<id>
  write container(:rest, url: "#{API}/api/archiv", auth: AUTH, write_method: :put,
                         write_url: ->(row) { "#{API}/api/archiv/#{row['id']}" })

  on_finish { |stats| puts "archiviert: #{stats.written}, Stand jetzt: #{stand.current}" }
end.run
archiviert: 5, Stand jetzt: 5        # erster Lauf: PUT /api/archiv/1 … /5
archiviert: 0, Stand jetzt: 5        # zweiter Lauf: keine Anfrage

2.6 Produktiver Lauf

Ein Export, wie er nachts per Cron läuft. Buchungszeilen mit Vorzeichen (betrag, negativ = Haben) werden geprüft, in Soll/Haben-Kennzeichen und Betrag umgebaut und vor dem Schreiben abgeglichen: Die Summe darf sich durch die Schritte nicht ändern, und jede Buchung muss ausgeglichen sein (Saldo 0). Ziel sind eine Buchhaltungs-Datenbank aus der Verbindungsdatei und eine CSV-Datei im Zeichensatz des Zielsystems.

# Verbindungen der Beispiele. Die Datei wird mit ERB gelesen: Zugangsdaten und Pfade kommen aus der Umgebung,
# bei Postgres z. B.  password: <%= ENV['PDS_PASSWORD'] %>
buchhaltung:
  type: sqlite
  database: <%= ENV.fetch('BUCHHALTUNG_DB', 'tmp/buchhaltung.sqlite3') %>
  table: buchungen
  create: true
  upsert: [belnr, buzei]
#
# Produktiver Lauf: Buchungen prüfen, Summe und Saldo abgleichen, wiederholbar in die Buchhaltungs-Datenbank
# und als CSV in Windows-1252 für das Zielsystem schreiben; mit Sperre, Laufhistorie und Slack bei Problemen
#
#   bin/etl run examples/buchungen_export.rb [--out=tmp] [--dry-run]
#
require 'fileutils'
require 'datapipeline'

DATA = File.expand_path('data', __dir__)
out = DataPipeline.params.fetch(:out, 'tmp')
FileUtils.mkdir_p(out)

# Zugangsdaten stehen nicht im Skript, sondern in der Verbindungsdatei (Default: config/connections.yml)
DataPipeline.connections_file = File.expand_path('config/connections.yml', __dir__)

# Slack nur, wenn ein Token gesetzt ist; so fällt ein fehlender Token schon vor dem Lauf auf
slack = DataPipeline::Slack.new(channel: '#etl') if ENV['SLACK_TOKEN']

DataPipeline.new do
  name 'buchungen_export'
  lock "#{out}/.buchungen.lock"              # läuft schon ein Export, bricht dieser ab (Exit-Code 3)
  history "#{out}/runs.ndjson"               # eine JSON-Zeile je Lauf, auch nach Abbruch

  read container(:csv, file: "#{DATA}/buchungen.csv", csv: { col_sep: ';' })

  # 1. prüfen: ohne on_error bricht ein Fehler den Lauf ab, es entsteht keine halbe Buchung
  validate 'belnr' => { required: true, format: /\A\d{10}\z/ },
           'konto' => { required: true, format: /\A\d+\z/ },
           'betrag' => { required: true, type: :decimal }

  # 2. umbauen mit Kurzschritten und einem map
  default 'text' => 'ohne Text'
  rename 'text' => 'buchungstext'
  cast 'buzei' => :integer, 'datum' => :date, 'betrag' => :decimal
  map do |row|
    row.merge('shkzg' => row['betrag'].negative? ? 'H' : 'S', 'dmbtr' => format('%.2f', row['betrag'].abs))
  end
  select %w[belnr buzei datum konto shkzg dmbtr betrag buchungstext]

  # 3. abgleichen, bevor etwas geschrieben wird
  totals 'betrag'
  reconcile do |stats|
    quelle, ergebnis = stats.total('betrag', :read), stats.total('betrag')
    next "Summe geändert: Quelle #{DataPipeline.decimal(quelle)}, Ergebnis #{DataPipeline.decimal(ergebnis)}" if quelle != ergebnis

    "Soll und Haben ungleich, Saldo #{DataPipeline.decimal(ergebnis)}" unless ergebnis.zero?
  end

  # 4. schreiben: Upsert macht den Lauf wiederholbar; die CSV-Datei im Zeichensatz des Zielsystems
  write connection(:buchhaltung)
  write container(:csv, file: "#{out}/buchungen_sap.csv", encoding: 'Windows-1252', csv: { col_sep: ';' },
                        columns: %w[belnr buzei datum konto shkzg dmbtr buchungstext])

  on_finish do |stats|
    puts "#{stats.name}: #{stats}"
    slack&.send_message("*#{stats.name}*: #{stats}") unless stats.ok?
  end
end.run
Teil Wozu
connections_file, connection(:buchhaltung) Zieldatenbank, Tabelle und Upsert-Schlüssel stehen in der Verbindungsdatei (6.2); in der Praxis genügt config/connections.yml ohne connections_file
lock ein zweiter Export zur selben Zeit bricht sofort ab (6.3)
history jeder Lauf hinterlässt eine JSON-Zeile, auch ein Abbruch (6.6)
validate ohne on_error eine falsche Zeile bricht den Lauf ab, bevor etwas geschrieben ist
default, rename, cast, select Kurzschritte statt kleiner map-Blöcke (3.4)
totals, reconcile Summen werden exakt gerechnet; eine Abweichung bricht vor dem Schreiben ab (6.4)
upsert: [belnr, buzei] ein wiederholter Lauf aktualisiert dieselben Zeilen, statt sie doppelt anzulegen (4.2)
encoding: 'Windows-1252' Zeichensatz der Zieldatei (4.4)
slack&.send_message Meldung nur bei Problemen und nur, wenn SLACK_TOKEN gesetzt ist (6.8)
INFO start
INFO finish read=7 written=7 rejected=0 betrag=0/0 0.007s
buchungen_export: read=7 written=7 rejected=0 betrag=0/0 0.007s

betrag=0/0 ist die Summe in der Quelle und im Ergebnis. Ein zweiter Lauf ergibt wieder 7 Zeilen in der Tabelle buchungen; tmp/runs.ndjson hat dann zwei Einträge. Ändert man in buchungen.csv einen Betrag, bricht der Lauf mit ReconcileError: Soll und Haben ungleich, Saldo … ab und schreibt nichts.

Für Cron gehören Zeitraum, Sperre und Historie auf die Kommandozeile, damit sie für alle Pipelines eines Skripts gelten (6.1):

# crontab: täglich 06:00; Exit-Code 1 = Abbruch, 2 = verworfene Zeilen, 3 = lief noch
0 6 * * * cd /srv/etl && bundle exec etl run export.rb --from=$(date -v-1d +\%F) --lock=data/.export.lock --history=data/runs.ndjson >> log/export.log 2>&1

(date -v-1d ist die macOS-Form; unter Linux date -d yesterday +\%F.)


3. Pipelines schreiben

3.1 Ablauf eines Laufs

flowchart LR
  SRC((Quelle)) --> STEPS[Schritte<br/>validate · filter · map<br/>rename · select · cast · default]
  LK[(lookup)] -.-> STEPS
  STEPS --> REC{reconcile}
  REC --> EDGE[header · trailer]
  EDGE --> DST1((Ziel 1))
  EDGE -->|only:| DST2((Ziel 2))
  STEPS -. on_error .-> REJ((Fehlerdatei))
  EDGE --> ST[Stats · history · on_finish]
  1. run nimmt die Sperre (lock), falls gesetzt, und liest die Quelle: im Batch ganz in den Speicher, im Streaming Zeile für Zeile.
  2. Jede Quellzeile läuft der Reihe nach durch die Schritte. Ein Schritt gibt null, eine oder mehrere Zeilen weiter; der nächste Schritt bekommt die Ausgabe des vorigen.
  3. Scheitert ein Schritt für eine Zeile, entscheidet on_error (3.6).
  4. Nach den Daten laufen reconcile (6.4) und dann header und trailer; sie kennen die Zähler.
  5. Die Zeilen gehen an alle Ziele: im Batch auf einmal (write), im Streaming sofort je Zeile (write_row).
  6. Am Ende schliesst run Quelle und Ziele (auch nach einem Fehler), speichert das Wasserzeichen (nur nach Erfolg), gibt die Sperre frei, schreibt die Historie und ruft on_finish auf.

3.2 DSL-Referenz

Alle Aufrufe stehen im Block von DataPipeline.new do … end. Ein unbekannter Aufruf löst NoMethodError aus, Tippfehler fallen also sofort auf.

Quelle und Ziele

Aufruf Wirkung
container(:typ, **opts) erzeugt ein Plugin (Kapitel 4); connection: :name nimmt die Optionen aus der Verbindungsdatei
connection(:name, **opts) Plugin aus der Verbindungsdatei, Typ von dort (6.2)
read(storage, opts = {}) Quelle; opts gehen an storage.set, z. B. read pg, query: '…', params: [...]
write(storage, only: ->(row) { … }) Ziel; mehrfach möglich, mit only: nur die passenden Zeilen (auch Kopf- und Schlusssätze)
lookup(storage, key:, unique: false, query:, params:) lädt eine Tabelle einmal als Hash: key => [Zeilen], mit unique: true key => Zeile; key als Spaltenname oder Lambda; ohne query: wird storage.read genutzt
query(storage, query: sql, params: []) führt sofort eine Abfrage aus und liefert die Zeilen

Schritte (3.3, 3.4)

Aufruf Wirkung
map(opts = {}) { |row, ctx| … } Hash = eine Zeile, Array = mehrere, nil oder [] = keine
filter(opts = {}) { |row, ctx| … } Zeile bleibt, wenn der Block wahr ist
rename 'alt' => 'neu' Schlüssel umbenennen, Position bleibt
select %w[a b c] nur diese Schlüssel in dieser Reihenfolge, fehlende als nil
cast 'betrag' => :decimal umwandeln in :integer, :decimal, :date, :string; leer wird nil
default 'land' => 'CH' Wert für nil oder leeren Text; ein Lambda bekommt die Zeile
validate(regeln | layout, warn: false, ignore: []) { |row| … } Prüfschritt (3.5, Kapitel 5)
header { |ctx| … }, trailer { |ctx| … } Zeile(n) vor bzw. nach den Daten

Fehler und Ergebnis (3.6, 3.7, Kapitel 6)

Aufruf Wirkung
on_error(:abort | :skip | :collect, to: storage) Umgang mit Fehlern in Schritten
totals 'spalte', … Summen über Quelle und Ergebnis
reconcile { |stats| … } Abgleich vor dem Schreiben; eine Meldung bricht ab
on_finish { |stats| … } nach jedem Lauf, auch nach einem Abbruch
name(str), version(str) Name und Version des Laufs für Log, Stats und Historie

Betrieb (Kapitel 6)

Aufruf Wirkung
lock(file) Sperre gegen parallele Läufe
history(file) eine JSON-Zeile je Lauf
watermark(name, column:, file:, initial:) höchster verarbeiteter Wert für inkrementelle Läufe
dry_run(flag = true), dry_run? nichts schreiben; dry_run? für eigene Schritte mit Nebenwirkungen
limit(n) nur die ersten n Quellzeilen
params Parameter von der Kommandozeile (--name=wert, --param key=wert)
logger(logger) eigener Ruby-Logger; Default: Warnungen auf $stderr, über etl Info

Ausführung und Einstellungen

Aufruf Wirkung
streamed(flag = true) Zeile für Zeile statt Batch (3.3)
verbose(flag = true) Quelle, Ergebnis und Ziel im Batch als JSON auf $stdout
configure(opts) mehrere Einstellungen als Hash, z. B. configure(streamed: true, version: '1.0')
run führt die Pipeline aus und liefert DataPipeline::Stats
DataPipeline.new(**settings) { … } Einstellungen nur für diese Pipeline, z. B. new(limit: 10)
DataPipeline.settings(opts) Defaults für alle folgenden Pipelines (so setzt etl Dry-Run und Log-Level); reset_settings! setzt zurück
DataPipeline.register(:typ, Klasse | 'Klasse', path:) eigenes Plugin bekannt machen (4.1)
DataPipeline.params, DataPipeline.runs Parameter bzw. Stats aller Läufe des Prozesses (ausserhalb des Blocks)
DataPipeline.history(file, name:), DataPipeline.decimal(r) Historie lesen (6.6), Summe als Dezimaltext (6.4)

3.3 Schritte

Rückgabe – Ein map-Block gibt einen Hash (eine Zeile), ein Array (mehrere Zeilen, z. B. Kopf + Positionen) oder nil/[] (keine Zeile) zurück; nil innerhalb eines Arrays wird übersprungen. filter behält die Zeile, wenn der Block wahr ist.

Kontext – Der zweite Blockparameter ctx (DataPipeline::Context) enthält index (Position der Zeile in diesem Schritt, ab 0), read (Anzahl Quellzeilen, im Streaming nil) und in header/trailer written (Anzahl Datenzeilen nach allen Schritten).

map { |row, ctx| row.merge('lfdnr' => format('%06d', ctx.index + 1)) }
trailer { |ctx| { 'satyp' => 'ENDE', 'anzahl' => ctx.written } }

Bedingung – condition: ->(row) { … } an map oder filter wendet den Schritt nur auf passende Zeilen an; die anderen fallen in diesem Schritt weg.

Kopf- und Schlusssätze – header steht vorne in der Ausgabe, wird im Batch aber erst nach den Daten berechnet und kennt deshalb ctx.read und ctx.written. Beide können eine Zeile oder ein Array zurückgeben. Ein Fehler in header oder trailer bricht immer ab.

Ohne Schritte wird die Quelle unverändert in die Ziele kopiert.

Batch und Streaming – Im Batch (Default) liest source.read alles in den Speicher, am Ende schreibt target.write alles auf einmal. Mit streamed geht jede Zeile einzeln von source.read { |row| … } durch die Schritte und sofort an target.write_row; am Ende ruft run target.finalize auf. Der Speicherbedarf bleibt konstant (gemessen: 500 000 CSV-Zeilen mit 30 MB statt 614 MB im Batch). Die Datenzeilen sind dieselben, mit diesen Unterschieden:

Thema Batch Streaming
header kennt read und written wird zuerst geschrieben, read und written sind nil
ctx.read in map/filter Anzahl Quellzeilen nil
reconcile vor dem Schreiben nach der letzten Zeile; die Zeilen sind schon geschrieben
Postgres als Ziel eine Transaktion für alles eine Transaktion je batch_size Zeilen, mit batch_size: :all eine für alles (4.2)
Postgres als Quelle eine Abfrage serverseitiger Cursor, je fetch_size Zeilen

limit n beendet im Streaming das Lesen nach n Zeilen.

3.4 Kurzschritte

Häufige kleine map-Blöcke gibt es als eigene Schritte. Sie verhalten sich wie map und heissen in Log und Fehlerdatei nach sich selbst (cast#3).

Schritt Wirkung Beispiel
rename Schlüssel umbenennen; die Position in der Zeile bleibt rename 'kunnr' => 'kundennr', 'name1' => 'name'
select nur diese Schlüssel, in dieser Reihenfolge; fehlende werden nil select %w[kundennr name land]
cast Wert umwandeln, leere Werte (nil, '', nur Leerzeichen) werden nil cast 'betrag' => :decimal, 'datum' => :date
default Wert für nil oder leeren Text; ein Lambda wird mit der Zeile aufgerufen default 'land' => 'CH', 'erfasst' => ->(row) { row['geaendert'] }
cast-Typ Ergebnis Gültig
:integer Integer ganze Zahl zur Basis 10 ('007' → 7)
:decimal Float alles, was Rubys Float() annimmt ('12.5', '1e3')
:date Date Date oder ISO-Text YYYY-MM-DD
:string String jeder Wert

Kann ein Wert nicht umgewandelt werden, scheitert der Schritt für diese Zeile mit betrag: "x" is not decimal und läuft über on_error. Ein unbekannter Typ fällt schon beim Aufbau der Pipeline auf.

3.5 Validierung

validate prüft Spalten nach Regeln, nach einem Layout (Kapitel 5) und/oder mit einem Block. Eine verletzte Regel löst DataPipeline::ValidationError aus und läuft damit über on_error; alle Meldungen einer Zeile stehen, mit ; getrennt, in einer Fehlermeldung. Leere Werte (nil, '', nur Leerzeichen) prüft nur required.

Regel Prüft Meldung
required: true Wert vorhanden und nicht leer konto: missing
max: 35, min: 1 Länge als Text name1: longer than 35, … shorter than 1
format: /\A\d+\z/ regulärer Ausdruck auf den Text konto: format /\A\d+\z/
in: %w[S H] erlaubte Werte shkzg: not in ["S", "H"]
type: :integer ganze Zahl zur Basis 10 menge: not integer
type: :decimal alles, was Rubys Float() annimmt dmbtr: not decimal
type: :date Date oder ISO-Text YYYY-MM-DD datum: not date
on_error :collect, to: container(:csv, file: 'data/fehler.csv', columns: %w[belnr konto dmbtr _step _error])
validate 'konto' => { required: true, format: /\A\d+\z/ },
         'shkzg' => { in: %w[S H] },
         'dmbtr' => { type: :decimal },
         'name1' => { max: 35 }
validate { |row| 'Betrag 0' if row['dmbtr'].to_f.zero? }        # nil = in Ordnung, Text oder Array = Fehler
validate LAYOUT, warn: true, ignore: %w[BKTXT]                   # Layout; nur Warnung; bekannte Lücke auslassen

Mit warn: true bleibt die Zeile drin, es gibt eine Warnung im Log und stats.warnings zählt mit (ins Log kommen höchstens 100). ignore: %w[FELD] lässt Meldungen zu diesen Feldern weg. validate prüft, wandelt aber nicht um; dafür gibt es cast (3.4). Die Feldtypen eines Layouts und ihr Unterschied zu diesen Regeln stehen in 5.6.

3.6 Fehler in Schritten

on_error legt fest, was passiert, wenn ein Schritt (map, filter, validate, Kurzschritte) für eine Zeile eine Exception wirft. Fehler beim Lesen, beim Schreiben, in header, trailer und reconcile brechen immer ab.

Modus Wirkung
:abort (Default) Lauf bricht ab, die Exception wird weitergereicht
:skip Zeile fällt weg, Warnung im Log, Zähler rejected
:collect, to: storage wie :skip, zusätzlich die Eingangszeile mit _step und _error per write_row ins Ziel storage

Ein Schritt heisst in Meldungen <art>#<nummer> in der Reihenfolge im Skript, z. B. validate#1, cast#3. Im Dry-Run wird die Fehlerdatei nicht geschrieben.

3.7 Ergebnis und Log

run liefert DataPipeline::Stats; dasselbe Objekt bekommen reconcile und on_finish.

Feld Inhalt
name, version aus name/version; über etl ist name ohne Angabe der Skriptname ohne .rb
read gelesene Quellzeilen
written Datenzeilen nach allen Schritten (ohne Kopf- und Schlusssätze)
output an die Ziele gegebene Zeilen inkl. Kopf- und Schlusssätzen
rejected, errors verworfene Zeilen; die ersten 100 als Hash mit step, error und der Eingangszeile row
warnings Zeilen mit Warnung aus validate …, warn: true
totals, total(spalte, :read | :written) Summen aus totals als Rational (6.4)
started_at, finished_at, duration Zeitpunkte und Laufzeit in Sekunden
dry_run Lauf war ein Dry-Run
error Exception eines abgebrochenen Laufs
ok? kein Abbruch und nichts verworfen
to_s read=9 written=4 rejected=4 0.097s; mit Warnungen warnings=n, mit totals spalte=quelle/ergebnis
to_h alles als Hash, wie in der Laufhistorie

Der Logger schreibt start und finish (Info), jede verworfene Zeile und Warnung (Warnung) und einen Abbruch (Fehler). on_finish läuft nach jedem Lauf, auch nach einem Abbruch; ein Fehler im Hook wird nur geloggt und ändert weder Ergebnis noch Exit-Code.

on_finish { |stats| warn "Buchungen: #{stats}" unless stats.ok? }

4. Plugins

container(:typ, **opts) erzeugt ein Plugin. Die mitgelieferten liegen im Namensraum DataPipeline und werden mit ihren Gems erst bei der ersten Nutzung geladen. Alle liefern Zeilen mit String-Schlüsseln und unterstützen read, read { |row| }, write(rows), write_row(row) und close.

container Klasse Gem Abschnitt
:postgres DataPipeline::PostgresStorage pg 4.2
:sqlite, :sqlite3 DataPipeline::SqliteStorage sqlite3 4.3
:csv DataPipeline::CsvStorage – 4.4
:json DataPipeline::JsonStorage – 4.5
:excel, :xlsx DataPipeline::ExcelStorage roo (lesen), caxlsx (schreiben) 4.6
:fixed DataPipeline::FixedStorage – 4.7
:rest DataPipeline::RestStorage http 4.8

4.1 Eigene Plugins

Ein Plugin ist eine Klasse mit Basisklasse DataPipeline::StoragePlugin. Es braucht nur die Methoden, die es anbietet; die Basisklasse meldet fehlende mit NotImplementedError.

Methode Pflicht Verwendung
initialize(**opts, &block) ja Verbindung oder Datei vorbereiten; möglichst erst bei der ersten Nutzung öffnen (Dry-Run)
read → Array<Hash<String, …>> als Quelle Batch
read { |row| } als Quelle Streaming
write(rows) als Ziel Batch
write_row(row) als Ziel Streaming und Fehlerdatei
query(query:, params: []) → Array<Hash> optional query und lookup
set(opts) Basisklasse Optionen nachreichen, genutzt von read(storage, opts)
finalize Basisklasse Ende eines Streams, ruft close auf
close Basisklasse Verbindung oder Datei freigeben; mehrfacher Aufruf muss gehen
DataPipeline.register :verzeichnis, VerzeichnisStorage                       # danach container(:verzeichnis, …)
DataPipeline.register :sftp, 'SftpStorage', path: 'lib/sftp_storage.rb'      # erst bei der ersten Nutzung geladen

Ohne register findet container :name eine Klasse <Name>Storage im Namensraum DataPipeline oder auf oberster Ebene. Ein vollständiges Beispiel steht in 2.4, Testhilfen in 8.2. Ein Plugin, das ein Gem braucht, lädt es mit self.class.require_gem 'name'; fehlt das Gem, gibt es einen verständlichen Hinweis.

4.2 PostgreSQL

pg = container :postgres, host: 'localhost', database: 'crm', username: 'etl', password: ENV['CRM_PASSWORD']
read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today - 7]
write container(:postgres, connection: :crm, table: 'beleg', upsert: %w[belnr], batch_size: :all)
Option Bedeutung
host, port (5432), database, username, password Verbindung; ein Verbindungsfehler meldet Error connection to database: …
query, params Abfrage für read mit Platzhaltern $1, $2, …
typed: true Werte als Ruby-Typen (Integer, Float, Date, …) statt als Text
fetch_size Zeilen je Abruf beim Lesen im Streaming (Default 1000)
table Zieltabelle, auch schema.tabelle; alte Form write(table, rows) geht weiter
truncate: true Tabelle vor dem ersten Schreiben leeren (in derselben Transaktion)
upsert: %w[belnr] insert … on conflict (belnr) do update set … für alle übrigen Spalten der Zeile
batch_size Streaming: Zeilen je Transaktion (Default 1000) oder :all
  • Lesen: Ohne typed kommen alle Werte als Text, NULL als nil. Im Streaming liest ein serverseitiger Cursor in einer Transaktion; Abfragen und Lookups auf derselben Verbindung bleiben dabei möglich.
  • Schreiben: Werte gehen als Bind-Parameter, Tabellen- und Spaltennamen werden gequotet, nil wird NULL. write schreibt alle Zeilen in einer Transaktion.
  • Upsert: Die Tabelle braucht einen Primärschlüssel oder Unique-Index auf genau den Upsert-Spalten. Hat die Zeile keine weiteren Spalten, wird do nothing geschrieben. Damit ist ein Lauf nach einem Abbruch wiederholbar.
  • Streaming: write_row sammelt Zeilen und schreibt je batch_size 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.

4.3 SQLite

write container(:sqlite, database: 'tmp/kunden.sqlite3', table: 'kunden', create: true, truncate: true)
Option Bedeutung
database Datei oder ':memory:'; wird erst bei der ersten Nutzung geöffnet, ein Dry-Run legt keine an
query, params Abfrage für read; params als Array für ?/$1, als Hash für :name
table Zieltabelle
create: true Tabelle beim ersten Schreiben aus der ersten Zeile anlegen (Integer → integer, Float → real, sonst text)
truncate: true Tabelle vor dem ersten Schreiben leeren
upsert: %w[belnr] wie bei Postgres; mit create: true wird der nötige Unique-Index mit angelegt

Werte kommen mit SQLite-Typen zurück (Integer, Float, String, nil); Date und Time werden als ISO-Text geschrieben. write schreibt in einer Transaktion.

4.4 CSV

read  container(:csv, file: 'kunden.csv', csv: { col_sep: ';' })
write container(:csv, file: 'export.csv', columns: %w[id name land], encoding: 'Windows-1252')
Option Bedeutung
file Pfad, Pflicht
csv: Optionen für Rubys CSV, gemergt mit den Defaults headers: true, col_sep: ','
columns Spaltenreihenfolge beim Schreiben (fehlende Felder bleiben leer, Kopfzeile aus columns); Schlüssel beim Lesen ohne Kopfzeile
layout Satzarten-Layout: schreiben in Feldreihenfolge ohne Kopfzeile, lesen mit headers: false (5.4)
encoding Zeichensatz der Datei, Default UTF-8
replace Ersatz für Zeichen, die der Zeichensatz nicht kennt, z. B. '?'
bom: true UTF-8-BOM schreiben (für Excel)
counter, verbose einen Punkt je geschriebene Zeile bzw. die Optionen ausgeben
  • Ohne columns gilt beim Schreiben die Reihenfolge des Hashes; Kopfzeile aus den Schlüsseln der ersten Zeile.
  • Lesen ohne Kopfzeile (headers: false): Schlüssel aus columns, sonst "0", "1", …
  • Leere Daten: ohne columns eine leere Datei, mit columns nur die Kopfzeile. Im Dry-Run entsteht keine Datei.
  • Zeichensatz: Gelesen wird immer als UTF-8, geschrieben wird von UTF-8 umkodiert. Ein Zeichen, das der Zeichensatz nicht kennt (z. B. € in ISO-8859-1; Windows-1252 kennt es), bricht mit Encoding::UndefinedConversionError ab, mit replace: '?' wird es ersetzt. Ein UTF-8-BOM am Dateianfang wird beim Lesen übersprungen. Ein encoding in csv: wird wie früher unverändert an Ruby weitergegeben.

4.5 JSON und NDJSON

read  container(:json, file: 'antwort.json', root: 'data.items')
write container(:json, file: 'bewegungen.ndjson')
Option Bedeutung
file Pfad
format :array oder :lines; Default aus der Endung (.ndjson, .jsonl → Zeilen)
root Pfad zum Array beim Lesen, z. B. 'data.items'
pretty eingerücktes JSON

Ein einzelnes Objekt wird als eine Zeile gelesen. NDJSON wird zeilenweise gelesen und geschrieben; ein JSON-Array wird im Streaming als […] geschrieben und in close abgeschlossen, die Datei ist also immer gültiges JSON.

4.6 Excel

read  container(:excel, file: 'kunden.xlsx', sheet: 'Kunden', skip: 2)
write container(:excel, file: 'eu_kunden.xlsx', sheet: 'EU-Kunden', columns: %w[kundennr name land])

Optionen: file, sheet (Name oder Index), headers, columns, skip (Titelzeilen vor der Kopfzeile). Gelesen wird zeilenweise, leere Zeilen werden übersprungen. Beim Schreiben bleibt Text immer Text: '0000000001' behält die Nullen, '=1+1' wird keine Formel; Zahlen und Datumswerte behalten ihren Typ. Die Datei entsteht in close.

4.7 Festbreiten-Dateien

write container(:fixed, file: 'out/cudo.txt', layout: LAYOUT, encoding: 'Windows-1252')
read  container(:fixed, file: 'in/rueckmeldung.txt', layout: LAYOUT)

Dateien ohne Trennzeichen, in denen jedes Feld eine feste Anzahl Zeichen hat. Die Breiten kommen aus einem Satzarten-Layout; Optionen file, layout (beide Pflicht), encoding, replace (wie CSV) und line_end ("\n" oder "\r\n"). Breiten, Ausrichtung und Lesen sind in 5.5 beschrieben.

4.8 REST-APIs

api = container :rest, url: 'https://api.example.com/v1/kunden', auth: "Bearer #{ENV['API_TOKEN']}",
                       root: 'data', page_param: 'page', retries: 3
Option Bedeutung
url, auth, headers, params Adresse, Authorization-Header, weitere Header, Query-Parameter
root Pfad zum Array in der Antwort, z. B. 'data'
page_param Paging über eine Seitennummer, bis eine Seite leer ist
next: ->(body) { … } Paging über eine Folge-URL aus der Antwort (nil = Ende)
max_pages Schutz vor endlosen Schleifen (Default 1000)
retries, retry_wait, timeout Wiederholung bei 429, 5xx und Netzwerkfehlern (Wartezeit retry_wait × Versuch)
batch_size Schreiben in Paketen als JSON-Array
write_method, write_url HTTP-Methode beim Schreiben; write_url: ->(row) { … } eine URL je Zeile (nicht mit batch_size)
ssl_verify: false jedes Server-Zertifikat akzeptieren (eigene CA, Zugriff über IP-Adresse)
  • 4xx bricht sofort mit DataPipeline::RestStorage::Error ab (status enthält den HTTP-Code, nil bei Netzwerkfehlern). Ein Fehler beim Schreiben bricht den Lauf ab.
  • query(query: 'pfad', params:) hängt den Pfad an url an und erlaubt lookup gegen die API (2.5).
  • Für APIs im RPC-Stil (Suchen und Anlegen per POST mit Body, z. B. die pds API) gibt es einzelne Aufrufe: call(:post, 'person/listkunden', json: { … }) liefert das geparste JSON. Mit retries: 0 wird ein anlegender Aufruf bei 5xx nicht wiederholt.

4.9 Weitere Beispiele

# REST nach Excel
api  = container :rest, url: 'https://api.example.com/v1/kunden', auth: "Bearer #{ENV['API_TOKEN']}",
                        root: 'data', page_param: 'page'
xlsx = container :excel, file: 'data/kunden.xlsx', sheet: 'Kunden', columns: %w[id name land]
read api
select %w[id name land]
write xlsx

4.10 Abhängigkeiten

Das Gem selbst braucht nur csv und logger (Ruby >= 3.4). Die Gems der Plugins sind optional und gehören ins Gemfile der Anwendung, die das Plugin nutzt:

Plugin Gem
:postgres pg
:sqlite sqlite3
:excel roo (lesen), caxlsx (schreiben)
:rest, DataPipeline::Slack http
:csv, :json, :fixed –

Eine CSV- oder JSON-Pipeline lädt keines dieser Gems. Fehlt ein Gem, meldet das Plugin bei der ersten Nutzung, welches ins Gemfile gehört.


5. Satzarten-Layouts

5.1 Wozu

Dateien für Schnittstellen wie SAP CUDO enthalten mehrere Satzarten (Datei-, Kopf-, Positions-, Schlusssatz) mit je einer festen Feldliste. DataPipeline::Layout beschreibt diese Listen einmal; damit lassen sich Zeilen prüfen, in der richtigen Feldreihenfolge schreiben (CSV oder feste Breite) und wieder lesen. Eine falsch benannte Spalte verschiebt so nicht mehr still alle folgenden Felder, sondern bricht ab. Ein vollständiges Beispiel sind die CUDO-Satzarten im Projekt etl (cudo/layout.rb), erzeugt aus der Schnittstellenbeschreibung.

LAYOUT = DataPipeline::Layout.new do
  record 'DOHD', [['SATYP', 'AN', 4, true], ['LFDNR', 'N', 10, true], ['BKTXT', 'AN', 25, true], ...]
  record 'LARC', [['SATYP', 'AN', 4, true], ['SUMME', 'N', 10, true]]
end

validate LAYOUT, warn: true                                  # Pflicht, Länge, Format je Feld
write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })

Die Satzart einer Zeile ist ihr erster Wert (oder der Wert von type_field:). Schlüssel werden ohne Rücksicht auf Gross-/Kleinschreibung zugeordnet, als String oder Symbol.

5.2 Felder und Datentypen

Jedes Feld ist ein Array [Name, Typ, Länge, Pflicht]:

Teil Bedeutung
Name Spaltenname in der Zeile
Typ AN, N, DAT oder TIM, Gross-/Kleinschreibung egal; fehlt er (nil), gilt AN; ein unbekannter Typ wird wie AN behandelt
Länge Zahl oder bei Nachkommastellen ein String ('11.2'); fehlt sie, wird die Länge nicht geprüft
Pflicht true = darf nicht leer sein; fehlt sie, ist das Feld optional

Geprüft wird der Wert als Text (to_s). Ein leerer Wert (nil, '', nur Leerzeichen) wird nur auf Pflicht geprüft.

Typ Inhalt Gültig Länge Meldung
AN Text jeder Wert höchstens Länge Zeichen NAME: longer than 10
N Zahl Ziffern, optional Vorzeichen und Punkt als Dezimaltrenner 10 = höchstens 10 Stellen ohne Nachkommastellen; '11.2' = höchstens 9 Vor- und 2 Nachkommastellen; Vorzeichen zählt nicht, führende Nullen schon NAME: not a number, … more than 9 digits, … more than 2 decimals
DAT Datum genau 8 Ziffern YYYYMMDD, gültiges Kalenderdatum – NAME: not a date YYYYMMDD
TIM Uhrzeit genau 6 Ziffern HHMMSS, 00:00:00 bis 23:59:59 – NAME: not a time HHMMSS

5.3 Formatieren

Ohne Option prüft das Layout nur und schreibt die Werte unverändert; ein Date würde als 2026-10-09 geprüft und abgelehnt. Mit Layout.new(format: true) bringt das Layout die Werte vor dem Prüfen und Schreiben in das Textformat ihres Typs. Geprüft wird dann genau der Text, der in die Datei kommt.

Typ Eingabe Ergebnis
N Integer, Float, Rational, BigDecimal Nachkommastellen aus der Länge ('11.2' → 2), kaufmännisch gerundet: 12.345 → 12.35, -0.005 → -0.01, 7 → 7.00; ein Float zählt mit seinem kürzesten Text, 12.345 also nicht als 12.3449999…
N Zahl als Text auf die Nachkommastellen aufgefüllt ('3.5' → 3.50); zu viele Nachkommastellen bleiben stehen und werden gemeldet, Text wird nie gerundet
DAT Date, Time, '2026-10-09' 20261009
TIM Time, '07:05:03' 070503
alle nil bleibt leer

Anderes bleibt unverändert und wird wie gewohnt geprüft. Führende Nullen ergänzt nur die Festbreiten-Datei (5.5); in CSV braucht es dafür ein map (format('%06d', …)).

LAYOUT = DataPipeline::Layout.new(format: true) do
  record 'KOPF', [['SATYP', 'AN', 4, true], ['DATUM', 'DAT', 8, true], ['SUMME', 'N', '11.2', true]]
end
LAYOUT.values({ 'satyp' => 'KOPF', 'datum' => Date.new(2026, 10, 9), 'summe' => 1234.5 })
# => ["KOPF", "20261009", "1234.50"]

5.4 Schreiben und Lesen als CSV

Mit layout: schreibt das CSV-Plugin jede Zeile in der Feldreihenfolge ihrer Satzart, ohne Kopfzeile. Fehlende Felder bleiben leer; ein Feld, das nicht im Layout steht, bricht mit DataPipeline::LayoutError ab (ausser mit strict: false, dann wird es weggelassen).

Mit headers: false liest dasselbe Plugin eine solche Datei wieder: Jede Zeile bekommt die Feldnamen ihrer Satzart (klein geschrieben) als Schlüssel. Eine unbekannte Satzart oder mehr Werte als Felder brechen mit LayoutError ab.

read container(:csv, file: 'in/cudo.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
# { 'satyp' => 'LARC', 'summe' => '12' }

5.5 Festbreiten-Dateien

container(:fixed, file:, layout:) schreibt und liest Dateien, in denen jedes Feld eine feste Anzahl Zeichen hat. Jedes AN- und N-Feld braucht dafür eine Länge, sonst bricht schon container mit LayoutError ab und nennt die Felder.

Typ Breite Ausrichtung
AN Länge links, rechts mit Leerzeichen aufgefüllt
N Vor- + Nachkommastellen + Punkt: '11.2' → 12, 5 → 5 rechts, links mit Nullen aufgefüllt, Vorzeichen vorne: -0000012.50
DAT, TIM 8 bzw. 6 genau so viele Ziffern
leeres Feld Breite des Felds nur Leerzeichen

Beim Schreiben werden die Werte immer formatiert (wie format: true). Ein Wert, der länger als sein Feld ist, wird nicht abgeschnitten, sondern bricht mit LayoutError ab; mit validate LAYOUT vorher fällt er schon als Zeilenfehler auf und kann über on_error gesammelt werden. Beim Lesen erkennt das Plugin die Satzart an ihrem Feld; die Schlüssel sind die Feldnamen klein geschrieben, die Werte ohne Auffüllung (Zahlen behalten ihre führenden Nullen, cast macht daraus Zahlen), leere Felder sind nil und leere Zeilen werden übersprungen. Breiten zählen Zeichen, nicht Bytes.

write container(:fixed, file: 'out/cudo.txt', layout: LAYOUT, encoding: 'Windows-1252')
# KOPF20261009000001234.50

read container(:fixed, file: 'out/cudo.txt', layout: LAYOUT)
# { 'satyp' => 'KOPF', 'datum' => '20261009', 'summe' => '000001234.50' }

5.6 Layout-Typen und validate-Regeln

Layout-Typen beschreiben das Textformat einer Datei mit festen Feldern; die Regel type: von validate (3.5) prüft dagegen, ob sich ein Wert in einen Ruby-Typ umwandeln lässt. Beides lässt sich kombinieren: Rohdaten mit Regeln prüfen und die fertigen Sätze gegen das Layout.

Layout-Typ ähnliche Regel Unterschied
AN + Länge max: 10 gleich
N type: :integer bzw. type: :decimal :decimal nimmt jedes Ruby-Float an (auch 1e3, .5); N nur Ziffern mit optionalem Punkt, dafür mit Stellenzahl
DAT type: :date :date erwartet ISO 8601 (2026-10-09) oder ein Date, DAT dagegen 20261009
TIM format: /\A\d{6}\z/ TIM prüft zusätzlich Stunden, Minuten und Sekunden

5.7 Optionen

Option Wirkung
type_field: 'satyp' Satzart aus dieser Spalte statt aus dem ersten Wert der Zeile
strict: false Felder, die nicht im Layout stehen, beim Schreiben weglassen statt abbrechen; beim Lesen mehr Werte als Felder erlauben
format: true Werte formatieren (5.3)

Eine Satzart, die nicht im Layout steht, ist immer ein Fehler (record type "X" not in layout (DOHD, LARC)).


6. Betrieb

6.1 Kommandozeile und Exit-Codes

etl (im Repository bin/etl) startet ein Pipeline-Skript; run ist optional:

bin/etl run export.rb --from=2025-11-01 --till=2025-11-30
bin/etl run examples/kunden_import.rb --dry-run --limit=20
bin/etl run pipeline.rb --param land=DE --out=data       # im Skript: params[:land], params[:out]
bin/etl run cudo.rb --lock=data/.cudo.lock --history=data/runs.ndjson
Option Wirkung
--from, --till in params[:from]/params[:till] und unverändert an das Skript weitergereicht (für Skripte mit eigenem OptionParser)
--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 (6.3)
--history=FILE Laufhistorie für alle Pipelines des Skripts (6.6)
--verbose, --quiet Log-Level Debug bzw. Warnung (Default: Info)
jede andere --name=wert params[:name] und unverändert an das Skript, z. B. --out=data, --incremental=1
Exit-Code Bedeutung
0 alle Pipelines ohne Abbruch und ohne verworfene Zeilen
1 eine Pipeline ist abgebrochen (auch ReconcileError) oder das Skript hat einen Fehler
2 fertig, aber mit verworfenen Zeilen (on_error :skip/:collect)
3 ein anderer Lauf hält die Sperre; es wurde nichts gemacht

Ohne etl (mit ruby skript.rb --out=x) liest params die Argumente direkt aus ARGV. Das Rakefile der Anwendung ruft bin/etl auf, z. B. rake process:posting[2025-11-01,2025-11-30]; ein fehlgeschlagener Lauf stoppt Rake.

6.2 Verbindungen

Zugangsdaten stehen nicht in den Skripten, sondern einmal in config/connections.yml (relativ zum Arbeitsverzeichnis; eine andere Datei über DataPipeline.connections_file = … oder ENV['DATAPIPELINE_CONNECTIONS']). Die Datei wird mit ERB gelesen: Passwörter kommen aus der Umgebung, die Datei selbst kann ins Repository. type ist der Plugin-Typ, alle anderen Schlüssel sind dessen Optionen.

# config/connections.yml
pds:
  type: postgres
  host: <%= ENV.fetch('PDS_HOST', 'localhost') %>
  database: pds
  username: etl
  password: <%= ENV['PDS_PASSWORD'] %>
read connection(:pds, query: 'select * from rechnung where datum >= $1', params: [params[:from]])
write container(:postgres, connection: :pds, table: 'export_log')

Optionen beim Aufruf haben Vorrang vor der Datei. Eine unbekannte Verbindung, eine fehlende Datei oder ein Typ, der nicht zu container(:typ, …) passt, bricht mit ArgumentError ab. Die Datei wird je Prozess einmal gelesen. Das Gem lädt keine .env; Umgebungsvariablen setzt die Anwendung oder der Cronjob.

6.3 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.

lock 'data/.cudo.lock'                         # in der Pipeline: von run bis zum Ende des Laufs
bin/etl run cudo.rb --lock=data/.cudo.lock     # für das ganze Skript, z. B. mehrere Pipelines nacheinander

Das Betriebssystem gibt die Sperre beim Ende des Prozesses frei, auch nach einem Absturz; es bleiben keine verwaisten Sperren. Die Datei bleibt liegen und enthält die PID des letzten Laufs; die Meldung des zweiten Laufs nennt sie. Die Sperre gilt auf einem Rechner (bei Netzlaufwerken nicht verlassen). Auch ein Dry-Run nimmt die Sperre und legt die Datei an.

6.4 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; verworfene Zeilen zählen in :read, aber nicht in :written.

reconcile läuft nach allen Schritten und vor dem Schreiben. Gibt der Block eine Meldung (oder ein Array von Meldungen) zurück, bricht der Lauf mit DataPipeline::ReconcileError ab und schreibt nichts (Exit-Code 1, on_finish läuft). nil heisst in Ordnung. Mehrere reconcile-Blöcke sind möglich, ihre Meldungen werden zusammengefasst.

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). 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 (4.2). Das Beispiel 2.6 zeigt einen Saldo-Abgleich.

6.5 Inkrementelle Läufe

watermark merkt sich je Name den höchsten Wert einer Spalte in einer JSON-Datei. Die Pipeline verfolgt jede Quellzeile und speichert den neuen Wert nur nach einem erfolgreichen Lauf (nicht nach einem Abbruch, nicht im Dry-Run). Zahlen als Text werden numerisch verglichen.

wm = watermark :rechnungen, column: 'rechnung_id', file: 'data/.state.json', initial: 0
read pg, query: 'select * from rechnung where rechnung_id > $1 order by rechnung_id', params: [wm.value]

wm.value ist der Stand vor dem Lauf, wm.current der höchste bisher gesehene Wert. Mit limit sollte die Quelle nach der Spalte sortiert sein. Verworfene Zeilen schieben das Wasserzeichen mit; bei :collect liegen sie in der Fehlerdatei. Gegen doppelte Läufe hilft die Sperre (6.3).

6.6 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. Parallele Läufe schreiben sich nicht in die Quere; ein Fehler beim Schreiben der Historie wird nur geloggt.

{"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"}}}
letzter = DataPipeline.history('data/runs.ndjson', name: 'cudo').reverse.find { |run| run['ok'] && !run['dry_run'] }

6.7 Dry-Run und Limit

Im Dry-Run läuft alles, aber nichts wird geschrieben: kein Ziel, keine Fehlerdatei, kein Wasserzeichen; Dateien und SQLite-Datenbanken werden nicht angelegt. Am Ende stehen die Zusammenfassung und die ersten Zeilen (preview, Default 10) als JSON auf $stdout. Geschrieben werden nur die Laufhistorie (mit "dry_run": true) und die Sperrdatei. Eigene Schritte mit Nebenwirkungen (API-Aufrufe in einem map) fragen dry_run? ab.

limit n verarbeitet nur die ersten n Quellzeilen; im Streaming wird das Lesen dann beendet.

6.8 Benachrichtigung über Slack

DataPipeline::Slack schickt Text über die Slack-API chat.postMessage. Einrichten:

  1. In Slack eine App anlegen (api.slack.com/apps), unter OAuth & Permissions den Bot-Scope chat:write vergeben, die App im Workspace installieren und den Bot User OAuth Token (xoxb-…) kopieren.
  2. Die App in den Ziel-Channel einladen (/invite @appname), sonst antwortet Slack mit not_in_channel.
  3. Das Gem http ins Gemfile der Anwendung aufnehmen (4.10).
  4. Den Token als Umgebungsvariable SLACK_TOKEN setzen oder mit token: übergeben; nicht ins Skript schreiben.
slack = DataPipeline::Slack.new                                       # Token aus ENV['SLACK_TOKEN'], Channel '#general'
slack = DataPipeline::Slack.new(channel: '#etl')                      # anderer Channel (Name oder ID wie 'C0123ABCD')
slack = DataPipeline::Slack.new(channel: '#etl', token: ENV.fetch('SLACK_ETL_TOKEN'))  # eigener Token je Anwendung

slack.send_message('Test aus der Pipeline', verbose: true)             # verbose: Antwort von Slack als JSON auf $stdout
Option Default Bedeutung
token: ENV['SLACK_TOKEN'] Bot-Token; fehlt er, bricht schon Slack.new mit ArgumentError ab
channel: '#general' Ziel-Channel als Name oder ID

send_message(text, verbose: false) liefert die Antwort von Slack als Hash. Meldet Slack einen Fehler (z. B. invalid_auth, channel_not_found, not_in_channel), wirft die Methode eine Exception mit diesem Code. In on_finish wird sie nur geloggt (on_finish: RuntimeError: Slack#send_message failed: …), der Lauf und sein Exit-Code bleiben unverändert. Es gibt keine Wiederholung bei Netzwerkfehlern.

Eine ausführliche Meldung mit Status, Zählern, Laufzeit und den ersten verworfenen Zeilen. Der Notifier wird vor der Pipeline angelegt, damit ein fehlender Token schon beim Start auffällt. Slack setzt *fett*, _kursiv_ und `code` um.

slack = DataPipeline::Slack.new(channel: '#etl')

DataPipeline.new do
  # … read, Schritte, write …

  on_finish do |stats|
    next if stats.ok? && stats.warnings.zero?                  # nur melden, wenn etwas zu tun ist

    status = if stats.error then ':red_circle: abgebrochen'
             elsif stats.rejected.positive? then ':large_orange_circle: mit verworfenen Zeilen'
             else ':large_yellow_circle: mit Warnungen'
             end
    lines = ["*Buchungsexport #{stats.version}* #{status}#{' (Dry-Run)' if stats.dry_run}",
             "Zeitraum #{params[:from]} – #{params[:till]}, Start #{stats.started_at.strftime('%d.%m.%Y %H:%M')}, " \
             "Dauer #{stats.duration}s",
             "gelesen #{stats.read} · geschrieben #{stats.written} · verworfen #{stats.rejected} · Warnungen #{stats.warnings}"]
    lines << "Fehler: `#{stats.error.class}: #{stats.error.message}`" if stats.error
    stats.errors.first(5).each do |e|
      lines << "• #{e[:step]}: #{e[:error]} – Beleg #{e[:row]['belnr']}"
    end
    lines << "… und #{stats.rejected - 5} weitere, siehe data/fehler.csv" if stats.rejected > 5

    slack.send_message(lines.join("\n"))
  end
end.run

So sieht die Meldung in Slack aus:

Buchungsexport 1.0 🟠 mit verworfenen Zeilen
Zeitraum 2025-11-01 – 2025-11-30, Start 01.12.2025 06:00, Dauer 4.213s
gelesen 1250 · geschrieben 1243 · verworfen 7 · Warnungen 0
• validate#1: konto: missing – Beleg 5100000123
• validate#1: dmbtr: not decimal – Beleg 5100000124
• validate#2: Betrag 0 – Beleg 5100000130
• validate#1: shkzg: not in ["S", "H"] – Beleg 5100000131
• validate#1: konto: format /\A\d+\z/ – Beleg 5100000140
… und 2 weitere, siehe data/fehler.csv

params gibt es im Pipeline-Block; ausserhalb heisst es DataPipeline.params.

6.9 Fehlersuche

Meldung Ursache Abhilfe
Unsupported storage type or class missing: XyzStorage Tippfehler in container(:xyz) oder eigenes Plugin nicht registriert Typ prüfen, DataPipeline.register (4.1)
… needs the gem 'pg': add gem 'pg' to the Gemfile Plugin-Gem fehlt Gem ins Gemfile der Anwendung, bundle install (4.10)
rejected by validate#2: … Zeile hat eine Regel verletzt Fehlerdatei (on_error :collect) ansehen; Schritt-Nummer = Reihenfolge im Skript
DataPipeline::LockError: … is locked by pid 4711 ein anderer Lauf läuft noch (Exit-Code 3) warten; hängt der Prozess, ihn beenden – die Sperre löst sich dann von selbst
DataPipeline::ReconcileError: … ein reconcile-Block hat eine Abweichung gemeldet Quelle und Schritte prüfen; es wurde nichts geschrieben (ausser im Streaming)
DataPipeline::LayoutError: X: fields not in layout: foo Zeile hat ein Feld, das die Satzart nicht kennt Spalte umbenennen (rename) oder weglassen (select), Layout ergänzen oder strict: false
DataPipeline::LayoutError: record type "X" not in layout erster Wert (bzw. type_field) ist keine bekannte Satzart Reihenfolge der Spalten prüfen; type_field: setzen
LayoutError: TEXT: "…" longer than 30 Wert passt nicht in das Feld einer Festbreiten-Datei vorher validate LAYOUT mit on_error, oder den Wert kürzen
Encoding::UndefinedConversionError: U+20AC from UTF-8 to ISO-8859-1 Zeichen gibt es im Zielzeichensatz nicht Windows-1252 statt ISO-8859-1, oder replace: '?'
connection :x not in config/connections.yml (a, b) Name falsch oder Datei nicht gefunden Namen prüfen; Pfad relativ zum Arbeitsverzeichnis, sonst connections_file setzen
NoMethodError: undefined method 'transform' (oder chained) Skript für 2.x auf map, header, trailer umstellen (7.1)
map: condition must be a lambda ->(row) { ... } condition: als String aus 2.x condition: ->(row) { … } (7.1)

Mehr sehen: --verbose (Log-Level Debug), verbose in der Pipeline (Daten als JSON), --dry-run --limit=20 für einen schnellen Blick auf das Ergebnis.


7. Umstieg

7.1 Von 2.x auf 3.0

3.0 entfernt das alte Ausführungsmodell: transform, den Parallelmodus, chained und condition: als String (eval). Alle Schritte laufen jetzt verkettet, jeder bekommt die Ausgabe des vorigen. Alles andere ist unverändert; ein Skript, das unter 2.1 ohne WARN deprecated lief, läuft auch unter 3.0.

2.x 3.0
transform { |row, index| … } map { |row, ctx| … }, Index als ctx.index
transform(processing: :prepend) { |_, _| kopf } header { |ctx| kopf }
transform(processing: :append) { |_, n| schluss } trailer { |ctx| schluss }, Anzahl als ctx.written
transform(condition: "row['a'] == 1") { … } map(condition: ->(row) { row['a'] == 1 }) { … }
chained, configure(chained: true) weglassen

Worauf beim Umstellen zu achten ist:

  • Parallelmodus: Ohne map, filter, validate und chained bekam unter 2.x jeder transform eine eigene Kopie der Quellzeilen, und die Ergebnisse wurden hintereinander angehängt. Zwei transform auf 2 Quellzeilen ergaben 4 Zeilen, zwei map ergeben 2 Zeilen mit beiden Änderungen. Wer wirklich jede Zeile mehrfach ausgeben will, gibt in einem map ein Array zurück: map { |r| [mit_b(r), mit_c(r)] }.
  • Kopfsatz: transform(processing: :prepend) lief vor den Daten, header läuft im Batch erst danach (steht aber vorne). Das zählt nur, wenn der Block etwas anderes tut, als eine Zeile zu liefern.
  • Schlusssatz: processing: :append bekam im Parallelmodus die Anzahl der Quellzeilen; ctx.written ist die Anzahl der Datenzeilen nach allen Schritten, ctx.read die der Quellzeilen.
  • Bedingung: Ein String in condition: bricht schon beim Aufbau der Pipeline mit ArgumentError ab.

Prüfen lässt sich die Umstellung mit etl run skript.rb --dry-run vorher und nachher: Die Zusammenfassung und die ersten Zeilen sollten gleich sein.

7.2 Von 2.0 auf 2.1

2.1 war abwärtskompatibel. Anders als in 2.0:

Thema 2.0 ab 2.1
Postgres, write_row im Streaming jede Zeile sofort, ohne Transaktion je 1000 Zeilen in einer Transaktion; der Rest in finalize/close, auch nach einem Abbruch
CSV lesen mit UTF-8-BOM BOM steht im ersten Spaltennamen BOM wird übersprungen
Stats#to_s – mit totals zusätzlich spalte=quelle/ergebnis
etl ohne name – stats.name = Skriptname

8. Entwicklung

8.1 Aufbau

Datei Inhalt
lib/datapipeline.rb DSL und Ausführung (run), Stats, Context, Kurzschritte, Summen, Historie
lib/datapipeline/validation.rb Regeln für validate
lib/datapipeline/layout.rb Satzarten-Layouts: prüfen, formatieren, feste Breite, lesen
lib/datapipeline/watermark.rb Wasserzeichen für inkrementelle Läufe
lib/datapipeline/lock.rb Sperre gegen parallele Läufe
lib/datapipeline/connections.rb Verbindungsdatei
lib/datapipeline/storage/ storage_plugin.rb (Basisklasse) und je Plugin eine Datei
lib/datapipeline/notification/slack.rb Slack
lib/datapipeline/version.rb DataPipeline::VERSION
exe/etl Kommando etl; bin/etl startet es im Repository mit dem Bundle
examples/ Beispiele aus Kapitel 2, Daten in examples/data/, Verbindungsdatei in examples/config/, Demo-API
datapipeline.gemspec, CHANGELOG.md Gem-Beschreibung, Versionsverlauf

8.2 Tests

bundle exec rake test startet die Minitest-Suite in test/, rund 190 Fälle. Einzelne Datei: bundle exec rake test TEST=test/core/steps_test.rb.

Ordner Inhalt
test/plugins je Plugin das gemeinsame Testkit test/support/storage_contract.rb (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung
test/core DSL, Schritte und Kurzschritte, Batch und Streaming, Fehlerbehandlung, Layouts, Sperre, Verbindungen, Summen, Historie, Wasserzeichen
test/cli bin/etl als Prozess: Parameter, Dry-Run, Sperre, Historie, Exit-Codes
test/examples die Beispiele aus Kapitel 2: Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API

Eine .env wird in Tests nicht geladen; kein Test erreicht eine Produktivdatenbank. Postgres-Tests legen je Test eine Wegwerf-Datenbank auf einem lokalen Server an (ETL_TEST_PG_HOST, _PORT, _USER, _PASSWORD; Default localhost, Port 5432, $USER). Ohne erreichbaren Server werden sie übersprungen (23 Tests). Mit Postgres.app geht ein eigener Testserver so, ohne die Daten der App anzufassen:

initdb -D tmp/pg -A trust && pg_ctl -D tmp/pg -o "-p 54329 -k ''" -l tmp/pg.log start
ETL_TEST_PG_PORT=54329 bundle exec rake test
pg_ctl -D tmp/pg stop

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.

8.3 Gem und Versionen

  • Version in lib/datapipeline/version.rb, Änderungen in CHANGELOG.md (neueste oben). Neue Funktionen erhöhen die mittlere Stelle, Breaking Changes die erste (wie 3.0, 7.1).
  • Das Gemfile bindet das Gem über gemspec ein und ergänzt die Gruppen plugins (pg, sqlite3, roo, caxlsx, http) und test; Gemfile.lock ist versioniert. Ohne Plugin-Gems: bundle config set without plugins.
  • Gem bauen: gem build datapipeline.gemspec.
  • Beispiele in Kapitel 2 werden mit einem Marker <!-- beispiel: examples/datei.rb --> vor dem Codeblock eingebunden; der Doku-Test vergleicht Block und Datei, Änderungen also immer in beiden machen.

9. Ausblick

Offene Punkte, Reihenfolge = empfohlene Umsetzung:

  1. Kommandozeile: etl check skript.rb (Plugins, Gems und Verbindungen prüfen, ohne zu lesen oder zu schreiben), etl list (Pipelines, Wasserzeichen, letzter Lauf aus der Historie), --json für die Stats.
  2. Qualität: CI auf git.wicom.li mit rake test und Postgres-Service, RuboCop, DataPipeline::Testing mit MemoryStorage als öffentliche Testhilfe für Anwendungen.
  3. SQLite im Streaming: write_row wie bei Postgres in Paketen schreiben.
  4. Slack: fertige Meldung aus den Stats (notify slack, on: :problems), Timeout und Wiederholung bei 429, Maskierung von &, <, >, Incoming Webhooks.
  5. Weitere Plugins: SFTP-Ablage.
  6. Veröffentlichung: eigenes Remote-Repository, Lizenz festlegen, Versionen taggen.