@@ -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