1use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use serde_json::Value;
29use tokio::sync::mpsc;
30use tokio_util::sync::CancellationToken;
31use tracing::{debug, info, warn};
32
33use crate::a2a::A2ARouter;
34use crate::chain::ChainManager;
35use crate::topic::{SubscriberId, SubscriberSink};
36
37#[derive(Debug)]
39struct Window {
40 started_at_ms: u64,
41 first_msg_ms: Option<u64>,
42 last_msg_ms: Option<u64>,
43 message_count: u64,
44 byte_count: u64,
45 hasher: blake3::Hasher,
46}
47
48impl Window {
49 fn new(now_ms: u64) -> Self {
50 Self {
51 started_at_ms: now_ms,
52 first_msg_ms: None,
53 last_msg_ms: None,
54 message_count: 0,
55 byte_count: 0,
56 hasher: blake3::Hasher::new(),
57 }
58 }
59
60 fn record(&mut self, bytes: &[u8], now_ms: u64) {
61 if self.first_msg_ms.is_none() {
62 self.first_msg_ms = Some(now_ms);
63 }
64 self.last_msg_ms = Some(now_ms);
65 self.message_count += 1;
66 self.byte_count += bytes.len() as u64;
67 self.hasher.update(bytes);
68 }
69
70 fn is_empty(&self) -> bool {
71 self.message_count == 0
72 }
73}
74
75pub struct TopicAnchor {
77 pub topic: String,
79 cancel: CancellationToken,
80 subscriber_id: SubscriberId,
81 router: Arc<A2ARouter>,
82}
83
84impl TopicAnchor {
85 pub fn shutdown(self) {
87 self.cancel.cancel();
88 self.router
89 .topic_router()
90 .unsubscribe_id(&self.topic, self.subscriber_id);
91 }
92}
93
94pub struct StreamWindowAnchor;
100
101impl StreamWindowAnchor {
102 pub fn start_topic(
111 a2a: Arc<A2ARouter>,
112 chain: Option<Arc<ChainManager>>,
113 topic: String,
114 window: Duration,
115 ) -> TopicAnchor {
116 let (tx, rx) = mpsc::channel::<Vec<u8>>(1024);
117 let subscriber_id = a2a
118 .topic_router()
119 .subscribe_sink(&topic, SubscriberSink::ExternalStream(tx));
120
121 let cancel = CancellationToken::new();
122 let child = cancel.child_token();
123 let topic_for_task = topic.clone();
124 tokio::spawn(async move {
125 run_consumer(topic_for_task, window, rx, chain, child).await;
126 });
127
128 info!(topic = %topic, sub_id = subscriber_id.0, window_ms = window.as_millis() as u64, "stream anchor started");
129
130 TopicAnchor {
131 topic,
132 cancel,
133 subscriber_id,
134 router: a2a,
135 }
136 }
137}
138
139async fn run_consumer(
140 topic: String,
141 window: Duration,
142 mut rx: mpsc::Receiver<Vec<u8>>,
143 chain: Option<Arc<ChainManager>>,
144 cancel: CancellationToken,
145) {
146 let started = Instant::now();
147 let now_ms = || chrono::Utc::now().timestamp_millis().max(0) as u64;
148 let mut current = Window::new(now_ms());
149 let mut timer = tokio::time::interval_at(tokio::time::Instant::now() + window, window);
150 timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
151
152 loop {
153 tokio::select! {
154 biased;
155 _ = cancel.cancelled() => {
156 flush_window(&topic, &mut current, chain.as_deref(), now_ms());
157 debug!(topic = %topic, "stream anchor cancelled");
158 return;
159 }
160 msg = rx.recv() => {
161 match msg {
162 Some(bytes) => current.record(&bytes, now_ms()),
163 None => {
164 flush_window(&topic, &mut current, chain.as_deref(), now_ms());
166 debug!(topic = %topic, "stream anchor: subscriber channel closed");
167 return;
168 }
169 }
170 }
171 _ = timer.tick() => {
172 let now = now_ms();
173 flush_window(&topic, &mut current, chain.as_deref(), now);
174 current = Window::new(now);
176 if started.elapsed() > Duration::from_secs(3600) {
178 debug!(topic = %topic, "stream anchor: hourly mark");
179 }
180 }
181 }
182 }
183}
184
185fn flush_window(topic: &str, window: &mut Window, chain: Option<&ChainManager>, now_ms: u64) {
186 if window.is_empty() {
187 return;
188 }
189 let hash = window.hasher.finalize();
190 let hash_hex = hex_encode(hash.as_bytes());
191 let payload = build_commit_payload(topic, window, now_ms, &hash_hex);
192
193 if let Some(cm) = chain {
194 cm.append("stream", "stream.window_commit", Some(payload.clone()));
195 debug!(
196 topic,
197 samples = window.message_count,
198 bytes = window.byte_count,
199 hash = %hash_hex,
200 "stream.window_commit appended"
201 );
202 } else {
203 warn!(
204 topic,
205 samples = window.message_count,
206 "chain manager unavailable; window_commit dropped"
207 );
208 }
209}
210
211fn build_commit_payload(topic: &str, window: &Window, window_end_ms: u64, hash_hex: &str) -> Value {
212 serde_json::json!({
213 "topic": topic,
214 "window_start_ms": window.started_at_ms,
215 "window_end_ms": window_end_ms,
216 "first_msg_ms": window.first_msg_ms,
217 "last_msg_ms": window.last_msg_ms,
218 "sample_count": window.message_count,
219 "byte_count": window.byte_count,
220 "chunk_count": window.message_count,
221 "blake3": hash_hex,
222 })
223}
224
225fn hex_encode(bytes: &[u8]) -> String {
226 let mut s = String::with_capacity(bytes.len() * 2);
227 for b in bytes {
228 s.push_str(&format!("{b:02x}"));
229 }
230 s
231}
232
233pub fn topic_matches(pattern: &str, topic: &str) -> bool {
239 if pattern == topic {
240 return true;
241 }
242 if let Some(prefix) = pattern.strip_suffix("*") {
243 return topic.starts_with(prefix);
244 }
245 false
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn topic_match_exact() {
254 assert!(topic_matches("sensor.mic", "sensor.mic"));
255 assert!(!topic_matches("sensor.mic", "sensor.cam"));
256 }
257
258 #[test]
259 fn topic_match_trailing_wildcard() {
260 assert!(topic_matches("sensor.*", "sensor.mic"));
261 assert!(topic_matches("sensor.*", "sensor.cam.depth"));
262 assert!(!topic_matches("sensor.*", "mic.left"));
263 }
264
265 #[test]
266 fn window_builds_hash_and_counts() {
267 let mut w = Window::new(10);
268 w.record(b"{\"a\":1}\n", 12);
269 w.record(b"{\"a\":2}\n", 14);
270 assert_eq!(w.message_count, 2);
271 assert_eq!(w.byte_count, 16);
272 let h = w.hasher.finalize();
273 let again = {
275 let mut h2 = blake3::Hasher::new();
276 h2.update(b"{\"a\":1}\n");
277 h2.update(b"{\"a\":2}\n");
278 h2.finalize()
279 };
280 assert_eq!(h.as_bytes(), again.as_bytes());
281 }
282}