Reduce task success asset registration lock contention#66854
Conversation
|
The failing CI job ( This is a All other CI checks pass. |
98668e8 to
e8bca3b
Compare
e8bca3b to
c8086d2
Compare
|
The CI failure in |
|
@hkc-8010 Converting to draft — this PR doesn't yet meet our Pull Request quality criteria. See the linked criteria for how to fix each item, then mark the PR "Ready for review". This is not a rejection — just an invitation to bring the PR up to standard. No rush. Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you. Drafted-by: Claude Code (Opus 4.7); reviewed by @potiuk before posting |
f6486ea to
3cca00a
Compare
3cca00a to
280a86a
Compare
280a86a to
4673ff3
Compare
|
Is it a good idea to make the endpoint succeed when lock contention happens? I feel it’s a consistency issue. We should either do retry in the endpoint, or report back to the client there’s something wrong. |
|
Retry/reconciliation for this path is intentionally out of scope for this PR (called out in the description) — the goal here is resolving the On why it still returns success: by the time Agreed it's not fully consistent. Happy to file a follow-up issue to track retry/reconciliation for dropped asset events as separate work if that addresses the concern for this PR. |
|
Renamed the PR title, Also looked at the |
|
How do you plan to do the followup? The response is indistinguishable to when the asset registration is successful. You response is too hand-wavy to the inconsistency. A concrete plan should be layed out so the behavior is not just silently broken until your next submission is merged. This PR is simply not an improvement on its own. |
|
Since this PR and the discussion mainly revolve around the early-commit approach and the resulting consistency trade-offs, I was wondering whether there might be another approach that avoids the early commit. To be more specific, would it be possible to keep the TI state update and asset registration in the same transaction, but defer acquiring the TI row lock until immediately before the My current understanding of the original flow is: This seems to mean that when asset registration takes a long time, the TI row lock is held for that entire period, causing later requests for the same TI to wait on I was wondering whether the ordering could instead be: If the state is no longer valid at that point, the transaction could be rolled back together with the asset changes. Would this potentially shorten the TI lock window while preserving atomicity between the state update and asset events? (Since #66853 identifies the long-held TI lock and waiter pile-up as the OOM path) |
|
@uranusjr @Andrushika you're right, and I've stopped defending the current shape. Returning 204 while a failed Proposal: a durable transactional outbox plus a scheduler reconciliation sweep, so a dropped registration is guaranteed to be delivered eventually rather than lost.
@Andrushika thanks, your reorder (register before taking the TI lock, then commit state and assets together) is a clean way to shrink the lock window and I considered it seriously. I leaned toward the outbox for two reasons. The reorder still needs a full audit of every Two questions before I implement:
|
03db675 to
530f4ec
Compare
|
@uranusjr I reworked this along the lines we discussed. The set-state endpoint no longer registers asset events under the task_instance lock and no longer drops them on failure. On success it now commits the TI state first (releasing the row lock), then tries the asset write once under a short lock timeout. If it cannot get the locks in time, or fails for any reason, it writes the events to a new asset_event_queue table. The scheduler drains that queue and retries until the events land, parking a row only after a bounded number of failures (with a metric). So the common case still registers inline, contention sheds to the queue, and nothing is silently dropped. I also kept the alias direct-INSERT fix, documented the enqueue-to-registration window in the asset-scheduling docs, and added a significant newsfragment. Validation: new endpoint and scheduler unit tests, plus breeze core-tests on Python 3.10 against both sqlite and postgres. Draining the TI state update itself through the same queue is left out, as we agreed. Would appreciate another look when you get a chance. |
|
To make the comparison concrete, I prototyped it: #70078 It registers the events before taking the I opened it as a draft for comparison. Happy to move it forward if this direction is useful, or drop it if the queue direction is preferred :) |
jason810496
left a comment
There was a problem hiding this comment.
It's a interesting direction to solve this critical issue, thanks!
#protm when this get merged.
530f4ec to
d902e26
Compare
jason810496
left a comment
There was a problem hiding this comment.
Wow this direction looks really clean!
But I traced the code, and I have one concern: if we skip the inline registration, registration only happens in _drain_asset_event_queue, which runs on the scheduler's main loop thread (am I reading this right?).
Yes, you're correct!
So all the registration work would move onto the scheduler loop, and registration can be a heavy job. Every event would also wait for the next drain tick, even when there is no contention.
Find another place to run the registration jobs? But I don't know where is appropriate.
This is the point: "registration can be a heavy job" so it would better not run within this API server hot path. The batch processing on scheduler side is a ideal location for me, mind that we will execute user defined "Airflow Listener" on asset change.
| # Fast-path: now that the state and durable queue marker are committed and the row lock is | ||
| # released, try to register the asset events synchronously and delete the marker. Under | ||
| # contention (or any failure) the marker stays and the scheduler drain registers them later. | ||
| # The isinstance check is redundant with ``enqueued_asset_events`` at runtime but narrows the | ||
| # payload type for the attribute access below. | ||
| if enqueued_asset_events and isinstance(ti_patch_payload, TISuccessStatePayload): | ||
| _try_register_enqueued_asset_events( | ||
| task_instance_id=task_instance_id, | ||
| task_outlets=ti_patch_payload.task_outlets, | ||
| outlet_events=ti_patch_payload.outlet_events, | ||
| session=session, | ||
| ) | ||
|
|
There was a problem hiding this comment.
| # Fast-path: now that the state and durable queue marker are committed and the row lock is | |
| # released, try to register the asset events synchronously and delete the marker. Under | |
| # contention (or any failure) the marker stays and the scheduler drain registers them later. | |
| # The isinstance check is redundant with ``enqueued_asset_events`` at runtime but narrows the | |
| # payload type for the attribute access below. | |
| if enqueued_asset_events and isinstance(ti_patch_payload, TISuccessStatePayload): | |
| _try_register_enqueued_asset_events( | |
| task_instance_id=task_instance_id, | |
| task_outlets=ti_patch_payload.task_outlets, | |
| outlet_events=ti_patch_payload.outlet_events, | |
| session=session, | |
| ) |
| def _try_register_enqueued_asset_events( | ||
| *, | ||
| task_instance_id: UUID, | ||
| task_outlets: list[AssetProfile], | ||
| outlet_events: list[dict[str, Any]], | ||
| session: SessionDep, | ||
| ) -> None: | ||
| """ | ||
| Fast-path synchronous registration of a completed task's already-enqueued asset events. | ||
|
|
||
| Runs after the task-instance state (and the durable queue marker) have been committed and the | ||
| row lock released, so this is only an optimization. On success it registers the events and | ||
| deletes the queue row in one transaction; on lock contention or any error it rolls back and | ||
| leaves the row for the scheduler drain. The task_instance row lock is never held for the | ||
| registration work, and because the marker is already durable the events are registered exactly | ||
| once and never dropped. | ||
| """ | ||
| ti = session.get(TI, task_instance_id) | ||
| if ti is None: | ||
| return | ||
|
|
||
| try: | ||
| with with_db_lock_timeout(session, lock_timeout=_ASSET_SYNC_LOCK_TIMEOUT): | ||
| TI.register_asset_changes_in_db(ti, task_outlets, outlet_events, session=session) | ||
| session.execute(delete(AssetEventQueue).where(AssetEventQueue.ti_id == task_instance_id)) | ||
| session.commit() | ||
| stats.incr("asset.event_queue.registered_inline") | ||
| return | ||
| except OperationalError as e: | ||
| session.rollback() | ||
| if not is_lock_not_available_error(e): | ||
| log.warning( | ||
| "Synchronous asset registration failed with a database error; leaving the event on " | ||
| "the queue for the scheduler to retry.", | ||
| task_instance_id=str(task_instance_id), | ||
| exc_info=True, | ||
| ) | ||
| except Exception: | ||
| session.rollback() | ||
| log.warning( | ||
| "Synchronous asset registration failed; leaving the event on the queue for the " | ||
| "scheduler to retry.", | ||
| task_instance_id=str(task_instance_id), | ||
| exc_info=True, | ||
| ) |
There was a problem hiding this comment.
I'd prefer to remove _try_register_enqueued_asset_events at all to make scheduler as the single entrypoint for calling the TI.register_asset_changes_in_db. So that the lifecycle of AssetEventQueue and Asset Events will be more clear.
| def _try_register_enqueued_asset_events( | |
| *, | |
| task_instance_id: UUID, | |
| task_outlets: list[AssetProfile], | |
| outlet_events: list[dict[str, Any]], | |
| session: SessionDep, | |
| ) -> None: | |
| """ | |
| Fast-path synchronous registration of a completed task's already-enqueued asset events. | |
| Runs after the task-instance state (and the durable queue marker) have been committed and the | |
| row lock released, so this is only an optimization. On success it registers the events and | |
| deletes the queue row in one transaction; on lock contention or any error it rolls back and | |
| leaves the row for the scheduler drain. The task_instance row lock is never held for the | |
| registration work, and because the marker is already durable the events are registered exactly | |
| once and never dropped. | |
| """ | |
| ti = session.get(TI, task_instance_id) | |
| if ti is None: | |
| return | |
| try: | |
| with with_db_lock_timeout(session, lock_timeout=_ASSET_SYNC_LOCK_TIMEOUT): | |
| TI.register_asset_changes_in_db(ti, task_outlets, outlet_events, session=session) | |
| session.execute(delete(AssetEventQueue).where(AssetEventQueue.ti_id == task_instance_id)) | |
| session.commit() | |
| stats.incr("asset.event_queue.registered_inline") | |
| return | |
| except OperationalError as e: | |
| session.rollback() | |
| if not is_lock_not_available_error(e): | |
| log.warning( | |
| "Synchronous asset registration failed with a database error; leaving the event on " | |
| "the queue for the scheduler to retry.", | |
| task_instance_id=str(task_instance_id), | |
| exc_info=True, | |
| ) | |
| except Exception: | |
| session.rollback() | |
| log.warning( | |
| "Synchronous asset registration failed; leaving the event on the queue for the " | |
| "scheduler to retry.", | |
| task_instance_id=str(task_instance_id), | |
| exc_info=True, | |
| ) |
| # Commit now to release the task_instance row lock before the asset registration runs, so | ||
| # concurrent task completions don't pile up waiting on it. The state, log entry and queue | ||
| # marker all commit together here. | ||
| session.commit() |
There was a problem hiding this comment.
It's fine to call _enqueue_asset_events in the single transaction (and we should for the atomicity) if we don't call the TI.register_asset_changes_in_db. Since adding new AssetEventQueue record take way less time.
| # Commit now to release the task_instance row lock before the asset registration runs, so | |
| # concurrent task completions don't pile up waiting on it. The state, log entry and queue | |
| # marker all commit together here. | |
| session.commit() |
| session.commit() | ||
| except Exception: | ||
| session.rollback() |
There was a problem hiding this comment.
Additionally, we should remove all the explicit commit and rollback in the route to make this atomic at session injection level.
| session.commit() | |
| except Exception: | |
| session.rollback() | |
| except Exception: |
| # Reassigning the id re-points any not-yet-drained asset_event_queue rows via the FK's | ||
| # ON UPDATE CASCADE, so the scheduler drain still finds this TI instead of dropping the | ||
| # pending asset events. |
There was a problem hiding this comment.
IIUC, We didn't add the actual statement to point the AssetEventQueue row to the new generated id here.
| """On the default (sqlite) path a successful TI with outlets registers assets inline: the | ||
| AssetEvent is written and no durable asset_event_queue row is left behind.""" | ||
| asset = AssetModel(id=1, name="my-task", uri="s3://bucket/my-task", group="asset", extra={}) | ||
| session.add_all([asset, AssetActive.for_asset(asset)]) |
There was a problem hiding this comment.
Then we should remove this test case after removing the _try_register_enqueued_asset_events call. I don't feel it worthwhile to add a specialize archciture for sqlite path. Having a single writer to register the asset changes should be better.
| assert session.scalars(select(AssetEvent)).all() == [] | ||
| mock_stats.incr.assert_called_once_with("asset.event_queue.enqueued") | ||
|
|
||
| def test_ti_update_state_lock_contention_enqueues(self, client, session, create_task_instance): |
There was a problem hiding this comment.
Ditto. We can remove test_ti_update_state_asset_registration_failure_enqueues and test_ti_update_state_lock_contention_enqueues.
|
|
||
| # Run further passes; on the pass that pushes attempts to max_attempts the row is parked | ||
| # and the failures metric fires. | ||
| for _ in range(max_attempts - 1): |
There was a problem hiding this comment.
Actually, we can make the test like the following to exercise more:
for cur_attempt in range(max_attempts+1):
# the row.attempts will be same as cur_attempt+1 for till max_attempts
# then row.attempts will stay as max_attempts and will not over max_attempts.
jason810496
left a comment
There was a problem hiding this comment.
If we really want to keep the fast path, we should create a nested session and don't explicit commit or rollback at route handler level. We should defer to DB level timeout under nested session, and let the SessionDep level session injection commit to ensure the correctness.
Somehow like (but definitely need more refinement)
def _try_register_inline_or_enqueue(
*,
session: Session,
ti: TaskInstance,
task_outlets: list[AssetProfile],
outlet_events: list[dict[str, Any]],
) -> None:
"""
Attempt asset-event registration inside the state transaction; enqueue on failure.
Runs under the caller's transaction — the one that holds the task_instance FOR UPDATE
lock and will be committed by the SessionDep teardown. The savepoint isolates the
attempt so a failure rolls back the partial registration without losing the state
change; the marker enqueued on failure commits atomically with the state.
BOUNDS — read carefully:
* lock_timeout / innodb_lock_wait_timeout bound each individual lock WAIT only.
* statement_timeout (PG) bounds each individual statement only.
* Nothing here bounds TOTAL attempt duration; a many-statement registration
(large outlet fan-out) holds the task_instance row lock for its full runtime.
This is the pre-PR contention shape; prefer commit-first if that matters.
"""
dialect_name = get_dialect_name(session)
old_mysql_timeout = None
try:
with session.begin_nested(): # SAVEPOINT; ctx mgr emits ROLLBACK TO on error
if dialect_name == "postgresql":
# SET LOCAL is transactional: rolled back with the savepoint on failure.
# On success (RELEASE) it persists to end of transaction — reset below.
session.execute(text(f"SET LOCAL lock_timeout = '{_ASSET_SYNC_LOCK_TIMEOUT}s'"))
session.execute(text(f"SET LOCAL statement_timeout = '{_ASSET_SYNC_LOCK_TIMEOUT}s'"))
elif dialect_name == "mysql":
# Session-scoped regardless of savepoints; restored in finally.
old_mysql_timeout = session.execute(
text("SELECT @@SESSION.innodb_lock_wait_timeout")
).scalar()
session.execute(
text(f"SET SESSION innodb_lock_wait_timeout = {_ASSET_SYNC_LOCK_TIMEOUT}")
)
TaskInstance.register_asset_changes_in_db(
ti, task_outlets, outlet_events, session=session
)
# Savepoint released: the SET LOCALs would otherwise apply to the rest of the
# request transaction (including the teardown commit path).
if dialect_name == "postgresql":
session.execute(text("SET LOCAL lock_timeout = DEFAULT"))
session.execute(text("SET LOCAL statement_timeout = DEFAULT"))
stats.incr("asset.event_queue.registered_inline")
return
except OperationalError as e:
# ROLLBACK TO SAVEPOINT has already run; the outer transaction (state change)
# is intact and the session is usable without a full rollback.
if not is_lock_not_available_error(e):
log.warning(
"Inline asset registration failed with a database error; enqueueing.",
task_instance_id=str(ti.id),
exc_info=True,
)
except Exception:
log.warning(
"Inline asset registration failed; enqueueing.",
task_instance_id=str(ti.id),
exc_info=True,
)
finally:
if old_mysql_timeout is not None:
# Safe here: any failure above was contained by the savepoint rollback.
session.execute(
text(f"SET SESSION innodb_lock_wait_timeout = {old_mysql_timeout}")
)
# Fallback: marker in the SAME transaction as the state — they commit together at
# SessionDep teardown, so at-least-once holds (a crash rolls back both; the client
# retries the state transition). merge, not add: overwrite a cleared try's leftover.
session.merge(
AssetEventQueue(
ti_id=ti.id,
payload=_asset_event_payload(task_outlets, outlet_events),
attempts=0,
)
)
stats.incr("asset.event_queue.enqueued")d902e26 to
ad902d0
Compare
|
@uranusjr following up on the queue design we landed on in Slack. After @jason810496's review I simplified this to be queue-only: the execution API commits a durable The tradeoff is that a task's asset events (and the downstream asset-triggered runs) become visible after a drain interval instead of immediately, and Wanted to confirm you are good dropping the inline path before I treat the direction as settled. @jason810496 thanks for pushing on this. |
… lock contention On task success, ti_update_state committed the TI state and then registered asset events while still holding the task_instance row lock. Under high fan-out (many tasks emitting assets at once) this held the lock across the long registration work, piling up requests and OOMKilling the API server. Record the asset events as a durable marker in a new asset_event_queue table, committed in the same transaction as the TI state, and let the scheduler drain that queue and run the registration. The task-completion request no longer registers assets itself, so the row lock is held only for the state write plus one insert. The drain resolves the task instance by natural key (dag_id, run_id, task_id, map_index) rather than its surrogate id, so clearing a task (which reassigns the id) does not drop pending events on any backend. A registration that keeps failing is retried up to [scheduler] asset_event_queue_max_attempts times, then parked and surfaced via the asset.event_queue.failures metric. dag.test runs in process without a scheduler, so it drains its own queued events after each task finishes, keeping asset events visible during a test run. Also replace the alias-event ORM append with a direct association insert to avoid an O(n) lazy-load on long-lived aliases. closes: apache#66853 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ad902d0 to
e2b51a8
Compare

Closes #66853
Summary
Under high fan-out (for example a dbt/Cosmos DAG where many tasks emit assets at once),
ti_update_statecommitted the task-instance state and then registered asset events while still holding thetask_instancerow lock. Holding that lock across the registration work (scanning scheduled DAGs and insertingAssetEvent/AssetDagRunQueuerows) piled up concurrent completions and OOMKilled the API server.This reworks the task-success path so asset registration never holds the
task_instancelock and a failed registration is never lost:asset_event_queuetable. The scheduler drains that queue and retries until the events are registered, so nothing is silently dropped.appendwith a direct association-table insert, so a long-lived alias does not lazy-load its entireasset_eventscollection to add one row.Asset-event creation is asynchronous when it cannot complete inline: there is a short, eventually consistent delay (usually one scheduler loop) before a deferred event and its downstream asset-triggered DAG run appear. This is documented and called out in a significant-change newsfragment. It replaces the earlier version of this PR, which returned success and only logged a dropped registration; the queue makes delivery at-least-once instead.
Changes
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py: commit state before registration; two-phase register-or-enqueue.airflow-core/src/airflow/models/asset.pyand migration0127_3_4_0_add_asset_event_queue.py: newasset_event_queuetable.airflow-core/src/airflow/jobs/scheduler_job_runner.py:_drain_asset_event_queuesweep (claims withSKIP LOCKED, retries until success, parks a row afterasset_event_queue_max_attempts).airflow-core/src/airflow/assets/manager.py: direct alias-event association insert.airflow-core/src/airflow/config_templates/config.yml:[scheduler]asset_event_queue_drain_interval,asset_event_queue_batch_size,asset_event_queue_max_attempts.asset.event_queue.enqueued/processed/failures/pending; docs note inasset-scheduling.rst; significant newsfragment.Validation
prek(ruff, ruff-format, mypy-airflow-core, newsfragment and migration checks) on the changed files.breeze testing core-testson Python 3.10 with both--backend sqliteand--backend postgres.PR Checklist
mainbranchasset-scheduling.rst) and significant newsfragment addedprekpasses on changed filesDeveloped with AI assistance; I reviewed the design, code, and tests before submitting.