Skip to main content

heliosdb_proxy/
lib.rs

1//! HeliosDB Proxy - Standalone Connection Router
2//!
3//! A standalone proxy for HeliosDB-Lite providing:
4//! - Connection pooling
5//! - Load balancing (read/write splitting)
6//! - Health monitoring
7//! - Transaction Replay (TR)
8//!
9//! # Deployment Options
10//!
11//! - **Standalone binary**: Run as a separate process
12//! - **Kubernetes sidecar**: Deploy alongside your application
13//! - **Embedded library**: Use as a library in your application
14//!
15//! # Quick Start
16//!
17//! ```bash
18//! # Start with config file
19//! heliosdb-proxy --config /etc/heliosdb/proxy.toml
20//!
21//! # Start with command line options
22//! heliosdb-proxy \
23//!   --listen 0.0.0.0:5432 \
24//!   --primary db-primary:5432 \
25//!   --standby db-standby-1:5432 \
26//!   --standby db-standby-2:5432
27//! ```
28//!
29//! # Configuration Example
30//!
31//! ```toml
32//! [proxy]
33//! listen_address = "0.0.0.0:5432"
34//! admin_address = "0.0.0.0:9090"
35//!
36//! [pool]
37//! min_connections = 5
38//! max_connections = 100
39//! idle_timeout_secs = 300
40//!
41//! [load_balancer]
42//! strategy = "round_robin"  # or "least_connections", "latency_based"
43//! read_write_split = true
44//!
45//! [health]
46//! check_interval_secs = 5
47//! failure_threshold = 3
48//!
49//! [[nodes]]
50//! host = "db-primary"
51//! port = 5432
52//! role = "primary"
53//!
54//! [[nodes]]
55//! host = "db-standby-1"
56//! port = 5432
57//! role = "standby"
58//! ```
59
60// ── Core modules (always available) ──────────────────────────────────
61pub mod admin;
62pub mod agent_contract;
63pub mod auth_scram;
64pub mod backend;
65pub mod batch;
66pub mod branch;
67pub mod client_tls;
68pub mod config;
69pub mod connection_pool;
70pub mod failover_controller;
71pub mod health_checker;
72pub mod http_gateway;
73pub(crate) mod http_util;
74pub mod load_balancer;
75pub mod mcp;
76pub mod mirror;
77pub mod pipeline;
78pub mod plugin_registry;
79pub mod primary_tracker;
80pub mod protocol;
81pub mod request;
82pub mod server;
83pub mod switchover_buffer;
84
85// ── Connection pooling modes (Session/Transaction/Statement) ─────────
86#[cfg(feature = "pool-modes")]
87pub mod pool;
88
89// ── TR (Transaction Replay) modules ─────────────────────────────────
90#[cfg(feature = "ha-tr")]
91pub mod cursor_restore;
92#[cfg(feature = "ha-tr")]
93pub mod failover_replay;
94#[cfg(feature = "ha-tr")]
95pub mod replay;
96#[cfg(feature = "ha-tr")]
97pub mod session_migrate;
98#[cfg(feature = "ha-tr")]
99pub mod transaction_journal;
100
101// ── Zero-downtime PG major-version upgrade orchestrator (T2.1) ─────
102#[cfg(feature = "ha-tr")]
103pub mod upgrade_orchestrator;
104
105// ── R&D: shadow execution (T3.4) ────────────────────────────────────
106#[cfg(feature = "ha-tr")]
107pub mod shadow_execute;
108
109// ── Query caching (L1/L2/L3 multi-tier cache) ──────────────────────
110#[cfg(feature = "query-cache")]
111pub mod cache;
112
113// ── Query routing hints ─────────────────────────────────────────────
114#[cfg(feature = "routing-hints")]
115pub mod routing;
116
117// ── Replica lag-aware routing ───────────────────────────────────────
118#[cfg(feature = "lag-routing")]
119pub mod lag;
120
121// ── Rate limiting and query throttling ──────────────────────────────
122#[cfg(feature = "rate-limiting")]
123pub mod rate_limit;
124
125// ── Circuit breaker pattern ─────────────────────────────────────────
126#[cfg(feature = "circuit-breaker")]
127pub mod circuit_breaker;
128
129// ── Query analytics and slow query log ──────────────────────────────
130#[cfg(feature = "query-analytics")]
131pub mod analytics;
132
133// ── Anomaly detection (T3.1) — rate spikes, credential stuffing,
134// SQL injection heuristics, novel query shapes ─────────────────────
135#[cfg(feature = "anomaly-detection")]
136pub mod anomaly;
137
138// ── Edge / geo proxy mode (T3.2) ───────────────────────────────────
139//
140// Always-on so `ProxyConfig` can carry an `[edge]` section on every
141// build (same pattern as mcp/http_gateway/mirror). The cache/registry
142// machinery inside is still gated behind the `edge-proxy` feature.
143pub mod edge;
144
145// ── Multi-tenancy support ───────────────────────────────────────────
146#[cfg(feature = "multi-tenancy")]
147pub mod multi_tenancy;
148
149// ── Authentication proxy ────────────────────────────────────────────
150#[cfg(feature = "auth-proxy")]
151pub mod auth;
152
153// ── Query rewriting ─────────────────────────────────────────────────
154#[cfg(feature = "query-rewriting")]
155pub mod rewriter;
156
157// ── WASM plugin system ──────────────────────────────────────────────
158#[cfg(feature = "wasm-plugins")]
159pub mod plugins;
160
161// ── GraphQL-to-SQL gateway ──────────────────────────────────────────
162#[cfg(feature = "graphql-gateway")]
163pub mod graphql;
164#[cfg(feature = "graphql-gateway")]
165pub mod graphql_gateway;
166
167// ── Schema-aware routing ────────────────────────────────────────────
168#[cfg(feature = "schema-routing")]
169pub mod schema_routing;
170
171// ── Distributed intelligent caching (DistribCache) ──────────────────
172#[cfg(feature = "distribcache")]
173pub mod distribcache;
174
175// ── Embedded skill-bundle deployer ──────────────────────────────────
176//
177// Always-on: the `heliosdb-proxy install skills` subcommand calls
178// into this. Adds ~80 KiB to the binary (the `.claude/skills/`
179// bundle, embedded by `include_dir!`).
180pub mod skills;
181
182use thiserror::Error;
183use uuid::Uuid;
184
185/// Proxy error types
186#[derive(Debug, Error)]
187pub enum ProxyError {
188    #[error("Configuration error: {0}")]
189    Config(String),
190
191    #[error("Network error: {0}")]
192    Network(String),
193
194    #[error("Connection error: {0}")]
195    Connection(String),
196
197    #[error("Protocol error: {0}")]
198    Protocol(String),
199
200    #[error("Pool error: {0}")]
201    Pool(String),
202
203    #[error("Health check error: {0}")]
204    HealthCheck(String),
205
206    #[error("Failover error: {0}")]
207    Failover(String),
208
209    #[error("Failover failed: {0}")]
210    FailoverFailed(String),
211
212    #[error("Transaction replay failed: {0}")]
213    ReplayFailed(String),
214
215    #[error("Session migration failed: {0}")]
216    SessionMigration(String),
217
218    #[error("Cursor restore failed: {0}")]
219    CursorRestore(String),
220
221    #[error("Routing error: {0}")]
222    Routing(String),
223
224    #[error("Authentication error: {0}")]
225    Auth(String),
226
227    #[error("Pool exhausted: {0}")]
228    PoolExhausted(String),
229
230    #[error("Timeout: {0}")]
231    Timeout(String),
232
233    #[error("Configuration error: {0}")]
234    Configuration(String),
235
236    #[error("No healthy nodes available")]
237    NoHealthyNodes,
238
239    #[error("IO error: {0}")]
240    Io(#[from] std::io::Error),
241
242    #[error("JSON error: {0}")]
243    Json(#[from] serde_json::Error),
244
245    #[error("Internal error: {0}")]
246    Internal(String),
247}
248
249pub type Result<T> = std::result::Result<T, ProxyError>;
250
251/// Proxy version
252pub const VERSION: &str = env!("CARGO_PKG_VERSION");
253
254/// Default listen port
255pub const DEFAULT_PORT: u16 = 5432;
256
257/// Default admin port
258pub const DEFAULT_ADMIN_PORT: u16 = 9090;
259
260/// Node identifier
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
262pub struct NodeId(pub Uuid);
263
264impl NodeId {
265    pub fn new() -> Self {
266        Self(Uuid::new_v4())
267    }
268}
269
270impl Default for NodeId {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276/// Node role in the cluster
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum NodeRole {
279    /// Primary node (accepts writes)
280    Primary,
281    /// Standby node (read-only, can be promoted)
282    Standby,
283    /// Read replica (read-only, cannot be promoted)
284    ReadReplica,
285    /// Unknown role (during discovery)
286    Unknown,
287}
288
289/// Node endpoint information
290#[derive(Debug, Clone)]
291pub struct NodeEndpoint {
292    /// Node identifier
293    pub id: NodeId,
294    /// Host address
295    pub host: String,
296    /// Port
297    pub port: u16,
298    /// Node role
299    pub role: NodeRole,
300    /// Weight for load balancing (higher = more traffic)
301    pub weight: u32,
302    /// Whether this node is enabled
303    pub enabled: bool,
304}
305
306impl NodeEndpoint {
307    pub fn new(host: impl Into<String>, port: u16) -> Self {
308        Self {
309            id: NodeId::new(),
310            host: host.into(),
311            port,
312            role: NodeRole::Unknown,
313            weight: 100,
314            enabled: true,
315        }
316    }
317
318    pub fn with_role(mut self, role: NodeRole) -> Self {
319        self.role = role;
320        self
321    }
322
323    pub fn with_weight(mut self, weight: u32) -> Self {
324        self.weight = weight;
325        self
326    }
327
328    pub fn address(&self) -> String {
329        format!("{}:{}", self.host, self.port)
330    }
331}
332
333// ── PostgreSQL compatibility integration test ───────────────────────
334//
335// Verifies that all feature modules can be instantiated and used in a
336// PostgreSQL-only context (no HeliosDB dependencies required).
337
338#[cfg(test)]
339mod postgresql_compat_tests {
340    use super::*;
341
342    /// All core types work for PostgreSQL endpoints.
343    #[test]
344    fn test_pg_node_endpoints() {
345        let primary =
346            NodeEndpoint::new("pg-primary.example.com", 5432).with_role(NodeRole::Primary);
347        let standby =
348            NodeEndpoint::new("pg-standby-1.example.com", 5432).with_role(NodeRole::Standby);
349        let replica = NodeEndpoint::new("pg-replica-1.example.com", 5433)
350            .with_role(NodeRole::ReadReplica)
351            .with_weight(50);
352
353        assert_eq!(primary.role, NodeRole::Primary);
354        assert_eq!(standby.role, NodeRole::Standby);
355        assert_eq!(replica.weight, 50);
356        assert_eq!(replica.address(), "pg-replica-1.example.com:5433");
357    }
358
359    /// Load balancer config works for PostgreSQL cluster with read/write splitting.
360    #[test]
361    fn test_pg_load_balancer_config() {
362        use load_balancer::*;
363
364        let config = LoadBalancerConfig {
365            read_write_split: true,
366            read_strategy: RoutingStrategy::RoundRobin,
367            write_strategy: RoutingStrategy::PrimaryOnly,
368            ..Default::default()
369        };
370
371        assert!(config.read_write_split);
372        assert_eq!(config.read_strategy, RoutingStrategy::RoundRobin);
373        assert_eq!(config.write_strategy, RoutingStrategy::PrimaryOnly);
374
375        // Verify the LB can be constructed
376        let _lb = LoadBalancer::new(config);
377    }
378
379    /// Health checker works with standard PostgreSQL `SELECT 1` checks.
380    #[test]
381    fn test_pg_health_config() {
382        use health_checker::*;
383
384        let config = HealthConfig {
385            check_query: "SELECT 1".to_string(),
386            detailed_checks: true,
387            ..Default::default()
388        };
389
390        assert_eq!(config.check_query, "SELECT 1");
391        assert!(config.detailed_checks);
392    }
393
394    /// Failover controller works for PostgreSQL streaming replication.
395    #[tokio::test]
396    async fn test_pg_failover() {
397        use failover_controller::*;
398
399        let controller = FailoverController::new(FailoverConfig {
400            auto_failover: true,
401            prefer_sync_standby: true,
402            ..Default::default()
403        });
404
405        let primary = NodeId::new();
406        controller.set_primary(primary).await;
407        assert_eq!(controller.get_primary().await, Some(primary));
408
409        // Register candidates
410        let sync_standby = NodeId::new();
411        controller
412            .register_candidate(FailoverCandidate {
413                node_id: sync_standby,
414                endpoint: NodeEndpoint::new("pg-sync", 5432).with_role(NodeRole::Standby),
415                is_sync: true,
416                lag_bytes: 0,
417                priority: 1,
418                last_heartbeat: None,
419            })
420            .await;
421
422        let async_standby = NodeId::new();
423        controller
424            .register_candidate(FailoverCandidate {
425                node_id: async_standby,
426                endpoint: NodeEndpoint::new("pg-async", 5432).with_role(NodeRole::Standby),
427                is_sync: false,
428                lag_bytes: 1024,
429                priority: 2,
430                last_heartbeat: None,
431            })
432            .await;
433
434        // Verify state
435        assert_eq!(controller.state().await, FailoverState::Normal);
436        assert_eq!(controller.failover_count(), 0);
437    }
438
439    /// Connection pool config works for PostgreSQL connections.
440    #[test]
441    fn test_pg_connection_pool() {
442        use connection_pool::*;
443
444        let config = PoolConfig {
445            min_connections: 2,
446            max_connections: 20,
447            test_on_acquire: true,
448            ..Default::default()
449        };
450
451        assert_eq!(config.min_connections, 2);
452        assert_eq!(config.max_connections, 20);
453        assert!(config.test_on_acquire);
454
455        let _pool = ConnectionPool::new(config);
456    }
457
458    /// Switchover buffer works for PostgreSQL planned switchover.
459    #[tokio::test]
460    async fn test_pg_switchover_buffer() {
461        use switchover_buffer::*;
462
463        let buffer = SwitchoverBuffer::new(BufferConfig {
464            buffer_timeout: std::time::Duration::from_secs(5),
465            max_buffered_queries: 1000,
466            ..Default::default()
467        });
468
469        assert_eq!(buffer.state(), BufferState::Passthrough);
470        assert!(!buffer.is_buffering());
471
472        // Simulate pg_ctl promote workflow
473        buffer.start_buffering();
474        assert!(buffer.is_buffering());
475
476        let rx = buffer
477            .buffer_query("INSERT INTO orders VALUES (1)".to_string(), vec![], 1)
478            .unwrap();
479
480        // Simulate promotion complete
481        buffer.stop_buffering();
482        buffer.drain(|_sql, _params| async { Ok(()) }).await;
483
484        let result = rx.await.unwrap();
485        assert!(matches!(result, BufferResult::Success));
486    }
487
488    /// Primary tracker works with the standalone mode for PostgreSQL.
489    #[test]
490    fn test_pg_primary_tracker_standalone() {
491        use primary_tracker::*;
492
493        let tracker = PrimaryTracker::new_standalone();
494
495        // Simulate discovering primary via pg_is_in_recovery()
496        let pg_primary = uuid::Uuid::new_v4();
497        tracker.set_primary(pg_primary, "pg-primary.local:5432".to_string());
498        tracker.confirm_primary();
499
500        assert!(tracker.has_primary());
501        assert!(tracker.get_primary().unwrap().is_confirmed);
502
503        // Simulate failover detected
504        tracker.clear_primary();
505        assert!(!tracker.has_primary());
506
507        // New primary
508        let pg_new_primary = uuid::Uuid::new_v4();
509        tracker.set_primary(pg_new_primary, "pg-standby.local:5432".to_string());
510        tracker.confirm_primary();
511        assert_eq!(
512            tracker.get_primary_address(),
513            Some("pg-standby.local:5432".to_string())
514        );
515    }
516
517    /// Transaction journal works for PostgreSQL transaction replay.
518    #[cfg(feature = "ha-tr")]
519    #[tokio::test]
520    async fn test_pg_transaction_replay() {
521        use transaction_journal::*;
522
523        let journal = TransactionJournal::new();
524        let tx_id = uuid::Uuid::new_v4();
525        let session_id = uuid::Uuid::new_v4();
526        let node = NodeId::new();
527
528        // Journal a PostgreSQL transaction
529        journal
530            .begin_transaction(tx_id, session_id, node, 0)
531            .await
532            .unwrap();
533        journal
534            .log_statement(tx_id, "BEGIN".to_string(), vec![], None, None, 1)
535            .await
536            .unwrap();
537        journal
538            .log_statement(
539                tx_id,
540                "INSERT INTO accounts (id, balance) VALUES ($1, $2)".to_string(),
541                vec![JournalValue::Int64(1), JournalValue::Float64(100.0)],
542                Some(12345),
543                Some(1),
544                5,
545            )
546            .await
547            .unwrap();
548        journal
549            .log_statement(
550                tx_id,
551                "UPDATE accounts SET balance = balance - $1 WHERE id = $2".to_string(),
552                vec![JournalValue::Float64(25.0), JournalValue::Int64(1)],
553                Some(67890),
554                Some(1),
555                3,
556            )
557            .await
558            .unwrap();
559
560        let j = journal.get_journal(&tx_id).await.unwrap();
561        assert_eq!(j.entries.len(), 3);
562        assert!(j.has_mutations);
563
564        // Verify statement types
565        assert_eq!(j.entries[0].statement_type, StatementType::Transaction);
566        assert_eq!(j.entries[1].statement_type, StatementType::Insert);
567        assert_eq!(j.entries[2].statement_type, StatementType::Update);
568
569        // Commit clears journal
570        journal.commit_transaction(tx_id).await.unwrap();
571        assert!(journal.get_journal(&tx_id).await.is_none());
572    }
573
574    /// Session migration works for PostgreSQL session parameters.
575    #[cfg(feature = "ha-tr")]
576    #[tokio::test]
577    async fn test_pg_session_migration() {
578        use session_migrate::*;
579
580        let migrate = SessionMigrate::new();
581        let session_id = uuid::Uuid::new_v4();
582        let node = NodeId::new();
583
584        let mut state =
585            SessionState::new(session_id, "postgres".to_string(), "mydb".to_string(), node);
586
587        // Set PostgreSQL-specific session parameters
588        state.set_parameter("timezone".to_string(), "America/New_York".to_string());
589        state.set_parameter("search_path".to_string(), "public, app_schema".to_string());
590        state.set_parameter("statement_timeout".to_string(), "30000".to_string());
591        state.set_parameter("work_mem".to_string(), "256MB".to_string());
592
593        // Add a prepared statement
594        state.add_prepared_statement(PreparedStatementInfo {
595            name: "get_user".to_string(),
596            query: "SELECT * FROM users WHERE id = $1".to_string(),
597            param_types: vec!["integer".to_string()],
598            created_at: chrono::Utc::now(),
599        });
600
601        migrate.register_session(state).await.unwrap();
602
603        // Generate SET statements for replay on new primary
604        let session = migrate.get_session(&session_id).await.unwrap();
605        let restore_stmts = session.generate_restore_statements();
606
607        assert!(restore_stmts.iter().any(|s| s.contains("America/New_York")));
608        assert!(restore_stmts.iter().any(|s| s.contains("search_path")));
609        assert!(restore_stmts
610            .iter()
611            .any(|s| s.contains("statement_timeout")));
612        assert!(restore_stmts.iter().any(|s| s.contains("PREPARE get_user")));
613    }
614
615    /// Pipeline supports PostgreSQL extended query protocol pipelining.
616    #[tokio::test]
617    async fn test_pg_pipelining() {
618        use pipeline::*;
619
620        let pipeline = RequestPipeline::new(PipelineConfig {
621            max_depth: 16,
622            enabled: true,
623            ..Default::default()
624        });
625
626        let conn_id = 1;
627
628        // Simulate pipelined Parse/Bind/Execute sequence
629        let t1 = pipeline
630            .submit(conn_id, b"Parse: SELECT $1::int".to_vec())
631            .unwrap();
632        let _t2 = pipeline.submit(conn_id, b"Bind: [42]".to_vec()).unwrap();
633        let _t3 = pipeline.submit(conn_id, b"Execute".to_vec()).unwrap();
634
635        assert_eq!(pipeline.depth(conn_id), 3);
636
637        // Complete in order (FIFO — matches PG protocol)
638        pipeline.complete_next(conn_id, b"ParseComplete".to_vec(), true, None);
639        pipeline.complete_next(conn_id, b"BindComplete".to_vec(), true, None);
640        pipeline.complete_next(conn_id, b"DataRow: 42".to_vec(), true, None);
641
642        assert_eq!(pipeline.depth(conn_id), 0);
643
644        let r1 = t1.wait().await.unwrap();
645        assert!(r1.success);
646    }
647
648    /// Batch INSERT works for PostgreSQL bulk inserts.
649    #[tokio::test]
650    async fn test_pg_batch_insert() {
651        use batch::*;
652
653        let config = BatchConfig {
654            max_batch_size: 3,
655            ..Default::default()
656        };
657        let batcher = std::sync::Arc::new(InsertBatcher::new(config));
658
659        batcher
660            .add(
661                "orders".to_string(),
662                vec!["id".to_string(), "total".to_string()],
663                vec![vec!["1".to_string(), "99.99".to_string()]],
664                "INSERT INTO orders (id, total) VALUES (1, 99.99)".to_string(),
665            )
666            .unwrap();
667
668        assert_eq!(batcher.batch_size("orders"), 1);
669
670        let stats = batcher.stats();
671        assert_eq!(stats.inserts_received, 1);
672        assert_eq!(stats.rows_received, 1);
673    }
674}