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}