spiral-sdk 0.1.11

Rust SDK for Spiral resource and table APIs
Documentation
//! Idempotent registration of already persisted table versions.
use spiral_contract::call::CallError;

use crate::api::tables::objects::TableId;
use crate::api::tables::{CreateVersionRequest, TablesError};
use crate::resources::arbor::Client as ArborClient;
use crate::scandal::types::{PreparedWriteReceipt, TableWriteResult};
use crate::{Client, Error, Table};

impl Client {
    /// Retry resource registration without replaying a consumed Arrow upload.
    ///
    /// The receipt must come from this project's authorized scandal write.
    /// Registration alone does not publish a table in Catalog.
    pub async fn register_prepared(&self, receipt: PreparedWriteReceipt) -> Result<Table, Error> {
        let result = register(&self.connection.arbor, receipt).await?;
        Ok(self.table_by_id(result.resource_id, result.version_id))
    }
}

pub(crate) async fn register(
    client: &ArborClient,
    receipt: PreparedWriteReceipt,
) -> Result<TableWriteResult, Error> {
    let result = client
        .create_version(
            TableId(receipt.resource_id),
            &CreateVersionRequest {
                base_version_id: receipt.base_version,
                version_id: receipt.version_id,
            },
        )
        .await
        .map_err(|error| match &error {
            Error::Arbor(failure) if registration_may_have_committed(failure) => {
                Error::RegistrationUnknown {
                    receipt: receipt.clone(),
                    message: error.to_string(),
                }
            }
            _ => error,
        })?;
    if result.version_id != receipt.version_id
        || result.parent_version_id != receipt.base_version
        || result.seq_no.0 != receipt.sequence.0
    {
        return Err(Error::RegistrationUnknown {
            receipt,
            message: "registration did not match the prepared receipt".into(),
        });
    }
    Ok(TableWriteResult {
        resource_id: receipt.resource_id,
        version_id: receipt.version_id,
    })
}

fn registration_may_have_committed(error: &CallError<TablesError>) -> bool {
    match error {
        CallError::Service(failure) => failure.status() >= 500,
        CallError::UnknownRemote(failure) => failure.status() >= 500,
        CallError::Transport(failure) => failure.observed_status().is_none_or(uncertain_status),
        CallError::Protocol(failure) => failure.observed_status().is_none_or(uncertain_status),
        CallError::Internal { .. } => true,
    }
}

fn uncertain_status(status: u16) -> bool {
    (200..300).contains(&status) || status >= 500
}

#[cfg(test)]
mod tests {
    use serde_json::json;
    use uuid::Uuid;
    use wiremock::matchers::{body_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::*;
    use crate::auth::TokenSource;

    #[tokio::test]
    async fn registration_retains_the_receipt_when_the_outcome_is_uncertain() {
        let receipt = PreparedWriteReceipt {
            base_version: Some(Uuid::now_v7()),
            resource_id: Uuid::new_v4(),
            sequence: 2.into(),
            version_id: Uuid::now_v7(),
        };
        for status in [200, 409, 500] {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path(format!(
                    "/api/tables/{}/versions",
                    receipt.resource_id
                )))
                .and(body_json(json!({
                    "baseVersionId": receipt.base_version,
                    "versionId": receipt.version_id,
                })))
                .respond_with(ResponseTemplate::new(status).set_body_json(json!({
                    "versionId": receipt.version_id,
                    "parentVersionId": receipt.base_version,
                    "seqNo": receipt.sequence,
                })))
                .expect(1)
                .mount(&server)
                .await;
            let result = register(
                &ArborClient::new(&server.uri())
                    .unwrap()
                    .with_token_source(TokenSource::fixed("test".into())),
                receipt.clone(),
            )
            .await;
            match status {
                200 => assert_eq!(result.unwrap().version_id, receipt.version_id),
                409 => assert!(matches!(result, Err(Error::Arbor(_)))),
                _ => assert!(matches!(
                    result,
                    Err(Error::RegistrationUnknown { receipt: retained, .. }) if retained == receipt
                )),
            }
        }

        let unavailable = ArborClient::new("http://127.0.0.1:9")
            .unwrap()
            .with_token_source(TokenSource::fixed("test".into()));
        assert!(matches!(
            register(&unavailable, receipt.clone()).await,
            Err(Error::RegistrationUnknown { receipt: retained, .. }) if retained == receipt
        ));
    }
}