Skip to main content

fraiseql_core/runtime/subscription/
manager.rs

1use std::sync::{
2    Arc,
3    atomic::{AtomicU64, Ordering},
4};
5
6use dashmap::DashMap;
7use tokio::sync::broadcast;
8
9#[allow(clippy::wildcard_imports)]
10// Reason: types::* re-exports the subscription type vocabulary used throughout this module
11use super::{SubscriptionError, types::*};
12use crate::schema::CompiledSchema;
13
14// =============================================================================
15// Subscription Manager
16// =============================================================================
17
18/// Maximum number of active subscriptions a single connection may hold.
19///
20/// Prevents a single authenticated connection from exhausting server memory by
21/// calling `subscribe()` in a loop.
22const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 100;
23
24/// Manages active subscriptions and event routing.
25///
26/// The `SubscriptionManager` is the central hub for:
27/// - Tracking active subscriptions per connection
28/// - Receiving events from database listeners
29/// - Matching events to subscriptions
30/// - Broadcasting to transport adapters
31pub struct SubscriptionManager {
32    /// Compiled schema for subscription definitions.
33    schema: Arc<CompiledSchema>,
34
35    /// Active subscriptions indexed by ID.
36    subscriptions: DashMap<SubscriptionId, ActiveSubscription>,
37
38    /// Subscriptions indexed by connection ID (for cleanup on disconnect).
39    subscriptions_by_connection: DashMap<String, Vec<SubscriptionId>>,
40
41    /// Broadcast channel for delivering events to transports.
42    event_sender: broadcast::Sender<SubscriptionPayload>,
43
44    /// Monotonic sequence counter for event ordering.
45    sequence_counter: AtomicU64,
46
47    /// Whether the deployment is multi-tenant (`security.multi_tenant`).
48    ///
49    /// In multi-tenant mode the tenant gate fails **closed**: an event is only
50    /// delivered when both the subscription and the event carry the *same*
51    /// tenant — a missing tenant on either side never matches (M-tenant-ws-failopen).
52    /// In single-tenant mode the gate stays permissive so existing deployments,
53    /// where tenant ids are typically absent, keep working.
54    multi_tenant: bool,
55}
56
57impl SubscriptionManager {
58    /// Create a new subscription manager.
59    ///
60    /// # Arguments
61    ///
62    /// * `schema` - Compiled schema containing subscription definitions
63    /// * `channel_capacity` - Broadcast channel capacity (default: 1024)
64    #[must_use]
65    pub fn new(schema: Arc<CompiledSchema>) -> Self {
66        Self::with_capacity(schema, 1024)
67    }
68
69    /// Create a new subscription manager with custom channel capacity.
70    #[must_use]
71    pub fn with_capacity(schema: Arc<CompiledSchema>, channel_capacity: usize) -> Self {
72        let (event_sender, _) = broadcast::channel(channel_capacity);
73        // Derive the tenancy mode from the compiled schema the manager already
74        // holds — no separate flag has to be threaded through every call site.
75        let multi_tenant = schema.is_multi_tenant();
76
77        Self {
78            schema,
79            subscriptions: DashMap::new(),
80            subscriptions_by_connection: DashMap::new(),
81            event_sender,
82            sequence_counter: AtomicU64::new(1),
83            multi_tenant,
84        }
85    }
86
87    /// Get a receiver for subscription payloads.
88    ///
89    /// Transport adapters use this to receive events for delivery.
90    #[must_use]
91    pub fn receiver(&self) -> broadcast::Receiver<SubscriptionPayload> {
92        self.event_sender.subscribe()
93    }
94
95    /// Subscribe to a subscription type.
96    ///
97    /// # Arguments
98    ///
99    /// * `subscription_name` - Name of the subscription type
100    /// * `user_context` - User authentication/authorization context
101    /// * `variables` - Runtime variables from client
102    /// * `connection_id` - Client connection identifier
103    ///
104    /// # Errors
105    ///
106    /// Returns error if subscription not found or user not authorized.
107    pub fn subscribe(
108        &self,
109        subscription_name: &str,
110        user_context: serde_json::Value,
111        variables: serde_json::Value,
112        connection_id: &str,
113    ) -> Result<SubscriptionId, SubscriptionError> {
114        self.subscribe_with_rls(
115            subscription_name,
116            user_context,
117            variables,
118            connection_id,
119            Vec::new(),
120        )
121    }
122
123    /// Subscribe with pre-evaluated RLS conditions for event-level filtering.
124    ///
125    /// The caller should evaluate the RLS policy at subscribe time and pass
126    /// the resulting conditions (via [`extract_rls_conditions`](super::extract_rls_conditions)).
127    /// During event delivery, each condition is checked against the event data:
128    /// the event is only delivered if **every** condition matches.
129    ///
130    /// # Errors
131    ///
132    /// Returns error if subscription not found or connection limit exceeded.
133    pub fn subscribe_with_rls(
134        &self,
135        subscription_name: &str,
136        user_context: serde_json::Value,
137        variables: serde_json::Value,
138        connection_id: &str,
139        rls_conditions: Vec<(String, serde_json::Value)>,
140    ) -> Result<SubscriptionId, SubscriptionError> {
141        // Find subscription definition
142        let mut definition = self
143            .schema
144            .find_subscription(subscription_name)
145            .ok_or_else(|| SubscriptionError::SubscriptionNotFound(subscription_name.to_string()))?
146            .clone();
147
148        // Expand filter_fields into argument_paths on the filter.
149        // Each filter_field name becomes an argument_path entry mapping
150        // the field name to a JSON pointer path (e.g., "user_id" → "/user_id").
151        if !definition.filter_fields.is_empty() {
152            let filter =
153                definition.filter.get_or_insert_with(|| crate::schema::SubscriptionFilter {
154                    argument_paths: std::collections::HashMap::new(),
155                    static_filters: Vec::new(),
156                });
157            for field in &definition.filter_fields {
158                filter
159                    .argument_paths
160                    .entry(field.clone())
161                    .or_insert_with(|| format!("/{field}"));
162            }
163        }
164
165        // Create active subscription with RLS conditions
166        let active = ActiveSubscription::new(
167            subscription_name,
168            definition,
169            user_context,
170            variables,
171            connection_id,
172        )
173        .with_rls_conditions(rls_conditions);
174
175        let id = active.id;
176
177        // Enforce per-connection subscription cap before inserting.
178        {
179            let mut conn_subs =
180                self.subscriptions_by_connection.entry(connection_id.to_string()).or_default();
181            if conn_subs.len() >= MAX_SUBSCRIPTIONS_PER_CONNECTION {
182                return Err(SubscriptionError::Internal(format!(
183                    "Connection '{connection_id}' has reached the maximum of \
184                     {MAX_SUBSCRIPTIONS_PER_CONNECTION} concurrent subscriptions"
185                )));
186            }
187            conn_subs.push(id);
188        }
189
190        // Store subscription
191        self.subscriptions.insert(id, active);
192
193        tracing::info!(
194            subscription_id = %id,
195            subscription_name = subscription_name,
196            connection_id = connection_id,
197            "Subscription created"
198        );
199
200        Ok(id)
201    }
202
203    /// Unsubscribe from a subscription.
204    ///
205    /// # Errors
206    ///
207    /// Returns error if subscription not found.
208    pub fn unsubscribe(&self, id: SubscriptionId) -> Result<(), SubscriptionError> {
209        let removed = self
210            .subscriptions
211            .remove(&id)
212            .ok_or_else(|| SubscriptionError::NotActive(id.to_string()))?;
213
214        // Remove from connection index
215        if let Some(mut subs) = self.subscriptions_by_connection.get_mut(&removed.1.connection_id) {
216            subs.retain(|s| *s != id);
217        }
218
219        tracing::info!(
220            subscription_id = %id,
221            subscription_name = removed.1.subscription_name,
222            "Subscription removed"
223        );
224
225        Ok(())
226    }
227
228    /// Unsubscribe all subscriptions for a connection.
229    ///
230    /// Called when a client disconnects.
231    ///
232    /// # Concurrency note
233    ///
234    /// A concurrent `subscribe` call that runs between the `DashMap` entry removal and the
235    /// per-subscription cleanup loop would create a new connection entry that is not cleaned
236    /// up by this call. A second-pass removal after the first loop closes this window for
237    /// all but the most extreme concurrent races. Any subscription that slips through is
238    /// benign: it will receive events until the broadcast receiver is dropped (which happens
239    /// on disconnect), and will be removed on the next disconnect or subscription-not-found
240    /// event for that ID.
241    pub fn unsubscribe_connection(&self, connection_id: &str) {
242        // First pass: remove the connection index atomically and clean up known subscriptions.
243        let first_pass_count = if let Some((_, subscription_ids)) =
244            self.subscriptions_by_connection.remove(connection_id)
245        {
246            let count = subscription_ids.len();
247            for id in subscription_ids {
248                self.subscriptions.remove(&id);
249            }
250            count
251        } else {
252            0
253        };
254
255        // Second pass: clean up any subscriptions added by a concurrent `subscribe` call that
256        // ran between the `remove()` above and the loop.  A concurrent `subscribe` that saw
257        // the connection entry absent would have inserted a *new* entry; removing it here
258        // closes the TOCTOU window to a negligible two-CAS race.
259        let second_pass_count = if let Some((_, subscription_ids)) =
260            self.subscriptions_by_connection.remove(connection_id)
261        {
262            let count = subscription_ids.len();
263            for id in subscription_ids {
264                self.subscriptions.remove(&id);
265                tracing::warn!(
266                    subscription_id = %id,
267                    connection_id = connection_id,
268                    "Cleaned up subscription added concurrently during disconnect"
269                );
270            }
271            count
272        } else {
273            0
274        };
275
276        tracing::info!(
277            connection_id = connection_id,
278            subscriptions_removed = first_pass_count + second_pass_count,
279            "All subscriptions removed for connection"
280        );
281    }
282
283    /// Get an active subscription by ID.
284    #[must_use]
285    pub fn get_subscription(&self, id: SubscriptionId) -> Option<ActiveSubscription> {
286        self.subscriptions.get(&id).map(|r| r.clone())
287    }
288
289    /// Get all active subscriptions for a connection.
290    #[must_use]
291    pub fn get_connection_subscriptions(&self, connection_id: &str) -> Vec<ActiveSubscription> {
292        self.subscriptions_by_connection
293            .get(connection_id)
294            .map(|ids| {
295                ids.iter()
296                    .filter_map(|id| self.subscriptions.get(id).map(|r| r.clone()))
297                    .collect()
298            })
299            .unwrap_or_default()
300    }
301
302    /// Get total number of active subscriptions.
303    #[must_use]
304    pub fn subscription_count(&self) -> usize {
305        self.subscriptions.len()
306    }
307
308    /// Get number of active connections with subscriptions.
309    #[must_use]
310    pub fn connection_count(&self) -> usize {
311        self.subscriptions_by_connection.len()
312    }
313
314    /// Publish an event to matching subscriptions.
315    ///
316    /// This is called by the database listener when an event is received.
317    /// The event is matched against all active subscriptions and delivered
318    /// to matching ones.
319    ///
320    /// # Arguments
321    ///
322    /// * `event` - The database event to publish
323    ///
324    /// # Returns
325    ///
326    /// Number of subscriptions that matched the event.
327    pub fn publish_event(&self, mut event: SubscriptionEvent) -> usize {
328        // Assign sequence number
329        event.sequence_number = self.sequence_counter.fetch_add(1, Ordering::SeqCst);
330
331        let mut matched = 0;
332
333        // Find matching subscriptions
334        for subscription in &self.subscriptions {
335            if self.matches_subscription(&event, &subscription) {
336                matched += 1;
337
338                // Project data for this subscription
339                let data = self.project_event_data(&event, &subscription);
340
341                let payload = SubscriptionPayload {
342                    subscription_id: subscription.id,
343                    subscription_name: subscription.subscription_name.clone(),
344                    event: event.clone(),
345                    data,
346                };
347
348                // Send to broadcast channel (may fail if no receivers, that's ok)
349                let _ = self.event_sender.send(payload);
350            }
351        }
352
353        if matched > 0 {
354            tracing::debug!(
355                event_id = event.event_id,
356                entity_type = event.entity_type,
357                operation = %event.operation,
358                matched = matched,
359                "Event matched subscriptions"
360            );
361        }
362
363        matched
364    }
365
366    /// Check if an event matches a subscription's filters and RLS conditions.
367    #[allow(clippy::cognitive_complexity)] // Reason: multi-criteria subscription matching (entity type, operation, filters, RLS conditions)
368    fn matches_subscription(
369        &self,
370        event: &SubscriptionEvent,
371        subscription: &ActiveSubscription,
372    ) -> bool {
373        // Tenant isolation (M-tenant-ws-failopen).
374        //
375        // Multi-tenant deployments fail **closed**: an event is delivered only
376        // when the subscription and the event carry the *same* tenant. A missing
377        // tenant on either side never matches — in particular a tenant-less
378        // subscriber must not receive any tenant's events (the fail-open hole),
379        // and a tenant-scoped subscriber must not receive untagged events.
380        //
381        // Single-tenant deployments stay permissive: tenant ids are typically
382        // absent, so the gate only filters out events that carry a *different*
383        // explicit tenant than a tenant-scoped subscription.
384        let tenant_ok = match (subscription.tenant_id.as_deref(), event.tenant_id.as_deref()) {
385            (Some(sub_tenant), Some(event_tenant)) => sub_tenant == event_tenant,
386            // One or both sides lack a tenant: only acceptable in single-tenant mode.
387            _ => !self.multi_tenant,
388        };
389        if !tenant_ok {
390            tracing::trace!(
391                subscription_id = %subscription.id,
392                sub_tenant = ?subscription.tenant_id,
393                event_tenant = ?event.tenant_id,
394                multi_tenant = self.multi_tenant,
395                "Tenant gate — event filtered"
396            );
397            return false;
398        }
399
400        // Check entity type matches (subscription return_type maps to entity)
401        if subscription.definition.return_type != event.entity_type {
402            return false;
403        }
404
405        // Check operation matches topic (if specified)
406        if let Some(ref topic) = subscription.definition.topic {
407            let expected_op = match topic.to_lowercase().as_str() {
408                t if t.contains("created") || t.contains("insert") => {
409                    Some(SubscriptionOperation::Create)
410                },
411                t if t.contains("updated") || t.contains("update") => {
412                    Some(SubscriptionOperation::Update)
413                },
414                t if t.contains("deleted") || t.contains("delete") => {
415                    Some(SubscriptionOperation::Delete)
416                },
417                _ => None,
418            };
419
420            if let Some(expected) = expected_op {
421                if event.operation != expected {
422                    return false;
423                }
424            }
425        }
426
427        // Check row-level security conditions (evaluated at subscribe time).
428        // Every condition must match (AND semantics) — RLS always wins.
429        for (field, expected_value) in &subscription.rls_conditions {
430            let actual = get_json_pointer_value(&event.data, field);
431            if actual != Some(expected_value) {
432                tracing::trace!(
433                    subscription_id = %subscription.id,
434                    field = field,
435                    expected = ?expected_value,
436                    actual = ?actual,
437                    "RLS condition mismatch — event filtered"
438                );
439                return false;
440            }
441        }
442
443        // Evaluate compiled WHERE filters against event.data and subscription variables
444        if let Some(ref filter) = subscription.definition.filter {
445            // Check argument-based filters (variable values must match event data)
446            for (arg_name, path) in &filter.argument_paths {
447                // Get the variable value provided by the client
448                if let Some(expected_value) = subscription.variables.get(arg_name) {
449                    // Get the actual value from event data using JSON pointer
450                    let actual_value = get_json_pointer_value(&event.data, path);
451
452                    // Compare values
453                    if actual_value != Some(expected_value) {
454                        tracing::trace!(
455                            subscription_id = %subscription.id,
456                            arg_name = arg_name,
457                            expected = ?expected_value,
458                            actual = ?actual_value,
459                            "Filter mismatch on argument"
460                        );
461                        return false;
462                    }
463                }
464            }
465
466            // Check static filter conditions
467            for condition in &filter.static_filters {
468                let actual_value = get_json_pointer_value(&event.data, &condition.path);
469
470                if !evaluate_filter_condition(actual_value, condition.operator, &condition.value) {
471                    tracing::trace!(
472                        subscription_id = %subscription.id,
473                        path = condition.path,
474                        operator = ?condition.operator,
475                        expected = ?condition.value,
476                        actual = ?actual_value,
477                        "Filter mismatch on static condition"
478                    );
479                    return false;
480                }
481            }
482        }
483
484        true
485    }
486
487    /// Project event data to subscription's field selection.
488    fn project_event_data(
489        &self,
490        event: &SubscriptionEvent,
491        subscription: &ActiveSubscription,
492    ) -> serde_json::Value {
493        // If no fields specified, return full event data
494        if subscription.definition.fields.is_empty() {
495            return event.data.clone();
496        }
497
498        // Project only requested fields
499        let mut projected = serde_json::Map::new();
500
501        for field in &subscription.definition.fields {
502            // Support both simple field names and JSON pointer paths
503            let value = if field.starts_with('/') {
504                get_json_pointer_value(&event.data, field).cloned()
505            } else {
506                event.data.get(field).cloned()
507            };
508
509            if let Some(v) = value {
510                // Use the field name (without leading slash) as the key
511                let key = field.trim_start_matches('/').to_string();
512                projected.insert(key, v);
513            }
514        }
515
516        serde_json::Value::Object(projected)
517    }
518}
519
520/// Retrieve a value from JSON data using a JSON pointer path.
521///
522/// # Lifetime Parameter
523///
524/// The lifetime `'a` is tied to the input `data` reference. The returned reference
525/// is guaranteed to live as long as the input data reference, enabling zero-copy
526/// access to nested JSON values without allocation.
527///
528/// # Arguments
529///
530/// * `data` - The JSON data object to query
531/// * `path` - The path to the value, either in JSON pointer format (/a/b/c) or dot notation (a.b.c)
532///
533/// # Returns
534///
535/// A reference to the JSON value if found, or `None` if the path doesn't exist.
536/// The returned reference has the same lifetime as the input data.
537///
538/// # Examples
539///
540/// ```rust
541/// # use serde_json::json;
542/// # fn get_json_pointer_value<'a>(data: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
543/// #     let normalized = if path.starts_with('/') { path.to_string() } else { format!("/{}", path.replace('.', "/")) };
544/// #     data.pointer(&normalized)
545/// # }
546/// let data = json!({"user": {"id": 123, "name": "Alice"}});
547/// let id = get_json_pointer_value(&data, "user/id");  // Some(&123)
548/// let alt = get_json_pointer_value(&data, "user.id"); // Some(&123)
549/// let missing = get_json_pointer_value(&data, "admin/id"); // None
550/// ```
551pub fn get_json_pointer_value<'a>(
552    data: &'a serde_json::Value,
553    path: &str,
554) -> Option<&'a serde_json::Value> {
555    // Normalize path to JSON pointer format
556    let normalized = if path.starts_with('/') {
557        path.to_string()
558    } else {
559        format!("/{}", path.replace('.', "/"))
560    };
561
562    data.pointer(&normalized)
563}
564
565/// Evaluate a filter condition against an actual value.
566pub fn evaluate_filter_condition(
567    actual: Option<&serde_json::Value>,
568    operator: crate::schema::FilterOperator,
569    expected: &serde_json::Value,
570) -> bool {
571    use crate::schema::FilterOperator;
572
573    match actual {
574        None => {
575            // Null/missing values only match specific conditions
576            matches!(operator, FilterOperator::Eq) && expected.is_null()
577        },
578        Some(actual_value) => match operator {
579            FilterOperator::Eq => actual_value == expected,
580            FilterOperator::Ne => actual_value != expected,
581            FilterOperator::Gt => {
582                compare_values(actual_value, expected) == Some(std::cmp::Ordering::Greater)
583            },
584            FilterOperator::Gte => {
585                matches!(
586                    compare_values(actual_value, expected),
587                    Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
588                )
589            },
590            FilterOperator::Lt => {
591                compare_values(actual_value, expected) == Some(std::cmp::Ordering::Less)
592            },
593            FilterOperator::Lte => {
594                matches!(
595                    compare_values(actual_value, expected),
596                    Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
597                )
598            },
599            FilterOperator::Contains => {
600                match (actual_value, expected) {
601                    // Array contains value
602                    (serde_json::Value::Array(arr), val) => arr.contains(val),
603                    // String contains substring
604                    (serde_json::Value::String(s), serde_json::Value::String(sub)) => {
605                        s.contains(sub.as_str())
606                    },
607                    _ => false,
608                }
609            },
610            FilterOperator::StartsWith => match (actual_value, expected) {
611                (serde_json::Value::String(s), serde_json::Value::String(prefix)) => {
612                    s.starts_with(prefix.as_str())
613                },
614                _ => false,
615            },
616            FilterOperator::EndsWith => match (actual_value, expected) {
617                (serde_json::Value::String(s), serde_json::Value::String(suffix)) => {
618                    s.ends_with(suffix.as_str())
619                },
620                _ => false,
621            },
622        },
623    }
624}
625
626/// Compare two JSON values for ordering (numeric and string comparisons).
627fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> Option<std::cmp::Ordering> {
628    match (a, b) {
629        // Numeric comparisons
630        (serde_json::Value::Number(a), serde_json::Value::Number(b)) => {
631            let a_f64 = a.as_f64()?;
632            let b_f64 = b.as_f64()?;
633            a_f64.partial_cmp(&b_f64)
634        },
635        // String comparisons
636        (serde_json::Value::String(a), serde_json::Value::String(b)) => Some(a.cmp(b)),
637        // Bool comparisons (false < true)
638        (serde_json::Value::Bool(a), serde_json::Value::Bool(b)) => Some(a.cmp(b)),
639        // Null comparisons
640        (serde_json::Value::Null, serde_json::Value::Null) => Some(std::cmp::Ordering::Equal),
641        // Incompatible types
642        _ => None,
643    }
644}
645
646impl std::fmt::Debug for SubscriptionManager {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        f.debug_struct("SubscriptionManager")
649            .field("subscription_count", &self.subscriptions.len())
650            .field("connection_count", &self.subscriptions_by_connection.len())
651            .finish_non_exhaustive()
652    }
653}