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