Skip to main content

postrust_graphql/subscription/
broker.rs

1//! PostgreSQL NOTIFY message broker for GraphQL subscriptions.
2//!
3//! This module provides a broker that listens to PostgreSQL NOTIFY events
4//! and broadcasts them to GraphQL subscription clients.
5
6use futures::stream::{Stream, StreamExt};
7use sqlx::postgres::PgListener;
8use sqlx::PgPool;
9use std::collections::{HashMap, HashSet};
10use std::pin::Pin;
11use std::sync::Arc;
12use tokio::sync::broadcast;
13use tokio::sync::{watch, RwLock};
14use tracing::{debug, error, info};
15
16/// Default channel capacity for broadcast channels
17const DEFAULT_CHANNEL_CAPACITY: usize = 256;
18
19/// A notification from PostgreSQL
20#[derive(Debug, Clone)]
21pub struct PgNotification {
22    /// The channel name (table name or custom channel)
23    pub channel: String,
24    /// The payload (usually JSON)
25    pub payload: String,
26    /// Process ID that sent the notification
27    pub process_id: u32,
28}
29
30/// Message broker that distributes PostgreSQL NOTIFY events to subscribers.
31pub struct NotifyBroker {
32    /// Database connection pool
33    pool: PgPool,
34    /// Channel senders keyed by channel name
35    channels: Arc<RwLock<HashMap<String, broadcast::Sender<PgNotification>>>>,
36    /// Capacity for new broadcast channels
37    channel_capacity: usize,
38    /// Whether the broker is running
39    running: Arc<RwLock<bool>>,
40    /// The channels a PostgreSQL connection is listening on. A channel is
41    /// listened to once however often it is asked for: two listeners on it
42    /// would each deliver every notification.
43    listening: Arc<RwLock<HashSet<String>>>,
44    /// Counts `stop` calls. Each listener waits on it beside its connection
45    /// and exits when it moves past the count it started under, so a stop
46    /// takes effect at once rather than when the next notification happens
47    /// to arrive -- on a quiet table, never. Dropping the broker ends them
48    /// the same way.
49    stops: watch::Sender<u64>,
50}
51
52impl NotifyBroker {
53    /// Create a new notification broker.
54    pub fn new(pool: PgPool) -> Self {
55        Self::with_capacity(pool, DEFAULT_CHANNEL_CAPACITY)
56    }
57
58    /// Create a new notification broker with custom channel capacity.
59    pub fn with_capacity(pool: PgPool, capacity: usize) -> Self {
60        Self {
61            pool,
62            channels: Arc::new(RwLock::new(HashMap::new())),
63            channel_capacity: capacity,
64            running: Arc::new(RwLock::new(false)),
65            listening: Arc::new(RwLock::new(HashSet::new())),
66            stops: watch::channel(0).0,
67        }
68    }
69
70    /// Start listening for notifications on the given channels.
71    ///
72    /// This spawns a background task that listens for PostgreSQL NOTIFY events
73    /// and broadcasts them to all subscribers.
74    pub async fn start(&self, listen_channels: Vec<String>) -> Result<(), BrokerError> {
75        {
76            let mut running = self.running.write().await;
77            if *running {
78                return Err(BrokerError::AlreadyRunning);
79            }
80            *running = true;
81        }
82        self.listen(listen_channels).await
83    }
84
85    /// Listen on whichever of `channels` the broker is not listening on yet,
86    /// over one new connection.
87    ///
88    /// The channels already listened to are left alone, so a subscriber on
89    /// one of them misses nothing while channels are added. On an error none
90    /// of the new channels is recorded, and asking again retries them.
91    pub async fn listen(&self, channels: Vec<String>) -> Result<(), BrokerError> {
92        let mut listening = self.listening.write().await;
93        let mut added = HashSet::new();
94        let new: Vec<String> = channels
95            .into_iter()
96            .filter(|channel| !listening.contains(channel) && added.insert(channel.clone()))
97            .collect();
98        if new.is_empty() {
99            return Ok(());
100        }
101
102        {
103            let mut senders = self.channels.write().await;
104            for channel in &new {
105                senders
106                    .entry(channel.clone())
107                    .or_insert_with(|| broadcast::channel(self.channel_capacity).0);
108            }
109        }
110
111        let mut listener = PgListener::connect_with(&self.pool)
112            .await
113            .map_err(BrokerError::Database)?;
114        for channel in &new {
115            listener
116                .listen(channel)
117                .await
118                .map_err(BrokerError::Database)?;
119            info!("Listening on PostgreSQL channel: {}", channel);
120        }
121        listening.extend(new);
122        drop(listening);
123
124        let channels = Arc::clone(&self.channels);
125        let mut stops = self.stops.subscribe();
126        let started_under = *stops.borrow_and_update();
127
128        tokio::spawn(async move {
129            loop {
130                let received = tokio::select! {
131                    _ = stops.wait_for(|stops| *stops != started_under) => break,
132                    received = listener.try_recv() => received,
133                };
134                match received {
135                    Ok(Some(notification)) => {
136                        let pg_notification = PgNotification {
137                            channel: notification.channel().to_string(),
138                            payload: notification.payload().to_string(),
139                            process_id: notification.process_id(),
140                        };
141
142                        debug!(
143                            "Received notification on channel '{}': {}",
144                            pg_notification.channel,
145                            &pg_notification.payload[..pg_notification.payload.len().min(100)]
146                        );
147
148                        // Broadcast to subscribers
149                        let channels_read = channels.read().await;
150                        if let Some(sender) = channels_read.get(&pg_notification.channel) {
151                            // Ignore send errors - means no active receivers
152                            let _ = sender.send(pg_notification);
153                        }
154                    }
155                    Ok(None) => {
156                        // No notification available, continue
157                        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
158                    }
159                    Err(e) => {
160                        error!("Error receiving notification: {:?}", e);
161                        // Try to reconnect after a delay
162                        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
163                    }
164                }
165            }
166            info!("Broker stopped, exiting listener loop");
167        });
168
169        Ok(())
170    }
171
172    /// Stop the broker, and every connection it is listening on.
173    pub async fn stop(&self) {
174        *self.running.write().await = false;
175        self.listening.write().await.clear();
176        self.stops.send_modify(|stops| *stops += 1);
177        info!("Broker stop requested");
178    }
179
180    /// Subscribe to notifications for a specific channel.
181    ///
182    /// Returns a stream of notifications for the given channel.
183    pub async fn subscribe(
184        &self,
185        channel: &str,
186    ) -> Result<Pin<Box<dyn Stream<Item = PgNotification> + Send>>, BrokerError> {
187        let channels = self.channels.read().await;
188
189        let sender = channels
190            .get(channel)
191            .ok_or_else(|| BrokerError::ChannelNotFound(channel.to_string()))?;
192
193        let receiver = sender.subscribe();
194
195        // Convert broadcast receiver to stream
196        let stream = tokio_stream::wrappers::BroadcastStream::new(receiver)
197            .filter_map(|result| futures::future::ready(result.ok()));
198
199        Ok(Box::pin(stream))
200    }
201
202    /// Subscribe to a channel, creating it if it doesn't exist.
203    ///
204    /// Note: This only creates a broadcast channel. You must also call
205    /// `listen_channel` to start receiving PostgreSQL notifications.
206    pub async fn subscribe_or_create(
207        &self,
208        channel: &str,
209    ) -> Pin<Box<dyn Stream<Item = PgNotification> + Send>> {
210        // First try to get existing channel
211        {
212            let channels = self.channels.read().await;
213            if let Some(sender) = channels.get(channel) {
214                let receiver = sender.subscribe();
215                let stream = tokio_stream::wrappers::BroadcastStream::new(receiver)
216                    .filter_map(|result| futures::future::ready(result.ok()));
217                return Box::pin(stream);
218            }
219        }
220
221        // Create new channel
222        {
223            let mut channels = self.channels.write().await;
224            // Double-check after acquiring write lock
225            if !channels.contains_key(channel) {
226                let (tx, _) = broadcast::channel(self.channel_capacity);
227                channels.insert(channel.to_string(), tx);
228            }
229        }
230
231        // Now subscribe
232        let channels = self.channels.read().await;
233        let sender = channels.get(channel).expect("just created");
234        let receiver = sender.subscribe();
235        let stream = tokio_stream::wrappers::BroadcastStream::new(receiver)
236            .filter_map(|result| futures::future::ready(result.ok()));
237        Box::pin(stream)
238    }
239
240    /// Add a new channel to listen on dynamically.
241    pub async fn listen_channel(&self, channel: &str) -> Result<(), BrokerError> {
242        self.listen(vec![channel.to_string()]).await
243    }
244
245    /// Check if the broker is currently running.
246    pub async fn is_running(&self) -> bool {
247        *self.running.read().await
248    }
249
250    /// Get the number of active channels.
251    pub async fn channel_count(&self) -> usize {
252        self.channels.read().await.len()
253    }
254}
255
256/// Errors that can occur in the broker.
257#[derive(Debug, thiserror::Error)]
258pub enum BrokerError {
259    /// The listening connection failed, or could not be established.
260    #[error("Database error: {0}")]
261    Database(#[from] sqlx::Error),
262
263    /// A channel was addressed that the broker is not listening on.
264    #[error("Channel not found: {0}")]
265    ChannelNotFound(String),
266
267    /// `run` was called on a broker that is already running. Two listeners on
268    /// one broker would each deliver every notification.
269    #[error("Broker is already running")]
270    AlreadyRunning,
271}
272
273/// Generate a channel name for table change notifications.
274pub fn table_channel_name(schema: &str, table: &str) -> String {
275    format!("postrust_{}_{}", schema, table)
276}
277
278/// Generate SQL to create a notification trigger for a table.
279pub fn create_notify_trigger_sql(schema: &str, table: &str) -> String {
280    let channel = table_channel_name(schema, table);
281    let trigger_name = format!("postrust_notify_{}_{}", schema, table);
282    let function_name = format!("postrust_notify_{}_{}_fn", schema, table);
283
284    format!(
285        r#"
286-- Create notification function
287CREATE OR REPLACE FUNCTION {schema}.{function_name}()
288RETURNS TRIGGER AS $$
289DECLARE
290    payload jsonb;
291BEGIN
292    IF TG_OP = 'DELETE' THEN
293        payload := jsonb_build_object(
294            'operation', 'DELETE',
295            'table', TG_TABLE_NAME,
296            'schema', TG_TABLE_SCHEMA,
297            'old', row_to_json(OLD)
298        );
299    ELSIF TG_OP = 'UPDATE' THEN
300        payload := jsonb_build_object(
301            'operation', 'UPDATE',
302            'table', TG_TABLE_NAME,
303            'schema', TG_TABLE_SCHEMA,
304            'old', row_to_json(OLD),
305            'new', row_to_json(NEW)
306        );
307    ELSIF TG_OP = 'INSERT' THEN
308        payload := jsonb_build_object(
309            'operation', 'INSERT',
310            'table', TG_TABLE_NAME,
311            'schema', TG_TABLE_SCHEMA,
312            'new', row_to_json(NEW)
313        );
314    END IF;
315
316    PERFORM pg_notify('{channel}', payload::text);
317
318    RETURN COALESCE(NEW, OLD);
319END;
320$$ LANGUAGE plpgsql;
321
322-- Create trigger
323DROP TRIGGER IF EXISTS {trigger_name} ON {schema}.{table};
324CREATE TRIGGER {trigger_name}
325    AFTER INSERT OR UPDATE OR DELETE ON {schema}.{table}
326    FOR EACH ROW
327    EXECUTE FUNCTION {schema}.{function_name}();
328"#,
329        schema = schema,
330        table = table,
331        channel = channel,
332        function_name = function_name,
333        trigger_name = trigger_name
334    )
335}
336
337/// Generate SQL to drop a notification trigger for a table.
338pub fn drop_notify_trigger_sql(schema: &str, table: &str) -> String {
339    let trigger_name = format!("postrust_notify_{}_{}", schema, table);
340    let function_name = format!("postrust_notify_{}_{}_fn", schema, table);
341
342    format!(
343        r#"
344DROP TRIGGER IF EXISTS {trigger_name} ON {schema}.{table};
345DROP FUNCTION IF EXISTS {schema}.{function_name}();
346"#,
347        schema = schema,
348        table = table,
349        trigger_name = trigger_name,
350        function_name = function_name
351    )
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_table_channel_name() {
360        assert_eq!(
361            table_channel_name("public", "users"),
362            "postrust_public_users"
363        );
364        assert_eq!(table_channel_name("api", "orders"), "postrust_api_orders");
365    }
366
367    #[test]
368    fn test_create_notify_trigger_sql() {
369        let sql = create_notify_trigger_sql("public", "users");
370        assert!(sql.contains("CREATE OR REPLACE FUNCTION"));
371        assert!(sql.contains("postrust_notify_public_users_fn"));
372        assert!(sql.contains("CREATE TRIGGER"));
373        assert!(sql.contains("pg_notify"));
374        assert!(sql.contains("postrust_public_users"));
375    }
376
377    #[test]
378    fn test_drop_notify_trigger_sql() {
379        let sql = drop_notify_trigger_sql("public", "users");
380        assert!(sql.contains("DROP TRIGGER IF EXISTS"));
381        assert!(sql.contains("DROP FUNCTION IF EXISTS"));
382    }
383}