systemprompt_events/services/
bridge.rs1use std::sync::atomic::{AtomicBool, Ordering};
21use std::time::Duration;
22
23use sqlx::PgPool;
24use sqlx::postgres::PgListener;
25use tokio::task::JoinHandle;
26use tracing::{debug, error, info, warn};
27
28use super::repository::EventOutboxRepository;
29use super::routing::{EventRouter, OUTBOX_CHANNEL, OutboxChannel};
30use systemprompt_identifiers::{EventOutboxId, UserId};
31use systemprompt_models::{A2AEvent, AgUiEvent, AnalyticsEvent, SystemEvent};
32
33const OUTBOX_RETENTION: Duration = Duration::from_secs(3600);
34const PRUNE_INTERVAL: Duration = Duration::from_secs(300);
35const RETRY_MIN: Duration = Duration::from_secs(1);
36const RETRY_MAX: Duration = Duration::from_secs(60);
37
38const READ_ONLY_SQL_TRANSACTION: &str = "25006";
40
41static LISTENING: AtomicBool = AtomicBool::new(true);
42
43#[must_use]
44pub fn is_listening() -> bool {
45 LISTENING.load(Ordering::Relaxed)
46}
47
48fn is_read_only_standby(err: &sqlx::Error) -> bool {
49 match err {
50 sqlx::Error::Database(db) => db.code().as_deref() == Some(READ_ONLY_SQL_TRANSACTION),
51 _ => false,
52 }
53}
54
55#[derive(Debug, Clone)]
56pub struct PostgresEventBridge {
57 pool: PgPool,
58 outbox: EventOutboxRepository,
59}
60
61impl PostgresEventBridge {
62 #[must_use]
63 pub fn new(pool: PgPool) -> Self {
64 Self {
65 outbox: EventOutboxRepository::new(pool.clone()),
66 pool,
67 }
68 }
69
70 pub fn start(self) -> JoinHandle<()> {
71 EventRouter::install_relay(self.pool.clone());
72 tokio::spawn(async move {
73 self.run().await;
74 })
75 }
76
77 async fn open_listener(&self) -> Result<PgListener, sqlx::Error> {
78 let mut listener = PgListener::connect_with(&self.pool).await?;
79 listener.listen(OUTBOX_CHANNEL).await?;
80 Ok(listener)
81 }
82
83 fn report_listener_failure(err: &sqlx::Error, retry_in: Duration) {
84 let retry_in_secs = retry_in.as_secs();
85 if is_read_only_standby(err) {
86 error!(
87 error = %err,
88 channel = OUTBOX_CHANNEL,
89 retry_in_secs,
90 "event bridge: pool is a read-only standby; LISTEN/NOTIFY requires the primary. \
91 Point the write pool (`database_write_url` secret, or `DATABASE_WRITE_URL` with \
92 the env secrets source) at the primary and restart"
93 );
94 } else {
95 error!(
96 error = %err,
97 channel = OUTBOX_CHANNEL,
98 retry_in_secs,
99 "event bridge: failed to open Postgres listener; retrying"
100 );
101 }
102 }
103
104 async fn run(self) {
105 let mut prune_tick = tokio::time::interval(PRUNE_INTERVAL);
106 prune_tick.tick().await;
107 let mut backoff = RETRY_MIN;
108
109 loop {
110 let mut listener = match self.open_listener().await {
111 Ok(listener) => listener,
112 Err(e) => {
113 LISTENING.store(false, Ordering::Relaxed);
114 Self::report_listener_failure(&e, backoff);
115 tokio::time::sleep(backoff).await;
116 backoff = (backoff * 2).min(RETRY_MAX);
117 continue;
118 },
119 };
120 backoff = RETRY_MIN;
121 LISTENING.store(true, Ordering::Relaxed);
122 info!(
123 channel = OUTBOX_CHANNEL,
124 "event bridge: listening for cross-replica events"
125 );
126
127 loop {
128 tokio::select! {
129 notification = listener.recv() => match notification {
130 Ok(notification) => {
131 self.deliver(notification.payload()).await;
132 },
133 Err(e) => {
134 warn!(error = %e, "event bridge: listener connection lost; reconnecting");
135 break;
136 },
137 },
138 _ = prune_tick.tick() => {
139 self.prune().await;
140 },
141 }
142 }
143 }
144 }
145
146 async fn deliver(&self, row_id: &str) {
147 let id = EventOutboxId::new(row_id);
148 let row = match self.outbox.find(&id).await {
149 Ok(Some(row)) => row,
150 Ok(None) => {
151 debug!(row_id, "event bridge: outbox row already pruned; skipping");
152 return;
153 },
154 Err(e) => {
155 error!(error = %e, row_id, "event bridge: failed to load outbox row");
156 return;
157 },
158 };
159
160 let Some(channel) = OutboxChannel::parse(&row.channel) else {
161 error!(channel = %row.channel, row_id, "event bridge: unknown outbox channel");
162 return;
163 };
164 Self::fan_in(channel, &row.user_id, row.payload).await;
165 }
166
167 pub(super) async fn fan_in(
168 channel: OutboxChannel,
169 user_id: &UserId,
170 payload: serde_json::Value,
173 ) {
174 match channel {
175 OutboxChannel::AgUi => match serde_json::from_value::<AgUiEvent>(payload) {
176 Ok(event) => {
177 EventRouter::route_agui_local(user_id, event).await;
178 },
179 Err(e) => error!(error = %e, "event bridge: failed to decode AG-UI event"),
180 },
181 OutboxChannel::A2A => match serde_json::from_value::<A2AEvent>(payload) {
182 Ok(event) => {
183 EventRouter::route_a2a_local(user_id, event).await;
184 },
185 Err(e) => error!(error = %e, "event bridge: failed to decode A2A event"),
186 },
187 OutboxChannel::System => match serde_json::from_value::<SystemEvent>(payload) {
188 Ok(event) => {
189 EventRouter::route_system_local(user_id, event).await;
190 },
191 Err(e) => error!(error = %e, "event bridge: failed to decode system event"),
192 },
193 OutboxChannel::Analytics => match serde_json::from_value::<AnalyticsEvent>(payload) {
194 Ok(event) => {
195 EventRouter::route_analytics_local(user_id, event).await;
196 },
197 Err(e) => error!(error = %e, "event bridge: failed to decode analytics event"),
198 },
199 }
200 }
201
202 async fn prune(&self) {
203 let cutoff = chrono::Utc::now()
204 - chrono::Duration::from_std(OUTBOX_RETENTION)
205 .unwrap_or_else(|_| chrono::Duration::seconds(3600));
206 match self.outbox.prune(cutoff).await {
207 Ok(deleted) => {
208 if deleted > 0 {
209 debug!(deleted, "event bridge: pruned expired outbox rows");
210 }
211 },
212 Err(e) => error!(error = %e, "event bridge: outbox prune failed"),
213 }
214 }
215}