New to Kestra?
Use blueprints to kickstart your first workflows.
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.
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.
| 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.
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.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.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.route (io.kestra.plugin.core.flow.If):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.errors alerts Slack when the flow fails.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.
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.
item_id, a version that increases on
every update and a deleted_at timestamp set when a row is logically removed.key_prefix plus the id, carrying a
v field with the version it was written at.DEL and SET.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;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.SECRET_.| 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 |
POSTGRES_PASSWORD, REDIS_PASSWORD and SLACK_WEBHOOK_URL secrets.key_prefix to match your cache.auto_remediate: true and dry_run: false to evict and refresh, then read the
remediated log for the proof.hourly trigger, which always runs in safe mode.updated_at instead of a numeric version by returning it from the query and storing
it in the cached value.