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