1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RuntimeState {
32 Stopped,
34 Starting,
36 Running,
38 ShuttingDown,
40}
41
42pub struct Forge {
44 config: ForgeConfig,
45 state: Arc<RwLock<RuntimeState>>,
46 node_id: NodeId,
47 start_time: Option<Instant>,
48
49 nomad: Option<NomadClient>,
51 store: BoxedStateStore,
52 router: BoxedMoERouter,
53 autoscaler: Autoscaler,
54 metrics: Option<Arc<ForgeMetrics>>,
55
56 jobs: DashMap<String, Job>,
58 experts: DashMap<usize, Expert>,
59 shards: DashMap<ShardId, Shard>,
60
61 scheduler_nodes: std::sync::Mutex<Vec<NodeResources>>,
63
64 shutdown_tx: broadcast::Sender<()>,
66}
67
68impl Forge {
69 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 pub fn node_id(&self) -> NodeId {
100 self.node_id
101 }
102
103 pub async fn state(&self) -> RuntimeState {
105 *self.state.read().await
106 }
107
108 pub fn uptime_secs(&self) -> u64 {
110 self.start_time
111 .map(|t| t.elapsed().as_secs())
112 .unwrap_or(0)
113 }
114
115 pub fn metrics(&self) -> Option<&Arc<ForgeMetrics>> {
117 self.metrics.as_ref()
118 }
119
120 pub fn store(&self) -> &BoxedStateStore {
122 &self.store
123 }
124
125 pub fn router(&self) -> &BoxedMoERouter {
127 &self.router
128 }
129
130 pub fn has_nomad(&self) -> bool {
132 self.nomad.is_some()
133 }
134
135 pub fn register_node(&self, node: NodeResources) {
140 self.scheduler_nodes.lock().unwrap().push(node);
141 }
142
143 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 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 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 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 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 let forge_state = Arc::new(RwLock::new(ForgeHttpState {
213 jobs: self.jobs.clone(),
214 metrics: self.metrics.clone(),
215 }));
216
217 let http_router = self.build_http_router(forge_state);
219
220 let http_server = HttpServer::new(self.config.http_config.clone())
222 .with_router(http_router);
223
224 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 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 self.save_state().await?;
257
258 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 pub fn signal_shutdown(&self) {
272 let _ = self.shutdown_tx.send(());
273 }
274
275 pub fn shutdown_receiver(&self) -> broadcast::Receiver<()> {
277 self.shutdown_tx.subscribe()
278 }
279
280 async fn load_state(&self) -> Result<()> {
283 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 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 pub async fn submit_job(&self, job: Job) -> Result<String> {
310 let job_id = job.id.clone();
311
312 if let Some(nomad) = &self.nomad {
314 nomad.submit_job(&job).await?;
315 }
316
317 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 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 pub fn get_job(&self, job_id: &str) -> Option<Job> {
343 self.jobs.get(job_id).map(|e| e.value().clone())
344 }
345
346 pub fn list_jobs(&self) -> Vec<Job> {
348 self.jobs.iter().map(|e| e.value().clone()).collect()
349 }
350
351 pub async fn stop_job(&self, job_id: &str, purge: bool) -> Result<()> {
353 if let Some(nomad) = &self.nomad {
355 nomad.stop_job(job_id, purge).await?;
356 }
357
358 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 pub async fn scale_job(&self, job_id: &str, group: &str, count: u32) -> Result<()> {
375 if let Some(nomad) = &self.nomad {
377 nomad
378 .scale_job(job_id, group, count, Some("Manual scale"))
379 .await?;
380 }
381
382 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 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 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 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 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 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 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
498struct ForgeHttpState {
500 jobs: DashMap<String, Job>,
501 metrics: Option<Arc<ForgeMetrics>>,
502}
503
504async 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 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 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 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}