pub mod catalog;
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
pub mod fallback;
pub mod memory;
#[cfg(feature = "serve-history-postgres")]
pub mod postgres;
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
pub mod sql;
#[cfg(feature = "serve-history-sqlite")]
pub mod sqlite;
pub mod templates;
use crate::error::CliResult;
use crate::executor::InvocationOutcome;
use crate::serve::config::HistoryBackendSpec;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Queued,
Pending,
Running,
Sharded,
Completed,
Failed,
Cancelled,
}
impl RunStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Pending => "pending",
Self::Running => "running",
Self::Sharded => "sharded",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvocationRecord {
pub row_id: String,
pub parent_record_key: Option<String>,
pub records_written: usize,
pub error: Option<String>,
}
impl From<&InvocationOutcome> for InvocationRecord {
fn from(o: &InvocationOutcome) -> Self {
Self {
row_id: o.row_id.clone(),
parent_record_key: o.parent_record_key.clone(),
records_written: o.records_written,
error: o.error.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub name: Option<String>,
pub labels: BTreeMap<String, String>,
pub status: RunStatus,
pub submitted_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub elapsed_secs: Option<f64>,
pub records_written: u64,
pub invocations: Vec<InvocationRecord>,
pub error: Option<String>,
pub idempotency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doctor_report: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<crate::serve::load::ConfigFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clock: Option<String>,
#[serde(default)]
pub attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_of: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback: Option<crate::serve::callback::CallbackSpec>,
}
impl RunRecord {
pub fn queued(
run_id: String,
name: Option<String>,
labels: BTreeMap<String, String>,
idempotency_key: Option<String>,
submitted_at: DateTime<Utc>,
) -> Self {
Self {
run_id,
name,
labels,
status: RunStatus::Queued,
submitted_at,
started_at: None,
finished_at: None,
elapsed_secs: None,
records_written: 0,
invocations: Vec::new(),
error: None,
idempotency_key,
doctor_report: None,
config_body: None,
config_format: None,
timeout_secs: None,
clock: None,
attempt: 0,
replay_of: None,
callback: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Claim {
Fresh,
Replay(String),
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteOutcome {
Deleted,
NotFound,
StillRunning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ReclaimReport {
pub requeued: usize,
pub failed: usize,
}
#[derive(Debug, Clone)]
pub struct InstanceHeartbeat {
pub started_at: DateTime<Utc>,
pub listen: Option<String>,
pub max_concurrent: u32,
pub in_flight: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstanceRecord {
pub instance_id: String,
pub started_at: DateTime<Utc>,
pub last_heartbeat: DateTime<Utc>,
pub listen: Option<String>,
pub max_concurrent: u32,
pub in_flight: u32,
}
#[derive(Debug, Default, Clone)]
pub struct ListFilter {
pub status: Option<RunStatus>,
pub name: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
pub cursor: Option<String>,
}
#[derive(Debug)]
pub struct ListPage {
pub runs: Vec<RunRecord>,
pub next_cursor: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum HistoryError {
#[error("run-history backend error: {0}")]
Backend(String),
#[error("{0}")]
Degraded(String),
}
#[derive(Debug, Clone)]
pub struct ShardInsert {
pub shard_id: String,
pub descriptor: serde_json::Value,
pub size_estimate: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ClaimedShard {
pub run_id: String,
pub shard_id: String,
pub descriptor: serde_json::Value,
pub run: RunRecord,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShardProgress {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub running: usize,
pub pending: usize,
}
impl ShardProgress {
pub fn all_terminal(&self) -> bool {
self.total > 0 && self.completed + self.failed == self.total
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub timestamp: DateTime<Utc>,
pub principal: String,
pub role: String,
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ip: Option<String>,
pub result: String,
}
#[derive(Debug, Default, Clone)]
pub struct AuditFilter {
pub principal: Option<String>,
pub action: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLogLine {
pub seq: u64,
pub ts: String,
pub level: String,
pub line: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLogPage {
pub lines: Vec<RunLogLine>,
pub truncated: bool,
}
pub const RUN_LOG_TRUNCATED_SEQ: u64 = u64::MAX;
#[async_trait]
pub trait RunHistory: Send + Sync {
async fn claim_idempotency(
&self,
key: &str,
fingerprint: &str,
run_id: &str,
window: Duration,
) -> Result<Claim, HistoryError>;
async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
let _ = run_id;
Ok(())
}
async fn recover_orphans(&self) -> Result<usize, HistoryError>;
async fn renew_leases(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
let _ = limit;
Ok(Vec::new())
}
async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
let _ = max_attempts;
Ok(ReclaimReport::default())
}
async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
self.upsert(rec).await.map(|_| true)
}
async fn finalize_sharded_parent(
&self,
run_id: &str,
status: RunStatus,
finished_at: DateTime<Utc>,
error: Option<String>,
) -> Result<bool, HistoryError> {
match self.get(run_id).await? {
Some(mut r) if r.status == RunStatus::Sharded => {
r.status = status;
r.finished_at = Some(finished_at);
r.error = error;
self.upsert(&r).await?;
Ok(true)
}
_ => Ok(false),
}
}
async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
let _ = run_id;
Ok(false)
}
async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
let _ = run_id;
Ok(())
}
async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
Ok(Vec::new())
}
async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
let _ = beat;
Ok(())
}
async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
let _ = ttl;
Ok(Vec::new())
}
async fn insert_shards(
&self,
run_id: &str,
shards: &[ShardInsert],
) -> Result<usize, HistoryError> {
let _ = (run_id, shards);
Ok(0)
}
async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
let _ = limit;
Ok(Vec::new())
}
async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
let _ = max_attempts;
Ok(ReclaimReport::default())
}
async fn finalize_shard(
&self,
run_id: &str,
shard_id: &str,
success: bool,
) -> Result<bool, HistoryError> {
let _ = (run_id, shard_id, success);
Ok(false)
}
async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
let _ = run_id;
Ok(ShardProgress::default())
}
async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
Ok(Vec::new())
}
async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
let _ = entry;
Ok(())
}
async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
let _ = filter;
Ok(Vec::new())
}
async fn record_run_logs(
&self,
run_id: &str,
lines: &[RunLogLine],
) -> Result<(), HistoryError> {
let _ = (run_id, lines);
Ok(())
}
async fn list_run_logs(
&self,
run_id: &str,
after_seq: Option<u64>,
limit: usize,
) -> Result<RunLogPage, HistoryError> {
let _ = (run_id, after_seq, limit);
Ok(RunLogPage::default())
}
async fn purge_run_logs(&self, older_than: Duration) -> Result<usize, HistoryError> {
let _ = older_than;
Ok(0)
}
async fn catalog_record(&self, update: &catalog::CatalogUpdate) -> Result<(), HistoryError> {
let _ = update;
Ok(())
}
async fn catalog_list_datasets(
&self,
filter: &catalog::CatalogListFilter,
) -> Result<catalog::CatalogDatasetPage, HistoryError> {
let _ = filter;
Ok(catalog::CatalogDatasetPage {
datasets: Vec::new(),
next_cursor: None,
})
}
async fn catalog_get_dataset(
&self,
id: &str,
) -> Result<Option<catalog::CatalogDatasetDetail>, HistoryError> {
let _ = id;
Ok(None)
}
async fn catalog_lineage(
&self,
root: Option<&str>,
depth: u32,
) -> Result<Vec<catalog::CatalogLineageEdge>, HistoryError> {
let _ = (root, depth);
Ok(Vec::new())
}
async fn catalog_record_config_snapshot(
&self,
snapshot: &catalog::ConfigSnapshot,
) -> Result<(), HistoryError> {
let _ = snapshot;
Ok(())
}
async fn catalog_last_config_snapshot(
&self,
pipeline: &str,
) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
let _ = pipeline;
Ok(None)
}
async fn template_register(
&self,
draft: &templates::TemplateDraft,
) -> Result<templates::TemplateRecord, HistoryError> {
let _ = draft;
Err(HistoryError::Backend(
"this run-history backend does not support the pipeline-template registry".into(),
))
}
async fn template_get(
&self,
id: &str,
version: Option<u32>,
) -> Result<Option<templates::TemplateRecord>, HistoryError> {
let _ = (id, version);
Ok(None)
}
async fn template_list(&self) -> Result<Vec<templates::TemplateSummary>, HistoryError> {
Ok(Vec::new())
}
async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
let _ = id;
Ok(Vec::new())
}
async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
let _ = (id, version);
Ok(0)
}
async fn template_set_tag(
&self,
id: &str,
tag: &str,
version: u32,
) -> Result<(), HistoryError> {
let _ = (id, tag, version);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template channels".into(),
))
}
async fn template_tags(&self, id: &str) -> Result<BTreeMap<String, u32>, HistoryError> {
let _ = id;
Ok(BTreeMap::new())
}
async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
let _ = (id, tag);
Ok(false)
}
async fn template_launch(
&self,
id: &str,
version: u32,
launched_by: Option<&str>,
) -> Result<Option<u32>, HistoryError> {
let _ = (id, version, launched_by);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template launches".into(),
))
}
async fn template_launches(
&self,
id: &str,
) -> Result<Vec<templates::LaunchRecord>, HistoryError> {
let _ = id;
Ok(Vec::new())
}
async fn template_set_deprecation(
&self,
id: &str,
record: Option<&templates::DeprecationRecord>,
) -> Result<(), HistoryError> {
let _ = (id, record);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template deprecation".into(),
))
}
async fn template_deprecation(
&self,
id: &str,
) -> Result<Option<templates::DeprecationRecord>, HistoryError> {
let _ = id;
Ok(None)
}
async fn template_state(&self, id: &str) -> Result<templates::TemplateState, HistoryError> {
Ok(templates::TemplateState::assemble(
self.template_versions(id).await?,
&self.template_launches(id).await?,
self.template_tags(id).await?,
self.template_deprecation(id).await?,
))
}
fn degraded(&self) -> bool;
}
pub async fn connect(
spec: &HistoryBackendSpec,
idem_retention: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
match spec {
HistoryBackendSpec::Memory => {
Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
}
HistoryBackendSpec::Postgres(url) => {
connect_postgres(url, idem_retention, lease_ttl, instance_id).await
}
HistoryBackendSpec::Sqlite(url) => {
connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
}
}
}
#[cfg(feature = "serve-history-postgres")]
async fn connect_postgres(
url: &str,
idem: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
let result = connect_with_retry("postgres", || {
postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
})
.await;
Ok(into_history(result, idem, "postgres"))
}
#[cfg(not(feature = "serve-history-postgres"))]
async fn connect_postgres(
_url: &str,
_idem: Duration,
_lease_ttl: Duration,
_instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Err(crate::error::CliError::Serve(
"persistent Postgres run history requires building faucet with the \
`serve-history-postgres` feature"
.into(),
))
}
#[cfg(feature = "serve-history-sqlite")]
async fn connect_sqlite(
url: &str,
idem: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
let result = connect_with_retry("sqlite", || {
sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
})
.await;
Ok(into_history(result, idem, "sqlite"))
}
#[cfg(not(feature = "serve-history-sqlite"))]
async fn connect_sqlite(
_url: &str,
_idem: Duration,
_lease_ttl: Duration,
_instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Err(crate::error::CliError::Serve(
"persistent SQLite run history requires building faucet with the \
`serve-history-sqlite` feature"
.into(),
))
}
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
const CONNECT_ATTEMPTS: usize = 8;
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<H, HistoryError>>,
{
let mut delay = Duration::from_millis(100);
for attempt in 1..=CONNECT_ATTEMPTS {
match make().await {
Ok(backend) => return Ok(backend),
Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
tracing::warn!(
backend = label,
attempt,
error = %e,
"run-history backend connect failed transiently; retrying before degrading"
);
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
Err(e) => return Err(e),
}
}
unreachable!("the final attempt returns Ok or Err rather than looping")
}
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
fn is_transient_connect_error(e: &HistoryError) -> bool {
let msg = e.to_string().to_ascii_lowercase();
[
"database is locked", "busy", "connection refused", "connection reset",
"timed out",
"timeout",
"starting up", "too many connections", ]
.iter()
.any(|needle| msg.contains(needle))
}
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
fn into_history<H: RunHistory + 'static>(
result: Result<H, HistoryError>,
idem: Duration,
label: &'static str,
) -> Arc<dyn RunHistory> {
match result {
Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
Box::new(backend),
idem,
label,
)),
Err(e) => {
tracing::error!(
backend = label, error = %e,
"run-history backend unavailable at startup; starting DEGRADED on in-memory store"
);
Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_classification() {
assert!(!RunStatus::Queued.is_terminal());
assert!(!RunStatus::Pending.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(RunStatus::Completed.is_terminal());
assert!(RunStatus::Failed.is_terminal());
assert!(RunStatus::Cancelled.is_terminal());
}
#[test]
fn run_record_serializes_status_snake_case() {
let rec = RunRecord::queued(
"r1".into(),
Some("n".into()),
Default::default(),
None,
Utc::now(),
);
let v = serde_json::to_value(&rec).unwrap();
assert_eq!(v["status"], "queued");
assert_eq!(v["run_id"], "r1");
assert!(v.get("doctor_report").is_none());
}
#[test]
fn pending_is_non_terminal_and_serializes_snake_case() {
assert!(!RunStatus::Pending.is_terminal());
assert_eq!(RunStatus::Pending.as_str(), "pending");
let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
rec.status = RunStatus::Pending;
rec.attempt = 2;
let v = serde_json::to_value(&rec).unwrap();
assert_eq!(v["status"], "pending");
assert_eq!(v["attempt"], 2);
assert!(v.get("config_body").is_none());
}
#[test]
fn shard_progress_all_terminal() {
assert!(!ShardProgress::default().all_terminal());
let mut p = ShardProgress {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
};
assert!(!p.all_terminal());
p = ShardProgress {
total: 3,
completed: 2,
failed: 1,
running: 0,
pending: 0,
};
assert!(p.all_terminal());
}
#[tokio::test]
async fn memory_backend_shard_methods_are_inert() {
use crate::serve::history::memory::MemoryHistory;
let h = MemoryHistory::new(Duration::from_secs(60));
assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
assert!(h.claim_shards(8).await.unwrap().is_empty());
assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
assert!(!h.finalize_shard("r", "0", true).await.unwrap());
assert_eq!(
h.shard_progress("r").await.unwrap(),
ShardProgress::default()
);
}
#[tokio::test]
async fn memory_backend_cluster_methods_are_inert() {
use crate::serve::history::memory::MemoryHistory;
let h = MemoryHistory::new(Duration::from_secs(60));
assert!(h.claim_pending(8).await.unwrap().is_empty());
assert_eq!(
h.reclaim_orphans(3).await.unwrap(),
ReclaimReport::default()
);
assert!(!h.cancel_pending("x").await.unwrap());
h.request_cancel("x").await.unwrap();
assert!(h.pending_cancellations().await.unwrap().is_empty());
assert!(
h.live_instances(Duration::from_secs(60))
.await
.unwrap()
.is_empty()
);
let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
assert!(h.finalize_owned(&rec).await.unwrap());
assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
}
}
#[cfg(all(
test,
any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
))]
mod connect_retry_tests {
use super::*;
use std::cell::Cell;
#[test]
fn classifies_transient_vs_permanent_connect_errors() {
assert!(is_transient_connect_error(&HistoryError::Backend(
"SQLite connection failed: error returned from database: (code: 5) \
database is locked"
.into()
)));
assert!(is_transient_connect_error(&HistoryError::Backend(
"connection refused (os error 111)".into()
)));
assert!(!is_transient_connect_error(&HistoryError::Backend(
"invalid sqlite url 'sqlite::nonsense': ParseError".into()
)));
}
#[tokio::test]
async fn retries_a_transient_failure_then_succeeds() {
let calls = Cell::new(0usize);
let result: Result<u32, HistoryError> = connect_with_retry("test", || {
let n = calls.get() + 1;
calls.set(n);
async move {
if n < 3 {
Err(HistoryError::Backend("database is locked".into()))
} else {
Ok(42u32)
}
}
})
.await;
assert_eq!(result.unwrap(), 42);
assert_eq!(
calls.get(),
3,
"two transient failures retried, third succeeds"
);
}
#[tokio::test]
async fn does_not_retry_a_permanent_error() {
let calls = Cell::new(0usize);
let result: Result<u32, HistoryError> = connect_with_retry("test", || {
calls.set(calls.get() + 1);
async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
})
.await;
assert!(result.is_err());
assert_eq!(
calls.get(),
1,
"a permanent error degrades immediately, no retry"
);
}
}