khora_data/ecs/maintenance.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! ECS maintenance subsystem — page compaction (orphan-row reclamation).
16//!
17//! A direct maintenance service for the ECS World. Unlike Agents there is no
18//! strategy negotiation: it compacts a fixed number of pages per frame to keep
19//! frame times predictable. This is the Stage-6 (`TickPhase::Maintenance`)
20//! housekeeping the frame model describes — Data-owned and self-budgeted.
21//!
22//! A component migration (`add_component` / `remove_component` /
23//! `remove_component_domain`) repoints an entity's metadata to a new page but
24//! leaves the old physical row orphaned, recording the source page in the
25//! World's dirty set (`StorageManager::dirty_pages`). No migration call site has
26//! to remember to forward anything. Each frame [`EcsMaintenance::tick`] drains up
27//! to `max_per_frame` dirty pages and compacts them via
28//! [`World::run_compaction`], which physically drops the fully-dead rows.
29//!
30//! # Usage
31//!
32//! ```rust,ignore
33//! use khora_data::ecs::{World, EcsMaintenance};
34//!
35//! let mut world = World::new();
36//! let mut maintenance = EcsMaintenance::new();
37//!
38//! // Each frame:
39//! maintenance.tick(&mut world);
40//! ```
41
42use super::World;
43
44const DEFAULT_MAX_PER_FRAME: usize = 10;
45
46/// Direct ECS maintenance service.
47///
48/// Compacts up to `max_per_frame` dirty pages each frame, reclaiming the
49/// orphaned rows left by component migrations. This replaces the former
50/// `GarbageCollectorAgent`.
51pub struct EcsMaintenance {
52 max_per_frame: usize,
53 last_compacted_count: usize,
54}
55
56impl EcsMaintenance {
57 /// Creates a new maintenance service with the default per-frame budget.
58 pub fn new() -> Self {
59 Self {
60 max_per_frame: DEFAULT_MAX_PER_FRAME,
61 last_compacted_count: 0,
62 }
63 }
64
65 /// Creates a new maintenance service with a custom per-frame page budget.
66 pub fn with_budget(max_per_frame: usize) -> Self {
67 Self {
68 max_per_frame,
69 last_compacted_count: 0,
70 }
71 }
72
73 /// Runs one frame of maintenance: compacts up to `max_per_frame` dirty pages.
74 ///
75 /// Dirty pages beyond the budget stay queued for the next frame — harmless,
76 /// since the query layer already skips orphan rows.
77 pub fn tick(&mut self, world: &mut World) {
78 self.last_compacted_count = world.run_compaction(self.max_per_frame);
79
80 if self.last_compacted_count > 0 {
81 log::trace!(
82 "EcsMaintenance: compacted {} page(s)",
83 self.last_compacted_count
84 );
85 }
86 }
87
88 /// Number of pages compacted in the last [`tick`](Self::tick).
89 pub fn last_compacted_count(&self) -> usize {
90 self.last_compacted_count
91 }
92
93 /// The maximum number of pages compacted per frame.
94 pub fn max_per_frame(&self) -> usize {
95 self.max_per_frame
96 }
97}
98
99impl Default for EcsMaintenance {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn empty_tick_compacts_nothing() {
111 let mut world = World::new();
112 let mut maintenance = EcsMaintenance::new();
113 maintenance.tick(&mut world);
114 assert_eq!(maintenance.last_compacted_count(), 0);
115 }
116
117 #[test]
118 fn budget_is_configurable() {
119 let maintenance = EcsMaintenance::with_budget(20);
120 assert_eq!(maintenance.max_per_frame(), 20);
121 }
122
123 #[test]
124 fn default_budget() {
125 assert_eq!(EcsMaintenance::new().max_per_frame(), DEFAULT_MAX_PER_FRAME);
126 }
127}