Repository: slatedb/slatedb (2026-03-29T19:00:52Z)

Issue:
Make `BytesRange` except empty bounds
With https://github.com/slatedb/slatedb/pull/1401 users might supply `RangeBounds<Bytes>` that have empty start or ending bounds (an empty bytes array []). This currently throws with the underlying `BytesRange`. The two options seem:
- Document the behavior correctly.
- Change the underlying `BytesRange` to except empty bounds. 


Discussion:
To clarify: when you say "empty start or ending bounds" do you mean `Unbounded`, or do you mean bytes with `[]`?
I meant the empty bytes array [], `Unbounded` should work fine.
@FiV0 was thinking more about this. Easy enough to add I think. Kinda curious about the use case for an empty bound (vs. `Unbounded`).
(If it's just an ergonomics thing, that makes sense to me, too :) )
I guess bytes[] inclusive for the end bound is still meaningful.
@ajityagaty looks like you're working on this? Shall I assign to you?
Yes please. I'll put out a PR today.
Heya @ajityagaty, wanted to check in. @nomiero was asking for this as well. If you like he can take over. If you're still planning on putting the PR up, that works, too. :)
Sorry for the delay. Is it ok if I just finish this one up? I have the changes ready and I was going to push the PR out tonight after re-reviewing it once more. 
@ajityagaty no worries! Take your time :)



Patch:
diff --git a/bindings/uniffi/src/error.rs b/bindings/uniffi/src/error.rs
index ae7c61b34..bf0ec9461 100644
--- a/bindings/uniffi/src/error.rs
+++ b/bindings/uniffi/src/error.rs
@@ -28,12 +28,6 @@ pub(crate) enum SlateDbError {
     #[error("invalid checkpoint_id UUID: {source}")]
     InvalidCheckpointId { source: uuid::Error },
 
-    #[error("range start cannot be empty")]
-    EmptyRangeStart,
-
-    #[error("range end cannot be empty")]
-    EmptyRangeEnd,
-
     #[error("range start must not be greater than range end")]
     RangeStartGreaterThanEnd,
 
diff --git a/bindings/uniffi/src/types.rs b/bindings/uniffi/src/types.rs
index 457c01529..2652e37bb 100644
--- a/bindings/uniffi/src/types.rs
+++ b/bindings/uniffi/src/types.rs
@@ -22,13 +22,6 @@ pub struct KeyRange {
 
 impl KeyRange {
     pub(crate) fn into_bounds(self) -> Result<KeyBounds, SlateDbError> {
-        if self.start.as_ref().is_some_and(|start| start.is_empty()) {
-            return Err(SlateDbError::EmptyRangeStart);
-        }
-        if self.end.as_ref().is_some_and(|end| end.is_empty()) {
-            return Err(SlateDbError::EmptyRangeEnd);
-        }
-
         if let (Some(start), Some(end)) = (&self.start, &self.end) {
             match start.cmp(end) {
                 std::cmp::Ordering::Greater => {
diff --git a/slatedb/src/bytes_range.rs b/slatedb/src/bytes_range.rs
index f05936b95..2815473e9 100644
--- a/slatedb/src/bytes_range.rs
+++ b/slatedb/src/bytes_range.rs
@@ -43,23 +43,8 @@ impl RangeBounds<Bytes> for BytesRange {
     }
 }
 
-fn is_bound_non_empty(bound: &Bound<Bytes>) -> bool {
-    match bound {
-        Included(b) | Excluded(b) => !b.is_empty(),
-        Unbounded => true,
-    }
-}
-
 impl BytesRange {
     pub(crate) fn new(start_bound: Bound<Bytes>, end_bound: Bound<Bytes>) -> Self {
-        assert!(
-            is_bound_non_empty(&start_bound),
-            "Start bound must be non-empty"
-        );
-        assert!(
-            is_bound_non_empty(&end_bound),
-            "End bound must be non-empty"
-        );
         let inner = ComparableRange::new(start_bound, end_bound);
         assert!(inner.non_empty(), "Range must be non-empty");
         Self { inner }
@@ -322,4 +307,20 @@ pub(crate) mod tests {
         let end = Bound::Included(Bytes::from("a"));
         BytesRange::new(start, end);
     }
+
+    #[test]
+    fn test_empty_included_start_bound_is_valid_and_contains_all_keys() {
+        let range = BytesRange::new(Bound::Included(Bytes::new()), Bound::Unbounded);
+        assert!(range.contains(&Bytes::new())); // b"" <= b"" holds for Included
+        assert!(range.contains(&Bytes::from("a")));
+        assert!(range.contains(&Bytes::from("z")));
+    }
+
+    #[test]
+    fn test_empty_excluded_start_bound_is_valid_and_contains_all_keys() {
+        let range = BytesRange::new(Bound::Excluded(Bytes::new()), Bound::Unbounded);
+        assert!(!range.contains(&Bytes::new())); // b"" < b"" is false for Excluded
+        assert!(range.contains(&Bytes::from("a")));
+        assert!(range.contains(&Bytes::from("z")));
+    }
 }


Test patch:
diff --git a/bindings/go/uniffi/slatedb_test.go b/bindings/go/uniffi/slatedb_test.go
index c3dc4bc95..808f786a5 100644
--- a/bindings/go/uniffi/slatedb_test.go
+++ b/bindings/go/uniffi/slatedb_test.go
@@ -921,13 +921,6 @@ func TestDbInvalidInputsAndErrorMapping(t *testing.T) {
 			t.Fatalf("Delete(empty key): got %v, want invalid error", err)
 		}
 
-		if _, err := handle.db.Scan(slatedb.KeyRange{
-			Start:          bytesPtr([]byte{}),
-			StartInclusive: true,
-		}); !errors.Is(err, slatedb.ErrErrorInvalid) {
-			t.Fatalf("Scan(empty start): got %v, want invalid error", err)
-		}
-
 		if _, err := handle.db.Scan(slatedb.KeyRange{
 			Start:          bytesPtr([]byte("z")),
 			StartInclusive: true,
@@ -937,6 +930,20 @@ func TestDbInvalidInputsAndErrorMapping(t *testing.T) {
 			t.Fatalf("Scan(start > end): got %v, want invalid error", err)
 		}
 
+		// Scan with empty start bound should succeed and be treated as unbounded start.
+		if _, err := handle.db.Put([]byte("seed"), []byte("value")); err != nil {
+			t.Fatalf("Put(seed): %v", err)
+		}
+		iter, err := handle.db.Scan(slatedb.KeyRange{
+			Start:          bytesPtr([]byte{}),
+			StartInclusive: true,
+		})
+		if err != nil {
+			t.Fatalf("Scan(empty start): %v", err)
+		}
+		t.Cleanup(iter.Destroy)
+		requireRows(t, drainIterator(t, iter), []string{"seed"}, []string{"value"})
+
 		batch := slatedb.NewWriteBatch()
 		t.Cleanup(batch.Destroy)
 		if err := batch.Put([]byte("batch"), []byte("value")); err != nil {
@@ -1426,13 +1433,6 @@ func TestDbReaderBuilderValidationAndErrors(t *testing.T) {
 
 		readerHandle := openTestReader(t, store, nil)
 
-		if _, err := readerHandle.reader.Scan(slatedb.KeyRange{
-			Start:          bytesPtr([]byte{}),
-			StartInclusive: true,
-		}); !errors.Is(err, slatedb.ErrErrorInvalid) {
-			t.Fatalf("DbReader.Scan(empty start): got %v, want invalid error", err)
-		}
-
 		if _, err := readerHandle.reader.Scan(slatedb.KeyRange{
 			Start:          bytesPtr([]byte("z")),
 			StartInclusive: true,
@@ -1441,6 +1441,17 @@ func TestDbReaderBuilderValidationAndErrors(t *testing.T) {
 		}); !errors.Is(err, slatedb.ErrErrorInvalid) {
 			t.Fatalf("DbReader.Scan(start > end): got %v, want invalid error", err)
 		}
+
+		// Scan with empty start bound should succeed and be treated as unbounded start.
+		readerIter, err := readerHandle.reader.Scan(slatedb.KeyRange{
+			Start:          bytesPtr([]byte{}),
+			StartInclusive: true,
+		})
+		if err != nil {
+			t.Fatalf("DbReader.Scan(empty start): %v", err)
+		}
+		t.Cleanup(readerIter.Destroy)
+		requireRows(t, drainIterator(t, readerIter), []string{"seed"}, []string{"value"})
 	})
 }
 
diff --git a/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbDbTest.java b/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbDbTest.java
index 50c5011ff..f4a6ce50a 100644
--- a/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbDbTest.java
+++ b/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbDbTest.java
@@ -295,8 +295,6 @@ void dbInvalidInputs() throws Exception {
 
             TestSupport.awaitFailure(Error.Invalid.class, db.put(new byte[0], TestSupport.bytes("value")));
             TestSupport.awaitFailure(Error.Invalid.class, db.delete(new byte[0]));
-            TestSupport.awaitFailure(
-                    Error.Invalid.class, db.scan(new KeyRange(new byte[0], true, null, false)));
             TestSupport.awaitFailure(
                     Error.Invalid.class,
                     db.scan(
@@ -306,6 +304,16 @@ void dbInvalidInputs() throws Exception {
                                     TestSupport.bytes("a"),
                                     true)));
 
+            // Scan with empty start bound should succeed and be treated as unbounded start.
+            TestSupport.await(db.put(TestSupport.bytes("seed"), TestSupport.bytes("value")));
+            try (DbIterator iterator =
+                    TestSupport.await(db.scan(new KeyRange(new byte[0], true, null, false)))) {
+                TestSupport.assertRows(
+                        TestSupport.drainIterator(iterator),
+                        new String[] {"seed"},
+                        new String[] {"value"});
+            }
+
             try (WriteBatch batch = new WriteBatch()) {
                 batch.put(TestSupport.bytes("batch"), TestSupport.bytes("value"));
                 TestSupport.await(db.write(batch));
diff --git a/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbReaderTest.java b/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbReaderTest.java
index ef447efae..ea75d4fd3 100644
--- a/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbReaderTest.java
+++ b/bindings/java/slatedb-uniffi/src/test/java/io/slatedb/uniffi/SlateDbReaderTest.java
@@ -289,8 +289,6 @@ void readerRejectsInvalidKeyRanges() throws Exception {
 
             try (TestSupport.ManagedReader readerHandle = TestSupport.openReader(store)) {
                 DbReader reader = readerHandle.reader();
-                TestSupport.awaitFailure(
-                        Error.Invalid.class, reader.scan(new KeyRange(new byte[0], true, null, false)));
                 TestSupport.awaitFailure(
                         Error.Invalid.class,
                         reader.scan(
@@ -299,6 +297,15 @@ void readerRejectsInvalidKeyRanges() throws Exception {
                                         true,
                                         TestSupport.bytes("a"),
                                         true)));
+
+                // Scan with empty start bound should succeed and be treated as unbounded start.
+                try (DbIterator iterator =
+                        TestSupport.await(reader.scan(new KeyRange(new byte[0], true, null, false)))) {
+                    TestSupport.assertRows(
+                            TestSupport.drainIterator(iterator),
+                            new String[] {"seed"},
+                            new String[] {"value"});
+                }
             }
         }
     }
diff --git a/bindings/node/tests/db.test.mjs b/bindings/node/tests/db.test.mjs
index 9ad58458e..7743edda3 100644
--- a/bindings/node/tests/db.test.mjs
+++ b/bindings/node/tests/db.test.mjs
@@ -307,16 +307,6 @@ test("db invalid inputs map to typed errors", async (t) => {
     { message: "key cannot be empty" },
   );
 
-  await expectInvalid(
-    () => db.scan({
-      start: bytes(""),
-      start_inclusive: true,
-      end: undefined,
-      end_inclusive: false,
-    }),
-    { message: "range start cannot be empty" },
-  );
-
   await expectInvalid(
     () => db.scan({
       start: bytes("z"),
@@ -336,6 +326,16 @@ test("db invalid inputs map to typed errors", async (t) => {
     }),
     { message: "range must be non-empty" },
   );
+
+  // Scan with empty start bound should succeed and be treated as unbounded start.
+  await db.put(bytes("seed"), bytes("value"));
+  const emptyStartScan = cleanup.track(await db.scan({
+    start: bytes(""),
+    start_inclusive: true,
+    end: undefined,
+    end_inclusive: false,
+  }));
+  requireRows(await drainIterator(emptyStartScan), ["seed"], ["value"]);
 });
 
 test("db writer fencing reports closed reason", async (t) => {
diff --git a/bindings/node/tests/reader.test.mjs b/bindings/node/tests/reader.test.mjs
index 9f1b89cee..3d3a025b9 100644
--- a/bindings/node/tests/reader.test.mjs
+++ b/bindings/node/tests/reader.test.mjs
@@ -343,16 +343,6 @@ test("reader rejects invalid key ranges", async (t) => {
 
   const reader = await openReader(store, { cleanup });
 
-  await expectInvalid(
-    () => reader.scan({
-      start: bytes(""),
-      start_inclusive: true,
-      end: undefined,
-      end_inclusive: false,
-    }),
-    { message: "range start cannot be empty" },
-  );
-
   await expectInvalid(
     () => reader.scan({
       start: bytes("z"),
@@ -372,4 +362,13 @@ test("reader rejects invalid key ranges", async (t) => {
     }),
     { message: "range must be non-empty" },
   );
+
+  // Scan with empty start bound should succeed and be treated as unbounded start.
+  const emptyStartScan = cleanup.track(await reader.scan({
+    start: bytes(""),
+    start_inclusive: true,
+    end: undefined,
+    end_inclusive: false,
+  }));
+  requireRows(await drainIterator(emptyStartScan), ["seed"], ["value"]);
 });
diff --git a/bindings/python/tests/test_db.py b/bindings/python/tests/test_db.py
index ad093b7b4..b383100f8 100644
--- a/bindings/python/tests/test_db.py
+++ b/bindings/python/tests/test_db.py
@@ -271,17 +271,6 @@ async def test_db_invalid_inputs_map_to_typed_errors() -> None:
             await db.delete(b"")
         assert exc.value.message == "key cannot be empty"
 
-        with pytest.raises(Error.Invalid) as exc:
-            await db.scan(
-                KeyRange(
-                    start=b"",
-                    start_inclusive=True,
-                    end=None,
-                    end_inclusive=False,
-                )
-            )
-        assert exc.value.message == "range start cannot be empty"
-
         with pytest.raises(Error.Invalid) as exc:
             await db.scan(
                 KeyRange(
@@ -304,6 +293,13 @@ async def test_db_invalid_inputs_map_to_typed_errors() -> None:
             )
         assert exc.value.message == "range must be non-empty"
 
+        # Scan with empty start bound should succeed and be treated as unbounded start.
+        await db.put(b"seed", b"value")
+        scan = await db.scan(
+            KeyRange(start=b"", start_inclusive=True, end=None, end_inclusive=False)
+        )
+        require_rows(await drain_iterator(scan), ["seed"], ["value"])
+
 
 @pytest.mark.asyncio
 async def test_db_writer_fencing_reports_closed_reason() -> None:
diff --git a/bindings/python/tests/test_reader.py b/bindings/python/tests/test_reader.py
index b8b5c88a2..f7d28e41f 100644
--- a/bindings/python/tests/test_reader.py
+++ b/bindings/python/tests/test_reader.py
@@ -266,17 +266,6 @@ async def test_reader_invalid_ranges_raise_invalid_errors() -> None:
         await db.flush_with_options(FlushOptions(flush_type=FlushType.MEM_TABLE))
 
         async with open_reader(store) as reader:
-            with pytest.raises(Error.Invalid) as exc:
-                await reader.scan(
-                    KeyRange(
-                        start=b"",
-                        start_inclusive=True,
-                        end=None,
-                        end_inclusive=False,
-                    )
-                )
-            assert exc.value.message == "range start cannot be empty"
-
             with pytest.raises(Error.Invalid) as exc:
                 await reader.scan(
                     KeyRange(
@@ -298,3 +287,9 @@ async def test_reader_invalid_ranges_raise_invalid_errors() -> None:
                     )
                 )
             assert exc.value.message == "range must be non-empty"
+
+            # Scan with empty start bound should succeed and be treated as unbounded start.
+            scan = await reader.scan(
+                KeyRange(start=b"", start_inclusive=True, end=None, end_inclusive=False)
+            )
+            require_rows(await drain_iterator(scan), ["seed"], ["value"])