Skip to content
Permalink

Comparing changes

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

Open a pull request

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

Commits on Aug 10, 2026

  1. 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.
    mgravell authored Aug 10, 2026
    Configuration menu
    Copy the full SHA
    14f624b View commit details
    Browse the repository at this point in the history
  2. Stabilize Windows CI (#3168)

    * 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.
    mgravell authored Aug 10, 2026
    Configuration menu
    Copy the full SHA
    91ac27c View commit details
    Browse the repository at this point in the history
  3. 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.
    mgravell authored Aug 10, 2026
    Configuration menu
    Copy the full SHA
    74bfe78 View commit details
    Browse the repository at this point in the history

Commits on Aug 14, 2026

  1. 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.
    mgravell authored Aug 14, 2026
    Configuration menu
    Copy the full SHA
    85cd5f6 View commit details
    Browse the repository at this point in the history
  2. 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.
    mgravell authored Aug 14, 2026
    Configuration menu
    Copy the full SHA
    228cd37 View commit details
    Browse the repository at this point in the history
  3. (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).
    mgravell authored Aug 14, 2026
    Configuration menu
    Copy the full SHA
    0d9f24f View commit details
    Browse the repository at this point in the history
  4. 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.
    mgravell authored Aug 14, 2026
    Configuration menu
    Copy the full SHA
    ac4850f View commit details
    Browse the repository at this point in the history

Commits on Aug 18, 2026

  1. 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.
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    c7b3a2b View commit details
    Browse the repository at this point in the history
  2. 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
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    c484d22 View commit details
    Browse the repository at this point in the history
  3. Create CNAME

    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    186a07f View commit details
    Browse the repository at this point in the history
  4. 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
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    f8e5ec8 View commit details
    Browse the repository at this point in the history
  5. 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
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    395d88d View commit details
    Browse the repository at this point in the history
  6. 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
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    89cf0c6 View commit details
    Browse the repository at this point in the history
  7. 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.
    mgravell authored Aug 18, 2026
    Configuration menu
    Copy the full SHA
    9201860 View commit details
    Browse the repository at this point in the history

Commits on Aug 20, 2026

  1. 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
    mgravell authored Aug 20, 2026
    Configuration menu
    Copy the full SHA
    3ecc614 View commit details
    Browse the repository at this point in the history
  2. 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.
    mgravell authored Aug 20, 2026
    Configuration menu
    Copy the full SHA
    de93da1 View commit details
    Browse the repository at this point in the history
  3. 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>
    HarnageaGabriel and mgravell authored Aug 20, 2026
    Configuration menu
    Copy the full SHA
    cb7f607 View commit details
    Browse the repository at this point in the history
  4. 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.
    mgravell committed Aug 20, 2026
    Configuration menu
    Copy the full SHA
    90bbf1f View commit details
    Browse the repository at this point in the history
Loading