Engineering Article

Atomic Redis Value Replacement Without Downtime: The Temporary Key Pattern

Atomic Redis value replacement without downtime: the temporary key pattern

When Redis stores sensitive runtime data such as authorization rules, feature flags, or live configuration, naive key updates can cause service outages. A simple DELETE followed by SET creates a window where the key does not exist, while running HSET on a live hash leaves stale fields behind.

The temporary key pattern uses the atomic RENAME command in Redis to swap data structures without downtime, guaranteeing zero missing-key windows and zero stale fields.

The problem and production context

Updating live data structures in place creates race conditions between write operations and concurrent client reads.

  • Failure scenario: An application updates configuration by executing KeyDeleteAsync("config:site") followed by StringSetAsync("config:site", newJson). During the interval between the two network operations, concurrent reader requests receive null or fallback defaults. If StringSetAsync fails or the network connection drops, the key remains missing until an administrator backfills it.
  • Why default approaches fall short: While cache entries tolerate transient misses, runtime configuration, permissions, and routing tables serve as sources of truth. Similarly, updating hashes in place with HSET modifies supplied fields without removing omitted fields. If an earlier record contained beta_access: true, that field persists after an update intended to revoke privileges. Adding members to a set shows the same defect: obsolete entries are never purged. Calling KeyDelete prior to HashSet or SetAdd reintroduces the empty key window.
  • Production impact: Downgraded accounts retain revoked permissions, critical microservices run with empty configuration fallbacks, and concurrent requests crash during routine deployments or cache warm-ups.

Mental model and core concepts

The temporary key pattern replicates filesystem atomic swaps at the Redis key-space level.

1. Two-step atomic swap mechanism

The replacement pattern consists of two sequenced phases:

  1. Write the new dataset completely to a temporary key (such as key:tmp).
  2. Run RENAME to replace the production key with the temporary key.

RENAME executes atomically on the Redis single-threaded execution pipeline. It removes the destination key if it exists and moves the temporary key into place within a single step. Readers observe either the old structure or the new structure, with no intermediate state or missing key interval.

2. Transaction batching with MULTI/EXEC

Executing writes and RENAME as individual network calls risks orphaning temporary keys if a network disconnect occurs mid-sequence. StackExchange.Redis transactions wrap writes, deletions, and renames inside a Redis MULTI/EXEC block. The transaction guarantees that either all commands execute together or the production key remains untouched.

3. Redis cluster hash tagging

In a clustered Redis environment, RENAME throws a cross-slot error if the source and destination keys map to different hash slots across cluster nodes. Enclosing the variable shard token in curly braces forces Redis to hash only the enclosed string: {user:123}:permissions and {user:123}:permissions:tmp share the identical hash slot and execute on the same node without cross-slot errors.

4. Distributed lock coordination

The rename pattern prevents read inconsistency but does not prevent concurrent background workers from overwriting each other’s temporary keys before swapping. Coordinating updates with a distributed lock acquired via LockTakeAsync with a unique GUID token ensures that only one worker writes and renames at any moment.

Production implementation

The following implementation provides generic atomic string replacement, set replacement, hash replacement, and distributed lock synchronization using StackExchange.Redis.

using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StackExchange.Redis;

public class RedisAtomicReplacer
{
    public async Task<bool> ReplaceAsync<T>(
        IDatabase db, string key, T value, TimeSpan? expiry = null)
    {
        string tempKey = $"{key}:tmp";
        TimeSpan ttl = expiry ?? TimeSpan.FromHours(1);

        var txn = db.CreateTransaction();
        var setTask = txn.StringSetAsync(
            tempKey, JsonSerializer.Serialize(value), ttl);
        var renameTask = txn.KeyRenameAsync(tempKey, key);

        bool committed = await txn.ExecuteAsync();
        await Task.WhenAll(setTask, renameTask);

        return committed;
    }

    public async Task ReplaceSetAsync(
        IDatabase db, string key, string[] members)
    {
        string tempKey = $"{key}:tmp";

        var txn = db.CreateTransaction();
        txn.KeyDeleteAsync(tempKey);
        txn.SetAddAsync(tempKey, members.Select(m => (RedisValue)m).ToArray());
        txn.KeyRenameAsync(tempKey, key);

        await txn.ExecuteAsync();
    }

    public async Task ReplaceHashAsync(
        IDatabase db, string key, HashEntry[] entries)
    {
        string tempKey = $"{key}:tmp";

        var txn = db.CreateTransaction();
        txn.KeyDeleteAsync(tempKey);
        txn.HashSetAsync(tempKey, entries);
        txn.KeyRenameAsync(tempKey, key);

        await txn.ExecuteAsync();
    }

    public async Task<bool> ExecuteWithLockAsync(
        IDatabase db, string lockKey, TimeSpan lockExpiry, Func<Task> action)
    {
        string token = Guid.NewGuid().ToString("N");
        bool acquired = await db.LockTakeAsync(lockKey, token, lockExpiry);

        if (!acquired)
        {
            return false;
        }

        try
        {
            await action();
            return true;
        }
        finally
        {
            await db.LockReleaseAsync(lockKey, token, CommandFlags.FireAndForget);
        }
    }

    public async Task SafeReplaceSetAsync(
        IDatabase db, string key, string[] members)
    {
        string lockKey = $"lock:{key}";
        TimeSpan lockExpiry = TimeSpan.FromSeconds(10);

        bool lockAcquired = await ExecuteWithLockAsync(
            db, lockKey, lockExpiry, () => ReplaceSetAsync(db, key, members));

        if (!lockAcquired)
        {
            throw new InvalidOperationException("Could not acquire lock for replacement");
        }
    }
}

Architectural trade-offs and edge cases

The temporary key pattern introduces specific operational trade-offs and concurrency requirements.

  • Latency versus consistency: Atomic renames guarantee read consistency and eliminate stale or missing states. However, building the entire data structure under a temporary key requires double the memory allocation in Redis during the swap phase. For large collections, this transient memory overhead can trigger key eviction policies or out-of-memory errors.
  • Failure recovery: If ExecuteAsync returns false, the transaction aborted and the production key remains unchanged. When an application process crashes after writing a :tmp key but before issuing RENAME, the orphaned temporary key consumes memory until expired. Configuring a TTL via KeyExpireAsync or running a periodic janitor sweep over *:tmp keys recovers orphaned storage.
  • Scale limitations: When replacing massive sets or hashes containing millions of entries, creating the complete temporary structure in a single transaction can block the Redis event loop. In massive dataset scenarios, versioned keys (config:v1, config:v2) combined with a pointer swap provide an alternative to bulk in-memory copies.

Common anti-patterns and gotchas

  • Unsynchronized concurrent writers: Executing temporary key writes without a distributed lock allows concurrent workers to interleave writes into the same :tmp key, causing corrupted payloads to be swapped into production.
  • Missing hash tags in cluster mode: Swapping keys in Redis Cluster without wrapping the shard key in curly braces ({user:123}) causes cross-slot exceptions because the main key and the temporary key hash to different physical slots.
  • In-place hash updates for complete replacements: Using HSET to replace a user profile or configuration record without clearing missing fields preserves deprecated or revoked attributes indefinitely.
  • Delete-then-set workflows: Deleting a live key before writing the replacement introduces an unhedged latency window where concurrent reader threads encounter null references.

Implementation checklist

  1. Identify critical keys that serve as sources of truth where missing or stale data causes outages.
  2. Ensure clustered environments use hash tags (e.g., {tenant:id}:config) on all main and temporary keys.
  3. Replace direct KeyDelete and StringSet sequences with transactional temporary key writes and atomic renames.
  4. Clean up leftover temporary keys at the start of replacement transactions to prevent stale merges.
  5. Set explicit TTLs on temporary keys to prevent orphaned memory usage in the event of unexpected worker process crashes.
  6. Guard multi-worker update paths with LockTakeAsync using unique tokens to serialize writes.
  7. Verify that fallback configurations or null checks do not trigger during background payload swaps.