diff --git a/Directory.Build.props b/Directory.Build.props index 3f88b7ba17..2e41569006 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,4 @@ - + 2.0.0 2014 - $([System.DateTime]::Now.Year) Stack Exchange, Inc. @@ -12,7 +12,7 @@ NETSDK1069 - $(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER012 + $(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER011;SER012 https://github.com/StackExchange/StackExchange.Redis/releases https://seredis.dev/ MIT diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md index f8ff94211e..b6e7fcd96a 100644 --- a/docs/CommandCoverage.md +++ b/docs/CommandCoverage.md @@ -42,6 +42,7 @@ Statuses: `policy` (excluded by design), `not-applicable` (not meaningful for a |---|---|---|---| | `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`. | +| `SEXPIRETIME` | 8.12.0 | `not-applicable` | Second-precision twin of `SPEXPIRETIME`, as `HTTL`; the library always reads `SPEXPIRETIME`. | | `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. | @@ -156,21 +157,6 @@ PRs welcome; follow the `implement-resp-command` skill. Pre-release server featu | 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`. | diff --git a/docs/exp/SER011.md b/docs/exp/SER011.md new file mode 100644 index 0000000000..ceb816e1a7 --- /dev/null +++ b/docs/exp/SER011.md @@ -0,0 +1,31 @@ +Redis 8.12 is currently in preview and may be subject to change. + +New features in Redis 8.12: + +- Bit enumeration (`BITRANGEINTARRAY`), surfaced as `IDatabase.StringBitPositions` / `StringBitPositionsAsync`: + the positions of the set (or clear) bits in a byte or bit range of a string or native bitmap, with optional paging +- Per-member set expiration (`SADDEX`, `SEXPIRE`/`SPEXPIRE`/`SEXPIREAT`/`SPEXPIREAT`, `SPTTL`, + `SPEXPIRETIME`, `SPERSIST`), surfaced as `IDatabase.SetAdd` (the `Expiration` overloads), `SetMemberExpire`, + `SetMemberGetTimeToLive`, `SetMemberGetExpireDateTime` and `SetMemberPersist` (and async + equivalents) +- Per-key eviction protection (`BLESS`), surfaced as `IDatabase.KeyBless`/`KeyUnbless`/`KeyBlessFlags` (and async + equivalents) with the `BlessFlags` enum, and `IServer.BlessedKeys`/`BlessedKeysAsync` for enumeration + +The corresponding library features must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);SER011 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER011 +``` diff --git a/notes/bitrangeintarray/plan.md b/notes/bitrangeintarray/plan.md new file mode 100644 index 0000000000..0bf37e5495 --- /dev/null +++ b/notes/bitrangeintarray/plan.md @@ -0,0 +1,310 @@ +--- +feature: bitrangeintarray +client: se-redis +hld: {path: none, sha: none} +tracks: [redis/redis#15986, redis/redis#15331] +target_version: "8.12" +decision_class: C +conventions: + - .claude/skills/implement-resp-command/SKILL.md#Steps + - .claude/skills/implement-resp-command/SKILL.md#Tests - the two layers that matter + - AGENTS.md#Public API tracking (important — easy to trip over) +estimated_size: small +integration_targets: [] +unit_targets: [RoundTripUnitTests.BitPositionsRoundTrip, ResultProcessorUnitTests.BasicAggregates] +open_questions: 6 +--- + +# BITRANGEINTARRAY: StackExchange.Redis implementation plan + +No shared client HLD exists for this command. The server facts below come from the description of +redis/redis#15986 (merged 2026-10-28) and from `src/commands/bitrangeintarray.json` at +redis/redis@6a5fee67 (`since` 8.12.0, group `bitmap`, arity -4, flags `READONLY`, ACL category +`BITMAP`, one key at position 1, `reply_schema` an array of non-negative integers). Requirement +ids `R.x` are assigned here from the PR's "Command behavior" bullets so they can be tracked. + +## 1. Summary + +Callers get `IDatabase.StringBitPositions` / `IDatabaseAsync.StringBitPositionsAsync`: the +offsets of the set bits (or, with `bit: false`, the clear bits) inside a byte or bit range of a +string or native bitmap, ascending, optionally capped with `limit`, returned as `long[]`. It is +the enumerating sibling of `StringBitPosition` (`BITPOS`, first match only) and `StringBitCount` +(`BITCOUNT`, count only), and takes the same `key, start, end, indexType` shape as both. + +Decision class **C** (new core command), but the smallest possible instance of it: the reply is a +flat integer array, so `ResultProcessor.Int64Array` is reused unchanged, and the argument shape +fits `Message.Create` (key plus at most six values), so no `Message` subclass is needed. + +Analogue traced end to end: `StringBitPosition` / `RedisCommand.BITPOS` - the interface pair +(`IDatabase.StringBitPosition`, `IDatabaseAsync.StringBitPositionAsync`), the implementation and +its message builder (`RedisDatabase.StringBitPosition`, `RedisDatabase.GetStringBitPositionMessage`), +the `IsPrimaryOnly` classification, the keyspace-isolation forwards +(`KeyPrefixedDatabase.StringBitPosition`, `KeyPrefixed.StringBitPositionAsync`), the exact-bytes +test (`RoundTripUnitTests/BitPositionRoundTrip.cs`) and the live tests (`BitTests.cs`). The +version gate and experimental gating follow `RedisFeatures.HashImport` and the `SER011` / +`Experiments.Server_8_12` gate introduced by #3261 (BLESS, also 8.12). + +`estimated_size: small`: two interface members, one implementation pair, one internal message +builder, two isolation forwards, one enum member plus its `IsPrimaryOnly` case, one literal, a +version constant and property, public-API lines, one round-trip test class, live tests, and one +ledger row deleted. No new types, no new result processor. + +## 2. Requirement coverage + +| Id | Server fact (from redis/redis#15986) | Where (file, symbol) | Proving test | Note | +|---|---|---|---|---| +| R.1 | Syntax `BITRANGEINTARRAY key start end [BYTE\|BIT] [LIMIT count] [ZEROS]` | `RedisDatabase.GetStringBitPositionsMessage` | `BitPositionsRoundTrip.*` (exact bytes) | Options always emitted in this canonical order | +| R.2 | Read-only; works on string values and native bitmaps | `RedisCommand.IsPrimaryOnly` (replica-eligible branch, next to `BITCOUNT`/`BITPOS`) | `BitTests.StringBitPositions_*` (live, written not run) | `READONLY` flag in the spec | +| R.3 | `start`/`end` required, `BITCOUNT` range semantics: inclusive, `BYTE` default or `BIT`, negative from the end, clamped, reversed range empty | `IDatabase.StringBitPositions` parameters `start = 0, end = -1, indexType = StringIndexType.Byte` | `BitPositionsRoundTrip.Defaults_SendStartAndEnd`, `BitIndexType_SendsToken`; live `StringBitPositions_Ranges` | Client always sends both bounds; no `StringIndex.Unbounded` handling (unlike `BITPOS`, the server has no open-ended form here) | +| R.4 | `ZEROS` returns clear bits, never past the end of the value | `bit: false` -> `RedisLiterals.ZEROS` appended | `BitPositionsRoundTrip.ClearBits_SendsZeros`; live `StringBitPositions_ClearBits` | Documented in the XML remarks, contrasting `StringBitPosition(bit: false)` | +| R.5 | `LIMIT count`: lowest `count` offsets; `LIMIT 0` -> empty array; negative -> server error (observed: `ERR LIMIT can't be negative`) | `limit` parameter (`long? limit = null`), argument check in `GetStringBitPositionsMessage` | `BitPositionsRoundTrip.Limit_SendsToken`, `Limit_Negative_Throws`; live `StringBitPositions_Limit` | Negative rejected client-side with `ArgumentOutOfRangeException` rather than a round-trip | +| R.6 | Paging: `BIT` -> next `start` = last offset + 1; `BYTE` -> reply completes the byte, next `start` = last offset / 8 + 1 | XML `` on `IDatabase.StringBitPositions` | live `StringBitPositions_PagesWithoutGaps` (BYTE and BIT) | No paging helper in this plan; see open question 4 | +| R.7 | Options accepted in any order; repeated option, last wins | n/a | n/a | Server-side leniency (observed: `ZEROS LIMIT 2 BIT` and `LIMIT 1 LIMIT 3` both accepted); the client emits one canonical order and never repeats | +| R.8 | Missing key or empty value -> empty array; other types -> `WRONGTYPE` | `ResultProcessor.Int64Array` (empty aggregate -> empty array); error propagates as `RedisServerException` | `ResultProcessorUnitTests.BasicAggregates.Int64Array` (`*0\r\n`); live `StringBitPositions_MissingKey` | Existing processor and tests already cover the empty shape | +| R.9 | Reply: ascending array of non-negative integers (`reply_schema`) | `ResultProcessor.Int64Array` | `BasicAggregates.Int64Array` (RESP2 `*`, streaming `*?`, RESP3 attribute prefix) | RESP3 has no distinct shape for an integer array | +| NF.1 | No fixed result cap; only client output-buffer limits bound the reply | `long[]` return; remarks advise `limit` for large bitmaps | n/a | See open question 3 (lease-based variant) | + +## 3. Public API to add + +Stable or experimental: **experimental**, `[Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]`, +because 8.12 is unreleased and #3261 (BLESS, same server version) establishes `SER011` as the +8.12 gate. See open question 1 for the ordering with #3261. + +### `IDatabase` (sync) + +```csharp +/// +/// Returns the positions of the bits set to 1 (or, when is false, set to 0) in a range of a string or bitmap, in ascending order. +/// Positions are counted as in : the string is an array of bits from left to right, where the most significant bit of the first byte is position 0. +/// +/// The key of the string or bitmap. +/// True to return the positions of set bits, false to return the positions of clear bits. +/// The start of the range (defaults to 0). +/// The end of the range, inclusive (defaults to -1, the last byte or bit). Negative values index from the end of the value. +/// Whether and are byte or bit offsets (defaults to ). +/// The maximum number of positions to return, lowest first; null returns every match in the range. See the remarks for paging. +/// The flags to use for this operation. +/// The matching bit positions in ascending order; empty when the key does not exist, the range is empty, or nothing matches. +/// +/// +/// +/// Unlike with false, +/// the range never extends past the end of the value, so no clear bits are reported beyond it. +/// +/// +/// To page through a large bitmap, pass and continue from the last position returned: +/// with , the next is that position plus one; +/// with , the server completes the byte containing the last position (so a page can hold up to 7 more than ), +/// and the next is that position divided by 8, plus one. A page shorter than ends the scan. +/// Pages are separate commands and do not share a snapshot. +/// +/// Available from Redis 8.12; older servers reject the command. +/// +[Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +long[] StringBitPositions(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None); +``` + +### `IDatabaseAsync` + +```csharp +/// +[Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +Task StringBitPositionsAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None); +``` + +Grounding: parameter list and defaults copied from `IDatabase.StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None)` +with `limit` inserted before `flags` (`flags` stays last, per every sibling). `bit` is required, +as on `StringBitPosition`. Return type `long[]` matches the other `ResultProcessor.Int64Array` +consumers. No new `APITypes/` type. + +### `RedisFeatures` + +```csharp +/// Is bit enumeration (BITRANGEINTARRAY) available? +public bool StringBitPositions => Version.IsAtLeast(v8_12_0); +``` + +plus the constant `v8_12_0` (see open question 2 for its value and for sharing it with #3261). + +### Back-compat + +Purely additive: two new interface members (default-free interfaces, so every implementor gains +them: `RedisDatabase`, `KeyPrefixedDatabase`, `KeyPrefixed`), one new `RedisFeatures` property, +one new `RedisCommand` member (internal). Nothing shipped changes. All public-API lines land in +`src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt`, prefixed `[SER011]` as the BLESS +lines are; nothing in the `net6.0/` folder (no TFM-specific types involved). + +## 4. Files to change + +| Path | add/edit | What | +|---|---|---| +| `src/StackExchange.Redis/Enums/RedisCommand.cs` | edit | `BITRANGEINTARRAY` member in the `BIT*` group (after `BITPOS`); a `case RedisCommand.BITRANGEINTARRAY:` in the replica-eligible branch of `IsPrimaryOnly`, next to `BITCOUNT`/`BITPOS` | +| `src/StackExchange.Redis/RedisLiterals.cs` | edit | `ZEROS = RedisValue.FromRaw("ZEROS"u8)`; `BIT`, `BYTE`, `LIMIT` already exist | +| `src/StackExchange.Redis/Interfaces/IDatabase.cs` | edit | `StringBitPositions` with the XML doc above, placed after `StringBitPosition` | +| `src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs` | edit | `StringBitPositionsAsync` with ``, after `StringBitPositionAsync` | +| `src/StackExchange.Redis/RedisDatabase.cs` | edit | `StringBitPositions` / `StringBitPositionsAsync` next to `StringBitPosition`; `internal Message GetStringBitPositionsMessage(in RedisKey key, bool bit, long start, long end, StringIndexType indexType, long? limit, CommandFlags flags)` next to `GetStringBitPositionMessage` | +| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs` | edit | sync forward `Inner.StringBitPositions(ToInner(key), bit, start, end, indexType, limit, flags)` | +| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs` | edit | async forward with `ToInner(key)` | +| `src/StackExchange.Redis/RedisFeatures.cs` | edit | `v8_12_0` constant (if #3261 has not already added it) and `StringBitPositions` property | +| `src/RESPite/Shared/Experiments.cs` | edit (conditional) | `Server_8_12 = "SER011"` only if #3261 has not landed; otherwise nothing | +| `Directory.Build.props` | edit (conditional) | `SER011` in `` only if #3261 has not landed | +| `docs/exp/SER011.md` | edit (conditional) | add `StringBitPositions` to the list of gated members if the page exists (it is added by #3261); otherwise create it as #3261 does | +| `src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt` | edit | the two interface members and the `RedisFeatures` property; the build names the exact lines | +| `docs/CommandCoverage.md` | edit | delete the `BITRANGEINTARRAY` row from the todo table (the audit script reports it RESOLVED otherwise) | +| `tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionsRoundTrip.cs` | add | exact-bytes tests via `TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, RequestResp, ResponseResp, log: log)` | +| `tests/StackExchange.Redis.Tests/BitTests.cs` | edit | live tests gated `Create(require: RedisFeatures.v8_12_0)` | + +Nothing outside this table is touched. In particular no `Message` subclass, no `ResultProcessor` +change, no `APITypes/`, no analyzer, no `docs/index.md` (no new docs page; see section 7). + +## 5. Ordered implementation steps + +1. **Enum, routing, literal.** `RedisCommand.BITRANGEINTARRAY`; its `IsPrimaryOnly` case in the + replica-eligible list (the spec flag is `READONLY`); `RedisLiterals.ZEROS`. + Check: `dotnet build src/StackExchange.Redis/StackExchange.Redis.csproj -c Release -f net10.0`. + Done when it builds (the exhaustive switch would otherwise throw at runtime, not compile time, + so re-read the case list). +2. **Version gate and experiment.** `RedisFeatures.v8_12_0` and `RedisFeatures.StringBitPositions`. + If #3261 is merged, reuse `Experiments.Server_8_12`, the `NoWarn` entry and `docs/exp/SER011.md` + (add the new members to that page); if not, add them exactly as #3261 does so the later merge + is textual. Check: build. Done when `Experiments.Server_8_12` resolves. +3. **Message builder.** `RedisDatabase.GetStringBitPositionsMessage`: validate `limit` (`< 0` -> + `ArgumentOutOfRangeException(nameof(limit))`); then select the `Message.Create(Database, flags, + RedisCommand.BITRANGEINTARRAY, key, ...)` overload by the number of trailing values: + `start, end` always; `indexType.ToLiteral()` only when `indexType == StringIndexType.Bit` + (mirrors `GetStringBitPositionMessage`, which omits the default `BYTE`); `RedisLiterals.LIMIT, + limit.Value` when `limit` has a value; `RedisLiterals.ZEROS` when `bit` is `false`. Worst case + is key plus six values, which `Message.Create` supports (`CommandKeyValueValueValueValueValueValueMessage`). + Check: build. Done when all eight combinations compile without a `RedisValue[]` allocation. +4. **Interfaces and implementations.** `IDatabase.StringBitPositions`, + `IDatabaseAsync.StringBitPositionsAsync`; `RedisDatabase` pair calling the builder and + `ExecuteSync(msg, ResultProcessor.Int64Array)` / `ExecuteAsync(...)`; `KeyPrefixedDatabase` and + `KeyPrefixed` forwards with `ToInner(key)`. Then the `PublicAPI.Unshipped.txt` lines the build + demands (`[SER011]` prefix). Check: `dotnet build Build.csproj -c Release /p:CI=true`. Done when + RS0016 is silent and every implementor compiles. +5. **Transaction analyzer (Step 9 of the convention skill): no.** `BITRANGEINTARRAY` is a + read-only enumeration; it replaces loops of `GETBIT`/`BITPOS`, not a `MULTI`/`WATCH` shape, so + no `TransactionAnalyzer` row. Nothing to do. +6. **Round-trip tests.** `RoundTripUnitTests/BitPositionsRoundTrip.cs` (section 6). Check: + `dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*BitPositionsRoundTrip*"`. + Done when every byte string matches and the negative-limit test throws. +7. **Live tests, written not run.** Add to `BitTests.cs` under `Create(require: RedisFeatures.v8_12_0)` + (section 6). Check: they compile and skip against the docker topology (`Skip` with the version + message). Done when the class builds and the skip reason names the version. +8. **Ledger.** Delete the `BITRANGEINTARRAY` row from `docs/CommandCoverage.md`. Check: the + `audit-command-coverage` script exits 0 against a fresh upstream checkout. Done when it does. + +## 6. Test plan + +### Result processor (`ResultProcessorUnitTests`) + +`ResultProcessor.Int64Array` is reused and already covered by `BasicAggregates.Int64Array`: +RESP2 `*3\r\n:1\r\n:2\r\n:3\r\n`, streaming `*?...\r\n.\r\n`, RESP3 doubles, empty `*0\r\n`, +null `*-1\r\n` and `_\r\n`, and an attribute-prefixed array. No new processor tests. If a +reviewer wants the command named in a test, add one `InlineData` row to that theory rather than a +new class. + +### Exact bytes (`RoundTripUnitTests/BitPositionsRoundTrip.cs`) + +Each test builds the message via `db.GetStringBitPositionsMessage(...)` on +`new RedisDatabase(null!, 0, null)` and asserts the outbound RESP, as `BitPositionRoundTrip` does. +`$16\r\nBITRANGEINTARRAY` is the command bulk. + +| Test | Call | Expected request | +|---|---|---| +| `Defaults_SendStartAndEnd` | `("k", true, 0, -1, Byte, null)` | `*4\r\n$16\r\nBITRANGEINTARRAY\r\n$1\r\nk\r\n$1\r\n0\r\n$2\r\n-1\r\n` | +| `BitIndexType_SendsToken` | `("k", true, 5, 10, Bit, null)` | `*5\r\n...$1\r\nk\r\n$1\r\n5\r\n$2\r\n10\r\n$3\r\nBIT\r\n` | +| `Limit_SendsToken` | `("k", true, 0, -1, Byte, 1)` | `*6\r\n...$1\r\n0\r\n$2\r\n-1\r\n$5\r\nLIMIT\r\n$1\r\n1\r\n` | +| `ClearBits_SendsZeros` | `("k", false, 24, 31, Bit, null)` | `*6\r\n...$2\r\n24\r\n$2\r\n31\r\n$3\r\nBIT\r\n$5\r\nZEROS\r\n` | +| `AllOptions_CanonicalOrder` | `("k", false, 2, -1, Bit, 2)` | `*8\r\n...$1\r\n2\r\n$2\r\n-1\r\n$3\r\nBIT\r\n$5\r\nLIMIT\r\n$1\r\n2\r\n$5\r\nZEROS\r\n` | +| `Limit_Zero_IsSent` | `("k", true, 0, -1, Byte, 0)` | `*6\r\n...$5\r\nLIMIT\r\n$1\r\n0\r\n` with reply `*0\r\n` -> empty array | +| `Limit_Negative_Throws` | `("k", true, 0, -1, Byte, -1)` | `Assert.Throws`, `ParamName == "limit"` | + +Replies for the positive cases: `*4\r\n:1\r\n:7\r\n:9\r\n:30\r\n` -> `[1, 7, 9, 30]`, and `*0\r\n` +-> empty. + +### Live tests (`BitTests.cs`, written not run, `Create(require: RedisFeatures.v8_12_0)`) + +Scenarios from the PR description, **observed** on 2026-10-30 against a local unstable build +(`redis_git_sha1:6a5fee67`, `redis_version:255.255.255`), for both a string bitmap (`TYPE` -> +`string`, encoding `raw`) and a native one (`bitmap-default-roaring yes`; `TYPE` -> `bitmap`, +encoding `bitmap-roaring`, `GET` -> `WRONGTYPE`). Setup for each: `SETBIT k 1 1`, `SETBIT k 7 1`, +`SETBIT k 9 1`, `SETBIT k 30 1`. + +| Test | Call | observed | +|---|---|---| +| `StringBitPositions_Ranges` | `(k, true)` / `(k, true, 1, -1)` / `(k, true, 5, 10, Bit)` | `1 7 9 30` / `9 30` / `7 9` | +| `StringBitPositions_ClearBits` | `(k, false, 24, 31, Bit)` / `(k, false, 30, 100, Bit)` | `24 25 26 27 28 29 31` / `31` (clamped) | +| `StringBitPositions_Limit` | `(k, true, 0, -1, Bit, 1)` / `(k, true, 2, -1, Bit, 2)` / `(k, true, 0, -1, Byte, 1)` | `1` / `7 9` / `1 7` (BYTE completes byte 0) | +| `StringBitPositions_PagesWithoutGaps` | loop with `limit` in both units applying the R.6 rule over a 10,000-bit random bitmap | union of pages equals the unpaged result, no repeats. The BYTE rule observed on the sample: `LIMIT 1` from byte 0 -> `1 7`; next start `7/8+1 = 1` -> `9`; next `9/8+1 = 2` -> `30`; next `30/8+1 = 4` -> empty | +| `StringBitPositions_MissingKey` | `(missing, true)` | empty | +| `StringBitPositions_ReversedRange` | `(k, true, 3, 1)` | empty | +| `StringBitPositions_LimitZero` | `(k, true, 0, -1, Byte, 0)` | empty | +| `StringBitPositions_WrongType` | on a list key | `RedisServerException` containing `WRONGTYPE` (observed: `WRONGTYPE Operation against a key holding the wrong kind of value`) | +| `StringBitPositions_KeyPrefix` | via `db.WithKeyPrefix("p:")` | request shows `p:k` (unit-level: `KeyPrefixedTests` style) | + +Commands: + +``` +dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*BitPositionsRoundTrip*" +dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "StackExchange.Redis.Tests.BitTests" +``` + +No analyzer change, so `StackExchange.Redis.Build.Tests` is not run. + +## 7. Docs, changelog, public-API files + +- `docs/.md`: **no**. There is no bitmap page today (`StringBitCount`/`BITCOUNT` appear + only in `docs/ReleaseNotes.md`), and one method does not justify starting one; the XML remarks + carry the paging rule. If a bitmap page is written later (native bitmaps, `RedisType.Bitmap`, + this command), it goes in `docs/` with an `index.md` entry. +- `docs/exp/SER011.md`: **yes**, add the two members to the gated list (page created by #3261). +- `PublicAPI.Unshipped.txt`: the two interface members and the `RedisFeatures` property, `[SER011]` + prefixed. `PublicAPI.Shipped.txt` untouched. +- `docs/CommandCoverage.md`: delete the row. +- `docs/ReleaseNotes.md` is frozen at 3.0; release notes live in GitHub Releases. Do not edit. + +## 8. Behaviour against older servers + +No client-side version check on the call path, matching every sibling. Below 8.12 the server +replies `ERR unknown command 'BITRANGEINTARRAY'`, which surfaces as `RedisServerException`. +`RedisFeatures.StringBitPositions` lets callers branch beforehand. Live tests skip through +`Create(require: RedisFeatures.v8_12_0)` with the standard version message. + +## 9. Risks and open questions + +1. **Ordering with #3261 (BLESS).** Both features are 8.12 and both want `SER011` / + `Experiments.Server_8_12`, `RedisFeatures.v8_12_0`, the `NoWarn` entry and `docs/exp/SER011.md`. + Default: land #3261 first and reuse; if this lands first, add them with identical text so the + merge is clean. Never create a second id for the same server version. +2. **`v8_12_0` value.** #3261 uses a placeholder `new Version(8, 12, 0)` and notes the 8.12 + integration image reports `255.255.255`. Observed: a local build of unstable at 6a5fee67 also + reports `redis_version:255.255.255`, so `IsAtLeast(8.12.0)` passes there and the live tests + run. The remaining risk is RC builds reporting `8.11.x`, on which they would skip. Default: keep + the placeholder, same as #3261, and correct both when the RC numbering is known. +3. **Large replies.** `long[]` is simple and matches `Int64Array` consumers, but a dense multi-MB + bitmap enumerated without `limit` allocates one `long` per set bit. A `Lease` variant + (the pattern `StringBitField(RedisKey, ReadOnlyMemory)` uses for + `Lease`) would avoid the array but needs a new processor. Default: `long[]` only, + remarks recommend `limit`; a lease overload is additive later. +4. **Paging helper.** An `IAsyncEnumerable` that applies the R.6 rule would remove the + byte-versus-bit arithmetic from callers, and the BYTE rule (completing the byte, next start is + `last / 8 + 1`) is easy to get wrong. Default: not in this plan; document the rule in the remarks + and add a `StringBitPositionsAsync` enumerable overload later if asked. +5. **Parameter shape.** `bool bit` plus `long? limit` mirrors `StringBitPosition` and keeps one + overload. Alternative considered: a `[Flags]`/options type. Default: keep the sibling shape; a + single boolean and one nullable integer do not justify a type. +6. **Replica routing.** Classified replica-eligible from the `READONLY` flag. A native bitmap + written on the primary is replicated through `BITCONVERT`/`BITROAROP`, so a replica should hold + the same representation; if an 8.12 replica ever lagged in representation the offsets would + still be identical (the command is representation-agnostic). Default: replica-eligible. + +## 10. Out of scope + +- Any other client's API shape; the HLD for this command does not exist yet, and when it does the + client-neutral proposal should be compared against section 3 here. +- `BITCONVERT`, `BITROAROP` (server-internal), `bitmap-default-roaring` configuration, and any + typed exposure of `OBJECT ENCODING bitmap-roaring`. +- A `Lease` or `IAsyncEnumerable` overload (open questions 3 and 4). +- `PublicAPI.Shipped.txt` promotion, release files, `.github/`. +- Changes to `StringBitPosition`, `StringBitCount` or `StringIndexType`. diff --git a/notes/set-member-expiry/plan.md b/notes/set-member-expiry/plan.md new file mode 100644 index 0000000000..57dd4887e8 --- /dev/null +++ b/notes/set-member-expiry/plan.md @@ -0,0 +1,301 @@ +--- +feature: set-member-expiry +client: se-redis +hld: {path: none, sha: none} +tracks: [redis/redis#15938] +target_version: "8.12" +decision_class: C +conventions: + - .claude/skills/implement-resp-command/SKILL.md#Steps + - .claude/skills/implement-resp-command/SKILL.md#Tests - the two layers that matter + - AGENTS.md#Public API tracking (important — easy to trip over) +estimated_size: medium +integration_targets: [] +unit_targets: [RoundTripUnitTests.SetMemberExpiryRoundTrip, ResultProcessorUnitTests.BasicAggregates, KeyNotificationUnitTests] +open_questions: 6 +--- + +# Set-member expiration: StackExchange.Redis implementation plan + +> **Revised in review (PR #3340):** the add-with-expiry surface shipped as **`SetAdd` overloads taking +> `Expiration`** — `SetAdd(key, value, Expiration, When, flags)` and `SetAdd(key, values, Expiration, When, flags)`, +> both returning `bool` (+ async) — not the four-per-arity `SetAddAndSetExpiry` shape planned below. +> `Expiration` already encodes `EX`/`PX`/`EXAT`/`PXAT`/`KEEPTTL`, which is exactly `SADDEX`'s expiration block, +> so the `TimeSpan?`/`DateTime`/`keepTtl` splits collapse; `Expiration.Persist` is rejected +> (`SADDEX` has no `PERSIST`; that is `SetMemberPersist`'s job). The overloads always send `SADDEX` - +> plain `SADD` is not an equivalent fallback, because the bare `SADDEX` form discards an existing member's +> TTL where `SADD` leaves existing members untouched (R.2). Everything below the surface - the message +> shapes, requirement ids, tests and open questions - is unchanged; read `SetAddAndSetExpiry` as +> `SetAdd(..., Expiration, ...)` (internally `SetAddWithExpiryMessage`). + +No shared client HLD exists for this feature. The server facts below come from redis/redis#15938 +("Set member expiration", merged to `unstable` as `a76a3689a`, shipped in 8.12-rc2, which reports +`redis_version:8.11.241`), from the command JSONs at that commit +(`src/commands/saddex.json`, `sexpire.json` and family), and from direct observation against an +8.12-rc2 build (the `setexpiry` demo in the demos repo). Requirement ids `R.x` are assigned here. + +## 1. Summary + +Callers get per-member expiration on sets — the hash-field-TTL idea (7.4's `HEXPIRE`) brought to +sets — as a mirror of the existing `HashField*` family: + +- `SetAddAndSetExpiry` (`SADDEX`): add members and/or (re)set their expiration in one atomic call, + with `MNX`/`MXX` batch conditions and `KEEPTTL`; +- `SetMemberExpire` (`SEXPIRE`/`SPEXPIRE`/`SEXPIREAT`/`SPEXPIREAT`, picked by precision) returning + `ExpireResult[]`; +- `SetMemberGetTimeToLive` (`SPTTL`) and `SetMemberGetExpireDateTime` (`SPEXPIRETIME`) returning + `long[]`; +- `SetMemberPersist` (`SPERSIST`) returning `PersistResult[]`; +- `KeyNotificationType.SExpired` so the 8.12 `sexpired` notification (including on the new subkey + channels, where the payload names the lapsed member) parses as a known type rather than + `Unknown`. **No new payload parsing is needed**: `KeyNotification.GetSubKeys()` on `main` + already parses every subkey channel kind, including the length-prefixed member list. + +Decision class **C** (new core commands). Analogue traced end to end: the hash-field expiry +family — `IDatabase.HashFieldExpire` (both overloads), `HashFieldGetTimeToLive`, +`HashFieldGetExpireDateTime`, `HashFieldPersist` and their message plumbing +(`RedisDatabase.HashFieldExpireExecute`, `GetHashFieldExpireMessage`, +`PickExpireCommandByPrecision`/`PickExpireAtCommandByPrecision`, `HashFieldExecute`), the +processors (`ResultProcessor.ExpireResultArray`, `PersistResultArray`, `Int64Array`), the +isolation forwards in `KeyPrefixedDatabase`/`KeyPrefixed`, and for `SADDEX` the `HSETEX` binding +(`HashFieldSetAndSetExpiry` + `HashFieldSetAndSetExpiryMessage`, whose `FNX`/`FXX` have exactly +the same all-or-nothing batch semantics as `MNX`/`MXX`). Version gating follows +`RedisFeatures.HashImport`; experimental gating follows the `SER011`/`Experiments.Server_8_12` +gate that #3261 (BLESS, also 8.12) introduces. + +`estimated_size: medium`: eighteen interface members (nine sync + nine async), one shared +execute/message-builder group, eighteen isolation forwards, eight enum members plus their +`IsPrimaryOnly` cases, three literals, a version constant and property, the experimental gate, +public-API lines, one notification enum member, one round-trip test class, one live test class, +notification test rows, and coverage-ledger edits. No new result processors, no new `APITypes/`. + +## 2. Requirement coverage + +| Id | Server fact | Where (file, symbol) | Proving test | Note | +|---|---|---|---|---| +| R.1 | `SADDEX key [MNX\|MXX] [EX s\|PX ms\|EXAT ts\|PXAT ts\|KEEPTTL] MEMBERS n member...` (arity -5, `WRITE DENYOOM FAST`); reply 0 = nothing changed / condition not met, 1 = at least one member added, updated or deleted | `IDatabase.SetAddAndSetExpiry` (4 overloads), `RedisDatabase.SetAddAndSetExpiryMessage` | `SetMemberExpiryRoundTrip.SetAddAndSetExpiry_*`; live `SetMemberExpiryTests.SetAddAndSetExpiry*` | Returns `bool` (see open question 3) | +| R.2 | `SADDEX` without an expiry token **discards** an existing member's TTL; `KEEPTTL` keeps it (t_set.c: "without KEEPTTL, its expiration is discarded") | `Expiration.CreateOrKeepTtl(expiry, keepTtl)`, as `HashFieldSetAndSetExpiry` | `SetAddAndSetExpiry_NoExpiry_SendsBareForm`, `KeepTtl_SendsToken`; live `SetAddAndSetExpiryKeepTtl` | XML docs call the discard out | +| R.3 | `MNX` = only if **none** of the members exist; `MXX` = only if **all** exist; all-or-nothing across the batch (one offender vetoes the whole call, reply 0) | `When.NotExists` → `MNX`, `When.Exists` → `MXX` in `SetAddAndSetExpiryMessage` | `SetAddAndSetExpiry_Mnx_SendsToken` / `_Mxx_SendsToken`; live `SetAddAndSetExpiryWhen` | Mirrors `HSETEX` `FNX`/`FXX` exactly (see open question 2) | +| R.4 | `SEXPIRE`/`SPEXPIRE`/`SEXPIREAT`/`SPEXPIREAT key t [NX\|XX\|GT\|LT] MEMBERS n member...` (arity -6, `WRITE FAST`); per-member reply codes, in order: -2 no such member, 0 condition not met, 1 set, 2 deleted (expiry already past) | `IDatabase.SetMemberExpire` (TimeSpan + DateTime), `GetSetMemberExpireMessage`, `PickSetMemberExpireCommandByPrecision`/`PickSetMemberExpireAtCommandByPrecision` | `SetMemberExpire_*` round-trips; live `SetMemberExpire`, `SetMemberExpireConditions`, `SetMemberExpireNoKey` | Reply codes match `ExpireResult` (2/1/0/-2) member for member | +| R.5 | Precision: seconds-granular input uses `SEXPIRE`/`SEXPIREAT`, else `SPEXPIRE`/`SPEXPIREAT` | the pick functions, as `PickExpireCommandByPrecision` | `SetMemberExpire_SecondsPrecision` vs `_MillisecondsPrecision` round-trips | Same `milliseconds % 1000` rule as hash | +| R.6 | `GT` loses to a member with **no** expiry (no expiry counts as infinite → reply 0); `NX`/`XX`/`GT`/`LT` conditions are per member | `ExpireWhen` passed through, `when.ToLiteral()` | live `SetMemberExpireConditions` (`GT` vs persisted member, observed `[0]`) | Same semantics as key-level `EXPIRE` and `HEXPIRE` | +| R.7 | `SPTTL key MEMBERS n member...` (`READONLY FAST`): per-member TTL ms; -1 no expiry, -2 no such member | `IDatabase.SetMemberGetTimeToLive` → `SetMemberExecute(RedisCommand.SPTTL, ...)`, `ResultProcessor.Int64Array` | `SetMemberGetTimeToLive_SendsMembersBlock`; live `SetMemberGetTimeToLive` | Replica-eligible in `IsPrimaryOnly` | +| R.8 | `SPEXPIRETIME key MEMBERS n member...` (`READONLY FAST`): per-member absolute expiry, Unix ms; -1/-2 as above | `IDatabase.SetMemberGetExpireDateTime` → `SetMemberExecute(RedisCommand.SPEXPIRETIME, ...)` | round-trip + live `SetMemberGetExpireDateTime` | Replica-eligible | +| R.9 | `SPERSIST key MEMBERS n member...` (`WRITE FAST`): per member 1 cleared, -1 had none, -2 no such member | `IDatabase.SetMemberPersist` → `SetMemberExecute(RedisCommand.SPERSIST, ...)`, `ResultProcessor.PersistResultArray` | round-trip + live `SetMemberPersist` | Codes match `PersistResult` (1/-1/-2) | +| R.10 | Seconds-precision read twins `STTL`/`SEXPIRETIME` exist server-side | n/a | n/a | Not exposed, mirroring hash (no `HTTL` in `RedisCommand`; the client reads ms and converts). Coverage ledger rows become `not-applicable` | +| R.11 | New notification event `sexpired` fires (classic and subkey channels) when a member lapses; subkey payload names key + members | `KeyNotificationType.SExpired` (`[AsciiHash("sexpired")]`) | `KeyNotificationUnitTests` rows (parse + `GetSubKeys`) | Payload parsing already complete on `main` (`KeyNotification.SubKeys.cs`); only the enum entry is missing | +| R.12 | Expiring members replicate as explicit deletes; commands behave as writes for routing | `IsPrimaryOnly`: `SADDEX`, `SEXPIRE`, `SEXPIREAT`, `SPEXPIRE`, `SPEXPIREAT`, `SPERSIST` primary-only; `SPTTL`, `SPEXPIRETIME` replica-eligible | n/a (routing switch is exhaustive; build proves it) | From the `WRITE`/`READONLY` flags | +| NF.1 | 8.12 is a release candidate; the surface may change | `[Experimental(Experiments.Server_8_12)]` on every new public member | build (`SER011` NoWarn in root `Directory.Build.props`) | Same gate as #3261 (open question 1) | + +## 3. Public API to add + +Stable or experimental: **experimental**, `[Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]` — +8.12 is unreleased and #3261 establishes `SER011` as the 8.12 gate. + +### `IDatabase` (sync; async twins on `IDatabaseAsync` with ``, returning `Task`) + +```csharp +// SADDEX — grounded in HashFieldSetAndSetExpiry (HSETEX), the same four-overload shape +bool SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); +bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); +bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); +bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + +// SEXPIRE family — grounded in HashFieldExpire +ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); +ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); + +// SPEXPIRETIME / SPTTL / SPERSIST — grounded in HashFieldGetExpireDateTime / HashFieldGetTimeToLive / HashFieldPersist +long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); +long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); +PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); +``` + +The full XML docs mirror the hash siblings' (`` of reply codes), with the set +specifics added: the `SADDEX` docs state the bare-form TTL discard (R.2) and that `MNX`/`MXX` are +all-or-nothing across the batch (R.3); `SetMemberExpire` documents that a member with no expiry +counts as infinite for `GT` (R.6). + +### `KeyNotificationType` + +```csharp +[AsciiHash("sexpired")] +SExpired = 1006, // side-effect block, after KeyMiss = 1005 +``` + +### `RedisFeatures` + +```csharp +v8_12_0 = new Version(8, 11, 241); // 8.12 RC2 is version 8.11.241 + +/// Is set-member expiration (SADDEX, SEXPIRE, ...) available? +public bool SetMemberExpiry => Version.IsAtLeast(v8_12_0); +``` + +### Back-compat + +Purely additive: new interface members (so every implementor gains them: `RedisDatabase`, +`KeyPrefixedDatabase`, `KeyPrefixed`), one `RedisFeatures` property, eight internal `RedisCommand` +members, one `KeyNotificationType` member (append-only enum; see open question 6 for the value). +Nothing shipped changes; no overload collides with the existing `SetAdd` pair (different method +name, so no RS0026 concern). All public-API lines land in `PublicAPI.Unshipped.txt`, the +experimental ones prefixed `[SER011]` as #3261's are; the `KeyNotificationType.SExpired` and +`RedisFeatures` lines are bare, matching #3261's treatment of its notification values. + +## 4. Files to change + +| Path | add/edit | What | +|---|---|---| +| `src/StackExchange.Redis/Enums/RedisCommand.cs` | edit | `SADDEX` (after `SADD`), `SEXPIRE`, `SEXPIREAT` (after `SETRANGE`), `SPERSIST`, `SPEXPIRE`, `SPEXPIREAT`, `SPEXPIRETIME` (before `SPOP`), `SPTTL` (after `SPOP`); `IsPrimaryOnly` cases per R.12 | +| `src/StackExchange.Redis/RedisLiterals.cs` | edit | `MEMBERS`, `MNX`, `MXX` (`NX`/`XX`/`GT`/`LT`/`KEEPTTL` tokens come via `ExpireWhen.ToLiteral()` and `Expiration`) | +| `src/StackExchange.Redis/Interfaces/IDatabase.cs` | edit | the nine sync members, `SetAddAndSetExpiry` after `SetAdd`, the `SetMember*` block before `SetMembers` | +| `src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs` | edit | the nine async twins with `` | +| `src/StackExchange.Redis/RedisDatabase.cs` | edit | implementations next to `SetAdd`; `SetAddAndSetExpiryMessage` (single + array, mirroring `HashFieldSetAndSetExpiryMessage`), `SetMemberExpireExecute` + `GetSetMemberExpireMessage` + the two pick functions (mirroring the hash versions), `SetMemberExecute` (mirroring `HashFieldExecute`, `MEMBERS` token) | +| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs` | edit | nine sync forwards with `ToInner(key)` | +| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs` | edit | nine async forwards with `ToInner(key)` | +| `src/StackExchange.Redis/KeyNotificationType.cs` | edit | `SExpired = 1006` with `[AsciiHash("sexpired")]` (metadata `TryParse`/`TryFormat` are source-generated from the attribute) | +| `src/StackExchange.Redis/RedisFeatures.cs` | edit | `v8_12_0` constant and `SetMemberExpiry` property | +| `src/RESPite/Shared/Experiments.cs` | edit | `Server_8_12 = "SER011"`, identical text to #3261 so the merge is clean | +| `Directory.Build.props` | edit | `SER011` in ``, identical to #3261 | +| `docs/exp/SER011.md` | add | same page #3261 adds, with the set-member-expiry bullet; on merge the page lists both features | +| `src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt` | edit | the eighteen interface members (`[SER011]`-prefixed), `RedisFeatures.SetMemberExpiry.get`, `KeyNotificationType.SExpired` | +| `docs/CommandCoverage.md` | edit | delete the `SADDEX`/`SEXPIRE`/`SEXPIREAT`/`SPERSIST`/`SPEXPIRE`/`SPEXPIREAT`/`SPEXPIRETIME`/`SPTTL` rows; `SEXPIRETIME` → `not-applicable` (R.10) | +| `tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs` | add | exact-bytes tests via the internal message builders (section 6) | +| `tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs` | add | live tests gated `Create(require: RedisFeatures.v8_12_0)`, mirroring `HashFieldTests.cs` | +| `tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs` | edit | `sexpired` parse row next to the `hexpired` one; a `__subkeyevent@*__:sexpired` `GetSubKeys` case | +| `tests/RedisConfigs/.docker/Redis/Dockerfile` | edit | `CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.12-rc2`, identical to #3261/#3315 | +| `tests/RedisConfigs/docker-compose.yml` | edit | the same image as the `CLIENT_LIBS_TEST_IMAGE` fallback, identical to #3261/#3315 | + +Nothing outside this table is touched. No new `ResultProcessor`, no `APITypes/`, no analyzer row +(step 5), no `docs/index.md` page (section 7). + +## 5. Ordered implementation steps + +1. **Enum, routing, literals.** The eight `RedisCommand` members and their `IsPrimaryOnly` cases; + `RedisLiterals.MEMBERS`/`MNX`/`MXX`. Check: build `src/StackExchange.Redis`. Done when it + builds and both `IsPrimaryOnly` branches list the right commands. +2. **Version gate and experiment.** `RedisFeatures.v8_12_0` + `SetMemberExpiry`; + `Experiments.Server_8_12`; `NoWarn`; `docs/exp/SER011.md` — all with #3261's exact text where + shared. Done when `Experiments.Server_8_12` resolves. +3. **Message builders.** `SetAddAndSetExpiryMessage` (single-member fast path via `Message.Create` + with up to six trailing values; array path building one `RedisValue[]`, token order + `[MNX|MXX] {expiry} MEMBERS n member...`), `GetSetMemberExpireMessage` (token order + `{expiry} [NX|XX|GT|LT] MEMBERS n member...`, retry category via + `flags.WithCategory(when.AsRetryCategory())` as the hash version), `SetMemberExecute`. + Done when all builders compile; they are `internal` for the round-trip tests. +4. **Interfaces, implementations, isolation, public API.** The eighteen members everywhere; + `PublicAPI.Unshipped.txt` lines. Check: `dotnet build Build.csproj -c Release /p:CI=true`. + Done when RS0016 is silent. +5. **Transaction analyzer (Step 9): no.** Per-member TTL on a set did not exist before 8.12, so no + `MULTI`/`WATCH` shape could have composed it — `SADD` + `KeyExpire` expires the *key*, which + `SADDEX` does not replace. No `TransactionAnalyzer` row; this reasoning recorded here. +6. **Notification type.** `KeyNotificationType.SExpired` + the `KeyNotificationUnitTests` rows. + Done when the parse row and the subkey-payload case pass. +7. **Round-trip tests** (section 6). Check: the filtered `dotnet test`. Done when every byte + string matches. +8. **Live tests, written not run** (section 6) + the Dockerfile bump so they can run against the + 8.12-rc2 topology. Done when the class builds and skips with the version message on older + servers. +9. **Ledger.** The `docs/CommandCoverage.md` edits. + +## 6. Test plan + +### Result processors (`ResultProcessorUnitTests`) + +All three processors are reused and already covered: `ResultProcessor.ExpireResultArray` and +`PersistResultArray` in `BasicAggregates`, `Int64Array` likewise (RESP2 `*`, streaming, RESP3, +empty, null). No new processor, no new processor tests. + +### Exact bytes (`RoundTripUnitTests/SetMemberExpiryRoundTrip.cs`) + +Via `new RedisDatabase(null!, 0, null)` + the internal builders, as `BitPositionRoundTrip` does. + +| Test | Call | Expected request | +|---|---|---| +| `SetAddAndSetExpiry_Ex` | `(k, "m", 15s)` | `*7 SADDEX k EX 15 MEMBERS 1 m` | +| `SetAddAndSetExpiry_Px` | `(k, "m", 1500ms)` | `*7 SADDEX k PX 1500 MEMBERS 1 m` | +| `SetAddAndSetExpiry_KeepTtl` | `(k, "m", null, keepTtl: true)` | `*6 SADDEX k KEEPTTL MEMBERS 1 m` | +| `SetAddAndSetExpiry_Bare` | `(k, "m", null)` | `*5 SADDEX k MEMBERS 1 m` | +| `SetAddAndSetExpiry_Mnx` | `(k, values, 15s, when: NotExists)` | `*9 SADDEX k MNX EX 15 MEMBERS 2 a b` | +| `SetAddAndSetExpiry_Mxx` | `(k, values, 15s, when: Exists)` | `... MXX ...` | +| `SetAddAndSetExpiry_ExAt` | `(k, "m", DateTime utc, whole seconds)` | `*7 SADDEX k EXAT MEMBERS 1 m` | +| `SetMemberExpire_Seconds` | `(k, [a,b], 90s)` | `*8 SEXPIRE k 90 MEMBERS 2 a b` | +| `SetMemberExpire_Millis` | `(k, [a], 1500ms)` | `*7 SPEXPIRE k 1500 MEMBERS 1 a` | +| `SetMemberExpire_Gt` | `(k, [a], 90s, GreaterThanCurrentExpiry)` | `*8 SEXPIRE k 90 GT MEMBERS 1 a` | +| `SetMemberExpire_At` | `(k, [a], DateTime, whole s / fractional)` | `SEXPIREAT` / `SPEXPIREAT` | +| `SetMemberGetTimeToLive` | `(k, [a,b])` | `*6 SPTTL k MEMBERS 2 a b`, reply `*2 :9500 :-1` → `[9500, -1]` | +| `SetMemberGetExpireDateTime` | `(k, [a])` | `*5 SPEXPIRETIME k MEMBERS 1 a` | +| `SetMemberPersist` | `(k, [a,b])` | `*6 SPERSIST k MEMBERS 2 a b`, reply `*2 :1 :-2` → `[Success, NoSuchField]` | + +(Abbreviated here; the test file asserts full RESP with length prefixes.) + +### Live tests (`SetMemberExpiryTests.cs`, `Create(require: RedisFeatures.v8_12_0)`) + +Mirroring `HashFieldTests.cs` scenario for scenario; **run and passing** (34 = 17 × RESP2/RESP3) +against the rebuilt test topology (`redislabs/client-libs-test:8.12-rc2`, reporting +`redis_version:8.11.241`); they skip with the standard version message on older servers. Covered: +add + TTL read-back (relative and absolute, with the `HashFieldTests` clock-skew +tolerance for the `DateTime` overloads); `NoSuchField` on a missing key; conditions including +`GT` losing to a persisted member (`[ExpireResult.ConditionNotMet]`); expiry-in-the-past → +`ExpireResult.Due` and the member gone; `SADDEX` bare-form TTL discard vs `KEEPTTL`; `MNX` veto +(one existing member → `false`, nothing added); `SPERSIST` → `[PersistResult.Success]` then +`[PersistResult.NoExpiryToRemove]`; `SetAddAndSetExpiry` + `SetMemberGetTimeToLive` through +`WithKeyPrefix` (isolation). + +Commands: + +``` +dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*SetMemberExpiry*" +dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*KeyNotificationUnitTests*" +``` + +No analyzer change, so `StackExchange.Redis.Build.Tests` is not run. + +## 7. Docs, changelog, public-API files + +- `docs/.md`: **no** — the hash-field family has no page either; the XML docs carry the + reply-code tables. A combined "member/field expiration" page could come later. +- `docs/exp/SER011.md`: **yes** (add; merges with #3261's copy). +- `PublicAPI.Unshipped.txt`: as section 3. `PublicAPI.Shipped.txt` untouched. +- `docs/CommandCoverage.md`: rows deleted / `SEXPIRETIME` re-marked. +- `docs/ReleaseNotes.md` is frozen at 3.0; not edited. + +## 8. Behaviour against older servers + +No client-side version check on the call path, matching every sibling. Below 8.12 the server +replies `ERR unknown command 'SADDEX'` (etc.), surfacing as `RedisServerException`. +`RedisFeatures.SetMemberExpiry` lets callers branch beforehand. Live tests skip through +`Create(require: RedisFeatures.v8_12_0)`. + +## 9. Risks and open questions + +1. **Ordering with #3261/#3315.** All three 8.12 branches add `Experiments.Server_8_12`, + `RedisFeatures.v8_12_0`, the `NoWarn` entry, `docs/exp/SER011.md` and the Dockerfile bump. + Default: identical text everywhere so whichever lands first wins and the rest merge clean + (the SER011.md bullet lists are the only real merge, and it is additive). +2. **`SADDEX` condition enum.** Reuses `When` (`NotExists` → `MNX`, `Exists` → `MXX`), exactly as + `HashFieldSetAndSetExpiry` maps `When` to `FNX`/`FXX` — which have the same all-or-nothing + batch semantics. Alternative considered: a dedicated `SetAddWhen` enum spelling out + "all new"/"all existing". Default: reuse `When` for sibling symmetry; the XML docs carry the + batch semantics. +3. **`SADDEX` return type.** The reply schema is strictly 0/1 ("at least one member was added, + updated or deleted"), so `bool` via `ResultProcessor.Boolean`. The `HSETEX` sibling returns + `RedisValue`, but `SetAdd(key, value)`'s `bool` is the closer precedent for a 0/1 set write. +4. **`ExpireResult`/`PersistResult` naming.** Their `NoSuchField` member (-2) now also means "no + such set member". Default: reuse the enums unchanged — the wire codes match exactly, and a + parallel enum pair would fragment the surface; the XML docs on the set members say "member". +5. **`RedisFeatures.v8_12_0` value.** `new Version(8, 11, 241)` — 8.12-rc2 reports + `redis_version:8.11.241` (observed; and #3261's current revision uses the same value). +6. **`KeyNotificationType.SExpired` value.** `hexpired` sits in the command block (`HExpired = 6`), + but #3261 takes 66/67 (`Bless`/`Unbless`) and `sexpired` is a side-effect event like `expired`, + so default: `SExpired = 1006` in the side-effect block, collision-free whichever PR lands + first. The enum is append-only; the value is forever either way. + +## 10. Out of scope + +- Any v4 `RespContext` surface (the demos repo's `setexpiry` mini-library shows that shape; this + plan is the classic `IDatabase` surface only). +- `STTL`/`SEXPIRETIME` seconds-precision bindings (R.10). +- `BLESS` (#3261) and `BITRANGEINTARRAY` (#3315), beyond sharing the SER011/v8_12_0 plumbing. +- A `TransactionAnalyzer` row (step 5: nothing to map). +- `PublicAPI.Shipped.txt` promotion, release files, `.github/`. diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index c8aa8f2022..7c5652c881 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -23,7 +23,8 @@ internal static class Experiments public const string Respite = "SER004"; public const string UnitTesting = "SER005"; public const string Transport = "SER009"; - public const string ClusterSlotStats = "SER012"; // SER011 is Server_8_12, on the open set-member-expiry PR + public const string Server_8_12 = "SER011"; + public const string ClusterSlotStats = "SER012"; // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/CommandMap.cs b/src/StackExchange.Redis/CommandMap.cs index 4b13864326..90a71e50f1 100644 --- a/src/StackExchange.Redis/CommandMap.cs +++ b/src/StackExchange.Redis/CommandMap.cs @@ -52,6 +52,8 @@ private CommandMap(CommandBytes[] map, byte[] bytes) RedisCommand.BGREWRITEAOF, RedisCommand.BGSAVE, RedisCommand.CLIENT, RedisCommand.CLUSTER, RedisCommand.CONFIG, RedisCommand.DBSIZE, RedisCommand.DEBUG, RedisCommand.FLUSHALL, RedisCommand.FLUSHDB, RedisCommand.INFO, RedisCommand.LASTSAVE, RedisCommand.MONITOR, RedisCommand.REPLICAOF, RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, RedisCommand.HOTKEYS, + + RedisCommand.BLESS, }); /// @@ -82,6 +84,8 @@ private CommandMap(CommandBytes[] map, byte[] bytes) RedisCommand.DEBUG, RedisCommand.FLUSHALL, RedisCommand.FLUSHDB, RedisCommand.INFO, RedisCommand.LASTSAVE, RedisCommand.MONITOR, RedisCommand.REPLICAOF, RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, RedisCommand.HOTKEYS, + RedisCommand.BLESS, + // supported by envoy but not enabled by stack exchange // RedisCommand.BITFIELD, // diff --git a/src/StackExchange.Redis/Enums/BlessFlags.cs b/src/StackExchange.Redis/Enums/BlessFlags.cs new file mode 100644 index 0000000000..ad811076d3 --- /dev/null +++ b/src/StackExchange.Redis/Enums/BlessFlags.cs @@ -0,0 +1,25 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis +{ + /// + /// Per-key protection flags managed by the BLESS command family. + /// + /// + [Flags] + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + public enum BlessFlags + { + /// + /// No flags; this is the state of an unblessed key, and is not valid as a command argument. + /// + None = 0, + + /// + /// The key is never chosen as an eviction victim under any maxmemory-policy (NO-EVICT). + /// + NoEvict = 1 << 0, + } +} diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs index d13417492c..50d6746e64 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs @@ -195,6 +195,7 @@ static CommandFlags DefaultCategory(RedisCommand command) case RedisCommand.DEL: case RedisCommand.UNLINK: case RedisCommand.PERSIST: + case RedisCommand.BLESS: // SET/CLEAR converge on a flag state; GET/SCAN are lowered to reads where we can see the subcommand case RedisCommand.RENAMENX: case RedisCommand.COPY: // default behavior fails if dest exists; REPLACE is raised where we can see the args case RedisCommand.MOVE: diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 9f1eaa1931..d3c79f3a23 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -41,6 +41,8 @@ internal enum RedisCommand BITFIELD_RO, BITOP, BITPOS, + BITRANGEINTARRAY, + BLESS, BLPOP, BRPOP, BRPOPLPUSH, @@ -193,6 +195,7 @@ internal enum RedisCommand RPUSHX, SADD, + SADDEX, SAVE, SCAN, SCARD, @@ -207,6 +210,8 @@ internal enum RedisCommand SETEX, SETNX, SETRANGE, + SEXPIRE, + SEXPIREAT, SHUTDOWN, SINTER, SINTERCARD, @@ -220,7 +225,12 @@ internal enum RedisCommand SORT, [AsciiHash("SORT_RO")] SORT_RO, + SPERSIST, + SPEXPIRE, + SPEXPIREAT, + SPEXPIRETIME, SPOP, + SPTTL, SPUBLISH, SRANDMEMBER, SREM, @@ -416,14 +426,20 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.RPUSH: case RedisCommand.RPUSHX: case RedisCommand.SADD: + case RedisCommand.SADDEX: case RedisCommand.SDIFFSTORE: case RedisCommand.SET: case RedisCommand.SETBIT: case RedisCommand.SETEX: case RedisCommand.SETNX: case RedisCommand.SETRANGE: + case RedisCommand.SEXPIRE: + case RedisCommand.SEXPIREAT: case RedisCommand.SINTERSTORE: case RedisCommand.SMOVE: + case RedisCommand.SPERSIST: + case RedisCommand.SPEXPIRE: + case RedisCommand.SPEXPIREAT: case RedisCommand.SPOP: case RedisCommand.SREM: case RedisCommand.SUNIONSTORE: @@ -469,6 +485,8 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.BITCOUNT: case RedisCommand.BITFIELD_RO: case RedisCommand.BITPOS: + case RedisCommand.BITRANGEINTARRAY: + case RedisCommand.BLESS: // SET/CLEAR are raised to primary-only where we can see the subcommand case RedisCommand.CLIENT: case RedisCommand.CLUSTER: case RedisCommand.COMMAND: @@ -554,6 +572,8 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.SMEMBERS: case RedisCommand.SMISMEMBER: case RedisCommand.SORT_RO: + case RedisCommand.SPEXPIRETIME: + case RedisCommand.SPTTL: case RedisCommand.SPUBLISH: case RedisCommand.SRANDMEMBER: case RedisCommand.SSUBSCRIBE: diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 822ee46764..748930ebf0 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1138,6 +1138,53 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// bool KeyMove(RedisKey key, int database, CommandFlags flags = CommandFlags.None); + /// + /// Turns on the specified protection flags for an existing key; with , the key is never + /// chosen as an eviction victim under any maxmemory-policy. + /// + /// The key to protect; the key must exist. + /// The flags to turn on. + /// The flags to use for this operation. + /// if the key's flags changed; if they were already set. + /// + /// + /// The flags are server metadata rather than part of the value: they survive overwrites of the value, and travel with + /// the key through replication, persistence, COPY, MOVE, RENAME and slot migration, but are + /// not carried by DUMP/RESTORE. A missing key is a server error, not . + /// + /// + /// This operation is rejected with an OOM error when the server is over maxmemory and cannot evict; + /// is always permitted. + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool KeyBless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None); + + /// + /// Turns off the specified protection flags for an existing key. + /// + /// The key to unprotect; the key must exist. + /// The flags to turn off. + /// The flags to use for this operation. + /// if the key's flags changed; if they were already clear. + /// + /// Unlike , this is permitted while the server is over maxmemory. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool KeyUnbless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None); + + /// + /// Gets the protection flags currently set on an existing key. + /// + /// The key to query; the key must exist. + /// The flags to use for this operation. + /// The active flags; for an unblessed key. A missing key is a server error. + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + BlessFlags KeyBlessFlags(RedisKey key, CommandFlags flags = CommandFlags.None); + /// /// Remove the existing timeout on key, turning the key from volatile (a key with an expiry set) to persistent (a key that will never expire as no timeout is associated). /// @@ -1770,6 +1817,48 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None); + /// + /// Adds the specified member to the set stored at key and/or sets its per-member expiration, in one atomic call + /// (SADDEX). If key does not exist, a new set is created first. + /// + /// The key of the set. + /// The value to add to the set and/or set the expiration of. + /// The expiry to set on the member: a or (implicitly converted), + /// or to keep an existing expiration (a new member gets none). The default (none) sends the bare + /// form, which discards any expiration the member already has - this is SADDEX's behaviour, not SADD's, which + /// leaves existing members (and their expirations) untouched. is not supported here: use + /// to remove expirations. + /// Which condition to apply the operation under; see the remarks. + /// The flags to use for this operation. + /// if at least one member was added, updated or deleted, else + /// (nothing changed, or the condition was not met). + /// + /// The condition applies to the member ( sends MNX: only if the member + /// does not exist yet; sends MXX: only if it already exists); with multiple members the condition is + /// all-or-nothing across the batch - one member that breaks it vetoes the whole call, and nothing is changed. + /// An expiration that is already in the past deletes the member instead of adding it. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 // similar overloads + bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + /// Adds the specified members to the set stored at key and/or sets their per-member expiration, in one atomic call + /// (SADDEX). If key does not exist, a new set is created first. + /// + /// The key of the set. + /// The values to add to the set and/or set the expiration of. + /// + /// + /// The flags to use for this operation. + /// + /// Unlike , the server does not report how many members were added. + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + /// /// Returns the members of the set resulting from the specified operation against the given sets. /// @@ -1915,6 +2004,155 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None); + /// + /// Sets a per-member time to live on the given members of the set stored at key. + /// After the timeout has expired, the member is automatically removed from the set. + /// + /// The key of the set. + /// The members to set the expiration of. + /// The timeout to set. + /// Under which condition the expiration will be set, per member, using . + /// A member with no expiration counts as infinitely far away for . + /// The flags to use for this operation. + /// + /// An array with, per member and in order, the result of the operation: + /// + /// + /// Result + /// Description + /// + /// + /// 2 + /// Member deleted because the specified expiration time is due. + /// + /// + /// 1 + /// Expiration time set/updated. + /// + /// + /// 0 + /// Expiration time not set/updated (a specified ExpireWhen condition is not met). + /// + /// + /// -2 + /// No such member exists. + /// + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 // similar overloads + ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); + + /// + /// Sets a per-member expiration time on the given members of the set stored at key. + /// After the timeout has expired, the member is automatically removed from the set. + /// + /// The key of the set. + /// The members to set the expiration of. + /// The exact expiry time to set. + /// + /// The flags to use for this operation. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + + /// + /// For each specified member of the set stored at key, gets the expiration time as a Unix timestamp in milliseconds (milliseconds since the Unix epoch). + /// + /// The key of the set. + /// The members to get the expiration time of. + /// The flags to use for this operation. + /// + /// An array with, per member and in order, the result of the operation: + /// + /// + /// Result + /// Description + /// + /// + /// > 0 + /// Expiration time, as a Unix timestamp in milliseconds. + /// + /// + /// -1 + /// Member has no associated expiration time. + /// + /// + /// -2 + /// No such member exists. + /// + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + + /// + /// For each specified member of the set stored at key, gets the remaining time to live in milliseconds. + /// + /// The key of the set. + /// The members to get the time to live of. + /// The flags to use for this operation. + /// + /// An array with, per member and in order, the result of the operation: + /// + /// + /// Result + /// Description + /// + /// + /// > 0 + /// Time to live, in milliseconds. + /// + /// + /// -1 + /// Member has no associated expiration time. + /// + /// + /// -2 + /// No such member exists. + /// + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + + /// + /// For each specified member of the set stored at key, removes the expiration time. + /// A member without an expiration stays in the set until removed explicitly. + /// + /// The key of the set. + /// The members to remove the expiration of. + /// The flags to use for this operation. + /// + /// An array with, per member and in order, the result of the operation: + /// + /// + /// Result + /// Description + /// + /// + /// 1 + /// Expiration time was removed. + /// + /// + /// -1 + /// Member has no associated expiration time. + /// + /// + /// -2 + /// No such member exists. + /// + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + /// /// Returns all the members of the set value stored at key. /// @@ -3493,6 +3731,36 @@ IEnumerable SortedSetScan( /// long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None); + /// + /// Returns the positions of the bits set to 1 (or, when is false, set to 0) in a range of a string or bitmap, in ascending order. + /// Positions are counted as in : the string is an array of bits from left to right, where the most significant bit of the first byte is position 0. + /// + /// The key of the string or bitmap. + /// True to return the positions of set bits, false to return the positions of clear bits. + /// The start of the range (defaults to 0). + /// The end of the range, inclusive (defaults to -1, the last byte or bit). Negative values index from the end of the value. + /// Whether and are byte or bit offsets (defaults to ). + /// The maximum number of positions to return, lowest first; null returns every match in the range. See the remarks for paging. + /// The flags to use for this operation. + /// The matching bit positions in ascending order; empty when the key does not exist, the range is empty, or nothing matches. + /// + /// + /// + /// Unlike with false, + /// the range never extends past the end of the value, so no clear bits are reported beyond it. + /// + /// + /// To page through a large bitmap, pass and continue from the last position returned: + /// with , the next is that position plus one; + /// with , the server completes the byte containing the last position (so a page can hold up to 7 more than ), + /// and the next is that position divided by 8, plus one. A page shorter than ends the scan. + /// Pages are separate commands and do not share a snapshot. + /// + /// Available from Redis 8.12; older servers reject the command. + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + long[] StringBitPositions(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None); + /// /// Decrements the number stored at key by decrement. /// If the key does not exist, it is set to 0 before performing the operation. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index fe2d820d44..eaa628e696 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -275,6 +275,18 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task KeyMoveAsync(RedisKey key, int database, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task KeyBlessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task KeyUnblessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task KeyBlessFlagsAsync(RedisKey key, CommandFlags flags = CommandFlags.None); + /// Task KeyPersistAsync(RedisKey key, CommandFlags flags = CommandFlags.None); @@ -434,6 +446,16 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 // similar overloads + Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + /// Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None); @@ -461,6 +483,28 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 // similar overloads + Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None); +#pragma warning restore RS0026 + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetMemberGetExpireDateTimeAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetMemberGetTimeToLiveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetMemberPersistAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None); + /// Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None); @@ -851,6 +895,10 @@ IAsyncEnumerable SortedSetScanAsync( /// Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task StringBitPositionsAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None); + /// Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 1c8007e9f6..e032c1e3e1 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; using System.Threading.Tasks; +using RESPite; namespace StackExchange.Redis { @@ -387,6 +389,31 @@ public partial interface IServer : IRedis /// IAsyncEnumerable KeysAsync(int database = -1, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None); + /// + /// Returns all keys on this server that have the specified protection flags set, using BLESS SCAN. + /// Note: to resume an iteration via cursor, cast the original enumerable or enumerator to . + /// + /// The flag to filter by. + /// The database ID. + /// The page size to iterate by. + /// The cursor position to resume at. + /// The page offset to start at. + /// The command flags to use. + /// An enumeration of blessed redis keys; the order is unspecified, and a key may be reported more than once. + /// + /// + /// Like , this only covers the keys held by this server; + /// in a cluster, enumerate each primary. The instance-wide count is reported as blessed_keys by INFO stats. + /// + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + IEnumerable BlessedKeys(BlessFlags bless, int database = -1, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + IAsyncEnumerable BlessedKeysAsync(BlessFlags bless, int database = -1, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None); + /// /// Return the time of the last DB save executed with success. /// A client may check if a BGSAVE command succeeded reading the LASTSAVE value, then issuing a BGSAVE command diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index 9d2a4dfb8d..731d56133a 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -144,6 +144,10 @@ public enum KeyNotificationType SUnionStore = 64, [AsciiHash("sdiffstore")] SDiffStore = 65, + [AsciiHash("bless")] + Bless = 66, + [AsciiHash("unbless")] + Unbless = 67, // side-effect notifications [AsciiHash("expired")] @@ -158,5 +162,7 @@ public enum KeyNotificationType TypeChanged = 1004, [AsciiHash("keymiss")] // class m; excluded from the "A" alias, so only seen when requested explicitly KeyMiss = 1005, + [AsciiHash("sexpired")] // a set member lapsed (8.12 set-member expiration); on the subkey channels the payload names the member + SExpired = 1006, #pragma warning restore CS1591 // docs, redundant } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 8584b2ceea..f279cef13e 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -293,6 +293,15 @@ public Task KeyMoveAsync(RedisKey key, int database, CommandFlags flags = public Task KeyPersistAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.KeyPersistAsync(ToInner(key), flags); + public Task KeyBlessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) => + Inner.KeyBlessAsync(ToInner(key), bless, flags); + + public Task KeyUnblessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) => + Inner.KeyUnblessAsync(ToInner(key), bless, flags); + + public Task KeyBlessFlagsAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => + Inner.KeyBlessFlagsAsync(ToInner(key), flags); + public Task KeyRandomAsync(CommandFlags flags = CommandFlags.None) => throw new NotSupportedException("RANDOMKEY is not supported when a key-prefix is specified"); @@ -496,6 +505,12 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl public Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) => Inner.SetAddAsync(ToInner(key), value, flags); + public Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAsync(ToInner(key), value, expiry, when, flags); + + public Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAsync(ToInner(key), values, expiry, when, flags); + public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) => Inner.SetCombineAndStoreAsync(operation, ToInner(destination), ToInner(keys), flags); @@ -523,6 +538,21 @@ public Task SetCombineLengthAsync(SetOperation operation, RedisKey[] keys, public Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.SetLengthAsync(ToInner(key), flags); + public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberExpireAsync(ToInner(key), members, expiry, when, flags); + + public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberExpireAsync(ToInner(key), members, expiry, when, flags); + + public Task SetMemberGetExpireDateTimeAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberGetExpireDateTimeAsync(ToInner(key), members, flags); + + public Task SetMemberGetTimeToLiveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberGetTimeToLiveAsync(ToInner(key), members, flags); + + public Task SetMemberPersistAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberPersistAsync(ToInner(key), members, flags); + public Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.SetMembersAsync(ToInner(key), flags); @@ -853,6 +883,9 @@ public Task StringBitPositionAsync(RedisKey key, bool bit, long start, lon public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) => Inner.StringBitPositionAsync(ToInner(key), bit, start, end, indexType, flags); + public Task StringBitPositionsAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None) => + Inner.StringBitPositionsAsync(ToInner(key), bit, start, end, indexType, limit, flags); + public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) => Inner.StringDeleteAsync(ToInner(key), when, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 8ca3bca485..b309a6b80b 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -265,6 +265,15 @@ public bool KeyMove(RedisKey key, int database, CommandFlags flags = CommandFlag public bool KeyPersist(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.KeyPersist(ToInner(key), flags); + public bool KeyBless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) => + Inner.KeyBless(ToInner(key), bless, flags); + + public bool KeyUnbless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) => + Inner.KeyUnbless(ToInner(key), bless, flags); + + public BlessFlags KeyBlessFlags(RedisKey key, CommandFlags flags = CommandFlags.None) => + Inner.KeyBlessFlags(ToInner(key), flags); + public RedisKey KeyRandom(CommandFlags flags = CommandFlags.None) => throw new NotSupportedException("RANDOMKEY is not supported when a key-prefix is specified"); @@ -465,6 +474,12 @@ public long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = Comma public bool SetAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) => Inner.SetAdd(ToInner(key), value, flags); + public bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAdd(ToInner(key), value, expiry, when, flags); + + public bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAdd(ToInner(key), values, expiry, when, flags); + public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) => Inner.SetCombineAndStore(operation, ToInner(destination), ToInner(keys), flags); @@ -492,6 +507,21 @@ public long SetCombineLength(SetOperation operation, RedisKey[] keys, long limit public long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.SetLength(ToInner(key), flags); + public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberExpire(ToInner(key), members, expiry, when, flags); + + public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberExpire(ToInner(key), members, expiry, when, flags); + + public long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberGetExpireDateTime(ToInner(key), members, flags); + + public long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberGetTimeToLive(ToInner(key), members, flags); + + public PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + Inner.SetMemberPersist(ToInner(key), members, flags); + public RedisValue[] SetMembers(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.SetMembers(ToInner(key), flags); @@ -817,6 +847,9 @@ public long StringBitPosition(RedisKey key, bool bit, long start, long end, Comm public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) => Inner.StringBitPosition(ToInner(key), bit, start, end, indexType, flags); + public long[] StringBitPositions(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None) => + Inner.StringBitPositions(ToInner(key), bit, start, end, indexType, limit, flags); + public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) => Inner.StringDelete(ToInner(key), when, flags); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index d901d5a485..49b9f72285 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,37 @@ #nullable enable +[SER011]StackExchange.Redis.IDatabaseAsync.StringBitPositionsAsync(StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, long? limit = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabase.StringBitPositions(StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, long? limit = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]! +StackExchange.Redis.RedisFeatures.StringBitPositions.get -> bool +[SER011]StackExchange.Redis.IDatabase.SetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.SetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.SetMemberExpire(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.TimeSpan expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ExpireResult[]! +[SER011]StackExchange.Redis.IDatabase.SetMemberExpire(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.DateTime expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ExpireResult[]! +[SER011]StackExchange.Redis.IDatabase.SetMemberGetExpireDateTime(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]! +[SER011]StackExchange.Redis.IDatabase.SetMemberGetTimeToLive(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]! +[SER011]StackExchange.Redis.IDatabase.SetMemberPersist(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.PersistResult[]! +[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberExpireAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.TimeSpan expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberExpireAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.DateTime expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberGetExpireDateTimeAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberGetTimeToLiveAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberPersistAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.KeyNotificationType.SExpired = 1006 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.RedisFeatures.SetMemberExpiry.get -> bool +[SER011]StackExchange.Redis.BlessFlags +[SER011]StackExchange.Redis.BlessFlags.NoEvict = 1 -> StackExchange.Redis.BlessFlags +[SER011]StackExchange.Redis.BlessFlags.None = 0 -> StackExchange.Redis.BlessFlags +[SER011]StackExchange.Redis.IDatabaseAsync.KeyBlessAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.BlessFlags bless, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.KeyBlessFlagsAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.KeyUnblessAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.BlessFlags bless, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabase.KeyBlessFlags(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.BlessFlags +[SER011]StackExchange.Redis.IDatabase.KeyBless(StackExchange.Redis.RedisKey key, StackExchange.Redis.BlessFlags bless, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.KeyUnbless(StackExchange.Redis.RedisKey key, StackExchange.Redis.BlessFlags bless, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IServer.BlessedKeysAsync(StackExchange.Redis.BlessFlags bless, int database = -1, int pageSize = 250, long cursor = 0, int pageOffset = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IAsyncEnumerable! +[SER011]StackExchange.Redis.IServer.BlessedKeys(StackExchange.Redis.BlessFlags bless, int database = -1, int pageSize = 250, long cursor = 0, int pageOffset = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IEnumerable! +StackExchange.Redis.KeyNotificationType.Bless = 66 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Unbless = 67 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.RedisFeatures.Bless.get -> bool [SER012]StackExchange.Redis.ClusterSlotStatsEntry [SER012]StackExchange.Redis.ClusterSlotStatsEntry.CpuTime.get -> System.TimeSpan? [SER012]StackExchange.Redis.ClusterSlotStatsEntry.KeyCount.get -> long diff --git a/src/StackExchange.Redis/RedisDatabase.Bless.cs b/src/StackExchange.Redis/RedisDatabase.Bless.cs new file mode 100644 index 0000000000..1d91dcc721 --- /dev/null +++ b/src/StackExchange.Redis/RedisDatabase.Bless.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +internal partial class RedisDatabase +{ + public bool KeyBless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessMessage(Database, RedisLiterals.SET, key, bless, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public Task KeyBlessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessMessage(Database, RedisLiterals.SET, key, bless, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public bool KeyUnbless(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessMessage(Database, RedisLiterals.CLEAR, key, bless, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public Task KeyUnblessAsync(RedisKey key, BlessFlags bless, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessMessage(Database, RedisLiterals.CLEAR, key, bless, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public BlessFlags KeyBlessFlags(RedisKey key, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessFlagsMessage(Database, key, flags); + return ExecuteSync(msg, ResultProcessor.BlessFlags); + } + + public Task KeyBlessFlagsAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + { + var msg = CreateBlessFlagsMessage(Database, key, flags); + return ExecuteAsync(msg, ResultProcessor.BlessFlags); + } + + internal static Message CreateBlessMessage(int db, in RedisValue subcommand, in RedisKey key, BlessFlags bless, CommandFlags flags) + { + var msg = new BlessMessage(db, flags, subcommand, key, bless); + // BLESS as a whole is replica-eligible (for GET); SET/CLEAR carry WRITE + msg.SetPrimaryOnly(); + return msg; + } + + internal static Message CreateBlessFlagsMessage(int db, in RedisKey key, CommandFlags flags) + => Message.Create(db, flags.WithCategory(CommandFlags.CommandRetryReadOnly), RedisCommand.BLESS, RedisLiterals.GET, key); + + /// + /// Writes one wire token per flag in ; the server currently accepts exactly one, but + /// rejecting additional tokens is the server's call, not ours. + /// + internal static int BlessTokenCount(BlessFlags bless, string paramName) + { + // no token at all is an arity error on the server, and undefined bits have no token we could write + if (bless == BlessFlags.None || (bless & ~BlessFlags.NoEvict) != 0) + { + throw new ArgumentOutOfRangeException(paramName, bless, "At least one known BLESS flag must be specified."); + } + return 1; // only NoEvict is defined today + } + + internal static void WriteBlessTokens(in MessageWriter writer, BlessFlags bless) + { + if ((bless & BlessFlags.NoEvict) != 0) writer.WriteBulkString(RedisLiterals.NO_EVICT); + } + + // BLESS {SET|CLEAR} key flag [flag ...] + private sealed class BlessMessage : Message.CommandKeyBase + { + private readonly RedisValue subcommand; + private readonly BlessFlags bless; + private readonly int tokenCount; + + public BlessMessage(int db, CommandFlags flags, in RedisValue subcommand, in RedisKey key, BlessFlags bless) + : base(db, flags, RedisCommand.BLESS, key) + { + tokenCount = BlessTokenCount(bless, nameof(bless)); + this.subcommand = subcommand; + this.bless = bless; + } + + protected override void WriteImpl(in MessageWriter writer) + { + writer.WriteHeader(Command, 2 + tokenCount); + writer.WriteBulkString(subcommand); + writer.Write(Key); + WriteBlessTokens(writer, bless); + } + + public override int ArgCount => 2 + tokenCount; + } +} diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 3c9c289185..cf484d97e1 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2269,6 +2269,190 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl return ExecuteAsync(msg, ResultProcessor.Int64); } + public bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + internal Message SetAddWithExpiryMessage(in RedisKey key, in RedisValue value, Expiration expiry, When when, CommandFlags flags) + { + // SADDEX has no PERSIST token; removing member expirations is SetMemberPersist's job + if (expiry.IsPersist) throw new ArgumentException("PERSIST is not supported by SADDEX; use " + nameof(SetMemberPersist) + " to remove member expirations.", nameof(expiry)); + int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); + if (when == When.Always) + { + return expiryTokenCount switch + { + 2 => Message.Create(Database, flags, RedisCommand.SADDEX, key, expiry.Operand, expiry.Value, RedisLiterals.MEMBERS, 1, value), + 1 => Message.Create(Database, flags, RedisCommand.SADDEX, key, expiry.Operand, RedisLiterals.MEMBERS, 1, value), + _ => Message.Create(Database, flags, RedisCommand.SADDEX, key, RedisLiterals.MEMBERS, 1, value), + }; + } + else + { + // we need an extra token + var existance = when switch + { + When.Exists => RedisLiterals.MXX, + When.NotExists => RedisLiterals.MNX, + _ => throw new ArgumentOutOfRangeException(nameof(when)), + }; + + return expiryTokenCount switch + { + 2 => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, expiry.Operand, expiry.Value, RedisLiterals.MEMBERS, 1, value), + 1 => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, expiry.Operand, RedisLiterals.MEMBERS, 1, value), + _ => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, RedisLiterals.MEMBERS, 1, value), + }; + } + } + + internal Message SetAddWithExpiryMessage(in RedisKey key, RedisValue[] values, Expiration expiry, When when, CommandFlags flags) + { + if (values.Length == 1) + { + return SetAddWithExpiryMessage(key, values[0], expiry, when, flags); + } + // Determine the base array size + int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); + var extraTokens = expiryTokenCount + (when == When.Always ? 2 : 3); // [MXX|MNX] {expiry} MEMBERS {length} + RedisValue[] args = new RedisValue[values.Length + extraTokens]; + + int index = 0; + switch (when) + { + case When.Always: + break; + case When.Exists: + args[index++] = RedisLiterals.MXX; + break; + case When.NotExists: + args[index++] = RedisLiterals.MNX; + break; + default: + throw new ArgumentOutOfRangeException(nameof(when)); + } + switch (expiryTokenCount) + { + case 2: + args[index++] = expiry.Operand; + args[index++] = expiry.Value; + break; + case 1: + args[index++] = expiry.Operand; + break; + } + args[index++] = RedisLiterals.MEMBERS; + args[index++] = values.Length; + for (int i = 0; i < values.Length; i++) + { + args[index++] = values[i]; + } + Debug.Assert(index == args.Length); + return Message.Create(Database, flags, RedisCommand.SADDEX, key, args); + } + + public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + { + long milliseconds = expiry.Ticks / TimeSpan.TicksPerMillisecond; + return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireCommandByPrecision, SyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members); + } + + public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + { + long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry); + return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireAtCommandByPrecision, SyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members); + } + + public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + { + long milliseconds = expiry.Ticks / TimeSpan.TicksPerMillisecond; + return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireCommandByPrecision, AsyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members); + } + + public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + { + long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry); + return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireAtCommandByPrecision, AsyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members); + } + + private T SetMemberExpireExecute(RedisKey key, long milliseconds, ExpireWhen when, Func getCmd, CustomExecutor executor, TProcessor processor, CommandFlags flags, params RedisValue[] members) + => executor(GetSetMemberExpireMessage(key, milliseconds, when, getCmd, flags, members), processor); + + internal Message GetSetMemberExpireMessage(RedisKey key, long milliseconds, ExpireWhen when, Func getCmd, CommandFlags flags, params RedisValue[] members) + { + if (members == null) throw new ArgumentNullException(nameof(members)); + var useSeconds = milliseconds % 1000 == 0; + var cmd = getCmd(useSeconds); + long expiry = useSeconds ? (milliseconds / 1000) : milliseconds; + + // S[P]EXPIRE[AT] ... NX/XX/GT/LT is a conditional write, exactly as for the key-level EXPIRE; + // a bare one keeps the last-wins default + flags = flags.WithCategory(when.AsRetryCategory()); + + var values = when switch + { + ExpireWhen.Always => new List { expiry, RedisLiterals.MEMBERS, members.Length }, + _ => new List { expiry, when.ToLiteral(), RedisLiterals.MEMBERS, members.Length }, + }; + values.AddRange(members); + return Message.Create(Database, flags, cmd, key, values.ToArray()); + } + + internal static RedisCommand PickSetMemberExpireCommandByPrecision(bool useSeconds) => useSeconds ? RedisCommand.SEXPIRE : RedisCommand.SPEXPIRE; + + internal static RedisCommand PickSetMemberExpireAtCommandByPrecision(bool useSeconds) => useSeconds ? RedisCommand.SEXPIREAT : RedisCommand.SPEXPIREAT; + + public long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPEXPIRETIME, key, SyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members); + + public Task SetMemberGetExpireDateTimeAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPEXPIRETIME, key, AsyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members); + + public long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPTTL, key, SyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members); + + public Task SetMemberGetTimeToLiveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPTTL, key, AsyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members); + + public PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPERSIST, key, SyncCustomArrExecutor>, ResultProcessor.PersistResultArray, flags, members); + + public Task SetMemberPersistAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) => + SetMemberExecute(RedisCommand.SPERSIST, key, AsyncCustomArrExecutor>, ResultProcessor.PersistResultArray, flags, members); + + private T SetMemberExecute(RedisCommand cmd, RedisKey key, CustomExecutor executor, TProcessor processor, CommandFlags flags = CommandFlags.None, params RedisValue[] members) + => executor(GetSetMemberMessage(cmd, key, flags, members), processor); + + internal Message GetSetMemberMessage(RedisCommand cmd, RedisKey key, CommandFlags flags, params RedisValue[] members) + { + if (members == null) throw new ArgumentNullException(nameof(members)); + var values = new List { RedisLiterals.MEMBERS, members.Length }; + values.AddRange(members); + return Message.Create(Database, flags, cmd, key, values.ToArray()); + } + public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(Database, flags, operation.ToSetCommand(), first, second); @@ -3951,6 +4135,18 @@ public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, return ExecuteAsync(msg, ResultProcessor.Int64); } + public long[] StringBitPositions(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringBitPositionsMessage(key, bit, start, end, indexType, limit, flags); + return ExecuteSync(msg, ResultProcessor.Int64Array, defaultValue: Array.Empty()); + } + + public Task StringBitPositionsAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, long? limit = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringBitPositionsMessage(key, bit, start, end, indexType, limit, flags); + return ExecuteAsync(msg, ResultProcessor.Int64Array, defaultValue: Array.Empty()); + } + public long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) { return StringIncrement(key, -value, flags); @@ -5644,6 +5840,36 @@ internal Message GetStringBitPositionMessage(in RedisKey key, bool bit, long sta }; } + internal Message GetStringBitPositionsMessage(in RedisKey key, bool bit, long start, long end, StringIndexType indexType, long? limit, CommandFlags flags) + { + if (limit < 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "LIMIT can't be negative."); + } + + // BITRANGEINTARRAY key start end [BYTE|BIT] [LIMIT count] [ZEROS]; the server accepts the options in + // any order, we always emit this one. BYTE is the default and is omitted, as for BITCOUNT/BITPOS. + bool unit = indexType switch + { + StringIndexType.Byte => false, + StringIndexType.Bit => true, + _ => throw new ArgumentOutOfRangeException(nameof(indexType)), + }; + const RedisCommand cmd = RedisCommand.BITRANGEINTARRAY; + // (unit token, LIMIT present, ZEROS present) + return (unit, limit.HasValue, !bit) switch + { + (false, false, false) => Message.Create(Database, flags, cmd, key, start, end), + (true, false, false) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.BIT), + (false, true, false) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.LIMIT, limit.GetValueOrDefault()), + (false, false, true) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.ZEROS), + (true, true, false) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.BIT, RedisLiterals.LIMIT, limit.GetValueOrDefault()), + (true, false, true) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.BIT, RedisLiterals.ZEROS), + (false, true, true) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.LIMIT, limit.GetValueOrDefault(), RedisLiterals.ZEROS), + (true, true, true) => Message.Create(Database, flags, cmd, key, start, end, RedisLiterals.BIT, RedisLiterals.LIMIT, limit.GetValueOrDefault(), RedisLiterals.ZEROS), + }; + } + internal Message GetStringGetExMessage(in RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) { var tokens = expiry.GetTokenCount(allowEnx: false); diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index ac98141662..65222a1733 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -51,7 +51,8 @@ namespace StackExchange.Redis v8_4_0_rc1 = new Version(8, 3, 224), // 8.4 RC1 is version 8.3.224 v8_6_0 = new Version(8, 6, 0), v8_8_0 = new Version(8, 7, 225), // 8.8 is version 8.7.225 - v8_10_0 = new Version(8, 9, 241); // 8.10 preview is version 8.9.241 + v8_10_0 = new Version(8, 9, 241), // 8.10 preview is version 8.9.241 + v8_12_0 = new Version(8, 11, 241); // 8.12 RC2 is version 8.11.241 #pragma warning restore SA1310 // Field names should not contain underscore #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter @@ -327,6 +328,21 @@ public RedisFeatures(Version version) /// public bool ClusterSlotStats => Version.IsAtLeast(v8_2_0_rc1); + /// + /// Is bit enumeration (BITRANGEINTARRAY) available? + /// + public bool StringBitPositions => Version.IsAtLeast(v8_12_0); + + /// + /// Is set-member expiration (SADDEX, SEXPIRE, SPTTL, SPERSIST, ...) available? + /// + public bool SetMemberExpiry => Version.IsAtLeast(v8_12_0); + + /// + /// Are key eviction-protection flags (BLESS) available? + /// + public bool Bless => Version.IsAtLeast(v8_12_0); + #pragma warning restore 1629 // Documentation text should end with a period. /// diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 8ae916464e..1a697cce39 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -27,6 +27,7 @@ public static readonly RedisValue BYTE = RedisValue.FromRaw("BYTE"u8), CH = RedisValue.FromRaw("CH"u8), CHANNELS = RedisValue.FromRaw("CHANNELS"u8), + CLEAR = RedisValue.FromRaw("CLEAR"u8), COUNT = RedisValue.FromRaw("COUNT"u8), DB = RedisValue.FromRaw("DB"u8), @default = RedisValue.FromRaw("default"u8), @@ -83,14 +84,18 @@ public static readonly RedisValue MAX = RedisValue.FromRaw("MAX"u8), MAXAGE = RedisValue.FromRaw("MAXAGE"u8), MAXLEN = RedisValue.FromRaw("MAXLEN"u8), + MEMBERS = RedisValue.FromRaw("MEMBERS"u8), MIN = RedisValue.FromRaw("MIN"u8), MINMATCHLEN = RedisValue.FromRaw("MINMATCHLEN"u8), + MNX = RedisValue.FromRaw("MNX"u8), MODULE = RedisValue.FromRaw("MODULE"u8), + MXX = RedisValue.FromRaw("MXX"u8), NO = RedisValue.FromRaw("NO"u8), NODES = RedisValue.FromRaw("NODES"u8), NOSAVE = RedisValue.FromRaw("NOSAVE"u8), SLOTS = RedisValue.FromRaw("SLOTS"u8), nosort = RedisValue.FromRaw("nosort"u8), + NO_EVICT = RedisValue.FromRaw("NO-EVICT"u8), NOT = RedisValue.FromRaw("NOT"u8), NOVALUES = RedisValue.FromRaw("NOVALUES"u8), NUMPAT = RedisValue.FromRaw("NUMPAT"u8), @@ -120,6 +125,7 @@ public static readonly RedisValue REWRITE = RedisValue.FromRaw("REWRITE"u8), RIGHT = RedisValue.FromRaw("RIGHT"u8), SAVE = RedisValue.FromRaw("SAVE"u8), + SCAN = RedisValue.FromRaw("SCAN"u8), SEGFAULT = RedisValue.FromRaw("SEGFAULT"u8), SET = RedisValue.FromRaw("SET"u8), SETINFO = RedisValue.FromRaw("SETINFO"u8), @@ -177,6 +183,7 @@ public static readonly RedisValue WITHCOORD = RedisValue.FromRaw("WITHCOORD"u8), WITHDIST = RedisValue.FromRaw("WITHDIST"u8), WITHHASH = RedisValue.FromRaw("WITHHASH"u8), + ZEROS = RedisValue.FromRaw("ZEROS"u8), // geo units ft = RedisValue.FromRaw("ft"u8), diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index e6d9cb000c..1aef07906c 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -416,6 +416,20 @@ private CursorEnumerable KeysAsync(int database, RedisValue pattern, i return CursorEnumerable.From(this, server, ExecuteAsync(msg, ResultProcessor.RedisKeyArray, defaultValue: Array.Empty()), pageOffset); } + IEnumerable IServer.BlessedKeys(BlessFlags bless, int database, int pageSize, long cursor, int pageOffset, CommandFlags flags) + => BlessedKeysAsync(bless, database, pageSize, cursor, pageOffset, flags); + + IAsyncEnumerable IServer.BlessedKeysAsync(BlessFlags bless, int database, int pageSize, long cursor, int pageOffset, CommandFlags flags) + => BlessedKeysAsync(bless, database, pageSize, cursor, pageOffset, flags); + + private CursorEnumerable BlessedKeysAsync(BlessFlags bless, int database, int pageSize, long cursor, int pageOffset, CommandFlags flags) + { + database = multiplexer.ApplyDefaultDatabase(database); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + RedisDatabase.BlessTokenCount(bless, nameof(bless)); // validate eagerly, not on first MoveNext + return new BlessScanEnumerable(this, database, bless, pageSize, cursor, pageOffset, flags); + } + public DateTime LastSave(CommandFlags flags = CommandFlags.None) { var msg = Message.Create(-1, flags, RedisCommand.LASTSAVE); @@ -980,6 +994,53 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } + private sealed class BlessScanEnumerable : CursorEnumerable + { + private readonly BlessFlags bless; + + public BlessScanEnumerable(RedisServer server, int db, BlessFlags bless, int pageSize, in RedisValue cursor, int pageOffset, CommandFlags flags) + : base(server, server.server, db, pageSize, cursor, pageOffset, flags) + { + this.bless = bless; + } + + // the server's own default COUNT (1024) differs from SCAN's, so always send ours + private protected override Message CreateMessage(in RedisValue cursor) + => new BlessScanMessage(db, flags.WithScanCursorCategory(cursor), cursor, bless, pageSize); + + // same [cursor, [keys...]] shape as SCAN + private protected override ResultProcessor Processor => KeysScanEnumerable.processor; + } + + // BLESS SCAN cursor flag [flag ...] COUNT count + internal sealed class BlessScanMessage : Message + { + private readonly RedisValue cursor; + private readonly BlessFlags bless; + private readonly int tokenCount, count; + + public BlessScanMessage(int db, CommandFlags flags, in RedisValue cursor, BlessFlags bless, int count) + : base(db, flags, RedisCommand.BLESS) + { + tokenCount = RedisDatabase.BlessTokenCount(bless, nameof(bless)); + this.cursor = cursor; + this.bless = bless; + this.count = count; + } + + protected override void WriteImpl(in MessageWriter writer) + { + writer.WriteHeader(Command, ArgCount); + writer.WriteBulkString(RedisLiterals.SCAN); + writer.WriteBulkString(cursor); + RedisDatabase.WriteBlessTokens(writer, bless); + writer.WriteBulkString(RedisLiterals.COUNT); + writer.WriteBulkString(count); + } + + public override int ArgCount => 4 + tokenCount; + } + public EndPoint? SentinelGetMasterAddressByName(string serviceName, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.GETMASTERADDRBYNAME, serviceName.AsRedisValue()); diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 7df8b83219..69f566d8e9 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -111,6 +111,8 @@ public static readonly ResultProcessor public static readonly ResultProcessor PersistResultArray = new PersistResultArrayProcessor(); + public static readonly ResultProcessor BlessFlags = new BlessFlagsProcessor(); + public static readonly ResultProcessor RedisChannelArrayLiteral = new RedisChannelArrayProcessor(RedisChannel.RedisChannelOptions.None); @@ -1831,6 +1833,27 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } + // BLESS GET: an array of active flag names; empty (not nil) for an unblessed key + private sealed class BlessFlagsProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) + { + if (reader.IsAggregate && !reader.IsNull) + { + var result = StackExchange.Redis.BlessFlags.None; + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) + { + // tokens from a newer server that we don't model are ignored, not fatal + if (iter.Value.Is("NO-EVICT"u8)) result |= StackExchange.Redis.BlessFlags.NoEvict; + } + SetResult(message, result); + return true; + } + return false; + } + } + private sealed class PersistResultArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 301c00ec0f..db5b66e097 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:custom-30445126297-debian +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.12-rc2 FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/Cluster/cluster-7000.conf b/tests/RedisConfigs/Cluster/cluster-7000.conf index ad11a23fda..8d6454c275 100644 --- a/tests/RedisConfigs/Cluster/cluster-7000.conf +++ b/tests/RedisConfigs/Cluster/cluster-7000.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7000.rdb" appendfilename "appendonly-7000.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/Cluster/cluster-7001.conf b/tests/RedisConfigs/Cluster/cluster-7001.conf index 589f9ea234..ac2e748caf 100644 --- a/tests/RedisConfigs/Cluster/cluster-7001.conf +++ b/tests/RedisConfigs/Cluster/cluster-7001.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7001.rdb" appendfilename "appendonly-7001.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/Cluster/cluster-7002.conf b/tests/RedisConfigs/Cluster/cluster-7002.conf index 66a376865d..a1a7ed8f9e 100644 --- a/tests/RedisConfigs/Cluster/cluster-7002.conf +++ b/tests/RedisConfigs/Cluster/cluster-7002.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7002.rdb" appendfilename "appendonly-7002.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/Cluster/cluster-7003.conf b/tests/RedisConfigs/Cluster/cluster-7003.conf index 1f48830235..57ae4f62f6 100644 --- a/tests/RedisConfigs/Cluster/cluster-7003.conf +++ b/tests/RedisConfigs/Cluster/cluster-7003.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7003.rdb" appendfilename "appendonly-7003.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/Cluster/cluster-7004.conf b/tests/RedisConfigs/Cluster/cluster-7004.conf index 93d75f38af..29e4f819a8 100644 --- a/tests/RedisConfigs/Cluster/cluster-7004.conf +++ b/tests/RedisConfigs/Cluster/cluster-7004.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7004.rdb" appendfilename "appendonly-7004.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/Cluster/cluster-7005.conf b/tests/RedisConfigs/Cluster/cluster-7005.conf index c9b5d55e24..0e2073865a 100644 --- a/tests/RedisConfigs/Cluster/cluster-7005.conf +++ b/tests/RedisConfigs/Cluster/cluster-7005.conf @@ -7,4 +7,6 @@ appendonly yes dbfilename "dump-7005.rdb" appendfilename "appendonly-7005.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKE +# the test cluster bus is plaintext and reachable only from the docker network / local host +cluster-bus-port-protected-mode no diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index af0df8bc40..0130dec75a 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:custom-30445126297-debian} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.12-rc2} additional_contexts: configs: . platform: linux diff --git a/tests/StackExchange.Redis.Tests/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs index 71f00b90d4..d084aa3be5 100644 --- a/tests/StackExchange.Redis.Tests/BitTests.cs +++ b/tests/StackExchange.Redis.Tests/BitTests.cs @@ -146,4 +146,102 @@ public async Task BitFieldAllGetGoesOutAsReadOnlyAndReachesAReplica() var write = Assert.Single(commands, c => c.Command == "BITFIELD"); Assert.False(conn.GetServer(write.EndPoint).IsReplica); } + + private static void SetSampleBits(IDatabase db, RedisKey key) + { + // the sample from redis/redis#15986: bits 1, 7, 9 and 30 + db.KeyDelete(key, CommandFlags.FireAndForget); + foreach (var bit in new long[] { 1, 7, 9, 30 }) + { + db.StringSetBit(key, bit, true, CommandFlags.FireAndForget); + } + } + + [Fact] + public async Task StringBitPositions_Ranges() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + SetSampleBits(db, key); + + Assert.Equal(new long[] { 1, 7, 9, 30 }, db.StringBitPositions(key, true)); + Assert.Equal(new long[] { 9, 30 }, db.StringBitPositions(key, true, 1, -1)); + Assert.Equal(new long[] { 7, 9 }, await db.StringBitPositionsAsync(key, true, 5, 10, StringIndexType.Bit)); + Assert.Empty(db.StringBitPositions(key, true, 3, 1)); // reversed range + } + + [Fact] + public async Task StringBitPositions_ClearBits() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + SetSampleBits(db, key); + + Assert.Equal(new long[] { 24, 25, 26, 27, 28, 29, 31 }, db.StringBitPositions(key, false, 24, 31, StringIndexType.Bit)); + // unlike BITPOS, the range is clamped to the value: nothing past bit 31 is reported + Assert.Equal(new long[] { 31 }, await db.StringBitPositionsAsync(key, false, 30, 100, StringIndexType.Bit)); + } + + [Fact] + public async Task StringBitPositions_Limit() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + SetSampleBits(db, key); + + Assert.Equal(new long[] { 1 }, db.StringBitPositions(key, true, 0, -1, StringIndexType.Bit, limit: 1)); + Assert.Equal(new long[] { 7, 9 }, db.StringBitPositions(key, true, 2, -1, StringIndexType.Bit, limit: 2)); + Assert.Equal(new long[] { 1, 7 }, db.StringBitPositions(key, true, 0, -1, StringIndexType.Byte, limit: 1)); // BYTE completes byte 0 + Assert.Empty(await db.StringBitPositionsAsync(key, true, 0, -1, StringIndexType.Byte, limit: 0)); + } + + [Theory] + [InlineData(StringIndexType.Bit)] + [InlineData(StringIndexType.Byte)] + public async Task StringBitPositions_PagesWithoutGaps(StringIndexType indexType) + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me() + ":" + indexType; + + db.KeyDelete(key, CommandFlags.FireAndForget); + var rand = new Random(12345); + for (int i = 0; i < 10_000; i++) + { + if (rand.Next(3) == 0) db.StringSetBit(key, i, true, CommandFlags.FireAndForget); + } + var all = await db.StringBitPositionsAsync(key, true); + Assert.NotEmpty(all); + + var paged = new System.Collections.Generic.List(); + long start = 0; + const int Limit = 97; + while (true) + { + var page = db.StringBitPositions(key, true, start, -1, indexType, limit: Limit); + paged.AddRange(page); + if (page.Length < Limit) break; + long last = page[page.Length - 1]; + start = indexType == StringIndexType.Bit ? last + 1 : (last / 8) + 1; + } + Assert.Equal(all, paged); + } + + [Fact] + public async Task StringBitPositions_MissingKeyAndWrongType() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + + db.KeyDelete(key, CommandFlags.FireAndForget); + Assert.Empty(db.StringBitPositions(key, true)); + + db.ListRightPush(key, "a", flags: CommandFlags.FireAndForget); + var ex = await Assert.ThrowsAsync(() => db.StringBitPositionsAsync(key, true)); + Assert.Contains("WRONGTYPE", ex.Message); + } } diff --git a/tests/StackExchange.Redis.Tests/BlessTests.cs b/tests/StackExchange.Redis.Tests/BlessTests.cs new file mode 100644 index 0000000000..a08b287263 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/BlessTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using StackExchange.Redis.KeyspaceIsolation; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Integration tests for the BLESS eviction-protection flags (Redis 8.12+). +/// +[RunPerProtocol] +public class BlessTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + [Fact] + public async Task BlessUnblessLifecycle() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.KeyDeleteAsync(key); + await db.StringSetAsync(key, "value"); + + Assert.Equal(BlessFlags.None, await db.KeyBlessFlagsAsync(key)); + Assert.True(await db.KeyBlessAsync(key, BlessFlags.NoEvict)); + Assert.False(await db.KeyBlessAsync(key, BlessFlags.NoEvict)); // already set; still success + Assert.Equal(BlessFlags.NoEvict, await db.KeyBlessFlagsAsync(key)); + + // the flag is key metadata, not part of the value + await db.StringSetAsync(key, "overwritten"); + Assert.Equal(BlessFlags.NoEvict, db.KeyBlessFlags(key)); + + Assert.True(db.KeyUnbless(key, BlessFlags.NoEvict)); + Assert.False(db.KeyUnbless(key, BlessFlags.NoEvict)); + Assert.Equal(BlessFlags.None, db.KeyBlessFlags(key)); + } + + [Fact] + public async Task MissingKeyIsAnError() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.KeyDeleteAsync(key); + + var ex = await Assert.ThrowsAsync(() => db.KeyBlessAsync(key, BlessFlags.NoEvict)); + Assert.Contains("no such key", ex.Message); + ex = await Assert.ThrowsAsync(() => db.KeyUnblessAsync(key, BlessFlags.NoEvict)); + Assert.Contains("no such key", ex.Message); + ex = await Assert.ThrowsAsync(() => db.KeyBlessFlagsAsync(key)); + Assert.Contains("no such key", ex.Message); + } + + [Fact] + public async Task DeletingTheKeyClearsTheFlag() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.StringSetAsync(key, "value"); + await db.KeyBlessAsync(key, BlessFlags.NoEvict); + + await db.KeyDeleteAsync(key); + await db.StringSetAsync(key, "value"); + Assert.Equal(BlessFlags.None, await db.KeyBlessFlagsAsync(key)); + } + + [Fact] + public async Task KeyPrefixIsApplied() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var db = conn.GetDatabase(); + RedisKey prefix = Me() + ":", key = "key", full = Me() + ":key"; + var prefixed = db.WithKeyPrefix(prefix); + await db.StringSetAsync(full, "value"); + + Assert.True(await prefixed.KeyBlessAsync(key, BlessFlags.NoEvict)); + Assert.Equal(BlessFlags.NoEvict, await db.KeyBlessFlagsAsync(full)); + Assert.Equal(BlessFlags.NoEvict, await prefixed.KeyBlessFlagsAsync(key)); + Assert.True(await prefixed.KeyUnblessAsync(key, BlessFlags.NoEvict)); + } + + [Fact] + public async Task BlessedKeysPagesThroughAllKeys() + { + await using var conn = Create(require: RedisFeatures.v8_12_0); + var dbId = TestConfig.GetDedicatedDB(conn); + var db = conn.GetDatabase(dbId); + var server = GetAnyPrimary(conn); + var prefix = Me(); + await server.FlushDatabaseAsync(dbId); + + const int Count = 100; + for (int i = 0; i < Count; i++) + { + RedisKey key = prefix + i; + await db.StringSetAsync(key, i); + if (i % 2 == 0) await db.KeyBlessAsync(key, BlessFlags.NoEvict); + } + + // small pages, so the cursor has to be followed; the server may repeat keys across pages + var sync = server.BlessedKeys(BlessFlags.NoEvict, dbId, pageSize: 7).Select(k => (string)k!).Distinct().ToList(); + Assert.Equal(Count / 2, sync.Count); + Assert.All(sync, k => Assert.Equal(0, int.Parse(k.Substring(prefix.Length)) % 2)); + + var async = new HashSet(); + await foreach (var key in server.BlessedKeysAsync(BlessFlags.NoEvict, dbId, pageSize: 7)) + { + async.Add((string)key!); + } + Assert.Equal(Count / 2, async.Count); + } +} diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index d288653d7f..e9599490bc 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -450,6 +450,7 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData("sadd", KeyNotificationType.SAdd)] [InlineData("set", KeyNotificationType.Set)] [InlineData("setrange", KeyNotificationType.SetRange)] + [InlineData("sexpired", KeyNotificationType.SExpired)] [InlineData("sortstore", KeyNotificationType.SortStore)] [InlineData("srem", KeyNotificationType.SRem)] [InlineData("spop", KeyNotificationType.SPop)] @@ -918,6 +919,32 @@ public void SubKeyEvent_HSet_ParsesCorrectly(string payload, string expectedFirs Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); } + [Theory] + [InlineData("14:presence:lobby|5:alice", new[] { "alice" })] + [InlineData("14:presence:lobby|5:alice|3:bob", new[] { "alice", "bob" })] + public void SubKeyEvent_SExpired_NamesTheLapsedMembers(string payload, string[] expectedSubKeys) + { + // 8.12 set-member expiration: __subkeyevent@0__:sexpired names the member(s) that lapsed, not just the key + var channel = RedisChannel.Literal("__subkeyevent@0__:sexpired"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.SExpired, notification.Type); + Assert.True(notification.IsType("sexpired"u8)); + Assert.Equal("presence:lobby", (string?)notification.GetKey()); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + [Theory] [InlineData("5:mykey|6:field1", new[] { "field1" })] [InlineData("5:mykey|6:field1|6:field2", new[] { "field1", "field2" })] diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index 108bd5ef7d..82aaa17b89 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs @@ -369,6 +369,27 @@ public void KeyPersist() mock.Received().KeyPersist("prefix:key", CommandFlags.None); } + [Fact] + public void KeyBless() + { + prefixed.KeyBless("key", BlessFlags.NoEvict, CommandFlags.None); + mock.Received().KeyBless("prefix:key", BlessFlags.NoEvict, CommandFlags.None); + } + + [Fact] + public void KeyUnbless() + { + prefixed.KeyUnbless("key", BlessFlags.NoEvict, CommandFlags.None); + mock.Received().KeyUnbless("prefix:key", BlessFlags.NoEvict, CommandFlags.None); + } + + [Fact] + public void KeyBlessFlags() + { + prefixed.KeyBlessFlags("key", CommandFlags.None); + mock.Received().KeyBlessFlags("prefix:key", CommandFlags.None); + } + [Fact] public void KeyRandom() { diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs index 5df1b380d7..9da4ec08b4 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs @@ -322,6 +322,27 @@ public async Task KeyPersistAsync() await mock.Received().KeyPersistAsync("prefix:key", CommandFlags.None); } + [Fact] + public async Task KeyBlessAsync() + { + await prefixed.KeyBlessAsync("key", BlessFlags.NoEvict, CommandFlags.None); + await mock.Received().KeyBlessAsync("prefix:key", BlessFlags.NoEvict, CommandFlags.None); + } + + [Fact] + public async Task KeyUnblessAsync() + { + await prefixed.KeyUnblessAsync("key", BlessFlags.NoEvict, CommandFlags.None); + await mock.Received().KeyUnblessAsync("prefix:key", BlessFlags.NoEvict, CommandFlags.None); + } + + [Fact] + public async Task KeyBlessFlagsAsync() + { + await prefixed.KeyBlessFlagsAsync("key", CommandFlags.None); + await mock.Received().KeyBlessFlagsAsync("prefix:key", CommandFlags.None); + } + [Fact] public Task KeyRandomAsync() { diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Bless.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Bless.cs new file mode 100644 index 0000000000..fef9b59fc8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Bless.cs @@ -0,0 +1,27 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// BLESS GET replies with an array of active flag names; an unblessed key is an empty array, not nil. +/// +public class Bless(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*0\r\n", BlessFlags.None)] + [InlineData("*1\r\n$8\r\nNO-EVICT\r\n", BlessFlags.NoEvict)] + [InlineData("~1\r\n$8\r\nNO-EVICT\r\n", BlessFlags.NoEvict)] // tolerate a RESP3 set + [InlineData("*1\r\n+NO-EVICT\r\n", BlessFlags.NoEvict)] + [InlineData("*1\r\n$6\r\nIN-RAM\r\n", BlessFlags.None)] // a flag from a newer server is ignored + [InlineData("*2\r\n$6\r\nIN-RAM\r\n$8\r\nNO-EVICT\r\n", BlessFlags.NoEvict)] + [InlineData("*1\r\n$8\r\nno-evict\r\n", BlessFlags.None)] // the server's token is upper-case; don't guess + public void BlessFlags_Valid(string resp, BlessFlags expected) + => Assert.Equal(expected, Execute(resp, ResultProcessor.BlessFlags)); + + [Theory] + [InlineData("*-1\r\n")] + [InlineData("$-1\r\n")] + [InlineData("$8\r\nNO-EVICT\r\n")] + [InlineData(":1\r\n")] + public void BlessFlags_Invalid(string resp) => ExecuteUnexpected(resp, ResultProcessor.BlessFlags); +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionsRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionsRoundTrip.cs new file mode 100644 index 0000000000..2cc79785f9 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionsRoundTrip.cs @@ -0,0 +1,105 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +/// +/// Exact wire bytes for BITRANGEINTARRAY key start end [BYTE|BIT] [LIMIT count] [ZEROS], as built by +/// , plus the reply parsing. +/// +public class BitPositionsRoundTrip(ITestOutputHelper log) +{ + private const string Cmd = "$16\r\nBITRANGEINTARRAY\r\n$1\r\nk\r\n"; + private const string SampleReply = "*4\r\n:1\r\n:7\r\n:9\r\n:30\r\n"; + private static readonly long[] Sample = [1, 7, 9, 30]; + + private static Message Build(bool bit, long start, long end, StringIndexType indexType, long? limit) + => new RedisDatabase(null!, 0, null).GetStringBitPositionsMessage("k", bit, start, end, indexType, limit, CommandFlags.None); + + [Fact(Timeout = 1000)] + public async Task Defaults_SendStartAndEnd() + { + var msg = Build(true, 0, -1, StringIndexType.Byte, null); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*4\r\n" + Cmd + "$1\r\n0\r\n$2\r\n-1\r\n", SampleReply, log: log); + Assert.Equal(Sample, result); + } + + [Fact(Timeout = 1000)] + public async Task BitIndexType_SendsToken() + { + var msg = Build(true, 5, 10, StringIndexType.Bit, null); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*5\r\n" + Cmd + "$1\r\n5\r\n$2\r\n10\r\n$3\r\nBIT\r\n", "*2\r\n:7\r\n:9\r\n", log: log); + Assert.Equal([7, 9], result); + } + + [Fact(Timeout = 1000)] + public async Task Limit_SendsToken() + { + var msg = Build(true, 0, -1, StringIndexType.Byte, 1); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*6\r\n" + Cmd + "$1\r\n0\r\n$2\r\n-1\r\n$5\r\nLIMIT\r\n$1\r\n1\r\n", "*2\r\n:1\r\n:7\r\n", log: log); + Assert.Equal([1, 7], result); + } + + [Fact(Timeout = 1000)] + public async Task ClearBits_SendsZeros() + { + var msg = Build(false, 24, 31, StringIndexType.Bit, null); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*6\r\n" + Cmd + "$2\r\n24\r\n$2\r\n31\r\n$3\r\nBIT\r\n$5\r\nZEROS\r\n", "*7\r\n:24\r\n:25\r\n:26\r\n:27\r\n:28\r\n:29\r\n:31\r\n", log: log); + Assert.Equal([24, 25, 26, 27, 28, 29, 31], result); + } + + [Fact(Timeout = 1000)] + public async Task ClearBits_ByteDefault_SendsZerosOnly() + { + var msg = Build(false, 0, -1, StringIndexType.Byte, null); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*5\r\n" + Cmd + "$1\r\n0\r\n$2\r\n-1\r\n$5\r\nZEROS\r\n", "*1\r\n:0\r\n", log: log); + Assert.Equal([0], result); + } + + [Fact(Timeout = 1000)] + public async Task LimitAndZeros_ByteDefault() + { + var msg = Build(false, 0, -1, StringIndexType.Byte, 2); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*7\r\n" + Cmd + "$1\r\n0\r\n$2\r\n-1\r\n$5\r\nLIMIT\r\n$1\r\n2\r\n$5\r\nZEROS\r\n", "*2\r\n:0\r\n:2\r\n", log: log); + Assert.Equal([0, 2], result); + } + + [Fact(Timeout = 1000)] + public async Task BitAndLimit() + { + var msg = Build(true, 2, -1, StringIndexType.Bit, 2); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*7\r\n" + Cmd + "$1\r\n2\r\n$2\r\n-1\r\n$3\r\nBIT\r\n$5\r\nLIMIT\r\n$1\r\n2\r\n", "*2\r\n:7\r\n:9\r\n", log: log); + Assert.Equal([7, 9], result); + } + + [Fact(Timeout = 1000)] + public async Task AllOptions_CanonicalOrder() + { + var msg = Build(false, 2, -1, StringIndexType.Bit, 2); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*8\r\n" + Cmd + "$1\r\n2\r\n$2\r\n-1\r\n$3\r\nBIT\r\n$5\r\nLIMIT\r\n$1\r\n2\r\n$5\r\nZEROS\r\n", "*2\r\n:2\r\n:3\r\n", log: log); + Assert.Equal([2, 3], result); + } + + [Fact(Timeout = 1000)] + public async Task Limit_Zero_IsSent() + { + var msg = Build(true, 0, -1, StringIndexType.Byte, 0); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, "*6\r\n" + Cmd + "$1\r\n0\r\n$2\r\n-1\r\n$5\r\nLIMIT\r\n$1\r\n0\r\n", "*0\r\n", log: log); + Assert.Empty(result); + } + + [Fact] + public void Limit_Negative_Throws() + { + var ex = Assert.Throws(() => Build(true, 0, -1, StringIndexType.Byte, -1)); + Assert.Equal("limit", ex.ParamName); + } + + [Fact] + public void IndexType_Undefined_Throws() + { + var ex = Assert.Throws(() => Build(true, 0, -1, (StringIndexType)42, null)); + Assert.Equal("indexType", ex.ParamName); + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BlessRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BlessRoundTrip.cs new file mode 100644 index 0000000000..656fec023c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BlessRoundTrip.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +/// +/// Wire bytes, routing and retry category for the BLESS subcommands. BLESS itself is replica-eligible, +/// so the write subcommands have to be raised to primary-only by the message factory. +/// +public class BlessRoundTrip(ITestOutputHelper log) +{ + [Theory(Timeout = 1000)] + [InlineData("SET", "*4\r\n$5\r\nBLESS\r\n$3\r\nSET\r\n$3\r\nkey\r\n$8\r\nNO-EVICT\r\n", ":1\r\n", true)] + [InlineData("SET", "*4\r\n$5\r\nBLESS\r\n$3\r\nSET\r\n$3\r\nkey\r\n$8\r\nNO-EVICT\r\n", ":0\r\n", false)] + [InlineData("CLEAR", "*4\r\n$5\r\nBLESS\r\n$5\r\nCLEAR\r\n$3\r\nkey\r\n$8\r\nNO-EVICT\r\n", ":1\r\n", true)] + public async Task SetClear_RoundTrips(string subcommand, string requestResp, string responseResp, bool expected) + { + var msg = RedisDatabase.CreateBlessMessage(0, subcommand == "SET" ? RedisLiterals.SET : RedisLiterals.CLEAR, "key", BlessFlags.NoEvict, CommandFlags.None); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, requestResp, responseResp, log: log); + Assert.Equal(expected, result); + } + + [Theory(Timeout = 1000)] + [InlineData("*1\r\n$8\r\nNO-EVICT\r\n", BlessFlags.NoEvict)] + [InlineData("*0\r\n", BlessFlags.None)] + public async Task Get_RoundTrips(string responseResp, BlessFlags expected) + { + var msg = RedisDatabase.CreateBlessFlagsMessage(0, "key", CommandFlags.None); + const string requestResp = "*3\r\n$5\r\nBLESS\r\n$3\r\nGET\r\n$3\r\nkey\r\n"; + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.BlessFlags, requestResp, responseResp, log: log); + Assert.Equal(expected, result); + } + + [Fact(Timeout = 1000)] + public async Task Scan_RoundTrips() + { + // COUNT is always sent: the server's default (1024) is not the library's page size + var msg = new RedisServer.BlessScanMessage(0, CommandFlags.None, 0, BlessFlags.NoEvict, 250); + const string requestResp = "*6\r\n$5\r\nBLESS\r\n$4\r\nSCAN\r\n$1\r\n0\r\n$8\r\nNO-EVICT\r\n$5\r\nCOUNT\r\n$3\r\n250\r\n"; + // reuses the SCAN reply processor; this only asserts the outbound bytes + await TestConnection.ExecuteAsync(msg, ResultProcessor.DemandOK, requestResp, "+OK\r\n", log: log); + } + + [Fact] + public void WritesArePrimaryOnly_ReadsAreNot() + { + Assert.False(RedisCommand.BLESS.IsPrimaryOnly()); + Assert.True(RedisDatabase.CreateBlessMessage(0, RedisLiterals.SET, "key", BlessFlags.NoEvict, CommandFlags.PreferReplica).IsPrimaryOnly()); + Assert.True(RedisDatabase.CreateBlessMessage(0, RedisLiterals.CLEAR, "key", BlessFlags.NoEvict, CommandFlags.PreferReplica).IsPrimaryOnly()); + Assert.False(RedisDatabase.CreateBlessFlagsMessage(0, "key", CommandFlags.PreferReplica).IsPrimaryOnly()); + } + + [Fact] + public void WriteDemandingReplicaFails() + => Assert.Throws(() => RedisDatabase.CreateBlessMessage(0, RedisLiterals.SET, "key", BlessFlags.NoEvict, CommandFlags.DemandReplica)); + + [Fact] + public void RetryCategories() + { + // flag state converges, so a replayed SET/CLEAR leaves the same end-state + Assert.Equal(CommandFlags.CommandRetryWriteChecked, RedisDatabase.CreateBlessMessage(0, RedisLiterals.SET, "key", BlessFlags.NoEvict, CommandFlags.None).Flags & Message.MaskRetryCategory); + Assert.Equal(CommandFlags.CommandRetryReadOnly, RedisDatabase.CreateBlessFlagsMessage(0, "key", CommandFlags.None).Flags & Message.MaskRetryCategory); + } + + [Theory] + [InlineData(BlessFlags.None)] + [InlineData((BlessFlags)2)] + [InlineData(BlessFlags.NoEvict | (BlessFlags)4)] + public void NoOrUnknownFlagsAreRejected(BlessFlags bless) + { + Assert.Throws(() => RedisDatabase.CreateBlessMessage(0, RedisLiterals.SET, "key", bless, CommandFlags.None)); + Assert.Throws(() => new RedisServer.BlessScanMessage(0, CommandFlags.None, 0, bless, 10)); + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs new file mode 100644 index 0000000000..6f2817246a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs @@ -0,0 +1,196 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +/// +/// Exact-bytes tests for the Redis 8.12 set-member expiration family: +/// SADDEX, SEXPIRE/SPEXPIRE/SEXPIREAT/SPEXPIREAT, +/// SPTTL, SPEXPIRETIME and SPERSIST. +/// +public class SetMemberExpiryRoundTrip(ITestOutputHelper log) +{ + // 2101-01-01T00:00:00Z + private static readonly DateTime NextCentury = new(2101, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private const long NextCenturyUnixSeconds = 4133980800; + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_Ex() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None); + const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_Px() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromMilliseconds(1500), keepTtl: false), When.Always, CommandFlags.None); + const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nPX\r\n$4\r\n1500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_KeepTtl() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: true), When.Always, CommandFlags.None); + const string RequestResp = "*6\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$7\r\nKEEPTTL\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_Bare_DiscardsTtl() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: false), When.Always, CommandFlags.None); + const string RequestResp = "*5\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":0\r\n", log: log); + Assert.False(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_ExAt() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", "m", new Expiration(NextCentury), When.Always, CommandFlags.None); + const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$4\r\nEXAT\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_Mnx() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "a", "b" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.NotExists, CommandFlags.None); + const string RequestResp = "*9\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$3\r\nMNX\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n"; + + // one existing member vetoes the whole batch: reply 0, nothing added + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":0\r\n", log: log); + Assert.False(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_Mxx() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "a", "b" }, new Expiration(NextCentury), When.Exists, CommandFlags.None); + const string RequestResp = "*9\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$3\r\nMXX\r\n$4\r\nEXAT\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetAddWithExpiry_SingleElementArray_SameAsSingle() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "m" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None); + const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log); + Assert.True(result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberExpire_SecondsPrecision() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberExpireMessage("k", 90_000, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a", "b"); + const string RequestResp = "*7\r\n$7\r\nSEXPIRE\r\n$1\r\nk\r\n$2\r\n90\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*2\r\n:1\r\n:-2\r\n", log: log); + Assert.Equal(new[] { ExpireResult.Success, ExpireResult.NoSuchField }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberExpire_MillisecondsPrecision() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberExpireMessage("k", 1500, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a"); + const string RequestResp = "*6\r\n$8\r\nSPEXPIRE\r\n$1\r\nk\r\n$4\r\n1500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:1\r\n", log: log); + Assert.Equal(new[] { ExpireResult.Success }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberExpire_Gt() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberExpireMessage("k", 90_000, ExpireWhen.GreaterThanCurrentExpiry, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a"); + const string RequestResp = "*7\r\n$7\r\nSEXPIRE\r\n$1\r\nk\r\n$2\r\n90\r\n$2\r\nGT\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n"; + + // GT loses to a member with no expiry (no expiry counts as infinite) + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:0\r\n", log: log); + Assert.Equal(new[] { ExpireResult.ConditionNotMet }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberExpireAt_SecondsPrecision() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberExpireMessage("k", NextCenturyUnixSeconds * 1000, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireAtCommandByPrecision, CommandFlags.None, "a"); + const string RequestResp = "*6\r\n$9\r\nSEXPIREAT\r\n$1\r\nk\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:1\r\n", log: log); + Assert.Equal(new[] { ExpireResult.Success }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberExpireAt_MillisecondsPrecision() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberExpireMessage("k", (NextCenturyUnixSeconds * 1000) + 500, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireAtCommandByPrecision, CommandFlags.None, "a"); + const string RequestResp = "*6\r\n$10\r\nSPEXPIREAT\r\n$1\r\nk\r\n$13\r\n4133980800500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n"; + + // an expiration already in the past deletes the member: reply 2 + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:2\r\n", log: log); + Assert.Equal(new[] { ExpireResult.Due }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberGetTimeToLive_SendsMembersBlock() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberMessage(RedisCommand.SPTTL, "k", CommandFlags.None, "a", "b"); + const string RequestResp = "*6\r\n$5\r\nSPTTL\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, RequestResp, "*2\r\n:9500\r\n:-1\r\n", log: log); + Assert.Equal(new long[] { 9500, -1 }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberGetExpireDateTime_SendsMembersBlock() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberMessage(RedisCommand.SPEXPIRETIME, "k", CommandFlags.None, "a"); + const string RequestResp = "*5\r\n$12\r\nSPEXPIRETIME\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, RequestResp, "*1\r\n:4133980800000\r\n", log: log); + Assert.Equal(new[] { NextCenturyUnixSeconds * 1000 }, result); + } + + [Fact(Timeout = 1000)] + public async Task SetMemberPersist_SendsMembersBlock() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.GetSetMemberMessage(RedisCommand.SPERSIST, "k", CommandFlags.None, "a", "b"); + const string RequestResp = "*6\r\n$8\r\nSPERSIST\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.PersistResultArray, RequestResp, "*2\r\n:1\r\n:-2\r\n", log: log); + Assert.Equal(new[] { PersistResult.Success, PersistResult.NoSuchField }, result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index 99b8e544db..35f76b8724 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -464,7 +464,10 @@ public async Task SortedSetScanLarge(int pageSize) for (int i = 0; i < 2000; i++) db.SortedSetAdd(key, "z" + i, i, flags: CommandFlags.FireAndForget); - int count = db.SortedSetScan(key, pageSize: pageSize).Count(); - Assert.Equal(2000, count); + var members = db.SortedSetScan(key, pageSize: pageSize).ToList(); + + // the 8.12 btree encoding can return duplicates even for an unmodified key + // (redis/redis#16158; the SCAN contract allows it), so count distinct members + Assert.Equal(2000, members.Select(e => e.Element).Distinct().Count()); } } diff --git a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs new file mode 100644 index 0000000000..e254c6146f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs @@ -0,0 +1,297 @@ +using System; +using System.Threading.Tasks; +using StackExchange.Redis.KeyspaceIsolation; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Tests for the Redis 8.12 set-member expiration family: +/// and friends. +/// +[RunPerProtocol] +public class SetMemberExpiryTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + // see HashFieldTests: absolute expiries (the DateTime overloads) are computed against the client clock and read + // back against the server's, which can sit a few ms off on CI + private const double ClockSkewToleranceMs = 1000; + + private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly DateTime nextCentury = new(2101, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private readonly TimeSpan oneYear = TimeSpan.FromMilliseconds(31536000000); + + private readonly RedisValue[] members = ["m1", "m2"]; + + [Fact] + public void SetAddWithExpiry() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + Assert.True(db.SetAdd(key, "m1", oneYear)); + var ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])); + Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds); + + // absolute form + Assert.True(db.SetAdd(key, "m2", nextCentury)); + var expireTime = Assert.Single(db.SetMemberGetExpireDateTime(key, ["m2"])); + var expected = (nextCentury - UnixEpoch).TotalMilliseconds; + Assert.InRange(expireTime, expected - ClockSkewToleranceMs, expected + ClockSkewToleranceMs); + } + + [Fact] + public async Task SetAddWithExpiryAsync() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + + Assert.True(await db.SetAddAsync(key, members, oneYear)); + var ttls = await db.SetMemberGetTimeToLiveAsync(key, members); + Assert.All(ttls, ttl => Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds)); + } + + [Fact] + public void SetAddWithExpiryKeepTtl() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + Assert.True(db.SetAdd(key, "m1", oneYear)); + + // KEEPTTL: the existing expiration survives the re-add + Assert.False(db.SetAdd(key, "m1", Expiration.KeepTtl)); + var ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])); + Assert.True(ttl > 0); + + // the bare form discards it + Assert.True(db.SetAdd(key, "m1", Expiration.Default)); + ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])); + Assert.Equal(-1, ttl); // present, no expiration + } + + [Fact] + public void SetAddWithExpiryWhen() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + // MNX on a fresh key: all new, applies + Assert.True(db.SetAdd(key, members, oneYear, when: When.NotExists)); + + // MNX with one existing member vetoes the whole batch: nothing is added at all + Assert.False(db.SetAdd(key, [(RedisValue)"m1", "newcomer"], oneYear, when: When.NotExists)); + Assert.False(db.SetContains(key, "newcomer")); + + // MXX: all exist, applies + Assert.True(db.SetAdd(key, members, oneYear + TimeSpan.FromMinutes(1), when: When.Exists)); + + // MXX with one missing member vetoes the whole batch + Assert.False(db.SetAdd(key, [(RedisValue)"m1", "ghost"], oneYear, when: When.Exists)); + } + + [Fact] + public void SetAddWithExpiryPastDeletes() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + db.SetAdd(key, "m1"); + + // an expiration already in the past deletes the member - and that counts as a change + Assert.True(db.SetAdd(key, "m1", DateTime.UtcNow.AddMinutes(-5))); + Assert.False(db.SetContains(key, "m1")); + } + + [Fact] + public void SetMemberExpire() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, members); + + var result = db.SetMemberExpire(key, members, oneYear); + Assert.Equal([ExpireResult.Success, ExpireResult.Success], result); + + result = db.SetMemberExpire(key, members, nextCentury); + Assert.Equal([ExpireResult.Success, ExpireResult.Success], result); + } + + [Fact] + public async Task SetMemberExpireAsync() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + await db.SetAddAsync(key, members); + + var result = await db.SetMemberExpireAsync(key, members, oneYear); + Assert.Equal([ExpireResult.Success, ExpireResult.Success], result); + + result = await db.SetMemberExpireAsync(key, members, nextCentury); + Assert.Equal([ExpireResult.Success, ExpireResult.Success], result); + } + + [Fact] + public void SetMemberExpireNoKey() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + var result = db.SetMemberExpire(key, members, oneYear); + Assert.Equal([ExpireResult.NoSuchField, ExpireResult.NoSuchField], result); + } + + [Fact] + public void SetMemberExpireMissingMember() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, "m1"); + + var result = db.SetMemberExpire(key, [(RedisValue)"m1", "ghost"], oneYear); + Assert.Equal([ExpireResult.Success, ExpireResult.NoSuchField], result); + } + + [Fact] + public void SetMemberExpireConditions() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, members); + + // NX: only when the member has no expiry + var result = db.SetMemberExpire(key, ["m1"], oneYear, ExpireWhen.HasNoExpiry); + Assert.Equal([ExpireResult.Success], result); + result = db.SetMemberExpire(key, ["m1"], oneYear, ExpireWhen.HasNoExpiry); + Assert.Equal([ExpireResult.ConditionNotMet], result); + + // GT: a shorter expiry loses + result = db.SetMemberExpire(key, ["m1"], TimeSpan.FromMinutes(5), ExpireWhen.GreaterThanCurrentExpiry); + Assert.Equal([ExpireResult.ConditionNotMet], result); + + // GT vs no expiry: no expiry counts as infinite, so GT loses + result = db.SetMemberExpire(key, ["m2"], oneYear, ExpireWhen.GreaterThanCurrentExpiry); + Assert.Equal([ExpireResult.ConditionNotMet], result); + + // LT against the pinned member wins + result = db.SetMemberExpire(key, ["m2"], oneYear, ExpireWhen.LessThanCurrentExpiry); + Assert.Equal([ExpireResult.Success], result); + } + + [Fact] + public void SetMemberExpirePastDeletes() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, "m1"); + + var result = db.SetMemberExpire(key, ["m1"], DateTime.UtcNow.AddMinutes(-5)); + Assert.Equal([ExpireResult.Due], result); + Assert.False(db.SetContains(key, "m1")); + } + + [Fact] + public void SetMemberGetTimeToLive() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, members); + db.SetMemberExpire(key, ["m1"], oneYear); + + var result = db.SetMemberGetTimeToLive(key, [(RedisValue)"m1", "m2", "ghost"]); + Assert.Equal(3, result.Length); + Assert.InRange(result[0], oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds); + Assert.Equal(-1, result[1]); // present, no expiration + Assert.Equal(-2, result[2]); // no such member + } + + [Fact] + public async Task SetMemberGetTimeToLiveAsync() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + await db.SetAddAsync(key, "m1"); + await db.SetMemberExpireAsync(key, ["m1"], oneYear); + + var ttl = Assert.Single(await db.SetMemberGetTimeToLiveAsync(key, ["m1"])); + Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds); + } + + [Fact] + public void SetMemberGetExpireDateTime() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, members); + db.SetMemberExpire(key, ["m1"], nextCentury); + + var result = db.SetMemberGetExpireDateTime(key, [(RedisValue)"m1", "m2", "ghost"]); + Assert.Equal(3, result.Length); + var expected = (nextCentury - UnixEpoch).TotalMilliseconds; + Assert.InRange(result[0], expected - ClockSkewToleranceMs, expected + ClockSkewToleranceMs); + Assert.Equal(-1, result[1]); + Assert.Equal(-2, result[2]); + } + + [Fact] + public void SetMemberPersist() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SetAdd(key, members); + db.SetMemberExpire(key, ["m1"], oneYear); + + var result = db.SetMemberPersist(key, [(RedisValue)"m1", "m2", "ghost"]); + Assert.Equal([PersistResult.Success, PersistResult.ConditionNotMet, PersistResult.NoSuchField], result); + + // pinned, not permanent: the member is still an ordinary member + Assert.Equal(-1, Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"]))); + Assert.True(db.SetRemove(key, "m1")); + } + + [Fact] + public async Task SetMemberPersistAsync() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + await db.KeyDeleteAsync(key, CommandFlags.FireAndForget); + await db.SetAddAsync(key, "m1"); + await db.SetMemberExpireAsync(key, ["m1"], oneYear); + + var result = await db.SetMemberPersistAsync(key, ["m1"]); + Assert.Equal([PersistResult.Success], result); + } + + [Fact] + public void SetMemberExpiryWithKeyPrefix() + { + var conn = Create(require: RedisFeatures.v8_12_0); + var key = Me(); + var raw = conn.GetDatabase(); + var prefixed = raw.WithKeyPrefix("prefix:"); + raw.KeyDelete([(RedisKey)("prefix:" + key), key], CommandFlags.FireAndForget); + + Assert.True(prefixed.SetAdd(key, "m1", oneYear)); + var ttl = Assert.Single(prefixed.SetMemberGetTimeToLive(key, ["m1"])); + Assert.True(ttl > 0); + + // the write really landed on the prefixed key + Assert.True(raw.SetContains("prefix:" + key, "m1")); + Assert.False(raw.KeyExists(key)); + } +}