Skip to main content

cadmpeg_core/decode/
transaction.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Transactional resource accounting for independent decode tasks.
3
4use super::{
5    DecodeArena, DecodeContext, DecodeDepth, DecodePolicy, ExpandSpec, ResourceDimension,
6    ResourceLimit, SpaceId,
7};
8
9#[derive(Debug, Clone)]
10pub(super) struct BudgetState {
11    pub(super) policy: DecodePolicy,
12    pub(super) input: u64,
13    pub(super) decompressed: u64,
14    pub(super) retained: u64,
15    pub(super) entities: u64,
16    pub(super) depth: u64,
17    pub(super) fuse: Option<ResourceLimit>,
18}
19
20#[derive(Debug, Clone, Copy)]
21pub(super) enum Event {
22    Charge(ResourceDimension, u64, &'static str),
23    Leave,
24    Available(u64),
25    Allowance(u64),
26    AllowanceUse(u64, u64),
27    Expand(ExpandSpec),
28    Space(usize),
29    Batch(Charges),
30}
31
32#[derive(Debug, Clone, Copy, Default)]
33pub(super) struct Charges {
34    amounts: [u64; 3],
35    depth: i128,
36    peak: i128,
37    minimum: i128,
38}
39
40pub(super) fn record(events: &mut Vec<Event>, event: Event) {
41    let mut batch = match events.last() {
42        Some(Event::Batch(batch)) => *batch,
43        _ => Charges::default(),
44    };
45    let compacted = match event {
46        Event::Charge(dimension, amount, _) => {
47            let index = match dimension {
48                ResourceDimension::DecompressedBytes => Some(0),
49                ResourceDimension::RetainedBytes => Some(1),
50                ResourceDimension::Entities => Some(2),
51                ResourceDimension::RecursionDepth => None,
52                ResourceDimension::InputBytes => {
53                    events.push(event);
54                    return;
55                }
56            };
57            if let Some(index) = index {
58                batch.amounts[index]
59                    .checked_add(amount)
60                    .map(|total| batch.amounts[index] = total)
61            } else {
62                batch.depth.checked_add(i128::from(amount)).map(|depth| {
63                    batch.depth = depth;
64                    batch.peak = batch.peak.max(depth);
65                })
66            }
67        }
68        Event::Leave => batch.depth.checked_sub(1).map(|depth| {
69            batch.depth = depth;
70            batch.minimum = batch.minimum.min(depth);
71        }),
72        _ => {
73            events.push(event);
74            return;
75        }
76    };
77    if compacted.is_none() {
78        events.push(event);
79    } else if let Some(Event::Batch(last)) = events.last_mut() {
80        *last = batch;
81    } else {
82        events.push(Event::Batch(batch));
83    }
84}
85
86impl Charges {
87    fn replay(self, candidate: &DecodeContext<'_>) -> bool {
88        let initial = candidate.budget.state().depth;
89        let minimum = i128::from(initial) + self.minimum;
90        let maximum = i128::from(initial) + self.peak;
91        let final_depth = i128::from(initial) + self.depth;
92        if minimum < 0 || maximum > i128::from(candidate.policy().limits.max_recursion_depth) {
93            return false;
94        }
95        let Ok(final_depth) = u64::try_from(final_depth) else {
96            return false;
97        };
98        for (dimension, amount) in [
99            ResourceDimension::DecompressedBytes,
100            ResourceDimension::RetainedBytes,
101            ResourceDimension::Entities,
102        ]
103        .into_iter()
104        .zip(self.amounts)
105        {
106            if candidate
107                .budget
108                .replay_charge(dimension, amount, "replay decode charge group")
109                .is_err()
110            {
111                return false;
112            }
113        }
114        let mut state = candidate.budget.state();
115        state.depth = final_depth;
116        candidate.budget.restore(&state);
117        true
118    }
119}
120
121/// Immutable resource state from which independent decode tasks can start.
122///
123/// A resumed task owns its counters and arena. Its work does not charge the
124/// original context until that context accepts the task's trace.
125#[derive(Debug, Clone)]
126pub struct DecodeCheckpoint {
127    state: BudgetState,
128    depth: DecodeDepth,
129    spaces: usize,
130}
131
132/// Charge totals between ordered budget observations and expansion boundaries.
133/// A refused group causes the caller to execute the original operations.
134#[derive(Debug)]
135pub struct DecodeTrace {
136    events: Vec<Event>,
137    refused: bool,
138}
139
140/// Rebinds a task's derived address spaces at its composition position.
141#[derive(Debug, Clone, Copy)]
142pub struct DecodeSpaceRebinding {
143    old_start: usize,
144    new_start: usize,
145    count: usize,
146}
147
148impl DecodeSpaceRebinding {
149    /// Whether every address-space identifier retains its value.
150    pub fn is_identity(&self) -> bool {
151        self.count == 0 || self.old_start == self.new_start
152    }
153
154    /// Rebinds a task-owned space. Spaces from before the task retain their IDs.
155    pub fn rebind(&self, space: &mut SpaceId) {
156        if let Some(relative) = space.index().checked_sub(self.old_start) {
157            if relative < self.count {
158                *space = SpaceId::from_index(self.new_start + relative);
159            }
160        }
161    }
162}
163
164impl DecodeTrace {
165    /// Storage estimate: event count times the event type size, excluding allocator overhead.
166    pub fn retained_bytes_estimate(&self) -> u64 {
167        super::u64_from_index(std::mem::size_of_val(self.events.as_slice()))
168    }
169}
170
171impl DecodeCheckpoint {
172    /// Starts an independent task and records its resource operations.
173    pub fn resume<'a>(&self, arena: &'a DecodeArena) -> DecodeContext<'a> {
174        let ctx = DecodeContext::new(arena, &self.state.policy, self.depth);
175        ctx.budget.restore(&self.state);
176        ctx.derived_spaces.set(self.spaces);
177        *ctx.budget.trace.borrow_mut() = Some(Vec::new());
178        ctx
179    }
180}
181
182impl DecodeContext<'_> {
183    /// Runs work in a caller-owned arena and commits all resource state.
184    /// The arena can be dropped after the returned owned value no longer needs it.
185    /// Charges, refusal state, address spaces and trace order remain cumulative.
186    pub fn with_arena<'b, T>(
187        &self,
188        arena: &'b DecodeArena,
189        work: impl FnOnce(&DecodeContext<'b>) -> T,
190    ) -> T {
191        let local = self.checkpoint().resume(arena);
192        *local.budget.trace.borrow_mut() = self.budget.trace.borrow_mut().take();
193        let result = work(&local);
194        self.budget.restore(&local.budget.state());
195        self.derived_spaces.set(local.derived_spaces.get());
196        *self.budget.trace.borrow_mut() = local.budget.trace.borrow_mut().take();
197        result
198    }
199
200    /// Reads an optional-enrichment allowance whose threshold decisions are
201    /// recorded with [`Self::observe_retained_allowance`].
202    pub fn retained_allowance(&self) -> u64 {
203        let available = self.budget.retained_available();
204        self.budget.record(Event::Allowance(available));
205        available
206    }
207
208    /// Records a threshold decision against a previously read allowance.
209    /// Each optional-output decision must be recorded. This also marks the
210    /// preceding `retained_bytes_available` read as a threshold observation.
211    pub fn observe_retained_allowance(&self, remaining: u64, requested: u64) {
212        self.budget
213            .record(Event::AllowanceUse(remaining, requested));
214    }
215
216    /// Captures the current counters without sharing mutable decode state.
217    pub fn checkpoint(&self) -> DecodeCheckpoint {
218        DecodeCheckpoint {
219            state: self.budget.state(),
220            depth: self.depth(),
221            spaces: self.derived_spaces.get(),
222        }
223    }
224
225    /// Ends recording for a task started with [`DecodeCheckpoint::resume`].
226    pub fn take_trace(&self) -> DecodeTrace {
227        let mut events = self.budget.trace.borrow_mut().take().unwrap_or_default();
228        let refused = self.resource_refusal().is_some();
229        if refused {
230            events.clear();
231        }
232        events.shrink_to_fit();
233        DecodeTrace { events, refused }
234    }
235
236    /// Applies a task's operations as one transaction.
237    ///
238    /// Returns false without changing this context if a charge refuses, a
239    /// budget observation changes, or derived address spaces need rebinding.
240    /// The caller must then execute the task against this context. This also
241    /// preserves budget-dependent optional output and the exact refusal site.
242    pub fn accept_trace(&self, trace: &DecodeTrace) -> bool {
243        self.accept_trace_rebinding(trace, DecodeSpaceRebinding::is_identity)
244    }
245
246    /// Applies a task's operations and rebinds its owned address-space IDs.
247    ///
248    /// `rebind` runs after all resource checks pass. It must rebind each escaped
249    /// task-owned space and return true, or return false with the output unchanged.
250    /// A false result leaves this context unchanged. A resource refusal requires
251    /// the caller to execute the task at its composition position.
252    pub fn accept_trace_rebinding(
253        &self,
254        trace: &DecodeTrace,
255        rebind: impl FnOnce(&DecodeSpaceRebinding) -> bool,
256    ) -> bool {
257        if trace.refused || self.resource_refusal().is_some() {
258            return false;
259        }
260        let arena = DecodeArena::new();
261        let candidate = self.checkpoint().resume(&arena);
262        *candidate.budget.trace.borrow_mut() = None;
263        let mut allowance_delta = 0_i128;
264        let mut exact_observation = false;
265        let mut spaces = DecodeSpaceRebinding {
266            old_start: 0,
267            new_start: 0,
268            count: 0,
269        };
270        for event in &trace.events {
271            let accepted = match *event {
272                Event::Batch(batch) => batch.replay(&candidate),
273                Event::Charge(dimension, amount, operation) => candidate
274                    .budget
275                    .replay_charge(dimension, amount, operation)
276                    .is_ok(),
277                Event::Leave => {
278                    candidate.budget.leave();
279                    true
280                }
281                Event::Available(available) | Event::Allowance(available) => {
282                    if exact_observation {
283                        return false;
284                    }
285                    allowance_delta =
286                        i128::from(candidate.budget.retained_available()) - i128::from(available);
287                    exact_observation =
288                        matches!(event, Event::Available(_)) && allowance_delta != 0;
289                    true
290                }
291                Event::AllowanceUse(remaining, requested) => {
292                    exact_observation = false;
293                    let adjusted = i128::from(remaining) + allowance_delta;
294                    (adjusted >= i128::from(requested)) == (remaining >= requested)
295                }
296                Event::Expand(spec) => candidate.admit_expand(spec).is_ok(),
297                Event::Space(expected) => {
298                    let Some(old_next) = expected.checked_add(1) else {
299                        return false;
300                    };
301                    let Some(new_next) = candidate.derived_spaces.get().checked_add(1) else {
302                        return false;
303                    };
304                    if spaces.count == 0 {
305                        spaces.old_start = old_next;
306                        spaces.new_start = new_next;
307                    }
308                    if old_next.checked_sub(spaces.old_start) != Some(spaces.count) {
309                        return false;
310                    }
311                    spaces.count += 1;
312                    candidate.derived_spaces.set(new_next);
313                    true
314                }
315            };
316            if !accepted {
317                return false;
318            }
319        }
320        if exact_observation || !rebind(&spaces) {
321            return false;
322        }
323        if let Some(events) = self.budget.trace.borrow_mut().as_mut() {
324            events.extend(trace.events.iter().map(|event| match *event {
325                Event::Space(expected) => {
326                    let mut space = SpaceId::from_index(expected + 1);
327                    spaces.rebind(&mut space);
328                    Event::Space(space.index() - 1)
329                }
330                event => event,
331            }));
332        }
333        self.budget.restore(&candidate.budget.state());
334        self.derived_spaces.set(candidate.derived_spaces.get());
335        true
336    }
337}
338
339#[cfg(test)]
340mod tests;