-
Notifications
You must be signed in to change notification settings - Fork 42
RFC for log segmentation #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
6110030
06f6e3d
b8ee4e6
50e0bea
2d9591b
31f14c6
bd44af0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| # RFC 0002: Logical Segmentation | ||
|
|
||
| **Status**: Draft | ||
|
|
||
| **Authors**: | ||
| - Jason Gustafson <12502538+hachikuji@users.noreply.github.com> | ||
|
|
||
| ## Summary | ||
|
|
||
| This RFC introduces logical segmentation as a partitioning mechanism for log data. Segments are logical boundaries in the sequence space that can be triggered by wall-clock time or manual intervention. They enable efficient seeking within the log and provide a foundation for future range-based query APIs. | ||
|
|
||
| ## Motivation | ||
|
|
||
| RFC 0001 defined a log entry key encoding as: | ||
|
|
||
| ``` | ||
| | version | type | key (TerminatedBytes) | sequence | | ||
| ``` | ||
|
|
||
| While this supports efficient scans for a specific key's entire log or a sequence number range, it lacks the ability to efficiently seek to a particular point based on criteria other than sequence number. Consider these use cases: | ||
|
|
||
| 1. **Tail queries**: "Read the last day of data" requires scanning from the beginning to find relevant entries, as there's no mapping from time to sequence number. | ||
|
|
||
| 2. **Time-bounded queries**: "Read entries from 2pm to 4pm yesterday" similarly requires a full scan. | ||
|
|
||
| 3. **Checkpoint recovery**: Restarting a consumer from a time-based checkpoint requires knowing which sequence numbers correspond to that time. | ||
|
|
||
| 4. **Data lifecycle**: Retention policies based on time need to identify which sequence ranges correspond to expired data. | ||
|
|
||
| 5. **Prefix queries**: RFC 0001's key encoding supports prefix-based scans (e.g., all keys under `/sensors/*`), but there's no way to seek within a prefix without knowing the full key. Sequence numbers are global, so scanning `/sensors/*` from sequence 1000 requires knowing which specific keys have entries at or after that sequence. With segments, a prefix scan can seek directly to a segment boundary, skipping earlier data across all matching keys. | ||
|
|
||
| The timeseries implementation solves similar problems using time-based buckets, but for logs, time isn't always the right partitioning dimension. A streaming system processing 1 million events per second has different needs than one processing 100 events per day. | ||
|
|
||
| ## Goals | ||
|
|
||
| - Enable efficient seeking within a log based on pluggable criteria (time, size, etc.) | ||
| - Provide a foundation for time-based retention policies and prefix queries | ||
|
|
||
| ## Non-Goals | ||
|
|
||
| - Defining specific retention policies (left for future RFCs) | ||
| - API design for range and prefix queries (left for future RFCs) | ||
| - Cross-key queries or joins | ||
| - Segment compaction or merging (users control segment granularity via triggers) | ||
|
|
||
| ## Design | ||
|
|
||
| ### Segment Concept | ||
|
|
||
| A **segment** is a logical boundary in the log's sequence space. Each segment represents a contiguous range of sequence numbers and carries metadata about its contents. Segments enable readers to efficiently skip portions of the log that don't match their query criteria. | ||
|
|
||
| Key properties of segments: | ||
|
|
||
| 1. **Sequential**: Segments are numbered starting from 0 and increment monotonically | ||
| 2. **Non-overlapping**: Each sequence number belongs to exactly one segment | ||
| 3. **Immutable once sealed**: A completed segment's boundaries and metadata don't change | ||
| 4. **Sparse in sequence space**: Segment boundaries don't need to align with sequence allocation blocks | ||
| 5. **Atomic batch placement**: Each write batch lands entirely within a single segment; batches never span segment boundaries | ||
|
|
||
| ### Key Encoding with Segments | ||
|
|
||
| The log entry key format is extended to include the segment ID: | ||
|
|
||
| ``` | ||
| Log Entry: | ||
| | version (u8) | type (u8) | segment_id (u64 BE) | key (TerminatedBytes) | sequence (u64 BE) | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need a full u64 for segment id? u32 would allow 4B segments. adding this to every key seems pretty significant for a log structure. There are even varint encodings that maintain lexicographical ordering (see https://github.com/khonsulabs/ordered-varint for example, which we could easily vendor)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. alternatively, we can make sequence a u32 and make the total ordering segment + sequence so that we have u64 in total (sequences are unique within a segment)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree 8 bytes is a lot. Another idea I was considering is to add an attribute flag packed into the record type to specify the segment_id size. We could use 4 bytes by default, but switch to 8 bytes if needed. We probably wouldn't need to implement it until we need it. Just need to know it is possible.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do like the idea to consider segment + sequence as a composite. One thing that bothers me a little bit is that we would have to bump when the u32 sequence is exhausted. Perhaps that's not a big deal, but I did kind of like the idea of representing "semantic segments" as we have with timeseries (each bucket represents an hour of time). It would be annoying to have to handle overflow segments. Perhaps we could consider using varlength u64 for the sequence number which always resets to 0? So u32 segment_id and varlength u64 sequence. This option wasn't possible until we had the fancypants delimiter for the key.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the concern the storage overhead? I would imagine that gets optimized away pretty well by the prefix encoding in the SSTs
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that + memory overhead, I guess prefix compression would basically eliminate storage overhead.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I implemented something close to what we discussed. First, I switched to a u32 segment encoding, Second, I changed the sequence number to be a varlength u64 which is defined relative to the base sequence from the SegmentMetadata.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's perfect since we'll get great prefix compression from the segment in slate but actually pretty bad compression from the sequence number so this may turn out to be better overall than the original design 👍 |
||
| ``` | ||
|
|
||
| This encoding ensures: | ||
|
|
||
| - All entries within a segment are contiguous in storage | ||
| - Within a segment, entries are grouped by key | ||
| - Within a key, entries are ordered by sequence number | ||
| - Prefix scans can be constrained to specific segments | ||
|
|
||
| ### Segment Metadata | ||
|
|
||
| Each segment has associated metadata stored in a separate record: | ||
|
|
||
| ``` | ||
| SegmentMeta Record: | ||
| Key: | version (u8) | type (u8=0x03) | segment_id (u64 BE) | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. one thought: does it make sense to include the notion of a segment key and a segment id, and use the id as the key for internally generated segments? The idea being that when we support user-generated segments they can select their own keys that can be looked up later
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a nice idea. Do you think this could be added through a version bump later? Another interesting idea I was considering is to allow segments to be defined at prefix/range granularity. That might open the door to enforcing different retention semantics for different keys. I do think there might an interesting application design space here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think these are neat ideas but I'm +1 to keeping things as simple as possible and only tinroducing those concepts if necessary later to avoid complicating things
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, sgtm |
||
| Value: | start_seq (u64 BE) | start_time_ms (i64 BE) | user_meta_len (u32 BE) | user_meta (bytes) | | ||
| ``` | ||
|
|
||
| The metadata tracks: | ||
| - **start_seq**: The first sequence number in this segment | ||
| - **start_time_ms**: Wall-clock time when the segment was created | ||
| - **user_meta**: Optional user-defined metadata (empty if `user_meta_len` is 0) | ||
|
|
||
| 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. | ||
|
|
||
| #### Metadata Lifecycle | ||
|
|
||
| Segment metadata is written in two phases: | ||
|
|
||
| 1. **On open**: When a new segment is created, a `SegmentMeta` record is written with `start_seq`, `start_time_ms`, and empty `user_meta`. This ensures the segment is immediately discoverable. | ||
|
|
||
| 2. **On seal**: When `seal_segment()` is called with user metadata, the `SegmentMeta` record is overwritten to include the user-provided bytes. If no user metadata is provided, the record is left unchanged. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we probably need to track the current segment id and increment it somewhere, right?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can just read the metadata in reverse order to find the last segment.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kind of expected we would keep the segment metadata in memory. It's tiny.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. makes sense, I had a brain fart that we could just compute it from the latest SST last key on restart |
||
|
|
||
| User metadata is limited to 64 KiB. This limit is enforced by the API and provides ample space for typical use cases (checksums, correlation IDs, configuration snapshots) while preventing abuse that could impact segment iteration performance. | ||
|
|
||
| ### Segment Triggers | ||
|
|
||
| Segments are "bumped" (a new segment is started) either automatically or manually. | ||
|
|
||
| #### Time-Based Trigger | ||
|
|
||
| The built-in trigger starts a new segment after a configurable wall-clock duration. Example: With a 1-hour interval, a new segment starts every hour. This provides predictable time-based partitioning similar to timeseries buckets. | ||
|
|
||
| Time-based triggering is simple to implement because it only requires comparing the current wall-clock time against the segment's `start_time_ms`. No internal state tracking is needed beyond what's already stored in the segment metadata. | ||
|
|
||
| #### Manual Trigger | ||
|
hachikuji marked this conversation as resolved.
Outdated
|
||
|
|
||
| Applications can explicitly seal segments and attach user-defined metadata: | ||
|
|
||
| ```rust | ||
| impl Log { | ||
| /// Seals the current segment and starts a new one. | ||
| /// | ||
| /// # Arguments | ||
| /// * `user_meta` - Optional user-defined metadata to attach to the sealed segment | ||
| /// | ||
| /// # Returns | ||
| /// The sealed segment ID | ||
| async fn seal_segment(&self, user_meta: Option<Bytes>) -> Result<u64, Error>; | ||
|
|
||
| /// Returns an iterator over all segments in the log. | ||
| /// | ||
| /// Segments are returned in order from oldest (segment 0) to newest. | ||
| /// Each segment includes its metadata (start sequence, start time, user metadata). | ||
| fn segments(&self) -> SegmentIterator; | ||
| } | ||
| ``` | ||
|
|
||
| Manual sealing enables use cases such as: | ||
| - Creating checkpoint boundaries aligned with application logic | ||
| - Attaching metadata accumulated during writes (checksums, record counts, correlation IDs) | ||
| - Aligning segments with external events (e.g., end of business day) | ||
|
|
||
| #### Future: Size-Based Triggers | ||
|
|
||
| Size-based triggers (e.g., bump after N entries or N bytes) require tracking entry counts or byte sizes, which adds complexity. Once the `count` API from RFC 0001 is implemented, size-based triggers could leverage it. This is deferred to a future RFC. | ||
|
|
||
| #### Future: Segment-Based Deletion | ||
|
|
||
| Segments provide a natural unit for data lifecycle management. A future RFC will define APIs for deleting entire segments, enabling efficient retention policies. Rather than scanning and deleting individual entries, retention can be enforced by dropping segments older than a threshold. This aligns with how the `segments()` iterator exposes segment boundaries—applications can iterate segments, inspect their `start_time_ms`, and delete those that have expired. | ||
|
|
||
| ### Configuration | ||
|
|
||
| Segment configuration is part of the main `Config` struct: | ||
|
|
||
| ```rust | ||
| struct Config { | ||
| storage: StorageConfig, | ||
| segmentation: SegmentConfig, | ||
| } | ||
|
|
||
| struct SegmentConfig { | ||
| /// Interval for automatic segment sealing based on wall-clock time. | ||
| /// If `None`, automatic sealing is disabled and segments only advance | ||
| /// via manual `seal_segment()` calls. | ||
| /// | ||
| /// Default: `None` (disabled) | ||
| auto_seal_interval: Option<Duration>, | ||
| } | ||
| ``` | ||
|
|
||
| With the default configuration (`auto_seal_interval: None`), the log writes to segment 0 indefinitely. Users who want time-based partitioning can enable it by setting an interval. This keeps the default behavior simple while allowing opt-in to automatic segment management. | ||
|
|
||
| ## Alternatives | ||
|
hachikuji marked this conversation as resolved.
|
||
|
|
||
| ### Time in the Key (No Segments) | ||
|
|
||
| An alternative is to embed timestamp directly in the key: | ||
|
|
||
| ``` | ||
| | version | type | timestamp (i64) | key (TerminatedBytes) | sequence | | ||
| ``` | ||
|
|
||
| Rejected because: | ||
| - Forces time-based ordering as the primary dimension | ||
| - Doesn't support size-based or manual partitioning | ||
| - Complicates key-based access patterns (all times for a key are scattered) | ||
|
|
||
| ### Fixed-Size Segments | ||
|
|
||
| Using fixed sequence ranges per segment (e.g., 1M sequences each): | ||
|
|
||
| ``` | ||
| segment_id = sequence / 1_000_000 | ||
| ``` | ||
|
|
||
| Rejected because: | ||
| - No flexibility for different workloads | ||
| - Time-based queries still require scanning segment metadata | ||
| - Doesn't naturally align with time boundaries | ||
|
|
||
| ## Open Questions | ||
|
|
||
| None at this time. | ||
|
|
||
| ## Updates | ||
|
|
||
| | Date | Description | | ||
| |------------|-------------| | ||
| | 2026-01-07 | Initial draft | | ||

Uh oh!
There was an error while loading. Please reload this page.