google_cloud_bigquery_v2/
job_poller.rs1use 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#[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#[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#[derive(Debug)]
78pub(crate) struct JobRetryPolicy {
79 pub job_level_attempt_limit: u32,
81 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#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum JobPollerError {
98 #[error(transparent)]
100 Rpc(#[from] GaxError),
101 #[error("BigQuery job failed ({}): {}", .error_result.reason, .error_result.message)]
103 JobFailed {
104 error_result: Box<crate::model::ErrorProto>,
106 errors: Vec<crate::model::ErrorProto>,
108 },
109}
110
111#[derive(Debug)]
113pub struct JobPoller {
114 policy: JobRetryPolicy,
115 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 pub fn with_attempt_limit(mut self, limit: u32) -> Self {
136 self.policy.job_level_attempt_limit = limit;
137 self
138 }
139
140 pub fn with_job_retry_backoff(mut self, backoff: ExponentialBackoff) -> Self {
142 self.policy.backoff = backoff;
143 self
144 }
145
146 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 {
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 }
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 pub fn into_job_poller(self) -> JobPoller {
208 JobPoller::new(self)
209 }
210
211 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 assert!(retried.status.is_none());
430
431 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 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}