From 34828353870ad8537423d43589ba53b451686f39 Mon Sep 17 00:00:00 2001 From: mgravell Date: Fri, 6 Nov 2026 21:52:53 +0000 Subject: [PATCH 1/3] Initial support for Redis 8.12 set-member expiration The hash-field-TTL idea (7.4's HEXPIRE) brought to sets (redis/redis#15938, 8.12-rc2), mirroring the HashField* family: - SetAddAndSetExpiry (SADDEX): add members and/or (re)set their expiration in one atomic call; When.NotExists/Exists map to MNX/MXX, which are all-or-nothing across the batch, exactly as HSETEX's FNX/FXX; the bare form discards an existing TTL, KEEPTTL keeps it - SetMemberExpire (SEXPIRE/SPEXPIRE/SEXPIREAT/SPEXPIREAT by precision) -> ExpireResult[], per member - SetMemberGetTimeToLive (SPTTL) and SetMemberGetExpireDateTime (SPEXPIRETIME) -> long[] - SetMemberPersist (SPERSIST) -> PersistResult[] - KeyNotificationType.SExpired ('sexpired', side-effect block), so the 8.12 notification - including on the subkey channels, whose payload names the lapsed member - parses as a known type; GetSubKeys() already handles the payload Gated [Experimental] behind SER011/Experiments.Server_8_12 and RedisFeatures.v8_12_0 (8.11.241), both shared with #3261/#3315; the test docker image moves to client-libs-test:8.12-rc2 as in those PRs. Round-trip tests pin the exact wire bytes; the live tests (HashFieldTests mirror) pass against the 8.12-rc2 topology on both protocols. Plan document in notes/set-member-expiry/. --- docs/CommandCoverage.md | 10 +- docs/exp/SER011.md | 4 + notes/set-member-expiry/plan.md | 290 ++++++++++++++++++ src/StackExchange.Redis/Enums/RedisCommand.cs | 16 + .../Interfaces/IDatabase.cs | 217 +++++++++++++ .../Interfaces/IDatabaseAsync.cs | 40 +++ .../KeyNotificationType.cs | 2 + .../KeyspaceIsolation/KeyPrefixed.cs | 27 ++ .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 27 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 20 ++ src/StackExchange.Redis/RedisDatabase.cs | 208 +++++++++++++ src/StackExchange.Redis/RedisFeatures.cs | 5 + src/StackExchange.Redis/RedisLiterals.cs | 3 + .../KeyNotificationUnitTests.cs | 27 ++ .../SetMemberExpiryRoundTrip.cs | 196 ++++++++++++ .../SetMemberExpiryTests.cs | 280 +++++++++++++++++ 16 files changed, 1363 insertions(+), 9 deletions(-) create mode 100644 notes/set-member-expiry/plan.md create mode 100644 tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs create mode 100644 tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md index 969ee7f76..1a108f416 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. | @@ -162,15 +163,6 @@ PRs welcome; follow the `implement-resp-command` skill. Pre-release server featu | `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). | | `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 index a339d6513..e127d8399 100644 --- a/docs/exp/SER011.md +++ b/docs/exp/SER011.md @@ -4,6 +4,10 @@ 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.SetAddAndSetExpiry`, `SetMemberExpire`, + `SetMemberGetTimeToLive`, `SetMemberGetExpireDateTime` and `SetMemberPersist` (and async + equivalents) The corresponding library features must also be considered subject to change: diff --git a/notes/set-member-expiry/plan.md b/notes/set-member-expiry/plan.md new file mode 100644 index 000000000..c8aabaeb5 --- /dev/null +++ b/notes/set-member-expiry/plan.md @@ -0,0 +1,290 @@ +--- +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 + +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/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index d0b1639b7..d2ccd9c0f 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -194,6 +194,7 @@ internal enum RedisCommand RPUSHX, SADD, + SADDEX, SAVE, SCAN, SCARD, @@ -208,6 +209,8 @@ internal enum RedisCommand SETEX, SETNX, SETRANGE, + SEXPIRE, + SEXPIREAT, SHUTDOWN, SINTER, SINTERCARD, @@ -221,7 +224,12 @@ internal enum RedisCommand SORT, [AsciiHash("SORT_RO")] SORT_RO, + SPERSIST, + SPEXPIRE, + SPEXPIREAT, + SPEXPIRETIME, SPOP, + SPTTL, SPUBLISH, SRANDMEMBER, SREM, @@ -417,14 +425,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: @@ -556,6 +570,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 735e585d7..df3eac487 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1770,6 +1770,74 @@ 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. + /// 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. (without ) sends the bare form, + /// which discards any expiration the member already has. + /// to send KEEPTTL: an existing expiration is kept; a new member gets none. + /// 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 SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + /// Adds the specified member to the set stored at key and/or sets its per-member expiration to an absolute time, in one atomic call. + /// 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 exact expiry time to set on the member. + /// + /// The flags to use for this operation. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime 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. + /// 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. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, 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 to an absolute time, in one atomic call. + /// 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 exact expiry time to set on the members. + /// + /// The flags to use for this operation. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime 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 +1983,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. /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index b7eaac386..7758d9302 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -434,6 +434,24 @@ 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime 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 +479,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); diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index 9d2a4dfb8..0c2e296a1 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -158,5 +158,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 5c6c405d1..6845d35ca 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -496,6 +496,18 @@ 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiryAsync(ToInner(key), value, expiry, keepTtl, when, flags); + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiryAsync(ToInner(key), value, expiry, when, flags); + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiryAsync(ToInner(key), values, expiry, keepTtl, when, flags); + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiryAsync(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 +535,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); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 10a411184..30836ed20 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -465,6 +465,18 @@ 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 SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiry(ToInner(key), value, expiry, keepTtl, when, flags); + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiry(ToInner(key), value, expiry, when, flags); + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiry(ToInner(key), values, expiry, keepTtl, when, flags); + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SetAddAndSetExpiry(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 +504,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); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6b1aeda87..a8a287b44 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -2,3 +2,23 @@ [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.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.DateTime expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.DateTime 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.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.DateTime 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.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.DateTime 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 diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index dddc6776e..9c631aee2 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2269,6 +2269,214 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl return ExecuteAsync(msg, ResultProcessor.Int64); } + public bool SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddAndSetExpiryMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddAndSetExpiryMessage(key, value, new(expiry), when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddAndSetExpiryMessage(key, values, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddAndSetExpiryMessage(key, values, new(expiry), when, flags); + return ExecuteSync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddAndSetExpiryMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = SetAddAndSetExpiryMessage(key, value, new(expiry), when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddAndSetExpiryMessage(key, values, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + var msg = SetAddAndSetExpiryMessage(key, values, new(expiry), when, flags); + return ExecuteAsync(msg, ResultProcessor.Boolean); + } + + internal Message SetAddAndSetExpiryMessage(in RedisKey key, in RedisValue value, Expiration expiry, When when, CommandFlags flags) + { + 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 SetAddAndSetExpiryMessage(in RedisKey key, RedisValue[] values, Expiration expiry, When when, CommandFlags flags) + { + if (values.Length == 1) + { + return SetAddAndSetExpiryMessage(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); diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index ca16c8bcf..e3aea889c 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -328,6 +328,11 @@ public RedisFeatures(Version version) /// 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); + #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 0f84f60a9..657273cc8 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -83,9 +83,12 @@ 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), diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index d288653d7..e9599490b 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/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs new file mode 100644 index 000000000..d2ef8f2e6 --- /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 SetAddAndSetExpiry_Ex() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_Px() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_KeepTtl() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_Bare_DiscardsTtl() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_ExAt() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_Mnx() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_Mxx() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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 SetAddAndSetExpiry_SingleElementArray_SameAsSingle() + { + var db = new RedisDatabase(null!, 0, null); + var msg = db.SetAddAndSetExpiryMessage("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/SetMemberExpiryTests.cs b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs new file mode 100644 index 000000000..5118ac8a1 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs @@ -0,0 +1,280 @@ +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 SetAddAndSetExpiry() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + + Assert.True(db.SetAddAndSetExpiry(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.SetAddAndSetExpiry(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 SetAddAndSetExpiryAsync() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + + Assert.True(await db.SetAddAndSetExpiryAsync(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 SetAddAndSetExpiryKeepTtl() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + + Assert.True(db.SetAddAndSetExpiry(key, "m1", oneYear)); + + // KEEPTTL: the existing expiration survives the re-add + Assert.False(db.SetAddAndSetExpiry(key, "m1", expiry: null, keepTtl: true)); + var ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])); + Assert.True(ttl > 0); + + // the bare form discards it + Assert.True(db.SetAddAndSetExpiry(key, "m1", expiry: null)); + ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])); + Assert.Equal(-1, ttl); // present, no expiration + } + + [Fact] + public void SetAddAndSetExpiryWhen() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + + // MNX on a fresh key: all new, applies + Assert.True(db.SetAddAndSetExpiry(key, members, oneYear, when: When.NotExists)); + + // MNX with one existing member vetoes the whole batch: nothing is added at all + Assert.False(db.SetAddAndSetExpiry(key, [(RedisValue)"m1", "newcomer"], oneYear, when: When.NotExists)); + Assert.False(db.SetContains(key, "newcomer")); + + // MXX: all exist, applies + Assert.True(db.SetAddAndSetExpiry(key, members, oneYear + TimeSpan.FromMinutes(1), when: When.Exists)); + + // MXX with one missing member vetoes the whole batch + Assert.False(db.SetAddAndSetExpiry(key, [(RedisValue)"m1", "ghost"], oneYear, when: When.Exists)); + } + + [Fact] + public void SetAddAndSetExpiryPastDeletes() + { + var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); + var key = Me(); + + db.SetAdd(key, "m1"); + + // an expiration already in the past deletes the member - and that counts as a change + Assert.True(db.SetAddAndSetExpiry(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.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.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(); + + 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.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.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.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.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.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.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.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.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 prefixed = conn.GetDatabase().WithKeyPrefix("prefix:"); + + Assert.True(prefixed.SetAddAndSetExpiry(key, "m1", oneYear)); + var ttl = Assert.Single(prefixed.SetMemberGetTimeToLive(key, ["m1"])); + Assert.True(ttl > 0); + + // the write really landed on the prefixed key + var raw = conn.GetDatabase(); + Assert.True(raw.SetContains("prefix:" + key, "m1")); + Assert.False(raw.KeyExists(key)); + } +} From d9ab2ba223eb3af4f034ec41eacc6313a6c88e40 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 9 Nov 2026 11:32:03 +0000 Subject: [PATCH 2/3] SADDEX rides SetAdd: two Expiration overloads replace the four-per-arity SetAddAndSetExpiry surface Review feedback (see the PR): Expiration already encodes EX/PX/EXAT/PXAT/KEEPTTL - exactly SADDEX's expiration block - so the TimeSpan?/DateTime/keepTtl splits collapse and the surface is SetAdd(key, value, Expiration, When, flags) and SetAdd(key, values, Expiration, When, flags), both bool (+async): 8 members become 4, with no new method name. A required Expiration keeps overload resolution away from the existing SetAdd members. - Expiration.Persist is rejected (SADDEX has no PERSIST; SetMemberPersist is the way to remove member expirations) - previously it would have been sent as a bogus token. - The overloads always send SADDEX: plain SADD is not an equivalent fallback for the no-expiry form, because bare SADDEX discards an existing member's TTL where SADD leaves existing members untouched; the XML docs call this out, and that the multi-value form reports only whether anything changed, not a count. - The SetMemberExpire/GetTimeToLive/GetExpireDateTime/Persist family is unchanged (ExpireWhen's GT/LT cannot ride Expiration, and it mirrors the HashField shape). - plan.md carries a revision note; SER011.md updated; all 50 SetMemberExpiry tests pass live against 8.12-rc2, 525 KeyPrefixed tests pass. --- docs/exp/SER011.md | 2 +- notes/set-member-expiry/plan.md | 11 ++++ .../Interfaces/IDatabase.cs | 58 +++++-------------- .../Interfaces/IDatabaseAsync.cs | 16 ++--- .../KeyspaceIsolation/KeyPrefixed.cs | 14 ++--- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 14 ++--- .../PublicAPI/PublicAPI.Unshipped.txt | 12 ++-- src/StackExchange.Redis/RedisDatabase.cs | 50 +++++----------- .../SetMemberExpiryRoundTrip.cs | 32 +++++----- .../SetMemberExpiryTests.cs | 34 +++++------ 10 files changed, 90 insertions(+), 153 deletions(-) diff --git a/docs/exp/SER011.md b/docs/exp/SER011.md index e127d8399..87930ba84 100644 --- a/docs/exp/SER011.md +++ b/docs/exp/SER011.md @@ -5,7 +5,7 @@ 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.SetAddAndSetExpiry`, `SetMemberExpire`, + `SPEXPIRETIME`, `SPERSIST`), surfaced as `IDatabase.SetAdd` (the `Expiration` overloads), `SetMemberExpire`, `SetMemberGetTimeToLive`, `SetMemberGetExpireDateTime` and `SetMemberPersist` (and async equivalents) diff --git a/notes/set-member-expiry/plan.md b/notes/set-member-expiry/plan.md index c8aabaeb5..57dd4887e 100644 --- a/notes/set-member-expiry/plan.md +++ b/notes/set-member-expiry/plan.md @@ -17,6 +17,17 @@ 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 diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index df3eac487..1d56c2bc8 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1771,14 +1771,16 @@ 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. - /// If key does not exist, a new set is created first. + /// 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. (without ) sends the bare form, - /// which discards any expiration the member already has. - /// to send KEEPTTL: an existing expiration is kept; a new member gets none. + /// 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 @@ -1792,50 +1794,22 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] #pragma warning disable RS0026 // similar overloads - bool SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); /// - /// Adds the specified member to the set stored at key and/or sets its per-member expiration to an absolute time, in one atomic call. - /// 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 exact expiry time to set on the member. - /// - /// The flags to use for this operation. - /// - /// - [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] - bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime 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. - /// 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. - /// - /// - [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] - bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, 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 to an absolute time, in one atomic call. - /// If key does not exist, a new set is created first. + /// 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 exact expiry time to set on the members. - /// + /// + /// /// 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 SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); #pragma warning restore RS0026 /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 7758d9302..fc6c36899 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -434,22 +434,14 @@ 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); + 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); - - /// - [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] - Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None); - - /// - [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] - Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); + Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None); #pragma warning restore RS0026 /// diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 6845d35ca..4f7a052b1 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -496,17 +496,11 @@ 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiryAsync(ToInner(key), value, expiry, keepTtl, when, 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 SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiryAsync(ToInner(key), value, expiry, when, flags); - - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiryAsync(ToInner(key), values, expiry, keepTtl, when, flags); - - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiryAsync(ToInner(key), values, 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); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 30836ed20..ae8a36b51 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -465,17 +465,11 @@ 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 SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiry(ToInner(key), value, expiry, keepTtl, when, 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 SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiry(ToInner(key), value, expiry, when, flags); - - public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiry(ToInner(key), values, expiry, keepTtl, when, flags); - - public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) => - Inner.SetAddAndSetExpiry(ToInner(key), values, 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); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index a8a287b44..cb7e15b0a 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -2,19 +2,15 @@ [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.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.DateTime expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER011]StackExchange.Redis.IDatabase.SetAddAndSetExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.DateTime 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 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.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, System.DateTime 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.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, System.DateTime 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 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! diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 9c631aee2..cf484d97e 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2269,60 +2269,36 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl return ExecuteAsync(msg, ResultProcessor.Int64); } - public bool SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + public bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = SetAddAndSetExpiryMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags); return ExecuteSync(msg, ResultProcessor.Boolean); } - public bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - { - var msg = SetAddAndSetExpiryMessage(key, value, new(expiry), when, flags); - return ExecuteSync(msg, ResultProcessor.Boolean); - } - - public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - { - if (values == null) throw new ArgumentNullException(nameof(values)); - var msg = SetAddAndSetExpiryMessage(key, values, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); - return ExecuteSync(msg, ResultProcessor.Boolean); - } - - public bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + 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 = SetAddAndSetExpiryMessage(key, values, new(expiry), when, flags); + var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags); return ExecuteSync(msg, ResultProcessor.Boolean); } - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + public Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) { - var msg = SetAddAndSetExpiryMessage(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); - return ExecuteAsync(msg, ResultProcessor.Boolean); - } - - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - { - var msg = SetAddAndSetExpiryMessage(key, value, new(expiry), when, flags); - return ExecuteAsync(msg, ResultProcessor.Boolean); - } - - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - { - if (values == null) throw new ArgumentNullException(nameof(values)); - var msg = SetAddAndSetExpiryMessage(key, values, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags); return ExecuteAsync(msg, ResultProcessor.Boolean); } - public Task SetAddAndSetExpiryAsync(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + 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 = SetAddAndSetExpiryMessage(key, values, new(expiry), when, flags); + var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags); return ExecuteAsync(msg, ResultProcessor.Boolean); } - internal Message SetAddAndSetExpiryMessage(in RedisKey key, in RedisValue value, Expiration expiry, When when, CommandFlags flags) + 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) { @@ -2352,11 +2328,11 @@ internal Message SetAddAndSetExpiryMessage(in RedisKey key, in RedisValue value, } } - internal Message SetAddAndSetExpiryMessage(in RedisKey key, RedisValue[] values, Expiration expiry, When when, CommandFlags flags) + internal Message SetAddWithExpiryMessage(in RedisKey key, RedisValue[] values, Expiration expiry, When when, CommandFlags flags) { if (values.Length == 1) { - return SetAddAndSetExpiryMessage(key, values[0], expiry, when, flags); + return SetAddWithExpiryMessage(key, values[0], expiry, when, flags); } // Determine the base array size int expiryTokenCount = expiry.GetTokenCount(allowEnx: false); diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs index d2ef8f2e6..6f2817246 100644 --- a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs @@ -16,10 +16,10 @@ public class SetMemberExpiryRoundTrip(ITestOutputHelper log) private const long NextCenturyUnixSeconds = 4133980800; [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_Ex() + public async Task SetAddWithExpiry_Ex() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None); + 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); @@ -27,10 +27,10 @@ public async Task SetAddAndSetExpiry_Ex() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_Px() + public async Task SetAddWithExpiry_Px() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromMilliseconds(1500), keepTtl: false), When.Always, CommandFlags.None); + 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); @@ -38,10 +38,10 @@ public async Task SetAddAndSetExpiry_Px() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_KeepTtl() + public async Task SetAddWithExpiry_KeepTtl() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: true), When.Always, CommandFlags.None); + 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); @@ -49,10 +49,10 @@ public async Task SetAddAndSetExpiry_KeepTtl() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_Bare_DiscardsTtl() + public async Task SetAddWithExpiry_Bare_DiscardsTtl() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: false), When.Always, CommandFlags.None); + 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); @@ -60,10 +60,10 @@ public async Task SetAddAndSetExpiry_Bare_DiscardsTtl() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_ExAt() + public async Task SetAddWithExpiry_ExAt() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", "m", new Expiration(NextCentury), When.Always, CommandFlags.None); + 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); @@ -71,10 +71,10 @@ public async Task SetAddAndSetExpiry_ExAt() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_Mnx() + public async Task SetAddWithExpiry_Mnx() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", new RedisValue[] { "a", "b" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.NotExists, CommandFlags.None); + 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 @@ -83,10 +83,10 @@ public async Task SetAddAndSetExpiry_Mnx() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_Mxx() + public async Task SetAddWithExpiry_Mxx() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", new RedisValue[] { "a", "b" }, new Expiration(NextCentury), When.Exists, CommandFlags.None); + 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); @@ -94,10 +94,10 @@ public async Task SetAddAndSetExpiry_Mxx() } [Fact(Timeout = 1000)] - public async Task SetAddAndSetExpiry_SingleElementArray_SameAsSingle() + public async Task SetAddWithExpiry_SingleElementArray_SameAsSingle() { var db = new RedisDatabase(null!, 0, null); - var msg = db.SetAddAndSetExpiryMessage("k", new RedisValue[] { "m" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None); + 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); diff --git a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs index 5118ac8a1..cfc174ca7 100644 --- a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs +++ b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs @@ -24,74 +24,74 @@ public class SetMemberExpiryTests(ITestOutputHelper output, SharedConnectionFixt private readonly RedisValue[] members = ["m1", "m2"]; [Fact] - public void SetAddAndSetExpiry() + public void SetAddWithExpiry() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); - Assert.True(db.SetAddAndSetExpiry(key, "m1", oneYear)); + 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.SetAddAndSetExpiry(key, "m2", nextCentury)); + 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 SetAddAndSetExpiryAsync() + public async Task SetAddWithExpiryAsync() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); - Assert.True(await db.SetAddAndSetExpiryAsync(key, members, oneYear)); + 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 SetAddAndSetExpiryKeepTtl() + public void SetAddWithExpiryKeepTtl() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); - Assert.True(db.SetAddAndSetExpiry(key, "m1", oneYear)); + Assert.True(db.SetAdd(key, "m1", oneYear)); // KEEPTTL: the existing expiration survives the re-add - Assert.False(db.SetAddAndSetExpiry(key, "m1", expiry: null, keepTtl: true)); + 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.SetAddAndSetExpiry(key, "m1", expiry: null)); + 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 SetAddAndSetExpiryWhen() + public void SetAddWithExpiryWhen() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); // MNX on a fresh key: all new, applies - Assert.True(db.SetAddAndSetExpiry(key, members, oneYear, when: When.NotExists)); + 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.SetAddAndSetExpiry(key, [(RedisValue)"m1", "newcomer"], oneYear, when: When.NotExists)); + 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.SetAddAndSetExpiry(key, members, oneYear + TimeSpan.FromMinutes(1), when: When.Exists)); + 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.SetAddAndSetExpiry(key, [(RedisValue)"m1", "ghost"], oneYear, when: When.Exists)); + Assert.False(db.SetAdd(key, [(RedisValue)"m1", "ghost"], oneYear, when: When.Exists)); } [Fact] - public void SetAddAndSetExpiryPastDeletes() + public void SetAddWithExpiryPastDeletes() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); @@ -99,7 +99,7 @@ public void SetAddAndSetExpiryPastDeletes() db.SetAdd(key, "m1"); // an expiration already in the past deletes the member - and that counts as a change - Assert.True(db.SetAddAndSetExpiry(key, "m1", DateTime.UtcNow.AddMinutes(-5))); + Assert.True(db.SetAdd(key, "m1", DateTime.UtcNow.AddMinutes(-5))); Assert.False(db.SetContains(key, "m1")); } @@ -268,7 +268,7 @@ public void SetMemberExpiryWithKeyPrefix() var key = Me(); var prefixed = conn.GetDatabase().WithKeyPrefix("prefix:"); - Assert.True(prefixed.SetAddAndSetExpiry(key, "m1", oneYear)); + Assert.True(prefixed.SetAdd(key, "m1", oneYear)); var ttl = Assert.Single(prefixed.SetMemberGetTimeToLive(key, ["m1"])); Assert.True(ttl > 0); From d10a538b74eabd4c7092b683cd8891888ce22d4e Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 9 Nov 2026 13:16:33 +0000 Subject: [PATCH 3/3] Each set-member-expiry test deletes its key up front: SADDEX's changed-anything reply and the MNX/NX conditions make residue from a previous run (or the other protocol) look like a failure --- .../SetMemberExpiryTests.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs index cfc174ca7..e254c6146 100644 --- a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs +++ b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs @@ -28,6 +28,7 @@ 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"])); @@ -45,6 +46,7 @@ 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); @@ -56,6 +58,7 @@ 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)); @@ -75,6 +78,7 @@ 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)); @@ -95,6 +99,7 @@ public void SetAddWithExpiryPastDeletes() { var db = Create(require: RedisFeatures.v8_12_0).GetDatabase(); var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); db.SetAdd(key, "m1"); @@ -108,6 +113,7 @@ 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); @@ -122,6 +128,7 @@ 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); @@ -136,6 +143,7 @@ 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); @@ -146,6 +154,7 @@ 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); @@ -157,6 +166,7 @@ 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 @@ -183,6 +193,7 @@ 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)); @@ -195,6 +206,7 @@ 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); @@ -210,6 +222,7 @@ 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); @@ -222,6 +235,7 @@ 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); @@ -238,6 +252,7 @@ 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); @@ -254,6 +269,7 @@ 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); @@ -266,14 +282,15 @@ public void SetMemberExpiryWithKeyPrefix() { var conn = Create(require: RedisFeatures.v8_12_0); var key = Me(); - var prefixed = conn.GetDatabase().WithKeyPrefix("prefix:"); + 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 - var raw = conn.GetDatabase(); Assert.True(raw.SetContains("prefix:" + key, "m1")); Assert.False(raw.KeyExists(key)); }