Skip to content

fix(chip-router): per-event results and write-service parity for Publish/PublishBatch - #2877

Draft
pkcll wants to merge 13 commits into
mainfrom
fix/chip-router-per-event-publish-results
Draft

pkcll wants to merge 13 commits into
mainfrom
fix/chip-router-per-event-publish-results

Conversation

@pkcll

@pkcll pkcll commented Oct 26, 2026 •

Copy link
Copy Markdown

Recreates #2861 (opened by cawthorne, closed in favor of this PR). Same branch and commit history; earlier review threads and CI history stay on the original.

A previous attempt to fix think in chainlink-common by reverting smartcontractkit/chainlink-common@dc8a1dc was here:
smartcontractkit/chainlink-common#2446 (comment)
But that PR has been closed for now, since it was intentional and implemented due to an audit finding:
https://ainv.smartcontract.com/findings/79362753-0fef-4a50-934c-572e8f7c504d

Summary

PublishBatch always returned an empty PublishResponse regardless of the batch's outcome. The node-side batch client (chainlink-common/pkg/chipingress/batch) requires one PublishResult per event to resolve delivery: since chainlink-common#2326 (an audit remediation for a data-loss finding) an empty results array is reported to every event's callback as ErrCodeResultsMismatch.

Against this router, a chainlink v2.67.2-rc.0 node's durable emitter therefore treated every event as undelivered and retransmitted the whole stream every 60s, forever:

  • measured: 301,385 failed to deliver event. Relying on retransmit. warnings on a single node, first at 12s after boot, zero successful acks;
  • the Kafka topic filled with duplicate retransmits (~20x counter inflation, ~4k msg/s);
  • metric-event delivery latency gapped 1.5–4.5 min, breaking downstream freshness-gated consumers (CRE fire drills' time() - <metric>_ts < 120 gates).

The events were forwarded to Kafka on every attempt — only the acks were missing — so the storm duplicated the stream rather than losing data.

The fix

The router now follows the atlas chip-ingress write-service (internal/server/server.go, internal/service/write_service.go). This includes the follow-up #2873 that was merged into this branch.

PublishBatch

  • Returns exactly one PublishResult per event, index-aligned with the batch (EventId echoed), merged across subscribers. An acceptance always wins over a rejection and a rejection only fills an empty slot, so the outcome does not depend on subscriber completion order. A nil-error response with no results (a detail-less success) accepts the whole batch.
  • A subscriber response with a non-empty result list whose length differs from the batch cannot be aligned with the events, so it is ignored as a failed forward. If that leaves no usable subscriber, the Internal error says so: which subscriber (name, endpoint), what it returned (returned N results for M events), and what a correct response is (exactly one PublishResult per event in input order, or no results to accept the whole batch).
  • Partial delivery (default): per-event rejections are passed through in a nil-error response. With transaction_enabled=true, any unaccepted event fails the RPC, because transactional callers resolve the whole batch from the RPC outcome alone.
  • Fails the RPC with codes.Internal when no subscribers are registered or no forward succeeded, so durable callers retain and retry (at-least-once) instead of acking events nothing carried.
  • An empty batch is a no-op success with empty results, before any subscriber check.

Publish (single event) is all-or-nothing like the write-service: no subscribers, or no subscriber accepted the event → codes.Internal (the message carries the subscriber's rejection reason); success → exactly one PublishResult.

Also

  • Ping is implemented (static pong) for write-service surface parity.
  • Every Internal failure message is meant to be actionable on its own: per-subscriber causes (RPC error or result-count mismatch, sorted and bounded), how to register a subscriber when none is registered, and for a transaction_enabled batch which events were rejected and that sending it without transaction_enabled delivers the accepted ones.
  • Failure RPCs use codes.Internal, the write-service's failure code, instead of codes.Unavailable.
  • Image build fixed without a Dockerfile workaround. main's Dockerfile cannot build today: go.mod's replace ... => ../../ (added by update to testcontainers-go version with moby #2510) does not resolve inside the container, so go mod download fails. Instead of deleting the replace and running go mod tidy at build time (which resolves missing packages at latest and shifts versions, so a rebuild of the same commit can differ), this PR removes the replace and requires the published framework v0.16.0, the first release that compiles with this module's moby pins (v0.15.21 and earlier fail in promtail.go; consumers such as chainlink already pin >= v0.16.6, so nothing is lifted for them). The Dockerfile is then plain go mod download → go build -mod=readonly from the checked-in go.mod/go.sum. The binary no longer imports framework (own zerolog logger mirroring framework.L); builder golang:1.26.6. Trade-off: the library is built against the published framework, not the in-repo one; examples/chip_router still replaces both modules locally.
  • Bumps the chainlink-common/pkg/chipingress pin (Dec 2025 → Sep 2026): the old pin's pb predates PublishResult.Error.
  • Release notes are in the module's own changeset, framework/components/chiprouter/.changeset/v1.0.5.md.

Tests

go test -race -count=20 ./cmd/chip-router/ — 26 tests covering: per-event results with matching ids; empty batch; no-subscribers and all-forwards-failed (Internal); downstream rejection pass-through and survival of another subscriber's RPC failure; acceptance-wins / order independence; detail-less acceptance; length-mismatch handling; transactional partial / all-rejected / all-accepted; single-event Publish (one result, no subscribers, all forwards failed, all rejected, detail-less success, empty rejection reason); Ping; and the contents of the failure messages above. golangci-lint (repo config): 0 issues.

TestLocalS3 is skipped on main (#2869: MinIO's quay.io image no longer allows anonymous pulls); this branch includes that skip.

Docker image

Built and exercised for real from this branch (06798d63; Docker Desktop 29.8.2, arm64 host, context = the module dir as in chainlink's docker_ctx):

  • docker build -f framework/components/chiprouter/Dockerfile framework/components/chiprouter succeeds: go mod download 5.6s, go build -mod=readonly 45.6s, no sed/tidy. With an empty module cache and GOFLAGS=-mod=readonly the binary also builds (exit 0) and leaves go.mod/go.sum unchanged. The image is linux/amd64 (the Dockerfile's existing TARGETARCH=amd64 default), non-root (uid 65532), entrypoint /chip-router.
  • Black-box run of the container over its real gRPC and admin ports (stub subscribers on the host), 9 checks, all passing: Ping → pong; Publish/PublishBatch with no subscribers → Internal with how to register one; empty batch → OK with 0 results; accepting subscriber → one positional result per event / exactly one for Publish; only subscriber returning a short list, a transaction_enabled rejection and a dead subscriber → Internal with the actionable messages; partial mode → OK with the per-event rejection.
  • A source-only change rebuilds with the go mod download layer CACHED (only the compile step reruns).
  • go mod tidy is stable in chiprouter and in examples/chip_router, which only picks up the framework version bump.

cawthorne and others added 10 commits October 7, 2026 01:58
PublishBatch always returned an empty PublishResponse regardless of the
batch's outcome. The node-side batch client
(chainlink-common/pkg/chipingress/batch) requires one PublishResult per
event to resolve delivery: since chainlink-common#2326 an empty results
array is reported to every event's callback as ErrCodeResultsMismatch,
so a chainlink v2.67 node's durable emitter against this router treated
every event as undelivered and retransmitted the whole stream every 60s,
forever — measured at 301k 'failed to deliver event. Relying on
retransmit.' warnings on a single node within minutes of startup, a ~20x
duplicated Kafka topic, and metric delivery-latency gaps long enough to
break downstream freshness gates.

- Ack each event (PublishResult{EventId}, nil error) when the batch was
  handed to at least one subscriber; the events were forwarded in that
  case, so the caller can resolve delivery and stop retransmitting.
- Report a per-event PublishError when no subscriber accepted the batch,
  so the caller retains and retries (at-least-once preserved).
- Return Unavailable when no subscribers are registered instead of an
  empty success — the previous behavior acked events that went nowhere,
  letting a durable store delete them undelivered (the data-loss shape
  flagged by the audit behind chainlink-common#2326).

Bumps the module's chainlink-common/pkg/chipingress pin (Dec 2025 ->
Sep 2026) — the old pin's pb predates PublishResult.Error.
…PC when nothing accepts

Addresses the review findings:

- A nil downstream RPC error does not mean every event was accepted — the
  protocol supports per-event rejections in a successful response. The
  router now aggregates the subscribers' per-event results and passes a
  rejection through only while no subscriber has accepted the event.
  Subscribers that accept without per-event detail (nil-error response,
  empty results array) count as accepting the whole batch — the router
  trusts a subscriber's RPC success at the forwarding boundary, the same
  as Publish does.
- When no subscriber accepts the batch, return Unavailable instead of a
  nil-error response with per-event errors: transactional callers resolve
  the whole batch from the RPC outcome alone, so per-event errors would
  still acknowledge everything. An RPC error makes every caller mode
  retain and retry.
…on fails the RPC

A subscriber's nil RPC error does not mean its events were accepted: the
pinned protocol reports per-event rejections in a successful response.
Renamed the flag to anyForwarded (what an RPC success actually tells the
router) and gated the response on the batch's own PublishOptions:

- transaction_enabled: any unaccepted event fails the RPC (Unavailable) —
  transactional callers resolve the whole batch from the RPC outcome
  alone, so per-event errors would still acknowledge everything,
  including events no subscriber produced.
- otherwise: per-event results as before (rejections passed through,
  acceptance wins over rejection across subscribers).
…ntics (#2873)

* fix(chip-router): match atlas write-service Publish/PublishBatch semantics

Aligns the router with the atlas chip-ingress write-service, addressing
the review findings on #2861:

- Publish (single event) now follows the write-service's all-or-nothing
  contract (atlas chip-ingress internal/server: Publish delegates to
  PublishCloudEvents with transaction_enabled=true): it fails the RPC
  when no subscribers are registered, when every forward fails, or when
  no subscriber accepts the event, and returns exactly one PublishResult
  on success. Previously it returned an empty PublishResponse with a nil
  error unconditionally, acking events that went nowhere.
- Failure RPCs use codes.Internal, the write-service's failure code, in
  place of codes.Unavailable.
- An empty batch short-circuits to a no-op success before the subscriber
  check, matching the write-service's empty-batch path.
- The per-event detail merge is now order-independent and sized to the
  batch: a later subscriber's results can extend detail beyond the first
  provider's length (previously trailing events were acked with no
  detail), and a detail-less success (empty results, nil error) now
  accepts the whole batch, clearing other subscribers' rejections —
  the intent documented in #2861 but not enforced by its merge.

Tests: single-event success/failure paths, empty batch, detail-less
acceptance, merge extension past the first provider's detail, rejection
surviving another subscriber's RPC failure; stub simplified to an
immutable struct.

* fix(chip-router): bump builder image to golang:1.26.6 to match go.mod

go.mod requires go >= 1.26.6 (bumped alongside the pkg/chipingress pin),
so the pinned golang:1.25.3 builder fails at 'go mod download' with
'go: go.mod requires go >= 1.26.6 (running go 1.25.3; GOTOOLCHAIN=local)'.

* fix(chip-router): implement Ping for write-service surface parity

The write-service answers Ping with a static pong; the router inherited
UnimplementedChipIngressServer's Unimplemented for it, which the new
atlas parity suite's P0 surface probe flags as drift. Ping never needs
to reach subscribers, so answer directly like the write-service.

* fix(chip-router): make the image build self-contained (no framework dep in the binary)

The image build recipe (chainlink cre environment: docker_ctx =
framework/components/chiprouter) passes the module dir as the build
context, so go.mod's `replace framework => ../../` (a monorepo-dev
convenience inherited from main) cannot resolve inside the container and
'go mod download' fails. Published framework releases also do not
compile against this module's moby pins, so the replace cannot simply be
dropped for the module as a whole.

The router binary only used framework for its global logger, so:
- cmd/chip-router now carries its own zerolog logger, mirroring
  framework.L's setup (CTF_LOG_LEVEL env var, console writer to stderr);
  output format is unchanged.
- the Dockerfile drops the replace for the isolated build and runs
  'go mod tidy' so the published framework — needed only by the
  deploy-side library, which is not compiled into the image — resolves
  from the proxy for module-graph completeness.

The deploy-side library (chiprouter.go) keeps its framework dependency
and the monorepo replace.

* fix(chip-router): reject mismatched batch results and tighten Publish failure path

PublishBatch now treats a non-empty subscriber response whose result count
differs from the batch size as a contract violation (the write-service returns
exactly one result per event in input order): it is logged at Warn and counted
as a failed forward instead of silently acking the events it does not cover.
Empty responses stay a detail-less success, which chainlink's chip-testsink
subscriber relies on.

Publish drops the redundant anyForwarded flag and surfaces the subscriber's
rejection reason in the Internal error when no subscriber accepted the event.

Tests: rewrite the merge test into mismatch cases, make the detail-less
acceptance test genuinely detail-less, and cover the single-subscriber
empty-response Publish path. Also fix gofmt import order and make the
package logger unexported.
…eset

chiprouter is its own Go module (tags framework/components/chiprouter/vX.Y.Z)
and consumers pin it separately. release-go-module.yml reads .changeset/<version>.md
from the module directory and fails when it is missing, so the notes in
framework/.changeset/v0.16.10.md could not release the router, while a framework
release would have carried no framework change. Move them to chiprouter v1.0.5.
…ssage

A subscriber may reject an event without a PublishError.Reason, which made the
router return a Publish error with an empty gRPC message. Prefix the message
('event rejected by subscribers: <reason>') so it is never empty.
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

📊 API Diff Results

No changes detected for module github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter

View full report

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

📊 API Diff Results

No changes detected for module github.com/smartcontractkit/chainlink-testing-framework/framework

View full report

… actionable

When no subscriber was usable the RPC failed with a bare 'all N subscribers failed
to accept the batch'; the reason (an RPC error, or a subscriber returning a result
list whose length differs from the batch) was only in the router's log, so a caller
could not tell what to fix.

Record why each subscriber was ignored and put it in the status message: subscriber
name and endpoint, what it did (RPC error, or 'returned N results for M events;
a subscriber must return exactly one PublishResult per event, in input order, or no
results to accept the whole batch'). Entries are sorted by subscriber so the text
does not depend on completion order, and bounded (5 subscribers, 200 chars of a
downstream error).

Also: the no-subscribers error says how to register one (POST /subscribers); a
transactional rejection lists which events were rejected and why, and that sending
the batch without transaction_enabled delivers the accepted ones; a rejection
without a reason is worded instead of left empty. Tests pin the message contents.
@pkcll
pkcll marked this pull request as ready for review October 27, 2026 12:47
@pkcll
pkcll requested a review from a team as a code owner October 27, 2026 12:47
Copilot AI balanced review requested due to automatic review settings October 27, 2026 12:47
@pkcll
pkcll marked this pull request as draft October 27, 2026 12:48
…ter module

The Dockerfile deleted go.mod's replace and ran 'go mod tidy' at build time.
Tidy resolves packages missing from go.mod at latest and shifts direct deps
(x/sync 0.22->0.23, x/net, x/sys, x/text, x/crypto, genproto, otel 1.46->1.47 in a
scratch simulation), so an image built from a pinned commit was not reproducible,
and an upstream release needing a newer go than the pinned builder broke the build
with no change in this repo.

The router binary imports neither the framework module nor the deploy-side library,
so give cmd/chip-router its own module (go.mod/go.sum checked in; versions equal to
what the parent resolved) and build from those pins only: go mod download from
go.mod+go.sum, then go build -mod=readonly. Same pattern as framework/leak/cmd.

The library module's go.mod no longer requires the binary's dependencies.
@github-actions

Copy link
Copy Markdown

⚠️ API Diff Results - github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter/cmd/chip-router

⚠️ Breaking Changes (1)

package github (1)
  • com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter/cmd/chip-router — 🗑️ Removed

✅ Compatible Changes (1)

package github (1)
  • com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter/cmd/chip-router — ➕ Added

📄 View full apidiff 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.

🔵 Needs a closer look

Delivery-critical acknowledgement changes and the isolated image build warrant human integration validation, and rejection-message bounds remain unresolved.

1 open issue
What changed in this PR

This PR fixes chip-router acknowledgements so durable emitters can resolve delivered events instead of repeatedly retransmitting them, and aligns publishing behavior with the chip-ingress write-service.

Changes:

  • Returns per-event results, merges subscriber outcomes, and handles transactional failures.
  • Adds Ping, actionable failure messages, and publishing regression tests.
  • Makes the router image build self-contained and updates dependencies and release notes.
File Description
framework/​examples/​chip_router/​go.mod Aligns the example’s Go version.
framework/​components/​chiprouter/​go.sum Updates dependency checksums.
framework/​components/​chiprouter/​go.mod Updates Go and publishing dependencies; makes zerolog direct.
framework/​components/​chiprouter/​Dockerfile Updates the builder and isolates dependency resolution.
framework/​components/​chiprouter/​cmd/​chip-router/​main.go Implements acknowledgement semantics, diagnostics, Ping, and local logging.
framework/​components/​chiprouter/​cmd/​chip-router/​main_test.go Adds publishing and failure-message regression coverage.
framework/​components/​chiprouter/​.changeset/​v1.0.5.md Documents delivery and image-build changes.

Review effort: Balanced

if e.GetReason() == "" {
return fmt.Sprintf("rejected (%s, no reason given)", e.GetErrorCode())
}
return e.GetReason()
@pkcll
pkcll requested review from Tofel and removed request for skudasov October 27, 2026 12:53
… a nested module

The image build failed because go.mod's 'replace framework => ../../' (added by #2510)
cannot resolve inside the container, whose build context is the module directory. The
earlier workaround deleted the replace and ran 'go mod tidy' at build time, which resolves
missing packages at latest and shifts versions, so a rebuild of the same commit could differ.

Remove the replace and require the published framework instead. v0.16.0 is the lowest
release that compiles with this module's moby pins (v0.15.21 and earlier fail in
promtail.go on container.HostConfig); consumers already pin framework >= v0.16.6, so
nothing is lifted for them. The module now builds standalone: the Dockerfile is a plain
COPY go.mod go.sum, go mod download, go build -mod=readonly, with no go.mod editing and no
tidy. This supersedes the nested cmd/chip-router module (reverted): one module, cmd/ stays
where it was, the library's dependencies are unchanged.

examples/chip_router keeps replacing framework and chiprouter locally; its go.mod only
picks up the framework version bump from 'go mod tidy'.

This branch has not been deployed

No deployments
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.

3 participants