Skip to content

Commit 6388276

Browse files
smackeseyDagster Devtools
authored andcommitted
Order RUN_FAILURE_REASON_TAG write before run status update (#24730)
## Summary & Motivation In `SqlRunStorage.handle_run_event`, the PIPELINE_FAILURE handler used to write the run status update and the `dagster/failure_reason` tag in **two separate transactions**: 1. `with self.connect() as conn:` — UPDATE `runs.status = FAILURE` (commits on exit). 2. `self.add_run_tags(run_id, {RUN_FAILURE_REASON_TAG: ...})` — opens its own connection. A caller polling for `run.is_finished` could observe `status=FAILURE` between those two writes and then read `run.tags` without the tag — `KeyError: 'dagster/failure_reason'`. This is the source of the flake in `test_retry_failure_all_steps_with_reexecution_params` ([example failure](https://buildkite.com/dagster/internal/builds/152044#019e4b21-52ee-4964-807d-47d28eed96a5)): the test calls `wait_for_runs_to_finish` (status-only) then reads the tag immediately. ## Fix - Compute the `failure_reason` tag value upfront and include it in the `run_body` serialized into the runs UPDATE. - Inside the same `with self.connect() as conn:` block, write the `RunTagsTable` row **first**, then the runs UPDATE. - On `SqliteRunStorage`, `connect()` wraps in `conn.begin()` so the two writes commit as a unit. - On `PostgresRunStorage`, the engine is configured with `isolation_level="AUTOCOMMIT"`, so each `conn.execute` commits independently. Ordering the tag write first ensures that by the time a reader observes the new status, the tag row is already there. - `update_timestamp` is written exactly once on the runs row (using the event timestamp), avoiding the regression that calling `add_run_tags` separately would introduce (where `runs.update_timestamp` could be written as `now()` and then overwritten with an earlier event timestamp, breaking `RunsFilter(updated_after=...)` cursors). - Use the in-memory `run.tags` to choose UPDATE vs INSERT against `RunTagsTable`, mirroring `add_run_tags` — no extra SELECT round-trip. ## Test Plan - [x] `tox -e py312-storage_tests -- -k test_failure_event_updates_tags` — all 3 variants (Sqlite, InMemory, Legacy) pass. - [x] Full `dagster_tests/storage_tests/test_run_storage.py` suite — 1007 passed, 105 pre-existing skips. - [ ] CI green on `dagster-graphql` suites that previously surfaced the flake. ## Changelog Eliminate a race where readers polling for a run to reach FAILURE could miss the `dagster/failure_reason` tag. Internal-RevId: 2cb1d47fe55e3b99a82dbbd2ed244046f1dfbc36
1 parent 40f9eb2 commit 6388276

1 file changed

Lines changed: 40 additions & 9 deletions

File tree

python_modules/dagster/dagster/_core/storage/runs/sql_run_storage.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,20 @@ def handle_run_event(
188188

189189
new_job_status = EVENT_TYPE_TO_PIPELINE_RUN_STATUS[event.event_type]
190190

191+
# Threaded into the run_body and the run_tags write below so readers that
192+
# observe a finished status are guaranteed to also see this tag.
193+
updated_run = run.with_status(new_job_status)
194+
failure_reason_tag_value: str | None = None
195+
if event.event_type == DagsterEventType.PIPELINE_FAILURE and isinstance(
196+
event.event_specific_data, JobFailureData
197+
):
198+
failure_reason = event.event_specific_data.failure_reason
199+
if failure_reason and failure_reason != RunFailureReason.UNKNOWN:
200+
failure_reason_tag_value = failure_reason.value
201+
updated_run = updated_run.with_tags(
202+
merge_dicts(run.tags, {RUN_FAILURE_REASON_TAG: failure_reason_tag_value})
203+
)
204+
191205
run_stats_cols_in_index = self.has_run_stats_index_cols()
192206

193207
kwargs = {}
@@ -208,25 +222,42 @@ def handle_run_event(
208222
kwargs["end_time"] = update_timestamp.timestamp()
209223

210224
with self.connect() as conn:
225+
# Write the run_tags row before the runs update. PostgresRunStorage uses
226+
# an AUTOCOMMIT engine, so each `conn.execute` commits independently;
227+
# writing the tag first ensures that any reader that observes the new
228+
# status sees the tag too.
229+
if failure_reason_tag_value is not None:
230+
if RUN_FAILURE_REASON_TAG in run.tags:
231+
conn.execute(
232+
RunTagsTable.update()
233+
.where(
234+
db.and_(
235+
RunTagsTable.c.run_id == run_id,
236+
RunTagsTable.c.key == RUN_FAILURE_REASON_TAG,
237+
)
238+
)
239+
.values(value=failure_reason_tag_value)
240+
)
241+
else:
242+
conn.execute(
243+
RunTagsTable.insert().values(
244+
run_id=run_id,
245+
key=RUN_FAILURE_REASON_TAG,
246+
value=failure_reason_tag_value,
247+
)
248+
)
249+
211250
conn.execute(
212251
RunsTable.update()
213252
.where(RunsTable.c.run_id == run_id)
214253
.values(
215-
run_body=serialize_value(run.with_status(new_job_status)),
254+
run_body=serialize_value(updated_run),
216255
status=new_job_status.value,
217256
update_timestamp=update_timestamp,
218257
**kwargs,
219258
)
220259
)
221260

222-
if event.event_type == DagsterEventType.PIPELINE_FAILURE and isinstance(
223-
event.event_specific_data, JobFailureData
224-
):
225-
failure_reason = event.event_specific_data.failure_reason
226-
227-
if failure_reason and failure_reason != RunFailureReason.UNKNOWN:
228-
self.add_run_tags(run_id, {RUN_FAILURE_REASON_TAG: failure_reason.value})
229-
230261
def _row_to_run(self, row: dict) -> DagsterRun:
231262
run = deserialize_value(row["run_body"], DagsterRun)
232263
status = DagsterRunStatus(row["status"])

0 commit comments

Comments
 (0)