Skip to main content

laminar_db/
catalog.rs

1//! Source and sink catalog for tracking registered streaming objects.
2#![allow(clippy::disallowed_types)] // cold path
3
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
6use std::sync::Arc;
7use std::time::Duration;
8
9use arrow::array::RecordBatch;
10use arrow::datatypes::SchemaRef;
11use parking_lot::RwLock;
12use tokio::sync::Notify;
13
14use laminar_core::streaming::{self, BackpressureStrategy, SourceConfig, WaitStrategy};
15
16/// Record type for Arrow-based streaming subscriptions.
17#[derive(Clone, Debug)]
18pub struct ArrowRecord {
19    pub(crate) batch: RecordBatch,
20}
21
22impl laminar_core::streaming::Record for ArrowRecord {
23    fn schema() -> SchemaRef {
24        // This is a placeholder; the actual schema is on the SourceEntry.
25        // ArrowRecord is only used as a type parameter; push_arrow bypasses this.
26        Arc::new(arrow::datatypes::Schema::empty())
27    }
28
29    fn to_record_batch(&self) -> RecordBatch {
30        self.batch.clone()
31    }
32}
33
34/// Bounded ring buffer for snapshot batches.
35///
36/// Concurrent `push()` calls each get a unique slot via atomic `fetch_add`.
37/// Per-slot mutex protects the actual read/write.
38struct SnapshotRing {
39    slots: Box<[parking_lot::Mutex<Option<RecordBatch>>]>,
40    tail: AtomicUsize,
41    capacity: usize,
42}
43
44impl SnapshotRing {
45    fn new(capacity: usize) -> Self {
46        let cap = capacity.max(1);
47        let slots: Vec<_> = (0..cap).map(|_| parking_lot::Mutex::new(None)).collect();
48        Self {
49            slots: slots.into_boxed_slice(),
50            tail: AtomicUsize::new(0),
51            capacity: cap,
52        }
53    }
54
55    fn push(&self, batch: RecordBatch) {
56        // fetch_add is atomic — concurrent pushers each get a unique slot.
57        let idx = self.tail.fetch_add(1, Ordering::Relaxed) % self.capacity;
58        *self.slots[idx].lock() = Some(batch);
59    }
60
61    fn snapshot(&self) -> Vec<RecordBatch> {
62        let tail = self.tail.load(Ordering::Acquire);
63        let count = tail.min(self.capacity);
64        // Read the most recent `count` slots, oldest first.
65        let start = if tail <= self.capacity {
66            0
67        } else {
68            tail % self.capacity
69        };
70        let mut result = Vec::with_capacity(count);
71        for i in 0..count {
72            let idx = (start + i) % self.capacity;
73            if let Some(batch) = self.slots[idx].lock().as_ref() {
74                result.push(batch.clone());
75            }
76        }
77        result
78    }
79}
80
81/// A registered source in the catalog.
82pub struct SourceEntry {
83    /// Source name.
84    pub name: String,
85    /// Arrow schema.
86    pub schema: SchemaRef,
87    /// Watermark column name, if configured.
88    pub watermark_column: Option<String>,
89    /// Maximum out-of-orderness for watermark generation.
90    pub max_out_of_orderness: Option<Duration>,
91    /// Whether this source uses `PROCTIME()` watermarks.
92    pub is_processing_time: std::sync::atomic::AtomicBool,
93    pub(crate) source: streaming::Source<ArrowRecord>,
94    pub(crate) sink: streaming::Sink<ArrowRecord>,
95    buffer: SnapshotRing,
96    /// Wakeup handle for `db.insert()` event-driven notification.
97    data_notify: Arc<Notify>,
98}
99
100impl SourceEntry {
101    /// Push a batch to both the channel and the snapshot ring.
102    pub(crate) fn push_and_buffer(
103        &self,
104        batch: RecordBatch,
105    ) -> Result<(), laminar_core::streaming::StreamingError> {
106        self.source.push_arrow(batch.clone())?;
107        self.buffer.push(batch);
108        self.data_notify.notify_one();
109        Ok(())
110    }
111
112    pub(crate) fn snapshot(&self) -> Vec<RecordBatch> {
113        self.buffer.snapshot()
114    }
115
116    pub(crate) fn data_notify(&self) -> Arc<Notify> {
117        Arc::clone(&self.data_notify)
118    }
119}
120
121pub(crate) struct SinkEntry {
122    pub(crate) input: String,
123}
124
125pub(crate) struct QueryEntry {
126    pub(crate) id: u64,
127    pub(crate) sql: String,
128    pub(crate) active: bool,
129}
130
131pub(crate) struct StreamEntry {
132    pub(crate) name: String,
133    emitted_rows: AtomicU64,
134}
135
136impl StreamEntry {
137    pub(crate) fn record_emitted_rows(&self, rows: u64) {
138        self.emitted_rows.fetch_add(rows, Ordering::Relaxed);
139    }
140
141    pub(crate) fn emitted_rows(&self) -> u64 {
142        self.emitted_rows.load(Ordering::Relaxed)
143    }
144}
145
146/// Central registry of sources, sinks, streams, and queries.
147pub struct SourceCatalog {
148    sources: RwLock<HashMap<String, Arc<SourceEntry>>>,
149    sinks: RwLock<HashMap<String, SinkEntry>>,
150    streams: RwLock<HashMap<String, Arc<StreamEntry>>>,
151    queries: RwLock<HashMap<u64, QueryEntry>>,
152    next_query_id: AtomicU64,
153    default_buffer_size: usize,
154    default_backpressure: BackpressureStrategy,
155}
156
157impl SourceCatalog {
158    /// Create a catalog with the given defaults for new sources.
159    #[must_use]
160    pub fn new(buffer_size: usize, backpressure: BackpressureStrategy) -> Self {
161        Self {
162            sources: RwLock::new(HashMap::new()),
163            sinks: RwLock::new(HashMap::new()),
164            streams: RwLock::new(HashMap::new()),
165            queries: RwLock::new(HashMap::new()),
166            next_query_id: AtomicU64::new(1),
167            default_buffer_size: buffer_size,
168            default_backpressure: backpressure,
169        }
170    }
171
172    #[allow(clippy::too_many_arguments)]
173    pub(crate) fn register_source(
174        &self,
175        name: &str,
176        schema: SchemaRef,
177        watermark_column: Option<String>,
178        max_out_of_orderness: Option<Duration>,
179        buffer_size: Option<usize>,
180        backpressure: Option<BackpressureStrategy>,
181    ) -> Result<Arc<SourceEntry>, crate::DbError> {
182        let mut sources = self.sources.write();
183        if sources.contains_key(name) {
184            return Err(crate::DbError::SourceAlreadyExists(name.to_string()));
185        }
186
187        let buf_size = buffer_size.unwrap_or(self.default_buffer_size);
188        let bp = backpressure.unwrap_or(self.default_backpressure);
189
190        // Channel buffer is at least 1024 to avoid blocking on small snapshot rings.
191        let channel_buf = buf_size.max(1024);
192        let config = SourceConfig {
193            channel: streaming::ChannelConfig {
194                buffer_size: channel_buf,
195                backpressure: bp,
196                wait_strategy: WaitStrategy::SpinYield,
197                track_stats: false,
198            },
199            name: Some(name.to_string()),
200        };
201
202        let (source, sink) = streaming::create_with_config::<ArrowRecord>(config);
203
204        let entry = Arc::new(SourceEntry {
205            name: name.to_string(),
206            schema,
207            watermark_column,
208            max_out_of_orderness,
209            is_processing_time: std::sync::atomic::AtomicBool::new(false),
210            source,
211            sink,
212            buffer: SnapshotRing::new(buf_size),
213            data_notify: Arc::new(Notify::new()),
214        });
215
216        sources.insert(name.to_string(), Arc::clone(&entry));
217        Ok(entry)
218    }
219
220    #[cfg(test)]
221    pub(crate) fn register_source_or_replace(
222        &self,
223        name: &str,
224        schema: SchemaRef,
225        watermark_column: Option<String>,
226        max_out_of_orderness: Option<Duration>,
227        buffer_size: Option<usize>,
228        backpressure: Option<BackpressureStrategy>,
229    ) -> Arc<SourceEntry> {
230        // Remove existing if present
231        self.sources.write().remove(name);
232        // Safe to unwrap since we just removed any conflict
233        self.register_source(
234            name,
235            schema,
236            watermark_column,
237            max_out_of_orderness,
238            buffer_size,
239            backpressure,
240        )
241        .unwrap()
242    }
243
244    /// Look up a registered source by name.
245    pub fn get_source(&self, name: &str) -> Option<Arc<SourceEntry>> {
246        self.sources.read().get(name).cloned()
247    }
248
249    /// Returns `true` if the source existed.
250    pub fn drop_source(&self, name: &str) -> bool {
251        self.sources.write().remove(name).is_some()
252    }
253
254    pub(crate) fn register_sink(&self, name: &str, input: &str) -> Result<(), crate::DbError> {
255        let mut sinks = self.sinks.write();
256        if sinks.contains_key(name) {
257            return Err(crate::DbError::SinkAlreadyExists(name.to_string()));
258        }
259        sinks.insert(
260            name.to_string(),
261            SinkEntry {
262                input: input.to_string(),
263            },
264        );
265        Ok(())
266    }
267
268    /// Returns `true` if the sink existed.
269    pub fn drop_sink(&self, name: &str) -> bool {
270        self.sinks.write().remove(name).is_some()
271    }
272
273    pub(crate) fn register_stream(&self, name: &str) -> Result<(), crate::DbError> {
274        let mut streams = self.streams.write();
275        if streams.contains_key(name) {
276            return Err(crate::DbError::StreamAlreadyExists(name.to_string()));
277        }
278
279        streams.insert(
280            name.to_string(),
281            Arc::new(StreamEntry {
282                name: name.to_string(),
283                emitted_rows: AtomicU64::new(0),
284            }),
285        );
286        Ok(())
287    }
288
289    pub(crate) fn get_stream_entry(&self, name: &str) -> Option<Arc<StreamEntry>> {
290        self.streams.read().get(name).cloned()
291    }
292
293    /// Returns `true` if the stream existed.
294    pub fn drop_stream(&self, name: &str) -> bool {
295        self.streams.write().remove(name).is_some()
296    }
297
298    /// All registered stream names.
299    pub fn list_streams(&self) -> Vec<String> {
300        self.streams.read().keys().cloned().collect()
301    }
302
303    /// All registered source names.
304    pub fn list_sources(&self) -> Vec<String> {
305        self.sources.read().keys().cloned().collect()
306    }
307
308    /// All registered sink names.
309    pub fn list_sinks(&self) -> Vec<String> {
310        self.sinks.read().keys().cloned().collect()
311    }
312
313    /// Input source/table name for a sink, if registered.
314    pub fn get_sink_input(&self, name: &str) -> Option<String> {
315        self.sinks.read().get(name).map(|e| e.input.clone())
316    }
317
318    pub(crate) fn register_query(&self, sql: &str) -> u64 {
319        let id = self.next_query_id.fetch_add(1, Ordering::Relaxed);
320        let mut queries = self.queries.write();
321        queries.insert(
322            id,
323            QueryEntry {
324                id,
325                sql: sql.to_string(),
326                active: true,
327            },
328        );
329        id
330    }
331
332    pub(crate) fn deactivate_query(&self, id: u64) -> bool {
333        // Cap retained deactivated queries so finished SELECTs can't accumulate
334        // unboundedly; over the cap, the oldest (lowest id) is dropped.
335        const MAX_INACTIVE_QUERIES: usize = 100;
336        let mut queries = self.queries.write();
337        if let Some(entry) = queries.get_mut(&id) {
338            let was_active = entry.active;
339            entry.active = false;
340            if was_active {
341                let inactive_count = queries.values().filter(|q| !q.active).count();
342                if inactive_count > MAX_INACTIVE_QUERIES {
343                    let oldest_inactive_id =
344                        queries.values().filter(|q| !q.active).map(|q| q.id).min();
345                    if let Some(oldest_id) = oldest_inactive_id {
346                        queries.remove(&oldest_id);
347                    }
348                }
349            }
350            true
351        } else {
352            false
353        }
354    }
355
356    pub(crate) fn list_queries(&self) -> Vec<(u64, String, bool)> {
357        self.queries
358            .read()
359            .values()
360            .map(|q| (q.id, q.sql.clone(), q.active))
361            .collect()
362    }
363
364    /// Schema for DESCRIBE queries.
365    pub fn describe_source(&self, name: &str) -> Option<SchemaRef> {
366        self.sources.read().get(name).map(|e| e.schema.clone())
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use arrow::datatypes::{DataType, Field, Schema};
374
375    fn test_schema() -> SchemaRef {
376        Arc::new(Schema::new(vec![
377            Field::new("id", DataType::Int64, false),
378            Field::new("value", DataType::Float64, false),
379        ]))
380    }
381
382    #[tokio::test]
383    async fn test_register_source() {
384        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
385        let result = catalog.register_source("test", test_schema(), None, None, None, None);
386        assert!(result.is_ok());
387        assert!(catalog.get_source("test").is_some());
388    }
389
390    #[tokio::test]
391    async fn test_register_duplicate_source() {
392        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
393        catalog
394            .register_source("test", test_schema(), None, None, None, None)
395            .unwrap();
396        let result = catalog.register_source("test", test_schema(), None, None, None, None);
397        assert!(matches!(
398            result,
399            Err(crate::DbError::SourceAlreadyExists(_))
400        ));
401    }
402
403    #[tokio::test]
404    async fn test_drop_source() {
405        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
406        catalog
407            .register_source("test", test_schema(), None, None, None, None)
408            .unwrap();
409        assert!(catalog.drop_source("test"));
410        assert!(catalog.get_source("test").is_none());
411    }
412
413    #[tokio::test]
414    async fn test_list_sources() {
415        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
416        catalog
417            .register_source("a", test_schema(), None, None, None, None)
418            .unwrap();
419        catalog
420            .register_source("b", test_schema(), None, None, None, None)
421            .unwrap();
422        let mut names = catalog.list_sources();
423        names.sort();
424        assert_eq!(names, vec!["a", "b"]);
425    }
426
427    #[tokio::test]
428    async fn test_register_sink() {
429        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
430        assert!(catalog.register_sink("output", "events").is_ok());
431        assert_eq!(catalog.list_sinks(), vec!["output"]);
432    }
433
434    #[tokio::test]
435    async fn test_register_query() {
436        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
437        let id = catalog.register_query("SELECT * FROM events");
438        assert_eq!(id, 1);
439        let queries = catalog.list_queries();
440        assert_eq!(queries.len(), 1);
441        assert!(queries[0].2); // active
442    }
443
444    #[tokio::test]
445    async fn test_deactivate_query() {
446        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
447        let id = catalog.register_query("SELECT * FROM events");
448        catalog.deactivate_query(id);
449        let queries = catalog.list_queries();
450        assert!(!queries[0].2); // inactive
451    }
452
453    #[tokio::test]
454    async fn test_deactivate_query_limit() {
455        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
456        let mut ids = Vec::new();
457        for i in 0..105 {
458            let sql = format!("SELECT * FROM events_{i}");
459            ids.push(catalog.register_query(&sql));
460        }
461        for id in &ids {
462            catalog.deactivate_query(*id);
463        }
464        let queries = catalog.list_queries();
465        assert_eq!(queries.len(), 100);
466        let remaining_ids: std::collections::HashSet<u64> = queries.iter().map(|q| q.0).collect();
467        for id in 1..=5 {
468            assert!(
469                !remaining_ids.contains(&id),
470                "Query {id} should have been evicted"
471            );
472        }
473        for id in 6..=105 {
474            assert!(
475                remaining_ids.contains(&id),
476                "Query {id} should be remaining"
477            );
478        }
479    }
480
481    #[tokio::test]
482    async fn test_describe_source() {
483        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
484        let schema = test_schema();
485        catalog
486            .register_source("test", schema.clone(), None, None, None, None)
487            .unwrap();
488        let result = catalog.describe_source("test");
489        assert!(result.is_some());
490        assert_eq!(result.unwrap().fields().len(), 2);
491    }
492
493    #[tokio::test]
494    async fn test_or_replace() {
495        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
496        catalog
497            .register_source("test", test_schema(), None, None, None, None)
498            .unwrap();
499        let entry = catalog.register_source_or_replace(
500            "test",
501            test_schema(),
502            Some("ts".into()),
503            None,
504            None,
505            None,
506        );
507        assert_eq!(entry.watermark_column, Some("ts".to_string()));
508    }
509
510    #[tokio::test]
511    async fn test_push_and_buffer_snapshot() {
512        let catalog = SourceCatalog::new(1024, BackpressureStrategy::Block);
513        let schema = test_schema();
514        let entry = catalog
515            .register_source("test", schema.clone(), None, None, None, None)
516            .unwrap();
517
518        let batch = RecordBatch::try_new(
519            schema,
520            vec![
521                Arc::new(arrow::array::Int64Array::from(vec![1])),
522                Arc::new(arrow::array::Float64Array::from(vec![1.5])),
523            ],
524        )
525        .unwrap();
526
527        entry.push_and_buffer(batch).unwrap();
528        let snap = entry.snapshot();
529        assert_eq!(snap.len(), 1);
530        assert_eq!(snap[0].num_rows(), 1);
531    }
532
533    #[tokio::test]
534    async fn test_buffer_capacity_drops_oldest() {
535        // SnapshotRing capacity=2; channel gets a larger buffer so pushes don't block.
536        let catalog = SourceCatalog::new(2, BackpressureStrategy::DropOldest);
537        let schema = test_schema();
538        let entry = catalog
539            .register_source("test", schema.clone(), None, None, None, None)
540            .unwrap();
541
542        let values: [(i64, f64); 3] = [(0, 1.0), (1, 2.0), (2, 3.0)];
543        for (id, val) in values {
544            let batch = RecordBatch::try_new(
545                schema.clone(),
546                vec![
547                    Arc::new(arrow::array::Int64Array::from(vec![id])),
548                    Arc::new(arrow::array::Float64Array::from(vec![val])),
549                ],
550            )
551            .unwrap();
552            entry.push_and_buffer(batch).unwrap();
553        }
554
555        let snap = entry.snapshot();
556        // SnapshotRing capacity=2, so only the last 2 batches remain
557        assert_eq!(snap.len(), 2);
558        let col = snap[0]
559            .column(0)
560            .as_any()
561            .downcast_ref::<arrow::array::Int64Array>()
562            .unwrap();
563        assert_eq!(col.value(0), 1); // batch 0 was dropped
564    }
565}