id: airflow-paused-dag-missed-run-backfill-guard
namespace: company.team
description: |
Find daily logical dates in an Airflow DAG window that have no DAG run,
then optionally backfill those dates one at a time after approval. The plan
is bounded by max_backfill_count and every requested date is verified again
from Airflow after the backfill.
triggers:
- id: audit_schedule
type: io.kestra.plugin.core.trigger.Schedule
description: Disabled schedule for a safe audit. Trigger inputs always force
read-only mode.
cron: "0 * * * *"
disabled: true
inputs:
auto_remediate: false
dry_run: true
inputs:
- id: airflow_base_url
type: STRING
displayName: Airflow Base URL
defaults: "http://localhost:8080"
description: Airflow webserver base URL reachable by the Kestra worker.
- id: dag_id
type: STRING
displayName: DAG ID
defaults: "target_dag"
description: Daily DAG whose logical dates are audited.
- id: window_start
type: STRING
displayName: Window Start
defaults: "2026-10-01T00:00:00Z"
description: Inclusive UTC logical date at the start of the audit window.
- id: window_end
type: STRING
displayName: Window End
defaults: "2026-10-05T00:00:00Z"
description: Inclusive UTC logical date at the end of the audit window.
- id: max_backfill_count
type: INT
displayName: Maximum Backfill Count
defaults: 3
description: Hard ceiling on missing daily dates that may be requested in one run.
- id: auto_remediate
type: BOOL
displayName: Auto Remediate
defaults: false
description: Enables the approval and backfill branch when true.
- id: dry_run
type: BOOL
displayName: Dry Run
defaults: true
description: Keeps the flow read-only when true. Must be false with
auto_remediate true to write.
tasks:
- id: read_runs_before
type: io.kestra.plugin.core.http.Request
description: Read all existing DAG runs from Airflow before planning. Missing
intervals have no run object to alert on.
uri: "{{ inputs.airflow_base_url }}/api/v1/dags/{{ inputs.dag_id
}}/dagRuns?limit=1000"
method: GET
headers:
Authorization: "Basic {{ (secret('POSTGRES_USERNAME') ~ ':' ~
secret('POSTGRES_PASSWORD')) | base64encode }}"
Accept: application/json
- id: plan_missing
type: io.kestra.plugin.core.output.OutputValues
description: Derive the expected daily UTC dates, subtract Airflow's logical
dates and cap the requested list before any approval.
values:
plan: >-
{{ {'runs': outputs.read_runs_before.body, 'start': inputs.window_start,
'end': inputs.window_end, 'max': inputs.max_backfill_count} | toJson |
jq('. as $r | ($r.runs | fromjson) as $j |
([$j.dag_runs[]?.logical_date] // []) as $present | [range(($r.start |
fromdateiso8601); (($r.end | fromdateiso8601) + 86400); 86400) |
todateiso8601] as $expected | [$expected[] | . as $d |
select(([$present[] | .[0:10] | select(. == ($d[0:10]))] | length) ==
0)] as $missing | {expected: $expected, missing: $missing, requested:
($missing[0:$r.max]), missing_count: ($missing | length), over_limit:
(($missing | length) > $r.max)} | tojson') }}
- id: log_plan
type: io.kestra.plugin.core.log.Log
description: Record the measured gap and the bounded request before any write gate.
message: >-
Airflow gap audit for {{ inputs.dag_id }}: {{
outputs.plan_missing.values.plan | jq('first | fromjson | .missing_count')
| first }} missing daily dates, requested {{
outputs.plan_missing.values.plan | jq('first | fromjson | .requested |
length') | first }} of max {{ inputs.max_backfill_count }}. Dates={{
outputs.plan_missing.values.plan | jq('first | fromjson | .requested |
join(",")') | first }}
- id: remediation_gate
type: io.kestra.plugin.core.flow.If
description: Only a non-dry run with auto_remediate enabled can reach approval
or TriggerDagRun.
condition: "{{ inputs.auto_remediate and not inputs.dry_run and
(outputs.plan_missing.values.plan | jq('first | fromjson | .requested |
length') | first) > 0 and not (outputs.plan_missing.values.plan |
jq('first | fromjson | .over_limit') | first) }}"
then:
- id: approve_backfill
type: io.kestra.plugin.core.flow.Pause
description: Approve the exact chronological logical-date list. Declining leaves
Airflow unchanged.
onResume:
- id: approved
type: BOOL
defaults: false
description: Set true only after reviewing the requested logical dates.
- id: approval_gate
type: io.kestra.plugin.core.flow.If
description: Continue only when the Pause explicitly returned approved true.
condition: "{{ outputs.approve_backfill.onResume.approved ?? false }}"
then:
- id: read_runs_after_pause
type: io.kestra.plugin.core.http.Request
description: Re-read Airflow immediately after approval so a newly-created run
makes the old plan stale instead of being duplicated.
uri: "{{ inputs.airflow_base_url }}/api/v1/dags/{{ inputs.dag_id
}}/dagRuns?limit=1000"
method: GET
headers:
Authorization: "Basic {{ (secret('POSTGRES_USERNAME') ~ ':' ~
secret('POSTGRES_PASSWORD')) | base64encode }}"
Accept: application/json
- id: plan_after_pause
type: io.kestra.plugin.core.output.OutputValues
description: Recompute the bounded missing-date plan from the post-approval
Airflow read.
values:
plan: >-
{{ {'runs': outputs.read_runs_after_pause.body, 'start':
inputs.window_start, 'end': inputs.window_end, 'max':
inputs.max_backfill_count} | toJson | jq('. as $r | ($r.runs |
fromjson) as $j | ([$j.dag_runs[]?.logical_date] // []) as
$present | [range(($r.start | fromdateiso8601); (($r.end |
fromdateiso8601) + 86400); 86400) | todateiso8601] as $expected
| [$expected[] | . as $d | select(([$present[] | .[0:10] |
select(. == ($d[0:10]))] | length) == 0)] as $missing |
{requested: ($missing[0:$r.max]), missing_count: ($missing |
length), over_limit: (($missing | length) > $r.max)} | tojson')
}}
- id: stale_plan_check
type: io.kestra.plugin.core.output.OutputValues
description: Compare the approved request with the fresh Airflow plan and refuse
a changed target set.
values:
result: >-
{{ {'before': outputs.plan_missing.values.plan, 'after':
outputs.plan_after_pause.values.plan} | toJson | jq('. as $r |
(($r.before | fromjson | first | fromjson).requested) as $b |
(($r.after | fromjson | first | fromjson).requested) as $a |
{same: ($b == $a), before: $b, after: $a} | tojson') }}
- id: reject_stale_plan
type: io.kestra.plugin.core.flow.If
description: A changed run list fails closed before any TriggerDagRun write.
condition: "{{ not (outputs.stale_plan_check.values.result | jq('first |
fromjson | .same') | first) }}"
then:
- id: fail_stale_plan
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Airflow run list changed during approval. Approved={{
outputs.stale_plan_check.values.result | jq('first | fromjson
| .before | join(\",\")') | first }} current={{
outputs.stale_plan_check.values.result | jq('first | fromjson
| .after | join(\",\")') | first }}. No backfill was started."
- id: backfill_missing_dates
type: io.kestra.plugin.core.flow.Loop
description: Trigger each missing logical date in chronological order with one
active Airflow run at a time.
values: "{{ outputs.plan_after_pause.values.plan | jq('first | fromjson |
.requested') | first }}"
concurrencyLimit: 1
tasks:
- id: trigger_missing_run
type: io.kestra.plugin.airflow.dags.TriggerDagRun
description: Create and wait for exactly one missing logical date. Airflow
receives the date as logical_date and returns its terminal
state.
baseUrl: "{{ inputs.airflow_base_url }}"
dagId: "{{ inputs.dag_id }}"
wait: true
pollFrequency: PT2S
maxDuration: PT10M
body:
logical_date: "{{ item.value }}"
conf:
kestra_execution_id: "{{ execution.id }}"
backfill_guard: true
options:
auth:
type: BASIC
username: "{{ secret('POSTGRES_USERNAME') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
- id: read_runs_after_backfill
type: io.kestra.plugin.core.http.Request
description: Read Airflow after all sequential triggers. The run list, not
TriggerDagRun acceptance, proves recovery.
uri: "{{ inputs.airflow_base_url }}/api/v1/dags/{{ inputs.dag_id
}}/dagRuns?limit=1000"
method: GET
headers:
Authorization: "Basic {{ (secret('POSTGRES_USERNAME') ~ ':' ~
secret('POSTGRES_PASSWORD')) | base64encode }}"
Accept: application/json
- id: verify_backfill
type: io.kestra.plugin.core.output.OutputValues
description: Fail the execution when any requested date is still absent or has a
failed Airflow run.
values:
result: >-
{{ {'runs': outputs.read_runs_after_backfill.body, 'requested':
outputs.plan_after_pause.values.plan} | toJson | jq('. as $r |
($r.runs | fromjson) as $j | (($r.requested | fromjson | first |
fromjson).requested) as $req | [$req[] | . as $d |
([$j.dag_runs[]? | select((.logical_date[0:10]) == ($d[0:10]))])
as $hits | if ($hits | length) == 0 then {date: $d, state:
"missing"} elif (($hits[0].state | ascii_downcase) == "failed")
then {date: $d, state: "failed"} else empty end] as $bad | {bad:
$bad, complete: (($bad | length) == 0)} | tojson') }}
- id: fail_incomplete_backfill
type: io.kestra.plugin.core.flow.If
description: Make missing or failed requested dates an explicit failed
execution.
condition: "{{ not (outputs.verify_backfill.values.result | jq('first | fromjson
| .complete') | first) }}"
then:
- id: fail_verification
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Airflow backfill verification failed: {{
outputs.verify_backfill.values.result | jq('first | fromjson |
.bad | map((.date + \"=\" + .state)) | join(\",\")') | first
}}"
- id: log_backfill_complete
type: io.kestra.plugin.core.log.Log
message: "Airflow backfill verified for {{ inputs.dag_id }}. requested={{
outputs.plan_after_pause.values.plan | jq('first | fromjson |
.requested | length') | first }} bad={{
outputs.verify_backfill.values.result | jq('first | fromjson |
.bad | length') | first }}"
else:
- id: log_declined
type: io.kestra.plugin.core.log.Log
message: "Backfill approval declined. Airflow was not changed."
else:
- id: log_read_only
type: io.kestra.plugin.core.log.Log
message: "Read-only result for {{ inputs.dag_id }}. auto_remediate={{
inputs.auto_remediate }} dry_run={{ inputs.dry_run }} missing={{
outputs.plan_missing.values.plan | jq('first | fromjson |
.missing_count') | first }} requested={{
outputs.plan_missing.values.plan | jq('first | fromjson | .requested |
length') | first }} over_limit={{ outputs.plan_missing.values.plan |
jq('first | fromjson | .over_limit') | first }}. No Airflow runs were
triggered."
errors:
- id: alert_execution_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert when Airflow cannot be read, a backfill run fails or
verification fails.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Airflow missing-run backfill guard FAILED for {{ inputs.dag_id }} in execution {{ execution.id }}. Check Airflow reachability, run state and the verification task before trusting the partition window."
}