diff --git a/sei-db/db_engine/litt/README.md b/sei-db/db_engine/litt/README.md index ecb3109b71..7587702e4c 100644 --- a/sei-db/db_engine/litt/README.md +++ b/sei-db/db_engine/litt/README.md @@ -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 diff --git a/sei-db/db_engine/litt/dbcache/cached_table.go b/sei-db/db_engine/litt/dbcache/cached_table.go index 82e85ddf9b..cccfc02f93 100644 --- a/sei-db/db_engine/litt/dbcache/cached_table.go +++ b/sei-db/db_engine/litt/dbcache/cached_table.go @@ -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) { + // 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) + 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) { + 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 { diff --git a/sei-db/db_engine/litt/disktable/compression_test.go b/sei-db/db_engine/litt/disktable/compression_test.go index c4b63dac19..109492052f 100644 --- a/sei-db/db_engine/litt/disktable/compression_test.go +++ b/sei-db/db_engine/litt/disktable/compression_test.go @@ -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() diff --git a/sei-db/db_engine/litt/disktable/disk_table.go b/sei-db/db_engine/litt/disktable/disk_table.go index 119de9fb53..5b5d8ead12 100644 --- a/sei-db/db_engine/litt/disktable/disk_table.go +++ b/sei-db/db_engine/litt/disktable/disk_table.go @@ -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}}) } diff --git a/sei-db/db_engine/litt/disktable/disk_table_subrange_test.go b/sei-db/db_engine/litt/disktable/disk_table_subrange_test.go new file mode 100644 index 0000000000..44aa5e8422 --- /dev/null +++ b/sei-db/db_engine/litt/disktable/disk_table_subrange_test.go @@ -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()) + }) + } +} diff --git a/sei-db/db_engine/litt/disktable/segment/segment.go b/sei-db/db_engine/litt/disktable/segment/segment.go index ac55f5353d..c3b68c5740 100644 --- a/sei-db/db_engine/litt/disktable/segment/segment.go +++ b/sei-db/db_engine/litt/disktable/segment/segment.go @@ -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() { + 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) + 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 diff --git a/sei-db/db_engine/litt/table.go b/sei-db/db_engine/litt/table.go index be219dda6d..c802bc21ca 100644 --- a/sei-db/db_engine/litt/table.go +++ b/sei-db/db_engine/litt/table.go @@ -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. diff --git a/sei-db/db_engine/litt/test/table_test.go b/sei-db/db_engine/litt/test/table_test.go index 3ce6a906c4..2d271002e1 100644 --- a/sei-db/db_engine/litt/test/table_test.go +++ b/sei-db/db_engine/litt/test/table_test.go @@ -201,6 +201,31 @@ func buildCachedMemKeyDiskTable( return dbcache.NewCachedTable(baseTable, writeCache, readCache, nil), nil } +// buildCacheDisabledMemKeyDiskTable wraps a base table in cachedTable with caches that can never hold an +// entry (max weight 0, which is what DefaultTableConfig configures and therefore what every table in this +// repo runs with today). Every read must fall through to the base table, so this covers the wrapper's +// delegation path — which the builders above cannot, since their caches are large enough that a written +// value is always still resident. +func buildCacheDisabledMemKeyDiskTable( + clock func() time.Time, + name string, + path string) (litt.ManagedTable, error) { + + baseTable, err := buildMemKeyDiskTable(clock, name, path) + if err != nil { + return nil, err + } + + writeCache := util.NewFIFOCache[string, []byte](0, func(k string, v []byte) uint64 { + return uint64(len(k) + len(v)) + }, nil) + readCache := util.NewFIFOCache[string, []byte](0, func(k string, v []byte) uint64 { + return uint64(len(k) + len(v)) + }, nil) + + return dbcache.NewCachedTable(baseTable, writeCache, readCache, nil), nil +} + func buildCachedPebbleDBKeyDiskTable( clock func() time.Time, name string, @@ -565,3 +590,162 @@ func TestSecondaryKeyBasics(t *testing.T) { }) } } + +// getSubrangeParityTest runs against every implementation, cached and uncached, and pins the property +// that makes it safe for a cached table to serve a subrange read by slicing a cached value: the read is +// indistinguishable from the same read against the base table. It covers a primary key, a secondary key +// (whose offsets are relative to its own aliased region, not to the parent value), out-of-bounds ranges, +// and the requirement that a subrange read never poisons a later full Get. +func getSubrangeParityTest(t *testing.T, tb *tableBuilder) { + rand := util.NewTestRandom() + directory := t.TempDir() + tableName := rand.String(8) + table, err := tb.builder(time.Now, tableName, directory) + require.NoError(t, err) + + // 0 1 + // 0123456789012345678 + value := []byte("the quick brown fox") + primary := []byte("primary") + // A secondary aliasing the strict sub-range "brown fox". Its cache entry holds only those bytes, and + // its on-disk address points at the same region, so both paths measure offsets from "brown". + sk := &types.SecondaryKey{Key: []byte("brown-fox"), Offset: 10, Length: 9} + require.NoError(t, table.Put(primary, value, sk)) + + verify := func(stage string) { + t.Helper() + + got, ok, err := table.GetSubrange(primary, 4, 5) + require.NoError(t, err, stage) + require.True(t, ok, stage) + require.Equal(t, []byte("quick"), got, stage) + // A hit slices a buffer holding the whole value (an unflushed write, or a cache entry), so the + // capacity must stop at the length or an append by the caller would corrupt the rest of it. + require.Equal(t, len(got), cap(got), stage) + + got, ok, err = table.GetSubrange(sk.Key, 6, 3) + require.NoError(t, err, stage) + require.True(t, ok, stage) + require.Equal(t, []byte("fox"), got, stage) + require.Equal(t, len(got), cap(got), stage) + + // A zero-length range is valid and yields an empty, non-nil slice. + got, ok, err = table.GetSubrange(primary, uint32(len(value)), 0) + require.NoError(t, err, stage) + require.True(t, ok, stage) + require.NotNil(t, got, stage) + require.Empty(t, got, stage) + + // A range past the end of the value errors, for the primary against the whole value and for the + // secondary against its (shorter) aliased region. + _, _, err = table.GetSubrange(primary, uint32(len(value)), 1) + require.Error(t, err, stage) + _, _, err = table.GetSubrange(sk.Key, 0, sk.Length+1) + require.Error(t, err, stage) + + // A key that was never written misses both caches and must report not-found from the base table, + // without an error. + got, ok, err = table.GetSubrange([]byte("does-not-exist"), 0, 1) + require.NoError(t, err, stage) + require.False(t, ok, stage) + require.Nil(t, got, stage) + + // A subrange read must never be cached under the key it was read from: a later Get still sees + // the whole value. + got, ok, err = table.Get(primary) + require.NoError(t, err, stage) + require.True(t, ok, stage) + require.Equal(t, value, got, stage) + } + + verify("before flush") + require.NoError(t, table.Flush()) + verify("after flush") + + require.NoError(t, table.Drop()) +} + +func TestGetSubrangeParity(t *testing.T) { + t.Parallel() + + // tableBuilders' cached entries hold every written value for the life of the test, so on their own + // they only ever exercise cache hits. Add a cache-disabled wrapper so the same assertions also run + // against the wrapper's fall-through to the base table. + builders := make([]*tableBuilder, 0, len(tableBuilders)+1) + builders = append(builders, tableBuilders...) + builders = append(builders, &tableBuilder{ + "cache-disabled mem keymap disk table", + buildCacheDisabledMemKeyDiskTable, + }) + + for _, tb := range builders { + tb := tb + t.Run(tb.name, func(t *testing.T) { + t.Parallel() + getSubrangeParityTest(t, tb) + }) + } +} + +// TestGetSubrangeServedFromCache proves that a cached table actually consults its caches rather than +// always reaching the base table, by seeding a cache entry for a key the base table has never heard of: +// only a cache hit can answer it. This is also the shape of the case that matters in production — a key +// still resident in a cache after the base table reclaimed it via GC or TTL expiry — and it shows that +// GetSubrange and Get agree about such a key existing. +func TestGetSubrangeServedFromCache(t *testing.T) { + t.Parallel() + + cacheWeight := func(k string, v []byte) uint64 { return uint64(len(k) + len(v)) } + + for _, cacheName := range []string{"write cache", "read cache"} { + cacheName := cacheName + t.Run(cacheName, func(t *testing.T) { + t.Parallel() + rand := util.NewTestRandom() + directory := t.TempDir() + tableName := rand.String(8) + + base, err := buildMemKeyDiskTable(time.Now, tableName, directory) + require.NoError(t, err) + + writeCache := util.NewFIFOCache[string, []byte](500, cacheWeight, nil) + readCache := util.NewFIFOCache[string, []byte](500, cacheWeight, nil) + table := dbcache.NewCachedTable(base, writeCache, readCache, nil) + + // Seed only the cache under test. The base table is left empty, so any answer for this key + // must have come from that cache. + value := []byte("the quick brown fox") + key := "cache-only" + if cacheName == "write cache" { + writeCache.Put(key, value) + } else { + readCache.Put(key, value) + } + + got, ok, err := table.GetSubrange([]byte(key), 4, 5) + require.NoError(t, err) + require.True(t, ok, "a cache-resident key must be served from the cache") + require.Equal(t, []byte("quick"), got) + + // The slice aliases the cache entry, so its capacity must stop at its length: an append by + // the caller must not be able to write into the cached value every later reader shares. + require.Equal(t, len(got), cap(got)) + got = append(got, "-appended"...) //nolint:gocritic // the point is that this must not alias + require.Equal(t, []byte("the quick brown fox"), value, "append must not touch the cached value") + + // Bounds are checked against the cached value's length, matching the base table's behavior. + _, _, err = table.GetSubrange([]byte(key), uint32(len(value)), 1) + require.Error(t, err) + + // GetSubrange and Get agree that the key exists, and the base table still does not have it. + _, ok, err = table.Get([]byte(key)) + require.NoError(t, err) + require.True(t, ok) + _, ok, err = base.GetSubrange([]byte(key), 4, 5) + require.NoError(t, err) + require.False(t, ok, "the base table was never written to") + + require.NoError(t, table.Drop()) + }) + } +} diff --git a/sei-db/ledger_db/block/littblock/codec_test.go b/sei-db/ledger_db/block/littblock/codec_test.go index 3c5aff9eca..26e1735b66 100644 --- a/sei-db/ledger_db/block/littblock/codec_test.go +++ b/sei-db/ledger_db/block/littblock/codec_test.go @@ -82,6 +82,28 @@ func TestBlockRoundTrip(t *testing.T) { } } +// TestBlockValueFraming pins the exact framing of a stored block value: the version byte, the embedded +// block number, and — most importantly — that the marshalled body begins precisely at +// blockValuePrefixLen. ReadBlockSubrange translates body-relative offsets by adding that constant without +// re-reading the value's prefix, so it is correct only while the constant matches the real layout. Any +// change to the framing that leaves blockValuePrefixLen stale fails here, loudly, rather than silently +// shifting every sub-range read. +func TestBlockValueFraming(t *testing.T) { + rng := utils.TestRngFromSeed(3) + for i := range 8 { + n := types.GlobalBlockNumber(i * 1000) + blk := types.GenBlock(rng) + body := types.BlockConv.Marshal(blk) + value := encodeBlock(n, blk) + + require.Len(t, value, blockValuePrefixLen+len(body)) + require.Equal(t, blockSerializationVersion, value[0]) + require.Equal(t, n, decodeKey(value[1:blockValuePrefixLen])) + require.Equal(t, body, value[blockValuePrefixLen:], + "marshalled body must begin exactly at blockValuePrefixLen") + } +} + func TestQCRoundTrip(t *testing.T) { rng := utils.TestRngFromSeed(2) for range 16 { diff --git a/sei-db/ledger_db/block/littblock/litt_block_db.go b/sei-db/ledger_db/block/littblock/litt_block_db.go index a514b63768..d694898da0 100644 --- a/sei-db/ledger_db/block/littblock/litt_block_db.go +++ b/sei-db/ledger_db/block/littblock/litt_block_db.go @@ -2,6 +2,7 @@ package littblock import ( "fmt" + "math" "sync" "sync/atomic" @@ -428,6 +429,78 @@ func (s *blockDB) Close() error { return nil } +// ReadBlockSubrange reads a byte range out of the block stored at GlobalBlockNumber n, without decoding +// the block. offset and length identify a range within the block's marshalled body — the proto(Block) +// bytes alone — typically the (offset, length) of one transaction recorded at write time. +// +// The range is returned verbatim, with no interpretation: this method does not know or check whether it +// delimits a transaction, a field, or the middle of either. Naming a range is the caller's job, so a +// caller that holds recorded transaction locations is the right place for a tx-shaped wrapper over this. +// +// Offsets are body-relative, not value-relative: encodeBlock frames the stored value as +// [version:1][GlobalBlockNumber:8][proto(Block)], and this method adds that fixed prefix itself. A writer +// locating each transaction inside the marshalled body therefore records exactly what it computed, with no +// conversion step to forget, and the framing stays a private detail of this package rather than something +// baked into every offset persisted elsewhere (e.g. a receipt's stored tx location). +// +// It reads only the requested bytes via LittDB's GetSubrange, skipping both the full-block disk read and +// the protobuf unmarshal that ReadBlockByNumber performs. The I/O savings apply while the ledger table is +// uncompressed (the default); on a compressed table GetSubrange still returns the correct bytes but must +// read and decompress the whole block value first. +// +// The returned bytes are NOT safe to mutate. Unlike ReadBlockByNumber, which hands back a freshly decoded +// block, this returns a slice aliasing LittDB's internal memory: before a flush it is a sub-slice of the +// live unflushed entry holding the whole block value, and on a cache hit a sub-slice of the cached copy. +// Writing through it would corrupt the block for every later reader. Copy the bytes first if they must be +// modified or must outlive the read. +// +// A recorded (offset, length) is meaningful only against the marshalled body it was computed from. Being +// body-relative, it survives a change to the value framing — the prefix is applied here, at read time, +// from the same constant encodeBlock uses — but not a change to how the body itself is marshalled, which +// moves the transactions within it. Should the prefix width ever vary by block version, this method is +// where the block's version would have to be resolved to pick the right one. +// +// This lives outside the types.BlockDB interface because the offset space is defined by this +// implementation's serialization. The result is one of: +// +// - Some(bytes) with a nil error: the byte range was read. +// - types.ErrPruned: n is strictly below the retention watermark (matches ReadBlockByNumber). A block +// below the watermark may be stranded from its covering QC and is never served. +// - None with a nil error: no block is present at n (never written, or not yet written). +// - a non-nil error: offset is too large to be prefixed without overflowing, the range is out of bounds +// for the block value, or the read failed. +func (s *blockDB) ReadBlockSubrange( + n types.GlobalBlockNumber, + offset uint32, + length uint32, +) (utils.Option[[]byte], error) { + // Refuse below-watermark blocks first: they may be stranded (covering QC reclaimed). Mirrors + // ReadBlockByNumber, and keeps ErrPruned the outcome a caller observes for a pruned n regardless of + // whether the offset is also malformed. + if uint64(n) < s.watermark.Load() { + return utils.None[[]byte](), types.ErrPruned + } + + // Translate the body-relative offset into the stored value's frame. The addition is checked: an offset + // near the top of the uint32 range would otherwise wrap to a small value and quietly read bytes from + // the start of the block instead of failing. + if offset > math.MaxUint32-blockValuePrefixLen { + return utils.None[[]byte](), fmt.Errorf( + "tx offset %d is too large to address within a block value", offset) + } + valueOffset := offset + blockValuePrefixLen + + value, exists, err := s.table.GetSubrange(blockKey(n), valueOffset, length) + if err != nil { + return utils.None[[]byte](), fmt.Errorf( + "failed to read tx range [%d, %d) of block %d's body: %w", offset, uint64(offset)+uint64(length), n, err) + } + if !exists { + return utils.None[[]byte](), nil + } + return utils.Some(value), nil +} + // ForceGC runs a synchronous garbage-collection pass over the table backing db, // so any pending prune takes effect immediately rather than on the periodic GC // schedule. db must be a *blockDB returned by NewBlockDB. Intended for tests and diff --git a/sei-db/ledger_db/block/littblock/litt_block_db_test.go b/sei-db/ledger_db/block/littblock/litt_block_db_test.go new file mode 100644 index 0000000000..42e3404ed0 --- /dev/null +++ b/sei-db/ledger_db/block/littblock/litt_block_db_test.go @@ -0,0 +1,132 @@ +package littblock + +import ( + "bytes" + "math" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// genBlockWithTxs returns a random block guaranteed to carry at least one transaction, so the +// tx-extraction assertions below are meaningful. +func genBlockWithTxs(rng utils.Rng) *types.Block { + for { + blk := types.GenBlock(rng) + if len(blk.Payload().Txs()) > 0 { + return blk + } + } +} + +// TestReadBlockSubrange covers the block-store sub-range read primitive: it must return exactly the +// requested byte range of a block's marshalled body, both before and after a flush, and it must yield a +// real transaction's bytes when given that transaction's range within the body. +func TestReadBlockSubrange(t *testing.T) { + dir := t.TempDir() + rng := utils.TestRngFromSeed(1) + + cfg, err := DefaultConfig(dir) + require.NoError(t, err) + db, err := NewBlockDB(cfg) + require.NoError(t, err) + defer func() { _ = db.Close() }() + impl := db.(*blockDB) + + // Write a QC covering block 0, then the block itself. + blk := genBlockWithTxs(rng) + require.NoError(t, db.WriteQC(0, 1, types.GenFullCommitQCRange(rng, 0, 1))) + require.NoError(t, db.WriteBlock(0, blk)) + + // The stored value for the primary block key is exactly encodeBlock's output. Offsets passed to + // ReadBlockSubrange are relative to the marshalled body, i.e. the stored value minus its fixed prefix. + stored := encodeBlock(0, blk) + body := stored[blockValuePrefixLen:] + bodyLen := uint32(len(body)) + + verify := func(stage string) { + t.Helper() + + // The whole body round-trips, proving the method applies the prefix itself. + res, err := impl.ReadBlockSubrange(0, 0, bodyLen) + require.NoError(t, err, stage) + got, ok := res.Get() + require.True(t, ok, stage) + require.Equal(t, body, got, stage) + + // A real transaction's raw bytes appear verbatim as a contiguous run in the body (each tx is a + // length-delimited `repeated bytes` element), so locating one within the body gives a valid + // (offset, length) — exactly what a writer computes and records, with no prefix arithmetic. + // Extracting that range must return the transaction. + for _, tx := range blk.Payload().Txs() { + idx := bytes.Index(body, tx) + require.GreaterOrEqual(t, idx, 0, stage) + //nolint:gosec // small test offsets/lengths fit u32 + res, err := impl.ReadBlockSubrange(0, uint32(idx), uint32(len(tx))) + require.NoError(t, err, stage) + got, ok := res.Get() + require.True(t, ok, stage) + require.Equal(t, tx, got, stage) + } + + // Offset 0 addresses the first body byte, never the version byte: a body-relative read can never + // reach into the prefix. + res, err = impl.ReadBlockSubrange(0, 0, 1) + require.NoError(t, err, stage) + got, ok = res.Get() + require.True(t, ok, stage) + require.Equal(t, body[:1], got, stage) + + // A range past the end of the body is an error, even though those bytes exist in the stored value + // ahead of the body. + _, err = impl.ReadBlockSubrange(0, bodyLen-1, 5) + require.Error(t, err, stage) + + // An offset too large to be prefixed without wrapping is rejected rather than read from the + // beginning of the block. + _, err = impl.ReadBlockSubrange(0, math.MaxUint32, 1) + require.Error(t, err, stage) + + // A block that was never written is simply absent (not an error). + res, err = impl.ReadBlockSubrange(1, 0, 1) + require.NoError(t, err, stage) + require.False(t, res.IsPresent(), stage) + } + + verify("before flush") + require.NoError(t, db.Flush()) + verify("after flush") +} + +// TestReadBlockSubrangePruned verifies that a block below the retention watermark is reported ErrPruned, +// matching ReadBlockByNumber, rather than served (it may be stranded from its covering QC). +func TestReadBlockSubrangePruned(t *testing.T) { + dir := t.TempDir() + rng := utils.TestRngFromSeed(2) + + db, err := NewBlockDB(strandingConfig(t, dir, 8)) + require.NoError(t, err) + defer func() { _ = db.Close() }() + impl := db.(*blockDB) + + writeSyntheticBatches(t, db, rng, 4, 5) // blocks 0..19; QCs [0,5),[5,10),[10,15),[15,20) + require.NoError(t, db.PruneBefore(5)) // watermark to 5: blocks 0..4 are below it + + res, err := impl.ReadBlockSubrange(2, 0, 1) + require.ErrorIs(t, err, types.ErrPruned) + require.False(t, res.IsPresent()) + + // Retention wins over argument shape: a below-watermark block asked for with an unusable offset still + // returns ErrPruned, matching ReadBlockByNumber and the documented contract. + res, err = impl.ReadBlockSubrange(2, math.MaxUint32, 1) + require.ErrorIs(t, err, types.ErrPruned) + require.False(t, res.IsPresent()) + + // A block at/above the watermark is still served. + res, err = impl.ReadBlockSubrange(5, 0, 1) + require.NoError(t, err) + require.True(t, res.IsPresent()) +}