Add durable execution to SnowflakeSqlApiOperator#69477
Conversation
| if not statement_status["running"]: | ||
| break | ||
| if self.do_xcom_push and context is not None: | ||
| context["ti"].xcom_push(key="query_ids", value=self.query_ids) |
There was a problem hiding this comment.
On the failure path query_ids is never pushed to xcom. When a statement errors, poll_on_queries() reports the error and this method raises RuntimeError at line 567, before it reaches this xcom_push. That is the case where the handles matter most, since you need them to look the failed statements up in Snowflake.
The pre-PR non-deferrable path pushed query_ids immediately after execute_query (before the poll loop), so the handles were recorded even when a query failed. This moves the push after the loop, so a failure now loses them.
Consider pushing the xcom near the top of poll_until_complete (right after self.query_ids = ..., before the poll loop) so it lands on both the fresh and reconnect paths and survives an error. Non-blocking.
Was generative AI tooling used to co-author this PR?
What
SnowflakeSqlApiOperatorsubmits one or more SQL statements to the Snowflake SQL API and polls their statement handles to completion on the worker. On a worker crash or preemption mid-poll, Airflow retries the task by callingexecute()again which re-submits the SQL from scratch, since nothing about the in-flight statement handles is persisted across attempts.Current behaviour
A retry after a crash always resubmits the full SQL, even if the original statements are still running (or already finished) in Snowflake. For non-idempotent SQL (
INSERT,UPDATE,CREATE TABLE, etc.) this risks duplicate writes; for expensive queries it's wasted warehouse compute, since the orphaned original execution keeps running with nobody polling it.Proposed change
Adds
ResumableJobMixinsupport (Airflow 3.3+) toSnowflakeSqlApiOperator, following the same pattern already completed forDatabricksSubmitRunOperator/DatabricksRunNowOperator. Before polling begins, the submitted statement handles are persisted totask_state_store. On retry, the operator reads them back and:durable=Trueis the default; setdurable=Falseto keep the old "always submit fresh on retry" behavior.deferrable=Truetakes precedence overdurable— the Triggerer already tracks handles across the wait in that mode.Includes a companion fix (already merged separately, #69450) that removed an unconditional per-handle sleep in
poll_on_queries(), which this port relies on to stay latency-neutral on the already-resolved case.Changes of Note
get_job_statusaggregates across the list of statement handles into the single status string the mixin's interface expects: anyerrorwins over anyrunning, which wins over all-success— matching Snowflake's all-or-nothing submission semantics (there's no per-statement repair, unlike Databricks'repair_run).poll_until_completealone on reconnect —get_job_resultis never invoked on that path. Sinceget_job_resultis wherecheck_query_output(the actual result fetch/log) lives,check_query_outputwas moved intopoll_until_completeitself, guarded by a flag so the fresh-submit path (which calls both methods) doesn't fetch/push twice.get_job_statusand treated as anot_foundsentinel, degrading to a fresh resubmit rather than failing the task.User implications / backcompat
No breaking change.
durabledefaults toTrueon Airflow 3.3+; on earlier versions it's a no-op stub and the operator always submits fresh, exactly as before. Iftask_state_storeisn't available at runtime, the operator logs that crash recovery is disabled and falls back to the same fresh-submit behavior.One minor, intentional behavior shift: a fresh submission that is still running on its very first status check now incurs one
poll_intervalof latency it didn't before (the old code had a sleep-free pre-check before entering the poll loop; the durable path goes straight into the sleep-guarded loop). One-time cost, not compounding, and documented in the test suite.Testing
Follow this to create a snowflake connection using RSA: https://medium.com/@chik0di/using-the-snowflakesqlapi-operator-in-airflow-0206632db2a3
DAG used:
Before changes
Before:
Initial try where worker crashed:
Later try repeats the same effort which was already done:
Duplicate effort:
First try suceeded in snowflake at 5:37:43 even when airflow task failed at: 5:37:17
After changes
Initial try where worker crashed

Worker is back up but job has already completed:

Since I had a custom backend configured, data stored is:
[Breeze:3.10.20] root@62f2e50bd27b:/opt/airflow$ cat /tmp/airflow_state/ti_multi_statement/snowflake_query_ids.json ["01c5873f-061b-2e62-0068-2101308e710b"]No duplicate resubmission.
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.