diff --git a/crates/engram-coordinator/src/boot_materializer.rs b/crates/engram-coordinator/src/boot_materializer.rs index ae48186e5..d912ce77b 100644 --- a/crates/engram-coordinator/src/boot_materializer.rs +++ b/crates/engram-coordinator/src/boot_materializer.rs @@ -390,16 +390,30 @@ pub(crate) async fn materialize_snapshot_resume( }) } -/// The placement-budget slice of the cold-boot shape, for callers that -/// need `(memory_mib, vcpus)` and nothing else (the resume verb's -/// `ScheduleContext`, ADR 0078). Best-effort by contract: `None` on any -/// miss keeps the pre-0072 capacity-soft posture — deliberately NOT -/// `materialize_cold_boot`, which does per-session slot resolution this -/// caller would throw away. +/// The `(memory_mib, vcpus)` a resume, a queued resume, or a move of +/// `session` reserves: the budget the session records +/// (`MetadataStore::session_budget`). That budget is the size of the VM +/// its snapshot restores and the figure the host accounting holds for the +/// session. The image's current config decides only for a session with no +/// recorded budget: a config that changed after the create does not +/// resize the VM. `None` when neither source resolves; the caller then +/// places without a capacity check. pub(crate) async fn resolve_resume_budget( meta: &std::sync::Arc, session: &Session, ) -> Option<(u32, u32)> { + match meta.session_budget(session.id).await { + Ok(Some((mem_mib, vcpus))) => { + if let (Ok(mem_mib), Ok(vcpus)) = (u32::try_from(mem_mib), u32::try_from(vcpus)) { + return Some((mem_mib, vcpus)); + } + } + Ok(None) => {} + Err(e) => { + tracing::warn!(session_id = %session.id, error = %e, + "session budget lookup failed; the image config decides the resume budget"); + } + } match meta.get_enabled_image(&session.image).await { Ok(Some(row)) => { let config = row.effective_config(); @@ -687,6 +701,26 @@ mod readiness_tests { (state, meta, host, session, sandbox) } + /// A resume reserves what the session records, not what the image's + /// config says now: the two differ after an image config change, and + /// the host accounting holds the recorded figure. + #[tokio::test] + async fn the_resume_budget_is_the_sessions_recorded_budget() { + let (state, meta, _host, session, _sandbox) = fixture().await; + let from_image = resolve_resume_budget(&state.services.meta, &session).await; + assert_ne!(from_image, Some((24_576, 6))); + + meta.with_db_mut(|db| { + let row = db.sessions.get_mut(&session.id).expect("session row"); + row.mem_budget_mib = 24_576; + row.cpu_budget_vcpus = 6; + }); + assert_eq!( + resolve_resume_budget(&state.services.meta, &session).await, + Some((24_576, 6)) + ); + } + #[tokio::test] async fn resolution_error_never_becomes_active() { let (state, meta, host, session, sandbox) = fixture().await; diff --git a/crates/engram-core/src/traits/metadata.rs b/crates/engram-core/src/traits/metadata.rs index 438c5654f..ac5bb8a01 100644 --- a/crates/engram-core/src/traits/metadata.rs +++ b/crates/engram-core/src/traits/metadata.rs @@ -736,6 +736,14 @@ pub trait MetadataStore: Send + Sync { .collect()) } + /// The `(mem_budget_mib, cpu_budget_vcpus)` the session reserves: the + /// figures its VM was created with, which every later restore of that + /// VM keeps. `None` when the row records no memory budget. The default + /// is `None` (mocks carry no budgets). + async fn session_budget(&self, _id: SessionId) -> Result, MetaError> { + Ok(None) + } + /// ADR 0048 C8: the RESIDENT assignments on `host_id` WITH their /// reservation budgets, for the admin drain (the don't-strand guard /// pre-checks that some survivor fits each session's budgets before diff --git a/crates/engram-postgres/src/lib.rs b/crates/engram-postgres/src/lib.rs index 04279d928..6e2979716 100644 --- a/crates/engram-postgres/src/lib.rs +++ b/crates/engram-postgres/src/lib.rs @@ -2754,6 +2754,20 @@ impl MetadataStore for PostgresStore { Ok(out) } + async fn session_budget(&self, id: SessionId) -> Result, MetaError> { + let row: Option<(i64, i32)> = sqlx::query_as( + r#" + SELECT COALESCE(mem_budget_mib, 0)::BIGINT, COALESCE(cpu_budget_vcpus, 0) + FROM sessions WHERE id = $1 + "#, + ) + .bind(id.as_uuid()) + .fetch_optional(&self.pool) + .await + .map_err(db_err)?; + Ok(row.filter(|(mem, _)| *mem > 0)) + } + async fn list_resident_assignments_with_budgets_on_host( &self, host_id: HostId, diff --git a/crates/engram-sim/src/meta/store_impl.rs b/crates/engram-sim/src/meta/store_impl.rs index df73be0db..b64274732 100644 --- a/crates/engram-sim/src/meta/store_impl.rs +++ b/crates/engram-sim/src/meta/store_impl.rs @@ -4611,6 +4611,16 @@ impl MetadataStore for SimMetadataStore { panic!("SimMeta: latest_capture_job_for_enable not implemented — add it plus a conformance case (ADR 0098 D4)") } + async fn session_budget(&self, id: SessionId) -> Result, MetaError> { + self.gate()?; + let db = self.db.lock(); + Ok(db + .sessions + .get(&id) + .map(|r| (r.mem_budget_mib, r.cpu_budget_vcpus)) + .filter(|(mem, _)| *mem > 0)) + } + async fn list_resident_assignments_with_budgets_on_host( &self, host_id: HostId, diff --git a/crates/engram-sim/tests/meta_conformance.rs b/crates/engram-sim/tests/meta_conformance.rs index 291034d16..051d35b5d 100644 --- a/crates/engram-sim/tests/meta_conformance.rs +++ b/crates/engram-sim/tests/meta_conformance.rs @@ -3023,6 +3023,53 @@ async fn a_host_without_reported_cores_has_no_cpu_gate(ctx: &Ctx) { )); } +/// `session_budget` is the reservation the session was created with. A +/// session with no recorded budget, and an unknown session, have none. +async fn session_budget_is_the_create_time_reservation(ctx: &Ctx) { + let meta = &ctx.meta; + let now = ctx.clock.now_utc(); + let host = HostId::new(); + meta.upsert_host(host_record(host, "conf-budget", now)) + .await + .unwrap(); + let budgeted = SessionId::new(); + let placed = meta + .reserve_and_persist_create( + engram_core::traits::metadata::SessionCreateWriteSet { + session_id: budgeted, + spec: spec("conf:budget"), + mem_budget_mib: 4096, + cpu_budget_vcpus: 2, + sealed_secrets: None, + capabilities: Vec::new(), + integration_policy_json: None, + runtime_spec: engram_core::types::runtime_spec::RuntimeSpec::new( + Vec::new(), + None, + None, + Vec::new(), + ), + oauth_binding: None, + }, + &[host], + 0, + ) + .await + .unwrap(); + assert!(matches!( + placed, + engram_core::traits::metadata::CreateDisposition::Placed(_) + )); + assert_eq!( + meta.session_budget(budgeted).await.unwrap(), + Some((4096, 2)) + ); + + let unbudgeted = meta.create_session(spec("conf:no-budget")).await.unwrap(); + assert_eq!(meta.session_budget(unbudgeted).await.unwrap(), None); + assert_eq!(meta.session_budget(SessionId::new()).await.unwrap(), None); +} + /// The register-time rehydrate list and the reconcile assignment list are /// one listing: every VM-resident state with a bound sandbox on the host. /// A rung-parked `Evicting` survivor and a `Created` survivor are as @@ -5596,6 +5643,10 @@ conformance!( t_a_host_without_reported_cores_has_no_cpu_gate, super::a_host_without_reported_cores_has_no_cpu_gate ); +conformance!( + t_session_budget_is_the_create_time_reservation, + super::session_budget_is_the_create_time_reservation +); conformance!( t_enabled_image_config_update, super::enabled_image_config_update