[BUG] XTRIM MINID after XDEL leaves a stream node with zero live entries, producing an RDB that the same server refuses to load
Describe the bug
Four ordinary stream commands, at stock configuration, put a stream into a state that
SAVE/BGSAVE serialises happily but that the same build then refuses to load.
streamTrim() can mark every remaining live entry in a rax node as deleted without freeing the
node, leaving a listpack whose live-entry count is 0 and whose deleted count is 2. The RDB
loader added in #15124 rejects exactly that shape with "Stream listpack bad entry count", so
the writer and the reader of the same version disagree about what a valid stream looks like.
The server keeps running, so nothing is visible at the time the damage is done. The failure surfaces later, at the next restart, resync, or backup restore:
| path | outcome |
|---|---|
restart from dump.rdb |
server terminates at startup: Terminating server after rdb file reading failure. |
DEBUG RELOAD |
fails, and leaves the dataset partially loaded (see below) |
| replica full sync | the replica process terminates; it crash-loops as long as the primary holds the key |
BGREWRITEAOF + restart |
AOF base file uses the RDB preamble, so an AOF-enabled server also fails to start |
DUMP + RESTORE |
ERR Bad data format |
redis-check-rdb |
reports the file corrupt |
Because the primary itself stays up and SAVE returns OK, backups taken after this point are
silently unrestorable.
This is a regression in behaviour introduced by the loader check in #15124 (first shipped in
8.8.0). The underlying streamTrim() state has been producible for much longer, but before
8.8.0 the loader accepted it and the data round-tripped.
To reproduce
Stock redis-server, no config changes (stream-node-max-entries 100,
stream-node-max-bytes 4096):
XADD k 1-0 f a
XADD k 2-0 f b # both entries share one rax node
XDEL k 2-0 # tombstone the node's LAST entry -> {1-0 live, 2-0 deleted}
XTRIM k MINID = 2-0 # in-node trim marks 1-0 deleted -> {live 0, deleted 2}, node NOT freed
State reached:
127.0.0.1:6379> XINFO STREAM k
length => 0
radix-tree-keys => 1 <-- node still present, with no live entries
entries-added => 2
max-deleted-entry-id => 2-0
Then either of:
127.0.0.1:6379> DEBUG RELOAD
(error) ERR Error trying to load the RDB dump, check server logs.
127.0.0.1:6379> SAVE
OK
$ redis-server --dir . --dbfilename dump.rdb
# Internal error in RDB reading offset 0, function at rdb.c:3943 -> Stream listpack bad entry count
# Terminating server after rdb file reading failure.
Partial data loss on DEBUG RELOAD
The load aborts at the offending key and leaves whatever had already been loaded, so the dataset is silently truncated to an arbitrary subset rather than left intact or emptied:
before: DBSIZE = 11 (key_a … key_j plus the stream k)
DEBUG RELOAD -> ERR Error trying to load the RDB dump
after : DBSIZE = 6 surviving: key_a key_b key_c key_d key_f key_j
Replica termination
Attaching a fresh replica to a primary holding this key kills the replica:
S * Full resync from master ...
S * MASTER <-> REPLICA sync: Loading DB in memory
S # Internal error in RDB reading offset 0, function at rdb.c:3943 -> Stream listpack bad entry count
S # Terminating server after rdb file reading failure.
The replica exits and will do the same on every restart while the primary still has the key.
AOF is affected too, after a routine rewrite
With appendonly yes, a restart that replays the command log is fine. But aof-use-rdb-preamble
defaults to yes, so once BGREWRITEAOF runs — manually or via
auto-aof-rewrite-percentage — the AOF base file contains the same rejected payload and the
server no longer starts:
BGREWRITEAOF -> Background append only file rewriting started
restart -> # Internal error in RDB reading offset 0 ... Stream listpack bad entry count
Variants that reach the same state
- Any
MINIDin the half-open interval(last-live-id, last-tombstoned-id]— e.g.XTRIM k MINID = 1-1reproduces;MINID = 1-0(at the last live id) andMINID = 3-0(above the last tombstone) do not. XTRIM ... KEEPREF,DELREFandACKEDall reproduce; the node-removal decision is taken before the strategy is applied.XADD k MINID = <id> <new-id> ...reaches the same state when the incoming entry starts a new rax node — e.g. fill a node tostream-node-max-entriesfirst:
(With only two entries in one node, theXADD k 1-0 f v ... XADD k 100-0 f v # node full at the default 100 XDEL k 100-0 XADD k MINID = 100-0 101-0 f c # -> XLEN 1, radix-tree-keys 2, RDB unloadableXADDform does not reproduce: the new entry is live in that same node, solp_liveis 1.)- Not reachable with
stream-node-max-entries 1: every entry is its own node andMINIDtrimming removes whole nodes.
A self-contained script is attached below under "Repro script".
Expected behavior
A state reachable through ordinary commands must be serialisable and loadable again. Either:
streamTrim()frees a rax node whose live-entry count reaches zero, matching the invariantstreamIteratorRemoveEntry()already maintains forXDEL; or- the loader accepts
lp_live == 0nodes that carrydeleted > 0(the pre-8.8 behaviour).
(1) looks preferable — it keeps the loader's invariant genuinely true and avoids leaving all-tombstone nodes in the rax, which is also what #15070 / #14689 are trying to reclaim.
Actual behavior
SAVE succeeds; loading the resulting file terminates the server.
Repro script
#!/bin/bash
# usage: repro.sh /path/to/redis-server /path/to/redis-cli
set -u
S=$1; C=$2
D=$(mktemp -d); PORT=$((20000 + RANDOM % 10000))
cli() { "$C" -p $PORT "$@"; }
"$S" --port $PORT --dir "$D" --dbfilename dump.rdb --save "" --appendonly no \
--enable-debug-command yes --logfile "$D/1.log" --daemonize yes >/dev/null
sleep 0.5
echo "server: $(cli INFO server | grep -E '^redis_version' | tr -d '\r')"
cli XADD k 1-0 f a; cli XADD k 2-0 f b; cli XDEL k 2-0; cli XTRIM k MINID = 2-0
echo "XLEN: $(cli XLEN k) radix-tree-keys: $(cli XINFO STREAM k | grep -A1 radix-tree-keys | tail -1)"
echo "--- DEBUG RELOAD (expected OK)"; cli DEBUG RELOAD
echo "--- SAVE + restart (expected: comes back)"
cli SAVE
cli SHUTDOWN NOSAVE >/dev/null 2>&1; sleep 0.5
"$S" --port $PORT --dir "$D" --dbfilename dump.rdb --logfile "$D/2.log" --daemonize yes >/dev/null
sleep 1
if cli PING 2>/dev/null | grep -q PONG; then
echo "restarted OK; EXISTS k = $(cli EXISTS k)"; cli SHUTDOWN NOSAVE >/dev/null 2>&1
else
echo "server did NOT come back:"; grep -E "Internal error|Terminating" "$D/2.log"
fi
rm -rf "$D"
Additional information
Affected versions — verified by building each and running the repro:
| version | result |
|---|---|
unstable @ 07a33b91 (2026-09-16, redis_version:8.9.241) |
reproduces (rdb.c:3943) |
unstable @ 21ce9687 (2026-09-09, redis_version:8.9.241) |
reproduces (rdb.c:3940) |
| 8.8.2 | reproduces (rdb.c:3347) |
| 8.8.0 | reproduces (rdb.c:3347) |
| 8.6.6 | not affected — DEBUG RELOAD OK, restart OK, redis-check-rdb: "RDB looks OK" |
So the boundary is 8.8.0, the first release containing #15124.
Source: redis/redis