Skip to content

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

Merged
dinwwwh merged 6 commits into
middleapi:mainfrom
dinwwwh:claude/redis-xadd-publish-race-5edbac
Sep 25, 2026
Merged

dinwwwh merged 6 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 adapters still interoperate.

Performance

  • publish() no longer scans every channel it has used to prune trim times. Measured per publish: 118 µs → 0.06 µs with 10,000 channels, 1.2 ms → 0.04 µs with 100,000.

Breaking changes

  • Custom BaseRedisPublisher subclasses implement evalScript() instead of addStreamEntry(), and RedisStreamTrimOptions is removed.
  • With a node-redis client keyPrefix, Pub/Sub channels now carry the prefix too, so they are isolated like keys. During a rolling deploy, subscribers on older versions miss events from newer publishers.
  • RedisPublisher throws a TypeError when the client's keyPrefix is not a string. A Buffer prefix could hold bytes that are not valid text, so channels would stop matching the stream key.

Docs

  • The prefix option now states it namespaces Pub/Sub channels as well as keys.

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.
  • New keyPrefix tests cover live delivery with and without resume, resume itself, and rejecting a Buffer prefix.
  • Publisher suites (61 tests) and the Bun suite (123 tests) pass against a real Redis and Upstash database.
  • The script 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: 0480e48

@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.10345% 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 (0480e48) with main (0caff28)

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) | 𝕏

Comment thread packages/publisher/src/adapters/base-redis.ts

@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) | 𝕏

…fixes keys

node-redis applies a client keyPrefix to EVAL keys but not to Pub/Sub
channels, so publishing to KEYS[1] sent events to a channel no subscriber
listened on. The script now takes the channel as an argument.

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

Reviewed changes

Reviewed the delta since the prior pullfrog review (8b41094 → da88065): the new commit resolves the keyPrefix regression flagged in review 5301495850, with no other behavioral changes.

  • keyPrefix fix — PUBLISH_SCRIPT now takes the raw channel as ARGV[1] and publishes to it, while KEYS[1] remains the (client-prefixed) stream key for XADD/XTRIM/EXPIRE. The argument list shifted by one so XTRIM MINID still receives exactness then minId.
  • Regression test — a new node-redis test publishes and resumes against a client created with keyPrefix, asserting both live delivery and resume-on-the-unprefixed-channel; it fails against the previous revision.

The concern raised in the prior review is resolved: the stream stays under the prefixed key (so readStreamEntries keeps matching) and PUBLISH targets the unprefixed channel subscribers listen on.

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

Trim times are inserted in time order, so publish() no longer walks every
channel it has used to find expired entries. Also shortens the publish
script comment and closes the keyPrefix test's subscriber on failure.

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

Reviewed changes

Reviewed the delta since the prior pullfrog review (da88065 → 81ef9b5), a performance follow-up on top of the atomic-publish fix.

  • Trim-time pruning short-circuits — publish() now breaks out of the lastTrimTimes sweep at the first still-live entry instead of scanning every channel each publish.
  • keyPrefix regression test cleanup — the test now passes an explicit prefixedRedis.duplicate() subscriber and destroys it via onTestFinished, replacing the (publisher as any).subscriber.destroy() poke.
  • PUBLISH_SCRIPT JSDoc — condensed to the script's argument layout; the old two-round-trip description is gone.

The early break is sound: lastTrimTimes only ever sets a key when it is absent and deletes expired entries from the front, so Map iteration order stays ascending by first-trim time — the first live entry implies every later one is live too. The prior keyPrefix thread remains resolved and no other threads are open.

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

The publish script now uses KEYS[1] as the channel too, dropping the extra
argument. RedisPublisher adds the client's keyPrefix to channels so they
still match, which also isolates channels for keyPrefix users. The prefix
option docs now say it covers Pub/Sub channels as well as keys.

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

Reviewed changes

Reviewed the delta since the prior pullfrog review (81ef9b5 → 4b0069e): a deliberate redesign of node-redis keyPrefix handling that replaces the previous raw-channel publish with channel prefixing that matches the stream key.

  • Channels now carry the client keyPrefix — PUBLISH_SCRIPT publishes on KEYS[1] (prefixed by node-redis) instead of a raw ARGV channel, and RedisPublisher reads redis.options.keyPrefix into a channelPrefix applied to both publishMessage() and subscribeChannel(), so publish, subscribe, XADD, and XREAD all resolve to keyPrefix + prefix + event.
  • Script args shift — ARGV[1] is now the payload; ARGV[2..4] stay exactness/minId/expireSeconds.
  • keyPrefix test extended — adds a non-resume publisher (covering the publishMessage() path) alongside the resume path, with an explicit duplicated subscriber.
  • Docs/JSDoc — the prefix option now states it namespaces Pub/Sub channels as well as keys.

The design is internally consistent: verified against the installed @redis/client@6.2.1 that EVAL prefixes keys while PUBLISH/SUBSCRIBE do not, so the explicit channelPrefix is both necessary and correctly applied, and redis.options.keyPrefix is populated from createClient({ keyPrefix }). tsc -b packages/publisher and eslint pass.

ℹ️ Rolling deploy loses events in both directions

The breaking change (channels carry the client keyPrefix) means a mixed-version fleet loses events in both directions, not just the one the PR body calls out. This only affects users who set a node-redis client keyPrefix.

Technical details
# Channel name changes under a client keyPrefix

## Affected sites
- `packages/publisher/src/adapters/redis.ts:38` — `channelPrefix` sourced from `redis.options.keyPrefix`.
- `packages/publisher/src/adapters/redis.ts:43`, `:47` — publish/subscribe on `keyPrefix + prefix + event`.
- `packages/publisher/src/adapters/base-redis.ts:15` — script `PUBLISH KEYS[1]` (prefixed).

## Required outcome
- None for the code; the change is intentional and matches the documented breaking note.

## Open questions for the human
- Confirm the release/migration note covers both directions: a new subscriber misses an old publisher's events, and an old subscriber misses a new publisher's events. The PR body mentions only the latter.

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

Channels get the client's keyPrefix as text, so a Buffer prefix with
non-UTF-8 bytes would no longer match the stream key and live delivery
would silently stop. The constructor now throws a TypeError instead.

@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 critical issues — one minor suggestion inline.

Reviewed changes

Reviewed the delta since the prior pullfrog review (4b0069e → 0480e48): a single constructor guard in RedisPublisher.

  • Reject non-string keyPrefix — RedisPublisher now throws a TypeError at construction when redis.options.keyPrefix is defined but not a string, checked before channelPrefix is derived and before redis.duplicate() runs.
  • Regression test — a unit test asserts the constructor throws for a Buffer keyPrefix.

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


const keyPrefix = redis.options?.keyPrefix

if (keyPrefix !== undefined && typeof keyPrefix !== '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.

node-redis types keyPrefix as RedisArgument (string | Buffer) and its parser prefixes keys from a Buffer via Buffer.concat, so a Buffer keyPrefix is type-legal and previously worked: the prior revision derived the channel name with String(keyPrefix), which matches the prefixed key bytes for UTF-8. This guard turns that into a construction-time TypeError, an additional runtime restriction that is not listed in the PR body's breaking-changes section.

Technical details
# Non-string `keyPrefix` now throws at construction

## Affected sites
- `packages/publisher/src/adapters/redis.ts:39` — guard rejects any defined non-string `keyPrefix`.
- `packages/publisher/src/adapters/redis.ts:44` — `channelPrefix = keyPrefix ?? ''`.

## Evidence
- `@redis/client@6.2.1`: `RedisClientOptions.keyPrefix?: RedisArgument`, `RedisArgument = string | Buffer` (`dist/lib/RESP/types.d.ts:74`).
- `prefixKey()` (`dist/lib/client/parser.js:11`) uses `Buffer.concat` when either side is a `Buffer`, so a `Buffer` prefix is a supported runtime path.

## Required outcome
- A `Buffer` keyPrefix either keeps working (UTF-8) or is called out as now throwing in the release notes / breaking-changes list.

## Open questions for the human
- Is rejecting `Buffer` intentional? If so, add it to the PR body's breaking-changes list.

@dinwwwh
dinwwwh merged commit ab120b7 into middleapi:main Sep 25, 2026
10 of 11 checks passed
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