Skip to content

chipingress/batch: publish queued messages on Stop instead of discarding them - #2461

Merged
skippaDaBitFlippa merged 5 commits into
mainfrom
chipingress-batch-drain-on-stop
Oct 15, 2026
Merged

skippaDaBitFlippa merged 5 commits into
mainfrom
chipingress-batch-drain-on-stop

Conversation

@skippaDaBitFlippa

Copy link
Copy Markdown

batch.Client.Stop flushed only the batch it was filling. Anything still queued on the buffer channel, typically when sends are saturated, was abandoned: never published, and its callback never ran, so callers couldn't count it as dropped or failed.

The batcher now drains everything already queued, in batches of at most batchSize, before returning. WithShutdownTimeout still bounds Stop. Batches drained after the timeout fail against the closed client and report the error to their callbacks.

Found while load-testing the node's legacy-telemetry path (smartcontractkit/chainlink#23949) against a local chip-ingress: a 100k-message burst lost exactly the buffer's contents on shutdown, with no metric recording it. Beholder's chip batch emitter uses this client too.

New test TestStop/Stop_publishes_messages_still_queued_behind_in-flight_sends holds the only send slot while 100 messages are queued, then calls Stop:

  • before: 4-6 of 100 published, the rest lost without callbacks
  • after: 100/100 published with callbacks (passes 20/20 with -race)

…ing them

On Stop the batcher flushed only the batch it was filling. Messages still
queued on the buffer channel, typically when sends are saturated, were
abandoned: never published and their callbacks never ran, so callers could
not count them as dropped or failed.

The batcher now drains everything already queued, in batches of at most
batchSize, before returning. Stop's shutdown timeout still bounds the wait;
batches drained after it expires fail against the closed client and report
the error to their callbacks.
@skippaDaBitFlippa
skippaDaBitFlippa requested a review from a team as a code owner October 13, 2026 20:21
Copilot AI balanced review requested due to automatic review settings October 13, 2026 20:21
@github-actions

Copy link
Copy Markdown
Contributor

👋 skippaDaBitFlippa, thanks for creating this pull request!

To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team.

Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks!

skippaDaBitFlippa added a commit to smartcontractkit/chainlink that referenced this pull request Oct 13, 2026
With the pinned chainlink-common batch client, Stop flushes only the batch
being filled; messages queued behind in-flight sends are discarded without
a callback (found in local load testing). Correct the Close and
ChipIngressDrainTimeout docs. Fix upstream: smartcontractkit/chainlink-common#2461.
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📊 API Diff Results

No changes detected for module github.com/smartcontractkit/chainlink-common/pkg/chipingress

View full report

Copilot AI 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.

🟡 Changes recommended

Concurrent enqueues can still be accepted after draining finishes, leaving messages and callbacks abandoned.

2 open findings
What changed in this PR

Updates batch shutdown to drain queued messages instead of silently abandoning them.

Changes:

  • Drains buffered messages in size-limited batches during shutdown.
  • Documents shutdown and timeout behavior.
  • Adds a regression test for queued messages behind an in-flight send.
File Description
pkg/​chipingress/​batch/​client.go Documents the expanded shutdown guarantees.
pkg/​chipingress/​batch/​client_test.go Tests successful queued-message draining.
pkg/​chipingress/​batch/​batcher.go Implements draining on cancellation.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +65 to +66
for len(input) > 0 {
batch = append(batch, <-input)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in d8a7031. QueueMessage now does its final stop check and the enqueue under an admission RWMutex read lock, and Stop closes stopCh under the write lock. Every message QueueMessage accepted is therefore on the buffer before the batcher drains it, and anything arriving afterwards gets ErrClientShutdown. New test: TestStop/every_message_accepted_while_Stop_races_producers_gets_a_callback.

Comment on lines +1284 to +1285
}, time.Second, time.Millisecond)
close(release)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added TestStop/Stop_returns_at_the_shutdown_timeout_and_late_batches_report_errors in d8a7031. It holds the only send slot past a 100ms shutdown timeout, asserts Stop returns within the bound and closes the client, and asserts every callback fires: the 2 messages in flight before the timeout succeed and the other 18 get the closed-client error.

QueueMessage checked stopCh and enqueued later, so a producer could add a
message after the batcher had drained and returned; that message was never
published and its callback never ran. An admission RWMutex now makes the
check-and-enqueue atomic with Stop closing stopCh.

Add tests for the shutdown-timeout path (Stop returns at the bound, closes
the client, late batches report errors to every callback) and for producers
racing Stop (every accepted message gets a callback).
@skippaDaBitFlippa

Copy link
Copy Markdown
Author

End-to-end check in the real consumer path: the chainlink node's legacy telemetry client (smartcontractkit/chainlink#23949) sending to a local chip-ingress + redpanda (atlas chip-e2e dev stack). Chainlink was pointed at this branch with a temporary replace. Each run is a burst of 100,000 ocr2-median messages followed by Close(). Settings: MaxBatchSize 1000, MaxConcurrentSends 10, shutdown timeout 5s. Kafka counts come from topic high-watermarks taken before and after each run.

Buffer Before (pinned v0.0.11-0.20260915184316) With this PR
10k 11,000 sent + 79,000 dropped. 10,000 unaccounted (no callback) 21,000 sent + 79,000 dropped = 100,000. Kafka 21,000. Close 2.0s
50k 11,003 sent + 39,000 dropped. ~50,000 unaccounted 60,000 sent + 39,000 dropped + 1,000 send errors = 100,000. Kafka 60,000. Close 5.0s (hit the timeout); the 1,000 in-flight messages reported errors ~0.2s after Close returned
  • Kafka equals the callback-reported sent count in every run.
  • QueueMessage p99 latency stays ~20-25µs.
  • Queue 5,000 then Close(): 5,000/5,000 delivered both before and after the change. The loss only happens when the buffer holds a backlog behind saturated sends.

pkcll
pkcll previously approved these changes Oct 13, 2026
4of9
4of9 previously approved these changes Oct 13, 2026
@skippaDaBitFlippa
skippaDaBitFlippa added this pull request to the merge queue Oct 13, 2026
@skippaDaBitFlippa
skippaDaBitFlippa removed this pull request from the merge queue due to a manual request Oct 13, 2026

@pkcll pkcll 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.

PR Review: #2461 — chipingress/batch: publish queued messages on Stop instead of discarding them

Summary

This fixes a real data-loss bug. Before, batchWithInterval handled ctx.Done() by flushing only the batch it was building, so everything still in messageBuffer was dropped. Those messages had already been accepted by QueueMessage with a nil return, and their callbacks never ran. I reproduced this independently: 2,000 queued, ~200 sent, ~1,800 dropped without callbacks.

The fix is small and well placed:

  • drain() empties the buffer in batches of at most batchSize.
  • An admission RWMutex makes "check stopCh, then enqueue" atomic with respect to Stop closing stopCh, so nothing can be accepted after the drain has run.
  • Behavior after the shutdown timeout is documented and tested.

Ran locally on d8a7031d5:

  • go test ./batch/ -race -count=30 -run TestStop ✅
  • full batch suite -race -count=5 ✅

Jira Context

N/A. Context: chainlink#23949 (legacy-telemetry load test).

Files Reviewed

pkg/chipingress/batch/batcher.go, pkg/chipingress/batch/client.go, pkg/chipingress/batch/client_test.go

Findings

Critical

None.

Major

None.

Minor

m1. No test fails if the admission lock is removed.

I reverted the drain while keeping the lock, and all three new tests fail (3/3), so the drain is well guarded. I then removed the lock and its re-check while keeping the drain. every message accepted while Stop races producers gets a callback still passed 100/100: the window between the fast-path stopCh check and the buffer send is too narrow to hit by chance.

If you're fine with a test seam, a deterministic version is possible: a test-only hook, nil in production, called right after the fast-path check:

// client.go
var testHookAfterFastPath func() // nil in production

// QueueMessage, after the fast-path stopCh check:
if testHookAfterFastPath != nil {
	testHookAfterFastPath()
}

// test: hold one producer at the hook until Stop has returned, then let it continue.
// With the fix it gets ErrClientShutdown. Without the re-check it gets nil and its
// callback never runs.

If you'd rather not add a seam, a sentence in the PR saying the lock is covered by reasoning rather than a test is fine too.

Nitpicks

  • drain has no direct unit test. drain() is now a small pure generic function, so a direct test would pin down chunking (≤ batchSize) and ordering, which the client-level tests don't assert. For example, pre-filled chan int (7 items), initial batch [-1], batchSize 3 ⇒ [[-1 0 1] [2 3 4] [5 6]].
  • len(input) > 0 relies on the batcher being input's only reader. The comment says so, and it's true today. A non-blocking select { case msg := <-input: … default: return } would hold even if a second reader is ever added, at no extra cost.
  • Log noise after the shutdown timeout. Every batch drained after Close() logs failed to publish batch at Error level. With the durable emitter's defaults (10k buffer, batch 50), that's up to ~200 error lines per slow shutdown. Consider one summary line, or Warn level, once stopCh is closed.
  • Exactly-once is checked in aggregate only. The tests compare total callbacks with accepted messages, so a double callback for one message plus a missing one for another would cancel out. A per-message counter would catch both.

Questions

None blocking.

Heads-ups (outside this PR)

  1. Durable emitter shutdown. DurableEmitter.stop() closes deleteCh right after batchEmitter.Stop() returns (durable_emitter.go ~L576-582), and a success callback does a blocking d.deleteCh <- id. If Stop returns on the shutdown timeout while success callbacks are still running, or blocked on a full deleteCh, a late callback panics with send on closed channel. In-flight RPCs are cancelled by Close(), which narrows the window but doesn't close it. Draining makes long shutdowns more likely. The durable emitter guard should land with or before the root-module bump that picks this PR up. No change is needed in this PR.
  2. #2436 (draft) adds an onDequeue hook to batchWithInterval. Whichever merges second needs to call it from drain() as well, or live-byte accounting misses drained messages.
  3. TestCallbacks/callbacks invoked on stop (still skipped on main) has a time.Sleep(10ms) before Stop() that worked around this race. It can go once that test is re-enabled.

Positive Notes

  • Tight diff. The drain sits exactly where the bug was, and the admission lock is minimal: it's held only across a non-blocking check and send.
  • Behavior after the shutdown timeout is defined: late batches fail against the closed client and still report to their callbacks. It's documented on Stop and tested.
  • The tests use per-event PublishResults that match the strict results contract, and each has a clear before/after story in the description.

Recommendation

Approve with minor changes. m1 is optional but recommended; the nits are optional. Fine to merge as is if m1 is consciously deferred.

The producers-vs-Stop stress test passed with the admission lock removed:
the window between the fast-path stop check and the enqueue is too narrow
to hit by chance. Replace it with a test that parks a producer in that
window through a per-client test hook (nil in production), runs Stop, then
resumes the producer and requires ErrClientShutdown. It fails without the
admission re-check.
@skippaDaBitFlippa
skippaDaBitFlippa dismissed stale reviews from 4of9 and pkcll via 456da1f October 13, 2026 21:33
@skippaDaBitFlippa

Copy link
Copy Markdown
Author

@pkcll m1 is done in 456da1f.

  • I went with a per-client field, afterFastPath func(), instead of a package-level var, so parallel tests in the package can't interfere through global state. It's nil in production and is called right after the fast-path stop check.
  • New test: TestStop/QueueMessage_that_passed_the_fast-path_check_before_Stop_is_rejected,_not_abandoned. It parks a producer at the hook, runs Stop to completion, then resumes the producer and requires ErrClientShutdown with no callback.
  • With the admission re-check removed, the test fails 3/3 ("a message accepted after the drain would never be published or called back"). With it in place, it passes 20/20 under -race.
  • I removed the probabilistic producers-vs-Stop test, since it couldn't catch this regression.
  • I haven't done the nits yet.
  • Heads-up 1 (the durable emitter can send on its closed deleteCh after a timed-out Stop) is noted for whoever bumps the root module.


// drain processes batch plus everything already buffered on input, without waiting for new items.
// The batcher is input's only reader, so a non-zero len guarantees the receive won't block.
func drain[T any](input <-chan T, batch []T, batchSize int, processFn func([]T)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: maybe this alternative is a bit clearer:

for {
    select {
    case msg := <-input:
        batch = append(batch, msg)
        if len(batch) >= batchSize {
            processFn(batch)
            batch = nil
        }

    default:
        if len(batch) > 0 {
            processFn(batch)
        }
        return
    }
}

@skippaDaBitFlippa
skippaDaBitFlippa added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit b866ad7 Oct 15, 2026
36 checks passed
@skippaDaBitFlippa
skippaDaBitFlippa deleted the chipingress-batch-drain-on-stop branch October 15, 2026 17:05
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.

5 participants