1use crate::traits::EntityKind;
2use candid::CandidType;
3use canic::{cdk::api::performance_counter, utils::time};
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::{cell::RefCell, collections::BTreeMap, marker::PhantomData};
7
8#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
14pub struct EventState {
15 pub ops: EventOps,
16 pub perf: EventPerf,
17 pub entities: BTreeMap<String, EntityCounters>,
18 pub since_ms: u64,
19}
20
21impl Default for EventState {
22 fn default() -> Self {
23 Self {
24 ops: EventOps::default(),
25 perf: EventPerf::default(),
26 entities: BTreeMap::new(),
27 since_ms: time::now_millis(),
28 }
29 }
30}
31
32#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
37pub struct EventOps {
38 pub load_calls: u64,
40 pub save_calls: u64,
41 pub delete_calls: u64,
42
43 pub plan_index: u64,
45 pub plan_keys: u64,
46 pub plan_range: u64,
47
48 pub rows_loaded: u64,
50 pub rows_deleted: u64,
51
52 pub index_inserts: u64,
54 pub index_removes: u64,
55 pub unique_violations: u64,
56}
57
58#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
63pub struct EntityCounters {
64 pub load_calls: u64,
65 pub save_calls: u64,
66 pub delete_calls: u64,
67 pub rows_loaded: u64,
68 pub rows_deleted: u64,
69 pub index_inserts: u64,
70 pub index_removes: u64,
71 pub unique_violations: u64,
72}
73
74#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
79pub struct EventPerf {
80 pub load_inst_total: u128,
82 pub save_inst_total: u128,
83 pub delete_inst_total: u128,
84
85 pub load_inst_max: u64,
87 pub save_inst_max: u64,
88 pub delete_inst_max: u64,
89}
90
91thread_local! {
92 static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
93}
94
95pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
97 EVENT_STATE.with(|m| f(&m.borrow()))
98}
99
100pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
102 EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
103}
104
105pub fn reset() {
107 with_state_mut(|m| *m = EventState::default());
108}
109
110pub fn reset_all() {
112 reset();
113}
114
115#[allow(clippy::missing_const_for_fn)]
117pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
118 *total = total.saturating_add(u128::from(delta_inst));
119 if delta_inst > *max {
120 *max = delta_inst;
121 }
122}
123
124#[derive(Clone, Copy, Debug)]
129pub enum ExecKind {
130 Load,
131 Save,
132 Delete,
133}
134
135#[must_use]
138pub(crate) fn exec_start(kind: ExecKind) -> u64 {
139 with_state_mut(|m| match kind {
140 ExecKind::Load => m.ops.load_calls = m.ops.load_calls.saturating_add(1),
141 ExecKind::Save => m.ops.save_calls = m.ops.save_calls.saturating_add(1),
142 ExecKind::Delete => m.ops.delete_calls = m.ops.delete_calls.saturating_add(1),
143 });
144
145 performance_counter(1)
147}
148
149pub(crate) fn exec_finish(kind: ExecKind, start_inst: u64, rows_touched: u64) {
151 let now = performance_counter(1);
152 let delta = now.saturating_sub(start_inst);
153
154 with_state_mut(|m| match kind {
155 ExecKind::Load => {
156 m.ops.rows_loaded = m.ops.rows_loaded.saturating_add(rows_touched);
157 add_instructions(
158 &mut m.perf.load_inst_total,
159 &mut m.perf.load_inst_max,
160 delta,
161 );
162 }
163 ExecKind::Save => {
164 add_instructions(
165 &mut m.perf.save_inst_total,
166 &mut m.perf.save_inst_max,
167 delta,
168 );
169 }
170 ExecKind::Delete => {
171 m.ops.rows_deleted = m.ops.rows_deleted.saturating_add(rows_touched);
172 add_instructions(
173 &mut m.perf.delete_inst_total,
174 &mut m.perf.delete_inst_max,
175 delta,
176 );
177 }
178 });
179}
180
181#[must_use]
183pub(crate) fn exec_start_for<E>(kind: ExecKind) -> u64
184where
185 E: EntityKind,
186{
187 let start = exec_start(kind);
188 with_state_mut(|m| {
189 let entry = m.entities.entry(E::PATH.to_string()).or_default();
190 match kind {
191 ExecKind::Load => entry.load_calls = entry.load_calls.saturating_add(1),
192 ExecKind::Save => entry.save_calls = entry.save_calls.saturating_add(1),
193 ExecKind::Delete => entry.delete_calls = entry.delete_calls.saturating_add(1),
194 }
195 });
196 start
197}
198
199pub(crate) fn exec_finish_for<E>(kind: ExecKind, start_inst: u64, rows_touched: u64)
201where
202 E: EntityKind,
203{
204 exec_finish(kind, start_inst, rows_touched);
205 with_state_mut(|m| {
206 let entry = m.entities.entry(E::PATH.to_string()).or_default();
207 match kind {
208 ExecKind::Load => entry.rows_loaded = entry.rows_loaded.saturating_add(rows_touched),
209 ExecKind::Delete => {
210 entry.rows_deleted = entry.rows_deleted.saturating_add(rows_touched);
211 }
212 ExecKind::Save => {}
213 }
214 });
215}
216
217pub(crate) struct Span<E: EntityKind> {
223 kind: ExecKind,
224 start: u64,
225 rows: u64,
226 finished: bool,
227 _marker: PhantomData<E>,
228}
229
230impl<E: EntityKind> Span<E> {
231 #[must_use]
232 pub(crate) fn new(kind: ExecKind) -> Self {
234 Self {
235 kind,
236 start: exec_start_for::<E>(kind),
237 rows: 0,
238 finished: false,
239 _marker: PhantomData,
240 }
241 }
242
243 pub(crate) const fn set_rows(&mut self, rows: u64) {
244 self.rows = rows;
245 }
246
247 #[expect(dead_code)]
248 pub(crate) const fn add_rows(&mut self, rows: u64) {
250 self.rows = self.rows.saturating_add(rows);
251 }
252
253 #[expect(dead_code)]
254 pub(crate) fn finish(mut self) {
256 if !self.finished {
257 exec_finish_for::<E>(self.kind, self.start, self.rows);
258 self.finished = true;
259 }
260 }
261}
262
263impl<E: EntityKind> Drop for Span<E> {
264 fn drop(&mut self) {
265 if !self.finished {
266 exec_finish_for::<E>(self.kind, self.start, self.rows);
267 self.finished = true;
268 }
269 }
270}
271
272#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
278pub struct EventReport {
279 pub counters: Option<EventState>,
281 pub entity_counters: Vec<EntitySummary>,
283}
284
285#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
290pub struct EntitySummary {
291 pub path: String,
292 pub load_calls: u64,
293 pub delete_calls: u64,
294 pub rows_loaded: u64,
295 pub rows_deleted: u64,
296 pub avg_rows_per_load: f64,
297 pub avg_rows_per_delete: f64,
298 pub index_inserts: u64,
299 pub index_removes: u64,
300 pub unique_violations: u64,
301}
302
303pub(crate) fn record_unique_violation_for<E>(m: &mut EventState)
305where
306 E: crate::traits::EntityKind,
307{
308 m.ops.unique_violations = m.ops.unique_violations.saturating_add(1);
309 let entry = m.entities.entry(E::PATH.to_string()).or_default();
310 entry.unique_violations = entry.unique_violations.saturating_add(1);
311}
312
313#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
319#[allow(clippy::struct_excessive_bools)]
320pub struct EventSelect {
321 pub data: bool,
322 pub index: bool,
323 pub counters: bool,
324 pub entities: bool,
325}
326
327impl EventSelect {
328 #[must_use]
329 pub const fn all() -> Self {
330 Self {
331 data: true,
332 index: true,
333 counters: true,
334 entities: true,
335 }
336 }
337}
338
339impl Default for EventSelect {
340 fn default() -> Self {
341 Self::all()
342 }
343}
344
345#[must_use]
347#[allow(clippy::cast_precision_loss)]
348pub fn report() -> EventReport {
349 let snap = with_state(Clone::clone);
350
351 let mut entity_counters: Vec<EntitySummary> = Vec::new();
352 for (path, ops) in &snap.entities {
353 let avg_load = if ops.load_calls > 0 {
354 ops.rows_loaded as f64 / ops.load_calls as f64
355 } else {
356 0.0
357 };
358 let avg_delete = if ops.delete_calls > 0 {
359 ops.rows_deleted as f64 / ops.delete_calls as f64
360 } else {
361 0.0
362 };
363
364 entity_counters.push(EntitySummary {
365 path: path.clone(),
366 load_calls: ops.load_calls,
367 delete_calls: ops.delete_calls,
368 rows_loaded: ops.rows_loaded,
369 rows_deleted: ops.rows_deleted,
370 avg_rows_per_load: avg_load,
371 avg_rows_per_delete: avg_delete,
372 index_inserts: ops.index_inserts,
373 index_removes: ops.index_removes,
374 unique_violations: ops.unique_violations,
375 });
376 }
377
378 entity_counters.sort_by(|a, b| {
379 match b
380 .avg_rows_per_load
381 .partial_cmp(&a.avg_rows_per_load)
382 .unwrap_or(Ordering::Equal)
383 {
384 Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
385 Ordering::Equal => a.path.cmp(&b.path),
386 other => other,
387 },
388 other => other,
389 }
390 });
391
392 EventReport {
393 counters: Some(snap),
394 entity_counters,
395 }
396}
397
398#[cfg(test)]
403#[allow(clippy::float_cmp)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn reset_all_clears_state() {
409 with_state_mut(|m| {
410 m.ops.load_calls = 3;
411 m.ops.index_inserts = 2;
412 m.perf.save_inst_max = 9;
413 m.entities.insert(
414 "alpha".to_string(),
415 EntityCounters {
416 load_calls: 1,
417 ..Default::default()
418 },
419 );
420 });
421
422 reset_all();
423
424 with_state(|m| {
425 assert_eq!(m.ops.load_calls, 0);
426 assert_eq!(m.ops.index_inserts, 0);
427 assert_eq!(m.perf.save_inst_max, 0);
428 assert!(m.entities.is_empty());
429 });
430 }
431
432 #[test]
433 fn report_sorts_entities_by_average_rows() {
434 reset_all();
435 with_state_mut(|m| {
436 m.entities.insert(
437 "alpha".to_string(),
438 EntityCounters {
439 load_calls: 2,
440 rows_loaded: 6,
441 ..Default::default()
442 },
443 );
444 m.entities.insert(
445 "beta".to_string(),
446 EntityCounters {
447 load_calls: 1,
448 rows_loaded: 5,
449 ..Default::default()
450 },
451 );
452 m.entities.insert(
453 "gamma".to_string(),
454 EntityCounters {
455 load_calls: 2,
456 rows_loaded: 6,
457 ..Default::default()
458 },
459 );
460 });
461
462 let report = report();
463 let paths: Vec<_> = report
464 .entity_counters
465 .iter()
466 .map(|e| e.path.as_str())
467 .collect();
468
469 assert_eq!(paths, ["beta", "alpha", "gamma"]);
471 assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
472 assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
473 assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
474 }
475}