1use crate::traits::EntityKind;
2use candid::CandidType;
3use canic_cdk::{api::performance_counter, utils::time::now_millis};
4use serde::{Deserialize, Serialize};
5use std::{cell::RefCell, cmp::Ordering, collections::BTreeMap, marker::PhantomData};
6
7#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
13pub struct EventState {
14 pub ops: EventOps,
15 pub perf: EventPerf,
16 pub entities: BTreeMap<String, EntityCounters>,
17 pub since_ms: u64,
18}
19
20impl Default for EventState {
21 fn default() -> Self {
22 Self {
23 ops: EventOps::default(),
24 perf: EventPerf::default(),
25 entities: BTreeMap::new(),
26 since_ms: now_millis(),
27 }
28 }
29}
30
31#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
36pub struct EventOps {
37 pub load_calls: u64,
39 pub exists_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 pub plan_full_scan: u64,
48
49 pub rows_loaded: u64,
51 pub rows_scanned: u64,
52 pub rows_deleted: u64,
53
54 pub index_inserts: u64,
56 pub index_removes: u64,
57 pub unique_violations: u64,
58}
59
60#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
65pub struct EntityCounters {
66 pub load_calls: u64,
67 pub exists_calls: u64,
68 pub save_calls: u64,
69 pub delete_calls: u64,
70 pub rows_loaded: u64,
71 pub rows_scanned: u64,
72 pub rows_deleted: u64,
73 pub index_inserts: u64,
74 pub index_removes: u64,
75 pub unique_violations: u64,
76}
77
78#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
83pub struct EventPerf {
84 pub load_inst_total: u128,
86 pub save_inst_total: u128,
87 pub delete_inst_total: u128,
88
89 pub load_inst_max: u64,
91 pub save_inst_max: u64,
92 pub delete_inst_max: u64,
93}
94
95thread_local! {
96 static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
97}
98
99pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
101 EVENT_STATE.with(|m| f(&m.borrow()))
102}
103
104pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
106 EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
107}
108
109pub fn reset() {
111 with_state_mut(|m| *m = EventState::default());
112}
113
114pub fn reset_all() {
116 reset();
117}
118
119#[allow(clippy::missing_const_for_fn)]
121pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
122 *total = total.saturating_add(u128::from(delta_inst));
123 if delta_inst > *max {
124 *max = delta_inst;
125 }
126}
127
128#[derive(Clone, Copy, Debug)]
133pub enum ExecKind {
134 Load,
135 Save,
136 Delete,
137}
138
139#[must_use]
142pub(crate) fn exec_start(kind: ExecKind) -> u64 {
143 with_state_mut(|m| match kind {
144 ExecKind::Load => m.ops.load_calls = m.ops.load_calls.saturating_add(1),
145 ExecKind::Save => m.ops.save_calls = m.ops.save_calls.saturating_add(1),
146 ExecKind::Delete => m.ops.delete_calls = m.ops.delete_calls.saturating_add(1),
147 });
148
149 performance_counter(1)
151}
152
153pub(crate) fn exec_finish(kind: ExecKind, start_inst: u64, rows_touched: u64) {
155 let now = performance_counter(1);
156 let delta = now.saturating_sub(start_inst);
157
158 with_state_mut(|m| match kind {
159 ExecKind::Load => {
160 m.ops.rows_loaded = m.ops.rows_loaded.saturating_add(rows_touched);
161 add_instructions(
162 &mut m.perf.load_inst_total,
163 &mut m.perf.load_inst_max,
164 delta,
165 );
166 }
167 ExecKind::Save => {
168 add_instructions(
169 &mut m.perf.save_inst_total,
170 &mut m.perf.save_inst_max,
171 delta,
172 );
173 }
174 ExecKind::Delete => {
175 m.ops.rows_deleted = m.ops.rows_deleted.saturating_add(rows_touched);
176 add_instructions(
177 &mut m.perf.delete_inst_total,
178 &mut m.perf.delete_inst_max,
179 delta,
180 );
181 }
182 });
183}
184
185#[must_use]
187pub(crate) fn exec_start_for<E>(kind: ExecKind) -> u64
188where
189 E: EntityKind,
190{
191 let start = exec_start(kind);
192 with_state_mut(|m| {
193 let entry = m.entities.entry(E::PATH.to_string()).or_default();
194 match kind {
195 ExecKind::Load => entry.load_calls = entry.load_calls.saturating_add(1),
196 ExecKind::Save => entry.save_calls = entry.save_calls.saturating_add(1),
197 ExecKind::Delete => entry.delete_calls = entry.delete_calls.saturating_add(1),
198 }
199 });
200 start
201}
202
203pub(crate) fn exec_finish_for<E>(kind: ExecKind, start_inst: u64, rows_touched: u64)
205where
206 E: EntityKind,
207{
208 exec_finish(kind, start_inst, rows_touched);
209 with_state_mut(|m| {
210 let entry = m.entities.entry(E::PATH.to_string()).or_default();
211 match kind {
212 ExecKind::Load => entry.rows_loaded = entry.rows_loaded.saturating_add(rows_touched),
213 ExecKind::Delete => {
214 entry.rows_deleted = entry.rows_deleted.saturating_add(rows_touched);
215 }
216 ExecKind::Save => {}
217 }
218 });
219}
220
221pub(crate) struct Span<E: EntityKind> {
227 kind: ExecKind,
228 start: u64,
229 rows: u64,
230 finished: bool,
231 _marker: PhantomData<E>,
232}
233
234impl<E: EntityKind> Span<E> {
235 #[must_use]
236 pub(crate) fn new(kind: ExecKind) -> Self {
238 Self {
239 kind,
240 start: exec_start_for::<E>(kind),
241 rows: 0,
242 finished: false,
243 _marker: PhantomData,
244 }
245 }
246
247 pub(crate) const fn set_rows(&mut self, rows: u64) {
248 self.rows = rows;
249 }
250
251 #[expect(dead_code)]
252 pub(crate) const fn add_rows(&mut self, rows: u64) {
254 self.rows = self.rows.saturating_add(rows);
255 }
256
257 #[expect(dead_code)]
258 pub(crate) fn finish(mut self) {
260 if !self.finished {
261 exec_finish_for::<E>(self.kind, self.start, self.rows);
262 self.finished = true;
263 }
264 }
265}
266
267impl<E: EntityKind> Drop for Span<E> {
268 fn drop(&mut self) {
269 if !self.finished {
270 exec_finish_for::<E>(self.kind, self.start, self.rows);
271 self.finished = true;
272 }
273 }
274}
275
276#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
282pub struct EventReport {
283 pub counters: Option<EventState>,
285 pub entity_counters: Vec<EntitySummary>,
287}
288
289#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
294pub struct EntitySummary {
295 pub path: String,
296 pub load_calls: u64,
297 pub exists_calls: u64,
298 pub delete_calls: u64,
299 pub rows_loaded: u64,
300 pub rows_scanned: u64,
301 pub rows_deleted: u64,
302 pub avg_rows_per_load: f64,
303 pub avg_rows_scanned_per_load: f64,
304 pub avg_rows_per_delete: f64,
305 pub index_inserts: u64,
306 pub index_removes: u64,
307 pub unique_violations: u64,
308}
309
310pub(crate) fn record_unique_violation_for<E>(m: &mut EventState)
312where
313 E: crate::traits::EntityKind,
314{
315 m.ops.unique_violations = m.ops.unique_violations.saturating_add(1);
316 let entry = m.entities.entry(E::PATH.to_string()).or_default();
317 entry.unique_violations = entry.unique_violations.saturating_add(1);
318}
319
320pub(crate) fn record_exists_call_for<E>()
322where
323 E: crate::traits::EntityKind,
324{
325 with_state_mut(|m| {
326 m.ops.exists_calls = m.ops.exists_calls.saturating_add(1);
327 let entry = m.entities.entry(E::PATH.to_string()).or_default();
328 entry.exists_calls = entry.exists_calls.saturating_add(1);
329 });
330}
331
332pub(crate) fn record_rows_scanned_for<E>(rows_scanned: u64)
334where
335 E: crate::traits::EntityKind,
336{
337 with_state_mut(|m| {
338 m.ops.rows_scanned = m.ops.rows_scanned.saturating_add(rows_scanned);
339 let entry = m.entities.entry(E::PATH.to_string()).or_default();
340 entry.rows_scanned = entry.rows_scanned.saturating_add(rows_scanned);
341 });
342}
343
344#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
350#[allow(clippy::struct_excessive_bools)]
351pub struct EventSelect {
352 pub data: bool,
353 pub index: bool,
354 pub counters: bool,
355 pub entities: bool,
356}
357
358impl EventSelect {
359 #[must_use]
360 pub const fn all() -> Self {
361 Self {
362 data: true,
363 index: true,
364 counters: true,
365 entities: true,
366 }
367 }
368}
369
370impl Default for EventSelect {
371 fn default() -> Self {
372 Self::all()
373 }
374}
375
376#[must_use]
378#[allow(clippy::cast_precision_loss)]
379pub fn report() -> EventReport {
380 let snap = with_state(Clone::clone);
381
382 let mut entity_counters: Vec<EntitySummary> = Vec::new();
383 for (path, ops) in &snap.entities {
384 let avg_load = if ops.load_calls > 0 {
385 ops.rows_loaded as f64 / ops.load_calls as f64
386 } else {
387 0.0
388 };
389 let avg_scanned = if ops.load_calls > 0 {
390 ops.rows_scanned as f64 / ops.load_calls as f64
391 } else {
392 0.0
393 };
394 let avg_delete = if ops.delete_calls > 0 {
395 ops.rows_deleted as f64 / ops.delete_calls as f64
396 } else {
397 0.0
398 };
399
400 entity_counters.push(EntitySummary {
401 path: path.clone(),
402 load_calls: ops.load_calls,
403 exists_calls: ops.exists_calls,
404 delete_calls: ops.delete_calls,
405 rows_loaded: ops.rows_loaded,
406 rows_scanned: ops.rows_scanned,
407 rows_deleted: ops.rows_deleted,
408 avg_rows_per_load: avg_load,
409 avg_rows_scanned_per_load: avg_scanned,
410 avg_rows_per_delete: avg_delete,
411 index_inserts: ops.index_inserts,
412 index_removes: ops.index_removes,
413 unique_violations: ops.unique_violations,
414 });
415 }
416
417 entity_counters.sort_by(|a, b| {
418 match b
419 .avg_rows_per_load
420 .partial_cmp(&a.avg_rows_per_load)
421 .unwrap_or(Ordering::Equal)
422 {
423 Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
424 Ordering::Equal => a.path.cmp(&b.path),
425 other => other,
426 },
427 other => other,
428 }
429 });
430
431 EventReport {
432 counters: Some(snap),
433 entity_counters,
434 }
435}
436
437#[cfg(test)]
442#[allow(clippy::float_cmp)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn reset_all_clears_state() {
448 with_state_mut(|m| {
449 m.ops.load_calls = 3;
450 m.ops.index_inserts = 2;
451 m.perf.save_inst_max = 9;
452 m.entities.insert(
453 "alpha".to_string(),
454 EntityCounters {
455 load_calls: 1,
456 ..Default::default()
457 },
458 );
459 });
460
461 reset_all();
462
463 with_state(|m| {
464 assert_eq!(m.ops.load_calls, 0);
465 assert_eq!(m.ops.index_inserts, 0);
466 assert_eq!(m.perf.save_inst_max, 0);
467 assert!(m.entities.is_empty());
468 });
469 }
470
471 #[test]
472 fn report_sorts_entities_by_average_rows() {
473 reset_all();
474 with_state_mut(|m| {
475 m.entities.insert(
476 "alpha".to_string(),
477 EntityCounters {
478 load_calls: 2,
479 rows_loaded: 6,
480 ..Default::default()
481 },
482 );
483 m.entities.insert(
484 "beta".to_string(),
485 EntityCounters {
486 load_calls: 1,
487 rows_loaded: 5,
488 ..Default::default()
489 },
490 );
491 m.entities.insert(
492 "gamma".to_string(),
493 EntityCounters {
494 load_calls: 2,
495 rows_loaded: 6,
496 ..Default::default()
497 },
498 );
499 });
500
501 let report = report();
502 let paths: Vec<_> = report
503 .entity_counters
504 .iter()
505 .map(|e| e.path.as_str())
506 .collect();
507
508 assert_eq!(paths, ["beta", "alpha", "gamma"]);
510 assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
511 assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
512 assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
513 }
514}