Repository navigation
Comparing changes
Open a pull request
base repository: StackExchange/StackExchange.Redis
base: 3.2.1
head repository: StackExchange/StackExchange.Redis
compare: 3.2.15
- 14 commits
- 44 files changed
- 2 contributors
Commits on Sep 15, 2026
-
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
Configuration menu - View commit details
-
Copy full SHA for a8da2bc - Browse repository at this point
Copy the full SHA a8da2bcView commit details
Commits on Sep 16, 2026
-
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.
Configuration menu - View commit details
-
Copy full SHA for 19674a2 - Browse repository at this point
Copy the full SHA 19674a2View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for e5e2a31 - Browse repository at this point
Copy the full SHA e5e2a31View commit details -
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]>
Configuration menu - View commit details
-
Copy full SHA for 2c89cc8 - Browse repository at this point
Copy the full SHA 2c89cc8View commit details -
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
Configuration menu - View commit details
-
Copy full SHA for 2c1d4b1 - Browse repository at this point
Copy the full SHA 2c1d4b1View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for fd6faa1 - Browse repository at this point
Copy the full SHA fd6faa1View commit details -
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
Configuration menu - View commit details
-
Copy full SHA for 900338c - Browse repository at this point
Copy the full SHA 900338cView commit details
Commits on Sep 17, 2026
-
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.
Configuration menu - View commit details
-
Copy full SHA for 1a4874c - Browse repository at this point
Copy the full SHA 1a4874cView commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for d70af99 - Browse repository at this point
Copy the full SHA d70af99View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for b734861 - Browse repository at this point
Copy the full SHA b734861View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for a1673ea - Browse repository at this point
Copy the full SHA a1673eaView commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for dbe7270 - Browse repository at this point
Copy the full SHA dbe7270View commit details -
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.Configuration menu - View commit details
-
Copy full SHA for b60a643 - Browse repository at this point
Copy the full SHA b60a643View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for 8ce72d6 - Browse repository at this point
Copy the full SHA 8ce72d6View commit details
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff 3.2.1...3.2.15