Skip to main content

arete_server/
runtime.rs

1use crate::bus::BusManager;
2use crate::cache::EntityCache;
3use crate::config::ServerConfig;
4use crate::config::TransactionConfig;
5use crate::health::HealthMonitor;
6use crate::http_server::HttpServer;
7use crate::materialized_view::MaterializedViewRegistry;
8use crate::mutation_batch::MutationBatch;
9use crate::program_runtime::ProgramRuntimeCatalog;
10use crate::projector::Projector;
11use crate::view::ViewIndex;
12use crate::websocket::client_manager::RateLimitConfig;
13use crate::websocket::WebSocketServer;
14use crate::Spec;
15use crate::WebSocketAuthPlugin;
16use crate::WebSocketUsageEmitter;
17use anyhow::Result;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::mpsc;
21use tracing::{error, info, info_span, Instrument};
22
23#[cfg(feature = "otel")]
24use crate::metrics::Metrics;
25
26/// Wait for shutdown signal (SIGINT on all platforms, SIGTERM on Unix)
27async fn shutdown_signal() {
28    let ctrl_c = async {
29        tokio::signal::ctrl_c()
30            .await
31            .expect("Failed to install Ctrl+C handler");
32    };
33
34    #[cfg(unix)]
35    let terminate = async {
36        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
37            .expect("Failed to install SIGTERM handler")
38            .recv()
39            .await;
40    };
41
42    #[cfg(not(unix))]
43    let terminate = std::future::pending::<()>();
44
45    tokio::select! {
46        _ = ctrl_c => {
47            info!("Received SIGINT (Ctrl+C), initiating shutdown");
48        }
49        _ = terminate => {
50            info!("Received SIGTERM, initiating graceful shutdown");
51        }
52    }
53}
54
55pub struct Runtime {
56    config: ServerConfig,
57    view_index: Arc<ViewIndex>,
58    spec: Option<Spec>,
59    program_runtime_catalog: ProgramRuntimeCatalog,
60    materialized_views: Option<MaterializedViewRegistry>,
61    websocket_auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
62    http_auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
63    websocket_usage_emitter: Option<Arc<dyn WebSocketUsageEmitter>>,
64    websocket_max_clients: Option<usize>,
65    websocket_rate_limit_config: Option<RateLimitConfig>,
66    #[cfg(feature = "otel")]
67    metrics: Option<Arc<Metrics>>,
68}
69
70impl Runtime {
71    #[cfg(feature = "otel")]
72    pub fn new(config: ServerConfig, view_index: ViewIndex, metrics: Option<Arc<Metrics>>) -> Self {
73        Self {
74            config,
75            view_index: Arc::new(view_index),
76            spec: None,
77            program_runtime_catalog: ProgramRuntimeCatalog::default(),
78            materialized_views: None,
79            websocket_auth_plugin: None,
80            http_auth_plugin: None,
81            websocket_usage_emitter: None,
82            websocket_max_clients: None,
83            websocket_rate_limit_config: None,
84            metrics,
85        }
86    }
87
88    #[cfg(not(feature = "otel"))]
89    pub fn new(config: ServerConfig, view_index: ViewIndex) -> Self {
90        Self {
91            config,
92            view_index: Arc::new(view_index),
93            spec: None,
94            program_runtime_catalog: ProgramRuntimeCatalog::default(),
95            materialized_views: None,
96            websocket_auth_plugin: None,
97            http_auth_plugin: None,
98            websocket_usage_emitter: None,
99            websocket_max_clients: None,
100            websocket_rate_limit_config: None,
101        }
102    }
103
104    pub fn with_spec(mut self, spec: Spec) -> Result<Self> {
105        self.program_runtime_catalog =
106            ProgramRuntimeCatalog::try_new(spec.program_runtime_definitions.clone())?;
107        self.spec = Some(spec);
108        Ok(self)
109    }
110
111    pub fn with_materialized_views(mut self, registry: MaterializedViewRegistry) -> Self {
112        self.materialized_views = Some(registry);
113        self
114    }
115
116    pub fn with_websocket_auth_plugin(
117        mut self,
118        websocket_auth_plugin: Arc<dyn WebSocketAuthPlugin>,
119    ) -> Self {
120        self.websocket_auth_plugin = Some(websocket_auth_plugin);
121        self
122    }
123
124    pub fn with_http_auth_plugin(mut self, http_auth_plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
125        self.http_auth_plugin = Some(http_auth_plugin);
126        self
127    }
128
129    pub fn with_websocket_usage_emitter(
130        mut self,
131        websocket_usage_emitter: Arc<dyn WebSocketUsageEmitter>,
132    ) -> Self {
133        self.websocket_usage_emitter = Some(websocket_usage_emitter);
134        self
135    }
136
137    pub fn with_websocket_max_clients(mut self, websocket_max_clients: usize) -> Self {
138        self.websocket_max_clients = Some(websocket_max_clients);
139        self
140    }
141
142    /// Configure rate limiting for WebSocket connections.
143    ///
144    /// This sets global rate limits such as maximum connections per IP,
145    /// timeouts, and rate windows. Per-subject limits are controlled
146    /// via AuthContext.Limits from the authentication token.
147    pub fn with_websocket_rate_limit_config(mut self, config: RateLimitConfig) -> Self {
148        self.websocket_rate_limit_config = Some(config);
149        self
150    }
151
152    /// Return the immutable capability plan selected by the builder.
153    pub fn plan(&self) -> crate::RuntimePlan {
154        self.config.runtime_plan
155    }
156
157    pub async fn run(self) -> Result<()> {
158        info!("Starting Arete runtime");
159
160        let plan = self.config.runtime_plan;
161        let transaction_config = if plan.transactions {
162            match self.config.transactions.clone() {
163                Some(config) => config,
164                None => TransactionConfig::from_env()?,
165            }
166        } else {
167            TransactionConfig::default()
168        };
169        if plan.transactions && !transaction_config.enabled {
170            anyhow::bail!(
171                "the runtime plan enables transactions but transaction configuration is disabled"
172            );
173        }
174        let program_runtime_catalog = self.program_runtime_catalog.clone();
175
176        let health_monitor = if plan.health {
177            self.config
178                .health
179                .as_ref()
180                .map(|health_config| HealthMonitor::new(health_config.clone()))
181        } else {
182            None
183        };
184        if let Some(monitor) = &health_monitor {
185            let _health_task = monitor.start().await;
186            info!("Health monitoring enabled");
187        }
188
189        let mut projector_handle = None;
190        let mut ws_handle = None;
191        let mut parser_handle = None;
192        let mut bus_cleanup_handle = None;
193        let mut stats_handle = None;
194        let mut mutations_tx_guard = None;
195        let mut snapshot_service: Option<Arc<crate::snapshot::SnapshotService>> = None;
196        let mut snapshot_manager_handle = None;
197        let mut snapshot_runtime = None;
198
199        if plan.live_runtime_enabled() {
200            let (mutations_tx, mutations_rx) = mpsc::channel::<MutationBatch>(1024);
201            mutations_tx_guard = Some(mutations_tx.clone());
202            let bus_manager = BusManager::new();
203            let entity_cache = EntityCache::new();
204
205            // Restore state from the latest snapshot (when enabled) before the
206            // WebSocket server spawns, so the first client's snapshot-on-subscribe
207            // is already warm. The VM portion is stashed for the generated
208            // runtime to hydrate before it connects to Yellowstone.
209            if let Some(spec) = self.spec.as_ref() {
210                let snapshot_config = match self.config.snapshots.clone() {
211                    Some(config) => Some(config),
212                    None => match crate::snapshot::SnapshotConfig::from_env() {
213                        Ok(config) => Some(config),
214                        Err(e) => {
215                            error!("Invalid snapshot configuration; snapshots disabled: {e:#}");
216                            None
217                        }
218                    },
219                };
220                if let Some(snapshot_config) = snapshot_config.filter(|c| c.enabled) {
221                    match crate::snapshot::SnapshotService::initialize(
222                        snapshot_config,
223                        spec,
224                        entity_cache.clone(),
225                        &self.view_index,
226                        mutations_tx.clone(),
227                    )
228                    .await
229                    {
230                        Ok(service) => {
231                            snapshot_runtime = Some(service.runtime());
232                            snapshot_manager_handle = Some(service.spawn());
233                            snapshot_service = Some(service);
234                        }
235                        Err(e) => {
236                            error!("Failed to initialize snapshots; continuing without: {e:#}")
237                        }
238                    }
239                }
240            }
241
242            #[cfg(feature = "otel")]
243            let projector = Projector::new(
244                self.view_index.clone(),
245                bus_manager.clone(),
246                entity_cache.clone(),
247                mutations_rx,
248                self.metrics.clone(),
249            );
250            #[cfg(not(feature = "otel"))]
251            let projector = Projector::new(
252                self.view_index.clone(),
253                bus_manager.clone(),
254                entity_cache.clone(),
255                mutations_rx,
256            );
257            let projector = match snapshot_runtime.clone() {
258                Some(runtime) => projector.with_snapshot_runtime(runtime),
259                None => projector,
260            };
261
262            projector_handle = Some(tokio::spawn(
263                async move {
264                    projector.run().await;
265                }
266                .instrument(info_span!("projector")),
267            ));
268
269            if plan.websocket {
270                if let Some(ws_config) = &self.config.websocket {
271                    #[cfg(feature = "otel")]
272                    let mut ws_server = WebSocketServer::new(
273                        ws_config.bind_address,
274                        bus_manager.clone(),
275                        entity_cache.clone(),
276                        self.view_index.clone(),
277                        self.metrics.clone(),
278                    );
279                    #[cfg(not(feature = "otel"))]
280                    let mut ws_server = WebSocketServer::new(
281                        ws_config.bind_address,
282                        bus_manager.clone(),
283                        entity_cache.clone(),
284                        self.view_index.clone(),
285                    );
286
287                    if let Some(max_clients) = self.websocket_max_clients {
288                        ws_server = ws_server.with_max_clients(max_clients);
289                    }
290                    if let Some(plugin) = self.websocket_auth_plugin.clone() {
291                        ws_server = ws_server.with_auth_plugin(plugin);
292                    }
293                    if let Some(emitter) = self.websocket_usage_emitter.clone() {
294                        ws_server = ws_server.with_usage_emitter(emitter);
295                    }
296                    if let Some(rate_limit_config) = self.websocket_rate_limit_config {
297                        ws_server = ws_server.with_rate_limit_config(rate_limit_config);
298                    }
299
300                    let bind_addr = ws_config.bind_address;
301                    ws_handle = Some(tokio::spawn(
302                        async move {
303                            if let Err(e) = ws_server.start().await {
304                                error!("WebSocket server error: {}", e);
305                            }
306                        }
307                        .instrument(info_span!("ws.server", %bind_addr)),
308                    ));
309                }
310            }
311
312            if let Some(spec) = self.spec.as_ref() {
313                if let Some(parser_setup) = spec.parser_setup.clone() {
314                    let program_id = spec
315                        .program_ids
316                        .first()
317                        .cloned()
318                        .unwrap_or_else(|| "unknown".to_string());
319                    info!("Starting parser runtime for program: {}", program_id);
320                    let health = health_monitor.clone();
321                    let reconnection_config = self.config.reconnection.clone().unwrap_or_default();
322                    let parser_snapshot_runtime = snapshot_runtime.clone();
323                    parser_handle = Some(tokio::spawn(
324                        async move {
325                            let parser = async move {
326                                parser_setup(mutations_tx, health, reconnection_config).await
327                            };
328                            let result = match parser_snapshot_runtime {
329                                Some(runtime) => runtime.scope(parser).await,
330                                None => parser.await,
331                            };
332                            if let Err(e) = result {
333                                error!("Vixen parser runtime error: {}", e);
334                            }
335                        }
336                        .instrument(info_span!("vixen.parser", %program_id)),
337                    ));
338                } else {
339                    info!("Spec provided but no parser_setup configured - skipping parser runtime");
340                }
341            } else {
342                info!("No spec provided - running in websocket-only mode");
343            }
344
345            let cleanup_bus = bus_manager.clone();
346            bus_cleanup_handle = Some(tokio::spawn(
347                async move {
348                    let mut interval = tokio::time::interval(Duration::from_secs(60));
349                    loop {
350                        interval.tick().await;
351                        let state_cleaned = cleanup_bus.cleanup_stale_state_buses().await;
352                        let list_cleaned = cleanup_bus.cleanup_stale_list_buses().await;
353                        if state_cleaned > 0 || list_cleaned > 0 {
354                            let (state_count, list_count) = cleanup_bus.bus_counts().await;
355                            info!(
356                                "Bus cleanup: removed {} state, {} list buses. Current: {} state, {} list",
357                                state_cleaned, list_cleaned, state_count, list_count
358                            );
359                        }
360                    }
361                }
362                .instrument(info_span!("bus.cleanup")),
363            ));
364
365            stats_handle = Some(tokio::spawn(
366                async move {
367                    let mut interval = tokio::time::interval(Duration::from_secs(30));
368                    loop {
369                        interval.tick().await;
370                        let (_state_buses, _list_buses) = bus_manager.bus_counts().await;
371                        let _cache_stats = entity_cache.stats().await;
372                    }
373                }
374                .instrument(info_span!("stats.reporter")),
375            ));
376        } else {
377            info!(
378                "Live runtime disabled; projection and Yellowstone resources were not initialized"
379            );
380        }
381
382        // Run the HTTP server on a dedicated OS thread with its own single-threaded
383        // tokio runtime so liveness remains responsive under projection load.
384        let _http_health_handle = if let Some(http_health_config) = &self.config.http_health {
385            let mut http_server = HttpServer::new(http_health_config.bind_address)
386                .with_runtime_plan(plan)
387                .with_program_runtime_catalog(program_runtime_catalog);
388            if let Some(target_id) = self.config.program_read_binding_target_id.clone() {
389                http_server = http_server.with_program_read_binding_target(target_id);
390            }
391            if let Some(target_id) = self.config.solana_gateway_target_id.clone() {
392                http_server = http_server.with_solana_gateway_target(target_id);
393            }
394            if let Some(monitor) = health_monitor.clone() {
395                http_server = http_server.with_health_monitor(monitor);
396            }
397            if let Some(runtime) = snapshot_runtime.clone() {
398                http_server = http_server.with_snapshot_runtime(runtime);
399            }
400            if let Some(plugin) = self
401                .http_auth_plugin
402                .clone()
403                .or_else(|| self.websocket_auth_plugin.clone())
404            {
405                http_server = http_server.with_auth_plugin(plugin);
406            }
407            if plan.transactions && transaction_config.enabled {
408                http_server = http_server.with_transaction_config(transaction_config.clone());
409            }
410            #[cfg(feature = "otel")]
411            {
412                http_server = http_server.with_metrics(self.metrics.clone());
413            }
414
415            let bind_addr = http_health_config.bind_address;
416            let join_handle = std::thread::Builder::new()
417                .name("health-server".into())
418                .spawn(move || {
419                    let rt = tokio::runtime::Builder::new_current_thread()
420                        .enable_all()
421                        .build()
422                        .expect("Failed to create health server runtime");
423                    rt.block_on(async move {
424                        let _span = info_span!("http.health", %bind_addr).entered();
425                        if let Err(e) = http_server.start().await {
426                            error!("HTTP health server error: {}", e);
427                        }
428                    });
429                })
430                .expect("Failed to spawn health server thread");
431            info!(
432                "HTTP health server running on dedicated thread at {}",
433                bind_addr
434            );
435            Some(join_handle)
436        } else {
437            None
438        };
439
440        info!("Arete runtime is running. Press Ctrl+C to stop.");
441
442        async fn wait_for_task(handle: Option<tokio::task::JoinHandle<()>>) {
443            if let Some(handle) = handle {
444                let _ = handle.await;
445            } else {
446                std::future::pending().await
447            }
448        }
449
450        tokio::select! {
451            _ = wait_for_task(ws_handle) => info!("WebSocket server task completed"),
452            _ = wait_for_task(projector_handle) => info!("Projector task completed"),
453            _ = wait_for_task(parser_handle) => info!("Parser runtime task completed"),
454            _ = wait_for_task(bus_cleanup_handle) => info!("Bus cleanup task completed"),
455            _ = wait_for_task(stats_handle) => info!("Stats reporter task completed"),
456            _ = shutdown_signal() => {}
457        }
458
459        // Final snapshot while the projector is still draining, so planned
460        // deploys restart near-lossless. Bounded to fit inside the platform's
461        // termination grace period.
462        if let Some(service) = snapshot_service.take() {
463            if let Some(handle) = snapshot_manager_handle.take() {
464                handle.abort();
465            }
466            if service.config().snapshot_on_shutdown {
467                info!("Taking final snapshot before shutdown");
468                match tokio::time::timeout(
469                    Duration::from_secs(20),
470                    service.snapshot_now(crate::snapshot::SnapshotTrigger::Shutdown),
471                )
472                .await
473                {
474                    Ok(Ok(_)) => {}
475                    Ok(Err(e)) => error!("Shutdown snapshot failed: {e:#}"),
476                    Err(_) => error!("Shutdown snapshot timed out"),
477                }
478            }
479        }
480        drop(mutations_tx_guard);
481
482        info!("Shutting down Arete runtime");
483        Ok(())
484    }
485}