Skip to main content

concinnity_host/store/blob/
data.rs

1// Payload residency: which blob payload sections are in memory right now.
2//
3// This is runtime memory policy, not format, so it sits here rather than in the
4// concinnity-blob format crate -- it deals in file paths and lazy disk reads,
5// both of which that crate is deliberately free of.
6
7use concinnity_core::ecs::PayloadLocator;
8use concinnity_core::result::CnResult;
9
10// State of one blob file's payload section.
11//
12// `Unloaded` is the lazy state of an overflow blob: its file is on disk but has
13// not been read yet. `Loaded` holds the resident bytes. `Released` means a
14// system deliberately freed the payload after consuming it -- reads then error
15// rather than reload, since the data is known to be no longer needed.
16enum BlobSlot {
17    // overflow blob not yet read; the String is its file path
18    Unloaded(String),
19    // payload section resident in memory
20    Loaded(Vec<u8>),
21    // payload deliberately released after use; reads error, no reload
22    Released,
23}
24
25/// Holds the raw payload sections of each blob file.
26///
27/// Indexed by `PayloadLocator::blob_index`. Blob 0's payload section is loaded
28/// eagerly by `load_raw()` -- it carries the defs and the primary payloads and
29/// is needed immediately. Overflow blobs (1, 2, ...) start `Unloaded` and are
30/// read from disk on demand the first time a locator references them, so a
31/// large world does not pay the RAM (or I/O) cost of every overflow blob at
32/// startup.
33///
34/// Systems call `release(blob_index)` after consuming a blob's payloads (e.g.
35/// after uploading SPIR-V to the GPU) so the memory is freed promptly.
36pub struct BlobData {
37    // slots[i] is the payload state of blob i
38    slots: Vec<BlobSlot>,
39    // True when the payloads came from blob files on disk (the `cn run` path).
40    // False for in-memory builds (`cn debug`) and empty stores. The
41    // asset-streaming subsystem reads this to decide whether a streamed
42    // payload can be re-read from its blob file on demand instead of held
43    // RAM-resident.
44    disk_backed: bool,
45}
46
47impl BlobData {
48    /// Build an in-memory store where every section is already resident. Used
49    /// by the `cn debug` path, which compiles payloads in memory with no blob
50    /// files, so there is nothing to lazily load. A `None` section is treated
51    /// as already released.
52    pub fn new(payload_sections: Vec<Option<Vec<u8>>>) -> Self {
53        let slots = payload_sections
54            .into_iter()
55            .map(|s| match s {
56                Some(bytes) => BlobSlot::Loaded(bytes),
57                None => BlobSlot::Released,
58            })
59            .collect();
60        Self {
61            slots,
62            disk_backed: false,
63        }
64    }
65
66    /// empty store for worlds with no compiled payloads (tests, runtime-only worlds)
67    pub fn empty() -> Self {
68        Self {
69            slots: Vec::new(),
70            disk_backed: false,
71        }
72    }
73
74    // Build a disk-backed store: blob 0's section is already resident, each
75    // overflow path stays `Unloaded` until a locator first reaches it.
76    pub(super) fn from_blob_files(blob0_payload: Vec<u8>, overflow_paths: Vec<String>) -> Self {
77        let mut slots = Vec::with_capacity(overflow_paths.len() + 1);
78        slots.push(BlobSlot::Loaded(blob0_payload));
79        slots.extend(overflow_paths.into_iter().map(BlobSlot::Unloaded));
80        Self {
81            slots,
82            disk_backed: true,
83        }
84    }
85
86    /// true when the payloads were loaded from blob files on disk, so a
87    /// streamed payload can be re-read from disk rather than kept in RAM
88    pub fn disk_backed(&self) -> bool {
89        self.disk_backed
90    }
91
92    /// read the bytes for a given locator
93    ///
94    /// An `Unloaded` overflow blob is read from its file on first access and
95    /// becomes `Loaded`. Errors if the locator is out of range, the blob was
96    /// released, or the on-demand load fails.
97    pub fn read(&mut self, locator: &PayloadLocator) -> Result<&[u8], CnResult> {
98        let idx = locator.blob_index as usize;
99        let slot = self.slots.get_mut(idx).ok_or_else(|| {
100            tracing::error!("BlobData: blob {} is out of range", locator.blob_index);
101            CnResult::FileIo
102        })?;
103        if let BlobSlot::Unloaded(path) = slot {
104            tracing::debug!(
105                "BlobData: lazily loading overflow blob {}",
106                locator.blob_index
107            );
108            let bytes = super::read_payload_section(&path.clone())?;
109            *slot = BlobSlot::Loaded(bytes);
110        }
111
112        let section = match &self.slots[idx] {
113            BlobSlot::Loaded(bytes) => bytes,
114            BlobSlot::Released => {
115                tracing::error!("BlobData: blob {} has been released", locator.blob_index);
116                return Err(CnResult::FileIo);
117            }
118            // Unreachable: an Unloaded slot was loaded just above.
119            BlobSlot::Unloaded(_) => return Err(CnResult::FileIo),
120        };
121
122        let start = locator.offset as usize;
123        let end = start.checked_add(locator.len as usize).ok_or_else(|| {
124            tracing::error!(
125                "BlobData: payload slice offset {} + len {} overflows in blob {}",
126                start,
127                locator.len,
128                locator.blob_index
129            );
130            CnResult::FileIo
131        })?;
132        section.get(start..end).ok_or_else(|| {
133            tracing::error!(
134                "BlobData: payload slice [{}, {}) out of bounds in blob {} (len={})",
135                start,
136                end,
137                locator.blob_index,
138                section.len()
139            );
140            CnResult::FileIo
141        })
142    }
143
144    /// release a blob's in-memory payload once all systems that need it have
145    /// finished consuming it (e.g. after GPU upload)
146    ///
147    /// subsequent `read()` calls for locators in this blob return an error
148    /// rather than reloading -- the data is known to no longer be needed -- so
149    /// only call this once you are sure no other system needs it
150    pub fn release(&mut self, blob_index: u32) {
151        if let Some(slot) = self.slots.get_mut(blob_index as usize)
152            && !matches!(slot, BlobSlot::Released)
153        {
154            tracing::debug!("BlobData: releasing payload for blob {}", blob_index);
155            *slot = BlobSlot::Released;
156        }
157    }
158
159    /// Release every payload section still resident, called once every system
160    /// has finished init. Systems read compiled payloads only during init and
161    /// cache what they keep (GPU uploads, decoded audio clips, streaming
162    /// sources that own their extracted bytes or re-read from disk), so nothing
163    /// consults `BlobData` again at runtime -- the resident sections are dead
164    /// weight past `World::start`. Never-loaded overflow slots are left as they
165    /// are: they hold only a file path and were needed by no system. Returns the
166    /// number of bytes freed.
167    pub fn release_all_resident(&mut self) -> usize {
168        let mut freed = 0;
169        for slot in &mut self.slots {
170            if let BlobSlot::Loaded(bytes) = slot {
171                freed += bytes.len();
172                *slot = BlobSlot::Released;
173            }
174        }
175        freed
176    }
177
178    // true if the blob's payload is resident in memory right now; an
179    // `Unloaded` overflow blob reports false until its first read
180    #[cfg(test)]
181    pub(crate) fn is_loaded(&self, blob_index: u32) -> bool {
182        matches!(
183            self.slots.get(blob_index as usize),
184            Some(BlobSlot::Loaded(_))
185        )
186    }
187}
188
189// The runtime `PayloadStore` a `PipelineContext` hands to systems. A thin
190// adapter over the inherent API so the pure ECS mechanism names no blob type.
191impl concinnity_core::ecs::PayloadStore for BlobData {
192    fn read(&mut self, locator: &PayloadLocator) -> Result<&[u8], CnResult> {
193        BlobData::read(self, locator)
194    }
195
196    fn release(&mut self, blob_index: u32) {
197        BlobData::release(self, blob_index)
198    }
199
200    fn disk_backed(&self) -> bool {
201        BlobData::disk_backed(self)
202    }
203
204    fn release_all_resident(&mut self) -> usize {
205        BlobData::release_all_resident(self)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use concinnity_core::SCHEMA_VERSION;
213    use concinnity_core::blob::{BlobMeta, encode_cnb};
214
215    fn locator(blob_index: u32, offset: u64, len: u64) -> PayloadLocator {
216        PayloadLocator {
217            blob_index,
218            offset,
219            len,
220        }
221    }
222
223    #[test]
224    fn disk_backed_defaults_false() {
225        assert!(!BlobData::empty().disk_backed());
226        assert!(!BlobData::new(vec![Some(vec![1, 2, 3])]).disk_backed());
227    }
228
229    #[test]
230    fn from_blob_files_is_disk_backed_with_blob0_resident() {
231        let bd = BlobData::from_blob_files(b"primary".to_vec(), vec!["1".into(), "2".into()]);
232        assert!(bd.disk_backed());
233        assert!(bd.is_loaded(0));
234        // overflow slots stay deferred until first read
235        assert!(!bd.is_loaded(1));
236        assert!(!bd.is_loaded(2));
237    }
238
239    #[test]
240    fn read_lazily_loads_an_unloaded_overflow_blob() {
241        let dir = tempfile::tempdir().unwrap();
242        let path = dir.path().join("1").to_string_lossy().into_owned();
243        let image = encode_cnb(SCHEMA_VERSION, &BlobMeta::default(), b"hello world").unwrap();
244        std::fs::write(&path, image).expect("write blob");
245
246        let mut bd = BlobData::from_blob_files(Vec::new(), vec![path]);
247        assert!(!bd.is_loaded(1));
248
249        assert_eq!(bd.read(&locator(1, 6, 5)).expect("read ok"), b"world");
250        // the lazy load promoted the slot to resident
251        assert!(bd.is_loaded(1));
252    }
253
254    #[test]
255    fn read_errors_when_a_deferred_overflow_blob_is_missing() {
256        let mut bd = BlobData::from_blob_files(Vec::new(), vec!["/nonexistent/cn/1".into()]);
257        assert_eq!(bd.read(&locator(1, 0, 1)), Err(CnResult::FileIo));
258    }
259
260    #[test]
261    fn read_errors_on_released_blob() {
262        // a `None` section is treated as already released
263        let mut bd = BlobData::new(vec![None]);
264        assert!(bd.read(&locator(0, 0, 1)).is_err());
265    }
266
267    #[test]
268    fn release_then_read_errors() {
269        let mut bd = BlobData::new(vec![Some(b"abcd".to_vec())]);
270        assert_eq!(bd.read(&locator(0, 0, 2)).expect("read ok"), b"ab");
271        bd.release(0);
272        assert!(!bd.is_loaded(0));
273        assert!(bd.read(&locator(0, 0, 2)).is_err());
274    }
275
276    #[test]
277    fn release_all_resident_frees_loaded_sections() {
278        // Blob 0 resident, blob 1 a deferred (never-loaded) overflow slot.
279        let mut bd = BlobData::from_blob_files(b"abcd".to_vec(), vec!["/nonexistent/cn/1".into()]);
280        assert!(bd.is_loaded(0));
281        assert!(!bd.is_loaded(1));
282
283        let freed = bd.release_all_resident();
284        assert_eq!(freed, 4, "blob 0's four bytes were freed");
285        assert!(!bd.is_loaded(0));
286        // The freed section now errors on read rather than reloading.
287        assert!(bd.read(&locator(0, 0, 1)).is_err());
288        // A second sweep frees nothing (idempotent).
289        assert_eq!(bd.release_all_resident(), 0);
290    }
291
292    #[test]
293    fn read_errors_on_out_of_range_blob() {
294        let mut bd = BlobData::empty();
295        assert!(bd.read(&locator(3, 0, 1)).is_err());
296    }
297
298    #[test]
299    fn read_errors_when_the_locator_runs_past_the_section() {
300        let mut bd = BlobData::new(vec![Some(b"abcd".to_vec())]);
301        assert!(bd.read(&locator(0, 2, 99)).is_err());
302        assert!(bd.read(&locator(0, u64::MAX, 1)).is_err());
303    }
304}