tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 840b1f792e58382d8f1f3327cc029728ec756d3a
parent 85faca6f29319af61eefdabfae659d406fec8888
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 13:14:33 -0700

ops: add readiness gates

- check runtime database connectivity through the store
- gate readiness on exact migration plan state
- require repository read smoke before reporting ready
- cover readiness transitions and live relay readyz output

Diffstat:
Mcrates/tangle/tests/run_integration.rs | 6++++++
Mcrates/tangle_runtime/src/lib.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle_store_surreal/src/lib.rs | 17+++++++++++++++++
3 files changed, 100 insertions(+), 3 deletions(-)

diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs @@ -410,6 +410,12 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { assert!(seller_detail.contains(profile.id().as_str())); assert!(seller_detail.contains("\"display_name\":\"Radroots Market\"")); assert!(seller_detail.contains("\"regions\":[\"cascadia\",\"pnw\"]")); + let readiness = http_get(port, "/readyz"); + assert!(readiness.contains("200 OK")); + assert!(readiness.contains("\"status\":\"ready\"")); + assert!(readiness.contains("\"database\":\"ready\"")); + assert!(readiness.contains("\"migrations\":\"ready\"")); + assert!(readiness.contains("\"repository\":\"ready\"")); let metrics = http_get(port, "/metrics"); assert!(metrics.contains("200 OK")); assert!(metrics.contains("text/plain; version=0.0.4")); diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -1555,8 +1555,54 @@ async fn runtime_healthz() -> Json<HealthDocument> { healthz().await } -async fn runtime_readyz() -> (StatusCode, Json<ReadinessDocument>) { - readyz(State(ReadinessState::ready())).await +async fn runtime_readyz( + State(state): State<RuntimeRelayState>, +) -> (StatusCode, Json<ReadinessDocument>) { + readyz(State(runtime_readiness_state(&state.store).await)).await +} + +async fn runtime_readiness_state(store: &SurrealStore) -> ReadinessState { + let database = readiness_status(store.ping().await); + let migrations = if database.is_ready() { + readiness_status(runtime_migrations_ready(store).await) + } else { + ReadinessCheckStatus::NotReady + }; + let repository = if database.is_ready() && migrations.is_ready() { + readiness_status(store.metrics_snapshot().await.map(|_| ())) + } else { + ReadinessCheckStatus::NotReady + }; + ReadinessState::new(database, migrations, repository) +} + +async fn runtime_migrations_ready(store: &SurrealStore) -> Result<(), RuntimeCommandError> { + let applied = store + .applied_migrations() + .await + .map_err(|error| RuntimeCommandError::store(error.to_string()))?; + let plan = base_migration_plan(); + if applied.len() != plan.migrations().len() { + return Err(RuntimeCommandError::store( + "runtime migrations are incomplete", + )); + } + for (applied, expected) in applied.iter().zip(plan.migrations()) { + if applied.name() != expected.name() || applied.checksum() != expected.checksum() { + return Err(RuntimeCommandError::store( + "runtime migrations do not match", + )); + } + } + Ok(()) +} + +fn readiness_status<E>(result: Result<(), E>) -> ReadinessCheckStatus { + if result.is_ok() { + ReadinessCheckStatus::Ready + } else { + ReadinessCheckStatus::NotReady + } } async fn runtime_metrics(State(state): State<RuntimeRelayState>) -> Result<Response, ApiError> { @@ -4897,7 +4943,7 @@ mod tests { listing_item_document, listing_projection_query, listings_router, load_runtime_config, metrics_router, migrate_runtime_database, parse_listing_query, parse_marketplace_search_query, parse_runtime_config_json, relay_info_router, - restore_runtime_store, search_document_query, websocket_router, + restore_runtime_store, runtime_readiness_state, search_document_query, websocket_router, }; use axum::{body::Body, response::IntoResponse}; use http::{HeaderValue, Request, StatusCode, header}; @@ -6494,6 +6540,34 @@ mod tests { } #[tokio::test] + async fn runtime_readiness_checks_database_migrations_and_repository() { + let config = SurrealConnectionConfig::memory("tangle_runtime", "readiness_gates") + .expect("memory config"); + let store = SurrealStore::connect_memory(&config) + .await + .expect("memory store"); + + let missing = runtime_readiness_state(&store).await; + assert_eq!( + missing, + ReadinessState::new( + ReadinessCheckStatus::Ready, + ReadinessCheckStatus::NotReady, + ReadinessCheckStatus::NotReady + ) + ); + + store + .apply_plan(&base_migration_plan()) + .await + .expect("apply plan"); + assert_eq!( + runtime_readiness_state(&store).await, + ReadinessState::ready() + ); + } + + #[tokio::test] async fn metrics_endpoint_reports_store_snapshot() { let store = runtime_memory_store().await; let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -1566,6 +1566,16 @@ impl SurrealStore { &self.db } + pub async fn ping(&self) -> Result<(), SurrealStoreError> { + self.db + .query("RETURN true;") + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + Ok(()) + } + pub async fn apply_plan( &self, plan: &SurrealMigrationPlan, @@ -5563,6 +5573,13 @@ mod tests { } #[tokio::test] + async fn store_ping_confirms_database_connectivity() { + let store = memory_store().await; + + store.ping().await.expect("ping"); + } + + #[tokio::test] async fn migration_tracking_detects_checksum_changes() { let store = memory_store().await; let original = migration_tracking_schema();