Skip to content

Set-member expiration (Redis 8.12): SADDEX / SEXPIRE / SPTTL / SPERSIST on IDatabase - #3340

Merged
mgravell merged 3 commits into
8.12from
marc/set-member-expiry
Nov 9, 2026
Merged

mgravell merged 3 commits into
8.12from
marc/set-member-expiry

Conversation

@mgravell

@mgravell mgravell commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Initial IDatabase support for Redis 8.12's set-member expiration (redis/redis#15938, in 8.12-rc2) — the hash-field-TTL idea brought to sets — mirroring the HashField* family throughout. Plan document (server facts, requirement coverage, API grounding, open questions) in notes/set-member-expiry/plan.md.

Surface

Member Command(s) Returns
SetAddAndSetExpiry (single/array × TimeSpan?/DateTime, mirror of HashFieldSetAndSetExpiry) SADDEX bool — at least one member added/updated/deleted
SetMemberExpire (TimeSpan/DateTime, ExpireWhen) SEXPIRE/SPEXPIRE/SEXPIREAT/SPEXPIREAT by precision ExpireResult[] per member
SetMemberGetTimeToLive SPTTL long[] (ms; −1 none, −2 missing)
SetMemberGetExpireDateTime SPEXPIRETIME long[] (Unix ms)
SetMemberPersist SPERSIST PersistResult[]

Notable mappings, each with the sibling that grounds it:

  • When.NotExists/When.Exists → MNX/MXX: all-or-nothing across the batch (one offender vetoes the whole call), which is exactly HSETEX's FNX/FXX semantics, so the same enum carries it; the XML docs spell out the batch veto.
  • Bare SADDEX discards an existing member TTL; KEEPTTL keeps it — same contract as SET/HSETEX, so Expiration.CreateOrKeepTtl is reused unchanged.
  • ExpireResult (2/1/0/−2) and PersistResult (1/−1/−2) match the per-member reply codes exactly, so no new enums or processors; SEXPIRE ... GT loses to a member with no expiry (no expiry = infinite), covered in docs and tests.
  • Seconds-precision read twins (STTL/SEXPIRETIME) are not exposed, as the hash family does with HTTL (CommandCoverage.md re-marked not-applicable).

Keyspace notifications

8.12 fires sexpired when a member lapses, including on the subkey channels where the payload names the member (__subkeyevent@0__:sexpired, 14:presence:lobby|5:alice). No new parsing was needed: KeyNotification.GetSubKeys() on main already handles every subkey channel kind including the length-prefixed member list. The only gap was the enum entry — KeyNotificationType.SExpired = 1006 (sexpired is a side-effect event like expired, so it sits in the side-effect block, which also keeps it clear of #3261's 66/67). Unit tests cover the parse row and a GetSubKeys case on the sexpired channel.

Shared 8.12 plumbing (overlaps #3261 / #3315)

Experiments.Server_8_12 = "SER011", docs/exp/SER011.md, the NoWarn entry, RedisFeatures.v8_12_0 = 8.11.241 and the client-libs-test:8.12-rc2 Dockerfile/compose bump are textually identical to those PRs, so whichever lands first wins and the rest merge clean (the SER011.md feature bullets are the only real merge, and it's additive). All new public members are [Experimental(Experiments.Server_8_12)].

Tests

  • RoundTripUnitTests/SetMemberExpiryRoundTrip — 16 exact-bytes tests (every token shape: EX/PX/EXAT/PXAT/KEEPTTL/bare, MNX/MXX, NX-style conditions, precision picking, the MEMBERS n block), all passing.
  • SetMemberExpiryTests — the HashFieldTests mirror plus the set specifics (MNX batch veto, bare-form TTL discard, GT vs pinned, past-expiry delete, pinned-but-removable, WithKeyPrefix isolation): 34/34 passing (17 × RESP2/RESP3) against the rebuilt 8.12-rc2 topology (redis_version:8.11.241); they skip with the version message on older servers.
  • Reply parsing reuses ExpireResultArray/PersistResultArray/Int64Array/Boolean — no new processors.
  • Transaction analyzer: nothing to map — per-member TTL on a set could not be composed from any pre-8.12 MULTI shape (SADD + KeyExpire expires the key); reasoning recorded in the plan.

dotnet build Build.csproj -c Release /p:CI=true is clean apart from the pre-existing NU1903 ILCompiler advisory in toys/AotRig (fails identically on main).

@mgravell

mgravell commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked per review: the add-with-expiry surface is now two SetAdd overloads taking a required Expiration — SetAdd(key, value, Expiration, When, flags) and SetAdd(key, values, Expiration, When, flags), both bool, plus async — replacing the four-per-arity SetAddAndSetExpiry family (8 members → 4, no new name). Expiration encodes exactly SADDEX's expiration block (EX/PX/EXAT/PXAT/KEEPTTL); the required parameter keeps resolution away from the existing SetAdd members.

Two deliberate calls, both documented in the XML remarks:

  • No SADD fallback for the no-expiry form: bare SADDEX discards an existing member's TTL (t_set.c), where SADD leaves existing members untouched — they are not equivalent, so the overload always sends SADDEX.
  • Expiration.Persist is rejected with ArgumentException (SADDEX has no PERSIST token; SetMemberPersist is the removal API). Previously it would have gone to the wire as a bogus token.
  • The multi-value remarks note that the server reports only whether anything changed, not a count (unlike SetAdd(values)).

The SetMemberExpire/TTL/Persist family is unchanged (GT/LT can't ride Expiration; mirrors the HashField shape). Verified locally against 8.12-rc2: all 50 SetMemberExpiry tests pass live, 525 KeyPrefixed tests pass, library builds clean.

@mgravell
mgravell marked this pull request as ready for review November 9, 2026 13:10
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/.
…ity 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.
…d-anything reply and the MNX/NX conditions make residue from a previous run (or the other protocol) look like a failure
@mgravell
mgravell force-pushed the marc/set-member-expiry branch from 428f12e to d10a538 Compare November 9, 2026 13:16
@mgravell
mgravell merged commit 9597c4b into 8.12 Nov 9, 2026
6 of 8 checks passed
@mgravell
mgravell deleted the marc/set-member-expiry branch November 9, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant