Push down db error returning

This commit is contained in:
Andras Schmelczer 2026-05-11 20:25:37 +01:00
parent ce995cdc33
commit 2d69d4b26d
11 changed files with 172 additions and 129 deletions

View file

@ -5,13 +5,15 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result};
use log::info;
use models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId,
};
use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions, types::chrono::Utc};
use crate::errors::{SyncServerError, database_error, server_error};
pub mod models;
/// Sentinel error indicating the `SQLite` database is busy (`SQLITE_BUSY`).
@ -20,6 +22,24 @@ pub mod models;
#[error("Database is busy")]
pub struct WriteBusyError;
/// Detects whether a `sqlx::Error` indicates the database is currently
/// unavailable for a retryable reason: a `SQLITE_BUSY` from the engine, or
/// a `PoolTimedOut` from our short acquire timeout. Both should surface as
/// 429 so the client retries instead of treating it as a server fault.
pub fn is_sqlite_busy_error(err: &sqlx::Error) -> bool {
match err {
sqlx::Error::Database(db_err) => {
// SQLITE_BUSY base code is 5. Extended codes share base 5.
let busy_by_code = db_err
.code()
.is_some_and(|c| c.parse::<u32>().is_ok_and(|n| n & 0xFF == 5));
busy_by_code || db_err.message().contains("database is locked")
}
sqlx::Error::PoolTimedOut => true,
_ => false,
}
}
use sqlx::{
Pool, Sqlite, pool::PoolConnection, sqlite::SqliteConnection, sqlite::SqlitePoolOptions,
};
@ -32,7 +52,7 @@ use super::websocket::{
models::{WebSocketServerMessage, WebSocketVaultUpdate},
};
use crate::config::database_config::DatabaseConfig;
use crate::consts::IDLE_POOL_TIMEOUT;
use crate::consts::{IDLE_POOL_TIMEOUT, POOL_ACQUIRE_TIMEOUT};
fn duration_millis_u64(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
@ -87,22 +107,16 @@ impl WriteTransaction {
pool: &Pool<Sqlite>,
write_guard: tokio::sync::OwnedMutexGuard<()>,
) -> Result<Self> {
let mut conn = pool
.acquire()
.await
.context("Cannot acquire connection for write transaction")?;
let mut conn = match pool.acquire().await {
Ok(conn) => conn,
Err(e) if is_sqlite_busy_error(&e) => return Err(WriteBusyError.into()),
Err(e) => {
return Err(anyhow::Error::from(e)
.context("Cannot acquire connection for write transaction"));
}
};
if let Err(e) = sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await {
let is_busy = match &e {
sqlx::Error::Database(db_err) => {
// SQLITE_BUSY base code is 5. Extended codes share base 5.
let busy_by_code = db_err
.code()
.is_some_and(|c| c.parse::<u32>().is_ok_and(|n| n & 0xFF == 5));
busy_by_code || db_err.message().contains("database is locked")
}
_ => false,
};
if is_busy {
if is_sqlite_busy_error(&e) {
return Err(WriteBusyError.into());
}
return Err(e).context("Cannot begin immediate transaction");
@ -113,22 +127,24 @@ impl WriteTransaction {
})
}
pub async fn commit(mut self) -> Result<()> {
pub async fn commit(mut self) -> Result<(), SyncServerError> {
if let Some(mut conn) = self.conn.take() {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.context("Failed to commit transaction")?;
.context("Failed to commit transaction")
.map_err(database_error)?;
}
Ok(())
}
pub async fn rollback(mut self) -> Result<()> {
pub async fn rollback(mut self) -> Result<(), SyncServerError> {
if let Some(mut conn) = self.conn.take() {
sqlx::query("ROLLBACK")
.execute(&mut *conn)
.await
.context("Failed to rollback transaction")?;
.context("Failed to rollback transaction")
.map_err(database_error)?;
}
Ok(())
}
@ -265,10 +281,14 @@ impl Database {
drop(init_conn);
// Per-connection PRAGMAs shared by both reader and writer pools.
// journal_mode = WAL is a no-op on an already-WAL database.
// Database-level PRAGMAs (auto_vacuum, journal_mode) are deliberately
// omitted here: they require a write lock to verify or set, so issuing
// them on every new pool connection blocks behind any in-flight writer
// and can fail with SQLITE_BUSY just to open a connection. The init
// connection above set them once; the WAL mode persists in the database
// header, so subsequent opens pick it up automatically.
let base_options = SqliteConnectOptions::new()
.filename(file_name.clone())
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(30))
.log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30))
// In WAL mode, NORMAL is safe: data survives OS crashes, only the
@ -292,6 +312,7 @@ impl Database {
// Reader pool: multiple connections for concurrent reads.
let reader = SqlitePoolOptions::new()
.max_connections(config.max_connections_per_vault)
.acquire_timeout(POOL_ACQUIRE_TIMEOUT)
.acquire_slow_threshold(Duration::from_secs(30))
// Disabled: the health-check query is subject to busy_timeout
// and blocks all connection checkouts when a write is active,
@ -309,6 +330,7 @@ impl Database {
// reader pool ensures writes never compete with reads for pool slots.
let writer = SqlitePoolOptions::new()
.max_connections(1)
.acquire_timeout(POOL_ACQUIRE_TIMEOUT)
.acquire_slow_threshold(Duration::from_secs(30))
.test_before_acquire(false)
.before_acquire(rollback_before_acquire)
@ -375,7 +397,10 @@ impl Database {
Ok(self.get_vault_pools(vault).await?.reader)
}
pub async fn create_write_transaction(&self, vault: &VaultId) -> Result<WriteTransaction> {
pub async fn create_write_transaction(
&self,
vault: &VaultId,
) -> Result<WriteTransaction, SyncServerError> {
let write_lock = {
let mut locks = self.write_locks.lock().await;
locks
@ -384,8 +409,10 @@ impl Database {
.clone()
};
let write_guard = write_lock.lock_owned().await;
let pools = self.get_vault_pools(vault).await?;
WriteTransaction::new(&pools.writer, write_guard).await
let pools = self.get_vault_pools(vault).await.map_err(database_error)?;
WriteTransaction::new(&pools.writer, write_guard)
.await
.map_err(database_error)
}
/// Return the latest state of all documents in the vault, optionally
@ -397,7 +424,7 @@ impl Database {
vault: &VaultId,
up_to_vault_update_id: Option<VaultUpdateId>,
connection: Option<&mut SqliteConnection>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
) -> Result<Vec<DocumentVersionWithoutContent>, SyncServerError> {
// `i64::MAX` makes the upper bound a no-op for callers that don't
// care about an exact snapshot (they pass `None`).
let upper = up_to_vault_update_id.unwrap_or(i64::MAX);
@ -423,7 +450,12 @@ impl Database {
query.fetch_all(&mut *conn).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.fetch_all(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.context("Cannot fetch latest documents")
@ -441,6 +473,7 @@ impl Database {
})
.collect()
})
.map_err(database_error)
}
/// Return the latest state of all documents (including deleted) in the
@ -454,7 +487,7 @@ impl Database {
vault_update_id: VaultUpdateId,
up_to_vault_update_id: Option<VaultUpdateId>,
connection: Option<&mut SqliteConnection>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
) -> Result<Vec<DocumentVersionWithoutContent>, SyncServerError> {
// `i64::MAX` makes the upper bound a no-op for callers that don't
// care about an exact snapshot (they pass `None`).
let upper = up_to_vault_update_id.unwrap_or(i64::MAX);
@ -499,7 +532,12 @@ impl Database {
query.fetch_all(&mut *conn).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.fetch_all(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.with_context(|| {
@ -519,13 +557,14 @@ impl Database {
})
.collect()
})
.map_err(database_error)
}
pub async fn get_max_update_id_in_vault(
&self,
vault: &VaultId,
connection: Option<&mut SqliteConnection>,
) -> Result<i64> {
) -> Result<i64, SyncServerError> {
let query = sqlx::query!(
r#"
select coalesce(max(vault_update_id), 0) as max_vault_update_id
@ -537,11 +576,17 @@ impl Database {
query.fetch_one(&mut *conn).await
} else {
query
.fetch_one(&self.get_connection_pool(vault).await?)
.fetch_one(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.map(|row| row.max_vault_update_id)
.context("Cannot fetch max update id in vault")
.map_err(database_error)
}
pub async fn get_latest_non_deleted_document_by_path(
@ -549,7 +594,7 @@ impl Database {
vault: &VaultId,
relative_path: &str,
connection: Option<&mut SqliteConnection>,
) -> Result<Option<StoredDocumentVersion>> {
) -> Result<Option<StoredDocumentVersion>, SyncServerError> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
@ -578,10 +623,16 @@ impl Database {
query.fetch_optional(&mut *conn).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.fetch_optional(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.context("Cannot fetch latest document version")
.map_err(database_error)
}
/// Find a doc whose CREATE was authored by this device with
@ -611,7 +662,7 @@ impl Database {
last_seen_vault_update_id: VaultUpdateId,
content: &[u8],
connection: Option<&mut SqliteConnection>,
) -> Result<Option<StoredDocumentVersion>> {
) -> Result<Option<StoredDocumentVersion>, SyncServerError> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
@ -646,10 +697,16 @@ impl Database {
query.fetch_optional(&mut *conn).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.fetch_optional(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.context("Cannot fetch lost-create candidate")
.map_err(database_error)
}
pub async fn get_latest_document(
@ -657,7 +714,7 @@ impl Database {
vault: &VaultId,
document_id: &DocumentId,
connection: Option<&mut SqliteConnection>,
) -> Result<Option<StoredDocumentVersion>> {
) -> Result<Option<StoredDocumentVersion>, SyncServerError> {
let document_id = document_id.as_hyphenated();
let query = sqlx::query_as!(
StoredDocumentVersion,
@ -683,10 +740,16 @@ impl Database {
query.fetch_optional(&mut *conn).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.fetch_optional(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.context("Cannot fetch latest document version")
.map_err(database_error)
}
pub async fn get_document_version(
@ -694,7 +757,7 @@ impl Database {
vault: &VaultId,
vault_update_id: VaultUpdateId,
connection: Option<&mut SqliteConnection>,
) -> Result<Option<StoredDocumentVersion>> {
) -> Result<Option<StoredDocumentVersion>, SyncServerError> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
@ -718,10 +781,16 @@ impl Database {
query.fetch_optional(&mut *conn).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.fetch_optional(
&self
.get_connection_pool(vault)
.await
.map_err(database_error)?,
)
.await
}
.context("Cannot fetch document version")
.map_err(database_error)
}
// inserting the document must be the last step of the transaction
@ -730,7 +799,7 @@ impl Database {
vault_id: &VaultId,
version: &StoredDocumentVersion,
mut transaction: WriteTransaction,
) -> Result<()> {
) -> Result<(), SyncServerError> {
let document_id = version.document_id.as_hyphenated();
let query = sqlx::query!(
r#"
@ -766,14 +835,12 @@ impl Database {
let _send_guard = self.broadcasts.acquire_send_lock(vault_id).await;
query
.execute(transaction.connection_mut()?)
.execute(transaction.connection_mut().map_err(server_error)?)
.await
.context("Cannot insert document version")?;
.context("Cannot insert document version")
.map_err(database_error)?;
transaction
.commit()
.await
.context("Failed to commit transaction")?;
transaction.commit().await?;
// Broadcast every commit to every connected client, including
// the originator. The HTTP response is the originator's normal

View file

@ -65,13 +65,11 @@ pub async fn get_unseen_documents(
.database
.get_latest_documents_since(vault_id, update_id, Some(up_to_vault_update_id), None)
.await
.map_err(server_error)
} else {
state
.database
.get_latest_documents(vault_id, Some(up_to_vault_update_id), None)
.await
.map_err(server_error)
}
}

View file

@ -21,6 +21,10 @@ pub const DEFAULT_MAX_PENDING_WS_CONNECTIONS: usize = 128;
pub const DEFAULT_LOG_DIRECTORY: &str = "logs";
pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_hours(24);
pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_mins(5);
/// Fail fast on pool acquire so a transiently locked database surfaces as
/// a 429 in seconds, not after a 30s busy_timeout. Callers retry.
pub const POOL_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5);
pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);

View file

@ -163,15 +163,26 @@ pub fn too_many_requests_error(error: anyhow::Error) -> SyncServerError {
SyncServerError::TooManyRequests(error)
}
/// Maps a `create_write_transaction` error to 429 if the database is busy,
/// or 500 for all other failures.
pub fn write_transaction_error(error: anyhow::Error) -> SyncServerError {
if error
.downcast_ref::<crate::app_state::database::WriteBusyError>()
.is_some()
{
/// Maps a database-operation error to 429 if the database is busy or the
/// pool acquire timed out (both retryable), or 500 for all other failures.
pub fn database_error(error: anyhow::Error) -> SyncServerError {
if is_database_busy(&error) {
too_many_requests_error(error)
} else {
server_error(error)
}
}
fn is_database_busy(error: &anyhow::Error) -> bool {
if error
.downcast_ref::<crate::app_state::database::WriteBusyError>()
.is_some()
{
return true;
}
error.chain().any(|cause| {
cause
.downcast_ref::<sqlx::Error>()
.is_some_and(crate::app_state::database::is_sqlite_busy_error)
})
}

View file

@ -14,7 +14,7 @@ use crate::{
database::models::{StoredDocumentVersion, VaultId},
},
config::user_config::User,
errors::{SyncServerError, client_error, server_error, write_transaction_error},
errors::{SyncServerError, client_error, server_error},
server::{responses::DocumentUpdateResponse, update_document},
utils::{
find_first_available_path::find_first_available_path, is_binary::is_binary,
@ -49,8 +49,7 @@ pub async fn create_document(
let mut transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(write_transaction_error)?;
.await?;
let sanitized_relative_path = sanitize_path(&request.relative_path).map_err(client_error)?;
let new_content = request.content.contents.to_vec();
@ -62,8 +61,7 @@ pub async fn create_document(
&sanitized_relative_path,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?;
.await?;
if let Some(latest_version) = latest_version {
// Only merge with an existing document the client couldn't have
@ -131,8 +129,7 @@ pub async fn create_document(
&new_content,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?
.await?
{
info!(
"Lost-create recovery: binding retry at `{sanitized_relative_path}` to existing doc {} (was at `{}`) in vault `{vault_id}` for device `{}`",
@ -161,8 +158,7 @@ pub async fn create_document(
&vault_id,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?;
.await?;
let deduped_path = find_first_available_path(
&vault_id,
@ -170,8 +166,7 @@ pub async fn create_document(
&state.database,
&mut transaction,
)
.await
.map_err(server_error)?;
.await?;
if deduped_path != sanitized_relative_path {
info!(
@ -198,8 +193,7 @@ pub async fn create_document(
state
.database
.insert_document_version(&vault_id, &new_version, transaction)
.await
.map_err(server_error)?;
.await?;
Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
new_version.into(),

View file

@ -1,4 +1,4 @@
use anyhow::{Context, anyhow};
use anyhow::anyhow;
use axum::{
Extension, Json,
extract::{Path, State},
@ -16,7 +16,7 @@ use crate::{
},
},
config::user_config::User,
errors::{SyncServerError, not_found_error, server_error, write_transaction_error},
errors::{SyncServerError, not_found_error, server_error},
utils::normalize::normalize,
};
@ -43,8 +43,7 @@ pub async fn delete_document(
let mut transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(write_transaction_error)?;
.await?;
let last_update_id = state
.database
@ -52,8 +51,7 @@ pub async fn delete_document(
&vault_id,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?;
.await?;
let latest_version = state
.database
@ -62,26 +60,17 @@ pub async fn delete_document(
&document_id,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?;
.await?;
let Some(latest_version) = latest_version else {
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
transaction.rollback().await?;
return Err(not_found_error(anyhow!(
"Document `{document_id}` not found in vault `{vault_id}`"
)));
};
if latest_version.is_deleted {
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
transaction.rollback().await?;
info!("Document `{document_id}` has already been deleted",);
return Ok(Json(latest_version.into()));
@ -110,8 +99,7 @@ pub async fn delete_document(
state
.database
.insert_document_version(&vault_id, &new_version, transaction)
.await
.map_err(server_error)?;
.await?;
Ok(Json(new_version.into()))
}

View file

@ -11,7 +11,7 @@ use crate::{
AppState,
database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId},
},
errors::{SyncServerError, not_found_error, server_error},
errors::{SyncServerError, not_found_error},
utils::normalize::normalize,
};
@ -40,8 +40,7 @@ pub async fn fetch_document_version(
let result = state
.database
.get_document_version(&vault_id, vault_update_id, None)
.await
.map_err(server_error)?
.await?
.map_or_else(
|| {
Err(not_found_error(anyhow!(

View file

@ -11,7 +11,7 @@ use crate::{
AppState,
database::models::{DocumentId, VaultId, VaultUpdateId},
},
errors::{SyncServerError, not_found_error, server_error},
errors::{SyncServerError, not_found_error},
utils::normalize::normalize,
};
@ -40,8 +40,7 @@ pub async fn fetch_document_version_content(
let result = state
.database
.get_document_version(&vault_id, vault_update_id, None)
.await
.map_err(server_error)?
.await?
.map_or_else(
|| {
Err(not_found_error(anyhow!(

View file

@ -22,9 +22,7 @@ use crate::{
},
},
config::user_config::User,
errors::{
SyncServerError, client_error, not_found_error, server_error, write_transaction_error,
},
errors::{SyncServerError, client_error, not_found_error, server_error},
server::requests::UpdateBinaryDocumentVersion,
utils::{
find_first_available_path::find_first_available_path, is_binary::as_non_binary_text,
@ -58,8 +56,7 @@ pub async fn update_binary(
let transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(write_transaction_error)?;
.await?;
update_document(
&parent_document.relative_path,
@ -104,8 +101,7 @@ pub async fn update_text(
let transaction = state
.database
.create_write_transaction(&vault_id)
.await
.map_err(write_transaction_error)?;
.await?;
update_document(
&parent_document.relative_path,
@ -131,8 +127,7 @@ async fn get_parent_document(
let parent = state
.database
.get_document_version(vault_id, parent_version_id, None)
.await
.map_err(server_error)?
.await?
.map_or_else(
|| {
Err(not_found_error(anyhow!(
@ -177,8 +172,7 @@ pub async fn update_document(
&vault_id,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?;
.await?;
let latest_version = state
.database
@ -187,8 +181,7 @@ pub async fn update_document(
&document_id,
Some(transaction.connection_mut().map_err(server_error)?),
)
.await
.map_err(server_error)?
.await?
.map_or_else(
|| {
Err(not_found_error(anyhow!(
@ -199,11 +192,7 @@ pub async fn update_document(
)?;
if latest_version.is_deleted {
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
transaction.rollback().await?;
info!("Document `{document_id}` has been deleted, ignoring update to it",);
return Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
@ -221,11 +210,7 @@ pub async fn update_document(
info!(
"Document content is the same as the latest version for `{document_id}`, skipping update"
);
transaction
.rollback()
.await
.context("Failed to roll back transaction")
.map_err(server_error)?;
transaction.rollback().await?;
return Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
latest_version.into(),
@ -289,8 +274,7 @@ pub async fn update_document(
{
let new_path =
find_first_available_path(&vault_id, requested, &state.database, &mut transaction)
.await
.map_err(server_error)?;
.await?;
if new_path != requested {
info!(
@ -321,8 +305,7 @@ pub async fn update_document(
state
.database
.insert_document_version(&vault_id, &new_version, transaction)
.await
.map_err(server_error)?;
.await?;
Ok(Json(if is_same_as_request {
DocumentUpdateResponse::FastForwardUpdate(new_version.into())

View file

@ -162,8 +162,7 @@ async fn websocket(
let cursor = state
.database
.get_max_update_id_in_vault(&vault_id, None)
.await
.map_err(server_error)?;
.await?;
drop(send_guard);
// Catch-up on versions committed while this client was offline,

View file

@ -1,6 +1,7 @@
use crate::app_state::database::{WriteTransaction, models::VaultId};
use crate::errors::{SyncServerError, server_error};
use crate::utils::dedup_paths::dedup_paths;
use anyhow::{Result, anyhow};
use anyhow::anyhow;
use log::{debug, info};
pub async fn find_first_available_path(
@ -8,7 +9,7 @@ pub async fn find_first_available_path(
sanitized_relative_path: &str,
database: &crate::app_state::database::Database,
transaction: &mut WriteTransaction,
) -> Result<String> {
) -> Result<String, SyncServerError> {
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
for candidate in dedup_paths(sanitized_relative_path) {
debug!("Checking candidate path for deconflicting names: `{candidate}`");
@ -16,7 +17,7 @@ pub async fn find_first_available_path(
.get_latest_non_deleted_document_by_path(
vault_id,
&candidate,
Some(transaction.connection_mut()?),
Some(transaction.connection_mut().map_err(server_error)?),
)
.await?
.is_none()
@ -30,7 +31,7 @@ pub async fn find_first_available_path(
);
}
Err(anyhow!(
Err(server_error(anyhow!(
"No available path candidates produced for `{sanitized_relative_path}` in vault `{vault_id}`"
))
)))
}