Skip to content

Commit 8cf5e64

Browse files
authored
Log: integration with slatedb segments (opendata-oss#450)
This patch integrates LogDb with SlateDb segmentation. Updates the key structure for prefix-based segments. Also updates the common key prefix RFC to remove the record tag field, which is incompatible with prefix-based segmentation.
1 parent ce10c4f commit 8cf5e64

14 files changed

Lines changed: 1106 additions & 474 deletions

log/rfcs/0002-logical-segmentation.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,22 @@ Key properties of segments:
4646

4747
### Key Encoding with Segments
4848

49-
The log entry key format is extended to include the segment ID:
49+
Every log storage key — log entries, listings, segment metadata, and the
50+
global sequence block — shares a 7-byte segmented prefix:
51+
52+
```
53+
| subsystem (u8) | version (u8) | segment_id (u32 BE) | record_type (u8) | ... record-specific fields ... |
54+
```
55+
56+
Placing `segment_id` before `record_type` keeps every record for a given
57+
segment contiguous in storage regardless of record type. This is the
58+
property a SlateDB segment extractor relies on: with a fixed 6-byte prefix
59+
`[subsystem, version, segment_id]`, all records for a segment land in the
60+
same SlateDB segment.
5061

5162
```
5263
Log Entry:
53-
| version (u8) | type (u8) | segment_id (u32 BE) | key (TerminatedBytes) | relative_seq (varint u64) |
64+
| sub | ver | segment_id (u32 BE) | record_type=0x10 | key (TerminatedBytes) | relative_seq (varint u64) |
5465
```
5566

5667
The `segment_id` is a 32-bit identifier, allowing up to ~4 billion segments. Future versions can expand this to 64 bits if needed.
@@ -64,13 +75,33 @@ This encoding ensures:
6475
- Within a key, entries are ordered by sequence number
6576
- Prefix scans can be constrained to specific segments
6677

78+
### The system segment
79+
80+
Segment id `0` is reserved as the **system segment**: it never holds user
81+
log entries. The records that aren't bound to a specific user segment live
82+
there, sharing the same 7-byte prefix layout (with `segment_id = 0`):
83+
84+
- **`SeqBlock`** (`record_type = 0x20`): the global block-based sequence
85+
allocator's persisted state. There is exactly one record.
86+
- **`SegmentMeta`** (`record_type = 0x30`): per-segment metadata describing
87+
user segments. The described segment id is encoded as a 4-byte suffix
88+
after `record_type`, so all `SegmentMeta` records sit in a contiguous
89+
range and can be scanned with a single prefix scan.
90+
91+
User log segments are numbered from `1`. A custom SlateDB compactor can
92+
recognize the system segment by its `segment_id = 0` prefix and exempt it
93+
from any retention-driven drains, while still allowing regular user
94+
segments to be retired via `CompactionSpec::DrainSegment` and pruned from
95+
the manifest.
96+
6797
### Segment Metadata
6898

69-
Each segment has associated metadata stored in a separate record:
99+
Each user segment has associated metadata stored as a single record in the
100+
system segment. The described segment's id is the suffix:
70101

71102
```
72103
SegmentMeta Record:
73-
Key: | version (u8) | type (u8=0x03) | segment_id (u32 BE) |
104+
Key: | sub | ver | segment_id=0 (u32 BE) | record_type=0x30 | described_segment_id (u32 BE) |
74105
Value: | start_seq (u64 BE) | start_time_ms (i64 BE) |
75106
```
76107

@@ -80,6 +111,14 @@ The metadata tracks:
80111

81112
End boundaries (end sequence, end time) are derived from the next segment's start values, or from the current log state for the active segment.
82113

114+
Living in the system segment decouples metadata from the per-segment
115+
SlateDB lifecycle: when a user segment is drained and pruned from the
116+
SlateDB manifest, the `SegmentMeta` record for it is not automatically
117+
removed and must be deleted by the writer as it observes the manifest
118+
update. The exact cleanup mechanism is out of scope for this RFC and is
119+
called out under [Segment-Based Deletion](#segment-based-deletion) as
120+
future work.
121+
83122
#### Metadata Lifecycle
84123

85124
When a new segment is created, a `SegmentMeta` record is written with `start_seq` and `start_time_ms`. This ensures the segment is immediately discoverable.
@@ -196,3 +235,4 @@ This would enable use cases such as:
196235
|------------|-------------|
197236
| 2026-01-07 | Initial draft |
198237
| 2026-01-12 | Added link to varint implementation |
238+
| 2026-05-19 | Key format v2: `segment_id` precedes `record_type`; system segment (id 0) reserved for `SeqBlock` and `SegmentMeta` records; user segments start at id 1. Enables a fixed 6-byte SlateDB segment-extractor prefix that routes every per-segment record (entries + listings) to the same SlateDB segment. |

log/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ mod model;
5555
mod range;
5656
mod reader;
5757
mod segment;
58+
mod segment_extractor;
5859
mod serde;
5960
#[cfg(feature = "http-server")]
6061
pub mod server;

log/src/listing.rs

Lines changed: 54 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -153,19 +153,10 @@ mod tests {
153153
.unwrap();
154154
}
155155

156-
#[storage_test]
157-
async fn should_return_empty_for_empty_range(storage: Arc<dyn Storage>) {
158-
// when
159-
let keys = storage.list_keys(0..0).await.unwrap();
160-
161-
// then
162-
assert!(keys.is_empty());
163-
}
164-
165156
#[storage_test]
166157
async fn should_return_empty_when_no_listing_entries(storage: Arc<dyn Storage>) {
167158
// when
168-
let keys = storage.list_keys(0..10).await.unwrap();
159+
let keys = storage.list_keys_in_segment(1).await.unwrap();
169160

170161
// then
171162
assert!(keys.is_empty());
@@ -174,12 +165,17 @@ mod tests {
174165
#[storage_test]
175166
async fn should_iterate_keys_in_single_segment(storage: Arc<dyn Storage>) {
176167
// given
177-
write_listing_entry(&*storage, 0, b"key-a").await;
178-
write_listing_entry(&*storage, 0, b"key-b").await;
179-
write_listing_entry(&*storage, 0, b"key-c").await;
168+
write_listing_entry(&*storage, 1, b"key-a").await;
169+
write_listing_entry(&*storage, 1, b"key-b").await;
170+
write_listing_entry(&*storage, 1, b"key-c").await;
180171

181172
// when
182-
let keys: Vec<Bytes> = storage.list_keys(0..1).await.unwrap().into_iter().collect();
173+
let keys: Vec<Bytes> = storage
174+
.list_keys_in_segment(1)
175+
.await
176+
.unwrap()
177+
.into_iter()
178+
.collect();
183179

184180
// then - keys returned in lexicographic order
185181
assert_eq!(keys.len(), 3);
@@ -189,60 +185,66 @@ mod tests {
189185
}
190186

191187
#[storage_test]
192-
async fn should_iterate_keys_across_multiple_segments(storage: Arc<dyn Storage>) {
193-
// given
194-
write_listing_entry(&*storage, 0, b"key-a").await;
195-
write_listing_entry(&*storage, 1, b"key-b").await;
196-
write_listing_entry(&*storage, 2, b"key-c").await;
188+
async fn should_isolate_listings_to_their_segment(storage: Arc<dyn Storage>) {
189+
// given — listing entries in multiple segments
190+
write_listing_entry(&*storage, 1, b"in-segment-1").await;
191+
write_listing_entry(&*storage, 2, b"in-segment-2").await;
197192

198193
// when
199-
let keys = storage.list_keys(0..3).await.unwrap();
200-
201-
// then
202-
assert_eq!(keys.len(), 3);
194+
let seg1 = storage.list_keys_in_segment(1).await.unwrap();
195+
let seg2 = storage.list_keys_in_segment(2).await.unwrap();
196+
197+
// then — each segment scan returns only its own listing entries
198+
assert_eq!(seg1.len(), 1);
199+
assert!(seg1.contains(&Bytes::from("in-segment-1")));
200+
assert_eq!(seg2.len(), 1);
201+
assert!(seg2.contains(&Bytes::from("in-segment-2")));
203202
}
204203

205204
#[storage_test]
206-
async fn should_deduplicate_keys_across_segments(storage: Arc<dyn Storage>) {
207-
// given - same key in multiple segments
208-
write_listing_entry(&*storage, 0, b"shared-key").await;
209-
write_listing_entry(&*storage, 1, b"shared-key").await;
210-
write_listing_entry(&*storage, 2, b"shared-key").await;
205+
async fn should_skip_log_entries_when_scanning_listings(storage: Arc<dyn Storage>) {
206+
use crate::segment::LogSegment;
207+
use crate::serde::LogEntryKey;
208+
use crate::serde::SegmentMeta;
209+
210+
// given — listing records alongside log entries in the same segment
211+
let segment_id = 1u32;
212+
let segment = LogSegment::new(segment_id, SegmentMeta::new(0, 1000));
213+
write_listing_entry(&*storage, segment_id, b"alpha").await;
214+
// Write a log entry that would lex-sort before listings (tag 0x10 < 0x40).
215+
let entry_key = LogEntryKey::new(segment_id, Bytes::from("alpha"), 1).serialize(0);
216+
storage
217+
.put_with_options(
218+
vec![common::Record::new(entry_key, Bytes::from("v")).into()],
219+
common::WriteOptions::default(),
220+
)
221+
.await
222+
.unwrap();
223+
// Anchor used for the segment metadata so deserialization is sane.
224+
let _ = segment;
211225

212226
// when
213-
let keys = storage.list_keys(0..3).await.unwrap();
227+
let keys = storage.list_keys_in_segment(segment_id).await.unwrap();
214228

215-
// then - only one instance of the key
229+
// then — listing scan only sees listing records, not log entries
216230
assert_eq!(keys.len(), 1);
217-
assert!(keys.contains(&Bytes::from("shared-key")));
218-
}
219-
220-
#[storage_test]
221-
async fn should_respect_segment_range(storage: Arc<dyn Storage>) {
222-
// given
223-
write_listing_entry(&*storage, 0, b"key-0").await;
224-
write_listing_entry(&*storage, 1, b"key-1").await;
225-
write_listing_entry(&*storage, 2, b"key-2").await;
226-
write_listing_entry(&*storage, 3, b"key-3").await;
227-
228-
// when - only query segments 1..3
229-
let keys: Vec<Bytes> = storage.list_keys(1..3).await.unwrap().into_iter().collect();
230-
231-
// then - only keys from segments 1 and 2
232-
assert_eq!(keys.len(), 2);
233-
assert_eq!(keys[0], Bytes::from("key-1"));
234-
assert_eq!(keys[1], Bytes::from("key-2"));
231+
assert!(keys.contains(&Bytes::from("alpha")));
235232
}
236233

237234
#[storage_test]
238235
async fn should_return_keys_in_lexicographic_order(storage: Arc<dyn Storage>) {
239236
// given - keys inserted out of order
240-
write_listing_entry(&*storage, 0, b"zebra").await;
241-
write_listing_entry(&*storage, 0, b"apple").await;
242-
write_listing_entry(&*storage, 0, b"mango").await;
237+
write_listing_entry(&*storage, 1, b"zebra").await;
238+
write_listing_entry(&*storage, 1, b"apple").await;
239+
write_listing_entry(&*storage, 1, b"mango").await;
243240

244241
// when
245-
let keys: Vec<Bytes> = storage.list_keys(0..1).await.unwrap().into_iter().collect();
242+
let keys: Vec<Bytes> = storage
243+
.list_keys_in_segment(1)
244+
.await
245+
.unwrap()
246+
.into_iter()
247+
.collect();
246248

247249
// then - keys returned in lexicographic order
248250
assert_eq!(keys[0], Bytes::from("apple"));

0 commit comments

Comments
 (0)