use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use clap::{Args, Parser, Subcommand}; use tidal_server::{ cluster::{ClusterState, build_cluster_router, ensure_experimental_enabled, load_topology}, config::{CONFIG_DIR_SCHEMA_FILE, CONFIG_DIR_TOPOLOGY_FILE, load_schema, resolve_config_path}, error::{Result, ServerError}, router::build_router, state::ServerState, }; use tidaldb::TidalDb; #[derive(Parser)] #[command(version, about = "HTTP wrapper for tidalDB")] struct Cli { #[command(subcommand)] mode: Command, } #[derive(Subcommand)] enum Command { #[command(about = "Run a single-node server wrapping one tidalDB instance")] Standalone(StandaloneArgs), #[command(about = "Run a multi-region cluster behind a single HTTP surface")] Cluster(ClusterArgs), } #[derive(Args)] struct ClusterArgs { #[arg(long, default_value = "127.0.0.1:9500", env = "PORT", value_parser = parse_listen_addr)] listen: String, #[arg(long)] schema: Option, #[arg(long)] topology: Option, /// Directory holding `default-schema.yaml` / `default-cluster.yaml`, used /// when `--schema` / `--topology` are not given. Wired from the container's /// `TIDAL_CONFIG` env var. #[arg(long, env = "TIDAL_CONFIG")] config_dir: Option, /// Opt in to the EXPERIMENTAL single-process cluster. /// /// Cluster mode replicates between regions over the real tidal-net gRPC /// transport (loopback), but every region runs inside THIS one process — /// there is no host/process isolation, so it is not production HA. It /// refuses to start without this flag (or the /// `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1` env var). #[arg(long)] experimental_cluster: bool, } #[derive(Args)] struct StandaloneArgs { #[arg(long, default_value = "127.0.0.1:9400", env = "PORT", value_parser = parse_listen_addr)] listen: String, #[arg(long)] schema: Option, /// Directory holding `default-schema.yaml`, used when `--schema` is not /// given. Wired from the container's `TIDAL_CONFIG` env var. #[arg(long, env = "TIDAL_CONFIG")] config_dir: Option, #[arg(long)] data_dir: Option, #[arg( long, help = "Bind address for Prometheus /metrics endpoint (e.g. 127.0.0.1:9091)" )] metrics: Option, } #[tokio::main] async fn main() { if let Err(err) = run().await { eprintln!("error: {err}"); std::process::exit(1); } } async fn run() -> Result<()> { let cli = Cli::parse(); init_tracing(); match cli.mode { Command::Standalone(args) => run_standalone(args).await, Command::Cluster(args) => run_cluster(args).await, } } /// Parse a listen address from either a full `host:port` string or a bare port number. /// When `PORT=8080` is set, clap passes `"8080"` — this normalises it to `0.0.0.0:8080`. fn parse_listen_addr(s: &str) -> std::result::Result { if s.contains(':') { s.parse::() .map(|_| s.to_string()) .map_err(|e| format!("invalid address '{s}': {e}")) } else { s.parse::() .map(|port| format!("0.0.0.0:{port}")) .map_err(|_| format!("expected host:port or port number, got '{s}'")) } } fn init_tracing() { let env_filter = std::env::var("TIDAL_SERVER_LOG").unwrap_or_else(|_| "info".into()); let _ = tracing_subscriber::fmt() .with_env_filter(env_filter) .try_init(); } async fn run_standalone(args: StandaloneArgs) -> Result<()> { let schema_path = resolve_config_path( args.schema.as_deref(), args.config_dir.as_deref(), CONFIG_DIR_SCHEMA_FILE, )?; let (schema, profiles) = load_schema(schema_path.as_deref())?; let mut builder = TidalDb::builder() .with_schema(schema.clone()) .with_profiles(profiles); if let Some(dir) = args.data_dir { builder = builder.with_data_dir(dir); } else { builder = builder.ephemeral(); } if let Some(ref addr) = args.metrics { builder = builder.enable_metrics(addr); } let db = builder.open()?; if let Some(addr) = db.metrics_addr() { tracing::info!("metrics endpoint listening on http://{addr}/metrics"); } let state = ServerState::new(db); let api_key = read_api_key(); serve(state, &args.listen, api_key).await } async fn run_cluster(args: ClusterArgs) -> Result<()> { // Honest gate: cluster mode replicates between regions over the real // tidal-net gRPC transport, but every region runs inside this single // process (no host/process isolation), so it is not production HA. Refuse // to start (and emit a loud WARN when permitted) so no operator mistakes it // for production HA. ensure_experimental_enabled(args.experimental_cluster)?; let schema_path = resolve_config_path( args.schema.as_deref(), args.config_dir.as_deref(), CONFIG_DIR_SCHEMA_FILE, )?; let topology_path = resolve_config_path( args.topology.as_deref(), args.config_dir.as_deref(), CONFIG_DIR_TOPOLOGY_FILE, )?; let (schema, profiles) = load_schema(schema_path.as_deref())?; let topology = load_topology(topology_path.as_deref())?; tracing::info!( regions = topology.regions.len(), leader = %topology.leader, "building cluster" ); // `ClusterState::new` starts the follower gRPC servers via // `GrpcTransport::new`, which blocks on its own tokio runtime and must not // run inside this reactor. Build it on a dedicated thread; the constructed // state (and its embedded runtimes) then lives for the server's lifetime. let state = std::thread::Builder::new() .name("cluster-build".into()) .spawn(move || ClusterState::new(&topology, schema, profiles)) .map_err(|e| ServerError::Cluster(format!("spawn cluster builder thread: {e}")))? .join() .map_err(|_| ServerError::Cluster("cluster builder thread panicked".into()))??; let api_key = read_api_key(); serve_cluster(state, &args.listen, api_key).await } async fn serve_cluster(state: ClusterState, addr: &str, api_key: Option>) -> Result<()> { let socket: SocketAddr = addr .parse() .map_err(|e| ServerError::BadRequest(format!("invalid addr: {e}")))?; let listener = tokio::net::TcpListener::bind(socket).await?; let actual = listener.local_addr()?; tracing::info!("listening on http://{actual}"); let state = Arc::new(state); let shutdown_state = state.clone(); axum::serve(listener, build_cluster_router(state, api_key)) .with_graceful_shutdown(cluster_shutdown_signal(shutdown_state.clone())) .await?; // axum::serve has returned, so the router (and every `Arc` it // held) is dropped. We should now be the sole owner; reclaim ownership and // shut every node down deterministically (checkpoint + WAL fsync + thread // join) before the process exits. If an Arc unexpectedly lingers, the // `Drop` backstop on `ClusterState` still runs the same shutdown. match Arc::try_unwrap(shutdown_state) { Ok(mut owned) => owned.shutdown(), Err(arc) => { tracing::warn!( strong = Arc::strong_count(&arc), "cluster state still shared after serve returned; relying on Drop for shutdown" ); } } Ok(()) } async fn cluster_shutdown_signal(state: Arc) { #[cfg(unix)] let sigterm = async { use tokio::signal::unix::{SignalKind, signal}; match signal(SignalKind::terminate()) { Ok(mut stream) => { stream.recv().await; } Err(err) => { tracing::warn!("SIGTERM handler registration failed: {err}"); std::future::pending::<()>().await; } } }; #[cfg(not(unix))] let sigterm = std::future::pending::<()>(); tokio::select! { result = tokio::signal::ctrl_c() => { if let Err(err) = result { tracing::warn!("ctrl-c handler error: {err}"); } } () = sigterm => {} } state.set_shutting_down(); tracing::info!("shutdown signal received, draining in-flight requests"); } /// Read the API key from the environment. /// /// If `TIDAL_API_KEY` is not set, all requests are accepted without /// authentication. This is appropriate for local development but should /// never be used in production. A startup warning is emitted. fn read_api_key() -> Option> { match std::env::var("TIDAL_API_KEY") { Ok(key) if !key.is_empty() => Some(Arc::from(key.as_str())), _ => { tracing::warn!( "TIDAL_API_KEY is not set — all endpoints are unauthenticated. \ Set this variable before exposing the server to any network." ); None } } } async fn serve(state: ServerState, addr: &str, api_key: Option>) -> Result<()> { let socket: SocketAddr = addr .parse() .map_err(|e| ServerError::BadRequest(format!("invalid addr: {e}")))?; let listener = tokio::net::TcpListener::bind(socket).await?; let actual = listener.local_addr()?; tracing::info!("listening on http://{actual}"); let state = Arc::new(state); let shutdown_state = state.clone(); axum::serve(listener, build_router(state, api_key)) .with_graceful_shutdown(shutdown_signal(shutdown_state.clone())) .await?; // axum::serve has returned, so the router (and every `Arc` it // held) is dropped. Deterministically reclaim sole ownership and run the // final durable shutdown HERE, exactly like `serve_cluster` — instead of // letting an implicit drop fire the `TidalDb::Drop` backstop at an // unspecified point. If an `Arc` unexpectedly lingers we cannot observe the // final flush from this stack frame, so we log that we are falling back to // `Drop` (which still runs the same shutdown and logs any flush failure at // error level) rather than silently exiting 0 on a possibly-failed flush. match Arc::try_unwrap(shutdown_state) { Ok(owned) => { // Dropping the sole-owner `ServerState` here drops its // `Arc`; that runs `TidalDb::Drop`, which attempts every // final durable flush and logs the first failure at error level // (SHUTDOWN-2). The drop is deterministic now, not deferred. drop(owned); tracing::info!("standalone shutdown: database closed (checkpoint + WAL fsync)"); } Err(arc) => { tracing::warn!( strong = Arc::strong_count(&arc), "server state still shared after serve returned; relying on Drop for final flush" ); } } Ok(()) } async fn shutdown_signal(state: Arc) { // SIGTERM is Unix-only; on other platforms we fall back to ctrl-c alone. #[cfg(unix)] let sigterm = async { use tokio::signal::unix::{SignalKind, signal}; match signal(SignalKind::terminate()) { Ok(mut stream) => { stream.recv().await; } Err(err) => { tracing::warn!("SIGTERM handler registration failed: {err}"); // If we cannot register SIGTERM, park this branch so the // select falls back to ctrl_c exclusively. std::future::pending::<()>().await; } } }; #[cfg(not(unix))] let sigterm = std::future::pending::<()>(); tokio::select! { result = tokio::signal::ctrl_c() => { if let Err(err) = result { tracing::warn!("ctrl-c handler error: {err}"); } } () = sigterm => {} } // Flip readiness BEFORE axum starts draining state.set_shutting_down(); tracing::info!("readiness flipped to not-ready, draining in-flight requests"); }