Skip to main content

icydb_core/metrics/
state.rs

1//! Module: metrics::state
2//! Responsibility: module-local ownership and contracts for metrics::state.
3//! Does not own: cross-module orchestration outside this module.
4//! Boundary: exposes this module API while keeping implementation details internal.
5
6//! Runtime metrics are update-only by contract.
7//! Query-side instrumentation is intentionally not surfaced by `report`, so
8//! query metrics are non-existent by design under IC query semantics.
9
10use candid::CandidType;
11use canic_cdk::utils::time::now_millis;
12use serde::Deserialize;
13use std::{cell::RefCell, cmp::Ordering, collections::BTreeMap};
14
15#[cfg_attr(doc, doc = "EventState\n\nMetrics window state.")]
16#[derive(CandidType, Clone, Debug, Deserialize)]
17pub struct EventState {
18    pub(crate) ops: EventOps,
19    pub(crate) perf: EventPerf,
20    pub(crate) entities: BTreeMap<String, EntityCounters>,
21    pub(crate) window_start_ms: u64,
22}
23
24impl EventState {
25    #[must_use]
26    pub const fn new(
27        ops: EventOps,
28        perf: EventPerf,
29        entities: BTreeMap<String, EntityCounters>,
30        window_start_ms: u64,
31    ) -> Self {
32        Self {
33            ops,
34            perf,
35            entities,
36            window_start_ms,
37        }
38    }
39
40    #[must_use]
41    pub const fn ops(&self) -> &EventOps {
42        &self.ops
43    }
44
45    #[must_use]
46    pub const fn perf(&self) -> &EventPerf {
47        &self.perf
48    }
49
50    #[must_use]
51    pub const fn entities(&self) -> &BTreeMap<String, EntityCounters> {
52        &self.entities
53    }
54
55    #[must_use]
56    pub const fn window_start_ms(&self) -> u64 {
57        self.window_start_ms
58    }
59}
60
61impl Default for EventState {
62    fn default() -> Self {
63        Self {
64            ops: EventOps::default(),
65            perf: EventPerf::default(),
66            entities: BTreeMap::new(),
67            window_start_ms: now_millis(),
68        }
69    }
70}
71
72#[cfg_attr(doc, doc = "EventOps\n\nOperation counters.")]
73#[derive(CandidType, Clone, Debug, Default, Deserialize)]
74pub struct EventOps {
75    // Executor entrypoints
76    pub(crate) load_calls: u64,
77    pub(crate) save_calls: u64,
78    pub(crate) delete_calls: u64,
79
80    // Planner kinds
81    pub(crate) plan_index: u64,
82    pub(crate) plan_keys: u64,
83    pub(crate) plan_range: u64,
84    pub(crate) plan_full_scan: u64,
85    pub(crate) plan_grouped_hash_materialized: u64,
86    pub(crate) plan_grouped_ordered_materialized: u64,
87
88    // Rows touched
89    pub(crate) rows_loaded: u64,
90    pub(crate) rows_scanned: u64,
91    pub(crate) rows_filtered: u64,
92    pub(crate) rows_aggregated: u64,
93    pub(crate) rows_emitted: u64,
94    pub(crate) rows_deleted: u64,
95
96    // Index maintenance
97    pub(crate) index_inserts: u64,
98    pub(crate) index_removes: u64,
99    pub(crate) reverse_index_inserts: u64,
100    pub(crate) reverse_index_removes: u64,
101    pub(crate) relation_reverse_lookups: u64,
102    pub(crate) relation_delete_blocks: u64,
103    pub(crate) unique_violations: u64,
104    pub(crate) non_atomic_partial_commits: u64,
105    pub(crate) non_atomic_partial_rows_committed: u64,
106}
107
108impl EventOps {
109    #[must_use]
110    pub const fn load_calls(&self) -> u64 {
111        self.load_calls
112    }
113
114    #[must_use]
115    pub const fn save_calls(&self) -> u64 {
116        self.save_calls
117    }
118
119    #[must_use]
120    pub const fn delete_calls(&self) -> u64 {
121        self.delete_calls
122    }
123
124    #[must_use]
125    pub const fn plan_index(&self) -> u64 {
126        self.plan_index
127    }
128
129    #[must_use]
130    pub const fn plan_keys(&self) -> u64 {
131        self.plan_keys
132    }
133
134    #[must_use]
135    pub const fn plan_range(&self) -> u64 {
136        self.plan_range
137    }
138
139    #[must_use]
140    pub const fn plan_full_scan(&self) -> u64 {
141        self.plan_full_scan
142    }
143
144    #[must_use]
145    pub const fn plan_grouped_hash_materialized(&self) -> u64 {
146        self.plan_grouped_hash_materialized
147    }
148
149    #[must_use]
150    pub const fn plan_grouped_ordered_materialized(&self) -> u64 {
151        self.plan_grouped_ordered_materialized
152    }
153
154    #[must_use]
155    pub const fn rows_loaded(&self) -> u64 {
156        self.rows_loaded
157    }
158
159    #[must_use]
160    pub const fn rows_scanned(&self) -> u64 {
161        self.rows_scanned
162    }
163
164    #[must_use]
165    pub const fn rows_filtered(&self) -> u64 {
166        self.rows_filtered
167    }
168
169    #[must_use]
170    pub const fn rows_aggregated(&self) -> u64 {
171        self.rows_aggregated
172    }
173
174    #[must_use]
175    pub const fn rows_emitted(&self) -> u64 {
176        self.rows_emitted
177    }
178
179    #[must_use]
180    pub const fn rows_deleted(&self) -> u64 {
181        self.rows_deleted
182    }
183
184    #[must_use]
185    pub const fn index_inserts(&self) -> u64 {
186        self.index_inserts
187    }
188
189    #[must_use]
190    pub const fn index_removes(&self) -> u64 {
191        self.index_removes
192    }
193
194    #[must_use]
195    pub const fn reverse_index_inserts(&self) -> u64 {
196        self.reverse_index_inserts
197    }
198
199    #[must_use]
200    pub const fn reverse_index_removes(&self) -> u64 {
201        self.reverse_index_removes
202    }
203
204    #[must_use]
205    pub const fn relation_reverse_lookups(&self) -> u64 {
206        self.relation_reverse_lookups
207    }
208
209    #[must_use]
210    pub const fn relation_delete_blocks(&self) -> u64 {
211        self.relation_delete_blocks
212    }
213
214    #[must_use]
215    pub const fn unique_violations(&self) -> u64 {
216        self.unique_violations
217    }
218
219    #[must_use]
220    pub const fn non_atomic_partial_commits(&self) -> u64 {
221        self.non_atomic_partial_commits
222    }
223
224    #[must_use]
225    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
226        self.non_atomic_partial_rows_committed
227    }
228}
229
230#[cfg_attr(doc, doc = "EntityCounters\n\nPer-entity counters.")]
231#[derive(CandidType, Clone, Debug, Default, Deserialize)]
232pub struct EntityCounters {
233    pub(crate) load_calls: u64,
234    pub(crate) save_calls: u64,
235    pub(crate) delete_calls: u64,
236    pub(crate) rows_loaded: u64,
237    pub(crate) rows_scanned: u64,
238    pub(crate) rows_filtered: u64,
239    pub(crate) rows_aggregated: u64,
240    pub(crate) rows_emitted: u64,
241    pub(crate) rows_deleted: u64,
242    pub(crate) index_inserts: u64,
243    pub(crate) index_removes: u64,
244    pub(crate) reverse_index_inserts: u64,
245    pub(crate) reverse_index_removes: u64,
246    pub(crate) relation_reverse_lookups: u64,
247    pub(crate) relation_delete_blocks: u64,
248    pub(crate) unique_violations: u64,
249    pub(crate) non_atomic_partial_commits: u64,
250    pub(crate) non_atomic_partial_rows_committed: u64,
251}
252
253impl EntityCounters {
254    #[must_use]
255    pub const fn load_calls(&self) -> u64 {
256        self.load_calls
257    }
258
259    #[must_use]
260    pub const fn save_calls(&self) -> u64 {
261        self.save_calls
262    }
263
264    #[must_use]
265    pub const fn delete_calls(&self) -> u64 {
266        self.delete_calls
267    }
268
269    #[must_use]
270    pub const fn rows_loaded(&self) -> u64 {
271        self.rows_loaded
272    }
273
274    #[must_use]
275    pub const fn rows_scanned(&self) -> u64 {
276        self.rows_scanned
277    }
278
279    #[must_use]
280    pub const fn rows_filtered(&self) -> u64 {
281        self.rows_filtered
282    }
283
284    #[must_use]
285    pub const fn rows_aggregated(&self) -> u64 {
286        self.rows_aggregated
287    }
288
289    #[must_use]
290    pub const fn rows_emitted(&self) -> u64 {
291        self.rows_emitted
292    }
293
294    #[must_use]
295    pub const fn rows_deleted(&self) -> u64 {
296        self.rows_deleted
297    }
298
299    #[must_use]
300    pub const fn index_inserts(&self) -> u64 {
301        self.index_inserts
302    }
303
304    #[must_use]
305    pub const fn index_removes(&self) -> u64 {
306        self.index_removes
307    }
308
309    #[must_use]
310    pub const fn reverse_index_inserts(&self) -> u64 {
311        self.reverse_index_inserts
312    }
313
314    #[must_use]
315    pub const fn reverse_index_removes(&self) -> u64 {
316        self.reverse_index_removes
317    }
318
319    #[must_use]
320    pub const fn relation_reverse_lookups(&self) -> u64 {
321        self.relation_reverse_lookups
322    }
323
324    #[must_use]
325    pub const fn relation_delete_blocks(&self) -> u64 {
326        self.relation_delete_blocks
327    }
328
329    #[must_use]
330    pub const fn unique_violations(&self) -> u64 {
331        self.unique_violations
332    }
333
334    #[must_use]
335    pub const fn non_atomic_partial_commits(&self) -> u64 {
336        self.non_atomic_partial_commits
337    }
338
339    #[must_use]
340    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
341        self.non_atomic_partial_rows_committed
342    }
343}
344
345#[cfg_attr(doc, doc = "EventPerf\n\nInstruction totals and maxima.")]
346#[derive(CandidType, Clone, Debug, Default, Deserialize)]
347pub struct EventPerf {
348    // Instruction totals per executor (ic_cdk::api::performance_counter(1))
349    pub(crate) load_inst_total: u128,
350    pub(crate) save_inst_total: u128,
351    pub(crate) delete_inst_total: u128,
352
353    // Maximum observed instruction deltas
354    pub(crate) load_inst_max: u64,
355    pub(crate) save_inst_max: u64,
356    pub(crate) delete_inst_max: u64,
357}
358
359impl EventPerf {
360    #[must_use]
361    pub const fn new(
362        load_inst_total: u128,
363        save_inst_total: u128,
364        delete_inst_total: u128,
365        load_inst_max: u64,
366        save_inst_max: u64,
367        delete_inst_max: u64,
368    ) -> Self {
369        Self {
370            load_inst_total,
371            save_inst_total,
372            delete_inst_total,
373            load_inst_max,
374            save_inst_max,
375            delete_inst_max,
376        }
377    }
378
379    #[must_use]
380    pub const fn load_inst_total(&self) -> u128 {
381        self.load_inst_total
382    }
383
384    #[must_use]
385    pub const fn save_inst_total(&self) -> u128 {
386        self.save_inst_total
387    }
388
389    #[must_use]
390    pub const fn delete_inst_total(&self) -> u128 {
391        self.delete_inst_total
392    }
393
394    #[must_use]
395    pub const fn load_inst_max(&self) -> u64 {
396        self.load_inst_max
397    }
398
399    #[must_use]
400    pub const fn save_inst_max(&self) -> u64 {
401        self.save_inst_max
402    }
403
404    #[must_use]
405    pub const fn delete_inst_max(&self) -> u64 {
406        self.delete_inst_max
407    }
408}
409
410thread_local! {
411    static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
412}
413
414/// Borrow metrics immutably.
415pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
416    EVENT_STATE.with(|m| f(&m.borrow()))
417}
418
419/// Borrow metrics mutably.
420pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
421    EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
422}
423
424/// Reset all counters (useful in tests).
425pub(super) fn reset() {
426    with_state_mut(|m| *m = EventState::default());
427}
428
429/// Reset all event state: counters, perf, and serialize counters.
430pub(crate) fn reset_all() {
431    reset();
432}
433
434/// Accumulate instruction counts and track a max.
435pub(super) fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
436    *total = total.saturating_add(u128::from(delta_inst));
437    if delta_inst > *max {
438        *max = delta_inst;
439    }
440}
441
442#[cfg_attr(doc, doc = "EventReport\n\nMetrics query payload.")]
443#[derive(CandidType, Clone, Debug, Default, Deserialize)]
444pub struct EventReport {
445    counters: Option<EventState>,
446    entity_counters: Vec<EntitySummary>,
447}
448
449impl EventReport {
450    #[must_use]
451    pub(crate) const fn new(
452        counters: Option<EventState>,
453        entity_counters: Vec<EntitySummary>,
454    ) -> Self {
455        Self {
456            counters,
457            entity_counters,
458        }
459    }
460
461    #[must_use]
462    pub const fn counters(&self) -> Option<&EventState> {
463        self.counters.as_ref()
464    }
465
466    #[must_use]
467    pub fn entity_counters(&self) -> &[EntitySummary] {
468        &self.entity_counters
469    }
470
471    #[must_use]
472    pub fn into_counters(self) -> Option<EventState> {
473        self.counters
474    }
475
476    #[must_use]
477    pub fn into_entity_counters(self) -> Vec<EntitySummary> {
478        self.entity_counters
479    }
480}
481
482#[cfg_attr(doc, doc = "EntitySummary\n\nPer-entity metrics summary.")]
483#[derive(CandidType, Clone, Debug, Default, Deserialize)]
484pub struct EntitySummary {
485    path: String,
486    load_calls: u64,
487    delete_calls: u64,
488    rows_loaded: u64,
489    rows_scanned: u64,
490    rows_filtered: u64,
491    rows_aggregated: u64,
492    rows_emitted: u64,
493    rows_deleted: u64,
494    avg_rows_per_load: f64,
495    avg_rows_scanned_per_load: f64,
496    avg_rows_per_delete: f64,
497    index_inserts: u64,
498    index_removes: u64,
499    reverse_index_inserts: u64,
500    reverse_index_removes: u64,
501    relation_reverse_lookups: u64,
502    relation_delete_blocks: u64,
503    unique_violations: u64,
504    non_atomic_partial_commits: u64,
505    non_atomic_partial_rows_committed: u64,
506}
507
508impl EntitySummary {
509    #[must_use]
510    pub const fn path(&self) -> &str {
511        self.path.as_str()
512    }
513
514    #[must_use]
515    pub const fn load_calls(&self) -> u64 {
516        self.load_calls
517    }
518
519    #[must_use]
520    pub const fn delete_calls(&self) -> u64 {
521        self.delete_calls
522    }
523
524    #[must_use]
525    pub const fn rows_loaded(&self) -> u64 {
526        self.rows_loaded
527    }
528
529    #[must_use]
530    pub const fn rows_scanned(&self) -> u64 {
531        self.rows_scanned
532    }
533
534    #[must_use]
535    pub const fn rows_filtered(&self) -> u64 {
536        self.rows_filtered
537    }
538
539    #[must_use]
540    pub const fn rows_aggregated(&self) -> u64 {
541        self.rows_aggregated
542    }
543
544    #[must_use]
545    pub const fn rows_emitted(&self) -> u64 {
546        self.rows_emitted
547    }
548
549    #[must_use]
550    pub const fn rows_deleted(&self) -> u64 {
551        self.rows_deleted
552    }
553
554    #[must_use]
555    pub const fn avg_rows_per_load(&self) -> f64 {
556        self.avg_rows_per_load
557    }
558
559    #[must_use]
560    pub const fn avg_rows_scanned_per_load(&self) -> f64 {
561        self.avg_rows_scanned_per_load
562    }
563
564    #[must_use]
565    pub const fn avg_rows_per_delete(&self) -> f64 {
566        self.avg_rows_per_delete
567    }
568
569    #[must_use]
570    pub const fn index_inserts(&self) -> u64 {
571        self.index_inserts
572    }
573
574    #[must_use]
575    pub const fn index_removes(&self) -> u64 {
576        self.index_removes
577    }
578
579    #[must_use]
580    pub const fn reverse_index_inserts(&self) -> u64 {
581        self.reverse_index_inserts
582    }
583
584    #[must_use]
585    pub const fn reverse_index_removes(&self) -> u64 {
586        self.reverse_index_removes
587    }
588
589    #[must_use]
590    pub const fn relation_reverse_lookups(&self) -> u64 {
591        self.relation_reverse_lookups
592    }
593
594    #[must_use]
595    pub const fn relation_delete_blocks(&self) -> u64 {
596        self.relation_delete_blocks
597    }
598
599    #[must_use]
600    pub const fn unique_violations(&self) -> u64 {
601        self.unique_violations
602    }
603
604    #[must_use]
605    pub const fn non_atomic_partial_commits(&self) -> u64 {
606        self.non_atomic_partial_commits
607    }
608
609    #[must_use]
610    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
611        self.non_atomic_partial_rows_committed
612    }
613}
614
615/// Build a metrics report gated by `window_start_ms`.
616///
617/// This is a window-start filter:
618/// - If `window_start_ms` is `None`, return the current window.
619/// - If `window_start_ms <= state.window_start_ms`, return the current window.
620/// - If `window_start_ms > state.window_start_ms`, return an empty report.
621///
622/// IcyDB stores aggregate counters only, so it cannot produce a precise
623/// sub-window report after `state.window_start_ms`.
624#[must_use]
625#[expect(clippy::cast_precision_loss)]
626pub(super) fn report_window_start(window_start_ms: Option<u64>) -> EventReport {
627    let snap = with_state(Clone::clone);
628    if let Some(requested_window_start_ms) = window_start_ms
629        && requested_window_start_ms > snap.window_start_ms
630    {
631        return EventReport::default();
632    }
633
634    let mut entity_counters: Vec<EntitySummary> = Vec::new();
635    for (path, ops) in &snap.entities {
636        let avg_load = if ops.load_calls > 0 {
637            ops.rows_loaded as f64 / ops.load_calls as f64
638        } else {
639            0.0
640        };
641        let avg_scanned = if ops.load_calls > 0 {
642            ops.rows_scanned as f64 / ops.load_calls as f64
643        } else {
644            0.0
645        };
646        let avg_delete = if ops.delete_calls > 0 {
647            ops.rows_deleted as f64 / ops.delete_calls as f64
648        } else {
649            0.0
650        };
651
652        entity_counters.push(EntitySummary {
653            path: path.clone(),
654            load_calls: ops.load_calls,
655            delete_calls: ops.delete_calls,
656            rows_loaded: ops.rows_loaded,
657            rows_scanned: ops.rows_scanned,
658            rows_filtered: ops.rows_filtered,
659            rows_aggregated: ops.rows_aggregated,
660            rows_emitted: ops.rows_emitted,
661            rows_deleted: ops.rows_deleted,
662            avg_rows_per_load: avg_load,
663            avg_rows_scanned_per_load: avg_scanned,
664            avg_rows_per_delete: avg_delete,
665            index_inserts: ops.index_inserts,
666            index_removes: ops.index_removes,
667            reverse_index_inserts: ops.reverse_index_inserts,
668            reverse_index_removes: ops.reverse_index_removes,
669            relation_reverse_lookups: ops.relation_reverse_lookups,
670            relation_delete_blocks: ops.relation_delete_blocks,
671            unique_violations: ops.unique_violations,
672            non_atomic_partial_commits: ops.non_atomic_partial_commits,
673            non_atomic_partial_rows_committed: ops.non_atomic_partial_rows_committed,
674        });
675    }
676
677    entity_counters.sort_by(|a, b| {
678        match b
679            .avg_rows_per_load
680            .partial_cmp(&a.avg_rows_per_load)
681            .unwrap_or(Ordering::Equal)
682        {
683            Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
684                Ordering::Equal => a.path.cmp(&b.path),
685                other => other,
686            },
687            other => other,
688        }
689    });
690
691    EventReport::new(Some(snap), entity_counters)
692}