Files
MechaCat02 5e1bda1f32 fix(core): cron double-fire, materialize warnings, group quota fast path, SSE channel cap
Remediate the MEDIUM backend correctness/perf findings from the 2026-07-11 audit.

B1 — a group cron template toggled disabled→enabled no longer re-fires every
descendant's cron. Materialization now KEEPS a disabled cron template's copies
(syncing `enabled` in place) instead of delete+recreate, preserving
`last_fired_at`; the scheduler already skips disabled copies, so keeping them is
inert. Queue/email arms still delete-on-disable (freeing the one-consumer slot).

B2 — `rematerialize_stateful_templates` warnings are now logged at the
app-create/delete and group-reparent chokepoints (were silently dropped); only
the `Err` arm was handled before.

B3 — the group KV/docs byte-quota check skips the O(rows) `SUM(octet_length(..))`
scan when a cheap upper bound (`rows_after * max_value_bytes`) is already under
the cap, so the full aggregate only runs near-cap. Every row is validated
≤ max_value_bytes, so the bound is sound (never lets an over-quota write through).

B6 — the in-process SSE broadcaster caps live channels per map
(`PICLOUD_REALTIME_MAX_CHANNELS`, default 100k); a subscribe that would open a
NEW channel past the cap is refused with 503 + `Retry-After: 1` rather than
growing the (app,topic)/(group,topic) maps unboundedly. Check+insert is atomic
under the map mutex (no TOCTOU); an existing-channel subscribe is exempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:47 +02:00

383 lines
14 KiB
Rust

//! In-process `RealtimeBroadcaster` — the SSE fan-out registry (v1.1.6).
//!
//! Sibling of [`crate::inbox::InboxRegistry`], but multi-receiver and
//! repeated-event: a `Mutex<HashMap<(AppId, topic), broadcast::Sender>>`
//! over `tokio::sync::broadcast` instead of a oneshot map. The publish
//! side ([`PubsubServiceImpl`]) and the SSE subscribe side both hold one
//! shared `Arc<InProcessBroadcaster>`.
//!
//! Delivery is best-effort: each channel has a bounded buffer
//! (`PICLOUD_REALTIME_BROADCAST_CAPACITY`, default 64); a slow consumer
//! that falls behind sees the oldest events dropped (standard
//! `broadcast` lag semantics — the receiver gets `RecvError::Lagged`).
//! SSE's transport-layer auto-reconnect is the recovery path; there's no
//! server-side replay in v1.1.6.
//!
//! Channels are created lazily on first subscribe. A periodic GC task
//! ([`spawn_realtime_gc`]) drops senders whose receiver count has fallen
//! to zero so one-shot subscribers don't grow the map unboundedly.
//!
//! Cluster mode (v1.3+) swaps this for a Postgres `LISTEN/NOTIFY`-backed
//! resolver behind the same `RealtimeBroadcaster` trait.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use picloud_shared::{AppId, BroadcasterError, GroupId, RealtimeBroadcaster, RealtimeEvent};
use tokio::sync::broadcast;
/// Default per-channel broadcast buffer depth.
pub const DEFAULT_BROADCAST_CAPACITY: usize = 64;
const ENV_CAPACITY: &str = "PICLOUD_REALTIME_BROADCAST_CAPACITY";
/// Default cap on the number of live channels PER map (per-app and per-group).
/// A subscribe that would create a NEW channel past this is refused, so an
/// attacker naming unbounded distinct topics can't grow the maps until OOM.
pub const DEFAULT_MAX_CHANNELS: usize = 100_000;
const ENV_MAX_CHANNELS: &str = "PICLOUD_REALTIME_MAX_CHANNELS";
/// Default GC sweep interval for empty channels.
pub const DEFAULT_GC_INTERVAL_SECS: u64 = 60;
pub struct InProcessBroadcaster {
inner: Mutex<HashMap<(AppId, String), broadcast::Sender<RealtimeEvent>>>,
/// §11.6 D2: group SHARED topic channels, keyed by the OWNING group so every
/// subtree app subscribing to the same shared topic shares one channel.
groups: Mutex<HashMap<(GroupId, String), broadcast::Sender<RealtimeEvent>>>,
capacity: usize,
/// Max live channels per map (see [`DEFAULT_MAX_CHANNELS`]).
max_channels: usize,
}
impl InProcessBroadcaster {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
inner: Mutex::new(HashMap::new()),
groups: Mutex::new(HashMap::new()),
capacity: capacity.max(1),
max_channels: DEFAULT_MAX_CHANNELS,
}
}
/// Override the per-map channel cap (0 → treated as 1; never unbounded).
#[must_use]
pub fn with_max_channels(mut self, max_channels: usize) -> Self {
self.max_channels = max_channels.max(1);
self
}
/// Build from `PICLOUD_REALTIME_BROADCAST_CAPACITY` (default 64) and
/// `PICLOUD_REALTIME_MAX_CHANNELS` (default 100_000).
#[must_use]
pub fn from_env() -> Self {
let capacity = match std::env::var(ENV_CAPACITY) {
Err(_) => DEFAULT_BROADCAST_CAPACITY,
Ok(v) => match v.parse::<usize>() {
Ok(n) if n > 0 => n,
Ok(_) => {
tracing::warn!(env = ENV_CAPACITY, value = %v, "must be > 0; using default");
DEFAULT_BROADCAST_CAPACITY
}
Err(e) => {
tracing::warn!(env = ENV_CAPACITY, value = %v, error = %e, "invalid; using default");
DEFAULT_BROADCAST_CAPACITY
}
},
};
let max_channels = match std::env::var(ENV_MAX_CHANNELS) {
Err(_) => DEFAULT_MAX_CHANNELS,
Ok(v) => match v.parse::<usize>() {
Ok(n) if n > 0 => n,
Ok(_) => {
tracing::warn!(env = ENV_MAX_CHANNELS, value = %v, "must be > 0; using default");
DEFAULT_MAX_CHANNELS
}
Err(e) => {
tracing::warn!(env = ENV_MAX_CHANNELS, value = %v, error = %e, "invalid; using default");
DEFAULT_MAX_CHANNELS
}
},
};
Self::new(capacity).with_max_channels(max_channels)
}
/// Number of live channels in both maps (test/observability helper).
#[must_use]
pub fn channel_count(&self) -> usize {
let app = self.inner.lock().map(|g| g.len()).unwrap_or(0);
let grp = self.groups.lock().map(|g| g.len()).unwrap_or(0);
app + grp
}
/// Drop senders with zero receivers across both maps. Returns how many were
/// removed. Called periodically by [`spawn_realtime_gc`].
pub fn gc(&self) -> usize {
let mut removed = 0;
if let Ok(mut g) = self.inner.lock() {
let before = g.len();
g.retain(|_, tx| tx.receiver_count() > 0);
removed += before - g.len();
}
if let Ok(mut g) = self.groups.lock() {
let before = g.len();
g.retain(|_, tx| tx.receiver_count() > 0);
removed += before - g.len();
}
removed
}
}
#[async_trait]
impl RealtimeBroadcaster for InProcessBroadcaster {
async fn subscribe(
&self,
app_id: AppId,
topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
let mut g = self
.inner
.lock()
.map_err(|_| BroadcasterError::Unavailable("broadcaster map poisoned".into()))?;
let key = (app_id, topic.to_string());
// Refuse to open a NEW channel past the cap; subscribing to an existing
// one (another receiver) is always allowed.
if !g.contains_key(&key) && g.len() >= self.max_channels {
return Err(BroadcasterError::Unavailable(
"realtime channel limit reached".into(),
));
}
let tx = g
.entry(key)
.or_insert_with(|| broadcast::channel(self.capacity).0);
Ok(tx.subscribe())
}
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent) {
let Ok(g) = self.inner.lock() else {
return;
};
// Only fan out to an existing channel: a topic with no live
// subscribers has no sender (publish never creates one). `send`
// returns Err iff every receiver has dropped — a benign no-op.
if let Some(tx) = g.get(&(app_id, topic.to_string())) {
let _ = tx.send(event);
}
}
async fn drop_topic(&self, app_id: AppId, topic: &str) {
if let Ok(mut g) = self.inner.lock() {
// Removing the sender closes the channel; existing receivers
// observe `RecvError::Closed` and disconnect cleanly.
g.remove(&(app_id, topic.to_string()));
}
}
async fn subscribe_group(
&self,
group_id: GroupId,
topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
let mut g = self
.groups
.lock()
.map_err(|_| BroadcasterError::Unavailable("group broadcaster map poisoned".into()))?;
let key = (group_id, topic.to_string());
if !g.contains_key(&key) && g.len() >= self.max_channels {
return Err(BroadcasterError::Unavailable(
"realtime channel limit reached".into(),
));
}
let tx = g
.entry(key)
.or_insert_with(|| broadcast::channel(self.capacity).0);
Ok(tx.subscribe())
}
async fn publish_group(&self, group_id: GroupId, topic: &str, event: RealtimeEvent) {
let Ok(g) = self.groups.lock() else {
return;
};
if let Some(tx) = g.get(&(group_id, topic.to_string())) {
let _ = tx.send(event);
}
}
async fn drop_group_topic(&self, group_id: GroupId, topic: &str) {
if let Ok(mut g) = self.groups.lock() {
g.remove(&(group_id, topic.to_string()));
}
}
}
/// Spawn the background GC sweep that drops empty channels every
/// `interval_secs` (default [`DEFAULT_GC_INTERVAL_SECS`]). Spawned at
/// startup alongside the other housekeeping tasks.
pub fn spawn_realtime_gc(broadcaster: Arc<InProcessBroadcaster>, interval_secs: u64) {
let period = Duration::from_secs(interval_secs.max(1));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(period);
ticker.tick().await; // skip the immediate first fire
loop {
ticker.tick().await;
let removed = broadcaster.gc();
if removed > 0 {
tracing::debug!(removed, "realtime broadcaster GC dropped empty channels");
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use serde_json::json;
fn event(topic: &str, n: i64) -> RealtimeEvent {
RealtimeEvent {
topic: topic.to_string(),
message: json!({ "n": n }),
published_at: Utc::now(),
}
}
#[tokio::test]
async fn multiple_subscribers_each_receive_each_event() {
let b = InProcessBroadcaster::new(16);
let app = AppId::new();
let mut rx1 = b.subscribe(app, "chat").await.unwrap();
let mut rx2 = b.subscribe(app, "chat").await.unwrap();
b.publish(app, "chat", event("chat", 1)).await;
b.publish(app, "chat", event("chat", 2)).await;
for rx in [&mut rx1, &mut rx2] {
assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 1 }));
assert_eq!(rx.recv().await.unwrap().message, json!({ "n": 2 }));
}
}
#[tokio::test]
async fn dropped_subscriber_does_not_leak_after_gc() {
let b = InProcessBroadcaster::new(16);
let app = AppId::new();
let rx = b.subscribe(app, "t").await.unwrap();
assert_eq!(b.channel_count(), 1);
drop(rx);
// GC reclaims the now-empty channel.
assert_eq!(b.gc(), 1);
assert_eq!(b.channel_count(), 0);
}
#[tokio::test]
async fn drop_topic_disconnects_existing_subscribers() {
let b = InProcessBroadcaster::new(16);
let app = AppId::new();
let mut rx = b.subscribe(app, "t").await.unwrap();
b.drop_topic(app, "t").await;
// Sender gone → receiver observes a closed channel.
assert!(rx.recv().await.is_err());
assert_eq!(b.channel_count(), 0);
}
#[tokio::test]
async fn slow_consumer_loses_oldest_events() {
// Capacity 2: a consumer that never drains sees the oldest
// events dropped (broadcast Lagged semantics).
let b = InProcessBroadcaster::new(2);
let app = AppId::new();
let mut rx = b.subscribe(app, "t").await.unwrap();
for i in 0..5 {
b.publish(app, "t", event("t", i)).await;
}
// First recv reports the lag rather than event 0.
let first = rx.recv().await;
assert!(
matches!(first, Err(broadcast::error::RecvError::Lagged(_))),
"expected Lagged, got {first:?}"
);
// Subsequent recvs return the most recent buffered events.
let next = rx.recv().await.unwrap();
assert_eq!(next.message, json!({ "n": 3 }));
}
#[tokio::test]
async fn cross_app_isolation() {
let b = InProcessBroadcaster::new(16);
let app_a = AppId::new();
let app_b = AppId::new();
let mut rx_a = b.subscribe(app_a, "shared").await.unwrap();
let mut rx_b = b.subscribe(app_b, "shared").await.unwrap();
b.publish(app_a, "shared", event("shared", 1)).await;
// App B's subscriber must not see app A's publish.
assert_eq!(rx_a.recv().await.unwrap().message, json!({ "n": 1 }));
assert!(rx_b.try_recv().is_err());
}
#[tokio::test]
async fn publish_with_no_subscribers_is_noop() {
let b = InProcessBroadcaster::new(16);
let app = AppId::new();
// No subscriber → no sender created → no panic, nothing fanned out.
b.publish(app, "ghost", event("ghost", 1)).await;
assert_eq!(b.channel_count(), 0);
}
#[tokio::test]
async fn group_channel_fans_out_and_isolates_by_group() {
// §11.6 D2: a group shared-topic channel is keyed by the OWNING group —
// a publish to group A never reaches a subscriber on group B, and the
// group map is separate from the per-app map.
let b = InProcessBroadcaster::new(16);
let g_a = GroupId::new();
let g_b = GroupId::new();
let mut rx_a1 = b.subscribe_group(g_a, "events").await.unwrap();
let mut rx_a2 = b.subscribe_group(g_a, "events").await.unwrap();
let mut rx_b = b.subscribe_group(g_b, "events").await.unwrap();
assert_eq!(b.channel_count(), 2, "one channel per (group, topic)");
b.publish_group(g_a, "events", event("events", 7)).await;
// Both of group A's subscribers see it...
assert_eq!(rx_a1.recv().await.unwrap().message, json!({ "n": 7 }));
assert_eq!(rx_a2.recv().await.unwrap().message, json!({ "n": 7 }));
// ...group B's does not (owning-group isolation).
assert!(rx_b.try_recv().is_err());
// GC reclaims both maps.
drop((rx_a1, rx_a2, rx_b));
assert_eq!(b.gc(), 2);
assert_eq!(b.channel_count(), 0);
}
#[tokio::test]
async fn subscribe_refuses_new_channel_past_the_cap() {
// Bound the maps: a subscribe that would open a NEW channel past the cap
// is refused, but subscribing to an already-open channel still works.
let b = InProcessBroadcaster::new(16).with_max_channels(2);
let app = AppId::new();
let _rx1 = b.subscribe(app, "a").await.unwrap();
let _rx2 = b.subscribe(app, "b").await.unwrap();
// Third distinct topic → over cap → refused.
assert!(matches!(
b.subscribe(app, "c").await,
Err(BroadcasterError::Unavailable(_))
));
// An additional receiver on an existing channel is still allowed.
assert!(b.subscribe(app, "a").await.is_ok());
}
#[tokio::test]
async fn drop_group_topic_disconnects_subscribers() {
let b = InProcessBroadcaster::new(16);
let g = GroupId::new();
let mut rx = b.subscribe_group(g, "t").await.unwrap();
b.drop_group_topic(g, "t").await;
assert!(rx.recv().await.is_err());
assert_eq!(b.channel_count(), 0);
}
}