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::config::WebSocketDeliveryConfig;
6use crate::health::HealthMonitor;
7use crate::http_server::HttpServer;
8use crate::materialized_view::MaterializedViewRegistry;
9use crate::mutation_batch::MutationBatch;
10use crate::program_runtime::ProgramRuntimeCatalog;
11use crate::projector::Projector;
12use crate::view::ViewIndex;
13use crate::websocket::client_manager::RateLimitConfig;
14use crate::websocket::server::ConnectionAcceptor;
15use crate::websocket::WebSocketServer;
16use crate::SolanaGatewayUsageObserver;
17use crate::Spec;
18use crate::WebSocketAuthPlugin;
19use crate::WebSocketUsageEmitter;
20use anyhow::Result;
21use std::net::SocketAddr;
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::net::{TcpListener, TcpStream};
25use tokio::sync::mpsc;
26use tokio::task::JoinHandle;
27use tokio_util::sync::CancellationToken;
28use tracing::{error, info, info_span, warn, Instrument};
29
30#[cfg(feature = "otel")]
31use crate::metrics::Metrics;
32
33/// Wait for shutdown signal (SIGINT on all platforms, SIGTERM on Unix)
34async fn shutdown_signal() {
35    let ctrl_c = async {
36        tokio::signal::ctrl_c()
37            .await
38            .expect("Failed to install Ctrl+C handler");
39    };
40
41    #[cfg(unix)]
42    let terminate = async {
43        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
44            .expect("Failed to install SIGTERM handler")
45            .recv()
46            .await;
47    };
48
49    #[cfg(not(unix))]
50    let terminate = std::future::pending::<()>();
51
52    tokio::select! {
53        _ = ctrl_c => {
54            info!("Received SIGINT (Ctrl+C), initiating shutdown");
55        }
56        _ = terminate => {
57            info!("Received SIGTERM, initiating graceful shutdown");
58        }
59    }
60}
61
62pub struct Runtime {
63    config: ServerConfig,
64    view_index: Arc<ViewIndex>,
65    spec: Option<Spec>,
66    program_runtime_catalog: ProgramRuntimeCatalog,
67    materialized_views: Option<MaterializedViewRegistry>,
68    websocket_auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
69    http_auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
70    websocket_usage_emitter: Option<Arc<dyn WebSocketUsageEmitter>>,
71    token_accounts_provider: Option<Arc<dyn crate::token_discovery::OwnerTokenAccountsProvider>>,
72    solana_gateway_usage_observer: Option<Arc<dyn SolanaGatewayUsageObserver>>,
73    websocket_max_clients: Option<usize>,
74    websocket_rate_limit_config: Option<RateLimitConfig>,
75    websocket_admission_provider: Option<Arc<dyn crate::WebSocketAdmissionProvider>>,
76    #[cfg(feature = "otel")]
77    metrics: Option<Arc<Metrics>>,
78}
79
80impl Runtime {
81    #[cfg(feature = "otel")]
82    pub fn new(config: ServerConfig, view_index: ViewIndex, metrics: Option<Arc<Metrics>>) -> Self {
83        Self {
84            config,
85            view_index: Arc::new(view_index),
86            spec: None,
87            program_runtime_catalog: ProgramRuntimeCatalog::default(),
88            materialized_views: None,
89            websocket_auth_plugin: None,
90            http_auth_plugin: None,
91            websocket_usage_emitter: None,
92            token_accounts_provider: None,
93            solana_gateway_usage_observer: None,
94            websocket_max_clients: None,
95            websocket_rate_limit_config: None,
96            websocket_admission_provider: None,
97            metrics,
98        }
99    }
100
101    #[cfg(not(feature = "otel"))]
102    pub fn new(config: ServerConfig, view_index: ViewIndex) -> Self {
103        Self {
104            config,
105            view_index: Arc::new(view_index),
106            spec: None,
107            program_runtime_catalog: ProgramRuntimeCatalog::default(),
108            materialized_views: None,
109            websocket_auth_plugin: None,
110            http_auth_plugin: None,
111            websocket_usage_emitter: None,
112            token_accounts_provider: None,
113            solana_gateway_usage_observer: None,
114            websocket_max_clients: None,
115            websocket_rate_limit_config: None,
116            websocket_admission_provider: None,
117        }
118    }
119
120    pub fn with_spec(mut self, spec: Spec) -> Result<Self> {
121        self.program_runtime_catalog =
122            ProgramRuntimeCatalog::try_new(spec.program_runtime_definitions.clone())?;
123        self.spec = Some(spec);
124        Ok(self)
125    }
126
127    pub fn with_materialized_views(mut self, registry: MaterializedViewRegistry) -> Self {
128        self.materialized_views = Some(registry);
129        self
130    }
131
132    pub fn with_websocket_auth_plugin(
133        mut self,
134        websocket_auth_plugin: Arc<dyn WebSocketAuthPlugin>,
135    ) -> Self {
136        self.websocket_auth_plugin = Some(websocket_auth_plugin);
137        self
138    }
139
140    pub fn with_http_auth_plugin(mut self, http_auth_plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
141        self.http_auth_plugin = Some(http_auth_plugin);
142        self
143    }
144
145    pub fn with_websocket_usage_emitter(
146        mut self,
147        websocket_usage_emitter: Arc<dyn WebSocketUsageEmitter>,
148    ) -> Self {
149        self.websocket_usage_emitter = Some(websocket_usage_emitter);
150        self
151    }
152
153    pub fn with_owner_token_accounts_provider(
154        mut self,
155        provider: Arc<dyn crate::token_discovery::OwnerTokenAccountsProvider>,
156    ) -> Self {
157        self.token_accounts_provider = Some(provider);
158        self
159    }
160
161    pub fn with_solana_gateway_usage_observer(
162        mut self,
163        observer: Arc<dyn SolanaGatewayUsageObserver>,
164    ) -> Self {
165        self.solana_gateway_usage_observer = Some(observer);
166        self
167    }
168
169    pub fn with_websocket_max_clients(mut self, websocket_max_clients: usize) -> Self {
170        self.websocket_max_clients = Some(websocket_max_clients);
171        self
172    }
173
174    /// Configure rate limiting for WebSocket connections.
175    ///
176    /// This sets global rate limits such as maximum connections per IP,
177    /// timeouts, and rate windows. Per-subject limits are controlled
178    /// via AuthContext.Limits from the authentication token.
179    pub fn with_websocket_rate_limit_config(mut self, config: RateLimitConfig) -> Self {
180        self.websocket_rate_limit_config = Some(config);
181        self
182    }
183
184    pub fn with_websocket_admission_provider(
185        mut self,
186        provider: Arc<dyn crate::WebSocketAdmissionProvider>,
187    ) -> Self {
188        self.websocket_admission_provider = Some(provider);
189        self
190    }
191
192    /// Return the immutable capability plan selected by the builder.
193    pub fn plan(&self) -> crate::RuntimePlan {
194        self.config.runtime_plan
195    }
196
197    /// Start everything and block until a shutdown signal, then stop cleanly.
198    ///
199    /// This is [`spawn`](Self::spawn) followed by waiting for SIGINT/SIGTERM
200    /// (or for a core task to exit) and [`RuntimeHandle::shutdown`]. Callers
201    /// that embed the server in a larger process should use `spawn` directly
202    /// and decide for themselves when to stop.
203    pub async fn run(self) -> Result<()> {
204        let mut handle = self.spawn().await?;
205        info!("Arete runtime is running. Press Ctrl+C to stop.");
206
207        tokio::select! {
208            _ = handle.exited() => {}
209            _ = shutdown_signal() => {}
210        }
211
212        handle.shutdown().await
213    }
214
215    /// Start the runtime's tasks and return a handle that owns them.
216    ///
217    /// Everything `run` starts is started here: projector, parser, snapshot
218    /// manager, bus cleanup, stats, and - only when configured - the WebSocket
219    /// listener and the HTTP health server. Nothing here installs signal
220    /// handlers or blocks; the handle is how the caller waits, serves
221    /// connections it accepted itself, and shuts the runtime down.
222    ///
223    /// A handle owns its runtime's channel, buses, cache and snapshot state
224    /// outright; nothing is shared through process globals, so a test or an
225    /// application can start and stop runtimes independently.
226    pub async fn spawn(self) -> Result<RuntimeHandle> {
227        info!("Starting Arete runtime");
228
229        let plan = self.config.runtime_plan;
230        let transaction_config = if plan.transactions {
231            match self.config.transactions.clone() {
232                Some(config) => config,
233                None => TransactionConfig::from_env()?,
234            }
235        } else {
236            TransactionConfig::default()
237        };
238        if plan.transactions && !transaction_config.enabled {
239            anyhow::bail!(
240                "the runtime plan enables transactions but transaction configuration is disabled"
241            );
242        }
243        // Resolved before any task starts, so a bad limit fails startup
244        // without leaving background work running.
245        let websocket_limits = match self.config.websocket_limits.clone() {
246            Some(limits) => {
247                limits.validate()?;
248                limits
249            }
250            None => crate::config::WebSocketLimitsConfig::from_env()?,
251        };
252        let http_limits = match self.config.http_limits.clone() {
253            Some(limits) => {
254                limits.validate()?;
255                limits
256            }
257            None => crate::config::HttpLimitsConfig::from_env()?,
258        };
259        let program_runtime_catalog = self.program_runtime_catalog.clone();
260
261        let health_monitor = if plan.health {
262            self.config
263                .health
264                .as_ref()
265                .map(|health_config| HealthMonitor::new(health_config.clone()))
266        } else {
267            None
268        };
269        let mut background = Vec::new();
270        if let Some(monitor) = &health_monitor {
271            background.push(monitor.start().await);
272            info!("Health monitoring enabled");
273        }
274
275        let mut projector_handle = None;
276        let mut ws_handle = None;
277        let mut parser_handle = None;
278        let mut mutations_tx_guard = None;
279        let mut snapshot_service: Option<Arc<crate::snapshot::SnapshotService>> = None;
280        let mut snapshot_manager_handle = None;
281        let mut snapshot_runtime = None;
282        let mut acceptor = None;
283        let mut entity_cache_handle = None;
284
285        if plan.live_runtime_enabled() {
286            let (mutations_tx, mutations_rx) = mpsc::channel::<MutationBatch>(1024);
287            mutations_tx_guard = Some(mutations_tx.clone());
288            let websocket_delivery = match self.config.websocket_delivery.clone() {
289                Some(config) => {
290                    config.validate()?;
291                    config
292                }
293                None => WebSocketDeliveryConfig::from_env()?,
294            };
295            info!(
296                list_bus_capacity = websocket_delivery.list_bus_capacity,
297                collection_coalesce_ms = ?websocket_delivery.collection_coalesce_ms,
298                "WebSocket delivery configured"
299            );
300            let bus_manager = BusManager::with_capacity(websocket_delivery.list_bus_capacity);
301            let entity_cache = EntityCache::new();
302            entity_cache_handle = Some(entity_cache.clone());
303
304            // Retained event tape for replayable append subscriptions. The
305            // builder wins over the process env so one host can enable replay
306            // for a single deployment and size it independently. A bad
307            // configuration disables replay rather than failing startup, the
308            // same posture snapshots take below.
309            let journal_config = match self.config.journal.clone() {
310                Some(config) => config,
311                None => match crate::journal::JournalConfig::from_env() {
312                    Ok(config) => config,
313                    Err(e) => {
314                        error!("Invalid journal configuration; event replay disabled: {e:#}");
315                        crate::journal::JournalConfig::default()
316                    }
317                },
318            };
319            let journal = Arc::new(crate::journal::EventJournal::new(journal_config));
320            if journal.is_enabled() {
321                info!(
322                    max_bytes_per_view = journal.config().max_bytes_per_view,
323                    max_records_per_view = journal.config().max_records_per_view,
324                    max_age_secs = journal.config().max_age.as_secs(),
325                    "Event replay enabled for append views"
326                );
327            }
328
329            // Restore state from the latest snapshot (when enabled) before the
330            // WebSocket server spawns, so the first client's snapshot-on-subscribe
331            // is already warm. The VM portion is stashed for the generated
332            // runtime to hydrate before it connects to Yellowstone.
333            if let Some(spec) = self.spec.as_ref() {
334                let snapshot_config = match self.config.snapshots.clone() {
335                    Some(config) => Some(config),
336                    None => match crate::snapshot::SnapshotConfig::from_env() {
337                        Ok(config) => Some(config),
338                        Err(e) => {
339                            error!("Invalid snapshot configuration; snapshots disabled: {e:#}");
340                            None
341                        }
342                    },
343                };
344                if let Some(snapshot_config) = snapshot_config.filter(|c| c.enabled) {
345                    match crate::snapshot::SnapshotService::initialize(
346                        snapshot_config,
347                        spec,
348                        entity_cache.clone(),
349                        &self.view_index,
350                        journal.clone(),
351                        mutations_tx.clone(),
352                    )
353                    .await
354                    {
355                        Ok(service) => {
356                            snapshot_runtime = Some(service.runtime());
357                            snapshot_manager_handle = Some(service.spawn());
358                            snapshot_service = Some(service);
359                        }
360                        Err(e) => {
361                            error!("Failed to initialize snapshots; continuing without: {e:#}")
362                        }
363                    }
364                }
365            }
366
367            #[cfg(feature = "otel")]
368            let projector = Projector::new(
369                self.view_index.clone(),
370                bus_manager.clone(),
371                entity_cache.clone(),
372                mutations_rx,
373                self.metrics.clone(),
374            );
375            #[cfg(not(feature = "otel"))]
376            let projector = Projector::new(
377                self.view_index.clone(),
378                bus_manager.clone(),
379                entity_cache.clone(),
380                mutations_rx,
381            );
382            let projector = match snapshot_runtime.clone() {
383                Some(runtime) => projector.with_snapshot_runtime(runtime),
384                None => projector,
385            };
386            // The parser's VM links itself to this when it registers, so the
387            // projector can ask it for entities the bounded cache dropped.
388            let entity_resync = crate::projector::EntityResync::new();
389            let projector = projector
390                .with_journal(journal.clone())
391                .with_entity_resync(entity_resync.clone());
392
393            // The projector runs for the lifetime of the server. Giving the
394            // task a span would make that span the parent of every batch
395            // whose producer does not carry an explicit context, creating an
396            // unbounded trace. `Projector::run` instead enters the bounded
397            // batch span before it processes and logs each batch.
398            projector_handle = Some(tokio::spawn(async move {
399                projector.run().await;
400            }));
401
402            // The connection-serving half of the WebSocket server exists
403            // whenever there is a live runtime, so a caller that owns its own
404            // listener can hand in the connections it accepts. The listener
405            // is bound only when a WebSocket address is configured.
406            let bind_address = self
407                .config
408                .websocket
409                .as_ref()
410                .map(|ws_config| ws_config.bind_address)
411                .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)));
412            #[cfg(feature = "otel")]
413            let mut ws_server = WebSocketServer::new(
414                bind_address,
415                bus_manager.clone(),
416                entity_cache.clone(),
417                self.view_index.clone(),
418                self.metrics.clone(),
419            );
420            #[cfg(not(feature = "otel"))]
421            let mut ws_server = WebSocketServer::new(
422                bind_address,
423                bus_manager.clone(),
424                entity_cache.clone(),
425                self.view_index.clone(),
426            );
427
428            ws_server = ws_server.with_journal(journal.clone());
429            ws_server = ws_server.with_delivery_config(websocket_delivery);
430            ws_server = ws_server.with_limits(websocket_limits.clone());
431            if let Some(max_clients) = self.websocket_max_clients {
432                ws_server = ws_server.with_max_clients(max_clients);
433            }
434            if let Some(plugin) = self.websocket_auth_plugin.clone() {
435                ws_server = ws_server.with_auth_plugin(plugin);
436            }
437            if let Some(emitter) = self.websocket_usage_emitter.clone() {
438                ws_server = ws_server.with_usage_emitter(emitter);
439            }
440            if let Some(rate_limit_config) = self.websocket_rate_limit_config {
441                ws_server = ws_server.with_rate_limit_config(rate_limit_config);
442            }
443            if let Some(provider) = self.websocket_admission_provider.clone() {
444                ws_server = ws_server.with_admission_provider(provider);
445            }
446            let (connection_acceptor, cleanup_handle) = ws_server.into_acceptor();
447            background.push(cleanup_handle);
448
449            if plan.websocket && self.config.websocket.is_some() {
450                let listener_acceptor = connection_acceptor.clone();
451                ws_handle = Some(tokio::spawn(
452                    async move {
453                        info!("Starting WebSocket server on {}", bind_address);
454                        let listener = match TcpListener::bind(&bind_address).await {
455                            Ok(listener) => listener,
456                            Err(e) => {
457                                error!("WebSocket server error: {}", e);
458                                return;
459                            }
460                        };
461                        if let Err(e) = listener_acceptor.serve_listener(listener).await {
462                            error!("WebSocket server error: {}", e);
463                        }
464                    }
465                    .instrument(info_span!("ws.server", %bind_address)),
466                ));
467            }
468            acceptor = Some(connection_acceptor);
469
470            if let Some(spec) = self.spec.as_ref() {
471                if let Some(parser_setup) = spec.parser_setup.clone() {
472                    let program_id = spec
473                        .program_ids
474                        .first()
475                        .cloned()
476                        .unwrap_or_else(|| "unknown".to_string());
477                    info!("Starting parser runtime for program: {}", program_id);
478                    let health = health_monitor.clone();
479                    let reconnection_config = self.config.reconnection.clone().unwrap_or_default();
480                    let parser_snapshot_runtime = snapshot_runtime.clone();
481                    let parser_journal = journal.clone();
482                    let parser_entity_resync = entity_resync.clone();
483                    // The parser runs for the lifetime of the server, like
484                    // the projector. A span on the task would be the current
485                    // span of every update it processes, and OpenTelemetry
486                    // keeps an open span's events until it closes: for a task
487                    // that runs as long as the server, an unbounded buffer.
488                    parser_handle = Some(tokio::spawn(async move {
489                        let parser = async move {
490                            parser_setup(mutations_tx, health, reconnection_config).await
491                        };
492                        let scoped = async move {
493                            match parser_snapshot_runtime {
494                                Some(runtime) => runtime.scope(parser).await,
495                                None => parser.await,
496                            }
497                        };
498                        // The tape is in scope even with snapshots off, so
499                        // a runtime that abandons its checkpoint can still
500                        // mark the hole it just created.
501                        let result = parser_entity_resync
502                            .scope(parser_journal.scope(scoped))
503                            .await;
504                        if let Err(e) = result {
505                            error!(%program_id, "Vixen parser runtime error: {}", e);
506                        }
507                    }));
508                } else {
509                    info!("Spec provided but no parser_setup configured - skipping parser runtime");
510                }
511            } else {
512                info!("No spec provided - running in websocket-only mode");
513            }
514
515            let cleanup_bus = bus_manager.clone();
516            background.push(tokio::spawn(
517                async move {
518                    let mut interval = tokio::time::interval(Duration::from_secs(60));
519                    loop {
520                        interval.tick().await;
521                        let state_cleaned = cleanup_bus.cleanup_stale_state_buses().await;
522                        let list_cleaned = cleanup_bus.cleanup_stale_list_buses().await;
523                        if state_cleaned > 0 || list_cleaned > 0 {
524                            let (state_count, list_count) = cleanup_bus.bus_counts().await;
525                            info!(
526                                "Bus cleanup: removed {} state, {} list buses. Current: {} state, {} list",
527                                state_cleaned, list_cleaned, state_count, list_count
528                            );
529                        }
530                    }
531                }
532                .instrument(info_span!("bus.cleanup")),
533            ));
534
535            background.push(tokio::spawn(
536                async move {
537                    let mut interval = tokio::time::interval(Duration::from_secs(30));
538                    loop {
539                        interval.tick().await;
540                        let (_state_buses, _list_buses) = bus_manager.bus_counts().await;
541                        let _cache_stats = entity_cache.stats().await;
542                    }
543                }
544                .instrument(info_span!("stats.reporter")),
545            ));
546        } else {
547            info!(
548                "Live runtime disabled; projection and Yellowstone resources were not initialized"
549            );
550        }
551
552        // Run the HTTP server on a dedicated OS thread with its own single-threaded
553        // tokio runtime so liveness remains responsive under projection load.
554        let http_shutdown = CancellationToken::new();
555        let http_health_thread = if let Some(http_health_config) = &self.config.http_health {
556            let mut http_server = HttpServer::new(http_health_config.bind_address)
557                .with_limits(http_limits.clone())
558                .with_runtime_plan(plan)
559                .with_program_runtime_catalog(program_runtime_catalog)
560                .with_shutdown(http_shutdown.clone());
561            if let Some(target_id) = self.config.program_read_binding_target_id.clone() {
562                http_server = http_server.with_program_read_binding_target(target_id);
563            }
564            if let Some(target_id) = self.config.solana_gateway_target_id.clone() {
565                http_server = http_server.with_solana_gateway_target(target_id);
566            }
567            if let Some(monitor) = health_monitor.clone() {
568                http_server = http_server.with_health_monitor(monitor);
569            }
570            if let Some(runtime) = snapshot_runtime.clone() {
571                http_server = http_server.with_snapshot_runtime(runtime);
572            }
573            if let Some(plugin) = self
574                .http_auth_plugin
575                .clone()
576                .or_else(|| self.websocket_auth_plugin.clone())
577            {
578                http_server = http_server.with_auth_plugin(plugin);
579            }
580            if plan.transactions && transaction_config.enabled {
581                http_server = http_server.with_transaction_config(transaction_config.clone());
582            }
583            if let Some(provider) = self.token_accounts_provider.clone() {
584                http_server = http_server.with_owner_token_accounts_provider(provider);
585            }
586            if let Some(observer) = self.solana_gateway_usage_observer.clone() {
587                http_server = http_server.with_solana_gateway_usage_observer(observer);
588            }
589            #[cfg(feature = "otel")]
590            {
591                http_server = http_server.with_metrics(self.metrics.clone());
592            }
593
594            let bind_addr = http_health_config.bind_address;
595            let join_handle = std::thread::Builder::new()
596                .name("health-server".into())
597                .spawn(move || {
598                    let rt = tokio::runtime::Builder::new_current_thread()
599                        .enable_all()
600                        .build()
601                        .expect("Failed to create health server runtime");
602                    rt.block_on(async move {
603                        let _span = info_span!("http.health", %bind_addr).entered();
604                        if let Err(e) = http_server.start().await {
605                            error!("HTTP health server error: {}", e);
606                        }
607                    });
608                })
609                .expect("Failed to spawn health server thread");
610            info!(
611                "HTTP health server running on dedicated thread at {}",
612                bind_addr
613            );
614            Some(join_handle)
615        } else {
616            None
617        };
618
619        Ok(RuntimeHandle {
620            plan,
621            health_monitor,
622            snapshot_runtime,
623            snapshot_service,
624            snapshot_manager_handle,
625            mutations_tx: mutations_tx_guard,
626            projector_handle,
627            parser_handle,
628            ws_handle,
629            background,
630            acceptor,
631            entity_cache: entity_cache_handle,
632            websocket_usage_emitter: self.websocket_usage_emitter,
633            http_shutdown,
634            http_health_thread,
635        })
636    }
637}
638
639/// A running [`Runtime`], owned by whoever called [`Runtime::spawn`].
640///
641/// Dropping the handle does **not** stop the runtime; the tasks it owns keep
642/// running on the tokio runtime. Call [`shutdown`](Self::shutdown) to stop
643/// them and release the memory they hold. This is deliberate: the same
644/// semantics as dropping a `JoinHandle`, and what lets `run` hand the handle
645/// across a `select!`.
646pub struct RuntimeHandle {
647    plan: crate::RuntimePlan,
648    health_monitor: Option<HealthMonitor>,
649    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
650    snapshot_service: Option<Arc<crate::snapshot::SnapshotService>>,
651    snapshot_manager_handle: Option<JoinHandle<()>>,
652    mutations_tx: Option<mpsc::Sender<MutationBatch>>,
653    projector_handle: Option<JoinHandle<()>>,
654    parser_handle: Option<JoinHandle<()>>,
655    ws_handle: Option<JoinHandle<()>>,
656    background: Vec<JoinHandle<()>>,
657    acceptor: Option<ConnectionAcceptor>,
658    entity_cache: Option<EntityCache>,
659    websocket_usage_emitter: Option<Arc<dyn WebSocketUsageEmitter>>,
660    http_shutdown: CancellationToken,
661    http_health_thread: Option<std::thread::JoinHandle<()>>,
662}
663
664/// Serves caller-accepted connections against one runtime. Obtained from
665/// [`RuntimeHandle::connection_server`]; cheap to clone into the task that
666/// owns each connection.
667#[derive(Clone)]
668pub struct ConnectionServer(ConnectionAcceptor);
669
670impl ConnectionServer {
671    /// Serve one accepted connection until the peer disconnects or the
672    /// runtime shuts down. See [`RuntimeHandle::serve_connection`].
673    pub async fn serve(&self, stream: TcpStream, remote_addr: SocketAddr) -> Result<()> {
674        self.0.serve(stream, remote_addr).await
675    }
676
677    /// Number of WebSocket clients currently connected to the runtime.
678    pub fn client_count(&self) -> usize {
679        self.0.client_count()
680    }
681}
682
683/// How long `shutdown` waits for listener-spawned sessions to finish their
684/// cleanup after being told to stop.
685const SESSION_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
686
687/// How long `shutdown` lets the projector drain queued batches after the
688/// producers have stopped.
689const PROJECTOR_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
690
691/// How long `shutdown` waits for accepted usage events to reach the API or
692/// durable spool storage.
693const USAGE_EMITTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
694
695/// Bound on the final snapshot, chosen to fit inside the platform's
696/// termination grace period.
697const SHUTDOWN_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(20);
698
699impl RuntimeHandle {
700    /// The capability plan this runtime was started with.
701    pub fn plan(&self) -> crate::RuntimePlan {
702        self.plan
703    }
704
705    /// Whether this runtime should take traffic: the stream is healthy and,
706    /// after a snapshot restore, the parser has caught back up to the slot
707    /// tip. The same test the HTTP `/ready` endpoint applies.
708    pub async fn is_ready(&self) -> bool {
709        let stream_ready = match self.health_monitor.as_ref() {
710            Some(monitor) => monitor.is_healthy().await,
711            None => true,
712        };
713        let snapshot_ready = self
714            .snapshot_runtime
715            .as_ref()
716            .is_none_or(crate::snapshot::SnapshotRuntime::resume_gate_ready);
717        stream_ready && snapshot_ready
718    }
719
720    /// Number of WebSocket clients currently connected to this runtime.
721    pub fn client_count(&self) -> usize {
722        self.acceptor
723            .as_ref()
724            .map(ConnectionAcceptor::client_count)
725            .unwrap_or(0)
726    }
727
728    /// What this runtime's entity cache holds: the entities kept per view
729    /// for snapshot-on-subscribe. For an embedder accounting for the
730    /// runtime's memory. `None` when the runtime has no live runtime.
731    pub async fn entity_cache_stats(&self) -> Option<crate::cache::CacheStats> {
732        match &self.entity_cache {
733            Some(cache) => Some(cache.stats().await),
734            None => None,
735        }
736    }
737
738    /// Serve a TCP connection the caller accepted, as this runtime's
739    /// WebSocket server would have: handshake, authentication, then the
740    /// subscription session until the peer disconnects.
741    ///
742    /// Resolves when the session ends, or when the runtime shuts down. Fails
743    /// if the runtime has no live runtime (no buses to subscribe to). Callers
744    /// that serve from an accept loop should take a
745    /// [`connection_server`](Self::connection_server), which is cheap to clone
746    /// into each connection's task.
747    pub async fn serve_connection(&self, stream: TcpStream, remote_addr: SocketAddr) -> Result<()> {
748        match self.connection_server() {
749            Some(server) => server.serve(stream, remote_addr).await,
750            None => anyhow::bail!("this runtime has no live runtime to serve connections from"),
751        }
752    }
753
754    /// A clonable handle that serves connections against this runtime.
755    ///
756    /// `None` when the runtime has no live runtime. Sessions served through a
757    /// clone are ended by [`shutdown`](Self::shutdown) like any other.
758    pub fn connection_server(&self) -> Option<ConnectionServer> {
759        self.acceptor.clone().map(ConnectionServer)
760    }
761
762    /// Resolves when a core task - projector, parser or WebSocket listener -
763    /// exits on its own. `run` treats that as a reason to shut down.
764    pub async fn exited(&mut self) {
765        async fn wait(handle: Option<&mut JoinHandle<()>>) {
766            match handle {
767                Some(handle) => {
768                    let _ = handle.await;
769                }
770                None => std::future::pending().await,
771            }
772        }
773
774        tokio::select! {
775            _ = wait(self.ws_handle.as_mut()) => info!("WebSocket server task completed"),
776            _ = wait(self.projector_handle.as_mut()) => info!("Projector task completed"),
777            _ = wait(self.parser_handle.as_mut()) => info!("Parser runtime task completed"),
778        }
779    }
780
781    /// Stop the runtime: take the final snapshot if configured, stop
782    /// producing, let the projector drain, close sessions, then stop
783    /// everything else.
784    ///
785    /// Order matters. The final snapshot is taken *first*, while the parser
786    /// is still running: capture takes the barrier exclusively, so it waits
787    /// for every in-flight update to reach the projector and records one
788    /// consistent cut. Aborting the parser before that could cut an update
789    /// between its VM write and its batch, and the snapshot would keep the
790    /// write without the projection. Only then is the parser aborted; anything
791    /// it produces after the snapshot is simply not restored. The sender is
792    /// dropped so the projector exits once its queue is empty, bounded by a
793    /// timeout after which it is aborted and awaited. Sessions are ended
794    /// through their normal cleanup, the HTTP health server is signalled and
795    /// its thread joined, and the remaining tasks are aborted.
796    pub async fn shutdown(mut self) -> Result<()> {
797        // Final snapshot before anything stops, so planned stops restart
798        // near-lossless. Bounded to fit inside a termination grace period.
799        if let Some(service) = self.snapshot_service.take() {
800            if let Some(handle) = self.snapshot_manager_handle.take() {
801                handle.abort();
802            }
803            if service.config().snapshot_on_shutdown {
804                info!("Taking final snapshot before shutdown");
805                match tokio::time::timeout(
806                    SHUTDOWN_SNAPSHOT_TIMEOUT,
807                    service.snapshot_now(crate::snapshot::SnapshotTrigger::Shutdown),
808                )
809                .await
810                {
811                    Ok(Ok(_)) => {}
812                    Ok(Err(e)) => error!("Shutdown snapshot failed: {e:#}"),
813                    Err(_) => error!("Shutdown snapshot timed out"),
814                }
815            }
816        }
817        if let Some(handle) = self.snapshot_manager_handle.take() {
818            handle.abort();
819        }
820
821        if let Some(parser) = self.parser_handle.take() {
822            parser.abort();
823            let _ = parser.await;
824        }
825        if let Some(acceptor) = &self.acceptor {
826            acceptor.shutdown();
827        }
828        if let Some(ws) = self.ws_handle.take() {
829            let _ = ws.await;
830        }
831        let sessions_drained = if let Some(acceptor) = &self.acceptor {
832            if tokio::time::timeout(SESSION_DRAIN_TIMEOUT, acceptor.wait_for_sessions())
833                .await
834                .is_err()
835            {
836                warn!(
837                    "Sessions did not finish within {:?} of shutdown",
838                    SESSION_DRAIN_TIMEOUT
839                );
840                false
841            } else {
842                true
843            }
844        } else {
845            true
846        };
847
848        let usage_events_drained = if !sessions_drained {
849            false
850        } else if let Some(acceptor) = &self.acceptor {
851            if tokio::time::timeout(
852                USAGE_EMITTER_SHUTDOWN_TIMEOUT,
853                acceptor.wait_for_usage_events(),
854            )
855            .await
856            .is_err()
857            {
858                warn!(
859                    "WebSocket usage event tasks did not finish within {:?}",
860                    USAGE_EMITTER_SHUTDOWN_TIMEOUT
861                );
862                false
863            } else {
864                true
865            }
866        } else {
867            true
868        };
869
870        if let Some(emitter) = self.websocket_usage_emitter.take() {
871            let operation = if usage_events_drained {
872                emitter.shutdown()
873            } else {
874                // A timed-out session can still enqueue its final usage event.
875                // Keep the receiver alive for that late producer, but put an
876                // ordered persistence barrier behind everything already in its
877                // queue so shutdown cannot strand the accepted backlog.
878                emitter.flush()
879            };
880            if tokio::time::timeout(USAGE_EMITTER_SHUTDOWN_TIMEOUT, operation)
881                .await
882                .is_err()
883            {
884                warn!(
885                    operation = if usage_events_drained {
886                        "shutdown"
887                    } else {
888                        "flush"
889                    },
890                    "WebSocket usage emitter operation did not finish within {:?}",
891                    USAGE_EMITTER_SHUTDOWN_TIMEOUT
892                );
893            }
894        }
895
896        drop(self.mutations_tx.take());
897        if let Some(mut projector) = self.projector_handle.take() {
898            if tokio::time::timeout(PROJECTOR_DRAIN_TIMEOUT, &mut projector)
899                .await
900                .is_err()
901            {
902                warn!(
903                    "Projector did not drain within {:?}; aborting it",
904                    PROJECTOR_DRAIN_TIMEOUT
905                );
906                projector.abort();
907                let _ = projector.await;
908            }
909        }
910
911        for handle in self.background.drain(..) {
912            handle.abort();
913        }
914
915        self.http_shutdown.cancel();
916        if let Some(thread) = self.http_health_thread.take() {
917            if let Err(e) = tokio::task::spawn_blocking(move || thread.join()).await {
918                error!("Health server thread join failed: {e}");
919            }
920        }
921
922        info!("Shutting down Arete runtime");
923        Ok(())
924    }
925}