Skip to content

Commit 35422e4

Browse files
tauhid621claude
andauthored
fix(bigquery): prevent duplicate job submission (stable job_id + 409 recovery) (#2054)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 134bfe9 commit 35422e4

4 files changed

Lines changed: 192 additions & 17 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixes
2+
body: Hardened job submission against duplicate writes and spurious 409 Already Exists failures. Query and copy jobs now submit with a single predetermined job_id and attach to the in-flight job via get_job when BigQuery reports the job already exists, instead of resubmitting. This prevents a retryable error escaping the polling retry (or the client library's transport retry resubmitting after a lost response) from either re-running non-idempotent DML as a second job or failing the run with a 409.
3+
time: 2026-06-18T12:00:00.000000-05:00
4+
custom:
5+
Author: tauhid621
6+
Issue: "2006"

dbt-bigquery/src/dbt/adapters/bigquery/connections.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from multiprocessing.context import SpawnContext
77
import re
88
import time
9-
from typing import Dict, Hashable, List, Optional, Tuple, TYPE_CHECKING
9+
from typing import Callable, Dict, Hashable, List, Optional, Tuple, TYPE_CHECKING
1010
import uuid
1111

1212
from google.auth.exceptions import RefreshError
@@ -22,7 +22,7 @@
2222
Table,
2323
TableReference,
2424
)
25-
from google.cloud.exceptions import BadRequest, Forbidden, NotFound
25+
from google.cloud.exceptions import BadRequest, Conflict, Forbidden, NotFound
2626

2727
from dbt_common.events.contextvars import get_node_info
2828
from dbt_common.events.functions import fire_event
@@ -233,6 +233,22 @@ def generate_job_id(self) -> str:
233233
self.jobs_by_thread[thread_id].append(job_id)
234234
return job_id
235235

236+
def _submit_or_attach(self, client: Client, job_id: str, submit: Callable):
237+
"""Submit a job, or attach to the existing one via get_job on 409 Conflict.
238+
239+
A stable job_id makes jobs.insert idempotent: a resubmission (dbt's retry
240+
or the client library's transport retry after a lost response) attaches to
241+
the in-flight job instead of spawning a second one that re-runs work.
242+
"""
243+
try:
244+
return submit()
245+
except Conflict:
246+
logger.debug(
247+
f"Job {job_id} already exists; attaching to the in-flight job "
248+
"instead of resubmitting to avoid duplicate execution."
249+
)
250+
return client.get_job(job_id)
251+
236252
def raw_execute(
237253
self,
238254
sql,
@@ -277,9 +293,11 @@ def raw_execute(
277293
job_params["job_timeout_ms"] = int(model_timeout * 1000)
278294

279295
with self.exception_handler(sql):
296+
# Mint the job_id once, outside the retry closure, so a re-entry
297+
# resubmits the same job instead of spawning a duplicate.
298+
job_id = self.generate_job_id()
280299

281300
def _execute_with_retry():
282-
job_id = self.generate_job_id()
283301
return self._query_and_results(
284302
conn,
285303
sql,
@@ -484,11 +502,18 @@ def copy_bq_table(self, source, destination, write_disposition) -> None:
484502
destination_ref.path,
485503
)
486504
with self.exception_handler(msg):
487-
copy_job = client.copy_table(
488-
source_ref_array,
489-
destination_ref,
490-
job_config=CopyJobConfig(write_disposition=write_disposition),
491-
retry=self._retry.create_reopen_with_deadline(conn),
505+
# Stable job_id: copy_table has no built-in 409 recovery of its own.
506+
job_id = self.generate_job_id()
507+
copy_job = self._submit_or_attach(
508+
client,
509+
job_id,
510+
lambda: client.copy_table(
511+
source_ref_array,
512+
destination_ref,
513+
job_config=CopyJobConfig(write_disposition=write_disposition),
514+
job_id=job_id,
515+
retry=self._retry.create_reopen_with_deadline(conn),
516+
),
492517
)
493518
model_timeout = getattr(conn, "_bq_model_timeout", None)
494519
copy_timeout = model_timeout or self._retry.create_job_execution_timeout(fallback=300)
@@ -626,13 +651,18 @@ def _query_and_results(
626651
polling_timeout = (
627652
timeout + 30 if timeout else None
628653
) # buffer for polling after job execution timeout
629-
# Cannot reuse job_config if destination is set and ddl is used
630-
query_job = client.query(
631-
query=sql,
632-
job_config=query_job_config,
633-
job_id=job_id,
634-
job_retry=None,
635-
timeout=self._retry.create_job_creation_timeout(),
654+
# Cannot reuse job_config if destination is set and ddl is used.
655+
# job_id is stable across retries (see raw_execute).
656+
query_job = self._submit_or_attach(
657+
client,
658+
job_id,
659+
lambda: client.query(
660+
query=sql,
661+
job_config=query_job_config,
662+
job_id=job_id,
663+
job_retry=None,
664+
timeout=self._retry.create_job_creation_timeout(),
665+
),
636666
)
637667
if (
638668
query_job.location is not None
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import uuid
2+
3+
import pytest
4+
5+
from dbt.tests.util import run_dbt
6+
7+
8+
_SEED = "select 1 as id"
9+
10+
11+
class TestStableJobIdAttachesOnConflict:
12+
"""End-to-end proof that a re-used job_id triggers a real BigQuery 409 and
13+
that dbt attaches to the in-flight/finished job (via get_job) instead of
14+
resubmitting non-idempotent DML a second time (inc-6741 / PR #2054).
15+
16+
With the old behavior (fresh job_id per attempt) the INSERT would run twice
17+
and the row count would be 2. With the stable job_id + 409-attach fix it
18+
stays 1.
19+
"""
20+
21+
@pytest.fixture(scope="class")
22+
def models(self):
23+
# A trivial model just to get a schema/dataset created for the test.
24+
return {"anchor.sql": _SEED}
25+
26+
def test_resubmit_same_job_id_does_not_duplicate(self, project):
27+
run_dbt(["run"])
28+
29+
conns = project.adapter.connections
30+
table = f"`{project.database}`.`{project.test_schema}`.`conflict_probe`"
31+
32+
with project.adapter.connection_named("__test_409"):
33+
conns.raw_execute(f"create or replace table {table} (id int64)")
34+
35+
# Pin the job_id so the second submission collides in BigQuery.
36+
fixed_id = f"dbt-conflict-test-{uuid.uuid4()}"
37+
original = conns.generate_job_id
38+
conns.generate_job_id = lambda: fixed_id
39+
try:
40+
dml = f"insert into {table} (id) values (1)"
41+
conns.raw_execute(dml) # 1st: real insert, job created
42+
conns.raw_execute(dml) # 2nd: same job_id -> 409 -> attach, no re-insert
43+
finally:
44+
conns.generate_job_id = original
45+
46+
_, iterator = conns.raw_execute(f"select count(*) as n from {table}")
47+
count = list(iterator)[0][0]
48+
49+
assert count == 1, f"expected 1 row (attached to existing job), got {count}"
50+
51+
def test_copy_job_resubmit_attaches(self, project):
52+
"""copy_bq_table has no built-in 409 recovery; verify _submit_or_attach
53+
keeps a resubmitted copy from failing the run."""
54+
run_dbt(["run"])
55+
56+
conns = project.adapter.connections
57+
src = project.adapter.Relation.create(
58+
database=project.database, schema=project.test_schema, identifier="copy_src"
59+
)
60+
dst = project.adapter.Relation.create(
61+
database=project.database, schema=project.test_schema, identifier="copy_dst"
62+
)
63+
64+
with project.adapter.connection_named("__test_409_copy"):
65+
conns.raw_execute(
66+
f"create or replace table `{src.database}`.`{src.schema}`.`{src.identifier}` "
67+
"as select 1 as id"
68+
)
69+
70+
fixed_id = f"dbt-conflict-copy-{uuid.uuid4()}"
71+
original = conns.generate_job_id
72+
conns.generate_job_id = lambda: fixed_id
73+
try:
74+
conns.copy_bq_table(src, dst, "WRITE_TRUNCATE")
75+
# Resubmit with the same job_id -> 409 -> attach, must not raise.
76+
conns.copy_bq_table(src, dst, "WRITE_TRUNCATE")
77+
finally:
78+
conns.generate_job_id = original
79+
80+
_, iterator = conns.raw_execute(
81+
f"select count(*) as n from `{dst.database}`.`{dst.schema}`.`{dst.identifier}`"
82+
)
83+
count = list(iterator)[0][0]
84+
85+
assert count == 1, f"expected 1 row in copy destination, got {count}"

dbt-bigquery/tests/unit/test_bigquery_connection_manager.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def test_copy_bq_table_appends(self):
110110
[self._table_ref("project", "dataset", "table1")],
111111
self._table_ref("project", "dataset", "table2"),
112112
job_config=ANY,
113+
job_id=ANY,
113114
retry=ANY,
114115
)
115116
args, kwargs = self.mock_client.copy_table.call_args
@@ -124,13 +125,36 @@ def test_copy_bq_table_truncates(self):
124125
[self._table_ref("project", "dataset", "table1")],
125126
self._table_ref("project", "dataset", "table2"),
126127
job_config=ANY,
128+
job_id=ANY,
127129
retry=ANY,
128130
)
129131
args, kwargs = self.mock_client.copy_table.call_args
130132
self.assertEqual(
131133
kwargs["job_config"].write_disposition, dbt.adapters.bigquery.impl.WRITE_TRUNCATE
132134
)
133135

136+
def test_copy_bq_table_attaches_to_existing_job_on_conflict(self):
137+
"""A resubmitted copy job (e.g. a transport retry after a lost response)
138+
must attach to the existing job via get_job rather than fail with a 409,
139+
since copy_table has no built-in Conflict recovery."""
140+
exceptions = dbt.adapters.bigquery.impl.google.cloud.exceptions
141+
job_id = "job_x"
142+
self.connections.generate_job_id = Mock(return_value=job_id)
143+
self.mock_client.copy_table.side_effect = exceptions.Conflict(
144+
f"Already Exists: Job project:{job_id}"
145+
)
146+
existing_job = Mock(job_id=job_id)
147+
self.mock_client.get_job.return_value = existing_job
148+
149+
self._copy_table(write_disposition=dbt.adapters.bigquery.impl.WRITE_TRUNCATE)
150+
151+
self.assertEqual(self.mock_client.copy_table.call_count, 1)
152+
# We must attach to the SAME job we tried to submit, not a different id.
153+
self.assertEqual(self.mock_client.copy_table.call_args.kwargs["job_id"], job_id)
154+
self.mock_client.get_job.assert_called_once_with(job_id)
155+
# We wait on the attached job, not a resubmitted one.
156+
existing_job.result.assert_called_once()
157+
134158
def test_job_labels_valid_json(self):
135159
expected = {"key": "value"}
136160
labels = self.connections._labels_from_query_comment(json.dumps(expected))
@@ -161,7 +185,11 @@ def _copy_table(self, write_disposition):
161185
self.connections.copy_bq_table(source, destination, write_disposition)
162186

163187
@patch("dbt.adapters.bigquery.connections.QueryJobConfig")
164-
def test_raw_execute_retries_with_fresh_job_id(self, MockQueryJobConfig):
188+
def test_raw_execute_reuses_job_id_on_retry(self, MockQueryJobConfig):
189+
"""The reopen-retry must reuse the SAME predetermined job_id across
190+
attempts. Minting a fresh id per attempt double-executes non-idempotent
191+
DML and duplicates rows (inc-6741). A stable id makes resubmission
192+
idempotent via BigQuery's 409 Conflict path."""
165193
exceptions = dbt.adapters.bigquery.impl.google.cloud.exceptions
166194
job_ids_used = []
167195

@@ -176,7 +204,33 @@ def capture_job_id(*args, **kwargs):
176204
self.mock_client.query.side_effect = capture_job_id
177205
self.connections.raw_execute("SELECT 1")
178206
self.assertEqual(self.mock_client.query.call_count, 2)
179-
self.assertNotEqual(job_ids_used[0], job_ids_used[1])
207+
self.assertEqual(job_ids_used[0], job_ids_used[1])
208+
209+
@patch("dbt.adapters.bigquery.connections.QueryJobConfig")
210+
def test_query_and_results_attaches_to_existing_job_on_conflict(self, MockQueryJobConfig):
211+
"""If the job_id already exists (a prior attempt submitted it), recover
212+
by attaching to the existing job via get_job instead of resubmitting,
213+
so a single statement never spawns a duplicate BigQuery job."""
214+
exceptions = dbt.adapters.bigquery.impl.google.cloud.exceptions
215+
job_id = "job_x"
216+
self.mock_client.query.side_effect = exceptions.Conflict(
217+
f"Already Exists: Job project:{job_id}"
218+
)
219+
existing_job = Mock(job_id=job_id, location="US", project="project")
220+
existing_job.result.return_value = iter([])
221+
self.mock_client.get_job.return_value = existing_job
222+
223+
query_job, _ = self.connections._query_and_results(
224+
self.mock_connection,
225+
"MERGE INTO t USING s ON ...",
226+
{"dry_run": False},
227+
job_id=job_id,
228+
)
229+
230+
self.mock_client.get_job.assert_called_once_with(job_id)
231+
self.assertIs(query_job, existing_job)
232+
# The DML must not be resubmitted.
233+
self.assertEqual(self.mock_client.query.call_count, 1)
180234

181235
@patch("dbt.adapters.bigquery.connections.QueryJobConfig")
182236
def test_raw_execute_no_retry_on_non_retryable_error(self, MockQueryJobConfig):

0 commit comments

Comments
 (0)