Query icon
OutputValues icon
RedisCLI icon
Docker icon
Log icon
If icon
SlackIncomingWebhook icon
Delete icon
Loop icon
Set icon
Fail icon
Schedule icon

Redis Cache vs PostgreSQL Staleness Reconcile Guard

Find Redis keys orphaned or stale against a PostgreSQL source of truth, alert Slack, evict and refresh them, then re-read and fail if any divergence remains.

Categories
DataInfrastructure

A read-through or write-behind cache drifts from its database in two ways that no single system can see on its own. A row deleted in PostgreSQL can leave its key behind in Redis, so the application keeps serving a record that no longer exists. A row updated in PostgreSQL can leave an older copy in the cache, so the application serves a value that is behind the truth.

This blueprint reconciles the two. It reads the authoritative keyset and a per-row version from PostgreSQL first, then probes exactly those keys in Redis with one MGET. A SCAN of Redis is never used, because its order is not stable and its counts drift between runs, so a cache diff built on it is not reproducible. Driving the keyset from the source of truth makes every run deterministic.

It reports two kinds of divergence, alerts Slack and on request evicts the orphans with DEL and refreshes the stale keys with SET from the database value, then reads the same keys again and fails the run if anything is still wrong.

This blueprint was created by zkasuran.

Findings

Finding Meaning Fix
ORPHAN The row is deleted in PostgreSQL (its deleted_at is set) but the key is still cached DEL the key
STALE The row is live but the cached version is not the database version SET the key to the current database value

A live row with no cached key is a cache miss, not a divergence, so it is counted and left alone. A deleted row with no cached key is already correct. The source of truth records deletions with a deleted_at timestamp, which is why the orphan set can be derived from PostgreSQL rather than from a scan of Redis.

How it works

  1. pg_read (io.kestra.plugin.jdbc.postgresql.Query, read-only) returns every row with its id, its version and whether it is deleted, in id order, up to batch_limit rows.
  2. keyset builds one MGET over the key of each row (key_prefix plus the id). redis_read (io.kestra.plugin.redis.cli.RedisCLI) runs it, so the cached value of every source key comes back in one round trip, with a nil for a miss.
  3. diff compares the two by position: an orphan is a deleted row still cached, a stale key is a live row whose cached version is not the database version. plan splits the result into keys to evict, keys to refresh and the full affected set. summary logs it.
  4. route (io.kestra.plugin.core.flow.If):
    • clean: logs it, nothing to do.
    • divergence: alert posts to Slack.
      • remediate_gate runs only when auto_remediate is true and dry_run is false.
      • evict_orphans (io.kestra.plugin.redis.string.Delete) removes the orphan keys.
      • refresh_stale (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) writes each stale key back from the database value with io.kestra.plugin.redis.string.Set.
      • reverify_read reads the affected keys again and verify_result fails the run if any orphan is still cached or any refreshed key is still off the database version.
  5. errors alerts Slack when the flow fails.

What you get

  • A per-key reconcile across two live systems, with PostgreSQL as the source of truth, not a diff of two Redis scans.
  • The orphans and the stale keys named, with the database version next to the cached version.
  • Eviction and refresh applied only behind two flags, one key at a time for the writes, then a second read of the same keys as proof.

Who it's for

  • Teams that cache PostgreSQL rows in Redis and have served a deleted record or a stale value.
  • SREs who want a scheduled check that the cache has not drifted from the database.
  • Teams evaluating the PostgreSQL and Redis plugins who want one flow that reads from both and writes to one.

Why orchestrate this with Kestra

A cache drifts quietly and the drift spans two systems that each look healthy alone. Kestra reads the authoritative keyset from PostgreSQL, probes exactly those keys in Redis, sends the divergence to Slack, evicts and refreshes only behind auto_remediate and dry_run, verifies the result and keeps a history of every reconcile.

Tested end to end

On Kestra 2.0.5 OSS against PostgreSQL 16 (w26-rec-pg) and Redis 7 (w26-rec-redis, password set), with a local HTTP sink standing in for Slack. The source table held 8 rows: 5 live (item:1 v5, item:2 v5, item:3 v7, item:4 v3, item:5 v2) and 3 deleted (item:6, item:7, item:8). The cache held item:1 v5, item:2 v5, item:3 v4, item:4 v2, item:6 v9, item:7 v4. So item:3 plus item:4 were stale, item:6 plus item:7 were orphaned, item:5 was a plain miss and item:8 was already gone.

Run Inputs Result
1 defaults 4 divergences (orphan item:6, item:7; stale item:3 db=7 cache=4, item:4 db=3 cache=2). Alert sent. redis-cli confirmed the 6 keys unchanged
2 auto_remediate: true, dry_run: true Same 4 divergences. The gate stayed closed. redis-cli confirmed no key changed
3 auto_remediate: true, dry_run: false Evicted 2 orphans, refreshed 2 stale keys, re-read reported 0 remaining. redis-cli after: item:6 plus item:7 gone, item:3 now v7, item:4 now v3, item:1 plus item:2 untouched. psql confirmed the table unchanged
4 clean cache, defaults 0 divergences, clean branch, no alert. The miss (item:5) plus the already-evicted delete (item:8) were not flagged
5 unreachable PostgreSQL host pg_read failed, the run failed, the errors block posted to Slack
6 auto_remediate: true, dry_run: false, with a writer re-adding orphan item:6 during the run Re-read still found item:6 cached. verify_result failed the run rather than reporting success, which is the point of the re-verify step

Detector proof: the degraded cache reported 4 and the clean cache reported 0, both matching the counts measured straight from psql plus redis-cli.

Prerequisites

  • A PostgreSQL table that is the source of truth, with an item_id, a version that increases on every update and a deleted_at timestamp set when a row is logically removed.
  • A Redis instance that caches one JSON value per row under key_prefix plus the id, carrying a v field with the version it was written at.
  • For the audit, a PostgreSQL role that can read the table. For remediation, a Redis user allowed to run DEL and SET.
  • Local testing:
    • docker run -d --name pg --network kw7 -e POSTGRES_PASSWORD=pw postgres:16;
    • docker run -d --name cache --network kw7 redis:7-alpine redis-server --requirepass pw;
    • seed a few rows, cache some at the right version, cache others a version behind, then leave a cached key for a deleted row.

Secrets

  • POSTGRES_PASSWORD: the password for pg_user, read by every query.
  • REDIS_PASSWORD: the Redis password, left empty for an instance with no password.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
pg_host / pg_port / pg_db / pg_user localhost, 5432, postgres, postgres PostgreSQL connection
pg_password secret('POSTGRES_PASSWORD') PostgreSQL password
pg_table catalog_item Source of truth table
redis_host / redis_port localhost, 6379 Redis connection
redis_password secret('REDIS_PASSWORD') Redis password, empty for none
key_prefix item: Cache key is this prefix plus the row id
batch_limit 5000 Upper bound on the keyset read in one run
network empty Docker network for the redis-cli container
auto_remediate false Allow DEL and SET
dry_run true Must be false for a change to apply

Quick start

  1. Add the POSTGRES_PASSWORD, REDIS_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow and set the connection inputs and key_prefix to match your cache.
  3. Run it once and read the orphan and stale keys.
  4. Run it with auto_remediate: true and dry_run: false to evict and refresh, then read the remediated log for the proof.
  5. Enable the hourly trigger, which always runs in safe mode.

How to extend

  • Compare on updated_at instead of a numeric version by returning it from the query and storing it in the cached value.
  • Page through a large table by ordering on the id and carrying a cursor in a KV store.
  • Reconcile several caches by looping over a list of prefixes and hosts.
  • Add a staleness budget so a key behind by one version is tolerated for a short window.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.