Skip to main content

arkhe_kernel/runtime/
stage.rs

1//! `StepStage` — transactional COW staging for one `step()` (9 buckets).
2//!
3//! Every commit-conditional write the kernel performs during a step is
4//! buffered here; on commit, `Instance::apply_stage` drains in canonical
5//! canonical order; on rollback, the stage is dropped without effect.
6//!
7//! Buckets (9):
8//! 1. `state_ops` — entity/component mutations
9//! 2. `events` — KernelEvent emissions (kernel-level drain)
10//! 3. `schedule_deltas` — scheduler add/cancel
11//! 4. `pending_signals` — outbound IPC (kernel routes post-commit)
12//! 5. `id_counters` — monotonic ID counter advances
13//! 6. `ledger_delta` — ResourceLedger updates
14//! 7. `wall_remainder_delta` — sub-tick time accumulator advance
15//! 8. `local_tick_delta` — logical tick advance
16//! 9. `observer_eviction_pending` — observers slated for eviction
17//!
18//! In-flight signal refcounts are NOT staged: the only refcount writers are
19//! the cross-instance signal router (delivery-side increment — it spans
20//! instances and so cannot route through a single instance's stage) and
21//! `force_unload` (drain), both mutating `Instance::inflight_refs` directly.
22
23use std::collections::VecDeque;
24
25use bytes::Bytes;
26use serde::{Deserialize, Serialize};
27
28use crate::abi::{EntityId, InstanceId, Principal, RouteId, TypeCode};
29use crate::state::ledger::ResourceLedger;
30use crate::state::{EntityMeta, ScheduledActionId, ScheduledEntry};
31
32use super::event::{KernelEvent, ObserverHandle};
33
34#[derive(Debug, Default, Clone, Serialize, Deserialize)]
35pub(crate) struct StepStage {
36    pub state_ops: Vec<StagedStateDelta>,
37    pub events: VecDeque<KernelEvent>,
38    pub schedule_deltas: Vec<ScheduledEntryDelta>,
39    pub pending_signals: Vec<PendingSignal>,
40    pub id_counters: IdCountersDelta,
41    pub ledger_delta: ResourceLedgerDelta,
42    pub wall_remainder_delta: u128,
43    pub local_tick_delta: u64,
44    pub observer_eviction_pending: Vec<ObserverHandle>,
45}
46
47impl StepStage {
48    /// Reset every bucket to its empty/zero state, retaining allocated
49    /// capacity so the kernel can reuse one `StepStage` as a per-step scratch
50    /// across actions without reallocating the staging buffers. After
51    /// `clear()` the stage is behaviourally identical to
52    /// `StepStage::default()` (all nine buckets empty / zero), which
53    /// `clear_resets_all_buckets` pins. The `let Self { .. }` destructure is
54    /// exhaustive on purpose: a newly added bucket fails to compile here until
55    /// it is cleared too, so the scratch can never silently leak state across
56    /// actions (which would break determinism).
57    pub(crate) fn clear(&mut self) {
58        let Self {
59            state_ops,
60            events,
61            schedule_deltas,
62            pending_signals,
63            id_counters,
64            ledger_delta,
65            wall_remainder_delta,
66            local_tick_delta,
67            observer_eviction_pending,
68        } = self;
69        state_ops.clear();
70        events.clear();
71        schedule_deltas.clear();
72        pending_signals.clear();
73        *id_counters = IdCountersDelta::default();
74        ledger_delta.ops.clear();
75        *wall_remainder_delta = 0;
76        *local_tick_delta = 0;
77        observer_eviction_pending.clear();
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub(crate) enum StagedStateDelta {
83    SpawnEntity {
84        id: EntityId,
85        meta: EntityMeta,
86    },
87    DespawnEntity {
88        id: EntityId,
89    },
90    SetComponent {
91        entity: EntityId,
92        type_code: TypeCode,
93        bytes: Bytes,
94        size: u64,
95    },
96    RemoveComponent {
97        entity: EntityId,
98        type_code: TypeCode,
99        size: u64,
100    },
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub(crate) enum ScheduledEntryDelta {
105    Add(ScheduledEntry),
106    Remove(ScheduledActionId),
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub(crate) struct PendingSignal {
111    pub target: InstanceId,
112    pub route: RouteId,
113    pub payload: Bytes,
114    pub principal: Principal,
115}
116
117#[derive(Debug, Default, Clone, Serialize, Deserialize)]
118pub(crate) struct IdCountersDelta {
119    pub next_entity_advance: u64,
120    pub next_scheduled_advance: u64,
121    pub next_source_seq_advance: u64,
122}
123
124#[derive(Debug, Default, Clone, Serialize, Deserialize)]
125pub(crate) struct ResourceLedgerDelta {
126    pub ops: Vec<LedgerOp>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub(crate) enum LedgerOp {
131    AddEntity(EntityId),
132    RemoveEntity(EntityId),
133    AddComponent {
134        entity: EntityId,
135        type_code: TypeCode,
136        size: u64,
137    },
138    RemoveComponent {
139        entity: EntityId,
140        type_code: TypeCode,
141        size: u64,
142    },
143}
144
145/// Project the post-commit component-byte total: `baseline` (the ledger's
146/// current `total_bytes`) with every staged component delta in `stage`
147/// applied, in saturating `u64` — matching [`ResourceLedger`]'s own
148/// arithmetic exactly. `step()` budget enforcement compares this (plus the
149/// not-yet-staged current Op) against `memory_budget_bytes`.
150///
151/// - `AddComponent` adds its caller-declared `size`. A replace over-counts
152///   by the prior size (the ledger would adjust by the delta) — conservative,
153///   favoring false-deny, so the projection stays an upper bound on the
154///   post-commit total.
155/// - `RemoveComponent` credits only the bytes the removal will actually free
156///   (the ledger's *stored* size), never the untrusted caller-declared
157///   `size`, so an inflated remove cannot poison the projection. A component
158///   added earlier in this same step is not yet in `ledger`, so its removal
159///   credits 0 — conservative.
160/// - `AddEntity` / `RemoveEntity` carry no component bytes here.
161///
162/// Working in `u64` (not `i64`) is deliberate: an oversized add saturates to
163/// `u64::MAX` and trips any budget below `u64::MAX`, including budgets above
164/// `i64::MAX` that an i64 threshold round-trip would silently disable.
165pub(crate) fn projected_component_bytes(
166    baseline: u64,
167    stage: &StepStage,
168    ledger: &ResourceLedger,
169) -> u64 {
170    let mut total = baseline;
171    for op in &stage.ledger_delta.ops {
172        match op {
173            LedgerOp::AddComponent { size, .. } => {
174                total = total.saturating_add(*size);
175            }
176            LedgerOp::RemoveComponent {
177                entity, type_code, ..
178            } => {
179                let freed = ledger.component_size(*entity, *type_code).unwrap_or(0);
180                total = total.saturating_sub(freed);
181            }
182            LedgerOp::AddEntity(_) | LedgerOp::RemoveEntity(_) => {}
183        }
184    }
185    total
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::abi::{CapabilityMask, Tick};
192
193    #[test]
194    fn step_stage_default_all_buckets_empty() {
195        let s = StepStage::default();
196        assert!(s.state_ops.is_empty());
197        assert!(s.events.is_empty());
198        assert!(s.schedule_deltas.is_empty());
199        assert!(s.pending_signals.is_empty());
200        assert_eq!(s.id_counters.next_entity_advance, 0);
201        assert_eq!(s.id_counters.next_scheduled_advance, 0);
202        assert_eq!(s.id_counters.next_source_seq_advance, 0);
203        assert!(s.ledger_delta.ops.is_empty());
204        assert_eq!(s.wall_remainder_delta, 0);
205        assert_eq!(s.local_tick_delta, 0);
206        assert!(s.observer_eviction_pending.is_empty());
207    }
208
209    #[test]
210    fn clear_resets_all_buckets() {
211        // Populate every bucket, then assert clear() returns the stage to the
212        // same emptiness as StepStage::default() (so scratch reuse cannot leak
213        // state across actions).
214        let id = EntityId::new(1).unwrap();
215        let mut s = StepStage::default();
216        s.state_ops.push(StagedStateDelta::DespawnEntity { id });
217        s.events.push_back(KernelEvent::ActionExecuted {
218            instance: InstanceId::new(1).unwrap(),
219            action_type: TypeCode(1),
220            at: Tick(1),
221        });
222        s.schedule_deltas
223            .push(ScheduledEntryDelta::Remove(ScheduledActionId::new(1).unwrap()));
224        s.pending_signals.push(PendingSignal {
225            target: InstanceId::new(1).unwrap(),
226            route: RouteId(1),
227            payload: Bytes::from_static(b"x"),
228            principal: Principal::System,
229        });
230        s.id_counters.next_entity_advance = 9;
231        s.id_counters.next_scheduled_advance = 9;
232        s.id_counters.next_source_seq_advance = 9;
233        s.ledger_delta.ops.push(LedgerOp::AddEntity(id));
234        s.wall_remainder_delta = 12345;
235        s.local_tick_delta = 7;
236
237        s.clear();
238
239        assert!(s.state_ops.is_empty());
240        assert!(s.events.is_empty());
241        assert!(s.schedule_deltas.is_empty());
242        assert!(s.pending_signals.is_empty());
243        assert_eq!(s.id_counters.next_entity_advance, 0);
244        assert_eq!(s.id_counters.next_scheduled_advance, 0);
245        assert_eq!(s.id_counters.next_source_seq_advance, 0);
246        assert!(s.ledger_delta.ops.is_empty());
247        assert_eq!(s.wall_remainder_delta, 0);
248        assert_eq!(s.local_tick_delta, 0);
249        assert!(s.observer_eviction_pending.is_empty());
250    }
251
252    #[test]
253    fn staged_state_delta_variants_clone() {
254        let id = EntityId::new(1).unwrap();
255        let meta = EntityMeta {
256            owner: Principal::System,
257            created: Tick(0),
258        };
259        let _ = StagedStateDelta::SpawnEntity { id, meta }.clone();
260        let _ = StagedStateDelta::DespawnEntity { id }.clone();
261        let _ = StagedStateDelta::SetComponent {
262            entity: id,
263            type_code: TypeCode(1),
264            bytes: Bytes::from_static(b"x"),
265            size: 1,
266        }
267        .clone();
268        let _ = StagedStateDelta::RemoveComponent {
269            entity: id,
270            type_code: TypeCode(1),
271            size: 1,
272        }
273        .clone();
274    }
275
276    #[test]
277    fn scheduled_entry_delta_variants() {
278        let entry = ScheduledEntry {
279            id: ScheduledActionId::new(1).unwrap(),
280            at: Tick(0),
281            actor: None,
282            principal: Principal::System,
283            action_type_code: TypeCode(0),
284            action_bytes: vec![],
285            caps_ceiling: CapabilityMask::all(),
286        };
287        let _ = ScheduledEntryDelta::Add(entry).clone();
288        let _ = ScheduledEntryDelta::Remove(ScheduledActionId::new(1).unwrap()).clone();
289    }
290
291    #[test]
292    fn projected_oversized_add_saturates_no_negative_wrap() {
293        // Regression: a size above i64::MAX must saturate the projection to
294        // u64::MAX (not wrap negative / clamp low), so an oversized component
295        // trips any budget below u64::MAX rather than bypassing the gate.
296        let id = EntityId::new(1).unwrap();
297        let mut stage = StepStage::default();
298        stage.ledger_delta.ops.push(LedgerOp::AddComponent {
299            entity: id,
300            type_code: TypeCode(1),
301            size: u64::MAX,
302        });
303        let ledger = ResourceLedger::new();
304        assert_eq!(projected_component_bytes(0, &stage, &ledger), u64::MAX);
305    }
306
307    #[test]
308    fn projected_remove_credits_stored_size_not_caller_size() {
309        // Regression: a RemoveComponent with an inflated caller `size` must
310        // NOT poison the projection — the credit is the ledger's stored size.
311        let id = EntityId::new(1).unwrap();
312        let mut ledger = ResourceLedger::new();
313        ledger.add_entity(id);
314        ledger.add_component(id, TypeCode(1), 100);
315        let mut stage = StepStage::default();
316        stage.ledger_delta.ops.push(LedgerOp::RemoveComponent {
317            entity: id,
318            type_code: TypeCode(1),
319            size: u64::MAX, // bogus inflated free — must be ignored
320        });
321        // Baseline 100, credit the stored 100 (not u64::MAX) → 0.
322        assert_eq!(projected_component_bytes(100, &stage, &ledger), 0);
323    }
324
325    #[test]
326    fn projected_remove_of_unaccounted_component_credits_zero() {
327        // A removal of a component the ledger does not know (e.g. added
328        // earlier in the same step, not yet applied) credits 0 — conservative.
329        let id = EntityId::new(1).unwrap();
330        let ledger = ResourceLedger::new();
331        let mut stage = StepStage::default();
332        stage.ledger_delta.ops.push(LedgerOp::RemoveComponent {
333            entity: id,
334            type_code: TypeCode(1),
335            size: 50,
336        });
337        assert_eq!(projected_component_bytes(0, &stage, &ledger), 0);
338    }
339
340    #[test]
341    fn ledger_op_variants() {
342        let id = EntityId::new(1).unwrap();
343        let _ = LedgerOp::AddEntity(id).clone();
344        let _ = LedgerOp::RemoveEntity(id).clone();
345        let _ = LedgerOp::AddComponent {
346            entity: id,
347            type_code: TypeCode(1),
348            size: 100,
349        }
350        .clone();
351        let _ = LedgerOp::RemoveComponent {
352            entity: id,
353            type_code: TypeCode(1),
354            size: 100,
355        }
356        .clone();
357    }
358
359    #[test]
360    fn pending_signal_construction() {
361        let s = PendingSignal {
362            target: InstanceId::new(1).unwrap(),
363            route: RouteId(1),
364            payload: Bytes::from_static(b"hello"),
365            principal: Principal::System,
366        };
367        assert_eq!(s.payload.len(), 5);
368    }
369}