tidaldb/tidal-server/src/main.rs
jx12n ad4134e280 chore: doc consolidation, seven-dimension review fixes, and commit hooks
- Eliminate the tidal/ self-contained doc mirror; docs now have two canonical
  homes (root *.md and docs/), with planning/specs/research/reviews moved up
- Remove stale .agents/skills and .ai mirrors; canonicalize skills under .claude/
- Add pre-commit hook + scripts/check-docs.sh doc-guard + scripts/install-hooks.sh
- Implement M0-M10 seven-dimension review findings across engine, net, server,
  and tidalctl (durability, replication, query, WAL, storage, CLI hardening)
2026-06-08 22:46:28 -06:00

340 lines
12 KiB
Rust

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<PathBuf>,
#[arg(long)]
topology: Option<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
#[arg(long)]
data_dir: Option<PathBuf>,
#[arg(
long,
help = "Bind address for Prometheus /metrics endpoint (e.g. 127.0.0.1:9091)"
)]
metrics: Option<String>,
}
#[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<String, String> {
if s.contains(':') {
s.parse::<SocketAddr>()
.map(|_| s.to_string())
.map_err(|e| format!("invalid address '{s}': {e}"))
} else {
s.parse::<u16>()
.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<Arc<str>>) -> 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<ClusterState>` 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<ClusterState>) {
#[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<Arc<str>> {
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<Arc<str>>) -> 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<ServerState>` 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<TidalDb>`; 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<ServerState>) {
// 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");
}