Skip to main content

memscope_rs/task_registry/
ledger.rs

1//! Task accounting over the same binding transitions as the memory ledger.
2
3use super::{TaskIdRegistry, TaskLedgerStats};
4use crate::event_store::{MemoryEvent, MemoryEventType};
5use std::collections::{BTreeMap, HashMap};
6
7/// Lifecycle gaps are independent of whether registry allocation charges succeeded.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
9pub struct TaskLifecycleQuality {
10    pub unmatched_reallocations: u64,
11    pub unmatched_deallocations: u64,
12    pub degraded_releases: u64,
13    pub conflicting_allocations: u64,
14    pub unattributed_operations: u64,
15}
16
17impl TaskLifecycleQuality {
18    pub fn is_complete(&self) -> bool {
19        *self == Self::default()
20    }
21}
22
23/// Task metrics and their lifecycle evidence, replayed from the same event slice.
24#[derive(Debug, Default)]
25pub struct TaskAccountingSnapshot {
26    pub stats: BTreeMap<u64, TaskLedgerStats>,
27    pub lifecycle_quality: TaskLifecycleQuality,
28    pub task_lifecycle_quality: BTreeMap<u64, TaskLifecycleQuality>,
29}
30
31struct OpenBinding {
32    ptr: usize,
33    serial: Option<u64>,
34    size: u64,
35    task: Option<u64>,
36}
37
38#[derive(Default)]
39struct TaskBindings {
40    slots: Vec<Option<OpenBinding>>,
41    by_addr: HashMap<usize, Vec<usize>>,
42}
43
44impl TaskBindings {
45    fn record_quality(&self, event: &MemoryEvent, snapshot: &mut TaskAccountingSnapshot) {
46        let mut gap = TaskLifecycleQuality::default();
47        let bucket = self.by_addr.get(&event.ptr);
48        match event.event_type {
49            MemoryEventType::Allocate => {
50                gap.conflicting_allocations = u64::from(bucket.is_some_and(|b| !b.is_empty()));
51            }
52            MemoryEventType::Deallocate | MemoryEventType::Reallocate => {
53                let size = if event.event_type == MemoryEventType::Deallocate {
54                    Some(event.size)
55                } else {
56                    event.old_size
57                };
58                let unmatched = self.find(event, size).is_none();
59                if event.event_type == MemoryEventType::Deallocate {
60                    gap.unmatched_deallocations = u64::from(unmatched);
61                } else {
62                    gap.unmatched_reallocations = u64::from(unmatched);
63                }
64                gap.degraded_releases = u64::from(
65                    event.degraded_release
66                        || (event.binding_serial.is_none() && bucket.is_some_and(|b| b.len() > 1)),
67                );
68            }
69            _ => return,
70        }
71        gap.unattributed_operations = u64::from(event.task_id.is_none());
72        snapshot.lifecycle_quality.add(gap);
73
74        // Every candidate owner is affected by an ambiguous close, not just the one
75        // selected by replay policy. The operation's actor may be another task.
76        let mut tasks = std::collections::BTreeSet::new();
77        tasks.extend(event.task_id);
78        if let Some(bucket) = bucket {
79            for index in bucket {
80                if let Some(binding) = &self.slots[*index] {
81                    if event.binding_serial.is_none()
82                        || gap.conflicting_allocations > 0
83                        || binding.serial == event.binding_serial
84                    {
85                        tasks.extend(binding.task);
86                    }
87                }
88            }
89        }
90        for task in tasks {
91            snapshot
92                .task_lifecycle_quality
93                .entry(task)
94                .or_default()
95                .add(gap);
96        }
97    }
98
99    fn push(&mut self, binding: OpenBinding) {
100        self.by_addr
101            .entry(binding.ptr)
102            .or_default()
103            .push(self.slots.len());
104        self.slots.push(Some(binding));
105    }
106
107    fn find(&self, event: &MemoryEvent, size: Option<usize>) -> Option<usize> {
108        let bucket = self.by_addr.get(&event.ptr)?;
109        if let Some(serial) = event.binding_serial {
110            // An explicit identity is authoritative. A late release of G must never
111            // consume G+1 just because its address or size happens to match.
112            return bucket.iter().copied().find(|index| {
113                self.slots[*index]
114                    .as_ref()
115                    .is_some_and(|binding| binding.serial == Some(serial))
116            });
117        }
118        bucket
119            .iter()
120            .copied()
121            .find(|index| {
122                self.slots[*index].as_ref().is_some_and(|binding| {
123                    size.is_none_or(|size| size == 0 || binding.size == size as u64)
124                })
125            })
126            .or_else(|| bucket.first().copied())
127    }
128
129    fn close(&mut self, index: usize) -> Option<OpenBinding> {
130        let binding = self.slots[index].take()?;
131        if let Some(bucket) = self.by_addr.get_mut(&binding.ptr) {
132            bucket.retain(|candidate| *candidate != index);
133            if bucket.is_empty() {
134                self.by_addr.remove(&binding.ptr);
135            }
136        }
137        Some(binding)
138    }
139}
140
141impl TaskIdRegistry {
142    /// Aggregate a fixed event cut by binding identity and allocating task.
143    ///
144    /// Live bytes stay with the origin task; allocation/release activity belongs to
145    /// the task performing the operation. Unattributed bindings are still indexed so
146    /// a later task can release them without consuming another generation.
147    pub fn task_stats_from_events(events: &[MemoryEvent]) -> BTreeMap<u64, TaskLedgerStats> {
148        Self::task_accounting_from_events(events).stats
149    }
150
151    /// Replay metrics and lifecycle gaps together, preserving cross-task ownership.
152    pub fn task_accounting_from_events(events: &[MemoryEvent]) -> TaskAccountingSnapshot {
153        let mut bindings = TaskBindings::default();
154        let mut snapshot = TaskAccountingSnapshot::default();
155        let mut stats: BTreeMap<u64, TaskLedgerStats> = BTreeMap::new();
156        for event in events {
157            bindings.record_quality(event, &mut snapshot);
158            match event.event_type {
159                MemoryEventType::Allocate => {
160                    open_observed(&mut bindings, &mut stats, event, event.ptr)
161                }
162                MemoryEventType::Deallocate => {
163                    let Some(index) = bindings.find(event, Some(event.size)) else {
164                        continue;
165                    };
166                    let Some(closed) = bindings.close(index) else {
167                        continue;
168                    };
169                    if let Some(actor_id) = event.task_id {
170                        let actor = stats.entry(actor_id).or_default();
171                        actor.deallocation_count += 1;
172                        actor.deallocated_bytes += closed.size;
173                    }
174                    if let Some(owner) = closed.task.and_then(|id| stats.get_mut(&id)) {
175                        owner.live_bytes = owner.live_bytes.saturating_sub(closed.size);
176                    }
177                }
178                MemoryEventType::Reallocate => {
179                    let new_ptr = event.new_ptr.unwrap_or(event.ptr);
180                    let Some(index) = bindings.find(event, event.old_size) else {
181                        // Capture may start mid-lifetime. The returned block is known to
182                        // exist, but no old bytes may be booked as released without evidence.
183                        open_observed(&mut bindings, &mut stats, event, new_ptr);
184                        continue;
185                    };
186                    let migrating = new_ptr != event.ptr;
187                    let binding = if migrating {
188                        bindings.close(index)
189                    } else {
190                        bindings.slots[index].take()
191                    };
192                    let Some(mut binding) = binding else {
193                        continue;
194                    };
195                    if let Some(owner) = binding.task.and_then(|id| stats.get_mut(&id)) {
196                        owner.live_bytes = owner
197                            .live_bytes
198                            .saturating_sub(binding.size)
199                            .saturating_add(event.size as u64);
200                    }
201                    if let Some(actor_id) = event.task_id {
202                        let actor = stats.entry(actor_id).or_default();
203                        actor.allocated_bytes += event.size as u64;
204                        actor.deallocated_bytes += binding.size;
205                    }
206                    binding.ptr = new_ptr;
207                    binding.size = event.size as u64;
208                    if migrating {
209                        bindings.push(binding);
210                    } else {
211                        // Keep the oldest-first legacy matching order on an in-place resize.
212                        bindings.slots[index] = Some(binding);
213                    }
214                }
215                _ => {}
216            }
217        }
218        snapshot.stats = stats;
219        snapshot
220    }
221}
222
223impl TaskLifecycleQuality {
224    fn add(&mut self, other: Self) {
225        self.unmatched_reallocations += other.unmatched_reallocations;
226        self.unmatched_deallocations += other.unmatched_deallocations;
227        self.degraded_releases += other.degraded_releases;
228        self.conflicting_allocations += other.conflicting_allocations;
229        self.unattributed_operations += other.unattributed_operations;
230    }
231}
232
233fn open_observed(
234    bindings: &mut TaskBindings,
235    stats: &mut BTreeMap<u64, TaskLedgerStats>,
236    event: &MemoryEvent,
237    ptr: usize,
238) {
239    if let Some(task_id) = event.task_id {
240        let entry = stats.entry(task_id).or_default();
241        entry.allocation_count += 1;
242        entry.allocated_bytes += event.size as u64;
243        entry.live_bytes = entry.live_bytes.saturating_add(event.size as u64);
244    }
245    bindings.push(OpenBinding {
246        ptr,
247        serial: event.binding_serial,
248        size: event.size as u64,
249        task: event.task_id,
250    });
251}