Skip to main content

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}