Skip to content

feat: add Kafka balance outcome consumer - #3191

Draft
og2701 wants to merge 3 commits into
og/balance-worker-statefrom
og/balance-worker-consumer
Draft

feat: add Kafka balance outcome consumer#3191
og2701 wants to merge 3 commits into
og/balance-worker-statefrom
og/balance-worker-consumer

Conversation

@og2701

@og2701 og2701 commented Sep 1, 2026

Copy link
Copy Markdown
Member

What this adds

This is PR 3 in the balance-worker stack. PR 1 computes and validates balance outcomes; PR 2 stores applied outcomes atomically in worker-local SQLite. This slice connects that state boundary to Kafka without routing live requests to it.

┌──────────────────────────────┐
│ Kafka TrackOutcome           │
│ partition 0 · offset 42      │
└──────────────┬───────────────┘
               │ strict envelope + customer key
               ▼
┌──────────────────────────────┐
│ Balance worker               │
│ parse → validate → fold      │
└──────────────┬───────────────┘
               │ one SQLite transaction
               ▼
┌──────────────────────────────┐
│ state + receipt + nextOffset │
│ committed together           │
└──────────────┬───────────────┘
               │ only after SQLite succeeds
               ▼
┌──────────────────────────────┐
│ Kafka consumer offset commit │
└──────────────────────────────┘

The producer ACK and consumer commit remain separate. Kafka first makes the outcome durable. This consumer only advances its group offset after the outcome is present in the worker's serving state.

Normal delivery

┌─────────────────────────┐       ┌─────────────────────────┐
│ before                  │       │ after                   │
│ balance: 10             │       │ balance: 5              │
│ revision: 0             │  ──►  │ revision: 1             │
│ SQLite nextOffset: 0    │       │ SQLite nextOffset: 1    │
└─────────────────────────┘       │ receipt: cmd_1          │
                                  │ Kafka commit: 1         │
                                  └─────────────────────────┘

The KafkaJS adapter runs with autoCommit: false. It parses the record, calls the PR 2 SQLite transaction, then commits the exact nextOffset returned by that transaction.

Crash and restore behavior

If SQLite commits but the Kafka commit fails during a rebalance, the group redelivers the record. SQLite already holds the receipt and offset, so the retry does not deduct again; it repairs the Kafka offset and seeks forward.

SQLite apply succeeds
        │
        ▼
Kafka commit fails
        │ redelivery
        ▼
SQLite says nextOffset = 43
        │
        ├── commit Kafka offset 43
        └── seek partition to 43

A replacement worker may restore SQLite at offset 100 while the consumer group remembers offset 200. The first delivered record now reconciles the group back to SQLite's offset before any outcome is folded. After that one reconciliation, ordinary Kafka offset gaps remain valid because transactional control records are not delivered to read_committed consumers.

Code

The consumer processes directly inside KafkaJS's per-partition callback, so this adds no second in-memory queue. partitionsConsumedConcurrently permits concurrency across partitions while KafkaJS preserves ordering inside each partition.

Scope

Included: versioned outcomes, customer keys, partition reconciliation, atomic fold-before-commit ordering, duplicate delivery, valid offset gaps, commit failure, malformed records, and graceful shutdown.

Not included: /track or /check routing, async command decisions, state bootstrap, S3 snapshots, Postgres projection, deployment, or production authority.

Before an authoritative rollout, poison records need an explicit parking/operator-recovery policy, and per-outcome offset commits need a load test to decide whether to batch them.

Stack

Verification

  • bun install --frozen-lockfile --ignore-scripts
  • bun -F @autumn/balance-worker test:unit — 19 tests, 61 expectations
  • bun -F @autumn/balance-worker ts
  • bun -F @autumn/balance-engine test:unit — 25 tests, 53 expectations
  • bun -F @autumn/balance-engine ts
  • node_modules/.bin/knip --workspace @autumn/balance-worker --no-progress
  • Scoped Biome check and git diff --check

No live Kafka broker was used in this slice; Kafka lifecycle behavior is covered with a fake KafkaJS consumer backed by the real SQLite store.


Summary by cubic

Adds a Kafka consumer that applies TrackOutcome records to the worker's SQLite store, committing the Kafka offset only after the SQLite transaction succeeds. No live /track or /check routing is wired yet; this is shadow consumption.

Delivery and recovery

  • Parses records through a versioned strict envelope and rejects malformed records or key/identity mismatches without committing.
  • Runs with autoCommit: false and commits once per batch, only after every record in that batch is folded into SQLite.
  • If a Kafka commit fails after SQLite commits, redelivery repairs the group offset from SQLite and seeks forward instead of deducting twice.
  • On a new partition assignment, reconciles the group offset to SQLite's stored progress before folding any outcome.
  • Fails with StateBehindKafkaLogStartError when SQLite progress predates the Kafka log start instead of folding records that may have been trimmed.
  • Adds kafkajs as a dependency.

Scope

  • No state bootstrap, S3 snapshots, Postgres projection, or deployment included; poison records need a parking policy and batched offset commits need a load test before authoritative rollout.

Written for commit ca99739. Summary will update on new commits.

Review in cubic

@og2701
og2701 marked this pull request as ready for review September 1, 2026 17:02
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T17:06:09.224571Z 527d832 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 527d83280d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

message,
}) => {
const partitionKey = JSON.stringify([recordTopic, partition]);
if (!initializedPartitions.has(partitionKey)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reset partition initialization after every reassignment

When a partition moves from this consumer to another worker and later returns, this process-wide set still marks it initialized, so the first newly assigned record skips reconciliation. If the other worker advanced the group while this worker's local SQLite remained behind, processTrackOutcomeRecord applies the newer outcome to stale state and throws OutOfOrderTrackOutcomeError, repeatedly crashing consumption instead of rewinding to the stored offset. Clear or rebuild this state on partition revocation/assignment rather than retaining it for the consumer's lifetime.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 8 files

Confidence score: 3/5

  • apps/balance-worker/src/kafka/kafkaTrackOutcomeConsumer.ts can leave a Kafka consumer running when shutdown races with startup, causing incomplete cleanup and potentially lingering processing; track the in-flight startup so stop() awaits or cancels it before disconnecting.
  • apps/balance-worker/src/kafka/trackOutcomeRecord.ts may accept malformed UTF-8 in outcomes or matching keys after replacement characters are inserted, leading to incorrect validation or key matching; use fatal UTF-8 decoding for both fields and map decode failures appropriately.
  • apps/balance-worker/tests/unit/kafka/kafka-test-fixtures.ts can leak temporary directories when SQLite initialization throws, leaving test artifacts behind; move store creation inside the cleanup-protected try block.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/balance-worker/tests/unit/kafka/kafka-test-fixtures.ts">

<violation number="1" location="apps/balance-worker/tests/unit/kafka/kafka-test-fixtures.ts:79">
P3: If `openSqliteBalanceStateStore` throws (e.g., SQLite fails to open the database file), the `try`/`catch` never runs, so the freshly created temp directory from `mkdtempSync` is left behind and never removed. Move `openSqliteBalanceStateStore` inside the `try` so the error path also closes/cleans up, or wrap the whole body including the open call.</violation>
</file>

<file name="apps/balance-worker/src/kafka/trackOutcomeRecord.ts">

<violation number="1" location="apps/balance-worker/src/kafka/trackOutcomeRecord.ts:124">
P2: Malformed UTF-8 can be accepted as a valid outcome or matching key because `Buffer.toString("utf8")` silently inserts U+FFFD before validation. Decode both value and key with fatal UTF-8 handling, mapping decoder failures to `InvalidKafkaTrackOutcomeRecordError`.</violation>
</file>

<file name="apps/balance-worker/src/kafka/kafkaTrackOutcomeConsumer.ts">

<violation number="1" location="apps/balance-worker/src/kafka/kafkaTrackOutcomeConsumer.ts:139">
P2: When shutdown races with startup, `stop()` returns before the consumer is marked started and never stops or disconnects it. Track the in-flight startup and have `stop()` await or cancel it before performing shutdown.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


let input: unknown;
try {
input = JSON.parse(value.toString("utf8"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Malformed UTF-8 can be accepted as a valid outcome or matching key because Buffer.toString("utf8") silently inserts U+FFFD before validation. Decode both value and key with fatal UTF-8 handling, mapping decoder failures to InvalidKafkaTrackOutcomeRecordError.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/balance-worker/src/kafka/trackOutcomeRecord.ts, line 124:

<comment>Malformed UTF-8 can be accepted as a valid outcome or matching key because `Buffer.toString("utf8")` silently inserts U+FFFD before validation. Decode both value and key with fatal UTF-8 handling, mapping decoder failures to `InvalidKafkaTrackOutcomeRecordError`.</comment>

<file context>
@@ -0,0 +1,140 @@
+
+	let input: unknown;
+	try {
+		input = JSON.parse(value.toString("utf8"));
+	} catch (cause) {
+		throw new InvalidKafkaTrackOutcomeRecordError({ cause });
</file context>

};

const stop = async (): Promise<void> => {
if (!isStarted || isStopped) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When shutdown races with startup, stop() returns before the consumer is marked started and never stops or disconnects it. Track the in-flight startup and have stop() await or cancel it before performing shutdown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/balance-worker/src/kafka/kafkaTrackOutcomeConsumer.ts, line 139:

<comment>When shutdown races with startup, `stop()` returns before the consumer is marked started and never stops or disconnects it. Track the in-flight startup and have `stop()` await or cancel it before performing shutdown.</comment>

<file context>
@@ -0,0 +1,149 @@
+	};
+
+	const stop = async (): Promise<void> => {
+		if (!isStarted || isStopped) return;
+		isStopped = true;
+		try {
</file context>

Comment thread apps/balance-worker/src/kafka/kafkaTrackOutcomeConsumer.ts
store: SqliteBalanceStateStore;
} => {
const directory = mkdtempSync(join(tmpdir(), "autumn-kafka-consumer-"));
const store = openSqliteBalanceStateStore({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: If openSqliteBalanceStateStore throws (e.g., SQLite fails to open the database file), the try/catch never runs, so the freshly created temp directory from mkdtempSync is left behind and never removed. Move openSqliteBalanceStateStore inside the try so the error path also closes/cleans up, or wrap the whole body including the open call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/balance-worker/tests/unit/kafka/kafka-test-fixtures.ts, line 79:

<comment>If `openSqliteBalanceStateStore` throws (e.g., SQLite fails to open the database file), the `try`/`catch` never runs, so the freshly created temp directory from `mkdtempSync` is left behind and never removed. Move `openSqliteBalanceStateStore` inside the `try` so the error path also closes/cleans up, or wrap the whole body including the open call.</comment>

<file context>
@@ -0,0 +1,102 @@
+	store: SqliteBalanceStateStore;
+} => {
+	const directory = mkdtempSync(join(tmpdir(), "autumn-kafka-consumer-"));
+	const store = openSqliteBalanceStateStore({
+		databasePath: join(directory, "balance-state.sqlite"),
+	});
</file context>

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Capy found 2 potential issues (1 high, 1 medium).

View 2 other findings in Capy.

Open in Capy Review


let isStarted = false;
let isStopped = false;
const initializedPartitions = new Set<string>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High: initializedPartitions never clears on reassignment, so SQLite-vs-group reconciliation runs only once per process lifetime.

Worker A processes a partition and marks it in initializedPartitions. After a rebalance the partition moves away (another member may advance the group offset) and later returns. The first delivered record skips the storedNextOffset check because the key is still present. applyDurableTrackOutcome then treats the gap as a valid Kafka offset hole, jumps next_offset forward, and either throws OutOfOrderTrackOutcomeError (crash loop) or applies a later outcome while silently skipping the missed range. Clear initializedPartitions on GROUP_JOIN/REBALANCING (or drop the set and compare every record against readNextOffset before folding).

Open in Capy Review

};

const stop = async (): Promise<void> => {
if (!isStarted || isStopped) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium: stop() is a no-op while start() is still in flight, so shutdown can leave a live Kafka consumer running.

Caller invokes start() (connect/subscribe/run in progress, isStarted still false) and concurrently or immediately calls stop() on SIGTERM. stop() hits if (!isStarted || isStopped) return and exits without stop/disconnect. When start() finishes, the consumer is running with no shutdown hook holding it. Track the in-flight start promise and make stop() await or cancel it, then always stop and disconnect once the consumer is connected.

Open in Capy Review

@og2701
og2701 marked this pull request as draft September 1, 2026 17:31
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated
checkout Ignored Ignored Sep 1, 2026 8:14pm UTC
landing-page Ignored Ignored Sep 1, 2026 8:14pm UTC

Request Review

@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 1, 2026 18:25 Inactive
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 1, 2026 20:14 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant