1use crate::{Error, Result, StreamIdentity};
5use bincode::{Decode, Encode};
6use cu29_clock::{CuDuration, CuTime};
7use cu29_runtime::config::LogStreamFeedbackConfig;
8
9pub const FEEDBACK_BUFFER_BYTES: usize = 320;
10const MAGIC: &[u8; 4] = b"CUFB";
11const LOSS_MARGIN_BP: u64 = 200;
12const HEALTHY_REPORTS: u8 = 3;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)]
16pub struct FeedbackPolicy {
17 pub report_interval_ms: u32,
18 pub timeout_ms: u32,
19 pub adaptation: Option<AdaptationBounds>,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)]
24pub struct FeedbackRequirements {
25 pub report_interval_ms: u32,
26 pub destination: [u8; 16],
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)]
30pub struct AdaptationBounds {
31 pub min_repair_every_source_symbols: u16,
32 pub max_repair_every_source_symbols: u16,
33}
34
35impl From<&LogStreamFeedbackConfig> for FeedbackPolicy {
36 fn from(config: &LogStreamFeedbackConfig) -> Self {
37 Self {
38 report_interval_ms: config.report_interval_ms,
39 timeout_ms: config.timeout_ms,
40 adaptation: config.adaptation.map(|bounds| AdaptationBounds {
41 min_repair_every_source_symbols: bounds.min_repair_every_source_symbols,
42 max_repair_every_source_symbols: bounds.max_repair_every_source_symbols,
43 }),
44 }
45 }
46}
47
48impl FeedbackPolicy {
49 pub fn validate(self, baseline: u16) -> Result<()> {
50 if self.report_interval_ms == 0
51 || self.timeout_ms <= self.report_interval_ms
52 || self.adaptation.is_some_and(|b| {
53 b.min_repair_every_source_symbols == 0
54 || b.min_repair_every_source_symbols > baseline
55 || baseline > b.max_repair_every_source_symbols
56 })
57 {
58 return Err(Error::InvalidConfig(
59 "invalid feedback cadence or FEC bounds",
60 ));
61 }
62 Ok(())
63 }
64}
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Encode, Decode)]
69pub struct SourceOutcomes {
70 pub first_esi: u32,
71 pub finalized: u64,
72 pub received: u64,
73 pub recovered: u64,
74 pub missing: u64,
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Encode, Decode)]
80pub struct ReceiverReport {
81 pub session_id: [u8; 16],
82 pub sender_id: u32,
83 pub destination: [u8; 16],
84 pub receiver_id: [u8; 16],
85 pub sequence: u64,
86 pub elapsed_us: u64,
87 pub received_bytes: u64,
88 pub received_packets: u64,
89 pub sources: SourceOutcomes,
90 pub invalid_packets: u64,
91 pub duplicate_packets: u64,
92 pub expired_records: u64,
93 pub buffered_records: u32,
94 pub record_capacity: u32,
95 pub latest_copperlist: Option<u64>,
96 pub request_recovery: bool,
97}
98
99pub fn destination_key(id: &str) -> [u8; 16] {
101 blake3::hash(id.as_bytes()).as_bytes()[..16]
102 .try_into()
103 .unwrap()
104}
105
106impl ReceiverReport {
107 fn valid(&self) -> bool {
108 self.elapsed_us > 0
109 && self.record_capacity > 0
110 && self.buffered_records <= self.record_capacity
111 && self
112 .sources
113 .received
114 .checked_add(self.sources.recovered)
115 .and_then(|n| n.checked_add(self.sources.missing))
116 == Some(self.sources.finalized)
117 }
118
119 pub fn encode_into(&self, output: &mut [u8]) -> Result<usize> {
121 if output.len() < FEEDBACK_BUFFER_BYTES {
122 return Err(Error::BufferTooSmall {
123 needed: FEEDBACK_BUFFER_BYTES,
124 available: output.len(),
125 });
126 }
127 if !self.valid() {
128 return Err(Error::InvalidConfig("invalid receiver report"));
129 }
130 output[..4].copy_from_slice(MAGIC);
131 let len = bincode::encode_into_slice(
132 self,
133 &mut output[4..FEEDBACK_BUFFER_BYTES],
134 bincode::config::standard().with_fixed_int_encoding(),
135 )
136 .map_err(|_| Error::InvalidConfig("feedback report exceeds packet capacity"))?
137 + 4;
138 Ok(len)
139 }
140
141 pub fn decode(packet: &[u8]) -> Result<Self> {
145 if packet.len() < MAGIC.len()
146 || packet.len() > FEEDBACK_BUFFER_BYTES
147 || &packet[..4] != MAGIC
148 {
149 return Err(Error::InvalidConfig("invalid feedback framing"));
150 }
151 let end = packet.len();
152 let (report, used): (Self, usize) = bincode::decode_from_slice(
153 &packet[4..end],
154 bincode::config::standard()
155 .with_fixed_int_encoding()
156 .with_limit::<FEEDBACK_BUFFER_BYTES>(),
157 )
158 .map_err(|_| Error::InvalidConfig("invalid feedback payload"))?;
159 if used != end - 4 || !report.valid() {
160 return Err(Error::InvalidConfig("invalid feedback counters"));
161 }
162 Ok(report)
163 }
164}
165
166pub(crate) struct SourceObserver<const W: usize> {
169 base: Option<u32>,
170 slots: [u8; W],
171 head: usize,
172 outcomes: SourceOutcomes,
173}
174impl<const W: usize> Default for SourceObserver<W> {
175 fn default() -> Self {
176 Self {
177 base: None,
178 slots: [0; W],
179 head: 0,
180 outcomes: SourceOutcomes::default(),
181 }
182 }
183}
184impl<const W: usize> SourceObserver<W> {
185 pub(crate) fn advance(&mut self, base: u32) {
186 let Some(previous) = self.base else {
187 self.base = Some(base);
188 self.outcomes.first_esi = base;
189 return;
190 };
191 let delta = base.wrapping_sub(previous);
192 if delta == 0 || delta >= 1 << 31 {
193 return;
194 }
195 for _ in 0..(delta as usize).min(W) {
197 match self.slots[self.head] {
198 1 => self.outcomes.received += 1,
199 2 => self.outcomes.recovered += 1,
200 _ => self.outcomes.missing += 1,
201 }
202 self.slots[self.head] = 0;
203 self.head = (self.head + 1) % W;
204 }
205 if delta as usize > W {
206 self.outcomes.missing += u64::from(delta) - W as u64;
207 self.head = (self.head + (delta as usize - W) % W) % W;
208 }
209 self.outcomes.finalized += u64::from(delta);
210 self.base = Some(base);
211 }
212 pub(crate) fn observe(&mut self, esi: u32, original: bool) {
213 let Some(base) = self.base else {
214 return;
215 };
216 let offset = esi.wrapping_sub(base) as usize;
217 if offset >= W {
218 return;
219 }
220 let slot = &mut self.slots[(self.head + offset) % W];
221 if original {
222 *slot = 1;
223 } else if *slot == 0 {
224 *slot = 2;
225 }
226 }
227 pub(crate) fn outcomes(&self) -> SourceOutcomes {
228 self.outcomes
229 }
230}
231
232#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
233pub enum FeedbackState {
234 #[default]
235 Waiting,
236 Active,
237 Stale,
238}
239
240#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
241pub struct FeedbackSnapshot {
242 pub state: FeedbackState,
243 pub accepted_reports: u64,
244 pub rejected_reports: u64,
245 pub invalid_reports: u64,
246 pub last_received: Option<CuTime>,
247 pub report: Option<ReceiverReport>,
248 pub effective_repair_every_source_symbols: u16,
249 pub baseline_repair_every_source_symbols: u16,
250 pub receiver_rates_available: bool,
251 pub source_metrics_available: bool,
252 pub receiver_bytes_per_second: u64,
253 pub receiver_packets_per_second: u64,
254 pub source_loss_basis_points: u16,
255 pub source_recovery_basis_points: u16,
256}
257
258pub struct FeedbackController {
260 policy: FeedbackPolicy,
261 identity: StreamIdentity,
262 destination: [u8; 16],
263 snapshot: FeedbackSnapshot,
264 last_step: CuTime,
265 smoothed_loss: Option<u64>,
266 healthy: u8,
267}
268impl FeedbackController {
269 pub fn new(
270 policy: FeedbackPolicy,
271 identity: StreamIdentity,
272 destination: [u8; 16],
273 baseline: u16,
274 ) -> Result<Self> {
275 policy.validate(baseline)?;
276 Ok(Self {
277 policy,
278 identity,
279 destination,
280 snapshot: FeedbackSnapshot {
281 effective_repair_every_source_symbols: baseline,
282 baseline_repair_every_source_symbols: baseline,
283 ..FeedbackSnapshot::default()
284 },
285 last_step: CuTime::default(),
286 smoothed_loss: None,
287 healthy: 0,
288 })
289 }
290 pub fn snapshot(&self) -> FeedbackSnapshot {
291 self.snapshot
292 }
293 pub fn invalid_report(&mut self) {
294 self.snapshot.invalid_reports += 1;
295 }
296 pub fn tick(&mut self, now: CuTime) {
297 if self
298 .snapshot
299 .last_received
300 .is_some_and(|last| elapsed(now, last) >= u64::from(self.policy.timeout_ms) * 1_000_000)
301 {
302 self.snapshot.state = FeedbackState::Stale;
303 self.healthy = 0;
304 self.smoothed_loss = None;
305 if elapsed(now, self.last_step) >= u64::from(self.policy.report_interval_ms) * 1_000_000
306 {
307 let baseline = self.snapshot.baseline_repair_every_source_symbols;
308 let current = &mut self.snapshot.effective_repair_every_source_symbols;
309 if *current < baseline {
310 *current += 1;
311 } else if *current > baseline {
312 *current -= 1;
313 }
314 self.last_step = now;
315 }
316 }
317 }
318 pub fn receive(&mut self, report: ReceiverReport, now: CuTime) -> bool {
320 self.tick(now);
321 let previous = self.snapshot.report;
322 let same_peer = previous.is_some_and(|p| p.receiver_id == report.receiver_id);
323 let invalid = !report.valid()
324 || report.session_id != self.identity.session_id
325 || report.sender_id != self.identity.sender_id
326 || report.destination != self.destination
327 || previous.is_some_and(|p| {
328 if same_peer {
329 report.sequence <= p.sequence
330 || report.elapsed_us <= p.elapsed_us
331 || (p.sources.finalized > 0
332 && report.sources.first_esi != p.sources.first_esi)
333 || report.sources.finalized < p.sources.finalized
334 || report.sources.received < p.sources.received
335 || report.sources.recovered < p.sources.recovered
336 || report.sources.missing < p.sources.missing
337 || report.received_bytes < p.received_bytes
338 || report.received_packets < p.received_packets
339 } else {
340 self.snapshot.state != FeedbackState::Stale
341 }
342 })
343 || self.snapshot.last_received.is_some_and(|last| {
344 self.snapshot.state != FeedbackState::Stale
345 && elapsed(now, last) < u64::from(self.policy.report_interval_ms) * 500_000
346 });
347 if invalid {
348 self.snapshot.rejected_reports += 1;
349 return false;
350 }
351 if let Some(previous) = previous.filter(|_| same_peer) {
353 let micros = report.elapsed_us - previous.elapsed_us;
354 self.snapshot.receiver_rates_available = true;
355 self.snapshot.receiver_bytes_per_second =
356 rate(report.received_bytes - previous.received_bytes, micros);
357 self.snapshot.receiver_packets_per_second =
358 rate(report.received_packets - previous.received_packets, micros);
359 let n = report.sources.finalized - previous.sources.finalized;
360 if n > 0 {
361 self.snapshot.source_metrics_available = true;
362 let lost = n.saturating_sub(report.sources.received - previous.sources.received);
363 let recovered = report.sources.recovered - previous.sources.recovered;
364 let loss = ((u128::from(lost) * 10_000) / u128::from(n)) as u64;
365 self.snapshot.source_loss_basis_points = loss as u16;
366 self.snapshot.source_recovery_basis_points = if lost == 0 {
367 10_000
368 } else {
369 ((u128::from(recovered) * 10_000) / u128::from(lost)).min(10_000) as u16
370 };
371 self.adapt(loss, report.sources.missing > previous.sources.missing);
372 }
373 } else {
374 self.healthy = 0;
375 self.smoothed_loss = None;
376 self.snapshot.receiver_rates_available = false;
377 self.snapshot.source_metrics_available = false;
378 self.snapshot.receiver_bytes_per_second = 0;
379 self.snapshot.receiver_packets_per_second = 0;
380 self.snapshot.source_loss_basis_points = 0;
381 self.snapshot.source_recovery_basis_points = 0;
382 }
383 self.snapshot.report = Some(report);
384 self.snapshot.last_received = Some(now);
385 self.snapshot.state = FeedbackState::Active;
386 self.snapshot.accepted_reports += 1;
387 self.last_step = now;
388 report.request_recovery
389 }
390 fn adapt(&mut self, loss: u64, unrecovered: bool) {
391 let Some(bounds) = self.policy.adaptation else {
392 return;
393 };
394 let smoothed = self.smoothed_loss.map_or(loss, |old| (old * 3 + loss) / 4);
395 self.smoothed_loss = Some(smoothed);
396 let target = ((10_000 - smoothed) / (smoothed + LOSS_MARGIN_BP)).clamp(
397 u64::from(bounds.min_repair_every_source_symbols),
398 u64::from(bounds.max_repair_every_source_symbols),
399 ) as u16;
400 let current = &mut self.snapshot.effective_repair_every_source_symbols;
401 if unrecovered {
402 *current = (*current / 2)
403 .min(target)
404 .max(bounds.min_repair_every_source_symbols);
405 self.healthy = 0;
406 } else if target < *current {
407 *current = target;
408 self.healthy = 0;
409 } else if target > *current {
410 self.healthy += 1;
411 if self.healthy >= HEALTHY_REPORTS {
412 *current += 1;
413 self.healthy = 0;
414 }
415 } else {
416 self.healthy = 0;
417 }
418 }
419}
420fn elapsed(now: CuTime, then: CuTime) -> u64 {
421 now.as_nanos().saturating_sub(then.as_nanos())
422}
423fn rate(count: u64, micros: u64) -> u64 {
424 (u128::from(count) * 1_000_000 / u128::from(micros.max(1))).min(u128::from(u64::MAX)) as u64
425}
426
427pub struct FeedbackReporter {
430 pub requirements: FeedbackRequirements,
431 identity: StreamIdentity,
432 destination: [u8; 16],
433 receiver_id: [u8; 16],
434 started: CuTime,
435 next_report: CuTime,
436 sequence: u64,
437}
438impl FeedbackReporter {
439 pub fn new(
440 manifest: &crate::SessionManifest,
441 receiver_id: [u8; 16],
442 now: CuTime,
443 ) -> Option<Self> {
444 let requirements = manifest.requirements.feedback?;
445 Some(Self {
446 requirements,
447 identity: manifest.identity,
448 destination: requirements.destination,
449 receiver_id,
450 started: now,
451 next_report: now + CuDuration::from_millis(u64::from(requirements.report_interval_ms)),
452 sequence: 0,
453 })
454 }
455 pub fn report(&mut self, now: CuTime, mut counters: ReceiverReport) -> Option<ReceiverReport> {
456 if now < self.next_report {
457 return None;
458 }
459 self.next_report =
460 now + CuDuration::from_millis(u64::from(self.requirements.report_interval_ms));
461 self.sequence = self.sequence.saturating_add(1);
462 counters.session_id = self.identity.session_id;
463 counters.sender_id = self.identity.sender_id;
464 counters.destination = self.destination;
465 counters.receiver_id = self.receiver_id;
466 counters.sequence = self.sequence;
467 counters.elapsed_us = (elapsed(now, self.started) / 1000).max(1);
468 Some(counters)
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 #[test]
476 fn observer_handles_wrap_repair_then_original_and_long_outage() {
477 let mut observer = SourceObserver::<4>::default();
478 observer.advance(u32::MAX - 1);
479 observer.observe(u32::MAX - 1, true);
480 observer.observe(u32::MAX, false);
481 observer.observe(u32::MAX, true);
482 observer.observe(0, false);
483 observer.advance(2);
484 assert_eq!(
485 observer.outcomes(),
486 SourceOutcomes {
487 first_esi: u32::MAX - 1,
488 finalized: 4,
489 received: 2,
490 recovered: 1,
491 missing: 1,
492 }
493 );
494 observer.advance(1_000_002);
495 assert_eq!(observer.outcomes().finalized, 1_000_004);
496 assert_eq!(observer.outcomes().missing, 1_000_001);
497 observer.observe(1_000_002, true);
498 observer.advance(1_000_003);
499 assert_eq!(observer.outcomes().received, 3);
500 }
501}