Visitar URL original
Restore: make the dedicated subscription connection lazy by mgravell · Pull Request #3310 · seredis/StackExchange.Redis · GitHub
Skip to content

Restore: make the dedicated subscription connection lazy - #3310

Merged
mgravell merged 1 commit into
mainfrom
marc/lazy-subscription-connection
Oct 30, 2026
Merged

mgravell merged 1 commit into
mainfrom
marc/lazy-subscription-connection

Conversation

@mgravell

Copy link
Copy Markdown
Member

Problem

The dedicated pub/sub connection (always under RESP2; under RESP3 unless SharedSubscriptionConnection is enabled) was opened eagerly as part of activating every endpoint. A client that never subscribes to anything still paid for a second socket per node, and a second reconnect per node on every failover.

This surfaced on Azure Managed Redis, whose options provider disables the configuration channel: 3.0.x showed a single RESP3 connection, but 3.4.x showed two, with the second idle. The hasSubscriptions gate sketched in #3067 was left commented out.

Change

  • ActivateServer opens the subscription bridge only when ServerEndPoint.RequiresEagerSubscriptionConnection is true: subscriptions supported, not shared, and either a configuration channel is set or subscriptions already exist (and may need to move to this endpoint). Otherwise the existing GetBridge paths create it on first SUBSCRIBE; with the default backlog policy that first command queues until the bridge is up.
  • The "second leg" connect-monitor completion in OnFullyEstablished and the maintenance handoff deadline check used IsSubscriberConnected, which reads an absent bridge as disconnected and would have hung the connect wait. Both now use IsSubscriberConnectedOrNotRequired.
  • The RESP3-wanted-but-negotiated-RESP2 fallback in OnFullyEstablished applies the same gate.
  • The endpoint-state connect log prints Subscription: NotRequired for a bridge that has not been asked for, instead of Disconnected.

Observed via CLIENT LIST

Probe: .NET 10 console app, ConfigurationOptions.Parse, Defaults set to AzureManagedRedisOptionsProvider (TLS off for the local plaintext server), against Redis 8.10.3 in docker. "Default" rows use the stock provider, which sets the configuration channel.

Before (3.4.20):

Provider Protocol Sockets at connect Flags
AMR RESP3 2 N sub=0 / N sub=0 (idle)
Default RESP3 2 N sub=0 / P sub=1
Default RESP2 2 N sub=0 / P sub=1

After (this branch):

Provider Protocol Sockets at connect Flags After explicit Subscribe
AMR RESP3 1 N sub=0 2: N / P sub=1
Default RESP3 2 N sub=0 / P sub=1 (config channel) same, sub=2
Default RESP2 2 N sub=0 / P sub=1 (config channel) same, sub=2

For reference, 3.0.25 with the AMR provider was 1 socket, N sub=0 (RESP3 shared mode, and it did not subscribe to the config channel under RESP3, a known gap).

Tests

  • AzureManagedRedisConnectionCount now asserts one connection before any pub/sub (all four protocol/sharing cells) and the expected count after.
  • New ConfigurationChannelOpensSubscriptionConnectionEagerly covers the eager path under RESP2 and RESP3, including the P flag and sub=1 on the second connection.
  • HelloClusterModeUnitTests.CrossSlotSurvivesSubscriptionReconnect and ConnectionAttemptUnitTests.SuccessfulAttemptsAreReported relied on the eager bridge; both now subscribe first, and the latter asserts no subscription attempt is reported before that.

Filtered local run (pub/sub, subscription, connection, config, maintenance, failover, sentinel, moved, default-options; net8.0): 844 passed, 0 failed, 89 skipped (no cluster/sentinel servers).

Caveat

With BacklogPolicy.FailFast, the first Subscribe on a long-connected multiplexer can throw NoConnectionAvailable: the bridge is created and still connecting at selection time. The subscription is still registered and completes once the bridge is up, but the caller sees an exception where the eager bridge previously hid it. If that matters, server selection for subscription commands could treat "interactive connected, subscription bridge connecting" as selectable; not changed here.

The dedicated pub/sub connection (always under RESP2; under RESP3 unless
SharedSubscriptionConnection is enabled) was opened eagerly as part of
activating every endpoint, so a client that never subscribes to anything
still paid for a second socket per node, and a second reconnect per node
on every failover. Seen on AMR, whose options provider disables the
configuration channel: 3.4.x showed two RESP3 connections where 3.0.x
showed one.

ActivateServer now opens that connection only when there is something to
subscribe to straight away: a configuration channel, or subscriptions that
already exist (and may need to move to this endpoint). Otherwise it is
created on first use, via the existing GetBridge paths; with the default
backlog policy the first SUBSCRIBE queues until it is up.

The connect wait ("second leg") and the maintenance handoff deadline
treated an absent subscription bridge as a disconnected one, which would
have left connects hanging; they now consider a connection that has not
been asked for as not required. The endpoint-state connect log says
"NotRequired" for that case instead of "Disconnected".

Tests: AzureManagedRedisConnectionCount now asserts one connection before
pub/sub and the expected count after; a new test covers the eager case
when a configuration channel is set; two tests that relied on the eager
subscription connection now subscribe first.
@mgravell mgravell changed the title Make the dedicated subscription connection lazy Restore: make the dedicated subscription connection lazy Oct 30, 2026
@mgravell
mgravell merged commit a4400f6 into main Oct 30, 2026
7 of 8 checks passed
@mgravell
mgravell deleted the marc/lazy-subscription-connection branch October 30, 2026 10:15
FloatingMilkshake added a commit to FloatingMilkshake/Azusa that referenced this pull request Oct 30, 2026
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [StackExchange.Redis](https://seredis.dev/) ([source](https://github.com/StackExchange/StackExchange.Redis)) | nuget | patch | `3.4.20` → `3.4.29` |

---

### Release Notes

<details>
<summary>StackExchange/StackExchange.Redis (StackExchange.Redis)</summary>

### [`v3.4.29`](https://github.com/StackExchange/StackExchange.Redis/releases/tag/3.4.29)

[Compare Source](seredis/StackExchange.Redis@3.4.20...3.4.29)

#### What's Changed

- (stabilty) Reset a CycleBuffer segment's start trim when it is recycled ([#&#8203;3302](seredis/StackExchange.Redis#3302)) by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3303](seredis/StackExchange.Redis#3303)
- Restore: make the dedicated subscription connection lazy by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3310](seredis/StackExchange.Redis#3310)
- Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3312](seredis/StackExchange.Redis#3312)
- Fix ConnectionGroupMember string-ctor dropping the name by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3314](seredis/StackExchange.Redis#3314)
- Parse unbracketed IPv6 ip:port from the server ([#&#8203;1422](seredis/StackExchange.Redis#1422)) by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3307](seredis/StackExchange.Redis#3307)
- Profile commands sent in a batch ([#&#8203;2175](seredis/StackExchange.Redis#2175)) by [@&#8203;mohsinaliwork686-rgb](https://github.com/mohsinaliwork686-rgb) in [#&#8203;3313](seredis/StackExchange.Redis#3313)

#### Tooling

- gitignore: .claude is per-user state except .claude/skills by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3306](seredis/StackExchange.Redis#3306)
- Command coverage ledger and audit skill by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3311](seredis/StackExchange.Redis#3311)
- Update public API docs by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3429](https://github.com/StackExchange/StackExchange.Redis/pull/3429)

#### New Contributors

- [@&#8203;mohsinaliwork686-rgb](https://github.com/mohsinaliwork686-rgb) made their first contribution in [#&#8203;3313](seredis/StackExchange.Redis#3313)

**Full Changelog**: <seredis/StackExchange.Redis@3.4.20...3.4.29>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTguMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE5OC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.milkshake.services/FloatingMilkshake/Azusa/pulls/13
Reviewed-by: Milkshake <me@floatingmilkshake.com>
FloatingMilkshake added a commit to FloatingMilkshake/MechanicalMilkshake that referenced this pull request Oct 30, 2026
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [StackExchange.Redis](https://seredis.dev/) ([source](https://github.com/StackExchange/StackExchange.Redis)) | nuget | patch | `3.4.20` → `3.4.29` |

---

### Release Notes

<details>
<summary>StackExchange/StackExchange.Redis (StackExchange.Redis)</summary>

### [`v3.4.29`](https://github.com/StackExchange/StackExchange.Redis/releases/tag/3.4.29)

[Compare Source](seredis/StackExchange.Redis@3.4.20...3.4.29)

#### What's Changed

- (stabilty) Reset a CycleBuffer segment's start trim when it is recycled ([#&#8203;3302](seredis/StackExchange.Redis#3302)) by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3303](seredis/StackExchange.Redis#3303)
- Restore: make the dedicated subscription connection lazy by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3310](seredis/StackExchange.Redis#3310)
- Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3312](seredis/StackExchange.Redis#3312)
- Fix ConnectionGroupMember string-ctor dropping the name by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3314](seredis/StackExchange.Redis#3314)
- Parse unbracketed IPv6 ip:port from the server ([#&#8203;1422](seredis/StackExchange.Redis#1422)) by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3307](seredis/StackExchange.Redis#3307)
- Profile commands sent in a batch ([#&#8203;2175](seredis/StackExchange.Redis#2175)) by [@&#8203;mohsinaliwork686-rgb](https://github.com/mohsinaliwork686-rgb) in [#&#8203;3313](seredis/StackExchange.Redis#3313)

#### Tooling

- gitignore: .claude is per-user state except .claude/skills by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3306](seredis/StackExchange.Redis#3306)
- Command coverage ledger and audit skill by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3311](seredis/StackExchange.Redis#3311)
- Update public API docs by [@&#8203;mgravell](https://github.com/mgravell) in [#&#8203;3429](https://github.com/StackExchange/StackExchange.Redis/pull/3429)

#### New Contributors

- [@&#8203;mohsinaliwork686-rgb](https://github.com/mohsinaliwork686-rgb) made their first contribution in [#&#8203;3313](seredis/StackExchange.Redis#3313)

**Full Changelog**: <seredis/StackExchange.Redis@3.4.20...3.4.29>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTguMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE5OC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.milkshake.services/FloatingMilkshake/MechanicalMilkshake/pulls/13
Reviewed-by: Milkshake <me@floatingmilkshake.com>
mgravell added a commit that referenced this pull request Oct 31, 2026
…, batch profiling, API promotion (#3322)

* Make the dedicated subscription connection lazy (#3310)

The dedicated pub/sub connection (always under RESP2; under RESP3 unless
SharedSubscriptionConnection is enabled) was opened eagerly as part of
activating every endpoint, so a client that never subscribes to anything
still paid for a second socket per node, and a second reconnect per node
on every failover. Seen on AMR, whose options provider disables the
configuration channel: 3.4.x showed two RESP3 connections where 3.0.x
showed one.

ActivateServer now opens that connection only when there is something to
subscribe to straight away: a configuration channel, or subscriptions that
already exist (and may need to move to this endpoint). Otherwise it is
created on first use, via the existing GetBridge paths; with the default
backlog policy the first SUBSCRIBE queues until it is up.

The connect wait ("second leg") and the maintenance handoff deadline
treated an absent subscription bridge as a disconnected one, which would
have left connects hanging; they now consider a connection that has not
been asked for as not required. The endpoint-state connect log says
"NotRequired" for that case instead of "Disconnected".

Tests: AzureManagedRedisConnectionCount now asserts one connection before
pub/sub and the expected count after; a new test covers the eager case
when a configuration channel is set; two tests that relied on the eager
subscription connection now subscribe first.

* Command coverage ledger and audit skill (#3311)

* Command coverage ledger and audit skill

Adds docs/CommandCoverage.md: every upstream Redis command the library does
not expose as a typed API, grouped and with a recorded decision (policy,
not applicable, deferred, todo, deprecated, enum-only, implemented). Seeded
from a diff of redis/redis src/commands (477 specs, @6a5fee67) against the
RedisCommand enum and source; it carries forward the dispositions from
#2055 (blocking is policy, Functions and ACL are deferred) and replaces
that hand-maintained list.

Adds the audit-command-coverage skill and its script, which re-runs the
diff on request and reports only what changed: new upstream commands with
no decision, ledger rows the code now covers, rows the spec no longer has,
and enum members the spec does not know. The script exits non-zero when
the ledger needs editing.

Closes #2055.

* Ledger: CLIENT TRACKING is v4, cluster/COMMAND introspection is administration, CLUSTER SHARDS note corrected

* Ledger: CLIENT PAUSE/UNPAUSE are operator tooling, not applicable

* Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values (#3312)

* Object-type metadata for native bitmaps (redis/redis#15331), plus the tokens that diff turned up

RedisType gains Bitmap ("bitmap", the TYPE reply for a native roaring
bitmap, 8.12) and Gcra ("gcra", already in the server's type-name table).
KeyNotificationType gains SetBit (emitted under class $ for string bitmaps
and class b for native ones) and the other event names the server emits
that the enum lacked: arinsert, armset, arring, arseek, gcra, pfadd,
sexpire, spersist, sinterstore, zrangestore, and keymiss (class m, side
effect group).

Fixes the Copy token: the server has only ever emitted "copy_to", so the
"copy" token never parsed and a RedisChannel.KeyEvent(KeyNotificationType.Copy)
subscription never fired. The member and its value are unchanged.

Both parsers are generated from [AsciiHash]; tests extend the token tables.

* Drop Gcra: the type was withdrawn before release, so there is nothing for the token to match

* Add sunionstore/sdiffstore events (from the keyspace-notifications docs); drop the history comment on Copy

* Fix ConnectionGroupMember string-ctor dropping the name (#3314)

* Forward the name argument in ConnectionGroupMember's string constructor

The string overload chained to the ConfigurationOptions constructor without passing `name`, so every
member built from a configuration string (the idiom used throughout docs/Failover.md) was named after
its first endpoint by Init() instead of the name the caller gave it. Regression test added.

* Parenthesize a Debug-only assertion that trips SA1407

`endSegmentLength == 0 & endSegmentCommitted == 0` in DebugAssertValid sits inside `#if DEBUG`, so CI
(Release) never compiles it and never saw the StyleCop error; any local Debug build of RESPite failed
with TreatWarningsAsErrors. Introduced by ee47e53 after the StyleCop bump in aa1e5c0.

* Parse unbracketed IPv6 ip:port from the server (#1422) (#3307)

* Parse unbracketed IPv6 ip:port from the server (#1422)

CLUSTER NODES, MOVED/ASK and CLIENT LIST write IPv6 addresses without
brackets, i.e. "2a06:4e80:3:a::a:7000". The RFC 3986 parser used for
configuration strings reads that as a (valid!) eight-group address with
no port, so cluster nodes ended up as "[2a06:4e80:3:a::a:7000]:0" and
redirections and client addresses were equally wrong.

Add Format.TryParseServerEndPoint for the server's "ip:port" form: when
there is no ']' and more than one ':', split at the last colon and
require both halves to parse; anything else goes to the existing parser
unchanged, so configuration parsing is not touched. Use it at the three
sites that receive server-emitted text.

The sentinel +switch-master handler gets host and port as separate
tokens and was gluing them back together with ':' before parsing; pass
them to the (host, port) overload instead.

* Parse unbracketed IPv6 in MOVING/SMIGRATED endpoints

ParseMigrationEndPoint used the configuration-string parser, so an unbracketed IPv6 address parsed as an address with port 0, which the placeholder check then discarded. Use TryParseServerEndPoint, as the other server-emitted sites do; tests for the MOVING successor and SMIGRATED source/target.

* Profile commands sent in a batch (#2175) (#3313)

Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>

* public-api.py: apply *REMOVED* at promotion; promote the API surface since 3.4.0 (#3316)

* public-api.py: apply *REMOVED* entries at promotion instead of copying them

The analyzer records a vanished shipped signature as a *REMOVED* line in
the unshipped file. Promotion moved every unshipped line across verbatim,
so a promotion after any such change would have left the shipped file
with both the dead signature and a *REMOVED* copy of it, and the analyzer
would then fail the build on the dead one (RS0017).

Promotion now consumes the marker: the named signature is dropped from the
shipped file, the marker is not carried over, and a marker that names
nothing shipped is an error. A *REMOVED* line already sitting in a shipped
file is also reported. The "+N promoted" note gains "-N removed", and an
unshipped file holding only removals is still emptied.

* Promote the shipped API surface: everything since 3.4.0

eng/public-api.py --promote. Fifty-two StackExchange.Redis entries (the
lag-aware availability surface, FailbackHealthCheck, HealthCheckProbeScope,
the 8.12-independent RedisType and KeyNotificationType tokens) and the
net6.0 SetUserPemCertificate overload move to shipped; the three parameter
renames from #3296 (muxer -> multiplexer on the Azure providers'
AfterConnectAsync, other -> obj on RESPite AsciiHash.Equals) are applied
rather than copied. Every unshipped file is now empty.

* Split third-party notices out of LICENSE so GitHub detects MIT (#3317)

* Split third-party notices out of LICENSE so GitHub detects MIT

The LICENSE file carried ~1.1KB of third-party notes after the MIT text,
which drops it below licensee's similarity threshold; GitHub reports the
repo license as "Other" (SPDX NOASSERTION). Keep LICENSE as the bare MIT
text and move the notes, verbatim, to THIRD-PARTY-NOTICES.md.

* Correct the third-party notices

- Redis: BSD-3 only through 7.2; RSALv2/SSPLv1 from 7.4, AGPLv3 added in
  8.0; point at redis.io/legal/licenses (the old /topics/license URL now
  redirects to the docs landing page).
- redis-doc: repo is archived under redis/redis-doc; note that redis/docs,
  its successor, is CC BY-NC-SA for newer content.
- Drop the Redis-64 and BookSleeve notes: neither is part of the
  development solution any more.
- Add the code actually incorporated from other projects (dotnet/runtime,
  dotnet/corefx, aspnet/BasicMiddleware) and the test-only redis-sharp copy,
  none of which the old notes mentioned.

---------

Co-authored-by: Mohsin Ali <mohsin.ali.work686@gmail.com>
Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>
mgravell added a commit that referenced this pull request Oct 31, 2026
A configuration channel makes the dedicated subscription connection part
of connecting (#3310): ActivateServer dials it eagerly, but the dial is
fire-and-forget by design, and the reconfigure connect wait covered the
interactive leg alone - so on a loaded machine ConnectAsync returned
while the library's own broadcast channel was still dialling.
ConfigurationChannelOpensSubscriptionConnectionEagerly (merged with the
3.4.29 sync) fails exactly there on CI, on both OSes, while fast local
machines win the race.

The wait itself broadens: when RequiresEagerSubscriptionConnection, the
endpoint's availability task also awaits the subscription executor's
ConnectNowAsync (short-circuits when up, joins the dial in flight,
declines onto a completed task under backoff, so the wait stays bounded
by the pass's budget), with a subscription-leg failure deliberately not
faulting the endpoint's availability - the interactive leg decides
selectability, and a refused subscription dial redials on demand.

Deliberately NOT by gating the connect monitors on the second leg, which
is how the v3 core does it (OnFullyEstablished): those monitors complete
on every reconnect, and gating them there re-times subscription
re-placement against one-shot publishes - Resp3DowngradeTests'
SubscribeQueuedBeforeDowngradeDoesNotPoisonInteractive fails
deterministically with the PUBLISH answered :0, the re-SUBSCRIBE a beat
behind it. The monitors keep their single-leg meaning; only the
reconfigure wait, which is what ConnectAsync means by connected, grows
the second leg.

The new test pins the race deterministically: an in-process server that
is slow to accept only the subscription leg (OnAcceptClientAsync now
has an overload seeing the declared connection type) must delay
ConnectAsync, not be escaped by it. Red on unfixed source with the CI
failure's exact shape; green with the fix, both protocols.
mgravell added a commit that referenced this pull request Oct 31, 2026
…3325)

A configuration channel makes the dedicated subscription connection part
of connecting (#3310): ActivateServer dials it eagerly, but the dial is
fire-and-forget by design, and the reconfigure connect wait covered the
interactive leg alone - so on a loaded machine ConnectAsync returned
while the library's own broadcast channel was still dialling.
ConfigurationChannelOpensSubscriptionConnectionEagerly (merged with the
3.4.29 sync) fails exactly there on CI, on both OSes, while fast local
machines win the race.

The wait itself broadens: when RequiresEagerSubscriptionConnection, the
endpoint's availability task also awaits the subscription executor's
ConnectNowAsync (short-circuits when up, joins the dial in flight,
declines onto a completed task under backoff, so the wait stays bounded
by the pass's budget), with a subscription-leg failure deliberately not
faulting the endpoint's availability - the interactive leg decides
selectability, and a refused subscription dial redials on demand.

Deliberately NOT by gating the connect monitors on the second leg, which
is how the v3 core does it (OnFullyEstablished): those monitors complete
on every reconnect, and gating them there re-times subscription
re-placement against one-shot publishes - Resp3DowngradeTests'
SubscribeQueuedBeforeDowngradeDoesNotPoisonInteractive fails
deterministically with the PUBLISH answered :0, the re-SUBSCRIBE a beat
behind it. The monitors keep their single-leg meaning; only the
reconfigure wait, which is what ConnectAsync means by connected, grows
the second leg.

The new test pins the race deterministically: an in-process server that
is slow to accept only the subscription leg (OnAcceptClientAsync now
has an overload seeing the declared connection type) must delay
ConnectAsync, not be escaped by it. Red on unfixed source with the CI
failure's exact shape; green with the fix, both protocols.
rebaseandpanic added a commit to rebaseandpanic/StackExchange.Redis that referenced this pull request Nov 3, 2026
… on demand

ConcurrentManagedConnectReturnsOneChildAndDisposesTheOther waited for
exactly two connections on the data node, the winner's interactive and
subscription ones. Since seredis#3310 the dedicated subscription connection is
opened only when there is something to subscribe to, and this test
configuration has no configuration channel, so the winner holds one
connection and the wait timed out although the losing multiplexer was
disposed. The test now waits for the data node to hold exactly the
connections the winner reports.

SentinelAddressIsRetainedWhileReconfigureIsBusy polled GetServer, which
throws while the peer is not yet part of the connection; it now treats
an unknown endpoint as not connected yet.
mgravell added a commit that referenced this pull request Nov 13, 2026
…eyed routing tests, SER1xx experiment block (#3355)

* Make the dedicated subscription connection lazy (#3310)

The dedicated pub/sub connection (always under RESP2; under RESP3 unless
SharedSubscriptionConnection is enabled) was opened eagerly as part of
activating every endpoint, so a client that never subscribes to anything
still paid for a second socket per node, and a second reconnect per node
on every failover. Seen on AMR, whose options provider disables the
configuration channel: 3.4.x showed two RESP3 connections where 3.0.x
showed one.

ActivateServer now opens that connection only when there is something to
subscribe to straight away: a configuration channel, or subscriptions that
already exist (and may need to move to this endpoint). Otherwise it is
created on first use, via the existing GetBridge paths; with the default
backlog policy the first SUBSCRIBE queues until it is up.

The connect wait ("second leg") and the maintenance handoff deadline
treated an absent subscription bridge as a disconnected one, which would
have left connects hanging; they now consider a connection that has not
been asked for as not required. The endpoint-state connect log says
"NotRequired" for that case instead of "Disconnected".

Tests: AzureManagedRedisConnectionCount now asserts one connection before
pub/sub and the expected count after; a new test covers the eager case
when a configuration channel is set; two tests that relied on the eager
subscription connection now subscribe first.

* Command coverage ledger and audit skill (#3311)

* Command coverage ledger and audit skill

Adds docs/CommandCoverage.md: every upstream Redis command the library does
not expose as a typed API, grouped and with a recorded decision (policy,
not applicable, deferred, todo, deprecated, enum-only, implemented). Seeded
from a diff of redis/redis src/commands (477 specs, @6a5fee67) against the
RedisCommand enum and source; it carries forward the dispositions from
#2055 (blocking is policy, Functions and ACL are deferred) and replaces
that hand-maintained list.

Adds the audit-command-coverage skill and its script, which re-runs the
diff on request and reports only what changed: new upstream commands with
no decision, ledger rows the code now covers, rows the spec no longer has,
and enum members the spec does not know. The script exits non-zero when
the ledger needs editing.

Closes #2055.

* Ledger: CLIENT TRACKING is v4, cluster/COMMAND introspection is administration, CLUSTER SHARDS note corrected

* Ledger: CLIENT PAUSE/UNPAUSE are operator tooling, not applicable

* Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values (#3312)

* Object-type metadata for native bitmaps (redis/redis#15331), plus the tokens that diff turned up

RedisType gains Bitmap ("bitmap", the TYPE reply for a native roaring
bitmap, 8.12) and Gcra ("gcra", already in the server's type-name table).
KeyNotificationType gains SetBit (emitted under class $ for string bitmaps
and class b for native ones) and the other event names the server emits
that the enum lacked: arinsert, armset, arring, arseek, gcra, pfadd,
sexpire, spersist, sinterstore, zrangestore, and keymiss (class m, side
effect group).

Fixes the Copy token: the server has only ever emitted "copy_to", so the
"copy" token never parsed and a RedisChannel.KeyEvent(KeyNotificationType.Copy)
subscription never fired. The member and its value are unchanged.

Both parsers are generated from [AsciiHash]; tests extend the token tables.

* Drop Gcra: the type was withdrawn before release, so there is nothing for the token to match

* Add sunionstore/sdiffstore events (from the keyspace-notifications docs); drop the history comment on Copy

* Fix ConnectionGroupMember string-ctor dropping the name (#3314)

* Forward the name argument in ConnectionGroupMember's string constructor

The string overload chained to the ConfigurationOptions constructor without passing `name`, so every
member built from a configuration string (the idiom used throughout docs/Failover.md) was named after
its first endpoint by Init() instead of the name the caller gave it. Regression test added.

* Parenthesize a Debug-only assertion that trips SA1407

`endSegmentLength == 0 & endSegmentCommitted == 0` in DebugAssertValid sits inside `#if DEBUG`, so CI
(Release) never compiles it and never saw the StyleCop error; any local Debug build of RESPite failed
with TreatWarningsAsErrors. Introduced by ee47e53 after the StyleCop bump in aa1e5c0.

* Parse unbracketed IPv6 ip:port from the server (#1422) (#3307)

* Parse unbracketed IPv6 ip:port from the server (#1422)

CLUSTER NODES, MOVED/ASK and CLIENT LIST write IPv6 addresses without
brackets, i.e. "2a06:4e80:3:a::a:7000". The RFC 3986 parser used for
configuration strings reads that as a (valid!) eight-group address with
no port, so cluster nodes ended up as "[2a06:4e80:3:a::a:7000]:0" and
redirections and client addresses were equally wrong.

Add Format.TryParseServerEndPoint for the server's "ip:port" form: when
there is no ']' and more than one ':', split at the last colon and
require both halves to parse; anything else goes to the existing parser
unchanged, so configuration parsing is not touched. Use it at the three
sites that receive server-emitted text.

The sentinel +switch-master handler gets host and port as separate
tokens and was gluing them back together with ':' before parsing; pass
them to the (host, port) overload instead.

* Parse unbracketed IPv6 in MOVING/SMIGRATED endpoints

ParseMigrationEndPoint used the configuration-string parser, so an unbracketed IPv6 address parsed as an address with port 0, which the placeholder check then discarded. Use TryParseServerEndPoint, as the other server-emitted sites do; tests for the MOVING successor and SMIGRATED source/target.

* Profile commands sent in a batch (#2175) (#3313)

Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>

* public-api.py: apply *REMOVED* at promotion; promote the API surface since 3.4.0 (#3316)

* public-api.py: apply *REMOVED* entries at promotion instead of copying them

The analyzer records a vanished shipped signature as a *REMOVED* line in
the unshipped file. Promotion moved every unshipped line across verbatim,
so a promotion after any such change would have left the shipped file
with both the dead signature and a *REMOVED* copy of it, and the analyzer
would then fail the build on the dead one (RS0017).

Promotion now consumes the marker: the named signature is dropped from the
shipped file, the marker is not carried over, and a marker that names
nothing shipped is an error. A *REMOVED* line already sitting in a shipped
file is also reported. The "+N promoted" note gains "-N removed", and an
unshipped file holding only removals is still emptied.

* Promote the shipped API surface: everything since 3.4.0

eng/public-api.py --promote. Fifty-two StackExchange.Redis entries (the
lag-aware availability surface, FailbackHealthCheck, HealthCheckProbeScope,
the 8.12-independent RedisType and KeyNotificationType tokens) and the
net6.0 SetUserPemCertificate overload move to shipped; the three parameter
renames from #3296 (muxer -> multiplexer on the Azure providers'
AfterConnectAsync, other -> obj on RESPite AsciiHash.Equals) are applied
rather than copied. Every unshipped file is now empty.

* Split third-party notices out of LICENSE so GitHub detects MIT (#3317)

* Split third-party notices out of LICENSE so GitHub detects MIT

The LICENSE file carried ~1.1KB of third-party notes after the MIT text,
which drops it below licensee's similarity threshold; GitHub reports the
repo license as "Other" (SPDX NOASSERTION). Keep LICENSE as the bare MIT
text and move the notes, verbatim, to THIRD-PARTY-NOTICES.md.

* Correct the third-party notices

- Redis: BSD-3 only through 7.2; RSALv2/SSPLv1 from 7.4, AGPLv3 added in
  8.0; point at redis.io/legal/licenses (the old /topics/license URL now
  redirects to the docs landing page).
- redis-doc: repo is archived under redis/redis-doc; note that redis/docs,
  its successor, is CC BY-NC-SA for newer content.
- Drop the Redis-64 and BookSleeve notes: neither is part of the
  development solution any more.
- Add the code actually incorporated from other projects (dotnet/runtime,
  dotnet/corefx, aspnet/BasicMiddleware) and the test-only redis-sharp copy,
  none of which the old notes mentioned.

* The read loop stops matching replies once teardown owns the response queue; a close with commands in flight no longer completes a call with the previous call's reply (#3330)

RecordConnectionFailed drains _writtenAwaitingResponse one message at a
time, completing each outside the lock, while the read loop can still be
matching replies that were already in the receive buffer. Each reply the
loop matched after the drain had taken a message went to the message
*after* the one it answered: GET key N completed, successfully, with key
N-1's value, and every reply behind it was off by one. Reproduced on
main with the reporter's loop: 616 wrong replies in 20,000 calls.

_isShutdown was already set under the queue's lock before the drain, and
the loop already consulted it - but only once the queue was empty.
Checking it before the dequeue closes the race: a reply arriving after
teardown has begun is dropped with the connection, and the message it
answered is failed by the drain as before.

The test re-creates the scenario against the real server (20 rounds of
500 in-flight GETs closed with allowCommandsToComplete: false) and
asserts that no call completes with another call's value; without the
fix it sees ~200 in a run.

Fixes #3329

* CLUSTER SLOT-STATS (8.2): per-slot usage statistics on IServer, experimental SER012 (#3341)

* CLUSTER SLOT-STATS (8.2): per-slot usage statistics on IServer, experimental SER012

IServer.ClusterSlotStatsBySlotRange(Async) and ClusterSlotStatsOrderBy(Async), modelled
on the CLUSTER SLOTS file layout and the HOTKEYS field handling: node-local read, lenient
nominal parsing, config-gated metrics as nullable properties (absent != zero), unknown
metrics preserved as Metadata, cpu-usec surfaced as TimeSpan. RedisFeatures.ClusterSlotStats
gates on 8.2 RC1. Promotes the CommandCoverage 'deferred' row.

* The storage-kind tripwire acknowledges SLOT-STATS and SLOTSRANGE: two new >8-byte literals, 28 -> 30

* Check the maximum operation count in HeartbeatConsistencyCheckPingsAsync (#3342)

* In-repo links follow the org move to seredis (#3326): github.com/StackExchange/StackExchange.Redis -> github.com/seredis/StackExchange.Redis everywhere, including the nupkg RepositoryUrl/PackageReleaseNotes metadata (#3344)

* MOVING handoff: hold new work in the backlog while the in-flight commands drain, so the recycle happens within a round trip instead of at the window end (#3347)

* MOVING handoff: hold new work in the backlog while the in-flight commands drain, so the recycle happens within a round trip instead of at the window end

The drain waited for "no caller work", which under steady traffic never comes: there is always a command in flight, so the recycle waited out the whole announced window and failed whatever was in flight at that instant. Measured live (endpoint rebind, single proxy, ~900 ops/s): recycle at +20.0s against a MOVING at +6.0s, 16 failed commands, missed-deadline warning.

While a handoff drains, the bridge holds a caller's work in the backlog instead of writing it to the connection about to be replaced; the backlog processor does not flush while the hold stands; the drain waits only for what is already written, capped at half the relaxed timeout (bounded by half the remaining window, floor one second); the connection is then recycled as before, the hold cleared, and the held work flushed to the replacement by the ordinary reconnect path. Timeouts are relaxed for the hold and the reconnect. A FailFast policy does not abandon held work for the handoff's own disconnect. Same scenario on this branch: recycle at +6.2s against a MOVING at +5.9s, 0 failed of 97k.

Deliberately a flag and nothing more: no per-message timeout cover (with relaxation off and a timeout shorter than the hold, held commands can time out in the backlog, as on any reconnect), and no retire under the write lock (a writer that reaches the lock after the recycle gets a write fault, as before). Also: the drain budget is recomputed after the DNS poll, a second MOVING during a handoff updates the successor pin rather than being dropped, and the pin lasts the window so the second bridge dials the same successor.

Tests: steady load (with and without relaxation), slow command inside the cap, blocking command past the cap; plus a fault-injector tier measurement that records the live timeline.

* OneLogicalEventFromEveryNodeRaisesOneEvent: wait for the one event rather than counting at the instant the second node relaxed; the callback is dispatched after the relaxation, so the count could be read as zero (flaked on main at ~1 in 6)

* INCREX and ARGREP are routed by key on a cluster (#3352)

IncrexMessageBase (#3065) and ArrayGrepMessage (#3076) derived from Message
directly, held a key, and never overrode GetHashSlot, so they reported
NoSlot. On a cluster that sends the command to an arbitrary node and leans
on -MOVED: an extra round trip for most calls, and a RedisServerException
under CommandFlags.NoRedirect.

Both now derive from Message.CommandKeyBase, as their single-key siblings
do, which supplies the slot (and the key in CommandAndKey).

Affects the StringIncrement overloads that take an Expiration (INCREX) and
ArrayGrep. No public API change.

---------

Co-authored-by: Mohsin Ali <mohsin.ali.work686@gmail.com>
Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com>
Co-authored-by: Nikolay Zdravkov <unsafeptr@gmail.com>
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