Skip to main content

zerodds_ast_layout/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The layout cache. Spec §7.
4//!
5//! Layout is computed from the model, never stored on its nodes, and
6//! cached in a side table keyed by `(type, layout key)`. Each entry
7//! records which types its computation consulted, so a change evicts
8//! that type and its dependents and nothing else.
9
10use crate::key::LayoutKey;
11use crate::plan::{LayoutError, TypeLayout};
12use crate::xcdr::{DepCollector, plan};
13use alloc::collections::BTreeMap;
14use alloc::vec::Vec;
15use zerodds_ast::{Ast, TypeNode};
16use zerodds_ast_core::{Change, Changeset, Id, NodeId};
17
18/// One cached plan together with what it depended on.
19#[derive(Debug, Clone)]
20struct Entry {
21    layout: TypeLayout,
22    deps: Vec<NodeId>,
23}
24
25/// Cached layout plans.
26///
27/// Offsets are invariant under endianness, so an entry serves both byte
28/// orders; it is keyed by everything else that moves one, which is
29/// [`LayoutKey::offset_key`]. Spec §7.
30#[derive(Debug, Clone, Default)]
31pub struct LayoutCache {
32    entries: BTreeMap<(NodeId, crate::OffsetKey), Entry>,
33    hits: u64,
34    misses: u64,
35}
36
37impl LayoutCache {
38    /// Empty cache.
39    #[must_use]
40    pub const fn new() -> Self {
41        Self {
42            entries: BTreeMap::new(),
43            hits: 0,
44            misses: 0,
45        }
46    }
47
48    /// The plan for a type, computing and caching it on first use.
49    ///
50    /// # Errors
51    /// Whatever [`plan`] returns — an unresolved reference, an
52    /// unsupported construct, a little-endian key request.
53    pub fn layout(
54        &mut self,
55        ast: &Ast,
56        type_: Id<TypeNode>,
57        key: LayoutKey,
58    ) -> Result<&TypeLayout, LayoutError> {
59        let node_id = ast.type_(type_)?.node_id;
60        let slot = (node_id, key.offset_key());
61
62        if let alloc::collections::btree_map::Entry::Vacant(vacant) = self.entries.entry(slot) {
63            let mut deps = DepCollector::new();
64            let layout = plan(ast, type_, key, &mut deps)?;
65            let _ = vacant.insert(Entry {
66                layout,
67                deps: deps.into_deps(),
68            });
69            self.misses = self.misses.saturating_add(1);
70        } else {
71            self.hits = self.hits.saturating_add(1);
72        }
73
74        self.entries
75            .get(&slot)
76            .map(|entry| &entry.layout)
77            .ok_or(LayoutError::Unresolved { node: node_id })
78    }
79
80    /// Evict everything whose plan consulted any node the changeset
81    /// touched.
82    ///
83    /// Precise rather than wholesale: a change to one type leaves plans
84    /// for unrelated types in place, and evicts the ones that contain it
85    /// however deeply. Spec §7.
86    pub fn invalidate(&mut self, changeset: &Changeset) {
87        let touched: Vec<NodeId> = changeset.nodes_touched().collect();
88        if touched.is_empty() {
89            return;
90        }
91        self.entries.retain(|(node, _), entry| {
92            !touched.contains(node) && !entry.deps.iter().any(|dep| touched.contains(dep))
93        });
94    }
95
96    /// Evict every entry.
97    pub fn clear(&mut self) {
98        self.entries.clear();
99    }
100
101    /// Number of cached plans.
102    #[must_use]
103    pub fn len(&self) -> usize {
104        self.entries.len()
105    }
106
107    /// Whether nothing is cached.
108    #[must_use]
109    pub fn is_empty(&self) -> bool {
110        self.entries.is_empty()
111    }
112
113    /// How many lookups were served from the cache.
114    #[must_use]
115    pub const fn hits(&self) -> u64 {
116        self.hits
117    }
118
119    /// How many lookups had to compute a plan.
120    #[must_use]
121    pub const fn misses(&self) -> u64 {
122        self.misses
123    }
124
125    /// Whether a plan for this type and key is cached.
126    #[must_use]
127    pub fn contains(&self, type_: NodeId, key: LayoutKey) -> bool {
128        self.entries.contains_key(&(type_, key.offset_key()))
129    }
130}
131
132/// The cache behind a lock, so it can register as a change observer.
133///
134/// Registers like any other consumer; it holds no privileged path into
135/// the model. Spec §5.3, §7.
136#[cfg(feature = "std")]
137pub struct SharedLayoutCache {
138    inner: std::sync::Mutex<LayoutCache>,
139}
140
141#[cfg(feature = "std")]
142impl SharedLayoutCache {
143    /// An empty shared cache.
144    #[must_use]
145    pub fn new() -> Self {
146        Self {
147            inner: std::sync::Mutex::new(LayoutCache::new()),
148        }
149    }
150
151    /// Run a closure against the cache.
152    ///
153    /// Returns `None` if the lock was poisoned by a panic elsewhere; the
154    /// caller decides whether that is fatal rather than this type
155    /// deciding for it.
156    pub fn with<R>(&self, f: impl FnOnce(&mut LayoutCache) -> R) -> Option<R> {
157        self.inner.lock().ok().map(|mut guard| f(&mut guard))
158    }
159
160    /// Number of cached plans, or zero if the lock is poisoned.
161    #[must_use]
162    pub fn len(&self) -> usize {
163        self.with(|cache| cache.len()).unwrap_or(0)
164    }
165
166    /// Whether nothing is cached.
167    #[must_use]
168    pub fn is_empty(&self) -> bool {
169        self.len() == 0
170    }
171}
172
173#[cfg(feature = "std")]
174impl Default for SharedLayoutCache {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180#[cfg(feature = "std")]
181impl core::fmt::Debug for SharedLayoutCache {
182    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
183        f.debug_struct("SharedLayoutCache")
184            .field("len", &self.len())
185            .finish()
186    }
187}
188
189#[cfg(feature = "std")]
190impl zerodds_ast_core::AstObserver for SharedLayoutCache {
191    fn on_commit(&self, changeset: &Changeset) {
192        let _ = self.with(|cache| cache.invalidate(changeset));
193    }
194}
195
196/// Whether a changeset can affect any layout at all.
197///
198/// A commit that only created nodes nothing points at cannot change an
199/// existing plan; this lets a consumer skip the retain sweep.
200#[must_use]
201pub fn touches_layout(changeset: &Changeset) -> bool {
202    changeset.iter().any(|change| {
203        matches!(
204            change,
205            Change::Updated { .. } | Change::Deleted { .. } | Change::Created { .. }
206        )
207    })
208}