ServiceStack.Redis v10.5 adds native expiration for individual hash fields on Redis 7.4+ and compatible servers (Valkey 9+). Related values can stay in a hash while each field has its own lifetime. These APIs use server commands directly, with no Lua or alternate-key emulation.
Expire Hash Fields​
Borrow an exclusive writer lease for expiry changes and persistence operations:
using var pool = new RedisManagerPool("localhost:6379");
await using var redis = await ((IRedisClientsManagerAsync)pool).GetClientAsync(token);
await redis.SetEntryInHashAsync("users:42", "presence", "online", token);
long[] results = await redis.HPExpireAsync("users:42", 30_000,
new[] { "presence", "missing" }, RedisHashExpiryCondition.NX, token);
// [1, -2]: expiry set for presence; missing field was not created
long[] ttl = await redis.HPTtlAsync("users:42", new[] { "presence" }, token);
long[] persisted = await redis.HPersistAsync("users:42", new[] { "presence" }, token);
| Command / async counterpart | Numeric units and purpose |
|---|---|
HExpire / HExpireAsync |
Relative seconds |
HPExpire / HPExpireAsync |
Relative milliseconds |
HExpireAt / HExpireAtAsync |
Absolute Unix seconds |
HPExpireAt / HPExpireAtAsync |
Absolute Unix milliseconds |
HTtl / HTtlAsync |
Remaining seconds per field |
HPTtl / HPTtlAsync |
Remaining milliseconds per field |
HExpireTime / HExpireTimeAsync |
Expiration as Unix seconds |
HPExpireTime / HPExpireTimeAsync |
Expiration as Unix milliseconds |
HPersist / HPersistAsync |
Remove independent field expiration |
Native absolute arguments are Unix timestamps, not local dates. Convert DateTimeOffset values with ToUnixTimeSeconds/ToUnixTimeMilliseconds. Expiration arguments must be nonnegative: zero relative expiration or a past absolute deadline deletes an existing field when the condition permits. Server-specific numeric limits are still enforced by the server.
Conditions and Per-Field Results​
The four expiry setters accept one RedisHashExpiryCondition:
None: set/update expiration unconditionally.NX: only when the field has no independent expiration.XX: only when it already has an expiration.GT: only when the new deadline is greater than its current deadline.LT: only when the new deadline is less than its current deadline.
For GT/LT, a field without expiration has an infinite lifetime. Conditions cannot be combined; invalid or combined enum values are rejected locally. Each field is evaluated independently.
Every API returns a long[] in requested order, including duplicate field occurrences:
| Operation | Result per field |
|---|---|
| Set expiration | -2 missing hash/field; 0 condition rejected; 1 expiration set; 2 field deleted immediately |
| TTL / expiration-time query | -2 missing hash/field; -1 no independent field expiration; otherwise the time in the command's units |
| Remove expiration | -2 missing hash/field; -1 already has no expiration; 1 expiration removed |
An expiring parent key still bounds the lifetime of every field. Setting/removing a field TTL does not reset the parent key's deadline. Redis can remove a hash when its last field disappears. Replacing a field with HSET clears that field's expiration; incrementing its numeric value retains it. HSET followed by an expiry command is two separate operations; this increment does not add atomic value-write-and-expire semantics. Existing typed POCO and private IDistributedCache hash formats are unchanged; use these APIs on application-owned hashes deliberately.
Use-cases​
Presence with per-field expiry​
To show who's online in a room, store each member as a field and refresh its expiry on every heartbeat. Members whose client stops sending heartbeats disappear on their own, while the room's other members stay:
public class Presence(IRedisClient redis)
{
// Each member is its own field, with its own lifetime (Redis 7.4+ / Valkey 9+)
public void Heartbeat(string room, string userId, TimeSpan ttl)
{
redis.SetEntryInHash(room, userId, DateTime.UtcNow.ToString("O"));
redis.HPExpire(room, (long)ttl.TotalMilliseconds, new[] { userId });
}
public List<string> Online(string room) => redis.GetHashKeys(room);
}
var presence = new Presence(redis);
presence.Heartbeat("presence:room:42", "alice", TimeSpan.FromSeconds(30));
presence.Heartbeat("presence:room:42", "bob", TimeSpan.FromSeconds(30));
List<string> online = presence.Online("presence:room:42"); // alice, bob - until a heartbeat is missed
SetEntryInHash replaces the field, which clears its previous expiry, so the expiry is always set again
straight after it. Before Redis 7.4 this needed a Sorted Set of last-seen times and a cleanup job; see the
driver tracking example for that approach.
Temporary grants​
Absolute expiry fits access that ends at a known time, such as a trial feature or a support engineer's temporary access. Store grants for one user in one hash and each grant ends independently:
var grants = $"grants:user:{userId}";
redis.SetEntryInHash(grants, "export-reports", "trial");
redis.HExpireAt(grants, DateTimeOffset.UtcNow.AddDays(14).ToUnixTimeSeconds(), new[] { "export-reports" });
bool canExport = redis.HashContainsEntry(grants, "export-reports");
long[] secondsLeft = redis.HTtl(grants, new[] { "export-reports" });
Use RedisHashExpiryCondition.GT when extending a grant, so a shorter extension can never cut an existing
grant short:
redis.HExpireAt(grants, newEnd.ToUnixTimeSeconds(), new[] { "export-reports" }, RedisHashExpiryCondition.GT);
Binary Data and Compatibility​
String extensions are available on IRedisClient/IRedisClientAsync. Native overloads accept raw byte[] hash
keys and byte[][] field names; IRedisNativeClient/Async extensions also accept a string hash key with binary
field names. Arbitrary bytes, Unicode, empty names and empty values retain their existing storage format.
Empty field lists and null keys/fields are rejected locally.
RedisNativeClient/RedisClient implement optional IRedisHashExpiryCommands/IRedisHashExpiryCommandsAsync capabilities. Existing client interfaces gain no required members. Third-party implementations can opt in; otherwise the extensions throw NotSupportedException. No new package dependencies are introduced, and this command family is available across the client's supported target frameworks.
These methods require standalone calls. Pipelines and transactions are rejected before sending hash-expiry commands; deferred integer-array callback support is outside this increment. Mutations require writers; TTL/time queries may use read-only leases with ordinary replica-staleness semantics.
Unsupported-command, wrong-type and ACL errors propagate without fallback. Caller cancellation closes an interrupted reply and preserves the caller token. Malformed/truncated arrays close the connection and reset buffered commands before reuse; they are not replayed as a parser fallback. Ordinary transport retry behavior and uncertain-write outcomes still apply. HGETEX/HSETEX are not part of this increment.
Behavior was validated on Redis 7.4.12, Redis 8.10.1 and Valkey 9.1.3. Redis 6.2.24 and Valkey 8.1.11 were tested for unsupported-command behavior without emulation. See the server references for individual command semantics: Redis HEXPIRE, HPTTL, HPERSIST and Valkey HEXPIRE.