1use crate::backend::StorageBackend;
7use crate::backend::table_names;
8use crate::backend::types::{FilterExpr, Scalar, ScalarIndexType, ScanRequest};
9use crate::storage::arrow_convert::build_timestamp_column;
10use crate::storage::property_builder::PropertyColumnBuilder;
11use crate::storage::value_codec::CrdtDecodeMode;
12use anyhow::{Result, anyhow};
13use arrow_array::types::TimestampNanosecondType;
14use arrow_array::{Array, ArrayRef, PrimitiveArray, RecordBatch, UInt8Array, UInt64Array};
15use arrow_schema::{Field, Schema as ArrowSchema, TimeUnit};
16use std::collections::HashMap;
17use std::sync::Arc;
18use tracing::info;
19use uni_common::DataType;
20use uni_common::Properties;
21use uni_common::core::id::{Eid, Vid};
22use uni_common::core::schema::Schema;
23
24pub const DEFAULT_MAX_COMPACTION_ROWS: usize = 5_000_000;
27
28pub const ENTRY_SIZE_ESTIMATE: usize = 145;
32
33pub fn check_oom_guard(
38 row_count: usize,
39 max_rows: usize,
40 entity_name: &str,
41 qualifier: &str,
42) -> Result<()> {
43 if row_count > max_rows {
44 let estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE;
45 return Err(anyhow!(
46 "Table for {}_{} has {} rows (estimated {:.2} GB in memory), exceeding max_compaction_rows limit of {}. \
47 Use chunked compaction or increase the limit. See issue #143.",
48 entity_name,
49 qualifier,
50 row_count,
51 estimated_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
52 max_rows
53 ));
54 }
55 Ok(())
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum Op {
61 Insert = 0,
63 Delete = 1,
65}
66
67#[derive(Clone, Debug)]
69pub struct L1Entry {
70 pub src_vid: Vid,
71 pub dst_vid: Vid,
72 pub eid: Eid,
73 pub op: Op,
74 pub version: u64,
75 pub properties: Properties,
76 pub created_at: Option<i64>,
78 pub updated_at: Option<i64>,
80}
81
82#[derive(Debug)]
87pub struct DeltaDataset {
88 edge_type: String,
89 direction: String, #[cfg_attr(not(feature = "lance-backend"), allow(dead_code))]
92 branch: Option<String>,
93}
94
95impl DeltaDataset {
96 pub fn new(_base_uri: &str, edge_type: &str, direction: &str) -> Self {
101 Self {
102 edge_type: edge_type.to_string(),
103 direction: direction.to_string(),
104 branch: None,
105 }
106 }
107
108 pub fn new_branched(
110 base_uri: &str,
111 edge_type: &str,
112 direction: &str,
113 branch: impl Into<String>,
114 ) -> Self {
115 let mut ds = Self::new(base_uri, edge_type, direction);
116 ds.branch = Some(branch.into());
117 ds
118 }
119
120 pub fn get_arrow_schema(&self, schema: &Schema) -> Result<Arc<ArrowSchema>> {
122 let mut fields = vec![
123 Field::new("src_vid", arrow_schema::DataType::UInt64, false),
124 Field::new("dst_vid", arrow_schema::DataType::UInt64, false),
125 Field::new("eid", arrow_schema::DataType::UInt64, false),
126 Field::new("op", arrow_schema::DataType::UInt8, false), Field::new("_version", arrow_schema::DataType::UInt64, false),
128 Field::new(
130 "_created_at",
131 arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
132 true,
133 ),
134 Field::new(
135 "_updated_at",
136 arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
137 true,
138 ),
139 ];
140
141 if let Some(type_props) = schema.properties.get(&self.edge_type) {
142 let mut sorted_props: Vec<_> = type_props.iter().collect();
143 sorted_props.sort_by_key(|(name, _)| *name);
144
145 for (name, meta) in sorted_props {
146 fields.push(Field::new(name, meta.r#type.to_arrow(), meta.nullable));
147 }
148 }
149
150 fields.push(Field::new(
152 "overflow_json",
153 arrow_schema::DataType::LargeBinary,
154 true,
155 ));
156
157 Ok(Arc::new(ArrowSchema::new(fields)))
158 }
159
160 pub fn build_record_batch(&self, entries: &[L1Entry], schema: &Schema) -> Result<RecordBatch> {
162 let arrow_schema = self.get_arrow_schema(schema)?;
163
164 let mut src_vids = Vec::with_capacity(entries.len());
165 let mut dst_vids = Vec::with_capacity(entries.len());
166 let mut eids = Vec::with_capacity(entries.len());
167 let mut ops = Vec::with_capacity(entries.len());
168 let mut versions = Vec::with_capacity(entries.len());
169
170 for entry in entries {
171 src_vids.push(entry.src_vid.as_u64());
172 dst_vids.push(entry.dst_vid.as_u64());
173 eids.push(entry.eid.as_u64());
174 ops.push(entry.op as u8);
175 versions.push(entry.version);
176 }
177
178 let mut columns: Vec<ArrayRef> = vec![
179 Arc::new(UInt64Array::from(src_vids)),
180 Arc::new(UInt64Array::from(dst_vids)),
181 Arc::new(UInt64Array::from(eids)),
182 Arc::new(UInt8Array::from(ops)),
183 Arc::new(UInt64Array::from(versions)),
184 ];
185
186 columns.push(build_timestamp_column(entries.iter().map(|e| e.created_at)));
188 columns.push(build_timestamp_column(entries.iter().map(|e| e.updated_at)));
189
190 let deleted_flags: Vec<bool> = entries.iter().map(|e| e.op == Op::Delete).collect();
193
194 let prop_columns = PropertyColumnBuilder::new(schema, &self.edge_type, entries.len())
196 .with_deleted(&deleted_flags)
197 .build(|i| &entries[i].properties)?;
198
199 columns.extend(prop_columns);
200
201 let overflow_column = self.build_overflow_json_column(entries, schema)?;
203 columns.push(overflow_column);
204
205 RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
206 }
207
208 fn build_overflow_json_column(&self, entries: &[L1Entry], schema: &Schema) -> Result<ArrayRef> {
210 crate::storage::property_builder::build_overflow_json_column(
211 entries.len(),
212 &self.edge_type,
213 schema,
214 |i| &entries[i].properties,
215 &[],
216 )
217 }
218
219 fn sort_entries(&self, entries: &mut [L1Entry]) {
221 let is_fwd = self.direction == "fwd";
222 entries.sort_by(|a, b| {
223 let key_a = if is_fwd { a.src_vid } else { a.dst_vid };
224 let key_b = if is_fwd { b.src_vid } else { b.dst_vid };
225 key_a.cmp(&key_b).then(a.version.cmp(&b.version))
226 });
227 }
228
229 fn parse_batch(&self, batch: &RecordBatch, schema: &Schema) -> Result<Vec<L1Entry>> {
230 let src_vids = batch
231 .column_by_name("src_vid")
232 .ok_or(anyhow!("Missing src_vid"))?
233 .as_any()
234 .downcast_ref::<UInt64Array>()
235 .ok_or(anyhow!("Invalid src_vid type"))?;
236 let dst_vids = batch
237 .column_by_name("dst_vid")
238 .ok_or(anyhow!("Missing dst_vid"))?
239 .as_any()
240 .downcast_ref::<UInt64Array>()
241 .ok_or(anyhow!("Invalid dst_vid type"))?;
242 let eids = batch
243 .column_by_name("eid")
244 .ok_or(anyhow!("Missing eid"))?
245 .as_any()
246 .downcast_ref::<UInt64Array>()
247 .ok_or(anyhow!("Invalid eid type"))?;
248 let ops = batch
249 .column_by_name("op")
250 .ok_or(anyhow!("Missing op"))?
251 .as_any()
252 .downcast_ref::<UInt8Array>()
253 .ok_or(anyhow!("Invalid op type"))?;
254 let versions = batch
255 .column_by_name("_version")
256 .ok_or(anyhow!("Missing _version"))?
257 .as_any()
258 .downcast_ref::<UInt64Array>()
259 .ok_or(anyhow!("Invalid _version type"))?;
260
261 let created_at_col = batch.column_by_name("_created_at").and_then(|c| {
263 c.as_any()
264 .downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
265 });
266 let updated_at_col = batch.column_by_name("_updated_at").and_then(|c| {
267 c.as_any()
268 .downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
269 });
270
271 let mut prop_cols = Vec::new();
273 if let Some(type_props) = schema.properties.get(&self.edge_type) {
274 for (name, meta) in type_props {
275 if let Some(col) = batch.column_by_name(name) {
276 prop_cols.push((name, meta.r#type.clone(), col));
277 }
278 }
279 }
280
281 let mut entries = Vec::with_capacity(batch.num_rows());
282
283 for i in 0..batch.num_rows() {
284 let op = match ops.value(i) {
285 0 => Op::Insert,
286 1 => Op::Delete,
287 _ => continue, };
289
290 let properties = self.extract_properties(&prop_cols, i)?;
291
292 let read_ts = |col: Option<&PrimitiveArray<TimestampNanosecondType>>| {
294 col.and_then(|c| (!c.is_null(i)).then(|| c.value(i)))
295 };
296 let created_at = read_ts(created_at_col);
297 let updated_at = read_ts(updated_at_col);
298
299 entries.push(L1Entry {
300 src_vid: Vid::from(src_vids.value(i)),
301 dst_vid: Vid::from(dst_vids.value(i)),
302 eid: Eid::from(eids.value(i)),
303 op,
304 version: versions.value(i),
305 properties,
306 created_at,
307 updated_at,
308 });
309 }
310 Ok(entries)
311 }
312
313 fn extract_properties(
315 &self,
316 prop_cols: &[(&String, DataType, &ArrayRef)],
317 row: usize,
318 ) -> Result<Properties> {
319 let mut properties = Properties::new();
320 for (name, dtype, col) in prop_cols {
321 if col.is_null(row) {
322 continue;
323 }
324 let val = Self::value_from_column(col.as_ref(), dtype, row)?;
325 properties.insert(name.to_string(), uni_common::Value::from(val));
326 }
327 Ok(properties)
328 }
329
330 fn value_from_column(
332 col: &dyn arrow_array::Array,
333 dtype: &uni_common::DataType,
334 row: usize,
335 ) -> Result<serde_json::Value> {
336 crate::storage::value_codec::value_from_column(col, dtype, row, CrdtDecodeMode::Lenient)
337 }
338
339 fn filter_column(&self) -> &'static str {
341 if self.direction == "fwd" {
342 "src_vid"
343 } else {
344 "dst_vid"
345 }
346 }
347
348 pub async fn open_or_create(
354 &self,
355 backend: &dyn StorageBackend,
356 schema: &Schema,
357 ) -> Result<()> {
358 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
359 let arrow_schema = self.get_arrow_schema(schema)?;
360 backend
361 .open_or_create_table(&table_name, arrow_schema)
362 .await
363 }
364
365 pub async fn write_run(&self, backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
371 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
372 crate::storage::manager::write_batch_with_lance_conflict_retry(backend, &table_name, batch)
373 .await
374 }
375
376 pub fn build_partial_record_batch(
391 &self,
392 entries: &[L1Entry],
393 touched_keys: &std::collections::HashSet<String>,
394 schema: &Schema,
395 ) -> Result<RecordBatch> {
396 let mut fields: Vec<Field> = vec![
400 Field::new("src_vid", arrow_schema::DataType::UInt64, false),
401 Field::new("dst_vid", arrow_schema::DataType::UInt64, false),
402 Field::new("eid", arrow_schema::DataType::UInt64, false),
403 Field::new("op", arrow_schema::DataType::UInt8, false),
404 Field::new("_version", arrow_schema::DataType::UInt64, false),
405 Field::new(
406 "_updated_at",
407 arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
408 true,
409 ),
410 ];
411
412 let type_props = schema.properties.get(&self.edge_type);
413 let mut sorted_touched_props: Vec<(&String, &uni_common::core::schema::PropertyMeta)> =
414 if let Some(tp) = type_props {
415 tp.iter()
416 .filter(|(name, _)| touched_keys.contains(*name))
417 .collect()
418 } else {
419 Vec::new()
420 };
421 sorted_touched_props.sort_by_key(|(name, _)| *name);
422
423 for (name, meta) in &sorted_touched_props {
424 fields.push(Field::new(*name, meta.r#type.to_arrow(), meta.nullable));
425 }
426
427 let schema_prop_names: std::collections::HashSet<&String> =
430 type_props.map(|tp| tp.keys().collect()).unwrap_or_default();
431 let any_overflow_touched = touched_keys.iter().any(|k| !schema_prop_names.contains(k));
432 if any_overflow_touched {
433 fields.push(Field::new(
434 "overflow_json",
435 arrow_schema::DataType::LargeBinary,
436 true,
437 ));
438 }
439
440 let arrow_schema = Arc::new(ArrowSchema::new(fields));
441
442 let mut columns: Vec<ArrayRef> = Vec::with_capacity(arrow_schema.fields().len());
443
444 let src_vids: Vec<u64> = entries.iter().map(|e| e.src_vid.as_u64()).collect();
445 let dst_vids: Vec<u64> = entries.iter().map(|e| e.dst_vid.as_u64()).collect();
446 let eids: Vec<u64> = entries.iter().map(|e| e.eid.as_u64()).collect();
447 let ops: Vec<u8> = entries.iter().map(|e| e.op as u8).collect();
448 let versions: Vec<u64> = entries.iter().map(|e| e.version).collect();
449 columns.push(Arc::new(UInt64Array::from(src_vids)));
450 columns.push(Arc::new(UInt64Array::from(dst_vids)));
451 columns.push(Arc::new(UInt64Array::from(eids)));
452 columns.push(Arc::new(UInt8Array::from(ops)));
453 columns.push(Arc::new(UInt64Array::from(versions)));
454 columns.push(build_timestamp_column(entries.iter().map(|e| e.updated_at)));
455
456 let default_deleted = vec![false; entries.len()];
457 for (name, meta) in &sorted_touched_props {
458 let extractor =
459 crate::storage::arrow_convert::PropertyExtractor::new(name, &meta.r#type);
460 let col = extractor.build_column(entries.len(), &default_deleted, |i| {
461 entries[i].properties.get(*name)
462 })?;
463 columns.push(col);
464 }
465
466 if any_overflow_touched {
467 let overflow_column = crate::storage::property_builder::build_overflow_json_column(
468 entries.len(),
469 &self.edge_type,
470 schema,
471 |i| &entries[i].properties,
472 &[],
473 )?;
474 columns.push(overflow_column);
475 }
476
477 RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
478 }
479
480 pub async fn merge_insert_partial_run(
486 &self,
487 backend: &dyn StorageBackend,
488 batch: RecordBatch,
489 ) -> Result<()> {
490 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
491 crate::storage::manager::merge_insert_batch_with_lance_conflict_retry(
492 backend,
493 &table_name,
494 batch,
495 &["eid"],
496 )
497 .await
498 }
499
500 pub async fn ensure_eid_index(&self, backend: &dyn StorageBackend) -> Result<()> {
502 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
503 let indices = backend.list_indexes(&table_name).await?;
504
505 if !indices
506 .iter()
507 .any(|idx| idx.columns.contains(&"eid".to_string()))
508 {
509 log::info!(
510 "Creating eid BTree index for edge type '{}'",
511 self.edge_type
512 );
513 if let Err(e) = backend
514 .create_scalar_index(&table_name, &["eid"], ScalarIndexType::BTree, None)
515 .await
516 {
517 log::warn!("Failed to create eid index for '{}': {}", self.edge_type, e);
518 }
519 }
520
521 Ok(())
522 }
523
524 pub fn table_name(&self) -> String {
526 table_names::delta_table_name(&self.edge_type, &self.direction)
527 }
528
529 pub async fn scan_all_backend(
533 &self,
534 backend: &dyn StorageBackend,
535 schema: &Schema,
536 ) -> Result<Vec<L1Entry>> {
537 self.scan_all_backend_with_limit(backend, schema, DEFAULT_MAX_COMPACTION_ROWS)
538 .await
539 }
540
541 pub async fn scan_all_backend_with_limit(
543 &self,
544 backend: &dyn StorageBackend,
545 schema: &Schema,
546 max_rows: usize,
547 ) -> Result<Vec<L1Entry>> {
548 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
549
550 if !backend.table_exists(&table_name).await? {
551 return Ok(vec![]);
552 }
553
554 let row_count = backend.count_rows(&table_name, None).await?;
555 check_oom_guard(row_count, max_rows, &self.edge_type, &self.direction)?;
556
557 info!(
558 edge_type = %self.edge_type,
559 direction = %self.direction,
560 row_count,
561 estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE,
562 "Starting delta scan for compaction (backend)"
563 );
564
565 let batches = backend.scan(ScanRequest::all(&table_name)).await?;
566
567 let mut entries = Vec::new();
568 for batch in batches {
569 let mut batch_entries = self.parse_batch(&batch, schema)?;
570 entries.append(&mut batch_entries);
571 }
572
573 self.sort_entries(&mut entries);
574
575 Ok(entries)
576 }
577
578 pub async fn replace(&self, backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
582 let table_name = self.table_name();
583 let arrow_schema = batch.schema();
584 backend
585 .replace_table_atomic(&table_name, vec![batch], arrow_schema)
586 .await
587 }
588
589 pub async fn delete_up_to_version(&self, backend: &dyn StorageBackend, hwm: u64) -> Result<()> {
600 let table_name = self.table_name();
601 if !backend.table_exists(&table_name).await? {
602 return Ok(());
603 }
604 backend
605 .delete_rows(&table_name, &FilterExpr::version_at_most(hwm))
606 .await
607 }
608
609 pub async fn read_deltas(
613 &self,
614 backend: &dyn StorageBackend,
615 vid: Vid,
616 schema: &Schema,
617 version_hwm: Option<u64>,
618 ) -> Result<Vec<L1Entry>> {
619 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
620
621 if !backend.table_exists(&table_name).await? {
622 return Ok(vec![]);
623 }
624
625 let base_filter = FilterExpr::equals(self.filter_column(), Scalar::UInt(vid.as_u64()));
626
627 let final_filter = match version_hwm {
629 Some(hwm) => FilterExpr::all([base_filter, FilterExpr::version_at_most(hwm)]),
630 None => base_filter,
631 };
632
633 let batches = backend
634 .scan(ScanRequest::all(&table_name).with_filter(final_filter))
635 .await?;
636
637 let mut entries = Vec::new();
638 for batch in batches {
639 let mut batch_entries = self.parse_batch(&batch, schema)?;
640 entries.append(&mut batch_entries);
641 }
642
643 Ok(entries)
644 }
645
646 pub async fn read_deltas_batch(
651 &self,
652 backend: &dyn StorageBackend,
653 vids: &[Vid],
654 schema: &Schema,
655 version_hwm: Option<u64>,
656 ) -> Result<HashMap<Vid, Vec<L1Entry>>> {
657 if vids.is_empty() {
658 return Ok(HashMap::new());
659 }
660
661 let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
662
663 if !backend.table_exists(&table_name).await? {
664 return Ok(HashMap::new());
665 }
666
667 let mut filter = FilterExpr::one_of(
668 self.filter_column(),
669 vids.iter().map(|v| Scalar::UInt(v.as_u64())),
670 );
671
672 if let Some(hwm) = version_hwm {
674 filter = FilterExpr::all([filter, FilterExpr::version_at_most(hwm)]);
675 }
676
677 let batches = backend
678 .scan(ScanRequest::all(&table_name).with_filter(filter))
679 .await?;
680
681 let is_fwd = self.direction == "fwd";
683 let mut result: HashMap<Vid, Vec<L1Entry>> = HashMap::new();
684 for batch in batches {
685 let entries = self.parse_batch(&batch, schema)?;
686 for entry in entries {
687 let vid = if is_fwd { entry.src_vid } else { entry.dst_vid };
688 result.entry(vid).or_default().push(entry);
689 }
690 }
691
692 Ok(result)
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 #[expect(
702 clippy::assertions_on_constants,
703 reason = "Validating configuration constants intentionally"
704 )]
705 fn test_constants_are_reasonable() {
706 assert_eq!(DEFAULT_MAX_COMPACTION_ROWS, 5_000_000);
708
709 assert!(ENTRY_SIZE_ESTIMATE >= 100, "Entry size estimate too low");
711 assert!(ENTRY_SIZE_ESTIMATE <= 300, "Entry size estimate too high");
712
713 let estimated_gb =
715 (DEFAULT_MAX_COMPACTION_ROWS * ENTRY_SIZE_ESTIMATE) as f64 / (1024.0 * 1024.0 * 1024.0);
716 assert!(
717 estimated_gb < 1.0,
718 "5M entries should fit in under 1GB with current estimate"
719 );
720 }
721
722 #[test]
723 fn test_memory_estimate_formatting() {
724 let row_count = 10_000_000;
726 let estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE;
727 let estimated_gb = estimated_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
728
729 assert!(
731 estimated_gb > 1.0 && estimated_gb < 2.0,
732 "10M rows should be 1-2 GB"
733 );
734 }
735
736 #[test]
737 fn test_check_oom_guard_below_limit() {
738 let result = check_oom_guard(1_000_000, 5_000_000, "KNOWS", "fwd");
739 assert!(result.is_ok());
740 }
741
742 #[test]
743 fn test_check_oom_guard_at_limit() {
744 let result = check_oom_guard(5_000_000, 5_000_000, "KNOWS", "fwd");
745 assert!(result.is_ok());
746 }
747
748 #[test]
749 fn test_check_oom_guard_above_limit() {
750 let result = check_oom_guard(5_000_001, 5_000_000, "KNOWS", "fwd");
751 assert!(result.is_err());
752 let msg = result.unwrap_err().to_string();
753 assert!(msg.contains("KNOWS_fwd"), "Error should name the entity");
754 assert!(msg.contains("5000001"), "Error should state the row count");
755 assert!(msg.contains("GB"), "Error should show GB estimate");
756 assert!(msg.contains("issue #143"), "Error should reference issue");
757 }
758
759 #[test]
760 fn test_op_values() {
761 assert_eq!(Op::Insert as u8, 0);
762 assert_eq!(Op::Delete as u8, 1);
763 }
764
765 fn entry(eid: u64, version: u64) -> L1Entry {
766 L1Entry {
767 src_vid: Vid::new(1),
768 dst_vid: Vid::new(2),
769 eid: Eid::new(eid),
770 op: Op::Insert,
771 version,
772 properties: Properties::new(),
773 created_at: None,
774 updated_at: None,
775 }
776 }
777
778 #[tokio::test]
783 async fn delete_up_to_version_preserves_newer_deltas() -> Result<()> {
784 use crate::backend::lance::LanceDbBackend;
785
786 let dir = tempfile::tempdir()?;
787 let uri = dir.path().to_str().unwrap();
788 let be = LanceDbBackend::connect(uri, None).await?;
789 let backend: &dyn StorageBackend = &be;
790 let schema = Schema::default();
791 let ds = DeltaDataset::new(uri, "KNOWS", "fwd");
792
793 let merged = ds.build_record_batch(&[entry(10, 1), entry(11, 2)], &schema)?;
795 ds.write_run(backend, merged).await?;
796
797 let newer = ds.build_record_batch(&[entry(12, 3)], &schema)?;
799 ds.write_run(backend, newer).await?;
800
801 ds.delete_up_to_version(backend, 2).await?;
803
804 let remaining = ds.scan_all_backend(backend, &schema).await?;
805 assert_eq!(
806 remaining.len(),
807 1,
808 "only the concurrently-appended version-3 delta should remain"
809 );
810 assert_eq!(remaining[0].eid, Eid::new(12));
811 assert_eq!(remaining[0].version, 3);
812 Ok(())
813 }
814}