Skip to content

Pull from upstream - #1

Open
deepakverma wants to merge 523 commits into
deepakverma:mainfrom
StackExchange:main
Open

Pull from upstream#1
deepakverma wants to merge 523 commits into
deepakverma:mainfrom
StackExchange:main

Conversation

@deepakverma

Copy link
Copy Markdown
Owner

No description provided.

NickCraver and others added 30 commits February 26, 2024 07:56
* Support `HeartbeatConsistencyChecks` in `Clone()`
* include heartbeatInterval
#2659)

* add new AddLibraryNameSuffix API for annotating connections with usage

* fixup test

* new partial for lib-name bits

* use hashing rather than array shenanigans

* move to shipped; fix comment typo

* comment example

* reverse comment owner
* Provide new LoggingTunnel API; this

* words

* fix PR number

* fix file location

* save the sln

* identify smessage as out-of-band

* add .ForAwait() throughout LoggingTunnel

* clarify meaning of path parameter
This issue was brought to my attention last night (thanks to Badrish Chandramouli): dotnet/dotnet-api-docs#6660

This changeset ensures that we do not honor self-signed certs or partial/broken chains as a result of `X509VerificationFlags.AllowUnknownCertificateAuthority` downstream and adds a few tests and utilities to generate test certificates (currently valid for ~9000 days). Instead we are checking that the certificate we're being told to trust is explicitly in the chain, given that the result of `.Build()` cannot be trusted for this case.

This also resolves an issue where `TrustIssuer` could be called but we'd error when _no errors_ were detected (due to requiring chain errors in our validator), this means users couldn't temporarily trust a cert while getting it installed on the machine for instance and migrating between the 2 setups was difficult.

This needs careful eyes, please scrutinize heavily. It's possible this breaks an existing user, but...it should be broken if so unless there's a case I'm not seeing.
… dedicated thread (#2667)

Resolving #2664.

This went through a few iterations early on, but in the current form the issue is that we can await hop to a thread pool thread and have our wait blocking there - not awesome. We want to avoid that entirely and do a full sync path here.
Further hardening following #2665. This is an additional check to match the .NET implementation for TLS cert checks so that we don't treat a cert flagged as non-TLS-server effectively. This ensures that a certificate either doesn't have OIDs here (valid, backwards compatible) or has the server-certificate OID indicating it's valid for consumption over TLS for us.

Cheers @bartonjs for the report and info here.
…ss ambiguous alternatives (#2702)

Closes #2697.

All of the replacements were empirically tested to be correct via simple programs in combination with a local redis instance.

Notably, there is one worrying nit; in testing it turns out that the `IDatabase.List{Left,Right}Pop(RedisKey, long, CommandFlags)` overload which I talked about in the issue _can_ actually return null, contrary to its nullability annotations. This occurs on missing key; in that case redis replies

	Nil reply: if the key does not exist.

as per https://redis.io/docs/latest/commands/lpop/, which then at

https://github.com/StackExchange/StackExchange.Redis/blob/cb8b20df0e2975717bde97ce95ac20e8e8353572/src/StackExchange.Redis/ResultProcessor.cs#L1546-L1547

and later at

https://github.com/StackExchange/StackExchange.Redis/blob/cb8b20df0e2975717bde97ce95ac20e8e8353572/src/StackExchange.Redis/ExtensionMethods.cs#L339-L341

turns into a `null`.

I briefly attempted to rectify this, but the `RedisValueArrayProcessor` poses a problem here, as changing it to derive
`ResultProcessor<RedisValue[]?>` causes the solution to light up in red, and I'd rather not mess with that as a first contribution without at least prior discussion concerning direction there.
* support reading from last message from stream with xread

* recover previous formatting

* add redisversion and use it in integration tests
* initial implementation of #2706

* build: net8 compat

* Docs: add HighIntegrity initial docs

* PR fixups

* Options fixes

* Update tests/StackExchange.Redis.Tests/TestBase.cs

* add tests that result boxes / continuations work correctly for high-integrity-mode

* - switch to counter rather than entropy
- add transaction work to basic tests, to ensure handled

* naming is hard

* - add explicit connection failure type
- burn the connection on failure
- add initial metrics

* benchmark impact of high performance mode

* be more flexible in HeartbeatConsistencyCheckPingsAsync

* add config tests

---------

Co-authored-by: Nick Craver <nickcraver@microsoft.com>
Co-authored-by: Nick Craver <nrcraver@gmail.com>
Closes/Fixes #2738

this is just to move the integration test against to newer version of Redis
Co-authored-by: Willem van Ketwich <REPLACE_EMAIL>
docker-compose is not available by default anymore on `ubuntu-latest`
[details here]( actions/runner-images#9692)
…ing forward (#2757)

* Project: Enable StyleCop and fix existing rules to make PRs easier going forward

This adopts StyleCop and fixes most issues (and disables things we wouldn't want) to make PRs more consistent in an automated way and prevent formatting problems affecting git history, etc. It also does all the documentation enforcement.

Note: since I had to fix up many anyway, I finally did the `<inheritdoc />` minimization on `IDatabase`/`IDatabaseAsync` to remove 10% of duplicate documentation. That should also make PRs and maintenance less monotonous.

* Fix stylecop issues
Closes/Fixes #2715 

This PR add support for a set of new commands related to expiration of individual members of hash:

> 	**_HashFieldExpire_** exposes the functionality of commands HPEXPIRE/HPEXPIREAT, for each specified field, it gets the value and sets the field's remaining time to live or expireation timestamp
> 	**_HashFieldExpireTime_** exposes the functionality of command HPEXPIRETIME, for specified field, it gets the remaining time to live in milliseconds or expiration timestamp
> 	**_HashFieldPersist_** exposes the functionality of command HPERSIST, for each specified field, it removes the expiration time
> 	**_HashFieldTimeToLive_** expoes the functionality of command HPTTL, for specified field, it gets the remaining time to live in milliseconds or expiration timestamp

---------

Co-authored-by: Nick Craver <nickcraver@microsoft.com>
Co-authored-by: Nick Craver <nrcraver@gmail.com>
It seems the Envoy apts have gone missing breaking out build, so instead of relying on the install let's docker compose their image as a proxy against the Redis supervisor instance as a simpler and faster-to-start setup that also works.

This rearranged some things to simplify the Docker story overall. A move to AzDO or just GitHub builds would simplify everything further, but we need to figure out Windows testing against a Docker setup in CI.

Note: we still can't use Linux containers on a Windows GitHub Actions host (actions/runner#904), so this remains much more complicated and not-really-testing-the-real-thing in the Windows front.
Closes/Fixes #2721 

Brings new functions to the API ;

- IDatabase.HashScanNoValues
- IDatabase.HashScanNoValues
- IDatabaseAsync.HashScanNoValuesAsync

...to enable the return type consisting of keys in the hash.
Added some unit and integration tests in paralleled to what is there for `HashScan` and `HashScanAsnyc`.

Co-authored-by: Nick Craver <nrcraver@gmail.com>
## Issue
#2763

## Solution
Simply added a lock around `_handlers` in `ConnectionMultiplexer.Subscription`, like I was suggesting in the issue.

## Unit Test
I added one that does exactly what the example code in #2763 was doing & testing for. I used the other tests as template/guide, let me know if something isn't up to spec.

---------

Co-authored-by: Nick Craver <nrcraver@gmail.com>
…cks (#2784)

* Fix #2778: Run CheckInfoReplication even with HeartbeatConsistencyChecks

This is an issue identified in ##2778 and #2779 where we're not updating replication topology if heartbeat consistency checks are enabled because we exit the `if` structure early. This still runs that check if it's due in both cases, without changing the behavior of the result processor.

* Add release notes
mgravell and others added 22 commits June 29, 2026 13:09
* investigate and fix #3123

* F+F the test setup

* nit, nuke a using direction

* in debug: make all string -> RedisValue *explicit* (incomplete, needs a fix to the ship-file)

* fix shipfiles for DEBUG hack

* docs

* fix exp link

* I knew we had a Write...(RedisKey) method somewhere!

* move NO ONE to RedisLiterals
…) (#3121)

Replaces the per-method 'int indent + local NewLine()' pattern in
AsciiHashGenerator with a small builder-style CodeWriter wrapping the
existing StringBuilder, per the second sketch in #3033. Generated
output is byte-for-byte identical.
* propose agents guidelines and skills

* add command source notes

* ensure `SORT_RO` parse correctly; impact: Execute would not apply command-map rename correctly otherwise

* Update AGENTS.md
* Throw a clear error when an Execute command contains whitespace

Execute("ACL SETUSER x") passes a whole command line as the single command
token, which gets sent as one unknown command and comes back as an opaque
server error. Since a redis command token never contains internal whitespace,
this is always a caller mistake, so the ExecuteMessage constructor now fails
fast with an ArgumentException-style RedisCommandException that names the
offending command and shows the correct token-per-argument form. Resolves #2689.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>

* test for simple space only

limit to simple space; if people are being *that* creative, that's on them

* Remove test case for 'echo\thello' command

Removed test case for command with tab character.

---------

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
Co-authored-by: Marc Gravell <marc.gravell@gmail.com>
#3129)

* fix #3127 fix #3128 allow reasonable CLIENT commands to proceed without admin mode enabled

* comments
* initial cut

* transactions

* iteration/evolution

* docs

* refactored API based on internal conversations

* more docs

* WIP

* clarify MULTI/EXEC logic

* avoid value-tuple
* fix: Queue batch commands to backlog during MOVED reconnection

When a MOVED-to-same-endpoint triggers reconnection (PR #3003), batch
commands issued during the ~50-100ms reconnection window fail immediately
with NoConnectionAvailable. This is because:

1. RedisBatch.Execute() calls SelectServer() which returns null for
   disconnected/reconnecting servers.
2. PhysicalBridge.TryEnqueue() returns false when !IsConnected, unlike
   TryWriteAsync/TryWriteSync which queue to the backlog.

This creates an inconsistency where individual async commands succeed
(queued via BacklogPolicy) but batch commands throw during the same
reconnection window.

Fix:
- RedisBatch.Execute(): When SelectServer returns null and
  BacklogPolicy.QueueWhileDisconnected is enabled, retry with
  allowDisconnected=true to find the server endpoint.
- PhysicalBridge.TryEnqueue(): When disconnected/reconnecting and
  BacklogPolicy.QueueWhileDisconnected is enabled, queue messages to
  the backlog instead of returning false.

This matches the existing behavior of individual command paths and
ensures batch commands are transparently queued and sent after
reconnection completes.

Tested with proxy redirect scenario (MOVED-to-same-endpoint + disconnect):
- Before: 1-3 NoConnectionAvailable errors per MOVED redirect
- After: 0 errors, batch waits for reconnection via backlog

* test: Add batch backlog tests for MOVED-triggered reconnection

Add two integration tests verifying batch commands are queued to the
backlog during MOVED-to-same-endpoint reconnection:

- MovedToSameEndpoint_BatchCommands_QueuedDuringReconnect: Verifies
  that a batch containing a MOVED-triggering command completes
  successfully after reconnection (commands queued to backlog).

- MovedToSameEndpoint_SubsequentBatch_QueuedDuringReconnect: Verifies
  that a second batch issued during the reconnection window is queued
  rather than throwing NoConnectionAvailable (fire-and-forget scenario).

---------

Co-authored-by: advaMosh <advaMosh@users.noreply.github.com>
…es (#3130)

SwitchPrimary only reconfigured when the new primary was an unknown
endpoint. On an in-place failover (old master demoted, replica promoted),
the new master is already in `servers`, so it no-op'd and the client kept
writing to the demoted node until restart.

Reconfigure with reconfigureAll:true when a known primary's cached role is
stale, so connected nodes re-read their role before re-election. Also move
the switch reconfigure out of the sentinelConnectionChildren lock.

Refs #1891
* LMOVEM

* test CI image available

* SUNIONCARD/SDIFFCARD

* XREAD/XREADGROUP MAXCOUNT/MAXSIZE

* cleanup unshipped

* fixup test versions
We hand-start every server instance the tests need, so drop a policy-rc.d
that tells invoke-rc.d/deb-systemd-invoke not to start services at all. The
packaged redis-server unit would otherwise race our own primary for
127.0.0.1:6379 on any run where WSL came up with systemd.

That also sidesteps an install hang we saw with the 8.10.0 package: its
redis.conf lost "supervised auto", so redis never sd_notify()s, the
Type=notify unit never leaves "activating", and the postinst's
"systemctl start" blocks (indefinitely, since redis then can't save its RDB
on SIGTERM and the unit disables the stop timeout). That is an upstream
packaging slip which they are already fixing, so this isn't a permanent
change in behaviour from 8.10 - but not starting the service is the right
thing for this job regardless.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR is best summarised by docs/Failover.md
Point directly to GitHub releases to save a click.
* Add RESPite.Transports.DuplexTransport + Tunnel.ConnectTransportAsync (SER009, experimental)

The [Experimental] transport seam decided with Marc: a duplex byte-transport abstraction that is
deliberately NOT a Stream and NOT a pipe, plus a null-default virtual on Tunnel that supplies the ENTIRE
transport for a connection -- the same hijack as BeforeAuthenticateAsync one level deeper (no socket is
created at all), returning null for every existing tunnel.

The shape is derived from measured transport work rather than taste, and each member's derivation is in
its doc comment: any-thread copying writes with an explicit Flush (batching at the caller's natural
boundaries was the largest single lever measured), PUSH inbound with transport-owned spans (pull
adapters over a push transport measured 24-40%), and an OnBatchEnd notification (coalescing burst
responses into one flush eliminated a measured 3x send amplification). Abstract classes by design so
members can be added with safe defaults while experimental; receive-into-caller-buffer is deliberately
deferred to exactly that mechanism.

House ceremony: SER009 registered in Experiments, docs/exp/SER009.md, PublicAPI entries in both
projects with the [SER009] experimental prefix. A working SocketSet implementation of this shape
already exists and is gated across plaintext/TLS/@abstract surfaces (SocketSet repo,
src/SocketSet.StackExchange.Redis + bench/tunnel-selftest); it retargets to these types next.

* SER009: the transport IS the IBufferWriter -- Output deleted, writer members abstract on the transport

With Flush on the transport, a separate .Output object never truly described output; the split was
inherited from IDuplexPipe, whose 3-object shape exists for independent Input/Output completion that
this contract does not have. GetMemory/GetSpan/Advance are now abstract members of DuplexTransport
itself, and passing the transport AS IBufferWriter<byte> grants stage-only access: the holder composes,
the owner flushes at its batch boundary.

TransportReceiver deliberately stays separate: it is the CONSUMER's half, and it must remain
abstract-class-evolvable on the net461/netstandard2.0 targets, where interfaces cannot grow.

Experimental (SER009), so this is a free change; PublicAPI entries updated to match. Implementation
side re-gated ALL PASS across plaintext/TLS/@abstract before this landed.

* SER009: GetSpan is a virtual convenience over GetMemory, not a second abstract

GetMemory is the one true abstract of the writer face; GetSpan defaults to GetMemory(sizeHint).Span,
so an implementer owes exactly one buffer-acquisition member. Transports with a genuinely cheaper span
path (the SocketSet implementation forwards to its connection's own GetSpan) override it -- nice if
they do, correct if they don't.

* SER009 doc: state plainly that this is not for external use at this time

The seam is public only because a seam must be public to be implemented; the doc now says so, spells
out that no stability of any kind is implied (members, semantics, name, home, existence -- any version,
no ceremony), and frames suppressing the diagnostic as accepting exactly that. If a generally useful
transport seam emerges it gets stabilised deliberately, not by this surface quietly hardening.
…the single copy (#3153)

The trio existed twice: here (redis-coupled factory) and drifted lib-generic copies on the proxy spike
branch. Duplicated code is asking for trouble, so this is the unification, replace/move not duplicate:
the SE.Redis files are GONE, the one copy lives in RESPite.Streams (it already leaned on RESPite.Buffers
-- the move is with the grain), and the proxy branch deletes its copies on next rebase.

What decoupled: the factory loses ConnectionType/ConfigurationOptions and takes (WriteMode, Stream,
MemoryPool<byte>?, CancellationToken); WriteMode.Default maps to Async as the lib-safe default. The
redis POLICY (pub/sub never wants sync latency mode) moves to the one call site in
PhysicalConnection.InitOutput, where it reads as policy instead of hiding in a lib factory.

RESPite gains the same conditional System.IO.Pipelines reference SE.Redis already carries (in-box from
net10.0) so PipeStreamWriter -- the comparison/troubleshooting implementation -- stays with its family
rather than being split back out via cross-assembly inheritance. Everything stays internal; RESPite
already grants IVT to StackExchange.Redis and the test projects.

Solution builds 0 errors on all TFMs; BufferedStreamWriter + RoundTrip + InProcess/WriteMode test
batteries all pass (129 tests).
* Fix KeyType handling of "none"
fix #3156

* fix generator handling of explicit ""

* additional tests to pin the new generator "" behavior
* first stab at transaction analyzer

* intermediate

* server version logic

* packaging; verifier arg validation; CI packing test

* redundant conditions and compound operations

* build cleanup

* final category

* update transaction docs

* upgrading to "warning"; "information" is too invisible

* advertise Foo[Async] instead of just Foo; handle ITransactionAsync

* Marc/tran analyzer review fixes (#3163)

* Identify the transaction terminator by symbol, not by method name

ITransaction inherits IDatabaseAsync.ExecuteAsync(string command, params object[] args) - a raw command,
very much queued, and the one people reach for exactly when the library has no wrapper for what they want.
The switch discarded it by name alongside the transaction's own ExecuteAsync() terminator, so it was
invisible to the counts every rule below depends on:

    _ = tran.StringGetAsync(key);
    _ = tran.ExecuteAsync("PERSIST", key);   // invisible
    _ = tran.KeyDeleteAsync(key);

reported SER303 "use StringGetDelete[Async](key)", and taking that advice drops the PERSIST. The guarded
families were wrong the same way: a raw command beside a conditional write still made the transaction a
one-command transaction as far as the analyzer could see.

Execute/ExecuteAsync now count as the terminator only when declared on ITransaction/ITransactionAsync,
which is exactly where the terminator lives (ITransaction.cs) and is not where the raw command lives.
A raw ExecuteAsync is now recorded as an ordinary queued operation; it matches nothing in the mapping
tables, so its only effect is to suppress, which is the whole point.

The third test is the control: both spellings of the terminator must still be recognised, or the rules
would go quiet everywhere and the two negatives above would pass for the wrong reason.

* Require queued commands to share a branch, and treat lambdas like loops

The loop check was the only thing standing between "one call site" and "one queued command", and it only
covered the repetition half of that gap.

Branching was not covered at all, so mutually exclusive code read as a pair:

    if (flag) { _ = tran.SetAddAsync(a, member); }
    else      { _ = tran.SetRemoveAsync(b, member); }

reported SER303 "use SetMove[Async](source, destination, value)" - which queues a removal the code
deliberately did not. The asymmetric form was the same: a StringGet followed by a KeyDelete queued only
under an if became GETDEL, and a condition guarding a command in a branch it is not itself in became a
conditional argument.

The fix is branch *matching* rather than a blanket "anything conditional is out", because two commands in
the same if body do always queue together and a compound command really does replace them - and because a
whole transaction inside an if or a try is ordinary code that must not go silent. Each call records the
innermost enclosing branch (the arm, not the if: the two arms of one if/else have to compare unequal), and
a transaction whose calls do not agree is disqualified. The terminator is exempt - "queue it all, then
commit inside an if" says nothing about whether the queued commands belong together.

Repetition also had a second, larger hole: a lambda or local function is one call site whose invocation
count is not visible at all, so

    _ = tran.KeyDeleteAsync(key);
    Queue(); Queue();
    void Queue() => _ = tran.KeyDeleteAsync(other);

reported SER304 over "these 2 queued KeyDeleteAsync calls" when three are queued. Those bodies now
disqualify exactly as a loop body does.

OperationsInTheSameBranch_AreStillFlagged is the control: without it, matching could be tightened to
"never fire under a conditional" and every negative here would still pass.

* Decline where the suggested command cannot carry an argument the caller wrote

Only arguments 0 and 1 were ever looked at, so everything after them was invisible - and several
suggestions cannot express what was there. The rewrites were silently lossy:

  - N x StringSet(key, value, expiry) -> MSET. MSET takes one expiry for the whole batch, not one per
    key, so following SER304 here makes both keys permanent. This one outlives the build.
  - N x HashSet(key, field, value, When.NotExists) -> HSET variadic, which has no NX.
  - StringGet + KeyExpire(key, expiry, ExpireWhen.HasNoExpiry) -> GETEX, which has no NX/XX.
  - Condition.KeyNotExists + StringSet(key, value, when: When.Exists) -> "use When.NotExists", turning
    code that never writes into code that writes when absent.

Each mapping now states which parameters its suggestion still carries, and an argument the caller wrote
that is not among them declines the rewrite. Per-mapping rather than a blanket "any extra argument is
out", because SET *does* take an expiry alongside NX and that is the commonest shape the rule exists for -
GuardedOperationWithExpiryAndFlags_IsStillFlagged is the control that keeps it. Family C carries a null
coverage set meaning "everything": it keeps the command exactly as written and only deletes the condition,
so no argument of it can go missing however exotic.

CommandFlags is exempt throughout - it is on every command, no suggestion mentions it, and the help pages
already say to carry it over verbatim. Omitted optional arguments do not count either: they carry no
intent, and are what the suggested form would default to anyway.

Coverage is stated as names the suggestion keeps rather than names it drops so that the fail-safe
direction is the default: a parameter added to an overload later reads as uncovered and goes quiet,
instead of being silently dropped by a rewrite that has never heard of it.

Also fixes a related sharp edge in ArgumentText: an omitted optional argument reports the *invocation*
as its syntax, so "the second argument" of tran.KeyDeleteAsync(key) was the text of the whole call. It
could only ever collide with another omission from the same call site, but it was never a member the
caller wrote, and RequiresMember was asserting nothing as a result.

* SER303: StringSet then KeyExpire is SET ... EX

Requested as a backlog item during review, and it fits family D exactly: two queued commands, no
condition, one command that does both.

No version clause. Setting a value and its lifetime in one command is as old as SET''s options (2.6.12),
so naming a version would be the noise the other Any entries avoid. An absolute expiry is covered too -
Expiration converts implicitly from DateTime as well as TimeSpan - though the EXAT form underneath that
one does want 6.2; that caveat goes on the help page rather than into the version clause, because putting
6.2 in the mapping would hide the ordinary relative case from everyone who has declared a floor below it.

Order is load-bearing, in the opposite direction to the reads already here: SET *clears* any TTL, so an
EXPIRE followed by a SET leaves no expiry at all. Only (StringSet, KeyExpire) maps, and
KeyExpireThenStringSet_IsNotFlagged pins that.

A StringSet that already carries an expiry, followed by an EXPIRE that overrides it, stays quiet: which
of the two lifetimes the single command should carry is a guess. That falls out of the coverage machinery
from the previous commit rather than needing its own check - "expiry" is simply absent from the first
operation''s coverage set - which is what turns MapPair''s coverage from one set into one per operation.
The same split lets GETEX keep rejecting an ExpireWhen while accepting the expiry beside it, where a
single shared set had to spell "when" in both senses at once.

Not covered, and worth its own item if anyone wants it: Condition.KeyNotExists + StringSet + KeyExpire is
StringSet(key, value, expiry, When.NotExists), but that is a condition *and* a pair, which is neither
family as they are drawn today.

* Do not walk every block twice to answer a question most blocks never ask

The reassigned-locals scan ran over every operation block before anything knew whether the block held a
transaction at all, so every method body in every project that references the package paid two full
Descendants() walks where one would do. The compilation-level short-circuit does not help here: it only
skips compilations that have never heard of StackExchange.Redis, which is precisely not the population
this analyzer ships to.

Moved below the "no transaction locals here" bail-out. Same answer, and the class comment''s claim of one
pass is now true for the blocks that take it.

Also, while here: parse the declared server version with the invariant culture, since it comes from a
config file rather than from someone typing in a locale; and stop re-trimming a method name that
QueuedOperation already trimmed, which read as though Map expected raw input.

* Document the shapes the analyzer now declines, and the new SET ... EX pair

The per-rule "deliberately not flagged" lists were repeating each other and had gone out of date in the
same places, so the family-wide cases move to one section in the rules index and each page points at it.
Three of them are new behaviour from this branch (a third queued command including a raw ExecuteAsync,
commands in different branches, arguments the single command cannot carry) and one - SER300 declining a
caller''s own when: - is worth stating next to the expiry case that is still flagged, since "extra
arguments suppress" is not the rule and would be a fair thing to conclude otherwise.

SER302 gets the opposite note: it keeps the command exactly as written and deletes only the condition, so
the argument caveat is the one family-wide case that does not apply to it.

SER303 gains the StringSet + KeyExpire row, the second (and opposite) reason order matters on this rule -
SET clears the TTL, so only one direction is SET ... EX - and the absolute-expiry caveat that the version
column cannot carry.

* implement-resp-command: consider whether the new command replaces a transaction

A surprising share of new commands are atomic compositions - GETDEL, GETEX, HGETDEL, SMOVE, SET NX/GET/IFEQ,
SMISMEMBER, every variadic form - and every one of them exists because people were writing a transaction to
get the same effect. TransactionAnalyzer is what tells those people to stop, and a command that is not added
to its tables is invisible there: the analyzer stays quiet about exactly the code the command was written to
replace. Cheap at the time, and nobody comes back for it later.

So the skill now asks the question as a step, and the new section says what a mapping costs: which table by
the shape of transaction it replaces, the server version the *suggestion* needs (not the flagged code), the
coverage set that stops a rewrite silently dropping an argument, and the same-member / order / key-direction
constraints that each table has a column for.

Two things it insists on because they are what the review of that analyzer turned up. Coverage is stated as
names kept rather than names dropped, so a parameter added to an overload later fails safe. And near-misses
get written down where the next person will hit them - LMOVE and LMPOP are both wrong for reasons that are
only obvious once you have had them explained.

* Say plainly that the usage rules are guidance, and where to report a bad one

These five read source text and cannot see keys, servers, or intent, so they are heuristics however
carefully drawn - and they arrive as warnings, in a package the consumer did not opt into an analyzer
from. Saying "this is a suggestion, not a defect report" costs a paragraph and sets the expectation the
severity otherwise sets wrongly.

The part that matters is the second half: a rule firing on correct code is a bug in the rule, not
something for the consumer to work around, and it reaches everyone. Suppression is documented right
below and is easy to reach for silently; the issue link is there so that reporting it is just as easy,
and the wording pushes that way first.

SER350 deliberately does not get this. It reports an actual build problem - code that was not generated -
rather than offering an opinion about working code, and there is nothing heuristic about it.

* Hedge the usage diagnostics: they are suggestions, and they are heuristics

"can be replaced", "are one command", "is redundant" are findings of fact, and these rules are not in a
position to state one - they read source text and cannot see keys, servers, or intent. Combined with a
warning severity, which already overstates the case, the wording claimed more certainty than the analysis
has. Titles now say "may be replaceable" / "may be redundant" / "may suit", and messages lead with
"Consider" or "looks like ... - consider".

Message arguments and their order are untouched, so this is wording only; the tests format through the
same descriptors and are unaffected.

SER350 deliberately keeps its plain phrasing. It reports that generated code was not emitted, which is a
fact about the build rather than an opinion about working code.

The help pages, the index list and the release-tracking notes move with the titles, since a message that
hedges next to a doc heading that does not is worse than either alone.

* Recognise CommandFlags by name as well as by type, and gate it with tests that fail without it

Flags never bear on any of the argument audit: they are on every command, no suggestion mentions them,
and the rewrite carries them over verbatim. The exclusion was by type alone, which is the check that can
come back null - and the failure mode is not a missed exclusion but silence everywhere, because every
command takes flags, so one unrecognised spelling would suppress every rule for anyone who passes them.
Name and type both, which costs a string comparison on a path that only runs for calls on a transaction.

The behaviour was technically already covered, but only by GuardedOperationWithExpiryAndFlags, which
carries an expiry alongside the flags and is really about the expiry - and only for SER300, where the
audit runs in three separate places. Three tests now pass flags as the *only* extra argument, one per
family.

Checked rather than assumed: with the exclusion disabled, exactly those three and the expiry one fail,
and nothing else does. A test that would pass either way is not a gate.
…ries (#3161)

* Sub-command retry categorization, tranche 1 (#3148)

Categorize commands whose side-effect profile depends on their arguments
rather than just the command name, via the (previously unused) WithCategory
hook at the message factories.

Fixes an oversight in WithCategory itself: CommandServerSpecific is
orthogonal to the severity ladder, but was being masked off - and since
setting a ladder bit suppresses WithDefaultCategory, a conditional
category would also have dropped the server-specific bit the default
would have supplied.

Corrects three classifications that were unsafe (retried by the default
policy when they should not be):
- SORT ... STORE was read-only; it writes the destination key
- GETEX/HGETEX with EX/PX/EXAT/PXAT/PERSIST were read-only; they mutate TTL
- SortedSetIncrement(When.Exists) emits ZADD ... XX INCR but inherited
  ZADD's last-wins, so a replay could double-increment

Demotions: XREADGROUP with an explicit id (re-reads own PEL) never ->
read-only; GeoRadius[ByMember] -> read-only (the typed API never emits
STORE); SCAN from cursor 0 is no longer node-affine.

Promotions to checked: SET with NX/XX or any ValueCondition (IFEQ/IFNE/
IFDEQ/IFDNE); ZADD with NX/XX/GT/LT; ZADD ... NX INCR; EXPIRE/HEXPIRE with
NX/XX/GT/LT; XADD with an explicit id or IDMP/IDMPAUTO; XCLAIM/XAUTOCLAIM
with JUSTID. COPY ... REPLACE -> last-wins.

Also implements CONFIG GET -> CommandRetryConnection, which the docs
already described but the code did not do.

* Sub-command retry categorization, tranche 2: server commands (#3148)

CLIENT, CLUSTER, CONFIG, SCRIPT, SLOWLOG, LATENCY, MEMORY and SENTINEL are
each a single RedisCommand spanning very different verbs, so the
whole-command default has to assume the most side-effecting one. The IServer
methods know which subcommand they issue, so categorize accordingly.

Two needed *raising*, because their parent command's default was the
permissive end:
- MEMORY PURGE: MEMORY defaults to read-only, so a purge was being treated
  as a harmless read
- CLIENT KILL: CLIENT defaults to connection-level

Demotions, all also flagged node-affine since the answer (or effect)
belongs to the server that was asked: CLUSTER NODES, SCRIPT EXISTS,
SCRIPT LOAD (idempotent - same SHA - and on the normal EVALSHA path),
SLOWLOG GET, LATENCY DOCTOR/HISTORY/LATEST, MEMORY DOCTOR/STATS/
MALLOC-STATS, and the SENTINEL introspection verbs (MASTER, MASTERS,
REPLICAS/SLAVES, SENTINELS, GET-MASTER-ADDR-BY-NAME).

SENTINEL FAILOVER, CONFIG SET/REWRITE/RESETSTAT, SCRIPT FLUSH, SLOWLOG
RESET and LATENCY RESET stay at the server-admin default.

Also de-duplicates a few sync/async message-construction pairs behind
shared GetXxxMessage factories, matching the convention used elsewhere,
and refreshes the now-stale "ignoring X" / "can be considered more safe"
comments in the per-command default table to point at where each case is
actually handled.

* be less verbose in the docs update

* words

* clarify transaction rules in failover.md

* Marc/sub command category fixes (#3164)

* Address cold-eyes review findings 1-4 on #3161

Two of these are correctness fixes to the new arg-specific rules; one is a
consistency gap; the last is the coverage that would have caught the first two.

1. XADD <ms>-* was categorized as an explicit id, and so retried by default.

   GetStreamAddCategory tested the id against the bare "*". Redis 7.0+ also
   accepts <ms>-*, where the server picks the *sequence* - so a replay appends
   5-1 after 5-0 rather than being rejected as "equal or smaller". messageId is
   an unvalidated RedisValue on the public StreamAdd overloads, so this is
   reachable, and WriteChecked (12) is <= the default MaxCommandRetryCategory
   (WriteLastWins, 16): it *is* retried. That made it a regression against the
   coarse rule, where XADD's whole-command WriteAccumulating (20) blocked it.

   Now keyed on "does the id end in *", which is exactly Redis's spelling for a
   server-assigned part, and covers both "*" and "<ms>-*". An id we cannot read
   (empty, or implausibly long) is treated as caller-specified: the server then
   rejects it deterministically on every attempt, so a replay is harmless.

2. The XREADGROUP demotion ignored CLAIM.

   The category keyed purely on the position, but WriteImpl emits CLAIM <ms>
   whenever claimMinIdleTime is set, independent of the position - and the
   public API lets you pass both. So StreamReadGroup(position: "0-0",
   claimMinIdleTime: 30s) was categorized CommandRetryReadOnly while the command
   may take entries from *other* consumers' pending lists and bump their
   delivery counts. That is the same side-effect that keeps XCLAIM off the read
   rung in WithJustIdCategory. CLAIM now suppresses the demotion outright, for
   both the single- and multi-stream forms.

3. HashFieldExpire did not get the ExpireWhen rule that KeyExpire does.

   Safe direction - it stayed at the WriteLastWins default - but it meant the
   NX/XX/GT/LT reasoning applied to keys and not to hash fields. The message
   build is split out of HashFieldExpireExecute into GetHashFieldExpireMessage
   so the rule sits with the other factories, and so it is testable.

4. Coverage for the rules that had none.

   The new rules were about half tested, and the untested half was
   disproportionately *demotions* - the direction where a mistake loosens safety
   rather than tightening it. Added: GETEX/HGETEX TTL variants, COPY REPLACE,
   EXPIRE/EXPIREAT and HEXPIRE conditions, XCLAIM/XAUTOCLAIM JUSTID, XREADGROUP
   positions, GEORADIUS, SCRIPT LOAD.

Verified the three fixes are load-bearing by reverting each in turn against the
new tests: exactly one test fails per fix (Accumulating->Checked for 1,
Never->ReadOnly for 2, Checked->LastWins for 3), and the 12 pure-coverage
assertions stay green either way. A test that passes against the unfixed code
would not have distinguished these from deliberate choices.

Not addressed here, both judgement calls rather than defects: SCRIPT LOAD is
marked CommandServerSpecific, which blocks the failover retry that is arguably
the most useful one for it; and CLUSTER NODES is spelled out by hand in
ServerEndPoint and ConnectionMultiplexer rather than going through the new
RedisServer.GetClusterNodesMessage.

Test run: 15/15 in CommandRetryCategoryUnitTests. The wider server-free unit
suites are 1746 passed / 5 failed, and those 5 fail identically on the
unmodified PR head (they need a live Redis on 6379).

* Loosen SCRIPT LOAD, and give CLUSTER NODES one spelling (review findings 5-6)

Both were flagged as judgement calls rather than defects in the review of #3161;
this takes the calls.

SCRIPT LOAD is no longer node-scoped.

  Everything else under SCRIPT is server-admin and node-scoped, and LOAD had
  inherited the second half of that. It should not: the SHA is a pure function
  of the script, so a replay on a *different* node returns the same answer, and
  nothing about it touches the keyspace, so there is no data outcome to get
  wrong. The bookkeeping already follows the wire rather than our intent -
  ResultProcessor.ScriptLoad records the hash via
  connection.BridgeCouldBeNull.ServerEndPoint.AddScript, i.e. against whichever
  endpoint actually replied, not the one we aimed at. So a load that lands
  elsewhere records correctly rather than corrupting a cache. Worst case the
  node we *meant* to warm is still cold, and a later EVALSHA there gets NOSCRIPT
  and falls back to EVAL - an already-handled path (IsScriptUnavailable).

  Being straight about the scope: this is stated intent rather than a live
  behaviour change. The only caller that could be retried across endpoints is
  IServer.ScriptLoad, and WithRetry wraps IDatabaseAsync only; the internal
  load-then-EVALSHA pairing in ScriptEvalMessage.GetMessages is written to one
  connection as a unit and so is never independently re-routed. It matters if
  either of those ever changes, and it costs nothing to be right now.

  The test asserts the *absence* of the node-scoped bit against the SCRIPT
  whole-command default as a control, because the category alone is identical
  either way - checking only the category would not have seen this change at
  all.

CLUSTER NODES now has one spelling.

  The PR added RedisServer.GetClusterNodesMessage but left ServerEndPoint
  .AutoConfigureAsync and ConnectionMultiplexer.GetEndpointsFromClusterNodes
  building the same message by hand - and in two different styles, one applying
  WithCategory and one passing the literal flags straight through. Three copies
  of one decision is how the three drift apart. Both now route through the
  factory, and the justification comment moves onto it.

  Behaviour-preserving by construction: NodeLocalRead is exactly the
  CommandRetryReadOnly | CommandServerSpecific pair both sites spelled out, and
  the multiplexer's site passed CommandFlags.None, so WithCategory yields the
  same flags the literal did. No new test - the factory's output is already
  pinned by ServerSubCommands_AreCategorizedBySubCommand, and the change is a
  reduction in call sites rather than in behaviour.

Test run: 15/15 in CommandRetryCategoryUnitTests; 1728/1728 across the
server-free unit suites (the 5 that need a live Redis on 6379 are excluded here
rather than failing). All target frameworks build with 0 warnings.

* RedisValue.StartsWith: stop measuring a UTF-16 string against a UTF-8 prefix

Two bugs, both from treating char count and byte count as the same number.

"Not enough characters to match" gave up whenever the string had fewer chars than the prefix had bytes,
which is wrong the moment anything is not ASCII: "e-acute, euro" is 2 chars but 5 UTF-8 bytes, so every
prefix of 3 bytes or more was rejected out of hand. A shorter string is not grounds to give up at all -
2 chars can be 8 bytes - so the early-out goes rather than being adjusted.

The cut that follows was right in principle: each char is at least one UTF-8 byte, so a prefix of N bytes
never needs more than N chars. But cutting at exactly N chars can land between the halves of a surrogate
pair, and the encoder then emits U+FFFD (EF BF BD) rather than the character being asked about - so
"a" + U+1F600 did not start with 61 F0, which it plainly does. The cut now takes the low half with it.

Both were false negatives only, and only for non-ASCII, which is presumably why they lasted: the ASCII
path that everything else exercises has char count and byte count agreeing by definition.

Tests first, and both failed at the predicted assertion before the fix.

* RedisValue.EndsWithAscii, and use it for the stream server-assigned-id test

IsServerAssignedId wanted one byte and paid for the whole value to get it: Length() on a string-backed id
is a full UTF-8 GetByteCount scan, and then the id was encoded again into a 64-byte stack buffer so that
the last byte could be read. A literal "*" or "5-*" is the overwhelmingly common case, and it is exactly
the case that path handles worst.

EndsWithAscii asks each storage kind directly, in the shape StartsWith(ReadOnlySpan<byte>) already uses:

  - blob kinds (short blob, byte[], memory manager): index the last byte of the raw span
  - sequence: TryGetLast walks to the final segment, skipping empties the way the rest of
    ReadOnlySequenceExtensions does; still no copy
  - string: compare the last *char*. ASCII-only is what makes this valid rather than merely fast - UTF-8
    never uses a byte below 0x80 as a continuation byte, so "the encoding ends with this byte" is exactly
    "the last char is this char". No encode, no length, and correct for non-ASCII rather than undefined.
  - integers: the final digit is value % 10. The sign is a non-issue if you negate the *remainder* rather
    than the value - that is safe even for long.MinValue, where negating the value overflows.
  - double: format and look. Nobody should be asking a double this, and going through Format is what keeps
    the answer agreeing with CopyTo and so with the wire, +inf/-inf included.

The call site loses MaxStreamIdBytes entirely: the 64-byte cap only ever existed to bound the stackalloc.
That is a deliberate behaviour change at the edges - a >64-byte value ending in "*" was classified
caller-specified and is now server-assigned - but every such value is an invalid id that the server
rejects deterministically on each attempt, so which retry category it carries cannot be observed.

Tested per storage kind, each asserted against the kind it actually landed in, because a different route
through the switch is the entire point; plus a value run through every kind that can hold it, since the
answer must not depend on how it happens to be stored. MemoryManager is fabricated via CreateForeign - no
ordinary conversion produces one, and "awkward to construct" is not a reason for a kind to go untested.

* EndsWithAscii: name the UnsafeRawSpan scratch local

On the TFMs without MemoryMarshal.CreateReadOnlySpan, a short blob''s span is built over a raw pointer to
the out parameter''s slot, so that slot has to stay put for as long as the span is read through. StartsWith
gets away with "out _" because it consumes the span in the same expression; this reads it across two
statements, so it names the local, as BlobSequenceEqual already does and says why.

No behaviour change on net10.0. Verified on both test TFMs - net481 is the one that actually takes the
pointer path.
…md '@' convention) (#3165)

'!name' has always meant a Unix domain socket, but '!@foo' silently produced a PATHNAME socket
literally named '@foo' -- the wrong socket, found by nothing. The parse now maps '@' after '!' to the
kernel's leading-NUL spelling, Linux-gated (no other platform has the namespace; elsewhere '@' stays a
literal filename, matching redis-cli). ToString round-trips for free: UnixDomainSocketEndPoint renders
abstract names back as '@name'.

Tests: FormatTests gains UDS parse/round-trip coverage (there was NONE, even for pathnames) -- pathname
and abstract cells, the latter Linux-gated. Verified live end-to-end besides: a ConnectionMultiplexer
built from ConfigurationOptions.Parse("!@se-abs-test,abortConnect=true") against a Garnet listening
on the same abstract name, SET/GET round-trip clean, no filesystem footprint.
…queue more than once" (#3166)

TryGetBranch lumped IAnonymousFunctionOperation and ILocalFunctionOperation in with ILoopOperation and
returned false, which the caller reads as "this call can queue N times" and uses to disqualify the whole
transaction. Roslyn hands out no separate operation block for a lambda or local function - the body arrives
as part of the containing method - so every transaction written inside one was silently invisible. That
includes top-level statements, where the entire program body is one synthesised method, so the analyzer said
nothing at all about the commonest way a small repro gets written.

The repeat risk belongs to the *captured* transaction, not to the boundary itself. A transaction that is a
local of the function being walked out of is created afresh on each invocation, so one invocation holds one
whole transaction and the counts within it are exact; whatever encloses the function governs how many
transactions there are, not what goes into each. The two boundary cases now stop the walk and accept when the
transaction local belongs to that function, and keep returning false when it was captured from outside. A
loop inside such a function is still hit first, as it must be.

Tests: the two existing negatives were already the captured shape, so they are unchanged and still pass. Four
added - transaction declared in a local function (the reported shape), in a lambda, in a local function called
in a loop (N transactions, not one with N commands), and a loop inside such a function (still suppressed).

docs/rules/index.md stated the old blanket behaviour as an intentional limitation; corrected.

No AnalyzerReleases change: detection coverage only, no new or altered diagnostic IDs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.