Skip to content

Record trigger failures and owner on its row; tag metrics by kind - #76101

Merged
potiuk merged 3 commits into
apache:mainfrom
aws-mwaa:ghaeli/to6-trigger-state
Nov 1, 2026
Merged

potiuk merged 3 commits into
apache:mainfrom
aws-mwaa:ghaeli/to6-trigger-state

Conversation

@seanghaeli

@seanghaeli seanghaeli commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

A trigger that keeps failing leaves nothing in the database, so no API, UI or alert can tell, and the row doesn't say who the trigger runs for. triggers.failed can't tell a watcher from a task's trigger either.

  • Each failure writes consecutive_failures, first_failed_at, last_failed_at and last_error to the trigger's row. last_error is the exception type and message only, masked like task logs and cut to 1,000 characters. An event sets last_event_at.
  • An error message is stored with any characters the database can't hold (such as NUL on Postgres) escaped, so a bad message can't stop the triggerer.
  • A trigger counts as healthy again, and its failure count resets, when it fires an event or runs for two minutes without failing (Trigger recovered is logged). So a watcher that ran fine for a day and then crashed once shows one failure, not every failure since it first crashed.
  • Task and callback triggers record dag_id and owner_ref when created; the migration adds nullable columns and fills the owner in for existing rows, and the triggerer fills it in for rows older components create during a rolling upgrade.
  • triggers.failed and triggers.succeeded get a trigger_kind tag (one of task, asset_watcher or callback), by what the trigger runs for. A new triggers.failing gauge counts failing triggers per triggerer and kind.

How to review: TriggerFailureTracker in jobs/triggerer_job_runner.py, then the row writes, Trigger.submit_failure and clear_recorded_failures in models/trigger.py.

Breeze on Postgres, a watcher with expired credentials next to healthy watchers, deferred tasks and a deadline callback:

# after this PR
=== A) [09:39:21] creds watcher failing, re-created as on main
--- row: (id, consecutive_failures, first_failed_at, last_failed_at, last_event_at, last_error)
(1, 36, '09:38:16', '09:39:27', None, 'ConnectionError: TO creds: the security token included in the request is expired')
  creds instance starts: 16 (bad=True 16); gaps s: [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0] ...
  triggers.failing (latest per kind): asset_watcher=1 callback=0 task=0 
  triggers.failed lines by tag:  37 triggers.failed,trigger_kind=asset_watcher; 1 triggers.failed,trigger_kind=task;
=== deferred task trigger that raised: wait_fail ti state=failed, trigger instances started=1, last poll=TO-TASKWAIT wait_fail poll 20, task triggers left on rows: 0
  triggers.failing (latest per kind): asset_watcher=1 callback=0 task=0 
  triggers.failed lines by tag:  40 triggers.failed,trigger_kind=asset_watcher; 1 triggers.failed,trigger_kind=task;
=== B) [09:39:37] credentials fixed and the watcher fires: recovery by an event
--- row:
(1, None, None, '09:39:37', '09:39:39', 'ConnectionError: TO creds: the security token included in the request is expired')
  creds instance starts: 1 (bad=True 0); gaps s: []
  triggers.failing (latest per kind): asset_watcher=0 callback=0 task=0 
  triggers.failed lines by tag:  41 triggers.failed,trigger_kind=asset_watcher; 1 triggers.failed,trigger_kind=task;
=== C) [09:39:51] fails again, then credentials fixed with no event: recovery by staying up
--- [09:40:03] failing again: row: failures=7  gauge(asset_watcher)=1
--- row:
(1, 8, '09:39:54', '09:40:08', '09:39:39', 'ConnectionError: TO creds: the security token included in the request is expired')
[09:40:10] credentials fixed, no event
[09:40:12] re-created with good credentials, up from about 2026-10-29T09:40:12.039683
--- [09:41:12] up ~60 s, no event yet: row: failures=9  gauge(asset_watcher)=1
--- [09:42:15] up ~120 s: row: failures=None  gauge(asset_watcher)=0
--- row:
(1, None, None, '09:40:10', '09:39:39', 'ConnectionError: TO creds: the security token included in the request is expired')
--- triggerer output: recovery lines
2026-10-29T09:39:39.465335Z [info     ] Trigger recovered              [airflow.jobs.triggerer_job_runner] after_failures=41 loc=triggerer_job_runner.py:961 trigger_id=1 trigger_kind=asset_watcher
2026-10-29T09:42:11.787707Z [info     ] Trigger recovered              [airflow.jobs.triggerer_job_runner] after_failures=9 loc=triggerer_job_runner.py:961 trigger_id=1 trigger_kind=asset_watcher

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code (Opus 5.5) following the guidelines

A trigger that kept failing left no trace in the database, so no API, UI or
alert could say it was failing, how often or why. Nothing on the row said who
the trigger ran for either, so a view of every trigger would need a different
lookup for each kind.

Each failure now records on the row how many failures in a row, since when,
and the error (type and message only, masked with the task SDK masker as the
task runner does, cut to 1,000 characters), and a triggerer that picks the
trigger up continues the count. The count resets once the trigger fires an
event or stays up for two minutes; the last error is kept. Failing triggers
are still re-created every couple of seconds as before, so this is one row
update per failure. A deferred task's or callback's trigger also records its
Dag and owner when it is created, and the migration fills them in for rows
that already exist. Rows created without one afterwards, by components still
on the previous version during a rolling upgrade, get it from the triggerer
when it picks them up.

triggers.failed and triggers.succeeded also could not tell an asset watcher
from a deferred task's or a callback's trigger, so there was no metric to
alert on for failing watchers alone, and nothing said how many triggers are
failing right now. They are now tagged with trigger_kind, and a new
triggers.failing gauge counts the triggers of each kind that failed since
they last recovered. Per-trigger tags would make cardinality grow with the
number of triggers, so the metrics stay per kind; which trigger is failing
is on its row.
Postgres refuses a NUL character in text, so a trigger error containing one made writing the failure raise and stop the triggerer.
@seanghaeli
seanghaeli marked this pull request as ready for review October 30, 2026 23:35

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this reads well. I checked the tracker's recovery and hand-over logic, the triggerer_id-guarded row writes, the masking of last_error (connections and variables fetched through the supervisor are registered with its masker, so redact() covers them), the NUL/surrogate escaping, and the migration's backfill and downgrade. The remaining eager-loading nit only adds a lazy load for rows without an owner during a rolling upgrade, as trigger.assets was already loaded per non-task trigger before, so it doesn't need to block this.


Drafted-by: Claude Code (Claude Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 8b178f5 into apache:main Nov 1, 2026
77 checks passed
hussein-awala added a commit to hussein-awala/airflow that referenced this pull request Nov 1, 2026
apache#76101 sets last_event_at on the trigger row in submit_event, which no
longer loads that row, so write it with an UPDATE by trigger id. The two
submit_event query budgets in test_trigger.py count that UPDATE.
@eladkal eladkal added this to the Airflow 3.5.0 milestone Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants