Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: StackExchange/StackExchange.Redis
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 3.2.1
Choose a base ref
...
head repository: StackExchange/StackExchange.Redis
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 3.2.15
Choose a head ref
  • 14 commits
  • 44 files changed
  • 2 contributors

Commits on Sep 15, 2026

  1. Drop explicit Microsoft.SourceLink.GitHub reference (#3222)

    The .NET SDK has included and enabled Source Link for GitHub-hosted
    repos by default since 8.0.100; our pinned SDK is well past that.
    Referencing the package explicitly overrides the SDK's own version
    with ours, which is why we had to hand-bump it for a CVE in
    Microsoft.Build.Tasks.Git (#3210) - a class of maintenance the SDK
    now handles for us if we get out of its way.
    
    Verified the generated *.sourcelink.json and the packed nuspec
    <repository> element are unchanged across all six TFMs.
    
    Closes #3218
    mgravell authored Sep 15, 2026
    Configuration menu
    Copy the full SHA
    a8da2bc View commit details
    Browse the repository at this point in the history

Commits on Sep 16, 2026

  1. A fully-down group throws RedisConnectionException (#3223) (#3224)

    A connection group reported "no member can serve this" as
    InvalidOperationException, where a plain multiplexer reports the same situation
    as RedisConnectionException. Since a group IS an IConnectionMultiplexer, a
    caller who wrote catch (RedisConnectionException) - or a Polly policy handling
    it - saw nothing, and that caller cannot reasonably be expected to know which
    implementation they were handed.
    
    Both throw sites - the active multiplexer and the active member - now go
    through one helper, so they cannot drift apart again. CommandFlags.None,
    because there is no message here to take flags from: the group is being asked
    for a connection, not asked to send, and the single-multiplexer path passes the
    message's flags into that same slot.
    
    The message gains a count per state, because "All connections are unavailable."
    alone does not say whether nothing ever connected or everything was judged
    unhealthy, and those point at different causes:
    
      All connections are unavailable. (2 unhealthy)
    
    Counts rather than names, deliberately: a member's name defaults to its
    endpoint, and an exception message travels - logs, error-reporting services,
    sometimes a response body. The deployment's hostnames are not ours to put
    there, and the count carries the diagnosis anyway. The leading sentence is
    unchanged, so anything already matching on it still matches.
    
    Four tests over a group whose members have nothing listening: the async path,
    the sync path and GetServer (which reach the two throw sites separately), that
    the message counts by state and contains no endpoint, and that mixed states
    stay distinct. The state assertions accept unhealthy OR not-connected, because
    which one a member lands in depends on whether the health check has run yet,
    and pinning the wrong one is how a test becomes a load-dependent flake.
    mgravell authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    19674a2 View commit details
    Browse the repository at this point in the history
  2. SwitchPrimary retires the servers its rebuild drops (#3225)

    When sentinel reports a primary on an endpoint the managed connection has
    never seen, SwitchPrimary rebuilds the endpoint set around it. That rebuild
    called servers.Clear(), which empties the lookup dictionary but leaves the
    ServerEndPoint in _serverSnapshot - and the snapshot, not the dictionary, is
    what ServerSelectionStrategy chooses from and what GetEndPoints reports.
    
    So the dropped node stayed in the multiplexer: connected, selectable, and
    still a candidate for commands, despite sentinel no longer naming it as part
    of the service. A later lookup for the same address then missed the cleared
    dictionary while the snapshot still held the first server, and built a second
    one alongside it.
    
    Retire them instead, via RetireServerAsync - the single spelling of
    retirement - so a dropped node drains what it owes rather than having its
    in-flight commands abandoned, and leaves the snapshot and every alias behind
    it. Only when GetReplicasForService actually answered: a null replica list is
    a failed query rather than a deployment with no replicas, and retiring on one
    would drop healthy nodes over a transient sentinel hiccup.
    
    Observed against the local sentinel topology on a connection pointed at an
    endpoint outside the service: EndPoints was rebuilt to the reported primary
    and its replica while the stray server remained in the snapshot, connected.
    mgravell authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    e5e2a31 View commit details
    Browse the repository at this point in the history
  3. Make fallback discovery and keep-alive probes cluster-slot aware (#3185)

    * Make fallback discovery and keep-alive probes cluster-slot aware
    
    On OSS cluster, the direct (NoRedirect) probe messages used during
    connection setup and keep-alive could target a hash slot the
    connected node doesn't own, so the server replies MOVED and the
    probe is dropped instead of following it.
    
    - Skip the replica_read_only SET fallback in AutoConfigureAsync once
      cluster topology already reports our role, since it's both
      redundant and slot-unsafe there.
    - Skip the tie-breaker GET fallback in AutoConfigureAsync on cluster,
      where a tie-breaker key isn't meaningful.
    - When the ECHO/PING/TIME tracer is unavailable, build the EXISTS
      fallback key with a hash-tag targeting a slot this endpoint
      actually owns, reusing the existing hash-tag cache.
    
    Fixes #2970.
    
    * Unify InventKey/tracer slot lookup, fix ClusterNode.Parent and replica tracer
    
    - Replace InventKey's O(16384) map-scan (ServerSelectionStrategy.GetHashTag)
      and the tracer key's node.Slots[0].From lookup with one primitive,
      ServerEndPoint.GetServableSlot(), used by both.
    - GetServableSlot() falls back to the node's parent's slots for a replica
      (CLUSTER NODES reports slot ranges only against primaries, so a replica's
      own Slots was always empty and its tracer key fell through untagged,
      still triggering MOVED on exactly the endpoints this PR targets).
    - Fix ClusterNode.Parent, which was structured so it only looked up a
      parent when the backing field was already non-null - a field never
      assigned elsewhere, so Parent always returned null.
    - Replace the cached-byte[]-prefix API (ServerSelectionStrategy.GetPrefix /
      GetHashTagPrefix, a 16384-entry static array allocated even in
      non-cluster processes, publishing shared mutable state as a RedisKey)
      with ServerSelectionStrategy.CreateKeyForSlot(slot, suffix), which
      composes the whole key per call instead of caching a byte[].
    - Memoize the composed tracer key on ServerEndPoint, invalidated when the
      servable slot changes, since GetTracerKey runs on the heartbeat path.
    - GetClusterNode uses ClusterConfiguration's O(1) endpoint indexer instead
      of a LINQ scan; also speeds up UpdateNodeRelations, which calls it.
    
    Addresses maintainer review points 1-3 and 7 on #3185.
    
    * Comment AutoConfigureAsync's cluster guards, drop redundant condition
    
    - Explain why the SET replica-detection probe can never work on cluster
      (a replica answers MOVED, not READONLY, for a write to its primary's
      slot, so no amount of slot-tagging fixes it - unlike the tracer key).
    - Explain the first-handshake window: serverType starts Standalone and
      only becomes Cluster once the CLUSTER NODES reply is processed, so on
      the very first AutoConfigureAsync call for a cluster node, neither this
      guard nor the tie-breaker GET guard suppresses anything - they only take
      effect from the next handshake/heartbeat onward.
    - Drop the redundant GetClusterNode(ClusterConfiguration) is not null
      clause from the SET-probe guard: ServerType == Cluster alone is
      sufficient, since the probe is being skipped for cluster mode as a
      whole (see comment), not because topology for this node is known.
    
    Addresses maintainer review point 4 on #3185.
    
    * Add cluster AutoConfigure/tracer test coverage, fix undisposed ServerEndPoint
    
    - AutoConfigureSkipsKeyProbesWhenClusterTopologyKnown: proves the
      tie-breaker GET and the SET replica_read_only probe are NOT issued once
      cluster topology is known, using a recording in-process server.
    - ExistsTracerOnClusterReplicaUsesPrimarySlot: a replica's tracer key
      targets its primary's slot (fails without the GetServableSlot fix).
    - ExistsTracerUsesPlainKeyWhileClusterTopologyIsNotKnown: covers
      ServerType == Cluster with topology not yet known (the first-handshake
      window), distinct from genuinely being standalone.
    - ExistsTracerUsesPlainKeyWithoutKnownOwnedSlots no longer leaks its
      hand-built ServerEndPoint.
    
    Addresses maintainer review point 6 on #3185.
    
    * Fix up for current main: provenance args, tracer-key publication, InventKey coverage
    
    - ServerEndPointClusterProbeUnitTests did not compile: main now requires a
      ServerProvenance on GetServerEndPoint and the ServerEndPoint constructor.
    - GetTracerKey cached its slot and key in two separate fields. A RedisKey is
      two references, so writing one while a heartbeat on another thread reads it
      could hand that reader a prefix from the new key and a value from the old.
      Cache one object instead: publishing it is a single reference write, which
      cannot tear. Also retires the MultipleSlots sentinel used to force the first
      computation.
    - IServer.InventKey is the public API this changes and had no coverage; assert
      that every node's invented key routes back to it, and that a replica's
      routes to its primary - the case the old map scan could not serve at all.
    
    ---------
    
    Co-authored-by: mgravell <[email protected]>
    HarnageaGabriel and mgravell authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    2c89cc8 View commit details
    Browse the repository at this point in the history
  4. Fix Sentinel connection leak and AbortOnConnectFail=false handling (#…

    …3187)
    
    Fix Sentinel connection leak and AbortOnConnectFail=false handling (#3187)
    Connecting via Sentinel with AbortOnConnectFail=false threw on a failed initial
    connect - unreachable sentinels, or no primary discovered within ConnectTimeout -
    instead of returning the multiplexer, and leaked the internally-created
    ConnectionMultiplexer instances (the sentinel monitor connection, and the
    per-retry primary candidates) since nothing disposed them on that path. This did
    not match the non-Sentinel ConnectImpl, which honours the flag by returning the
    muxer with LastException set.
    - AbortOnConnectFail=false now returns a usable multiplexer with LastException
      set and the same event wiring and background retry as the success path, so a
      caller can attach handlers and let it reconnect once sentinels become
      reachable. AbortOnConnectFail=true still throws, with the same messages, but
      disposes what it created first.
    - ScheduleSentinelPrimaryReconnect null-checked the timer field and then assigned
      it. It is now reached from the connect path as well as from ConnectionFailed -
      wired up a few lines earlier, on a connection already failing in the background
      - so two threads could each create one, and only the published timer can be
      disposed on restore; the loser would fire SwitchPrimary every second for the
      life of the process. Build first, publish with a compare-exchange, start only
      the winner.
    - The initial switchover swallowed failures only when discovery had not
      succeeded, so a connection that had reached its primary was torn down and the
      exception thrown at a caller who set the flag precisely to avoid that.
    Note this relaxes a public API: GetSentinelMasterConnection with
    AbortOnConnectFail=false no longer reports "not a Sentinel connection" for a
    soft-failed sentinel connect, returning a disconnected multiplexer instead.
    Fixes #2980
    HarnageaGabriel authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    2c1d4b1 View commit details
    Browse the repository at this point in the history
  5. Fix a vacuous cluster-probe assertion, and pin the first-handshake re…

    …sidue (#3226)
    
    AutoConfigureSkipsKeyProbesWhenClusterTopologyKnown asserted that no SET
    carried "replica_read_only" - the C# identifier. RedisLiterals gives that
    literal the wire value "replica-read-only", so the underscored spelling never
    appeared in a recorded command, the predicate was never true, and
    Assert.DoesNotContain passed whatever the client sent. Removing the cluster
    guard it exists to protect left the test green; with the wire spelling it fails,
    as it should. The behaviour itself was correct - only the assertion was dead.
    
    Also pins what the *first* handshake sends, which the existing test cannot see
    because it clears its recording after connecting. The guards added in #3185 read
    the per-endpoint ServerType, which is seeded Standalone and only becomes Cluster
    once the CLUSTER NODES reply has been processed - and these probes are composed
    into the same pipeline batch that carries that command, with HELLO (whose "mode"
    field would also answer this) still in flight. So one unslotted round still goes
    out per new ServerEndPoint. That is the remaining scope of #2970, and it is now
    asserted rather than assumed.
    mgravell authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    fd6faa1 View commit details
    Browse the repository at this point in the history
  6. A tracer redirect proves liveness, so it completes the handshake (#3227)

    The keyed EXISTS tracer - the last fallback, used when ECHO, PING and TIME are
    all unavailable - cannot target a slot the node owns during its first handshake.
    The CLUSTER NODES reply that would say which slots those are is still in flight
    in the same pipeline batch, and HELLO's "mode" field with it, so ServerType is
    still the seeded Standalone when the key is chosen. On an OSS cluster the probe
    therefore earns a MOVED.
    
    TracerProcessor classified that as a protocol failure and tore the connection
    down. The node never established, and the connect burned its whole
    ConnectTimeout before giving up: measured at 10s against the local six-node
    cluster, with slot-serving nodes still disconnected afterwards.
    
    A redirect answers the only question a tracer asks - is this server up, speaking
    RESP, and talking to us - so treat MOVED and ASK as success. That needs no
    topology, adds no round-trip, and leaves standalone untouched. The connection
    then completes "From command: EXISTS" as it should, and the tracer key becomes
    correctly slotted from the next autoconfigure onwards.
    
    The new test seeds a single endpoint so the rest are discovered from topology,
    and asserts without polling that every slot-serving node established on the
    first pass: 0/6 runs passed before this change, 6/6 after. It deliberately
    ignores nodes serving no slots, which are registered inert and never dialled -
    whether those report as connected is a separate matter, and a separate bug.
    
    Fixes #2970
    mgravell authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    900338c View commit details
    Browse the repository at this point in the history

Commits on Sep 17, 2026

  1. CI: resolve the NuGet cache key once, so the post step never walks th…

    …e tree (#3231)
    
    The Windows job has been failing at teardown with
    
      The template is not valid. .github/workflows/CI.yml (Line: 86, Col: 16):
      hashFiles('Directory.Packages.props, global.json, **/*.csproj') failed.
      Fail to hash files under directory 'D:\a\StackExchange.Redis\StackExchange.Redis'
    
    after every real step had already passed - build, tests and all. The failing
    step is "Post Cache NuGet packages": actions/cache re-evaluates `key` in its post
    step to decide what to save, and that runs at the end of the job rather than the
    beginning.
    
    #3209 moved the hash ahead of the redis servers so the restore would see a still
    tree, and that half works - the restore step passes. But the post step re-walks
    the workspace once sixteen redis processes have been writing through DrvFs and
    every project has grown bin/ and obj/ across six target frameworks, which is the
    same "enumerating a tree that is changing underneath you" the existing comment
    describes, only worse.
    
    Resolve the expression once into a step output and hand actions/cache a plain
    string. The glob then runs exactly where it is safe - straight after checkout -
    and the post step has nothing left to enumerate.
    
    Left in pwsh to match the rest of the job, which has no defaults.run.shell and
    so inherits the Windows runner's default.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    1a4874c View commit details
    Browse the repository at this point in the history
  2. Compare strings against blobs without decoding them into a string (#3228

    )
    
    * Compare strings against blobs without decoding them into a string
    
    Equality between a string and a byte-backed RedisValue fell through to
    (string?)x == (string?)y, which materialises the blob as a transient string
    before comparing. That allocation is proportional to the payload: at 64KB it is
    a 131KB string, over the large-object-heap threshold, on every comparison. And
    because the whole payload is decoded before anything is compared, a value that
    differs in its first byte costs exactly as much as one that matches.
    
    Answer the same question without building the string: decode a chunk at a time
    into a small stack buffer and compare as we go, stopping at the chunk that
    contains a mismatch. A bounds check first - UTF-8 yields at most one char per
    byte, and never more than three bytes per char - rejects mismatched lengths
    before touching any content. Sequence-backed values carry up to four bytes
    across a segment boundary, since a sequence split mid-character has to be
    resumed rather than treated as invalid data.
    
    The relation is deliberately unchanged: still "the blob, read as UTF-8 text,
    equals the string". Comparing the other way - encoding the string and matching
    raw bytes - looks equivalent but is not, because UTF-8 does not round-trip
    strings holding unpaired surrogates: "\uD800" encodes to the same bytes as
    "�", so the two would compare equal while GetHashCode, which hashes the
    decoded form, keeps them in different buckets. Equal values with different hash
    codes silently break Dictionary and HashSet.
    
    Measured against the local six-node figures in
    RedisValueEqualityBenchmarks (net10.0, string vs byte[]):
    
      64KB, equal          43,121ns / 131,110B  ->  5,397ns / 0B   (8x)
      64KB, differs first  57,286ns / 131,110B  ->     52ns / 0B   (1092x)
      1KB,  differs first     119ns /   2,072B  ->     55ns / 0B   (2.2x)
      16B,  equal              59ns /      56B  ->     56ns / 0B
    
    Allocation is gone at every size. Small values are otherwise a wash, and two
    cases are marginally slower - 1KB differing at the last byte goes 138ns ->
    153ns - where the per-chunk overhead lands on a path that has to decode
    everything anyway.
    
    Down-level targets keep the previous implementation: System.Text.Unicode.Utf8
    does not exist there.
    
    The tests compare against the old behaviour directly, over text that does not
    survive a UTF-8 round trip (truncated, overlong and invalid sequences, surrogate
    pairs, lone surrogates) and, for sequences, at every possible split point.
    
    * Review feedback: document the carry invariant and the benchmark's scope
    
    - Assert that the decoder consumed at least the carried prefix. It holds because
      `pending` only ever holds a sequence that was incomplete but valid, and such a
      sequence is consumed as a unit - but the reasoning is not local to the line,
      and a violation would throw from the slice rather than answer wrongly.
    - Say that the "no progress" branch is defence rather than a case that arises: a
      final-block decode of a non-empty input always yields at least the replacement
      character, so it is believed unreachable and is there to fail the comparison
      instead of spinning if that ever stops being true.
    - Spell out that the benchmark has no in-run before and after: its baseline is
      the method being changed, so comparing against a previous implementation means
      running it on both commits. Also drops a reference to a PR number that will
      read oddly once that PR is closed.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    d70af99 View commit details
    Browse the repository at this point in the history
  3. Add RedisValue.EqualityComparer, with an opt-in binary reading (#3230)

    * Add RedisValue.EqualityComparer, with an opt-in binary reading
    
    Comparing and hashing a RedisValue works on the text a value decodes to, which
    is what lets a blob equal the string it spells. For callers slinging large
    payloads that reading costs more than they want, and for them the bytes would do
    just as well.
    
    Rather than change what the type itself means - see #3229, where doing so turned
    out to break a Hashtable keyed by RedisValue and probed by string, silently -
    offer the byte reading as something a caller asks for:
    
        new Dictionary<RedisValue, T>(RedisValue.EqualityComparer.Binary)
    
    An abstract base with Default and Binary, implementing both the typed and the
    untyped comparer interfaces; the untyped side accepts anything RedisValue itself
    accepts from object - string, byte[], the numerics - via the same forgiving
    TryParse that Equals(object) uses. Derivation is closed via a private protected
    constructor: nobody needs to derive from this to write their own rules, and
    leaving it open would fix the shape forever.
    
    Deliberately equality only. A total order would have to choose between the
    numeric, textual and raw-byte readings of a value, and those disagree; Redis
    does not define one either, since sorted sets order by score and ZRANGEBYLEX
    orders member bytes.
    
    Binary agrees with Default wherever a value's UTF8 form round-trips - all
    well-formed text - and differs where it does not, in both directions: a string
    holding an unpaired surrogate encodes as U+FFFD does, and a blob that is not
    canonical UTF8 decodes to U+FFFD without re-encoding to itself. Both its
    equality and its hashing read the same bytes, so it stays self-consistent; that
    is what makes the byte reading safe here and not on the type itself, whose
    GetHashCode hashes decoded text.
    
    Measured against Default on the same matrix (net10.0, string vs byte[]):
    
      16B    equal            56.3ns -> 10.8ns
      1KB    equal           131.9ns -> 41.1ns
      1KB    differs first    54.9ns -> 15.2ns
      64KB   equal          5,397ns  -> 2,155ns
      64KB   differs first     52.4ns -> 15.5ns
      hash 64KB blob        31,026ns ->   884ns
    
    Two things the benchmarks caught that the tests could not: comparing a string
    against a blob has to encode a chunk at a time onto the blob's own bytes, or a
    mismatch in the first byte still costs a full pass over both sides; and the
    length precheck has to come after that, because measuring a string's UTF8 length
    walks all of it and dominates everything else.
    
    * Review: fix the chunk-boundary surrogate guard, and pin numeric divergence
    
    The guard meant to stop a surrogate pair being split across chunks extended the
    chunk by a character. That was wrong twice over:
    
    - it could overrun the encode buffer, which is sized for ChunkChars * 3 bytes;
      511 three-byte characters followed by a straddling pair need 1537 of 1536, and
      Encoding.UTF8.GetBytes throws ArgumentException rather than returning a wrong
      answer;
    - where the character at the seam was itself a lone high surrogate, the extra
      character was the *following* pair's high half, so the split simply moved onto
      a real pair - each half encoding to U+FFFD, and an equal value comparing
      unequal.
    
    Step back instead. A chunk then never ends on a high surrogate and never exceeds
    ChunkChars characters, so it cannot overrun; and it is only reachable when take
    == ChunkChars, so it cannot reach zero. Fuzzing a 500-530 character corpus over
    the awkward alphabet gave 247 wrong answers in 20,000 before and 0 after, with
    no exceptions.
    
    The tests could not have caught this: the longest value in the corpus was 300
    characters, so the chunking loop never ran past its first pass. Added cases that
    put a straddling pair, a lone high surrogate before a real pair, and a pair
    approached from the other side at exactly the 512-character seam, plus a plain
    2000-character value for the multi-chunk path.
    
    Also documented and pinned a divergence that was missing: operator == runs
    Simplify() first, so "1.0", "1.00" and "1" are all equal under the default rules,
    as are "0" and "-0.0", while Binary compares the bytes and says otherwise. That
    is the right answer for a byte reading - it is how the server identifies members
    - but it belonged in the remarks, which claimed agreement for "all well-formed
    text".
    
    Two smaller things from the same review: seed the hash from RandomNumberGenerator
    rather than Guid.NewGuid, whose version and variant bits are fixed; and take the
    cheap exit when two strings are ordinally equal, since identical text encodes
    identically. The unequal case still goes the byte route, because two different
    strings can share a UTF-8 form.
    
    * Drop the superseded surrogate-guard comment left above its replacement
    
    * Pin that Binary does not depend on how a value is stored
    
    The default rules can simplify a string and the bytes of that same string
    differently (#3233), so the same text is not always equal to itself. Binary
    reads the UTF8 form of both sides and so is not exposed to that; asserting it
    keeps the property rather than leaving it as a happy accident.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    b734861 View commit details
    Browse the repository at this point in the history
  4. Close the test-suite gaps from #3234 (#3235)

    * Close the test-suite gaps from #3234
    
    Four separate things, all small, none of which the suite could tell us about
    because each was either hidden behind a gate or only visible as a flake.
    
    **LatencyTests could not run at all.** GetHistory and GetLatest issue
    `server.Execute("debug", "sleep", "0.5")`, and DEBUG is not in the exclusion
    list in Message.RequiresDatabase - so it requires a database, while
    IServer.Execute has none to give and refuses outright. Both tests failed
    deterministically the moment RunLongRunning was set; nothing sets it, so nobody
    noticed. Routed through IDatabase instead, which keeps the database and works.
    There is only one endpoint in this configuration, so it still lands on the
    server being measured.
    
    Note this leaves a real limitation in place: IServer.Execute cannot issue any
    command that RequiresDatabase says needs one, even where the subcommand plainly
    does not - DEBUG SLEEP needs no database, DEBUG OBJECT does, and the command
    token alone cannot tell them apart. Worth its own decision rather than a change
    smuggled in here.
    
    **UnroutableRedirect's remaining race.** The target is made unaddressable by
    SetAnnouncedAddress *after* AddEmptyNode has already published it, leaving a
    window where a topology refresh can see it still announcing an address, route
    straight to it, and leave no redirect to test. Under RESP2 the subscription
    connection completing can trigger exactly such a refresh. Added an
    AddEmptyNode overload that fixes the announced address before publication, so
    the node is never observable half-configured. An overload rather than an
    optional parameter: StackExchange.Redis.Server is a shipped package.
    
    This one matters more than a flake normally would: as the test's own comment
    says, when the target becomes routable "every other test in this class silently
    stops testing anything" - so it fails open, and a green run proved less than it
    appeared to. 15/15 clean after.
    
    **CountKeys was flaky by construction.** It flushed two dedicated databases
    fire-and-forget on a connection it disposed immediately, then wrote and counted
    on a different one; nothing ordered the flushes against the writes, so a loaded
    run counted leftovers. Awaited instead. 10/10 clean.
    
    **AGENTS.md described the test override wrongly** - as a gitignored
    TestConfig.json. It is RedisTestConfig.json, it is tracked, and it is read as an
    embedded resource, so editing it needs a rebuild and dirties the tree rather
    than acting as a private override. Also documents RunLongRunning, since that is
    what hides the gated tests above.
    
    * Review: target the primary explicitly, and refuse a contradictory announced address
    
    - LatencyTests now takes the server via GetAnyPrimary and sends DEBUG SLEEP with
      DemandMaster, so both provably reach the same node. My previous comment said
      there was only one endpoint; that is true of this configuration today - the
      replica is not discovered as an endpoint, and GetEndPoints()[0] is the primary
      - but resting on it was incidental, and this makes the guarantee explicit.
    
    - AddEmptyNode's new overload threw away an explicit announced address for a
      DnsEndPoint, because ApplyNameOnlyIdentity runs afterwards and forces Empty
      for name-only identities. Refuse that combination instead of quietly doing
      something else, and say so in the docs.
    
    - Points at #3236 for the IServer.Execute limitation, which turns out to be a
      v3 regression affecting far more than DEBUG.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    a1673ea View commit details
    Browse the repository at this point in the history
  5. IServer.Execute supplies the default database rather than refusing th…

    …e command (#3237)
    
    * IServer.Execute supplies the default database rather than refusing the command
    
    server.Execute("DBSIZE") - and KEYS, SCAN, RANDOMKEY, OBJECT, DEBUG, FLUSHDB,
    EVAL - threw "A target database is required". Message.RequiresDatabase answers
    true for anything not on its exclusion list, and IServer.Execute passes -1,
    having no database to offer.
    
    This is a v3 regression. Ad-hoc commands used to travel as RedisCommand.UNKNOWN,
    which the Message constructor deliberately exempts from the check, so it never
    applied to this path at all; #3083 began recognising them so CommandMap aliasing
    would work, which brought the assertion with it. The sibling direction from
    IDatabase was given an allowance at the same time - RemoveDbIfNotRequired strips
    a database that is not needed, with a comment noting we should not start
    complaining about what was historically accepted. IServer got no such allowance.
    
    Resolve the default database instead of refusing, but only where the command
    actually requires one, so nothing else changes: commands on the exclusion list
    and commands we do not recognise still travel with no database and leave the
    connection's selection alone.
    
    Uses multiplexer.ApplyDefaultDatabase, which is what the database-taking
    Execute overload already does for a null database, and what DatabaseSize,
    FlushDatabase and friends already do. That makes server.Execute("DBSIZE") agree
    with server.DatabaseSize() - before this they were the same command reaching
    different databases.
    
    Note this is deliberately *not* a return to the v2 behaviour. Sending with no
    SELECT meant the command ran against whichever database that physical connection
    had last selected, so the answer depended on unrelated earlier calls; measured
    against a live server, a connection that had touched database 5 and then 9
    reported db=5 then db=9 for commands that named no database at all. Restoring
    that exactly would mean restoring results that are silently wrong for anyone
    using more than one database.
    
    The tests cover all three routes through RemoveDbIfNotRequired, and assert which
    database the connection ends up on rather than comparing key counts - the server
    is shared with other tests, so counts move and two readings of one prove nothing.
    CLIENT INFO works as the oracle precisely because CLIENT is on the exclusion
    list and so does not itself move the selection.
    
    * Pin the no-database half, and say which database Execute uses
    
    Review follow-ups on the tests, plus the documentation gap.
    
    ExclusionListCommandsStillSendNoSelect only called CLIENT INFO twice, so it
    proved nothing the oracle helper did not already assume, and nothing covered
    the unrecognised-command route at all. It now runs PING (recognised, on the
    exclusion list) and ACL WHOAMI (not recognised, so the assertion never applied)
    between the readings, and is renamed for what it actually asserts.
    
    That turns out to be the half worth pinning. Replacing DatabaseForAdHoc with
    the simpler "always pass the default and let RemoveDbIfNotRequired strip it"
    fails exactly there, and nowhere else: that method only strips for recognised
    commands, so ACL keeps the database and starts emitting SELECT. The test was
    silent about this before. The original bug still fails the other eight.
    
    Also: the missing Assert.NotEqual(0, otherDb) guard, without which "still on
    otherDb" would hold whether or not a SELECT happened; a skip for DEBUG via the
    existing AssertDebugCommandEnabledAsync, since Redis 7 disables it by default
    and only this repo's own config turns it on; version requirements for ACL
    (6.0), FUNCTION (7.0) and CLIENT INFO (6.2, the oracle), matching how the rest
    of the suite gates; and a comment saying Assert.NotNull is deliberately weak -
    the assertion is that it does not throw, and reading the reply would only
    couple the test to server versions.
    
    The IServer.Execute docs did not mention databases at all, and the overload
    taking no database claimed the command "is assumed to be not database-specific"
    - which was the behaviour being fixed. Both now say a command that requires a
    database uses the configured default, and point at the database-taking overload
    for anything else.
    
    * Drop a version guard that can never be false
    
    The ACL step in CommandsNeedingNoDatabaseLeaveTheSelectionAlone was guarded on
    6.0, but the test already requires 6.2 for CLIENT INFO, so the branch is always
    taken. A guard that cannot fail is worse than none: it reads as though the step
    is optional, and nothing would notice if it stopped running. Left a note saying
    which requirement covers it, so it is obvious what to restore if the 6.2 one
    ever goes.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    dbe7270 View commit details
    Browse the repository at this point in the history
  6. Dial a cluster node before waiting for it to connect (#3238)

    * Dial a cluster node before waiting for it to connect
    
    Connecting to a cluster seeded with one endpoint intermittently took the whole
    ConnectTimeout - 1 in 15 against the local docker cluster, 10004ms against a
    10000ms timeout - and came back reporting a node as unresponsive that had never
    been dialled at all:
    
        Registering 127.0.0.1:7005 without connecting: serves no slots
        127.0.0.1:7005: OnConnectedAsync init (State=(null))
        ... 10s later ...
          Server[5] (127.0.0.1:7005) Status: WaitingForActivation
          127.0.0.1:7005: Did not respond (Task.Status: WaitingForActivation)
    
    Cluster discovery registers a node it has no reason to dial *inert*: known and
    addressable via GetServer, but with no bridge, since first use creates one
    anyway. GetServerEndPoint only activates a server it *creates*, so an endpoint
    already held inert comes back untouched - and ReconfigureAsync then calls
    OnConnectedAsync on it, which parks a TaskCompletionSource that nothing will
    ever complete, because nothing is connecting. The connect waits out its entire
    timeout on that one task and then marks the node DidNotRespond.
    
    The node that lands in this state is a replica of a shard we have not connected
    to yet. UpdateClusterRange materialises primaries only - it skips anything with
    IsReplica or no slots - and a replica is otherwise created when its own primary
    autoconfigures and resolves its genealogy, which for such a shard has not
    happened. Discovery meets it first, in the CLUSTER NODES sweep that runs before
    the UpdateNodeRelations pass in the same method, and registers it inert. CLUSTER
    SLOTS does list it, since a slot range names its primary and then its replicas,
    so it is also in the set the connect goes on to wait for. Whether the primary's
    autoconfigure wins that race is what made this intermittent.
    
    Activate the server before awaiting it. Activate is idempotent - it hands back
    an existing bridge - so this touches only the case that was broken, and only
    endpoints the connect is actually going to wait for: a node that serves nothing
    is absent from the CLUSTER SLOTS view, never reaches this loop, and stays inert
    as intended. That complement is asserted too, so the fix cannot quietly become
    "dial everything".
    
    The toy server could not express any of this: its CLUSTER SLOTS emitted the slot
    owner and nothing else, so a replica was never discoverable from the slot view,
    and CLUSTER NODES reported every node as its own master. Both now follow the
    real shape. That is what lets the regression test be deterministic rather than a
    1-in-15 race - one shard is made to fail CLUSTER NODES, so nothing resolves its
    genealogy and its replica reliably arrives inert. Without the fix that test
    takes 10059ms against a 10000ms timeout and leaves the replica disconnected;
    with it, 92ms and connected. The test asserts the inert registration actually
    happened, so it fails loudly rather than passing vacuously if discovery stops
    taking that route.
    
    Note the newly-dialled node now earns one unslotted probe round on its first
    handshake, like any other new ServerEndPoint - the residue pinned in #3226.
    
    Fixes #3232
    
    * Fix the discovery check that left the replica undialled, not just the symptom
    
    The inert-registration sweep decided "not listed in CLUSTER SLOTS" by asking
    whether a ServerEndPoint existed yet. Those are different questions, and a
    replica of a shard we have not reached answers them differently: it is in the
    SLOTS reply, and so in the set the connect waits for, but nothing has created it
    - UpdateClusterRange materialises primaries only, and its own primary's
    genealogy pass has not run. So it was registered inert, and the log said
    "serves no slots" about a node the same reply had just listed.
    
    Test SLOTS membership directly instead, by identity rather than by the endpoint
    chosen for clusterEndpoints, since SelectIdentity may have picked a hostname
    where NODES gives the address. A node the slot map lists is now created dialled,
    which is also what keeps the UpdateNodeRelations pass a few lines below working
    for it - skipping the registration entirely would have left it unresolvable
    there. It gets its own log line, so the two routes are distinguishable.
    
    Keep the ActivateServer call. An inert node can still reach that loop
    legitimately: ActivateAllServers runs once, before the retry loop, so a node
    registered inert on one attempt and listed in SLOTS on the next arrives
    unactivated, and waiting on a server without making sure it is dialled is wrong
    whatever put it there.
    
    Tests, all three now failing for distinct reasons when sabotaged separately:
    
    - the regression test asserted the misclassification as its precondition, which
      the discovery fix removes. It now asserts the opposite - the replica appears
      under the slot-map line and not the inert one - so it still fails loudly if
      discovery stops taking that route, and it is what catches a revert of the
      discovery fix.
    - the complement asserted "not connected", which is also true of a node being
      dialled right now, so it would not have noticed the regression it names. It
      now asserts no interactive bridge was ever created, and catches the fix
      degrading into "dial everything".
    - a third test pins the hazard the safety net exists for: OnConnectedAsync on an
      undialled server parks a completion nobody will signal. Note it has to
      activate *both* legs to end the wait - under RESP2 the monitors are only
      completed once the subscription connection is up - which is why ActivateServer
      activates the pair.
    
    Toy server, from the same review:
    
    - AddReplicaNode set the primary link after publication, the window #3235 closed
      for announced addresses: a topology read landing in it sees a slotless primary,
      which is precisely the shape the complement test sets up deliberately. Passed
      through AddEmptyNodeCore so publication stays last.
    - a replica said role:master in HELLO, INFO replication and ROLE while NODES and
      SLOTS called it a replica. Role is read back from the node itself during
      autoconfigure, so that contradiction would quietly undermine any later test
      about replica routing; all three now follow the flag.
    - EmptyNodes was a shared mutable List<Node> handed out to callers.
    
    * Share the activation helper with its test, and stop claiming a path I cannot show
    
    Review follow-ups.
    
    The connect-loop ActivateServer call has no test that fails without it, and the
    two suggested ways to get one do not hold:
    
    - Registering a SLOTS-listed node inert on a connected multiplexer and calling
      ReconfigureAsync(first: true) cannot fail. The first-run prologue calls
      ActivateAllServers over the whole snapshot before the loop, so the node is
      already dialled by the time the loop sees it. Measured with the call removed:
      27ms, node connected.
    - The retry case cannot carry an inert node between attempts either. A retry
      needs healthy == false, meaning no server of any type connected in that
      attempt; but discovery only runs off a *connected* cluster server, so an
      attempt that registers anything inert is healthy by construction and does not
      retry.
    
    So the comment now says what the call is - an invariant guard, kept because it
    costs one idempotent call and the failure it prevents is a silent stall - rather
    than implying it covers a live route. The route that did reach it is fixed at
    source. There is a sentinel path that looks capable of producing the same state
    (IsStalePrimaryView creates inert), but I have not demonstrated it and will not
    claim it.
    
    ActivateServer is internal now, and the hazard test calls it instead of copying
    its two-bridge logic. The RESP2 rule that made that test hang first time round -
    waiters are only completed once the subscription connection is up - is stated on
    the helper, where it belongs, instead of in a comment next to a copy that
    nothing keeps in step.
    
    Also: deleted a superseded comment left above its replacement, and named the
    complement test's local after what it holds.
    
    Not changed: the Info signature. toys/ inherits IsPackable=false from the root
    Directory.Build.props - only src/ sets it true - and dotnet pack on the project
    emits nothing despite the PackageId, so there is no shipped surface to break and
    no compatibility decision to state. The two in-repo overrides are the whole
    population.
    
    The intermittent HotKeysClusterTests skip is #3239: it fails open on an empty
    sampling window, so every assertion after it - including the IsSampled check the
    sampled case exists for - silently does not run.
    
    * Say when the connect-loop guard fires
    
    A guard that repairs the state in silence hides any new route into it: the
    connect simply works, and the only evidence is code nobody can show is reached.
    Log the endpoint when a server arrives at the wait with no connection open, so
    the question "does anything still get here?" has an answer in the field - and so
    the sentinel route I suspected but did not claim (IsStalePrimaryView creates
    inert) can be confirmed or ruled out from a log rather than by argument.
    
    The regression test now also asserts the guard stayed quiet, which is what says
    discovery is still the thing fixing this rather than the guard papering over it.
    Verified both ways: with the discovery fix reverted the line appears, naming the
    replica, and the assertion fails.
    
    * Take pub/sub out of the discovery test, and file what it turned up
    
    CI failed this test at 10007ms against a 10000ms timeout. Not the bug it guards:
    the earlier assertions passed, so discovery did register the replica from the
    slot map and did dial it, and the connect-loop guard never fired. What had not
    finished was the replica's *subscription* connection:
    
      127.0.0.1:6381: Endpoint is (Interactive: ConnectedEstablished,
                                   Subscription: ConnectedEstablishing)
      127.0.0.1:6381: Did not respond (Task.Status: WaitingForActivation)
    
    Under RESP2 the OnConnectedAsync waiters are only completed once that second
    connection is up, so a node whose interactive leg is fully established still
    holds the whole connect open - the same presentation as #3232 from an unrelated
    cause. That is #3240, with the log and what I could and could not establish
    about it; it is not reproducible here, in 47 runs including 22 of the full suite
    and 10 under deliberate CPU contention.
    
    This test is about whether discovery classifies and dials a slot-map node, so it
    now connects with withPubSub: false and depends on one leg rather than two. That
    is a narrowing, not a workaround: it still fails with the discovery fix reverted,
    re-checked after the change.
    
    Worth recording that the in-process server only learned to model a replica in
    this PR, so nothing had exercised that path before - which is also why nothing
    covers it now that this test steps around it. #3240 says so.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    b60a643 View commit details
    Browse the repository at this point in the history
  7. Point the obsolete messages at 4.0, since 3.2 has shipped without them (

    #3244)
    
    27 [Obsolete] messages across 11 files promised removal in 3.2. 3.2 is released
    and every one of them is still here, so the text now reads as a deadline that
    visibly passed. 4.0 is imminent and is where the break actually belongs.
    
    All 27 are already error: true, so nobody can compile against them today and
    this changes no behaviour - only what the compiler error says. The members stay
    binary-present, which is the point: removing them is a binary break for anything
    built against 2.x that has not been recompiled, and a major version is where
    that is allowed to happen.
    
    Mechanical and verified as such: one substring, 27 replacements, 27 changed
    lines across 11 files, nothing else touched. Covers the slave/replica renames
    (ClientFlags, ClientType, CommandFlags, ReplicationChangeOptions, IServer,
    ClusterConfiguration, RedisFeatures), the settings that no longer do anything
    (UseSsl, HighPrioritySocketThreads, PreserveAsyncOrder), and the exception-detail
    properties that moved to ConfigurationOptions.
    mgravell authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    8ce72d6 View commit details
    Browse the repository at this point in the history
Loading