Skip to main content

forge_orchestration/
runtime.rs

1//! Forge runtime and control plane
2//!
3//! ## Table of Contents
4//! - **Forge**: Main runtime struct
5//! - **ForgeHandle**: Handle for interacting with running Forge
6
7use crate::autoscaler::{Autoscaler, MetricsSnapshot, ScalingDecision};
8use crate::builder::ForgeConfig;
9use crate::error::Result;
10use crate::job::Job;
11use crate::metrics::ForgeMetrics;
12use crate::moe::{BoxedMoERouter, RouteResult};
13use crate::networking::{HttpServer, HttpState};
14use crate::nomad::NomadClient;
15use crate::scheduler::{reconcile::Reconciler, sim::SimCell, NodeResources};
16use crate::storage::{keys, store_get_json, store_set_json, BoxedStateStore};
17use crate::types::{Expert, NodeId, Shard, ShardId};
18use axum::{
19    extract::{Path, State},
20    routing::{delete, get, post},
21    Json, Router,
22};
23use dashmap::DashMap;
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::{broadcast, RwLock};
27use tracing::{error, info, warn};
28
29/// Runtime state
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RuntimeState {
32    /// Not started
33    Stopped,
34    /// Starting up
35    Starting,
36    /// Running normally
37    Running,
38    /// Shutting down
39    ShuttingDown,
40}
41
42/// Main Forge runtime
43pub struct Forge {
44    config: ForgeConfig,
45    state: Arc<RwLock<RuntimeState>>,
46    node_id: NodeId,
47    start_time: Option<Instant>,
48
49    // Core components
50    nomad: Option<NomadClient>,
51    store: BoxedStateStore,
52    router: BoxedMoERouter,
53    autoscaler: Autoscaler,
54    metrics: Option<Arc<ForgeMetrics>>,
55
56    // Runtime state
57    jobs: DashMap<String, Job>,
58    experts: DashMap<usize, Expert>,
59    shards: DashMap<ShardId, Shard>,
60
61    // Nodes registered for the embedded scheduler / reconcile loop
62    scheduler_nodes: std::sync::Mutex<Vec<NodeResources>>,
63
64    // Shutdown signal
65    shutdown_tx: broadcast::Sender<()>,
66}
67
68impl Forge {
69    /// Create a new Forge instance (use ForgeBuilder instead)
70    pub(crate) fn new(
71        config: ForgeConfig,
72        nomad: Option<NomadClient>,
73        store: BoxedStateStore,
74        router: BoxedMoERouter,
75        autoscaler: Autoscaler,
76        metrics: Option<Arc<ForgeMetrics>>,
77    ) -> Self {
78        let (shutdown_tx, _) = broadcast::channel(1);
79
80        Self {
81            config,
82            state: Arc::new(RwLock::new(RuntimeState::Stopped)),
83            node_id: NodeId::new(),
84            start_time: None,
85            nomad,
86            store,
87            router,
88            autoscaler,
89            metrics,
90            jobs: DashMap::new(),
91            experts: DashMap::new(),
92            shards: DashMap::new(),
93            scheduler_nodes: std::sync::Mutex::new(Vec::new()),
94            shutdown_tx,
95        }
96    }
97
98    /// Get the node ID
99    pub fn node_id(&self) -> NodeId {
100        self.node_id
101    }
102
103    /// Get current runtime state
104    pub async fn state(&self) -> RuntimeState {
105        *self.state.read().await
106    }
107
108    /// Get uptime in seconds
109    pub fn uptime_secs(&self) -> u64 {
110        self.start_time
111            .map(|t| t.elapsed().as_secs())
112            .unwrap_or(0)
113    }
114
115    /// Get metrics instance
116    pub fn metrics(&self) -> Option<&Arc<ForgeMetrics>> {
117        self.metrics.as_ref()
118    }
119
120    /// Get the state store
121    pub fn store(&self) -> &BoxedStateStore {
122        &self.store
123    }
124
125    /// Get the MoE router
126    pub fn router(&self) -> &BoxedMoERouter {
127        &self.router
128    }
129
130    /// Check if Nomad is configured
131    pub fn has_nomad(&self) -> bool {
132        self.nomad.is_some()
133    }
134
135    /// Register a node available to the embedded scheduler / reconcile loop.
136    ///
137    /// Register nodes before [`Forge::run`], which seeds the reconcile loop with
138    /// the registered set.
139    pub fn register_node(&self, node: NodeResources) {
140        self.scheduler_nodes.lock().unwrap().push(node);
141    }
142
143    /// Build a [`Reconciler`] that shares this runtime's state store and is
144    /// seeded with the registered nodes. The reconciler converges submitted jobs
145    /// (persisted under `keys::JOBS`) onto node assignments.
146    ///
147    /// [`Forge::run`] spawns one automatically; this is exposed for tests and
148    /// advanced embedding. It uses a fresh autoscaler with the same config as the
149    /// runtime's — the reconciler is the autoscaling authority for the loop.
150    pub async fn new_reconciler(&self) -> Result<Reconciler> {
151        let nodes: Vec<NodeResources> = self.scheduler_nodes.lock().unwrap().clone();
152        let autoscaler = Arc::new(Autoscaler::new(self.autoscaler.config().clone())?);
153        let mut reconciler = Reconciler::new(self.store.clone(), autoscaler);
154        for node in nodes {
155            reconciler.register_node(node);
156        }
157        reconciler.bootstrap().await?;
158        Ok(reconciler)
159    }
160
161    /// Run the Forge control plane
162    pub async fn run(mut self) -> Result<()> {
163        {
164            let mut state = self.state.write().await;
165            *state = RuntimeState::Starting;
166        }
167
168        info!(
169            node_id = %self.node_id,
170            node_name = %self.config.node_name,
171            "Starting Forge control plane"
172        );
173
174        self.start_time = Some(Instant::now());
175
176        // Verify Nomad connectivity if configured
177        if let Some(nomad) = &self.nomad {
178            match nomad.health().await {
179                Ok(true) => info!("Nomad connection verified"),
180                Ok(false) => warn!("Nomad returned unhealthy status"),
181                Err(e) => warn!(error = %e, "Failed to connect to Nomad"),
182            }
183        }
184
185        // Load existing state from store
186        self.load_state().await?;
187
188        {
189            let mut state = self.state.write().await;
190            *state = RuntimeState::Running;
191        }
192
193        info!("Forge control plane running");
194
195        // Spawn the reconcile control loop. It shares the state store (so it sees
196        // jobs submitted via `submit_job`) and the nodes registered via
197        // `register_node`; with no nodes registered it idles harmlessly.
198        match self.new_reconciler().await {
199            Ok(reconciler) => {
200                let shutdown_rx = self.shutdown_tx.subscribe();
201                tokio::spawn(async move {
202                    if let Err(e) = reconciler.run(shutdown_rx).await {
203                        error!(error = %e, "reconcile loop exited with error");
204                    }
205                });
206                info!("Reconcile control loop spawned");
207            }
208            Err(e) => warn!(error = %e, "Failed to start reconcile loop"),
209        }
210
211        // Create shared state for HTTP handlers
212        let forge_state = Arc::new(RwLock::new(ForgeHttpState {
213            jobs: self.jobs.clone(),
214            metrics: self.metrics.clone(),
215        }));
216
217        // Build HTTP router
218        let http_router = self.build_http_router(forge_state);
219
220        // Start HTTP server
221        let http_server = HttpServer::new(self.config.http_config.clone())
222            .with_router(http_router);
223
224        // Run until shutdown
225        let mut shutdown_rx = self.shutdown_tx.subscribe();
226
227        tokio::select! {
228            result = http_server.serve() => {
229                if let Err(e) = result {
230                    error!(error = %e, "HTTP server error");
231                }
232            }
233            _ = shutdown_rx.recv() => {
234                info!("Shutdown signal received");
235            }
236        }
237
238        self.shutdown().await?;
239
240        Ok(())
241    }
242
243    /// Shutdown the control plane
244    pub async fn shutdown(&self) -> Result<()> {
245        {
246            let mut state = self.state.write().await;
247            if *state == RuntimeState::Stopped {
248                return Ok(());
249            }
250            *state = RuntimeState::ShuttingDown;
251        }
252
253        info!("Shutting down Forge control plane");
254
255        // Save state
256        self.save_state().await?;
257
258        // Send shutdown signal
259        let _ = self.shutdown_tx.send(());
260
261        {
262            let mut state = self.state.write().await;
263            *state = RuntimeState::Stopped;
264        }
265
266        info!("Forge control plane stopped");
267        Ok(())
268    }
269
270    /// Signal shutdown
271    pub fn signal_shutdown(&self) {
272        let _ = self.shutdown_tx.send(());
273    }
274
275    /// Subscribe to shutdown signal
276    pub fn shutdown_receiver(&self) -> broadcast::Receiver<()> {
277        self.shutdown_tx.subscribe()
278    }
279
280    // State management
281
282    async fn load_state(&self) -> Result<()> {
283        // Load jobs from store
284        let job_keys = self.store.list_prefix(keys::JOBS).await?;
285        for key in job_keys {
286            if let Some(job) = store_get_json::<Job>(self.store.as_ref(), &key).await? {
287                self.jobs.insert(job.id.clone(), job);
288            }
289        }
290
291        info!(jobs = self.jobs.len(), "Loaded state from store");
292        Ok(())
293    }
294
295    async fn save_state(&self) -> Result<()> {
296        // Save all jobs
297        for entry in self.jobs.iter() {
298            let key = keys::job(&entry.key());
299            store_set_json(self.store.as_ref(), &key, entry.value()).await?;
300        }
301
302        info!(jobs = self.jobs.len(), "Saved state to store");
303        Ok(())
304    }
305
306    // Job management
307
308    /// Submit a job
309    pub async fn submit_job(&self, job: Job) -> Result<String> {
310        let job_id = job.id.clone();
311
312        // Submit to Nomad if configured
313        if let Some(nomad) = &self.nomad {
314            nomad.submit_job(&job).await?;
315        }
316
317        // Store locally
318        let key = keys::job(&job_id);
319        store_set_json(self.store.as_ref(), &key, &job).await?;
320        self.jobs.insert(job_id.clone(), job);
321
322        if let Some(metrics) = &self.metrics {
323            metrics.record_job_submitted();
324        }
325
326        info!(job_id = %job_id, "Job submitted");
327        Ok(job_id)
328    }
329
330    /// Submit a simulation cell (world shard + co-resident agent policies) for
331    /// gang scheduling. Persisted under `keys::simcell(id)`; the reconcile loop
332    /// picks it up and gang-schedules its members all-or-nothing (co-located per
333    /// the cell's [`crate::scheduler::sim::CoPlacement`]).
334    pub async fn submit_sim_cell(&self, cell: SimCell) -> Result<String> {
335        let id = cell.id.clone();
336        store_set_json(self.store.as_ref(), &keys::simcell(&id), &cell).await?;
337        info!(cell_id = %id, "Sim cell submitted");
338        Ok(id)
339    }
340
341    /// Get a job by ID
342    pub fn get_job(&self, job_id: &str) -> Option<Job> {
343        self.jobs.get(job_id).map(|e| e.value().clone())
344    }
345
346    /// List all jobs
347    pub fn list_jobs(&self) -> Vec<Job> {
348        self.jobs.iter().map(|e| e.value().clone()).collect()
349    }
350
351    /// Stop a job
352    pub async fn stop_job(&self, job_id: &str, purge: bool) -> Result<()> {
353        // Stop in Nomad if configured
354        if let Some(nomad) = &self.nomad {
355            nomad.stop_job(job_id, purge).await?;
356        }
357
358        // Remove locally
359        if purge {
360            let key = keys::job(job_id);
361            self.store.delete(&key).await?;
362            self.jobs.remove(job_id);
363        }
364
365        if let Some(metrics) = &self.metrics {
366            metrics.record_job_completed(true);
367        }
368
369        info!(job_id = %job_id, purge = purge, "Job stopped");
370        Ok(())
371    }
372
373    /// Scale a job's task group
374    pub async fn scale_job(&self, job_id: &str, group: &str, count: u32) -> Result<()> {
375        // Scale in Nomad if configured
376        if let Some(nomad) = &self.nomad {
377            nomad
378                .scale_job(job_id, group, count, Some("Manual scale"))
379                .await?;
380        }
381
382        // Update local state
383        if let Some(mut job) = self.jobs.get_mut(job_id) {
384            for g in &mut job.groups {
385                if g.name == group {
386                    g.scaling.desired = count;
387                    break;
388                }
389            }
390        }
391
392        if let Some(metrics) = &self.metrics {
393            let direction = "manual";
394            metrics.record_scale_event(job_id, direction);
395            metrics.set_instances(job_id, group, count as f64);
396        }
397
398        info!(job_id = %job_id, group = %group, count = count, "Job scaled");
399        Ok(())
400    }
401
402    // MoE routing
403
404    /// Route an input to an expert
405    pub async fn route(&self, input: &str) -> RouteResult {
406        let experts: Vec<Expert> = self.experts.iter().map(|e| e.value().clone()).collect();
407
408        let result = if experts.is_empty() {
409            self.router.route(input, 8).await
410        } else {
411            self.router.route_with_experts(input, &experts).await
412        };
413
414        if let Some(metrics) = &self.metrics {
415            metrics.record_route(self.router.name(), result.expert_index, 0.001);
416        }
417
418        result
419    }
420
421    /// Register an expert
422    pub fn register_expert(&self, expert: Expert) {
423        info!(index = expert.index, node = %expert.node, "Expert registered");
424        self.experts.insert(expert.index, expert);
425    }
426
427    /// Update expert load
428    pub fn update_expert_load(&self, index: usize, load: f64) {
429        if let Some(mut expert) = self.experts.get_mut(&index) {
430            expert.update_load(load);
431        }
432    }
433
434    // Autoscaling
435
436    /// Evaluate autoscaling for a job
437    pub async fn evaluate_scaling(
438        &self,
439        job_id: &str,
440        cpu: f64,
441        memory: f64,
442        instances: u32,
443    ) -> ScalingDecision {
444        let metrics = MetricsSnapshot::new(cpu, memory, instances);
445        let decision = self.autoscaler.evaluate(job_id, metrics).await;
446
447        // Execute the decision (previously it was computed and discarded). Apply
448        // the delta to each of the job's groups via `scale_job`, which updates
449        // Nomad (if configured), local state, and metrics. The autoscaler already
450        // enforced hysteresis before returning a scaling action.
451        if decision.is_scaling() {
452            let groups: Vec<(String, u32)> = self
453                .jobs
454                .get(job_id)
455                .map(|j| {
456                    j.groups
457                        .iter()
458                        .map(|g| (g.name.clone(), g.scaling.desired))
459                        .collect()
460                })
461                .unwrap_or_default();
462
463            for (group, current) in groups {
464                let target = match &decision {
465                    ScalingDecision::ScaleUp(n) => current.saturating_add(*n),
466                    ScalingDecision::ScaleDown(n) => current.saturating_sub(*n),
467                    ScalingDecision::ScaleTo(c) => *c,
468                    ScalingDecision::NoChange => current,
469                };
470                if target != current {
471                    if let Err(e) = self.scale_job(job_id, &group, target).await {
472                        warn!(job_id = %job_id, group = %group, error = %e, "autoscale execution failed");
473                    }
474                }
475            }
476        }
477
478        decision
479    }
480
481    // HTTP router
482
483    fn build_http_router(&self, state: Arc<RwLock<ForgeHttpState>>) -> Router {
484        let state = HttpState { app: state };
485
486        Router::new()
487            .route("/health", get(health_handler))
488            .route("/ready", get(ready_handler))
489            .route("/api/v1/jobs", get(list_jobs_handler))
490            .route("/api/v1/jobs", post(submit_job_handler))
491            .route("/api/v1/jobs/:id", get(get_job_handler))
492            .route("/api/v1/jobs/:id", delete(stop_job_handler))
493            .route("/metrics", get(metrics_handler))
494            .with_state(state)
495    }
496}
497
498// HTTP state for handlers
499struct ForgeHttpState {
500    jobs: DashMap<String, Job>,
501    metrics: Option<Arc<ForgeMetrics>>,
502}
503
504// HTTP handlers
505
506async fn health_handler() -> Json<serde_json::Value> {
507    Json(serde_json::json!({
508        "status": "healthy",
509        "version": env!("CARGO_PKG_VERSION")
510    }))
511}
512
513async fn ready_handler() -> axum::http::StatusCode {
514    axum::http::StatusCode::OK
515}
516
517async fn list_jobs_handler(
518    State(state): State<HttpState<ForgeHttpState>>,
519) -> Json<Vec<Job>> {
520    let app = state.app.read().await;
521    let jobs: Vec<Job> = app.jobs.iter().map(|e| e.value().clone()).collect();
522    Json(jobs)
523}
524
525async fn get_job_handler(
526    State(state): State<HttpState<ForgeHttpState>>,
527    Path(id): Path<String>,
528) -> std::result::Result<Json<Job>, axum::http::StatusCode> {
529    let app = state.app.read().await;
530    app.jobs
531        .get(&id)
532        .map(|e| Json(e.value().clone()))
533        .ok_or(axum::http::StatusCode::NOT_FOUND)
534}
535
536async fn submit_job_handler(
537    State(state): State<HttpState<ForgeHttpState>>,
538    Json(job): Json<Job>,
539) -> std::result::Result<Json<serde_json::Value>, axum::http::StatusCode> {
540    let app = state.app.read().await;
541    let job_id = job.id.clone();
542    app.jobs.insert(job_id.clone(), job);
543    Ok(Json(serde_json::json!({ "job_id": job_id })))
544}
545
546async fn stop_job_handler(
547    State(state): State<HttpState<ForgeHttpState>>,
548    Path(id): Path<String>,
549) -> axum::http::StatusCode {
550    let app = state.app.read().await;
551    if app.jobs.remove(&id).is_some() {
552        axum::http::StatusCode::NO_CONTENT
553    } else {
554        axum::http::StatusCode::NOT_FOUND
555    }
556}
557
558async fn metrics_handler(
559    State(state): State<HttpState<ForgeHttpState>>,
560) -> std::result::Result<String, axum::http::StatusCode> {
561    let app = state.app.read().await;
562    match &app.metrics {
563        Some(m) => m
564            .gather_text()
565            .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR),
566        None => Err(axum::http::StatusCode::NOT_FOUND),
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::builder::ForgeBuilder;
574    use crate::job::{Driver, Task};
575
576    #[tokio::test]
577    async fn test_forge_creation() {
578        let forge = ForgeBuilder::new().build().unwrap();
579        assert_eq!(forge.state().await, RuntimeState::Stopped);
580    }
581
582    #[tokio::test]
583    async fn test_job_management() {
584        let forge = ForgeBuilder::new().build().unwrap();
585
586        let job = Job::new("test-job").with_group(
587            "api",
588            Task::new("server")
589                .driver(Driver::Exec)
590                .command("/bin/server"),
591        );
592
593        let job_id = forge.submit_job(job).await.unwrap();
594        assert!(forge.get_job(&job_id).is_some());
595
596        let jobs = forge.list_jobs();
597        assert_eq!(jobs.len(), 1);
598
599        forge.stop_job(&job_id, true).await.unwrap();
600        assert!(forge.get_job(&job_id).is_none());
601    }
602
603    #[tokio::test]
604    async fn test_routing() {
605        let forge = ForgeBuilder::new().build().unwrap();
606
607        let result = forge.route("test-input").await;
608        assert!(result.expert_index < 8);
609    }
610
611    #[tokio::test]
612    async fn test_expert_registration() {
613        let forge = ForgeBuilder::new().build().unwrap();
614
615        let expert = Expert::new(0, NodeId::new());
616        forge.register_expert(expert);
617
618        forge.update_expert_load(0, 0.5);
619    }
620
621    #[tokio::test]
622    async fn test_evaluate_scaling_executes_decision() {
623        let forge = ForgeBuilder::new().build().unwrap();
624        let mut job = Job::new("svc").with_group(
625            "api",
626            Task::new("s").driver(Driver::Exec).command("/bin/s"),
627        );
628        job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(2);
629        let id = forge.submit_job(job).await.unwrap();
630
631        // High utilization -> ScaleUp, and the decision is actually applied.
632        let decision = forge.evaluate_scaling(&id, 0.95, 0.5, 2).await;
633        assert!(matches!(decision, ScalingDecision::ScaleUp(_)));
634
635        let updated = forge.get_job(&id).unwrap();
636        assert_eq!(updated.groups[0].scaling.desired, 3, "scale decision must be executed");
637    }
638
639    #[tokio::test]
640    async fn test_reconciler_integration_schedules_jobs() {
641        let forge = ForgeBuilder::new().build().unwrap();
642        forge.register_node(NodeResources::new(NodeId::new(), 8000, 8192));
643
644        let job = Job::new("svc").with_group(
645            "api",
646            Task::new("s")
647                .driver(Driver::Exec)
648                .command("/bin/s")
649                .resources(1000, 1024),
650        );
651        forge.submit_job(job).await.unwrap();
652
653        // The reconciler shares the store, so it sees the submitted job and binds it.
654        let mut reconciler = forge.new_reconciler().await.unwrap();
655        let report = reconciler.reconcile_once().await.unwrap();
656        assert!(report.scheduled >= 1, "reconciler should schedule the submitted replica");
657        assert!(reconciler.bound_count() >= 1);
658    }
659
660    #[tokio::test]
661    async fn test_submit_sim_cell_gang_scheduled_via_reconciler() {
662        use crate::scheduler::sim::{AgentPolicy, CoPlacement, SimWorld};
663        use crate::types::GpuResources;
664        use std::time::Duration;
665
666        let forge = ForgeBuilder::new().build().unwrap();
667        forge.register_node(
668            NodeResources::new(NodeId::new(), 8000, 16384)
669                .with_gpu(GpuResources::new(0, "A100", 8192))
670                .with_gpu(GpuResources::new(1, "A100", 8192)),
671        );
672
673        let cell = SimCell::new("habitat", SimWorld::cpu(1000, 2048), Duration::from_millis(50))
674            .with_agent(AgentPolicy::gpu("policy-a", 200, 256, 4096))
675            .with_agent(AgentPolicy::gpu("policy-b", 200, 256, 4096))
676            .with_co_placement(CoPlacement::InterconnectLocalGpu);
677        forge.submit_sim_cell(cell).await.unwrap();
678
679        // SimCell -> store -> reconciler -> gang: the cell is gang-scheduled.
680        let mut reconciler = forge.new_reconciler().await.unwrap();
681        let report = reconciler.reconcile_once().await.unwrap();
682        assert_eq!(report.sim_scheduled, 1, "sim cell should be gang-scheduled");
683        assert_eq!(reconciler.sim_bound_count(), 1);
684    }
685}