1use crate::engine::{Engine, EngineError};
22use crate::server::{engine, Shared};
23use crate::store::{session_client, PgMode, PgStore, Store, CLUSTER_SCHEMA};
24use axum::body::Body;
25use axum::extract::{Request, State};
26use axum::http::{HeaderValue, Method, StatusCode};
27use axum::middleware::{self, Next};
28use axum::response::{IntoResponse, Response};
29use axum::{Json, Router};
30use serde_json::{json, Value};
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::{Arc, Mutex};
33use std::time::{Duration, Instant};
34
35pub const LEADER_LOCK_KEY: i64 = 0x6f72_6269_746c_6472;
37const SCHEMA_LOCK_KEY: i64 = 0x6f72_6269_7473_6368;
39
40pub type EngineFactory =
44 Box<dyn Fn(Store) -> std::result::Result<Engine, EngineError> + Send + Sync>;
45
46#[derive(Debug, Clone)]
47pub struct ClusterConfig {
48 pub url: String,
50 pub node_id: String,
52 pub advertise_addr: Option<String>,
54 pub check_interval: Duration,
56 pub lease: Duration,
60 pub session_timeout: Duration,
64}
65
66impl ClusterConfig {
67 pub fn new(url: &str, node_id: &str) -> ClusterConfig {
68 ClusterConfig {
69 url: url.to_string(),
70 node_id: node_id.to_string(),
71 advertise_addr: None,
72 check_interval: Duration::from_secs(1),
73 lease: Duration::from_secs(3),
74 session_timeout: Duration::from_secs(5),
75 }
76 }
77}
78
79pub fn default_node_id() -> String {
81 std::env::var("HOSTNAME")
82 .ok()
83 .or_else(|| std::fs::read_to_string("/etc/hostname").ok())
84 .map(|h| h.trim().to_string())
85 .filter(|h| !h.is_empty())
86 .unwrap_or_else(|| format!("node-{}", &uuid::Uuid::new_v4().to_string()[..8]))
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Role {
91 Standby,
92 Leader,
93}
94
95#[derive(Debug, Clone)]
96struct Status {
97 role: Role,
98 term: i64,
100 leader_node: Option<String>,
101 leader_addr: Option<String>,
102 last_ok: Option<Instant>,
104}
105
106pub struct Node {
108 config: ClusterConfig,
109 factory: EngineFactory,
110 status: Mutex<Status>,
111 stop: AtomicBool,
112}
113
114impl Node {
115 pub fn new(config: ClusterConfig, factory: EngineFactory) -> Arc<Node> {
116 Arc::new(Node {
117 config,
118 factory,
119 status: Mutex::new(Status {
120 role: Role::Standby,
121 term: 0,
122 leader_node: None,
123 leader_addr: None,
124 last_ok: None,
125 }),
126 stop: AtomicBool::new(false),
127 })
128 }
129
130 pub fn config(&self) -> &ClusterConfig {
131 &self.config
132 }
133
134 fn status(&self) -> std::sync::MutexGuard<'_, Status> {
135 self.status.lock().unwrap_or_else(|p| p.into_inner())
136 }
137
138 pub fn is_leader(&self) -> bool {
141 let s = self.status();
142 s.role == Role::Leader && s.last_ok.is_some_and(|t| t.elapsed() < self.config.lease)
143 }
144
145 pub fn role(&self) -> Role {
146 if self.is_leader() {
147 Role::Leader
148 } else {
149 Role::Standby
150 }
151 }
152
153 pub fn term(&self) -> i64 {
154 self.status().term
155 }
156
157 pub fn standby_engine(&self) -> std::result::Result<Engine, EngineError> {
159 let store = PgStore::connect_with(&self.config.url, PgMode::ReadOnly)
160 .map_err(|e| EngineError::new(500, "STORAGE_ERROR", e.to_string()))?;
161 (self.factory)(Store::Postgres(store))
162 }
163
164 pub fn stop(&self) {
167 self.stop.store(true, Ordering::SeqCst);
168 }
169
170 fn stopped(&self) -> bool {
171 self.stop.load(Ordering::SeqCst)
172 }
173
174 fn leader_json(&self) -> Value {
175 let s = self.status();
176 match &s.leader_node {
177 Some(n) => json!({ "node_id": n, "addr": s.leader_addr }),
178 None => Value::Null,
179 }
180 }
181
182 fn health_fields(&self) -> Value {
184 json!({
185 "role": if self.is_leader() { "leader" } else { "standby" },
186 "node_id": self.config.node_id,
187 "term": self.term(),
188 "leader": self.leader_json(),
189 })
190 }
191
192 fn not_leader(&self) -> Response {
193 let (node, addr) = {
194 let s = self.status();
195 (s.leader_node.clone(), s.leader_addr.clone())
196 };
197 let message = match (&node, &addr) {
198 (Some(n), Some(a)) if *n != self.config.node_id => {
199 format!("this server is a standby; the leader is `{n}` at {a}")
200 }
201 (Some(n), None) if *n != self.config.node_id => {
202 format!("this server is a standby; the leader is `{n}`")
203 }
204 _ => "this server is a standby and no leader is serving yet; retry shortly".into(),
205 };
206 let mut resp = (
207 StatusCode::SERVICE_UNAVAILABLE,
208 Json(json!({ "error": {
209 "code": "NOT_LEADER",
210 "message": message,
211 "leader_node": node,
212 "leader_addr": addr,
213 }})),
214 )
215 .into_response();
216 let h = resp.headers_mut();
217 h.insert("retry-after", HeaderValue::from_static("1"));
218 if let Some(v) = addr.and_then(|a| HeaderValue::from_str(&a).ok()) {
219 h.insert("x-orbit-leader", v);
220 }
221 resp
222 }
223}
224
225#[derive(Clone)]
228struct Gate {
229 node: Option<Arc<Node>>,
230 state: Shared,
231}
232
233pub fn layer(app: Router, node: Option<Arc<Node>>, state: Shared) -> Router {
238 app.layer(middleware::from_fn_with_state(Gate { node, state }, gate))
239}
240
241fn is_read(method: &Method, path: &str) -> bool {
243 (method == Method::GET || method == Method::HEAD) && path.starts_with("/v1/")
244}
245
246async fn gate(State(g): State<Gate>, req: Request, next: Next) -> Response {
247 let path = req.uri().path().to_string();
248 if path == "/v1/ready" {
249 let (ready, mut body) = match &g.node {
250 None => (true, json!({ "role": "single" })),
251 Some(n) => (n.is_leader(), n.health_fields()),
252 };
253 body["ready"] = json!(ready);
254 let status = if ready {
255 StatusCode::OK
256 } else {
257 StatusCode::SERVICE_UNAVAILABLE
258 };
259 return (status, Json(body)).into_response();
260 }
261 if path == "/v1/health" {
262 let resp = next.run(req).await;
263 let fields = match &g.node {
264 None => json!({ "role": "single", "node_id": null, "term": null, "leader": null }),
265 Some(n) => n.health_fields(),
266 };
267 return merge_json(resp, fields).await;
268 }
269 let Some(node) = &g.node else {
270 return next.run(req).await;
271 };
272 if node.is_leader() {
273 return next.run(req).await;
274 }
275 if is_read(req.method(), &path) {
276 if path.starts_with("/v1/workflows") || path.contains("/queries/") {
278 let st = g.state.clone();
279 let _ = tokio::task::spawn_blocking(move || engine(&st).refresh_definitions()).await;
280 }
281 return next.run(req).await;
282 }
283 node.not_leader()
284}
285
286pub(crate) async fn merge_json(resp: Response, fields: Value) -> Response {
288 if resp.status() != StatusCode::OK {
289 return resp;
290 }
291 let (mut parts, body) = resp.into_parts();
292 let Ok(bytes) = axum::body::to_bytes(body, 1 << 20).await else {
293 return Response::from_parts(parts, Body::empty());
294 };
295 let mut v: Value = match serde_json::from_slice(&bytes) {
296 Ok(v @ Value::Object(_)) => v,
297 _ => return Response::from_parts(parts, Body::from(bytes)),
298 };
299 if let (Some(obj), Value::Object(extra)) = (v.as_object_mut(), fields) {
300 obj.extend(extra);
301 }
302 let out = serde_json::to_vec(&v).unwrap_or_default();
303 parts.headers.remove("content-length");
304 Response::from_parts(parts, Body::from(out))
305}
306
307pub fn grpc_gate(
310 node: Arc<Node>,
311) -> impl FnMut(tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> + Clone {
312 move |req| {
313 if node.is_leader() {
314 return Ok(req);
315 }
316 let addr = node.status().leader_addr.clone();
317 let mut s = tonic::Status::unavailable(match &addr {
318 Some(a) => format!("NOT_LEADER: this server is a standby; the leader is at {a}"),
319 None => "NOT_LEADER: this server is a standby".to_string(),
320 });
321 s.metadata_mut()
322 .insert("orbit-error-code", "NOT_LEADER".parse().expect("ascii"));
323 if let Some(v) = addr.and_then(|a| a.parse().ok()) {
324 s.metadata_mut().insert("x-orbit-leader", v);
325 }
326 Err(s)
327 }
328}
329
330pub fn start(node: Arc<Node>, state: Shared) -> std::io::Result<std::thread::JoinHandle<()>> {
336 tracing::info!(
337 node_id = %node.config.node_id,
338 advertise_addr = node.config.advertise_addr.as_deref().unwrap_or(""),
339 "cluster mode: standby until elected"
340 );
341 std::thread::Builder::new()
342 .name("orbit-cluster".into())
343 .spawn(move || election_loop(&node, &state))
344}
345
346fn now_ms() -> i64 {
347 std::time::SystemTime::now()
348 .duration_since(std::time::UNIX_EPOCH)
349 .map(|d| d.as_millis() as i64)
350 .unwrap_or(0)
351}
352
353fn connect(config: &ClusterConfig) -> Result<postgres::Client, String> {
354 let mut c = session_client(&config.url, &format!("orbit-cluster:{}", config.node_id))
355 .map_err(|e| e.to_string())?;
356 let _ = c.batch_execute(&format!(
358 "SET idle_session_timeout = '{}ms'",
359 config.session_timeout.as_millis()
360 ));
361 c.batch_execute(&format!(
362 "SET statement_timeout = '{}ms'",
363 config.lease.as_millis()
364 ))
365 .map_err(|e| e.to_string())?;
366 let mut tx = c.transaction().map_err(|e| e.to_string())?;
367 tx.execute("SELECT pg_advisory_xact_lock($1)", &[&SCHEMA_LOCK_KEY])
368 .map_err(|e| e.to_string())?;
369 let exists: bool = tx
370 .query_one("SELECT to_regclass('orbit_cluster') IS NOT NULL", &[])
371 .map_err(|e| e.to_string())?
372 .get(0);
373 if !exists {
374 tx.batch_execute(CLUSTER_SCHEMA)
375 .map_err(|e| e.to_string())?;
376 }
377 tx.commit().map_err(|e| e.to_string())?;
378 Ok(c)
379}
380
381fn sleep_interval(node: &Node) {
382 std::thread::sleep(node.config.check_interval);
383}
384
385fn election_loop(node: &Node, state: &Shared) {
386 let mut reader_ok = true;
387 let mut followed: Option<(i64, Option<String>)> = None;
388 while !node.stopped() {
389 let mut client = match connect(&node.config) {
390 Ok(c) => c,
391 Err(e) => {
392 tracing::warn!(error = %e, "cluster: cannot reach PostgreSQL for leader election; retrying");
393 sleep_interval(node);
394 continue;
395 }
396 };
397 let elected = loop {
399 if node.stopped() {
400 return;
401 }
402 if !reader_ok {
403 reader_ok = install_standby_engine(node, state);
404 }
405 match client.query_one("SELECT pg_try_advisory_lock($1)", &[&LEADER_LOCK_KEY]) {
406 Ok(r) if r.get::<_, bool>(0) => break true,
407 Ok(_) => {}
408 Err(e) => {
409 tracing::warn!(error = %e, "cluster: election session failed; reconnecting");
410 break false;
411 }
412 }
413 match client.query_one(
414 "SELECT term, leader_node, leader_addr FROM orbit_cluster WHERE id = 1",
415 &[],
416 ) {
417 Ok(r) => {
418 let (term, leader, addr): (i64, Option<String>, Option<String>) =
419 (r.get(0), r.get(1), r.get(2));
420 if followed.as_ref() != Some(&(term, leader.clone())) {
421 tracing::info!(
422 term,
423 leader = leader.as_deref().unwrap_or("none"),
424 "cluster: standby; following the leader"
425 );
426 followed = Some((term, leader.clone()));
427 }
428 let mut s = node.status();
429 s.term = term;
430 s.leader_node = leader;
431 s.leader_addr = addr;
432 }
433 Err(e) => {
434 tracing::warn!(error = %e, "cluster: election session failed; reconnecting");
435 break false;
436 }
437 }
438 state.changed.notify_waiters();
440 sleep_interval(node);
441 };
442 if !elected {
443 sleep_interval(node);
444 continue;
445 }
446 let reason = lead(node, state, &mut client);
447 {
449 let mut s = node.status();
450 s.role = Role::Standby;
451 s.last_ok = None;
452 }
453 tracing::error!(node_id = %node.config.node_id, reason = %reason, "cluster: lost leadership; stepped down to standby");
454 drop(client); reader_ok = install_standby_engine(node, state);
456 followed = None;
457 if !node.stopped() {
458 sleep_interval(node);
459 }
460 }
461}
462
463fn install_standby_engine(node: &Node, state: &Shared) -> bool {
467 let (engine_new, ok) = match node.standby_engine() {
468 Ok(e) => (e, true),
469 Err(e) => {
470 tracing::warn!(error = %e, "cluster: cannot open the standby store; retrying");
471 match Store::in_memory()
472 .map_err(|e| e.to_string())
473 .and_then(|s| (node.factory)(s).map_err(|e| e.to_string()))
474 {
475 Ok(e) => (e, false),
476 Err(_) => return false,
477 }
478 }
479 };
480 swap_engine(state, engine_new);
481 ok
482}
483
484fn swap_engine(state: &Shared, mut new: Engine) {
485 new.changed = state.changed.clone();
486 let old = std::mem::replace(&mut *engine(state), new);
487 drop(old);
488 state.changed.notify_waiters();
489}
490
491fn lead(node: &Node, state: &Shared, client: &mut postgres::Client) -> String {
493 let addr = node.config.advertise_addr.clone();
494 let term: i64 = match client.query_one(
495 "UPDATE orbit_cluster SET term = term + 1, leader_node = $1, leader_addr = $2, since_ms = $3 WHERE id = 1 RETURNING term",
496 &[&node.config.node_id, &addr, &now_ms()],
497 ) {
498 Ok(r) => r.get(0),
499 Err(e) => return format!("cannot record the new term: {e}"),
500 };
501 {
502 let mut s = node.status();
503 s.term = term;
504 s.leader_node = Some(node.config.node_id.clone());
505 s.leader_addr = addr.clone();
506 }
507 tracing::info!(node_id = %node.config.node_id, term, "cluster: elected leader; recovering executions from history");
508 let started = Instant::now();
509 let _ = client.batch_execute("SET idle_session_timeout = 0");
512 let opened = PgStore::connect_with(&node.config.url, PgMode::Fenced(term))
513 .map_err(|e| e.to_string())
514 .and_then(|s| (node.factory)(Store::Postgres(s)).map_err(|e| e.to_string()));
515 let _ = client.batch_execute(&format!(
516 "SET idle_session_timeout = '{}ms'",
517 node.config.session_timeout.as_millis()
518 ));
519 let leader_engine = match opened {
520 Ok(e) => e,
521 Err(e) => return format!("cannot open the engine: {e}"),
522 };
523 let active = leader_engine.active_count();
524 swap_engine(state, leader_engine);
525 {
526 let mut s = node.status();
527 s.role = Role::Leader;
528 s.last_ok = Some(Instant::now());
529 }
530 tracing::info!(node_id = %node.config.node_id, term, active_executions = active, recovery_ms = started.elapsed().as_millis() as u64, "cluster: leader is serving");
531 loop {
532 sleep_interval(node);
533 if node.stopped() {
534 return "node is shutting down".into();
535 }
536 let check = client.query_one(
537 "SELECT (SELECT term FROM orbit_cluster WHERE id = 1),
538 EXISTS (SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid() AND granted)",
539 &[],
540 );
541 match check {
542 Ok(r) => {
543 let (current, holds): (Option<i64>, bool) = (r.get(0), r.get(1));
544 if !holds {
545 return "the election lock is no longer held".into();
546 }
547 if current != Some(term) {
548 return format!(
549 "the cluster moved to term {} (this node led term {term})",
550 current.unwrap_or(-1)
551 );
552 }
553 node.status().last_ok = Some(Instant::now());
556 }
557 Err(e) => return format!("the election session failed: {e}"),
558 }
559 }
560}