core_api/subscription.rs
1//! Post-commit subscription API.
2//!
3//! [`GraphDb`] exposes three subscription entry-points:
4//! - [`GraphDb::subscribe_rule`] — edge-fire / retract events for one named rule.
5//! - [`GraphDb::subscribe_all_rules`] — edge events for every rule.
6//! - [`GraphDb::subscribe_writes`] — node and property mutations.
7//!
8//! All three return a [`Subscription`] handle. Dropping it unregisters the
9//! subscriber on the next commit (via [`Weak`] upgrade failure in the distribution
10//! loop).
11//!
12//! # Ordering invariant
13//!
14//! Events are pushed inside `log_then_apply_with` **after** the WAL fsync and
15//! the in-memory `apply` have both completed. A subscriber that queries the db
16//! immediately after receiving an event therefore observes the state that
17//! produced it.
18//!
19//! # Bounded queue / Lagged
20//!
21//! Each subscription has a fixed-capacity queue (default [`DEFAULT_SUB_CAPACITY`]).
22//! When the queue is full, events are dropped and a missed count is incremented.
23//! The next [`Subscription::try_recv`] / [`Subscription::recv_timeout`] call
24//! that finds an empty queue and a non-zero miss count returns
25//! [`DbEvent::Lagged { missed }`] before continuing with queued events.
26//!
27//! # v1 scope
28//!
29//! Rule-edge events and write-mutation events only. Incremental query
30//! subscriptions (differential dataflow) are roadmap (Plan-15 T3+).
31
32use core_storage::Value;
33use serde::Serialize;
34use std::collections::VecDeque;
35use std::sync::{Arc, Condvar, Mutex, Weak};
36use std::time::Duration;
37
38/// Default per-subscriber queue capacity.
39pub const DEFAULT_SUB_CAPACITY: usize = 65_536;
40
41/// A post-commit event delivered to subscribers.
42///
43/// Serialises as internally-tagged JSON (`"type"` discriminant, snake_case).
44///
45/// ```json
46/// {"type":"edge_fired","rule":"skill_fit","src_key":"p1","dst_key":"proj-01",
47/// "edge_type":"FIT","weight":0.87,"commit_seq":42}
48/// {"type":"lagged","missed":3}
49/// ```
50#[derive(Debug, Clone, Serialize, PartialEq)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum DbEvent {
53 /// A rule derived a new edge.
54 EdgeFired {
55 rule: String,
56 src_key: String,
57 dst_key: String,
58 edge_type: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 weight: Option<f64>,
61 commit_seq: u64,
62 },
63 /// A rule retracted a previously derived edge.
64 EdgeRetracted {
65 rule: String,
66 src_key: String,
67 dst_key: String,
68 edge_type: String,
69 commit_seq: u64,
70 },
71 /// A node was inserted.
72 NodeInserted {
73 label: String,
74 key: String,
75 commit_seq: u64,
76 },
77 /// A node was deleted.
78 NodeDeleted { key: String, commit_seq: u64 },
79 /// A user-inserted edge was added.
80 EdgeInserted {
81 edge_type: String,
82 src: String,
83 dst: String,
84 commit_seq: u64,
85 },
86 /// A user-inserted edge was deleted.
87 EdgeDeleted {
88 edge_type: String,
89 src: String,
90 dst: String,
91 commit_seq: u64,
92 },
93 /// A property was set on a node.
94 PropSet {
95 key: String,
96 field: String,
97 commit_seq: u64,
98 },
99 /// A property was removed from a node.
100 PropRemoved {
101 key: String,
102 field: String,
103 commit_seq: u64,
104 },
105 /// One or more events were dropped due to a full queue.
106 ///
107 /// The subscriber must re-read graph state to recover consistency for
108 /// lossless consumers. `missed` is the count of dropped events.
109 Lagged { missed: u64 },
110 /// A row appeared in the result of a `subscribe_query` subscription.
111 ///
112 /// Emitted after each commit when a full re-run of the subscribed Cypher
113 /// query returns a row that was absent in the previous run.
114 ///
115 /// **Full re-run per commit; use LIMIT to bound execution cost.**
116 QueryRowAdded {
117 columns: Vec<String>,
118 row: Vec<Option<Value>>,
119 },
120 /// A row disappeared from the result of a `subscribe_query` subscription.
121 ///
122 /// Emitted after each commit when a row present in the previous run is
123 /// absent from the current run.
124 ///
125 /// **Full re-run per commit; use LIMIT to bound execution cost.**
126 QueryRowRemoved {
127 columns: Vec<String>,
128 row: Vec<Option<Value>>,
129 },
130}
131
132// ---------------------------------------------------------------------------
133// Internal queue
134// ---------------------------------------------------------------------------
135
136pub(crate) struct SubInner {
137 mu: Mutex<SubQueue>,
138 condvar: Condvar,
139}
140
141impl std::fmt::Debug for SubInner {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.debug_struct("SubInner").finish_non_exhaustive()
144 }
145}
146
147struct SubQueue {
148 items: VecDeque<DbEvent>,
149 missed: u64,
150 capacity: usize,
151}
152
153impl SubInner {
154 pub(crate) fn new(capacity: usize) -> Arc<Self> {
155 Arc::new(SubInner {
156 mu: Mutex::new(SubQueue {
157 items: VecDeque::new(),
158 missed: 0,
159 capacity,
160 }),
161 condvar: Condvar::new(),
162 })
163 }
164
165 /// Push an event, dropping it (incrementing `missed`) if the queue is full.
166 ///
167 /// `notify_one` is only called when an item is actually enqueued. On
168 /// overflow we increment `missed` but skip the notification: no waiter can
169 /// consume a dropped event, and the spurious wakeup just wastes a syscall.
170 pub(crate) fn push(&self, event: DbEvent) {
171 let mut q = self.mu.lock().unwrap();
172 if q.items.len() >= q.capacity {
173 q.missed += 1;
174 // No notify: the dropped event cannot be consumed.
175 return;
176 }
177 q.items.push_back(event);
178 drop(q);
179 self.condvar.notify_one();
180 }
181
182 fn pop_one(q: &mut SubQueue) -> Option<DbEvent> {
183 if let Some(item) = q.items.pop_front() {
184 return Some(item);
185 }
186 if q.missed > 0 {
187 let missed = std::mem::take(&mut q.missed);
188 return Some(DbEvent::Lagged { missed });
189 }
190 None
191 }
192
193 /// Non-blocking read.
194 pub(crate) fn try_recv(&self) -> Option<DbEvent> {
195 let mut q = self.mu.lock().unwrap();
196 Self::pop_one(&mut q)
197 }
198
199 /// Blocking read with deadline. Returns `None` on timeout.
200 pub(crate) fn recv_timeout(&self, timeout: Duration) -> Option<DbEvent> {
201 let mut q = self.mu.lock().unwrap();
202 let deadline = std::time::Instant::now() + timeout;
203 loop {
204 if let Some(item) = Self::pop_one(&mut q) {
205 return Some(item);
206 }
207 let now = std::time::Instant::now();
208 if now >= deadline {
209 return None;
210 }
211 let remaining = deadline - now;
212 let (q2, timed_out) = self.condvar.wait_timeout(q, remaining).unwrap();
213 q = q2;
214 if timed_out.timed_out() {
215 // One last try in case a push arrived just as we timed out.
216 return Self::pop_one(&mut q);
217 }
218 }
219 }
220}
221
222// ---------------------------------------------------------------------------
223// Filter
224// ---------------------------------------------------------------------------
225
226/// What events a subscriber receives.
227pub(crate) enum SubFilter {
228 /// Only edge events for the named rule.
229 Rule(String),
230 /// All rule edge events.
231 AllRules,
232 /// Write events only (node/prop mutations; no edge-fire/retract).
233 Writes,
234}
235
236pub(crate) fn event_matches(event: &DbEvent, filter: &SubFilter) -> bool {
237 match filter {
238 SubFilter::Rule(name) => match event {
239 DbEvent::EdgeFired { rule, .. } | DbEvent::EdgeRetracted { rule, .. } => rule == name,
240 _ => false,
241 },
242 SubFilter::AllRules => {
243 matches!(
244 event,
245 DbEvent::EdgeFired { .. } | DbEvent::EdgeRetracted { .. }
246 )
247 }
248 SubFilter::Writes => matches!(
249 event,
250 DbEvent::NodeInserted { .. }
251 | DbEvent::NodeDeleted { .. }
252 | DbEvent::EdgeInserted { .. }
253 | DbEvent::EdgeDeleted { .. }
254 | DbEvent::PropSet { .. }
255 | DbEvent::PropRemoved { .. }
256 ),
257 }
258}
259
260// ---------------------------------------------------------------------------
261// Registry entry
262// ---------------------------------------------------------------------------
263
264pub(crate) struct SubEntry {
265 pub(crate) filter: SubFilter,
266 pub(crate) inner: Weak<SubInner>,
267}
268
269// ---------------------------------------------------------------------------
270// Public handle
271// ---------------------------------------------------------------------------
272
273/// A live subscription handle returned by [`GraphDb::subscribe_rule`] etc.
274///
275/// Dropping this value unregisters the subscriber: the next commit will detect
276/// the dead [`Weak`] reference and prune the entry, so no resources leak.
277#[derive(Clone, Debug)]
278pub struct Subscription(pub(crate) Arc<SubInner>);
279
280impl Subscription {
281 /// Non-blocking read. Returns `None` if the queue is empty.
282 pub fn try_recv(&self) -> Option<DbEvent> {
283 self.0.try_recv()
284 }
285
286 /// Blocking read with timeout. Returns `None` on timeout.
287 ///
288 /// Use `tokio::task::spawn_blocking` to bridge into an async context.
289 pub fn recv_timeout(&self, timeout: Duration) -> Option<DbEvent> {
290 self.0.recv_timeout(timeout)
291 }
292}