Skip to content

Part 1: Bigtable client and Kafka ingestion#3776

Open
Kbhat1 wants to merge 2 commits into
mainfrom
mvcc-bigtable-pt1
Open

Part 1: Bigtable client and Kafka ingestion#3776
Kbhat1 wants to merge 2 commits into
mainfrom
mvcc-bigtable-pt1

Conversation

@Kbhat1

@Kbhat1 Kbhat1 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Description

  • Bigtable client with frozen MVCC row-key encoding (shard|store|key|inverted-version)
  • Kafka consumer that ingests the node changelog stream into Bigtable: per-partition ordering, redelivery dedupe, offsets committed only after durable writes
  • Zero node-behavior change

Testing

  • Verified end to end in GCP

…rt 1)

Part 1 of the Bigtable historical MVCC state offload: the Bigtable data
client (hand-rolled over the repo's pinned gRPC v1.57, with bounded read
retries, keepalive, and billing-dimension cost metrics), the frozen row
key encoding (m|shard|store|key|inverted-version mutation rows and
v|bucket|inverted-version version markers), the point-read Reader, and
the Kafka consumer that ingests the changelog stream into Bigtable with
per-partition ordering, version dedupe, locality-chunked bulk writes,
and offset commits only after durable sink writes. Connects to Google
Cloud Managed Kafka via TLS + SASL/PLAIN.

Part 2 wires the node-side pruned-read fallback on top of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large new ingestion path and custom Bigtable transport affect historical data correctness and Kafka offset semantics, but it runs out-of-process and does not change node SS behavior in this PR.

Overview
Adds historical state offload infrastructure so pruned SS history can live in Bigtable while the node keeps using local SS for hot paths. This PR is ingestion + client library only; node read fallback is explicitly deferred to part 2.

historical package introduces a custom gRPC Bigtable client (workaround for the repo’s pinned grpc version), frozen MVCC row keys (shard|store|key|inverted-version), point-in-time Get/Has/LastVersion, read retries, and OTel/Prometheus cost metrics.

consumer package adds a standalone historical-offload-consumer binary: Kafka → batched writes → Bigtable sink with mutation rows before version markers, partition-ordered workers, redelivery dedupe (compactRecords/compactMutations), offset commit only after durable sink writes, and optional metrics server.

offload/kafka.go gains shared SASL/PLAIN (Username/Password) via ValidateSASL and NewSASLMechanism for GCP Managed Kafka (producer and consumer). go.mod pulls cloud.google.com/go/bigtable and related deps.

Reviewed by Cursor Bugbot for commit bcde772. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 19, 2026, 3:20 AM

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.93781% with 497 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.95%. Comparing base (7fee5c2) to head (bcde772).

Files with missing lines Patch % Lines
sei-db/state_db/ss/offload/historical/bigtable.go 47.69% 206 Missing and 21 partials ⚠️
sei-db/state_db/ss/offload/consumer/consumer.go 4.84% 157 Missing ⚠️
sei-db/state_db/ss/offload/consumer/bigtable.go 76.25% 27 Missing and 6 partials ⚠️
...d/consumer/cmd/historical-offload-consumer/main.go 0.00% 32 Missing ⚠️
sei-db/state_db/ss/offload/consumer/kafka.go 54.09% 27 Missing and 1 partial ⚠️
sei-db/state_db/ss/offload/consumer/config.go 50.00% 8 Missing and 8 partials ⚠️
sei-db/state_db/ss/offload/consumer/compact.go 93.75% 1 Missing and 1 partial ⚠️
sei-db/state_db/ss/offload/kafka.go 85.71% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3776      +/-   ##
==========================================
- Coverage   60.14%   58.95%   -1.19%     
==========================================
  Files        2303     2212      -91     
  Lines      192032   181473   -10559     
==========================================
- Hits       115491   106995    -8496     
+ Misses      66230    65075    -1155     
+ Partials    10311     9403     -908     
Flag Coverage Δ
sei-db 70.41% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 49.49% <50.93%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/state_db/ss/offload/consumer/metrics.go 100.00% <100.00%> (ø)
sei-db/state_db/ss/offload/historical/metrics.go 100.00% <100.00%> (ø)
sei-db/state_db/ss/offload/consumer/compact.go 93.75% <93.75%> (ø)
sei-db/state_db/ss/offload/kafka.go 61.19% <85.71%> (+1.81%) ⬆️
sei-db/state_db/ss/offload/consumer/config.go 50.00% <50.00%> (ø)
sei-db/state_db/ss/offload/consumer/kafka.go 54.09% <54.09%> (ø)
...d/consumer/cmd/historical-offload-consumer/main.go 0.00% <0.00%> (ø)
sei-db/state_db/ss/offload/consumer/bigtable.go 76.25% <76.25%> (ø)
sei-db/state_db/ss/offload/consumer/consumer.go 4.84% <4.84%> (ø)
sei-db/state_db/ss/offload/historical/bigtable.go 47.69% <47.69%> (ø)

... and 131 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Part 1 of the Bigtable historical-state offload adds a well-tested, self-contained gRPC Bigtable client and a Kafka ingestion consumer with no node-behavior changes. No hard blockers, but there is a credential-exposure hardening gap (SASL/PLAIN accepted without TLS) plus two latent-correctness/observability issues (non-contiguous LastVersion, unaggregated lag gauge) worth addressing before the part-2 read path lands.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • REVIEW_GUIDELINES.md (from the base branch) and cursor-review.md are both empty, so no repo-specific guidelines and no Cursor second-opinion pass were available for this synthesis.
  • LastVersion/BigtableLastVersion (historical/bigtable.go) returns the max version marker across buckets. Because Kafka is hash-partitioned and consumed concurrently with per-partition ordering, version N's marker can be durable while lower versions on a slower partition are not yet written — so this value overstates contiguous history. Not consumed in this PR (read fallback is part 2), but if part 2 uses it to gate historical reads/pruning it needs a contiguous watermark, not a max. (Codex High — agreed, downgraded to non-blocker since it's latent in this part.)
  • General: the mutation-row and version-marker cell timestamps are derived from the block version (BigtableTimestamp(version)), which is intentional for idempotent redelivery — worth a brief note/test that operators must not enable Bigtable GC-by-age on the column family, or old-height cells could be reaped.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

switch strings.ToLower(cfg.SASLMechanism) {
case "", kafkaOptionNone:
return nil
case "plain":

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] SASL/PLAIN sends the username and password to the broker in cleartext, but this plain case only validates that credentials are present — it never requires TLS. The aws-msk-iam case below correctly rejects !TLSEnabled; the plain case should do the same so a misconfigured producer/consumer can't leak the service-account credential over an unencrypted connection. The shipped example config and README both set TLSEnabled: true, so this is a defense-in-depth guard, but it's cheap to add:

case "plain":
    if !cfg.TLSEnabled {
        return fmt.Errorf("kafka tls must be enabled for sasl plain")
    }
    if cfg.Username == "" || cfg.Password == "" {
        return fmt.Errorf("kafka username and password are required for sasl plain")
    }

(Codex High.)

}
m.sinkWriteLatency.Record(ctx, writeLatency.Seconds())
if lag >= 0 {
m.kafkaLag.Record(ctx, lag)

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] kafkaLag is a single unlabeled gauge, and recordBatch is called concurrently by every worker goroutine (one per shard/partition-group). Each worker overwrites the gauge with only its latest batch's lag, so it's last-writer-wins: a low-lag partition can mask another partition that is far behind, and the exported value flaps between workers. Consider a per-partition attribute on the gauge, or tracking a process-wide max/sum, so the metric reflects the worst-case backlog rather than whichever worker recorded last. (Codex Medium.)

}
if bytes > 0 {
m.bytesWritten.Add(ctx, bytes, attrs)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hot-path metric attrs reallocated

Medium Severity

recordRead and recordWrite build metric.WithAttributes(attribute.String("table", table)) on every RPC. The table name is fixed at client construction, so this allocates on the ingestion and read hot paths. Existing SeiDB metrics (for example CacheMetrics) precompute the attribute option once at init and reuse it.

Fix in Cursor Fix in Web

Triggered by learned rule: OTel metrics: guard attribute cardinality and use native types

Reviewed by Cursor Bugbot for commit 0c5d3dd. Configure here.

grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Idle keepalive pings disabled

Low Severity

The client sets gRPC keepalive Time/Timeout specifically to detect silently dropped Bigtable connections, but omits PermitWithoutStream: true. Without that flag, keepalive pings are only sent while a stream is active, so idle connections between consumer batches can still die quietly until the next RPC fails.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c5d3dd. Configure here.

@Kbhat1 Kbhat1 changed the title Add Bigtable historical state offload: client and Kafka ingestion (part 1/2) Part 1: Add Bigtable historical state offload: client and Kafka ingestion Jul 17, 2026
@Kbhat1 Kbhat1 changed the title Part 1: Add Bigtable historical state offload: client and Kafka ingestion Part 1: BigTable Part 1: client and Kafka ingestion Jul 17, 2026
@Kbhat1 Kbhat1 changed the title Part 1: BigTable Part 1: client and Kafka ingestion Part 1: Bigtable Part 1: client and Kafka ingestion Jul 17, 2026
@Kbhat1 Kbhat1 changed the title Part 1: Bigtable Part 1: client and Kafka ingestion Part 1: Bigtable client and Kafka ingestion Jul 17, 2026
Comment on lines 91 to +108

switch strings.ToLower(c.SASLMechanism) {
return ValidateSASL(*c)
}

// ValidateSASL checks the SASL mechanism and the credentials it requires.
// Shared by the producer and the offload consumer configs.
func ValidateSASL(cfg KafkaConfig) error {
switch strings.ToLower(cfg.SASLMechanism) {
case "", kafkaOptionNone:
return nil
case "plain":
if cfg.Username == "" || cfg.Password == "" {
return fmt.Errorf("kafka username and password are required for sasl plain")
}
case "aws-msk-iam":
if !c.TLSEnabled {
if !cfg.TLSEnabled {
return fmt.Errorf("kafka tls must be enabled for aws-msk-iam")
}
if c.Region == "" {
if cfg.Region == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 ValidateSASL's "plain" case in sei-db/state_db/ss/offload/kafka.go only checks that Username/Password are non-empty and never requires cfg.TLSEnabled, unlike the aws-msk-iam case a few lines below which does. This lets an operator configure SASLMechanism="plain" with TLSEnabled=false, causing NewKafkaStream/NewKafkaReader to dial a plaintext TCP connection while sending SASL/PLAIN credentials (e.g. a GCP service-account key, per the consumer README) in the clear, since PLAIN is base64-framed but not encrypted. Fix by requiring TLSEnabled in the "plain" branch, mirroring the aws-msk-iam check.

Extended reasoning...

This PR adds a new "plain" case to the shared ValidateSASL function in sei-db/state_db/ss/offload/kafka.go to support Google Cloud Managed Kafka authentication. The new case validates only that Username and Password are non-empty:

case "plain":
    if cfg.Username == "" || cfg.Password == "" {
        return fmt.Errorf("kafka username and password are required for sasl plain")
    }

The very next case in the same switch statement, "aws-msk-iam", explicitly requires TLS:

case "aws-msk-iam":
    if !cfg.TLSEnabled {
        return fmt.Errorf("kafka tls must be enabled for aws-msk-iam")
    }

SASL/PLAIN transmits the username and password base64-encoded in the SASL exchange, which is a framing, not an encryption. Anyone who can observe the TCP connection can trivially decode the base64 payload and recover the credentials. TLS is what actually protects the credentials on the wire for PLAIN auth — this is precisely why the codebase already enforces TLSEnabled for aws-msk-iam right below it. The PR does not carry that same discipline over to the newly-added plain mechanism.

Code path: Both NewKafkaStream (producer, existing) and NewKafkaReader (new consumer, sei-db/state_db/ss/offload/consumer/kafka.go) gate TLS strictly on cfg.TLSEnabled:

if cfg.TLSEnabled {
    dialer.TLS = &tls.Config{MinVersion: tls.VersionTLS12}
}

ValidateSASL runs before this and does not block a config where SASLMechanism == "plain" and TLSEnabled == false. So such a config passes validation, and the dialer/mechanism proceed to dial plaintext TCP while still attaching plain.Mechanism{Username, Password} to the connection — sending the credentials over an unencrypted channel.

Step-by-step proof:

  1. Operator sets Kafka.TLSEnabled = false (default value if omitted) and Kafka.SASLMechanism = "plain" with Username/Password populated (e.g. following the README's Google Cloud Managed Kafka example, where Password is a base64-encoded GCP service-account key JSON).
  2. KafkaReaderConfig.Validate() calls offload.ValidateSASL(c.saslConfig()), which only checks Username != "" && Password != "" for "plain" and returns nil — no error.
  3. NewKafkaReader (or NewKafkaStream) proceeds: since cfg.TLSEnabled is false, dialer.TLS is left nil, so the connection to the Kafka brokers is a plaintext TCP socket.
  4. NewSASLMechanism still returns plain.Mechanism{Username, Password} and attaches it to the dialer/transport, so the SASL PLAIN handshake — carrying the base64-framed (not encrypted) credentials — is sent over that plaintext socket.
  5. Any on-path network observer (e.g. anyone with visibility into the VPC/hosting network) can capture the TCP stream and base64-decode the SASL PLAIN frame to recover the GCP service-account credentials.

This is confirmed directly by the PR's own test, TestKafkaReaderConfigValidateSASL in sei-db/state_db/ss/offload/consumer/kafka_test.go: it exercises plain with Username/Password set and never sets TLSEnabled, and asserts cfg.Validate() returns no error — i.e., the test locks in the gap rather than catching it.

Fix: add a TLS check in the "plain" branch, e.g.:

case "plain":
    if !cfg.TLSEnabled {
        return fmt.Errorf("kafka tls must be enabled for sasl plain")
    }
    if cfg.Username == "" || cfg.Password == "" {
        return fmt.Errorf("kafka username and password are required for sasl plain")
    }

This mirrors the existing aws-msk-iam guard exactly and is a one-line addition. The shipped example config (config/example-bigtable.json) already sets TLSEnabled: true, so the fix does not affect the documented happy path — it only closes a misconfiguration gap that would otherwise leak credentials.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bcde772. Configure here.

m.sinkWriteLatency.Record(ctx, writeLatency.Seconds())
if lag >= 0 {
m.kafkaLag.Record(ctx, lag)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lag gauge races across workers

Medium Severity

consumer_kafka_lag is a single process-wide gauge updated from every worker after each batch. With concurrent workers, last writer wins, so a low-lag partition can overwrite a high-lag one and the scraped value no longer reflects true consumer lag.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bcde772. Configure here.

"consumer_kafka_lag",
metric.WithDescription("Messages between the last processed offset and the partition high watermark (max across the batch)"),
metric.WithUnit("{message}"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consumer metrics lack scope prefix

Low Severity

Prometheus series use short names like consumer_records_processed_total and consumer_kafka_lag. The meter seidb_offload_consumer does not become a series prefix, so these names are underscoped relative to repo convention (seiwal_*, pebblecache_*, bigtable_*) and the OTel naming rule.

Fix in Cursor Fix in Web

Triggered by learned rule: OTel Prometheus export: namespace prefix and unit auto-suffix naming

Reviewed by Cursor Bugbot for commit bcde772. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A well-structured, well-tested Part 1 that adds a self-contained Bigtable client and a Kafka→Bigtable ingestion consumer with no node-behavior change. No blocking issues; a few robustness/design notes are worth addressing before Part 2 wires in the read fallback.

Findings: 0 blocking | 7 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's review file (cursor-review.md) was empty — that pass produced no output, so its perspective is not reflected here.
  • Merged from Codex: LastVersion returns the max version marker, but because versions are ingested concurrently across partition-sharded workers, a higher version can be marked durable before lower ones finish. LastVersion is therefore a high-water-mark, not a 'complete up to V' guarantee. Harmless in this PR (unused by node code) but the contract should be documented/tightened before Part 2's read fallback relies on it to decide the local-SS retention boundary — otherwise pruned-but-not-yet-ingested versions could read as absent.
  • Merged from Codex: decoded changelog entries are never structurally validated. A malformed/corrupt message with a negative Version reaches bigtableInvertedVersionuint64FromNonNegativeInt64, which panics; an unrecovered panic in a worker goroutine crashes the whole consumer. Since offsets commit only after a successful write, a single poison message becomes a restart/crash loop. The input stream is the node's own trusted changelog so this is unlikely in practice, but converting these encode-time panics into per-message errors (poison-message handling / dead-letter or skip-with-log) would make ingestion robust. compactMutations also assumes non-nil Changesets/Pairs elements.
  • The example config documents storing a base64 service-account key as the Kafka Password in a plaintext JSON file. Consider recommending a secrets manager / env indirection in the README rather than a key committed to an operator's config file.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

return bigtableValueFromRow(row, r.family)
}

func BigtableLastVersion(ctx context.Context, readRows BigtableReadRowsFunc) (int64, 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] LastVersion returns max(bucketVersion) across all buckets. Because consecutive versions are ingested concurrently by partition-sharded workers, a higher version's marker can be written before a lower version finishes, so this value does not guarantee every version below it is present. That's fine here (nothing consumes it yet), but Part 2's read fallback will presumably use it to decide which versions are safely offloaded — please document that this is a high-water-mark, not a contiguity guarantee, or track a distinct 'contiguously-complete' watermark before relying on it for pruning decisions.

records := make([]Record, 0, len(msgs))
var firstVersion, lastVersion int64
for i, msg := range msgs {
entry, err := DecodeEntry(msg.Value)

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] Decoded entries are used without structural validation. A corrupt message with a negative Version flows into BigtableMutationRowKeybigtableInvertedVersionuint64FromNonNegativeInt64, which panics; an unrecovered panic in the worker goroutine crashes the process. Because offsets are committed only after a successful write, the same poison message is re-fetched on restart → crash loop. Consider validating the entry here (non-negative version, non-nil changesets/pairs) and returning it as a bounded/skippable error instead of letting encoding panic.

// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant