Schedule icon
Trigger icon
List icon
Script icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Download icon
Upload icon
Move icon
Fail icon

Redeliver outbound FTP feeds the partner never received

Compare an outbound FTP spool with the partner inbox, find handoffs that never landed, re-upload behind approval then prove the bytes by checksum.

Categories
DataInfrastructure

Safety

Re-uploading a feed hands the same business data to a third party a second time. A duplicate drop can turn into duplicate invoices, duplicate orders or a double counted day. An overwrite can destroy a file the partner has already taken in. The handoff job that reported success is also the least trustworthy witness in the story, so this flow never believes it. Every guard below is load bearing:

Guard What it stops
Partner inbox listing is the only authority A handoff job that logged success while nothing arrived
sla_minutes delivery window Fighting the nightly transfer over a file it is still allowed to send
Writer markers (.part, .lock, .tmp, .writing) Handing over a feed the producer is still writing
deliverable_pattern write allowlist Pushing a README, a checksum sidecar or an operator scratch file to the partner
Zero byte source refused Publishing an aborted export as an empty feed day
max_file_megabytes per-file ceiling One oversized feed occupying the worker and the partner link
max_files_per_run ceiling Treating a two hundred file backlog as a retry job instead of an incident
Archive directory membership Sending a name that was already handed over once
Diverged copy refused Overwriting a partner copy that is larger than ours, so the two sides disagree
dry_run plus auto_remediate plus Pause Any write at all without three deliberate decisions
Both listings re-read after the approval Acting on a plan that went stale while a person was reading it
Partner side SHA-256 compared with the source Calling a truncated or corrupted upload a delivery
Archive move only after the checksum matched A delivered feed that the next run would send again

How it works

  1. list_spool (io.kestra.plugin.fs.ftp.List) reads the outbound spool. It lists everything, not just the deliverable pattern, because a writer marker has to be visible before its sibling can be called late.
  2. list_archive (io.kestra.plugin.fs.ftp.List) reads the archive of files this flow has already handed over.
  3. list_partner_inbox (io.kestra.plugin.fs.ftp.List) reads the partner inbox on the partner's own server. This listing is the only authority on what the partner holds.
  4. reconcile (io.kestra.plugin.scripts.python.Script) joins the listings by file name and gives every spool entry one verdict: missing when the partner has no copy, short_delivery when the partner copy is smaller, delivered when the sizes match, diverged when the partner copy is larger, then within_sla, writer_active, writer_marker, not_deliverable, empty_source, too_large, already_archived and over_ceiling for everything it refuses or spares. Only missing and short_delivery become targets, oldest first.
  5. report (io.kestra.plugin.core.log.Log) prints one line per spool entry with the spool size, the partner side size, the age and the reason.
  6. handoff_gap (io.kestra.plugin.core.flow.If) runs only when something needs attention. notify_gap (io.kestra.plugin.slack.notifications.SlackIncomingWebhook) posts the whole reconciliation, spared entries included, so the refusals are as visible as the repairs.
  7. route_redelivery (io.kestra.plugin.core.flow.If) requires auto_remediate true, dry_run false plus at least one target. Miss one and the run stays a report.
  8. approval (io.kestra.plugin.core.flow.Pause) waits for a person. approved_redelivery reads onResume.approved ?? false, so a pause resumed with no answer is a refusal.
  9. recheck_spool, recheck_archive plus recheck_partner read both servers again, then revalidate_plan (io.kestra.plugin.scripts.python.Script) compares every approved target with those second listings. A source that vanished, changed size, changed last modified time, grew a writer marker, reached the archive or reached the partner in full makes the plan stale. plan_unchanged (io.kestra.plugin.core.flow.If) then either runs the batch or stale_plan (io.kestra.plugin.core.execution.Fail) stops the whole run before a single byte is written.
  10. redeliver (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) handles one file at a time. fetch_spool_file (io.kestra.plugin.fs.ftp.Download with checksumAlgorithm: SHA_256) reads the source and records its hash, push_to_partner (io.kestra.plugin.fs.ftp.Upload) is the only write that touches the partner, confirm_partner_bytes (io.kestra.plugin.fs.ftp.Download with validateChecksum: true plus checksumExpected) reads the file back off the partner and fails the iteration on any mismatch, then archive_spool_file (io.kestra.plugin.fs.ftp.Move) renames the source into the archive. That rename is what makes the next run a no-op instead of a second delivery.
  11. reread_partner, reread_spool plus reread_archive read both servers a third time and verify_delivery (io.kestra.plugin.scripts.python.Script) exits non-zero unless every approved file is on the partner at the full source size, has left the spool, sits in the archive at the same size plus every partner file the run did not target still has the size it had before.
  12. audit_delivered_bytes (io.kestra.plugin.core.flow.Loop) downloads the archived source then the partner copy once more and lets the plugin compare their SHA-256, so the proof is about the state at the end of the run, not only the moment of the upload.
  13. errors fires alert_failure (io.kestra.plugin.slack.notifications.SlackIncomingWebhook) on any failure, including a checksum mismatch, a refused upload or a stale plan.

The outputs worth reading afterwards: {{ outputs.reconcile.vars.findings }} is the full table with a verdict and a reason per spool entry, {{ outputs.reconcile.vars.targets }} is only what the run is willing to send, {{ outputs.reconcile.vars.missingBytes }} is how many bytes the partner is short of, {{ outputs.revalidate_plan.vars.staleReport }} names anything that changed during the approval and {{ outputs.verify_delivery.vars.failures }} lists every check that did not hold.

What you get

  • The gap named per file instead of per directory: which feed, how many bytes short, how long it has been waiting plus whether the flow will touch it.
  • A redelivery that proves itself. The partner copy is read back and compared with the source by SHA-256 before the source is archived, so a transfer that half worked is a failed run, not a green one.
  • An archive rename that makes the operation idempotent. Run it twice and the second pass has nothing to do.
  • Refusals you can act on. A diverged partner copy, an empty export or a name already handed over are reported with the sizes on both sides rather than quietly overwritten.
  • A failure that leaves the spool intact, so the next run can finish the work without a person reconstructing what landed.

Who it's for

Data platform and integration teams who push files to a partner, a bank, a carrier or a clearing house over FTP and find out days later that a feed never arrived, because the only evidence anyone kept was the transfer job's own exit code.

Why orchestrate this with Kestra

Done by hand this is two terminals, a listing diff done by eye, a put from the wrong directory plus no record of which file was sent twice. The flow turns that into one audited unit: both listings are read through the FTP plugin, the reconciliation is printed and posted before anything moves, the approval is recorded against the execution, the credentials live in secrets rather than a .netrc plus the delivery is proved by the plugin's own checksum comparison. A spool and a partner inbox are two systems that only a reconciliation can connect. The Pause task is what makes a second delivery safe to automate at all.

Tested end to end

Run on Kestra 2.0.5 (OSS, commit 172dd0f) with plugin-fs 2.12.0 and plugin-scripts 1.14.0, against two vsftpd 3.0.2 servers (fauria/vsftpd@sha256:6d71d7c7f1b0) on one Docker network: a producer spool server holding /outbound plus /outbound/sent next to a separate partner server holding /inbox. Every assertion below was read back with lftp 4.9.2 from a third container, never from the flow's own output. The fixture is eleven spool files covering all twelve verdicts, two archived files plus six partner files, including a 1.5 MB feed and a partner file that belongs to nobody in the spool.

The two authentic gaps are orders-2026-10-14.csv, 1200 bytes, 185 minutes old with no partner copy at all, plus shipments-2026-10-14.csv, 1500 bytes, 170 minutes old with only the first 600 bytes on the partner (the independent reader confirms those 600 bytes are a true byte prefix of the source). Before the repair run lftp measures 2 overdue feeds and 2100 outstanding bytes. After it measures 0 and 0. The partner file count has grown by exactly one.

Case Execution Result
Healthy spool, nothing overdue 2P5h6xUnnyLDEQGriSL0TL SUCCESS, 0 gaps, 0 targets, no Slack message, both servers byte identical afterwards
Degraded, dry run 3ZjenhIskykarqhjTbCX6O SUCCESS, 5 findings need attention, 2 targets named, 0 bytes written
Remediation allowed but still dry TjztN37JbfWiUx4tk8ZIm SUCCESS, no pause offered, report only, nothing written
Approval declined 5Ms6DO7Z9RqAo6ep3yRVlZ SUCCESS, pause reached, nothing uploaded
Pause resumed with no answer 3u9YvApGF8N5DDxnOUNta9 SUCCESS, read as a refusal, nothing uploaded
Approved redelivery E2umU9cZUBOh9veTKDGLS SUCCESS, 2 files delivered, 2100 bytes closed, both sources archived, partner SHA-256 equal to the source
Second run straight after 1IqNGimawl0DXkEbNf5VRJ SUCCESS, 0 targets, no pause offered, not one byte resent
Partner revoked write on the inbox 5rKlYFJqOLnHoUMiVLpVys FAILED on the upload, partner unchanged, source still in the spool, nothing archived
Same spool once the partner allowed writes 6hh8fzh6f4Ey9bi3ZhoUBB SUCCESS, both feeds delivered, 0 overdue feeds left
Source unreadable on the second file FY5MLcxqeuKD2eWN0dG4N FAILED after the first file, first feed delivered plus archived, second never reached the partner
Next run after that interrupted pass 3B68GTKeHlgdViu7aNf71P SUCCESS, only the unfinished feed targeted, the finished one untouched
Source rewritten during the approval 2a2w8XMVAlCd5bdji1wnaz FAILED, 1 of 2 stale, whole batch refused before any write
Partner received the file during the approval 6hLwxttqaCB36u2uR0gFGc FAILED, no duplicate upload
Name archived during the approval 2byEIqqbOJzXMIRGRGlapb FAILED, no second handover
Writer marker appeared during the approval 6LseSUO6M1ckApoDrFb2WM FAILED, partial feed never sent
Per-run ceiling at 1 40X46Sj52FSSDZdLFDOSx SUCCESS, oldest gap planned, second held as over_ceiling
Per-file ceiling at 1 MB 4t49y7HXJwZYNOUsxg2NMS SUCCESS, the 1.5 MB feed refused as too_large, normal gaps still planned
SLA window at 175 minutes 49cIPIiBYSWX7rB9tofU77 SUCCESS, the 170 minute feed spared, the 185 minute feed still flagged
Allowlist that excludes one gap rhX7jo5zfu9XBmNG40krD SUCCESS, the excluded name reported not_deliverable, never pushed

In every run the protected entries behaved the same way: the file inside the SLA window, the file with a live .part marker, the marker itself, the README.txt outside the allowlist, the zero byte export, the already archived name, the diverged partner copy plus the partner file with no spool counterpart were all left byte for byte unchanged.

The healthy, approved, idempotent, interrupted, reconciled and race paths were then reproduced on a second pair of fresh FTP containers with different users and passwords (3bvdgm3pHsK6tCDOKNHWce, 13x8WQP4ZHl2dh56yEvNPl, BiqCYUASrsVkABAg3YaKo, 2Iur1xPfTj6fnUwEIywXes, 574fnXwH5uAKsfiD7jViyO, 63vkqwgQQvzNNpPzkDoiOl) with the same measured numbers. The spool_arrival trigger shape was proved separately on an enabled copy: it fired a real execution carrying seven spool files with their sizes and timestamps. action: NONE left every file in place.

Prerequisites

  • Two FTP endpoints reachable from the Kestra worker: the spool server and the partner server. The spool account needs list, read plus rename inside the spool directory and the archive directory under it. The partner account needs list, write plus read back inside the inbox directory and nothing else.
  • Passive mode has to work from the worker, since the plugin uses it.
  • Kestra 2.0 with plugin-fs plus a Docker task runner for the Python steps.
  • A Slack incoming webhook.

Secrets

Secret What it is
FTP_SPOOL_HOST Host of the FTP server holding the outbound spool
FTP_SPOOL_USERNAME Spool account with list, read plus rename rights
FTP_SPOOL_PASSWORD Password for the spool account
FTP_PARTNER_HOST Host of the partner FTP server
FTP_PARTNER_USERNAME Account the partner issued for the drop zone
FTP_PARTNER_PASSWORD Password for the partner account
SLACK_WEBHOOK_URL Incoming webhook for the reconciliation plus the failure alert

Inputs

Input Type Default What it does
spool_host STRING secret('FTP_SPOOL_HOST') spool server
spool_port STRING 21 spool control port
spool_username STRING secret('FTP_SPOOL_USERNAME') spool account
spool_password STRING secret('FTP_SPOOL_PASSWORD') spool password
spool_directory STRING /outbound the only directory read for candidates
archive_directory STRING /outbound/sent where a delivered source is renamed to
partner_host STRING secret('FTP_PARTNER_HOST') partner server
partner_port STRING 21 partner control port
partner_username STRING secret('FTP_PARTNER_USERNAME') partner account
partner_password STRING secret('FTP_PARTNER_PASSWORD') partner password
partner_inbox STRING /inbox the only directory this flow ever writes to
deliverable_pattern STRING ^[A-Za-z0-9._-]+\.(csv|edi|dat)$ write allowlist by file name
in_progress_suffixes ARRAY .part, .lock, .tmp, .writing markers that mean the producer is still writing
sla_minutes INT 60 how old a spool file must be before it counts as a missed handoff
max_files_per_run INT 3 per-run redelivery ceiling, oldest first
max_file_megabytes INT 64 per-file size ceiling
scan_limit INT 200 maximum entries read from each directory
dry_run BOOL true report without writing
auto_remediate BOOL false allow a redelivery to be considered
slack_channel STRING #data-handoffs channel for the report plus the failure alert

Quick start

  1. Add the seven secrets above to your Kestra instance.
  2. Run it as it ships. dry_run true with auto_remediate false lists every spool file with its partner side size plus a verdict. Nothing is written.
  3. Read the report before you touch anything. A diverged line means the partner holds more bytes than you do under that name, which is a conversation, not a retry.
  4. Check that deliverable_pattern matches your feed names plus that in_progress_suffixes matches what your producer writes while a file is open.
  5. Set sla_minutes to a little more than your normal handoff window, so the flow stays out of the transfer job's way.
  6. Run with dry_run: false and auto_remediate: true. The execution pauses with the plan in the log and in Slack.
  7. Approve it. The flow re-reads both servers, uploads only what is still eligible, compares the partner bytes with the source by SHA-256, archives each delivered source then fails the run if any of that does not hold.
  8. Enable the nightly_reconcile schedule once you trust the report. It forces report only mode, so turning it on can never upload a file by itself.

How to extend

  • Give each partner its own execution of this flow with its own spool directory, inbox, allowlist plus SLA, rather than one flow for every trading partner.
  • Enable spool_arrival instead of the schedule to reconcile whenever the spool changes. Its regExp is matched against the whole remote path, so keep the pattern and from in step. Remember too that the trigger copies each matched file into internal storage on every poll.
  • Swap io.kestra.plugin.fs.ftp for io.kestra.plugin.fs.ftps or io.kestra.plugin.fs.sftp when the partner moves off plain FTP. The task names, the checksum properties plus the whole reconciliation are the same.
  • Write the verdict table to a warehouse table per run, which turns the report into a delivery SLA record per partner and per feed.
  • Replace the Slack tasks with PagerDuty when a missed feed has a contractual deadline.
  • Add a partner side manifest check by uploading a sidecar hash file next to each feed, if the partner publishes one.

Limits: detection compares names and sizes, not content. A partner copy that is the same size as the source but holds different bytes reads as delivered, because hashing every file on both sides on every run would mean downloading the whole spool twice. The SHA-256 comparison covers what this flow uploads, which is where it can cause harm. A partner who renames or repackages files on arrival will also look like a partner who received nothing. Point it at the directory the partner leaves alone. Failing that, match on their naming scheme first. The partner side checksum proves the bytes arrived in that directory, not that the partner has processed them. diverged, empty_source plus already_archived are refusals by design and are reported on every run until a person clears them. The SLA and the ages come from each server's own last modified timestamps, which a plain FTP listing reports to the minute, so set sla_minutes with more than a minute of margin. One execution carries one spool directory. Two concurrent executions over the same spool are not serialized by this flow: use a flow level concurrency limit if you schedule it more often than a run takes. scan_limit bounds each listing, so a spool holding more entries than that is read in part: raise it or split the spool rather than trusting a truncated listing.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.