#15808·Redis

[BUG] XTRIM MINID after XDEL leaves a stream node with zero live entries, producing an RDB that the same server refuses to load

Author: hanke580Created Sep 16, 2026Updated Sep 16, 2026

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 MINID in the half-open interval (last-live-id, last-tombstoned-id] — e.g. XTRIM k MINID = 1-1 reproduces; MINID = 1-0 (at the last live id) and MINID = 3-0 (above the last tombstone) do not.
  • XTRIM ... KEEPREF, DELREF and ACKED all 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 to stream-node-max-entries first:
    XADD 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 unloadable
    
    (With only two entries in one node, the XADD form does not reproduce: the new entry is live in that same node, so lp_live is 1.)
  • Not reachable with stream-node-max-entries 1: every entry is its own node and MINID trimming 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:

  1. streamTrim() frees a rax node whose live-entry count reaches zero, matching the invariant streamIteratorRemoveEntry() already maintains for XDEL; or
  2. the loader accepts lp_live == 0 nodes that carry deleted > 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.