feat(realtime): external SSE subscription for group shared topics (§11.6 D2 / Track A M6)
Shared TOPICS fanned out only to in-cluster trigger handlers; external clients
could not subscribe (per-app topics already can). Add SSE for shared topics.
- RealtimeBroadcaster gains a parallel (group_id, topic) channel map:
subscribe_group / publish_group / drop_group_topic (default no-ops so
NoopRealtimeBroadcaster + test doubles are untouched). InProcessBroadcaster
implements them with a second map; GC + channel_count span both.
- Route GET /realtime/shared/topics/{topic}: Host->app dispatch (as the per-app
route), then RealtimeAuthority::authorize_subscribe_shared resolves the OWNING
GROUP from the app's chain (kind=topic, root segment). Reads-open model — the
resolution IS the authorization, consistent with in-script shared reads; a
foreign-subtree app never resolves (404, the isolation boundary). No principal
machinery needed.
- GroupPubsubServiceImpl::with_realtime bridges a shared-topic publish to the
owning-group channel (best-effort) after the durable trigger fan-out.
- Host wires the broadcaster into the group pubsub service + the collection
resolver into the authority.
Auth-model note: chose reads-open (subtree app's Host is the grant) over
"authenticated principal + GroupKvRead" — it's both simpler and faithful to how
shared-collection reads already work. Pinned by realtime broadcaster group-map
tests, realtime_api shared-route tests (404 + stream), and
group_pubsub_service::publish_bridges_to_the_group_broadcaster. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, BroadcasterError, RealtimeBroadcaster, RealtimeEvent};
|
||||
use picloud_shared::{AppId, BroadcasterError, GroupId, RealtimeBroadcaster, RealtimeEvent};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Default per-channel broadcast buffer depth.
|
||||
@@ -37,6 +37,9 @@ 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,
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ impl InProcessBroadcaster {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
groups: Mutex::new(HashMap::new()),
|
||||
capacity: capacity.max(1),
|
||||
}
|
||||
}
|
||||
@@ -69,21 +73,29 @@ impl InProcessBroadcaster {
|
||||
Self::new(capacity)
|
||||
}
|
||||
|
||||
/// Number of live channels in the map (test/observability helper).
|
||||
/// Number of live channels in both maps (test/observability helper).
|
||||
#[must_use]
|
||||
pub fn channel_count(&self) -> usize {
|
||||
self.inner.lock().map(|g| g.len()).unwrap_or(0)
|
||||
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. Returns how many were removed.
|
||||
/// Called periodically by [`spawn_realtime_gc`].
|
||||
/// 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 Ok(mut g) = self.inner.lock() else {
|
||||
return 0;
|
||||
};
|
||||
let before = g.len();
|
||||
g.retain(|_, tx| tx.receiver_count() > 0);
|
||||
before - g.len()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +135,36 @@ impl RealtimeBroadcaster for InProcessBroadcaster {
|
||||
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 tx = g
|
||||
.entry((group_id, topic.to_string()))
|
||||
.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
|
||||
@@ -239,4 +281,40 @@ mod tests {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ pub fn heartbeat_secs_from_env() -> u64 {
|
||||
pub fn realtime_router(state: RealtimeState) -> Router {
|
||||
Router::new()
|
||||
.route("/realtime/topics/{topic}", get(sse_topic))
|
||||
// §11.6 D2: group SHARED topics. Same Host→app dispatch; the owning group
|
||||
// is resolved from the app's chain by the authority (reads-open model).
|
||||
.route("/realtime/shared/topics/{topic}", get(sse_shared_topic))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -158,6 +161,64 @@ async fn sse_topic(
|
||||
resp
|
||||
}
|
||||
|
||||
/// §11.6 D2: SSE for a group SHARED topic. Host→app resolves the subscriber's
|
||||
/// app; the authority resolves the owning group from that app's chain (reads
|
||||
/// open — the declaration is the grant; a foreign-subtree app 404s). The stream
|
||||
/// is keyed by the owning group, so every subtree subscriber shares one channel
|
||||
/// and sees every subtree app's publishes.
|
||||
async fn sse_shared_topic(
|
||||
State(state): State<RealtimeState>,
|
||||
Path(topic): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let Some(app_id) = state.app_domains.resolve_app(host) else {
|
||||
return not_found("no app claims this host");
|
||||
};
|
||||
|
||||
let group_id = match state
|
||||
.authority
|
||||
.authorize_subscribe_shared(app_id, &topic)
|
||||
.await
|
||||
{
|
||||
Ok(gid) => gid,
|
||||
Err(SubscribeDenied::NotFound) => return not_found("shared topic not found"),
|
||||
Err(SubscribeDenied::Unauthorized) => return unauthorized(),
|
||||
Err(SubscribeDenied::Backend(e)) => {
|
||||
tracing::error!(error = %e, "realtime authority backend error");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({ "error": "internal error" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let rx = match state.broadcaster.subscribe_group(group_id, &topic).await {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to acquire shared-topic subscription");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({ "error": "internal error" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let sse = Sse::new(event_stream(rx))
|
||||
.keep_alive(KeepAlive::new().interval(state.heartbeat).text("heartbeat"));
|
||||
let mut resp = sse.into_response();
|
||||
resp.headers_mut().insert(
|
||||
"X-Accel-Buffering",
|
||||
axum::http::HeaderValue::from_static("no"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Map the broadcast receiver into a stream of SSE events. Lagged
|
||||
/// notifications (slow consumer) are skipped; a closed channel
|
||||
/// (`drop_topic`, or all senders gone) ends the stream and the SSE
|
||||
@@ -214,8 +275,9 @@ mod tests {
|
||||
use picloud_shared::{AppId, RealtimeEvent};
|
||||
use tower::ServiceExt; // oneshot
|
||||
|
||||
/// Authority stub returning a fixed verdict.
|
||||
struct StubAuthority(Result<(), SubscribeDenied>);
|
||||
/// Authority stub returning a fixed verdict. For the shared-topic path it
|
||||
/// maps `Ok` → a fixed owning group, else the same denial.
|
||||
struct StubAuthority(Result<(), SubscribeDenied>, picloud_shared::GroupId);
|
||||
#[async_trait]
|
||||
impl RealtimeAuthority for StubAuthority {
|
||||
async fn authorize_subscribe(
|
||||
@@ -226,6 +288,13 @@ mod tests {
|
||||
) -> Result<(), SubscribeDenied> {
|
||||
self.0.clone()
|
||||
}
|
||||
async fn authorize_subscribe_shared(
|
||||
&self,
|
||||
_: AppId,
|
||||
_: &str,
|
||||
) -> Result<picloud_shared::GroupId, SubscribeDenied> {
|
||||
self.0.clone().map(|()| self.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// App-domain table that maps a fixed host to a fixed app.
|
||||
@@ -250,7 +319,7 @@ mod tests {
|
||||
RealtimeState {
|
||||
app_domains: domains(host, app),
|
||||
broadcaster,
|
||||
authority: Arc::new(StubAuthority(verdict)),
|
||||
authority: Arc::new(StubAuthority(verdict, picloud_shared::GroupId::new())),
|
||||
heartbeat: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
@@ -378,6 +447,74 @@ mod tests {
|
||||
assert!(text.contains("\"hi\":1"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_topic_not_resolved_is_404() {
|
||||
// §11.6 D2: a subscriber app whose chain reaches no declaring group gets
|
||||
// 404 (the reads-open resolution IS the authorization; a foreign subtree
|
||||
// never resolves).
|
||||
let app = AppId::new();
|
||||
let st = state(
|
||||
app,
|
||||
"app.example.com",
|
||||
Err(SubscribeDenied::NotFound),
|
||||
Arc::new(InProcessBroadcaster::new(8)),
|
||||
);
|
||||
let appr = realtime_router(st);
|
||||
let req = Request::builder()
|
||||
.uri("/realtime/shared/topics/events")
|
||||
.header("host", "app.example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
appr.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_topic_streams_group_publishes() {
|
||||
// A resolvable shared topic returns an SSE stream subscribed to the
|
||||
// OWNING GROUP channel; a publish_group on that group is delivered.
|
||||
let app = AppId::new();
|
||||
let group = picloud_shared::GroupId::new();
|
||||
let broadcaster = Arc::new(InProcessBroadcaster::new(8));
|
||||
let st = RealtimeState {
|
||||
app_domains: domains("app.example.com", app),
|
||||
broadcaster: broadcaster.clone(),
|
||||
authority: Arc::new(StubAuthority(Ok(()), group)),
|
||||
heartbeat: Duration::from_millis(100),
|
||||
};
|
||||
let appr = realtime_router(st);
|
||||
let req = Request::builder()
|
||||
.uri("/realtime/shared/topics/events")
|
||||
.header("host", "app.example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = appr.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let mut body = resp.into_body().into_data_stream();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
broadcaster
|
||||
.publish_group(
|
||||
group,
|
||||
"events",
|
||||
RealtimeEvent {
|
||||
topic: "events".into(),
|
||||
message: serde_json::json!({ "hi": 2 }),
|
||||
published_at: chrono::Utc::now(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let chunk = tokio::time::timeout(Duration::from_secs(2), body.next())
|
||||
.await
|
||||
.expect("a chunk within timeout")
|
||||
.expect("stream item")
|
||||
.expect("chunk ok");
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
assert!(text.contains("\"hi\":2"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeat_fires_on_idle_connection() {
|
||||
let app = AppId::new();
|
||||
|
||||
Reference in New Issue
Block a user