Repository navigation
Comparing changes
Open a pull request
base repository: StackExchange/StackExchange.Redis
base: 3.1.13
head repository: StackExchange/StackExchange.Redis
compare: 3.1.31
- 18 commits
- 155 files changed
- 2 contributors
Commits on Aug 10, 2026
-
The classic leg used ONE connection whatever -c said; give each clien…
…t its own (#3170) readme.md documents `+m`/`-m` as "when enabled clients share a connection, otherwise each client has a separate connection". The classic (old-core) leg did not implement the second half: OldCoreBenchmarkBase built a single ConnectionMultiplexer in its constructor and `GetClient(int index)` ignored the index entirely, returning that one IDatabase to every worker. `Multiplexed` was never read on this leg at all -- `+m` parsed and then did nothing. So `-c` was CONCURRENCY (in-flight requests pipelined onto one connection), not connection count, on the leg that ships. The fan-out above it was always correct: BenchmarkBase allocates ClientCount tasks and calls GetClient(i) per worker; only the worker-to-connection mapping collapsed. A multiplexer is one connection per endpoint by design, so "a connection per client" means a multiplexer per client. Now builds `Multiplexed ? 1 : ClientCount` of them and maps worker i to `i % connectionCount`, so `+m` reproduces the previous behaviour exactly and is the only way to get it. NewCoreBenchmark already did this (`new(count: Multiplexed ? 1 : ClientCount)`); BridgeBenchmark shares this base and inherits the fix. VERIFIED against a live server, sampling DURING the run from two independent vantage points -- the OS TCP table by owning pid, and the server's own CLIENT LIST: -c 1 OS 1,1,1,1 CLIENT LIST 1 (+ the cli itself) -c 8 OS 8,8,8,8 CLIENT LIST 8 -c 50 OS 50,50,50,50 CLIENT LIST 50 -c 50 +m OS 1,1,1,1 CLIENT LIST 1 The `+m` cell is the discriminating one: without it, a fix that simply hard-wired N connections would pass just as happily. `--basic` also exercised (exit 0). AND A BANNER THAT CANNOT AGREE WITH A FLAG THAT DID NOTHING. The per-test banner printed ", mux" whenever `+m` was passed -- including on the leg where it had no effect -- so it reported the FLAG, not the behaviour, and could not have caught this. `ConnectionCount` is virtual, defaulting to the intended count, and OldCoreBenchmarkBase overrides it with `_connectionMultiplexers.Length`: the number actually created. It is printed on the `### ... ###` announce line, which survives `-q` so a harness can gate on it, and in the per-test banner.
Configuration menu - View commit details
-
Copy full SHA for 14f624b - Browse repository at this point
Copy the full SHA 14f624bView commit details -
* Tests: probe for absent servers instead of discovering them by timeout Cluster tests set connectTimeout=10000 and only skip once a connection attempt has failed, so with no cluster running each of ~50 tests burned 10 seconds before reporting a 1ms skip. Sentinel was the same shape via SentinelBase.InitializeAsync, which polls for 15 seconds per test and then reports a failure rather than a skip. Add Skip.IfNoServer, backed by a single short TCP connect per endpoint cached for the run, and route the six cluster configuration overrides through TestBase.GetClusterConfiguration so the probe happens before a configuration is handed out. This only reports whether anything is listening: a server that is up but unreachable still fails, as it must. Also, while in SentinelBase: wait on the primary connection rather than only the sentinel connection (the retry loop already used the former as its success condition, but the assert checked the latter, so it could pass having never reached the primary), and give the assert a message. Full suite against a primary/replica/secure/failover-only topology goes from 10m34s with 27 failures to 49s with none attributable to the missing servers; against the full topology the skip count is unchanged at 150, confirming nothing is newly suppressed. CI: the Windows job now starts that smaller topology by default, since the cluster and sentinel instances gossip continuously and that job is a fractional vCPU running Windows running WSL running redis. Dispatch with windows-topology=full for everything. Also allows pinning the apt redis version instead of silently testing whatever is GA, and polls for readiness rather than sleeping a fixed 5 seconds. * CI: don't let the readiness probe kill its own step The wsl-bash shell runs with -euo pipefail, so `out=$(redis-cli ... PING)` aborts the entire step the first time a server is not yet accepting connections - which is the exact state the poll exists to wait out. The previous code never hit this because every redis-cli call was guarded with `|| echo ...`. Effect on the last run: the step died 41ms after launching the servers, continue-on-error painted it green, and the suite then ran against a Redis that wasn't there - thousands of "not possible to connect" failures across MultiPrimaryTests, RespProtocolTests and friends, ten minutes later and nowhere near the cause. So: tolerate the expected probe failure, and drop continue-on-error in favour of failing this step explicitly when a required port never comes up. One clear "no server on: 6379" beats several thousand connection errors in a later step. * Attack the actual Windows CI failures, not the topology Three post-change runs said the reduced topology was not the lever: each failed on a different set of tests, none needing the servers it omits, and the job got no faster. So windows-topology defaults back to full - this job is our only coverage of .NET Framework and of Windows' own socket/TLS stacks, and that is worth keeping complete. `basic` stays available via workflow_dispatch as a bisection tool. What the failures actually show is a machine that cannot honour the library's defaults, so: - Raise the thread pool floor (TestConfig static ctor) to max(64, cores*8). The pool grows ~1-2 threads/sec past its minimum, and the suite opens many connections at once; that ramp is what turns a healthy server into "Timeout performing PING (5000ms)" when a synchronous caller is parked waiting for a completion that cannot get a thread. Free on a fast machine. - Add a timeout floor, REDIS_TESTS_MIN_TIMEOUT_MS, set to 20s on the Windows job only, applied to SyncTimeout and AsyncTimeout and only where the test did not ask for a specific timeout - a test choosing a short timeout is testing timeout behaviour. Not applied to ConnectTimeout: bisection showed that flooring it breaks tests that simulate a failure and then allow a fixed window for the heartbeat to reconnect, because a stalled attempt can no longer be retried inside that window. SyncTimeout breaks that same test, so ConnectFailTimeoutTests.NoticesConnectFail now states its dependency explicitly. - Fix two genuinely fragile tests. SelectByLatency compared injected latencies 10ms and 15ms apart and waited a fixed 100ms to "settle"; scheduling noise on a contended machine exceeds that margin and inverts the ordering, so the margins are now 150/300ms and it polls until selection converges. And SelectByWeight's failure was really Assert.True(conn.IsConnected) immediately after ConnectGroupAsync, which races health-check probes that need a round trip; that pattern appeared at six sites, all now waiting via GroupWait. Also drops a duplicate endpoint probe in favour of caching the existing TestConfig.IsServerRunning. Verified: full suite green both with and without the floor (0 failed, 5731 passed, 150 skipped - the same skip count as before any of this), and the whole traversal builds across every TFM. * Tests: wait for the subscription connection before asserting on it PubSubGetAllAnyOrder asserts sub.IsConnected() immediately after connecting, but the subscription connection is separate from the interactive one and can still be coming up at that point - so on a slow or contended machine the assert loses the race. This was the only remaining failure on the Windows CI job, reported as the rather opaque "IsConnected" (the nameof used as the assert message). Wait via the existing UntilConditionAsync helper first. Same treatment for the identical assert in FailoverTests. Note this cannot be demonstrated locally: the race needs a machine slow enough to lose it, so a local run proves only that nothing regressed.
Configuration menu - View commit details
-
Copy full SHA for 91ac27c - Browse repository at this point
Copy the full SHA 91ac27cView commit details -
Cut the CI build time: analyzers once per project, cache NuGet (#3171)
The .NET Build step was ~4 minutes on the Windows runner. Measured with /p:ReportAnalyzer=true across all 42 compilations of a clean build: analyzers are 226s of CPU and ~40% of wall time, of which StyleCop is 155s and PublicApiAnalyzers 37s. Our own analyzer and generators are 5.2s - about 2% - so they are not the problem. Restore is another ~80s, uncached on both runners. - run analyzers on one TFM per project (the newest it builds) rather than once per TFM: every rule we run is TFM-agnostic, so a six-TFM library was paying for the same diagnostics six times. Generators are unaffected. /p:RunAnalyzers=true gets the full per-TFM sweep back. - StyleCop applies to src/ only; it was buying nothing in tests/ and toys/ that review does not. - cache ~/.nuget/packages in both CI jobs. - --no-build on the CI test steps, which were each re-entering the build for ~15s. - drop GeneratePackageOnBuild from RESPite.Benchmark, which packed a nupkg on every Release build; the pack step still publishes it. Clean full build locally: 26.7s -> ~19s, Csc CPU 110-124s -> 70-83s.
Configuration menu - View commit details
-
Copy full SHA for 74bfe78 - Browse repository at this point
Copy the full SHA 74bfe78View commit details
Commits on Aug 14, 2026
-
Always issue HELLO when the server should understand it, not just for…
… RESP3 (#3175) * Always issue HELLO when the server should understand it, not just for RESP3 Fixes #2968 (remaining concern). When INFO is unavailable, discovery fell back to `SET {guid} replica-read-only PX 1 NX` to detect a read-only replica. That probe writes a random, unprefixable key, so it can never be allow-listed by an ACL key pattern - which is exactly the situation that makes INFO unavailable in the first place (INFO and CONFIG are both @dangerous). HELLO's reply carries "role", needs no key, and is in the @connection category. So: issue HELLO whenever it is available and the assumed server version is 6.0+, and skip the key-based probe when HELLO is going to tell us (or has already told us) the role. Two independent opt-outs remain: `$hello=` in the command map, and a sub-6.0 `defaultVersion`. As a bonus, RESP2 connections now learn the server version, mode and connection id from the handshake as well. The RESP2 HELLO is deliberately not the same message as the RESP3 one: - `HELLO 3` stays first-in-pipeline and carries the credentials, as it must for RESP3 negotiation to happen at all on a secured server. - `HELLO 2` is a bare HELLO issued *after* AUTH, on the interactive connection only. It isn't negotiating anything, and folding credentials in would change how credential failures surface (there is a pre-existing difference in behaviour between AUTH failing on its own and AUTH failing inside HELLO; that is a separate bug, not one to inherit here). Also excludes HELLO from the twemproxy and envoyproxy command maps, and verifies against both proxies locally: - twemproxy 0.5.0 (the newest release) *closes the connection* on an unsupported command, so HELLO is fatal. Since v3 raised the assumed default version to 6.0, RESP3 - and therefore HELLO - became the default, and a default-configured twemproxy connection never became usable at all. That is a v3 regression against v2, where the assumed version of 3.0 meant no HELLO was ever sent. - envoy up to ~1.31 answers "unsupported command"; 1.39 instead proxies HELLO to an arbitrary backend node, so the version/role/mode in the reply describe some other server. Taking that at face value would flip a proxy endpoint into cluster mode or mark it as a replica, so HELLO's mode/role are now only applied to server types that support auto-configure - proxies are excluded even if a hand-rolled command map re-enables the command. Bumps the test-topology envoy pin from v1.31 to v1.39 (8 minors stale, and the two versions behave differently here). Tests use a recording in-process server to pin exactly which commands the handshake issues: HELLO at the right protocol level, absent when disabled either way, and the replica probe present only when HELLO cannot tell us the role. The Resp3HandshakeTests matrix no longer skips RESP2 clients (they issue HELLO now too), and asserts the negotiated protocol. * Drop the discovery-HELLO logger message EventId 110 is in use in another in-progress PR, and this doesn't warrant a logger message of its own - the handshake HELLO is already visible in the detail/parse logs.
Configuration menu - View commit details
-
Copy full SHA for 85cd5f6 - Browse repository at this point
Copy the full SHA 85cd5f6View commit details -
Fix two RESP3 connect failure modes: credential desync (#3172) and su…
…bscription poisoning on downgrade (#3154) (#3176) * Never send both HELLO AUTH and a standalone AUTH Fixes #3172, and the RESP3 credential-failure hang more generally. Redis has a protocol bug (reproduced on 7.4.10, 8.0.6 and an 8.9 preview; valkey 8.1.9 is unaffected): inside a pipelined batch, a *failing* AUTH only gets a reply if it is the first command in that batch. Otherwise the error is silently dropped, so every subsequent reply on that connection lands on the wrong message: AUTH bad, PING -> -WRONGPASS, +PONG (correct) PING, AUTH bad, PING -> +PONG, +PONG (error dropped) HELLO 3, AUTH bad, PING -> <hello map>, +PONG (error dropped) AUTH bad, AUTH bad, PING -> -WRONGPASS, +PONG (second dropped) The handshake wrote `HELLO <ver> AUTH user pass` *and* a standalone `AUTH user pass` ("we auth EVEN IF we have used HELLO to AUTH"), so with credentials the server rejects, the second AUTH's error vanished and the whole handshake shifted by one reply: the tie-breaker GET consumed the tracer's reply, the tracer never completed, and the connection never became usable. Instead of a clean AuthException, callers saw "no connection became available" timeouts, and topology detection read the wrong replies - which is #3172: a sentinel reporting "The ConnectionMultiplexer is not a Sentinel connection. Detected as: Standalone" under v3's RESP3 default, cured by forcing protocol=resp2. v2 never hit this because it assumed server version 3.0 and so never issued HELLO. #3140 (separate sentinel credentials) makes "credentials the sentinel rejects" an easy case to land on. So: AUTH goes first in the batch (where its errors are reported correctly), and HELLO follows it, bare. HELLO only carries credentials when AUTH is unavailable in the command map - which the ConnectionMultiplexer constructor already only permits when RESP3 is being attempted. Verified against the live sentinel topology: before, RESP3 + rejected credentials gave "Detected as: Standalone" and no primary connection; after, all four protocol/credential combinations resolve the primary. ConfigTests. MutableOptions no longer needs to skip RESP3 - that asymmetry was this bug - so it now covers both protocols as a regression test. * Reroute subscriptions off the interactive connection when we downgrade to RESP2 Fixes #3154. Under RESP3 subscriptions share the interactive connection; under RESP2 they need their own. Which bridge a subscription goes to is therefore decided by what we know-or-expect the protocol to be at the time it is queued - and that can change underneath a queued message: a connection negotiates RESP3, later reconnects, the new HELLO fails (timeout, expired token, failover to a down-level node), and the tracer settles the connection as RESP2. Anything queued in the interactive bridge's backlog while we still expected RESP3 - notably the SUBSCRIBEs that EnsureSubscriptions pushes during the disconnect - is then written to the interactive connection, which puts it into subscriber mode: from that point it rejects every ordinary command with ERR only [P|S][UN]SUBSCRIBE / PING / QUIT allowed in this context so the connection is permanently poisoned, and the multiplexer has no idea. The fix is at the write, not at the downgrade: when the interactive bridge is about to write a subscription-bridge message on a connection it now knows to be RESP2, it hands the message to the subscription bridge instead. That covers every drain path (direct write, sync and async backlog processors) without needing to catch the moment the protocol resolved, and it is robust to any other way a queue can outlive the expectation it was filled under. Only that direction is handled: we never upgrade an existing expectation from RESP2 to RESP3 (HELLO is only issued when RESP3 is expected in the first place), so the mirror case cannot arise from a protocol change. Resp3DowngradeTests reproduces the whole sequence in-process: it negotiates RESP3, makes the toy server stop understanding HELLO, holds the accept gate shut so there is a real window in which we are disconnected but still expecting RESP3, queues a subscribe into that window, then releases the gate. Before this change the SET after reconnect fails with the subscriber-mode error above. It also asserts the underlying invariant directly - under RESP2 no single connection may carry both subscription and ordinary commands - which needed a per-connection command record in the test server rather than inferring it from symptoms.
Configuration menu - View commit details
-
Copy full SHA for 228cd37 - Browse repository at this point
Copy the full SHA 228cd37View commit details -
(dev/eng) Support transport feeds (#3173)
* PhysicalConnection transport mode: push-feed IO via Tunnel.ConnectTransportAsync (SER009) When a Tunnel yields a DuplexTransport, PhysicalConnection now runs with no socket, no Stream, no SslStream and no reader thread: the transport is acquired BEFORE socket creation (the widest form of the existing connectTo=null no-socket pattern), outbound rides the existing _output seam through a TransportWriter : BufferedStreamWriter adapter (the transport IS an IBufferWriter with explicit Flush, so every call site is untouched), and inbound is PUSH -- TransportFeed commits transport-owned spans into the same CycleBuffer + CommitAndParseFrames the pull loops use, on the transport's threads. One copy, zero thread hops, no read syscall surface on this side. TLS contract: a transport tunnel owns TLS; config.Ssl=true alongside one throws NotSupportedException rather than silently double-encrypting or silently skipping. Teardown disposes the transport in Shutdown alongside socket/output, and Dispose treats an owned transport like an owned socket. Gated end-to-end from the SocketSet side (bench/tunnel-selftest, mux + mux-tls cells): a real ConnectionMultiplexer over SocketSetTunnel against Garnet -- connect (HELLO handshake included), PING, SET/GET, 500-op pipelined burst, plaintext AND transport-terminated TLS, ALL PASS first run. The 129-test writer/round-trip/in-proc battery still passes (classic paths untouched). * Transport mode TLS: the transport sees the intent, and gets to disagree (SER009) A transport tunnel owns connect and TLS end-to-end, so two things were wrong: it could not SEE the TLS configuration (the same engine may be run with or without TLS - that cannot be assumed either way), and ConnectedAsync prejudiced against config.Ssl by throwing outright. Ssl=true is entirely valid in transport mode; the real question is whether the TRANSPORT disagrees. ConnectTransportAsync now takes a TlsOptions: a readonly struct wrapping the ConfigurationOptions, whose accessors proxy through to just the TLS-relevant settings (IsEnabled, SslHost, SslProtocols, CheckCertificateRevocation, the certificate validation/selection callbacks including their ambient env-var fallbacks, GetSslClientAuthenticationOptions on net6+, and ResolveHost, which applies the same SslHost-or-endpoint-host fallback our own TLS path uses). Zero-alloc, nothing snapshotted, and it cannot be used to reach - or change - anything else about the configuration. Backend-specific settings remain the tunnel constructor's business. DuplexTransport gains IsEncrypted (virtual, default false: the safe answer, so a TLS-terminating transport that forgets to override is refused rather than trusted). ConnectedAsync now fails only on actual disagreement - config.Ssl with a transport reporting plaintext - recorded as AuthenticationFailure like the SslStream path, not the generic InternalFailure catch. The converse (encrypted when the config did not demand it) is accepted and logged: more secure than asked for is fine. On the "actively assert ssl.IsEncrypted after the handshake" advice for the pre-existing SslStream path: it is moot, and the comment at the site now says so with both source links. On every TFM we target, SslStream.IsEncrypted (and IsSigned) is an alias for IsAuthenticated - "handshake completed, no exception" - which a successful await already proved; it says nothing about the bytes on the wire. What forbids plaintext there is the EncryptionPolicy.RequireEncryption we already pass. Rather than a tautological assert that implies a guarantee it does not make, net6+ now logs the NegotiatedCipherSuite alongside SslProtocol, so a surprising negotiation is visible. Two rough edges found while in here: LoggingTunnel never forwarded ConnectTransportAsync to its tail, so wrapping a transport tunnel silently bypassed the transport and fell back to a socket. It now forwards and wraps via a new virtual Log(DuplexTransport, ...) hook - LoggingDuplexTransport captures into the same .in/.out pair the stream path produces, so ReplayAsync/ValidateAsync read both unchanged. Outbound is captured in Advance (the last point the bytes are still ours; hence GetSpan routes through our GetMemory), inbound before the consumer sees it, and IsEncrypted forwards to the inner transport. Since our constructor clears Ssl from the live options so captures are plaintext, the tail is handed a Clone with the original intent restored - otherwise a logged transport would have quietly connected in the clear. The default hook returns the transport unchanged: a custom subclass that only implements stream logging connects unlogged rather than failing the connection. TransportFeed did not implement OnBatchEnd, which the contract asks for explicitly. TransportWriter now tracks bytes advanced since the last flush (approximate in the safe direction: a redundant flush attempt, never a missed one) and the callback flushes through PhysicalBridge.TryFlushStagedWrites, which takes the write lock with TryTakeInstant or does nothing at all. The lock is what makes staging exclusive, so an unguarded flush could hand a half-written frame to a transport mid-Advance; instant-or-nothing also declines safely if the burst is being pumped inline on a thread that already holds it. The only thing that can be staged there is an inbound-triggered write that did not demand a flush, so the common path is one volatile read. Solution builds clean on all TFMs (warnings-as-errors). net10.0 suite: 5666 pass / 8 fail, against 6 failures on a HEAD baseline in a temporary worktree - a disjoint set of names, all connect-timeout shaped, from running against a trimmed local server topology; each of the 8 passes in isolation. Note that transport mode itself has no test coverage in this repo at all, so the above is compile-and-review verified, not exercised. * TLA casing: LogInformationTlsConnectionEstablished/ConfiguringTls, not TLS Framework convention is Pascal casing for acronyms of three or more letters (only two-letter ones stay upper: IPEndPoint, IOException), and the BCL is consistent about this exact acronym: SslStream, SslProtocols.Tls13, SslClientAuthenticationOptions, TlsCipherSuite. The repo already leaned that way - TlsHandshakeAsync, and the new TlsOptions - leaving these two internal LoggerMessage partials as the only TLS-cased identifiers in src. Internal, so this is rename-only: no API file, no compat surface, six call sites. Prose is untouched - "Configuring TLS" in a log message is English, where the upper-case acronym is correct. * NoWarn SER009 solution-wide; drop the per-file pragmas The experimental gate exists to stop *randoms* leaning on an unstable transport seam, not to make our own consuming code apologise for itself in every file - and SER001/004/005/007/008 are already handled this way in the root props, so SER009 was the odd one out. That removes the file-level disables in PhysicalConnection.Transport.cs and LoggingTunnel.cs and the disable/restore pair around the ConnectTransportAsync call site; the [SER009] prefixes in the PublicAPI files are unrelated and stay. Also: both files this branch introduced were missing the utf-8-bom the .editorconfig asks for. * TlsOptions.WithTls(): force the intent, do not clone the configuration LoggingTunnel needed the tail to see TLS after its own constructor cleared Ssl from the live options, and cloning ConfigurationOptions per connect attempt to carry one bit was a poor trade. TlsOptions now has a private _forceTls alongside the wrapped options, a private (options, forceTls) constructor, and an internal WithTls() - so the call site is one line and allocation-free: `if (_ssl) tls = tls.WithTls();`. IsEnabled ORs the flag over the configuration; every other accessor is untouched, since only the intent differs, never the host/protocols/callbacks. The public constructor chains to the private one (with the null check as a throw expression) rather than assigning a subset and letting the compiler zero-init the rest via an implicit : this(). Internal member and private constructor, so no PublicAPI change. Builds clean on all TFMs with RunAnalyzers=true; Parse/Config/SSL batteries pass (310). * Revert the TLS logger renames: the method name IS the shipped event name The generated half settles it - the source generator emits new EventId(<id>, nameof(<Method>)), so the method name lands in EventId.Name and reaches consumers as the event name ("EventName" in most structured sinks), where it can be filtered or alerted on. Nothing is free at 8M+ downloads a day, so the awkward spelling stays: LogInformationConfiguringTLS and LogInformationTLSConnectionEstablished are back, with a note at the top of the generated section stating that renaming these is a telemetry change rather than a refactor (and that EventName can pin an old name if one ever must move), plus a marker at the two TLA-offending declarations so nobody tidies them again. While applying the same reasoning to the code added on this branch: the net6+ cipher-suite variant now declares EventId 99 - the same event as its sibling, one logical "TLS connection established" carrying an extra property where the platform can report it - instead of minting 110 and thereby reporting the same event under different ids on netfx and modern TFMs. The generator is happy to share an id across overloads; verified in the emitted source. LogInformationTransportConnected takes 110, and since both of those ids are new on this unshipped branch, no consumer ever saw either. Builds clean on all TFMs with RunAnalyzers=true; Config/SSL/Parse batteries pass (310).Configuration menu - View commit details
-
Copy full SHA for 0d9f24f - Browse repository at this point
Copy the full SHA 0d9f24fView commit details -
Preparation work for smart-client-handoffs (#3174)
* fix unknown PUSH handling * Toy server: node identity groundwork for endpoint reconciliation Test-only; prerequisite for the endpoint-identity work (#2826) and for reacting to endpoints we did not choose the form of. - node aliases, resolved through TryGetNode, so one node answers to several names; kept in their own map rather than as extra _nodes entries, since EndPointComparer cannot order mixed endpoint types and AddEmptyNode derives the next endpoint's form from an arbitrary key - CLUSTER NODES emits ip:port@cport[,hostname]; the cport was previously string-concatenated as "@1" + port, which is accidentally right for four-digit ports and wrong from 10000 up - per-node cluster-preferred-endpoint-type, with the complement metadata map in CLUSTER SLOTS and the prescribed placeholder endpoint values (null, "", ?); ? is derived from hostname-preferred-but-unannounced rather than being settable, so combinations the real server cannot produce cannot be expressed - gated on a 7.0 server version: below that the preference is inert, the hostname is not emitted, and the SLOTS node block is three elements - CONFIG GET reports the answering node's view of the announce settings, which costs CLUSTER CONFIG GET its LockFree status - AddEmptyNode(EndPoint) overload, since the existing one copies the form of an existing key Verified against a real 8.9.241 cluster at RESP2 and RESP3: the preference belongs to the *answering* node and governs every entry in its reply, while hostname availability is per described node - so asking a hostname-preferring node about a peer with no hostname yields ?. Also tightens the in-process TLS harness: the certificate is built on first use so it can cover aliases registered after construction, and validation no longer forgives a name mismatch on thumbprint alone. * ClusterNode: expose the endpoint trailer instead of truncating it CLUSTER NODES reports "ip:port@cport[,hostname[,aux-field=value]*]" since 4.0, and we discarded everything from the '@' onwards. The text was never lost (ClusterNode.Raw has it) but the parsed view did not offer it, and the hostname is a node's second identity - the thing endpoint reconciliation needs (#2826). Additive on ClusterNode: - ClusterBusPort (int?), null on pre-4.0 lines that report no cport - Hostname (string?), null when unannounced or the slot is empty - AuxFields, preserving unrecognized keys since the set is documented as extensible No named ShardId/HumanNodename properties: neither is observable on a real 8.9 server (cluster-announce-human-nodename does not appear in the reply at all), so naming them would mean shipping API for fields we cannot demonstrate. A named accessor stays additive if that changes. Parsing is lenient by design - an exception here does silent damage to topology, per the warning on the constructor - so an unparseable cport yields null and aux entries without '=' are skipped. Toy server gains SetAuxField, and CLUSTER NODES now handles the positional subtlety that the hostname slot can be empty while aux fields follow ("@17000,,shard-id=abc"). Tests: grammar cases against the parser directly, realistic shapes round tripped through the toy server, and end-to-end against a real cluster with version gates (4.0 for the bus port, 7.0 for hostnames) - including setting cluster-announce-hostname and restoring it. Those run per protocol, so they also cover CLUSTER NODES arriving as a RESP3 verbatim string, which had no end-to-end coverage before. * Add CLUSTER SLOTS: object model, parser, IServer surface, export The second topology view, and a prerequisite for endpoint reconciliation (#2826): CLUSTER SLOTS is the only source that conveys which naming form a deployment prefers, and it carries the node-id, which is the one identity that does not depend on who is answering. Laid out per the HotKeys precedent rather than growing the existing files: ClusterSlots.cs (IServer partial + model), ClusterSlots.ResultProcessor.cs (the parse, on RespReader), ClusterSlots.Server.cs (RedisServer implementation). Model: ClusterSlotsResult.Assignments -> ClusterSlotAssignment{Slots, Primary, Replicas} -> ClusterSlotNode. Deliberately lossless: AnnouncedEndpoint keeps the literal the server sent, Metadata keeps unrecognized keys, and EndPoint is populated only when the reported value is usable as one. The three placeholder endpoint values are encoded rather than merely documented: null ("the server does not know this node's address"), "" ("the node does not know its own") and "?" ("an unknown node") all yield a null EndPoint, since substituting the connection's own address is a caller decision - and is outright wrong for "?". Metadata is walked pairwise rather than by declared length, so the RESP3 map and the RESP2 flat array need no special casing. ExportConfiguration now writes slots.txt alongside nodes.txt, one line per node per range so it greps like nodes.txt; ExportOptions.Cluster covers it (comment updated), and ExportOptions.All is -1 so it is included by default. Rendered from the model rather than dumped as RESP: users do not want to read RESP, CONFIG GET already sets the precedent for rendering a structured reply as flat text, and the rendering is faithful because the model is lossless. Tests: the naming matrix against the toy server (ip/hostname/unknown preference, unannounced hostnames, both address placeholders, pre-7.0 three-element node blocks, migrated slots, node-id stability across forms), plus real-cluster coverage at both protocols for keyspace coverage, replicas, the export, and SLOTS/NODES agreeing on primary node-ids - which is the premise id-keyed reconciliation rests on. * Fault unroutable -MOVED/-ASK targets instead of dialling "?" A hostname-preferring node redirecting to a peer that has announced no hostname reports "MOVED <slot> ?:<port>", and "?" denotes an *unknown* node - so unlike a missing or empty endpoint it cannot be resolved to the node that answered. Reachable on any OSS cluster with cluster-preferred-endpoint-type=hostname and a node whose cluster-announce-hostname is unset; most plausibly one that had it set via CONFIG SET without CONFIG REWRITE and was then restarted. Demonstrated before fixing, and it was worse than a bad message: Format.TryParseEndPoint accepts "?" as a hostname, so TryResend called GetServerEndPoint(endpoint) - which defaults to activate:true - and thus created and dialled a ServerEndPoint for a host literally named "?". The phantom stayed in GetEndPoints() with a live connect loop, the command sat in the backlog for the full async timeout, and the caller got a timeout exception recommending they check connectTimeout, with a link to the timeouts troubleshooting article. Now: unusable targets are rejected before any endpoint is created ("?", an empty host, or port 0 - the last covering the unknown-endpoint form, which parsed to the nonsense DnsEndPoint(":<port>", 0)); a topology refresh is still requested, which genuinely recovers, since CLUSTER NODES reports addresses positionally whatever the preference; and the command faults immediately with a message naming the cause and quoting what the server actually sent. New RedisErrorKind.UnknownRedirectTarget, appended so existing values keep their numbers, and documented as a client-side classification rather than a server error code. Included in FaultContext's known-not-applied set alongside Moved/Ask: the redirect proves the command did not run, so a retry is a first attempt rather than a repeat, which is what lets WithRetry recover once the refresh has landed. * Shadow the CLUSTER SLOTS topology alongside CLUSTER NODES Builds an internal id-keyed view of the cluster from CLUSTER SLOTS and records it during autoconfigure, without routing on it. The point is to have an oracle: each subsequent step (slot map, then discovery, then pruning) can be taken with the two views still demonstrably agreeing. Keyed on node-id because it is the only identity in a reply that does not depend on which node answered - names vary with the answering node's preferred endpoint type, so keying on a name means one node can produce two entries. Keyed on the id they merge, and the names become attributes. Identity accumulation is the union of the primary endpoint field and the metadata complement, and the primary is classified by *content* rather than position: a first cut read only the metadata and consequently lost whichever form the answering node happened to prefer, which the tests caught. Tests assert the shadow view agrees with the routing view on primary node-ids and on slot ownership per node - as sorted slot sets, so differing range fragmentation between the two views is not mistaken for disagreement. Covered against the toy server across naming configurations, and against a live six-node cluster whose ownership is heavily fragmented, which is the stronger check on range boundaries. * Do not invoke CLUSTER SLOTS from autoconfigure yet The ClusterTopology model and SetClusterSlots stay - they are the id-keyed view the routing work needs - but the autoconfigure request is commented out with the reason in place. This PR is scoped to work that cannot destabilise a connection, and asking every server for an extra command on every autoconfigure is a new failure surface on the connect path (an unexpected error reply to an internal call, or a proxy that mangles the command) for no user-visible benefit until routing consumes it. It is enabled in the follow-up, where ordering also matters: the request must precede CLUSTER NODES so identities are known before NODES starts creating servers by address. The topology tests consequently source the reply explicitly via the public ClusterSlotsAsync rather than relying on autoconfigure, so they cover the model and the parser here; the wiring is covered where it is switched on. * Recognize CLUSTER SLOTS metadata keys without allocating The metadata keys come from a fixed vocabulary, so match them over the raw bytes via the usual [AsciiHash] enum rather than materializing a string per entry: ClusterSlotMetadataKey plus a case-insensitive generated TryParse, since the contract renders these keys as IP/Hostname in prose but lower-case in its examples and the casing cannot be relied on. Recognized keys are surfaced as ClusterSlotNode.Ip/Hostname and no longer appear in Metadata, which now holds only what we do not recognize. That is what makes the saving real - keeping them in a string-keyed list would allocate the key anyway - and it matches the ClusterNode.Hostname/AuxFields precedent, where the hostname likewise is not repeated in the collection. Worth more than it first appears: ranges repeat a node whenever its slot ownership is not contiguous, so this is one allocation avoided per key per *range*, not per node - on the live test cluster that is 43 ranges rather than 6 nodes. Toy server gains SetSlotsMetadata so the forward-compatibility path is actually exercised: an unrecognized key must be preserved as declared, and an upper-case known key must still be recognized rather than landing in Metadata. * Report IReadOnlyList rather than IList on the new cluster members IList advertises Add/Remove/set that throw at runtime on the collections we actually hand back, which is a lie in the type system. The only argument for it was matching ClusterNode.Slots/Children, and those are shipped so they cannot move; every member changed here is unshipped, so consistency-with-a-wart buys nothing. Changed: ClusterSlotsResult.Assignments, ClusterSlotAssignment.Replicas, ClusterSlotNode.Metadata, ClusterNode.AuxFields. Also unpicks the `x?.AsReadOnly() ?? (IList<T>)Array.Empty<T>()` expressions, which were dense enough to misread, into a plain null check returning `[]`. No allocation difference: an empty collection expression targeting an interface compiles to Array.Empty<T>(), so only a populated list pays for the wrapper. * Fix Windows CI: colliding toy-server node ids, and a test asserting fleet shape Two unrelated failures, one real bug. Node.NewId created a Random *per call*, and .NET Framework seeds Random from Environment.TickCount - so two nodes constructed within the same tick received identical 40-character ids. Only reachable on the net481 leg, which is why Linux never showed it. The failure was maximally misleading: the shadow topology keys on node-id, so it correctly merged the two lying nodes into one, and the assertion that broke was the comparison against CLUSTER NODES - the *fake* was wrong and the client looked wrong. Now one shared Random on that path, plus an interlocked counter woven into the first eight characters so ids are unique by construction rather than by luck, with a regression test that creates 26 nodes back to back. ClusterSlotsTests.ReplicasAreReported asserted that some assignment reports replicas, which is a statement about the deployment rather than the parser: the Windows CI fleet is smaller than the local compose and has none. Skips in that case instead, so it still exercises the replica parsing wherever replicas exist.
Configuration menu - View commit details
-
Copy full SHA for ac4850f - Browse repository at this point
Copy the full SHA ac4850fView commit details
Commits on Aug 18, 2026
-
Add SER305/SER306: waiting for a queued transaction result (#3178)
* Fix: a retrying transaction did not pre-complete fire-and-forget RetryTransaction records each queued call and hands back a durable proxy task, completed later from whichever attempt succeeds. It did that regardless of the flags - which it could not see, since they are captured inside the generated per-command state - so a fire-and-forget operation stayed incomplete until ExecuteAsync forwarded it. A plain RedisTransaction hands back an *already-completed* task there: the caller has explicitly declined the reply, so there is nothing to wait for. The result was that identical caller code returned instantly on one and blocked for good on the other, which defeats the stated goal of letting ITransaction code move onto a retrying database unchanged. The generated captured-arguments struct now exposes its CommandFlags through IFlaggedRedisArgs - following IMappableRedisArgs, which exists for the same reason - and RetryTransaction settles the proxy up front when the flag is set. The operation is still recorded and replayed like any other: fire-and-forget declines the reply, not the command. Because the later forwarding uses TrySet*, it simply finds the proxy already settled, so a discarded attempt can no longer fault a result the caller was told not to expect. The value used is default(TResult) rather than the command's own "no reply" value, which lives inside the command implementation and is not reachable from the funnel - a distinction without a difference, since neither is an answer from the server. * Add SER305/SER306: waiting for a queued transaction result Commands queued on an ITransaction or IBatch are buffered, not sent; the task comes back incomplete and stays that way until Execute[Async]. So awaiting one at the point of queueing waits for something that can only happen further down the method, and the caller hangs for good. This is the mistake "always await your tasks" actively teaches, and it fails silently, so SER305 is an error rather than a suggestion. It can be, without hedging, because the shape is certain by construction: the wait sits at the queueing site, so no arrangement of the surrounding code rescues it - even a prior Execute is no help, since this command was queued after it. That is also why the flow-sensitive shapes (a task captured to a local, or gathered by Task.WhenAll, then awaited) are deliberately not reported: awaiting a captured task *after* Execute is the recommended fix, and an error that gets the order wrong condemns correct code. SER306 is the one case that survives the wait. Fire-and-forget hands back an already-completed task carrying the default value, so awaiting it is legal - it just never carries the server's answer, whenever it is read. Separate ID and a warning rather than an error, so that deliberate fire-and-forget code can silence it without silencing the rule that says a transaction cannot work. Two decisions that were not obvious: - terminators are recognised by *declaring interface*, never by name. ITransaction[Async].ExecuteAsync(CommandFlags) must be awaited, while IDatabaseAsync.ExecuteAsync(string, ...) - the raw-command escape hatch - is an ordinary queued command that must still be reported; a name match gets both exactly the wrong way round. The receiver is not always the interface either: a decorator class implementing it resolves to the class's member, which this repo's own build caught on KeyPrefixedTransaction. - a non-constant flags expression is read only far enough to prove FireAndForget *present* (`flags | CommandFlags.FireAndForget` - `|` only sets bits). There is deliberately no matching treatment of &/~ to prove it absent: that would feed SER305, and an error must not rest on an expression we only partly understood. The code fixes live in a new StackExchange.Redis.CodeFixes assembly, packed alongside the analyzer. Separate because a fix needs Workspaces, which csc does not ship and the IDE does - what RS1038 exists to say, though the pinned analyzer version predates that rule, so the layout is chosen rather than prompted. * Keep the fire-and-forget probe allocation-free Testing a generic value against an interface boxes in IL, and that test sits on every operation queued to a retrying transaction. Value-type generics are not shared, though, so the JIT knows the exact TState: the optimizing JIT devirtualizes the property to a field load and removes the allocation completely - 11 bytes of machine code, no allocator call. It only does that in optimized code, and measured under default tiering the method stayed in tier-0 long enough to cost 24 bytes on every call. AggressiveOptimization compiles it straight to full opts, which takes that to zero from the first call. Guarded on #if NET because the enum member does not exist on the netfx targets, which keep the box. Recorded because it is counter-intuitive: rewriting the `is` pattern as a cast does *not* avoid the box. Both spellings emit the same box, and both were measured - the cast form only looked better because it happened to promote to tier-1 sooner, still leaving a startup window that this does not. * Record the measurements behind the fire-and-forget probe, not just the conclusion * Build no proxy at all for a fire-and-forget operation Settling a TaskCompletionSource up front was the wrong shape: it still built one, and a TaskCompletionSource constructs its Task in its own constructor, so every fire-and-forget operation queued cost two objects - 96 bytes measured - to carry a value that is fixed before the call returns. The flag now goes to RecordedOp, which builds no proxy when the reply has been declined and hands back the shared completed task instead: CompletedTask<T> for the typed shape, Task.CompletedTask for the void one. That is the same instance a plain RedisTransaction returns for this case, so the two paths now agree by construction rather than by producing equivalent values. Forwarding and faulting become no-ops for such an operation, which is what the already-settled proxy achieved before by relying on TrySet* finding it settled; this states it directly instead. The operation is still recorded and replayed exactly as any other, since fire-and-forget declines the reply, not the command. Pinned by identity rather than by a byte count, which would be brittle: two fire-and-forget calls hand back the same task instance, the void shape hands back Task.CompletedTask, and an operation that did not decline its reply still gets a durable proxy of its own. * Convince the compiler instead of asserting with `!` Three `_proxy!` in a row asserted an invariant that is not visible at the point of use: the null check lived in ForwardSuccess, which does not flow across a method boundary into Forward. Null-conditional on each branch says the same thing to the reader and to the compiler, with no suppression and no early return. Note `?.` also skips the *argument*, so in the faulted branch attempt.Exception is no longer read when there is no proxy - and reading it is what observes a fault. That is safe only because a fire-and-forget attempt is the shared completed task RedisTransaction hands back, which cannot fault; the comment says so, since it is not visible locally. Checked while here that Observe's discard is not elided in optimized code: a faulted task left alone raises one UnobservedTaskException, and `_ = t.Exception` raises none, so the access is genuinely made. GC.KeepAlive around it measured identically and would only be noise - the getter marks the holder handled, so it has side effects the JIT cannot optimize away.
Configuration menu - View commit details
-
Copy full SHA for c7b3a2b - Browse repository at this point
Copy the full SHA c7b3a2bView commit details -
Add SER307 (sync over async), and a DedicatedThreads opt-in (#3179)
* Add a DedicatedThreads feature flag, and drop an `!` The reader and writer that do not depend on the thread-pool are already built and wired: ConfigurationOptions.WriteMode reaches BufferedStreamWriter.Create, and Sync mode starts a "SE.Redis Sync Writer" thread, which makes IsSync true, which is what makes PhysicalConnection.StartReading take its dedicated "SE.Redis Sync Reader" branch instead of Task.Run. All that was missing was a way for anyone outside this assembly to ask for it. SetFeatureFlag is the route, alongside preventthreadtheft: no new public API to live with, and EditorBrowsable(Never) already says "under support guidance" rather than "reach for this". It matters for an application whose thread-pool is saturated - most often by sync-over-async - where the reply from redis cannot be processed because processing it needs a thread and every thread is waiting on one. It does not fix the thread-pool, and nothing here can; it means only that redis traffic keeps flowing while the real problem is found. Two limits are deliberate. It promotes an *unstated* preference, so anything explicitly configured still wins; and it leaves pub/sub on the existing policy rather than quietly reversing a decision made for latency reasons. Resolving the mode moved out of InitOutput into a function, because three rules now interact and getting their precedence wrong would surface only as a performance characteristic - the kind of bug that goes unnoticed for a year. That also makes it testable without connecting, which matters here: the flag is process-wide static, so a test that set it and then connected would race every other test in the run. Also swaps `_attempt!` for `?? throw`: same loud failure if the order ever changes, but the compiler is convinced rather than told. * Add SER307: blocking on a redis call instead of awaiting it Sync over async is worse against a multiplexed client than the general case, because the thing being waited for needs the same resource the wait is consuming: the reply arrives at the socket promptly, but turning it into a completed Task needs a thread, and the blocked callers are holding them. Past a certain concurrency nothing completes, so nothing is released. The symptom is timeouts with the data already sitting unread in the socket, which looks like a network or server fault and is neither. The detection is the SER305 machinery re-gated: same unwrapping of .Result, .Wait() and .GetAwaiter().GetResult(), same fire-and-forget test, but keyed on IRedisAsync - the root of every async surface, so IDatabaseAsync, IServer and ISubscriber all come along - rather than on IBatch/ITransactionAsync. Three shapes are deliberately *not* reported: - awaiting. That is correct usage of an async API and is what the rule steers people towards; flagging it would advise them in a circle. - the synchronous API. db.StringGet(key) is a genuinely synchronous path, not sync-over-async, and is the answer for callers that cannot be async. - awaiting a fire-and-forget result away from a transaction. It is a no-op await rather than a mistake, and this repo alone contains 416 of them; a rule that noisy would be turned off wholesale, taking SER305 with it. Blocking on one still reports, as SER306 - it waits for nothing. Unlike SER305, an unreadable flags argument still warns here: the harmful reading is much the more likely, and being wrong costs a warning rather than a build. docs/SyncOverAsync.md is the explainer, and is what the diagnostic links to rather than a rule page - what people need at that moment is the thread-pool story, not a description of the squiggle. It covers why raising MinThreads moves the wall rather than removing it, how to tell this apart from the problems in Timeouts.md and ThreadTheft.md, and the DedicatedThreads flag as a mitigation that explicitly does not fix the thread-pool. Verified by building the whole repo with the rules live: zero hits across src, tests and toys. * Show a worked timeout message in the sync-over-async doc Describing the signature in prose leaves the reader to assemble it from three separate facts; showing one message with all of them makes it a pattern-match against what they are already looking at. The `in` figure is called out as the tell, because it is the one that overturns the natural first conclusion: the server has answered and the bytes are in the buffer, so the network, the server and the connection are all fine - there is simply no thread free to pick the reply up. An idle `mgr` beside a busy `WORKER` says the same from the other side. Numbers are illustrative rather than from any particular incident, and are internally consistent so the example reads as a real message. * Correct: the synchronous API is not a fix for this The page and the SER307 message both offered "or use the synchronous API" as the answer for a caller that cannot be made async. That is wrong, and wrong in the direction that does harm: it would send people to something with the same failure. db.StringGet(key) is not literally sync-over-async - it does not wrap an async call and block on it - but that distinction is about mechanism, not about consequence. The calling thread still blocks until redis replies, and processing that reply still needs a thread; if the caller is on the thread-pool, as it is under ASP.NET and anything else pool-driven, the pool starves exactly the same way. The earlier text reasoned from the mechanism and stopped there. So the shipped message now says "await it instead" and stops, the page has a section saying plainly that the synchronous API is not an escape hatch, and the fire-and-forget advice is "drop the flag" rather than "go synchronous". The rule still does not flag synchronous calls, which is a scoping decision rather than an endorsement: whether one is legitimate depends on which thread the caller is on, and an analyzer cannot see that. The test that pins this now says so, instead of describing the synchronous API as the answer. * Stop leaning on `mgr`, which 3.0 no longer emits The worked example was assembled from the 2.x message documented in Timeouts.md, and included `mgr` - the dedicated SocketManager thread-pool. That machinery is gone: ConfigurationOptions.SocketManager is marked obsolete as "no longer used by StackExchange.Redis", and no part of this assembly emits the field any more. Reading an idle `mgr` as corroboration was advice about a number nobody will find. The current statistics are IOCP, WORKER and - on .NET 5 and above - POOL, which carries thread count, queued and completed work items, and active timers. That is a better signal than `mgr` was for this particular failure: QueuedItems counts work the pool has accepted and not yet started, so a large value says directly that work is arriving faster than threads can be found for it, which is the same story `in` tells from the other side. Timeouts.md keeps its 2.x and 1.x examples, since they are explicitly historical and people do still meet old messages, but now says plainly that `mgr` belongs to 2.x and what replaced it. * Remove the dead SocketManager pool from Timeouts.md Stripping `mgr` from the field list was not enough: the page also *described* the dedicated thread-pool as current machinery - "StackExchange.Redis maintains a dedicated thread-pool", "this can be customized by explicitly providing a SocketManager", "the vast majority of asynchronous operations will be serviced by this dedicated pool". None of that has been true since 3.0. Removing only the field name would have left the explanation behind it intact, which is the worse half. So: the example message is the current one (IOCP/WORKER/POOL, no `mgr`), the `mgr` row is gone from the field table, the "3 different ways" list is now the two that exist, and the paragraph contrasting the global pool against "the dedicated StackExchange.Redis thread-pool" no longer draws a distinction that does not exist. One `mgr` reference is kept deliberately, as a single line saying it belonged to 2.x and is gone: people arrive here holding an old message or old advice, and silence would leave them assuming the page had simply not covered it. It points at the DedicatedThreads mitigation, which is the nearest thing 3.0 has to what they were looking for. Note this also drops the 1.x example message and the sentence contrasting it with 2.0. That is beyond removing `mgr` - it is version archaeology two majors back, and it carried a stale `mgr: Inactive` - but it is easily restored if it is still earning its place. * Say that the thread-per-connection cost is not permanent The wide-cluster caveat reads as a flat "not for you" to precisely the people most likely to need the mitigation, and leaves them with nowhere to go. Naming the direction - dedicated readers over io_uring and IOCP, servicing many connections from a small fixed set of threads rather than a pair each - turns that into "not yet". Deliberately hedged: no date, and an explicit note that nothing on the page depends on it. A roadmap line in documentation is a promise people will hold you to, so it says what is being worked towards and stops there. * Stop citing SER305 from the sync-over-async page SER305 links to its own rule page, not to this one, and it describes a different failure: a command queued to a transaction cannot complete because it has not been sent, which is transaction semantics rather than anything to do with the thread-pool. Listing it here borrowed authority the page does not have for it, and invited a reader to think the two were the same story. SER306 stays, on the merits rather than on a link: it is the same subject matter, and SER307 defers the fire-and-forget case to it, so somebody arriving from SER307 needs to know where that went. The pointer to the rules index now says the transaction rules are a different problem, which is where anyone who wants SER305 will find it. * Qualify the Thread Theft reference as largely historical Sending a reader on modern .NET to that page unqualified costs them time on a problem they almost certainly do not have. The default path already asks for RunContinuationsAsynchronously, so continuations do not run inline on the reader; the preventthreadtheft flag switches that *off* in favour of dispatching by hand, for hosts whose synchronization context cannot be trusted to honour the option - and the autodetect only enables it for LegacyAspNetSynchronizationContext, i.e. classic ASP.NET on .NET Framework. Kept rather than dropped, because the two failures do look alike from the outside - both end with a reply that is not processed - so it is worth being able to rule it out. It is now labelled well enough to rule out quickly. * ThreadTheft.md: say who this is for, before saying what to do The page opens by telling the reader to set preventthreadtheft and see if it helps, and only explains several paragraphs later that the situation requires a SynchronizationContext whose Post dispatches synchronously - in practice classic ASP.NET. Somebody on .NET 8 who followed an exception link here will therefore try the flag first and learn nothing from it, because ASP.NET Core installs no SynchronizationContext at all and the default RunContinuationsAsynchronously already covers them. The body needed nothing: it is accurate and explains the mechanism, the exact web.config settings that produce it, and why the flag is not overhead in the case that needs it. Only the ordering was wrong, so this adds the applicability note above the advice rather than rewriting anything. Also adds a closing pointer, because the two failures present alike: thread theft has a distinctive marker in `rs: CompletePendingMessage`, and timeouts *without* it suggest a reader that cannot get a thread rather than one being hijacked - which is sync-over-async, and applies on every runtime. * Tell people how to turn SER307 off SER307's help link goes straight to this page rather than to a rules page, so it was the one rule in the set whose diagnostic led somewhere with no suppression guidance at all - every other ID has a rules page ending in a Suppressing section. It is also the rule most likely to arrive as hundreds of warnings at once on an existing codebase, which is exactly when somebody needs that paragraph and is least inclined to go hunting for it. Offers all three levers, in the order of preference that keeps coverage: a pragma at a site that has been decided about, NoWarn for a project being worked through, and a severity downgrade for teams that want it visible without failing the build. Says plainly that suppressing does not fix anything, but says it after showing how - a rule that lectures before it answers the question gets removed wholesale, which costs more than the warning did. * Spell out the connection count per node, and what RESP2 costs here "A reader and a writer per connection" leaves the reader to work out how many connections they have, and the answer is protocol-dependent: RESP3 multiplexes pub/sub over the interactive connection, RESP2 needs a second one per node. The arithmetic is not the interesting part, though, and it does not come out where you would guess. The second RESP2 connection does *not* double the thread cost, because subscription connections are deliberately excluded from sync mode and stay on the thread-pool - which means the flag protects commands and leaves pub/sub exposed. On RESP3 the single connection carries both, so both are covered. That is a behavioural difference rather than a sizing one, and it is the thing somebody on RESP2 with stalling pub/sub needs to know. Says RESP3 is the default "where the server supports it" rather than flatly: TryResp3 leans on the server version when Protocol is unspecified, so it is negotiated via HELLO rather than unconditional. * Trim the RESP2 pub/sub aside back to the sizing fact The previous wording framed subscription connections staying on the thread-pool as a coverage gap - "the flag covers your commands and not your pub/sub" - and suggested moving to RESP3 if pub/sub stalls. That reads as a shortcoming, and it is not one: the failure this page describes needs a caller blocked waiting on a reply, and pub/sub is out-of-band delivery, so the dynamic does not apply. Leaning on it would send people chasing a protocol migration for a problem they do not have. What is left is the part that was actually useful: two threads per node either way, one connection per node under RESP3, two under RESP2 with the second not adding to the count - and why. * Drop the protocol digression from the thread-cost caveat The paragraph explained that RESP2 has a second connection per node and that it does not add threads. Both true, and neither changes the number the reader came for: it is two threads per node on either protocol, because only the interactive connection takes them. Explaining a distinction that does not affect the answer just invites the question of whether it should have. The bullet now says "for each node you connect to", which is accurate on both protocols and needs no accompanying paragraph. * Note that the thread cost excludes pub/sub connections Short of stating it, a reader sizing this against a RESP2 deployment has to guess whether the second connection to each node counts. It does not, and one parenthesis settles it without reopening the protocol discussion that the previous commit removed. * Test that DedicatedThreads actually takes effect, on both protocols The unit tests pinned the decision - which write mode a connection resolves to - and nothing pinned the consequence. That is the half that can rot silently: the policy could stay right while the plumbing under it stopped honouring the mode, and the docs would keep making a promise nothing checked. IsSyncReader/IsSyncWriter on IInternalConnectionMultiplexer answer it per endpoint and connection type, alongside the GetConnectionId that was already there in the same shape. They are named for what the library calls this internally - WriteMode.Sync, BufferedStreamWriter.IsSync, and threads named "SE.Redis Sync Reader"/"Sync Writer" - rather than for the flag that requests it, since the flag is a request and these report what happened. IsSyncReader mirrors the branching in StartReading rather than restating the rule, so a transport-backed connection correctly reports neither. The tests run per protocol, and the protocols differ in a way worth pinning: under RESP2 pub/sub is a second connection that stays on the thread-pool, and under RESP3 there is no second connection at all, so the subscription lookup returns the interactive one and reports dedicated. Asserting "pub/sub is not dedicated" on RESP3 would have been asserting against the shared connection. Each test builds an unshared connection inside the window where the flag is set - it is read at connect time, so a shared connection would answer for whenever it was made - and the class is non-parallel, since the flag is process-wide. Note the two directions validate each other: the same property is asserted false without the flag and true with it, so a stuck value fails one of them, and Assert.True/False(bool?) treats null as failure, so an absent connection cannot pass quietly either. * Be specific that the pub/sub exclusion is a RESP2 concern
Configuration menu - View commit details
-
Copy full SHA for c484d22 - Browse repository at this point
Copy the full SHA c484d22View commit details -
Configuration menu - View commit details
-
Copy full SHA for 186a07f - Browse repository at this point
Copy the full SHA 186a07fView commit details -
Point documentation links at seredis.dev (#3181)
The docs site now serves from the custom domain, so move every reference off the stackexchange.github.io URL. The old links still 301, but the canonical domain is what we should be shipping. This covers the NuGet metadata (PackageProjectUrl and the packaged README), the repo READMEs, the historical release notes, and the URLs baked into shipped code: timeout/thread-theft help links in exception messages, the ConnectionMultiplexer remarks, the [Experimental] SERnnn URL format, and the analyzer rule help links. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw
Configuration menu - View commit details
-
Copy full SHA for f8e5ec8 - Browse repository at this point
Copy the full SHA f8e5ec8View commit details -
Add a Jekyll config for the docs site, and switch to Cayman (#3182)
* Add a Jekyll config for the docs site, and switch to Cayman The site had no _config.yml at all, so it ran on GitHub's defaults: the Primer theme, and a site title/description quietly sourced from the repo's name and description fields - meaning an edit in the repo settings UI changed the site's metadata. This pins both, pins the URL, and picks a theme deliberately. Also opts into jekyll-sitemap (not part of the always-on GitHub Pages plugin set), which gives us the sitemap.xml and robots.txt the site was missing, and excludes docs.csproj, which was being published as site content. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw * Point the docs index at GitHub Releases for release notes ReleaseNotes.md is frozen at 3.0 - it says so itself in its first entry - so the index was sending people to a page whose top line tells them to go somewhere else. Make Releases the primary link and keep the old page in parentheses for the historical record. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw * Point the READMEs at GitHub Releases for release notes Same fix as the docs index, in the two remaining places that sent people to the frozen page - including the README embedded in the NuGet package, which is the one most readers actually see. PackageReleaseNotes already pointed at Releases, so the package metadata and its README now agree. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw
Configuration menu - View commit details
-
Copy full SHA for 395d88d - Browse repository at this point
Copy the full SHA 395d88dView commit details -
Docs site: title duplication, stray underlines, favicon (#3183)
* Stop Cayman printing every page title twice The theme puts the page title in its header banner, and titles_from_headings derives that title from each page's first heading - which is still sitting in the body, so every page reads its own title twice in a row. Worst on the rules pages, where the title is a full sentence. strip_title drops the heading from the body once it has been promoted; the banner is unaffected. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw * Remove setext underlines left behind in Configuration.md The headings here were converted to ATX (#) at some point without removing the old underlines, so ten of them are still in the file. Two render as visible junk - a literal "===" under the page title and a literal "-" under Basic Configuration Strings - and the other eight become horizontal rules directly beneath the heading they used to underline. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw * Give the docs site a favicon Uses the existing project logo. The theme's head includes head-custom.html and ships a version with the favicon line commented out, so overriding that file is the hook. Serves the SVG to browsers that take one and a 16/32/48 .ico otherwise - the latter at the site root, so the automatic /favicon.ico probe finds it too. Jekyll can only see files under docs/, so the SVG is a copy of the one in assets/ rather than a reference to it. Claude-Session: https://claude.ai/code/session_01NxkjXSR7CSGzyw89tJJVtw
Configuration menu - View commit details
-
Copy full SHA for 89cf0c6 - Browse repository at this point
Copy the full SHA 89cf0c6View commit details -
Add SER308: flag the library's own Wait/WaitAll/TryWait helpers (#3180)
* Add SER308: the library's own Wait helpers block a thread too Wait, WaitAll and TryWait are what SER307 discourages everywhere else, reached through the API the library itself offers for it: the calling thread is held for the round-trip while the reply needs a thread of its own. Shipping SER307 while providing a blessed way to do the same thing was only ever half a position. An analyzer rule rather than [Obsolete], and the reason is entirely about how it is turned off. [Obsolete] reports CS0618, which every obsoletion from every source shares, so a consumer wanting to silence *this* has to silence *all* of them - including deprecations in their own code and in unrelated packages. That was the review feedback, and it is right. ObsoleteAttribute.DiagnosticId exists for exactly this and would be the natural answer, but it is net5+ and this library still targets netstandard2.0: it would give a granular ID on some target frameworks and CS0618 on others. A hybrid was considered and rejected - the attribute would need #if on the interface *and* on ConnectionMultiplexer's own members, since marking only the interface does not warn through the class, and on modern targets both mechanisms would report at the same location. [Experimental] is granular but would be saying something untrue; these APIs are long-standing, not preview. Recorded in the descriptor as worth revisiting rather than settled: if netstandard2.0 and net4x are ever dropped, the attribute becomes strictly better and this rule can retire in its favour. Two unrelated interfaces declare these - IRedisAsync for database, server and subscriber calls, and IConnectionMultiplexer for its own - so both are gathered. Matching goes through FindImplementationForInterfaceMember as well as directly, because ConnectionMultiplexer is what Connect returns, making `conn.Wait(task)` the common shape with the class as its containing type. Testing one interface would have left half the surface unguarded, which is how the first attempt at this missed the multiplexer entirely. * Suppress SER308 where this repo blocks deliberately Two kinds, and the distinction is in the comments rather than left to the reader. Most are decorator layers - KeyPrefixed, RedisBase, the Retry* and MultiGroup* wrappers, the test fixture - which must implement the interfaces in full and simply forward; forwarding a flagged member is not using it, and the implementation cannot be dropped while the interface declares it. The exception is CursorEnumerable's synchronous scan path, which is the real thing: it is reached only from a caller who asked for the sync API, and TryWait is what applies the configured timeout to it. Test call sites are suppressed rather than rewritten to await, deliberately: the Wait helpers apply the multiplexer's timeout, which a bare await does not, so rewriting would quietly change what those tests exercise.
Configuration menu - View commit details
-
Copy full SHA for 9201860 - Browse repository at this point
Copy the full SHA 9201860View commit details
Commits on Aug 20, 2026
-
Transport seam: start reading before the handshake, and report a tunn…
…el's refusal (#3188) * Transport mode: start reading before the handshake is written A transport is already connected when ConnectedAsync adopts it, and OnConnectedAsync sends the handshake. Until now the receiver was attached afterwards -- BeginConnectAsync reaches StartReading only once ConnectedAsync has returned -- so the reply could be on the wire before Start, and the transport had no way to know that would happen. It is not a theoretical window. Against a transport-backed multiplexer every SUBSCRIBE failed with "no connection became available": the subscription connection lost that race every time, while the interactive connection happened to win it, so ordinary command traffic looked perfectly healthy. The bytes were the handshake reply. StartTransportReading moves up to immediately after InitTransportOutput and becomes idempotent, since StartReading still calls it on the path every connection takes. This also makes DuplexTransport.Start's contract true as written -- "exactly one receiver, set once, before any data is expected" -- rather than a promise each implementer discovers is conditional and has to work around by staging bytes. Verified cross-repo against a real transport implementation (SocketSet's tunnel) and a real redis-server, as a discriminating pair: with that implementation's own staging workaround REMOVED, pub/sub through the tunnel passes with this change and fails without it. Nothing in this repo's tests touches the transport API, so there is no unit test to add here. Claude-Session: https://claude.ai/code/session_01QHTFkVFxokbBCukuUnkhGe * A tunnel that refuses a transport should say why ConnectTransportAsync is called before the try in BeginConnectAsync is entered, and that method runs as BeginConnectAsync(log).RedisFireAndForget() -- so an exception out of a tunnel went nowhere at all. The caller waited out the full connect budget and got the generic "It was not possible to connect", with the tunnel's own explanation lost. That explanation is the whole value of throwing there: a transport refuses a dial when the configuration asks for something it cannot do, and the message names it. The case that showed this up was a tunnel told Ssl=true with no TLS provider configured; the diagnosis it produced instead read as a hang. Wrapping the acquisition and recording it via RecordConnectionFailed puts the reason in the connect log and the ConnectionFailed event, where the socket path's failures already appear. It does not make the refusal faster -- the bridge still retries to its budget -- it makes it explicable. Verified as a pair against a tunnel that refuses: with this change the tunnel's own sentence is in the connect log; without it the log carries only the timeout. Claude-Session: https://claude.ai/code/session_01QHTFkVFxokbBCukuUnkhGe
Configuration menu - View commit details
-
Copy full SHA for 3ecc614 - Browse repository at this point
Copy the full SHA 3ecc614View commit details -
Treat a write losing its connection as a closure, not an internal fau…
…lt (#3167) (#3189) Shutdown() discards the output writer, and it cannot wait for the write lock: teardown is usually spotted on the read loop, which must not block. So a writer already inside the lock can have the pipe pulled out from under it at any point. That is an ordinary closure, but the write path reported it as InternalFailure wrapping InvalidOperationException("Output pipe not initialized") - which reads as a client bug, is not a recognised connection failure, and also gets announced through ConnectionMultiplexer.InternalError. IdentifyFailureType already maps ObjectDisposedException to SocketClosed; the write path just never consulted it. Changes: - PhysicalConnection keeps an _isShutdown flag, set before the output is discarded, so a writer that finds it gone can tell a closure from a genuinely uninitialised pipe: the former now throws ObjectDisposedException, the latter still InvalidOperationException. This also removes a duplicate copy of that throw in Flush(). - New PhysicalConnection.ClassifyWriteFailure centralises the decision: honour an inner RedisConnectionException's own failure type, defer to IdentifyFailureType, and only fall back to InternalFailure when there is nothing better. PhysicalBridge (both write-failure paths) and Message.WriteTo now use it, and only report OnInternalError when it really was internal. Two further faults found while reproducing this, both in the same window: - ProcessBridgeBacklog re-read the nullable `physical` field to pass to WriteMessageInsideLock (whose parameter is not nullable) after testing it in the loop guard. OnDisconnected nulls that field, so the drain could hand it null and NRE - surfacing as InternalFailure "Failed to write". Now snapshotted per message, matching the idiom used elsewhere in the file. - OperationCanceledException was not classified. RESPite already documents it as expected teardown noise alongside ObjectDisposedException, and the read loop treats a cancelled read as SocketClosed; the write path now matches, gated on our own OutputCancel having fired so a caller's cancellation is not mislabelled. Tests: a deterministic test that shuts the output down before the write and asserts the failure is not badged internal, plus an explicit (opt-in) stress test that races real teardowns against concurrent writers - the latter is what found all three faults, but it has to saturate the box to hit the window, so it is not part of the default run.
Configuration menu - View commit details
-
Copy full SHA for de93da1 - Browse repository at this point
Copy the full SHA de93da1View commit details -
Add NOMKSTREAM support to XADD (#3186)
* Add NOMKSTREAM support to XADD Adds a nomkstream option to StreamAdd/StreamAddAsync so callers can append to a stream without implicitly creating it, matching the XADD NOMKSTREAM flag (Redis 6.2+). Existing overloads keep their signatures for binary compatibility; new all-optional overloads carry the flag. Closes #3138. * XADD: carry NOMKSTREAM (and MINID) on a StreamAddOptions parameter Reworks the public surface: instead of inserting a `bool nomkstream` into the four StreamAdd overload families and their async counterparts, the new options travel on a required `StreamAddOptions` parameter, adding two overloads per interface rather than eight. Because `options` is required it cannot tie with the shipped overloads, so those keep their signatures *and* their default values - PublicAPI.Shipped.txt is untouched. That matters: stripping the defaults broke positional callers, e.g. db.StreamAdd(key, "f", "v", (RedisValue?)"1-1", 5L, true, 10L, StreamTrimMode.DeleteReferences); which no longer bound to either overload once `nomkstream` sat mid-list. [OverloadResolutionPriority] would also have disambiguated, but it is only honoured at LangVersion 13+, so a C# 12 consumer would get CS0121 instead. The flag is spelled `CreateStream` (default true), matching the MKSTREAM parameter we already expose as `StreamCreateConsumerGroup(..., createStream, ...)`, rather than leaking the wire keyword. Since the options type takes future XADD options for free, MINID trimming is included too - previously reachable only through XTRIM - so XADD's grammar is now fully expressible. Notes: - StreamAddOptions is a readonly struct, passed by value publicly and by `in` to the message builders. CreateStream is stored inverted so that `default` means "create the stream", matching the command's own default. - Both builders now share the prefix arithmetic and emission, so NOMKSTREAM, MAXLEN|MINID [~], LIMIT, the trim-mode keyword and the idempotency arguments can only be ordered one way. - The options overloads validate up front (MaxLength xor MinId, MessageId xor IdempotentId, LIMIT requiring ~ and a threshold); the shipped positional overloads deliberately do not, preserving their current behaviour of letting the server rule on odd combinations. --------- Co-authored-by: Marc Gravell <marc.gravell@gmail.com>
Configuration menu - View commit details
-
Copy full SHA for cb7f607 - Browse repository at this point
Copy the full SHA cb7f607View commit details -
Docs site: name the maintainer in the footer, not the repository owner
Cayman's footer renders "<repository> is maintained by <site.github.owner_name>", and that name comes from whoever owns the repository, so seredis.dev read "StackExchange.Redis is maintained by StackExchange". That is a statement about ownership rather than maintenance, and it isn't true of this project. jekyll-github-metadata merges anything set under `github:` in _config.yml over the values it reads from the API (SiteGitHubMunger#github_namespace is `drop.merge(@original_config)`), so overriding owner_name/owner_url is enough - no layout override, and no copy of the theme's markup to keep in step. Nothing else on the site reads either key; Cayman uses them only in that footer span. The alternative was dropping the line entirely via `is_project_page: false`, but Cayman guards its "View on GitHub" button with the same flag, which we do want.
Configuration menu - View commit details
-
Copy full SHA for 90bbf1f - Browse repository at this point
Copy the full SHA 90bbf1fView 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.1.13...3.1.31