mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 16:21:09 +00:00
[AZ-659] [AZ-660] [AZ-661] Implement frame publisher + gRPC detection client
AZ-659: FramePublisher with per-consumer drop accounting (Arc<Bytes> zero-copy fan-out). Adds ConsumerId enum, PublisherStats, FrameReceiver wrapper, and publisher integration tests (AC-1, AC-2, AC-3). AZ-660: Bi-directional tonic gRPC stream to ../detections. Reconnect with bounded exponential backoff (1 s → 30 s cap). Drop-oldest in-flight budgeting (max_concurrent_in_flight = 2). ai_locked frame skipping. Integration tests against fixture in-process server (AC-1: happy path 30 fps/10 s, AC-2: reconnect, AC-3: budget drops, AC-4: ai_locked skipping). AZ-661: Schema validation (hard SchemaMismatch error on version mismatch), model_version latch with ModelVersionChanged events, sliding-window p99 latency tracker with Tier1Degraded/Tier1Recovered transitions. Integration tests (AC-1, AC-2, AC-3). Also: update module-layout.md for frame_ingest and detection_client to reflect the streaming API shape; code review report batch_18. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+110
-24
@@ -1,4 +1,4 @@
|
||||
//! `frame_ingest` — RTSP pull + decode + timestamp.
|
||||
//! `frame_ingest` — RTSP pull + decode + timestamp + publish.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-657 `frame_ingest_rtsp_session` — session lifecycle + bounded
|
||||
@@ -7,18 +7,31 @@
|
||||
//! fallback) + per-frame monotonic timestamping + decode stats
|
||||
//! (this crate, `internal/decoder.rs` + `internal/timestamp.rs`).
|
||||
//! - AZ-659 `frame_ingest_publisher` — bounded broadcast + per-consumer
|
||||
//! drop policy.
|
||||
//! drop policy (this crate, `internal/publisher.rs`).
|
||||
//!
|
||||
//! ## AZ-658 surface (extends AZ-657)
|
||||
//!
|
||||
//! `FrameIngest::run` now takes a [`FrameDecoder`]. The lifecycle loop
|
||||
//! `FrameIngest::run` takes a [`FrameDecoder`]. The lifecycle loop
|
||||
//! stamps the capture timestamp the moment a packet leaves the
|
||||
//! transport, hands the encoded payload to the decoder, and emits one
|
||||
//! [`Frame`] per decoded picture with `decode_ts_monotonic_ns` set
|
||||
//! when the decoder returned. Single-frame decode errors increment
|
||||
//! `decode_errors_total` and drop the frame; the stream is never
|
||||
//! aborted (AC-3). The decoder backend (`Nvdec` / `Software`) is
|
||||
//! observable via [`FrameIngestHandle::decoder_backend`].
|
||||
//! aborted. The decoder backend (`Nvdec` / `Software`) is observable
|
||||
//! via [`FrameIngestHandle::decoder_backend`].
|
||||
//!
|
||||
//! ## AZ-659 surface (extends AZ-658)
|
||||
//!
|
||||
//! Decoded frames flow through a [`FramePublisher`]. The publisher
|
||||
//! exposes [`FrameIngestHandle::subscribe_as`] for the three known
|
||||
//! consumers (`detection_client`, `movement_detector`,
|
||||
//! `telemetry_stream`); each subscriber's lag is folded into a
|
||||
//! per-consumer drop counter visible via
|
||||
//! [`FrameIngestHandle::dropped_frames`]. Drops are *counted* and
|
||||
//! `tracing::warn`-logged with a reason tag — never silent.
|
||||
//! `FrameIngestHandle::subscribe()` is preserved for legacy callers
|
||||
//! that don't fit one of the three named consumer roles; lag on
|
||||
//! those raw receivers is not attributed to any consumer counter.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
@@ -38,6 +51,10 @@ pub use internal::decoder::{
|
||||
FfmpegDecoder, FrameDecoder,
|
||||
};
|
||||
pub use internal::lifecycle::{BackoffPolicy, LifecycleStats, SessionState};
|
||||
pub use internal::publisher::{
|
||||
ConsumerId, FramePublisher, FrameReceiver, PublisherStats, RecvError as FrameRecvError,
|
||||
TryRecvError as FrameTryRecvError, DEFAULT_CHANNEL_DEPTH,
|
||||
};
|
||||
pub use internal::rtsp_client::{
|
||||
OpenError, RtspPacket, RtspSessionConfig, RtspTransport, RtspTransportHint, StreamError,
|
||||
};
|
||||
@@ -53,7 +70,7 @@ const NAME: &str = "frame_ingest";
|
||||
const RED_FRAME_AGE: Duration = Duration::from_secs(5);
|
||||
|
||||
pub struct FrameIngest {
|
||||
tx: broadcast::Sender<Frame>,
|
||||
publisher: Arc<FramePublisher>,
|
||||
ai_lock_tx: watch::Sender<bool>,
|
||||
state_tx: watch::Sender<SessionState>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
@@ -65,6 +82,10 @@ pub struct FrameIngest {
|
||||
}
|
||||
|
||||
impl FrameIngest {
|
||||
/// Default constructor — `channel_capacity` maps directly to the
|
||||
/// publisher's `channel_depth` (see `description.md §3`). Use
|
||||
/// [`Self::with_backoff`] when both the depth and the reopen
|
||||
/// backoff need to be customised.
|
||||
pub fn new(channel_capacity: usize) -> Self {
|
||||
Self::with_backoff(
|
||||
channel_capacity,
|
||||
@@ -73,13 +94,13 @@ impl FrameIngest {
|
||||
}
|
||||
|
||||
pub fn with_backoff(channel_capacity: usize, backoff: BackoffPolicy) -> Self {
|
||||
let (tx, _rx) = broadcast::channel(channel_capacity);
|
||||
let publisher = Arc::new(FramePublisher::new(channel_capacity));
|
||||
let (ai_lock_tx, _) = watch::channel(false);
|
||||
let (state_tx, _) = watch::channel(SessionState::Closed);
|
||||
let (shutdown_tx, _) = watch::channel(false);
|
||||
let (backend_tx, _) = watch::channel(None);
|
||||
Self {
|
||||
tx,
|
||||
publisher,
|
||||
ai_lock_tx,
|
||||
state_tx,
|
||||
shutdown_tx,
|
||||
@@ -91,9 +112,18 @@ impl FrameIngest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared accessor for the underlying [`FramePublisher`]. The
|
||||
/// composition root passes this `Arc` to consumers that prefer
|
||||
/// to subscribe themselves (named via [`ConsumerId`]) rather
|
||||
/// than receiving a pre-built [`FrameReceiver`] over the
|
||||
/// handle.
|
||||
pub fn publisher(&self) -> Arc<FramePublisher> {
|
||||
Arc::clone(&self.publisher)
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> FrameIngestHandle {
|
||||
FrameIngestHandle {
|
||||
tx: self.tx.clone(),
|
||||
publisher: Arc::clone(&self.publisher),
|
||||
ai_lock_tx: self.ai_lock_tx.clone(),
|
||||
state_rx: self.state_tx.subscribe(),
|
||||
shutdown_tx: self.shutdown_tx.clone(),
|
||||
@@ -115,7 +145,7 @@ impl FrameIngest {
|
||||
T: RtspTransport + 'static,
|
||||
D: FrameDecoder + 'static,
|
||||
{
|
||||
let tx = self.tx.clone();
|
||||
let publisher = Arc::clone(&self.publisher);
|
||||
let ai_lock = self.ai_lock_tx.subscribe();
|
||||
let state_tx = self.state_tx.clone();
|
||||
let backend_tx = self.backend_tx.clone();
|
||||
@@ -135,7 +165,7 @@ impl FrameIngest {
|
||||
transport,
|
||||
decoder,
|
||||
config,
|
||||
tx,
|
||||
publisher,
|
||||
ai_lock,
|
||||
state_tx,
|
||||
shutdown_rx,
|
||||
@@ -158,7 +188,7 @@ async fn lifecycle_loop<T>(
|
||||
transport: Arc<Mutex<T>>,
|
||||
mut decoder: Box<dyn FrameDecoder + Send>,
|
||||
config: RtspSessionConfig,
|
||||
tx: broadcast::Sender<Frame>,
|
||||
publisher: Arc<FramePublisher>,
|
||||
mut ai_lock: watch::Receiver<bool>,
|
||||
state_tx: watch::Sender<SessionState>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -250,12 +280,14 @@ async fn lifecycle_loop<T>(
|
||||
pix_fmt: dp.pix_fmt,
|
||||
ai_locked: locked,
|
||||
};
|
||||
// Send errors are no-ops when
|
||||
// the broadcast has no
|
||||
// subscribers; per-consumer
|
||||
// back-pressure is AZ-659's
|
||||
// problem.
|
||||
let _ = tx.send(frame);
|
||||
// The publisher folds lag
|
||||
// into per-consumer drop
|
||||
// counters; the lifecycle
|
||||
// loop never blocks on a
|
||||
// slow consumer. Return
|
||||
// value (subscriber count)
|
||||
// is informational.
|
||||
publisher.publish(frame);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -309,7 +341,7 @@ async fn lifecycle_loop<T>(
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FrameIngestHandle {
|
||||
tx: broadcast::Sender<Frame>,
|
||||
publisher: Arc<FramePublisher>,
|
||||
ai_lock_tx: watch::Sender<bool>,
|
||||
state_rx: watch::Receiver<SessionState>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
@@ -320,12 +352,47 @@ pub struct FrameIngestHandle {
|
||||
}
|
||||
|
||||
impl FrameIngestHandle {
|
||||
/// Subscribe to the frame stream. Consumers receive every frame
|
||||
/// after they subscribed; back-pressure is implemented via
|
||||
/// broadcast channel lag (see AZ-659 for the slow-consumer
|
||||
/// policy).
|
||||
/// Raw, unaccounted subscription. Used by legacy callers and
|
||||
/// tests that don't fit one of the three named [`ConsumerId`]
|
||||
/// roles. Lag on this receiver is *not* attributed to any
|
||||
/// per-consumer drop counter — prefer [`Self::subscribe_as`] for
|
||||
/// production consumers so the per-consumer drop dashboard
|
||||
/// stays accurate.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
|
||||
self.tx.subscribe()
|
||||
self.publisher.subscribe_raw()
|
||||
}
|
||||
|
||||
/// Subscribe under a named consumer identity. Per-consumer lag
|
||||
/// is folded into the matching drop counter and surfaced via
|
||||
/// [`Self::dropped_frames`]. The returned [`FrameReceiver`]
|
||||
/// transparently retries past lag so callers never observe
|
||||
/// `Lagged` — they only see the next available frame.
|
||||
pub fn subscribe_as(&self, consumer: ConsumerId) -> FrameReceiver {
|
||||
self.publisher.subscribe(consumer)
|
||||
}
|
||||
|
||||
/// Shared accessor for the underlying [`FramePublisher`]. Useful
|
||||
/// when a consumer needs to subscribe multiple times (e.g.
|
||||
/// reopening a receiver after a transient logical reset) without
|
||||
/// holding the full ingest handle.
|
||||
pub fn publisher(&self) -> Arc<FramePublisher> {
|
||||
Arc::clone(&self.publisher)
|
||||
}
|
||||
|
||||
/// Per-consumer drop counter. Increments by `n` every time the
|
||||
/// matching [`FrameReceiver`] would otherwise have surfaced
|
||||
/// `RecvError::Lagged(n)`.
|
||||
pub fn dropped_frames(&self, consumer: ConsumerId) -> u64 {
|
||||
self.publisher.stats().drops_for(consumer)
|
||||
}
|
||||
|
||||
/// Total publish attempts since the publisher was constructed.
|
||||
/// Increments on every decoded frame even when there are zero
|
||||
/// subscribers — the metric is the publish *rate*, not the
|
||||
/// delivered-frame rate. Use [`Self::dropped_frames`] for the
|
||||
/// delivered-vs-published delta per consumer.
|
||||
pub fn publishes_total(&self) -> u64 {
|
||||
self.publisher.stats().publishes_total()
|
||||
}
|
||||
|
||||
/// `bringCameraDown`/`bringCameraUp` per `description.md §2`. When
|
||||
@@ -467,4 +534,23 @@ mod tests {
|
||||
handle.set_ai_lock(false);
|
||||
assert!(!handle.ai_locked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_exposes_publisher_metrics_before_run() {
|
||||
// Arrange
|
||||
let ingest = FrameIngest::new(4);
|
||||
let handle = ingest.handle();
|
||||
|
||||
// Assert — fresh publisher exposes zero metrics for every
|
||||
// known consumer (the AZ-659 health surface contract).
|
||||
assert_eq!(handle.publishes_total(), 0);
|
||||
assert_eq!(handle.dropped_frames(ConsumerId::DetectionClient), 0);
|
||||
assert_eq!(handle.dropped_frames(ConsumerId::MovementDetector), 0);
|
||||
assert_eq!(handle.dropped_frames(ConsumerId::Telemetry), 0);
|
||||
assert_eq!(
|
||||
handle.publisher().channel_depth(),
|
||||
4,
|
||||
"channel_capacity from constructor must propagate to the publisher"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user