Skip to content

redis: write to the master only, Redis Sentinel support and repair of refused writes - #2212

Closed
kbinetruy wants to merge 5 commits into
sipwise:masterfrom
kbinetruy:redis-sentinel
Closed

kbinetruy wants to merge 5 commits into
sipwise:masterfrom
kbinetruy:redis-sentinel

Conversation

@kbinetruy

@kbinetruy kbinetruy commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Behind Redis Sentinel, rtpengine stays on the address it was given. After a failover its writes go to the demoted master, which refuses them (with --redis-write, for good), and the new master refuses writes with NOREPLICAS until it has a replica. Refused or skipped writes are never made again. The first commit makes the write connection require a master, by default; the other four, opt-in, follow the master through Sentinel and write back what the failover lost.

  1. MT#55283 redis: write call data to a master only
  2. MT#55283 redis: find the master through Redis Sentinel
  3. MT#55283 redis: keyspace subscription follows the Sentinel master
  4. MT#55283 redis: add --redis-repair-writes
  5. MT#55283 redis: add --redis-resync-on-master-change

23 files, +3185 −85; 1840 of the added lines are tests.

Lab: Redis 7.2, 3 Sentinels, min-replicas-to-write 1, ~21 writes/s Before #2211 #2211 + Sentinel + repair, resync
Failover: acknowledged creates never stored 114–149 of 152–200 114–115 of 152 21–24 of 152 0
Failover: deleted calls that come back 3 3 3 0
Frozen master: longest time without an ng reply 34.3–35.5 s 1.30–1.33 s 1.31–1.33 s 1.29–1.33 s
Frozen master: deleted calls that come back 0–1 2 2 0
Long calls restored with stale state 4 of 4 4 of 4 0 of 4 0 of 4
Runs per scenario 3–5 3 3 10

With Sentinel, rtpengine used the new master 1.26–1.66 s after SENTINEL FAILOVER. Valkey 8.1 gave the same results.

1. Master only. --redis-write now requires the master role at connect, as --redis alone already did, and a connection that requires it is dropped on a write refused with READONLY or MASTERDOWN. The reconnect then checks the role, paced by --redis-reconnect-pause. Other refusals, such as NOREPLICAS, keep the connection. The read database of --redis still accepts any role when --redis-write is given.

2. Sentinel. The keyword sentinel replaces IP:PORT in --redis, --redis-write or --redis-subscribe. A "redis sentinel" thread asks every Sentinel each --redis-sentinel-interval, and at once on a role error. It takes the highest config epoch, never an older one than in use, checks the candidate with ROLE, and publishes it with a generation number. redis_check_conn() compares that number and reconnects on a change. No Sentinel I/O runs under r->lock.

3. Subscription. With sentinel, a one-second timer in the notification loop closes the subscription when the master changed, and the loop subscribes again on the new master. Missed notifications are not replayed.

4. --redis-repair-writes. An own call whose SET or DEL was refused, lost with the connection or skipped is queued (at most 100000): a live call by reference, a deleted call's key by ID and database. A "redis repair" thread writes it again, one call at a time: the call's current state, or a DEL unless a new call has taken the ID (an unwritten one then takes the key over). After taking the lock it lets waiting signalling writes go first (at most 100 ms), and waits 1 s after a failed write. The lock order is in a comment at the top of the repair code.

5. --redis-resync-on-master-change. redis_connect() keeps master_replid from its INFO. When the write connection reconnects to a master with another ID (Sentinel switch, moved address, restart), every own call stored at least once (walked with the call iterator) and the recent own DELs are queued for the repair. No key is scanned.

Option Default Meaning
--redis-sentinel=[PW@]IP:PORT[,…] unset The Sentinels.
--redis-sentinel-master=NAME unset The master's name.
--redis-sentinel-interval=MS 1000 Time between Sentinel queries.
--redis-repair-writes off Rewrite refused or skipped writes.
--redis-resync-on-master-change off Rewrite own calls on a new master. Enables the repair.
--redis-resync-delete-window=MS 30000 How long own DELs are remembered for the resync.
--redis-resync-delete-max=INT 4096 How many at most.

New statistics: redisrepairedwrites, redisresyncs, redisresynccalls and redisresyncdeletes.

Testing. Seven new daemon tests (redis-master-only, redis-sentinel, redis-sentinel-start, redis-sentinel-subscribe, redis-repair, redis-repair-ids, redis-resync), with fake Redis and fake Sentinel servers; each fails on its parent commit; about 56 s in all. In redis-errors, the refusals that keep the connection are now NOREPLICAS and LOADING (a READONLY drops it). make asan-check on master f950a9f7 with the TLS follow-up: exit 0 on Debian trixie and Ubuntu noble, 10235–10237 ok, 0 not ok.

Questions:

  1. Is the sentinel keyword in the existing endpoint options right, or would you prefer per-role flags or one switch?
  2. Should the repair and the resync stay two opt-in options?

Lab method, failure modes and locking notes: https://github.com/kbinetruy/rtpengine/blob/redis-notes/redis-sentinel-notes.md

--redis-write accepts any role, so after a failover that demotes its
node, rtpengine reconnects to the same address, accepts the replica,
and every later write is refused with READONLY. --redis alone requires
the master at connect, but keeps the connection when its writes are
refused.

The write connection now requires the master role, as --redis alone
does, and a connection that requires it is dropped on a write refused
with READONLY or MASTERDOWN, so that the next operation reconnects and
checks the role. Other refusals keep the connection. The read database
of --redis, when --redis-write is given, still accepts any role.

Tested by auto-daemon-tests-redis-master-only.pl; the READONLY cases
of auto-daemon-tests-redis-errors.pl, which expected the connection to be
kept, now use NOREPLICAS.
Behind Redis Sentinel, rtpengine stays on the address it was given:
after a failover its writes go to the old master, which refuses them
until it is demoted, then serves them as a replica.

New options --redis-sentinel, --redis-sentinel-master and
--redis-sentinel-interval; the keyword "sentinel" replaces IP:PORT in
--redis, --redis-write or --redis-subscribe. A "redis sentinel" thread
asks every Sentinel for the master, takes the highest config epoch
(never an older one than in use), checks the node with ROLE and
publishes it with a generation number. It also runs at once when a
connection meets a replica. redis_check_conn() compares the generation
and reconnects on a change; these connections require the master role.
Without "sentinel", none of this runs.

Tested by auto-daemon-tests-redis-sentinel.pl and -sentinel-start.pl.
A subscription made through "sentinel" stayed on the old master after a
failover, and missed the notifications of the new one.

With Sentinel, the notification event loop gets a one-second timer:
when the master changed, it closes the subscription, and
redis_notify_loop() subscribes again on the new master. The
subscription's context also gets a connect callback, since hiredis
frees a context that fails to connect without calling the disconnect
callback. Notifications missed during the switch are not replayed.

Tested by auto-daemon-tests-redis-sentinel-subscribe.pl.
After a failover the new master refuses writes with NOREPLICAS until it
has a replica. Those writes are counted and logged, but never made
again: a call created then is missing at the next restore, and a call
deleted then comes back.

With --redis-repair-writes (off by default), an own call whose write was
not stored (an error reply, a connection lost before the reply, a
skipped write) is marked in a table keyed by call ID: the call itself,
held, while it is in the call table; its ID and database once it is
deleted, as a deleted call keeps its memory until its last reference
goes. A stored write clears the mark. A marked SET supersedes a marked
DEL of the same ID; if its call was never written, it takes that key
over, so that its own deletion deletes it. A "redis repair" thread
writes the marked calls again, one at a time, through
redis_write_call(), now shared with redis_update_onekey(): a live call
gets its current state, the key of a deleted call a DEL, unless a new
call uses its ID. Signalling writes waiting for r->lock go first, for
100 ms at most. A failed write is retried after a second; at most 100000
calls are kept. New statistic: redisrepairedwrites. A repaired DEL
assumes one writer per database.

Tested by auto-daemon-tests-redis-repair.pl and
auto-daemon-tests-redis-repair-ids.pl.
Replication is asynchronous, so a master that is replaced takes with it
the writes it acknowledged but had not replicated: a call created then
is missing on the new master, and a call deleted then comes back.

--redis-resync-on-master-change (off by default) keeps the
master_replid of the INFO that redis_connect() already sends. When the
write connection reconnects to another ID, every own call stored at
least once, and the own DELs of the last --redis-resync-delete-window
(at most --redis-resync-delete-max), are queued for the repair thread
of --redis-repair-writes; the calls through the call iterator, one at a
time. No key is scanned. New statistics: redisresyncs, redisresynccalls
and redisresyncdeletes.

Tested by auto-daemon-tests-redis-resync.pl.
Comment thread daemon/redis.c Outdated
return;
LOCK(&redis_repair_lock);
if (!redis_repair_pending)
redis_repair_pending = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using str objects as key type for the hash table (str_hash + str_equal) to avoid having to dup/copy the key strings all the time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed together with the other comments on this table; see the reply under the call_t comment.

Comment thread daemon/redis.c Outdated
static mutex_t redis_repair_lock = MUTEX_STATIC_INIT;
static cond_t redis_repair_cond = COND_STATIC_INIT;
static GHashTable *redis_repair_pending;
static atomic64 redis_repair_count; /* entries, for a lock-free test */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repair_count seems to be effectively just a bool flag? Also since it's used as a condition it should always be accessed under the lock. AFAICT it can probably be removed altogether?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed together with the other comments on this table; see the reply under the call_t comment.

Comment thread daemon/redis.c Outdated
}
}
g_hash_table_replace(redis_repair_pending, g_strndup(callid->s, callid->len),
GINT_TO_POINTER(db + 1));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why + 1 and then - 1 below? Is the LLM assuming that this is needed to distinguish absent keys from keys with zero values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed together with the other comments on this table; see the reply under the call_t comment.

Comment thread daemon/redis.c Outdated
Comment on lines +4191 to +4195
if (g_hash_table_iter_next(&it, &k, &v)) {
g_hash_table_iter_steal(&it);
id = k;
db = GPOINTER_TO_INT(v) - 1;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about keeping call_t objects directly in the hash table (by reference) instead of using their call IDs and then having to look them up? Could even use a queue instead of hash table. Just an idea.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all four are right. Reworked: one str-keyed table whose entries hold the call itself while it is in the call table, and only its ID and DB once it is gone. A destroyed call_t keeps its memory until its last reference: about 54 KB for a plain call, about 20 KB more per re-INVITE, so about 5 GB at the 100000 cap. Its deletion converts or removes the entry. No lookup and no copies for live calls; repair_count and the +1 are gone, and the table is created once. A queued SET of an unwritten call that supersedes a queued DEL of its ID takes that key over, as in #2211. I kept a table rather than a queue because the cap relies on collapsing duplicates by ID.

@kbinetruy
kbinetruy force-pushed the redis-sentinel branch 2 times, most recently from 693942b to ec0db66 Compare October 23, 2026 12:40
Comment thread daemon/main.c Resolved

@rfuchs rfuchs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall seems reasonable. A few more comments. The IPC logic between the threads seems a bit shaky, but OK.

Comment thread daemon/redis_sentinel.c Resolved
Comment thread daemon/redis.c Resolved
Comment thread daemon/redis.c Resolved
Comment thread daemon/redis.c Resolved
Comment thread daemon/redis.c Resolved
kbinetruy added a commit to kbinetruy/rtpengine that referenced this pull request Nov 4, 2026
Writing to a master only is now the default (--redis-require-master is gone); the resync walks the calls with the call iterator; lock order, test names and counts, and make asan-check on master f950a9f as in the reworked series. sipwise#2211 is merged.
@kbinetruy kbinetruy changed the title redis: opt-in Redis Sentinel support and repair of refused writes redis: write to the master only, Redis Sentinel support and repair of refused writes Nov 4, 2026

@rfuchs rfuchs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more hint for your LLM. If no interest then I will try to merge as it is.

Comment thread daemon/redis_sentinel.c Resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants