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..87930ba84 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.SetAdd` (the `Expiration` overloads), `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..57dd4887e
--- /dev/null
+++ b/notes/set-member-expiry/plan.md
@@ -0,0 +1,301 @@
+---
+feature: set-member-expiry
+client: se-redis
+hld: {path: none, sha: none}
+tracks: [redis/redis#15938]
+target_version: "8.12"
+decision_class: C
+conventions:
+ - .claude/skills/implement-resp-command/SKILL.md#Steps
+ - .claude/skills/implement-resp-command/SKILL.md#Tests - the two layers that matter
+ - AGENTS.md#Public API tracking (important — easy to trip over)
+estimated_size: medium
+integration_targets: []
+unit_targets: [RoundTripUnitTests.SetMemberExpiryRoundTrip, ResultProcessorUnitTests.BasicAggregates, KeyNotificationUnitTests]
+open_questions: 6
+---
+
+# Set-member expiration: StackExchange.Redis implementation plan
+
+> **Revised in review (PR #3340):** the add-with-expiry surface shipped as **`SetAdd` overloads taking
+> `Expiration`** — `SetAdd(key, value, Expiration, When, flags)` and `SetAdd(key, values, Expiration, When, flags)`,
+> both returning `bool` (+ async) — not the four-per-arity `SetAddAndSetExpiry` shape planned below.
+> `Expiration` already encodes `EX`/`PX`/`EXAT`/`PXAT`/`KEEPTTL`, which is exactly `SADDEX`'s expiration block,
+> so the `TimeSpan?`/`DateTime`/`keepTtl` splits collapse; `Expiration.Persist` is rejected
+> (`SADDEX` has no `PERSIST`; that is `SetMemberPersist`'s job). The overloads always send `SADDEX` -
+> plain `SADD` is not an equivalent fallback, because the bare `SADDEX` form discards an existing member's
+> TTL where `SADD` leaves existing members untouched (R.2). Everything below the surface - the message
+> shapes, requirement ids, tests and open questions - is unchanged; read `SetAddAndSetExpiry` as
+> `SetAdd(..., Expiration, ...)` (internally `SetAddWithExpiryMessage`).
+
+No shared client HLD exists for this feature. The server facts below come from redis/redis#15938
+("Set member expiration", merged to `unstable` as `a76a3689a`, shipped in 8.12-rc2, which reports
+`redis_version:8.11.241`), from the command JSONs at that commit
+(`src/commands/saddex.json`, `sexpire.json` and family), and from direct observation against an
+8.12-rc2 build (the `setexpiry` demo in the demos repo). Requirement ids `R.x` are assigned here.
+
+## 1. Summary
+
+Callers get per-member expiration on sets — the hash-field-TTL idea (7.4's `HEXPIRE`) brought to
+sets — as a mirror of the existing `HashField*` family:
+
+- `SetAddAndSetExpiry` (`SADDEX`): add members and/or (re)set their expiration in one atomic call,
+ with `MNX`/`MXX` batch conditions and `KEEPTTL`;
+- `SetMemberExpire` (`SEXPIRE`/`SPEXPIRE`/`SEXPIREAT`/`SPEXPIREAT`, picked by precision) returning
+ `ExpireResult[]`;
+- `SetMemberGetTimeToLive` (`SPTTL`) and `SetMemberGetExpireDateTime` (`SPEXPIRETIME`) returning
+ `long[]`;
+- `SetMemberPersist` (`SPERSIST`) returning `PersistResult[]`;
+- `KeyNotificationType.SExpired` so the 8.12 `sexpired` notification (including on the new subkey
+ channels, where the payload names the lapsed member) parses as a known type rather than
+ `Unknown`. **No new payload parsing is needed**: `KeyNotification.GetSubKeys()` on `main`
+ already parses every subkey channel kind, including the length-prefixed member list.
+
+Decision class **C** (new core commands). Analogue traced end to end: the hash-field expiry
+family — `IDatabase.HashFieldExpire` (both overloads), `HashFieldGetTimeToLive`,
+`HashFieldGetExpireDateTime`, `HashFieldPersist` and their message plumbing
+(`RedisDatabase.HashFieldExpireExecute`, `GetHashFieldExpireMessage`,
+`PickExpireCommandByPrecision`/`PickExpireAtCommandByPrecision`, `HashFieldExecute`), the
+processors (`ResultProcessor.ExpireResultArray`, `PersistResultArray`, `Int64Array`), the
+isolation forwards in `KeyPrefixedDatabase`/`KeyPrefixed`, and for `SADDEX` the `HSETEX` binding
+(`HashFieldSetAndSetExpiry` + `HashFieldSetAndSetExpiryMessage`, whose `FNX`/`FXX` have exactly
+the same all-or-nothing batch semantics as `MNX`/`MXX`). Version gating follows
+`RedisFeatures.HashImport`; experimental gating follows the `SER011`/`Experiments.Server_8_12`
+gate that #3261 (BLESS, also 8.12) introduces.
+
+`estimated_size: medium`: eighteen interface members (nine sync + nine async), one shared
+execute/message-builder group, eighteen isolation forwards, eight enum members plus their
+`IsPrimaryOnly` cases, three literals, a version constant and property, the experimental gate,
+public-API lines, one notification enum member, one round-trip test class, one live test class,
+notification test rows, and coverage-ledger edits. No new result processors, no new `APITypes/`.
+
+## 2. Requirement coverage
+
+| Id | Server fact | Where (file, symbol) | Proving test | Note |
+|---|---|---|---|---|
+| R.1 | `SADDEX key [MNX\|MXX] [EX s\|PX ms\|EXAT ts\|PXAT ts\|KEEPTTL] MEMBERS n member...` (arity -5, `WRITE DENYOOM FAST`); reply 0 = nothing changed / condition not met, 1 = at least one member added, updated or deleted | `IDatabase.SetAddAndSetExpiry` (4 overloads), `RedisDatabase.SetAddAndSetExpiryMessage` | `SetMemberExpiryRoundTrip.SetAddAndSetExpiry_*`; live `SetMemberExpiryTests.SetAddAndSetExpiry*` | Returns `bool` (see open question 3) |
+| R.2 | `SADDEX` without an expiry token **discards** an existing member's TTL; `KEEPTTL` keeps it (t_set.c: "without KEEPTTL, its expiration is discarded") | `Expiration.CreateOrKeepTtl(expiry, keepTtl)`, as `HashFieldSetAndSetExpiry` | `SetAddAndSetExpiry_NoExpiry_SendsBareForm`, `KeepTtl_SendsToken`; live `SetAddAndSetExpiryKeepTtl` | XML docs call the discard out |
+| R.3 | `MNX` = only if **none** of the members exist; `MXX` = only if **all** exist; all-or-nothing across the batch (one offender vetoes the whole call, reply 0) | `When.NotExists` → `MNX`, `When.Exists` → `MXX` in `SetAddAndSetExpiryMessage` | `SetAddAndSetExpiry_Mnx_SendsToken` / `_Mxx_SendsToken`; live `SetAddAndSetExpiryWhen` | Mirrors `HSETEX` `FNX`/`FXX` exactly (see open question 2) |
+| R.4 | `SEXPIRE`/`SPEXPIRE`/`SEXPIREAT`/`SPEXPIREAT key t [NX\|XX\|GT\|LT] MEMBERS n member...` (arity -6, `WRITE FAST`); per-member reply codes, in order: -2 no such member, 0 condition not met, 1 set, 2 deleted (expiry already past) | `IDatabase.SetMemberExpire` (TimeSpan + DateTime), `GetSetMemberExpireMessage`, `PickSetMemberExpireCommandByPrecision`/`PickSetMemberExpireAtCommandByPrecision` | `SetMemberExpire_*` round-trips; live `SetMemberExpire`, `SetMemberExpireConditions`, `SetMemberExpireNoKey` | Reply codes match `ExpireResult` (2/1/0/-2) member for member |
+| R.5 | Precision: seconds-granular input uses `SEXPIRE`/`SEXPIREAT`, else `SPEXPIRE`/`SPEXPIREAT` | the pick functions, as `PickExpireCommandByPrecision` | `SetMemberExpire_SecondsPrecision` vs `_MillisecondsPrecision` round-trips | Same `milliseconds % 1000` rule as hash |
+| R.6 | `GT` loses to a member with **no** expiry (no expiry counts as infinite → reply 0); `NX`/`XX`/`GT`/`LT` conditions are per member | `ExpireWhen` passed through, `when.ToLiteral()` | live `SetMemberExpireConditions` (`GT` vs persisted member, observed `[0]`) | Same semantics as key-level `EXPIRE` and `HEXPIRE` |
+| R.7 | `SPTTL key MEMBERS n member...` (`READONLY FAST`): per-member TTL ms; -1 no expiry, -2 no such member | `IDatabase.SetMemberGetTimeToLive` → `SetMemberExecute(RedisCommand.SPTTL, ...)`, `ResultProcessor.Int64Array` | `SetMemberGetTimeToLive_SendsMembersBlock`; live `SetMemberGetTimeToLive` | Replica-eligible in `IsPrimaryOnly` |
+| R.8 | `SPEXPIRETIME key MEMBERS n member...` (`READONLY FAST`): per-member absolute expiry, Unix ms; -1/-2 as above | `IDatabase.SetMemberGetExpireDateTime` → `SetMemberExecute(RedisCommand.SPEXPIRETIME, ...)` | round-trip + live `SetMemberGetExpireDateTime` | Replica-eligible |
+| R.9 | `SPERSIST key MEMBERS n member...` (`WRITE FAST`): per member 1 cleared, -1 had none, -2 no such member | `IDatabase.SetMemberPersist` → `SetMemberExecute(RedisCommand.SPERSIST, ...)`, `ResultProcessor.PersistResultArray` | round-trip + live `SetMemberPersist` | Codes match `PersistResult` (1/-1/-2) |
+| R.10 | Seconds-precision read twins `STTL`/`SEXPIRETIME` exist server-side | n/a | n/a | Not exposed, mirroring hash (no `HTTL` in `RedisCommand`; the client reads ms and converts). Coverage ledger rows become `not-applicable` |
+| R.11 | New notification event `sexpired` fires (classic and subkey channels) when a member lapses; subkey payload names key + members | `KeyNotificationType.SExpired` (`[AsciiHash("sexpired")]`) | `KeyNotificationUnitTests` rows (parse + `GetSubKeys`) | Payload parsing already complete on `main` (`KeyNotification.SubKeys.cs`); only the enum entry is missing |
+| R.12 | Expiring members replicate as explicit deletes; commands behave as writes for routing | `IsPrimaryOnly`: `SADDEX`, `SEXPIRE`, `SEXPIREAT`, `SPEXPIRE`, `SPEXPIREAT`, `SPERSIST` primary-only; `SPTTL`, `SPEXPIRETIME` replica-eligible | n/a (routing switch is exhaustive; build proves it) | From the `WRITE`/`READONLY` flags |
+| NF.1 | 8.12 is a release candidate; the surface may change | `[Experimental(Experiments.Server_8_12)]` on every new public member | build (`SER011` NoWarn in root `Directory.Build.props`) | Same gate as #3261 (open question 1) |
+
+## 3. Public API to add
+
+Stable or experimental: **experimental**, `[Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]` —
+8.12 is unreleased and #3261 establishes `SER011` as the 8.12 gate.
+
+### `IDatabase` (sync; async twins on `IDatabaseAsync` with ``, returning `Task`)
+
+```csharp
+// SADDEX — grounded in HashFieldSetAndSetExpiry (HSETEX), the same four-overload shape
+bool SetAddAndSetExpiry(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None);
+bool SetAddAndSetExpiry(RedisKey key, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None);
+bool SetAddAndSetExpiry(RedisKey key, RedisValue[] values, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+
+// SEXPIRE family — grounded in HashFieldExpire
+ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None);
+ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None);
+
+// SPEXPIRETIME / SPTTL / SPERSIST — grounded in HashFieldGetExpireDateTime / HashFieldGetTimeToLive / HashFieldPersist
+long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None);
+long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None);
+PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None);
+```
+
+The full XML docs mirror the hash siblings' (`` of reply codes), with the set
+specifics added: the `SADDEX` docs state the bare-form TTL discard (R.2) and that `MNX`/`MXX` are
+all-or-nothing across the batch (R.3); `SetMemberExpire` documents that a member with no expiry
+counts as infinite for `GT` (R.6).
+
+### `KeyNotificationType`
+
+```csharp
+[AsciiHash("sexpired")]
+SExpired = 1006, // side-effect block, after KeyMiss = 1005
+```
+
+### `RedisFeatures`
+
+```csharp
+v8_12_0 = new Version(8, 11, 241); // 8.12 RC2 is version 8.11.241
+
+/// Is set-member expiration (SADDEX, SEXPIRE, ...) available?
+public bool SetMemberExpiry => Version.IsAtLeast(v8_12_0);
+```
+
+### Back-compat
+
+Purely additive: new interface members (so every implementor gains them: `RedisDatabase`,
+`KeyPrefixedDatabase`, `KeyPrefixed`), one `RedisFeatures` property, eight internal `RedisCommand`
+members, one `KeyNotificationType` member (append-only enum; see open question 6 for the value).
+Nothing shipped changes; no overload collides with the existing `SetAdd` pair (different method
+name, so no RS0026 concern). All public-API lines land in `PublicAPI.Unshipped.txt`, the
+experimental ones prefixed `[SER011]` as #3261's are; the `KeyNotificationType.SExpired` and
+`RedisFeatures` lines are bare, matching #3261's treatment of its notification values.
+
+## 4. Files to change
+
+| Path | add/edit | What |
+|---|---|---|
+| `src/StackExchange.Redis/Enums/RedisCommand.cs` | edit | `SADDEX` (after `SADD`), `SEXPIRE`, `SEXPIREAT` (after `SETRANGE`), `SPERSIST`, `SPEXPIRE`, `SPEXPIREAT`, `SPEXPIRETIME` (before `SPOP`), `SPTTL` (after `SPOP`); `IsPrimaryOnly` cases per R.12 |
+| `src/StackExchange.Redis/RedisLiterals.cs` | edit | `MEMBERS`, `MNX`, `MXX` (`NX`/`XX`/`GT`/`LT`/`KEEPTTL` tokens come via `ExpireWhen.ToLiteral()` and `Expiration`) |
+| `src/StackExchange.Redis/Interfaces/IDatabase.cs` | edit | the nine sync members, `SetAddAndSetExpiry` after `SetAdd`, the `SetMember*` block before `SetMembers` |
+| `src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs` | edit | the nine async twins with `` |
+| `src/StackExchange.Redis/RedisDatabase.cs` | edit | implementations next to `SetAdd`; `SetAddAndSetExpiryMessage` (single + array, mirroring `HashFieldSetAndSetExpiryMessage`), `SetMemberExpireExecute` + `GetSetMemberExpireMessage` + the two pick functions (mirroring the hash versions), `SetMemberExecute` (mirroring `HashFieldExecute`, `MEMBERS` token) |
+| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs` | edit | nine sync forwards with `ToInner(key)` |
+| `src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs` | edit | nine async forwards with `ToInner(key)` |
+| `src/StackExchange.Redis/KeyNotificationType.cs` | edit | `SExpired = 1006` with `[AsciiHash("sexpired")]` (metadata `TryParse`/`TryFormat` are source-generated from the attribute) |
+| `src/StackExchange.Redis/RedisFeatures.cs` | edit | `v8_12_0` constant and `SetMemberExpiry` property |
+| `src/RESPite/Shared/Experiments.cs` | edit | `Server_8_12 = "SER011"`, identical text to #3261 so the merge is clean |
+| `Directory.Build.props` | edit | `SER011` in ``, identical to #3261 |
+| `docs/exp/SER011.md` | add | same page #3261 adds, with the set-member-expiry bullet; on merge the page lists both features |
+| `src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt` | edit | the eighteen interface members (`[SER011]`-prefixed), `RedisFeatures.SetMemberExpiry.get`, `KeyNotificationType.SExpired` |
+| `docs/CommandCoverage.md` | edit | delete the `SADDEX`/`SEXPIRE`/`SEXPIREAT`/`SPERSIST`/`SPEXPIRE`/`SPEXPIREAT`/`SPEXPIRETIME`/`SPTTL` rows; `SEXPIRETIME` → `not-applicable` (R.10) |
+| `tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs` | add | exact-bytes tests via the internal message builders (section 6) |
+| `tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs` | add | live tests gated `Create(require: RedisFeatures.v8_12_0)`, mirroring `HashFieldTests.cs` |
+| `tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs` | edit | `sexpired` parse row next to the `hexpired` one; a `__subkeyevent@*__:sexpired` `GetSubKeys` case |
+| `tests/RedisConfigs/.docker/Redis/Dockerfile` | edit | `CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.12-rc2`, identical to #3261/#3315 |
+| `tests/RedisConfigs/docker-compose.yml` | edit | the same image as the `CLIENT_LIBS_TEST_IMAGE` fallback, identical to #3261/#3315 |
+
+Nothing outside this table is touched. No new `ResultProcessor`, no `APITypes/`, no analyzer row
+(step 5), no `docs/index.md` page (section 7).
+
+## 5. Ordered implementation steps
+
+1. **Enum, routing, literals.** The eight `RedisCommand` members and their `IsPrimaryOnly` cases;
+ `RedisLiterals.MEMBERS`/`MNX`/`MXX`. Check: build `src/StackExchange.Redis`. Done when it
+ builds and both `IsPrimaryOnly` branches list the right commands.
+2. **Version gate and experiment.** `RedisFeatures.v8_12_0` + `SetMemberExpiry`;
+ `Experiments.Server_8_12`; `NoWarn`; `docs/exp/SER011.md` — all with #3261's exact text where
+ shared. Done when `Experiments.Server_8_12` resolves.
+3. **Message builders.** `SetAddAndSetExpiryMessage` (single-member fast path via `Message.Create`
+ with up to six trailing values; array path building one `RedisValue[]`, token order
+ `[MNX|MXX] {expiry} MEMBERS n member...`), `GetSetMemberExpireMessage` (token order
+ `{expiry} [NX|XX|GT|LT] MEMBERS n member...`, retry category via
+ `flags.WithCategory(when.AsRetryCategory())` as the hash version), `SetMemberExecute`.
+ Done when all builders compile; they are `internal` for the round-trip tests.
+4. **Interfaces, implementations, isolation, public API.** The eighteen members everywhere;
+ `PublicAPI.Unshipped.txt` lines. Check: `dotnet build Build.csproj -c Release /p:CI=true`.
+ Done when RS0016 is silent.
+5. **Transaction analyzer (Step 9): no.** Per-member TTL on a set did not exist before 8.12, so no
+ `MULTI`/`WATCH` shape could have composed it — `SADD` + `KeyExpire` expires the *key*, which
+ `SADDEX` does not replace. No `TransactionAnalyzer` row; this reasoning recorded here.
+6. **Notification type.** `KeyNotificationType.SExpired` + the `KeyNotificationUnitTests` rows.
+ Done when the parse row and the subkey-payload case pass.
+7. **Round-trip tests** (section 6). Check: the filtered `dotnet test`. Done when every byte
+ string matches.
+8. **Live tests, written not run** (section 6) + the Dockerfile bump so they can run against the
+ 8.12-rc2 topology. Done when the class builds and skips with the version message on older
+ servers.
+9. **Ledger.** The `docs/CommandCoverage.md` edits.
+
+## 6. Test plan
+
+### Result processors (`ResultProcessorUnitTests`)
+
+All three processors are reused and already covered: `ResultProcessor.ExpireResultArray` and
+`PersistResultArray` in `BasicAggregates`, `Int64Array` likewise (RESP2 `*`, streaming, RESP3,
+empty, null). No new processor, no new processor tests.
+
+### Exact bytes (`RoundTripUnitTests/SetMemberExpiryRoundTrip.cs`)
+
+Via `new RedisDatabase(null!, 0, null)` + the internal builders, as `BitPositionRoundTrip` does.
+
+| Test | Call | Expected request |
+|---|---|---|
+| `SetAddAndSetExpiry_Ex` | `(k, "m", 15s)` | `*7 SADDEX k EX 15 MEMBERS 1 m` |
+| `SetAddAndSetExpiry_Px` | `(k, "m", 1500ms)` | `*7 SADDEX k PX 1500 MEMBERS 1 m` |
+| `SetAddAndSetExpiry_KeepTtl` | `(k, "m", null, keepTtl: true)` | `*6 SADDEX k KEEPTTL MEMBERS 1 m` |
+| `SetAddAndSetExpiry_Bare` | `(k, "m", null)` | `*5 SADDEX k MEMBERS 1 m` |
+| `SetAddAndSetExpiry_Mnx` | `(k, values, 15s, when: NotExists)` | `*9 SADDEX k MNX EX 15 MEMBERS 2 a b` |
+| `SetAddAndSetExpiry_Mxx` | `(k, values, 15s, when: Exists)` | `... MXX ...` |
+| `SetAddAndSetExpiry_ExAt` | `(k, "m", DateTime utc, whole seconds)` | `*7 SADDEX k EXAT MEMBERS 1 m` |
+| `SetMemberExpire_Seconds` | `(k, [a,b], 90s)` | `*8 SEXPIRE k 90 MEMBERS 2 a b` |
+| `SetMemberExpire_Millis` | `(k, [a], 1500ms)` | `*7 SPEXPIRE k 1500 MEMBERS 1 a` |
+| `SetMemberExpire_Gt` | `(k, [a], 90s, GreaterThanCurrentExpiry)` | `*8 SEXPIRE k 90 GT MEMBERS 1 a` |
+| `SetMemberExpire_At` | `(k, [a], DateTime, whole s / fractional)` | `SEXPIREAT` / `SPEXPIREAT` |
+| `SetMemberGetTimeToLive` | `(k, [a,b])` | `*6 SPTTL k MEMBERS 2 a b`, reply `*2 :9500 :-1` → `[9500, -1]` |
+| `SetMemberGetExpireDateTime` | `(k, [a])` | `*5 SPEXPIRETIME k MEMBERS 1 a` |
+| `SetMemberPersist` | `(k, [a,b])` | `*6 SPERSIST k MEMBERS 2 a b`, reply `*2 :1 :-2` → `[Success, NoSuchField]` |
+
+(Abbreviated here; the test file asserts full RESP with length prefixes.)
+
+### Live tests (`SetMemberExpiryTests.cs`, `Create(require: RedisFeatures.v8_12_0)`)
+
+Mirroring `HashFieldTests.cs` scenario for scenario; **run and passing** (34 = 17 × RESP2/RESP3)
+against the rebuilt test topology (`redislabs/client-libs-test:8.12-rc2`, reporting
+`redis_version:8.11.241`); they skip with the standard version message on older servers. Covered:
+add + TTL read-back (relative and absolute, with the `HashFieldTests` clock-skew
+tolerance for the `DateTime` overloads); `NoSuchField` on a missing key; conditions including
+`GT` losing to a persisted member (`[ExpireResult.ConditionNotMet]`); expiry-in-the-past →
+`ExpireResult.Due` and the member gone; `SADDEX` bare-form TTL discard vs `KEEPTTL`; `MNX` veto
+(one existing member → `false`, nothing added); `SPERSIST` → `[PersistResult.Success]` then
+`[PersistResult.NoExpiryToRemove]`; `SetAddAndSetExpiry` + `SetMemberGetTimeToLive` through
+`WithKeyPrefix` (isolation).
+
+Commands:
+
+```
+dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*SetMemberExpiry*"
+dotnet test --project tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter-class "*KeyNotificationUnitTests*"
+```
+
+No analyzer change, so `StackExchange.Redis.Build.Tests` is not run.
+
+## 7. Docs, changelog, public-API files
+
+- `docs/.md`: **no** — the hash-field family has no page either; the XML docs carry the
+ reply-code tables. A combined "member/field expiration" page could come later.
+- `docs/exp/SER011.md`: **yes** (add; merges with #3261's copy).
+- `PublicAPI.Unshipped.txt`: as section 3. `PublicAPI.Shipped.txt` untouched.
+- `docs/CommandCoverage.md`: rows deleted / `SEXPIRETIME` re-marked.
+- `docs/ReleaseNotes.md` is frozen at 3.0; not edited.
+
+## 8. Behaviour against older servers
+
+No client-side version check on the call path, matching every sibling. Below 8.12 the server
+replies `ERR unknown command 'SADDEX'` (etc.), surfacing as `RedisServerException`.
+`RedisFeatures.SetMemberExpiry` lets callers branch beforehand. Live tests skip through
+`Create(require: RedisFeatures.v8_12_0)`.
+
+## 9. Risks and open questions
+
+1. **Ordering with #3261/#3315.** All three 8.12 branches add `Experiments.Server_8_12`,
+ `RedisFeatures.v8_12_0`, the `NoWarn` entry, `docs/exp/SER011.md` and the Dockerfile bump.
+ Default: identical text everywhere so whichever lands first wins and the rest merge clean
+ (the SER011.md bullet lists are the only real merge, and it is additive).
+2. **`SADDEX` condition enum.** Reuses `When` (`NotExists` → `MNX`, `Exists` → `MXX`), exactly as
+ `HashFieldSetAndSetExpiry` maps `When` to `FNX`/`FXX` — which have the same all-or-nothing
+ batch semantics. Alternative considered: a dedicated `SetAddWhen` enum spelling out
+ "all new"/"all existing". Default: reuse `When` for sibling symmetry; the XML docs carry the
+ batch semantics.
+3. **`SADDEX` return type.** The reply schema is strictly 0/1 ("at least one member was added,
+ updated or deleted"), so `bool` via `ResultProcessor.Boolean`. The `HSETEX` sibling returns
+ `RedisValue`, but `SetAdd(key, value)`'s `bool` is the closer precedent for a 0/1 set write.
+4. **`ExpireResult`/`PersistResult` naming.** Their `NoSuchField` member (-2) now also means "no
+ such set member". Default: reuse the enums unchanged — the wire codes match exactly, and a
+ parallel enum pair would fragment the surface; the XML docs on the set members say "member".
+5. **`RedisFeatures.v8_12_0` value.** `new Version(8, 11, 241)` — 8.12-rc2 reports
+ `redis_version:8.11.241` (observed; and #3261's current revision uses the same value).
+6. **`KeyNotificationType.SExpired` value.** `hexpired` sits in the command block (`HExpired = 6`),
+ but #3261 takes 66/67 (`Bless`/`Unbless`) and `sexpired` is a side-effect event like `expired`,
+ so default: `SExpired = 1006` in the side-effect block, collision-free whichever PR lands
+ first. The enum is append-only; the value is forever either way.
+
+## 10. Out of scope
+
+- Any v4 `RespContext` surface (the demos repo's `setexpiry` mini-library shows that shape; this
+ plan is the classic `IDatabase` surface only).
+- `STTL`/`SEXPIRETIME` seconds-precision bindings (R.10).
+- `BLESS` (#3261) and `BITRANGEINTARRAY` (#3315), beyond sharing the SER011/v8_12_0 plumbing.
+- A `TransactionAnalyzer` row (step 5: nothing to map).
+- `PublicAPI.Shipped.txt` promotion, release files, `.github/`.
diff --git a/src/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..1d56c2bc8 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabase.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs
@@ -1770,6 +1770,48 @@ public partial interface IDatabase : IRedis, IDatabaseAsync
///
long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Adds the specified member to the set stored at key and/or sets its per-member expiration, in one atomic call
+ /// (SADDEX). If key does not exist, a new set is created first.
+ ///
+ /// The key of the set.
+ /// The value to add to the set and/or set the expiration of.
+ /// The expiry to set on the member: a or (implicitly converted),
+ /// or to keep an existing expiration (a new member gets none). The default (none) sends the bare
+ /// form, which discards any expiration the member already has - this is SADDEX's behaviour, not SADD's, which
+ /// leaves existing members (and their expirations) untouched. is not supported here: use
+ /// to remove expirations.
+ /// Which condition to apply the operation under; see the remarks.
+ /// The flags to use for this operation.
+ /// if at least one member was added, updated or deleted, else
+ /// (nothing changed, or the condition was not met).
+ ///
+ /// The condition applies to the member ( sends MNX: only if the member
+ /// does not exist yet; sends MXX: only if it already exists); with multiple members the condition is
+ /// all-or-nothing across the batch - one member that breaks it vetoes the whole call, and nothing is changed.
+ /// An expiration that is already in the past deletes the member instead of adding it.
+ ///
+ ///
+ [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]
+#pragma warning disable RS0026 // similar overloads
+ bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+
+ ///
+ /// Adds the specified members to the set stored at key and/or sets their per-member expiration, in one atomic call
+ /// (SADDEX). If key does not exist, a new set is created first.
+ ///
+ /// The key of the set.
+ /// The values to add to the set and/or set the expiration of.
+ ///
+ ///
+ /// The flags to use for this operation.
+ ///
+ /// Unlike , the server does not report how many members were added.
+ ///
+ [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]
+ bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+#pragma warning restore RS0026
+
///
/// Returns the members of the set resulting from the specified operation against the given sets.
///
@@ -1915,6 +1957,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..fc6c36899 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
@@ -434,6 +434,16 @@ public partial interface IDatabaseAsync : IRedisAsync
///
Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None);
+ ///
+ [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]
+#pragma warning disable RS0026 // similar overloads
+ Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+
+ ///
+ [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)]
+ Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None);
+#pragma warning restore RS0026
+
///
Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None);
@@ -461,6 +471,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..4f7a052b1 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
@@ -496,6 +496,12 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl
public Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) =>
Inner.SetAddAsync(ToInner(key), value, flags);
+ public Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) =>
+ Inner.SetAddAsync(ToInner(key), value, expiry, when, flags);
+
+ public Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) =>
+ Inner.SetAddAsync(ToInner(key), values, expiry, when, flags);
+
public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.SetCombineAndStoreAsync(operation, ToInner(destination), ToInner(keys), flags);
@@ -523,6 +529,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..ae8a36b51 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
@@ -465,6 +465,12 @@ public long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = Comma
public bool SetAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) =>
Inner.SetAdd(ToInner(key), value, flags);
+ public bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) =>
+ Inner.SetAdd(ToInner(key), value, expiry, when, flags);
+
+ public bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) =>
+ Inner.SetAdd(ToInner(key), values, expiry, when, flags);
+
public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.SetCombineAndStore(operation, ToInner(destination), ToInner(keys), flags);
@@ -492,6 +498,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..cb7e15b0a 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -2,3 +2,19 @@
[SER011]StackExchange.Redis.IDatabaseAsync.StringBitPositionsAsync(StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, long? limit = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
[SER011]StackExchange.Redis.IDatabase.StringBitPositions(StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, long? limit = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]!
StackExchange.Redis.RedisFeatures.StringBitPositions.get -> bool
+[SER011]StackExchange.Redis.IDatabase.SetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool
+[SER011]StackExchange.Redis.IDatabase.SetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool
+[SER011]StackExchange.Redis.IDatabase.SetMemberExpire(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.TimeSpan expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ExpireResult[]!
+[SER011]StackExchange.Redis.IDatabase.SetMemberExpire(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.DateTime expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ExpireResult[]!
+[SER011]StackExchange.Redis.IDatabase.SetMemberGetExpireDateTime(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]!
+[SER011]StackExchange.Redis.IDatabase.SetMemberGetTimeToLive(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long[]!
+[SER011]StackExchange.Redis.IDatabase.SetMemberPersist(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.PersistResult[]!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.Expiration expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberExpireAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.TimeSpan expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberExpireAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, System.DateTime expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberGetExpireDateTimeAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberGetTimeToLiveAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER011]StackExchange.Redis.IDatabaseAsync.SetMemberPersistAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+StackExchange.Redis.KeyNotificationType.SExpired = 1006 -> StackExchange.Redis.KeyNotificationType
+StackExchange.Redis.RedisFeatures.SetMemberExpiry.get -> bool
diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs
index dddc6776e..cf484d97e 100644
--- a/src/StackExchange.Redis/RedisDatabase.cs
+++ b/src/StackExchange.Redis/RedisDatabase.cs
@@ -2269,6 +2269,190 @@ public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags fl
return ExecuteAsync(msg, ResultProcessor.Int64);
}
+ public bool SetAdd(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags);
+ return ExecuteSync(msg, ResultProcessor.Boolean);
+ }
+
+ public bool SetAdd(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None)
+ {
+ if (values == null) throw new ArgumentNullException(nameof(values));
+ var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags);
+ return ExecuteSync(msg, ResultProcessor.Boolean);
+ }
+
+ public Task SetAddAsync(RedisKey key, RedisValue value, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = SetAddWithExpiryMessage(key, value, expiry, when, flags);
+ return ExecuteAsync(msg, ResultProcessor.Boolean);
+ }
+
+ public Task SetAddAsync(RedisKey key, RedisValue[] values, Expiration expiry, When when = When.Always, CommandFlags flags = CommandFlags.None)
+ {
+ if (values == null) throw new ArgumentNullException(nameof(values));
+ var msg = SetAddWithExpiryMessage(key, values, expiry, when, flags);
+ return ExecuteAsync(msg, ResultProcessor.Boolean);
+ }
+
+ internal Message SetAddWithExpiryMessage(in RedisKey key, in RedisValue value, Expiration expiry, When when, CommandFlags flags)
+ {
+ // SADDEX has no PERSIST token; removing member expirations is SetMemberPersist's job
+ if (expiry.IsPersist) throw new ArgumentException("PERSIST is not supported by SADDEX; use " + nameof(SetMemberPersist) + " to remove member expirations.", nameof(expiry));
+ int expiryTokenCount = expiry.GetTokenCount(allowEnx: false);
+ if (when == When.Always)
+ {
+ return expiryTokenCount switch
+ {
+ 2 => Message.Create(Database, flags, RedisCommand.SADDEX, key, expiry.Operand, expiry.Value, RedisLiterals.MEMBERS, 1, value),
+ 1 => Message.Create(Database, flags, RedisCommand.SADDEX, key, expiry.Operand, RedisLiterals.MEMBERS, 1, value),
+ _ => Message.Create(Database, flags, RedisCommand.SADDEX, key, RedisLiterals.MEMBERS, 1, value),
+ };
+ }
+ else
+ {
+ // we need an extra token
+ var existance = when switch
+ {
+ When.Exists => RedisLiterals.MXX,
+ When.NotExists => RedisLiterals.MNX,
+ _ => throw new ArgumentOutOfRangeException(nameof(when)),
+ };
+
+ return expiryTokenCount switch
+ {
+ 2 => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, expiry.Operand, expiry.Value, RedisLiterals.MEMBERS, 1, value),
+ 1 => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, expiry.Operand, RedisLiterals.MEMBERS, 1, value),
+ _ => Message.Create(Database, flags, RedisCommand.SADDEX, key, existance, RedisLiterals.MEMBERS, 1, value),
+ };
+ }
+ }
+
+ internal Message SetAddWithExpiryMessage(in RedisKey key, RedisValue[] values, Expiration expiry, When when, CommandFlags flags)
+ {
+ if (values.Length == 1)
+ {
+ return SetAddWithExpiryMessage(key, values[0], expiry, when, flags);
+ }
+ // Determine the base array size
+ int expiryTokenCount = expiry.GetTokenCount(allowEnx: false);
+ var extraTokens = expiryTokenCount + (when == When.Always ? 2 : 3); // [MXX|MNX] {expiry} MEMBERS {length}
+ RedisValue[] args = new RedisValue[values.Length + extraTokens];
+
+ int index = 0;
+ switch (when)
+ {
+ case When.Always:
+ break;
+ case When.Exists:
+ args[index++] = RedisLiterals.MXX;
+ break;
+ case When.NotExists:
+ args[index++] = RedisLiterals.MNX;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(when));
+ }
+ switch (expiryTokenCount)
+ {
+ case 2:
+ args[index++] = expiry.Operand;
+ args[index++] = expiry.Value;
+ break;
+ case 1:
+ args[index++] = expiry.Operand;
+ break;
+ }
+ args[index++] = RedisLiterals.MEMBERS;
+ args[index++] = values.Length;
+ for (int i = 0; i < values.Length; i++)
+ {
+ args[index++] = values[i];
+ }
+ Debug.Assert(index == args.Length);
+ return Message.Create(Database, flags, RedisCommand.SADDEX, key, args);
+ }
+
+ public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None)
+ {
+ long milliseconds = expiry.Ticks / TimeSpan.TicksPerMillisecond;
+ return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireCommandByPrecision, SyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members);
+ }
+
+ public ExpireResult[] SetMemberExpire(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None)
+ {
+ long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry);
+ return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireAtCommandByPrecision, SyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members);
+ }
+
+ public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None)
+ {
+ long milliseconds = expiry.Ticks / TimeSpan.TicksPerMillisecond;
+ return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireCommandByPrecision, AsyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members);
+ }
+
+ public Task SetMemberExpireAsync(RedisKey key, RedisValue[] members, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None)
+ {
+ long milliseconds = Expiration.GetUnixTimeMilliseconds(expiry);
+ return SetMemberExpireExecute(key, milliseconds, when, PickSetMemberExpireAtCommandByPrecision, AsyncCustomArrExecutor>, ResultProcessor.ExpireResultArray, flags, members);
+ }
+
+ private T SetMemberExpireExecute(RedisKey key, long milliseconds, ExpireWhen when, Func getCmd, CustomExecutor executor, TProcessor processor, CommandFlags flags, params RedisValue[] members)
+ => executor(GetSetMemberExpireMessage(key, milliseconds, when, getCmd, flags, members), processor);
+
+ internal Message GetSetMemberExpireMessage(RedisKey key, long milliseconds, ExpireWhen when, Func getCmd, CommandFlags flags, params RedisValue[] members)
+ {
+ if (members == null) throw new ArgumentNullException(nameof(members));
+ var useSeconds = milliseconds % 1000 == 0;
+ var cmd = getCmd(useSeconds);
+ long expiry = useSeconds ? (milliseconds / 1000) : milliseconds;
+
+ // S[P]EXPIRE[AT] ... NX/XX/GT/LT is a conditional write, exactly as for the key-level EXPIRE;
+ // a bare one keeps the last-wins default
+ flags = flags.WithCategory(when.AsRetryCategory());
+
+ var values = when switch
+ {
+ ExpireWhen.Always => new List { expiry, RedisLiterals.MEMBERS, members.Length },
+ _ => new List { expiry, when.ToLiteral(), RedisLiterals.MEMBERS, members.Length },
+ };
+ values.AddRange(members);
+ return Message.Create(Database, flags, cmd, key, values.ToArray());
+ }
+
+ internal static RedisCommand PickSetMemberExpireCommandByPrecision(bool useSeconds) => useSeconds ? RedisCommand.SEXPIRE : RedisCommand.SPEXPIRE;
+
+ internal static RedisCommand PickSetMemberExpireAtCommandByPrecision(bool useSeconds) => useSeconds ? RedisCommand.SEXPIREAT : RedisCommand.SPEXPIREAT;
+
+ public long[] SetMemberGetExpireDateTime(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPEXPIRETIME, key, SyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members);
+
+ public Task SetMemberGetExpireDateTimeAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPEXPIRETIME, key, AsyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members);
+
+ public long[] SetMemberGetTimeToLive(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPTTL, key, SyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members);
+
+ public Task SetMemberGetTimeToLiveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPTTL, key, AsyncCustomArrExecutor>, ResultProcessor.Int64Array, flags, members);
+
+ public PersistResult[] SetMemberPersist(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPERSIST, key, SyncCustomArrExecutor>, ResultProcessor.PersistResultArray, flags, members);
+
+ public Task SetMemberPersistAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) =>
+ SetMemberExecute(RedisCommand.SPERSIST, key, AsyncCustomArrExecutor>, ResultProcessor.PersistResultArray, flags, members);
+
+ private T SetMemberExecute(RedisCommand cmd, RedisKey key, CustomExecutor executor, TProcessor processor, CommandFlags flags = CommandFlags.None, params RedisValue[] members)
+ => executor(GetSetMemberMessage(cmd, key, flags, members), processor);
+
+ internal Message GetSetMemberMessage(RedisCommand cmd, RedisKey key, CommandFlags flags, params RedisValue[] members)
+ {
+ if (members == null) throw new ArgumentNullException(nameof(members));
+ var values = new List { RedisLiterals.MEMBERS, members.Length };
+ values.AddRange(members);
+ return Message.Create(Database, flags, cmd, key, values.ToArray());
+ }
+
public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None)
{
var msg = Message.Create(Database, flags, operation.ToSetCommand(), first, second);
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..6f2817246
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SetMemberExpiryRoundTrip.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+///
+/// Exact-bytes tests for the Redis 8.12 set-member expiration family:
+/// SADDEX, SEXPIRE/SPEXPIRE/SEXPIREAT/SPEXPIREAT,
+/// SPTTL, SPEXPIRETIME and SPERSIST.
+///
+public class SetMemberExpiryRoundTrip(ITestOutputHelper log)
+{
+ // 2101-01-01T00:00:00Z
+ private static readonly DateTime NextCentury = new(2101, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private const long NextCenturyUnixSeconds = 4133980800;
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_Ex()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None);
+ const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_Px()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl(TimeSpan.FromMilliseconds(1500), keepTtl: false), When.Always, CommandFlags.None);
+ const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nPX\r\n$4\r\n1500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_KeepTtl()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: true), When.Always, CommandFlags.None);
+ const string RequestResp = "*6\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$7\r\nKEEPTTL\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_Bare_DiscardsTtl()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", "m", Expiration.CreateOrKeepTtl((TimeSpan?)null, keepTtl: false), When.Always, CommandFlags.None);
+ const string RequestResp = "*5\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":0\r\n", log: log);
+ Assert.False(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_ExAt()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", "m", new Expiration(NextCentury), When.Always, CommandFlags.None);
+ const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$4\r\nEXAT\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_Mnx()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "a", "b" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.NotExists, CommandFlags.None);
+ const string RequestResp = "*9\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$3\r\nMNX\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n";
+
+ // one existing member vetoes the whole batch: reply 0, nothing added
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":0\r\n", log: log);
+ Assert.False(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_Mxx()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "a", "b" }, new Expiration(NextCentury), When.Exists, CommandFlags.None);
+ const string RequestResp = "*9\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$3\r\nMXX\r\n$4\r\nEXAT\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetAddWithExpiry_SingleElementArray_SameAsSingle()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.SetAddWithExpiryMessage("k", new RedisValue[] { "m" }, Expiration.CreateOrKeepTtl(TimeSpan.FromSeconds(15), keepTtl: false), When.Always, CommandFlags.None);
+ const string RequestResp = "*7\r\n$6\r\nSADDEX\r\n$1\r\nk\r\n$2\r\nEX\r\n$2\r\n15\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\nm\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Boolean, RequestResp, ":1\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberExpire_SecondsPrecision()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberExpireMessage("k", 90_000, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a", "b");
+ const string RequestResp = "*7\r\n$7\r\nSEXPIRE\r\n$1\r\nk\r\n$2\r\n90\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*2\r\n:1\r\n:-2\r\n", log: log);
+ Assert.Equal(new[] { ExpireResult.Success, ExpireResult.NoSuchField }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberExpire_MillisecondsPrecision()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberExpireMessage("k", 1500, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a");
+ const string RequestResp = "*6\r\n$8\r\nSPEXPIRE\r\n$1\r\nk\r\n$4\r\n1500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:1\r\n", log: log);
+ Assert.Equal(new[] { ExpireResult.Success }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberExpire_Gt()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberExpireMessage("k", 90_000, ExpireWhen.GreaterThanCurrentExpiry, RedisDatabase.PickSetMemberExpireCommandByPrecision, CommandFlags.None, "a");
+ const string RequestResp = "*7\r\n$7\r\nSEXPIRE\r\n$1\r\nk\r\n$2\r\n90\r\n$2\r\nGT\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n";
+
+ // GT loses to a member with no expiry (no expiry counts as infinite)
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:0\r\n", log: log);
+ Assert.Equal(new[] { ExpireResult.ConditionNotMet }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberExpireAt_SecondsPrecision()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberExpireMessage("k", NextCenturyUnixSeconds * 1000, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireAtCommandByPrecision, CommandFlags.None, "a");
+ const string RequestResp = "*6\r\n$9\r\nSEXPIREAT\r\n$1\r\nk\r\n$10\r\n4133980800\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:1\r\n", log: log);
+ Assert.Equal(new[] { ExpireResult.Success }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberExpireAt_MillisecondsPrecision()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberExpireMessage("k", (NextCenturyUnixSeconds * 1000) + 500, ExpireWhen.Always, RedisDatabase.PickSetMemberExpireAtCommandByPrecision, CommandFlags.None, "a");
+ const string RequestResp = "*6\r\n$10\r\nSPEXPIREAT\r\n$1\r\nk\r\n$13\r\n4133980800500\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n";
+
+ // an expiration already in the past deletes the member: reply 2
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.ExpireResultArray, RequestResp, "*1\r\n:2\r\n", log: log);
+ Assert.Equal(new[] { ExpireResult.Due }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberGetTimeToLive_SendsMembersBlock()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberMessage(RedisCommand.SPTTL, "k", CommandFlags.None, "a", "b");
+ const string RequestResp = "*6\r\n$5\r\nSPTTL\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, RequestResp, "*2\r\n:9500\r\n:-1\r\n", log: log);
+ Assert.Equal(new long[] { 9500, -1 }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberGetExpireDateTime_SendsMembersBlock()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberMessage(RedisCommand.SPEXPIRETIME, "k", CommandFlags.None, "a");
+ const string RequestResp = "*5\r\n$12\r\nSPEXPIRETIME\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n1\r\n$1\r\na\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64Array, RequestResp, "*1\r\n:4133980800000\r\n", log: log);
+ Assert.Equal(new[] { NextCenturyUnixSeconds * 1000 }, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SetMemberPersist_SendsMembersBlock()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetSetMemberMessage(RedisCommand.SPERSIST, "k", CommandFlags.None, "a", "b");
+ const string RequestResp = "*6\r\n$8\r\nSPERSIST\r\n$1\r\nk\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.PersistResultArray, RequestResp, "*2\r\n:1\r\n:-2\r\n", log: log);
+ Assert.Equal(new[] { PersistResult.Success, PersistResult.NoSuchField }, result);
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs
new file mode 100644
index 000000000..e254c6146
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/SetMemberExpiryTests.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Threading.Tasks;
+using StackExchange.Redis.KeyspaceIsolation;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Tests for the Redis 8.12 set-member expiration family:
+/// and friends.
+///
+[RunPerProtocol]
+public class SetMemberExpiryTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture)
+{
+ // see HashFieldTests: absolute expiries (the DateTime overloads) are computed against the client clock and read
+ // back against the server's, which can sit a few ms off on CI
+ private const double ClockSkewToleranceMs = 1000;
+
+ private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private readonly DateTime nextCentury = new(2101, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private readonly TimeSpan oneYear = TimeSpan.FromMilliseconds(31536000000);
+
+ private readonly RedisValue[] members = ["m1", "m2"];
+
+ [Fact]
+ public void SetAddWithExpiry()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ Assert.True(db.SetAdd(key, "m1", oneYear));
+ var ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"]));
+ Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds);
+
+ // absolute form
+ Assert.True(db.SetAdd(key, "m2", nextCentury));
+ var expireTime = Assert.Single(db.SetMemberGetExpireDateTime(key, ["m2"]));
+ var expected = (nextCentury - UnixEpoch).TotalMilliseconds;
+ Assert.InRange(expireTime, expected - ClockSkewToleranceMs, expected + ClockSkewToleranceMs);
+ }
+
+ [Fact]
+ public async Task SetAddWithExpiryAsync()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ await db.KeyDeleteAsync(key, CommandFlags.FireAndForget);
+
+ Assert.True(await db.SetAddAsync(key, members, oneYear));
+ var ttls = await db.SetMemberGetTimeToLiveAsync(key, members);
+ Assert.All(ttls, ttl => Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds));
+ }
+
+ [Fact]
+ public void SetAddWithExpiryKeepTtl()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ Assert.True(db.SetAdd(key, "m1", oneYear));
+
+ // KEEPTTL: the existing expiration survives the re-add
+ Assert.False(db.SetAdd(key, "m1", Expiration.KeepTtl));
+ var ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"]));
+ Assert.True(ttl > 0);
+
+ // the bare form discards it
+ Assert.True(db.SetAdd(key, "m1", Expiration.Default));
+ ttl = Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"]));
+ Assert.Equal(-1, ttl); // present, no expiration
+ }
+
+ [Fact]
+ public void SetAddWithExpiryWhen()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ // MNX on a fresh key: all new, applies
+ Assert.True(db.SetAdd(key, members, oneYear, when: When.NotExists));
+
+ // MNX with one existing member vetoes the whole batch: nothing is added at all
+ Assert.False(db.SetAdd(key, [(RedisValue)"m1", "newcomer"], oneYear, when: When.NotExists));
+ Assert.False(db.SetContains(key, "newcomer"));
+
+ // MXX: all exist, applies
+ Assert.True(db.SetAdd(key, members, oneYear + TimeSpan.FromMinutes(1), when: When.Exists));
+
+ // MXX with one missing member vetoes the whole batch
+ Assert.False(db.SetAdd(key, [(RedisValue)"m1", "ghost"], oneYear, when: When.Exists));
+ }
+
+ [Fact]
+ public void SetAddWithExpiryPastDeletes()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ db.SetAdd(key, "m1");
+
+ // an expiration already in the past deletes the member - and that counts as a change
+ Assert.True(db.SetAdd(key, "m1", DateTime.UtcNow.AddMinutes(-5)));
+ Assert.False(db.SetContains(key, "m1"));
+ }
+
+ [Fact]
+ public void SetMemberExpire()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, members);
+
+ var result = db.SetMemberExpire(key, members, oneYear);
+ Assert.Equal([ExpireResult.Success, ExpireResult.Success], result);
+
+ result = db.SetMemberExpire(key, members, nextCentury);
+ Assert.Equal([ExpireResult.Success, ExpireResult.Success], result);
+ }
+
+ [Fact]
+ public async Task SetMemberExpireAsync()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ await db.KeyDeleteAsync(key, CommandFlags.FireAndForget);
+ await db.SetAddAsync(key, members);
+
+ var result = await db.SetMemberExpireAsync(key, members, oneYear);
+ Assert.Equal([ExpireResult.Success, ExpireResult.Success], result);
+
+ result = await db.SetMemberExpireAsync(key, members, nextCentury);
+ Assert.Equal([ExpireResult.Success, ExpireResult.Success], result);
+ }
+
+ [Fact]
+ public void SetMemberExpireNoKey()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ var result = db.SetMemberExpire(key, members, oneYear);
+ Assert.Equal([ExpireResult.NoSuchField, ExpireResult.NoSuchField], result);
+ }
+
+ [Fact]
+ public void SetMemberExpireMissingMember()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, "m1");
+
+ var result = db.SetMemberExpire(key, [(RedisValue)"m1", "ghost"], oneYear);
+ Assert.Equal([ExpireResult.Success, ExpireResult.NoSuchField], result);
+ }
+
+ [Fact]
+ public void SetMemberExpireConditions()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, members);
+
+ // NX: only when the member has no expiry
+ var result = db.SetMemberExpire(key, ["m1"], oneYear, ExpireWhen.HasNoExpiry);
+ Assert.Equal([ExpireResult.Success], result);
+ result = db.SetMemberExpire(key, ["m1"], oneYear, ExpireWhen.HasNoExpiry);
+ Assert.Equal([ExpireResult.ConditionNotMet], result);
+
+ // GT: a shorter expiry loses
+ result = db.SetMemberExpire(key, ["m1"], TimeSpan.FromMinutes(5), ExpireWhen.GreaterThanCurrentExpiry);
+ Assert.Equal([ExpireResult.ConditionNotMet], result);
+
+ // GT vs no expiry: no expiry counts as infinite, so GT loses
+ result = db.SetMemberExpire(key, ["m2"], oneYear, ExpireWhen.GreaterThanCurrentExpiry);
+ Assert.Equal([ExpireResult.ConditionNotMet], result);
+
+ // LT against the pinned member wins
+ result = db.SetMemberExpire(key, ["m2"], oneYear, ExpireWhen.LessThanCurrentExpiry);
+ Assert.Equal([ExpireResult.Success], result);
+ }
+
+ [Fact]
+ public void SetMemberExpirePastDeletes()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, "m1");
+
+ var result = db.SetMemberExpire(key, ["m1"], DateTime.UtcNow.AddMinutes(-5));
+ Assert.Equal([ExpireResult.Due], result);
+ Assert.False(db.SetContains(key, "m1"));
+ }
+
+ [Fact]
+ public void SetMemberGetTimeToLive()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, members);
+ db.SetMemberExpire(key, ["m1"], oneYear);
+
+ var result = db.SetMemberGetTimeToLive(key, [(RedisValue)"m1", "m2", "ghost"]);
+ Assert.Equal(3, result.Length);
+ Assert.InRange(result[0], oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds);
+ Assert.Equal(-1, result[1]); // present, no expiration
+ Assert.Equal(-2, result[2]); // no such member
+ }
+
+ [Fact]
+ public async Task SetMemberGetTimeToLiveAsync()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ await db.KeyDeleteAsync(key, CommandFlags.FireAndForget);
+ await db.SetAddAsync(key, "m1");
+ await db.SetMemberExpireAsync(key, ["m1"], oneYear);
+
+ var ttl = Assert.Single(await db.SetMemberGetTimeToLiveAsync(key, ["m1"]));
+ Assert.InRange(ttl, oneYear.TotalMilliseconds - 10_000, oneYear.TotalMilliseconds);
+ }
+
+ [Fact]
+ public void SetMemberGetExpireDateTime()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, members);
+ db.SetMemberExpire(key, ["m1"], nextCentury);
+
+ var result = db.SetMemberGetExpireDateTime(key, [(RedisValue)"m1", "m2", "ghost"]);
+ Assert.Equal(3, result.Length);
+ var expected = (nextCentury - UnixEpoch).TotalMilliseconds;
+ Assert.InRange(result[0], expected - ClockSkewToleranceMs, expected + ClockSkewToleranceMs);
+ Assert.Equal(-1, result[1]);
+ Assert.Equal(-2, result[2]);
+ }
+
+ [Fact]
+ public void SetMemberPersist()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ db.SetAdd(key, members);
+ db.SetMemberExpire(key, ["m1"], oneYear);
+
+ var result = db.SetMemberPersist(key, [(RedisValue)"m1", "m2", "ghost"]);
+ Assert.Equal([PersistResult.Success, PersistResult.ConditionNotMet, PersistResult.NoSuchField], result);
+
+ // pinned, not permanent: the member is still an ordinary member
+ Assert.Equal(-1, Assert.Single(db.SetMemberGetTimeToLive(key, ["m1"])));
+ Assert.True(db.SetRemove(key, "m1"));
+ }
+
+ [Fact]
+ public async Task SetMemberPersistAsync()
+ {
+ var db = Create(require: RedisFeatures.v8_12_0).GetDatabase();
+ var key = Me();
+ await db.KeyDeleteAsync(key, CommandFlags.FireAndForget);
+ await db.SetAddAsync(key, "m1");
+ await db.SetMemberExpireAsync(key, ["m1"], oneYear);
+
+ var result = await db.SetMemberPersistAsync(key, ["m1"]);
+ Assert.Equal([PersistResult.Success], result);
+ }
+
+ [Fact]
+ public void SetMemberExpiryWithKeyPrefix()
+ {
+ var conn = Create(require: RedisFeatures.v8_12_0);
+ var key = Me();
+ var raw = conn.GetDatabase();
+ var prefixed = raw.WithKeyPrefix("prefix:");
+ raw.KeyDelete([(RedisKey)("prefix:" + key), key], CommandFlags.FireAndForget);
+
+ Assert.True(prefixed.SetAdd(key, "m1", oneYear));
+ var ttl = Assert.Single(prefixed.SetMemberGetTimeToLive(key, ["m1"]));
+ Assert.True(ttl > 0);
+
+ // the write really landed on the prefixed key
+ Assert.True(raw.SetContains("prefix:" + key, "m1"));
+ Assert.False(raw.KeyExists(key));
+ }
+}