aimdb_core/remote/metadata.rs
1//! Record metadata types for remote introspection (feature `remote`).
2//!
3//! [`RecordMetadata`] describes a registered record's runtime state — buffer
4//! configuration, producer/consumer counts, the `writable` flag, and
5//! (feature-gated) buffer metrics / stage profiling — for the AimX `record.list`
6//! response. Computed on demand from a record's static structure; core keeps no
7//! per-record metadata state.
8
9use alloc::format;
10use alloc::string::{String, ToString};
11#[cfg(feature = "observability")]
12use alloc::vec::Vec;
13use core::any::TypeId;
14use serde::{Deserialize, Serialize};
15
16use crate::graph::RecordOrigin;
17use crate::record_id::{RecordId, RecordKey};
18
19/// Metadata about a registered record type
20///
21/// Provides information for remote introspection, including buffer
22/// configuration and producer/consumer counts.
23///
24/// When the `observability` feature is enabled, additional fields are included
25/// for buffer-level statistics (produced_count, consumed_count, etc.).
26///
27/// # Liveness is an observability extra
28///
29/// A `record.list` reply enumerates records the server **registered**, not
30/// records that have carried a value — a server registering a fixed pool up
31/// front lists the whole pool. Only `produced_count` distinguishes them, and
32/// it is `observability`-gated by design (liveness
33/// derives from buffer counters, which constrained targets compile away). So a
34/// server whose clients need that distinction must be built with
35/// `observability` on; without it the honest client answer is "unknown".
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RecordMetadata {
38 /// Unique record identifier (index in the storage)
39 pub record_id: u32,
40
41 /// Unique record key (stable identifier for lookup)
42 pub record_key: String,
43
44 /// Record type name (Rust type name)
45 pub name: String,
46
47 /// TypeId as hexadecimal string
48 pub type_id: String,
49
50 /// How the record gets its values (Source, Link, Transform, Passive)
51 pub origin: RecordOrigin,
52
53 /// Buffer type: "spmc_ring", "single_latest", "mailbox", or "none"
54 pub buffer_type: String,
55
56 /// Buffer capacity (None for unbounded or no buffer)
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub buffer_capacity: Option<usize>,
59
60 /// Number of registered producer services
61 pub producer_count: usize,
62
63 /// Number of registered consumer services
64 pub consumer_count: usize,
65
66 /// Whether write operations are permitted for this record
67 pub writable: bool,
68
69 /// Number of outbound connector links registered
70 pub outbound_connector_count: usize,
71
72 /// Data-contract schema name (e.g. `"temperature"`), when the serving
73 /// dispatch owns a schema registry that can resolve it (the WebSocket
74 /// connector's `StreamableRegistry`). Core alone cannot map a `TypeId` to a
75 /// contract name and leaves it `None`.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub schema_type: Option<String>,
78
79 /// The record key's **leaf segment** — everything after the last `.` or `/`
80 /// (`"vienna"` for `"temp.vienna"` or `"sensors/temp/vienna"`).
81 ///
82 /// Only an entity identifier where the leaf *is* the node. Under a deeper
83 /// scheme it is not: `"station.17.temperature"` yields `"temperature"`, not
84 /// the `"17"` a caller wants — such clients must parse the key themselves.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub entity: Option<String>,
87
88 // ===== Buffer metrics (feature-gated) =====
89 /// Total items pushed to the buffer (metrics feature only).
90 ///
91 /// Doubles as the liveness signal: `> 0` has carried a value, `0` is an
92 /// untouched registration, absent is unknowable (see the type-level note).
93 #[cfg(feature = "observability")]
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub produced_count: Option<u64>,
96
97 /// Total items consumed from the buffer (metrics feature only)
98 #[cfg(feature = "observability")]
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub consumed_count: Option<u64>,
101
102 /// Total items dropped due to overflow/lag (metrics feature only)
103 #[cfg(feature = "observability")]
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub dropped_count: Option<u64>,
106
107 /// Current buffer occupancy: (items, capacity) (metrics feature only)
108 #[cfg(feature = "observability")]
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub occupancy: Option<(usize, usize)>,
111
112 // ===== Stage profiling (feature-gated) =====
113 /// Per-stage timing metrics (`.source()`/`.tap()`/`.link()`), if the
114 /// `observability` feature is enabled and any stage has been registered.
115 #[cfg(feature = "observability")]
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub stage_profiling: Option<Vec<crate::profiling::StageProfilingInfo>>,
118
119 // ===== Signal gauges (feature-gated) =====
120 /// Per-record domain-signal statistics (last/min/max/mean) fed by
121 /// `Observable::observe()`, if the `observability` feature is enabled and any
122 /// gauge has been registered.
123 #[cfg(feature = "observability")]
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub signal_stats: Option<Vec<crate::profiling::SignalStatsInfo>>,
126}
127
128impl RecordMetadata {
129 /// Creates a new record metadata entry
130 ///
131 /// # Arguments
132 /// * `record_id` - The RecordId index
133 /// * `record_key` - The unique record key
134 /// * `type_id` - The TypeId of the record
135 /// * `name` - The Rust type name
136 /// * `origin` - How the record gets its values (Source, Link, Transform, Passive)
137 /// * `buffer_type` - Buffer type string
138 /// * `buffer_capacity` - Optional buffer capacity
139 /// * `producer_count` - Number of producers
140 /// * `consumer_count` - Number of consumers
141 /// * `writable` - Whether writes are permitted
142 /// * `outbound_connector_count` - Number of outbound connectors
143 #[allow(clippy::too_many_arguments)]
144 pub fn new<K: RecordKey>(
145 record_id: RecordId,
146 record_key: K,
147 type_id: TypeId,
148 name: String,
149 origin: RecordOrigin,
150 buffer_type: String,
151 buffer_capacity: Option<usize>,
152 producer_count: usize,
153 consumer_count: usize,
154 writable: bool,
155 outbound_connector_count: usize,
156 ) -> Self {
157 let entity = Some(super::topic_leaf(record_key.as_str()).to_string());
158 Self {
159 record_id: record_id.raw(),
160 record_key: record_key.as_str().to_string(),
161 name,
162 type_id: format!("{:?}", type_id),
163 origin,
164 buffer_type,
165 buffer_capacity,
166 producer_count,
167 consumer_count,
168 writable,
169 outbound_connector_count,
170 schema_type: None,
171 entity,
172 #[cfg(feature = "observability")]
173 produced_count: None,
174 #[cfg(feature = "observability")]
175 consumed_count: None,
176 #[cfg(feature = "observability")]
177 dropped_count: None,
178 #[cfg(feature = "observability")]
179 occupancy: None,
180 #[cfg(feature = "observability")]
181 stage_profiling: None,
182 #[cfg(feature = "observability")]
183 signal_stats: None,
184 }
185 }
186
187 /// Sets buffer metrics from a snapshot (metrics feature only)
188 ///
189 /// Populates produced_count, consumed_count, dropped_count, and occupancy
190 /// from the provided metrics snapshot.
191 #[cfg(feature = "observability")]
192 pub fn with_buffer_metrics(mut self, snapshot: crate::buffer::BufferMetricsSnapshot) -> Self {
193 self.produced_count = Some(snapshot.produced_count);
194 self.consumed_count = Some(snapshot.consumed_count);
195 self.dropped_count = Some(snapshot.dropped_count);
196 // Only include occupancy if it's meaningful (non-zero capacity)
197 if snapshot.occupancy.1 > 0 {
198 self.occupancy = Some(snapshot.occupancy);
199 }
200 self
201 }
202
203 /// Attaches a stage profiling snapshot (profiling feature only).
204 #[cfg(feature = "observability")]
205 pub fn with_stage_profiling(
206 mut self,
207 stages: Vec<crate::profiling::StageProfilingInfo>,
208 ) -> Self {
209 if !stages.is_empty() {
210 self.stage_profiling = Some(stages);
211 }
212 self
213 }
214
215 /// Attaches a signal-gauge snapshot (observability feature only).
216 #[cfg(feature = "observability")]
217 pub fn with_signal_stats(mut self, signals: Vec<crate::profiling::SignalStatsInfo>) -> Self {
218 if !signals.is_empty() {
219 self.signal_stats = Some(signals);
220 }
221 self
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::record_id::StringKey;
229
230 #[test]
231 fn test_record_metadata_creation() {
232 let type_id = TypeId::of::<i32>();
233 let metadata = RecordMetadata::new(
234 RecordId::new(0),
235 StringKey::new("test.record"),
236 type_id,
237 "i32".to_string(),
238 RecordOrigin::Source,
239 "spmc_ring".to_string(),
240 Some(100),
241 1,
242 2,
243 false,
244 0,
245 );
246
247 assert_eq!(metadata.record_id, 0);
248 assert_eq!(metadata.record_key, "test.record");
249 assert_eq!(metadata.name, "i32");
250 assert!(matches!(metadata.origin, RecordOrigin::Source));
251 assert_eq!(metadata.buffer_type, "spmc_ring");
252 assert_eq!(metadata.buffer_capacity, Some(100));
253 assert_eq!(metadata.producer_count, 1);
254 assert_eq!(metadata.consumer_count, 2);
255 assert_eq!(metadata.outbound_connector_count, 0);
256 assert!(!metadata.writable);
257 }
258
259 #[test]
260 fn test_record_metadata_serialization() {
261 let type_id = TypeId::of::<String>();
262 let metadata = RecordMetadata::new(
263 RecordId::new(1),
264 StringKey::new("app.config"),
265 type_id,
266 "String".to_string(),
267 RecordOrigin::Passive,
268 "single_latest".to_string(),
269 None,
270 1,
271 1,
272 true,
273 2,
274 );
275
276 let json = serde_json::to_string(&metadata).unwrap();
277 assert!(json.contains("\"record_id\":1"));
278 assert!(json.contains("\"record_key\":\"app.config\""));
279 assert!(json.contains("\"name\":\"String\""));
280 assert!(json.contains("\"origin\":\"passive\""));
281 assert!(json.contains("\"buffer_type\":\"single_latest\""));
282 assert!(json.contains("\"writable\":true"));
283 assert!(json.contains("\"outbound_connector_count\":2"));
284 }
285}