Skip to content

fix(publisher, bun)!: keep Redis Pub/Sub delivery in stream order - #2073

Open
dinwwwh wants to merge 2 commits into
middleapi:mainfrom
dinwwwh:claude/redis-xadd-publish-race-5edbac
Open

dinwwwh wants to merge 2 commits into
middleapi:mainfrom
dinwwwh:claude/redis-xadd-publish-race-5edbac

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

With resume enabled, publish() added the stream entry and published it in two separate round trips, so concurrent publishers could deliver events out of stream order. A subscriber resuming from its last received ID could then skip an event or receive one twice. The Redis adapters now add and publish in a single Lua script, so live delivery always matches stream order.

Fixes

  • Live events arrive in stream order under concurrent publishers (node-redis, Upstash, Bun), so resuming from the last received ID no longer skips or repeats events.
  • One round trip per publish instead of two when resume is enabled.
  • The message format is unchanged, so mixed versions and adapters still interoperate.

Breaking changes

  • Custom BaseRedisPublisher subclasses implement evalScript() instead of addStreamEntry(), and RedisStreamTrimOptions is removed.

Testing

  • New concurrent-publish test in the Redis, Upstash, and Bun suites. It fails on the old code (10/10, 3/3, and 10/10 runs) and passes with the fix.
  • Full publisher and Bun suites pass on local Redis 8.0.5; the Upstash and cross-adapter suites pass on a real Upstash database.
  • The script also keeps stream order on a local 3-master Redis Cluster, with subscribers on every node (manual check).
  • Lint, scoped type checks, and the JSDoc backlink check pass.

@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2073

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2073

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2073

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2073

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2073

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2073

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2073

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2073

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2073

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2073

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2073

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2073

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2073

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2073

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2073

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2073

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2073

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2073

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2073

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2073

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2073

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2073

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2073

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2073

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2073

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2073

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2073

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2073

commit: f3bdab5

@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/publisher/src/adapters/upstash.ts 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing dinwwwh:claude/redis-xadd-publish-race-5edbac (f3bdab5) with main (a16c885)

Open in CodSpeed

With resume enabled, publish() ran XADD and PUBLISH as two round trips, so
concurrent publishers could deliver events out of stream order and a
subscriber resuming from its last received ID could skip or repeat events.
The Redis adapters now add and publish in one Lua script.

BREAKING CHANGE: BaseRedisPublisher subclasses implement evalScript()
instead of addStreamEntry(), and RedisStreamTrimOptions is removed.
@dinwwwh
dinwwwh force-pushed the claude/redis-xadd-publish-race-5edbac branch from 8584fe9 to 8b41094 Compare September 24, 2026 07:40
@dinwwwh dinwwwh changed the title fix(publisher, bun)!: publish Redis events atomically and support cluster clients fix(publisher, bun)!: keep Redis Pub/Sub delivery in stream order Sep 24, 2026

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

Important

The atomic-publish script publishes to KEYS[1], which node-redis prefixes, while Pub/Sub channels are never prefixed — so a client-level keyPrefix silently breaks live delivery on the resume path. Details inline.

Reviewed changes

  • Atomic resume publish — publish() now runs one Lua script (XADD + optional XTRIM/EXPIRE + PUBLISH) through a new evalScript() hook, so live Pub/Sub delivery order matches stream order under concurrent publishers.
  • Adapter port — node-redis, Upstash, and Bun implement evalScript() instead of addStreamEntry(); RedisStreamTrimOptions is removed (breaking).
  • Cluster support (node-redis) — new structural RedisPublisherClient accepts standalone and cluster clients; connectIfNeeded dedupes in-flight connects; unsubscribe swallows ClientClosedError.
  • Tests — concurrent-publish ordering tests added to the Redis, Upstash, and Bun suites; docs note cluster support.

ℹ️ Cluster-only paths have no automated coverage

The new cluster support rests on three things no CI job exercises (CI runs standalone redis:7-alpine): the in-flight connect() dedup in connectIfNeeded, the ClientClosedError swallow in unsubscribe, and the claim that createCluster() clients satisfy RedisPublisherClient. Manual testing against a real cluster (as the PR describes) is reasonable, but these paths can regress unnoticed. Two cheap additions lock in the parts that do not need a live cluster: a *.test-d.ts asserting createCluster() is assignable to RedisPublisherClient (the lock/ratelimit adapters encode cluster support as an explicit RedisClientType | RedisClusterType union rather than a structural interface), and a unit test using a stub client whose isOpen stays false while connect() is pending, asserting concurrent callers await a single connect.

Technical details
# Lock the type-level and dedup guarantees without a live cluster

## Affected sites
- `packages/publisher/src/adapters/redis.ts:11-20` — `RedisPublisherClient`; no type test asserts cluster assignability.
- `packages/publisher/src/adapters/redis.ts:92-102` — `pendingConnects` dedup, only reachable with a cluster-like client whose `isOpen` is `true` before topology is ready.
- `packages/publisher/src/adapters/redis.ts:61-72` — `ClientClosedError` swallow.

## Required outcome
- A failing change to the interface (e.g. requiring an extra member cluster clients lack) or to the dedup logic should be caught by CI.

## Suggested approach
- `*.test-d.ts` with `const client: RedisPublisherClient = createCluster({...})`.
- A unit test passing a stub implementing `RedisPublisherClient` where `connect()` resolves after a tick while `isOpen` remains `false`, then firing concurrent `publish()` calls and asserting `connect()` ran once.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

* concurrent publishers, so a subscriber resuming from its last received ID skips nothing.
* Kept on one line because `EVAL` sends it with every call.
*/
const PUBLISH_SCRIPT = `local id=redis.call('XADD',KEYS[1],'*','data',ARGV[1]) if ARGV[2] then redis.call('XTRIM',KEYS[1],'MINID',ARGV[2],ARGV[3]) redis.call('EXPIRE',KEYS[1],ARGV[4]) end redis.call('PUBLISH',KEYS[1],'{"data":'..ARGV[1]..',"id":"'..id..'"}')`

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.

With a node-redis client-level keyPrefix, this publishes to the wrong channel. node-redis prefixes the EVAL keys (KEYS[1]) but never Pub/Sub channels (the keyPrefix option docs state "Pub/Sub channels are NOT prefixed"), while subscribe() listens on the unprefixed channel — so live delivery on the resume path silently stops. Before this PR publishMessage() sent the unprefixed channel, so it worked.

Technical details
# Publish to the same channel subscribers use

## Affected sites
- `packages/publisher/src/adapters/base-redis.ts:18` — `PUBLISH` targets `KEYS[1]`.
- `packages/publisher/src/adapters/redis.ts:76` — `redis.eval(script, { keys: [channel], ... })` applies the client `keyPrefix` to that key.

## Evidence
- `@redis/client@6.2.1` parser: `EVAL` uses `pushKeys` (prefix applied), `XADD`/`XREAD`/`XTRIM` use `pushKey` (prefix applied), `PUBLISH` uses a plain `parser.push` (no prefix). The `keyPrefix` JSDoc: "Pub/Sub channels are NOT prefixed".
- Reproduced at the parser level with `keyPrefix: 'app:'`: EVAL args are `["EVAL","S","1","app:chan","d"]` while PUBLISH args are `["PUBLISH","chan","msg"]`.
- Old code: `addStreamEntry` used the prefixed `XADD` key, `publishMessage` used the unprefixed channel — the stream and the channel were independently correct.

## Required outcome
- With `keyPrefix` set, a live subscriber on `channel` must receive events, while the stream stays under the prefixed key so `readStreamEntries` keeps matching.

## Suggested approach
- Pass the raw (unprefixed) channel to the script as an `ARGV` and `PUBLISH` to that, keeping `KEYS[1]` for `XADD`/`XTRIM`/`EXPIRE` (needed for stream reads and cluster slot routing). Bun and Upstash have no key prefix, so the two values are identical there today.

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

ℹ️ No new issues in this delta — the node-redis cluster work was reverted, narrowing the PR to the atomic-publish fix. The keyPrefix finding from the prior review is unaffected by that revert; it is not repeated here.

Reviewed changes

Reviewed the delta since the prior pullfrog review (8584fe9 → 8b41094): the cluster-support changes are gone, leaving only the atomic-publish fix.

  • Cluster support reverted — RedisPublisherClient, the in-flight connect() dedup (pendingConnects), the ClientClosedError swallow in unsubscribe, and the xRead cast are all removed; redis.ts is back to RedisClientType with the original connectIfNeeded/unsubscribe. The publisher.mdx cluster comment is removed too.
  • Atomic publish retained — PUBLISH_SCRIPT + evalScript() in base-redis.ts, the three adapter ports, and the concurrent-publish tests are unchanged from the prior revision.
  • No new issues from the revert — the restored connectIfNeeded/unsubscribe match the base behavior, which is correct for standalone clients (a standalone connect() flips isOpen synchronously, so concurrent callers do not double-connect).

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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.

1 participant