use crate::errors::{FirestoreError, FirestoreErrorPublicGenericDetails, FirestoreSystemError};
use crate::{FirestoreDb, FirestoreOperationWaitOptions, FirestoreResult};
use futures::StreamExt;
use gcloud_sdk::google::longrunning::operation::Result as LroResult;
use gcloud_sdk::google::longrunning::{GetOperationRequest, Operation};
use gcloud_sdk::tonic::Code;
use gcloud_sdk::RequestTimeouts;
use std::fmt::{Display, Formatter};
use std::time::Duration;
use tokio::time::error::Elapsed;
use tokio::time::Instant;
use tracing::*;
const POLL_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_CONCURRENT_POLLS: usize = 16;
const NO_PRACTICAL_DEADLINE: Duration = Duration::from_secs(30 * 365 * 24 * 60 * 60);
pub(crate) trait OperationAction: Display {
fn kind(&self) -> &'static str;
}
pub(crate) struct StartedOperation<A> {
pub name: String,
pub action: A,
}
impl<A: Display> Display for StartedOperation<A> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.action, self.name)
}
}
#[derive(Debug, Clone)]
pub(crate) struct OperationDeadline {
at: Instant,
timeout: Duration,
poll_interval: Duration,
poll_timeout: Duration,
}
impl OperationDeadline {
pub(crate) fn from_now(options: &FirestoreOperationWaitOptions) -> Self {
Self {
at: Instant::now() + options.timeout.min(NO_PRACTICAL_DEADLINE),
timeout: options.timeout,
poll_interval: options.poll_interval,
poll_timeout: options.timeout.min(POLL_TIMEOUT),
}
}
#[cfg(test)]
pub(crate) fn with_poll_timeout(self, poll_timeout: Duration) -> Self {
Self {
poll_timeout,
..self
}
}
pub(super) fn poll_cutoff(&self) -> Instant {
Instant::now().min(self.at) + self.poll_timeout
}
pub(super) fn has_passed(&self) -> bool {
Instant::now() >= self.at
}
pub(super) fn timed_out(&self, waiting_for: &str) -> FirestoreError {
FirestoreError::SystemError(FirestoreSystemError::new(
FirestoreErrorPublicGenericDetails::new("OPERATION_WAIT_TIMEOUT".to_string()),
format!(
"timed out after {:?} waiting for: {waiting_for}",
self.timeout
),
))
}
pub(super) async fn sleep_until_next_round(&self) {
let remaining = self.at.saturating_duration_since(Instant::now());
tokio::time::sleep(self.poll_interval.min(remaining)).await;
}
}
enum PollVerdict {
Pending,
Done,
Failed(FirestoreError),
}
struct TrackedOperation<'a, A> {
operation: &'a StartedOperation<A>,
span: Span,
polls: u32,
}
impl<A: OperationAction> TrackedOperation<'_, A> {
fn finish(&self, state: &'static str, began: Instant) {
self.span.record("/firestore/polls", self.polls);
self.span.record("/firestore/state", state);
self.span
.record("/firestore/response_time", began.elapsed().as_millis());
}
fn judge<F>(
&mut self,
result: Result<FirestoreResult<Operation>, Elapsed>,
deadline: &OperationDeadline,
began: Instant,
on_poll: &mut F,
) -> PollVerdict
where
F: FnMut(&StartedOperation<A>, &Operation),
{
self.polls += 1;
let span = self.span.clone();
let _entered = span.enter();
let operation = self.operation;
let polled = match result {
Err(_) => {
warn!(
poll = self.polls,
operation = operation.name.as_str(),
action = operation.action.kind(),
poll_timeout_ms = deadline.poll_timeout.as_millis(),
"Polling an operation did not answer in time; polling it again.",
);
return PollVerdict::Pending;
}
Ok(Err(FirestoreError::DatabaseError(err))) if err.retry_possible => {
warn!(
%err,
poll = self.polls,
operation = operation.name.as_str(),
action = operation.action.kind(),
"Polling an operation failed with a retryable error; polling it again.",
);
return PollVerdict::Pending;
}
Ok(Err(err)) => {
error!(
%err,
operation = operation.name.as_str(),
action = operation.action.kind(),
label = %operation.action,
"Polling an operation failed.",
);
self.finish("poll_failed", began);
return PollVerdict::Failed(err);
}
Ok(Ok(polled)) => polled,
};
on_poll(operation, &polled);
debug!(
poll = self.polls,
operation = operation.name.as_str(),
action = operation.action.kind(),
label = %operation.action,
done = polled.done,
"Polled a pending operation.",
);
if !polled.done {
return PollVerdict::Pending;
}
if let Some(LroResult::Error(status)) = polled.result {
error!(
operation = operation.name.as_str(),
action = operation.action.kind(),
label = %operation.action,
code = status.code,
message = status.message.as_str(),
"Operation failed.",
);
self.finish("failed", began);
let status = gcloud_sdk::google::rpc::Status {
message: format!("{operation} failed: {}", status.message),
..status
};
return PollVerdict::Failed(FirestoreError::from(status));
}
info!(
operation = operation.name.as_str(),
action = operation.action.kind(),
label = %operation.action,
polls = self.polls,
elapsed_ms = began.elapsed().as_millis(),
"Operation reached a terminal state.",
);
self.finish("done", began);
PollVerdict::Done
}
}
impl FirestoreDb {
async fn get_operation(&self, name: &str) -> FirestoreResult<Operation> {
self.operations_client()
.get_operation(self.request_with_timeout(
GetOperationRequest {
name: name.to_string(),
},
RequestTimeouts::with_total,
))
.await
.map(|response| response.into_inner())
.map_err(|status| match status.code() {
Code::NotFound => FirestoreError::retryable_read(status),
_ => FirestoreError::from_read_status(status),
})
}
pub(crate) async fn wait_for_operations<A, F>(
&self,
operations: &[&StartedOperation<A>],
deadline: &OperationDeadline,
mut on_poll: F,
) -> FirestoreResult<()>
where
A: OperationAction,
F: FnMut(&StartedOperation<A>, &Operation),
{
let began = Instant::now();
let mut pending: Vec<TrackedOperation<A>> = operations
.iter()
.map(|operation| TrackedOperation {
operation,
span: span!(
Level::INFO,
"Wait For Operation",
"/firestore/operation" = operation.name.as_str(),
"/firestore/action" = operation.action.kind(),
"/firestore/polls" = field::Empty,
"/firestore/state" = field::Empty,
"/firestore/response_time" = field::Empty,
),
polls: 0,
})
.collect();
loop {
let polls: Vec<_> = pending
.iter()
.map(|tracked| {
async move {
tokio::time::timeout_at(
deadline.poll_cutoff(),
self.get_operation(&tracked.operation.name),
)
.await
}
.instrument(tracked.span.clone())
})
.collect();
let polled: Vec<_> = futures::stream::iter(polls)
.buffered(MAX_CONCURRENT_POLLS)
.collect()
.await;
let mut first_failure = None;
let mut still_pending = Vec::with_capacity(pending.len());
for (mut tracked, result) in pending.into_iter().zip(polled) {
match tracked.judge(result, deadline, began, &mut on_poll) {
PollVerdict::Pending => still_pending.push(tracked),
PollVerdict::Done => {}
PollVerdict::Failed(err) => {
first_failure.get_or_insert(err);
}
}
}
pending = still_pending;
if let Some(err) = first_failure {
for tracked in &pending {
tracked.finish("abandoned", began);
}
return Err(err);
}
if pending.is_empty() {
return Ok(());
}
if deadline.has_passed() {
let names: Vec<String> = pending
.iter()
.map(|tracked| tracked.operation.to_string())
.collect();
for tracked in &pending {
tracked.finish("pending", began);
}
warn!(
pending = names.join(", "),
timeout_ms = deadline.timeout.as_millis(),
"Timed out waiting for operations to finish.",
);
return Err(deadline.timed_out(&names.join(", ")));
}
deadline.sleep_until_next_round().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::fake_firestore::{
done_operation_response, failed_operation_response, pending_operation_response,
FakeFirestore, FakeResponse,
};
use gcloud_sdk::prost::Message as _;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
const GET_OPERATION: &str = "/google.longrunning.Operations/GetOperation";
struct TestAction;
impl Display for TestAction {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "test action")
}
}
impl OperationAction for TestAction {
fn kind(&self) -> &'static str {
"test"
}
}
fn started(id: &str) -> StartedOperation<TestAction> {
StartedOperation {
name: format!("projects/fake-firestore/databases/(default)/operations/{id}"),
action: TestAction,
}
}
async fn fake_polls(
answer: impl Fn(&str, &str, u32) -> FakeResponse + Send + Sync + 'static,
) -> FakeFirestore {
let polls: Mutex<HashMap<String, u32>> = Mutex::default();
FakeFirestore::start(move |method, bytes| {
assert_eq!(method, GET_OPERATION, "unexpected RPC");
let name = GetOperationRequest::decode(bytes).unwrap().name;
let id = name.rsplit('/').next().unwrap().to_string();
let count = {
let mut polls = polls.lock().unwrap();
let count = polls.entry(id.clone()).or_default();
*count += 1;
*count
};
(
format!("GetOperation({id})#{count}"),
answer(&name, &id, count),
)
})
.await
}
#[tokio::test]
async fn a_hung_poll_is_abandoned_while_the_other_operations_are_polled() {
let fake = fake_polls(|name, id, count| match (id, count) {
("hung", 1) => FakeResponse::Hang,
(_, 1) => pending_operation_response(name),
_ => done_operation_response(name),
})
.await;
let (hung, other) = (started("hung"), started("other"));
let mut observed_done = Vec::new();
let began = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(5),
fake.db.wait_for_operations(
&[&hung, &other],
&OperationDeadline::from_now(
&FirestoreOperationWaitOptions::new(Duration::from_secs(3))
.with_poll_interval(Duration::from_millis(10)),
)
.with_poll_timeout(Duration::from_millis(100)),
|operation, polled| {
if polled.done {
observed_done.push(operation.name.clone());
}
},
),
)
.await
.expect("a hung poll must not hold the wait");
result.unwrap();
assert!(
began.elapsed() < Duration::from_secs(1),
"a hung poll held the wait for {:?}",
began.elapsed()
);
assert!(observed_done.contains(&other.name), "{observed_done:?}");
assert!(observed_done.contains(&hung.name), "{observed_done:?}");
}
#[tokio::test]
async fn every_outcome_of_a_round_is_observed_before_the_first_failure_returns() {
let fake = fake_polls(|name, id, _| match id {
"failing" => failed_operation_response(name, 9, "build failed"),
_ => done_operation_response(name),
})
.await;
let (failing, finishing) = (started("failing"), started("finishing"));
let mut observed = Vec::new();
let err = fake
.db
.wait_for_operations(
&[&failing, &finishing],
&OperationDeadline::from_now(
&FirestoreOperationWaitOptions::new(Duration::from_secs(5))
.with_poll_interval(Duration::from_millis(10)),
),
|operation, polled| observed.push((operation.name.clone(), polled.done)),
)
.await
.unwrap_err();
assert!(err.to_string().contains("build failed"), "{err}");
assert_eq!(
observed,
vec![(failing.name.clone(), true), (finishing.name.clone(), true)]
);
}
}