Schedule icon
List icon
If icon
OutputValues icon
Queries icon
Loop icon
Copy icon
Log icon
SlackIncomingWebhook icon
Fail icon

Incremental, idempotent lake ingestion from MinIO with DuckDB

Ingest new MinIO files into a partitioned lake with DuckDB. Kestra skips duplicates, rebuilds touched days so late data stays correct, blocks bad schemas.

Categories
DataInfrastructure

Producers drop Parquet files into a MinIO landing prefix. Every hour this flow takes the files that are there, checks them against a column contract, appends the new rows to a curated events dataset partitioned by day, rebuilds daily_metrics for every day the batch touched, then moves the files to a processed prefix. DuckDB reads and writes the lake directly over S3, so no file is downloaded to the worker.

Three common lake problems are handled:

  • Late data. A file for a day that was already loaded rebuilds that day's metrics from all its events. Distinct counts such as active_users stay correct, where appending per-batch rollups would double count them.
  • Resent files. Rows whose event_id is already in events are skipped, so a producer retry loads nothing twice.
  • Bad batches. Wrong column names or values that do not parse (for example amount = 'n/a') block the batch. The files stay in the landing zone and the run ends FAILED.

This blueprint was created by zkasuran.

How it works

  1. list_incoming (io.kestra.plugin.minio.List) freezes the batch. Every later step uses exactly these keys, so a file that lands during the run waits for the next one.
  2. has_batch (io.kestra.plugin.core.flow.If) ends the run with a log line when there is nothing new.
  3. batch (io.kestra.plugin.core.output.OutputValues) turns the keys into a DuckDB list of s3:// URLs. list_curated checks whether events already has files.
  4. profile (io.kestra.plugin.jdbc.duckdb.Queries) reads the batch with httpfs and checks the column names and that event_id, event_time and amount parse in every row.
  5. contract (io.kestra.plugin.core.flow.If) loads when the profile is clean.
    • load (io.kestra.plugin.jdbc.duckdb.Queries) types the rows, removes duplicates inside the batch and against events, writes the new rows to events/event_day=<day>/batch-<execution>-0.parquet, then rewrites daily_metrics/event_day=<day>/metrics0.parquet for each touched day from all of that day's events.
    • archive (io.kestra.plugin.core.flow.Loop with io.kestra.plugin.minio.Copy, delete: true) moves each file to processed_prefix, keeping its sub-path.
    • On a contract failure block_batch (io.kestra.plugin.core.execution.Fail) ends the run with the observed column types and the unparseable row count.
  6. concurrency.limit: 1 keeps two runs from taking the same files. errors alerts on any other failure. Slack is optional.

Inputs

  • bucket (STRING, default data-lake): the lake bucket.
  • incoming_prefix (STRING, default raw/incoming/): landing zone.
  • processed_prefix (STRING, default raw/processed/): where ingested files are moved.
  • curated_prefix (STRING, default lake/curated/): holds events/ and daily_metrics/.
  • notify_slack (BOOL, default false): post results to Slack.

Prerequisites

  • MinIO or any S3-compatible store, with the bucket created.
  • Producers writing Parquet with the columns event_id, user_id, country, amount, event_time.
  • Network access from the worker to the DuckDB extension repository on first run, to install httpfs.

Secrets

  • MINIO_ENDPOINT: endpoint URL, for example http://minio:9000 or https://s3.example.com. The flow passes the host to DuckDB and turns SSL on for https.
  • MINIO_ACCESS_KEY_ID: access key.
  • MINIO_SECRET_KEY_ID: secret key.
  • SLACK_WEBHOOK_URL: Slack incoming webhook. Only needed when notify_slack is true.

Quick start

  1. Add the secrets and create the bucket.
  2. Drop one or more Parquet files under raw/incoming/ and run the flow. The log shows the new row count and the rebuilt days.
  3. Run it again. The log says there are no new files.
  4. Drop a file with the same event_ids again. It is archived with 0 new rows.

Expected outputs

  • s3://<bucket>/<curated_prefix>events/event_day=<day>/*.parquet: row-level curated events.
  • s3://<bucket>/<curated_prefix>daily_metrics/event_day=<day>/metrics0.parquet: one row per country per day with events, active_users and revenue.
  • outputs.load.outputs: the last row holds loaded, duplicates_skipped and days_rebuilt.

How to extend

  • Move a blocked batch to a quarantine prefix so later good files are not held behind it.
  • Turn the column list into an input so one flow serves several datasets.
  • Swap the Schedule for io.kestra.plugin.minio.Trigger to ingest as soon as files land.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.