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/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. 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 c33038c9a..bdc311f33 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,6 +60,7 @@ Documentation - [Command groups](Groups) - `db.Strings`, `db.Hashes`, ...: where every command lives, and how they are called - [Ad-hoc commands](Execute) - running commands without a dedicated API, building them in pieces with `Compose`, and the low-allocation `ExecuteResp` API - [Extending the client](Extending) - for library authors: adding commands this client does not have, and migrating off `Execute(string, object[])` +- [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 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 c7a0beb41..a68e8f01c 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -53,5 +53,3 @@ virtual RESPite.Messages.RespReader.PairProjection.Invoke(ref T [SER012]static RESPite.Messages.RespValue.TryCaptureNext(object? owner, scoped ref RESPite.Messages.RespReader reader, out RESPite.Messages.RespValue value) -> bool [SER012]RESPite.Messages.RespValue.TryGetSpan(out System.ReadOnlySpan payload) -> bool [SER012]static RESPite.Messages.RespValue.explicit operator string?(RESPite.Messages.RespValue value) -> string? -*REMOVED*[SER004]override RESPite.AsciiHash.Equals(object? other) -> bool -[SER004]override RESPite.AsciiHash.Equals(object? obj) -> bool diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 8729b00cd..eb65816b9 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/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index c1f5f190e..fd5631640 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -233,7 +233,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 50aad0a37..56ac6dc64 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/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 8e00a8af2..c967c4677 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1760,14 +1760,17 @@ 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 (#3264): the library's own - /// configuration channel lives on that second connection, and nothing else will ever dial it. + /// The dedicated subscription connection - always under RESP2, and under RESP3 unless + /// is enabled (#3264) - is only dialled here + /// when there is something for it to do straight away: the library's own configuration channel, which nothing + /// else will ever dial for, or subscriptions that already exist and may need to move to this endpoint (see + /// ). Otherwise it is dialled on first use, so + /// an application that never subscribes never pays for the second socket - or for its reconnect on every + /// failover (#3310). /// /// internal static void ActivateServer(ServerEndPoint server, ILogger? log) { - // bool hasSubscriptions = GetSubscriptionsCount() != 0; // the core dials - HERE, because "activate this server" is what this method means, and a // caller that says it expects the server to come up. When the v3 bridges stopped dialling // this had to take their place. Doing it only in ReconfigureAsync's loop left an @@ -1782,11 +1785,11 @@ internal static void ActivateServer(ServerEndPoint server, ILogger? log) server.Multiplexer.Connections.DialEndpointSoon(server.EndPoint); } - if (server.SupportsSubscriptions && !server.SharesSubscriptionConnection()) + if (server.RequiresEagerSubscriptionConnection) { // the configuration channel is subscribed by CONNECTING rather than by anyone asking for it - // see the method's own notes, including its known cost - server.Multiplexer.Connections.DialSubscriptionSocketForConfigurationChannel(server.EndPoint); + server.Multiplexer.Connections.DialSubscriptionSocketIfRequired(server.EndPoint); } } @@ -1997,7 +2000,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/Contexts/RespConnectionManager.cs b/src/StackExchange.Redis/Contexts/RespConnectionManager.cs index e1dcf4983..2f9f1c8b6 100644 --- a/src/StackExchange.Redis/Contexts/RespConnectionManager.cs +++ b/src/StackExchange.Redis/Contexts/RespConnectionManager.cs @@ -684,6 +684,16 @@ internal bool IsSubscriptionConnected(EndPoint endpoint) && interactive.IsConnectedNow; } + /// Whether a dedicated subscription socket has ever been asked for on this endpoint. + /// The endpoint. + /// + /// The subscription socket is dialled on demand, so "absent" is a different answer from "down": + /// connect waits and the endpoint-state log both need to say "not required" rather than treating a + /// socket nobody asked for as a disconnected one. Like , this + /// is a question, and asking it must not dial. + /// + internal bool HasDedicatedSubscriptionConnection(EndPoint endpoint) => _subscriptions.ContainsKey(endpoint); + /// How many sockets this core currently holds to one endpoint. /// The endpoint. /// @@ -1345,7 +1355,12 @@ await SubscribeToConfigurationChannelAsync(context, cancellationToken) } } - /// Dial a subscription socket for the library's own configuration channel. + /// + /// Dial a subscription socket when activation already knows it will be needed: for the library's own + /// configuration channel, or for subscriptions that already exist and may need to move to this endpoint. + /// Otherwise it stays undialled - the socket is dialled on first use, so an application that never + /// subscribes never pays for it (#3310). + /// /// The endpoint being activated. /// /// @@ -1372,12 +1387,13 @@ await SubscribeToConfigurationChannelAsync(context, cancellationToken) /// opens roots the multiplexer, so a multiplexer that is abandoned without being disposed is no /// longer collected (GarbageCollectionTests.MuxerIsCollected). Kept anyway, because /// withdrawing it loses the configuration channel entirely under RESP2 - six failing tests - /// against two - but it is not finished. + /// against two - but it is not finished. Gating the dial on anyone actually needing it (above) is + /// what keeps that cost away from clients that never subscribe. /// /// - internal void DialSubscriptionSocketForConfigurationChannel(EndPoint endpoint) + internal void DialSubscriptionSocketIfRequired(EndPoint endpoint) { - if (_multiplexer.ConfigurationChangedChannel is null + if ((_multiplexer.ConfigurationChangedChannel is null && !_multiplexer.AnySubscribed()) || !_multiplexer.CommandMap.IsAvailable(RedisCommand.SUBSCRIBE)) { return; diff --git a/src/StackExchange.Redis/Contexts/RespEndpointExecutor.cs b/src/StackExchange.Redis/Contexts/RespEndpointExecutor.cs index f9ab70503..6533f4ad3 100644 --- a/src/StackExchange.Redis/Contexts/RespEndpointExecutor.cs +++ b/src/StackExchange.Redis/Contexts/RespEndpointExecutor.cs @@ -1186,7 +1186,7 @@ internal bool TrySendBatch(List operations, int database) } } - return SendRun(connection, QueuedOperation.ToRun(operations), operations.Count, database); + return SendRun(connection, QueuedOperation.ToRun(operations), operations.Count, database, profile: true); } /// @@ -1194,7 +1194,14 @@ internal override bool TryWriteRun(RespConnection connection, RespRunEntry[] run => SendRun(connection, run, count, Database); /// Write a run, preceded by a SELECT if this connection is on another database. - internal bool SendRun(RespConnection connection, RespRunEntry[] run, int count, int database) + /// + /// Only a batch asks for profiling (): its commands are ordinary commands + /// that happen to travel together, and a batch used to skip profiling altogether because it bypasses + /// Dispatch, where single sends start their records (#2175). A transaction's run stays + /// unprofiled here, as it always was - its operations answer QUEUED and then answer again + /// inside EXEC, which is not the shape a ProfiledCommand describes. + /// + internal bool SendRun(RespConnection connection, RespRunEntry[] run, int count, int database, bool profile = false) { // a batch's and a transaction's operations are built by the composing executor rather than by // Dispatch, so this is where they learn who counts their outcome. Without it a deployment whose @@ -1207,6 +1214,18 @@ internal bool SendRun(RespConnection connection, RespRunEntry[] run, int count, operation.Server = Server; operation.IsSubscription = IsSubscriptionEndpoint; Interlocked.Increment(ref _operationCount); + + // the same record a single send starts in Dispatch, with the same database rule: + // the COMMAND's database, and -1 for a command that names no database at all + if (profile && _startProfile is { } start) + { + start( + operation, + operation.Command, + operation.Flags, + CommandFlagsInternal.RequiresDatabase(operation.Command) ? operation.Database : -1, + _endpoint); + } } } diff --git a/src/StackExchange.Redis/Contexts/RespHandshake.cs b/src/StackExchange.Redis/Contexts/RespHandshake.cs index 566c772f4..8436a8626 100644 --- a/src/StackExchange.Redis/Contexts/RespHandshake.cs +++ b/src/StackExchange.Redis/Contexts/RespHandshake.cs @@ -1754,8 +1754,11 @@ private static RoleReply ParsePrimary(ref RespReader reader) reader.SkipChildren(); } - if (!string.IsNullOrEmpty(host) && !string.IsNullOrEmpty(port) - && Format.TryParseEndPoint(host + ":" + port, out var peer)) + // host and port arrive as separate tokens, so hand them over separately: gluing them + // with ':' turns an unbracketed IPv6 host into an address with no port (#1422), which + // is the same mistake the sentinel +switch-master handler used to make + if (host is { Length: > 0 } && port is { Length: > 0 } + && Format.TryParseEndPoint(host, port, out var peer)) { (peers ??= new()).Add(peer); } @@ -1774,8 +1777,9 @@ private static RoleReply ParseReplica(ref RespReader reader) if (reader.TryMoveNext()) port = reader.IsScalar ? reader.ReadString() : null; List? peers = null; - if (!string.IsNullOrEmpty(host) && !string.IsNullOrEmpty(port) - && Format.TryParseEndPoint(host + ":" + port, out var primary)) + // separate tokens, separate arguments: see ParsePrimary on unbracketed IPv6 hosts + if (host is { Length: > 0 } && port is { Length: > 0 } + && Format.TryParseEndPoint(host, port, out var primary)) { peers = new() { primary }; } diff --git a/src/StackExchange.Redis/Contexts/RespOperationBatch.cs b/src/StackExchange.Redis/Contexts/RespOperationBatch.cs index 6b6afcd97..c7f61da05 100644 --- a/src/StackExchange.Redis/Contexts/RespOperationBatch.cs +++ b/src/StackExchange.Redis/Contexts/RespOperationBatch.cs @@ -219,6 +219,7 @@ private bool Enqueue(RespPayloadOperation operation, in RespRequest request, Can operation.Diagnostics.Status = RespCommandStatus.WaitingInBacklog; operation.Slot = request.Slot; operation.Database = Database; // read if it is ever re-sent on its own, after a redirect + operation.Command = request.Command; // identity, for the profiling record (#2175) and diagnostics lock (_sync) { diff --git a/src/StackExchange.Redis/Contexts/RespRedirect.cs b/src/StackExchange.Redis/Contexts/RespRedirect.cs index 4556a546b..5022e57cb 100644 --- a/src/StackExchange.Redis/Contexts/RespRedirect.cs +++ b/src/StackExchange.Redis/Contexts/RespRedirect.cs @@ -98,9 +98,11 @@ internal static bool TryParseText(scoped ReadOnlySpan text, out RespRedire if (!TryParseSlot(rest.Slice(0, space), out var slot)) return false; // the endpoint has to become a string eventually - EndPoint parsing is string-shaped all the - // way down - but only on the redirect path, which is rare by construction + // way down - but only on the redirect path, which is rare by construction. Server-emitted + // ip:port, so it takes the same parser as CLUSTER NODES: the server writes an IPv6 address + // UNBRACKETED, which the configuration-string parser reads as an address with no port (#1422). var target = System.Text.Encoding.UTF8.GetString(rest.Slice(space + 1).ToArray()); - var endpoint = Format.TryParseEndPoint(target, out var parsed) && !IsUnroutableTarget(parsed) + var endpoint = Format.TryParseServerEndPoint(target, out var parsed) && !IsUnroutableTarget(parsed) ? parsed : null; 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/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/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/LoggerExtensions.cs b/src/StackExchange.Redis/LoggerExtensions.cs index f6673c32a..afb0d69c0 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, BridgeState interactiveState, BridgeState subscriptionState); + internal static partial void LogInformationEndpointState(this ILogger logger, EndPointLogValue endPoint, BridgeState interactiveState, string subscriptionState); [LoggerMessage( Level = LogLevel.Information, diff --git a/src/StackExchange.Redis/Maintenance/MaintenanceNotificationReader.cs b/src/StackExchange.Redis/Maintenance/MaintenanceNotificationReader.cs index 82ca30e57..e1b8a337f 100644 --- a/src/StackExchange.Redis/Maintenance/MaintenanceNotificationReader.cs +++ b/src/StackExchange.Redis/Maintenance/MaintenanceNotificationReader.cs @@ -264,8 +264,13 @@ private static List ReadSlotMigrations(ConnectionMultiplex /// /// 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/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 71c27026d..ada05228b 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 dfb6a78c4..1910e4231 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -810,44 +810,6 @@ static StackExchange.Redis.DatabaseTaskExtensions.AsTask(this StackExchange.Redi static StackExchange.Redis.DatabaseTaskExtensions.AsTask(this StackExchange.Redis.IDatabaseAsync! database, System.Threading.Tasks.ValueTask pending, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.Task! static StackExchange.Redis.DatabaseTaskExtensions.AsTask(this StackExchange.Redis.IDatabaseAsync! database, System.Threading.Tasks.ValueTask pending) -> System.Threading.Tasks.Task! static StackExchange.Redis.DatabaseTaskExtensions.AsTask(this StackExchange.Redis.IDatabaseAsync! database, System.Threading.Tasks.ValueTask pending, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.Task! -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? StackExchange.Redis.Availability.MultiGroupOptions.ClientCache.get -> StackExchange.Redis.Caching.CacheOptions? StackExchange.Redis.Availability.MultiGroupOptions.Builder.ClientCache.get -> StackExchange.Redis.Caching.CacheOptions? StackExchange.Redis.Availability.MultiGroupOptions.Builder.ClientCache.set -> void -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! 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 diff --git a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs index 9490ebde9..0f062171d 100644 --- a/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs +++ b/src/StackExchange.Redis/ServerEndPoint.Maintenance.cs @@ -539,11 +539,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 4dc563da5..169a221f0 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -157,6 +157,28 @@ 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.AnySubscribed()); + + /// + /// 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 dialled 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() + || Multiplexer.ConnectionsIfCreated is not { } core + || !core.HasDedicatedSubscriptionConnection(EndPoint) + || core.IsSubscriptionConnected(EndPoint); + public bool SupportsPrimaryWrites => supportsPrimaryWrites ??= !IsReplica || !ReplicaReadOnly || AllowReplicaWrites; private readonly List> _pendingConnectionMonitors = new List>(); @@ -207,6 +229,21 @@ internal BridgeState InteractiveConnectionState internal BridgeState SubscriptionConnectionState => Multiplexer.ConnectionsIfCreated?.IsSubscriptionConnected(EndPoint) == true ? BridgeState.ConnectedEstablished : InteractiveConnectionState; + /// + /// for logs, distinguishing a dedicated subscription connection that + /// has simply not been needed yet (it is dialled on demand) from one that is down. + /// + internal string SubscriptionConnectionStateDescription + { + get + { + if (SharesSubscriptionConnection()) return InteractiveConnectionState.ToString(); + var core = Multiplexer.ConnectionsIfCreated; + if (core is null || !core.HasDedicatedSubscriptionConnection(EndPoint)) return "NotRequired"; + return (core.IsSubscriptionConnected(EndPoint) ? BridgeState.ConnectedEstablished : BridgeState.Disconnected).ToString(); + } + } + public long OperationCount => Multiplexer.ConnectionsIfCreated?.OperationCount(EndPoint) ?? 0; public bool RequiresReadMode => serverType == ServerType.Cluster && IsReplica; diff --git a/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs b/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs index e5d69776d..4894f30fd 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() 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/ConnectionAttemptUnitTests.cs b/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs index 62c20afd2..b5ab9cb44 100644 --- a/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectionAttemptUnitTests.cs @@ -25,9 +25,11 @@ public async Task SuccessfulAttemptsAreReported() await using var conn = await ConnectionMultiplexer.ConnectAsync(options); - // v4 dials the subscription connection when something needs it (pay-per-play) rather than with the - // interactive one, so subscribe: that attempt is the one being checked - await conn.GetSubscriber().SubscribeAsync(RedisChannel.Literal("attempts"), (_, _) => { }); + // the dedicated subscription connection is dialled 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)), (_, _) => { }); try { diff --git a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs index 201f15f7a..e25bc1768 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,25 +255,73 @@ public async Task AzureManagedRedisConnectionCount(RedisProtocol protocol, bool Assert.Equal(0, self.ShardedSubscriptionCount); Assert.Equal(protocol, self.Protocol); - // One socket before anything subscribes, shared or not: this core dials the subscription socket only - // when something needs it - the configuration channel (disabled for Azure Managed Redis) or a - // subscriber - so sharing (#3264) changes WHERE pub/sub lives, not whether a socket opens up front. - // AssertCanPubSubAsync below is what dials it. And nothing extra for a second core any more. There used to - // be a transitional allowance here for the shipped bridge's socket sitting beside the new core's; - // it went when the topology commands moved and the shipped bridge stopped dialling, so the server - // now sees exactly the one-core shape, which is the shape this test is about. - const int expectedCount = 1; - - // the STEADY shape, given a moment to settle: under the engine flag sockets are dialled - // asynchronously, so an immediate read can catch one mid-open or mid-close - the same grace + // Nobody has subscribed to anything yet: one connection, whatever the protocol or sharing mode. This + // core dials the subscription socket only when something needs it - the configuration channel + // (disabled for Azure Managed Redis) or a subscriber - so sharing (#3264) changes WHERE pub/sub + // lives, not whether a socket opens up front. The STEADY shape, given a moment to settle: sockets are + // dialled asynchronously, so an immediate read can catch one mid-open or mid-close - the same grace // ConnectUsesSingleSocket gives its own count. A socket that persists still fails, with the real // number in the message; only a transient one is forgiven. - await Poll.UntilAsync(() => serverObj.ClientCount == expectedCount, timeoutMilliseconds: 2000); + await Poll.UntilAsync(() => serverObj.ClientCount == 1, timeoutMilliseconds: 2000); namedClients = (await server.ClientListAsync()).Where(x => x.Name == config.ClientName).ToArray(); - Assert.Equal(expectedCount, serverObj.ClientCount); + 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 dialled yet + } await AssertCanPubSubAsync(conn, $"{nameof(AzureManagedRedisConnectionCount)}:{protocol}:{sharedSubscriptionConnection}"); + + // first use dialled the dedicated connection, unless pub/sub shares the RESP3 interactive one + var expectedCount = protocol is RedisProtocol.Resp3 && sharedSubscriptionConnection ? 1 : 2; + await Poll.UntilAsync(() => serverObj.ClientCount == expectedCount, timeoutMilliseconds: 2000); + Assert.Equal(expectedCount, serverObj.ClientCount); + 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 + } + + /// + /// 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/FormatTests.cs b/tests/StackExchange.Redis.Tests/FormatTests.cs index 733756780..19d4aaa64 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/HelloClusterModeUnitTests.cs b/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs index 150fabe8f..2beecc1a0 100644 --- a/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/HelloClusterModeUnitTests.cs @@ -51,6 +51,9 @@ public async Task CrossSlotSurvivesSubscriptionReconnect(RedisProtocol protocol) // for a reconnect that never has to happen await conn.GetSubscriber().SubscribeAsync(RedisChannel.Literal("hello-mode"), static (_, _) => { }); 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 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/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)] diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index defc8703c..6f8ff597c 100644 --- a/tests/StackExchange.Redis.Tests/ProfilingTests.cs +++ b/tests/StackExchange.Redis.Tests/ProfilingTests.cs @@ -106,6 +106,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() { 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);