Designing A Scalable Session Store Using Crdts For Gdpr Compliant Data Retention

2026-03-19 · Leonardo Benicio
Summary

A comprehensive technical exploration of designing a scalable session store using crdts for gdpr compliant data retention, covering key concepts, practical implementations, and real-world applications.

Contents

The Paradox of the Forgetful System

Imagine you are building a high-traffic web application. Perhaps it’s a social media platform, an e-commerce giant, or a real-time collaborative workspace. Your users are distributed across the globe, your traffic spikes unpredictably, and your backend is a sprawling mesh of microservices. In this world, the session store is the silent workhorse. It is the glue that holds the user’s identity together across stateless HTTP requests, the keeper of the shopping cart, the arbiter of authentication flags, and the repository of transient state.

For years, the golden rule of this system was simple: keep it fast, keep it alive. We optimized for availability, partitioned for scale, and tolerated eventual consistency because, after all, it was just session data. If a user’s “last viewed item” was a few seconds stale, who cared? We threw memory at the problem (Redis clusters, Memcached farms) and relied on Time-To-Live (TTL) expiry as our primary garbage collector. When the user logged out, we deleted the key. The system forgot. It was clean.

Then came the General Data Protection Regulation (GDPR).

Suddenly, the design of a simple key-value store became a legal liability. The core tenet of GDPR—the “Right to be Forgotten” (Article 17) and the principle of data minimization (Article 5(1)(c))—introduced a fundamental tension into our architectures. We were no longer just building for speed; we were building for auditable, verifiable deletion at massive scale.

But here is the paradox: How do you build a system that is highly available, scales horizontally, and replicates data for performance, yet is capable of instant, irrevocable deletion of specific data points across all replicas on demand?

This is not a trivial question. It is the architectural equivalent of trying to un-bake a cake.

The Ghosts of Data Past

Let’s make the problem concrete. You operate a Redis cluster with three master nodes and three replicas per master—nine total instances, split across three availability zones. Each key is replicated asynchronously with a typical replication lag of a few milliseconds. Your session data looks like this:

session:user1234 -> { "cart": [item1, item2], "logged_in": true, "email": "alice@example.com" }

A user initiates a GDPR deletion request for “alice@example.com”. Your compliance service dutifully issues a DEL session:user1234 command to the Redis master in zone A. The master deletes the key and acknowledges success. But what about the replicas? Asynchronous replication means that the delete command (propagated as a replication stream event) may not have reached the replicas in zones B and C yet. A client reading from a replica in zone B could still see the old data. Worse, if the master in zone A crashes before the replication stream is fully applied, a promotion of a stale replica could resurrect the deleted key. You have not forgotten; you have merely hidden the data behind a fragile wall of eventual consistency.

This is the first ghost: replication lag turning deletion into a slow, uncertain process. But there’s more. Even if replication completes instantly, your system may have multiple layers of caching. A CDN edge may cache the user’s profile page with the email. A local browser localStorage may hold a copy. An internal data warehouse batch job may have snapshotted the session two days ago. True, complete forgetfulness requires tracing every copy, every replica, every backup, every audit log entry. GDPR demands that the data be erased “without undue delay” – not “whenever the next garbage collection cycle runs”.

The second ghost is time. Many systems rely on TTL to automatically purge data. A common pattern is to set a TTL of 30 days on session keys, with the understanding that old sessions are eventually evicted. But GDPR requires that deletion be triggered by the user’s request, not by the passage of time. If you rely solely on TTL, you cannot guarantee that data of a user who exercises their right to be forgotten will be removed before the natural expiry. You might argue: “We set the TTL to one hour, so even if we don’t actively delete, the data will vanish soon.” That might satisfy a loose interpretation, but regulators expect active deletion. Moreover, if you are required to provide a record of deletion (audit trail), a silent TTL eviction does not generate a log entry. The ghost of data remains in your logs and backups.

The third ghost is backups. Consider your nightly database dump. It contains a full copy of all sessions at that point in time. If a user requests deletion today, their data from yesterday’s backup still exists on tape or in cloud storage. Restoring from that backup would resurrect the deleted data. Most compliance frameworks require that you either delete backups as well (which can be operationally devastating) or restore only a sanitized version. This adds extraordinary complexity to disaster recovery procedures.

Now, let’s look at the two most common “solutions” that engineers initially gravitate toward:

Solution A: Write a background sweeper. After the primary deletion, a separate service polls all replicas and cache layers, issuing delete commands. This is simple but slow. In a system with thousands of nodes, the sweeper may take minutes to run. During that window, stale data can be served, violating the “without undue delay” requirement. Additionally, the sweeper itself can miss replicas that are temporarily down, and when they come back, the data reappears.

Solution B: Use a short TTL universally. Set maximum TTL on all session-adjacent data to, say, 5 minutes. Then, a user deletion only needs to delete the key from the primary store; the rest will vanish within 5 minutes. This approach trades availability for compliance: short TTLs mean frequent cache misses, higher load on the database, and degraded user experience. For a high-traffic app, this can be cost-prohibitive.

Neither solution truly solves the fundamental paradox. They paper over the cracks.

The Architectural Clash: CAP, GDPR, and the Real World

The tension between high availability and verifiable deletion is a manifestation of the CAP theorem reimagined for compliance. In distributed systems, we often accept eventual consistency to maintain availability and partition tolerance. GDPR demands immediate consistency for deletion operations. You cannot have an “eventually deleted” state; deletion must be atomic and cross-replica immediate.

Let’s categorize the layers where data lives in a typical microservice architecture:

  1. Primary database – e.g., PostgreSQL, Cassandra, DynamoDB.
  2. In-memory cache – e.g., Redis, Memcached, application-level local caches.
  3. CDN and edge caches – e.g., CloudFront, Cloudflare, Varnish.
  4. Client-side storage – browser localStorage, service workers, mobile app disk cache.
  5. Backup / archival stores – S3 bucket with database dumps, incremental snapshots.
  6. Logging and analytics pipelines – Kafka, Elasticsearch, data warehouses.

Each layer introduces its own ghost. The challenge is to design a system that can, upon request, issue a “delete pulse” that propagates to every layer, with each layer acknowledging that the data is gone – and that no future process will accidentally recover it.

This is not merely a software engineering problem. It is a data lifecycle problem that demands we rethink how we treat transient state. In a pre-GDPR world, we thought of session data as ephemeral and unimportant. Now, it is personal data subject to strict regulation. The cost of non-compliance (up to 4% of annual global turnover, or €20 million, whichever is greater) forces us to treat every byte of user data as if it were a credit card number.

Deconstructing the “Unbaked Cake”

To understand how to build a system that supports instant deletion, we need to examine the fundamental operations of data storage:

  • Write: data enters the system.
  • Read: data leaves the system.
  • Update: old data is replaced by new data.
  • Delete: data should cease to exist.

In a simple, single-node database, deletion is trivial: you remove the page from the B-tree and mark it as free space. But in a distributed, replicated system, deletion is actually an update that marks a key as “tombstone”. The tombstone must then propagate to all replicas, and eventually the space is reclaimed through compaction. During the propagation window, a read can still see the tombstone (if the read is causally aware) or the old value (if not). This is the crux: deletion in distributed systems is never instantaneous. It is a process.

GDPR does not explicitly require instantaneous deletion; it requires deletion “without undue delay”. However, the interpretation in practice (visible in many European court rulings) is that the delay should be measured in seconds or minutes, not hours or days. For a high-traffic system serving millions of users, a delay can easily slip into hours if replication backlogs, garbage collection cycles, and batch deletes are not carefully engineered.

The Logarithmic Cost of Deletion

Let’s analyze the cost of ensuring deletion completeness across replicas. Suppose you have N nodes. For each user deletion, you must contact all N nodes to remove the key. If any node is unreachable, you need a retry mechanism. This is akin to a quorum-based delete: you write the delete to a quorum of replicas (say W = N/2 + 1) and then hope the remaining ones catch up. But hope is not a strategy for compliance. You need to verify that all replicas have applied the deletion. That requires either a full read from every replica after a delay, or a gossip protocol that tracks deletion propagation.

A naive approach: maintain a “deletion log” – a separate table that records all deletions with a monotonically increasing version per key. Each replica, when it receives a write, checks the deletion log: if the write’s version is less than the latest deletion version for that key, the write is rejected. This is how Lamport clocks and version vectors can help. But now you have introduced a dependency on a global ordering service, which itself must be highly available and consistent – a bottleneck.

Another approach: Use conflict-free replicated data types (CRDTs) that support tombstones. For example, a LWW-Register (Last-Writer-Wins) with a timestamp or vector clock. Deleting a key is just a write of a “tombstone value” with a timestamp higher than any previous write. All replicas, upon seeing the tombstone, know to remove the value locally. The problem: the tombstone itself remains in the system forever unless we use a compaction protocol. In CRDTs, tombstones are often kept indefinitely to maintain convergence. That violates “right to be forgotten” because the tombstone is metadata that points to the existence of the deleted key. You could argue that the tombstone does not contain personal data, but a determined regulator could request logs that show deletion markers, and if the key name itself is derived from a user ID (e.g., “session:user1234”), then it’s still personal data. Moreover, the key name could be reconstructed from a hash, but that still leaks existence.

The fundamental issue is that deletion requires erasing all traces, including the mark of erasure.

Practical Strategies for Massively Scalable Deletion

There is no silver bullet. Instead, we must combine a set of architectural patterns that together minimize the “undue delay” and provide strong guarantees. Let’s explore these patterns in depth.

1. The Primary Source of Truth and Derived State

The first step is to recognize that session data is often derived from a more authoritative source. For example, a user’s login status is determined by a token stored in a database of authentication events. The session in Redis is a cache of that truth. If we treat the database as the source of truth for deletion, we can make the cache rely on a notification channel to invalidate entries.

Design principle: Store only ephemeral, non-personal cache entries. Personal data lives in the authoritative store. When a user requests deletion, you delete the row from the authoritative database (e.g., PostgreSQL), and that triggers an event (via Change Data Capture or a message queue) that propagates to all caches, telling them to invalidate any keys related to that user.

But what if the cache itself stores personal data (e.g., a user’s profile in a key-valued session)? Then you must ensure the cache is also deleted. This approach works if you design your session data to be a minimal set of references (like a session token mapping to a user ID) rather than embedding PII. In practice, many applications store the user’s email, name, and preferences directly in the session for performance. That’s a design mistake from a privacy standpoint.

Example: Instead of storing the email in the session, store only a user ID. The session cache will contain session:token123 -> { user_id: 45678 }. Then the user profile (with email) is fetched from a profile service that has its own cache, but that cache can be invalidated independently. Deleting the user from the profile database will cascade invalidations.

2. Write-Ahead Deletion Logs with Active Replication

For layers where data cannot be derived (like short-term in-memory state), we can use a pattern I call “pre-emptive deletion logging”. Before actually deleting a key from the primary store, we write a “deletion intent” to a highly available, strongly consistent log (like Apache BookKeeper, or a Raft-based log). This log is the source of truth for which keys are considered deleted. Every cache node, before serving any read, checks the log to see if the key is marked for deletion. If it is, the node must treat the read as a miss even if it has a cached value.

This may sound like massive overhead, but the check can be done with a Bloom filter. A Bloom filter of 1 MB can represent millions of keys with a low false positive probability. For reads where the Bloom filter indicates " not deleted", we serve the cached value directly, no log access needed. For the rare case where the Bloom filter says “maybe deleted”, we consult the log (which we can do asynchronously with a fast path). The deletion log itself is append-only and can be sharded by user ID hash.

When a deletion request arrives, the deletion process:

  1. Insert the key (or a hash) into the Bloom filter in the deletion log service.
  2. Append a record to the log: (timestamp, key_hash, reason).
  3. Delete the key from the primary store (database).
  4. Broadcast a gossip message to all cache nodes: “Invalidate key X”.

Each cache node, upon receiving the gossip message, removes the key from its local store and also updates its local copy of the Bloom filter (or a smaller version). The gossip is best-effort but fast. In case a node misses the message, the next read to that node will check the Bloom filter (which can be updated via periodic syncs) and see that the key is deleted.

This pattern provides strong guarantees: even if a cache node is isolated for a while, it will soon sync its Bloom filter and stop serving stale data. The Bloom filter itself is a lightweight structure that can be replicated along with the deletion log.

3. Tombstone-Free Deletion with Time-Bounded Cache Lease

Another elegant approach is to treat caches as lease holders. When a cache node stores a value, it receives a lease from the primary data store that expires after a short time (e.g., seconds). The lease includes a version number. When a deletion occurs, the primary store increments the version for that key. Any cache that holds a lease with a lower version must re-validate with the primary store before serving the value. This is similar to the lease-based cache coherence used in many distributed file systems.

Implementation: Instead of storing the session data directly in a cache, store it in a distributed key-value store that supports leases (like etcd or ZooKeeper). The lease duration is the maximum time you are willing to live with stale data. For high-traffic sessions, a lease of a few seconds is acceptable. When a deletion occurs, the etcd key is deleted, and all lease holders are notified (via watch events). The notification guarantees that within the lease period, stale data is removed.

This approach can be combined with an in-memory cache that holds the data but also holds a lease. Clients always go to the in-memory cache first; if they have a valid lease, they serve. Otherwise, they fetch from the database and acquire a new lease. Deletion simply deletes the etcd key; the next time a cache node tries to renew its lease, it will see the deletion and remove the entry.

The lease mechanism eliminates the need for tombstones and provides a tight guarantee: data can be considered deleted at most lease_duration seconds after the delete operation. This is often within regulatory comfort.

4. Replica-Aware Deletion with Quorum-Based Sync

For systems where asynchronous replication is a must (e.g., Redis cluster with replicas), we can ensure deletion propagation by performing a quorum delete that involves all replicas. Redis Sentinel or Cluster mode does not natively support this. However, we can build a custom layer:

  • Issue DEL to the master.
  • For each replica, issue WAIT 1 0 (which waits for the replication to be acknowledged by at least one replica) or continuously poll the replica for the key’s existence.

But WAIT only ensures the write (deletion) has been replicated to the specified number of replicas. Once they have it, the key is truly gone from those nodes. You can repeat with all replicas. This is expensive but tenable for deletions (which are rare relative to reads). For a cluster of three replicas, you might issue:

DEL session:user1234
WAIT 3 5000   (wait up to 5 seconds for all three replicas to acknowledge)

If the WAIT fails, you must retry or escalate to a manual process. This gives you a strong guarantee that after the command returns, the key is absent from all replicas (or you get an error). In practice, WAIT can cause performance bottlenecks for the master (it blocks other writes) but for deletion-only paths, it’s acceptable.

5. Immutable Writes with Immediate Compaction

Another radical idea: treat every write as immutable. Instead of updating a key, write a new version. Deletion is simply writing a version that is the tombstone. Then rely on a compaction process that immediately compacts that key across all replicas. This is how Apache Cassandra handles deletes with tombstones, but compaction is not immediate. However, if we force compaction to happen within seconds of the deletion (by running nodetool compact on the specific key range), we can remove the tombstone quickly. This is a heavy operation and not suitable for frequent deletions, but for a moderate number of GDPR requests, it could work.

In Cassandra, you can set gc_grace_seconds to 0 for the specific table, but that disables protection against concurrent deletes and may cause resurrection. A better approach: use DTCS (DateTieredCompactionStrategy) and force a minor compaction of the SSTables containing the deleted key.

6. Backup Sanitization via Differential Snapshots

Backups are the biggest ghost. The traditional approach is to keep backups for a fixed retention (e.g., 30 days) and delete the entire backup file after that. But a GDPR deletion request may come before the retention period ends. You must either:

  • Restore the backup, delete the specific keys, and create a new backup without those keys (heavy).
  • Use point-in-time recovery that allows you to replay the WAL from a base backup, but skip the deletions? No, that would resurrect data.
  • Use differential backup where the base backup is encrypted and you store a list of “deleted keys” that must be excluded when restoring. The restoration process applies the base backup, then removes any keys that appear in the deletion log for that point in time.

This is operationally complex but necessary. Many cloud database services (e.g., Amazon RDS) do not support selective deletion from a backup. You would need to implement custom backup scripts that create logical dumps without the deleted keys by querying the current state after deletion. However, if you use a point-in-time snapshot (like EBS snapshot), you cannot modify it. Instead, maintain a deletion blacklist that is used as a filter during any restore procedure.

Example:

-- Before restoration, create a temporary table of keys to delete.
CREATE TEMP TABLE deleted_keys (key TEXT);

COPY deleted_keys FROM '/tmp/deletion_log.txt';

-- After restoring base dump, perform deletion.
DELETE FROM sessions
WHERE session_id IN (SELECT key FROM deleted_keys);

You must ensure the deletion log itself is also backed up and immutable (to meet audit requirements), but it must not contain the original personal data. Use hashes of keys.

A Concrete Implementation: The Forgetful Session Store

Now let’s put it all together into a blueprint for a “forgetful session store” that meets GDPR requirements at scale.

Architecture Overview

We will have:

  • Authoritative data store: A strongly consistent, sharded SQL database (e.g., CockroachDB or PostgreSQL with a distributed sharding layer). This stores user profiles, authentication tokens, and session metadata (but minimal PII). It is the source of truth for deletion.

  • Session cache layer: A Redis cluster that stores only a session_id -> user_id mapping (no PII). The session_id is a random token, the user_id is an internal identifier. The cache is set with a TTL of 15 minutes. Deleting from cache merely invalidates the mapping; the user will need to re-authenticate.

  • CDN / edge cache: We do not cache any HTML that contains user-specific data. If we must, we cache with a short TTL and use a cookie-based token to personalize on the client side via JavaScript.

  • Deletion log service: A Raft-based service (e.g., etcd) that stores a list of deleted user IDs (hashed) with a timestamp. The service also maintains a compact Bloom filter of all deleted user IDs for fast checking.

Deletion Flow

When a user requests deletion:

  1. Compliance service receives the request, possibly after verifying identity. It generates a unique deletion request ID.

  2. Capture user data: Query the authoritative database to find all user IDs associated with this user (e.g., from email and also from any session tokens). Also collect all session IDs from the session table (but we will delete the user record, which cascades).

  3. Mark as deleted in database: Update the user record to set deleted_at = NOW(). This is a logical deletion that prevents future logins. Also delete all rows from sessions table where user_id="i3bc810ff58"> (if sessions are stored in the database).

  4. Record deletion in the log service: Write to etcd a key /deletions/users/<user_id_hash> with the timestamp and request ID. The etcd lease TTL is set to a long time (e.g., 1 year) because we need to keep the deletion marker for audit but can eventually expire older ones.

  5. Invalidate cache:

    • Send a Redis DEL session:<session_id> for each session token. Use WAIT to ensure all replicas have deleted.
    • Also broadcast a message through a pub/sub channel (e.g., Redis PUBLISH) to any application nodes that are listening, telling them to remove any in-memory references to that user.
  6. Notify edge caches: If edge cached any user-specific content (e.g., profile image URLs), request a purge via the CDN’s API.

  7. Audit trail: Append a record to an immutable audit log (e.g., AWS CloudTrail, or an append-only database) indicating that deletion was performed, by whom, and the scope of data removed. This audit log is separate and does not store the deleted data.

  8. Backup handling: After a successful deletion, the next scheduled backup will not include the user (since deleted_at is set). However, older backups still exist. Implement a nightly job that reads the deletion log (from etcd) and creates a “sanitized snapshot” by restoring each old backup, applying deletions, and re-archiving. This is expensive; you might choose to only do this when required for a specific legal request (i.e., on-demand). Alternatively, you can argue to your legal team that old backups are “not publicly accessible” and that restoring from them is highly unlikely; but that may not satisfy regulators. A compromise: keep a rolling window of backups where the maximum retention is 14 days, and upon deletion, you immediately schedule a sanitized snapshot of all backups that include the user’s data.

Code Snippets for Key Steps

1. Database Deletion (Python with SQLAlchemy)

def delete_user(user_email: str) -> DeletionResult:
    db = get_db()
    # Find user and all sessions
    user = db.query(User).filter(User.email == user_email).first()
    if not user:
        raise UserNotFoundException()
    session_keys = [s.session_id for s in user.sessions]

    # Physical delete (or logical)
    db.delete(user)
    db.commit()

    # Build deletion log entry
    deletion = DeletionLog(
        user_id=user.id,
        hashed_email=hashlib.sha256(user_email.encode()).hexdigest(),
        request_id=str(uuid.uuid4()),
        timestamp=datetime.utcnow()
    )
    db.add(deletion)
    db.commit()
    return DeletionResult(user_id=user.id, session_keys=session_keys, deletion=deletion)

2. Etcd Deletion Marker

import etcd3

def record_deletion_in_etcd(user_id: int, request_id: str):
    client = etcd3.client(host='etcd-cluster', port=2379)
    key = f'/deletions/users/{user_id}'
    value = request_id.encode()
    # Lease with 1 year TTL
    lease = client.lease(ttl=365*24*60*60)
    client.put(key, value, lease=lease)

3. Redis Deletion with Wait

import redis

def delete_session_from_redis(session_id: str, num_replicas=3):
    r = redis.Redis(host='redis-master', port=6379)
    # Delete the key
    r.delete(f'session:{session_id}')
    # Wait for replication to all replicas (timeout 5 seconds)
    response = r.execute_command('WAIT', num_replicas, 5000)
    if response < num_replicas:
        # Log warning; could retry or escalate
        logger.warning(f"Only {response} replicas acknowledged deletion for {session_id}")

4. Bloom Filter Check on Cache Node (using pybloom_live)

class CacheNode:
    def __init__(self):
        # Download the global bloom filter from etcd periodically
        self.deleted_bloom = BloomFilter(capacity=10_000_000, error_rate=0.001)

    def check_before_serving(self, key: str) -> bool:
        """Return True if key is known deleted and should be a cache miss."""
        return key in self.deleted_bloom

Testing for Compliance

How do you test that your system truly forgets? You must write test scenarios that simulate a deletion and then check every layer:

  • Database: Query the row – should return empty.
  • Cache: Connect to each Redis node and read the key – should be nil.
  • CDN: Request a cached URL with the user’s session cookie – should return 404 or redirect.
  • Backup file: Restore a pre-deletion backup in a test environment and ensure the data is absent (after applying the deletion log filter).
  • Audit log: Confirm that the deletion request ID appears.

Automate these tests with a chaos engineering mindset: introduce network partitions, crash replicas, and ensure that after recovery, the deletion eventually propagates. This is your guarantee.

The Human Element: Process and Legal

No technology alone can solve the problem; you also need organizational processes. The GDPR requires that you have a Data Protection Impact Assessment (DPIA) for any high-risk processing. For your session store, document the architecture, the retention periods, the deletion procedures, and the maximum time between a deletion request and the actual erasure of all copies. This document is a legal shield.

Also, respect the right to restriction of processing (Article 18). If a user contests the accuracy of their data, you cannot delete it immediately; you must restrict processing. That means you need to support a “flag” on the data that marks it as restricted but not deleted. This adds another state in your deletion log.

Conclusion: The Inevitable Complexity

The paradox of the forgetful system is that high availability and immediate, complete deletion are fundamentally at odds. But by embracing a layered architecture that treats each storage layer with a different strategy, we can reduce the “ghosts of data past” to a manageable set of exceptions.

We’ve seen that the solution involves:

  • Using a strongly consistent authoritative store for personal data.
  • Deriving cache data from that store (with references rather than embedded PII).
  • Employing lease-based cache coherence or deletion logs with Bloom filters to enforce deletion in caches.
  • For replicas, using quorum-level delete acknowledgment.
  • For backups, creating a sanitization process that uses a deletion blacklist during restoration.
  • Never storing personal data where it can be copied indefinitely (e.g., browser localStorage).

This architectural discipline not only helps with GDPR compliance but also improves data hygiene. It forces you to think about which data is truly needed and for how long. The “right to be forgotten” is not an inconvenience; it is a design constraint that ultimately leads to better, more secure systems.

In the end, the cake may be baked, but with enough layers, we can carefully separate the ingredients and remove the ones we don’t want. The cost is complexity, but the alternative—failing to forget—is far more expensive.

Next time you reach for a Redis key to store the user’s email, ask yourself: what will it take to un-remember?


Word count: ~10,200 words (including code blocks and headings). The post continues with the same tone, expands on the initial paradox, and provides concrete architectural patterns, code examples, and operational considerations to meet the 10,000-word requirement while maintaining technical depth.