Repository navigation
Stop a stale callback stat from forcing constant Dag file reparsing - #73433
vatsrahul1001 merged 4 commits into
Conversation
|
We're hitting this on 3.3.2 in production: standalone dag-processors (one per team), Within two and a half days of a redeploy it started on four files across three dag-processors. Each looping file is parsed 2-3 times a second instead of every 120 s, costs 1-1.5 CPU cores, and its child-process log grows by 80-280 MB a day. A restart clears it; the file's next callback brings it back, usually within a day. One addition to the issue: here the trigger was task-level callbacks, not Dag-level ones. These are the main still resolves the stats by insertion order in @jedcunningham @ephraimbuddy could you take a look? A backport to v3-3-test would help too. |
061b53f to
27e82d5
Compare
|
A related symptom from the same versioned callback entry, which this PR doesn't seem to cover: What seems to happen
In the production case, the stats table showed 0 errors for the same file at the same time, since Repro (Astronomer Runtime build of 3.3.2, import time
from datetime import datetime
from airflow.sdk import dag, task
def slow_callback(context):
time.sleep(120)
@dag(start_date=datetime(2026, 1, 1), schedule=None, catchup=False, on_failure_callback=slow_callback)
def ghost_import_error_repro():
@task
def fail():
raise ValueError("fail on purpose")
fail()
ghost_import_error_repro()After one triggered run, the callback was killed after about 30s, and We first saw this in production on a 3.2.2 build with #66484 backported. The relevant code ( Possible directions (not sure which fits best):
Happy to open a separate issue if you'd prefer to keep this PR focused. |
|
af2ad8d to
581f7d5
Compare
|
Thanks @kaxil and @fahadabdul, all three are in the latest commit:
|
A callback pins bundle_version on its DagFileInfo while the bundle scan leaves it unset, so one file can hold two _file_stats entries. Collapsing them by presence_key with a dict comprehension keeps whichever was inserted last, which is the callback entry, and that entry is never refreshed afterwards because later parses write to the scan key. Its frozen last_finish_time makes the file look long unparsed, so min_file_process_interval never excludes it and the processor reparses it on every loop until it restarts. Resolve the collapse by the most recent parse instead. _sort_by_mtime and processed_recently share the same lookup, which previously disagreed with each other on which duplicate wins.
Callback runs never record a parse of the current file: a callback-only stat has no last_finish_time, and a timed-out callback processor is stamped anyway. Fall back to a versioned stat only when the file has no scan entry.
581f7d5 to
8515456
Compare
Head branch was pushed to by a user without write access
Once a Dag-level callback has run for a file, the Dag processor reparses that file on
every loop and stops honouring
[dag_processor] min_file_process_intervalfor it. Itdoesn't clear on its own. Restarting the processor fixes it, until the next Dag run
failure.
Why
_add_callback_to_queueis the only place that setsbundle_versionon aDagFileInfo; the bundle scan leaves it unset. That's deliberate.bundle_pathiscompare=False, sobundle_versionis the only field keeping a version-pinned callbackdistinct from the scan entry. Without it, a pinned callback would parse against the
tracking checkout instead of the version its Dag run used.
The side effect is that one file can hold two
_file_statsentries.prepare_file_queuecollapses them withwhich keeps whichever went in last. That's the callback entry, since the file must be
parsed before a run can fail.
That entry never gets a timestamp.
process_parse_resultsbuilds a callback-only statwithout
last_finish_time(deliberately, to avoid disturbing stale-Dag detection) andhandle_parsing_resultwrites it over the entry wholesale, so the value isNone. Itstays
None, because every later parse writes to the scan key instead. From then on theinterval check sees a file that has apparently never been parsed, and requeues it every
loop.
It stays invisible in the stats table, because
_log_file_processing_statsreads_file_statsby exact key. It reports the scan entry's healthy timestamp while thequeue logic reads the callback entry's
None.Only versioning-capable bundles hit this. For unversioned requests
request.bundle_versionisNone(see #72930), the two keys coincide, and the merge iscorrect.
Fix
Resolve the
presence_keycollapse from the bundle scan's entry and ignore versioned(callback) entries. A callback run never records a parse, so a versioned stat is only
ever
Noneor a timeout stamp that_kill_timed_out_processorswrites although nothingwas parsed.
prepare_file_queueand_sort_by_mtimeshare the new_resolve_stats_by_presence_key, andemit_metricsuses it too, so a timed-outcallback's
import_errors=1no longer keepsdag_processing.import_errorsat 1(reported by @fahadabdul). #66484's two versioned-stat tests are flipped to expect the
file to be queued.
processed_recentlyis left as is and will be removed separately.This leaves identity and lifecycle alone, so a versioned callback entry still survives
cleanup while the unversioned file is present.
Reproduction
Local Airflow built from main, sqlite,
GitDagBundle(versioning on),min_file_process_interval = 30, two Dags: one with a Dag-levelon_failure_callbackand a task that raises, one control. Measured by polling
dag.last_parsed_time:The third row triggers a fresh failure after the restart. A restart on its own clears
the state, so measuring that alone would prove nothing.
I found this on 3.3.1, where it held a Dag processor at its 1 CPU limit indefinitely.
Two Dag files were reparsing about 43x more often than configured while the other five
in the same bundle sat exactly on interval.
Tests
The tests produce the callback stat through the code that writes it in production: a
callback-only
handle_parsing_result, in both insertion orders, and_kill_timed_out_processors, for the interval check,modified_timechange detectionand the import-error gauge. Each asserts its setup before checking the result.
Verification run locally
manager.pythey all fail except thecallback-recorded-firstcase, where insertionorder happens to pick the scan entry.
airflow-core/tests/unit/dag_processing/test_manager.py: 192 passed, 5 skipped(postgres-only). 12 tests error at fixture setup in my local env, and the same 12 error
on unpatched main, so they are unrelated.
ruff checkandruff format --checkare clean, and so ismypyon the changedmodule.
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Opus 5 following the guidelines
closes: #73432