Skip to content

Do not deliver pub/sub push messages as replies outside an active subscription - #4701

Merged
uglide merged 5 commits into
masterfrom
fix/pubsub-push-outside-subscription
Aug 26, 2026
Merged

uglide merged 5 commits into
masterfrom
fix/pubsub-push-outside-subscription

Conversation

@uglide

@uglide uglide commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A RESP3 pub/sub push arriving while no pub/sub read loop is running β€” e.g. a message frame the server delivers after the unsubscribe confirmation, as observed behind the Redis Enterprise DMC proxy β€” was propagated by the default push consumer, and Protocol.process returned it as the next command's reply. The reply stream desyncs and the command typically fails with:

java.lang.ClassCastException: java.util.ArrayList cannot be cast to [B
    at redis.clients.jedis.BuilderFactory$18.build(BuilderFactory.java:283)

This is the root cause of the flaky advancedPubsubWithClientCache failures in the Redis Enterprise nightly CI (any client that reuses a pooled connection after subscribe/unsubscribe is exposed).

Fix

  • Connection tracks a volatile activeSubscription flag, set/cleared around the subscribe/process loops of JedisPubSubBase (proceed, proceedWithPatterns) and JedisShardedPubSubBase (proceed) β€” cleared in finally, so pooled connections are always returned with the flag off.
  • The default pub/sub push consumer is now created per connection via PushConsumerChainImpl.pubSubConsumer(BooleanSupplier) and only propagates pub/sub-typed pushes while the flag is set. Outside a subscription, the push is debug-logged and left to the rest of the consumer chain (consumed by default), so Protocol.process continues to the real reply.

Behavior notes

  • During a subscription, behavior is byte-for-byte identical to before; the pub/sub loops keep receiving propagated frames via getUnflushedObject().
  • RESP2 is unaffected: its pub/sub frames arrive as regular multi-bulk replies and never reach the push consumer chain.
  • Custom push consumers keep their propagate/drop contract, and now also observe out-of-subscription pub/sub pushes that the default consumer previously short-circuited.
  • The public PUBSUB_CONSUMER constant is unchanged.

Testing

  • New ConnectionMockTest cases deterministically reproduce the bug with TcpMockServer: a message push injected before a PING corrupts the reply without the fix (verified failing) and is dropped with it; a second test verifies propagation still works while the flag is set.
  • New PushConsumerChainImplTest cases cover the gated consumer for all pub/sub push types and verify chain fall-through semantics when inactive.
  • Full unit suite passes locally.

πŸ€– Generated with Claude Code


Note

Medium Risk
Changes core RESP3 read/push handling on every connection; low risk for normal pub/sub API usage but can affect hand-rolled subscribe + getUnflushedObject() code paths.

Overview
Fixes RESP3 reply-stream corruption when a pub/sub push (e.g. a message after UNSUBSCRIBE, as seen behind Redis Enterprise) arrives on a pooled connection without an active subscribe loop. Those frames were propagated by the default push consumer and returned as the next command reply, often causing ClassCastException.

Connection now tracks activeSubscription, set in finally around JedisPubSubBase / JedisShardedPubSubBase proceed loops. The default consumer is pubSubConsumer(this::isActiveSubscription) instead of always-on PUBSUB_CONSUMER: pub/sub pushes propagate only during a real read loop; otherwise they are debug-logged and not surfaced as command replies. Later **PushConsumer**s can still see those pushes when the default consumer does not propagate. PUBSUB_CONSUMER remains unchanged for unconditional propagation.

Release notes document the behavior change. Tests add mock-server regression coverage and gated-consumer unit tests; auth integration tests use a delegating mock so connection state is not forked by spy().

Reviewed by Cursor Bugbot for commit 344f2e2. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Test Results

β€‡β€ˆ206 files  Β±0β€‚β€ƒβ€‡β€ˆ206 suites  Β±0   7m 32s ⏱️ -6s
8β€ˆ101 tests Β±0  7β€ˆ796 βœ…  -β€Š237  305 πŸ’€ +237  0 ❌ Β±0 
8β€ˆ121 runsβ€Š Β±0  7β€ˆ816 βœ…  -β€Š237  305 πŸ’€ +237  0 ❌ Β±0 

Results for commit 344f2e2. ± Comparison against base commit cb03a88.

This pull request skips 237 tests.
redis.clients.jedis.UnboundRedisClusterClientTest ‑ testAskResponseWithHimportSet
redis.clients.jedis.commands.commandobjects.CommandObjectsListCommandsTest[1] ‑ testLmovemAndBlmovem()[1]
redis.clients.jedis.commands.commandobjects.CommandObjectsListCommandsTest[1] ‑ testLmovemAndBlmovemBinary()[1]
redis.clients.jedis.commands.commandobjects.CommandObjectsListCommandsTest[2] ‑ testLmovemAndBlmovem()[2]
redis.clients.jedis.commands.commandobjects.CommandObjectsListCommandsTest[2] ‑ testLmovemAndBlmovemBinary()[2]
redis.clients.jedis.commands.commandobjects.CommandObjectsSetCommandsTest[1] ‑ testSdiffcard()[1]
redis.clients.jedis.commands.commandobjects.CommandObjectsSetCommandsTest[1] ‑ testSunioncard()[1]
redis.clients.jedis.commands.commandobjects.CommandObjectsSetCommandsTest[2] ‑ testSdiffcard()[2]
redis.clients.jedis.commands.commandobjects.CommandObjectsSetCommandsTest[2] ‑ testSunioncard()[2]
redis.clients.jedis.commands.commandobjects.CommandObjectsTimeSeriesCommandsTest[1] ‑ testTsNRange()[1]
…

♻️ This comment has been updated with latest results.

@uglide
uglide requested review from atakavci and ggivo and removed request for ggivo August 24, 2026 10:13

@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: a77fbd2597

ℹ️ 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".

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

left a note around atomics, which i feel needs change. other than that, looks good to me.

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

LGTM

@ggivo ggivo left a comment

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.

@igor.malinovskiy
LGTM and resolve the issue.
One caveat is that pub/sub messages will no longer be propagated by getUnflushedObject().

An alternative solution: if we could actually flip the logic and drop all pub/sub push messages by default in Connection, and propagate them only in getUnflushedObject() (or a new dedicated method used by JedisPubSub)

uglide and others added 5 commits August 26, 2026 16:36
A RESP3 pub/sub push arriving while no pub/sub read loop is running --
e.g. a message the server delivers after the unsubscribe confirmation,
as observed behind the Redis Enterprise DMC proxy -- was propagated by
the default push consumer and returned by Protocol.process as the next
command's reply, desyncing the stream and typically failing with
ClassCastException: java.util.ArrayList cannot be cast to [B.

Track an active-subscription flag on Connection, set around the
subscribe/process loops of JedisPubSubBase and JedisShardedPubSubBase,
and gate the default pub/sub push consumer on it. During a subscription
behavior is unchanged; outside one, a pub/sub push is logged and left
to the rest of the consumer chain (consumed by default), so the reply
stream stays aligned. RESP2 is unaffected: its pub/sub frames arrive as
regular multi-bulk replies and never reach the push consumer chain.
Custom push consumers keep their propagate/drop contract and now also
observe pub/sub pushes that the default consumer previously
short-circuited.

Fixes the flaky advancedPubsubWithClientCache failures against Redis
Enterprise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TokenBasedAuthenticationIntegrationTests.testJedisPubSubReauth wires the
pooled connection through AuthXManager.addConnection, where the test
substitutes a Mockito spy -- a shallow field-copy of the connection. The
copied pushConsumers chain still held a consumer bound to the ORIGINAL
object's boolean field, while the pub/sub loop set the flag on the copy,
so the SUBSCRIBE confirmation push was dropped and the subscription
never registered.

Keep the flag in an AtomicBoolean holder and let the consumer capture
the holder instead of `this`; shallow copies share the holder, so flag
updates made through any copy stay visible to the chain. Adds a mock
test reproducing the spy wiring (verified failing before this change).
Review feedback: an AtomicBoolean in core Connection is not a fair
trade-off when the only consumer needing it was a test that wraps the
pooled connection in a Mockito spy.

Revert the flag to a plain volatile boolean and fix
testJedisPubSubReauth instead: replace spy(connection) -- which works
on a shallow field-copy and forks the connection's state from its push
consumer chain -- with a delegating mock
(mock(Connection.class, delegatesTo(original))). The mock still records
invocations for the AUTH-count verification but forwards every call to
the single real Connection, so the subscription flag set by the pub/sub
loop stays visible to the push consumer chain.

Also drop the ConnectionMockTest case that encoded the shallow-copy
support, since that pattern is no longer meant to be supported.
The flag is written and read only by the thread currently driving the
connection: inside the subscription the pub/sub loop thread both sets
the flag and reads the pushes, and across threads the pool hand-off
(returnObject/borrowObject) establishes the necessary happens-before.
This matches the existing convention for connection state (e.g. the
plain `broken` field).

Also add the pub/sub push behavior change to the 8.1.0 release notes.
@uglide
uglide force-pushed the fix/pubsub-push-outside-subscription branch from 8bb0e1f to 344f2e2 Compare August 26, 2026 14:37
@uglide

uglide commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

run integration tests

@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: 344f2e2d18

ℹ️ 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".

subscribe(channels);
process();
} finally {
authenticator.client.setActiveSubscription(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the subscription gate before the final callback

On RESP3, when the final UNSUBSCRIBE/PUNSUBSCRIBE confirmation sets subscribedChannels to zero, its callback still runs inside process() while this flag remains true. If that callback issues a normal command through the same Jedis connection and a delayed pub/sub push arrives before the command reply, the gated consumer propagates the push and the command receives the wrong responseβ€”the corruption this change is intended to prevent. Clear the gate as soon as the confirmation count reaches zero, before invoking the final unsubscribe callback; the sharded loop has the same ordering.

Useful? React with πŸ‘Β / πŸ‘Ž.

@uglide
uglide merged commit 4c3d45c into master Aug 26, 2026
15 checks passed
@uglide
uglide deleted the fix/pubsub-push-outside-subscription branch August 26, 2026 15:24
@uglide

uglide commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Jedis RE Integration Tests (RE 8.0.16)

Tests Passed βœ… Failed ❌ Skipped ⏭️ Flaky πŸ‚ Duration ⏱️
7486 5714 6 1766 0 not captured

---- Details for maintainers

Github Test Reporter by CTRF πŸ’š

@uglide

uglide commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Jedis RE Integration Tests (RE 100.0.20)

Tests Passed βœ… Failed ❌ Skipped ⏭️ Flaky πŸ‚ Duration ⏱️
7486 6402 10 1074 0 not captured

---- Details for maintainers

Github Test Reporter by CTRF πŸ’š

@uglide

uglide commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Jedis RE Integration Tests (RE 8.0.22)

Tests Passed βœ… Failed ❌ Skipped ⏭️ Flaky πŸ‚ Duration ⏱️
7486 5798 9 1679 0 not captured

---- Details for maintainers

Github Test Reporter by CTRF πŸ’š

@ggivo ggivo added the bug label Aug 28, 2026
@ggivo ggivo added this to the 8.1.0 milestone Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants