Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3.3 KiB
Rust
87 lines
3.3 KiB
Rust
//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime
|
|
//! delivery (v1.1.6).
|
|
//!
|
|
//! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait
|
|
//! lives here in `picloud-shared` because the publish side
|
|
//! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE
|
|
//! handler in orchestrator-core) live in different crates and both need
|
|
//! one shared instance. The in-process impl lives in orchestrator-core
|
|
//! (`Mutex<HashMap<(AppId, topic), broadcast::Sender>>`); cluster mode
|
|
//! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind
|
|
//! the same trait without touching either caller.
|
|
//!
|
|
//! Delivery is **best-effort, at-most-once**: this is the realtime path,
|
|
//! NOT the durable one. Durable trigger fan-out (retry / dead-letter)
|
|
//! goes through the outbox and is the publish caller's separate concern.
|
|
//! A slow SSE consumer loses the oldest events (bounded broadcast
|
|
//! buffer); SSE's own transport-layer auto-reconnect is the recovery
|
|
//! mechanism (no server-side replay in v1.1.6).
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use thiserror::Error;
|
|
use tokio::sync::broadcast;
|
|
|
|
use crate::AppId;
|
|
|
|
/// A single realtime event delivered to in-process SSE subscribers. The
|
|
/// SSE handler serializes this to `data: {...}\n\n` on the wire.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealtimeEvent {
|
|
pub topic: String,
|
|
pub message: serde_json::Value,
|
|
pub published_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum BroadcasterError {
|
|
/// Reserved for backends that can fail to register a subscriber
|
|
/// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process
|
|
/// impl never returns this.
|
|
#[error("realtime broadcaster unavailable: {0}")]
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait RealtimeBroadcaster: Send + Sync {
|
|
/// Subscribe to events on `(app_id, topic)`. Returns a receiver that
|
|
/// yields events until dropped. Channels are created lazily on first
|
|
/// subscribe.
|
|
async fn subscribe(
|
|
&self,
|
|
app_id: AppId,
|
|
topic: &str,
|
|
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError>;
|
|
|
|
/// Publish an event to in-process subscribers. NOT durable — the
|
|
/// outbox-backed durable fan-out is the publish caller's separate
|
|
/// concern. A publish with no live subscribers is a silent no-op.
|
|
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent);
|
|
|
|
/// Drop every subscriber for a topic (called on topic DELETE). Live
|
|
/// receivers observe a closed channel and disconnect cleanly.
|
|
async fn drop_topic(&self, app_id: AppId, topic: &str);
|
|
}
|
|
|
|
/// Bootstrap / test impl: subscribe yields a receiver on a throwaway
|
|
/// channel, publish is a no-op. Lets a `Services`-style bundle build
|
|
/// without the real registry wired in.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct NoopRealtimeBroadcaster;
|
|
|
|
#[async_trait]
|
|
impl RealtimeBroadcaster for NoopRealtimeBroadcaster {
|
|
async fn subscribe(
|
|
&self,
|
|
_app_id: AppId,
|
|
_topic: &str,
|
|
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
|
let (_tx, rx) = broadcast::channel(1);
|
|
Ok(rx)
|
|
}
|
|
|
|
async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {}
|
|
|
|
async fn drop_topic(&self, _app_id: AppId, _topic: &str) {}
|
|
}
|