Skip to main content

linkrs_server/client/
cursor_context.rs

1//! Session-scoped forward-only cursors over streaming results.
2//!
3//! A cursor holds one [`StreamingQueryResult`] execution handle plus a small
4//! row buffer. Fetching pulls chunks until the requested page is full (or
5//! the execution is exhausted); the frontend never buffers the full result.
6//! Explicit close drops the handle promptly. Query deregistration runs
7//! through the handle's drop callback, the single path shared with streaming
8//! queries, which also covers idle-timeout sweeps, fetch-failure removals,
9//! and session drops.
10
11use parking_lot::RwLock;
12use std::collections::{HashMap, VecDeque};
13use std::fmt;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, Instant};
16
17use crate::query::executor::streaming::StreamingQueryResult;
18use linkrs_core::Value;
19
20/// Cap on concurrently open cursors per session.
21pub const MAX_CURSORS_PER_SESSION: usize = 16;
22/// Idle cursors are reclaimed on the next cursor operation.
23pub const CURSOR_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
24/// Largest page a single fetch returns.
25pub const MAX_CURSOR_PAGE_SIZE: usize = 10_000;
26/// Smallest page a single fetch returns.
27pub const MIN_CURSOR_PAGE_SIZE: usize = 1;
28
29/// One open cursor: an execution handle plus buffered rows.
30pub struct CursorState {
31    query: String,
32    columns: Vec<String>,
33    result: StreamingQueryResult,
34    buffered: VecDeque<Vec<Value>>,
35    exhausted: bool,
36    last_active: Instant,
37}
38
39impl fmt::Debug for CursorState {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("CursorState")
42            .field("query", &self.query)
43            .field("columns", &self.columns)
44            .field("buffered", &self.buffered.len())
45            .field("exhausted", &self.exhausted)
46            .finish()
47    }
48}
49
50/// One fetched page: column names plus row-major values.
51pub struct CursorPage {
52    pub columns: Vec<String>,
53    pub rows: Vec<Vec<Value>>,
54    pub has_more: bool,
55}
56
57/// Session-owned cursor registry, keyed by server-assigned cursor id.
58#[derive(Debug)]
59pub struct CursorContext {
60    cursors: RwLock<HashMap<u64, CursorState>>,
61    next_id: AtomicU64,
62}
63
64impl Default for CursorContext {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl CursorContext {
71    pub fn new() -> Self {
72        Self {
73            cursors: RwLock::new(HashMap::new()),
74            next_id: AtomicU64::new(1),
75        }
76    }
77
78    /// Register a new cursor over an already-started streaming result.
79    pub fn open(
80        &self,
81        query: String,
82        result: StreamingQueryResult,
83        columns: Vec<String>,
84    ) -> Result<u64, String> {
85        self.sweep_expired();
86        let mut cursors = self.cursors.write();
87        if cursors.len() >= MAX_CURSORS_PER_SESSION {
88            return Err(format!(
89                "Too many open cursors (limit {MAX_CURSORS_PER_SESSION}); close idle ones first"
90            ));
91        }
92        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
93        cursors.insert(
94            id,
95            CursorState {
96                query,
97                columns,
98                result,
99                buffered: VecDeque::new(),
100                exhausted: false,
101                last_active: Instant::now(),
102            },
103        );
104        Ok(id)
105    }
106
107    /// Pull chunks until `page_size` rows are ready or the execution ends.
108    /// Blocking: callers run this off the async runtime.
109    pub fn fetch(&self, cursor_id: u64, page_size: usize) -> Result<CursorPage, String> {
110        self.sweep_expired();
111        let page_size = page_size.clamp(MIN_CURSOR_PAGE_SIZE, MAX_CURSOR_PAGE_SIZE);
112        // Snapshot the handle without holding the lock across blocking pulls.
113        let (result, must_pull) = {
114            let cursors = self.cursors.read();
115            let cursor = cursors
116                .get(&cursor_id)
117                .ok_or_else(|| format!("Unknown cursor: {cursor_id}"))?;
118            (
119                cursor.result.clone(),
120                !cursor.exhausted && cursor.buffered.len() < page_size,
121            )
122        };
123
124        let mut pulled: Vec<(Vec<String>, Vec<Vec<Value>>)> = Vec::new();
125        let mut pulled_rows: usize = 0;
126        let mut now_exhausted = false;
127        if must_pull {
128            // Seed from the current buffer fill, read without a lock held:
129            // the merge below re-checks existence, so a concurrent close
130            // simply discards the pulled rows.
131            loop {
132                match result.next_chunk() {
133                    Ok(Some(chunk)) => {
134                        pulled_rows += chunk.rows.len();
135                        pulled.push((chunk.col_names(), chunk.rows));
136                        if pulled_rows >= page_size {
137                            break;
138                        }
139                    }
140                    Ok(None) => {
141                        now_exhausted = true;
142                        break;
143                    }
144                    Err(e) => {
145                        self.cursors.write().remove(&cursor_id);
146                        return Err(format!("Cursor query failed: {e}"));
147                    }
148                }
149            }
150        }
151
152        // Merge under the write lock; a concurrent close wins over the fetch.
153        let mut cursors = self.cursors.write();
154        let cursor = cursors
155            .get_mut(&cursor_id)
156            .ok_or_else(|| format!("Unknown cursor: {cursor_id}"))?;
157        for (names, rows) in pulled {
158            if cursor.columns.is_empty() {
159                cursor.columns = names;
160            }
161            cursor.buffered.extend(rows);
162        }
163        cursor.exhausted = cursor.exhausted || now_exhausted;
164        let mut rows = Vec::with_capacity(page_size.min(cursor.buffered.len()));
165        while rows.len() < page_size {
166            match cursor.buffered.pop_front() {
167                Some(row) => rows.push(row),
168                None => break,
169            }
170        }
171        cursor.last_active = Instant::now();
172        let has_more = !(cursor.exhausted && cursor.buffered.is_empty());
173        Ok(CursorPage {
174            columns: cursor.columns.clone(),
175            rows,
176            has_more,
177        })
178    }
179
180    /// Release a cursor; dropping the handle runs query deregistration.
181    pub fn close(&self, cursor_id: u64) -> bool {
182        self.sweep_expired();
183        self.cursors.write().remove(&cursor_id).is_some()
184    }
185
186    /// Release every cursor; dropping each handle runs query deregistration.
187    pub fn close_all(&self) {
188        self.cursors.write().clear();
189    }
190
191    /// Number of currently open cursors.
192    pub fn active_count(&self) -> usize {
193        self.cursors.read().len()
194    }
195
196    /// Drop cursors idle past the timeout.
197    fn sweep_expired(&self) {
198        let mut cursors = self.cursors.write();
199        cursors.retain(|_, cursor| cursor.last_active.elapsed() < CURSOR_IDLE_TIMEOUT);
200    }
201}