Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ require (
)

require (
cloud.google.com/go/bigtable v1.37.0
cloud.google.com/go/compute/metadata v0.8.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
Expand Down Expand Up @@ -265,6 +267,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/image v0.36.0
golang.org/x/mod v0.32.0
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/Zur
cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac=
cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q=
cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU=
cloud.google.com/go/bigtable v1.37.0 h1:Q+x7y04lQ0B+WXp03wc1/FLhFt4CwcQdkwWT0M4Jp3w=
cloud.google.com/go/bigtable v1.37.0/go.mod h1:HXqddP6hduwzrtiTCqZPpj9ij4hGZb4Zy1WF/dT+yaU=
cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY=
cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s=
cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI=
Expand Down Expand Up @@ -175,6 +177,8 @@ cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZ
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY=
cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck=
cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w=
Expand Down Expand Up @@ -2287,6 +2291,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/perf v0.0.0-20230113213139-801c7ef9e5c5/go.mod h1:UBKtEnL8aqnd+0JHqZ+2qoMDwtuy6cYhhKNoHLBiTQc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
57 changes: 57 additions & 0 deletions sei-db/state_db/ss/offload/consumer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Historical State Offload (Bigtable)

Bigtable holds immutable MVCC mutation rows for history that local SS has
pruned. The shape is narrow:

- local SS remains the hot store for recent state, writes, imports, pruning, and iterators
- Bigtable keeps immutable MVCC mutation rows for older history
- reads below local SS retention can fall back to Bigtable for `Get` and `Has`

Row keys are salted with an inverted height suffix:

```text
m | shard(store,key) | store_name | state_key | inverted_height
```

Reads scan from `inverted(target_height)` and stop after the first row, giving
the latest write at or before the requested height. Ordered prefix iteration is
intentionally not served from the offload store.

## Consumer

The consumer reads historical offload changelog messages from Kafka and writes
them into Bigtable. Kafka offsets are committed only after the sink write
succeeds. Mutation rows are written before the version marker.

```bash
cbt -project my-gcp-project -instance sei-history createtable state_mutations
cbt -project my-gcp-project -instance sei-history createfamily state_mutations state

go run ./sei-db/state_db/ss/offload/consumer/cmd/historical-offload-consumer \
./sei-db/state_db/ss/offload/consumer/config/example-bigtable.json
```

The example config is a local/dev placeholder. Set real Kafka brokers and
Bigtable credentials/config in your own config.

For Google Cloud Managed Service for Apache Kafka, connect with TLS plus
SASL/PLAIN using service-account credentials:

```json
"Kafka": {
"Brokers": ["bootstrap.CLUSTER.REGION.managedkafka.PROJECT.cloud.goog:9092"],
"TLSEnabled": true,
"SASLMechanism": "plain",
"Username": "kafka-client@PROJECT.iam.gserviceaccount.com",
"Password": "<base64-encoded service account key JSON>"
}
```

## Current Limits

- The node-side read fallback lands in part 2; this part is the client library
and the ingestion pipeline.
- No cross-row transaction on ingest; mutation rows are written first and the
version marker is written last, so replay is idempotent after partial failure.
- No automatic table creation from the binary.
- No backfill tooling; coverage starts when ingestion starts.
233 changes: 233 additions & 0 deletions sei-db/state_db/ss/offload/consumer/bigtable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package consumer

import (
"context"
"fmt"
"sort"
"strconv"
"time"

"github.com/sei-protocol/sei-chain/sei-db/proto"
"github.com/sei-protocol/sei-chain/sei-db/state_db/ss/offload/historical"
"golang.org/x/sync/errgroup"
)

type BigtableConfig = historical.BigtableConfig

const (
defaultBigtableMutationChunkRows = 1024
defaultBigtableMutationChunkConcurrency = 8
)

type bigtableSink struct {
client *historical.BigtableClient
applyBulk historical.BigtableApplyBulkFunc
family string
shards int
bulkChunkRows int
bulkChunkWorkers int
}

var _ Sink = (*bigtableSink)(nil)

func NewBigtableSink(cfg BigtableConfig) (Sink, error) {
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
ctx := context.Background()
client, err := historical.OpenBigtableClient(ctx, cfg)
if err != nil {
return nil, err
}
return &bigtableSink{
client: client,
applyBulk: client.ApplyBulk,
family: cfg.Family,
shards: cfg.Shards,
bulkChunkRows: defaultBigtableMutationChunkRows,
bulkChunkWorkers: defaultBigtableMutationChunkConcurrency,
}, nil
}

func (s *bigtableSink) Close() error {
if s.client != nil {
return s.client.Close()
}
return nil
}

func (s *bigtableSink) WriteBatch(ctx context.Context, records []Record) error {
records = compactRecords(records)
if len(records) == 0 {
return nil
}
if err := s.writeRecordRows(ctx, records); err != nil {
return err
}
return s.writeVersionMarkers(ctx, records)
}

func (s *bigtableSink) writeRecordRows(ctx context.Context, records []Record) error {
rows := make([]historical.BigtableRowMutation, 0, bigtableRowMutationCount(records))
for _, rec := range records {
rows = s.appendRecordRowMutations(rows, rec.Entry)
}
if len(rows) == 0 {
return nil
}
return s.applyRecordRowMutations(ctx, rows)
}

func (s *bigtableSink) applyRecordRowMutations(ctx context.Context, rows []historical.BigtableRowMutation) error {
chunks := bigtableRowMutationChunks(rows, s.bulkChunkRows)
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(s.bulkChunkWorkers)
for _, chunk := range chunks {
chunk := chunk
g.Go(func() error {
errs, err := s.applyBulk(gctx, chunk)
return bigtableBulkError(chunk, errs, err)
})
}
return g.Wait()
}

func (s *bigtableSink) appendRecordRowMutations(rows []historical.BigtableRowMutation, entry *proto.ChangelogEntry) []historical.BigtableRowMutation {
for _, mutation := range compactMutations(entry) {
rows = append(rows, s.mutationRow(entry.Version, mutation.storeName, mutation.pair))
}
for _, up := range entry.Upgrades {
rows = append(rows, s.upgradeRow(entry.Version, up))
}
return rows
}

// mutationRow writes value+deleted cells for live pairs but only a deleted
// cell for tombstones, saving a cell per delete. Readers must therefore check
// the deleted column before trusting any value cell — a replayed live write
// followed by a tombstone leaves both cells on the row.
func (s *bigtableSink) mutationRow(version int64, storeName string, pair *proto.KVPair) historical.BigtableRowMutation {
ts := historical.BigtableTimestamp(version)
deleted := pair.Delete || pair.Value == nil

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] deleted := pair.Delete || pair.Value == nil conflates a live write of a nil value with a tombstone, so a genuine nil-value write is stored (and later read) as absent. bigtableValueFromRow mirrors this (value == nil → ErrNotFound), so it's self-consistent, but worth a comment confirming SS never emits a live nil-value pair — otherwise such writes silently disappear.

rowKey := historical.BigtableMutationRowKey(storeName, pair.Key, version, s.shards)
if deleted {
return historical.BigtableRowMutation{
RowKey: rowKey,
SetCells: []historical.BigtableSetCell{{
Family: s.family,
Qualifier: historical.BigtableDeletedColumn,
TimestampMicros: ts,
Value: boolByte(true),
}},
}
}
return historical.BigtableRowMutation{
RowKey: rowKey,
SetCells: []historical.BigtableSetCell{
{Family: s.family, Qualifier: historical.BigtableValueColumn, TimestampMicros: ts, Value: pair.Value},
{Family: s.family, Qualifier: historical.BigtableDeletedColumn, TimestampMicros: ts, Value: boolByte(false)},
},
}
}

func (s *bigtableSink) upgradeRow(version int64, up *proto.TreeNameUpgrade) historical.BigtableRowMutation {
ts := historical.BigtableTimestamp(version)
return historical.BigtableRowMutation{
RowKey: historical.BigtableUpgradeRowKey(version, up.Name),
SetCells: []historical.BigtableSetCell{
{Family: s.family, Qualifier: "rename_from", TimestampMicros: ts, Value: []byte(up.RenameFrom)},
{Family: s.family, Qualifier: historical.BigtableDeletedColumn, TimestampMicros: ts, Value: boolByte(up.Delete)},
},
}
}

func (s *bigtableSink) writeVersionMarkers(ctx context.Context, records []Record) error {
rows := make([]historical.BigtableRowMutation, 0, len(records))
ingestedAt := []byte(strconv.FormatInt(time.Now().UnixNano(), 10))
for _, rec := range records {
version := rec.Entry.Version
ts := historical.BigtableTimestamp(version)
rows = append(rows, historical.BigtableRowMutation{
RowKey: historical.BigtableVersionRowKey(version),
SetCells: []historical.BigtableSetCell{
{Family: s.family, Qualifier: "topic", TimestampMicros: ts, Value: []byte(rec.Topic)},
{Family: s.family, Qualifier: "partition", TimestampMicros: ts, Value: []byte(strconv.Itoa(rec.Partition))},
{Family: s.family, Qualifier: "offset", TimestampMicros: ts, Value: []byte(strconv.FormatInt(rec.Offset, 10))},
{Family: s.family, Qualifier: "ingested_at_unix_nano", TimestampMicros: ts, Value: ingestedAt},
},
})
}
errs, err := s.applyBulk(ctx, rows)
if err := bigtableBulkError(rows, errs, err); err != nil {
return fmt.Errorf("insert bigtable version markers: %w", err)
}
return nil
}

func bigtableRowMutationCount(records []Record) int {
total := 0
for _, rec := range records {
total += entryMutationCapacity(rec.Entry) + len(rec.Entry.Upgrades)
}
return total
}

func bigtableRowMutationChunks(rows []historical.BigtableRowMutation, maxRows int) [][]historical.BigtableRowMutation {
if len(rows) == 0 {
return nil
}
if maxRows <= 0 {
maxRows = len(rows)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].RowKey < rows[j].RowKey
})

chunks := make([][]historical.BigtableRowMutation, 0, (len(rows)+maxRows-1)/maxRows)
start := 0
startLocality := bigtableRowLocality(rows[0].RowKey)
for i := 1; i < len(rows); i++ {
locality := bigtableRowLocality(rows[i].RowKey)
if i-start >= maxRows || locality != startLocality {
chunks = append(chunks, rows[start:i])
start = i
startLocality = locality
}
}
return append(chunks, rows[start:])
}

func bigtableRowLocality(rowKey string) string {
// Mutation row keys are m|shard|store|key|version; keep chunks inside one
// shard prefix so separate chunks can hit separate Bigtable tablets.
if len(rowKey) >= 3 && rowKey[0] == 'm' {
return rowKey[:3]
}
if len(rowKey) > 0 {
return rowKey[:1]
}
return rowKey
}

func bigtableBulkError(rows []historical.BigtableRowMutation, errs []error, err error) error {
if err != nil {
return err
}
if len(errs) != len(rows) {
return fmt.Errorf("bigtable returned %d mutation results for %d rows", len(errs), len(rows))
}
for i, rowErr := range errs {
if rowErr != nil {
return fmt.Errorf("row %q: %w", rows[i].RowKey, rowErr)
}
}
return nil
}

func boolByte(v bool) []byte {
if v {
return []byte{1}
}
return []byte{0}
}
Loading
Loading