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, 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 fn get_job(&self, job_id: &str) -> Option<Job> {
332 self.jobs.get(job_id).map(|e| e.value().clone())
333 }
334
335 pub fn list_jobs(&self) -> Vec<Job> {
337 self.jobs.iter().map(|e| e.value().clone()).collect()
338 }
339
340 pub async fn stop_job(&self, job_id: &str, purge: bool) -> Result<()> {
342 if let Some(nomad) = &self.nomad {
344 nomad.stop_job(job_id, purge).await?;
345 }
346
347 if purge {
349 let key = keys::job(job_id);
350 self.store.delete(&key).await?;
351 self.jobs.remove(job_id);
352 }
353
354 if let Some(metrics) = &self.metrics {
355 metrics.record_job_completed(true);
356 }
357
358 info!(job_id = %job_id, purge = purge, "Job stopped");
359 Ok(())
360 }
361
362 pub async fn scale_job(&self, job_id: &str, group: &str, count: u32) -> Result<()> {
364 if let Some(nomad) = &self.nomad {
366 nomad
367 .scale_job(job_id, group, count, Some("Manual scale"))
368 .await?;
369 }
370
371 if let Some(mut job) = self.jobs.get_mut(job_id) {
373 for g in &mut job.groups {
374 if g.name == group {
375 g.scaling.desired = count;
376 break;
377 }
378 }
379 }
380
381 if let Some(metrics) = &self.metrics {
382 let direction = "manual";
383 metrics.record_scale_event(job_id, direction);
384 metrics.set_instances(job_id, group, count as f64);
385 }
386
387 info!(job_id = %job_id, group = %group, count = count, "Job scaled");
388 Ok(())
389 }
390
391 pub async fn route(&self, input: &str) -> RouteResult {
395 let experts: Vec<Expert> = self.experts.iter().map(|e| e.value().clone()).collect();
396
397 let result = if experts.is_empty() {
398 self.router.route(input, 8).await
399 } else {
400 self.router.route_with_experts(input, &experts).await
401 };
402
403 if let Some(metrics) = &self.metrics {
404 metrics.record_route(self.router.name(), result.expert_index, 0.001);
405 }
406
407 result
408 }
409
410 pub fn register_expert(&self, expert: Expert) {
412 info!(index = expert.index, node = %expert.node, "Expert registered");
413 self.experts.insert(expert.index, expert);
414 }
415
416 pub fn update_expert_load(&self, index: usize, load: f64) {
418 if let Some(mut expert) = self.experts.get_mut(&index) {
419 expert.update_load(load);
420 }
421 }
422
423 pub async fn evaluate_scaling(
427 &self,
428 job_id: &str,
429 cpu: f64,
430 memory: f64,
431 instances: u32,
432 ) -> ScalingDecision {
433 let metrics = MetricsSnapshot::new(cpu, memory, instances);
434 let decision = self.autoscaler.evaluate(job_id, metrics).await;
435
436 if decision.is_scaling() {
441 let groups: Vec<(String, u32)> = self
442 .jobs
443 .get(job_id)
444 .map(|j| {
445 j.groups
446 .iter()
447 .map(|g| (g.name.clone(), g.scaling.desired))
448 .collect()
449 })
450 .unwrap_or_default();
451
452 for (group, current) in groups {
453 let target = match &decision {
454 ScalingDecision::ScaleUp(n) => current.saturating_add(*n),
455 ScalingDecision::ScaleDown(n) => current.saturating_sub(*n),
456 ScalingDecision::ScaleTo(c) => *c,
457 ScalingDecision::NoChange => current,
458 };
459 if target != current {
460 if let Err(e) = self.scale_job(job_id, &group, target).await {
461 warn!(job_id = %job_id, group = %group, error = %e, "autoscale execution failed");
462 }
463 }
464 }
465 }
466
467 decision
468 }
469
470 fn build_http_router(&self, state: Arc<RwLock<ForgeHttpState>>) -> Router {
473 let state = HttpState { app: state };
474
475 Router::new()
476 .route("/health", get(health_handler))
477 .route("/ready", get(ready_handler))
478 .route("/api/v1/jobs", get(list_jobs_handler))
479 .route("/api/v1/jobs", post(submit_job_handler))
480 .route("/api/v1/jobs/:id", get(get_job_handler))
481 .route("/api/v1/jobs/:id", delete(stop_job_handler))
482 .route("/metrics", get(metrics_handler))
483 .with_state(state)
484 }
485}
486
487struct ForgeHttpState {
489 jobs: DashMap<String, Job>,
490 metrics: Option<Arc<ForgeMetrics>>,
491}
492
493async fn health_handler() -> Json<serde_json::Value> {
496 Json(serde_json::json!({
497 "status": "healthy",
498 "version": env!("CARGO_PKG_VERSION")
499 }))
500}
501
502async fn ready_handler() -> axum::http::StatusCode {
503 axum::http::StatusCode::OK
504}
505
506async fn list_jobs_handler(
507 State(state): State<HttpState<ForgeHttpState>>,
508) -> Json<Vec<Job>> {
509 let app = state.app.read().await;
510 let jobs: Vec<Job> = app.jobs.iter().map(|e| e.value().clone()).collect();
511 Json(jobs)
512}
513
514async fn get_job_handler(
515 State(state): State<HttpState<ForgeHttpState>>,
516 Path(id): Path<String>,
517) -> std::result::Result<Json<Job>, axum::http::StatusCode> {
518 let app = state.app.read().await;
519 app.jobs
520 .get(&id)
521 .map(|e| Json(e.value().clone()))
522 .ok_or(axum::http::StatusCode::NOT_FOUND)
523}
524
525async fn submit_job_handler(
526 State(state): State<HttpState<ForgeHttpState>>,
527 Json(job): Json<Job>,
528) -> std::result::Result<Json<serde_json::Value>, axum::http::StatusCode> {
529 let app = state.app.read().await;
530 let job_id = job.id.clone();
531 app.jobs.insert(job_id.clone(), job);
532 Ok(Json(serde_json::json!({ "job_id": job_id })))
533}
534
535async fn stop_job_handler(
536 State(state): State<HttpState<ForgeHttpState>>,
537 Path(id): Path<String>,
538) -> axum::http::StatusCode {
539 let app = state.app.read().await;
540 if app.jobs.remove(&id).is_some() {
541 axum::http::StatusCode::NO_CONTENT
542 } else {
543 axum::http::StatusCode::NOT_FOUND
544 }
545}
546
547async fn metrics_handler(
548 State(state): State<HttpState<ForgeHttpState>>,
549) -> std::result::Result<String, axum::http::StatusCode> {
550 let app = state.app.read().await;
551 match &app.metrics {
552 Some(m) => m
553 .gather_text()
554 .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR),
555 None => Err(axum::http::StatusCode::NOT_FOUND),
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::builder::ForgeBuilder;
563 use crate::job::{Driver, Task};
564
565 #[tokio::test]
566 async fn test_forge_creation() {
567 let forge = ForgeBuilder::new().build().unwrap();
568 assert_eq!(forge.state().await, RuntimeState::Stopped);
569 }
570
571 #[tokio::test]
572 async fn test_job_management() {
573 let forge = ForgeBuilder::new().build().unwrap();
574
575 let job = Job::new("test-job").with_group(
576 "api",
577 Task::new("server")
578 .driver(Driver::Exec)
579 .command("/bin/server"),
580 );
581
582 let job_id = forge.submit_job(job).await.unwrap();
583 assert!(forge.get_job(&job_id).is_some());
584
585 let jobs = forge.list_jobs();
586 assert_eq!(jobs.len(), 1);
587
588 forge.stop_job(&job_id, true).await.unwrap();
589 assert!(forge.get_job(&job_id).is_none());
590 }
591
592 #[tokio::test]
593 async fn test_routing() {
594 let forge = ForgeBuilder::new().build().unwrap();
595
596 let result = forge.route("test-input").await;
597 assert!(result.expert_index < 8);
598 }
599
600 #[tokio::test]
601 async fn test_expert_registration() {
602 let forge = ForgeBuilder::new().build().unwrap();
603
604 let expert = Expert::new(0, NodeId::new());
605 forge.register_expert(expert);
606
607 forge.update_expert_load(0, 0.5);
608 }
609
610 #[tokio::test]
611 async fn test_evaluate_scaling_executes_decision() {
612 let forge = ForgeBuilder::new().build().unwrap();
613 let mut job = Job::new("svc").with_group(
614 "api",
615 Task::new("s").driver(Driver::Exec).command("/bin/s"),
616 );
617 job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(2);
618 let id = forge.submit_job(job).await.unwrap();
619
620 let decision = forge.evaluate_scaling(&id, 0.95, 0.5, 2).await;
622 assert!(matches!(decision, ScalingDecision::ScaleUp(_)));
623
624 let updated = forge.get_job(&id).unwrap();
625 assert_eq!(updated.groups[0].scaling.desired, 3, "scale decision must be executed");
626 }
627
628 #[tokio::test]
629 async fn test_reconciler_integration_schedules_jobs() {
630 let forge = ForgeBuilder::new().build().unwrap();
631 forge.register_node(NodeResources::new(NodeId::new(), 8000, 8192));
632
633 let job = Job::new("svc").with_group(
634 "api",
635 Task::new("s")
636 .driver(Driver::Exec)
637 .command("/bin/s")
638 .resources(1000, 1024),
639 );
640 forge.submit_job(job).await.unwrap();
641
642 let mut reconciler = forge.new_reconciler().await.unwrap();
644 let report = reconciler.reconcile_once().await.unwrap();
645 assert!(report.scheduled >= 1, "reconciler should schedule the submitted replica");
646 assert!(reconciler.bound_count() >= 1);
647 }
648}