Files
redis/tests/integration/replication-iothreads.tcl
7a58b432c7 Handle primary/replica clients in IO threads (#14335)
# Problem

While introducing Async IO
threads(https://github.com/redis/redis/pull/13695) primary and replica
clients were left to be handled inside main thread due to data race and
synchronization issues. This PR solves this issue with the additional
hope it increases performance of replication.

# Overview

## Moving the clients to IO threads

Since clients first participate in a handshake and an RDB replication
phases it was decided they are moved to IO-thread after RDB replication
is done. For primary client this was trivial as the master client is
created only after RDB sync (+ some additional checks one can see in
`isClientMustHandledByMainThread`). Replica clients though are moved to
IO threads immediately after connection (as are all clients) so
currently in `unstable` replication happens while this client is in
IO-thread. In this PR it was moved to main thread after receiving the
first `REPLCONF` message from the replica, but it is a bit hacky and we
can remove it. I didn't find issues between the two versions.

## Primary client (replica node)

We have few issues here:
- during `serverCron` a `replicationCron` is ran which periodically
sends `REPLCONF ACK` message to the master, also checks for timed-out
master. In order to prevent data races we utilize`IOThreadClientsCron`.
The client is periodically sent to main thread and during
`processClientsFromIOThread` it's checked if it needs to run the
replication cron behaviour.

- data races with main thread - specifically `lastinteraction` and
`read_reploff` members of the primary client that are written to in
`readQueryFromClient` could be accessed at the same time from main
thread during execution of `INFO REPLICATION`(`genRedisInfoString`). To
solve this the members were duplicated so if the client is in IO-thread
it writes to the duplicates and they are synced with the original
variables each time the client is send to main thread ( that means `INFO
REPLICATION` could potentially return stale values).

- During `freeClient` the primary client is fetched to main thread but
when caching it(`replicationCacheMaster`) the thread id will remain the
id of the IO thread it was from. This creates problems when resurrecting
the master client. Here the call to `unbindClientFromIOThreadEventLoop`
in `freeClient` was rewritten to call `keepClientInMainThread` which
automatically fixes the problem.

- During `exitScriptTimedoutMode` the master is queued for reprocessing
(specifically process any pending commands ASAP after it's unblocked).
We do that by putting it in the `server.unblocked_clients` list, which
are processed in the next `beforeSleep` cycle in main thread. Since this
will create a contention between main and IO thread, we just skip this
queueing in `unblocked_clients` and just queue the client to main thread
- the `processClientsFromIOThread` will process the pending commands
just as main would have.

## Replica clients (primary node)

We move the client after RDB replication is done and after replication
backlog is fed with its first message.
We do that so that the client's reference to the first replication
backlog node is initialized before it's read from IO-thread, hence no
contention with main thread on it.

### Shared replication buffer

Currently in unstable the replication buffer is shared amongst clients.
This is done via clients holding references to the nodes inside the
buffer. A node from the buffer can be trimmed once each replica client
has read it and send its contents. The reference is
`client->ref_repl_buf_node`. The replication buffer is written to by
main thread in `feedReplicationBuffer` and the refcounting is intrusive
- it's inside the replication-buffer nodes themselves.

Since the replica client changes the refcount (decreases the refcount of
the node it has just read, and increases the refcount of the next node
it starts to read) during `writeToClient` we have a data race with main
thread when it feeds the replication buffer. Moreover, main thread also
updates the `used` size of the node - how much it has written to it,
compared to its capacity which the replica client relies on to know how
much to read. Obviously replica being in IO-thread creates another data
race here. To mitigate these issues a few new variables were added to
the client's struct:

- `io_curr_repl_node` - starting node this replica is reading from
inside IO-thread
- `io_bound_repl_node` - the last node in the replication buffer the
replica sees before being send to IO-thread.

These values are only allowed to be updated in main thread. The client
keeps track of how much it has read into the buffer via the old
`ref_repl_buf_node`. Generally while in IO-thread the replica client
will now keep refcount of the `io_curr_repl_node` until it's processed
all the nodes up to `io_bound_repl_node` - at that point its returned to
main thread which can safely update the refcounts.
The `io_bound_repl_node` reference is there so the replica knows when to
stop reading from the repl buffer - imagine that replica reads from the
last node of the replication buffer while main thread feeds data to it -
we will create a data race on the `used` value
(`_writeToClientSlave`(IO-thread) vs `feedReplicationBuffer`(main)).
That's why this value is updated just before the replica is being send
to IO thread.
*NOTE*, this means that when replicas are handled by IO threads they
will hold more than one node at a time (i.e `io_curr_repl_node` up to
`io_bound_repl_node`) meaning trimming will happen a bit less
frequently. Tests show no significant problems with that.
(tnx to @ShooterIT for the `io_curr_repl_node` and `io_bound_repl_node`
mechanism as my initial implementation had similar semantics but was way
less clear)

Example of how this works:

* Replication buffer state at time N:
   | node 0| ... | node M, used_size K |
* replica caches `io_curr_repl_node`=0, `io_bound_repl_node`=M and
`io_bound_block_pos`=K
* replica moves to IO thread and processes all the data it sees
* Replication buffer state at time N + 1:
| node 0| ... | node M, used_size Full | |node M + 1| |node M + 2,
used_size L|, where Full > M
* replica moves to main thread at time N + 1, at this point following
happens
   - refcount to node 0 (io_curr_repl_node) is decreased
- `ref_repl_buf_node` becomes node M(io_bound_repl_node) (we still have
size-K bytes to process from there)
- refcount to node M is increased (now all nodes from 0 up to M-1
including can be trimmed unless some other replica holds reference to
them)
- And just before the replica is send back to IO thread the following
are updated:
   - `io_bound_repl_node` ref becomes node M+2
   - `io_bound_block_pos` becomes L

Note that replica client is only moved to main if it has processed all
the data it knows about (i.e up to `io_bound_repl_node` +
`io_bound_block_pos`)

### Replica clients kept in main as much as possible

During implementation an issue arose - how fast is the replica client
able to get knowledge about new data from the replication buffer and how
fast can it trim it. In order for that to happen ASAP whenever a replica
is moved to main it remains there until the replication buffer is fed
new data. At that point its put in the pending write queue and special
cased in handleClientsWithPendingWrites so that its send to IO thread
ASAP to write the new data to replica. Also since each time the replica
writes its whole repl data it knows about that means after it's send to
main thread `processClientsFromIOThread` is able to immediately update
the refcounts and trim whatever it can.

### ACK messages from primary

Slave clients need to periodically read `REPLCONF ACK` messages from
client. Since replica can remain in main thread indefinitely if no DB
change occurs, a new atomic `pending_read` was added during
`readQueryFromClient`. If a replica client has a pending read it's
returned back to IO-thread in order to process the read even if there is
no pending repl data to write.

### Replicas during shutdown

During shutdown the main thread pauses write actions and periodically
checks if all replicas have reached the same replication offset as the
primary node. During `finishShutdown` that may or may not be the case.
Either way a client data may be read from the replicas and even we may
try to write any pending data to them inside `flushSlavesOutputBuffers`.
In order to prevent races all the replicas from IO threads are moved to
main via `fetchClientFromIOThread`. A cancel of the shutdown should be
ok, since the mechanism employed by `handleClientsWithPendingWrites`
should return the client back to IO thread when needed.

## Notes

While adding new tests timing issues with Tsan tests were found and
fixed.

Also there is a data race issue caught by Tsan on the `last_error`
member of the `client` struct. It happens when both IO-thread and main
thread make a syscall using a `client` instance - this can happen only
for primary and replica clients since their data can be accessed by
commands send from other clients. Specific example is the `INFO
REPLICATION` command.
Although other such races were fixed, as described above, this once is
insignificant and it was decided to be ignored in `tsan.sup`.

---------

Co-authored-by: Yuan Wang <[email protected]>
Co-authored-by: Yuan Wang <[email protected]>
2026-01-21 16:19:12 +02:00

332 lines
11 KiB
Tcl

#
# Copyright (c) 2025-Present, Redis Ltd.
# All rights reserved.
#
# Licensed under your choice of (a) the Redis Source Available License 2.0
# (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
# GNU Affero General Public License v3 (AGPLv3).
#
# Tests for master and slave clients in IO threads
# Helper function to get master client IO thread from INFO replication
proc get_master_client_io_thread {r} {
return [status $r master_client_io_thread]
}
# Helper function to get slave client IO thread from INFO replication
proc get_slave_client_io_thread {r slave_idx} {
set info [$r info replication]
set lines [split $info "\r\n"]
foreach line $lines {
if {[string match "slave${slave_idx}:*" $line]} {
# Parse the slave line to extract io-thread value
set parts [split $line ","]
foreach part $parts {
if {[string match "*io-thread=*" $part]} {
set kv [split $part "="]
assert_equal [llength $kv] 2
return [lindex $kv 1]
}
}
}
}
return -1
}
start_server {overrides {io-threads 4 save ""} tags {"iothreads repl network external:skip"}} {
start_server {overrides {io-threads 4 save ""}} {
set master [srv 0 client]
set master_host [srv 0 host]
set master_port [srv 0 port]
set slave [srv -1 client]
test {Setup slave} {
$slave slaveof $master_host $master_port
wait_for_condition 1000 100 {
[s -1 master_link_status] eq {up}
} else {
fail "Replication not started."
}
}
test {Master client moves to IO thread after sync complete} {
# Check master client thread assignment (master client is on slave side)
wait_for_condition 100 100 {
[get_master_client_io_thread $slave] > 0
} else {
fail "Master client was not assigned to IO thread"
}
}
test {Slave client assignment to IO threads} {
# Verify slave is connected and online
wait_replica_online $master 0
# Slave client is connected - force a write so that it's assigned to an
# IO thread.
assert_equal "OK" [$master set x x]
# Check slave client thread assignment
wait_for_condition 50 100 {
[get_slave_client_io_thread $master 0] > 0
} else {
fail "Slave client was not assigned to IO thread"
}
}
test {WAIT command works with master/slave in IO threads} {
# Test basic WAIT functionality
$master set wait_test_key1 value1
$master set wait_test_key2 value2
$master incr wait_counter
assert {[$master wait 1 2000] == 1}
# Verify data reached slave
wait_for_condition 10 100 {
[$slave get wait_test_key1] eq "value1" &&
[$slave get wait_test_key2] eq "value2" &&
[$slave get wait_counter] eq "1"
} else {
fail "commands not propagated to IO thread slave in time"
}
}
test {Replication data integrity with IO threads} {
# Generate significant replication traffic
for {set i 0} {$i < 100} {incr i} {
$master set bulk_key_$i [string repeat "data" 10]
$master lpush bulk_list element_$i
$master zadd bulk_zset $i member_$i
if {$i % 20 == 0} {
# Periodically verify WAIT works
assert {[$master wait 1 2000] == 1}
}
}
# Final verification
wait_for_condition 50 100 {
[$slave get bulk_key_99] eq [string repeat "data" 10] &&
[$slave llen bulk_list] eq 100 &&
[$slave zcard bulk_zset] eq 100
} else {
fail "Replication data integrity failed"
}
}
test {WAIT timeout behavior with slave in IO thread} {
set slave_pid [srv -1 pid]
# Pause slave to test timeout
pause_process $slave_pid
# Should timeout and return 0 acks
$master set timeout_test_key timeout_value
set start_time [clock milliseconds]
assert {[$master wait 1 2000] == 0}
set elapsed [expr {[clock milliseconds] - $start_time}]
assert_range $elapsed 2000 2500
# Resume and verify recovery
resume_process $slave_pid
assert {[$master wait 1 2000] == 1}
# Verify data reached slave after resume
wait_for_condition 10 100 {
[$slave get timeout_test_key] eq "timeout_value"
} else {
fail "commands not propagated to IO thread slave in time"
}
}
test {Network interruption recovery with IO threads} {
# Generate traffic before interruption
for {set i 0} {$i < 50} {incr i} {
$master set pre_interrupt_$i value_$i
}
# Simulate network interruption
pause_process $slave_pid
# Continue writing during interruption
for {set i 0} {$i < 50} {incr i} {
$master set during_interrupt_$i value_$i
}
# WAIT should timeout
assert {[$master wait 1 2000] == 0}
# Resume slave and verify recovery
resume_process $slave_pid
# Verify WAIT works again
assert {[$master wait 1 2000] == 1}
# Wait for reconnection and catch up
wait_for_condition 100 100 {
[$slave get during_interrupt_49] eq "value_49"
} else {
fail "Slave didn't catch up after network recovery"
}
$master set post_recovery_test recovery_value
wait_for_condition 10 100 {
[$slave get post_recovery_test] eq "recovery_value"
} else {
fail "Slave didn't receive 'set post_recovery_test' command"
}
# Check thread assignments after recovery
wait_for_condition 100 100 {
[get_master_client_io_thread $slave] > 0
} else {
fail "Slave client not assigned to IO thread after recovery"
}
}
test {Replication reconnection cycles with IO threads} {
# Test multiple disconnect/reconnect cycles
for {set cycle 0} {$cycle < 3} {incr cycle} {
# Generate traffic
for {set i 0} {$i < 20} {incr i} {
$master set cycle_${cycle}_key_$i value_$i
}
assert {[$master wait 1 2000] == 1}
# Record thread assignments during cycle
set master_thread [get_master_client_io_thread $slave]
set slave_thread [get_slave_client_io_thread $master 0]
puts "Cycle $cycle - Master thread: $master_thread, Slave thread: $slave_thread"
# Disconnect and reconnect (except last cycle)
if {$cycle < 2} {
$slave replicaof no one
after 100
$slave replicaof $master_host $master_port
wait_for_sync $slave
}
}
# Verify final state
wait_for_condition 10 100 {
[$slave get cycle_2_key_19] eq "value_19"
} else {
fail "last command not propagated to IO thread slave in time"
}
}
test {INFO replication shows correct thread information} {
# Test INFO replication output format
set info [$master info replication]
# Should show master role
assert_match "*role:master*" $info
# Should have slave thread information
assert_match "*slave0:*io-thread=*" $info
# Test we can parse the thread ID
set slave_thread [get_slave_client_io_thread $master 0]
assert_morethan $slave_thread 0
# Test master client thread info
set slave_info [$slave info replication]
assert_match "*role:slave*" $slave_info
assert_match "*master_client_io_thread:*" $slave_info
set master_thread [get_master_client_io_thread $slave]
assert_morethan $master_thread 0
}
}
}
start_server {overrides {io-threads 4 save ""} tags {"iothreads repl network external:skip"}} {
start_server {overrides {io-threads 4 save ""}} {
start_server {overrides {io-threads 4 save ""}} {
start_server {overrides {io-threads 4 save ""}} {
set master [srv 0 client]
set master_host [srv 0 host]
set master_port [srv 0 port]
set slave1 [srv -1 client]
set slave2 [srv -2 client]
set slave3 [srv -3 client]
test {Multiple slaves across IO threads} {
# Setup replication for all slaves
$slave1 replicaof $master_host $master_port
$slave2 replicaof $master_host $master_port
$slave3 replicaof $master_host $master_port
# Wait for all slaves to be online
wait_replica_online $master 0
wait_replica_online $master 1
wait_replica_online $master 2
set iterations 5
while {[incr iterations -1] >= 0} {
# Slave clients are connected - force a write so that they are assigned
# to IO threads.
assert_equal "OK" [$master set x x]
wait_for_condition 10 100 {
([get_slave_client_io_thread $master 0] > 0) &&
([get_slave_client_io_thread $master 1] > 0) &&
([get_slave_client_io_thread $master 2] > 0)
} else {
continue
}
break
}
if {$iterations < 0} {
fail "Replicas failed to be assigned to IO threads in time"
}
# Test concurrent replication to all slaves
for {set i 0} {$i < 200} {incr i} {
$master set multi_key_$i value_$i
if {$i % 50 == 0} {
assert {[$master wait 3 2000] == 3}
}
}
# Final verification all slaves got data
wait_for_condition 50 100 {
[$slave1 get multi_key_199] eq "value_199" &&
[$slave2 get multi_key_199] eq "value_199" &&
[$slave3 get multi_key_199] eq "value_199"
} else {
fail "Multi-slave replication failed"
}
}
test {WAIT with multiple slaves in IO threads} {
# Test various WAIT scenarios
$master set wait_multi_test1 value1
assert {[$master wait 3 2000] == 3}
$master set wait_multi_test2 value2
assert {[$master wait 2 2000] >= 2}
$master set wait_multi_test3 value3
assert {[$master wait 1 2000] >= 1}
# Verify all slaves have the data
wait_for_condition 10 100 {
[$slave1 get wait_multi_test3] eq "value3" &&
[$slave2 get wait_multi_test3] eq "value3" &&
[$slave3 get wait_multi_test3] eq "value3"
} else {
fail "commands not propagated to io thread slaves in time"
}
}
}
}
}
}