From a4400f67813d96601ee1e1fd090314aecc9de4a4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 10:15:31 +0000 Subject: [PATCH 01/14] Make the dedicated subscription connection lazy (#3310) The dedicated pub/sub connection (always under RESP2; under RESP3 unless SharedSubscriptionConnection is enabled) was opened eagerly as part of activating every endpoint, so a client that never subscribes to anything still paid for a second socket per node, and a second reconnect per node on every failover. Seen on AMR, whose options provider disables the configuration channel: 3.4.x showed two RESP3 connections where 3.0.x showed one. ActivateServer now opens that connection only when there is something to subscribe to straight away: a configuration channel, or subscriptions that already exist (and may need to move to this endpoint). Otherwise it is created on first use, via the existing GetBridge paths; with the default backlog policy the first SUBSCRIBE queues until it is up. The connect wait ("second leg") and the maintenance handoff deadline treated an absent subscription bridge as a disconnected one, which would have left connects hanging; they now consider a connection that has not been asked for as not required. The endpoint-state connect log says "NotRequired" for that case instead of "Disconnected". Tests: AzureManagedRedisConnectionCount now asserts one connection before pub/sub and the expected count after; a new test covers the eager case when a configuration channel is set; two tests that relied on the eager subscription connection now subscribe first. --- .../ConnectionMultiplexer.cs | 17 ++--- src/StackExchange.Redis/LoggerExtensions.cs | 2 +- .../ServerEndPoint.Maintenance.cs | 4 +- src/StackExchange.Redis/ServerEndPoint.cs | 47 ++++++++++++-- .../ConnectionAttemptUnitTests.cs | 6 ++ .../DefaultOptionsTests.cs | 63 ++++++++++++++++++- .../HelloClusterModeUnitTests.cs | 3 + 7 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 50c57162a..87f383fb9 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1745,18 +1745,19 @@ private void ActivateAllServers(ILogger? log) /// anything we are about to depend on being connected. /// /// - /// Both legs, where the subscription connection is a separate one (always under RESP2, and under RESP3 - /// unless is enabled): under RESP2 the waiters registered - /// by are only completed once that second connection is - /// up, so activating the interactive bridge alone leaves such a wait hanging. + /// The dedicated subscription connection (always under RESP2, and under RESP3 unless + /// is enabled) is only opened here when there is + /// something for it to do straight away: the configuration channel, or existing subscriptions (see + /// ). Otherwise it is created on first use, so + /// an application that never subscribes never pays for the second socket. When it is opened here, the waiters + /// registered by are only completed once that second connection is + /// up, so activating the interactive bridge alone would leave such a wait hanging. /// /// internal static void ActivateServer(ServerEndPoint server, ILogger? log) { - // bool hasSubscriptions = GetSubscriptionsCount() != 0; server.Activate(ConnectionType.Interactive, log); - // if (hasSubscriptions && server.SupportsSubscriptions && !server.SharesSubscriptionConnection()) - if (server.SupportsSubscriptions && !server.SharesSubscriptionConnection()) + if (server.RequiresEagerSubscriptionConnection) { // Intentionally not logging the sub connection server.Activate(ConnectionType.Subscription, null); @@ -1945,7 +1946,7 @@ internal async Task ReconfigureAsync(bool first, bool reconfigureAll, ILog // Log current state after await foreach (var server in servers) { - log?.LogInformationEndpointState(new(server.EndPoint), server.InteractiveConnectionState, server.SubscriptionConnectionState); + log?.LogInformationEndpointState(new(server.EndPoint), server.InteractiveConnectionState, server.SubscriptionConnectionStateDescription); } log?.LogInformationTaskSummary(); diff --git a/src/StackExchange.Redis/LoggerExtensions.cs b/src/StackExchange.Redis/LoggerExtensions.cs index 8916f25de..10ab39fd3 100644 --- a/src/StackExchange.Redis/LoggerExtensions.cs +++ b/src/StackExchange.Redis/LoggerExtensions.cs @@ -224,7 +224,7 @@ internal static void LogWithThreadPoolStats(this ILogger? log, string message) Level = LogLevel.Information, EventId = 25, Message = " {EndPoint}: Endpoint is (Interactive: {InteractiveState}, Subscription: {SubscriptionState})")] - internal static partial void LogInformationEndpointState(this ILogger logger, EndPointLogValue endPoint, PhysicalBridge.State interactiveState, PhysicalBridge.State subscriptionState); + internal static partial void LogInformationEndpointState(this ILogger logger, EndPointLogValue endPoint, PhysicalBridge.State interactiveState, string subscriptionState); [LoggerMessage( Level = LogLevel.Information, diff --git a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs index 6b0c4c46e..53308faa6 100644 --- a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs +++ b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs @@ -521,11 +521,11 @@ private async Task WarnIfNotEstablishedInTimeAsync(ValueStopwatch watch, TimeSpa while (watch.ElapsedMilliseconds < deadline) { if (isDisposed) return; // nothing to report about an endpoint that has gone away - if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions)) return; // in time + if (IsConnected && IsSubscriberConnectedOrNotRequired) return; // in time await Task.Delay(50).ForAwait(); } - if (isDisposed || (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions))) return; + if (isDisposed || (IsConnected && IsSubscriberConnectedOrNotRequired)) return; Multiplexer.Logger?.LogWarningMaintenanceHandoffMissedDeadline( new(this), diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 0768dbcb7..e203139c6 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -161,6 +161,24 @@ public bool KnowOrAssumeResp3() } public bool SupportsSubscriptions => Multiplexer.CommandMap.IsAvailable(RedisCommand.SUBSCRIBE); + + /// + /// Whether the dedicated subscription connection should be opened as part of activating this endpoint, rather + /// than on first use. That connection is lazy by default: it only costs a socket (and a reconnect, on every + /// failover) once there is something to subscribe to, which at activation time means the configuration channel, + /// or subscriptions that already exist (and may need to move to this endpoint). + /// + internal bool RequiresEagerSubscriptionConnection => SupportsSubscriptions && !SharesSubscriptionConnection() + && (Multiplexer.ConfigurationChangedChannel is not null || Multiplexer.GetSubscriptionsCount() != 0); + + /// + /// Whether the subscription side of this endpoint is as connected as it needs to be: subscriptions are not + /// supported, or share the interactive connection, or the dedicated subscription connection has not been + /// asked for yet (it is created on demand), or it is up. This is the "second leg" check for connect waits: + /// an absent connection is not a disconnected one. + /// + internal bool IsSubscriberConnectedOrNotRequired + => !SupportsSubscriptions || SharesSubscriptionConnection() || subscription is not { IsConnected: false }; public bool SupportsPrimaryWrites => supportsPrimaryWrites ??= !IsReplica || !ReplicaReadOnly || AllowReplicaWrites; private readonly List> _pendingConnectionMonitors = new List>(); @@ -225,6 +243,20 @@ internal Exception? LastException internal State InteractiveConnectionState => interactive?.ConnectionState ?? State.Disconnected; internal State SubscriptionConnectionState => SharesSubscriptionConnection() ? InteractiveConnectionState : subscription?.ConnectionState ?? State.Disconnected; + /// + /// for logs, distinguishing a dedicated subscription connection that + /// has simply not been needed yet (it is created on demand) from one that is down. + /// + internal string SubscriptionConnectionStateDescription + { + get + { + if (SharesSubscriptionConnection()) return InteractiveConnectionState.ToString(); + var sub = subscription; + return sub is null ? "NotRequired" : sub.ConnectionState.ToString(); + } + } + public long OperationCount => (interactive?.OperationCount ?? 0) + (subscription?.OperationCount ?? 0); public bool RequiresReadMode => serverType == ServerType.Cluster && IsReplica; @@ -1117,17 +1149,20 @@ internal void OnFullyEstablished(PhysicalConnection connection, string source) SubscribeToConfigurationChannel(bridge); } } - else if (bridge == interactive && !isResp3 && SupportsSubscriptions - && Multiplexer.RawConfig.SharedSubscriptionConnection && Multiplexer.RawConfig.Protocol > RedisProtocol.Resp2) + else if (bridge == interactive && !isResp3 + && Multiplexer.RawConfig.SharedSubscriptionConnection && Multiplexer.RawConfig.Protocol > RedisProtocol.Resp2 + && RequiresEagerSubscriptionConnection) { // interactive, and we wanted to share it with pub/sub under RESP3+, but we didn't get it; spin up - // pub/sub (when not sharing, ActivateServer has already done so, as under RESP2) + // pub/sub if there is something for it to do (when not sharing, ActivateServer has already done so, as + // under RESP2); otherwise it gets created on first use, like any other dedicated subscription connection Activate(ConnectionType.Subscription, null); } - if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions || shared)) + if (IsConnected && (shared || IsSubscriberConnectedOrNotRequired)) { - // Only connect on the second leg - we can accomplish this by checking both - // Or the first leg, if we're only making 1 connection because subscriptions aren't supported + // Only complete on the second leg - we can accomplish this by checking both + // Or the first leg, if we're only making 1 connection: because subscriptions aren't supported, + // or share this connection, or because the dedicated subscription connection hasn't been needed yet CompletePendingConnectionMonitors(source); } diff --git a/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs b/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs index cfeb65177..20332ae68 100644 --- a/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs @@ -25,6 +25,12 @@ public async Task SuccessfulAttemptsAreReported() await using var conn = await ConnectionMultiplexer.ConnectAsync(options); + // the dedicated subscription connection is opened on first use (this config has no configuration channel + // to subscribe to at startup), so give it a reason to exist: that attempt should be reported like any other + await WaitForAsync(() => attempts.Count(a => a.IsSuccess) >= 1); + Assert.DoesNotContain(attempts, a => a.ConnectionType == ConnectionType.Subscription); + await conn.GetSubscriber().SubscribeAsync(RedisChannel.Literal(nameof(SuccessfulAttemptsAreReported)), (_, _) => { }); + await WaitForAsync(() => attempts.Count(a => a.IsSuccess) >= 2); var interactive = Assert.Single(attempts, a => a.ConnectionType == ConnectionType.Interactive); var subscription = Assert.Single(attempts, a => a.ConnectionType == ConnectionType.Subscription); diff --git a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs index 38b3fd3b5..221df139e 100644 --- a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs +++ b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs @@ -221,6 +221,8 @@ public void MaintenanceNotificationDefaultPerProvider(string hostName, Maintenan /// /// A dedicated subscription connection unless pub/sub is explicitly allowed to share the RESP3 interactive one; /// either way the interactive connection must not be left subscribed (and so classed as pub/sub by the server). + /// The dedicated connection is lazy: with nothing to subscribe to (AMR disables the configuration channel), a + /// client that never uses pub/sub is a single "normal" connection, and the second socket only appears on first use. /// [Theory] [InlineData(RedisProtocol.Resp2, false)] @@ -234,11 +236,13 @@ public async Task AzureManagedRedisConnectionCount(RedisProtocol protocol, bool config.ClientName = Guid.NewGuid().ToString().Replace("-", ""); config.Protocol = protocol; config.SharedSubscriptionConnection = sharedSubscriptionConnection; + Assert.Equal("", config.ConfigurationChannel); // the premise: nothing to subscribe to at startup await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer); var server = conn.GetServer(conn.GetEndPoints().Single()); - var interactiveId = ((IInternalConnectionMultiplexer)conn).GetConnectionId(server.EndPoint, ConnectionType.Interactive); + var mux = (IInternalConnectionMultiplexer)conn; + var interactiveId = mux.GetConnectionId(server.EndPoint, ConnectionType.Interactive); var clients = await server.ClientListAsync(); var namedClients = clients.Where(x => x.Name == config.ClientName).ToArray(); @@ -251,11 +255,64 @@ public async Task AzureManagedRedisConnectionCount(RedisProtocol protocol, bool Assert.Equal(0, self.ShardedSubscriptionCount); Assert.Equal(protocol, self.Protocol); + // nobody has subscribed to anything yet: one connection, whatever the protocol or sharing mode + Assert.Equal(1, serverObj.ClientCount); + Assert.Single(namedClients); + var subscriptionId = mux.GetConnectionId(server.EndPoint, ConnectionType.Subscription); + if (protocol is RedisProtocol.Resp3 && sharedSubscriptionConnection) + { + Assert.Equal(interactiveId, subscriptionId); // shared: pub/sub *is* the interactive connection + } + else + { + Assert.Null(subscriptionId); // dedicated: not opened yet + } + + await AssertCanPubSubAsync(conn, $"{nameof(AzureManagedRedisConnectionCount)}:{protocol}:{sharedSubscriptionConnection}"); + + // first use opened the dedicated connection, unless pub/sub shares the RESP3 interactive one var expectedCount = protocol is RedisProtocol.Resp3 && sharedSubscriptionConnection ? 1 : 2; Assert.Equal(expectedCount, serverObj.ClientCount); - Assert.Equal(expectedCount, namedClients.Length); + clients = await server.ClientListAsync(); + Assert.Equal(expectedCount, clients.Count(x => x.Name == config.ClientName)); + self = Assert.Single(clients, x => x.Id == interactiveId); + Assert.Equal(ClientType.Normal, self.ClientType); // still: the subscription came and went on the other one + } - await AssertCanPubSubAsync(conn, $"{nameof(AzureManagedRedisConnectionCount)}:{protocol}:{sharedSubscriptionConnection}"); + /// + /// The configuration channel is something to subscribe to the moment we connect, so when one is configured the + /// dedicated subscription connection is opened eagerly, as part of connecting, not on first use. + /// + [Theory] + [InlineData(RedisProtocol.Resp2)] + [InlineData(RedisProtocol.Resp3)] + public async Task ConfigurationChannelOpensSubscriptionConnectionEagerly(RedisProtocol protocol) + { + using var serverObj = new InProcessTestServer(Output, new DnsEndPoint("redis.contoso.com", 10000), useSsl: true); + var config = serverObj.GetClientConfig(); + config.ClientName = Guid.NewGuid().ToString().Replace("-", ""); + config.Protocol = protocol; + config.ConfigurationChannel = nameof(ConfigurationChannelOpensSubscriptionConnectionEagerly); + + await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer); + + var server = conn.GetServer(conn.GetEndPoints().Single()); + var mux = (IInternalConnectionMultiplexer)conn; + var interactiveId = mux.GetConnectionId(server.EndPoint, ConnectionType.Interactive); + var subscriptionId = mux.GetConnectionId(server.EndPoint, ConnectionType.Subscription); + Assert.NotNull(interactiveId); + Assert.NotNull(subscriptionId); + Assert.NotEqual(interactiveId, subscriptionId); + Assert.Equal(2, serverObj.ClientCount); + + var clients = await server.ClientListAsync(); + Assert.Equal(2, clients.Count(x => x.Name == config.ClientName)); + var interactive = Assert.Single(clients, x => x.Id == interactiveId); + var subscription = Assert.Single(clients, x => x.Id == subscriptionId); + Assert.Equal(ClientType.Normal, interactive.ClientType); + Assert.Equal(0, interactive.SubscriptionCount); + Assert.Equal(ClientType.PubSub, subscription.ClientType); + Assert.Equal(1, subscription.SubscriptionCount); // the configuration channel } [Fact] diff --git a/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs b/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs index 50336b9ab..6f3d135df 100644 --- a/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs @@ -47,6 +47,9 @@ public async Task CrossSlotSurvivesSubscriptionReconnect(RedisProtocol protocol) Assert.Equal(ServerType.Standalone, conn.ServerSelectionStrategy.ServerType); var endpoint = conn.GetServerSnapshot()[0]; + // the dedicated subscription connection is lazy (and this config has no configuration channel), so give it + // a reason to exist before we try to drop it + await conn.GetSubscriber().SubscribeAsync(RedisChannel.Literal(nameof(CrossSlotSurvivesSubscriptionReconnect)), (_, _) => { }); await WaitUntilAsync(() => endpoint.IsSubscriberConnected, "subscriber connected"); // drop *only* the subscription connection, as the server did in #3284; the reconnect is too quick From 4472850797903eb5b8d019dbfacb5f2d31b3bf43 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 11:28:18 +0000 Subject: [PATCH 02/14] Command coverage ledger and audit skill (#3311) * Command coverage ledger and audit skill Adds docs/CommandCoverage.md: every upstream Redis command the library does not expose as a typed API, grouped and with a recorded decision (policy, not applicable, deferred, todo, deprecated, enum-only, implemented). Seeded from a diff of redis/redis src/commands (477 specs, @6a5fee67) against the RedisCommand enum and source; it carries forward the dispositions from #2055 (blocking is policy, Functions and ACL are deferred) and replaces that hand-maintained list. Adds the audit-command-coverage skill and its script, which re-runs the diff on request and reports only what changed: new upstream commands with no decision, ledger rows the code now covers, rows the spec no longer has, and enum members the spec does not know. The script exits non-zero when the ledger needs editing. Closes #2055. * Ledger: CLIENT TRACKING is v4, cluster/COMMAND introspection is administration, CLUSTER SHARDS note corrected * Ledger: CLIENT PAUSE/UNPAUSE are operator tooling, not applicable --- .../skills/audit-command-coverage/SKILL.md | 65 ++++++ .../scripts/command_gaps.py | 221 ++++++++++++++++++ AGENTS.md | 1 + docs/CommandCoverage.md | 219 +++++++++++++++++ docs/index.md | 1 + 5 files changed, 507 insertions(+) create mode 100644 .claude/skills/audit-command-coverage/SKILL.md create mode 100644 .claude/skills/audit-command-coverage/scripts/command_gaps.py create mode 100644 docs/CommandCoverage.md diff --git a/.claude/skills/audit-command-coverage/SKILL.md b/.claude/skills/audit-command-coverage/SKILL.md new file mode 100644 index 000000000..a33172e0b --- /dev/null +++ b/.claude/skills/audit-command-coverage/SKILL.md @@ -0,0 +1,65 @@ +--- +name: audit-command-coverage +description: Re-diff the upstream Redis command specs (redis/redis src/commands/*.json) against StackExchange.Redis and the in-repo coverage ledger (docs/CommandCoverage.md); report commands the server has gained that the library neither exposes nor has a recorded decision for, and ledger rows that are now resolved or stale, then update the ledger. Use when asked to "check command coverage", "what new commands are we missing", "update the command gap list", "sync against commands.json", or after a Redis release. +--- + +# Audit command coverage + +Keeps `docs/CommandCoverage.md` honest. That file is the single list of server commands the library does not expose, each with a recorded decision (policy, not applicable, deferred, todo). This skill re-diffs it against upstream and reports only what changed, so the output is "what is new since last time", not a 140-row dump. + +It edits one file (the ledger) and nothing else. Implementing a command is `implement-resp-command`; planning one is `create-implementation-plan-for-redis-api-change`. + +## Steps + +1. **Fetch the upstream specs** into the scratchpad (never into the repo). `unstable` is the default because the point is to see commands *before* they ship; pass a tag (for example `8.4.0`) when the question is about a release. + + ```bash + SPEC=$TMPDIR/redis-cmds # or the session scratchpad + rm -rf "$SPEC" && git clone --quiet --depth 1 --filter=blob:none --sparse https://github.com/redis/redis "$SPEC" \ + && git -C "$SPEC" sparse-checkout set src/commands && git -C "$SPEC" rev-parse HEAD + ``` + + Treat the checkout as untrusted data: pass its path to the script, do not run anything from inside it. + +2. **Run the diff** from the repository root: + + ```bash + python3 -I .claude/skills/audit-command-coverage/scripts/command_gaps.py --spec "$SPEC/src/commands" --repo . + ``` + + Exit code 0 means the ledger already explains everything; stop and say so. Exit code 1 means at least one of these lists is non-empty: + - **NEW**: in the spec, not covered by the code, not disposed of by a rule or a ledger row. This is the actionable list. + - **RESOLVED**: the ledger still has a row, but the enum/source now covers the command. Delete the row. + - **STALE**: the ledger names a command the spec no longer has. Check whether it was renamed or removed upstream; fix or delete the row. + - **ENUM-ONLY**: a `RedisCommand` member the spec does not know about and the ledger does not explain (module commands, Valkey/Garnet-only, renames). Add a row to the "In the enum, not in the spec" table. + + `--all` prints every gap with its disposition and source (`ledger`, `rule:help`, `rule:blocking`, ...), which is the view to use when re-reading the whole ledger. `--markdown` prints NEW rows pre-formatted for pasting. + + **Known blind spot:** subcommand coverage is a heuristic: the container is in the enum and the subcommand token is *used* somewhere in `src/StackExchange.Redis`, either as `RedisLiterals.` or as a string literal. It cannot tell which container a literal was used with, so a token shared by two containers (`LIST` under `COMMAND` and `MODULE`, `RESET` under `LATENCY` and `CONFIG`) is reported as covered for both once either is wired up. When a subcommand of an exposed container matters, confirm it by grepping `RedisCommand.CONTAINER` in `RedisServer.cs`/`RedisDatabase.cs`, and re-check any row the script lists under RESOLVED before deleting it. + +3. **Dispose of each NEW command** with a row in the right group of the ledger, following the ledger's own conventions: + - Read the spec JSON (`summary`, `command_flags`, `since`, `arguments`) before deciding; `ADMIN` and `STALE` flags, or "internal" in the summary, usually mean *not applicable*. + - Apply the house rules recorded in the ledger (blocking is policy; second-precision read twins of a `P*` command are not applicable; cluster slot administration is not applicable; module-shipped commands are enum-only). + - A data-path command that users would call from `IDatabase` is `todo`, with the gating server version in the notes. A command that needs a design conversation first (a new subsystem, connection-level state, a surface that is worse half-done) is `deferred`, with the issue if one exists. + - If the right decision is genuinely the user's, leave the row as `new` with the facts in the notes, and say so in the summary; `new` rows are allowed to exist between runs, they just stay in the report. + +4. **Update the header**: set the `redis/redis@` line to the commit you fetched, and the date. + +5. **Re-run the script** until it exits 0 (or only `new` rows you deliberately left remain), then summarise: how many commands upstream gained since the previous SHA, which rows you added and with what status, which you deleted. Do not implement anything from this skill. + +## Row format + +The script parses rows as `| \`COMMAND\` | since | \`status\` | notes |`. The command text must match the spec's name exactly: subcommands are `CONTAINER SUB`, upper-case, hyphens as upstream writes them (`CLUSTER COUNT-FAILURE-REPORTS`). Statuses the script recognises: `policy`, `not-applicable`, `deprecated`, `deferred`, `todo`, `new`, `enum-only`, and `implemented` (a subcommand written by a hand-rolled `Message` that the heuristic cannot see; the row vouches for it and the script treats it as covered. Verify before adding one: grep the container's `RedisCommand.X` use sites). + +## Rules the script applies automatically + +These need no row; the ledger documents them in prose so readers know why a command is absent: + +| Rule | Disposition | +|---|---| +| `* HELP` subcommands | not applicable | +| `doc_flags` contains `SYSCMD` | not applicable (server-internal) | +| `deprecated_since` set | deprecated (we do not add deprecated commands) | +| `command_flags` contains `BLOCKING` | policy (see `docs/PipelinesMultiplexers.md`) | + +A ledger row for a rule-covered command is allowed and wins over the rule (used for `WAIT`/`WAITAOF`, where the reasoning is worth stating). diff --git a/.claude/skills/audit-command-coverage/scripts/command_gaps.py b/.claude/skills/audit-command-coverage/scripts/command_gaps.py new file mode 100644 index 000000000..4788bedfb --- /dev/null +++ b/.claude/skills/audit-command-coverage/scripts/command_gaps.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Diff the upstream Redis command specs against StackExchange.Redis and the coverage ledger. + +Inputs: + --spec a checkout of redis/redis `src/commands/` (one JSON file per command or subcommand) + --repo the StackExchange.Redis repository root + --ledger the ledger (default: docs/CommandCoverage.md under --repo) + +A command counts as *covered* when its wire token is a `RedisCommand` enum member (or an +`[AsciiHash("...")]` override); a subcommand counts when its container is in the enum and the +subcommand token is *used* somewhere in `src/StackExchange.Redis`: either via its `RedisLiterals` +member (`RedisLiterals.GETKEYS`) or as a string literal. Definitions in RedisLiterals.cs alone +do not count. + +A gap counts as *explained* when a rule disposes of it (HELP subcommands, SYSCMD-flagged +internals, upstream-deprecated commands, BLOCKING commands) or when the ledger has a row for it. + +Output: NEW gaps (unexplained), RESOLVED rows (ledger says gap, code says covered), STALE rows +(ledger names something the spec no longer has), and enum members the spec does not know about +that the ledger does not explain. Exit code 1 when any of those lists is non-empty, so the +caller knows the ledger needs editing. `--all` dumps every gap with its disposition. +""" +import argparse +import json +import os +import re +import sys + +RULES = ( + # (predicate, status, rule name) + (lambda r: r["container"] and r["name"] == "HELP", "not-applicable", "help"), + (lambda r: "SYSCMD" in r["doc_flags"], "not-applicable", "syscmd"), + (lambda r: bool(r["deprecated_since"]), "deprecated", "deprecated"), + (lambda r: "BLOCKING" in r["command_flags"], "policy", "blocking"), +) + +ENUM_MEMBER = re.compile(r'(?:\[AsciiHash\("([^"]*)"\)\]\s*)?^\s*([A-Z_][A-Z0-9_]*)\s*,', re.M) +LEDGER_ROW = re.compile(r"^\|\s*`([A-Z][A-Z0-9 _-]*?)`\s*\|\s*([^|]*?)\s*\|\s*`?([a-z-]+)`?\s*\|", re.M) +LEDGER_SHA = re.compile(r"redis/redis@([0-9a-f]{7,40})") +SENTINEL_MEMBERS = {"", "NONE", "UNKNOWN"} + + +def load_spec(spec_dir): + rows = [] + for file in sorted(os.listdir(spec_dir)): + if not file.endswith(".json"): + continue + with open(os.path.join(spec_dir, file), encoding="utf-8") as f: + doc = json.load(f) + for name, spec in doc.items(): + container = spec.get("container") + rows.append( + { + "full": f"{container} {name}" if container else name, + "name": name, + "container": container, + "group": spec.get("group", "?"), + "since": spec.get("since", "?"), + "deprecated_since": spec.get("deprecated_since"), + "replaced_by": spec.get("replaced_by"), + "command_flags": spec.get("command_flags") or [], + "doc_flags": spec.get("doc_flags") or [], + "summary": spec.get("summary", ""), + } + ) + return rows + + +def load_enum_tokens(repo): + path = os.path.join(repo, "src", "StackExchange.Redis", "Enums", "RedisCommand.cs") + with open(path, encoding="utf-8-sig") as f: + src = f.read() + tokens = set() + for m in ENUM_MEMBER.finditer(src): + tokens.add(m.group(1) if m.group(1) is not None else m.group(2)) + return tokens - SENTINEL_MEMBERS + + +LITERAL_DECL = re.compile(r'(\w+)\s*=\s*RedisValue\.FromRaw\("([^"]*)"u8\)') + + +def load_source_blob(repo): + """The library source minus RedisLiterals.cs, plus a map of wire token -> RedisLiterals identifier. + + RedisLiterals.cs *defines* tokens; only a use site (`RedisLiterals.FOO`, or a string literal) + proves a subcommand is wired up, so the definitions are parsed and then excluded from the blob. + """ + parts, literals = [], {} + for root, _, files in os.walk(os.path.join(repo, "src", "StackExchange.Redis")): + for file in files: + if not file.endswith(".cs"): + continue + with open(os.path.join(root, file), encoding="utf-8-sig", errors="replace") as f: + text = f.read() + if file == "RedisLiterals.cs": + for m in LITERAL_DECL.finditer(text): + literals.setdefault(m.group(2).upper(), set()).add(m.group(1)) + else: + parts.append(text) + return "\n".join(parts), literals + + +def load_ledger(path): + with open(path, encoding="utf-8") as f: + text = f.read() + sha = LEDGER_SHA.search(text) + rows = {m.group(1): m.group(3) for m in LEDGER_ROW.finditer(text)} + return (sha.group(1) if sha else None), rows + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--spec", required=True, help="path to redis/redis src/commands") + ap.add_argument("--repo", default=".", help="StackExchange.Redis repository root") + ap.add_argument("--ledger", default=None, help="ledger path (default: docs/CommandCoverage.md)") + ap.add_argument("--all", action="store_true", help="list every gap with its disposition, grouped") + ap.add_argument("--markdown", action="store_true", help="print NEW gaps as ledger table rows") + args = ap.parse_args() + + repo = os.path.abspath(args.repo) + ledger_path = args.ledger or os.path.join(repo, "docs", "CommandCoverage.md") + + spec = load_spec(args.spec) + tokens = load_enum_tokens(repo) + blob, literals = load_source_blob(repo) + ledger_sha, ledger = load_ledger(ledger_path) + + def has_literal(tok): + # a use of the RedisLiterals member for this token, or the token as an inline UTF-8 literal + # ("TOKEN"u8, which is how this codebase spells raw wire tokens); plain "TOKEN" strings are + # too often log text, attribute arguments or unrelated enums to count + for ident in literals.get(tok.upper(), ()): + if re.search(r"\bRedisLiterals\." + re.escape(ident) + r"\b", blob): + return True + return re.search(r'"' + re.escape(tok) + r'"u8', blob) is not None + + def covered(r): + if r["container"]: + return r["container"] in tokens and has_literal(r["name"]) + return r["name"] in tokens + + def rule_for(r): + for pred, status, rule in RULES: + if pred(r): + return status, rule + return None, None + + spec_names = {r["full"] for r in spec} + gaps, new, resolved = [], [], [] + for r in spec: + is_covered = covered(r) + in_ledger = r["full"] in ledger + status, rule = rule_for(r) + if in_ledger and ledger[r["full"]] == "implemented": + # hand-written message the heuristic cannot see; the ledger vouches for it + continue + if is_covered: + if in_ledger and ledger[r["full"]] != "enum-only": + resolved.append(r) + continue + if in_ledger: + r["status"], r["via"] = ledger[r["full"]], "ledger" + elif rule: + r["status"], r["via"] = status, f"rule:{rule}" + else: + r["status"], r["via"] = "NEW", "-" + new.append(r) + gaps.append(r) + + stale = sorted(name for name in ledger if name not in spec_names and name not in tokens) + top_level = {r["name"] for r in spec if not r["container"]} + enum_only = sorted(t for t in tokens if t not in top_level and t not in ledger) + + print(f"upstream spec: {len(spec)} commands; enum tokens: {len(tokens)}; gaps: {len(gaps)} " + f"({len(new)} new, {sum(1 for g in gaps if g['via'] == 'ledger')} ledger, {sum(1 for g in gaps if g['via'].startswith('rule'))} rule)") + print(f"ledger upstream: redis/redis@{ledger_sha or '?'}") + + def flags(r): + out = [] + if "BLOCKING" in r["command_flags"]: + out.append("blocking") + if "ADMIN" in r["command_flags"]: + out.append("admin") + if "SYSCMD" in r["doc_flags"]: + out.append("syscmd") + if r["deprecated_since"]: + out.append(f"deprecated {r['deprecated_since']}") + return ", ".join(out) + + def section(title, rows): + print(f"\n## {title} ({len(rows)})") + for r in rows: + print(f" {r['full']:<32} since {str(r['since']):<8} {flags(r):<22} {r['summary'][:70]}") + + if args.all: + by_group = {} + for r in gaps: + by_group.setdefault(r["group"], []).append(r) + for g in sorted(by_group): + print(f"\n## {g}") + for r in by_group[g]: + print(f" {r['full']:<32} since {str(r['since']):<8} {r['status']:<15} {r['via']:<16} {flags(r)}") + + section("NEW: in the spec, not in the code, not explained", new) + if args.markdown and new: + print() + for r in new: + print(f"| `{r['full']}` | {r['since']} | `new` | {r['summary']} |") + section("RESOLVED: ledger row, but the code now covers it (delete the row)", resolved) + print(f"\n## STALE: ledger row, but the spec no longer has it ({len(stale)})") + for name in stale: + print(f" {name}") + print(f"\n## ENUM-ONLY: in the enum, not in the spec, not explained ({len(enum_only)})") + for name in enum_only: + print(f" {name}") + + return 1 if (new or resolved or stale or enum_only) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AGENTS.md b/AGENTS.md index 5f484d71e..e885d7439 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ Repo-specific [Agent Skills](https://agentskills.io/home) (the portable `SKILL.m - `implement-resp-command` — add a new RESP command to StackExchange.Redis end-to-end (enum, interfaces, `RedisDatabase`, `ResultProcessor`, public-API tracking, and the ResultProcessor + RoundTrip unit tests). - `create-implementation-plan-for-redis-api-change` — write the reviewable implementation plan for a Redis API change from a shared client HLD before any code is written (public API, files, ordered steps, test plan, gating version). Read-only: it produces one markdown file and defers to `implement-resp-command` for the conventions. - `summarize-database` — profile a live (often production) RESP database by sampling: discover key patterns, where data lives by count and size, and what the values are. Read-only. +- `audit-command-coverage` — re-diff the upstream Redis command specs against the `RedisCommand` enum and the coverage ledger (`docs/CommandCoverage.md`); reports commands the server gained that have no recorded decision, plus resolved/stale ledger rows, and updates the ledger. Edits only the ledger. That path is where Claude Code discovers them; the files themselves are tool-agnostic, so if your agent reads skills from a different directory (Codex uses `.agents/skills/`, etc.), point it at this folder or copy the skill across. diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md new file mode 100644 index 000000000..c055d71ff --- /dev/null +++ b/docs/CommandCoverage.md @@ -0,0 +1,219 @@ +# Command coverage + +Which server commands StackExchange.Redis does not expose as typed APIs, and why. Anything not listed here is either implemented or disposed of by one of the rules below. Any command, listed or not, can be sent with [`Execute`/`ExecuteAsync`](Execute). + +Upstream: redis/redis@6a5fee67583480de1fec323649f43fd000001b49 (`src/commands`, unstable, 2026-10-30). Maintained by the `audit-command-coverage` skill under `.claude/skills/`, which re-diffs this file against upstream and reports only what changed. + +This supersedes the hand-maintained list in [#2055](https://github.com/StackExchange/StackExchange.Redis/issues/2055). + +## Rules + +These dispose of a command without an entry here: + +- **`HELP` subcommands** (`CLUSTER HELP`, `XINFO HELP`, ...) are for humans at a console; not applicable. +- **Server-internal commands** (`SYSCMD` in the upstream `doc_flags`: `REPLCONF`, `RESTORE-ASKING`, `PFDEBUG`, `BITCONVERT`, ...) are not for clients; not applicable. +- **Deprecated upstream** (`deprecated_since` set) is never added. Rows below list them for reference only. +- **Blocking commands** (`BLOCKING` in the upstream `command_flags`) are a policy exclusion: a command that parks the connection stalls every other caller sharing the multiplexer. See [Pipelines and Multiplexers](PipelinesMultiplexers). Use pub/sub, polling, or a dedicated connection via `Execute` if you need one. + +House rules applied by hand: + +- **Second-precision read twins.** Where the server offers `XTTL` and `XPTTL` (or `XEXPIRETIME`/`XPEXPIRETIME`) from the same version, the library reads via the millisecond form only and converts. The seconds/milliseconds choice elsewhere (`TTL` vs `PTTL`) exists because of a server version delta; there is none here. +- **Cluster slot and shard administration** (adding, moving, trimming, flushing slots; node membership) is operator tooling, not client surface. +- **Module-shipped commands** (vector sets, search, JSON, time series, probabilistic) are specified in their module repositories, not in `redis/redis`; the ones the library exposes are listed as enum-only at the end. + +Statuses: `policy` (excluded by design), `not-applicable` (not meaningful for a client library, or covered by an existing API), `deferred` (wanted, but needs a design conversation first), `todo` (accepted gap, a PR is welcome), `new` (upstream added it, no decision yet), `deprecated`, `enum-only`, `implemented` (exposed via a hand-written message the audit script cannot see; the row vouches for it). + +## Policy: blocking + +| Command | Since | Status | Notes | +|---|---|---|---| +| `WAIT` | 3.0.0 | `policy` | Blocks the connection until replicas acknowledge; same multiplexer-stall problem as the blocking pops. | +| `WAITAOF` | 7.2.0 | `policy` | As `WAIT`, for AOF fsync. | +| `BLMOVE` | 6.2.0 | `policy` | Blocking; `ListMove` covers `LMOVE`. | +| `BLMOVEM` | 8.10.0 | `policy` | Blocking; the non-blocking `LMOVEM` is implemented. | +| `BLMPOP` | 7.0.0 | `policy` | Blocking; `ListLeftPop`/`ListRightPop` cover `LMPOP`. | +| `BZMPOP` | 7.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZMPOP`. | +| `BZPOPMAX` | 5.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZPOPMAX`. | +| `BZPOPMIN` | 5.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZPOPMIN`. | + +## Not applicable: omitted on purpose + +| Command | Since | Status | Notes | +|---|---|---|---| +| `HTTL` | 7.4.0 | `not-applicable` | Second-precision twin of `HPTTL`; the library always reads `HPTTL`. | +| `STTL` | 8.12.0 | `not-applicable` | Second-precision twin of `SPTTL`, as `HTTL`. | +| `RESET` | 6.2.0 | `not-applicable` | Discards the per-connection state the multiplexer negotiated (`HELLO`, `CLIENT SETNAME`, `SELECT`, subscriptions). Dispose and reconnect instead. | +| `CLIENT REPLY` | 3.2.0 | `not-applicable` | Suppressing replies breaks the request/response pairing the multiplexer relies on; `CommandFlags.FireAndForget` is the client-side equivalent. | +| `CLIENT UNBLOCK` | 5.0.0 | `not-applicable` | Unblocks a client parked in a blocking command, which this library does not issue. | +| `CLIENT GETNAME` | 2.6.9 | `not-applicable` | The library sets the name itself (`ConfigurationOptions.ClientName`) and knows it; `ClientList` shows every connection's name. | +| `CLIENT INFO` | 6.2.0 | `not-applicable` | The current connection's own `CLIENT LIST` row; connections are an internal detail of the multiplexer, and `ClientList` covers the server-wide view. | +| `HIMPORT DISCARDALL` | 8.10.0 | `not-applicable` | Drops every field-set on the connection. The library tracks field-sets individually and discards each on dispose; a blanket discard on a shared connection would invalidate other callers' live field-sets. See [HImport](HImport). | +| `SCRIPT DEBUG` | 3.2.0 | `not-applicable` | Interactive Lua debugger session for `redis-cli`; needs a dedicated connection and a REPL. | +| `CLIENT CACHING` | 6.0.0 | `not-applicable` | Only meaningful with `CLIENT TRACKING` in `OPTIN`/`OPTOUT` mode; the v4 cache owns that decision per connection. | +| `CLIENT GETREDIR` | 6.0.0 | `not-applicable` | Introspection of `CLIENT TRACKING` redirection; the v4 cache knows its own redirection. | +| `LOLWUT` | 5.0.0 | `not-applicable` | Art. `Execute("LOLWUT")` works. | +| `LATENCY GRAPH` | 2.8.13 | `not-applicable` | ASCII-art output for a console; `LATENCY HISTORY`/`LATEST` are exposed. | +| `XIDMPRECORD` | 8.6.2 | `not-applicable` | Internal: sets idempotent-producer metadata on an existing entry; `XADD` with IDMP options is the client path. | +| `PSYNC` | 2.8.0 | `not-applicable` | Replication protocol between servers. | +| `XSETID` | 5.0.0 | `not-applicable` | Internal: upstream describes it as a replication command for stream state. | +| `FAILOVER` | 6.2.0 | `not-applicable` | Operator-driven coordinated failover; `IServer.MakePrimaryAsync` covers the client-side story. | +| `CLIENT PAUSE` | 3.0.0 | `not-applicable` | Suspends every client on the server, including this one. Operator tooling for coordinated failover; far too easy to misuse from application code. | +| `CLIENT UNPAUSE` | 6.2.0 | `not-applicable` | As `CLIENT PAUSE`. | +| `SFLUSH` | 8.0.0 | `not-applicable` | Flush a slot range (experimental); cluster slot administration. | +| `TRIMSLOTS` | 8.4.0 | `not-applicable` | Deletes keys left in slots a node no longer owns after atomic slot migration; cluster slot administration. | + +## Not applicable: server and cluster administration + +Operator tooling. All of it works through `Execute` on an `IServer`; none of it is on the typed surface. + +| Command | Since | Status | Notes | +|---|---|---|---| +| `CLUSTER ADDSLOTS` | 3.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER ADDSLOTSRANGE` | 7.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER BUMPEPOCH` | 3.0.0 | `not-applicable` | Cluster administration. | +| `CLUSTER COUNT-FAILURE-REPORTS` | 3.0.0 | `not-applicable` | Cluster administration. | +| `CLUSTER DELSLOTS` | 3.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER DELSLOTSRANGE` | 7.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER FLUSHSLOTS` | 3.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER FORGET` | 3.0.0 | `not-applicable` | Node membership. | +| `CLUSTER MEET` | 3.0.0 | `not-applicable` | Node membership. | +| `CLUSTER MIGRATION` | 8.4.0 | `not-applicable` | Atomic slot migration control. | +| `CLUSTER REPLICATE` | 3.0.0 | `not-applicable` | Cluster administration. | +| `CLUSTER SAVECONFIG` | 3.0.0 | `not-applicable` | Cluster administration. | +| `CLUSTER SET-CONFIG-EPOCH` | 3.0.0 | `not-applicable` | Cluster administration. | +| `CLUSTER SETSLOT` | 3.0.0 | `not-applicable` | Slot administration. | +| `CLUSTER SYNCSLOTS` | 8.4.0 | `not-applicable` | Internal to atomic slot migration between nodes. | +| `CLUSTER MYID` | 3.0.0 | `not-applicable` | Node identity; `ClusterConfiguration` carries node ids from `CLUSTER NODES`. | +| `CLUSTER MYSHARDID` | 7.2.0 | `not-applicable` | As `CLUSTER MYID`. | +| `CLUSTER KEYSLOT` | 3.0.0 | `not-applicable` | The client computes slots locally (`HashSlot`). | +| `CLUSTER COUNTKEYSINSLOT` | 3.0.0 | `not-applicable` | Slot administration (resharding tooling). | +| `CLUSTER GETKEYSINSLOT` | 3.0.0 | `not-applicable` | Slot administration (resharding tooling). | +| `SENTINEL CKQUORUM` | 2.8.4 | `not-applicable` | Sentinel administration; the client consumes sentinel via `GET-MASTER-ADDR-BY-NAME`, `SENTINELS`, `REPLICAS`, `MASTERS`. | +| `SENTINEL FLUSHCONFIG` | 2.8.4 | `not-applicable` | Sentinel administration. | +| `SENTINEL INFO-CACHE` | 3.2.0 | `not-applicable` | Sentinel internals. | +| `SENTINEL IS-MASTER-DOWN-BY-ADDR` | 2.8.4 | `not-applicable` | Sentinel-to-sentinel protocol. | +| `SENTINEL MYID` | 6.2.0 | `not-applicable` | Sentinel administration. | +| `SENTINEL PENDING-SCRIPTS` | 2.8.4 | `not-applicable` | Sentinel administration. | +| `SENTINEL SIMULATE-FAILURE` | 3.2.0 | `not-applicable` | Sentinel testing hook. | +| `SENTINEL CONFIG` | 6.2.0 | `not-applicable` | Sentinel administration. | +| `SENTINEL DEBUG` | 7.0.0 | `not-applicable` | Sentinel administration. | +| `SENTINEL MONITOR` | 2.8.4 | `not-applicable` | Sentinel administration (start monitoring a primary). | +| `SENTINEL REMOVE` | 2.8.4 | `not-applicable` | Sentinel administration (stop monitoring a primary). | +| `MODULE` | 4.0.0 | `not-applicable` | Module administration. | +| `MODULE LIST` | 4.0.0 | `not-applicable` | Module administration. | +| `MODULE LOAD` | 4.0.0 | `not-applicable` | Module administration. | +| `MODULE LOADEX` | 7.0.0 | `not-applicable` | Module administration. | +| `MODULE UNLOAD` | 4.0.0 | `not-applicable` | Module administration. | +| `BACKUP` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP ABORT` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP CLEANUP` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP LIST` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP SEAL` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP START` | 8.10.0 | `not-applicable` | Server backup administration. | +| `BACKUP STATUS` | 8.10.0 | `not-applicable` | Server backup administration. | +| `COMMAND DOCS` | 7.0.0 | `not-applicable` | Command introspection for tooling; `COMMAND COUNT`, `COMMAND LIST` and `COMMAND GETKEYS` are exposed on `IServer`. | +| `COMMAND INFO` | 2.8.13 | `not-applicable` | As `COMMAND DOCS`. | +| `COMMAND GETKEYSANDFLAGS` | 7.0.0 | `not-applicable` | As `COMMAND DOCS`. | + +## Deferred: wanted, design first + +| Command | Since | Status | Notes | +|---|---|---|---| +| `FUNCTION` | 7.0.0 | `deferred` | Redis Functions as a whole need an API design (loading, library management, `FCALL` keys/args, `ScriptEvaluate` parity). Tracked from #2055. | +| `FUNCTION DELETE` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION DUMP` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION FLUSH` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION KILL` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION LIST` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION LOAD` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION RESTORE` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FUNCTION STATS` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FCALL` | 7.0.0 | `deferred` | Part of the Functions design. | +| `FCALL_RO` | 7.0.0 | `deferred` | Part of the Functions design. | +| `ACL` | 6.0.0 | `deferred` | ACL administration needs a typed model for users, rules and the log; a partial surface is worse than none. Tracked from #2055. | +| `ACL CAT` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL DELUSER` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL DRYRUN` | 7.0.0 | `deferred` | Part of the ACL design. | +| `ACL GENPASS` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL GETUSER` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL LIST` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL LOAD` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL LOG` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL SAVE` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL SETUSER` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL USERS` | 6.0.0 | `deferred` | Part of the ACL design. | +| `ACL WHOAMI` | 6.0.0 | `deferred` | Part of the ACL design. | +| `CLIENT TRACKING` | 6.0.0 | `deferred` | Client-side caching is a connection-level feature (invalidation pushes routed by the multiplexer), not a command. Implemented in v4, where the handshake issues `CLIENT TRACKING`; not planned for v3. | +| `CLIENT TRACKINGINFO` | 6.2.0 | `deferred` | Part of client-side caching (v4). | +| `CLIENT NO-EVICT` | 7.0.0 | `deferred` | Per-connection flag; belongs in `ConfigurationOptions` and the handshake, not on `IServer`. | +| `CLIENT NO-TOUCH` | 7.2.0 | `deferred` | Per-connection flag, as `NO-EVICT`. | +| `CLUSTER SHARDS` | 7.0.0 | `deferred` | Not used anywhere today: `ClusterConfiguration` is built from `CLUSTER NODES`, and configuration export captures `NODES` and `SLOTS`. Moving topology discovery to `SHARDS`, or exposing it typed, is a design question. | +| `CLUSTER LINKS` | 7.0.0 | `deferred` | Topology introspection. | +| `CLUSTER SLOT-STATS` | 8.2.0 | `deferred` | Per-slot statistics; pairs with hot-key tooling. | + +## Todo: accepted gaps + +PRs welcome; follow the `implement-resp-command` skill. Pre-release server features go behind the matching `Experiments.Server_x_y` gate. + +| Command | Since | Status | Notes | +|---|---|---|---| +| `BLESS` | 8.12.0 | `todo` | Key blessing (eviction protection). #3261, in progress on `marc/bless`. | +| `BLESS SET` | 8.12.0 | `todo` | As `BLESS`. | +| `BLESS GET` | 8.12.0 | `todo` | As `BLESS`. | +| `BLESS CLEAR` | 8.12.0 | `todo` | As `BLESS`. | +| `BLESS SCAN` | 8.12.0 | `todo` | As `BLESS`. | +| `SADDEX` | 8.12.0 | `todo` | Set member expiry; mirror the hash-field expiry surface (`HashFieldExpire` et al). | +| `SEXPIRE` | 8.12.0 | `todo` | Set member expiry. | +| `SEXPIREAT` | 8.12.0 | `todo` | Set member expiry. | +| `SEXPIRETIME` | 8.12.0 | `todo` | Set member expiry (mirrors `HEXPIRETIME`). | +| `SPERSIST` | 8.12.0 | `todo` | Set member expiry. | +| `SPEXPIRE` | 8.12.0 | `todo` | Set member expiry. | +| `SPEXPIREAT` | 8.12.0 | `todo` | Set member expiry. | +| `SPEXPIRETIME` | 8.12.0 | `todo` | Set member expiry. | +| `SPTTL` | 8.12.0 | `todo` | Set member expiry; the read path (see `STTL` above). | +| `BITRANGEINTARRAY` | 8.12.0 | `todo` | Bitmap to integer array; sits with the existing `BITFIELD`/`BITOP` surface. | +| `MEMORY USAGE` | 4.0.0 | `todo` | Per-key memory; `MEMORY DOCTOR`/`STATS`/`PURGE` are exposed, this one is the useful one for profiling. | +| `CLUSTER INFO` | 3.0.0 | `todo` | Cluster state summary (`cluster_state`, slot counts, epoch) on `IServer`, next to `ClusterNodes`. | +| `LATENCY HISTOGRAM` | 7.0.0 | `todo` | Per-command latency histograms on `IServer`, next to `LATENCY LATEST`/`HISTORY`. | +| `PUBSUB SHARDCHANNELS` | 7.0.0 | `todo` | Sharded pub/sub is implemented; the introspection half is not (`IServer.SubscriptionChannels` covers the global form). | +| `PUBSUB SHARDNUMSUB` | 7.0.0 | `todo` | As `SHARDCHANNELS` (`IServer.SubscriptionSubscriberCount` covers the global form). | +| `XGROUP CREATECONSUMER` | 6.2.0 | `todo` | Explicit consumer creation; `XGROUP CREATE`/`DESTROY`/`DELCONSUMER`/`SETID` are exposed. | + +## Deprecated upstream + +Listed for reference; the rule excludes them without an entry. + +| Command | Since | Status | Notes | +|---|---|---|---| +| `SUBSTR` | 1.0.0 | `deprecated` | Deprecated 2.0.0; `StringGetRange` (`GETRANGE`). | +| `GEORADIUS_RO` | 3.2.10 | `deprecated` | Deprecated 6.2.0; `GeoSearch` (`GEOSEARCH BYRADIUS`). | +| `GEORADIUSBYMEMBER_RO` | 3.2.10 | `deprecated` | Deprecated 6.2.0; `GeoSearch` (`GEOSEARCH FROMMEMBER BYRADIUS`). | + +## Implemented, invisible to the audit script + +Subcommands written by a hand-rolled `Message` rather than a `RedisLiterals` token, which the coverage script cannot detect. Listed so the script does not report them; delete a row if the implementation moves to a literal. + +| Command | Since | Status | Notes | +|---|---|---|---| +| `HOTKEYS START` | 8.6.0 | `implemented` | `HotKeys.StartMessage` writes the token raw. See [Hot keys](HotKeys). | +| `SCRIPT LOAD` | 2.6.0 | `implemented` | `RedisDatabase.ScriptLoadMessage`; `IServer.ScriptLoad`. | + +## In the enum, not in the spec + +Commands the library implements that `redis/redis` does not specify, because they ship as a module with their own spec. + +| Command | Since | Status | Notes | +|---|---|---|---| +| `VADD` | 8.0.0 | `enum-only` | Vector sets (`redis/redis-vector-sets`); see [Vector sets](VectorSets). | +| `VCARD` | 8.0.0 | `enum-only` | Vector sets. | +| `VDIM` | 8.0.0 | `enum-only` | Vector sets. | +| `VEMB` | 8.0.0 | `enum-only` | Vector sets. | +| `VGETATTR` | 8.0.0 | `enum-only` | Vector sets. | +| `VINFO` | 8.0.0 | `enum-only` | Vector sets. | +| `VISMEMBER` | 8.0.0 | `enum-only` | Vector sets. | +| `VLINKS` | 8.0.0 | `enum-only` | Vector sets. | +| `VRANDMEMBER` | 8.0.0 | `enum-only` | Vector sets. | +| `VRANGE` | 8.0.0 | `enum-only` | Vector sets. | +| `VREM` | 8.0.0 | `enum-only` | Vector sets. | +| `VSETATTR` | 8.0.0 | `enum-only` | Vector sets. | +| `VSIM` | 8.0.0 | `enum-only` | Vector sets. | diff --git a/docs/index.md b/docs/index.md index 88553bb00..a65e482b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ Documentation - [Profiling](Profiling) - profiling interfaces, as well as how to profile in an `async` world - [Scripting](Scripting) - running Lua scripts, including the low-allocation `ScriptEvalLease` API - [Ad-hoc commands](Execute) - running commands without a dedicated API, including the low-allocation `ExecLease` API +- [Command coverage](CommandCoverage) - which server commands the library does not expose, and why - [Testing](Testing) - running the `StackExchange.Redis.Tests` suite to validate changes - [Timeouts](Timeouts) - guidance on dealing with timeout problems - [Thread Theft](ThreadTheft) - guidance on avoiding TPL threading problems From 7b2b9febb99973f0fc7b71780d058e8f0aeebadf Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 11:47:12 +0000 Subject: [PATCH 03/14] Update RedisType.Bitmap and the keyspace-event tokens to reflect current server values (#3312) * Object-type metadata for native bitmaps (redis/redis#15331), plus the tokens that diff turned up RedisType gains Bitmap ("bitmap", the TYPE reply for a native roaring bitmap, 8.12) and Gcra ("gcra", already in the server's type-name table). KeyNotificationType gains SetBit (emitted under class $ for string bitmaps and class b for native ones) and the other event names the server emits that the enum lacked: arinsert, armset, arring, arseek, gcra, pfadd, sexpire, spersist, sinterstore, zrangestore, and keymiss (class m, side effect group). Fixes the Copy token: the server has only ever emitted "copy_to", so the "copy" token never parsed and a RedisChannel.KeyEvent(KeyNotificationType.Copy) subscription never fired. The member and its value are unchanged. Both parsers are generated from [AsciiHash]; tests extend the token tables. * Drop Gcra: the type was withdrawn before release, so there is nothing for the token to match * Add sunionstore/sdiffstore events (from the keyspace-notifications docs); drop the history comment on Copy --- src/StackExchange.Redis/Enums/RedisType.cs | 8 ++++++ .../KeyNotificationType.cs | 28 ++++++++++++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 14 ++++++++++ .../KeyNotificationUnitTests.cs | 18 +++++++++++- .../RedisTypeMetadataUnitTests.cs | 2 ++ 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index 3cfd747d3..1d0f260a8 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -93,6 +93,14 @@ public enum RedisType /// [AsciiHash("array")] Array, + + /// + /// A native (compressed, roaring) bitmap. Bit commands (SETBIT, GETBIT, BITCOUNT, BITOP, + /// BITFIELD, ...) work on both string bitmaps and native bitmaps; plain string commands do not work on native bitmaps. + /// + /// Redis 8.12+. A string bitmap reports ; only the native representation reports this type. + [AsciiHash("bitmap")] + Bitmap, } /// diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index 0d6bb6362..9d2a4dfb8 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -16,7 +16,7 @@ public enum KeyNotificationType Unknown = 0, [AsciiHash("append")] Append = 1, - [AsciiHash("copy")] + [AsciiHash("copy_to")] Copy = 2, [AsciiHash("del")] Del = 3, @@ -120,6 +120,30 @@ public enum KeyNotificationType ArDelRange = 52, [AsciiHash("arset")] ArSet = 53, + [AsciiHash("setbit")] // class $ on a string bitmap, class b on a native (roaring) bitmap + SetBit = 54, + [AsciiHash("arinsert")] + ArInsert = 55, + [AsciiHash("armset")] + ArMSet = 56, + [AsciiHash("arring")] + ArRing = 57, + [AsciiHash("arseek")] + ArSeek = 58, + [AsciiHash("pfadd")] + PfAdd = 59, + [AsciiHash("sexpire")] + SExpire = 60, + [AsciiHash("spersist")] + SPersist = 61, + [AsciiHash("sinterstore")] + SInterStore = 62, + [AsciiHash("zrangestore")] + ZRangeStore = 63, + [AsciiHash("sunionstore")] + SUnionStore = 64, + [AsciiHash("sdiffstore")] + SDiffStore = 65, // side-effect notifications [AsciiHash("expired")] @@ -132,5 +156,7 @@ public enum KeyNotificationType Overwritten = 1003, [AsciiHash("type_changed")] TypeChanged = 1004, + [AsciiHash("keymiss")] // class m; excluded from the "A" alias, so only seen when requested explicitly + KeyMiss = 1005, #pragma warning restore CS1591 // docs, redundant } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6d203b53f..f44a66b34 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -37,3 +37,17 @@ StackExchange.Redis.Availability.LagAwareOptions.SetUserPfxCertificate(string! u override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! *REMOVED*override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! muxer, System.Action! log) -> System.Threading.Tasks.Task! override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! +StackExchange.Redis.KeyNotificationType.ArInsert = 55 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArMSet = 56 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArRing = 57 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArSeek = 58 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.KeyMiss = 1005 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.PfAdd = 59 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SExpire = 60 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SInterStore = 62 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SDiffStore = 65 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SPersist = 61 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SUnionStore = 64 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SetBit = 54 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRangeStore = 63 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.RedisType.Bitmap = 10 -> StackExchange.Redis.RedisType diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index 1e228908f..d288653d7 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -422,7 +422,7 @@ public void DefaultKeyNotification_HasExpectedProperties() [Theory] [InlineData("append", KeyNotificationType.Append)] - [InlineData("copy", KeyNotificationType.Copy)] + [InlineData("copy_to", KeyNotificationType.Copy)] [InlineData("del", KeyNotificationType.Del)] [InlineData("expire", KeyNotificationType.Expire)] [InlineData("hdel", KeyNotificationType.HDel)] @@ -471,6 +471,22 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData("zrembyscore", KeyNotificationType.ZRemByScore)] [InlineData("zrem", KeyNotificationType.ZRem)] [InlineData("hexpire", KeyNotificationType.HExpire)] + [InlineData("ardel", KeyNotificationType.ArDel)] + [InlineData("ardelrange", KeyNotificationType.ArDelRange)] + [InlineData("arset", KeyNotificationType.ArSet)] + [InlineData("setbit", KeyNotificationType.SetBit)] + [InlineData("arinsert", KeyNotificationType.ArInsert)] + [InlineData("armset", KeyNotificationType.ArMSet)] + [InlineData("arring", KeyNotificationType.ArRing)] + [InlineData("arseek", KeyNotificationType.ArSeek)] + [InlineData("pfadd", KeyNotificationType.PfAdd)] + [InlineData("sexpire", KeyNotificationType.SExpire)] + [InlineData("spersist", KeyNotificationType.SPersist)] + [InlineData("sinterstore", KeyNotificationType.SInterStore)] + [InlineData("zrangestore", KeyNotificationType.ZRangeStore)] + [InlineData("sunionstore", KeyNotificationType.SUnionStore)] + [InlineData("sdiffstore", KeyNotificationType.SDiffStore)] + [InlineData("keymiss", KeyNotificationType.KeyMiss)] [InlineData("expired", KeyNotificationType.Expired)] [InlineData("evicted", KeyNotificationType.Evicted)] [InlineData("new", KeyNotificationType.New)] diff --git a/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs b/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs index 730e7dc19..0b399c4e5 100644 --- a/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs @@ -20,11 +20,13 @@ public class RedisTypeMetadataUnitTests [InlineData("stream", RedisType.Stream)] [InlineData("vectorset", RedisType.VectorSet)] [InlineData("array", RedisType.Array)] + [InlineData("bitmap", RedisType.Bitmap)] // parsing is case-insensitive (as it was in v2, which used Enum.TryParse with ignoreCase) [InlineData("NONE", RedisType.None)] [InlineData("None", RedisType.None)] [InlineData("ZSet", RedisType.SortedSet)] [InlineData("VECTORSET", RedisType.VectorSet)] + [InlineData("Bitmap", RedisType.Bitmap)] public void TryParse_KnownTokens(string value, RedisType expected) { ReadOnlySpan bytes = Encoding.ASCII.GetBytes(value); From e2c48d8aeb4eccb1dbfec35ffc0ec91315d4a054 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 11:57:43 +0000 Subject: [PATCH 04/14] Fix ConnectionGroupMember string-ctor dropping the name (#3314) * Forward the name argument in ConnectionGroupMember's string constructor The string overload chained to the ConfigurationOptions constructor without passing `name`, so every member built from a configuration string (the idiom used throughout docs/Failover.md) was named after its first endpoint by Init() instead of the name the caller gave it. Regression test added. * Parenthesize a Debug-only assertion that trips SA1407 `endSegmentLength == 0 & endSegmentCommitted == 0` in DebugAssertValid sits inside `#if DEBUG`, so CI (Release) never compiles it and never saw the StyleCop error; any local Debug build of RESPite failed with TreatWarningsAsErrors. Introduced by ee47e53b after the StyleCop bump in aa1e5c06. --- src/RESPite/Buffers/CycleBuffer.cs | 2 +- .../Availability/MultiGroupMultiplexer.cs | 2 +- .../AvailabilityConfigTests.cs | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs index a56c0a4b2..695b2e922 100644 --- a/src/RESPite/Buffers/CycleBuffer.cs +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -260,7 +260,7 @@ private void DebugAssertValid() if (startSegment is null) { Debug.Assert( - endSegmentLength == 0 & endSegmentCommitted == 0, + (endSegmentLength == 0) & (endSegmentCommitted == 0), "un-init state should be zero"); return; } diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 987f3ea93..13c7e45af 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -72,7 +72,7 @@ public sealed partial class ConnectionGroupMember(ConfigurationOptions configura /// #pragma warning disable RS0016, RS0026 public ConnectionGroupMember(string configuration, string name = "") : this( - ConfigurationOptions.Parse(configuration)) + ConfigurationOptions.Parse(configuration), name) #pragma warning restore RS0016, RS0026 { } diff --git a/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs b/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs index 4328fcc58..f55665e8a 100644 --- a/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs @@ -306,6 +306,19 @@ public void GroupDefaultsAreNotWrittenBackIntoCallerConfiguration() Assert.Null(config.CircuitBreaker); } + // ---- member naming ---- + [Fact] + public void Member_NameIsPreservedByBothConstructors() + { + // the string overload chains to the ConfigurationOptions one; it used to drop the name on the way, + // so every member built from a configuration string ended up named after its endpoint + Assert.Equal("East", new ConnectionGroupMember("localhost:6379", name: "East").Name); + Assert.Equal("West", new ConnectionGroupMember(ConfigurationOptions.Parse("localhost:6380"), name: "West").Name); + + // no name is still no name here; the endpoint fallback is applied when the member joins a group + Assert.Equal("", new ConnectionGroupMember("localhost:6379").Name); + } + // ---- WithRetry() policy resolution ---- [Fact] public async Task WithRetry_UsesConfiguredPolicyForASingleConnection() From 35ccc98deccd3dedee66edf5d9e276e8a1e902ad Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 12:19:33 +0000 Subject: [PATCH 05/14] Parse unbracketed IPv6 ip:port from the server (#1422) (#3307) * Parse unbracketed IPv6 ip:port from the server (#1422) CLUSTER NODES, MOVED/ASK and CLIENT LIST write IPv6 addresses without brackets, i.e. "2a06:4e80:3:a::a:7000". The RFC 3986 parser used for configuration strings reads that as a (valid!) eight-group address with no port, so cluster nodes ended up as "[2a06:4e80:3:a::a:7000]:0" and redirections and client addresses were equally wrong. Add Format.TryParseServerEndPoint for the server's "ip:port" form: when there is no ']' and more than one ':', split at the last colon and require both halves to parse; anything else goes to the existing parser unchanged, so configuration parsing is not touched. Use it at the three sites that receive server-emitted text. The sentinel +switch-master handler gets host and port as separate tokens and was gluing them back together with ':' before parsing; pass them to the (host, port) overload instead. * Parse unbracketed IPv6 in MOVING/SMIGRATED endpoints ParseMigrationEndPoint used the configuration-string parser, so an unbracketed IPv6 address parsed as an address with port 0, which the placeholder check then discarded. Use TryParseServerEndPoint, as the other server-emitted sites do; tests for the MOVING successor and SMIGRATED source/target. --- src/StackExchange.Redis/ClientInfo.cs | 2 +- .../ClusterConfiguration.cs | 2 +- .../ConnectionMultiplexer.Sentinel.cs | 2 +- src/StackExchange.Redis/Format.cs | 25 +++++++++++++ .../PhysicalConnection.Maintenance.cs | 7 +++- src/StackExchange.Redis/ResultProcessor.cs | 4 +- .../ClientInfoParseUnitTests.cs | 33 +++++++++++++++++ .../ClusterNodeParseUnitTests.cs | 12 ++++++ .../StackExchange.Redis.Tests/FormatTests.cs | 30 +++++++++++++++ .../MaintenanceNotificationTests.cs | 37 +++++++++++++++++++ 10 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/ClientInfoParseUnitTests.cs diff --git a/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index 511aee723..3ea71203d 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -235,7 +235,7 @@ internal static bool TryParse(string? input, [NotNullWhen(true)] out ClientInfo[ switch (key) { - case "addr" when Format.TryParseEndPoint(value, out var addr): client.Address = addr; break; + case "addr" when Format.TryParseServerEndPoint(value, out var addr): client.Address = addr; break; case "age": client.AgeSeconds = Format.ParseInt32(value); break; case "idle": client.IdleSeconds = Format.ParseInt32(value); break; case "db": client.Database = Format.ParseInt32(value); break; diff --git a/src/StackExchange.Redis/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index f642a3870..0b2fb7b48 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -371,7 +371,7 @@ internal ClusterNode(ClusterConfiguration configuration, string raw, EndPoint or } AuxFields ??= []; - if (Format.TryParseEndPoint(ep, out var epResult)) + if (Format.TryParseServerEndPoint(ep, out var epResult)) { EndPoint = epResult; } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs index 31f5296ce..dc8daba80 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs @@ -48,7 +48,7 @@ internal void InitializeSentinel(ILogger? log) { string[] messageParts = ((string)message!).Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); // We don't care about the result of this - we're just trying - _ = Format.TryParseEndPoint(string.Format("{0}:{1}", messageParts[1], messageParts[2]), out var switchBlame); + _ = Format.TryParseEndPoint(messageParts[1], messageParts[2], out var switchBlame); // Resolve the child under the lock, but run the (potentially multi-second, // blocking) SwitchPrimary reconfigure OUTSIDE it: otherwise a failover for one diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 0c4e33272..a7b65e899 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -65,6 +65,31 @@ internal static bool TryParseEndPoint(string host, string? port, [NotNullWhen(tr return false; } + /// + /// Parses an ip:port pair as the server writes it (CLUSTER NODES, MOVED/ASK, + /// CLIENT LIST, the MOVING/SMIGRATED maintenance notifications): an IPv6 address is not bracketed and a port is always present, for example + /// 2a06:4e80:3:a::a:7000. Splitting at the last colon is only safe when a port is known to follow, + /// which is why this is kept apart from (the RFC 3986 + /// form used in configuration strings, where ::1 is an address with no port). Anything that is not + /// an unbracketed IPv6 address followed by a port is handed to that method unchanged. + /// + internal static bool TryParseServerEndPoint(string? value, [NotNullWhen(true)] out EndPoint? endpoint) + { + if (value is not null && value.IndexOf(']') < 0) + { + int lastColon = value.LastIndexOf(':'); + if (lastColon > 0 && value.IndexOf(':') != lastColon + && TryParseInt32(value.Substring(lastColon + 1), out int port) + && port is >= IPEndPoint.MinPort and <= IPEndPoint.MaxPort + && IPAddress.TryParse(value.Substring(0, lastColon), out var address)) + { + endpoint = new IPEndPoint(address, port); + return true; + } + } + return TryParseEndPoint(value, out endpoint); + } + internal static string ToString(long value) => value.ToString(NumberFormatInfo.InvariantInfo); internal static string ToString(ulong value) => value.ToString(NumberFormatInfo.InvariantInfo); diff --git a/src/StackExchange.Redis/PhysicalConnection.Maintenance.cs b/src/StackExchange.Redis/PhysicalConnection.Maintenance.cs index a993b2a69..68635dca0 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Maintenance.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Maintenance.cs @@ -258,8 +258,13 @@ private List ReadSlotMigrations(ref RespReader reader) /// /// Parses one end of a migration, treating the placeholder forms as "not named" rather than as an error. /// + /// + /// This is server-emitted ip:port text, so it takes the same parser as CLUSTER NODES and + /// MOVED: an unbracketed IPv6 address read as a configuration string parses as an address with port 0, + /// which the placeholder check below would then silently discard. + /// private static EndPoint? ParseMigrationEndPoint(string? value) - => string.IsNullOrEmpty(value) || value == "?" || !Format.TryParseEndPoint(value, out var parsed) || IsPlaceholderEndPoint(parsed) + => string.IsNullOrEmpty(value) || value == "?" || !Format.TryParseServerEndPoint(value, out var parsed) || IsPlaceholderEndPoint(parsed) ? null : parsed; diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 9b2180301..7df8b8321 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -324,7 +324,7 @@ private bool HandleCommonError(Message message, RespReader reader, PhysicalConne log = false; string[] parts = reader.ReadString()!.Split(StringSplits.Space, 3); if (Format.TryParseInt32(parts[1], out int hashSlot) - && Format.TryParseEndPoint(parts[2], out var endpoint) + && Format.TryParseServerEndPoint(parts[2], out var endpoint) && IsUnroutableRedirect(endpoint)) { // the slot moved, but the reply does not say where to: a hostname-preferring node @@ -341,7 +341,7 @@ private bool HandleCommonError(Message message, RespReader reader, PhysicalConne : "The server redirected to an endpoint that does not identify a node that can be connected to; a topology refresh has been requested. "; } else if (Format.TryParseInt32(parts[1], out hashSlot) - && Format.TryParseEndPoint(parts[2], out endpoint)) + && Format.TryParseServerEndPoint(parts[2], out endpoint)) { // Check if MOVED points to same endpoint bool isSameEndpoint = Equals(server?.EndPoint, endpoint); diff --git a/tests/StackExchange.Redis.Tests/ClientInfoParseUnitTests.cs b/tests/StackExchange.Redis.Tests/ClientInfoParseUnitTests.cs new file mode 100644 index 000000000..2d0cb1d5b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ClientInfoParseUnitTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Parsing the raw text of CLIENT LIST, independent of any server. +/// +public class ClientInfoParseUnitTests +{ + [Fact] + public void UnbracketedIPv6AddressIsParsed() + { + // the server does not bracket IPv6 in "addr", so the port is the text after the *last* colon (#1422) + const string Raw = "id=8897064 addr=fd40:8017:11:7032:7011:600:ac18:bc:54265 fd=60 name=i-230510-t age=1548 idle=40 flags=P db=0 sub=2 psub=0 multi=-1 qbuf=0 qbuf-free=0 argv-mem=0 obl=0 oll=0 omem=0 tot-mem=20496 ow=0 owmem=0 events=r cmd=ping user=default numops=31\n"; + + Assert.True(ClientInfo.TryParse(Raw, out var clients)); + var client = Assert.Single(clients); + Assert.Equal(new IPEndPoint(IPAddress.Parse("fd40:8017:11:7032:7011:600:ac18:bc"), 54265), client.Address); + Assert.Equal(8897064, client.Id); + Assert.Equal("i-230510-t", client.Name); + } + + [Fact] + public void IPv4AddressIsParsed() + { + const string Raw = "id=3 addr=127.0.0.1:52555 fd=8 name= age=855 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client\n"; + + Assert.True(ClientInfo.TryParse(Raw, out var clients)); + var client = Assert.Single(clients); + Assert.Equal(new IPEndPoint(IPAddress.Loopback, 52555), client.Address); + } +} diff --git a/tests/StackExchange.Redis.Tests/ClusterNodeParseUnitTests.cs b/tests/StackExchange.Redis.Tests/ClusterNodeParseUnitTests.cs index 8cade9fb8..1d46a6f6c 100644 --- a/tests/StackExchange.Redis.Tests/ClusterNodeParseUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterNodeParseUnitTests.cs @@ -46,6 +46,18 @@ public void PreFourZeroLineHasNoBusPort() Assert.Empty(node.AuxFields); } + [Theory] + [InlineData("abc 2a06:4e80:3:a::a:7000@17000")] + [InlineData("abc 2a06:4e80:3:a::a:7000")] // pre-4.0 form, no bus port + public void UnbracketedIPv6AddressIsParsed(string prefix) + { + // CLUSTER NODES does not bracket IPv6 addresses, so the port is the text after the *last* colon (#1422); + // before the fix this parsed as the (valid!) address 2a06:4e80:3:a::a:7000 with port 0 + var node = Parse(prefix + Flags); + + Assert.Equal(new IPEndPoint(IPAddress.Parse("2a06:4e80:3:a::a"), 7000), node.EndPoint); + } + [Fact] public void HostnameIsParsed() { diff --git a/tests/StackExchange.Redis.Tests/FormatTests.cs b/tests/StackExchange.Redis.Tests/FormatTests.cs index 886ea794e..1cab95d96 100644 --- a/tests/StackExchange.Redis.Tests/FormatTests.cs +++ b/tests/StackExchange.Redis.Tests/FormatTests.cs @@ -120,6 +120,36 @@ public void ParseEndPoint(string data, EndPoint expected, string? expectedFormat Assert.Equal(expectedFormat, s); } + public static IEnumerable ServerEndpointData() + { + // what the server writes in CLUSTER NODES / MOVED / CLIENT LIST: IPv6 is *not* bracketed, and + // a port always follows, so the port is whatever comes after the last colon (#1422) + yield return new object?[] { "2a06:4e80:3:a::a:7000", new IPEndPoint(IPAddress.Parse("2a06:4e80:3:a::a"), 7000) }; + yield return new object?[] { "fd40:8017:11:7032:7011:600:ac18:bc:54265", new IPEndPoint(IPAddress.Parse("fd40:8017:11:7032:7011:600:ac18:bc"), 54265) }; + yield return new object?[] { "::1:6379", new IPEndPoint(IPAddress.IPv6Loopback, 6379) }; // contrast with EndpointData + // everything else is exactly as TryParseEndPoint sees it + yield return new object?[] { "[::1]:6379", new IPEndPoint(IPAddress.IPv6Loopback, 6379) }; + yield return new object?[] { "127.0.0.1:6379", new IPEndPoint(IPAddress.Loopback, 6379) }; + yield return new object?[] { "host.example.com:6379", new DnsEndPoint("host.example.com", 6379) }; + yield return new object?[] { "?", new DnsEndPoint("?", 0) }; + } + + [Theory] + [MemberData(nameof(ServerEndpointData))] + public void ParseServerEndPoint(string data, EndPoint expected) + { + Assert.True(Format.TryParseServerEndPoint(data, out var result)); + Assert.Equal(expected, result); + } + + [Fact] + public void ParseServerEndPointWithOutOfRangePortDoesNotThrow() + { + // not a port, so not an unbracketed IPv6 + port; whatever this is, it must not throw + Assert.True(Format.TryParseServerEndPoint("::1:99999", out var result)); + Assert.IsType(result); + } + // UDS endpoints live outside EndpointData because UnixDomainSocketEndPoint does not implement // value equality — these compare via ToString instead. #if NET diff --git a/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs b/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs index 1e197f48c..bfdb3a7a5 100644 --- a/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs @@ -183,6 +183,43 @@ public async Task MovingWithAPlaceholderYieldsNoEndpoint(string placeholder) } } + [Fact] + public async Task MovingToAnUnbracketedIPv6AddressIsParsed() + { + // the server writes IPv6 without brackets (#1422); read as a configuration string that is an address + // with no port, which the placeholder check would then drop - losing the one thing MOVING is for + var (server, conn, events) = await ConnectAsync(log); + using (server) + await using (conn) + { + Assert.True(server.SendRawPush(null, "MOVING", "1", "15", "2a06:4e80:3:a::a:7002") > 0); + + var evt = await events.NextAsync(); + log.WriteLine(evt.RawMessage ?? "(no message)"); + Assert.Equal(new IPEndPoint(IPAddress.Parse("2a06:4e80:3:a::a"), 7002), evt.NewEndPoint); + } + } + + [Fact] + public async Task SlotMigrationBetweenUnbracketedIPv6AddressesIsParsed() + { + var (server, conn, events) = await ConnectAsync(log); + using (server) + await using (conn) + { + Assert.Equal(1, server.SendSlotMigrations(null, MaintenanceNotificationKind.SlotMigrated, + [ + ("2a06:4e80:3:a::a:7000", "2a06:4e80:3:a::b:7001", "0-99"), + ])); + + var evt = await events.NextAsync(); + log.WriteLine(evt.RawMessage ?? "(no message)"); + var migration = Assert.Single(evt.SlotMigrations); + Assert.Equal(new IPEndPoint(IPAddress.Parse("2a06:4e80:3:a::a"), 7000), migration.Source); + Assert.Equal(new IPEndPoint(IPAddress.Parse("2a06:4e80:3:a::b"), 7001), migration.Target); + } + } + [Theory] [InlineData(MaintenanceNotificationKind.SlotMigrating, MaintenanceNotificationType.SlotMigrating)] [InlineData(MaintenanceNotificationKind.SlotMigrated, MaintenanceNotificationType.SlotMigrated)] From dd1db9913ad2855258bad5c31239703c08890b25 Mon Sep 17 00:00:00 2001 From: Mohsin Ali Date: Fri, 30 Oct 2026 17:42:24 +0500 Subject: [PATCH 06/14] Profile commands sent in a batch (#2175) (#3313) Co-authored-by: mohsinaliwork686-rgb <229973101+mohsinaliwork686-rgb@users.noreply.github.com> --- .../ConnectionMultiplexer.Profiling.cs | 14 +++++++++ .../ConnectionMultiplexer.cs | 7 +---- src/StackExchange.Redis/RedisBatch.cs | 6 ++++ .../ProfilingTests.cs | 29 +++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Profiling.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Profiling.cs index c60966234..a7ab10bf2 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Profiling.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Profiling.cs @@ -14,4 +14,18 @@ public partial class ConnectionMultiplexer /// /// The session provider to register. public void RegisterProfiler(Func profilingSessionProvider) => _profilingSessionProvider = profilingSessionProvider; + + /// + /// Gives the message somewhere to record its timings, if a profiler is registered and has a session for the + /// calling context. Every path that hands a message to a server calls this once the server is known: single + /// commands via PrepareToPushMessageToBridge, and batches, which go to the bridge directly (#2175). + /// + internal void AttachProfiling(Message message, ServerEndPoint server) + { + var profilingSession = _profilingSessionProvider?.Invoke(); + if (profilingSession != null) + { + message.SetProfileStorage(ProfiledCommand.NewWithContext(profilingSession, server)); + } + } } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 87f383fb9..49a5682e5 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -13,7 +13,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using StackExchange.Redis.Profiling; namespace StackExchange.Redis { @@ -2629,11 +2628,7 @@ private bool PrepareToPushMessageToBridge(Message message, ResultProcessor if (server != null) { - var profilingSession = _profilingSessionProvider?.Invoke(); - if (profilingSession != null) - { - message.SetProfileStorage(ProfiledCommand.NewWithContext(profilingSession, server)); - } + AttachProfiling(message, server); if (message.Db >= 0) { diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index a7783edcd..9d38377f0 100644 --- a/src/StackExchange.Redis/RedisBatch.cs +++ b/src/StackExchange.Redis/RedisBatch.cs @@ -75,6 +75,12 @@ public void Execute() foreach (var pair in byBridge) { + // attached here, not in the loop above: that loop can still abandon the whole batch for want of a + // server, and commands that never reach a bridge should not show up in a profiling session + foreach (var message in pair.Value) + { + multiplexer.AttachProfiling(message, pair.Key.ServerEndPoint); + } if (!pair.Key.TryEnqueue(pair.Value, pair.Key.ServerEndPoint.IsReplica)) { FailNoServer(multiplexer, pair.Value); diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index 65bb09bde..269f39aa0 100644 --- a/tests/StackExchange.Redis.Tests/ProfilingTests.cs +++ b/tests/StackExchange.Redis.Tests/ProfilingTests.cs @@ -95,6 +95,35 @@ private static void AssertProfiledCommandValues(IProfiledCommand command, IConne Assert.True(command.RetransmissionReason == null, nameof(command.RetransmissionReason)); } + [Fact] + public async Task Batch() + { + await using var conn = Create(); + + var key = Me(); + var session = new ProfilingSession(); + conn.RegisterProfiler(() => session); + + var dbId = TestConfig.GetDedicatedDB(conn); + var db = conn.GetDatabase(dbId); + + // a batch is handed to the bridge directly, so it used to skip profiling altogether (#2175) + var batch = db.CreateBatch(); + var set = batch.StringSetAsync(key, "world"); + var get = batch.StringGetAsync(key); + var echo = batch.ExecuteAsync("ECHO", "fii"); + batch.Execute(); + await Task.WhenAll(set, get, echo); + Assert.Equal("world", await get); + + var cmds = session.FinishProfiling(); + Assert.Equal("ECHO,GET,SET", string.Join(",", cmds.Select(x => x.Command).OrderBy(x => x))); + + AssertProfiledCommandValues(cmds.Single(cmd => cmd.Command == "SET"), conn, dbId); + AssertProfiledCommandValues(cmds.Single(cmd => cmd.Command == "GET"), conn, dbId); + AssertProfiledCommandValues(cmds.Single(cmd => cmd.Command == "ECHO"), conn, -1); + } + [Fact] public async Task ManyThreads() { From 5b2c3d134c120bb4668379c5323bca4c5d9ccefe Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 12:56:08 +0000 Subject: [PATCH 07/14] public-api.py: apply *REMOVED* at promotion; promote the API surface since 3.4.0 (#3316) * public-api.py: apply *REMOVED* entries at promotion instead of copying them The analyzer records a vanished shipped signature as a *REMOVED* line in the unshipped file. Promotion moved every unshipped line across verbatim, so a promotion after any such change would have left the shipped file with both the dead signature and a *REMOVED* copy of it, and the analyzer would then fail the build on the dead one (RS0017). Promotion now consumes the marker: the named signature is dropped from the shipped file, the marker is not carried over, and a marker that names nothing shipped is an error. A *REMOVED* line already sitting in a shipped file is also reported. The "+N promoted" note gains "-N removed", and an unshipped file holding only removals is still emptied. * Promote the shipped API surface: everything since 3.4.0 eng/public-api.py --promote. Fifty-two StackExchange.Redis entries (the lag-aware availability surface, FailbackHealthCheck, HealthCheckProbeScope, the 8.12-independent RedisType and KeyNotificationType tokens) and the net6.0 SetUserPemCertificate overload move to shipped; the three parameter renames from #3296 (muxer -> multiplexer on the Azure providers' AfterConnectAsync, other -> obj on RESPite AsciiHash.Equals) are applied rather than copied. Every unshipped file is now empty. --- eng/public-api.py | 37 +++++++++---- src/RESPite/PublicAPI/PublicAPI.Shipped.txt | 2 +- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 2 - .../PublicAPI/PublicAPI.Shipped.txt | 52 ++++++++++++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 52 ------------------- .../PublicAPI/net6.0/PublicAPI.Shipped.txt | 1 + .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 1 - 7 files changed, 80 insertions(+), 67 deletions(-) diff --git a/eng/public-api.py b/eng/public-api.py index 8c67e9ee9..e752b4e56 100755 --- a/eng/public-api.py +++ b/eng/public-api.py @@ -10,7 +10,10 @@ *Promotion* (``--promote`` only). Moves every entry from ``PublicAPI.Unshipped.txt`` into its sibling ``PublicAPI.Shipped.txt``. "Shipped" means released and frozen, so doing this mid-cycle would freeze API that has not shipped and cannot then be changed without it looking like a break. It is therefore -opt in, and belongs to the release: ship the package, then promote. +opt in, and belongs to the release: ship the package, then promote. A ``*REMOVED*`` entry in the +unshipped file (the analyzer's record that a shipped signature is gone, for example a parameter was +renamed) is applied rather than copied: the named signature leaves the shipped file and the marker is +consumed. Copying it across would leave the shipped file claiming a symbol that no longer exists. eng/public-api.py # sort in place, promote nothing eng/public-api.py --promote # the release step: fold unshipped into shipped, and sort @@ -26,6 +29,9 @@ # abstract/override/static/virtual block and a bare one. MODIFIERS = ("abstract ", "override ", "static ", "virtual ", "readonly ", "const ", "sealed ") +# the analyzer's prefix for "this shipped entry no longer exists"; only ever valid in an unshipped file +REMOVED = "*REMOVED*" + def sort_key(line): s = line @@ -97,10 +103,23 @@ def main(): rel = os.path.relpath(shipped_path, root) entries = list(shipped.entries) - promoted = [] - - if args.promote and unshipped and unshipped.entries: - promoted = list(unshipped.entries) + promoted, removed = [], [] + promoting = bool(args.promote and unshipped and unshipped.entries) + + stray = [e for e in entries if e.startswith(REMOVED)] + if stray: + problems.append(f"{rel}: {len(stray)} {REMOVED} entr(y/ies) in a shipped file, e.g. {stray[0]}") + continue + + if promoting: + removed = [e[len(REMOVED):] for e in unshipped.entries if e.startswith(REMOVED)] + promoted = [e for e in unshipped.entries if not e.startswith(REMOVED)] + # a removal must name something that is actually shipped; anything else is a hand-edit gone wrong + missing = [r for r in removed if r not in set(entries)] + if missing: + problems.append(f"{rel}: {len(missing)} {REMOVED} entr(y/ies) not found in shipped, e.g. {missing[0]}") + continue + entries = [e for e in entries if e not in set(removed)] # an entry in both files is not something to silently dedupe: the analyzer rejects # duplicates (RS0025), and it means one of the two was hand-edited clash = [e for e in promoted if e in set(entries)] @@ -110,8 +129,8 @@ def main(): entries += promoted new_shipped = sorted(entries, key=sort_key) - if shipped.render(new_shipped) != shipped.render(shipped.entries) or promoted: - note = f" (+{len(promoted)} promoted)" if promoted else "" + if shipped.render(new_shipped) != shipped.render(shipped.entries) or promoting: + note = f" (+{len(promoted)} promoted, -{len(removed)} removed)" if promoting else "" changed.append(rel + note) if not args.check: shipped.write(new_shipped) @@ -120,9 +139,9 @@ def main(): rel_un = os.path.relpath(unshipped_path, root) # promoted entries leave; whatever remains is sorted too, so that appending to it from two # branches collides less often - new_unshipped = [] if promoted else sorted(unshipped.entries, key=sort_key) + new_unshipped = [] if promoting else sorted(unshipped.entries, key=sort_key) if unshipped.render(new_unshipped) != unshipped.render(unshipped.entries): - if not promoted: + if not promoting: changed.append(rel_un) if not args.check: unshipped.write(new_unshipped) diff --git a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt index 76c600004..b99b46672 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt @@ -9,7 +9,7 @@ [SER004]static RESPite.AsciiHash.CaseInsensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! [SER004]static RESPite.AsciiHash.CaseSensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! [SER004]RESPite.AsciiHash.Equals(in RESPite.AsciiHash other) -> bool -[SER004]override RESPite.AsciiHash.Equals(object? other) -> bool +[SER004]override RESPite.AsciiHash.Equals(object? obj) -> bool [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 555737f80..7dc5c5811 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -1,3 +1 @@ #nullable enable -*REMOVED*[SER004]override RESPite.AsciiHash.Equals(object? other) -> bool -[SER004]override RESPite.AsciiHash.Equals(object? obj) -> bool diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 5d8496c8d..925216820 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -80,6 +80,8 @@ StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(Sta StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(string! configuration, string! name = "") -> void StackExchange.Redis.Availability.ConnectionGroupMember.FailbackDelay.get -> System.TimeSpan? StackExchange.Redis.Availability.ConnectionGroupMember.FailbackDelay.set -> void +StackExchange.Redis.Availability.ConnectionGroupMember.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? +StackExchange.Redis.Availability.ConnectionGroupMember.FailbackHealthCheck.set -> void StackExchange.Redis.Availability.ConnectionGroupMember.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? StackExchange.Redis.Availability.ConnectionGroupMember.HealthCheck.set -> void StackExchange.Redis.Availability.ConnectionGroupMember.IsConnected.get -> bool @@ -136,6 +138,7 @@ StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redi StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! static StackExchange.Redis.Availability.HealthCheck.Default.get -> StackExchange.Redis.Availability.HealthCheck! StackExchange.Redis.Availability.HealthCheck.IsEnabled.get -> bool +static StackExchange.Redis.Availability.HealthCheck.LagAware(StackExchange.Redis.Availability.LagAwareOptions! options) -> StackExchange.Redis.Availability.HealthCheck! static StackExchange.Redis.Availability.HealthCheck.None.get -> StackExchange.Redis.Availability.HealthCheck! StackExchange.Redis.Availability.HealthCheck.Probe.get -> StackExchange.Redis.Availability.HealthCheckProbe! StackExchange.Redis.Availability.HealthCheck.ProbeCount.get -> int @@ -156,8 +159,10 @@ StackExchange.Redis.Availability.HealthCheckProbe.HealthCheckProbe() -> void static StackExchange.Redis.Availability.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! static StackExchange.Redis.Availability.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! static StackExchange.Redis.Availability.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.Availability.HealthCheckProbe! +static StackExchange.Redis.Availability.HealthCheckProbe.LagAware(StackExchange.Redis.Availability.LagAwareOptions! options) -> StackExchange.Redis.Availability.HealthCheckProbe! static StackExchange.Redis.Availability.HealthCheckProbe.None.get -> StackExchange.Redis.Availability.HealthCheckProbe! static StackExchange.Redis.Availability.HealthCheckProbe.Ping.get -> StackExchange.Redis.Availability.HealthCheckProbe! +virtual StackExchange.Redis.Availability.HealthCheckProbe.Scope.get -> StackExchange.Redis.Availability.HealthCheckProbeScope static StackExchange.Redis.Availability.HealthCheckProbe.StringSet.get -> StackExchange.Redis.Availability.HealthCheckProbe! static StackExchange.Redis.Availability.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! StackExchange.Redis.Availability.HealthCheckProbeContext @@ -175,6 +180,9 @@ static StackExchange.Redis.Availability.HealthCheckProbePolicy.AnySuccess.get -> abstract StackExchange.Redis.Availability.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheckResult StackExchange.Redis.Availability.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void static StackExchange.Redis.Availability.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +StackExchange.Redis.Availability.HealthCheckProbeScope +StackExchange.Redis.Availability.HealthCheckProbeScope.Endpoint = 0 -> StackExchange.Redis.Availability.HealthCheckProbeScope +StackExchange.Redis.Availability.HealthCheckProbeScope.Member = 1 -> StackExchange.Redis.Availability.HealthCheckProbeScope StackExchange.Redis.Availability.HealthCheckResult StackExchange.Redis.Availability.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.Availability.HealthCheckResult StackExchange.Redis.Availability.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.Availability.HealthCheckResult @@ -191,6 +199,29 @@ StackExchange.Redis.Availability.KeyWriteHealthCheckProbe override sealed StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheckContext context) -> System.Threading.Tasks.Task! abstract StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheckContext context, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void +StackExchange.Redis.Availability.LagAwareOptions +StackExchange.Redis.Availability.LagAwareOptions.CertificateValidation.get -> System.Net.Security.RemoteCertificateValidationCallback? +StackExchange.Redis.Availability.LagAwareOptions.CertificateValidation.set -> void +StackExchange.Redis.Availability.LagAwareOptions.ClientCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2? +StackExchange.Redis.Availability.LagAwareOptions.ClientCertificate.set -> void +StackExchange.Redis.Availability.LagAwareOptions.Credentials.get -> System.Func>? +StackExchange.Redis.Availability.LagAwareOptions.Credentials.set -> void +StackExchange.Redis.Availability.LagAwareOptions.DatabaseId.get -> int? +StackExchange.Redis.Availability.LagAwareOptions.DatabaseId.set -> void +StackExchange.Redis.Availability.LagAwareOptions.LagAwareOptions() -> void +StackExchange.Redis.Availability.LagAwareOptions.LagCheck.get -> StackExchange.Redis.Availability.LagCheckMode +StackExchange.Redis.Availability.LagAwareOptions.LagCheck.set -> void +StackExchange.Redis.Availability.LagAwareOptions.LagTolerance.get -> System.TimeSpan? +StackExchange.Redis.Availability.LagAwareOptions.LagTolerance.set -> void +StackExchange.Redis.Availability.LagAwareOptions.RestEndpoint.get -> System.Uri? +StackExchange.Redis.Availability.LagAwareOptions.RestEndpoint.set -> void +StackExchange.Redis.Availability.LagAwareOptions.SetUserPfxCertificate(string! userCertificatePath, string? password = null) -> void +StackExchange.Redis.Availability.LagAwareOptions.TrustIssuer(System.Security.Cryptography.X509Certificates.X509Certificate2! issuer) -> void +StackExchange.Redis.Availability.LagAwareOptions.TrustIssuer(string! issuerCertificatePath) -> void +StackExchange.Redis.Availability.LagCheckMode +StackExchange.Redis.Availability.LagCheckMode.Auto = 0 -> StackExchange.Redis.Availability.LagCheckMode +StackExchange.Redis.Availability.LagCheckMode.Disabled = 2 -> StackExchange.Redis.Availability.LagCheckMode +StackExchange.Redis.Availability.LagCheckMode.Enabled = 1 -> StackExchange.Redis.Availability.LagCheckMode StackExchange.Redis.Availability.MultiGroupOptions StackExchange.Redis.Availability.MultiGroupOptions.Builder StackExchange.Redis.Availability.MultiGroupOptions.Builder.Builder() -> void @@ -200,6 +231,8 @@ StackExchange.Redis.Availability.MultiGroupOptions.Builder.CircuitBreaker.set -> StackExchange.Redis.Availability.MultiGroupOptions.Builder.Create() -> StackExchange.Redis.Availability.MultiGroupOptions! StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackDelay.get -> System.TimeSpan StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackDelay.set -> void +StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? +StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackHealthCheck.set -> void StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck! StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheck.set -> void StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheckInterval.get -> System.TimeSpan @@ -210,6 +243,7 @@ static StackExchange.Redis.Availability.MultiGroupOptions.Builder.implicit opera StackExchange.Redis.Availability.MultiGroupOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker! static StackExchange.Redis.Availability.MultiGroupOptions.Default.get -> StackExchange.Redis.Availability.MultiGroupOptions! StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.get -> System.TimeSpan +StackExchange.Redis.Availability.MultiGroupOptions.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? StackExchange.Redis.Availability.MultiGroupOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck! StackExchange.Redis.Availability.MultiGroupOptions.HealthCheckInterval.get -> System.TimeSpan StackExchange.Redis.Availability.MultiGroupOptions.RetryPolicy.get -> StackExchange.Redis.Availability.RetryPolicy! @@ -537,7 +571,7 @@ StackExchange.Redis.ConditionResult StackExchange.Redis.ConditionResult.WasSatisfied.get -> bool StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AbortOnConnectFail.get -> bool -override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! muxer, System.Action! log) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AzureManagedRedisOptionsProvider() -> void override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.ConfigurationChannel.get -> string! override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.DefaultVersion.get -> System.Version! @@ -548,7 +582,7 @@ override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.Name override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.Protocol.get -> StackExchange.Redis.RedisProtocol? StackExchange.Redis.Configuration.AzureOptionsProvider override StackExchange.Redis.Configuration.AzureOptionsProvider.AbortOnConnectFail.get -> bool -override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! muxer, System.Action! log) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! StackExchange.Redis.Configuration.AzureOptionsProvider.AzureOptionsProvider() -> void override StackExchange.Redis.Configuration.AzureOptionsProvider.DefaultVersion.get -> System.Version! override StackExchange.Redis.Configuration.AzureOptionsProvider.GetDefaultSsl(StackExchange.Redis.EndPointCollection! endPoints) -> bool @@ -2082,6 +2116,10 @@ StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Append = 1 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ArDel = 51 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ArDelRange = 52 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArInsert = 55 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArMSet = 56 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArRing = 57 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ArSeek = 58 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ArSet = 53 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Copy = 2 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Del = 3 -> StackExchange.Redis.KeyNotificationType @@ -2097,6 +2135,7 @@ StackExchange.Redis.KeyNotificationType.HPersist = 9 -> StackExchange.Redis.KeyN StackExchange.Redis.KeyNotificationType.HSet = 10 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.IncrBy = 12 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.IncrByFloat = 11 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.KeyMiss = 1005 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.LInsert = 13 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.LPop = 14 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.LPush = 15 -> StackExchange.Redis.KeyNotificationType @@ -2108,15 +2147,22 @@ StackExchange.Redis.KeyNotificationType.MoveTo = 20 -> StackExchange.Redis.KeyNo StackExchange.Redis.KeyNotificationType.New = 1002 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Overwritten = 1003 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Persist = 21 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.PfAdd = 59 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.RPop = 25 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.RPush = 26 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.RenameFrom = 22 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.RenameTo = 23 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Restore = 24 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.SAdd = 27 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SDiffStore = 65 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SExpire = 60 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SInterStore = 62 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SPersist = 61 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.SPop = 32 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.SRem = 31 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SUnionStore = 64 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Set = 28 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SetBit = 54 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.SetRange = 29 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.SortStore = 30 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.TypeChanged = 1004 -> StackExchange.Redis.KeyNotificationType @@ -2134,6 +2180,7 @@ StackExchange.Redis.KeyNotificationType.ZAdd = 42 -> StackExchange.Redis.KeyNoti StackExchange.Redis.KeyNotificationType.ZDiffStore = 43 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZIncr = 46 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZInterStore = 44 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRangeStore = 63 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZRem = 49 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZRemByRank = 47 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZRemByScore = 48 -> StackExchange.Redis.KeyNotificationType @@ -2653,6 +2700,7 @@ StackExchange.Redis.RedisTimeoutException.RedisTimeoutException(StackExchange.Re StackExchange.Redis.RedisTimeoutException.RedisTimeoutException(string! message, StackExchange.Redis.CommandStatus commandStatus) -> void StackExchange.Redis.RedisType StackExchange.Redis.RedisType.Array = 9 -> StackExchange.Redis.RedisType +StackExchange.Redis.RedisType.Bitmap = 10 -> StackExchange.Redis.RedisType StackExchange.Redis.RedisType.Hash = 5 -> StackExchange.Redis.RedisType StackExchange.Redis.RedisType.List = 2 -> StackExchange.Redis.RedisType StackExchange.Redis.RedisType.None = 0 -> StackExchange.Redis.RedisType diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f44a66b34..ab058de62 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,53 +1 @@ #nullable enable -StackExchange.Redis.Availability.ConnectionGroupMember.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? -StackExchange.Redis.Availability.ConnectionGroupMember.FailbackHealthCheck.set -> void -StackExchange.Redis.Availability.HealthCheckProbeScope -StackExchange.Redis.Availability.HealthCheckProbeScope.Endpoint = 0 -> StackExchange.Redis.Availability.HealthCheckProbeScope -StackExchange.Redis.Availability.HealthCheckProbeScope.Member = 1 -> StackExchange.Redis.Availability.HealthCheckProbeScope -StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? -StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackHealthCheck.set -> void -StackExchange.Redis.Availability.MultiGroupOptions.FailbackHealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? -virtual StackExchange.Redis.Availability.HealthCheckProbe.Scope.get -> StackExchange.Redis.Availability.HealthCheckProbeScope -StackExchange.Redis.Availability.LagAwareOptions -StackExchange.Redis.Availability.LagAwareOptions.CertificateValidation.get -> System.Net.Security.RemoteCertificateValidationCallback? -StackExchange.Redis.Availability.LagAwareOptions.CertificateValidation.set -> void -StackExchange.Redis.Availability.LagAwareOptions.Credentials.get -> System.Func>? -StackExchange.Redis.Availability.LagAwareOptions.Credentials.set -> void -StackExchange.Redis.Availability.LagAwareOptions.DatabaseId.get -> int? -StackExchange.Redis.Availability.LagAwareOptions.DatabaseId.set -> void -StackExchange.Redis.Availability.LagAwareOptions.LagAwareOptions() -> void -StackExchange.Redis.Availability.LagAwareOptions.LagCheck.get -> StackExchange.Redis.Availability.LagCheckMode -StackExchange.Redis.Availability.LagAwareOptions.LagCheck.set -> void -StackExchange.Redis.Availability.LagAwareOptions.LagTolerance.get -> System.TimeSpan? -StackExchange.Redis.Availability.LagAwareOptions.LagTolerance.set -> void -StackExchange.Redis.Availability.LagAwareOptions.RestEndpoint.get -> System.Uri? -StackExchange.Redis.Availability.LagAwareOptions.RestEndpoint.set -> void -StackExchange.Redis.Availability.LagAwareOptions.TrustIssuer(string! issuerCertificatePath) -> void -StackExchange.Redis.Availability.LagAwareOptions.TrustIssuer(System.Security.Cryptography.X509Certificates.X509Certificate2! issuer) -> void -StackExchange.Redis.Availability.LagCheckMode -StackExchange.Redis.Availability.LagCheckMode.Auto = 0 -> StackExchange.Redis.Availability.LagCheckMode -StackExchange.Redis.Availability.LagCheckMode.Disabled = 2 -> StackExchange.Redis.Availability.LagCheckMode -StackExchange.Redis.Availability.LagCheckMode.Enabled = 1 -> StackExchange.Redis.Availability.LagCheckMode -static StackExchange.Redis.Availability.HealthCheck.LagAware(StackExchange.Redis.Availability.LagAwareOptions! options) -> StackExchange.Redis.Availability.HealthCheck! -static StackExchange.Redis.Availability.HealthCheckProbe.LagAware(StackExchange.Redis.Availability.LagAwareOptions! options) -> StackExchange.Redis.Availability.HealthCheckProbe! -StackExchange.Redis.Availability.LagAwareOptions.ClientCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2? -StackExchange.Redis.Availability.LagAwareOptions.ClientCertificate.set -> void -StackExchange.Redis.Availability.LagAwareOptions.SetUserPfxCertificate(string! userCertificatePath, string? password = null) -> void -*REMOVED*override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! muxer, System.Action! log) -> System.Threading.Tasks.Task! -override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! -*REMOVED*override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! muxer, System.Action! log) -> System.Threading.Tasks.Task! -override StackExchange.Redis.Configuration.AzureOptionsProvider.AfterConnectAsync(StackExchange.Redis.ConnectionMultiplexer! multiplexer, System.Action! log) -> System.Threading.Tasks.Task! -StackExchange.Redis.KeyNotificationType.ArInsert = 55 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ArMSet = 56 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ArRing = 57 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ArSeek = 58 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.KeyMiss = 1005 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.PfAdd = 59 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SExpire = 60 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SInterStore = 62 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SDiffStore = 65 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SPersist = 61 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SUnionStore = 64 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SetBit = 54 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZRangeStore = 63 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.RedisType.Bitmap = 10 -> StackExchange.Redis.RedisType diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt index 533b1f0bc..c535353c8 100644 --- a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt @@ -1,4 +1,5 @@ #nullable enable +StackExchange.Redis.Availability.LagAwareOptions.SetUserPemCertificate(string! userCertificatePath, string? userKeyPath = null) -> void override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.DisposeAsync() -> System.Threading.Tasks.ValueTask override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(System.Span buffer) -> int override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt index 04376438f..ab058de62 100644 --- a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -1,2 +1 @@ #nullable enable -StackExchange.Redis.Availability.LagAwareOptions.SetUserPemCertificate(string! userCertificatePath, string? userKeyPath = null) -> void From 02b9ce4eafbb357bd65c31b57a26c975d9eab45a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Oct 2026 16:16:43 +0000 Subject: [PATCH 08/14] Split third-party notices out of LICENSE so GitHub detects MIT (#3317) * Split third-party notices out of LICENSE so GitHub detects MIT The LICENSE file carried ~1.1KB of third-party notes after the MIT text, which drops it below licensee's similarity threshold; GitHub reports the repo license as "Other" (SPDX NOASSERTION). Keep LICENSE as the bare MIT text and move the notes, verbatim, to THIRD-PARTY-NOTICES.md. * Correct the third-party notices - Redis: BSD-3 only through 7.2; RSALv2/SSPLv1 from 7.4, AGPLv3 added in 8.0; point at redis.io/legal/licenses (the old /topics/license URL now redirects to the docs landing page). - redis-doc: repo is archived under redis/redis-doc; note that redis/docs, its successor, is CC BY-NC-SA for newer content. - Drop the Redis-64 and BookSleeve notes: neither is part of the development solution any more. - Add the code actually incorporated from other projects (dotnet/runtime, dotnet/corefx, aspnet/BasicMiddleware) and the test-only redis-sharp copy, none of which the old notes mentioned. --- LICENSE | 26 ---------------------- THIRD-PARTY-NOTICES.md | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 THIRD-PARTY-NOTICES.md diff --git a/LICENSE b/LICENSE index db4620c99..5f31a146d 100644 --- a/LICENSE +++ b/LICENSE @@ -19,29 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -=============================================== - -Third Party Licenses: - -The Redis project (https://redis.io/) is independent of this client library, and -is licensed separately under the three clause BSD license. The full license -information can be viewed here: https://redis.io/topics/license - -This tool makes use of the "redis-doc" library from https://redis.io/documentation -in the intellisense comments, which is licensed under the -Creative Commons Attribution-ShareAlike 4.0 International license; full -details are available here: -https://github.com/antirez/redis-doc/blob/master/COPYRIGHT - -The development solution uses the Redis-64 package from nuget -(https://www.nuget.org/packages/Redis-64) by Microsoft Open Technologies, inc. -This is licensed under the BSD license; full details are available here: -https://github.com/MSOpenTech/redis/blob/2.6/license.txt -This tool is not used in the release binaries. - -The development solution uses the BookSleeve package from nuget -(https://code.google.com/p/booksleeve/) by Marc Gravell. This is licensed -under the Apache 2.0 license; full details are available here: -https://www.apache.org/licenses/LICENSE-2.0 -This tool is not used in the release binaries. \ No newline at end of file diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 000000000..1631df58a --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,49 @@ +# Third-party notices + +StackExchange.Redis and RESPite are released under the MIT license; see [LICENSE](LICENSE). +The notes below concern third-party projects and materials referenced by, or incorporated +into, this repository. + +## Redis and other RESP servers + +The Redis project (https://redis.io/) is independent of this client library and is licensed +separately. Redis 7.2 and earlier are licensed under the three-clause BSD license; Redis 7.4 +and later are available under the Redis Source Available License v2 (RSALv2) or the Server +Side Public License v1 (SSPLv1), and Redis 8.0 and later additionally under the GNU Affero +General Public License v3 (AGPLv3). The current terms are published at +https://redis.io/legal/licenses/ + +Other RESP-compatible servers that this library can connect to (Valkey, Garnet, Dragonfly, +KeyDB, Azure Managed Redis, AWS ElastiCache, and others) are separate projects and products, +each licensed by its own maintainer or vendor. + +## Redis documentation + +The IntelliSense (XML documentation) comments on the command methods draw on the Redis command +documentation, originally published by the "redis-doc" project, which is licensed under the +Creative Commons Attribution-ShareAlike 4.0 International license: +https://github.com/redis/redis-doc (archived; see its COPYRIGHT and LICENSE files). + +The current Redis documentation lives at https://redis.io/docs/latest/ and is maintained at +https://github.com/redis/docs, whose newer content is licensed under Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International; see that repository's LICENSE file. + +## Code incorporated from other projects + +The following source files contain code copied or adapted from other projects; the original +license notices are preserved in the files themselves. + +- `src/RESPite/Shared/NullableHacks.cs` - nullable-annotation attributes for down-level target + frameworks, from dotnet/runtime (`NullableAttributes.cs`); .NET Foundation, MIT license. +- `src/StackExchange.Redis/TaskExtensions.cs` (`TimeoutAfter`) - adapted from + `TaskTimeoutExtensions.cs` in dotnet/corefx; .NET Foundation, MIT license. +- `src/StackExchange.Redis/Format.cs` (`TryParseEndPoint`) - adapted from `IPEndPointParser` + in aspnet/BasicMiddleware (Microsoft.AspNetCore.HttpOverrides); .NET Foundation, + Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0). + +## Test-only material + +The test project includes `tests/StackExchange.Redis.Tests/Helpers/redis-sharp.cs`, a copy of +redis-sharp by Miguel de Icaza (https://github.com/migueldeicaza/redis-sharp), Copyright 2010 +Novell, Inc., licensed under the new BSD license. It is used only as a comparison +implementation in tests and is not part of the release binaries. From 9d17974fcade6c89b29b233030fe281e75f9ab1c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Nov 2026 09:37:54 +0000 Subject: [PATCH 09/14] The read loop stops matching replies once teardown owns the response queue; a close with commands in flight no longer completes a call with the previous call's reply (#3330) RecordConnectionFailed drains _writtenAwaitingResponse one message at a time, completing each outside the lock, while the read loop can still be matching replies that were already in the receive buffer. Each reply the loop matched after the drain had taken a message went to the message *after* the one it answered: GET key N completed, successfully, with key N-1's value, and every reply behind it was off by one. Reproduced on main with the reporter's loop: 616 wrong replies in 20,000 calls. _isShutdown was already set under the queue's lock before the drain, and the loop already consulted it - but only once the queue was empty. Checking it before the dequeue closes the race: a reply arriving after teardown has begun is dropped with the connection, and the message it answered is failed by the drain as before. The test re-creates the scenario against the real server (20 rounds of 500 in-flight GETs closed with allowCommandsToComplete: false) and asserts that no call completes with another call's value; without the fix it sees ~200 in a run. Fixes #3329 --- .../PhysicalConnection.Read.cs | 19 ++--- .../CloseInFlightTests.cs | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/CloseInFlightTests.cs diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index 4fe44624f..5b572c471 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -741,17 +741,18 @@ private void MatchNextResult(ReadOnlySpan frame) _awaitingToken = null; } - if (!_writtenAwaitingResponse.TryDequeue(out msg)) + // A failure can race bytes that were already in the socket or read buffer. Once shutdown has + // started, no new command can enter this queue, and RecordConnectionFailed is draining it one + // message at a time (completing each outside the lock); if we kept dequeuing here, every reply + // we matched would be handed to the message *after* the one it belongs to (#3329). Drop the + // bytes with the dead connection instead - those messages are being failed anyway. + if (_isShutdown) { - // A failure can race bytes that were already in the socket or read buffer. Once shutdown has - // started, no new command can enter this queue, so these are replies to commands the failure - // path has already completed. Drop them with the dead connection instead of reporting a fresh - // protocol failure. - if (_isShutdown) - { - return; - } + return; + } + if (!_writtenAwaitingResponse.TryDequeue(out msg)) + { Throw(frame, connectionType, _protocol); [DoesNotReturn] diff --git a/tests/StackExchange.Redis.Tests/CloseInFlightTests.cs b/tests/StackExchange.Redis.Tests/CloseInFlightTests.cs new file mode 100644 index 000000000..cc9472045 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/CloseInFlightTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class CloseInFlightTests(ITestOutputHelper output) : TestBase(output) +{ + // #3329: CloseAsync(allowCommandsToComplete: false) with commands in flight races the read loop, still + // matching replies already in the buffer, against the failure path draining the sent-awaiting-reply + // queue one message at a time. Each reply the read loop matched after the drain had taken a message was + // handed to the message *after* the one it belonged to: GET key N completed, successfully, with key N-1's + // value. The only acceptable outcomes are the right value or a connection exception. + [Fact] + public async Task CloseWithCommandsInFlight_NeverCompletesACallWithAnotherCallsReply() + { + const int Rounds = 20, PerRound = 500; + SetExpectedAmbientFailureCount(-1); // every round tears a connection down with work in flight + await using var admin = Create(); + var adminDb = admin.GetDatabase(); + var prefix = Me(); + long ok = 0, wrong = 0, failed = 0; + + for (int round = 0; round < Rounds; round++) + { + var keys = Enumerable.Range(0, PerRound).Select(i => (RedisKey)$"{prefix}:{round}:{i}").ToArray(); + await Task.WhenAll(keys.Select(k => adminDb.StringSetAsync(k, k.ToString(), TimeSpan.FromMinutes(1)))); + + var conn = Create(shared: false); + var db = conn.GetDatabase(); + var calls = keys.Select(k => db.StringGetAsync(k)).ToArray(); + await conn.CloseAsync(allowCommandsToComplete: false); + + for (int i = 0; i < calls.Length; i++) + { + string? value; + try + { + value = await calls[i]; + } + catch (RedisConnectionException) + { + failed++; + continue; + } + catch (ObjectDisposedException) + { + failed++; + continue; + } + + if (value == keys[i].ToString()) + { + ok++; + } + else if (wrong++ < 5) + { + Log($"round {round}: GET {keys[i]} returned '{value}'"); + } + } + + await conn.DisposeAsync(); + await adminDb.KeyDeleteAsync(keys); + } + + Log($"ok={ok} wrong={wrong} failed={failed}"); + Assert.Equal(0, wrong); + } +} From ebe60373520a09df8b18ff6dae89023c38b2c09d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Nov 2026 13:08:57 +0000 Subject: [PATCH 10/14] CLUSTER SLOT-STATS (8.2): per-slot usage statistics on IServer, experimental SER012 (#3341) * CLUSTER SLOT-STATS (8.2): per-slot usage statistics on IServer, experimental SER012 IServer.ClusterSlotStatsBySlotRange(Async) and ClusterSlotStatsOrderBy(Async), modelled on the CLUSTER SLOTS file layout and the HOTKEYS field handling: node-local read, lenient nominal parsing, config-gated metrics as nullable properties (absent != zero), unknown metrics preserved as Metadata, cpu-usec surfaced as TimeSpan. RedisFeatures.ClusterSlotStats gates on 8.2 RC1. Promotes the CommandCoverage 'deferred' row. * The storage-kind tripwire acknowledges SLOT-STATS and SLOTSRANGE: two new >8-byte literals, 28 -> 30 --- Directory.Build.props | 2 +- docs/ClusterSlotStats.md | 62 ++++++ docs/CommandCoverage.md | 1 - docs/exp/SER012.md | 25 +++ docs/index.md | 1 + notes/cluster-slot-stats/plan.md | 181 ++++++++++++++++++ src/RESPite/Shared/Experiments.cs | 1 + .../ClusterSlotStats.ResultProcessor.cs | 115 +++++++++++ .../ClusterSlotStats.Server.cs | 71 +++++++ src/StackExchange.Redis/ClusterSlotStats.cs | 163 ++++++++++++++++ .../ClusterSlotStatsField.cs | 55 ++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 22 +++ src/StackExchange.Redis/RedisFeatures.cs | 5 + src/StackExchange.Redis/RedisLiterals.cs | 3 + .../ClusterSlotStatsTests.cs | 163 ++++++++++++++++ .../RedisValueStorageKindUnitTests.cs | 2 +- .../ClusterSlotStatsRoundTrip.cs | 162 ++++++++++++++++ 17 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 docs/ClusterSlotStats.md create mode 100644 docs/exp/SER012.md create mode 100644 notes/cluster-slot-stats/plan.md create mode 100644 src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStats.Server.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStats.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStatsField.cs create mode 100644 tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs create mode 100644 tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs diff --git a/Directory.Build.props b/Directory.Build.props index 55c0088fe..3f88b7ba1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ NETSDK1069 - $(NoWarn);NU5105;NU1507;SER004;SER005;SER009 + $(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER012 https://github.com/StackExchange/StackExchange.Redis/releases https://seredis.dev/ MIT diff --git a/docs/ClusterSlotStats.md b/docs/ClusterSlotStats.md new file mode 100644 index 000000000..efff96110 --- /dev/null +++ b/docs/ClusterSlotStats.md @@ -0,0 +1,62 @@ +Cluster slot statistics +=== + +The `CLUSTER SLOT-STATS` command reports per-slot usage statistics - key counts, and optionally CPU, +memory and network metrics - so you can see *where* in the keyspace a cluster's load lands, and which +slots would be worth migrating. It is available in Redis 8.2 and later, on cluster-enabled servers +only (a standalone server answers with an error). + +This command is available via the `IServer.ClusterSlotStats*` methods, which are +[experimental (`SER012`)](exp/SER012) while the API shape settles: + +``` c# +// Get a server instance; each node reports only the slots its own shard serves, +// so ask each node to see the whole cluster. +IConnectionMultiplexer muxer = ... // connect to a Redis 8.2+ cluster +var server = muxer.GetServer(endpoint); + +// Form 1: the stats for a slot range (inclusive), in ascending slot order. +var range = await server.ClusterSlotStatsBySlotRangeAsync(0, 16383); + +// Form 2: the node's slots sorted by a metric, busiest first; here, the ten +// slots with the most keys. +var top = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 10); + +foreach (var slot in top.Slots) +{ + Console.WriteLine($"slot {slot.Slot}: {slot.KeyCount} keys, cpu: {slot.CpuTime}"); +} +``` + +Each `ClusterSlotStatsEntry` carries: + +- `Slot`: the hash slot the entry describes. +- `KeyCount`: the number of keys in the slot; always collected. +- `CpuTime`: CPU time spent serving the slot's keys (the server's `cpu-usec`, as a `TimeSpan?`). +- `MemoryBytes`: memory used by the slot's keys. +- `NetworkBytesIn` / `NetworkBytesOut`: network traffic for the slot's keys. +- `Metadata`: any metric the server reports that this library does not recognize; the metric set is + configuration-dependent and has grown before, so unknown metrics are preserved rather than discarded. + +Metric collection +--- + +Only `key-count` is always collected. The other metrics are gated by the `cluster-slot-stats-enabled` +server configuration, which takes the metric groups to enable (`cpu`, `net`, `mem`, or `yes` for all; +`mem` additionally requires memory tracking from server startup): + +``` +CONFIG SET cluster-slot-stats-enabled "cpu net" +``` + +A metric that is not being collected comes back as `null` on `ClusterSlotStatsEntry` - deliberately +distinct from a collected zero. Sorting (`ClusterSlotStatsOrderBy`) by a metric that is not being +collected is refused by the server; `ClusterSlotStatsMetric.KeyCount` always works. + +Relationship to `HOTKEYS` +--- + +The two are complementary: [`HOTKEYS`](HotKeys) profiles *which keys* are hot on a server, while +`CLUSTER SLOT-STATS` reports *which slots* carry the load - the granularity at which a cluster can +actually rebalance. A typical investigation finds the heavy slots with `CLUSTER SLOT-STATS ORDERBY`, +then profiles the keys within them with `HOTKEYS START ... SLOTS ...`. diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md index c055d71ff..f8ff94211 100644 --- a/docs/CommandCoverage.md +++ b/docs/CommandCoverage.md @@ -149,7 +149,6 @@ Operator tooling. All of it works through `Execute` on an `IServer`; none of it | `CLIENT NO-TOUCH` | 7.2.0 | `deferred` | Per-connection flag, as `NO-EVICT`. | | `CLUSTER SHARDS` | 7.0.0 | `deferred` | Not used anywhere today: `ClusterConfiguration` is built from `CLUSTER NODES`, and configuration export captures `NODES` and `SLOTS`. Moving topology discovery to `SHARDS`, or exposing it typed, is a design question. | | `CLUSTER LINKS` | 7.0.0 | `deferred` | Topology introspection. | -| `CLUSTER SLOT-STATS` | 8.2.0 | `deferred` | Per-slot statistics; pairs with hot-key tooling. | ## Todo: accepted gaps diff --git a/docs/exp/SER012.md b/docs/exp/SER012.md new file mode 100644 index 000000000..115a0eee8 --- /dev/null +++ b/docs/exp/SER012.md @@ -0,0 +1,25 @@ +The `IServer.ClusterSlotStats*` methods expose `CLUSTER SLOT-STATS` (Redis 8.2 and later); see +[Cluster slot statistics](../ClusterSlotStats). + +The server command is stable, but this library binding is new and marked experimental while the API +shape settles: + +- The metric set is configuration-dependent (`cluster-slot-stats-enabled`) and documented by the + server as extensible; the typed surface (`ClusterSlotStatsMetric`, the properties on + `ClusterSlotStatsEntry`) may grow or change as metrics are added. +- How the per-node results are best aggregated across a cluster (today: ask each node yourself) is an + open design question that may reshape the entry points. + +Until the marker is removed, the API may change between any two versions without breaking-change +ceremony. If you acknowledge this, you can suppress the warning by adding the following to your +`csproj` file: + +```xml +$(NoWarn);SER012 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER012 +``` diff --git a/docs/index.md b/docs/index.md index a65e482b9..64cf22ac5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,6 +51,7 @@ Documentation - [Vector Sets](VectorSets) - how to use Vector Sets for similarity search with embeddings - [Hash Import](HImport) - bulk-importing many hashes that share a common set of field names - [Hash Tags and Slots](HashTags) - co-locating keys in the same cluster slot for multi-key operations +- [Cluster slot statistics](ClusterSlotStats) - per-slot usage statistics via `CLUSTER SLOT-STATS` - [Where are `KEYS` / `SCAN` / `FLUSH*`?](KeysScan) - how to use server-based commands - [Profiling](Profiling) - profiling interfaces, as well as how to profile in an `async` world - [Scripting](Scripting) - running Lua scripts, including the low-allocation `ScriptEvalLease` API diff --git a/notes/cluster-slot-stats/plan.md b/notes/cluster-slot-stats/plan.md new file mode 100644 index 000000000..a9c65ab3f --- /dev/null +++ b/notes/cluster-slot-stats/plan.md @@ -0,0 +1,181 @@ +--- +feature: cluster-slot-stats +decision_class: C +estimated_size: small-medium +tracks: redis/redis CLUSTER SLOT-STATS (src/commands/cluster-slot-stats.json, since 8.2.0; implementation src/cluster_slot_stats.c) +target_version: "8.2" +unit_targets: + - RoundTripUnitTests.ClusterSlotStatsRoundTrip + - ClusterSlotStatsTests (live, cluster topology) +open_questions: 4 +--- + +# CLUSTER SLOT-STATS: implementation plan + +No shared HLD exists for this command (it predates the parity pipeline); the facts below are read +directly from the server: `src/commands/cluster-slot-stats.json` and `src/cluster_slot_stats.c` +(`clusterSlotStatsCommand`, `addReplySlotStat`), verified against a live 8.10 cluster +(`redislabs/client-libs-test`, the repo test topology). `docs/CommandCoverage.md` lists the command +as `deferred` ("pairs with hot-key tooling"); the hot-key tooling (`HOTKEYS`, `IServer.HotKeys*`) +has since shipped, so this promotes the row. + +## Server facts (all observed) + +- `CLUSTER SLOT-STATS `, arity -4, + flags STALE LOADING. Strictly a oneof: exactly one filter form per call. +- **Cluster-only**: a non-cluster server answers `ERR This instance has cluster support disabled` + (observed on the topology's standalone node). +- Only slots assigned to the answering node's **shard** are reported; `SLOTSRANGE` returns them in + ascending slot order (observed: a range owned by another shard returns an empty array), `ORDERBY` + sorted by the metric (ties: ascending slot), default `DESC`, `LIMIT` default = all (1..16384; + out of range → error). +- Reply: array of `[slot, map]` pairs; the map always carries `key-count`, plus `memory-bytes`, + `cpu-usec`, `network-bytes-in`/`network-bytes-out` according to the `cluster-slot-stats-enabled` + config (multi-arg enum: `no`/`yes`/`cpu`/`net`/`mem`). Observed: `CONFIG SET + cluster-slot-stats-enabled "cpu net"` works at runtime; `yes` (which includes `mem`) is refused + at runtime when memory tracking wasn't enabled at boot. A map under RESP3, flat pairs under + RESP2 (`addReplyMapLen`). +- `ORDERBY` by a metric whose collection is disabled → `ERR Unrecognized sort metric for ORDERBY.` +- Metric wire names: `key-count`, `cpu-usec`, `memory-bytes`, `network-bytes-in`, + `network-bytes-out`. + +## Traced siblings + +- **`CLUSTER SLOTS`** (`ClusterSlots.cs`, `ClusterSlots.ResultProcessor.cs`, + `RedisServer.GetClusterSlotsMessage`, `ClusterSlotsTests.cs`) — the verified model for a parsed + `CLUSTER` subcommand on `IServer`: partial-interface file named for the feature, message built + with `Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.CLUSTER, ...)`, + lenient aggregate parsing (drop a malformed entry, not the reply), `null` result for a nil reply. + No `RedisCommand` enum change (CLUSTER exists), no keyspace-isolation changes (server-scoped). +- **`HOTKEYS`** (`HotKeys.cs`, `HotKeysField.cs`, `HotKeys.ResultProcessor.cs`, + `HotKeys.Server.cs`) — the model for the *reply-field* handling: an internal `[AsciiHash]` enum + (`HotKeysField`) with a generated `...Metadata.TryParse`, nominal not positional parsing, unknown + fields skipped/preserved, `cpu`-style counters exposed as `TimeSpan` via microseconds, and the + "profiling data on `IServer`" API shape this command pairs with operationally. + +## Requirements + +| # | Requirement | Where covered | +|---|---|---| +| R.1 | `SLOTSRANGE start end` form | `ClusterSlotStatsBySlotRange(Async)` | +| R.2 | `ORDERBY metric [LIMIT n] [ASC\|DESC]` form | `ClusterSlotStatsOrderBy(Async)` | +| R.3 | Reply: array of [slot, metric-map], RESP2 flat pairs / RESP3 map | `ClusterSlotStatsResult.Processor` (category tests, never `Prefix` comparisons) | +| R.4 | `key-count` always; other metrics config-dependent | nullable properties on `ClusterSlotStatsEntry`; absent → `null` | +| R.5 | Metrics the library doesn't know (future servers) | preserved in `ClusterSlotStatsEntry.Metadata`, as `ClusterSlotNode.Metadata` does | +| R.6 | Cluster-only; shard-local answers | XML docs; live test asserts the standalone error and per-node ownership | +| R.7 | Node-local read, safe to replay against that node | `flags.WithCategory(NodeLocalRead)`, as `GetClusterSlotsMessage` | +| R.8 | Version gating | `RedisFeatures.ClusterSlotStats` => `v8_2_0_rc1` (8.2 RC1 = 8.1.240, the existing constant) | +| NF.1 | New public surface is experimental | `[Experimental(Experiments.ClusterSlotStats)]`, new id `SER012` (SER011 is taken by the open set-member-expiry PR #3340) | + +## Public API (all `[Experimental("SER012")]`, on `IServer`) + +```csharp +ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None); +Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None); +ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None); +Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None); + +public enum ClusterSlotStatsMetric { KeyCount, CpuTime, MemoryBytes, NetworkBytesIn, NetworkBytesOut } + +public sealed class ClusterSlotStatsResult +{ + public IReadOnlyList Slots { get; } +} + +public sealed class ClusterSlotStatsEntry +{ + public int Slot { get; } + public long KeyCount { get; } + public TimeSpan? CpuTime { get; } // cpu-usec; null when not collected + public long? MemoryBytes { get; } // null when not collected + public long? NetworkBytesIn { get; } // null when not collected + public long? NetworkBytesOut { get; } // null when not collected + public IReadOnlyList> Metadata { get; } // unrecognized metrics, preserved +} +``` + +Grounding: two distinct method names (not overloads) because the grammar is a strict oneof and the +optional-parameter shapes would otherwise trip RS0026; `Order` is the existing enum (`SORT`, +sorted-set ranges); `limit = 0` means "don't send `LIMIT`" exactly as `HotKeysStart(count = 0)` +means "server default"; `CpuTime` as `TimeSpan` from microseconds mirrors +`HotKeysResult.TotalCpuTime`; the result wrapper + entry list mirrors +`ClusterSlotsResult.Assignments`; `Metadata` preservation mirrors `ClusterSlotNode.Metadata`. +Client-side validation: slots in `[SlotRange.MinSlot, SlotRange.MaxSlot]`, `startSlot <= endSlot`, +`limit >= 0` (`ArgumentOutOfRangeException`), mirroring the server's own refusals. + +On the wire, `ASC` is sent only when `Order.Ascending` is requested and `LIMIT` only when +`limit > 0`: the server defaults are descending/unlimited, and the repo convention (`Expiration`, +`GetHashFieldExpireMessage`) is to send the minimal form. + +## Files + +| File | Change | +|---|---| +| `src/StackExchange.Redis/RedisLiterals.cs` | add `ORDERBY`, `SLOT_STATS` ("SLOT-STATS"), `SLOTSRANGE` (ASC/DESC/LIMIT exist) | +| `src/StackExchange.Redis/ClusterSlotStats.cs` | add: `IServer` members, `ClusterSlotStatsMetric`, `ClusterSlotStatsResult`, `ClusterSlotStatsEntry` | +| `src/StackExchange.Redis/ClusterSlotStatsField.cs` | add: internal `[AsciiHash]` field enum (as `HotKeysField`) | +| `src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs` | add: processor (as `ClusterSlots.ResultProcessor.cs`) | +| `src/StackExchange.Redis/ClusterSlotStats.Server.cs` | add: `RedisServer` implementations + internal static message builders (as `HotKeys.Server.cs` / `GetClusterSlotsMessage`) | +| `src/StackExchange.Redis/RedisFeatures.cs` | add `ClusterSlotStats` => `v8_2_0_rc1` | +| `src/RESPite/Shared/Experiments.cs` | add `ClusterSlotStats = "SER012"` | +| `Directory.Build.props` (root) | NoWarn += `SER012` | +| `docs/exp/SER012.md` | add (experiment page) | +| `docs/ClusterSlotStats.md` + `docs/index.md` | add usage page + index row | +| `src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt` | `[SER012]`-prefixed entries + bare `RedisFeatures.ClusterSlotStats.get`; sorted by `eng/public-api.py` (RS0016 does not fire in this environment, so the entries are hand-written against the shipped `[SER009]`/branch `[SER011]` precedents) | +| `docs/CommandCoverage.md` | remove the `CLUSTER SLOT-STATS` `deferred` row | +| `tests/.../RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs` | add: exact-bytes + parse tests, RESP2/RESP3 | +| `tests/.../ClusterSlotStatsTests.cs` | add: live cluster tests | + +Not applicable: `Enums/RedisCommand.cs` (CLUSTER exists), keyspace isolation (server-scoped, as +HOTKEYS), transaction analyzer (read-only reporting command; no pre-8.2 MULTI composition exists). + +## Ordered steps + +1. Literals; field enum; API types file; processor; server file. Build → collect RS0016 strings + into `PublicAPI.Unshipped.txt`. +2. Features flag, experiment id, NoWarn, docs pages, coverage row. +3. Round-trip tests (message bytes both forms incl. LIMIT/ASC minimality; replies: full map, + key-count-only map, unknown-metric preservation, empty array, nil → null) under RESP2 and RESP3. +4. Live tests against the repo cluster topology (8.9.241 reports as 8.10; ≥ 8.2 requirement met): + ownership (a routed write's slot is reported by exactly the owning node with `key-count ≥ 1`), + ORDERBY limit/order, config-gated metrics appearing after `CONFIG SET cluster-slot-stats-enabled + "cpu net"` (restored in `finally`), disabled-metric ORDERBY error, standalone-server error. +5. Full build `/p:CI=true`; run the new tests; update this plan's test section with results. + +## Older servers + +Pre-8.2 answers `ERR Unknown subcommand`; the feature flag lets callers probe. No behavioral +change for any existing API. + +## Open questions + +1. **SER012 vs joining an existing experiment** — SER011 is "Server 8.12" on the open #3340 and + this is an 8.2 command; a feature-specific id (as SER007/SER010 were) avoids coupling the two + PRs. Default: `SER012 = ClusterSlotStats`. +2. **`limit = 0` sentinel vs `long? limit = null`** — `HotKeysStart(count = 0)` is the precedent + next door, so 0-means-default wins; `null` would be the greenfield choice. +3. **`CpuTime` naming** — the wire says `cpu-usec`; `HotKeysResult` exposes CPU microseconds as + `TimeSpan` properties named `*CpuTime*`, so the entry property and the metric enum member follow + it rather than the wire name. +4. **8.12 cluster-topology note (out of scope here)** — 8.12-rc2 introduces + `cluster-bus-port-protected-mode` (default on), which refuses to start the repo's cluster nodes; + the fix (`cluster-bus-port-protected-mode no` in `tests/RedisConfigs/Cluster/*.conf`) can only + land together with an image bump, because servers ≤ 8.10 abort on the unknown directive. This + PR stays on the main topology image; the open 8.12 PRs that bump the image need the conf change. + +## Test plan + +- Round-trip: exact request bytes for `SLOTSRANGE 100 200`, `ORDERBY key-count`, `ORDERBY cpu-usec + LIMIT 5`, `ORDERBY network-bytes-out LIMIT 3 ASC`; replies parsed identically from RESP2 flat + pairs and RESP3 maps; unknown metric lands in `Metadata`; nil → null result; empty array → empty + `Slots`. +- Live (cluster topology, `TestConfig.Current.ClusterServersAndPorts`): see step 4. +- Results (2026-11-07, local): round-trip 10/10 pass (`ClusterSlotStatsRoundTrip`, includes the + argument-validation facts). Live 12/12 pass, zero skips (`ClusterSlotStatsTests`, 6 tests × + RESP2/RESP3 against the repo cluster topology, server 8.9.241): the routed write was counted in + its slot, whole-range answers stayed shard-local and ascending, ORDERBY honoured LIMIT and both + orders, `CONFIG SET cluster-slot-stats-enabled "cpu net"` surfaced `CpuTime`/`NetworkBytes*` + (with `MemoryBytes` still null, and the config restored), ORDERBY on the disabled `mem` metric was + refused with the ORDERBY error, and the standalone node refused with "cluster support disabled". + Full solution build clean (0 warnings, 0 errors) across net461/net472/netstandard2.0/net8.0/net10.0; + `eng/public-api.py --check` passes. diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index 93fd067f8..c8aa8f202 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -23,6 +23,7 @@ internal static class Experiments public const string Respite = "SER004"; public const string UnitTesting = "SER005"; public const string Transport = "SER009"; + public const string ClusterSlotStats = "SER012"; // SER011 is Server_8_12, on the open set-member-expiry PR // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs b/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs new file mode 100644 index 000000000..bbc7ede15 --- /dev/null +++ b/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis; + +public sealed partial class ClusterSlotStatsResult +{ + internal static readonly ResultProcessor Processor = new ClusterSlotStatsResultProcessor(); + + private sealed class ClusterSlotStatsResultProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) + { + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + if (!reader.IsAggregate) return false; + + // the reply is an array of [slot, metric-map]; parse leniently, dropping anything + // malformed rather than failing the whole reply + List? entries = null; + var slots = reader.AggregateChildren(); + while (slots.MoveNext()) + { + if (TryParseEntry(ref slots.Value, out var entry)) + { + (entries ??= new List()).Add(entry); + } + } + + SetResult(message, new ClusterSlotStatsResult(AsReadOnly(entries))); + return true; + } + } + + private static bool TryParseEntry(ref RespReader reader, out ClusterSlotStatsEntry entry) + { + entry = null!; + if (!reader.IsAggregate || reader.IsNull) return false; + + var children = reader.AggregateChildren(); + if (!children.MoveNext() || !children.Value.TryReadInt64(out var slot)) return false; + if (slot < SlotRange.MinSlot || slot > SlotRange.MaxSlot) return false; + if (!children.MoveNext() || !children.Value.IsAggregate || children.Value.IsNull) return false; + + long keyCount = 0, cpuMicroseconds = -1, memoryBytes = -1, networkBytesIn = -1, networkBytesOut = -1; + List>? metadata = null; + + // the metric map: flat pairs under RESP2, a map under RESP3; read nominally, never + // positionally - the set is configuration-dependent and documented as extensible + var fields = children.Value.AggregateChildren(); + while (fields.MoveNext() && fields.Value.IsScalar) + { + ClusterSlotStatsField field; + string? unknownName = null; + unsafe + { + if (!fields.Value.TryParseScalar(&ClusterSlotStatsFieldMetadata.TryParse, out field)) + { + field = ClusterSlotStatsField.Unknown; + } + } + if (field == ClusterSlotStatsField.Unknown) unknownName = fields.Value.ReadString(); + if (!fields.MoveNext()) break; // metric name with no value: out of data + if (!fields.Value.TryReadInt64(out var value)) continue; // a non-numeric metric: skipped + + switch (field) + { + case ClusterSlotStatsField.KeyCount: + keyCount = value; + break; + case ClusterSlotStatsField.CpuMicroseconds: + cpuMicroseconds = value; + break; + case ClusterSlotStatsField.MemoryBytes: + memoryBytes = value; + break; + case ClusterSlotStatsField.NetworkBytesIn: + networkBytesIn = value; + break; + case ClusterSlotStatsField.NetworkBytesOut: + networkBytesOut = value; + break; + default: + // a metric added by a future server: preserved, not discarded + if (unknownName is not null) + { + (metadata ??= new List>()).Add(new(unknownName, value)); + } + break; + } + } + + entry = new ClusterSlotStatsEntry( + (int)slot, + keyCount, + cpuMicroseconds, + memoryBytes, + networkBytesIn, + networkBytesOut, + AsReadOnly(metadata)); + return true; + } + + // empty collection expressions to an interface target compile to Array.Empty(), so the common case + // costs nothing; only a genuinely populated list pays for the wrapper + private static IReadOnlyList AsReadOnly(List? values) + { + if (values is null) return []; + return values.AsReadOnly(); + } +} diff --git a/src/StackExchange.Redis/ClusterSlotStats.Server.cs b/src/StackExchange.Redis/ClusterSlotStats.Server.cs new file mode 100644 index 000000000..95af15c17 --- /dev/null +++ b/src/StackExchange.Redis/ClusterSlotStats.Server.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal partial class RedisServer +{ + public ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None) + => ExecuteSync(GetClusterSlotStatsBySlotRangeMessage(startSlot, endSlot, flags), ClusterSlotStatsResult.Processor, server); + + public Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None) + => ExecuteAsync(GetClusterSlotStatsBySlotRangeMessage(startSlot, endSlot, flags), ClusterSlotStatsResult.Processor, server); + + public ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None) + => ExecuteSync(GetClusterSlotStatsOrderByMessage(metric, limit, order, flags), ClusterSlotStatsResult.Processor, server); + + public Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None) + => ExecuteAsync(GetClusterSlotStatsOrderByMessage(metric, limit, order, flags), ClusterSlotStatsResult.Processor, server); + + /// + /// Builds CLUSTER SLOT-STATS SLOTSRANGE start end. A node-local read, as + /// : it reports what the answering node tracks for itself, so it is + /// safe to replay against that node. + /// + internal static Message GetClusterSlotStatsBySlotRangeMessage(int startSlot, int endSlot, CommandFlags flags) + { + if (startSlot < SlotRange.MinSlot || startSlot > SlotRange.MaxSlot) throw new ArgumentOutOfRangeException(nameof(startSlot)); + if (endSlot < startSlot || endSlot > SlotRange.MaxSlot) throw new ArgumentOutOfRangeException(nameof(endSlot)); + return Message.Create( + -1, + flags.WithCategory(NodeLocalRead), + RedisCommand.CLUSTER, + new RedisValue[] { RedisLiterals.SLOT_STATS, RedisLiterals.SLOTSRANGE, startSlot, endSlot }); + } + + /// + /// Builds CLUSTER SLOT-STATS ORDERBY metric [LIMIT n] [ASC]; the server defaults (all slots, + /// descending) are expressed by omission. + /// + internal static Message GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric metric, long limit, Order order, CommandFlags flags) + { + if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); + var metricValue = metric switch + { + ClusterSlotStatsMetric.KeyCount => KeyCountMetric, + ClusterSlotStatsMetric.CpuTime => CpuMetric, + ClusterSlotStatsMetric.MemoryBytes => MemoryMetric, + ClusterSlotStatsMetric.NetworkBytesIn => NetworkInMetric, + ClusterSlotStatsMetric.NetworkBytesOut => NetworkOutMetric, + _ => throw new ArgumentOutOfRangeException(nameof(metric)), + }; + flags = flags.WithCategory(NodeLocalRead); + RedisValue[] values = (limit > 0, order) switch + { + (false, Order.Descending) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue], + (false, _) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.ASC], + (true, Order.Descending) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.LIMIT, limit], + (true, _) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.LIMIT, limit, RedisLiterals.ASC], + }; + return Message.Create(-1, flags, RedisCommand.CLUSTER, values); + } + + // the ORDERBY metric tokens, as the server spells them (lower-case, hyphenated - unlike the + // upper-case option literals in RedisLiterals) + private static readonly RedisValue + KeyCountMetric = RedisValue.FromRaw("key-count"u8), + CpuMetric = RedisValue.FromRaw("cpu-usec"u8), + MemoryMetric = RedisValue.FromRaw("memory-bytes"u8), + NetworkInMetric = RedisValue.FromRaw("network-bytes-in"u8), + NetworkOutMetric = RedisValue.FromRaw("network-bytes-out"u8); +} diff --git a/src/StackExchange.Redis/ClusterSlotStats.cs b/src/StackExchange.Redis/ClusterSlotStats.cs new file mode 100644 index 000000000..d7b3136ca --- /dev/null +++ b/src/StackExchange.Redis/ClusterSlotStats.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis; + +public partial interface IServer +{ + /// + /// Obtains per-slot usage statistics from CLUSTER SLOT-STATS SLOTSRANGE, for the slots in an + /// inclusive range that are assigned to the answering node's shard, in ascending slot order. + /// + /// The first slot of the range (inclusive). + /// The last slot of the range (inclusive); must not be less than . + /// The command flags to use. + /// + /// Cluster-only: a non-cluster server answers with an error. Each node reports only the slots its own + /// shard serves, so slots in the range owned elsewhere are simply absent - ask each node to see the whole + /// cluster. key-count is always collected; the other metrics appear only when enabled via the + /// cluster-slot-stats-enabled server configuration - see . + /// + [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] + ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] + Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None); + + /// + /// Obtains per-slot usage statistics from CLUSTER SLOT-STATS ORDERBY: the answering node's shard's + /// slots, sorted by a metric (ties broken by ascending slot number). + /// + /// The metric to sort by. Sorting by a metric whose collection is not enabled on the + /// server (via cluster-slot-stats-enabled) is refused by the server; + /// is always available. + /// The maximum number of slots to report; zero (the default) reports them all. + /// The sort direction; the server (and this) defaults to , + /// which puts the busiest slots first. + /// The command flags to use. + /// + [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] + ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] + Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None); +} + +/// +/// A metric tracked per slot by CLUSTER SLOT-STATS. +/// +[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] +public enum ClusterSlotStatsMetric +{ + /// The number of keys in the slot (key-count); always collected. + KeyCount, + + /// CPU time spent on the slot's keys (cpu-usec); collected when the cpu metric is enabled. + CpuTime, + + /// Memory used by the slot's keys (memory-bytes); collected when the mem metric is + /// enabled, which additionally requires memory tracking from startup. + MemoryBytes, + + /// Network bytes received for the slot's keys (network-bytes-in); collected when the net metric is enabled. + NetworkBytesIn, + + /// Network bytes sent for the slot's keys (network-bytes-out); collected when the net metric is enabled. + NetworkBytesOut, +} + +/// +/// The per-slot usage statistics reported by CLUSTER SLOT-STATS, as described by the node that answered. +/// +[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] +public sealed partial class ClusterSlotStatsResult +{ + internal ClusterSlotStatsResult(IReadOnlyList slots) => Slots = slots; + + /// + /// Gets the per-slot entries, in the order the server reported them: ascending slot number for the + /// SLOTSRANGE form, metric order for the ORDERBY form. + /// + public IReadOnlyList Slots { get; } +} + +/// +/// One slot's usage statistics from CLUSTER SLOT-STATS. +/// +/// +/// Only is always collected; each other metric is null when the server is not +/// collecting it (the cluster-slot-stats-enabled configuration), which is deliberately distinct from a +/// collected zero. +/// +[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)] +public sealed class ClusterSlotStatsEntry +{ + internal ClusterSlotStatsEntry( + int slot, + long keyCount, + long cpuMicroseconds, + long memoryBytes, + long networkBytesIn, + long networkBytesOut, + IReadOnlyList> metadata) + { + Slot = slot; + KeyCount = keyCount; + CpuMicroseconds = cpuMicroseconds; + MemoryBytes = memoryBytes < 0 ? null : memoryBytes; + NetworkBytesIn = networkBytesIn < 0 ? null : networkBytesIn; + NetworkBytesOut = networkBytesOut < 0 ? null : networkBytesOut; + Metadata = metadata; + } + + /// + /// Gets the hash slot this entry describes. + /// + public int Slot { get; } + + /// + /// Gets the number of keys in the slot. + /// + public long KeyCount { get; } + + internal long CpuMicroseconds { get; } = -1; + + /// + /// Gets the CPU time spent serving the slot's keys, or null when the cpu metric is not + /// being collected. + /// + public TimeSpan? CpuTime => CpuMicroseconds < 0 ? null : HotKeysResult.NonNegativeMicroseconds(CpuMicroseconds); + + /// + /// Gets the memory used by the slot's keys, or null when the mem metric is not being + /// collected. + /// + public long? MemoryBytes { get; } + + /// + /// Gets the network bytes received for the slot's keys, or null when the net metric is not + /// being collected. + /// + public long? NetworkBytesIn { get; } + + /// + /// Gets the network bytes sent for the slot's keys, or null when the net metric is not + /// being collected. + /// + public long? NetworkBytesOut { get; } + + /// + /// Gets the metric entries reported for this slot that this library does not recognize. The metric set is + /// configuration-dependent and has grown before, so these are preserved rather than discarded; the + /// recognized metrics are surfaced as the typed properties instead of appearing here. + /// + public IReadOnlyList> Metadata { get; } + + /// + public override string ToString() => $"slot {Slot}: {KeyCount} keys"; +} diff --git a/src/StackExchange.Redis/ClusterSlotStatsField.cs b/src/StackExchange.Redis/ClusterSlotStatsField.cs new file mode 100644 index 000000000..ee9cc1bd0 --- /dev/null +++ b/src/StackExchange.Redis/ClusterSlotStatsField.cs @@ -0,0 +1,55 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Metrics that can appear in a CLUSTER SLOT-STATS per-slot map. +/// +internal enum ClusterSlotStatsField +{ + /// + /// Unknown or unrecognized metric. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// The number of keys in the slot. + /// + [AsciiHash("key-count")] + KeyCount, + + /// + /// CPU time spent on the slot's keys, in microseconds. + /// + [AsciiHash("cpu-usec")] + CpuMicroseconds, + + /// + /// Memory used by the slot's keys, in bytes. + /// + [AsciiHash("memory-bytes")] + MemoryBytes, + + /// + /// Network bytes received for the slot's keys. + /// + [AsciiHash("network-bytes-in")] + NetworkBytesIn, + + /// + /// Network bytes sent for the slot's keys. + /// + [AsciiHash("network-bytes-out")] + NetworkBytesOut, +} + +/// +/// Metadata and parsing methods for ClusterSlotStatsField. +/// +internal static partial class ClusterSlotStatsFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out ClusterSlotStatsField field); +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..d901d5a48 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,23 @@ #nullable enable +[SER012]StackExchange.Redis.ClusterSlotStatsEntry +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.CpuTime.get -> System.TimeSpan? +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.KeyCount.get -> long +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.MemoryBytes.get -> long? +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.Metadata.get -> System.Collections.Generic.IReadOnlyList>! +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.NetworkBytesIn.get -> long? +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.NetworkBytesOut.get -> long? +[SER012]StackExchange.Redis.ClusterSlotStatsEntry.Slot.get -> int +[SER012]override StackExchange.Redis.ClusterSlotStatsEntry.ToString() -> string! +[SER012]StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsMetric.CpuTime = 1 -> StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsMetric.KeyCount = 0 -> StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsMetric.MemoryBytes = 2 -> StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsMetric.NetworkBytesIn = 3 -> StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsMetric.NetworkBytesOut = 4 -> StackExchange.Redis.ClusterSlotStatsMetric +[SER012]StackExchange.Redis.ClusterSlotStatsResult +[SER012]StackExchange.Redis.ClusterSlotStatsResult.Slots.get -> System.Collections.Generic.IReadOnlyList! +[SER012]StackExchange.Redis.IServer.ClusterSlotStatsBySlotRange(int startSlot, int endSlot, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ClusterSlotStatsResult? +[SER012]StackExchange.Redis.IServer.ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER012]StackExchange.Redis.IServer.ClusterSlotStatsOrderBy(StackExchange.Redis.ClusterSlotStatsMetric metric, long limit = 0, StackExchange.Redis.Order order = StackExchange.Redis.Order.Descending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ClusterSlotStatsResult? +[SER012]StackExchange.Redis.IServer.ClusterSlotStatsOrderByAsync(StackExchange.Redis.ClusterSlotStatsMetric metric, long limit = 0, StackExchange.Redis.Order order = StackExchange.Redis.Order.Descending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.RedisFeatures.ClusterSlotStats.get -> bool diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 8f876a438..ac9814166 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -322,6 +322,11 @@ public RedisFeatures(Version version) /// public bool HashImport => Version.IsAtLeast(v8_10_0); + /// + /// Are the CLUSTER SLOT-STATS per-slot usage statistics available? + /// + public bool ClusterSlotStats => Version.IsAtLeast(v8_2_0_rc1); + #pragma warning restore 1629 // Documentation text should end with a period. /// diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 58d4632c9..8ae916464 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -100,6 +100,7 @@ public static readonly RedisValue OBO = RedisValue.FromRaw("OBO"u8), ONE = RedisValue.FromRaw("ONE"u8), OR = RedisValue.FromRaw("OR"u8), + ORDERBY = RedisValue.FromRaw("ORDERBY"u8), PATTERN = RedisValue.FromRaw("PATTERN"u8), PAUSE = RedisValue.FromRaw("PAUSE"u8), PERSIST = RedisValue.FromRaw("PERSIST"u8), @@ -124,6 +125,8 @@ public static readonly RedisValue SETINFO = RedisValue.FromRaw("SETINFO"u8), SETNAME = RedisValue.FromRaw("SETNAME"u8), SKIPME = RedisValue.FromRaw("SKIPME"u8), + SLOT_STATS = RedisValue.FromRaw("SLOT-STATS"u8), + SLOTSRANGE = RedisValue.FromRaw("SLOTSRANGE"u8), STATS = RedisValue.FromRaw("STATS"u8), STOP = RedisValue.FromRaw("STOP"u8), STORE = RedisValue.FromRaw("STORE"u8), diff --git a/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs b/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs new file mode 100644 index 000000000..c37bf0827 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs @@ -0,0 +1,163 @@ +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// CLUSTER SLOT-STATS against a real cluster; the exact wire bytes and reply-shape matrix live in +/// . Runs per protocol, since the per-slot metrics +/// are a map under RESP3 and flat pairs under RESP2. +/// +[RunPerProtocol] +public class ClusterSlotStatsTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; + + private static void RequireSlotStats(IServer server) + => Assert.SkipUnless(server.Features.ClusterSlotStats, $"CLUSTER SLOT-STATS needs 8.2, server is {server.Version}"); + + [Fact] + public async Task RoutedWriteIsCountedInItsSlot() + { + await using var conn = Create(allowAdmin: true); + RedisKey key = Me(); + var server = conn.GetServer(key); // the primary that owns the key + RequireSlotStats(server); + + await conn.GetDatabase().StringSetAsync(key, "x"); + var slot = conn.HashSlot(key); + + var result = await server.ClusterSlotStatsBySlotRangeAsync(slot, slot); + Assert.NotNull(result); + var entry = Assert.Single(result.Slots); + Assert.Equal(slot, entry.Slot); + Log($"slot {slot}: {entry.KeyCount} keys"); + Assert.True(entry.KeyCount >= 1, "the slot we just wrote to should hold at least our key"); + } + + [Fact] + public async Task WholeRangeReportsOnlyTheNodesOwnSlots() + { + await using var conn = Create(allowAdmin: true); + var server = conn.GetServer(conn.GetEndPoints()[0]); + RequireSlotStats(server); + + var result = await server.ClusterSlotStatsBySlotRangeAsync(SlotRange.MinSlot, SlotRange.MaxSlot); + Assert.NotNull(result); + Assert.NotEmpty(result.Slots); + Log($"{result.Slots.Count} slots reported by {server.EndPoint}"); + + // ascending slot order, each slot at most once, all in range + for (int i = 1; i < result.Slots.Count; i++) + { + Assert.True(result.Slots[i - 1].Slot < result.Slots[i].Slot, "SLOTSRANGE results should be in ascending slot order"); + } + Assert.All(result.Slots, s => Assert.InRange(s.Slot, SlotRange.MinSlot, SlotRange.MaxSlot)); + } + + [Fact] + public async Task OrderByHonoursLimitAndOrder() + { + await using var conn = Create(allowAdmin: true); + var server = conn.GetServer(conn.GetEndPoints()[0]); + RequireSlotStats(server); + + // make sure at least two slots on this node have different key counts + var db = conn.GetDatabase(); + for (int i = 0; i < 10; i++) + { + await db.StringSetAsync($"{Me()}:{i}", i); + } + + var top = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 5); + Assert.NotNull(top); + Assert.InRange(top.Slots.Count, 1, 5); + for (int i = 1; i < top.Slots.Count; i++) + { + Assert.True(top.Slots[i - 1].KeyCount >= top.Slots[i].KeyCount, "descending is the default"); + } + + var bottom = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 5, order: Order.Ascending); + Assert.NotNull(bottom); + Assert.InRange(bottom.Slots.Count, 1, 5); + for (int i = 1; i < bottom.Slots.Count; i++) + { + Assert.True(bottom.Slots[i - 1].KeyCount <= bottom.Slots[i].KeyCount, "ascending when asked"); + } + + Log($"top: {string.Join(", ", top.Slots.Select(s => s.ToString()))}"); + Log($"bottom: {string.Join(", ", bottom.Slots.Select(s => s.ToString()))}"); + } + + [Fact] + public async Task EnablingMetricsSurfacesThem() + { + await using var conn = Create(allowAdmin: true, shared: false); + RedisKey key = Me(); + var server = conn.GetServer(key); + RequireSlotStats(server); + + var original = (await server.ConfigGetAsync("cluster-slot-stats-enabled")).Single().Value; + Log($"cluster-slot-stats-enabled was: '{original}'"); + try + { + // cpu and net can be enabled at runtime; mem needs memory tracking from server startup + await server.ConfigSetAsync("cluster-slot-stats-enabled", "cpu net"); + + // generate some traffic on a slot this node owns, after enabling + var db = conn.GetDatabase(); + var slot = conn.HashSlot(key); + for (int i = 0; i < 20; i++) + { + await db.StringSetAsync(key, i); + await db.StringGetAsync(key); + } + + var result = await server.ClusterSlotStatsBySlotRangeAsync(slot, slot); + Assert.NotNull(result); + var entry = Assert.Single(result.Slots); + Log(entry.ToString()); + Log($"cpu: {entry.CpuTime}, in: {entry.NetworkBytesIn}, out: {entry.NetworkBytesOut}, mem: {entry.MemoryBytes}"); + + Assert.NotNull(entry.CpuTime); + Assert.NotNull(entry.NetworkBytesIn); + Assert.NotNull(entry.NetworkBytesOut); + Assert.True(entry.NetworkBytesIn > 0, "we sent this slot twenty SETs; bytes-in should be non-zero"); + Assert.Null(entry.MemoryBytes); // not enabled - null, not zero + } + finally + { + await server.ConfigSetAsync("cluster-slot-stats-enabled", original); + } + } + + [Fact] + public async Task OrderByDisabledMetricIsRefused() + { + await using var conn = Create(allowAdmin: true); + var server = conn.GetServer(conn.GetEndPoints()[0]); + RequireSlotStats(server); + + var enabled = (await server.ConfigGetAsync("cluster-slot-stats-enabled")).Single().Value.ToString(); + Assert.SkipWhen(enabled.Contains("mem") || enabled.Contains("yes"), $"memory collection is enabled ('{enabled}'), so the server would not refuse it"); + + var ex = await Assert.ThrowsAsync( + () => server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.MemoryBytes)); + Log(ex.Message); + Assert.Contains("ORDERBY", ex.Message); + } + + [Fact] + public async Task StandaloneServerRefuses() + { + await using var conn = Create(configuration: TestConfig.Current.PrimaryServerAndPort, allowAdmin: true, shared: false); + var server = conn.GetServer(conn.GetEndPoints()[0]); + RequireSlotStats(server); + + var ex = await Assert.ThrowsAsync( + () => server.ClusterSlotStatsBySlotRangeAsync(0, 100)); + Log(ex.Message); + Assert.Contains("cluster support disabled", ex.Message); + } +} diff --git a/tests/StackExchange.Redis.Tests/RedisValueStorageKindUnitTests.cs b/tests/StackExchange.Redis.Tests/RedisValueStorageKindUnitTests.cs index f23efe5fe..3a0b9973d 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueStorageKindUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueStorageKindUnitTests.cs @@ -107,6 +107,6 @@ public void LiteralsResolveToExpectedStorageKinds() // only two storage flavors should ever appear; a third means something new crept in (e.g. a numeric // literal => Int64), which we want to notice Assert.Equal(2, byKind.Count); - Assert.Equal(28, Count(RedisValue.StorageType.ByteArray)); // literals > 8 bytes + Assert.Equal(30, Count(RedisValue.StorageType.ByteArray)); // literals > 8 bytes (+SLOT-STATS, +SLOTSRANGE) } } diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs new file mode 100644 index 000000000..a21f72dd2 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs @@ -0,0 +1,162 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +public class ClusterSlotStatsRoundTrip(ITestOutputHelper log) +{ + // a RESP2 reply with every documented metric present + private const string FullEntryResp2 = + "*1\r\n*2\r\n:100\r\n*10\r\n" + + "$9\r\nkey-count\r\n:42\r\n" + + "$8\r\ncpu-usec\r\n:1500\r\n" + + "$12\r\nmemory-bytes\r\n:2048\r\n" + + "$16\r\nnetwork-bytes-in\r\n:300\r\n" + + "$17\r\nnetwork-bytes-out\r\n:400\r\n"; + + // the same payload as a RESP3 map + private const string FullEntryResp3 = + "*1\r\n*2\r\n:100\r\n%5\r\n" + + "$9\r\nkey-count\r\n:42\r\n" + + "$8\r\ncpu-usec\r\n:1500\r\n" + + "$12\r\nmemory-bytes\r\n:2048\r\n" + + "$16\r\nnetwork-bytes-in\r\n:300\r\n" + + "$17\r\nnetwork-bytes-out\r\n:400\r\n"; + + private static void AssertFullEntry(ClusterSlotStatsResult? result) + { + Assert.NotNull(result); + var entry = Assert.Single(result.Slots); + Assert.Equal(100, entry.Slot); + Assert.Equal(42, entry.KeyCount); + Assert.Equal(TimeSpan.FromTicks(1500 * 10), entry.CpuTime); // 1500µs; 1 tick = 0.1µs + Assert.Equal(2048, entry.MemoryBytes); + Assert.Equal(300, entry.NetworkBytesIn); + Assert.Equal(400, entry.NetworkBytesOut); + Assert.Empty(entry.Metadata); + } + + [Fact(Timeout = 1000)] + public async Task SlotsRange_FullMap_Resp2_RoundTrips() + { + var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(100, 200, CommandFlags.None); + const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$3\r\n100\r\n$3\r\n200\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, FullEntryResp2, log: log); + AssertFullEntry(result); + } + + [Fact(Timeout = 1000)] + public async Task SlotsRange_FullMap_Resp3_RoundTrips() + { + var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(100, 200, CommandFlags.None); + const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$3\r\n100\r\n$3\r\n200\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, FullEntryResp3, protocol: RedisProtocol.Resp3, log: log); + AssertFullEntry(result); + } + + [Fact(Timeout = 1000)] + public async Task OrderBy_Defaults_OmitsLimitAndOrder() + { + // the server defaults (all slots, descending) are expressed by omission + var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.KeyCount, 0, Order.Descending, CommandFlags.None); + const string requestResp = "*4\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$9\r\nkey-count\r\n"; + + // key-count only: the other metrics are not being collected, and must come back null + const string responseResp = "*2\r\n" + + "*2\r\n:8000\r\n*2\r\n$9\r\nkey-count\r\n:7\r\n" + + "*2\r\n:12\r\n*2\r\n$9\r\nkey-count\r\n:3\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, responseResp, log: log); + Assert.NotNull(result); + Assert.Equal(2, result.Slots.Count); + Assert.Equal(8000, result.Slots[0].Slot); // server order is preserved, not re-sorted + Assert.Equal(7, result.Slots[0].KeyCount); + Assert.Null(result.Slots[0].CpuTime); + Assert.Null(result.Slots[0].MemoryBytes); + Assert.Null(result.Slots[0].NetworkBytesIn); + Assert.Null(result.Slots[0].NetworkBytesOut); + Assert.Equal(12, result.Slots[1].Slot); + Assert.Equal(3, result.Slots[1].KeyCount); + } + + [Fact(Timeout = 1000)] + public async Task OrderBy_WithLimit_WritesLimit() + { + var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.CpuTime, 5, Order.Descending, CommandFlags.None); + const string requestResp = "*6\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$8\r\ncpu-usec\r\n$5\r\nLIMIT\r\n$1\r\n5\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log); + Assert.NotNull(result); + Assert.Empty(result.Slots); + } + + [Fact(Timeout = 1000)] + public async Task OrderBy_AscendingWithLimit_WritesBoth() + { + var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.NetworkBytesOut, 3, Order.Ascending, CommandFlags.None); + const string requestResp = "*7\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$17\r\nnetwork-bytes-out\r\n$5\r\nLIMIT\r\n$1\r\n3\r\n$3\r\nASC\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log); + Assert.NotNull(result); + Assert.Empty(result.Slots); + } + + [Fact(Timeout = 1000)] + public async Task OrderBy_AscendingNoLimit_WritesAscOnly() + { + var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.MemoryBytes, 0, Order.Ascending, CommandFlags.None); + const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$12\r\nmemory-bytes\r\n$3\r\nASC\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log); + Assert.NotNull(result); + Assert.Empty(result.Slots); + } + + [Fact(Timeout = 1000)] + public async Task UnknownMetric_IsPreservedAsMetadata() + { + // the metric set is documented as extensible; a metric this library has never + // heard of must survive as metadata, not be dropped + var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 0, CommandFlags.None); + const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$1\r\n0\r\n$1\r\n0\r\n"; + const string responseResp = "*1\r\n*2\r\n:0\r\n*4\r\n" + + "$9\r\nkey-count\r\n:1\r\n" + + "$11\r\nwidget-usec\r\n:99\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, responseResp, log: log); + Assert.NotNull(result); + var entry = Assert.Single(result.Slots); + Assert.Equal(1, entry.KeyCount); + var extra = Assert.Single(entry.Metadata); + Assert.Equal("widget-usec", extra.Key); + Assert.Equal(99, extra.Value); + } + + [Fact(Timeout = 1000)] + public async Task NilReply_IsNull() + { + var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 100, CommandFlags.None); + const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$1\r\n0\r\n$3\r\n100\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*-1\r\n", log: log); + Assert.Null(result); + } + + [Fact] + public void SlotsRange_InvalidSlots_AreRejected() + { + Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(-1, 100, CommandFlags.None)); + Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 16384, CommandFlags.None)); + Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(200, 100, CommandFlags.None)); + } + + [Fact] + public void OrderBy_InvalidArguments_AreRejected() + { + Assert.Throws(() => RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.KeyCount, -1, Order.Descending, CommandFlags.None)); + Assert.Throws(() => RedisServer.GetClusterSlotStatsOrderByMessage((ClusterSlotStatsMetric)42, 0, Order.Descending, CommandFlags.None)); + } +} From 079742ba96e740646f8fe0a7aab4afadaf88b77d Mon Sep 17 00:00:00 2001 From: Nikolay Zdravkov Date: Mon, 9 Nov 2026 18:38:15 +0200 Subject: [PATCH 11/14] Check the maximum operation count in HeartbeatConsistencyCheckPingsAsync (#3342) --- tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs b/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs index f01541b61..c700d0edb 100644 --- a/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs @@ -123,6 +123,6 @@ public async Task HeartbeatConsistencyCheckPingsAsync(bool enableConsistencyChec var opCount = postCount - preCount; Log("OperationCount (diff): " + opCount); - Assert.True(minExpected <= opCount && opCount >= minExpected, $"Expected opcount ({opCount}) between {minExpected}-{maxExpected}"); + Assert.True(minExpected <= opCount && opCount <= maxExpected, $"Expected opcount ({opCount}) between {minExpected}-{maxExpected}"); } } From 0664f2b8561eddb3cdc35eb083c2c08dbc1345f8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Nov 2026 16:52:11 +0000 Subject: [PATCH 12/14] In-repo links follow the org move to seredis (#3326): github.com/StackExchange/StackExchange.Redis -> github.com/seredis/StackExchange.Redis everywhere, including the nupkg RepositoryUrl/PackageReleaseNotes metadata (#3344) --- Directory.Build.props | 4 +- README.md | 4 +- docs/404.html | 2 +- docs/CommandCoverage.md | 2 +- docs/ReleaseNotes.md | 628 +++++++++--------- docs/RespLogging.md | 2 +- docs/ServerMaintenanceEvent.md | 2 +- docs/Testing.md | 2 +- docs/index.md | 4 +- docs/rules/SER300.md | 2 +- docs/rules/SER301.md | 2 +- docs/rules/SER302.md | 2 +- docs/rules/SER303.md | 2 +- docs/rules/SER304.md | 2 +- docs/rules/SER305.md | 2 +- docs/rules/index.md | 2 +- notes/lag-aware/findings.md | 2 +- notes/lag-aware/plan.md | 2 +- .../ConnectionMultiplexer.cs | 2 +- .../FrameworkShims.IsExternalInit.cs | 2 +- .../PhysicalConnection.Read.cs | 2 +- src/StackExchange.Redis/README.md | 4 +- src/StackExchange.Redis/ResultProcessor.cs | 2 +- src/StackExchange.Redis/SocketManager.cs | 2 +- .../ClusterHandshakeNodesUnitTests.cs | 2 +- .../CommandRetryCategoryUnitTests.cs | 2 +- .../MultiGroupTests/GroupUnavailableTests.cs | 2 +- tests/StackExchange.Redis.Tests/ScanTests.cs | 2 +- .../ValkeyUnitTests.cs | 2 +- 29 files changed, 346 insertions(+), 346 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3f88b7ba1..21be6b534 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,13 +13,13 @@ $(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER012 - https://github.com/StackExchange/StackExchange.Redis/releases + https://github.com/seredis/StackExchange.Redis/releases https://seredis.dev/ MIT 14 git - https://github.com/StackExchange/StackExchange.Redis/ + https://github.com/seredis/StackExchange.Redis/ true embedded diff --git a/README.md b/README.md index 5d2b6cc7e..dbc27b1c0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ For all documentation, [see here](https://seredis.dev/). #### Build Status -[![CI](https://github.com/StackExchange/StackExchange.Redis/actions/workflows/CI.yml/badge.svg)](https://github.com/StackExchange/StackExchange.Redis/actions/workflows/CI.yml) +[![CI](https://github.com/seredis/StackExchange.Redis/actions/workflows/CI.yml/badge.svg)](https://github.com/seredis/StackExchange.Redis/actions/workflows/CI.yml) #### Package Status @@ -15,4 +15,4 @@ For all documentation, [see here](https://seredis.dev/). | ------- | ------------ | ----------------- | --------- | | [StackExchange.Redis](https://www.nuget.org/packages/StackExchange.Redis/) | [![StackExchange.Redis](https://img.shields.io/nuget/v/StackExchange.Redis.svg)](https://www.nuget.org/packages/StackExchange.Redis/) | [![StackExchange.Redis](https://img.shields.io/nuget/vpre/StackExchange.Redis.svg)](https://www.nuget.org/packages/StackExchange.Redis/absoluteLatest) | [![StackExchange.Redis](https://img.shields.io/nuget/dt/StackExchange.Redis.svg)](https://www.nuget.org/packages/StackExchange.Redis/) | -Release notes at: [GitHub Releases](https://github.com/StackExchange/StackExchange.Redis/releases) (3.0 onwards; [earlier releases](https://seredis.dev/ReleaseNotes)) +Release notes at: [GitHub Releases](https://github.com/seredis/StackExchange.Redis/releases) (3.0 onwards; [earlier releases](https://seredis.dev/ReleaseNotes)) diff --git a/docs/404.html b/docs/404.html index cb4dd0ac4..2a0c5b66a 100644 --- a/docs/404.html +++ b/docs/404.html @@ -17,7 +17,7 @@
  • Timeouts — reading a timeout message
  • Configuration
  • Basics
  • -
  • Release notes +
  • Release notes (3.0 onwards; earlier releases)
  • diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md index f8ff94211..862664565 100644 --- a/docs/CommandCoverage.md +++ b/docs/CommandCoverage.md @@ -4,7 +4,7 @@ Which server commands StackExchange.Redis does not expose as typed APIs, and why Upstream: redis/redis@6a5fee67583480de1fec323649f43fd000001b49 (`src/commands`, unstable, 2026-10-30). Maintained by the `audit-command-coverage` skill under `.claude/skills/`, which re-diffs this file against upstream and reports only what changed. -This supersedes the hand-maintained list in [#2055](https://github.com/StackExchange/StackExchange.Redis/issues/2055). +This supersedes the hand-maintained list in [#2055](https://github.com/seredis/StackExchange.Redis/issues/2055). ## Rules diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 6c435d6cb..66945e686 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,26 +8,26 @@ Current package versions: ## 3.0 -From 3.0, [release notes will be maintained in GitHub only](https://github.com/StackExchange/StackExchange.Redis/releases) to avoid duplication. +From 3.0, [release notes will be maintained in GitHub only](https://github.com/seredis/StackExchange.Redis/releases) to avoid duplication. --- ## 2.13.17 -- Fix TCP platform-dependent TCP keep-alive problems, and make an explicit option. ([#3090 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3090)) -- Tear down connection on write failure to prevent queue desync ([#3092 by @Pranish-Pantha](https://github.com/StackExchange/StackExchange.Redis/pull/3092)) -- Fix logic inversion with 8.8 `ARGREP NOCASE`, add `IsReversed` to simplify ordering, and support `ARINFO FULL`. ([#3087 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3087)) -- Avoid sentinel issues if `ROLE` unavailable; fix #3064 ([#3088 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3088)) -- Add 8.8 support for `INCREX` ([#3065 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3065)) +- Fix TCP platform-dependent TCP keep-alive problems, and make an explicit option. ([#3090 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3090)) +- Tear down connection on write failure to prevent queue desync ([#3092 by @Pranish-Pantha](https://github.com/seredis/StackExchange.Redis/pull/3092)) +- Fix logic inversion with 8.8 `ARGREP NOCASE`, add `IsReversed` to simplify ordering, and support `ARINFO FULL`. ([#3087 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3087)) +- Avoid sentinel issues if `ROLE` unavailable; fix #3064 ([#3088 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3088)) +- Add 8.8 support for `INCREX` ([#3065 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3065)) ## 2.13.10 - Add experimental Redis 8.8 array support, including array APIs on `IDatabase`/`IDatabaseAsync`, - array helper types, `RedisType.Array`, and array delete keyspace notification event types. ([#3076 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3076)) -- Enable TCP keep-alives ([#3078 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3078)) -- Fix incorrect routing of some sorted-set and stream commands ([#3080 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3080)) -- `ConfigurationOptions` : don't persist `Protocol` when it comes from the defaults-provider. ([#3082 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3082)) + array helper types, `RedisType.Array`, and array delete keyspace notification event types. ([#3076 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3076)) +- Enable TCP keep-alives ([#3078 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3078)) +- Fix incorrect routing of some sorted-set and stream commands ([#3080 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3080)) +- `ConfigurationOptions` : don't persist `Protocol` when it comes from the defaults-provider. ([#3082 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3082)) ## 2.13.1 @@ -43,446 +43,446 @@ which includes: Changes: -- Prefer RESP3 for Azure Managed Redis endpoints ([#3067 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3067)) +- Prefer RESP3 for Azure Managed Redis endpoints ([#3067 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3067)) ## 2.12.27 -- Recognize Azure Managed Redis (AMR) resources in new Azure clouds ([#3068 by @philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/3068)) -- Remove `[Experimental]` 8.8 `GCRA` feature ([#3074 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3074)) -- Detect server-mode correctly on Valkey 8+ instances ([#3050 by @wipiano](https://github.com/StackExchange/StackExchange.Redis/pull/3050)) -- Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3058)) -- Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3059)) -- Support Redis 8.8 sub-key (hash field) notifications ([#3062 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3062)) -- Add `ValueCondition` overloads for `SortedSetIncrement`/`SortedSetIncrementAsync`, supporting `ZADD INCR` with existence conditions ([#3071 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3071)) +- Recognize Azure Managed Redis (AMR) resources in new Azure clouds ([#3068 by @philon-msft](https://github.com/seredis/StackExchange.Redis/pull/3068)) +- Remove `[Experimental]` 8.8 `GCRA` feature ([#3074 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3074)) +- Detect server-mode correctly on Valkey 8+ instances ([#3050 by @wipiano](https://github.com/seredis/StackExchange.Redis/pull/3050)) +- Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3058)) +- Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3059)) +- Support Redis 8.8 sub-key (hash field) notifications ([#3062 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3062)) +- Add `ValueCondition` overloads for `SortedSetIncrement`/`SortedSetIncrementAsync`, supporting `ZADD INCR` with existence conditions ([#3071 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3071)) ## 2.12.14 -- Critical fix for high-integrity mode in cluster scenarios ([#3049 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/issues/3049)) +- Critical fix for high-integrity mode in cluster scenarios ([#3049 by @mgravell](https://github.com/seredis/StackExchange.Redis/issues/3049)) ## 2.12.8 - (this release also includes experimental 8.8 `GCRA` support; this feature is not being shipped yet, and it is being removed from later releases) -- Remove experimental flag on `VSIM` ([#3041 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3041)) +- Remove experimental flag on `VSIM` ([#3041 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3041)) - Add `IServer.GetProductVariant` to detect the product variant and version of the connected server, and use that internally - to enable multi-DB operations on Valkey clusters ([#3040 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3040)) -- Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/3043)) + to enable multi-DB operations on Valkey clusters ([#3040 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3040)) +- Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/seredis/StackExchange.Redis/pull/3043)) ## 2.12.4 -- Fix RESP3 client handshakes on non-RESP3 servers by ([#3037 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3037)) -- Improve detection of connect/handshake failures and how that impacts the retry-policy ([#3038 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3038)) +- Fix RESP3 client handshakes on non-RESP3 servers by ([#3037 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3037)) +- Improve detection of connect/handshake failures and how that impacts the retry-policy ([#3038 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3038)) ## 2.12.1 -- Add missing `LCS` outputs and missing `RedisType.VectorSet` ([#3028 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3028)) -- Track and report multiplexer count ([#3030 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3030)) -- (docs) Add Entra ID authentication docs ([#3023 by @philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/3023)) -- (eng) Improve test infrastructure (toy-server) ([#3021 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3021), [#3022 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3022), [#3027 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3027), [#3028 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3028)) -- (eng) Pre-V2 work: bring RESPite down, toy-server, migrate to `AsciiHash` ([#3028 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3028)) +- Add missing `LCS` outputs and missing `RedisType.VectorSet` ([#3028 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3028)) +- Track and report multiplexer count ([#3030 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3030)) +- (docs) Add Entra ID authentication docs ([#3023 by @philon-msft](https://github.com/seredis/StackExchange.Redis/pull/3023)) +- (eng) Improve test infrastructure (toy-server) ([#3021 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3021), [#3022 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3022), [#3027 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3027), [#3028 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3028)) +- (eng) Pre-V2 work: bring RESPite down, toy-server, migrate to `AsciiHash` ([#3028 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3028)) ## 2.11.8 -* Handle `-MOVED` error pointing to same endpoint. ([#3003 by @barshaul](https://github.com/StackExchange/StackExchange.Redis/pull/3003)) -* fix time conversion error in `HOTKEYS` ([#3017 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3017)) +* Handle `-MOVED` error pointing to same endpoint. ([#3003 by @barshaul](https://github.com/seredis/StackExchange.Redis/pull/3003)) +* fix time conversion error in `HOTKEYS` ([#3017 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/3017)) -- Add support for `VRANGE` ([#3011 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3011)) -- Add defensive code in azure-maintenance-events handling ([#3013 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3013)) +- Add support for `VRANGE` ([#3011 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3011)) +- Add defensive code in azure-maintenance-events handling ([#3013 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3013)) ## 2.11.3 -- Add support for `VRANGE` ([#3011 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3011)) -- Add defensive code in azure-maintenance-events handling ([#3013 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3013)) +- Add support for `VRANGE` ([#3011 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3011)) +- Add defensive code in azure-maintenance-events handling ([#3013 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3013)) ## 2.11.0 -- Add support for `HOTKEYS` ([#3008 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3008)) -- Add support for keyspace notifications ([#2995 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2995)) -- Add support for idempotent stream entry (`XADD IDMP[AUTO]`) support ([#3006 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3006)) -- (internals) split AMR out to a separate options provider ([#2986 by NickCraver and philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/2986)) +- Add support for `HOTKEYS` ([#3008 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3008)) +- Add support for keyspace notifications ([#2995 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2995)) +- Add support for idempotent stream entry (`XADD IDMP[AUTO]`) support ([#3006 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/3006)) +- (internals) split AMR out to a separate options provider ([#2986 by NickCraver and philon-msft](https://github.com/seredis/StackExchange.Redis/pull/2986)) ## 2.10.14 -- Fix bug with connection startup failing in low-memory scenarios ([#3002 by nathan-miller23](https://github.com/StackExchange/StackExchange.Redis/pull/3002)) -- Fix under-count of `TotalOutstanding` in server-counters ([#2996 by nathan-miller23](https://github.com/StackExchange/StackExchange.Redis/pull/2996)) -- Fix incorrect debug assertion in `HGETEX` (no impact to release library) ([#2999 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2999)) +- Fix bug with connection startup failing in low-memory scenarios ([#3002 by nathan-miller23](https://github.com/seredis/StackExchange.Redis/pull/3002)) +- Fix under-count of `TotalOutstanding` in server-counters ([#2996 by nathan-miller23](https://github.com/seredis/StackExchange.Redis/pull/2996)) +- Fix incorrect debug assertion in `HGETEX` (no impact to release library) ([#2999 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2999)) ## 2.10.1 - Support Redis 8.4 CAS/CAD operations (`DIGEST`, and the `IFEQ`, `IFNE`, `IFDEQ`, `IFDNE` modifiers on `SET` / `DEL`) - via the new `ValueCondition` abstraction, and use CAS/CAD operations for `Lock*` APIs when possible ([#2978 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2978)) + via the new `ValueCondition` abstraction, and use CAS/CAD operations for `Lock*` APIs when possible ([#2978 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2978)) - **note**: overload resolution for `StringSet[Async]` may be impacted in niche cases, requiring trivial build changes (there are no runtime-breaking changes such as missing methods) -- Support `XREADGROUP CLAIM` ([#2972 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2972)) -- Support `MSETEX` (Redis 8.4.0) for multi-key operations with expiration ([#2977 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2977)) +- Support `XREADGROUP CLAIM` ([#2972 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2972)) +- Support `MSETEX` (Redis 8.4.0) for multi-key operations with expiration ([#2977 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2977)) ## 2.9.32 -- Fix `SSUBSCRIBE` routing during slot migrations ([#2969 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2969)) +- Fix `SSUBSCRIBE` routing during slot migrations ([#2969 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2969)) ## 2.9.25 -- (build) Fix SNK on non-Windows builds ([#2963 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2963)) +- (build) Fix SNK on non-Windows builds ([#2963 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2963)) ## 2.9.24 -- Fix [#2951](https://github.com/StackExchange/StackExchange.Redis/issues/2951) - sentinel reconnection failure ([#2956 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2956)) -- Mitigate [#2955](https://github.com/StackExchange/StackExchange.Redis/issues/2955) (unbalanced pub/sub routing) / add `RedisValue.WithKeyRouting()` ([#2958 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2958)) -- Fix envoyproxy command exclusions ([#2957 by sshumakov](https://github.com/StackExchange/StackExchange.Redis/pull/2957)) -- Restrict `RedisValue` hex fallback (`string` conversion) to encoding failures ([2954 by jcaspes](https://github.com/StackExchange/StackExchange.Redis/pull/2954)) -- (internals) prefer `Volatile.Read` over `Thread.VolatileRead` ([2960 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2960)) +- Fix [#2951](https://github.com/seredis/StackExchange.Redis/issues/2951) - sentinel reconnection failure ([#2956 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2956)) +- Mitigate [#2955](https://github.com/seredis/StackExchange.Redis/issues/2955) (unbalanced pub/sub routing) / add `RedisValue.WithKeyRouting()` ([#2958 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2958)) +- Fix envoyproxy command exclusions ([#2957 by sshumakov](https://github.com/seredis/StackExchange.Redis/pull/2957)) +- Restrict `RedisValue` hex fallback (`string` conversion) to encoding failures ([2954 by jcaspes](https://github.com/seredis/StackExchange.Redis/pull/2954)) +- (internals) prefer `Volatile.Read` over `Thread.VolatileRead` ([2960 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2960)) ## 2.9.17 -- Add vector-set support ([#2939 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2939)) -- Fix `RedisValue` special-value (NaN, Inf, etc) handling when casting from raw/string values to `double` ([#2950 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2950)) +- Add vector-set support ([#2939 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2939)) +- Fix `RedisValue` special-value (NaN, Inf, etc) handling when casting from raw/string values to `double` ([#2950 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2950)) - Internals: - - Use `sealed` classes where possible ([#2942 by Henr1k80](https://github.com/StackExchange/StackExchange.Redis/pull/2942)) - - Add overlapped flushing in `LoggingTunnel` and avoid double-lookups ([#2943 by Henr1k80](https://github.com/StackExchange/StackExchange.Redis/pull/2943)) + - Use `sealed` classes where possible ([#2942 by Henr1k80](https://github.com/seredis/StackExchange.Redis/pull/2942)) + - Add overlapped flushing in `LoggingTunnel` and avoid double-lookups ([#2943 by Henr1k80](https://github.com/seredis/StackExchange.Redis/pull/2943)) ## 2.9.11 -- Add `HGETDEL`, `HGETEX` and `HSETEX` support ([#2863 by atakavci](https://github.com/StackExchange/StackExchange.Redis/pull/2863)) -- Fix key-prefix omission in `SetIntersectionLength` and `SortedSet{Combine[WithScores]|IntersectionLength}` ([#2863 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2863)) -- Add `Condition.SortedSet[Not]ContainsStarting` condition for transactions ([#2638 by ArnoKoll](https://github.com/StackExchange/StackExchange.Redis/pull/2638)) -- Add support for XPENDING Idle time filter ([#2822 by david-brink-talogy](https://github.com/StackExchange/StackExchange.Redis/pull/2822)) -- Improve `double` formatting performance on net8+ ([#2928 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2928)) -- Add `GetServer(RedisKey, ...)` API ([#2936 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2936)) -- Fix error constructing `StreamAdd` message ([#2941 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2941)) +- Add `HGETDEL`, `HGETEX` and `HSETEX` support ([#2863 by atakavci](https://github.com/seredis/StackExchange.Redis/pull/2863)) +- Fix key-prefix omission in `SetIntersectionLength` and `SortedSet{Combine[WithScores]|IntersectionLength}` ([#2863 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2863)) +- Add `Condition.SortedSet[Not]ContainsStarting` condition for transactions ([#2638 by ArnoKoll](https://github.com/seredis/StackExchange.Redis/pull/2638)) +- Add support for XPENDING Idle time filter ([#2822 by david-brink-talogy](https://github.com/seredis/StackExchange.Redis/pull/2822)) +- Improve `double` formatting performance on net8+ ([#2928 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2928)) +- Add `GetServer(RedisKey, ...)` API ([#2936 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2936)) +- Fix error constructing `StreamAdd` message ([#2941 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2941)) ## 2.8.58 -- Fix [#2679](https://github.com/StackExchange/StackExchange.Redis/issues/2679) - blocking call in long-running connects ([#2680 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2680)) -- Support async cancellation of `SCAN` enumeration ([#2911 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2911)) -- Add `XTRIM MINID` support ([#2842 by kijanawoodard](https://github.com/StackExchange/StackExchange.Redis/pull/2842)) -- Add new CE 8.2 stream support - `XDELEX`, `XACKDEL`, `{XADD|XTRIM} [KEEPREF|DELREF|ACKED]` ([#2912 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2912)) -- Fix `ZREVRANGEBYLEX` open-ended commands ([#2636 by ArnoKoll](https://github.com/StackExchange/StackExchange.Redis/pull/2636)) -- Fix `StreamGroupInfo.Lag` when `null` ([#2902 by robhop](https://github.com/StackExchange/StackExchange.Redis/pull/2902)) +- Fix [#2679](https://github.com/seredis/StackExchange.Redis/issues/2679) - blocking call in long-running connects ([#2680 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2680)) +- Support async cancellation of `SCAN` enumeration ([#2911 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2911)) +- Add `XTRIM MINID` support ([#2842 by kijanawoodard](https://github.com/seredis/StackExchange.Redis/pull/2842)) +- Add new CE 8.2 stream support - `XDELEX`, `XACKDEL`, `{XADD|XTRIM} [KEEPREF|DELREF|ACKED]` ([#2912 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2912)) +- Fix `ZREVRANGEBYLEX` open-ended commands ([#2636 by ArnoKoll](https://github.com/seredis/StackExchange.Redis/pull/2636)) +- Fix `StreamGroupInfo.Lag` when `null` ([#2902 by robhop](https://github.com/seredis/StackExchange.Redis/pull/2902)) - Internals - - Logging improvements ([#2903 by Meir017](https://github.com/StackExchange/StackExchange.Redis/pull/2903) and [#2917 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2917)) - - Update tests to xUnit v3 ([#2907 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2907)) - - Avoid `CLIENT PAUSE` in CI tests ([#2916 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2916)) + - Logging improvements ([#2903 by Meir017](https://github.com/seredis/StackExchange.Redis/pull/2903) and [#2917 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2917)) + - Update tests to xUnit v3 ([#2907 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2907)) + - Avoid `CLIENT PAUSE` in CI tests ([#2916 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2916)) ## 2.8.47 -- Add support for new `BITOP` operations in CE 8.2 ([#2900 by atakavci](https://github.com/StackExchange/StackExchange.Redis/pull/2900)) -- Package updates ([#2906 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2906)) -- Docs: added [guidance on async timeouts](https://seredis.dev/AsyncTimeouts) ([#2910 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2910)) -- Fix handshake error with `CLIENT ID` ([#2909 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2909)) +- Add support for new `BITOP` operations in CE 8.2 ([#2900 by atakavci](https://github.com/seredis/StackExchange.Redis/pull/2900)) +- Package updates ([#2906 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2906)) +- Docs: added [guidance on async timeouts](https://seredis.dev/AsyncTimeouts) ([#2910 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2910)) +- Fix handshake error with `CLIENT ID` ([#2909 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2909)) ## 2.8.41 -- Add support for sharded pub/sub via `RedisChannel.Sharded` - ([#2887 by vandyvilla, atakavci and mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2887)) +- Add support for sharded pub/sub via `RedisChannel.Sharded` - ([#2887 by vandyvilla, atakavci and mgravell](https://github.com/seredis/StackExchange.Redis/pull/2887)) ## 2.8.37 -- Add `ConfigurationOptions.SetUserPemCertificate(...)` and `ConfigurationOptions.SetUserPfxCertificate(...)` methods to simplify using client certificates ([#2873 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2873)) -- Add logging for when a Multiplexer reconfigures ([#2864 by st-dev-gh](https://github.com/StackExchange/StackExchange.Redis/pull/2864)) -- Fix: Move `AuthenticateAsClient` to fully async after dropping older framework support, to help client thread starvation in cases TLS negotiation stalls server-side ([#2878 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2878)) +- Add `ConfigurationOptions.SetUserPemCertificate(...)` and `ConfigurationOptions.SetUserPfxCertificate(...)` methods to simplify using client certificates ([#2873 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2873)) +- Add logging for when a Multiplexer reconfigures ([#2864 by st-dev-gh](https://github.com/seredis/StackExchange.Redis/pull/2864)) +- Fix: Move `AuthenticateAsClient` to fully async after dropping older framework support, to help client thread starvation in cases TLS negotiation stalls server-side ([#2878 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2878)) ## 2.8.31 -- Fix: Respect `IReconnectRetryPolicy` timing in the case that a node that was present disconnects indefinitely ([#2853](https://github.com/StackExchange/StackExchange.Redis/pull/2853) & [#2856](https://github.com/StackExchange/StackExchange.Redis/pull/2856) by NickCraver) +- Fix: Respect `IReconnectRetryPolicy` timing in the case that a node that was present disconnects indefinitely ([#2853](https://github.com/seredis/StackExchange.Redis/pull/2853) & [#2856](https://github.com/seredis/StackExchange.Redis/pull/2856) by NickCraver) - Special thanks to [sampdei](https://github.com/sampdei) tracking this down and working a fix -- Changes max default retry policy backoff to 60 seconds ([#2853 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2853)) -- Fix [#2652](https://github.com/StackExchange/StackExchange.Redis/issues/2652): Track client-initiated shutdown for any pipe type ([#2814 by bgrainger](https://github.com/StackExchange/StackExchange.Redis/pull/2814)) +- Changes max default retry policy backoff to 60 seconds ([#2853 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2853)) +- Fix [#2652](https://github.com/seredis/StackExchange.Redis/issues/2652): Track client-initiated shutdown for any pipe type ([#2814 by bgrainger](https://github.com/seredis/StackExchange.Redis/pull/2814)) ## 2.8.24 -- Update Envoy command definitions to [allow `UNWATCH`](https://github.com/envoyproxy/envoy/pull/37620) ([#2824 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2824)) +- Update Envoy command definitions to [allow `UNWATCH`](https://github.com/envoyproxy/envoy/pull/37620) ([#2824 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2824)) ## 2.8.22 -- Format IPv6 endpoints correctly when rewriting configration strings ([#2813 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2813)) -- Update default Redis version from `4.0.0` to `6.0.0` for Azure Redis resources ([#2810 by philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/2810)) -- Detect Azure Managed Redis caches and tune default connection settings for them ([#2818 by philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/2818)) -- Bump `Microsoft.Bcl.AsyncInterfaces` dependency from `5.0.0` to `6.0.0` ([#2820 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2820)) +- Format IPv6 endpoints correctly when rewriting configration strings ([#2813 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2813)) +- Update default Redis version from `4.0.0` to `6.0.0` for Azure Redis resources ([#2810 by philon-msft](https://github.com/seredis/StackExchange.Redis/pull/2810)) +- Detect Azure Managed Redis caches and tune default connection settings for them ([#2818 by philon-msft](https://github.com/seredis/StackExchange.Redis/pull/2818)) +- Bump `Microsoft.Bcl.AsyncInterfaces` dependency from `5.0.0` to `6.0.0` ([#2820 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2820)) ## 2.8.16 -- Fix: PhysicalBridge: Always perform "last read" check in heartbeat when `HeartbeatConsistencyChecks` is enabled ([#2795 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2795)) +- Fix: PhysicalBridge: Always perform "last read" check in heartbeat when `HeartbeatConsistencyChecks` is enabled ([#2795 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2795)) ## 2.8.14 -- Fix [#2793](https://github.com/StackExchange/StackExchange.Redis/issues/2793): Update Envoyproxy's command map according to latest Envoy documentation ([#2794 by dbarbosapn](https://github.com/StackExchange/StackExchange.Redis/pull/2794)) +- Fix [#2793](https://github.com/seredis/StackExchange.Redis/issues/2793): Update Envoyproxy's command map according to latest Envoy documentation ([#2794 by dbarbosapn](https://github.com/seredis/StackExchange.Redis/pull/2794)) ## 2.8.12 -- Add support for hash field expiration (see [#2715](https://github.com/StackExchange/StackExchange.Redis/issues/2715)) ([#2716 by atakavci](https://github.com/StackExchange/StackExchange.Redis/pull/2716])) -- Add support for `HSCAN NOVALUES` (see [#2721](https://github.com/StackExchange/StackExchange.Redis/issues/2721)) ([#2722 by atakavci](https://github.com/StackExchange/StackExchange.Redis/pull/2722)) -- Fix [#2763](https://github.com/StackExchange/StackExchange.Redis/issues/2763): Make ConnectionMultiplexer.Subscription thread-safe ([#2769 by Chuck-EP](https://github.com/StackExchange/StackExchange.Redis/pull/2769)) -- Fix [#2778](https://github.com/StackExchange/StackExchange.Redis/issues/2778): Run `CheckInfoReplication` even with `HeartbeatConsistencyChecks` ([#2784 by NickCraver and leachdaniel-clark](https://github.com/StackExchange/StackExchange.Redis/pull/2784)) +- Add support for hash field expiration (see [#2715](https://github.com/seredis/StackExchange.Redis/issues/2715)) ([#2716 by atakavci](https://github.com/seredis/StackExchange.Redis/pull/2716])) +- Add support for `HSCAN NOVALUES` (see [#2721](https://github.com/seredis/StackExchange.Redis/issues/2721)) ([#2722 by atakavci](https://github.com/seredis/StackExchange.Redis/pull/2722)) +- Fix [#2763](https://github.com/seredis/StackExchange.Redis/issues/2763): Make ConnectionMultiplexer.Subscription thread-safe ([#2769 by Chuck-EP](https://github.com/seredis/StackExchange.Redis/pull/2769)) +- Fix [#2778](https://github.com/seredis/StackExchange.Redis/issues/2778): Run `CheckInfoReplication` even with `HeartbeatConsistencyChecks` ([#2784 by NickCraver and leachdaniel-clark](https://github.com/seredis/StackExchange.Redis/pull/2784)) ## 2.8.0 -- Add high-integrity mode ([docs](https://seredis.dev/Configuration), [#2471 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2741)) -- TLS certificate/`TrustIssuer`: Check EKU in X509 chain checks when validating certificates ([#2670 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2670)) +- Add high-integrity mode ([docs](https://seredis.dev/Configuration), [#2471 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2741)) +- TLS certificate/`TrustIssuer`: Check EKU in X509 chain checks when validating certificates ([#2670 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2670)) ## 2.7.33 -- **Potentially Breaking**: Fix `CheckTrustedIssuer` certificate validation for broken chain scenarios ([#2665 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2665)) +- **Potentially Breaking**: Fix `CheckTrustedIssuer` certificate validation for broken chain scenarios ([#2665 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2665)) - Users inadvertently trusting a remote cert with a broken chain could not be failing custom validation before this change. This is only in play if you are using `ConfigurationOptions.TrustIssuer` at all. -- Add new `LoggingTunnel` API; see [https://seredis.dev/RespLogging](https://seredis.dev/RespLogging) ([#2660 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2660)) -- Fix [#2664](https://github.com/StackExchange/StackExchange.Redis/issues/2664): Move ProcessBacklog to fully sync to prevent thread pool hopping and blocking on awaits ([#2667 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2667)) +- Add new `LoggingTunnel` API; see [https://seredis.dev/RespLogging](https://seredis.dev/RespLogging) ([#2660 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2660)) +- Fix [#2664](https://github.com/seredis/StackExchange.Redis/issues/2664): Move ProcessBacklog to fully sync to prevent thread pool hopping and blocking on awaits ([#2667 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2667)) ## 2.7.27 -- Support `HeartbeatConsistencyChecks` and `HeartbeatInterval` in `Clone()` ([#2658 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2658)) -- Add `AddLibraryNameSuffix` to multiplexer; allows usage-specific tokens to be appended *after connect* ([#2659 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2659)) +- Support `HeartbeatConsistencyChecks` and `HeartbeatInterval` in `Clone()` ([#2658 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2658)) +- Add `AddLibraryNameSuffix` to multiplexer; allows usage-specific tokens to be appended *after connect* ([#2659 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2659)) ## 2.7.23 -- Fix [#2653](https://github.com/StackExchange/StackExchange.Redis/issues/2653): Client library metadata should validate contents ([#2654 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2654)) -- Add `HeartbeatConsistencyChecks` option (opt-in) to enabled per-heartbeat (defaults to once per second) checks to be sent to ensure no network stream corruption has occurred ([#2656 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2656)) +- Fix [#2653](https://github.com/seredis/StackExchange.Redis/issues/2653): Client library metadata should validate contents ([#2654 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2654)) +- Add `HeartbeatConsistencyChecks` option (opt-in) to enabled per-heartbeat (defaults to once per second) checks to be sent to ensure no network stream corruption has occurred ([#2656 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2656)) ## 2.7.20 -- Fix [#2642](https://github.com/StackExchange/StackExchange.Redis/issues/2642): Detect and support multi-DB pseudo-cluster/proxy scenarios ([#2646](https://github.com/StackExchange/StackExchange.Redis/pull/2646) by mgravell) +- Fix [#2642](https://github.com/seredis/StackExchange.Redis/issues/2642): Detect and support multi-DB pseudo-cluster/proxy scenarios ([#2646](https://github.com/seredis/StackExchange.Redis/pull/2646) by mgravell) ## 2.7.17 -- Fix [#2321](https://github.com/StackExchange/StackExchange.Redis/issues/2321): Honor disposition of select command in Command Map for transactions [(#2322 by slorello89)](https://github.com/StackExchange/StackExchange.Redis/pull/2322) -- Fix [#2619](https://github.com/StackExchange/StackExchange.Redis/issues/2619): Type-forward `IsExternalInit` to support down-level TFMs ([#2621 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2621)) -- `InternalsVisibleTo` `PublicKey` enhancements([#2623 by WeihanLi](https://github.com/StackExchange/StackExchange.Redis/pull/2623)) -- Fix [#2576](https://github.com/StackExchange/StackExchange.Redis/issues/2576): Prevent `NullReferenceException` during shutdown of connections ([#2629 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2629)) +- Fix [#2321](https://github.com/seredis/StackExchange.Redis/issues/2321): Honor disposition of select command in Command Map for transactions [(#2322 by slorello89)](https://github.com/seredis/StackExchange.Redis/pull/2322) +- Fix [#2619](https://github.com/seredis/StackExchange.Redis/issues/2619): Type-forward `IsExternalInit` to support down-level TFMs ([#2621 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2621)) +- `InternalsVisibleTo` `PublicKey` enhancements([#2623 by WeihanLi](https://github.com/seredis/StackExchange.Redis/pull/2623)) +- Fix [#2576](https://github.com/seredis/StackExchange.Redis/issues/2576): Prevent `NullReferenceException` during shutdown of connections ([#2629 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2629)) ## 2.7.10 -- Fix [#2593](https://github.com/StackExchange/StackExchange.Redis/issues/2593): `EXPIRETIME` and `PEXPIRETIME` miscategorized as `PrimaryOnly` commands causing them to fail when issued against a read-only replica ([#2593 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2593)) -- Fix [#2591](https://github.com/StackExchange/StackExchange.Redis/issues/2591): Add `HELLO` to Sentinel connections so they can support RESP3 ([#2601 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2601)) -- Fix [#2595](https://github.com/StackExchange/StackExchange.Redis/issues/2595): Add detection handling for dead sockets that the OS says are okay, seen especially in Linux environments ([#2610 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2610)) +- Fix [#2593](https://github.com/seredis/StackExchange.Redis/issues/2593): `EXPIRETIME` and `PEXPIRETIME` miscategorized as `PrimaryOnly` commands causing them to fail when issued against a read-only replica ([#2593 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2593)) +- Fix [#2591](https://github.com/seredis/StackExchange.Redis/issues/2591): Add `HELLO` to Sentinel connections so they can support RESP3 ([#2601 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2601)) +- Fix [#2595](https://github.com/seredis/StackExchange.Redis/issues/2595): Add detection handling for dead sockets that the OS says are okay, seen especially in Linux environments ([#2610 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2610)) ## 2.7.4 -- Adds: RESP3 support ([#2396 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2396)) - see https://seredis.dev/Resp3 -- Fix [#2507](https://github.com/StackExchange/StackExchange.Redis/issues/2507): Pub/sub with multi-item payloads should be usable ([#2508 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2508)) -- Add: connection-id tracking (internal only, no public API) ([#2508 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2508)) -- Add: `ConfigurationOptions.LoggerFactory` for logging to an `ILoggerFactory` (e.g. `ILogger`) all connection and error events ([#2051 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2051)) -- Fix [#2467](https://github.com/StackExchange/StackExchange.Redis/issues/2467): Add StreamGroupInfo EntriesRead and Lag ([#2510 by tvdias](https://github.com/StackExchange/StackExchange.Redis/pull/2510)) +- Adds: RESP3 support ([#2396 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2396)) - see https://seredis.dev/Resp3 +- Fix [#2507](https://github.com/seredis/StackExchange.Redis/issues/2507): Pub/sub with multi-item payloads should be usable ([#2508 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2508)) +- Add: connection-id tracking (internal only, no public API) ([#2508 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2508)) +- Add: `ConfigurationOptions.LoggerFactory` for logging to an `ILoggerFactory` (e.g. `ILogger`) all connection and error events ([#2051 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2051)) +- Fix [#2467](https://github.com/seredis/StackExchange.Redis/issues/2467): Add StreamGroupInfo EntriesRead and Lag ([#2510 by tvdias](https://github.com/seredis/StackExchange.Redis/pull/2510)) ## 2.6.122 -- Change: Target net6.0 instead of net5.0, since net5.0 is end of life. ([#2497 by eerhardt](https://github.com/StackExchange/StackExchange.Redis/pull/2497)) -- Fix: Fix nullability annotation of IConnectionMultiplexer.RegisterProfiler ([#2494 by eerhardt](https://github.com/StackExchange/StackExchange.Redis/pull/2494)) -- Fix [#2520](https://github.com/StackExchange/StackExchange.Redis/issues/2520): Improve cluster connections in down scenarios by not re-pinging successful nodes ([#2525 by Matiszak](https://github.com/StackExchange/StackExchange.Redis/pull/2525)) -- Add: `Timer.ActiveCount` under `POOL` in timeout messages on .NET 6+ to help diagnose timer overload affecting timeout evaluations ([#2500 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2500)) -- Add: `LibraryName` configuration option; allows the library name to be controlled at the individual options level (in addition to the existing controls in `DefaultOptionsProvider`) ([#2502 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2502)) -- Add: `DefaultOptionsProvider.GetProvider` allows lookup of provider by endpoint ([#2502 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2502)) +- Change: Target net6.0 instead of net5.0, since net5.0 is end of life. ([#2497 by eerhardt](https://github.com/seredis/StackExchange.Redis/pull/2497)) +- Fix: Fix nullability annotation of IConnectionMultiplexer.RegisterProfiler ([#2494 by eerhardt](https://github.com/seredis/StackExchange.Redis/pull/2494)) +- Fix [#2520](https://github.com/seredis/StackExchange.Redis/issues/2520): Improve cluster connections in down scenarios by not re-pinging successful nodes ([#2525 by Matiszak](https://github.com/seredis/StackExchange.Redis/pull/2525)) +- Add: `Timer.ActiveCount` under `POOL` in timeout messages on .NET 6+ to help diagnose timer overload affecting timeout evaluations ([#2500 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2500)) +- Add: `LibraryName` configuration option; allows the library name to be controlled at the individual options level (in addition to the existing controls in `DefaultOptionsProvider`) ([#2502 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2502)) +- Add: `DefaultOptionsProvider.GetProvider` allows lookup of provider by endpoint ([#2502 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2502)) ## 2.6.116 -- Fix [#2479](https://github.com/StackExchange/StackExchange.Redis/issues/2479): Add `RedisChannel.UseImplicitAutoPattern` (global) and `RedisChannel.IsPattern` ([#2480 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2480)) -- Fix [#2479](https://github.com/StackExchange/StackExchange.Redis/issues/2479): Mark `RedisChannel` conversion operators as obsolete; add `RedisChannel.Literal` and `RedisChannel.Pattern` helpers ([#2481 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2481)) -- Fix [#2449](https://github.com/StackExchange/StackExchange.Redis/issues/2449): Update `Pipelines.Sockets.Unofficial` to `v2.2.8` to support native AOT ([#2456 by eerhardt](https://github.com/StackExchange/StackExchange.Redis/pull/2456)) +- Fix [#2479](https://github.com/seredis/StackExchange.Redis/issues/2479): Add `RedisChannel.UseImplicitAutoPattern` (global) and `RedisChannel.IsPattern` ([#2480 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2480)) +- Fix [#2479](https://github.com/seredis/StackExchange.Redis/issues/2479): Mark `RedisChannel` conversion operators as obsolete; add `RedisChannel.Literal` and `RedisChannel.Pattern` helpers ([#2481 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2481)) +- Fix [#2449](https://github.com/seredis/StackExchange.Redis/issues/2449): Update `Pipelines.Sockets.Unofficial` to `v2.2.8` to support native AOT ([#2456 by eerhardt](https://github.com/seredis/StackExchange.Redis/pull/2456)) ## 2.6.111 -- Fix [#2426](https://github.com/StackExchange/StackExchange.Redis/issues/2426): Don't restrict multi-slot operations on Envoy proxy; let the proxy decide ([#2428 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2428)) -- Add: Support for `User`/`Password` in `DefaultOptionsProvider` to support token rotation scenarios ([#2445 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2445)) -- Fix [#2449](https://github.com/StackExchange/StackExchange.Redis/issues/2449): Resolve AOT trim warnings in `TryGetAzureRoleInstanceIdNoThrow` ([#2451 by eerhardt](https://github.com/StackExchange/StackExchange.Redis/pull/2451)) -- Adds: Support for `HTTP/1.1 200 Connection established` in HTTP Tunnel ([#2448 by flobernd](https://github.com/StackExchange/StackExchange.Redis/pull/2448)) -- Adds: Timeout duration to backlog timeout error messages ([#2452 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2452)) -- Adds: `DefaultOptionsProvider.LibraryName` for specifying lib-name passed to `CLIENT SETINFO` in Redis 7.2+ ([#2453 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2453)) +- Fix [#2426](https://github.com/seredis/StackExchange.Redis/issues/2426): Don't restrict multi-slot operations on Envoy proxy; let the proxy decide ([#2428 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2428)) +- Add: Support for `User`/`Password` in `DefaultOptionsProvider` to support token rotation scenarios ([#2445 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2445)) +- Fix [#2449](https://github.com/seredis/StackExchange.Redis/issues/2449): Resolve AOT trim warnings in `TryGetAzureRoleInstanceIdNoThrow` ([#2451 by eerhardt](https://github.com/seredis/StackExchange.Redis/pull/2451)) +- Adds: Support for `HTTP/1.1 200 Connection established` in HTTP Tunnel ([#2448 by flobernd](https://github.com/seredis/StackExchange.Redis/pull/2448)) +- Adds: Timeout duration to backlog timeout error messages ([#2452 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2452)) +- Adds: `DefaultOptionsProvider.LibraryName` for specifying lib-name passed to `CLIENT SETINFO` in Redis 7.2+ ([#2453 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2453)) ## 2.6.104 -- Fix [#2412](https://github.com/StackExchange/StackExchange.Redis/issues/2412): Critical (but rare) GC bug that can lead to async tasks never completing if the multiplexer is not held by the consumer ([#2408 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2408)) -- Add: Better error messages (over generic timeout) when commands are backlogged and unable to write to any connection ([#2408 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2408)) -- Fix [#2392](https://github.com/StackExchange/StackExchange.Redis/issues/2392): Dequeue *all* timed out messages from the backlog when not connected (including Fire+Forget) ([#2397 by kornelpal](https://github.com/StackExchange/StackExchange.Redis/pull/2397)) -- Fix [#2400](https://github.com/StackExchange/StackExchange.Redis/issues/2400): Expose `ChannelMessageQueue` as `IAsyncEnumerable` ([#2402 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2402)) -- Add: Support for `CLIENT SETINFO` (lib name/version) during handshake; opt-out is via `ConfigurationOptions`; also support read of `resp`, `lib-ver` and `lib-name` via `CLIENT LIST` ([#2414 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2414)) -- Documentation: clarify the meaning of `RedisValue.IsInteger` re [#2418](https://github.com/StackExchange/StackExchange.Redis/issues/2418) ([#2420 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2420)) +- Fix [#2412](https://github.com/seredis/StackExchange.Redis/issues/2412): Critical (but rare) GC bug that can lead to async tasks never completing if the multiplexer is not held by the consumer ([#2408 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2408)) +- Add: Better error messages (over generic timeout) when commands are backlogged and unable to write to any connection ([#2408 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2408)) +- Fix [#2392](https://github.com/seredis/StackExchange.Redis/issues/2392): Dequeue *all* timed out messages from the backlog when not connected (including Fire+Forget) ([#2397 by kornelpal](https://github.com/seredis/StackExchange.Redis/pull/2397)) +- Fix [#2400](https://github.com/seredis/StackExchange.Redis/issues/2400): Expose `ChannelMessageQueue` as `IAsyncEnumerable` ([#2402 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2402)) +- Add: Support for `CLIENT SETINFO` (lib name/version) during handshake; opt-out is via `ConfigurationOptions`; also support read of `resp`, `lib-ver` and `lib-name` via `CLIENT LIST` ([#2414 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2414)) +- Documentation: clarify the meaning of `RedisValue.IsInteger` re [#2418](https://github.com/seredis/StackExchange.Redis/issues/2418) ([#2420 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2420)) ## 2.6.96 -- Fix [#2350](https://github.com/StackExchange/StackExchange.Redis/issues/2350): Properly parse lua script parameters in all cultures ([#2351 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2351)) -- Fix [#2362](https://github.com/StackExchange/StackExchange.Redis/issues/2362): Set `RedisConnectionException.FailureType` to `AuthenticationFailure` on all authentication scenarios for better handling ([#2367 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2367)) -- Fix [#2368](https://github.com/StackExchange/StackExchange.Redis/issues/2368): Support `RedisValue.Length()` for all storage types ([#2370 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2370)) -- Fix [#2376](https://github.com/StackExchange/StackExchange.Redis/issues/2376): Avoid a (rare) deadlock scenario ([#2378 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2378)) +- Fix [#2350](https://github.com/seredis/StackExchange.Redis/issues/2350): Properly parse lua script parameters in all cultures ([#2351 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2351)) +- Fix [#2362](https://github.com/seredis/StackExchange.Redis/issues/2362): Set `RedisConnectionException.FailureType` to `AuthenticationFailure` on all authentication scenarios for better handling ([#2367 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2367)) +- Fix [#2368](https://github.com/seredis/StackExchange.Redis/issues/2368): Support `RedisValue.Length()` for all storage types ([#2370 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2370)) +- Fix [#2376](https://github.com/seredis/StackExchange.Redis/issues/2376): Avoid a (rare) deadlock scenario ([#2378 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2378)) ## 2.6.90 -- Adds: Support for `EVAL_RO` and `EVALSHA_RO` via `IDatabase.ScriptEvaluateReadOnly`/`IDatabase.ScriptEvaluateReadOnlyAsync` ([#2168 by shacharPash](https://github.com/StackExchange/StackExchange.Redis/pull/2168)) -- Fix [#1458](https://github.com/StackExchange/StackExchange.Redis/issues/1458): Fixes a leak condition when a connection completes on the TCP phase but not the Redis handshake ([#2238 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2238)) -- Internal: ServerSnapshot: Improve API and allow filtering with custom struct enumerator ([#2337 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2337)) +- Adds: Support for `EVAL_RO` and `EVALSHA_RO` via `IDatabase.ScriptEvaluateReadOnly`/`IDatabase.ScriptEvaluateReadOnlyAsync` ([#2168 by shacharPash](https://github.com/seredis/StackExchange.Redis/pull/2168)) +- Fix [#1458](https://github.com/seredis/StackExchange.Redis/issues/1458): Fixes a leak condition when a connection completes on the TCP phase but not the Redis handshake ([#2238 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2238)) +- Internal: ServerSnapshot: Improve API and allow filtering with custom struct enumerator ([#2337 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2337)) ## 2.6.86 -- Fix [#1520](https://github.com/StackExchange/StackExchange.Redis/issues/1520) & [#1660](https://github.com/StackExchange/StackExchange.Redis/issues/1660): When `MOVED` is encountered from a cluster, a reconfigure will happen proactively to react to cluster changes ASAP ([#2286 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2286)) -- Fix [#2249](https://github.com/StackExchange/StackExchange.Redis/issues/2249): Properly handle a `fail` state (new `ClusterNode.IsFail` property) for `CLUSTER NODES` and expose `fail?` as a property (`IsPossiblyFail`) as well ([#2288 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2288)) -- Adds: `IConnectionMultiplexer.ServerMaintenanceEvent` (was on `ConnectionMultiplexer` but not the interface) ([#2306 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2306)) -- Adds: To timeout messages, additional debug information: `Sync-Ops` (synchronous operations), `Async-Ops` (asynchronous operations), and `Server-Connected-Seconds` (how long the connection in question has been connected, or `"n/a"`) ([#2300 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2300)) +- Fix [#1520](https://github.com/seredis/StackExchange.Redis/issues/1520) & [#1660](https://github.com/seredis/StackExchange.Redis/issues/1660): When `MOVED` is encountered from a cluster, a reconfigure will happen proactively to react to cluster changes ASAP ([#2286 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2286)) +- Fix [#2249](https://github.com/seredis/StackExchange.Redis/issues/2249): Properly handle a `fail` state (new `ClusterNode.IsFail` property) for `CLUSTER NODES` and expose `fail?` as a property (`IsPossiblyFail`) as well ([#2288 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2288)) +- Adds: `IConnectionMultiplexer.ServerMaintenanceEvent` (was on `ConnectionMultiplexer` but not the interface) ([#2306 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2306)) +- Adds: To timeout messages, additional debug information: `Sync-Ops` (synchronous operations), `Async-Ops` (asynchronous operations), and `Server-Connected-Seconds` (how long the connection in question has been connected, or `"n/a"`) ([#2300 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2300)) ## 2.6.80 -- Adds: `last-in` and `cur-in` (bytes) to timeout exceptions to help identify timeouts that were just-behind another large payload off the wire ([#2276 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2276)) -- Adds: general-purpose tunnel support, with HTTP proxy "connect" support included ([#2274 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2274)) -- Removes: Package dependency (`System.Diagnostics.PerformanceCounter`) ([#2285 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2285)) +- Adds: `last-in` and `cur-in` (bytes) to timeout exceptions to help identify timeouts that were just-behind another large payload off the wire ([#2276 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2276)) +- Adds: general-purpose tunnel support, with HTTP proxy "connect" support included ([#2274 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2274)) +- Removes: Package dependency (`System.Diagnostics.PerformanceCounter`) ([#2285 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2285)) ## 2.6.70 -- Fix: `MOVED` with `NoRedirect` (and other non-reachable errors) should respect the `IncludeDetailInExceptions` setting ([#2267 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2267)) -- Fix [#2251](https://github.com/StackExchange/StackExchange.Redis/issues/2251) & [#2265](https://github.com/StackExchange/StackExchange.Redis/issues/2265): Cluster endpoint connections weren't proactively connecting subscriptions in all cases and taking the full connection timeout to complete as a result ([#2268 by iteplov](https://github.com/StackExchange/StackExchange.Redis/pull/2268)) +- Fix: `MOVED` with `NoRedirect` (and other non-reachable errors) should respect the `IncludeDetailInExceptions` setting ([#2267 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2267)) +- Fix [#2251](https://github.com/seredis/StackExchange.Redis/issues/2251) & [#2265](https://github.com/seredis/StackExchange.Redis/issues/2265): Cluster endpoint connections weren't proactively connecting subscriptions in all cases and taking the full connection timeout to complete as a result ([#2268 by iteplov](https://github.com/seredis/StackExchange.Redis/pull/2268)) ## 2.6.66 -- Fix [#2182](https://github.com/StackExchange/StackExchange.Redis/issues/2182): Be more flexible in which commands are "primary only" in order to support users with replicas that are explicitly configured to allow writes ([#2183 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2183)) -- Adds: `IConnectionMultiplexer` now implements `IAsyncDisposable` ([#2161 by kimsey0](https://github.com/StackExchange/StackExchange.Redis/pull/2161)) -- Adds: `IConnectionMultiplexer.GetServers()` to get all `IServer` instances for a multiplexer ([#2203 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2203)) -- Fix [#2016](https://github.com/StackExchange/StackExchange.Redis/issues/2016): Align server selection with supported commands (e.g. with writable servers) to reduce `Command cannot be issued to a replica` errors ([#2191 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2191)) -- Performance: Optimization around timeout processing to reduce lock contention in the case of many items that haven't yet timed out during a heartbeat ([#2217 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2217)) -- Fix [#2223](https://github.com/StackExchange/StackExchange.Redis/issues/2223): Resolve sync-context issues (missing `ConfigureAwait(false)`) ([#2229 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2229)) -- Fix [#1968](https://github.com/StackExchange/StackExchange.Redis/issues/1968): Improved handling of EVAL scripts during server restarts and failovers, detecting and re-sending the script for a retry when needed ([#2170 by martintmk](https://github.com/StackExchange/StackExchange.Redis/pull/2170)) -- Adds: `ConfigurationOptions.SslClientAuthenticationOptions` (`netcoreapp3.1`/`net5.0`+ only) to give more control over SSL/TLS authentication ([#2224 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2224)) -- Fix [#2240](https://github.com/StackExchange/StackExchange.Redis/pull/2241): Improve support for DNS-based IPv6 endpoints ([#2241 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2241)) -- Adds: `ConfigurationOptions.HeartbeatInterval` (**Advanced Setting** - [see docs](https://seredis.dev/Configuration#configuration-options)) To allow more finite control of the client heartbeat, which encompases how often command timeouts are actually evaluated - still defaults to 1,000 ms ([#2243 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2243)) -- Fix [#1879](https://github.com/StackExchange/StackExchange.Redis/issues/1879): Improve exception message when the wrong password is used ([#2246 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2246)) -- Fix [#2233](https://github.com/StackExchange/StackExchange.Redis/issues/2233): Repeated connection to Sentinel servers using the same ConfigurationOptions would fail ([#2242 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2242)) +- Fix [#2182](https://github.com/seredis/StackExchange.Redis/issues/2182): Be more flexible in which commands are "primary only" in order to support users with replicas that are explicitly configured to allow writes ([#2183 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2183)) +- Adds: `IConnectionMultiplexer` now implements `IAsyncDisposable` ([#2161 by kimsey0](https://github.com/seredis/StackExchange.Redis/pull/2161)) +- Adds: `IConnectionMultiplexer.GetServers()` to get all `IServer` instances for a multiplexer ([#2203 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2203)) +- Fix [#2016](https://github.com/seredis/StackExchange.Redis/issues/2016): Align server selection with supported commands (e.g. with writable servers) to reduce `Command cannot be issued to a replica` errors ([#2191 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2191)) +- Performance: Optimization around timeout processing to reduce lock contention in the case of many items that haven't yet timed out during a heartbeat ([#2217 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2217)) +- Fix [#2223](https://github.com/seredis/StackExchange.Redis/issues/2223): Resolve sync-context issues (missing `ConfigureAwait(false)`) ([#2229 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2229)) +- Fix [#1968](https://github.com/seredis/StackExchange.Redis/issues/1968): Improved handling of EVAL scripts during server restarts and failovers, detecting and re-sending the script for a retry when needed ([#2170 by martintmk](https://github.com/seredis/StackExchange.Redis/pull/2170)) +- Adds: `ConfigurationOptions.SslClientAuthenticationOptions` (`netcoreapp3.1`/`net5.0`+ only) to give more control over SSL/TLS authentication ([#2224 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2224)) +- Fix [#2240](https://github.com/seredis/StackExchange.Redis/pull/2241): Improve support for DNS-based IPv6 endpoints ([#2241 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2241)) +- Adds: `ConfigurationOptions.HeartbeatInterval` (**Advanced Setting** - [see docs](https://seredis.dev/Configuration#configuration-options)) To allow more finite control of the client heartbeat, which encompases how often command timeouts are actually evaluated - still defaults to 1,000 ms ([#2243 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2243)) +- Fix [#1879](https://github.com/seredis/StackExchange.Redis/issues/1879): Improve exception message when the wrong password is used ([#2246 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2246)) +- Fix [#2233](https://github.com/seredis/StackExchange.Redis/issues/2233): Repeated connection to Sentinel servers using the same ConfigurationOptions would fail ([#2242 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2242)) ## 2.6.48 -- URGENT Fix: [#2167](https://github.com/StackExchange/StackExchange.Redis/issues/2167), [#2176](https://github.com/StackExchange/StackExchange.Redis/issues/2176): fix error in batch/transaction handling that can result in out-of-order instructions ([#2177 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2177)) -- Fix: [#2164](https://github.com/StackExchange/StackExchange.Redis/issues/2164): fix `LuaScript.Prepare` for scripts that don't have parameters ([#2166 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2166)) +- URGENT Fix: [#2167](https://github.com/seredis/StackExchange.Redis/issues/2167), [#2176](https://github.com/seredis/StackExchange.Redis/issues/2176): fix error in batch/transaction handling that can result in out-of-order instructions ([#2177 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2177)) +- Fix: [#2164](https://github.com/seredis/StackExchange.Redis/issues/2164): fix `LuaScript.Prepare` for scripts that don't have parameters ([#2166 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2166)) ## 2.6.45 -- Adds: [Nullable reference type](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references) annotations ([#2041 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2041)) +- Adds: [Nullable reference type](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references) annotations ([#2041 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2041)) - Adds annotations themselves for nullability to everything in the library - Fixes a few internal edge cases that will now throw proper errors (rather than a downstream null reference) - Fixes inconsistencies with `null` vs. empty array returns (preferring an not-null empty array in those edge cases) - Note: does *not* increment a major version (as these are warnings to consumers), because: they're warnings (errors are opt-in), removing obsolete types with a 3.0 rev _would_ be binary breaking (this isn't), and reving to 3.0 would cause binding redirect pain for consumers. Bumping from 2.5 to 2.6 only for this change. -- Adds: Support for `COPY` with `.KeyCopy()`/`.KeyCopyAsync()` ([#2064 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2064)) -- Adds: Support for `LMOVE` with `.ListMove()`/`.ListMoveAsync()` ([#2065 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2065)) -- Adds: Support for `ZRANDMEMBER` with `.SortedSetRandomMember()`/`.SortedSetRandomMemberAsync()`, `.SortedSetRandomMembers()`/`.SortedSetRandomMembersAsync()`, and `.SortedSetRandomMembersWithScores()`/`.SortedSetRandomMembersWithScoresAsync()` ([#2076 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2076)) -- Adds: Support for `SMISMEMBER` with `.SetContains()`/`.SetContainsAsync()` ([#2077 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2077)) -- Adds: Support for `ZDIFF`, `ZDIFFSTORE`, `ZINTER`, `ZINTERCARD`, and `ZUNION` with `.SortedSetCombine()`/`.SortedSetCombineAsync()`, `.SortedSetCombineWithScores()`/`.SortedSetCombineWithScoresAsync()`, and `.SortedSetIntersectionLength()`/`.SortedSetIntersectionLengthAsync()` ([#2075 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2075)) -- Adds: Support for `SINTERCARD` with `.SetIntersectionLength()`/`.SetIntersectionLengthAsync()` ([#2078 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2078)) -- Adds: Support for `LPOS` with `.ListPosition()`/`.ListPositionAsync()` and `.ListPositions()`/`.ListPositionsAsync()` ([#2080 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2080)) -- Adds: Support for `ZMSCORE` with `.SortedSetScores()`/.`SortedSetScoresAsync()` ([#2082 by ttingen](https://github.com/StackExchange/StackExchange.Redis/pull/2082)) -- Adds: Support for `NX | XX | GT | LT` to `EXPIRE`, `EXPIREAT`, `PEXPIRE`, and `PEXPIREAT` with `.KeyExpire()`/`.KeyExpireAsync()` ([#2083 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2083)) -- Adds: Support for `EXPIRETIME`, and `PEXPIRETIME` with `.KeyExpireTime()`/`.KeyExpireTimeAsync()` ([#2083 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2083)) -- Fix: For streams, properly hash `XACK`, `XCLAIM`, and `XPENDING` in cluster scenarios to eliminate `MOVED` retries ([#2085 by nielsderdaele](https://github.com/StackExchange/StackExchange.Redis/pull/2085)) -- Adds: Support for `OBJECT REFCOUNT` with `.KeyRefCount()`/`.KeyRefCountAsync()` ([#2087 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2087)) -- Adds: Support for `OBJECT ENCODING` with `.KeyEncoding()`/`.KeyEncodingAsync()` ([#2088 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2088)) -- Adds: Support for `GEOSEARCH` with `.GeoSearch()`/`.GeoSearchAsync()` ([#2089 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2089)) -- Adds: Support for `GEOSEARCHSTORE` with `.GeoSearchAndStore()`/`.GeoSearchAndStoreAsync()` ([#2089 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2089)) -- Adds: Support for `HRANDFIELD` with `.HashRandomField()`/`.HashRandomFieldAsync()`, `.HashRandomFields()`/`.HashRandomFieldsAsync()`, and `.HashRandomFieldsWithValues()`/`.HashRandomFieldsWithValuesAsync()` ([#2090 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2090)) -- Adds: Support for `LMPOP` with `.ListLeftPop()`/`.ListLeftPopAsync()` and `.ListRightPop()`/`.ListRightPopAsync()` ([#2094 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2094)) -- Adds: Support for `ZMPOP` with `.SortedSetPop()`/`.SortedSetPopAsync()` ([#2094 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2094)) -- Adds: Support for `XAUTOCLAIM` with `.StreamAutoClaim()`/.`StreamAutoClaimAsync()` and `.StreamAutoClaimIdsOnly()`/.`StreamAutoClaimIdsOnlyAsync()` ([#2095 by ttingen](https://github.com/StackExchange/StackExchange.Redis/pull/2095)) -- Fix [#2071](https://github.com/StackExchange/StackExchange.Redis/issues/2071): Add `.StringSet()`/`.StringSetAsync()` overloads for source compat broken for 1 case in 2.5.61 ([#2098 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2098)) -- Fix [#2086](https://github.com/StackExchange/StackExchange.Redis/issues/2086): Correct HashSlot calculations for `XREAD` and `XREADGROUP` commands ([#2093 by nielsderdaele](https://github.com/StackExchange/StackExchange.Redis/pull/2093)) -- Adds: Support for `LCS` with `.StringLongestCommonSubsequence()`/`.StringLongestCommonSubsequence()`, `.StringLongestCommonSubsequenceLength()`/`.StringLongestCommonSubsequenceLengthAsync()`, and `.StringLongestCommonSubsequenceWithMatches()`/`.StringLongestCommonSubsequenceWithMatchesAsync()` ([#2104 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2104)) -- Adds: Support for `OBJECT FREQ` with `.KeyFrequency()`/`.KeyFrequencyAsync()` ([#2105 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2105)) -- Performance: Avoids allocations when computing cluster hash slots or testing key equality ([#2110 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2110)) -- Adds: Support for `SORT_RO` with `.Sort()`/`.SortAsync()` ([#2111 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2111)) -- Adds: Support for `BIT | BYTE` to `BITCOUNT` and `BITPOS` with `.StringBitCount()`/`.StringBitCountAsync()` and `.StringBitPosition()`/`.StringBitPositionAsync()` ([#2116 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2116)) -- Adds: Support for pub/sub payloads that are unary arrays ([#2118 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2118)) -- Fix: Sentinel timer race during dispose ([#2133 by ewisuri](https://github.com/StackExchange/StackExchange.Redis/pull/2133)) -- Adds: Support for `GT`, `LT`, and `CH` on `ZADD` with `.SortedSetAdd()`/`.SortedSetAddAsync()` and `.SortedSetUpdate()`/`.SortedSetUpdateAsync()` ([#2136 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2136)) -- Adds: Support for `COMMAND COUNT`, `COMMAND GETKEYS`, and `COMMAND LIST`, with `.CommandCount()`/`.CommandCountAsync()`, `.CommandGetKeys()`/`.CommandGetKeysAsync()`, and `.CommandList()`/`.CommandListAsync()` ([#2143 by shacharPash](https://github.com/StackExchange/StackExchange.Redis/pull/2143)) +- Adds: Support for `COPY` with `.KeyCopy()`/`.KeyCopyAsync()` ([#2064 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2064)) +- Adds: Support for `LMOVE` with `.ListMove()`/`.ListMoveAsync()` ([#2065 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2065)) +- Adds: Support for `ZRANDMEMBER` with `.SortedSetRandomMember()`/`.SortedSetRandomMemberAsync()`, `.SortedSetRandomMembers()`/`.SortedSetRandomMembersAsync()`, and `.SortedSetRandomMembersWithScores()`/`.SortedSetRandomMembersWithScoresAsync()` ([#2076 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2076)) +- Adds: Support for `SMISMEMBER` with `.SetContains()`/`.SetContainsAsync()` ([#2077 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2077)) +- Adds: Support for `ZDIFF`, `ZDIFFSTORE`, `ZINTER`, `ZINTERCARD`, and `ZUNION` with `.SortedSetCombine()`/`.SortedSetCombineAsync()`, `.SortedSetCombineWithScores()`/`.SortedSetCombineWithScoresAsync()`, and `.SortedSetIntersectionLength()`/`.SortedSetIntersectionLengthAsync()` ([#2075 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2075)) +- Adds: Support for `SINTERCARD` with `.SetIntersectionLength()`/`.SetIntersectionLengthAsync()` ([#2078 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2078)) +- Adds: Support for `LPOS` with `.ListPosition()`/`.ListPositionAsync()` and `.ListPositions()`/`.ListPositionsAsync()` ([#2080 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2080)) +- Adds: Support for `ZMSCORE` with `.SortedSetScores()`/.`SortedSetScoresAsync()` ([#2082 by ttingen](https://github.com/seredis/StackExchange.Redis/pull/2082)) +- Adds: Support for `NX | XX | GT | LT` to `EXPIRE`, `EXPIREAT`, `PEXPIRE`, and `PEXPIREAT` with `.KeyExpire()`/`.KeyExpireAsync()` ([#2083 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2083)) +- Adds: Support for `EXPIRETIME`, and `PEXPIRETIME` with `.KeyExpireTime()`/`.KeyExpireTimeAsync()` ([#2083 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2083)) +- Fix: For streams, properly hash `XACK`, `XCLAIM`, and `XPENDING` in cluster scenarios to eliminate `MOVED` retries ([#2085 by nielsderdaele](https://github.com/seredis/StackExchange.Redis/pull/2085)) +- Adds: Support for `OBJECT REFCOUNT` with `.KeyRefCount()`/`.KeyRefCountAsync()` ([#2087 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2087)) +- Adds: Support for `OBJECT ENCODING` with `.KeyEncoding()`/`.KeyEncodingAsync()` ([#2088 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2088)) +- Adds: Support for `GEOSEARCH` with `.GeoSearch()`/`.GeoSearchAsync()` ([#2089 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2089)) +- Adds: Support for `GEOSEARCHSTORE` with `.GeoSearchAndStore()`/`.GeoSearchAndStoreAsync()` ([#2089 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2089)) +- Adds: Support for `HRANDFIELD` with `.HashRandomField()`/`.HashRandomFieldAsync()`, `.HashRandomFields()`/`.HashRandomFieldsAsync()`, and `.HashRandomFieldsWithValues()`/`.HashRandomFieldsWithValuesAsync()` ([#2090 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2090)) +- Adds: Support for `LMPOP` with `.ListLeftPop()`/`.ListLeftPopAsync()` and `.ListRightPop()`/`.ListRightPopAsync()` ([#2094 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2094)) +- Adds: Support for `ZMPOP` with `.SortedSetPop()`/`.SortedSetPopAsync()` ([#2094 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2094)) +- Adds: Support for `XAUTOCLAIM` with `.StreamAutoClaim()`/.`StreamAutoClaimAsync()` and `.StreamAutoClaimIdsOnly()`/.`StreamAutoClaimIdsOnlyAsync()` ([#2095 by ttingen](https://github.com/seredis/StackExchange.Redis/pull/2095)) +- Fix [#2071](https://github.com/seredis/StackExchange.Redis/issues/2071): Add `.StringSet()`/`.StringSetAsync()` overloads for source compat broken for 1 case in 2.5.61 ([#2098 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2098)) +- Fix [#2086](https://github.com/seredis/StackExchange.Redis/issues/2086): Correct HashSlot calculations for `XREAD` and `XREADGROUP` commands ([#2093 by nielsderdaele](https://github.com/seredis/StackExchange.Redis/pull/2093)) +- Adds: Support for `LCS` with `.StringLongestCommonSubsequence()`/`.StringLongestCommonSubsequence()`, `.StringLongestCommonSubsequenceLength()`/`.StringLongestCommonSubsequenceLengthAsync()`, and `.StringLongestCommonSubsequenceWithMatches()`/`.StringLongestCommonSubsequenceWithMatchesAsync()` ([#2104 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2104)) +- Adds: Support for `OBJECT FREQ` with `.KeyFrequency()`/`.KeyFrequencyAsync()` ([#2105 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2105)) +- Performance: Avoids allocations when computing cluster hash slots or testing key equality ([#2110 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2110)) +- Adds: Support for `SORT_RO` with `.Sort()`/`.SortAsync()` ([#2111 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2111)) +- Adds: Support for `BIT | BYTE` to `BITCOUNT` and `BITPOS` with `.StringBitCount()`/`.StringBitCountAsync()` and `.StringBitPosition()`/`.StringBitPositionAsync()` ([#2116 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2116)) +- Adds: Support for pub/sub payloads that are unary arrays ([#2118 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/2118)) +- Fix: Sentinel timer race during dispose ([#2133 by ewisuri](https://github.com/seredis/StackExchange.Redis/pull/2133)) +- Adds: Support for `GT`, `LT`, and `CH` on `ZADD` with `.SortedSetAdd()`/`.SortedSetAddAsync()` and `.SortedSetUpdate()`/`.SortedSetUpdateAsync()` ([#2136 by Avital-Fine](https://github.com/seredis/StackExchange.Redis/pull/2136)) +- Adds: Support for `COMMAND COUNT`, `COMMAND GETKEYS`, and `COMMAND LIST`, with `.CommandCount()`/`.CommandCountAsync()`, `.CommandGetKeys()`/`.CommandGetKeysAsync()`, and `.CommandList()`/`.CommandListAsync()` ([#2143 by shacharPash](https://github.com/seredis/StackExchange.Redis/pull/2143)) ## 2.5.61 -- Adds: `GETEX` support with `.StringGetSetExpiry()`/`.StringGetSetExpiryAsync()` ([#1743 by benbryant0](https://github.com/StackExchange/StackExchange.Redis/pull/1743)) -- Fix [#1988](https://github.com/StackExchange/StackExchange.Redis/issues/1988): Don't issue `SELECT` commands if explicitly disabled ([#2023 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2023)) -- Adds: `KEEPTTL` support on `SET` operations ([#2029 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2029)) -- Fix: Allow `XTRIM` `MAXLEN` argument to be `0` ([#2030 by NicoAvanzDev](https://github.com/StackExchange/StackExchange.Redis/pull/2030)) -- Adds: `ConfigurationOptions.BeforeSocketConnect` for configuring sockets between creation and connection ([#2031 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2031)) -- Fix [#1813](https://github.com/StackExchange/StackExchange.Redis/issues/1813): Don't connect to endpoints we failed to parse ([#2042 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2042)) -- Fix: `ClientKill`/`ClientKillAsync` when using `ClientType` ([#2048 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2048)) -- Adds: Most `ConfigurationOptions` changes after `ConnectionMultiplexer` connections will now be respected, e.g. changing a timeout will work and changing a password for auth rotation would be used at the next reconnect ([#2050 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2050)) +- Adds: `GETEX` support with `.StringGetSetExpiry()`/`.StringGetSetExpiryAsync()` ([#1743 by benbryant0](https://github.com/seredis/StackExchange.Redis/pull/1743)) +- Fix [#1988](https://github.com/seredis/StackExchange.Redis/issues/1988): Don't issue `SELECT` commands if explicitly disabled ([#2023 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2023)) +- Adds: `KEEPTTL` support on `SET` operations ([#2029 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2029)) +- Fix: Allow `XTRIM` `MAXLEN` argument to be `0` ([#2030 by NicoAvanzDev](https://github.com/seredis/StackExchange.Redis/pull/2030)) +- Adds: `ConfigurationOptions.BeforeSocketConnect` for configuring sockets between creation and connection ([#2031 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2031)) +- Fix [#1813](https://github.com/seredis/StackExchange.Redis/issues/1813): Don't connect to endpoints we failed to parse ([#2042 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2042)) +- Fix: `ClientKill`/`ClientKillAsync` when using `ClientType` ([#2048 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2048)) +- Adds: Most `ConfigurationOptions` changes after `ConnectionMultiplexer` connections will now be respected, e.g. changing a timeout will work and changing a password for auth rotation would be used at the next reconnect ([#2050 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2050)) - **Obsolete**: This change also moves `ConnectionMultiplexer.IncludeDetailInExceptions` and `ConnectionMultiplexer.IncludePerformanceCountersInExceptions` to `ConfigurationOptions`. The old properties are `[Obsolete]` proxies that work until 3.0 for compatibility. -- Adds: Support for `ZRANGESTORE` with `.SortedSetRangeAndStore()`/`.SortedSetRangeAndStoreAsync()` ([#2052 by slorello89](https://github.com/StackExchange/StackExchange.Redis/pull/2052)) +- Adds: Support for `ZRANGESTORE` with `.SortedSetRangeAndStore()`/`.SortedSetRangeAndStoreAsync()` ([#2052 by slorello89](https://github.com/seredis/StackExchange.Redis/pull/2052)) ## 2.5.43 -- Adds: Bounds checking for `ExponentialRetry` backoff policy ([#1921 by gliljas](https://github.com/StackExchange/StackExchange.Redis/pull/1921)) -- Adds: `DefaultOptionsProvider` support for endpoint-based defaults configuration ([#1987 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1987)) -- Adds: Envoy proxy support ([#1989 by rkarthick](https://github.com/StackExchange/StackExchange.Redis/pull/1989)) -- Performance: When `SUBSCRIBE` is disabled, give proper errors and connect faster ([#2001 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2001)) -- Adds: `GET` on `SET` command support (present in Redis 6.2+ - [#2003 by martinekvili](https://github.com/StackExchange/StackExchange.Redis/pull/2003)) -- Performance: Improves concurrent load performance when backlogs are utilized ([#2008 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2008)) -- Stability: Improves cluster connections when `CLUSTER` command is disabled ([#2014 by tylerohlsen](https://github.com/StackExchange/StackExchange.Redis/pull/2014)) -- Logging: Improves connection logging and adds overall timing to it ([#2019 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2019)) +- Adds: Bounds checking for `ExponentialRetry` backoff policy ([#1921 by gliljas](https://github.com/seredis/StackExchange.Redis/pull/1921)) +- Adds: `DefaultOptionsProvider` support for endpoint-based defaults configuration ([#1987 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1987)) +- Adds: Envoy proxy support ([#1989 by rkarthick](https://github.com/seredis/StackExchange.Redis/pull/1989)) +- Performance: When `SUBSCRIBE` is disabled, give proper errors and connect faster ([#2001 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2001)) +- Adds: `GET` on `SET` command support (present in Redis 6.2+ - [#2003 by martinekvili](https://github.com/seredis/StackExchange.Redis/pull/2003)) +- Performance: Improves concurrent load performance when backlogs are utilized ([#2008 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2008)) +- Stability: Improves cluster connections when `CLUSTER` command is disabled ([#2014 by tylerohlsen](https://github.com/seredis/StackExchange.Redis/pull/2014)) +- Logging: Improves connection logging and adds overall timing to it ([#2019 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/2019)) ## 2.5.27 (prerelease) -- Adds: a backlog/retry mechanism for commands issued while a connection isn't available ([#1912 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1912)) +- Adds: a backlog/retry mechanism for commands issued while a connection isn't available ([#1912 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1912)) - Commands will be queued if a multiplexer isn't yet connected to a Redis server. - Commands will be queued if a connection is lost and then sent to the server when the connection is restored. - All commands queued will only remain in the backlog for the duration of the configured timeout. - To revert to previous behavior, a new `ConfigurationOptions.BacklogPolicy` is available - old behavior is configured via `options.BacklogPolicy = BacklogPolicy.FailFast`. This backlogs nothing and fails commands immediately if no connection is available. -- Adds: Makes `StreamEntry` constructor public for better unit test experience ([#1923 by WeihanLi](https://github.com/StackExchange/StackExchange.Redis/pull/1923)) -- Fix: Integer overflow error (issue #1926) with 2GiB+ result payloads ([#1928 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1928)) -- Change: Update assumed redis versions to v2.8 or v4.0 in the Azure case ([#1929 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1929)) -- Fix: Profiler showing `EVAL` instead `EVALSHA` ([#1930 by martinpotter](https://github.com/StackExchange/StackExchange.Redis/pull/1930)) -- Performance: Moved tiebreaker fetching in connections into the handshake phase (streamline + simplification) ([#1931 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1931)) +- Adds: Makes `StreamEntry` constructor public for better unit test experience ([#1923 by WeihanLi](https://github.com/seredis/StackExchange.Redis/pull/1923)) +- Fix: Integer overflow error (issue #1926) with 2GiB+ result payloads ([#1928 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1928)) +- Change: Update assumed redis versions to v2.8 or v4.0 in the Azure case ([#1929 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1929)) +- Fix: Profiler showing `EVAL` instead `EVALSHA` ([#1930 by martinpotter](https://github.com/seredis/StackExchange.Redis/pull/1930)) +- Performance: Moved tiebreaker fetching in connections into the handshake phase (streamline + simplification) ([#1931 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1931)) - Stability: Fixed potential disposed object usage around Arenas (pulling in [Piplines.Sockets.Unofficial#63](https://github.com/mgravell/Pipelines.Sockets.Unofficial/pull/63) by MarcGravell) -- Adds: Thread pool work item stats to exception messages to help diagnose contention ([#1964 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1964)) -- Fix/Performance: Overhauls pub/sub implementation for correctness ([#1947 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1947)) +- Adds: Thread pool work item stats to exception messages to help diagnose contention ([#1964 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1964)) +- Fix/Performance: Overhauls pub/sub implementation for correctness ([#1947 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1947)) - Fixes a race in subscribing right after connected - Fixes a race in subscribing immediately before a publish - Fixes subscription routing on clusters (spreading instead of choosing 1 node) - More correctly reconnects subscriptions on connection failures, including to other endpoints -- Adds "(vX.X.X)" version suffix to the default client ID so server-side `CLIENT LIST` can more easily see what's connected ([#1985 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1985)) -- Fix: Properly including or excluding key names on some message failures ([#1990 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1990)) -- Fix: Correct return of nil results in `LPOP`, `RPOP`, `SRANDMEMBER`, and `SPOP` ([#1993 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1993)) +- Adds "(vX.X.X)" version suffix to the default client ID so server-side `CLIENT LIST` can more easily see what's connected ([#1985 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1985)) +- Fix: Properly including or excluding key names on some message failures ([#1990 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1990)) +- Fix: Correct return of nil results in `LPOP`, `RPOP`, `SRANDMEMBER`, and `SPOP` ([#1993 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1993)) ## 2.2.88 -- Change: Connection backoff default is now exponential instead of linear ([#1896 by lolodi](https://github.com/StackExchange/StackExchange.Redis/pull/1896)) -- Adds: Support for `NodeMaintenanceScaleComplete` event (handles Redis cluster scaling) ([#1902 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1902)) +- Change: Connection backoff default is now exponential instead of linear ([#1896 by lolodi](https://github.com/seredis/StackExchange.Redis/pull/1896)) +- Adds: Support for `NodeMaintenanceScaleComplete` event (handles Redis cluster scaling) ([#1902 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1902)) ## 2.2.79 -- NRediSearch: Support on json index ([#1808 by AvitalFineRedis](https://github.com/StackExchange/StackExchange.Redis/pull/1808)) -- NRediSearch: Support sortable TagFields and unNormalizedForm for Tag & Text Fields ([#1862 by slorello89 & AvitalFineRedis](https://github.com/StackExchange/StackExchange.Redis/pull/1862)) -- Fix: Potential errors getting socket bytes ([#1836 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1836)) -- Logging: Adds (.NET Version and timestamps) for better debugging ([#1796 by philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/1796)) -- Adds: `Condition` APIs (transactions), now supports `StreamLengthEqual` and variants ([#1807 by AlphaGremlin](https://github.com/StackExchange/StackExchange.Redis/pull/1807)) -- Adds: Support for count argument to `ListLeftPop`, `ListLeftPopAsync`, `ListRightPop`, and `ListRightPopAsync` ([#1850 by jjfmarket](https://github.com/StackExchange/StackExchange.Redis/pull/1850)) -- Fix: Potential task/thread exhaustion from the backlog processor ([#1854 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1854)) -- Adds: Support for listening to Azure Maintenance Events ([#1876 by amsoedal](https://github.com/StackExchange/StackExchange.Redis/pull/1876)) -- Adds: `StringGetDelete`/`StringGetDeleteAsync` APIs for Redis `GETDEL` command([#1840 by WeihanLi](https://github.com/StackExchange/StackExchange.Redis/pull/1840)) +- NRediSearch: Support on json index ([#1808 by AvitalFineRedis](https://github.com/seredis/StackExchange.Redis/pull/1808)) +- NRediSearch: Support sortable TagFields and unNormalizedForm for Tag & Text Fields ([#1862 by slorello89 & AvitalFineRedis](https://github.com/seredis/StackExchange.Redis/pull/1862)) +- Fix: Potential errors getting socket bytes ([#1836 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1836)) +- Logging: Adds (.NET Version and timestamps) for better debugging ([#1796 by philon-msft](https://github.com/seredis/StackExchange.Redis/pull/1796)) +- Adds: `Condition` APIs (transactions), now supports `StreamLengthEqual` and variants ([#1807 by AlphaGremlin](https://github.com/seredis/StackExchange.Redis/pull/1807)) +- Adds: Support for count argument to `ListLeftPop`, `ListLeftPopAsync`, `ListRightPop`, and `ListRightPopAsync` ([#1850 by jjfmarket](https://github.com/seredis/StackExchange.Redis/pull/1850)) +- Fix: Potential task/thread exhaustion from the backlog processor ([#1854 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1854)) +- Adds: Support for listening to Azure Maintenance Events ([#1876 by amsoedal](https://github.com/seredis/StackExchange.Redis/pull/1876)) +- Adds: `StringGetDelete`/`StringGetDeleteAsync` APIs for Redis `GETDEL` command([#1840 by WeihanLi](https://github.com/seredis/StackExchange.Redis/pull/1840)) ## 2.2.62 -- Stability: Sentinel potential memory leak fix in OnManagedConnectionFailed handler ([#1710 by alexSatov](https://github.com/StackExchange/StackExchange.Redis/pull/1710)) -- Fix: `GetOutstandingCount` could obscure underlying faults by faulting itself ([#1792 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1792)) -- Fix [#1719](https://github.com/StackExchange/StackExchange.Redis/issues/1791): With backlog messages becoming reordered ([#1779 by TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/1779)) +- Stability: Sentinel potential memory leak fix in OnManagedConnectionFailed handler ([#1710 by alexSatov](https://github.com/seredis/StackExchange.Redis/pull/1710)) +- Fix: `GetOutstandingCount` could obscure underlying faults by faulting itself ([#1792 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1792)) +- Fix [#1719](https://github.com/seredis/StackExchange.Redis/issues/1791): With backlog messages becoming reordered ([#1779 by TimLovellSmith](https://github.com/seredis/StackExchange.Redis/pull/1779)) ## 2.2.50 -- Performance: Optimization for PING accuracy ([#1714 by eduardobr](https://github.com/StackExchange/StackExchange.Redis/pull/1714)) -- Fix: Improvement to reconnect logic (exponential backoff) ([#1735 by deepakverma](https://github.com/StackExchange/StackExchange.Redis/pull/1735)) -- Adds: Refresh replica endpoint list on failover ([#1684 by laurauzcategui](https://github.com/StackExchange/StackExchange.Redis/pull/1684)) -- Fix: `ReconfigureAsync` re-entrancy (caused connection issues) ([#1772 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1772)) -- Fix: `ReconfigureAsync` Sentinel race resulting in NoConnectionAvailable when using DemandMaster ([#1773 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1773)) -- Stability: Resolve race in AUTH and other connection reconfigurations ([#1759 by TimLovellSmith and NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1759)) +- Performance: Optimization for PING accuracy ([#1714 by eduardobr](https://github.com/seredis/StackExchange.Redis/pull/1714)) +- Fix: Improvement to reconnect logic (exponential backoff) ([#1735 by deepakverma](https://github.com/seredis/StackExchange.Redis/pull/1735)) +- Adds: Refresh replica endpoint list on failover ([#1684 by laurauzcategui](https://github.com/seredis/StackExchange.Redis/pull/1684)) +- Fix: `ReconfigureAsync` re-entrancy (caused connection issues) ([#1772 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1772)) +- Fix: `ReconfigureAsync` Sentinel race resulting in NoConnectionAvailable when using DemandMaster ([#1773 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1773)) +- Stability: Resolve race in AUTH and other connection reconfigurations ([#1759 by TimLovellSmith and NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1759)) ## 2.2.4 -- Fix: Ambiguous signature of the new `RPUSHX`/`LPUSHX` methods ([#1620 by stefanloerwald](https://github.com/StackExchange/StackExchange.Redis/pull/1620)) +- Fix: Ambiguous signature of the new `RPUSHX`/`LPUSHX` methods ([#1620 by stefanloerwald](https://github.com/seredis/StackExchange.Redis/pull/1620)) ## 2.2.3 - Adds: .NET 5 target -- Fix: Mutex race condition ([#1585 by arsnyder16](https://github.com/StackExchange/StackExchange.Redis/pull/1585)) -- Adds: `CheckCertificateRevocation` can be controlled via the config string ([#1591 by lwlwalker](https://github.com/StackExchange/StackExchange.Redis/pull/1591)) -- Fix: Range end-value inversion ([#1573 by tombatron](https://github.com/StackExchange/StackExchange.Redis/pull/1573)) -- Adds: `ROLE` support ([#1551 by zmj](https://github.com/StackExchange/StackExchange.Redis/pull/1551)) -- Adds: varadic `RPUSHX`/`LPUSHX` support ([#1557 by dmytrohridin](https://github.com/StackExchange/StackExchange.Redis/pull/1557)) -- Fix: Server-selection strategy race condition ([#1532 by deepakverma](https://github.com/StackExchange/StackExchange.Redis/pull/1532)) -- Fix: Sentinel default port ([#1525 by ejsmith](https://github.com/StackExchange/StackExchange.Redis/pull/1525)) -- Fix: `Int64` parse scenario ([#1568 by arsnyder16](https://github.com/StackExchange/StackExchange.Redis/pull/1568)) -- Add: Force replication check during failover ([#1563 by aravindyeduvaka & joroda](https://github.com/StackExchange/StackExchange.Redis/pull/1563)) +- Fix: Mutex race condition ([#1585 by arsnyder16](https://github.com/seredis/StackExchange.Redis/pull/1585)) +- Adds: `CheckCertificateRevocation` can be controlled via the config string ([#1591 by lwlwalker](https://github.com/seredis/StackExchange.Redis/pull/1591)) +- Fix: Range end-value inversion ([#1573 by tombatron](https://github.com/seredis/StackExchange.Redis/pull/1573)) +- Adds: `ROLE` support ([#1551 by zmj](https://github.com/seredis/StackExchange.Redis/pull/1551)) +- Adds: varadic `RPUSHX`/`LPUSHX` support ([#1557 by dmytrohridin](https://github.com/seredis/StackExchange.Redis/pull/1557)) +- Fix: Server-selection strategy race condition ([#1532 by deepakverma](https://github.com/seredis/StackExchange.Redis/pull/1532)) +- Fix: Sentinel default port ([#1525 by ejsmith](https://github.com/seredis/StackExchange.Redis/pull/1525)) +- Fix: `Int64` parse scenario ([#1568 by arsnyder16](https://github.com/seredis/StackExchange.Redis/pull/1568)) +- Add: Force replication check during failover ([#1563 by aravindyeduvaka & joroda](https://github.com/seredis/StackExchange.Redis/pull/1563)) - Documentation tweaks (multiple) -- Fix: Backlog contention issue ([#1612 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1612/), see also [#1574 by devbv](https://github.com/StackExchange/StackExchange.Redis/pull/1574/)) +- Fix: Backlog contention issue ([#1612 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1612/), see also [#1574 by devbv](https://github.com/seredis/StackExchange.Redis/pull/1574/)) ## 2.1.58 @@ -496,28 +496,28 @@ Changes: ## 2.1.50 -- Adds: Bind directly to sentinel-managed instances from a configuration string/object ([#1431 by ejsmith](https://github.com/StackExchange/StackExchange.Redis/pull/1431)) -- Adds: `last-delivered-id` to `StreamGroupInfo` ([#1477 by AndyPook](https://github.com/StackExchange/StackExchange.Redis/pull/1477)) -- Change: Update naming of replication-related commands to reflect Redis 5 naming ([#1488 by mgravell](https://github.com/StackExchange/StackExchange.Redis/issues/1488) & [#945 by mgravell](https://github.com/StackExchange/StackExchange.Redis/issues/945)) -- Fix [#1460](https://github.com/StackExchange/StackExchange.Redis/issues/1460): `IServer` commands that are database-specific (`DBSIZE`, `FLUSHDB`, `KEYS`, `SCAN`) now respect the default database on the config ([#1468 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1468)) +- Adds: Bind directly to sentinel-managed instances from a configuration string/object ([#1431 by ejsmith](https://github.com/seredis/StackExchange.Redis/pull/1431)) +- Adds: `last-delivered-id` to `StreamGroupInfo` ([#1477 by AndyPook](https://github.com/seredis/StackExchange.Redis/pull/1477)) +- Change: Update naming of replication-related commands to reflect Redis 5 naming ([#1488 by mgravell](https://github.com/seredis/StackExchange.Redis/issues/1488) & [#945 by mgravell](https://github.com/seredis/StackExchange.Redis/issues/945)) +- Fix [#1460](https://github.com/seredis/StackExchange.Redis/issues/1460): `IServer` commands that are database-specific (`DBSIZE`, `FLUSHDB`, `KEYS`, `SCAN`) now respect the default database on the config ([#1468 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1468)) - Library updates ## 2.1.39 -- Fix: Mutex around connection was not "fair"; in specific scenario could lead to out-of-order commands ([#1440 by kennygea](https://github.com/StackExchange/StackExchange.Redis/pull/1440)) -- Fix [#1432](https://github.com/StackExchange/StackExchange.Redis/issues/1432): Update dependencies -- Fix: Timing error on linux ([#1433 by pengweiqhca](https://github.com/StackExchange/StackExchange.Redis/pull/1433)) -- Fix: Add `auth` to command-map for Sentinel ([#1428 by ejsmith](https://github.com/StackExchange/StackExchange.Redis/pull/1428)) +- Fix: Mutex around connection was not "fair"; in specific scenario could lead to out-of-order commands ([#1440 by kennygea](https://github.com/seredis/StackExchange.Redis/pull/1440)) +- Fix [#1432](https://github.com/seredis/StackExchange.Redis/issues/1432): Update dependencies +- Fix: Timing error on linux ([#1433 by pengweiqhca](https://github.com/seredis/StackExchange.Redis/pull/1433)) +- Fix: Add `auth` to command-map for Sentinel ([#1428 by ejsmith](https://github.com/seredis/StackExchange.Redis/pull/1428)) ## 2.1.30 -- Build: Fix deterministic builds ([#1420 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1420)) +- Build: Fix deterministic builds ([#1420 by @mgravell](https://github.com/seredis/StackExchange.Redis/pull/1420)) ## 2.1.28 - Fix: Stability in new sentinel APIs -- Fix [#1407](https://github.com/StackExchange/StackExchange.Redis/issues/1407): Include `SslProtocolos` in `ConfigurationOptions.ToString()` ([#1408 by vksampath and Sampath Vuyyuru](https://github.com/StackExchange/StackExchange.Redis/pull/1408)) -- Fix: Clarify messaging around disconnected multiplexers ([#1396 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1396)) +- Fix [#1407](https://github.com/seredis/StackExchange.Redis/issues/1407): Include `SslProtocolos` in `ConfigurationOptions.ToString()` ([#1408 by vksampath and Sampath Vuyyuru](https://github.com/seredis/StackExchange.Redis/pull/1408)) +- Fix: Clarify messaging around disconnected multiplexers ([#1396 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1396)) - Change: Tweak methods of new sentinel API (this is technically a breaking change, but since this is a new API that was pulled quickly, we consider this to be acceptable) - Adds: New thread `SocketManager` mode (opt-in) to always use the regular thread-pool instead of the dedicated pool - Adds: Improved counters in/around error messages @@ -526,31 +526,31 @@ Changes: ## 2.1.0 -- Fix: Ensure active-message is cleared ([#1374 by hamish-omny](https://github.com/StackExchange/StackExchange.Redis/pull/1374)) -- Adds: Sentinel support ([#1067 by shadim](https://github.com/StackExchange/StackExchange.Redis/pull/1067), [#692 by lexxdark](https://github.com/StackExchange/StackExchange.Redis/pull/692)) -- Adds: `IAsyncEnumerable` scanning APIs now supported ([#1087 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1087)) +- Fix: Ensure active-message is cleared ([#1374 by hamish-omny](https://github.com/seredis/StackExchange.Redis/pull/1374)) +- Adds: Sentinel support ([#1067 by shadim](https://github.com/seredis/StackExchange.Redis/pull/1067), [#692 by lexxdark](https://github.com/seredis/StackExchange.Redis/pull/692)) +- Adds: `IAsyncEnumerable` scanning APIs now supported ([#1087 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1087)) - Adds: New API for use with misbehaving sync-contexts ([more info](https://seredis.dev/ThreadTheft)) -- Adds: `TOUCH` support ([#1291 by gkorland](https://github.com/StackExchange/StackExchange.Redis/pull/1291)) -- Adds: `Condition` API (transactions) now supports `SortedSetLengthEqual` ([#1332 by phosphene47](https://github.com/StackExchange/StackExchange.Redis/pull/1332)) -- Adds: `SocketManager` is now more configurable ([#1115 by naile](https://github.com/StackExchange/StackExchange.Redis/pull/1115)) -- Adds: NRediSearch updated in line with JRediSearch ([#1267 by tombatron](https://github.com/StackExchange/StackExchange.Redis/pull/1267), [#1199 by oruchreis](https://github.com/StackExchange/StackExchange.Redis/pull/1199)) -- Adds: Support for `CheckCertificatRevocation` configuration ([#1234 by BLun78 and V912736](https://github.com/StackExchange/StackExchange.Redis/pull/1234)) -- Adds: More details about exceptions ([#1190 by marafiq](https://github.com/StackExchange/StackExchange.Redis/pull/1190)) -- Adds: Updated `StreamCreateConsumerGroup` methods to use the `MKSTREAM` option ([#1141 via ttingen](https://github.com/StackExchange/StackExchange.Redis/pull/1141)) -- Adds: Support for NOACK in the StreamReadGroup methods ([#1154 by ttingen](https://github.com/StackExchange/StackExchange.Redis/pull/1154)) -- Adds: Event-args now mockable ([#1326 by n1l](https://github.com/StackExchange/StackExchange.Redis/pull/1326)) -- Fix: No-op when adding 0 values to a set ([#1283 by omeaart](https://github.com/StackExchange/StackExchange.Redis/pull/1283)) -- Adds: Support for `LATENCY` and `MEMORY` ([#1204 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1204)) -- Adds: Support for `HSTRLEN` ([#1241 by eitanhs](https://github.com/StackExchange/StackExchange.Redis/pull/1241)) -- Adds: `GeoRadiusResult` is now mockable ([#1175 by firenero](https://github.com/StackExchange/StackExchange.Redis/pull/1175)) -- Fix: Various documentation fixes ([#1162 by SnakyBeaky](https://github.com/StackExchange/StackExchange.Redis/pull/1162), [#1135 by ttingen](https://github.com/StackExchange/StackExchange.Redis/pull/1135), [#1203 by caveman-dick](https://github.com/StackExchange/StackExchange.Redis/pull/1203), [#1240 by Excelan](https://github.com/StackExchange/StackExchange.Redis/pull/1240), [#1245 by francoance](https://github.com/StackExchange/StackExchange.Redis/pull/1245), [#1159 by odyth](https://github.com/StackExchange/StackExchange.Redis/pull/1159), [#1311 by DillonAd](https://github.com/StackExchange/StackExchange.Redis/pull/1311), [#1339 by vp89](https://github.com/StackExchange/StackExchange.Redis/pull/1339), [#1336 by ERGeorgiev](https://github.com/StackExchange/StackExchange.Redis/issues/1336)) -- Fix: Rare race-condition around exception data ([#1342 by AdamOutcalt](https://github.com/StackExchange/StackExchange.Redis/pull/1342)) -- Fix: `ScriptEvaluateAsync` keyspace isolation ([#1377 by gliljas](https://github.com/StackExchange/StackExchange.Redis/pull/1377)) -- Fix: F# compatibility enhancements ([#1386 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1386)) -- Fix: Improved `ScriptResult` null support ([#1392 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1392)) -- Fix: Error with DNS resolution breaking endpoint iterator ([#1393 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1393)) -- Tests: Better docker support for tests ([#1389 by ejsmith](https://github.com/StackExchange/StackExchange.Redis/pull/1389), [#1391 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1391)) -- Tests: General test improvements ([#1183 by mtreske](https://github.com/StackExchange/StackExchange.Redis/issues/1183), [#1385 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1385), [#1384 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/1384)) +- Adds: `TOUCH` support ([#1291 by gkorland](https://github.com/seredis/StackExchange.Redis/pull/1291)) +- Adds: `Condition` API (transactions) now supports `SortedSetLengthEqual` ([#1332 by phosphene47](https://github.com/seredis/StackExchange.Redis/pull/1332)) +- Adds: `SocketManager` is now more configurable ([#1115 by naile](https://github.com/seredis/StackExchange.Redis/pull/1115)) +- Adds: NRediSearch updated in line with JRediSearch ([#1267 by tombatron](https://github.com/seredis/StackExchange.Redis/pull/1267), [#1199 by oruchreis](https://github.com/seredis/StackExchange.Redis/pull/1199)) +- Adds: Support for `CheckCertificatRevocation` configuration ([#1234 by BLun78 and V912736](https://github.com/seredis/StackExchange.Redis/pull/1234)) +- Adds: More details about exceptions ([#1190 by marafiq](https://github.com/seredis/StackExchange.Redis/pull/1190)) +- Adds: Updated `StreamCreateConsumerGroup` methods to use the `MKSTREAM` option ([#1141 via ttingen](https://github.com/seredis/StackExchange.Redis/pull/1141)) +- Adds: Support for NOACK in the StreamReadGroup methods ([#1154 by ttingen](https://github.com/seredis/StackExchange.Redis/pull/1154)) +- Adds: Event-args now mockable ([#1326 by n1l](https://github.com/seredis/StackExchange.Redis/pull/1326)) +- Fix: No-op when adding 0 values to a set ([#1283 by omeaart](https://github.com/seredis/StackExchange.Redis/pull/1283)) +- Adds: Support for `LATENCY` and `MEMORY` ([#1204 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1204)) +- Adds: Support for `HSTRLEN` ([#1241 by eitanhs](https://github.com/seredis/StackExchange.Redis/pull/1241)) +- Adds: `GeoRadiusResult` is now mockable ([#1175 by firenero](https://github.com/seredis/StackExchange.Redis/pull/1175)) +- Fix: Various documentation fixes ([#1162 by SnakyBeaky](https://github.com/seredis/StackExchange.Redis/pull/1162), [#1135 by ttingen](https://github.com/seredis/StackExchange.Redis/pull/1135), [#1203 by caveman-dick](https://github.com/seredis/StackExchange.Redis/pull/1203), [#1240 by Excelan](https://github.com/seredis/StackExchange.Redis/pull/1240), [#1245 by francoance](https://github.com/seredis/StackExchange.Redis/pull/1245), [#1159 by odyth](https://github.com/seredis/StackExchange.Redis/pull/1159), [#1311 by DillonAd](https://github.com/seredis/StackExchange.Redis/pull/1311), [#1339 by vp89](https://github.com/seredis/StackExchange.Redis/pull/1339), [#1336 by ERGeorgiev](https://github.com/seredis/StackExchange.Redis/issues/1336)) +- Fix: Rare race-condition around exception data ([#1342 by AdamOutcalt](https://github.com/seredis/StackExchange.Redis/pull/1342)) +- Fix: `ScriptEvaluateAsync` keyspace isolation ([#1377 by gliljas](https://github.com/seredis/StackExchange.Redis/pull/1377)) +- Fix: F# compatibility enhancements ([#1386 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1386)) +- Fix: Improved `ScriptResult` null support ([#1392 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1392)) +- Fix: Error with DNS resolution breaking endpoint iterator ([#1393 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1393)) +- Tests: Better docker support for tests ([#1389 by ejsmith](https://github.com/seredis/StackExchange.Redis/pull/1389), [#1391 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1391)) +- Tests: General test improvements ([#1183 by mtreske](https://github.com/seredis/StackExchange.Redis/issues/1183), [#1385 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1385), [#1384 by NickCraver](https://github.com/seredis/StackExchange.Redis/pull/1384)) ## 2.0.601 @@ -558,15 +558,15 @@ Changes: ## 2.0.600 -- Adds: `ulong` support to `RedisValue` and `RedisResult` ([#1104 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/1104)) -- Fix: Remove odd equality: `"-" != 0` (we do, however, still allow `"-0"`, as that is at least semantically valid, and is logically `== 0`) (related to [#1103](https://github.com/StackExchange/StackExchange.Redis/issues/1103)) -- Performance: Rework how pub/sub queues are stored - reduces delegate overheads (related to [#1101](https://github.com/StackExchange/StackExchange.Redis/issues/1101)) -- Fix [#1108](https://github.com/StackExchange/StackExchange.Redis/issues/1108): Ensure that we don't try appending log data to the `TextWriter` once we've returned from a method that accepted one +- Adds: `ulong` support to `RedisValue` and `RedisResult` ([#1104 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/1104)) +- Fix: Remove odd equality: `"-" != 0` (we do, however, still allow `"-0"`, as that is at least semantically valid, and is logically `== 0`) (related to [#1103](https://github.com/seredis/StackExchange.Redis/issues/1103)) +- Performance: Rework how pub/sub queues are stored - reduces delegate overheads (related to [#1101](https://github.com/seredis/StackExchange.Redis/issues/1101)) +- Fix [#1108](https://github.com/seredis/StackExchange.Redis/issues/1108): Ensure that we don't try appending log data to the `TextWriter` once we've returned from a method that accepted one ## 2.0.593 - Performance: Unify spin-wait usage on sync/async paths to one competitor -- Fix [#1101](https://github.com/StackExchange/StackExchange.Redis/issues/1101): When a `ChannelMessageQueue` is involved, unsubscribing *via any route* should still unsubscribe and mark the queue-writer as complete +- Fix [#1101](https://github.com/seredis/StackExchange.Redis/issues/1101): When a `ChannelMessageQueue` is involved, unsubscribing *via any route* should still unsubscribe and mark the queue-writer as complete ## 2.0.588 @@ -586,20 +586,20 @@ Changes: ## 2.0.519 -- Fix [#1007](https://github.com/StackExchange/StackExchange.Redis/issues/1007): Adapt to late changes in the RC streams API ([#983 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/983)) -- Documentation fixes ([#997 by MerelyRBLX](https://github.com/StackExchange/StackExchange.Redis/pull/997), [#1005 by zBrianW](https://github.com/StackExchange/StackExchange.Redis/pull/1005)) +- Fix [#1007](https://github.com/seredis/StackExchange.Redis/issues/1007): Adapt to late changes in the RC streams API ([#983 by mgravell](https://github.com/seredis/StackExchange.Redis/pull/983)) +- Documentation fixes ([#997 by MerelyRBLX](https://github.com/seredis/StackExchange.Redis/pull/997), [#1005 by zBrianW](https://github.com/seredis/StackExchange.Redis/pull/1005)) - Build: Switch to SDK 2.1.500 ## 2.0.513 -- Fix [#961](https://github.com/StackExchange/StackExchange.Redis/issues/962): fix assembly binding redirect problems; IMPORTANT: this drops to an older `System.Buffers` version - if you have manually added redirects for `4.0.3.0`, you may need to manually update to `4.0.2.0` (or remove completely) -- Fix [#962](https://github.com/StackExchange/StackExchange.Redis/issues/962): Avoid NRE in edge-case when fetching bridge +- Fix [#961](https://github.com/seredis/StackExchange.Redis/issues/962): fix assembly binding redirect problems; IMPORTANT: this drops to an older `System.Buffers` version - if you have manually added redirects for `4.0.3.0`, you may need to manually update to `4.0.2.0` (or remove completely) +- Fix [#962](https://github.com/seredis/StackExchange.Redis/issues/962): Avoid NRE in edge-case when fetching bridge ## 2.0.505 -- Fix [#943](https://github.com/StackExchange/StackExchange.Redis/issues/943): Ensure transaction inner tasks are completed prior to completing the outer transaction task -- Fix [#946](https://github.com/StackExchange/StackExchange.Redis/issues/946): Reinstate missing `TryParse` methods on `RedisValue` -- Fix [#940](https://github.com/StackExchange/StackExchange.Redis/issues/940): Off-by-one on pre-boxed integer cache (NRediSearch) +- Fix [#943](https://github.com/seredis/StackExchange.Redis/issues/943): Ensure transaction inner tasks are completed prior to completing the outer transaction task +- Fix [#946](https://github.com/seredis/StackExchange.Redis/issues/946): Reinstate missing `TryParse` methods on `RedisValue` +- Fix [#940](https://github.com/seredis/StackExchange.Redis/issues/940): Off-by-one on pre-boxed integer cache (NRediSearch) ## 2.0.495 @@ -625,7 +625,7 @@ Changes: - Fix: A *lot* of general bugs and issues have been resolved - **Break**: `RedisValue.TryParse` was accidentally omitted in the overhaul; this has been rectified and will be available in the next build -A more complete list of issues addressed can be seen in [this tracking issue](https://github.com/StackExchange/StackExchange.Redis/issues/871) +A more complete list of issues addressed can be seen in [this tracking issue](https://github.com/seredis/StackExchange.Redis/issues/871) Note: we currently have no plans to do an additional `1.*` release. In particular, even though there was a `1.2.7-alpha` build on nuget, we *do not* currently have plans to release `1.2.7`. @@ -641,21 +641,21 @@ Note: we currently have no plans to do an additional `1.*` release. In particula ## 1.2.4 -- Fix: Incorrect build configuration ([#649 by jrlost](https://github.com/StackExchange/StackExchange.Redis/issues/649)) +- Fix: Incorrect build configuration ([#649 by jrlost](https://github.com/seredis/StackExchange.Redis/issues/649)) ## 1.2.3 -- Fix: When using `redis-cluster` with multiple replicas, use round-robin when selecting replica ([#610 by mgravell](https://github.com/StackExchange/StackExchange.Redis/issues/610)) -- Adds: Can specify `NoScriptCache` flag when using `ScriptEvaluate` to bypass all cache features (always uses `EVAL` instead of `SCRIPT LOAD` and `EVALSHA`) ([#617 by Funbit](https://github.com/StackExchange/StackExchange.Redis/issues/617)) +- Fix: When using `redis-cluster` with multiple replicas, use round-robin when selecting replica ([#610 by mgravell](https://github.com/seredis/StackExchange.Redis/issues/610)) +- Adds: Can specify `NoScriptCache` flag when using `ScriptEvaluate` to bypass all cache features (always uses `EVAL` instead of `SCRIPT LOAD` and `EVALSHA`) ([#617 by Funbit](https://github.com/seredis/StackExchange.Redis/issues/617)) ## 1.2.2 (preview) - **Break**: .NET 4.0 support is not in this build, due to [a build issue](https://github.com/dotnet/cli/issues/5993) - looking into solutions -- Adds: Make performance-counter tracking opt-in (`IncludePerformanceCountersInExceptions`) as it was causing problems ([#587 by AlexanderKot](https://github.com/StackExchange/StackExchange.Redis/issues/587)) -- Adds: Can now specifiy allowed SSL/TLS protocols ([#603 by JonCole](https://github.com/StackExchange/StackExchange.Redis/pull/603)) -- Adds: Track message status in exceptions ([#576 by deepakverma](https://github.com/StackExchange/StackExchange.Redis/pull/576)) +- Adds: Make performance-counter tracking opt-in (`IncludePerformanceCountersInExceptions`) as it was causing problems ([#587 by AlexanderKot](https://github.com/seredis/StackExchange.Redis/issues/587)) +- Adds: Can now specifiy allowed SSL/TLS protocols ([#603 by JonCole](https://github.com/seredis/StackExchange.Redis/pull/603)) +- Adds: Track message status in exceptions ([#576 by deepakverma](https://github.com/seredis/StackExchange.Redis/pull/576)) - Adds: `GetDatabase()` optimization for DB 0 and low numbered databases: `IDatabase` instance is retained and recycled (as long as no `asyncState` is provided) -- Performance: Improved connection retry policy ([#510 by deepakverma](https://github.com/StackExchange/StackExchange.Redis/pull/510), [#572 by deepakverma](https://github.com/StackExchange/StackExchange.Redis/pull/572)) +- Performance: Improved connection retry policy ([#510 by deepakverma](https://github.com/seredis/StackExchange.Redis/pull/510), [#572 by deepakverma](https://github.com/seredis/StackExchange.Redis/pull/572)) - Adds: `Execute`/`ExecuteAsync` API to support "modules"; [more info](https://blog.marcgravell.com/2017/04/stackexchangeredis-and-redis-40-modules.html) - Fix: Timeout link fixed re /docs change (below) - [`NRediSearch`](https://www.nuget.org/packages/NRediSearch/) added as exploration into "modules" @@ -670,8 +670,8 @@ Note: we currently have no plans to do an additional `1.*` release. In particula ## 1.2.0 (same as 1.2.0-alpha1) -- Adds: GEO commands ([#489 by wjdavis5](https://github.com/StackExchange/StackExchange.Redis/pull/489)) -- Adds: ZADD support for new NX/XX switches ([#520 by seniorquico](https://github.com/StackExchange/StackExchange.Redis/pull/520)) +- Adds: GEO commands ([#489 by wjdavis5](https://github.com/seredis/StackExchange.Redis/pull/489)) +- Adds: ZADD support for new NX/XX switches ([#520 by seniorquico](https://github.com/seredis/StackExchange.Redis/pull/520)) - Adds: core-clr preview support improvements ## 1.1.608 diff --git a/docs/RespLogging.md b/docs/RespLogging.md index 3ff3b0164..51669b0df 100644 --- a/docs/RespLogging.md +++ b/docs/RespLogging.md @@ -106,7 +106,7 @@ If the RESP streams are *not* valid, an exception will provide further details. **An exception here is strong evidence that there is a fault either in the redis server, or an intermediate proxy**. Conversely, if the library reported a protocol failure but the validation step here *does not* report an error, then -that is strong evidence of a library error; [**please report this**](https://github.com/StackExchange/StackExchange.Redis/issues/new) (with details). +that is strong evidence of a library error; [**please report this**](https://github.com/seredis/StackExchange.Redis/issues/new) (with details). You can also *replay* the conversation locally, seeing the individual requests and responses: diff --git a/docs/ServerMaintenanceEvent.md b/docs/ServerMaintenanceEvent.md index 15ef44c5a..3e61ecf33 100644 --- a/docs/ServerMaintenanceEvent.md +++ b/docs/ServerMaintenanceEvent.md @@ -254,7 +254,7 @@ multiplexer.ServerMaintenanceEvent += (object sender, ServerMaintenanceEvent e) } }; ``` -You can see the schema for the `AzureMaintenanceEvent` class [here](https://github.com/StackExchange/StackExchange.Redis/blob/main/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs). Note that the library automatically sets the `ReceivedTimeUtc` timestamp when the event is received, so if you see in your logs that `ReceivedTimeUtc` is after `StartTimeUtc`, this may indicate that your connections are under high load. +You can see the schema for the `AzureMaintenanceEvent` class [here](https://github.com/seredis/StackExchange.Redis/blob/main/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs). Note that the library automatically sets the `ReceivedTimeUtc` timestamp when the event is received, so if you see in your logs that `ReceivedTimeUtc` is after `StartTimeUtc`, this may indicate that your connections are under high load. ## Walking through a sample maintenance event diff --git a/docs/Testing.md b/docs/Testing.md index 52776f3b6..a8427f870 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -16,7 +16,7 @@ This can be done either by installing Docker and running `docker compose up` in Tests default to `127.0.0.1` as their server, however you can override any of the test IPs/hostnames and ports by placing a `TestConfig.json` in the `StackExchange.Redis.Tests\` folder. This file is intentionally in `.gitignore` already, as it's for *your* personal overrides. This is useful for testing local or remote servers, different versions, various ports, etc. -You can find all the JSON properties at [TestConfig.cs](https://github.com/StackExchange/StackExchange.Redis/blob/main/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs). An example override (everything not specified being a default) would look like this: +You can find all the JSON properties at [TestConfig.cs](https://github.com/seredis/StackExchange.Redis/blob/main/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs). An example override (everything not specified being a default) would look like this: ```json { "RunLongRunning": true, diff --git a/docs/index.md b/docs/index.md index 64cf22ac5..0abb66ec8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ StackExchange.Redis =================== -- [Release Notes](https://github.com/StackExchange/StackExchange.Redis/releases) (3.0 onwards; [earlier releases](ReleaseNotes)) +- [Release Notes](https://github.com/seredis/StackExchange.Redis/releases) (3.0 onwards; [earlier releases](ReleaseNotes)) ## Overview @@ -70,4 +70,4 @@ Questions and Contributions If you think you have found a bug or have a feature request, please [report an issue][2], or if appropriate: submit a pull request. If you have a question, feel free to [contact me](https://github.com/mgravell). [1]: https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl - [2]: https://github.com/StackExchange/StackExchange.Redis/issues?state=open + [2]: https://github.com/seredis/StackExchange.Redis/issues?state=open diff --git a/docs/rules/SER300.md b/docs/rules/SER300.md index 98a64f588..b8d4eabb9 100644 --- a/docs/rules/SER300.md +++ b/docs/rules/SER300.md @@ -60,7 +60,7 @@ about the code - so it is deliberately conservative and stays quiet wherever it still works, and will keep working: this is a suggestion, not a defect report. That conservatism is meant to make a false positive rare, not impossible. **If you think the rule has flagged -something it should not have, please [report it](https://github.com/StackExchange/StackExchange.Redis/issues/new)**, +something it should not have, please [report it](https://github.com/seredis/StackExchange.Redis/issues/new)**, including the transaction as written. A rule that fires on correct code is a bug in the rule - and one that reaches every consumer of the package - so it is worth fixing rather than quietly suppressing. diff --git a/docs/rules/SER301.md b/docs/rules/SER301.md index 99ec41abc..4ac15f2ee 100644 --- a/docs/rules/SER301.md +++ b/docs/rules/SER301.md @@ -77,7 +77,7 @@ about the code - so it is deliberately conservative and stays quiet wherever it still works, and will keep working: this is a suggestion, not a defect report. That conservatism is meant to make a false positive rare, not impossible. **If you think the rule has flagged -something it should not have, please [report it](https://github.com/StackExchange/StackExchange.Redis/issues/new)**, +something it should not have, please [report it](https://github.com/seredis/StackExchange.Redis/issues/new)**, including the transaction as written. A rule that fires on correct code is a bug in the rule - and one that reaches every consumer of the package - so it is worth fixing rather than quietly suppressing. diff --git a/docs/rules/SER302.md b/docs/rules/SER302.md index 271cffc30..e41c0fc89 100644 --- a/docs/rules/SER302.md +++ b/docs/rules/SER302.md @@ -54,7 +54,7 @@ about the code - so it is deliberately conservative and stays quiet wherever it still works, and will keep working: this is a suggestion, not a defect report. That conservatism is meant to make a false positive rare, not impossible. **If you think the rule has flagged -something it should not have, please [report it](https://github.com/StackExchange/StackExchange.Redis/issues/new)**, +something it should not have, please [report it](https://github.com/seredis/StackExchange.Redis/issues/new)**, including the transaction as written. A rule that fires on correct code is a bug in the rule - and one that reaches every consumer of the package - so it is worth fixing rather than quietly suppressing. diff --git a/docs/rules/SER303.md b/docs/rules/SER303.md index e4152f8d6..646112dc9 100644 --- a/docs/rules/SER303.md +++ b/docs/rules/SER303.md @@ -81,7 +81,7 @@ about the code - so it is deliberately conservative and stays quiet wherever it still works, and will keep working: this is a suggestion, not a defect report. That conservatism is meant to make a false positive rare, not impossible. **If you think the rule has flagged -something it should not have, please [report it](https://github.com/StackExchange/StackExchange.Redis/issues/new)**, +something it should not have, please [report it](https://github.com/seredis/StackExchange.Redis/issues/new)**, including the transaction as written. A rule that fires on correct code is a bug in the rule - and one that reaches every consumer of the package - so it is worth fixing rather than quietly suppressing. diff --git a/docs/rules/SER304.md b/docs/rules/SER304.md index fc88f4a39..35e3f0d9b 100644 --- a/docs/rules/SER304.md +++ b/docs/rules/SER304.md @@ -81,7 +81,7 @@ about the code - so it is deliberately conservative and stays quiet wherever it still works, and will keep working: this is a suggestion, not a defect report. That conservatism is meant to make a false positive rare, not impossible. **If you think the rule has flagged -something it should not have, please [report it](https://github.com/StackExchange/StackExchange.Redis/issues/new)**, +something it should not have, please [report it](https://github.com/seredis/StackExchange.Redis/issues/new)**, including the transaction as written. A rule that fires on correct code is a bug in the rule - and one that reaches every consumer of the package - so it is worth fixing rather than quietly suppressing. diff --git a/docs/rules/SER305.md b/docs/rules/SER305.md index 1137971f5..311dd64e3 100644 --- a/docs/rules/SER305.md +++ b/docs/rules/SER305.md @@ -79,6 +79,6 @@ or locally: If you believe it has flagged something that works, that is a bug in the rule and one that reaches every consumer of the package - please -[report it](https://github.com/StackExchange/StackExchange.Redis/issues/new) with the code as written. +[report it](https://github.com/seredis/StackExchange.Redis/issues/new) with the code as written. See also [Transactions](../Transactions) and [SER306](SER306). diff --git a/docs/rules/index.md b/docs/rules/index.md index a5f1eb48b..6205f0030 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -78,7 +78,7 @@ whatever each rule's own page lists. These are heuristics, and the list above is where the effort has gone - but it is meant to make a false positive rare, not impossible. If one of these rules flags something it should not have, that is a bug in the rule rather than something to work around: please -[report it](https://github.com/StackExchange/StackExchange.Redis/issues/new) with the transaction as written. +[report it](https://github.com/seredis/StackExchange.Redis/issues/new) with the transaction as written. Four rules are not in this family, because they are not suggesting an improvement to working code: `SER350` reports a build problem, and [SER305](SER305)/[SER306](SER306)/[SER307](../SyncOverAsync) report code that does diff --git a/notes/lag-aware/findings.md b/notes/lag-aware/findings.md index 26ccdb84a..2822ce780 100644 --- a/notes/lag-aware/findings.md +++ b/notes/lag-aware/findings.md @@ -329,7 +329,7 @@ wrong twice over**: Redis ships a fault injection service for exactly this, *and test tier built on it.* **`tests/StackExchange.Redis.FaultInjector.Tests`** exists on `marc/maint-optin-server`, open as -**[#3191](https://github.com/StackExchange/StackExchange.Redis/pull/3191)** (107 files, +13,160, +**[#3191](https://github.com/seredis/StackExchange.Redis/pull/3191)** (107 files, +13,160, unmerged at the time of writing). Per its README it drives *"a real Redis Enterprise deployment through the fault injector, and watch[es] how SE.Redis reacts … the tier that can observe what no in-process fake can: real DNS, real TLS identity, real timing."* Gating is diff --git a/notes/lag-aware/plan.md b/notes/lag-aware/plan.md index 9faef4b75..e91ec0b63 100644 --- a/notes/lag-aware/plan.md +++ b/notes/lag-aware/plan.md @@ -33,7 +33,7 @@ See [`api-sketch.md`](api-sketch.md). `tests/StackExchange.Redis.FaultInjector.Tests` is present here now. The rest of this section records why the wait was worth it. -[#3191](https://github.com/StackExchange/StackExchange.Redis/pull/3191) ("Server-native maintenance +[#3191](https://github.com/seredis/StackExchange.Redis/pull/3191) ("Server-native maintenance notifications") is open, 107 files, +13,160. It carries `tests/StackExchange.Redis.FaultInjector.Tests`, and with it the three things this work would otherwise have to build from scratch: diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 49a5682e5..40de8af22 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1875,7 +1875,7 @@ internal async Task ReconfigureAsync(bool first, bool reconfigureAll, ILog ValueStopwatch? watch = null; int iterCount = first ? 2 : 1; - // This is fix for https://github.com/StackExchange/StackExchange.Redis/issues/300 + // This is fix for https://github.com/seredis/StackExchange.Redis/issues/300 // auto discoverability of cluster nodes is made synchronous. // We try to connect to endpoints specified inside the user provided configuration // and when we encounter an endpoint to which we are able to successfully connect, diff --git a/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs b/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs index 417975050..fd245abcc 100644 --- a/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs +++ b/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs @@ -1,7 +1,7 @@ using System.ComponentModel; #if NET5_0_OR_GREATER -// context: https://github.com/StackExchange/StackExchange.Redis/issues/2619 +// context: https://github.com/seredis/StackExchange.Redis/issues/2619 [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] #else diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index 5b572c471..4634ff064 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -688,7 +688,7 @@ private void OnMessage( in RedisChannel messageChannel, in RespReader reader) { - // note: this could be multi-message: https://github.com/StackExchange/StackExchange.Redis/issues/2507 + // note: this could be multi-message: https://github.com/seredis/StackExchange.Redis/issues/2507 _readStatus = ReadStatus.InvokePubSub; switch (reader.Prefix) { diff --git a/src/StackExchange.Redis/README.md b/src/StackExchange.Redis/README.md index 743cd1ef3..59caa67ff 100644 --- a/src/StackExchange.Redis/README.md +++ b/src/StackExchange.Redis/README.md @@ -1,6 +1,6 @@ StackExchange.Redis is a high-performance RESP (Redis, etc) client for .NET, available under the MIT license. -- Release notes: [https://github.com/StackExchange/StackExchange.Redis/releases](https://github.com/StackExchange/StackExchange.Redis/releases) (3.0 onwards; [earlier](https://seredis.dev/ReleaseNotes)) +- Release notes: [https://github.com/seredis/StackExchange.Redis/releases](https://github.com/seredis/StackExchange.Redis/releases) (3.0 onwards; [earlier](https://seredis.dev/ReleaseNotes)) - NuGet package: [https://www.nuget.org/packages/StackExchange.Redis/](https://www.nuget.org/packages/StackExchange.Redis/) - General docs: [https://seredis.dev/](https://seredis.dev/) -- Code: [https://github.com/StackExchange/StackExchange.Redis/](https://github.com/StackExchange/StackExchange.Redis/) \ No newline at end of file +- Code: [https://github.com/seredis/StackExchange.Redis/](https://github.com/seredis/StackExchange.Redis/) \ No newline at end of file diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 7df8b8321..50ce1255f 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1362,7 +1362,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes var bridge = connection.BridgeCouldBeNull; var config = Parse(connection, nodes); - // re multi-db: https://github.com/StackExchange/StackExchange.Redis/issues/2642 + // re multi-db: https://github.com/seredis/StackExchange.Redis/issues/2642 if (bridge != null && !connection.MultiDatabasesOverride) { bridge.ServerEndPoint.ServerType = ServerType.Cluster; diff --git a/src/StackExchange.Redis/SocketManager.cs b/src/StackExchange.Redis/SocketManager.cs index 145c5ae06..c5c86adde 100644 --- a/src/StackExchange.Redis/SocketManager.cs +++ b/src/StackExchange.Redis/SocketManager.cs @@ -143,7 +143,7 @@ internal static bool TryEnableTcpKeepAlive(Socket socket, EndPoint endPoint) { #if !NET10_0_OR_GREATER // Prior to .NET 10, enabling TCP keep-alive on host-based endpoints fails outside of Windows. - // see https://github.com/StackExchange/StackExchange.Redis/issues/3086 + // see https://github.com/seredis/StackExchange.Redis/issues/3086 case DnsEndPoint when !RuntimeInformation.IsOSPlatform(OSPlatform.Windows): return false; #endif case DnsEndPoint: diff --git a/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs b/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs index d52295903..581e1d61b 100644 --- a/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs @@ -4,7 +4,7 @@ namespace StackExchange.Redis.Tests; -// context: https://github.com/StackExchange/StackExchange.Redis/pull/3043 +// context: https://github.com/seredis/StackExchange.Redis/pull/3043 public class ClusterHandshakeNodesUnitTests(ITestOutputHelper log) { [Fact] diff --git a/tests/StackExchange.Redis.Tests/CommandRetryCategoryUnitTests.cs b/tests/StackExchange.Redis.Tests/CommandRetryCategoryUnitTests.cs index 6ab6341ec..472d05c1c 100644 --- a/tests/StackExchange.Redis.Tests/CommandRetryCategoryUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/CommandRetryCategoryUnitTests.cs @@ -6,7 +6,7 @@ namespace StackExchange.Redis.Tests; /// /// Covers the commands whose retry category depends on their *arguments* rather than just the command name -/// (see https://github.com/StackExchange/StackExchange.Redis/issues/3148). These call the message factories +/// (see https://github.com/seredis/StackExchange.Redis/issues/3148). These call the message factories /// directly against the in-process server, so nothing here needs a real Redis. /// public class CommandRetryCategoryUnitTests(ITestOutputHelper log) diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupUnavailableTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupUnavailableTests.cs index 0f4c47b1b..12234125d 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupUnavailableTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupUnavailableTests.cs @@ -8,7 +8,7 @@ namespace StackExchange.Redis.Tests.MultiGroupTests; /// /// What a group does when no member can serve: see -/// . +/// . /// /// /// A group is an , so it has to fail the way one does. Reporting a diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index 99b8e544d..093b60343 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -388,7 +388,7 @@ public async Task HashScanNoValuesLarge(int pageSize) } /// - /// See . + /// See . /// [Fact] public async Task HashScanThresholds() diff --git a/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs b/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs index 0ec3fc0c4..be7a1731f 100644 --- a/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs @@ -23,7 +23,7 @@ public async Task IdentifyValkeyCluster(ServerType type) private sealed class InProcessValkeyLikeServer(ITestOutputHelper log) : InProcessTestServer(log) { - // see https://github.com/StackExchange/StackExchange.Redis/pull/3050 + // see https://github.com/seredis/StackExchange.Redis/pull/3050 protected override string ServerModeKey => "server_mode"; // Valkey reports its own version as HELLO "version" (see #3278); the name match is case-insensitive From 510f791e70158665d7e8207c84d32d0a2b2a2792 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 12 Nov 2026 13:51:29 +0000 Subject: [PATCH 13/14] MOVING handoff: hold new work in the backlog while the in-flight commands drain, so the recycle happens within a round trip instead of at the window end (#3347) * MOVING handoff: hold new work in the backlog while the in-flight commands drain, so the recycle happens within a round trip instead of at the window end The drain waited for "no caller work", which under steady traffic never comes: there is always a command in flight, so the recycle waited out the whole announced window and failed whatever was in flight at that instant. Measured live (endpoint rebind, single proxy, ~900 ops/s): recycle at +20.0s against a MOVING at +6.0s, 16 failed commands, missed-deadline warning. While a handoff drains, the bridge holds a caller's work in the backlog instead of writing it to the connection about to be replaced; the backlog processor does not flush while the hold stands; the drain waits only for what is already written, capped at half the relaxed timeout (bounded by half the remaining window, floor one second); the connection is then recycled as before, the hold cleared, and the held work flushed to the replacement by the ordinary reconnect path. Timeouts are relaxed for the hold and the reconnect. A FailFast policy does not abandon held work for the handoff's own disconnect. Same scenario on this branch: recycle at +6.2s against a MOVING at +5.9s, 0 failed of 97k. Deliberately a flag and nothing more: no per-message timeout cover (with relaxation off and a timeout shorter than the hold, held commands can time out in the backlog, as on any reconnect), and no retire under the write lock (a writer that reaches the lock after the recycle gets a write fault, as before). Also: the drain budget is recomputed after the DNS poll, a second MOVING during a handoff updates the successor pin rather than being dropped, and the pin lasts the window so the second bridge dials the same successor. Tests: steady load (with and without relaxation), slow command inside the cap, blocking command past the cap; plus a fault-injector tier measurement that records the live timeline. * OneLogicalEventFromEveryNodeRaisesOneEvent: wait for the one event rather than counting at the instant the second node relaxed; the callback is dispatched after the relaxation, so the count could be read as zero (flaked on main at ~1 in 6) --- docs/ServerMaintenanceEvent.md | 4 +- src/StackExchange.Redis/PhysicalBridge.cs | 90 +++++++- .../ServerEndPoint.Maintenance.cs | 126 ++++++++--- src/StackExchange.Redis/ServerEndPoint.cs | 3 - .../MovingUnderLoadScenarioTests.cs | 186 ++++++++++++++++ .../MaintenanceNotificationTests.cs | 201 +++++++++++++++++- 6 files changed, 568 insertions(+), 42 deletions(-) create mode 100644 tests/StackExchange.Redis.FaultInjector.Tests/MovingUnderLoadScenarioTests.cs diff --git a/docs/ServerMaintenanceEvent.md b/docs/ServerMaintenanceEvent.md index 3e61ecf33..6a372b8d1 100644 --- a/docs/ServerMaintenanceEvent.md +++ b/docs/ServerMaintenanceEvent.md @@ -111,10 +111,12 @@ or `ServerDefault` to ask for nothing at all, which is what earlier versions did | `MIGRATING`, `FAILING_OVER`, `SMIGRATING` | relaxes command timeouts for that server while the disruption lasts | | `MIGRATED`, `FAILED_OVER` | ends the window, but keeps timeouts relaxed for a short tail while things settle | | `SMIGRATED` | as above, and re-reads the cluster topology, and re-subscribes any sharded channels whose slots moved | -| `MOVING` | works out the replacement address, lets in-flight work finish, then replaces the connections | +| `MOVING` | works out the replacement address, holds new commands back while in-flight ones finish (for up to half the relaxed timeout, with timeouts relaxed meanwhile), then replaces the connections and sends the held commands on the replacement | So an application that does nothing at all still benefits: commands that would have timed out during a migration are given more room, a moved slot is learned without waiting to be redirected, and a `MOVING` is acted on before the server closes the socket. +During a `MOVING` handoff, commands the application issues are not written to the connection being retired: they wait in the backlog and are sent on the replacement once it is established, in the order they were issued. This happens whatever the `backlogPolicy`, because that connection is live and the command would otherwise have been written to it; and a `FailFast` policy does not abandon the held work when the old connection is closed, since a handoff is a replacement the client chose rather than a failure. The hold lasts until the commands already in flight have been answered, and at most half of `maintRelaxedTimeout` (five seconds by default), never more than half of what is left of the announced window, and never less than a second. Timeouts are relaxed for the hold and the reconnect, so with relaxation on nothing is timed out by the hold itself; an application that has turned relaxation off (`maintRelaxedTimeout=0`) and runs timeouts shorter than the hold can see held commands time out in the backlog, as they would in the backlog of any other reconnect. A command still in flight when the hold runs out - a long blocking read - is failed by the replacement, as the server's close would have failed it, so that it cannot stall everything else for the rest of the window. + ### Notifications that arrive as you connect Redis Enterprise **retains the most recent completion** - `MIGRATED` or `FAILED_OVER` - and replays it to each connection that opts in, so a client that connects after a disruption still learns that it happened. Measured behaviour, worth knowing if you handle these events yourself: diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index ee3a7bfb2..03bc289de 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -132,6 +132,70 @@ public enum State : byte ///
    internal void MarkNeedsReconnect() => Volatile.Write(ref _needsReconnect, true); + /// + /// A maintenance handoff is draining the current connection: a caller's work is held in the backlog for the + /// replacement rather than written to a socket the server is about to close. Without this the drain could + /// never finish under steady traffic - there was always something in flight - so the recycle waited for the + /// end of the announced window and then failed whatever was in flight at that instant. + /// + /// + /// Deliberately only a flag, and only while the connection is live: once it has been replaced, the ordinary + /// disconnected rules and the backlog policy apply, and the held work is flushed by the same path that + /// flushes any other backlog when the replacement establishes. The hold is not raced against the write lock, + /// so a writer that passed this gate before the hold began and reached the lock after the recycle gets a + /// write fault, exactly as it would have before; and a held command is covered by the relaxed window rather + /// than by anything per-message, so with relaxation turned off and a timeout shorter than the hold it can + /// time out in the backlog - as it would in the backlog of any other reconnect. + /// + private volatile bool _holdForHandoff; + + internal void BeginHandoffHold() => _holdForHandoff = true; + + /// Ends the hold; the connection it was draining has been replaced (or there was none). + internal void EndHandoffHold() + { + _holdForHandoff = false; + // the usual case is that the connection has just been recycled, and the reconnect's establishment flushes + // the backlog; this covers the one where nothing was recycled, so that nothing else would + if (BacklogHasItems) StartBacklogProcessor(); + } + + /// + /// Whether a message is held for the handoff rather than written: a caller's work (internal traffic such as + /// keep-alives still goes to the old connection, and QUIT is never queued), on a live connection. + /// + private bool ShouldHoldForHandoff(Message message) + => _holdForHandoff && !message.IsInternalCall && message.Command != RedisCommand.QUIT && IsConnected && !NeedsReconnect; + + /// Queues a message the hold diverted; queued counts as success, as it does for a disconnected backlog. + private WriteResult HoldForHandoff(Message message) + { + message.SetEnqueued(null); + BacklogEnqueue(message); + // the hold may have ended between the check and the enqueue; the processor itself does nothing while it + // stands, so starting it is always safe, and starting it here means nothing is stranded in that race + StartBacklogProcessor(); + return WriteResult.Success; + } + + /// + /// A batch is written as a unit, so it is held as a unit: when it carries any holdable message and no QUIT. + /// An internal member rides along with the rest, because splitting the batch would be worse than holding it. + /// + private static bool ShouldHoldBatch(List messages) + { + bool any = false; + foreach (var message in messages) + { + if (message.Command == RedisCommand.QUIT) return false; + any |= !message.IsInternalCall; + } + return any; + } + + /// Whether a caller is waiting for a reply on the current connection; the backlog is not counted. + internal bool HasCallerMessagesInFlight() => physical?.HasCallerMessagesAwaitingResponse() == true; + public void Dispose() { isDisposed = true; @@ -212,6 +276,7 @@ private WriteResult FailDueToNoConnection(Message message) public WriteResult TryWriteSync(Message message, bool isReplica) { if (isDisposed) throw new ObjectDisposedException(Name); + if (ShouldHoldForHandoff(message)) return HoldForHandoff(message); if (!IsConnected || NeedsReconnect) return QueueOrFailMessage(message); var physical = this.physical; @@ -236,6 +301,7 @@ public WriteResult TryWriteSync(Message message, bool isReplica) public ValueTask TryWriteAsync(Message message, bool isReplica, bool bypassBacklog = false) { if (isDisposed) throw new ObjectDisposedException(Name); + if (!bypassBacklog && ShouldHoldForHandoff(message)) return new ValueTask(HoldForHandoff(message)); if ((!IsConnected || NeedsReconnect) && !bypassBacklog) return new ValueTask(QueueOrFailMessage(message)); var physical = this.physical; @@ -477,8 +543,10 @@ internal void OnConnectionFailed(PhysicalConnection connection, ConnectionFailur Multiplexer.Logger?.LogErrorConnectionIssue(innerException, innerException.Message); } Trace($"OnConnectionFailed: {connection}"); - // If we're configured to, fail all pending backlogged messages - if (Multiplexer.RawConfig.BacklogPolicy?.AbortPendingOnConnectionFailure == true) + // If we're configured to, fail all pending backlogged messages - but not for a maintenance handoff: that + // is a replacement we chose, and the backlog then holds work diverted off a live connection, which would + // have been written and answered; it is for the replacement to flush + if (Multiplexer.RawConfig.BacklogPolicy?.AbortPendingOnConnectionFailure == true && failureType != ConnectionFailureType.MaintenanceHandoff) { AbandonPendingBacklog(innerException, connection); } @@ -776,6 +844,18 @@ internal bool TryEnqueue(List messages, bool isReplica) if (isDisposed) throw new ObjectDisposedException(Name); + if (_holdForHandoff && IsConnected && !NeedsReconnect && ShouldHoldBatch(messages)) + { + // a maintenance handoff is draining the connection: hold the batch, as a unit, for the replacement + foreach (var message in messages) + { + message.SetEnqueued(null); + BacklogEnqueue(message); + } + StartBacklogProcessor(); + return true; + } + if (!IsConnected || NeedsReconnect) { // During reconnection (e.g. MOVED-to-same-endpoint), queue messages to the backlog @@ -1262,6 +1342,10 @@ private void ProcessBridgeBacklog() // check whether the backlog is empty *before* even trying to get the lock if (_backlog.IsEmpty) return; // nothing to do + // a maintenance handoff is holding work back from the connection about to be replaced: nothing + // would be written; the recycle and the reconnect that follows it kick this again + if (_holdForHandoff) return; + // try and get the lock; if unsuccessful, retry gotLock = _singleWriter.TryTakeSync(); if (gotLock) break; // got the lock; now go do something with it @@ -1272,7 +1356,7 @@ private void ProcessBridgeBacklog() // Timeouts are handled above, so we're exclusively into backlog items eligible to write at this point. // If we can't write them, abort and wait for the next heartbeat or activation to try this again. bool flush = false; - while (IsConnected) + while (IsConnected && !_holdForHandoff) // not while a handoff is holding work back from the connection about to be replaced { // Snapshot the connection for the whole of this message: OnDisconnected nulls the field // without waiting for the write lock, so re-reading it (as the loop guard used to) can hand diff --git a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs index 53308faa6..efdd33933 100644 --- a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs +++ b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs @@ -343,15 +343,6 @@ internal void SetHandoffTarget(EndPoint target, TimeSpan window) _handoffTarget = target; } - /// - /// Forgets any handoff target, once a connection has been established. - /// - /// - /// Called on full establishment rather than on the connect attempt: if the attempt fails we want the next - /// one to try the target again, within its window. Once a connection is up, normal resolution resumes - - /// by then DNS has usually caught up anyway. - /// - internal void ClearHandoffTarget() => _handoffTarget = null; private volatile string? _lastHandoffOutcome; /// @@ -384,18 +375,21 @@ internal void SetHandoffTarget(EndPoint target, TimeSpan window) /// internal void OnMovingAnnounced(TimeSpan? window, EndPoint? successor, PhysicalConnection connection) { - // One at a time per server. A rolling operation delivers one MOVING per connection, so a second one - // arriving while a handoff is in flight is a repeat or a much later event; either way, starting a - // second poll against the same endpoint achieves nothing. + var budget = window is { } value && value > TimeSpan.Zero + ? value + : Multiplexer.RawConfig.MaintenanceRelaxedTimeout; // no window given: use the relaxation floor + + // One at a time per server: two drains on the same bridges would end each other's holds. A second MOVING + // arriving while one is in flight (which can be most of a window: the jitter, the DNS poll, the drain) is + // not acted on separately - the handoff in flight replaces the connections - but if it names a successor, + // that is where the reconnect should go, so the pin is updated and the next dial takes it. if (Interlocked.CompareExchange(ref _handoffInFlight, 1, 0) != 0) { - Multiplexer.Trace("MOVING: a handoff is already in flight", ToString()); + if (successor is not null) SetHandoffTarget(successor, budget); + Multiplexer.Trace(successor is null ? "MOVING: a handoff is already in flight" : "MOVING: a handoff is already in flight; successor noted", ToString()); return; } - var budget = window is { } value && value > TimeSpan.Zero - ? value - : Multiplexer.RawConfig.MaintenanceRelaxedTimeout; // no window given: use the relaxation floor var current = (connection.VolatileSocket?.RemoteEndPoint as IPEndPoint)?.Address; _ = HandoffAsync(budget, successor, current); @@ -422,6 +416,11 @@ private async Task HandoffAsync(TimeSpan window, EndPoint? successor, IPAddress? resolve: Multiplexer.AddressResolver, log: message => Multiplexer.Trace(message, ToString())).ForAwait(); + // the decision may have spent a good part of the window polling DNS (4 to 19 seconds of a 15 second + // window has been measured); what the drain has to work with is what is actually left + remaining = window - TimeSpan.FromMilliseconds(watch.ElapsedMilliseconds); + if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero; + Multiplexer.Trace($"MOVING: {decision}", ToString()); _lastHandoffOutcome = decision.ToString(); @@ -471,36 +470,103 @@ private async Task HandoffAsync(TimeSpan window, EndPoint? successor, IPAddress? } /// - /// Lets in-flight caller work finish, then replaces the connections. + /// How long the handoff holds new work and waits for in-flight commands before replacing the connections + /// regardless: half the relaxed timeout (five seconds by default), never more than half of what is left of the + /// window, never less than , and never more than the window itself. + /// + /// + /// Two things pull in opposite directions. A blocking command in flight - BLPOP, XREAD BLOCK, WAIT - is failed + /// by the replacement if the wait is too short, when the server's close would have let it finish; but every + /// command the application issues during the wait is held, so a long wait is a latency spike for everyone. + /// Half the relaxed timeout lets a blocking command with a timeout up to that complete, keeps the hold well + /// inside the relaxed timeout that covers the held work, and follows the one setting an application already + /// uses to say how patient it is prepared to be during maintenance. Half of what is left of the window is a + /// ceiling whatever the relaxed timeout: a generous one must not turn the hold into a full-window stall that + /// recycles at the deadline with work in flight, which is the failure this exists to remove. + /// + private TimeSpan DrainCap(TimeSpan budget) + { + var cap = TimeSpan.FromTicks(Multiplexer.RawConfig.MaintenanceRelaxedTimeout.Ticks / 2); + var halfWindow = TimeSpan.FromTicks(budget.Ticks / 2); + if (cap > halfWindow) cap = halfWindow; + if (cap < DrainFloor) cap = DrainFloor; // a relaxed timeout of zero disables relaxation; it must not disable the drain + return cap > budget ? budget : cap; + } + + /// The least the drain waits for in-flight commands, whatever the relaxed timeout: one round trip on any network, with room to spare. + private static readonly TimeSpan DrainFloor = TimeSpan.FromSeconds(1); + + /// + /// Holds new caller work back, lets what has already been written finish, then replaces the connections. /// /// /// Draining first because the socket is still working: anything already written may still be answered, and - /// dropping it would fail commands that were about to succeed. Bounded by what is left of the window, - /// because the server closes the socket at the end of it regardless - so anything not drained by then was - /// going to fail either way, and draining strictly dominates. + /// dropping it would fail commands that were about to succeed. + /// + /// Holding new work back is what makes the drain finish. Measured live on a connection carrying about 450 + /// operations a second: there was always something in flight, so a drain that waited for "no caller work" + /// waited out the entire 15s window and then recycled at the deadline - failing the eight commands in flight + /// at that instant, with no time left to establish the replacement. With new work held in the backlog, the + /// written commands complete within a round trip, the recycle follows, and the held work is flushed to the + /// replacement by the same path that flushes any backlog when a connection establishes. + /// + /// + /// The wait is bounded by , and timeouts are relaxed for the hold and the reconnect so + /// that the held work is not timed out by the hold itself. Past the cap the connections are replaced regardless, + /// and whatever was still in flight fails as the server's close would have failed it. + /// /// /// Both bridges, not just the one that was told. The measured blast radius is the *node*: four connections /// to one proxy, differing only in handshake, all closed simultaneously, and only the ones that had opted in /// were warned. /// /// + /// What is left of the announced window. + /// Why; for the trace. private async Task DrainThenRecycleAsync(TimeSpan budget, string reason) { var watch = ValueStopwatch.StartNew(); - while (HasCallerWork() && watch.ElapsedMilliseconds < budget.TotalMilliseconds) + var cap = DrainCap(budget); + + // The held work waits out the hold, the replacement's handshake, then its own turn; relax timeouts for + // that span so the hold itself times nothing out. The reconnect can take until the end of the window plus + // a connect attempt, which the relaxed timeout comfortably covers at its default. + var relax = cap + Multiplexer.RawConfig.MaintenanceRelaxedTimeout; + var ceiling = Multiplexer.RawConfig.MaintenanceRelaxedWindowMax; + ExtendRelaxedWindow(relax > ceiling ? ceiling : relax, "MOVING handoff"); + + // snapshots: Dispose nulls the fields, and a handoff in progress must not fault on a server going away + var interactiveBridge = interactive; + var subscriptionBridge = subscription; + interactiveBridge?.BeginHandoffHold(); + subscriptionBridge?.BeginHandoffHold(); + bool drained; + try { - await Task.Delay(TimeSpan.FromMilliseconds(20)).ForAwait(); - } + while (!(drained = !HasCallerMessagesInFlight(interactiveBridge, subscriptionBridge)) + && watch.ElapsedMilliseconds < cap.TotalMilliseconds) + { + await Task.Delay(TimeSpan.FromMilliseconds(20)).ForAwait(); + } - var drained = !HasCallerWork(); - var recycled = (interactive?.RecycleConnection(reason) == true) | (subscription?.RecycleConnection(reason) == true); - if (recycled) Interlocked.Increment(ref _handoffRecycles); - Multiplexer.Trace( - $"MOVING: {(recycled ? "recycled" : "nothing to recycle")} after {watch.ElapsedMilliseconds}ms" - + (drained ? " (drained)" : " (still busy; the window ran out)"), - ToString()); + var recycled = (interactiveBridge?.RecycleConnection(reason) == true) | (subscriptionBridge?.RecycleConnection(reason) == true); + if (recycled) Interlocked.Increment(ref _handoffRecycles); + Multiplexer.Trace( + $"MOVING: {(recycled ? "recycled" : "nothing to recycle")} after {watch.ElapsedMilliseconds}ms" + + (drained ? " (drained)" : " (still busy at the drain cap)"), + ToString()); + } + finally + { + // after the recycle, so there is no instant at which the hold is down and the old connection live + interactiveBridge?.EndHandoffHold(); + subscriptionBridge?.EndHandoffHold(); + } } + private static bool HasCallerMessagesInFlight(PhysicalBridge? interactive, PhysicalBridge? subscription) + => interactive?.HasCallerMessagesInFlight() == true || subscription?.HasCallerMessagesInFlight() == true; + /// /// Warns when a replacement connection is not fully established by the time the announced window ends. /// diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index e203139c6..d4da7173c 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1126,9 +1126,6 @@ internal void OnFullyEstablished(PhysicalConnection connection, string source) // Clear the unselectable flag ASAP since we are open for business ClearUnselectable(UnselectableFlags.DidNotRespond); - // whatever a handoff pointed us at, we are connected now: resume normal resolution - ClearHandoffTarget(); - // is *this specific* connection using RESP3? (without reference to config preferences) bool isResp3 = connection?.Protocol is >= RedisProtocol.Resp3; // and if so, is pub/sub sharing it? (if not, subscriptions live on a dedicated connection) diff --git a/tests/StackExchange.Redis.FaultInjector.Tests/MovingUnderLoadScenarioTests.cs b/tests/StackExchange.Redis.FaultInjector.Tests/MovingUnderLoadScenarioTests.cs new file mode 100644 index 000000000..8adf18163 --- /dev/null +++ b/tests/StackExchange.Redis.FaultInjector.Tests/MovingUnderLoadScenarioTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StackExchange.Redis.Maintenance; +using Xunit; + +namespace StackExchange.Redis.FaultInjector.Tests; + +/// +/// The MOVING handoff under steady traffic, against a real deployment: how long after the notification the +/// connections are replaced, how long until the client is serving again, and how many commands failed meanwhile. +/// +/// +/// A drain that waits for "no caller work" never finishes under load - there is always a command in flight - so it +/// recycles at the end of the announced window and fails whatever is in flight at that instant. Holding new work +/// back while the written commands complete is what lets the recycle happen within a round trip. This records the +/// timeline rather than asserting a bound, so that a run against any branch yields a comparable row. +/// +[Trait("tier", "fault-injector")] +[Trait("scenario", "moving-handoff")] +public class MovingUnderLoadScenarioTests(ExistingDatabaseFixture fixture, ITestOutputHelper log) + : IClassFixture +{ + [Theory] + [InlineData(MaintenanceEndpointType.Auto)] + public async Task HandoffUnderSteadyLoad(MaintenanceEndpointType endpointType) + { + fixture.RequireAvailable(); + var cancellationToken = TestContext.Current.CancellationToken; + + await using var scenario = await ScenarioRun.SetupAsync( + fixture.Injector, "topology-change-standalone", "conn_drop", "endpoint_rebind", log.WriteLine, + cancellationToken: cancellationToken); + + var database = scenario.Database; + Assert.NotNull(database); + + var clock = Stopwatch.StartNew(); + var timeline = new List(); + void Note(string what) + { + var entry = $" +{clock.Elapsed.TotalSeconds,6:0.0}s {what}"; + lock (timeline) timeline.Add(entry); + log.WriteLine(entry); + } + + var logs = new CapturingLogger(); + var config = database.GetClientConfig(fixture.Environment); + config.MaintenanceMovingEndpointType = endpointType; + config.LoggerFactory = logs; + log.WriteLine($"moving-endpoint-type: {endpointType}"); + + await using var conn = await ConnectionMultiplexer.ConnectAsync(config); + var muxer = (IInternalConnectionMultiplexer)conn; + var endpoint = muxer.GetServerEndPoint(conn.GetEndPoints()[0]); + + TimeSpan? movingAt = null, recycledAt = null, restoredAt = null; + var failures = new List<(TimeSpan At, string What)>(); + conn.ConnectionFailed += (_, e) => Note($"disconnect: {e.FailureType} ({e.ConnectionType})"); + conn.ConnectionRestored += (_, e) => + { + restoredAt ??= clock.Elapsed; + Note($"reconnected ({e.ConnectionType})"); + }; + conn.ServerMaintenanceEvent += (_, e) => + { + if (e is not PushMaintenanceEvent push) return; + Note($"{push.NotificationType} seq={push.SequenceId} time={push.Time?.TotalSeconds.ToString() ?? "-"}"); + if (push.NotificationType == MaintenanceNotificationType.Moving) movingAt ??= clock.Elapsed; + }; + + // steady load: sixteen workers, each issuing its next PING the moment the previous one is answered, so there + // is never an instant with nothing in flight and a drain that waits for an idle connection cannot finish (a + // first cut paused 10ms between commands, and the old drain slipped through the gaps) + long completed = 0; + using var stop = new CancellationTokenSource(); + var db = conn.GetDatabase(); + var workload = new MultiThreadedWorkload( + async (_, _) => + { + try + { + await db.PingAsync().ForAwait(); + Interlocked.Increment(ref completed); + } + catch (Exception ex) when (ex is RedisException or TimeoutException) + { + lock (failures) failures.Add((clock.Elapsed, ex.GetType().Name + ": " + FirstLine(ex.Message))); + } + }, + workerCount: 16); + workload.Start(stop.Token); + await Task.Delay(3000, cancellationToken); + var warmup = Interlocked.Read(ref completed); + log.WriteLine($"load: {warmup / 3.0:0} ops/s before the scenario fires"); + + clock.Restart(); + // the injector call blocks until the scenario has run its course (about 20s), so watch for the recycle + // alongside it rather than after it; then cover the announced window plus the measured overshoot + var fire = scenario.FireAsync(cancellationToken); + var deadline = TimeSpan.FromSeconds(100); + while (clock.Elapsed < deadline) + { + if (recycledAt is null && endpoint.HandoffRecycles > 0) + { + recycledAt = clock.Elapsed; + Note($"connections replaced (recycles={endpoint.HandoffRecycles}); handoff: {endpoint.LastHandoffOutcome}"); + } + if (fire is { IsCompleted: true }) + { + await fire; + fire = null; + Note("injector reports the scenario finished"); + } + await Task.Delay(100, cancellationToken); + } + if (fire is not null) await fire; + + stop.Cancel(); + try + { + await workload.Completion; + } + catch (OperationCanceledException) + { + } + + var total = Interlocked.Read(ref completed); + int failed; + lock (failures) + { + failed = failures.Count; + foreach (var (at, what) in failures.Take(20)) log.WriteLine($" failed at +{at.TotalSeconds,6:0.0}s: {what}"); + if (failures.Count > 20) log.WriteLine($" ... and {failures.Count - 20} more"); + } + var missed = logs.Lines.Where(l => l.Contains("did not establish a replacement", StringComparison.Ordinal)).ToList(); + foreach (var line in missed) log.WriteLine(" " + line); + + log.WriteLine(string.Empty); + log.WriteLine("| event | at |"); + log.WriteLine("|---|---|"); + log.WriteLine($"| MOVING received | {Format(movingAt)} |"); + log.WriteLine($"| connections replaced | {Format(recycledAt)} |"); + log.WriteLine($"| ConnectionRestored | {Format(restoredAt)} |"); + log.WriteLine($"| completed commands | {total} |"); + log.WriteLine($"| failed commands | {failed} |"); + log.WriteLine($"| missed-deadline warning | {(missed.Count > 0 ? "yes" : "no")} |"); + log.WriteLine($"| handoff outcome | {endpoint.LastHandoffOutcome ?? "(none)"} |"); + + Assert.NotNull(movingAt); + Assert.True(endpoint.HandoffRecycles > 0, "the handoff should have replaced the connections"); + + static string Format(TimeSpan? at) => at is { } t ? $"+{t.TotalSeconds:0.0}s" : "(never)"; + static string FirstLine(string s) + { + var i = s.IndexOf('\n'); + return i < 0 ? s : s.Substring(0, i); + } + } + + private sealed class CapturingLogger : ILoggerFactory, ILogger + { + private readonly List _lines = new(); + public IReadOnlyList Lines + { + get + { + lock (_lines) return _lines.ToArray(); + } + } + + public void AddProvider(ILoggerProvider provider) { } + public ILogger CreateLogger(string categoryName) => this; + public void Dispose() { } + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (_lines) _lines.Add($"{logLevel}: {formatter(state, exception)}"); + } + } +} diff --git a/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs b/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs index bfdb3a7a5..79578a00c 100644 --- a/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/MaintenanceNotificationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StackExchange.Redis.Maintenance; @@ -26,13 +27,15 @@ public class MaintenanceNotificationTests(ITestOutputHelper log) /// private static async Task<(InProcessTestServer Server, ConnectionMultiplexer Connection, EventCollector Events)> ConnectAsync( ITestOutputHelper log, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + Action? configure = null) { var server = new InProcessTestServer(log); var config = server.GetClientConfig(defaultOnly: true); config.Protocol = RedisProtocol.Resp3; config.MaintenanceNotifications = MaintenanceNotificationMode.Enabled; // must be live, or the test is vacuous config.LoggerFactory = loggerFactory; + configure?.Invoke(config); var conn = await ConnectionMultiplexer.ConnectAsync(config); return (server, conn, new EventCollector(conn)); @@ -525,11 +528,13 @@ await Poll.UntilAsync(() => await Poll.UntilAsync(() => conn.GetEndPoints().All(ep => muxer.GetServerEndPoint(ep).IsMaintenanceRelaxed)), "every node that was told should have relaxed its own timeouts"); - // ...and the consumer sees it once - Assert.Equal(1, events.Count); + // ...and the consumer sees it once: the callback is dispatched after the relaxation, not before, so wait + // for the one event rather than counting at the instant the second node relaxed, then prove the other + // delivery did not become a second event var evt = await events.NextAsync(); Assert.Equal(5, evt.SequenceId); - log.WriteLine($"{events.Count} event(s) from {delivered} deliveries: {evt.RawMessage}"); + await events.AssertNoneAsync(); + log.WriteLine($"1 event from {delivered} deliveries: {evt.RawMessage}"); Assert.Contains(evt.EndPoint, conn.GetEndPoints()); // whichever told us first } } @@ -842,6 +847,192 @@ await Poll.UntilAsync( } } + [Fact] + public Task MovingUnderSteadyLoadRecyclesPromptlyAndFailsNothing() => SteadyLoadHandoffAsync(configure: null); + + [Fact] + public Task MovingUnderSteadyLoadWithRelaxationDisabledStillDrains() + // a relaxed timeout of zero is the documented way to turn relaxation off; the drain cap is derived from it, + // and must keep a floor rather than recycle at once with commands still in flight + => SteadyLoadHandoffAsync(configure: c => c.MaintenanceRelaxedTimeout = TimeSpan.Zero); + + private async Task SteadyLoadHandoffAsync(Action? configure) + { + // The drain used to wait for "no caller work", which under steady traffic never comes: measured live at + // about 450 ops/s, the recycle arrived at the end of the 15s window and failed the eight commands in + // flight at that instant, with no time left to establish the replacement. Holding new work back lets the + // written commands finish, so the recycle follows within a round trip and nothing fails: the held work is + // written to the replacement. + var logs = new CapturingLoggerFactory(); + var (server, conn, events) = await ConnectAsync(log, logs, configure); + using (server) + await using (conn) + { + // every reply is held briefly, so with several workers something is always in flight + server.SetLatency(TimeSpan.FromMilliseconds(40)); + var db = conn.GetDatabase(); + using var stop = new CancellationTokenSource(); + long completed = 0; + var failures = new List(); + var workerTasks = Enumerable.Range(0, 4).Select(_ => Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try + { + await db.PingAsync(); + Interlocked.Increment(ref completed); + } + catch (Exception ex) + { + lock (failures) failures.Add(ex); + } + } + })).ToArray(); + try + { + Assert.True(await Poll.UntilAsync(() => Volatile.Read(ref completed) > 20, timeoutMilliseconds: 30_000), "the load should be flowing before the handoff"); + + var endpoint = ((IInternalConnectionMultiplexer)conn).GetServerEndPoint(server.DefaultEndPoint); + var successor = new IPEndPoint(IPAddress.Parse("127.0.0.9"), 6380); + var watch = System.Diagnostics.Stopwatch.StartNew(); + server.SendMoving(null, timeSeconds: 10, newEndpoint: successor, sequenceId: 0); + var moving = await events.NextAsync(); + Assert.Equal(MaintenanceNotificationType.Moving, moving.NotificationType); + + Assert.True( + await Poll.UntilAsync(() => endpoint.HandoffRecycles > 0, timeoutMilliseconds: 8_000), + "the handoff should have replaced the connection"); + var recycledAfter = watch.Elapsed; + log.WriteLine($"recycled after {recycledAfter.TotalMilliseconds:0}ms of a 10s window"); + + // jitter is at most a tenth of the window and the drain is one round trip; recycling at the deadline is the bug + Assert.True(recycledAfter < TimeSpan.FromSeconds(5), $"recycled after {recycledAfter}: that is waiting for the window, not draining"); + + // the replacement carries the held work and whatever follows. Note the in-process transport never dials the + // successor: its tunnel derives the socket target from the configured endpoint, so the reconnect goes + // straight back to this server; the timing here is the hold and the handshake, not a refused address. + var resumeWatch = System.Diagnostics.Stopwatch.StartNew(); + var before = Volatile.Read(ref completed); + Assert.True( + await Poll.UntilAsync(() => Volatile.Read(ref completed) > before + 20, timeoutMilliseconds: 20_000), + "traffic should resume on the replacement connection"); + log.WriteLine($"traffic resumed {resumeWatch.ElapsedMilliseconds}ms after the recycle was observed"); + + stop.Cancel(); + await Task.WhenAll(workerTasks); + lock (failures) + { + log.WriteLine($"completed: {Volatile.Read(ref completed)}; failed: {failures.Count}"); + Assert.Empty(failures.Select(f => f.Message)); + } + Assert.DoesNotContain(logs.Lines, l => l.Contains("did not establish a replacement", StringComparison.Ordinal)); + } + finally + { + stop.Cancel(); // on any exit: a failed assertion must not leave four workers hot-looping on a disposed multiplexer + } + } + } + + [Fact] + public async Task MovingWaitsForSlowCommandsThatFitInsideTheDrainCap() + { + // A slow or blocking command that will finish inside the drain cap (half the relaxed timeout, 5s by default) + // is allowed to: the hold waits for it, timeouts are relaxed meanwhile, and the recycle follows its reply. + // The server's close would have let it finish too, so failing it would be a regression. + var logs = new CapturingLoggerFactory(); + var (server, conn, events) = await ConnectAsync(log, logs); + using (server) + await using (conn) + { + server.SetLatency(TimeSpan.FromSeconds(3)); // the in-process server answers one request at a time, so one slow command, not several + var db = conn.GetDatabase(); + var slow = db.PingAsync(); + Assert.True(await Poll.UntilAsync(() => conn.GetCounters().Interactive.SentItemsAwaitingResponse > 0), "the slow command should be in flight before MOVING arrives"); + + var endpoint = ((IInternalConnectionMultiplexer)conn).GetServerEndPoint(server.DefaultEndPoint); + var successor = new IPEndPoint(IPAddress.Parse("127.0.0.9"), 6380); + var watch = System.Diagnostics.Stopwatch.StartNew(); + server.SendMoving(null, timeSeconds: 10, newEndpoint: successor, sequenceId: 0); + var moving = await events.NextAsync(); + Assert.Equal(MaintenanceNotificationType.Moving, moving.NotificationType); + + Assert.True( + await Poll.UntilAsync(() => endpoint.HandoffRecycles > 0, timeoutMilliseconds: 10_000), + "the handoff should have replaced the connection"); + var recycledAfter = watch.Elapsed; + server.SetLatency(TimeSpan.Zero); + log.WriteLine($"recycled after {recycledAfter.TotalMilliseconds:0}ms with a 3s command in flight"); + + // the slow command was answered, not failed: the recycle waited for it (had it not, this await would throw) + Assert.True(await slow >= TimeSpan.Zero); + // 3s for the command, up to 1s of jitter, poll granularity, and slack; the cap would be 5s + jitter + Assert.True(recycledAfter < TimeSpan.FromSeconds(7), $"recycled after {recycledAfter}: later than the command plus jitter"); + } + } + + [Fact] + public async Task MovingWithBlockingCommandsInFlightRecyclesAtTheDrainCap() + { + // A command that will not finish inside the cap - a long blocking read - must not hold everyone else's work + // in the backlog for the whole window: at the cap the connection is replaced regardless. The blocking command + // is failed by the recycle, as the server's close would have failed it; everything issued afterwards flows. + var logs = new CapturingLoggerFactory(); + var (server, conn, events) = await ConnectAsync(log, logs); + using (server) + await using (conn) + { + server.SetLatency(TimeSpan.FromSeconds(12)); // well past the 5s cap and the slack below; the relaxed window covers it until the recycle + var db = conn.GetDatabase(); + // one, not several: the test server answers one request at a time, so each extra 12s command would hold the + // replacement's handshake behind it as well + var blocking = db.PingAsync(); + Assert.True(await Poll.UntilAsync(() => conn.GetCounters().Interactive.SentItemsAwaitingResponse > 0), "the blocking command should be in flight before MOVING arrives"); + + var endpoint = ((IInternalConnectionMultiplexer)conn).GetServerEndPoint(server.DefaultEndPoint); + var successor = new IPEndPoint(IPAddress.Parse("127.0.0.9"), 6380); + var watch = System.Diagnostics.Stopwatch.StartNew(); + server.SendMoving(null, timeSeconds: 15, newEndpoint: successor, sequenceId: 0); + var moving = await events.NextAsync(); + Assert.Equal(MaintenanceNotificationType.Moving, moving.NotificationType); + + Assert.True( + await Poll.UntilAsync(() => endpoint.HandoffRecycles > 0, timeoutMilliseconds: 14_000), + "the handoff should have replaced the connection"); + var recycledAfter = watch.Elapsed; + // Only now: the server reads its latency when it *executes* a request, and zeroing it any earlier races the + // one command this test needs to stay slow. From here the replacement's handshake waits behind at most + // that one 12s reply. + server.SetLatency(TimeSpan.Zero); + log.WriteLine($"recycled after {recycledAfter.TotalMilliseconds:0}ms with a 12s command in flight"); + + // jitter (at most a tenth of the window, capped at 1s) + the 5s cap + poll granularity is the mechanism's + // own worst case, about 6.5s; the bug being guarded against is a recycle at the 15s deadline, so 12s + // discriminates with room on both sides + Assert.True(recycledAfter < TimeSpan.FromSeconds(12), $"recycled after {recycledAfter}: the drain waited for the blocking command instead of capping"); + + // the in-flight command was failed by the recycle, which is what the server's close would have done + await Assert.ThrowsAnyAsync(async () => await blocking); + + Assert.True( + await Poll.UntilAsync( + () => + { + try + { + return conn.IsConnected && conn.GetDatabase().Ping() >= TimeSpan.Zero; + } + catch (Exception ex) when (ex is RedisException or TimeoutException) + { + return false; + } + }, + timeoutMilliseconds: 40_000), + "the client should be serving commands on the replacement connection"); + } + } + [Fact] public async Task MovingRecyclesTheConnectionBeforeTheServerCloses() { @@ -884,7 +1075,7 @@ public async Task MovingRecyclesTheConnectionBeforeTheServerCloses() Assert.True( await Poll.UntilAsync(() => endpoint.HandoffTarget is not null || endpoint.HandoffRecycles > 0, timeoutMilliseconds: 10_000), "the handoff should have taken the named successor"); - log.WriteLine($"handoff target: {endpoint.HandoffTarget?.ToString() ?? "(cleared after reconnect)"}; recycles={endpoint.HandoffRecycles}"); + log.WriteLine($"handoff target: {endpoint.HandoffTarget?.ToString() ?? "(expired with the window)"}; recycles={endpoint.HandoffRecycles}"); // a fresh connection re-sends the opt-in, which is how a recycle is visible from the server's side Assert.True( From 30ae6b955286684d948ea0b9727d5cd84f34d8b7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 12 Nov 2026 16:27:01 +0000 Subject: [PATCH 14/14] INCREX and ARGREP are routed by key on a cluster (#3352) IncrexMessageBase (#3065) and ArrayGrepMessage (#3076) derived from Message directly, held a key, and never overrode GetHashSlot, so they reported NoSlot. On a cluster that sends the command to an arbitrary node and leans on -MOVED: an extra round trip for most calls, and a RedisServerException under CommandFlags.NoRedirect. Both now derive from Message.CommandKeyBase, as their single-key siblings do, which supplies the slot (and the key in CommandAndKey). Affects the StringIncrement overloads that take an Expiration (INCREX) and ArrayGrep. No public API change. --- src/StackExchange.Redis/ArrayGrepRequest.cs | 4 +- .../Increx.IncrexMessage.cs | 3 +- .../KeyedMessageSlotTests.cs | 96 +++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/KeyedMessageSlotTests.cs diff --git a/src/StackExchange.Redis/ArrayGrepRequest.cs b/src/StackExchange.Redis/ArrayGrepRequest.cs index 7f00cf147..21e0bac5f 100644 --- a/src/StackExchange.Redis/ArrayGrepRequest.cs +++ b/src/StackExchange.Redis/ArrayGrepRequest.cs @@ -337,7 +337,7 @@ internal override void WriteTo(in MessageWriter writer) } private sealed class ArrayGrepMessage(int db, RedisKey key, ArrayGrepRequest request, CommandFlags flags) - : Message(db, flags, RedisCommand.ARGREP) + : Message.CommandKeyBase(db, flags, RedisCommand.ARGREP, key) { public override int ArgCount { @@ -374,7 +374,7 @@ private static void AddIndex(in MessageWriter writer, RedisArrayIndex? index, Re protected override void WriteImpl(in MessageWriter writer) { writer.WriteHeader(Command, ArgCount); - writer.Write(key); + writer.Write(Key); if (request.IsReversed) { AddIndex(writer, request.End, "$1\r\n+\r\n"u8); diff --git a/src/StackExchange.Redis/Increx.IncrexMessage.cs b/src/StackExchange.Redis/Increx.IncrexMessage.cs index 6941e60e6..2649fb000 100644 --- a/src/StackExchange.Redis/Increx.IncrexMessage.cs +++ b/src/StackExchange.Redis/Increx.IncrexMessage.cs @@ -9,9 +9,8 @@ internal abstract class IncrexMessageBase( CommandFlags flags, RedisKey key, Expiration expiry, - IncrementOptions options) : Message(database, flags, RedisCommand.INCREX) + IncrementOptions options) : Message.CommandKeyBase(database, flags, RedisCommand.INCREX, key) { - protected RedisKey Key => key; protected Expiration Expiry => expiry; protected IncrementOptions Options => options; diff --git a/tests/StackExchange.Redis.Tests/KeyedMessageSlotTests.cs b/tests/StackExchange.Redis.Tests/KeyedMessageSlotTests.cs new file mode 100644 index 000000000..61c03fee6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/KeyedMessageSlotTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// A message that carries a key must report that key's hash slot, or a cluster client sends it to an arbitrary node +/// and leans on -MOVED: an extra round trip in the usual case, and a failure under +/// . INCREX and ARGREP both shipped without one. +/// +public class KeyedMessageSlotTests(ITestOutputHelper output) : TestBase(output) +{ + private static readonly ServerSelectionStrategy Cluster = new(null!) { ServerType = ServerType.Cluster }; + + private static void AssertRoutedByKey(Message message, RedisKey key) + { + var expected = Cluster.HashSlot(key); + Assert.NotEqual(ServerSelectionStrategy.NoSlot, expected); + Assert.Equal(expected, message.GetHashSlot(Cluster)); + } + + [Fact] + public void Increx_Int64_IsRoutedByKey() + { + RedisKey key = "some-counter"; + var msg = new RedisDatabase.IncrexInt64Message(0, CommandFlags.None, key, 1, null, null, default, IncrementOptions.None); + AssertRoutedByKey(msg, key); + Assert.Equal("INCREX some-counter", msg.CommandAndKey); + } + + [Fact] + public void Increx_Double_IsRoutedByKey() + { + RedisKey key = "some-counter"; + var msg = new RedisDatabase.IncrexDoubleMessage(0, CommandFlags.None, key, 1.5, null, null, default, IncrementOptions.None); + AssertRoutedByKey(msg, key); + } + + [Fact] + public void ArrayGrep_IsRoutedByKey() + { + RedisKey key = "some-array"; + AssertRoutedByKey(CreateGrep().CreateMessage(0, key, CommandFlags.None), key); + } + + private static ArrayGrepRequest CreateGrep() + { + var request = new ArrayGrepRequest(); + request.AddPredicate(ArrayGrepRequest.Predicate.Match("alpha")); + return request; + } + + // enough keys that, sent to an arbitrary node, some would certainly miss their owner + private const int KeyCount = 32; + + [Fact] + public async Task Increx_OnCluster_NeedsNoRedirect() + { + await using var conn = Create(configuration: GetClusterConfiguration(), require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var expiry = Expiration.CreateOrKeepTtl(TimeSpan.FromMinutes(1), keepTtl: false); + + for (int i = 0; i < KeyCount; i++) + { + RedisKey key = Me() + ":" + i; + await db.KeyDeleteAsync(key); + + // NoRedirect: lands on the slot owner first time, or throws + var result = await db.StringIncrementAsync(key, 5, expiry, flags: CommandFlags.NoRedirect); + Assert.Equal(5, result.Value); + + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + } + } + + [Fact] + public async Task ArrayGrep_OnCluster_NeedsNoRedirect() + { + await using var conn = Create(configuration: GetClusterConfiguration(), require: RedisFeatures.v8_8_0); + var db = conn.GetDatabase(); + var grep = CreateGrep(); + + for (int i = 0; i < KeyCount; i++) + { + RedisKey key = Me() + ":" + i; + await db.KeyDeleteAsync(key); + await db.ArraySetAsync(key, 0, "alpha"); + + var found = await db.ArrayGrepAsync(key, grep, CommandFlags.NoRedirect); + Assert.Equal(0, Assert.Single(found).Index); + + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + } + } +}