Skip to content
1 change: 1 addition & 0 deletions sei-db/db_engine/litt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ type Table interface {
Put(key []byte, value []byte, secondaryKeys ...*types.SecondaryKey) error
PutBatch(batch []*types.PutRequest) error
Get(key []byte) ([]byte, bool, error)
GetSubrange(key []byte, offset uint32, length uint32) ([]byte, bool, error)
Exists(key []byte) (bool, error)
Flush() error
Size() uint64
Expand Down
68 changes: 68 additions & 0 deletions sei-db/db_engine/litt/dbcache/cached_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,74 @@ func (c *cachedTable) Get(key []byte) (value []byte, exists bool, err error) {
return value, exists, nil
}

// GetSubrange reads only the [offset, offset+length) byte range of the value stored under key.
//
// Cache policy — subrange reads consult both caches, but never populate them:
//
// - Populating would be unsafe. Both caches are keyed by the full key and hold the full value, so
// storing a sub-range under that key would hand a later Get a partial value it is entitled to treat
// as the whole thing.
// - Consulting is safe, because every path agrees that a key's offsets are relative to that key's own
// logical value. Put stores value[sk.Offset:sk.Offset+sk.Length] under a secondary key, the base table
// addresses that secondary at the same aliased region on disk, and readCache holds whatever Get
// returned. A cache hit therefore slices exactly the bytes the base table would have read.
// - Consulting is also strictly cheaper. A hit means the value is already materialized in memory, so
// slicing it costs no I/O at all, where the base table would pay a keymap lookup plus a disk read.
// Bypassing the caches would make GetSubrange slower than plain Get for precisely the hot,
// recently-written values a subrange read is meant to serve.
//
// Because hits come from the same caches Get uses, the two agree on whether a key exists — including for
// a key already reclaimed from the base table by GC or TTL expiry but still resident in a cache.
//
// Caching sub-ranges themselves, under specially structured keys, would speed up misses as well, but that
// adds complexity we do not yet know we need. Revisit if profiling shows subrange misses are hot enough to
// justify it.
func (c *cachedTable) GetSubrange(key []byte, offset uint32, length uint32) (value []byte, exists bool, err error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The consult-but-never-populate policy is well argued for safety, but note the perf consequence for the motivating workload: extracting N transactions from one block on a cache-enabled table costs N keymap lookups + N file opens, where a single Get + N in-memory slices would cost one. So GetSubrange is pessimal exactly where the caller reads several ranges from the same value. Moot today (DefaultTableConfig sets both cache sizes to 0), and the doc already flags subrange-keyed caching as future work — but worth a line in that comment saying multi-range callers should prefer one Get while the caches can't help them, so the follow-up producer doesn't loop this per tx.

// hot tracks whether the value was served from one of this table's caches (a "hot" read) for metrics.
var hot bool
if c.metrics != nil {
start := time.Now()
defer func() {
if exists && value != nil {
c.metrics.ReportReadOperation(c.Name(), time.Since(start), uint64(len(value)), hot)
}
}()
}

stringKey := util.UnsafeBytesToString(key)

if cached, ok := c.writeCache.Get(stringKey); ok {
// The value was recently written.
hot = true
return subrangeOf(cached, offset, length)
}
if cached, ok := c.readCache.Get(stringKey); ok {
// The value was recently read in full.
hot = true
return subrangeOf(cached, offset, length)
}

value, exists, err = c.base.GetSubrange(key, offset, length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This fall-through (and the exists == false return from the base table) is never exercised through the cached wrapper by the new tests. In TestGetSubrangeParity the cached builders create a 500-weight write cache that Put fills with the 19-byte value, and cachedTable.Flush only delegates to base.Flush() — nothing evicts — so every read in that test, before and after flush, is a write-cache hit. TestGetSubrangeServedFromCache deliberately tests hits only. Since DefaultTableConfig sets both cache sizes to 0, this miss path is precisely the production path through the wrapper. Worth adding a case with a zero/tiny-weight cache (or a key that was never written) so the base delegation and the not-found return are covered end-to-end.

This comment was marked as low quality.

if err != nil {
return value, exists, fmt.Errorf("failed to get subrange from base table: %w", err)
}
return value, exists, nil
}

// subrangeOf slices a cached full value, applying the same bounds check (and reporting it the same way)
// as the base table, so a cache hit and a cache miss are indistinguishable to the caller.
func subrangeOf(value []byte, offset uint32, length uint32) ([]byte, bool, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This bounds check + capped three-index slice is now duplicated verbatim in three places (here, DiskTable.GetSubrange, and the compressed branch of Segment.ReadSubrange), including the identical error string. The parity property TestGetSubrangeParity pins depends on all three staying byte-identical, so extracting one exported helper (e.g. in litt/util) would make drift impossible rather than merely tested-against.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Two small things here:

  1. The out-of-bounds error from this helper is returned to the caller unwrapped (line 155/160: return subrangeOf(...)), while a base-table error gets wrapped with "failed to get subrange from base table" (line 165). Since the identical error text is also produced by DiskTable.GetSubrange and Segment.ReadSubrange, an operator reading a log line can't tell which of the three layers rejected the range. Wrapping the cache-hit path with something like "cached value:" would disambiguate at no cost.

  2. The doc contract in table.go:104 promises a zero-length range "returns an empty (non-nil) slice when the key exists". That holds for every production path (PutBatch rejects nil values at disk_table.go:1049, and the disk path allocates via make), but this helper would return nil for a nil cached value — which is exactly the shape TestGetSubrangeServedFromCache constructs by seeding the cache directly. Not reachable through Put today; noting it in case the seeding pattern gets reused.

end := uint64(offset) + uint64(length)
if end > uint64(len(value)) {
return nil, false, fmt.Errorf(
"subrange [%d, %d) is out of bounds for value of length %d", offset, end, len(value))
}
// Capped (three-index) slice: without it the sub-range would carry spare capacity reaching into the
// rest of the cached value, and an append by the caller would silently overwrite the bytes that
// follow — corrupting the entry for every later reader. Capping forces such an append to allocate.
return value[offset:end:end], true, nil
}

func (c *cachedTable) Exists(key []byte) (exists bool, err error) {
_, exists = c.writeCache.Get(util.UnsafeBytesToString(key))
if exists {
Expand Down
54 changes: 54 additions & 0 deletions sei-db/db_engine/litt/disktable/compression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,60 @@ func TestCompressionFullValueAliasSecondary(t *testing.T) {
verify("after flush")
}

// TestGetSubrangeCompressed proves GetSubrange stays correct on a compressed table: since a compressed
// on-disk blob cannot be sliced, Segment.ReadSubrange falls back to reading and decompressing the whole
// value and then slicing the requested range out of the plaintext (see Segment.ReadSubrange). This checks
// that fallback for both a compressible value (stored S2-tagged) and an incompressible one (stored
// CompressionNone-tagged on the same compressed segment), before and after flush.
func TestGetSubrangeCompressed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
table := buildCompressedMemKeyDiskTable(t, time.Now, "subrange-compressed", []string{dir}, types.CompressionS2)
defer func() { require.NoError(t, table.Close()) }()

compressible := compressiblePayload()
incompressible := incompressiblePayload()
require.NoError(t, table.Put([]byte("compressible"), compressible))
require.NoError(t, table.Put([]byte("incompressible"), incompressible))

verifyKey := func(stage string, key string, value []byte) {
t.Helper()
n := uint32(len(value))

// Full range.
got, ok, err := table.GetSubrange([]byte(key), 0, n)
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.Equal(t, value, got, stage)

// Prefix, middle, suffix, and a zero-length read.
for _, r := range []struct{ off, length uint32 }{
{0, 10},
{n / 2, 10},
{n - 10, 10},
{n / 2, 0},
} {
got, ok, err := table.GetSubrange([]byte(key), r.off, r.length)
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.Equal(t, value[r.off:r.off+r.length], got, stage)
}

// Out of bounds is still an error against the decompressed (logical) length.
_, _, err = table.GetSubrange([]byte(key), n-1, 10)
require.Error(t, err, stage)
}

verify := func(stage string) {
verifyKey(stage, "compressible", compressible)
verifyKey(stage, "incompressible", incompressible)
}

verify("before flush")
require.NoError(t, table.Flush())
verify("after flush")
}

func TestCompressionRejectsSubRangeSecondary(t *testing.T) {
t.Parallel()
dir := t.TempDir()
Expand Down
47 changes: 47 additions & 0 deletions sei-db/db_engine/litt/disktable/disk_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,53 @@ func (d *DiskTable) Get(key []byte) (value []byte, exists bool, err error) {
return data, true, nil
}

// GetSubrange reads only the [offset, offset+length) byte range of the value stored under key. See the
// litt.Table.GetSubrange contract; this is the base implementation that reaches keymap + disk.
func (d *DiskTable) GetSubrange(key []byte, offset uint32, length uint32) (value []byte, exists bool, err error) {
if ok, err := d.errorMonitor.IsOk(); !ok {
return nil, false, fmt.Errorf(
"cannot process GetSubrange() request, DB is in panicked state due to error: %w", err)
}

// Data not yet flushed lives in memory as the full value; slice the requested range out of it.
if v, ok := d.unflushedDataCache.Load(util.UnsafeBytesToString(key)); ok {
full := v.([]byte)
end := uint64(offset) + uint64(length)
if end > uint64(len(full)) {
return nil, false, fmt.Errorf(
"subrange [%d, %d) is out of bounds for value of length %d", offset, end, len(full))
}
// Capped (three-index) slice: without it the sub-range would carry spare capacity reaching into
// the rest of the live unflushed value, and an append by the caller would silently overwrite the
// bytes that follow. Capping forces such an append to allocate instead.
return full[offset:end:end], true, nil
}

// Look up the address of the data.
address, ok, err := d.keymap.Get(key)
if err != nil {
return nil, false, fmt.Errorf("failed to get address: %w", err)
}
if !ok {
return nil, false, nil
}

// Reserve the segment that contains the data.
seg, ok := d.controlLoop.getReservedSegment(address.Index())
if !ok {
return nil, false, nil
}
defer seg.Release()

// Read only the requested byte range from disk.
data, err := seg.ReadSubrange(key, address, offset, length)
if err != nil {
return nil, false, fmt.Errorf("failed to read data subrange: %w", err)
}

return data, true, nil
}

func (d *DiskTable) Put(key []byte, value []byte, secondaryKeys ...*types.SecondaryKey) error {
return d.PutBatch([]*types.PutRequest{{Key: key, Value: value, SecondaryKeys: secondaryKeys}})
}
Expand Down
97 changes: 97 additions & 0 deletions sei-db/db_engine/litt/disktable/disk_table_subrange_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package disktable

import (
"testing"
"time"

"github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/types"
"github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util"
"github.com/stretchr/testify/require"
)

// TestGetSubrange exercises the GetSubrange read path across every disk-table implementation, both before
// a flush (served from the in-memory unflushed data cache) and after (served from the keymap + a bounded
// segment read). It checks that a sub-range read returns exactly value[offset:offset+length], that it
// composes with secondary keys (which alias a sub-range of the value), that a zero-length read is valid,
// that an out-of-bounds range errors, and that a missing key reports not-found without an error.
func TestGetSubrange(t *testing.T) {
t.Parallel()
for _, tb := range tableBuilders {
t.Run(tb.name, func(t *testing.T) {
t.Parallel()
rand := util.NewTestRandom()
directory := t.TempDir()
tableName := rand.String(8)
table, err := tb.builder(time.Now, tableName, []string{directory})
require.NoError(t, err)

// 0 1 2 3 4
// 0123456789012345678901234567890123456789012
value := []byte("the quick brown fox jumps over the lazy dog")
primary := []byte("primary")
// A secondary aliasing the strict sub-range "brown fox", to prove GetSubrange composes with a
// secondary key (its address already points at a sub-range of the value's bytes).
sk := &types.SecondaryKey{Key: []byte("brown-fox"), Offset: 10, Length: 9}
require.NoError(t, table.Put(primary, value, sk))

valueLen := uint32(len(value))

verify := func(stage string) {
t.Helper()

// The full range equals a plain Get.
got, ok, err := table.GetSubrange(primary, 0, valueLen)
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.Equal(t, value, got, stage)

// Assorted sub-ranges, including zero-length reads in the middle and at the very end.
ranges := []struct{ off, length uint32 }{
{0, 3}, // "the"
{4, 5}, // "quick"
{valueLen - 3, 3}, // "dog"
{10, 0}, // zero-length in the middle
{valueLen, 0}, // zero-length at the very end
}
for _, r := range ranges {
got, ok, err := table.GetSubrange(primary, r.off, r.length)
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.NotNil(t, got, stage)
require.Equal(t, value[r.off:r.off+r.length], got, stage)
// The sub-range may alias the whole value in memory, so its capacity must stop at its
// length: otherwise an append by the caller would overwrite the bytes that follow.
require.Equal(t, len(got), cap(got), stage)
}

// A sub-range read of a secondary key stays within the secondary's aliased region.
got, ok, err = table.GetSubrange(sk.Key, 0, sk.Length)
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.Equal(t, value[sk.Offset:sk.Offset+sk.Length], got, stage)

got, ok, err = table.GetSubrange(sk.Key, 6, 3) // "fox" within "brown fox"
require.NoError(t, err, stage)
require.True(t, ok, stage)
require.Equal(t, value[sk.Offset+6:sk.Offset+6+3], got, stage)

// A range that runs past the end of the value is an error.
_, _, err = table.GetSubrange(primary, valueLen-1, 5)
require.Error(t, err, stage)
_, _, err = table.GetSubrange(primary, valueLen+1, 0)
require.Error(t, err, stage)

// A missing key reports not-found with no error.
_, ok, err = table.GetSubrange([]byte("does-not-exist"), 0, 1)
require.NoError(t, err, stage)
require.False(t, ok, stage)
}

verify("before flush")
require.NoError(t, table.Flush())
verify("after flush")

require.NoError(t, table.Drop())
})
}
}
54 changes: 54 additions & 0 deletions sei-db/db_engine/litt/disktable/segment/segment.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,60 @@ func (s *Segment) Read(key []byte, dataAddress types.Address) ([]byte, error) {
return s.maybeDecompress(value)
}

// ReadSubrange fetches only the [offset, offset+length) byte range of the value identified by dataAddress,
// avoiding a read of the full value.
//
// For an uncompressed segment this issues a bounded read of exactly length bytes, seeking to the value's
// on-disk offset plus the requested sub-offset — so the I/O cost scales with length, not the full value.
// For a compressed segment the on-disk blob is a single compressed unit that cannot be sliced, so this
// falls back to reading and decompressing the whole value (via Read) and then slicing out the requested
// range. Either way the returned bytes are the plaintext value[offset:offset+length].
//
// The requested range must lie within the value; otherwise an error is returned.
//
// It is only thread safe to read from a segment if the key being read has previously been flushed to disk.
func (s *Segment) ReadSubrange(key []byte, dataAddress types.Address, offset uint32, length uint32) ([]byte, error) {
// A compressed value cannot be partially read from disk: decompress the whole thing, then slice. The
// bounds check is against the decompressed length, since dataAddress.ValueSize() is the compressed size.
if s.IsCompressed() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The compressed fallback is correct, but note the cost profile for the intended use case: reading N transactions out of one block means N full reads + N decompressions of the same value, i.e. strictly worse than one Get plus N slices. The interface doc says "without the I/O savings", which undersells it. Worth stating in the litt.Table.GetSubrange doc that a caller extracting multiple ranges from the same value on a compressed table should use Get and slice instead — or add a GetSubranges batch entry point when the receipt-side producer lands.

value, err := s.Read(key, dataAddress)
if err != nil {
return nil, err
}
end := uint64(offset) + uint64(length)
if end > uint64(len(value)) {
return nil, fmt.Errorf("subrange [%d, %d) is out of bounds for value of length %d",
offset, end, len(value))
}
// Capped (three-index) slice, so that every GetSubrange path returns a sub-range whose capacity
// stops at its length. The decompression buffer sliced here is not shared with another reader
// today, but keeping the guarantee uniform means callers never have to know which path served
// them before deciding whether an append is safe.
return value[offset:end:end], nil
}

// For an uncompressed value, dataAddress.ValueSize() is the exact value length, so we can bound the
// range and read only the requested bytes directly from disk.
end := uint64(offset) + uint64(length)
if end > uint64(dataAddress.ValueSize()) {
return nil, fmt.Errorf("subrange [%d, %d) is out of bounds for value of length %d",
offset, end, dataAddress.ValueSize())
}

values, err := s.shardForAddress(dataAddress)
if err != nil {
return nil, fmt.Errorf("failed to resolve shard for read: %w", err)
}

// The value starts at dataAddress.Offset(); the sub-range starts offset bytes further in. Both operands
// and their sum are bounded by the value file size (< 2^32), so the uint32 addition cannot overflow.
value, err := values.read(dataAddress.Offset()+offset, length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] dataAddress.Offset()+offset is the only arithmetic here done in uint32 rather than uint64. The comment above asserts the sum is bounded by the value file size (< 2^32), but valueFile.write only refuses to start a value once v.size > MaxUint32, so a value that begins at, say, 4.0e9 and is 1e9 long ends past 2^32. If that ever happened, the addition would wrap to a small index, valueFile.read's bounds check (computed in uint64 against flushedSize) would pass, and you'd silently return the wrong bytes rather than an error. Practically unreachable with current segment size limits, but since end is already computed in uint64 two lines up, it costs nothing to compute the start in uint64 too and reject > MaxUint32 explicitly, converting a silent-wrong-data mode into an error.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The no-overflow argument in the comment holds for every case that matters, but not unconditionally: when length > 0 the bounds check above pins Offset()+offset+length <= Offset()+ValueSize() <= fileSize, so no wrap. The one case it doesn't cover is length == 0 with offset == ValueSize() and Offset()+ValueSize() == 2^32, where Offset()+offset wraps to 0 and the read is issued at file offset 0 — harmless, since a zero-length read returns an empty slice either way. Computing the start in uint64 (or short-circuiting length == 0) would make the invariant hold by construction instead of by argument.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] valueFile.read opens the value file (os.OpenFile), seeks, ReadFulls, and closes on every call, so a sub-range read saves bytes transferred but still pays the same per-read open/seek/close as a full Read. For the target case (one tx out of a block) the fixed syscall cost may well dominate the savings, which makes the benchmark suggested elsewhere in this review worth having before the receipt producer is built on the assumption that "cost scales with length" — as written, only the transfer component does.

if err != nil {
return nil, fmt.Errorf("failed to read value subrange: %w", err)
}
return value, nil
}

// maybeDecompress decodes an on-disk value from a compressed segment (stripping the per-value algorithm
// tag and decompressing the body; see types.EncodeValue), or returns it unchanged if the segment is not
// compressed. All value reads (Segment.Read and SegmentReader.Read) pass through here so the on-disk
Expand Down
28 changes: 28 additions & 0 deletions sei-db/db_engine/litt/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ type Table interface {
// method.
Get(key []byte) (value []byte, exists bool, err error)

// GetSubrange retrieves only the [offset, offset+length) byte range of the value stored under key,
// without materializing the whole value. The returned boolean indicates whether the key exists (false
// if it does not, in which case value is nil).
//
// This is intended for large values where a caller knows the exact byte range it needs (for example, a
// single serialized transaction within a serialized block): rather than read the entire value and slice
// it, an uncompressed on-disk value is read with a bounded, seek-then-read of exactly length bytes, so
// the I/O cost scales with length rather than the full value size. (A value stored in a compressed
// segment is a single compressed unit that cannot be sliced on disk, so for those the full value is
// read and decompressed before the sub-range is sliced out — still correct, but without the I/O
// savings. The default table configuration is uncompressed.)
//
// The requested range must be within the value: if offset+length exceeds the value's length, an error
// is returned. A zero-length range is valid and returns an empty (non-nil) slice when the key exists.
//
// Caching note: for a table with caching enabled, GetSubrange consults the read/write value caches
// like Get does — a cached value is already in memory, so slicing it is cheaper than any disk read —
// but unlike Get it never populates them, since a sub-range stored under the full key would corrupt a
// later Get. A subrange read of an uncached key therefore always reaches the base table (see the
// cachedTable implementation for the full rationale).
//
// As with Get, the returned data is NOT safe to mutate, and the key byte slice must not be modified
// after it is passed to this method. The returned slice may alias internal memory holding the whole
// value (an unflushed write or a cache entry), but its capacity is always capped to its length, so an
// append allocates a new array rather than overwriting whatever follows the range in that shared
// buffer.
GetSubrange(key []byte, offset uint32, length uint32) (value []byte, exists bool, err error)

// Exists returns true if the key exists in the database, and false otherwise. This is faster than calling Get.
//
// It is not safe to modify the key byte slice after it is passed to this method.
Expand Down
Loading
Loading