Skip to main content

google_cloud_bigquery_v2/
job_poller.rs

1// Copyright 2026 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Helpers to await the completion of an [InsertJob] request
16
17use crate::builder::job_service::InsertJob;
18use crate::model::Job;
19use google_cloud_gax::backoff_policy::BackoffPolicy;
20use google_cloud_gax::error::Error as GaxError;
21use google_cloud_gax::error::rpc::{Code, Status};
22use google_cloud_gax::exponential_backoff::ExponentialBackoff;
23use google_cloud_gax::retry_state::RetryState;
24use google_cloud_lro::Poller;
25
26impl google_cloud_lro::internal::DiscoveryOperation for Job {
27    fn name(&self) -> Option<&String> {
28        self.job_reference.as_ref().map(|r| &r.job_id)
29    }
30
31    fn done(&self) -> bool {
32        self.status
33            .as_ref()
34            .map(|s| s.state == "DONE")
35            .unwrap_or(false)
36    }
37
38    fn error(&self) -> Option<Status> {
39        self.status.as_ref().and_then(|s| {
40            s.error_result.as_ref().map(|e| {
41                Status::default()
42                    .set_code(Code::Unknown)
43                    .set_message(e.message.clone())
44            })
45        })
46    }
47}
48
49/// Determines if a BigQuery job failure reason is transient and eligible for
50/// job-level retry.
51///
52/// Returns `true` for retryable reasons (`jobBackendError`,
53/// `jobInternalError`, `jobRateLimitExceeded`, `tableUnavailable`) per
54/// BigQuery error handling specification.
55#[allow(dead_code)]
56pub(crate) fn is_retryable_job_error(reason: &str) -> bool {
57    matches!(
58        reason,
59        "jobBackendError" | "jobInternalError" | "jobRateLimitExceeded" | "tableUnavailable"
60    )
61}
62
63/// Prepares a `Job` instance for retry by assigning a new synthetic job ID
64/// and clearing existing execution status.
65///
66/// To preserve idempotency and avoid job execution collisions, each job-level
67/// retry must use a unique job ID while retaining original reference details
68/// (project ID, location) and configuration settings.
69#[allow(dead_code)]
70pub(crate) fn prepare_job_for_retry(mut job: Job) -> Job {
71    job.job_reference.get_or_insert_default().job_id = uuid::Uuid::new_v4().to_string();
72    job.status = None;
73    job
74}
75
76/// Configuration policy for BigQuery job-level retries.
77#[derive(Debug)]
78pub(crate) struct JobRetryPolicy {
79    /// Maximum number of general job-level attempts for retryable job errors.
80    pub job_level_attempt_limit: u32,
81    /// Backoff strategy between retry attempts.
82    pub backoff: ExponentialBackoff,
83}
84
85impl Default for JobRetryPolicy {
86    fn default() -> Self {
87        Self {
88            job_level_attempt_limit: 3,
89            backoff: ExponentialBackoff::default(),
90        }
91    }
92}
93
94/// Errors returned by the JobPoller.
95#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum JobPollerError {
98    /// An error occurred during the RPC or LRO polling.
99    #[error(transparent)]
100    Rpc(#[from] GaxError),
101    /// The job completed, but the BigQuery service reported an internal error.
102    #[error("BigQuery job failed ({}): {}", .error_result.reason, .error_result.message)]
103    JobFailed {
104        /// Final error result of the job.
105        error_result: Box<crate::model::ErrorProto>,
106        /// Errors and warnings encountered during the running of the job.
107        errors: Vec<crate::model::ErrorProto>,
108    },
109}
110
111/// A poller that monitors the status of an inserted BigQuery job and handles retries.
112#[derive(Debug)]
113pub struct JobPoller {
114    policy: JobRetryPolicy,
115    // Because the builder holds a `Job` which is >7kB, we ought to store it
116    // on the heap.
117    //
118    // This is important across `await` points where stack variables
119    // are captured in the async fn's state machine. This bloats the size of
120    // the returned `Future`, which can potentially overflow the stack.
121    //
122    // See #6391.
123    builder: Box<InsertJob>,
124}
125
126impl JobPoller {
127    pub(crate) fn new(builder: InsertJob) -> Self {
128        Self {
129            policy: JobRetryPolicy::default(),
130            builder: Box::new(builder),
131        }
132    }
133
134    /// Sets the maximum number of job-level attempts.
135    pub fn with_attempt_limit(mut self, limit: u32) -> Self {
136        self.policy.job_level_attempt_limit = limit;
137        self
138    }
139
140    /// Sets the exponential backoff policy for job-level retries.
141    pub fn with_job_retry_backoff(mut self, backoff: ExponentialBackoff) -> Self {
142        self.policy.backoff = backoff;
143        self
144    }
145
146    /// Polls the job until it is done, returning the final Job status.
147    pub async fn until_done(self) -> Result<Job, JobPollerError> {
148        let mut attempts = 0_u32;
149        let mut builder = self.builder;
150        let backoff = self.policy.backoff;
151        let start_time = std::time::Instant::now();
152
153        loop {
154            // NOTE: the client library intercepts errors and retries internally
155            // according to the policies set on `builder`.
156            {
157                let poller = (*builder).clone().poller();
158                let job = poller.until_done().await?;
159                let Some(status) = &job.status else {
160                    return Ok(job);
161                };
162                let Some(err) = &status.error_result else {
163                    return Ok(job);
164                };
165
166                attempts += 1;
167                if !is_retryable_job_error(&err.reason)
168                    || attempts >= self.policy.job_level_attempt_limit
169                {
170                    return Err(JobPollerError::JobFailed {
171                        error_result: Box::new(err.clone()),
172                        errors: status.errors.clone(),
173                    });
174                }
175
176                let job = prepare_job_for_retry(job);
177                *builder = (*builder).set_job(job);
178
179                // We use a block so that `job` (~7kB) is not allocated on the
180                // stack across the sleep `await` point.
181            }
182
183            let retry_state = RetryState::new(true)
184                .set_start(start_time)
185                .set_attempt_count(attempts);
186            let delay = backoff.on_failure(&retry_state);
187            tokio::time::sleep(delay).await;
188        }
189    }
190}
191
192impl InsertJob {
193    /// Returns a `JobPoller`, which can retry on [job-level errors].
194    ///
195    /// If the job fails with an internal error, the `JobPoller` will retry the
196    /// `InsertJob` operation. Note that the client library will supply a
197    /// synthetic job ID for any retries.
198    ///
199    /// ```no_run
200    /// # async fn example(builder: google_cloud_bigquery_v2::builder::job_service::InsertJob) -> Result<(), Box<dyn std::error::Error>> {
201    /// let job = builder.into_job_poller().until_done().await?;
202    /// # Ok(())
203    /// # }
204    /// ```
205    ///
206    /// [job-level errors]: https://docs.cloud.google.com/bigquery/docs/error-messages#errortable
207    pub fn into_job_poller(self) -> JobPoller {
208        JobPoller::new(self)
209    }
210
211    /// Creates a [Poller] to work with `InsertJob`.
212    pub fn poller(self) -> impl google_cloud_lro::Poller<crate::model::Job, crate::model::Job> {
213        let req = self.0.request.clone();
214        let stub = self.0.stub.clone();
215        let mut options = self.0.options.clone();
216        options.set_retry_policy(google_cloud_gax::retry_policy::NeverRetry);
217
218        let polling_error_policy =
219            std::sync::Arc::new(google_cloud_gax::polling_error_policy::AlwaysContinue);
220        let polling_backoff_policy = std::sync::Arc::new(
221            google_cloud_gax::exponential_backoff::ExponentialBackoff::default(),
222        );
223
224        let shared_location = std::sync::Arc::new(std::sync::OnceLock::<String>::new());
225        let shared_location_for_start = shared_location.clone();
226        let shared_location_for_query = shared_location.clone();
227
228        let query = move |name: String| {
229            let stub_clone = stub.clone();
230            let options_clone = options.clone();
231            let req_clone = req.clone();
232            let shared_location_clone = shared_location_for_query.clone();
233            async move {
234                let loc = shared_location_clone
235                    .get()
236                    .cloned()
237                    .or_else(|| {
238                        req_clone
239                            .job
240                            .as_ref()
241                            .and_then(|j| j.job_reference.as_ref())
242                            .and_then(|jr| jr.location.clone())
243                    })
244                    .unwrap_or_default();
245
246                let get_req = crate::model::GetJobRequest {
247                    project_id: req_clone.project_id.clone(),
248                    job_id: name,
249                    location: loc,
250                    _unknown_fields: std::default::Default::default(),
251                };
252                let job = stub_clone
253                    .get_job(get_req, options_clone)
254                    .await
255                    .map(crate::Response::into_body)?;
256
257                Ok(job)
258            }
259        };
260
261        let start = move || {
262            let shared_location_clone = shared_location_for_start.clone();
263            async move {
264                let res = self.send().await;
265                if let Ok(ref job) = res
266                    && let Some(ref jr) = job.job_reference
267                    && let Some(ref loc) = jr.location
268                {
269                    let _ = shared_location_clone.set(loc.clone());
270                }
271                res
272            }
273        };
274
275        google_cloud_lro::internal::new_discovery_poller(
276            polling_error_policy,
277            polling_backoff_policy,
278            start,
279            query,
280        )
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::model::{
288        ErrorProto, JobConfiguration, JobConfigurationQuery, JobReference, JobStatus,
289    };
290    use google_cloud_lro::internal::DiscoveryOperation;
291
292    #[test]
293    fn name_none() {
294        let job = Job::default();
295        assert_eq!(job.name(), None);
296    }
297
298    #[test]
299    fn name_some() {
300        let job = Job::new().set_job_reference(JobReference::new().set_job_id("test-id"));
301        assert_eq!(job.name().map(|s| s.as_str()), Some("test-id"));
302    }
303
304    #[test]
305    fn done_none() {
306        let job = Job::default();
307        assert!(!job.done());
308    }
309
310    #[test]
311    fn done_false() {
312        let job = Job::new().set_status(JobStatus::new().set_state("RUNNING"));
313        assert!(!job.done());
314    }
315
316    #[test]
317    fn done_true() {
318        let job = Job::new().set_status(JobStatus::new().set_state("DONE"));
319        assert!(job.done());
320    }
321
322    #[test]
323    fn error_none() {
324        let job = Job::default();
325        assert!(job.error().is_none());
326
327        let job_no_error = Job::new().set_status(JobStatus::new().set_state("DONE"));
328        assert!(job_no_error.error().is_none());
329    }
330
331    #[test]
332    fn error_some() {
333        let job = Job::new()
334            .set_status(JobStatus::new().set_error_result(ErrorProto::new().set_message("failed")));
335        let err = job.error().expect("should have error");
336        assert_eq!(err.code, Code::Unknown);
337        assert_eq!(err.message, "failed");
338    }
339
340    #[test]
341    fn retryable_job_errors() {
342        assert!(is_retryable_job_error("jobBackendError"));
343        assert!(is_retryable_job_error("jobInternalError"));
344        assert!(is_retryable_job_error("jobRateLimitExceeded"));
345        assert!(is_retryable_job_error("tableUnavailable"));
346
347        assert!(!is_retryable_job_error("invalidQuery"));
348        assert!(!is_retryable_job_error("accessDenied"));
349        assert!(!is_retryable_job_error("notFound"));
350        assert!(!is_retryable_job_error("backendError"));
351        assert!(!is_retryable_job_error(""));
352    }
353
354    #[test]
355    fn job_retry_policy_defaults() {
356        let policy = JobRetryPolicy::default();
357        assert_eq!(policy.job_level_attempt_limit, 3);
358    }
359
360    #[test]
361    fn prepare_job_for_retry_generates_new_id_and_resets_status() {
362        let original_job = Job::new()
363            .set_job_reference(
364                JobReference::new()
365                    .set_project_id("test-project")
366                    .set_job_id("original-job-id")
367                    .set_location("US"),
368            )
369            .set_status(
370                JobStatus::new().set_state("DONE").set_error_result(
371                    ErrorProto::new()
372                        .set_reason("jobBackendError")
373                        .set_message("backend failed"),
374                ),
375            );
376
377        let retried_job = prepare_job_for_retry(original_job);
378
379        assert!(retried_job.status.is_none());
380
381        let ref_data = retried_job
382            .job_reference
383            .expect("should have job reference");
384        assert_eq!(ref_data.project_id, "test-project");
385        assert_eq!(ref_data.location.as_deref(), Some("US"));
386        assert_ne!(ref_data.job_id, "original-job-id");
387        assert!(uuid::Uuid::parse_str(&ref_data.job_id).is_ok());
388    }
389
390    #[test]
391    fn prepare_job_for_retry_handles_none_job_reference() {
392        let original_job = Job::new().set_status(JobStatus::new().set_state("DONE"));
393
394        let retried_job = prepare_job_for_retry(original_job);
395        assert!(retried_job.status.is_none());
396
397        let ref_data = retried_job
398            .job_reference
399            .expect("should create job reference when missing");
400        assert!(uuid::Uuid::parse_str(&ref_data.job_id).is_ok());
401    }
402
403    #[test]
404    fn prepare_job_for_retry_preserves_job_configuration_and_metadata() {
405        let original_job = Job::new()
406            .set_job_reference(
407                JobReference::new()
408                    .set_project_id("my-project")
409                    .set_job_id("initial-id")
410                    .set_location("EU"),
411            )
412            .set_configuration(
413                JobConfiguration::new()
414                    .set_query(JobConfigurationQuery::new().set_query("SELECT 42"))
415                    .set_labels([("env".to_string(), "test".to_string())]),
416            )
417            .set_user_email("user@example.com")
418            .set_status(
419                JobStatus::new().set_state("DONE").set_error_result(
420                    ErrorProto::new()
421                        .set_reason("jobInternalError")
422                        .set_message("internal error"),
423                ),
424            );
425
426        let retried = prepare_job_for_retry(original_job);
427
428        // Status must be reset to None for retry submission
429        assert!(retried.status.is_none());
430
431        // Configuration and user_email must be preserved
432        assert_eq!(
433            retried
434                .configuration
435                .as_ref()
436                .and_then(|c| c.query.as_ref())
437                .map(|q| q.query.as_str()),
438            Some("SELECT 42")
439        );
440        assert_eq!(
441            retried
442                .configuration
443                .as_ref()
444                .and_then(|c| c.labels.get("env").map(|s| s.as_str())),
445            Some("test")
446        );
447        assert_eq!(retried.user_email.as_str(), "user@example.com");
448
449        // JobReference metadata preserved, but job_id replaced with a new valid UUID
450        let ref_data = retried.job_reference.expect("must have reference");
451        assert_eq!(ref_data.project_id, "my-project");
452        assert_eq!(ref_data.location.as_deref(), Some("EU"));
453        assert_ne!(ref_data.job_id, "initial-id");
454        assert!(uuid::Uuid::parse_str(&ref_data.job_id).is_ok());
455    }
456
457    #[test]
458    fn custom_retry_policy_builder() {
459        let mut policy = JobRetryPolicy::default();
460        assert_eq!(policy.job_level_attempt_limit, 3);
461
462        policy.job_level_attempt_limit = 5;
463        assert_eq!(policy.job_level_attempt_limit, 5);
464    }
465
466    #[test]
467    fn job_poller_error_job_failed_display() {
468        let err_proto = ErrorProto::new()
469            .set_reason("invalidQuery")
470            .set_message("syntax error");
471        let sub_error = ErrorProto::new()
472            .set_reason("invalid")
473            .set_message("detailed error");
474
475        let poller_err = JobPollerError::JobFailed {
476            error_result: Box::new(err_proto),
477            errors: vec![sub_error],
478        };
479
480        let JobPollerError::JobFailed {
481            error_result,
482            errors,
483        } = &poller_err
484        else {
485            panic!("expected JobPollerError::JobFailed");
486        };
487        assert_eq!(error_result.reason, "invalidQuery");
488        assert_eq!(errors.len(), 1);
489        assert_eq!(errors[0].reason, "invalid");
490        assert_eq!(
491            poller_err.to_string(),
492            "BigQuery job failed (invalidQuery): syntax error"
493        );
494    }
495}