Skip to main content

tael_server/
config.rs

1use std::path::PathBuf;
2
3use crate::storage::StoreLocation;
4
5/// Selected storage backend. `TaelBackend` (the purpose-built tiered engine)
6/// is the default; pass `--storage duckdb` or set `TAEL_STORAGE=duckdb` to use
7/// the legacy embedded-DuckDB backend instead.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum StorageBackend {
10    Duckdb,
11    TaelBackend,
12}
13
14impl StorageBackend {
15    /// Parse a backend name (from the `--storage` flag or `TAEL_STORAGE`).
16    /// Anything that isn't explicitly `duckdb` selects the default tael-backend.
17    pub fn parse(s: &str) -> Self {
18        match s.trim().to_lowercase().as_str() {
19            "duckdb" | "duck" => StorageBackend::Duckdb,
20            _ => StorageBackend::TaelBackend,
21        }
22    }
23}
24
25pub struct ServerConfig {
26    pub otlp_grpc_addr: String,
27    pub rest_api_addr: String,
28    pub rest_api_socket: Option<String>,
29    pub data_dir: String,
30    pub wal_dir: String,
31    pub storage: StorageBackend,
32    /// When non-empty, this process runs as a stateless **query tier**: reads
33    /// are served by a `FanoutStore` that scatter-gathers across these shard
34    /// base URLs (`http://shard-0:7701,...`) instead of a local engine. Set via
35    /// `TAEL_QUERY_SHARDS`. See `docs/tael-server-scaling-ha.md` §3.
36    pub query_shards: Vec<String>,
37    /// Standby base URLs this node ships its WAL to as a **leader**
38    /// (`http://standby-1:7701,...`). Set via `TAEL_WAL_STANDBYS`. Only honored
39    /// by the tael-backend engine. See §5.1.
40    pub wal_standbys: Vec<String>,
41    /// How many standbys must ack a write before it returns. `None` = all
42    /// (fully synchronous); `Some(0)` = async best-effort. Set via
43    /// `TAEL_WAL_REQUIRED_ACKS`.
44    pub wal_required_acks: Option<usize>,
45    /// Cluster coordination (chitchat) for automatic leader election + epoch
46    /// fencing of WAL replication. `Some` when `TAEL_CLUSTER_LISTEN` is set.
47    /// See `docs/tael-server-scaling-ha.md` §5.1.
48    pub cluster: Option<ClusterSettings>,
49    /// Where the cold tier and blob store keep their objects. Defaults to local
50    /// filesystem (unchanged single-binary behavior); GCS is opt-in and needs
51    /// the `cloud` build feature.
52    pub object_store: ObjectStoreConfig,
53    /// Where trace comments are stored. Defaults to the local JSONL file;
54    /// Postgres (Cloud SQL) is opt-in and needs the `cloud` build feature.
55    pub comments: CommentsConfig,
56}
57
58/// Object-storage selection for the cold (Parquet) tier and the blob store.
59/// All fields default to the local-filesystem behavior, so a bare run is
60/// unchanged.
61#[derive(Debug, Clone)]
62pub struct ObjectStoreConfig {
63    /// Cold-tier backend (`TAEL_COLD_STORE`, `fs` | `gcs`).
64    pub cold: StoreLocation,
65    /// Blob-store backend (`TAEL_BLOB_STORE`, `fs` | `gcs`).
66    pub blobs: StoreLocation,
67    /// Cold bucket URL when `cold == Gcs` (`TAEL_COLD_BUCKET`, `gs://b/prefix`).
68    pub cold_bucket: Option<String>,
69    /// Blob bucket URL when `blobs == Gcs` (`TAEL_BLOB_BUCKET`, `gs://b/prefix`).
70    pub blob_bucket: Option<String>,
71    /// Whether this node owns blob GC over a shared object store
72    /// (`TAEL_BLOB_GC_ROLE=coordinator`). When the blob store is shared (GCS),
73    /// per-node mark-and-sweep would delete blobs other shards reference, so it
74    /// is disabled unless this node is the coordinator. Ignored for local FS
75    /// (each node owns its own blobs).
76    pub blob_gc_coordinator: bool,
77}
78
79impl ObjectStoreConfig {
80    fn from_env() -> Self {
81        Self {
82            cold: std::env::var("TAEL_COLD_STORE")
83                .map(|s| StoreLocation::parse(&s))
84                .unwrap_or_default(),
85            blobs: std::env::var("TAEL_BLOB_STORE")
86                .map(|s| StoreLocation::parse(&s))
87                .unwrap_or_default(),
88            cold_bucket: non_empty_env("TAEL_COLD_BUCKET"),
89            blob_bucket: non_empty_env("TAEL_BLOB_BUCKET"),
90            blob_gc_coordinator: std::env::var("TAEL_BLOB_GC_ROLE")
91                .map(|s| s.trim().eq_ignore_ascii_case("coordinator"))
92                .unwrap_or(false),
93        }
94    }
95
96    /// Whether the blob store is shared across nodes (object storage rather
97    /// than a node-local directory). Drives the blob-GC single-owner guard.
98    pub fn blobs_shared(&self) -> bool {
99        self.blobs == StoreLocation::Gcs
100    }
101}
102
103/// Where trace comments live.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum CommentsBackend {
106    /// Local append-only JSONL file (`<data_dir>/trace_comments.jsonl`).
107    #[default]
108    Jsonl,
109    /// Postgres (e.g. Cloud SQL) — requires the `cloud` feature.
110    Postgres,
111}
112
113impl CommentsBackend {
114    pub fn parse(s: &str) -> Self {
115        match s.trim().to_lowercase().as_str() {
116            "postgres" | "postgresql" | "pg" => CommentsBackend::Postgres,
117            _ => CommentsBackend::Jsonl,
118        }
119    }
120}
121
122/// Comments-store selection.
123#[derive(Debug, Clone)]
124pub struct CommentsConfig {
125    pub backend: CommentsBackend,
126    /// Postgres connection URL when `backend == Postgres`
127    /// (`TAEL_COMMENTS_DATABASE_URL`).
128    pub database_url: Option<String>,
129}
130
131impl CommentsConfig {
132    fn from_env() -> Self {
133        Self {
134            backend: std::env::var("TAEL_COMMENTS_STORE")
135                .map(|s| CommentsBackend::parse(&s))
136                .unwrap_or_default(),
137            database_url: non_empty_env("TAEL_COMMENTS_DATABASE_URL"),
138        }
139    }
140}
141
142/// Gossip-cluster settings for HA leader election (chitchat).
143#[derive(Debug, Clone)]
144pub struct ClusterSettings {
145    /// Stable unique node id within the replication group (election orders on it).
146    pub node_id: String,
147    /// UDP gossip listen address (`TAEL_CLUSTER_LISTEN`).
148    pub listen_addr: String,
149    /// Address peers reach this node on (`TAEL_CLUSTER_ADVERTISE`; default = listen).
150    pub advertise_addr: String,
151    /// Seed peers' gossip addresses (`TAEL_CLUSTER_SEEDS`).
152    pub seeds: Vec<String>,
153    /// Replication-group id peers must share (`TAEL_CLUSTER_ID`, default `tael`).
154    pub cluster_id: String,
155}
156
157impl ServerConfig {
158    pub fn from_env() -> Self {
159        let mut config = Self {
160            otlp_grpc_addr: std::env::var("TAEL_OTLP_GRPC_ADDR")
161                .unwrap_or_else(|_| "127.0.0.1:4317".into()),
162            rest_api_addr: std::env::var("TAEL_REST_API_ADDR")
163                .unwrap_or_else(|_| "127.0.0.1:7701".into()),
164            rest_api_socket: std::env::var("TAEL_REST_API_SOCKET")
165                .ok()
166                .filter(|s| !s.trim().is_empty()),
167            data_dir: std::env::var("TAEL_DATA_DIR").unwrap_or_else(|_| default_data_dir()),
168            wal_dir: std::env::var("TAEL_WAL_DIR")
169                .or_else(|_| std::env::var("WALRUS_DATA_DIR"))
170                .unwrap_or_else(|_| default_wal_dir()),
171            // Default to the tael-backend engine; `TAEL_STORAGE` can override.
172            storage: std::env::var("TAEL_STORAGE")
173                .map(|s| StorageBackend::parse(&s))
174                .unwrap_or(StorageBackend::TaelBackend),
175            query_shards: parse_csv_env("TAEL_QUERY_SHARDS"),
176            wal_standbys: parse_csv_env("TAEL_WAL_STANDBYS"),
177            wal_required_acks: std::env::var("TAEL_WAL_REQUIRED_ACKS")
178                .ok()
179                .and_then(|s| s.trim().parse().ok()),
180            cluster: cluster_from_env(),
181            object_store: ObjectStoreConfig::from_env(),
182            comments: CommentsConfig::from_env(),
183        };
184        // A `--storage <duckdb|tael-backend>` flag (or `--storage=…`) takes
185        // precedence over the env var.
186        if let Some(s) = storage_flag() {
187            config.storage = s;
188        }
189        config
190    }
191}
192
193fn default_data_dir() -> String {
194    default_tael_home().join("data").display().to_string()
195}
196
197fn default_wal_dir() -> String {
198    default_tael_home().join("wal_files").display().to_string()
199}
200
201fn default_tael_home() -> PathBuf {
202    home_dir()
203        .map(|home| home.join(".tael"))
204        .unwrap_or_else(|| PathBuf::from(".tael"))
205}
206
207fn home_dir() -> Option<PathBuf> {
208    std::env::var_os("HOME")
209        .map(PathBuf::from)
210        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
211        .or_else(
212            || match (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH")) {
213                (Some(drive), Some(path)) => {
214                    let mut home = PathBuf::from(drive);
215                    home.push(path);
216                    Some(home)
217                }
218                _ => None,
219            },
220        )
221}
222
223/// Build cluster settings from `TAEL_CLUSTER_*`. Returns `None` (coordination
224/// off) unless `TAEL_CLUSTER_LISTEN` is set.
225fn cluster_from_env() -> Option<ClusterSettings> {
226    let listen_addr = std::env::var("TAEL_CLUSTER_LISTEN").ok()?;
227    let advertise_addr =
228        std::env::var("TAEL_CLUSTER_ADVERTISE").unwrap_or_else(|_| listen_addr.clone());
229    // Default node id to the advertise address — unique per node, stable.
230    let node_id = std::env::var("TAEL_NODE_ID").unwrap_or_else(|_| advertise_addr.clone());
231    Some(ClusterSettings {
232        node_id,
233        listen_addr,
234        advertise_addr,
235        seeds: parse_csv_env("TAEL_CLUSTER_SEEDS"),
236        cluster_id: std::env::var("TAEL_CLUSTER_ID").unwrap_or_else(|_| "tael".to_string()),
237    })
238}
239
240/// Read an env var, returning `None` when unset or blank.
241fn non_empty_env(var: &str) -> Option<String> {
242    std::env::var(var).ok().filter(|s| !s.trim().is_empty())
243}
244
245/// Parse a comma-separated env var into a trimmed, non-empty list.
246fn parse_csv_env(var: &str) -> Vec<String> {
247    std::env::var(var)
248        .ok()
249        .map(|s| {
250            s.split(',')
251                .map(|p| p.trim().to_string())
252                .filter(|p| !p.is_empty())
253                .collect()
254        })
255        .unwrap_or_default()
256}
257
258/// Scan the process args for `--storage <value>` / `--storage=<value>`.
259fn storage_flag() -> Option<StorageBackend> {
260    let mut args = std::env::args().skip(1);
261    while let Some(arg) = args.next() {
262        if arg == "--storage" {
263            return args.next().map(|v| StorageBackend::parse(&v));
264        }
265        if let Some(v) = arg.strip_prefix("--storage=") {
266            return Some(StorageBackend::parse(v));
267        }
268    }
269    None
270}