laminar_db/db/
committed_read.rs1use std::time::Instant;
4
5use arrow::array::RecordBatch;
6use arrow::datatypes::SchemaRef;
7
8use super::LaminarDB;
9use crate::error::DbError;
10use crate::mv_store::{CommittedRead, CommittedReadError, VIEW_READ_DEADLINE};
11
12#[derive(Debug, Clone)]
14pub struct CommittedQuery {
15 pub epoch: u64,
17 pub checkpoint_id: u64,
19 pub taken_at_ms: u64,
21 pub schema: SchemaRef,
23 pub batches: Vec<RecordBatch>,
25}
26
27impl LaminarDB {
28 pub async fn query_committed(
53 &self,
54 sql: &str,
55 at: CommittedRead,
56 ) -> Result<CommittedQuery, DbError> {
57 self.query_committed_until(sql, at, Instant::now() + VIEW_READ_DEADLINE)
58 .await
59 }
60
61 pub(crate) async fn query_committed_until(
62 &self,
63 sql: &str,
64 at: CommittedRead,
65 deadline: Instant,
66 ) -> Result<CommittedQuery, DbError> {
67 if self.shutdown.load(std::sync::atomic::Ordering::Relaxed) {
68 return Err(DbError::Shutdown);
69 }
70 let (cut, stream) = self.committed_stream(sql, at, deadline).await?;
71 let schema = stream.schema();
72 let batches = tokio::time::timeout_at(
73 tokio::time::Instant::from_std(deadline),
74 futures::TryStreamExt::try_collect::<Vec<_>>(stream),
75 )
76 .await
77 .map_err(|_| CommittedReadError::DeadlineExceeded { epoch: cut.epoch })?
78 .map_err(crate::query_rows::execution_error)?;
79 Ok(CommittedQuery {
80 epoch: cut.epoch,
81 checkpoint_id: cut.checkpoint_id,
82 taken_at_ms: cut.taken_at_ms,
83 schema,
84 batches,
85 })
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use std::sync::Arc;
92 use std::time::Duration;
93
94 use arrow::array::{Array, Int64Array};
95 use laminar_core::streaming::StreamCheckpointConfig;
96
97 use super::*;
98 use crate::config::LaminarConfig;
99
100 async fn pipeline(dir: &std::path::Path) -> Arc<LaminarDB> {
101 let db = LaminarDB::open_with_config(LaminarConfig {
102 storage_dir: Some(dir.to_path_buf()),
103 checkpoint: Some(StreamCheckpointConfig {
104 interval_ms: None,
105 ..StreamCheckpointConfig::default()
106 }),
107 ..LaminarConfig::default()
108 })
109 .unwrap();
110 for sql in [
111 "CREATE SOURCE events (k BIGINT)",
112 "CREATE MATERIALIZED VIEW tail AS SELECT k FROM events WITH (retention = '10 rows')",
113 "CREATE MATERIALIZED VIEW counts AS SELECT k, COUNT(*) AS n FROM events GROUP BY k",
114 ] {
115 db.execute(sql).await.unwrap();
116 }
117 db.start().await.unwrap();
118 db
119 }
120
121 async fn push_and_checkpoint(db: &LaminarDB, k: i64) -> u64 {
122 let batch = RecordBatch::try_from_iter_with_nullable(vec![(
123 "k",
124 Arc::new(Int64Array::from(vec![k])) as arrow::array::ArrayRef,
125 true,
126 )])
127 .unwrap();
128 db.source_untyped("events")
129 .unwrap()
130 .push_arrow(batch)
131 .unwrap();
132 tokio::time::sleep(Duration::from_millis(200)).await;
133 let result = db.checkpoint().await.unwrap();
134 assert!(result.success, "{:?}", result.error);
135 result.epoch
136 }
137
138 fn manifest_exists(dir: &std::path::Path, checkpoint_id: u64) -> bool {
139 let suffix = std::path::Path::new(&format!("{checkpoint_id:020}")).join("manifest.json");
140 let mut pending = vec![dir.to_path_buf()];
141 while let Some(path) = pending.pop() {
142 let Ok(entries) = std::fs::read_dir(&path) else {
143 continue;
144 };
145 for entry in entries.flatten() {
146 let path = entry.path();
147 if path.ends_with(&suffix) {
148 return true;
149 }
150 if path.is_dir() {
151 pending.push(path);
152 }
153 }
154 }
155 false
156 }
157
158 fn keys(batches: &[RecordBatch]) -> Vec<i64> {
159 let mut keys: Vec<i64> = batches
160 .iter()
161 .flat_map(|batch| {
162 let column = batch
163 .column(0)
164 .as_any()
165 .downcast_ref::<Int64Array>()
166 .unwrap();
167 (0..column.len())
168 .map(|row| column.value(row))
169 .collect::<Vec<_>>()
170 })
171 .collect();
172 keys.sort_unstable();
173 keys
174 }
175
176 #[tokio::test]
177 async fn a_pinned_cut_outlives_later_checkpoints_until_it_is_released() {
178 let dir = tempfile::tempdir().unwrap();
179 let db = pipeline(dir.path()).await;
180 let first = push_and_checkpoint(&db, 1).await;
181 let views = db.mv_store.read().committed_views().unwrap();
182 let held = views
183 .pin(
184 CommittedRead::Exact(first),
185 Instant::now() + VIEW_READ_DEADLINE,
186 )
187 .await
188 .unwrap();
189 push_and_checkpoint(&db, 1).await;
191 push_and_checkpoint(&db, 1).await;
192 tokio::time::sleep(Duration::from_millis(300)).await;
193
194 let pinned = held.checkpoint_id;
195 assert!(
196 manifest_exists(dir.path(), pinned),
197 "retention keeps the pinned cut"
198 );
199 let shape = db.mv_store.read().view_shape("counts").unwrap();
200 let rows = held.read_view(&views, &shape, &[]).await.unwrap();
201 let counts = rows[0]
202 .column(1)
203 .as_any()
204 .downcast_ref::<Int64Array>()
205 .unwrap();
206 assert_eq!(counts.values().to_vec(), [1]);
207
208 drop(held);
209 push_and_checkpoint(&db, 1).await;
210 for _ in 0..50 {
211 if !manifest_exists(dir.path(), pinned) {
212 break;
213 }
214 tokio::time::sleep(Duration::from_millis(100)).await;
215 }
216 assert!(
217 !manifest_exists(dir.path(), pinned),
218 "a released cut is retired"
219 );
220 let mut error = None;
221 for _ in 0..50 {
222 match db
223 .query_committed("SELECT n FROM counts", CommittedRead::Exact(first))
224 .await
225 {
226 Ok(_) => tokio::time::sleep(Duration::from_millis(100)).await,
227 Err(other) => {
228 error = Some(other);
229 break;
230 }
231 }
232 }
233 assert!(
234 matches!(
235 error,
236 Some(DbError::CommittedRead(CommittedReadError::Retired { requested }))
237 if requested == first
238 ),
239 "{error:?}"
240 );
241 db.shutdown().await.unwrap();
242 }
243
244 #[tokio::test]
245 async fn a_declared_freshness_survives_a_pipeline_restart() {
246 let dir = tempfile::tempdir().unwrap();
247 let db = LaminarDB::open_with_config(LaminarConfig {
248 storage_dir: Some(dir.path().to_path_buf()),
249 checkpoint: Some(StreamCheckpointConfig {
250 interval_ms: Some(1_000),
251 ..StreamCheckpointConfig::default()
252 }),
253 ..LaminarConfig::default()
254 })
255 .unwrap();
256 db.execute("CREATE SOURCE events (k BIGINT)").await.unwrap();
257 db.execute(
258 "CREATE MATERIALIZED VIEW counts AS SELECT k, COUNT(*) AS n FROM events GROUP BY k \
259 WITH (freshness = '5 seconds')",
260 )
261 .await
262 .unwrap();
263 db.start().await.unwrap();
264 db.stop_pipeline().await.unwrap();
265 db.start().await.unwrap();
266 let contracts = db.mv_store.read().freshness_contracts();
267 assert_eq!(
268 contracts.declared_freshness("counts"),
269 Some(Duration::from_secs(5))
270 );
271 db.stop_pipeline().await.unwrap();
272 db.execute("DROP MATERIALIZED VIEW counts").await.unwrap();
273 assert_eq!(contracts.declared_freshness("counts"), None);
274 db.shutdown().await.unwrap();
275 }
276
277 #[tokio::test]
278 async fn a_read_waits_for_its_epoch_until_the_deadline() {
279 let dir = tempfile::tempdir().unwrap();
280 let db = pipeline(dir.path()).await;
281 let first = push_and_checkpoint(&db, 1).await;
282 let error = db
283 .query_committed_until(
284 "SELECT k FROM tail",
285 CommittedRead::AtLeast(first + 100),
286 Instant::now() + Duration::from_millis(200),
287 )
288 .await
289 .unwrap_err();
290 assert!(
291 matches!(
292 error,
293 DbError::CommittedRead(CommittedReadError::NotCommitted {
294 requested: Some(requested),
295 latest: Some(latest),
296 }) if requested == first + 100 && latest == first
297 ),
298 "{error:?}"
299 );
300
301 let writer = Arc::clone(&db);
302 let next = tokio::spawn(async move {
303 tokio::time::sleep(Duration::from_millis(100)).await;
304 push_and_checkpoint(&writer, 2).await
305 });
306 let read = db
307 .query_committed("SELECT k FROM tail", CommittedRead::AtLeast(first + 1))
308 .await
309 .unwrap();
310 let second = next.await.unwrap();
311 assert_eq!(read.epoch, second);
312 assert_eq!(keys(&read.batches), [1, 2]);
313 db.shutdown().await.unwrap();
314 }
315}