Flow icon
Write icon
Switch icon
CsvToIon icon
JsonToIon icon
Size icon
Assert icon
IonToJson icon
Reverse icon
DeduplicateItems icon
FilterItems icon
Split icon
Loop icon
Subflow icon
OutputValues icon
If icon
Concat icon
IonToCsv icon
Labels icon
Set icon
Fail icon
Delete icon
Log icon
SlackIncomingWebhook icon

Newest-wins de-duplication and partitioned processing of a change feed with core storage tasks only

Kestra flow that de-duplicates a CSV or JSONL change feed newest-wins with Reverse and DeduplicateItems, then splits it into subflow partitions.

Categories
CoreData

A change feed lists the same record several times, once per update, and most exports deliver it newest first. Loading it means keeping the newest version per key, honouring deletes, validating rows and processing the result in chunks. This flow does that with core storage tasks and two serdes converters, with no Python, shell or SQL: it shows how far Reverse, DeduplicateItems, FilterItems, Split, Concat, Size and Write go when chained, and how a Loop of Subflow calls processes partitions with failure isolation. It runs on a built-in 20-row sample until you upload a feed.

How it works

  1. write_sample_feed writes a sample CSV with storage.Write when no source_file is uploaded: 20 versions of 15 customers, newest first, with two tombstones, three invalid rows and a record deleted then re-created.
  2. read_feed (Switch on source_format) converts the CSV or JSONL to ION. feed_size records the bytes and refuse_empty_feed (Assert) stops a header-only feed.
  3. The newest-wins trick: DeduplicateItems keeps the last occurrence of a key, so a newest-first feed is reversed first. Reverse is a text task and the converters write binary ION on Kestra 2.0, so feed_as_jsonl turns the feed into one JSON object per line, reverse_feed flips it and reversed_feed_as_ion reads it back. All three are skipped for oldest_first.
  4. dedupe_newest_wins keeps one row per customer_id.
  5. drop_tombstones (FilterItems, EXCLUDE) removes records whose newest version has status equal to tombstone_status. It runs after the de-duplication on purpose: before it, an older active version of a deleted record would survive and a record deleted then re-created would be lost.
  6. split_rows or split_bytes partitions the file (Split takes exactly one of rows and bytes, hence two tasks with a runIf).
  7. process_partitions (Loop, three at a time, transmitFailed: false) starts one Subflow per partition URI, passing it to the worker's FILE input with the partition index and max_reject_ratio, and labels the child execution with the parent run id. The Loop outputs collect one row per partition: state, worker execution id, file URIs, counts.
  8. tally sorts the rows by partition index, since Loop outputs arrive in completion order. assemble_clean concatenates the clean outputs (Concat) into a CSV; concat_rejects joins the row-level rejects of successful partitions with every row of refused ones. Size reports both files.
  9. label_run puts the counts on the execution. partition_failures stores the failed partitions in KV with a 7-day ttl (partition file, worker execution id, rejects file) and fails the run unless fail_on_partition_failure is false; a clean run deletes the entry.
  10. errors reads errorLogs() once, logs the failed task and message, stores them in KV and posts to Slack. finally logs a summary and afterExecution posts the outcome.

The worker subflow

Its id is the partition_flow_id input. It validates each row, writes valid and rejected rows to two files and refuses the partition when the reject share is above max_reject_ratio. Add your load task (JDBC insert, S3 upload, HTTP request) after reject_ratio_gate; outputs.valid_rows.uri is a kestra:// URI.

id: change-feed-partition-worker
namespace: company.team
inputs:
  - id: partition
    type: FILE
  - id: partition_index
    type: INT
    defaults: 0
  - id: max_reject_ratio
    type: FLOAT
    defaults: 0.5
tasks:
  - id: valid_rows
    type: io.kestra.plugin.core.storage.FilterItems
    from: "{{ inputs.partition }}"
    filterCondition: "{{ email contains '@' and (country | length) == 2 }}"
    filterType: INCLUDE
    errorOrNullBehavior: EXCLUDE
  - id: rejected_rows
    type: io.kestra.plugin.core.storage.FilterItems
    from: "{{ inputs.partition }}"
    filterCondition: "{{ email contains '@' and (country | length) == 2 }}"
    filterType: EXCLUDE
    errorOrNullBehavior: INCLUDE
  - id: reject_ratio_gate
    type: io.kestra.plugin.core.execution.Assert
    conditions:
      - "{{ outputs.valid_rows.processedItemsTotal == 0 or (outputs.valid_rows.droppedItemsTotal / outputs.valid_rows.processedItemsTotal) <= inputs.max_reject_ratio }}"
    errorMessage: "partition {{ inputs.partition_index }}: {{ outputs.valid_rows.droppedItemsTotal }} of {{ outputs.valid_rows.processedItemsTotal }} rows rejected, above max_reject_ratio {{ inputs.max_reject_ratio }}"
outputs:
  - id: clean_uri
    type: STRING
    value: "{{ outputs.valid_rows.uri }}"
  - id: rejects_uri
    type: STRING
    value: "{{ outputs.rejected_rows.uri }}"
  - id: rows_in
    type: INT
    value: "{{ outputs.valid_rows.processedItemsTotal }}"
  - id: rows_rejected
    type: INT
    value: "{{ outputs.valid_rows.droppedItemsTotal }}"

How to run

  1. Create the worker flow above in the same namespace (Kestra 2.0 with the serdes and Slack plugins). Set the SLACK_WEBHOOK_URL secret or leave it: both Slack tasks have allowFailure: true.
  2. Run this flow with the defaults: 20 rows in, 15 records, 11 clean rows, 3 rejected, 3 partitions. Outputs: clean_file (one row per record, newest version, no tombstones, in feed order), rejects_file (rows that failed validation plus every row of a refused partition) and summary (the counts as JSON).
  3. Run it again with source_order: oldest_first on the same sample: 12 clean rows and the wrong delete applied, which is what a wrongly declared order does.
  4. Upload your own CSV or JSONL as source_file, raise rows_per_partition and adjust max_reject_ratio. Point the after_export trigger at the flow that produces your feed (it reads that flow's export_file output).

Error handling

  • Four checks block a wrong input combination before the run starts (JSONL without a file, a row count below 1, a byte size without a KB/MB/GB unit, a ratio outside 0..1).
  • An empty or ragged feed fails fast; the failed task and message land in the logs, the change_feed_last_failure KV entry and the Slack alert.
  • Partition isolation: one bad chunk ends in the rejects file and the change_feed_failed_partitions dead-letter entry while the others are loaded; fail_on_partition_failure: false makes that a successful run with the rejects reported. A worker flow id typo fails every partition and the error message says so.
  • Labels on the run and on every worker execution filter the Executions page by parent run or by failed partitions. The sla fails a run above two hours and labels it change_feed_sla: breached.

How to adapt

  • Edit dedupe_newest_wins.expr, the drop_tombstones filter and the worker filterCondition for your own columns and rules.
  • Use split_by: bytes when partitions must fit a target's request size limit.
  • Write JSONL (IonToJson) or Parquet (IonToParquet) instead of CSV in assemble_clean.
  • Replay a refused partition by running the worker with the partition_uri from the dead-letter entry once the feed is fixed.

See also

See How

New to Kestra?

Use blueprints to kickstart your first workflows.