#![allow(clippy::unwrap_used)] //! tidalDB + Axum: production-ready embedding patterns. //! //! Demonstrates: //! - Schema defined at startup before `axum::serve` //! - `Arc` shared state across handlers //! - Three routes: `POST /signal`, `GET /feed`, `GET /health` //! - `TidalError` to HTTP status mapping //! - Graceful shutdown with `db.close()` //! //! `TidalDb` is not `Clone`, so it must be wrapped in `Arc` for use with //! Axum's `State`, which requires `T: Clone + Send + Sync + 'static`. //! //! # Running //! //! ```bash //! cargo run --example axum_embedding --manifest-path tidal/Cargo.toml //! # Then: //! # curl http://127.0.0.1:3000/health //! # curl "http://127.0.0.1:3000/feed?user_id=1" //! # curl -X POST http://127.0.0.1:3000/signal \ //! # -H 'Content-Type: application/json' \ //! # -d '{"entity_id": 1, "signal": "view", "weight": 1.0}' //! ``` use std::sync::Arc; use std::time::Duration; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; use tidaldb::{TidalDb, TidalError}; // ── Request / Response types ──────────────────────────────────────────────── /// JSON body for `POST /signal`. #[derive(serde::Deserialize)] struct SignalRequest { entity_id: u64, signal: String, weight: f64, } /// Query parameters for `GET /feed`. #[derive(serde::Deserialize)] struct FeedQuery { user_id: u64, } /// JSON response for `GET /health`. #[derive(serde::Serialize)] struct HealthResponse { status: &'static str, items: u64, } /// JSON response for `GET /feed`. #[derive(serde::Serialize)] struct FeedResponse { items: Vec, total_candidates: usize, } #[derive(serde::Serialize)] struct FeedItem { entity_id: u64, score: f64, rank: usize, } // ── Error mapping ─────────────────────────────────────────────────────────── /// Maps `TidalError` variants to appropriate HTTP status codes. /// /// This is the recommended pattern for wrapping tidalDB errors in an HTTP API. /// Each error variant maps to the semantically correct HTTP status: /// /// - `Backpressure` / `RateLimited` -> 429 Too Many Requests (client should retry) /// - `NotFound` -> 404 Not Found /// - `Schema` / `Config` / `InvalidInput` -> 400 Bad Request (client's fault) /// - `Query` -> 400 Bad Request (malformed query) /// - `PolicyViolation` / `SessionExpired` -> 403 Forbidden /// - Everything else -> 500 Internal Server Error fn error_to_status(err: &TidalError) -> StatusCode { match err { TidalError::Backpressure { .. } | TidalError::RateLimited { .. } => { StatusCode::TOO_MANY_REQUESTS } TidalError::NotFound { .. } => StatusCode::NOT_FOUND, TidalError::Schema(_) | TidalError::Config(_) | TidalError::InvalidInput(_) => { StatusCode::BAD_REQUEST } TidalError::Query(_) => StatusCode::BAD_REQUEST, TidalError::PolicyViolation { .. } | TidalError::SessionExpired { .. } => { StatusCode::FORBIDDEN } // Storage, Durability, Internal -- all server-side issues. _ => StatusCode::INTERNAL_SERVER_ERROR, } } /// Axum-compatible error wrapper that converts `TidalError` into an HTTP response. struct AppError(TidalError); impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let status = error_to_status(&self.0); let body = serde_json::json!({ "error": self.0.to_string() }); (status, Json(body)).into_response() } } impl From for AppError { fn from(err: TidalError) -> Self { Self(err) } } // ── Handlers ──────────────────────────────────────────────────────────────── /// `POST /signal` -- record an engagement signal. /// /// Accepts a JSON body with `entity_id`, `signal` type name, and `weight`. /// The signal is recorded into the WAL-backed signal ledger and is immediately /// reflected in subsequent ranking queries. async fn signal_handler( State(db): State>, Json(req): Json, ) -> Result { db.signal( &req.signal, EntityId::new(req.entity_id), req.weight, Timestamp::now(), )?; Ok(StatusCode::NO_CONTENT) } /// `GET /feed?user_id=123` -- retrieve a ranked feed. /// /// Executes a RETRIEVE query using the `trending` profile. The profile /// ranks items by share + view velocity with per-creator diversity. /// Personalization is applied when `for_user` is set. async fn feed_handler( State(db): State>, Query(params): Query, ) -> Result, AppError> { let query = tidaldb::query::retrieve::Retrieve::builder() .for_user(params.user_id) .profile("trending") .limit(20) .build() .map_err(TidalError::from)?; let results = db.retrieve(&query)?; let items = results .items .iter() .map(|r| FeedItem { entity_id: r.entity_id.as_u64(), score: r.score, rank: r.rank, }) .collect(); Ok(Json(FeedResponse { items, total_candidates: results.total_candidates, })) } /// `GET /health` -- health check endpoint. /// /// Returns 200 with item count if the database is operational, /// or 500 if it has been closed or is in a degraded state. async fn health_handler(State(db): State>) -> Result, AppError> { db.health_check()?; Ok(Json(HealthResponse { status: "ok", items: db.item_count(), })) } // ── Main ──────────────────────────────────────────────────────────────────── /// Resolves when Ctrl+C is received. async fn shutdown_signal() { tokio::signal::ctrl_c() .await .unwrap_or_else(|e| eprintln!("ctrl-c error: {e}")); } #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter("tidaldb=info") .init(); // ── Schema definition ─────────────────────────────────────────────── // Define the schema before opening the database. In production, this // would match your application's signal types and text fields. let mut schema = SchemaBuilder::new(); let _ = schema .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime]) .velocity(true) .add(); let _ = schema .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); let _ = schema .signal( "share", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(3 * 24 * 3600), }, ) .windows(&[Window::TwentyFourHours, Window::AllTime]) .velocity(true) .add(); let schema = schema.build()?; // ── Database initialization ───────────────────────────────────────── // Wrap in Arc: TidalDb is not Clone, but Axum's State requires Clone. // The Arc lets the router and main both hold references. let db = Arc::new(TidalDb::builder().ephemeral().with_schema(schema).open()?); // ── Router ────────────────────────────────────────────────────────── let app = Router::new() .route("/signal", post(signal_handler)) .route("/feed", get(feed_handler)) .route("/health", get(health_handler)) .with_state(Arc::clone(&db)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; let addr = listener.local_addr()?; println!("listening on http://{addr}"); println!(" POST /signal -> record engagement signal"); println!(" GET /feed?user_id=123 -> ranked feed (trending profile)"); println!(" GET /health -> health check + item count"); println!("press Ctrl+C to stop"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; // ── Graceful shutdown ─────────────────────────────────────────────── // After axum::serve returns (Ctrl+C received), we own the only remaining // Arc reference. try_unwrap extracts the TidalDb so we can call close() // for a clean shutdown (WAL drain, checkpoint, storage flush). // // If try_unwrap fails (another reference still exists), the Drop impl // will still perform best-effort cleanup. match Arc::try_unwrap(db) { Ok(db) => { println!("shutting down tidalDB..."); db.close()?; println!("tidalDB closed cleanly."); } Err(arc) => { // Drop triggers best-effort shutdown_inner(). drop(arc); println!("tidalDB dropped (best-effort shutdown)."); } } Ok(()) }