From 6a81cf9317d502b104c6860a37c8117c750e00f2 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Tue, 10 Nov 2026 16:39:43 -0800 Subject: [PATCH] coordinator: a resume reserves the session's recorded budget A resume, a queued resume and a move took their budget from the image's current config. The session row keeps the budget of the create, and the host accounting, the queue and the park rule all read the row. After an image config change the two figures differ for every older session. With a larger image budget, the resume fit check found no host while the park rule saw the queued session fit one, so no park yielded. The session stayed queued until the fleet added a host. A move of such a session was refused as no_fit for the same reason. resolve_resume_budget now reads the recorded budget through the new MetadataStore::session_budget. The image config decides only for a session with no recorded budget. A snapshot restores the VM at its original size, so the recorded figure is also the correct one. Tests: the_resume_budget_is_the_sessions_recorded_budget; conformance scenario session_budget_is_the_create_time_reservation on the simulator and Postgres. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01HNhpmz4QPYaVCE7tFna54D --- .../src/boot_materializer.rs | 46 ++++++++++++++--- crates/engram-core/src/traits/metadata.rs | 8 +++ crates/engram-postgres/src/lib.rs | 14 +++++ crates/engram-sim/src/meta/store_impl.rs | 10 ++++ crates/engram-sim/tests/meta_conformance.rs | 51 +++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) 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