Skip to content

Commit 6da3edf

Browse files
markhannumclaude
andcommitted
test: reproduce sql_logfill auto-disable when master is at capacity
Adds tests/sql_logfill_capacity_autodisable.test, which drives the exact condition the feature commit handles: the master rejecting the logfill's comdb2_transaction_logs() request because it is at capacity. To make it deterministic (rather than racing real sql-pool saturation) the test loads a query-prioritization ruleset on the master that REJECTs any query against comdb2_transaction_logs -- a ruleset REJECT is delivered as CDB2ERR_REJECTED, the same error a full sql queue produces. It then stops a replicant, advances the log to open a gap, and restarts the replicant. Since send_rep_all_req/send_rep_log_req are suppressed while sql_logfill is active, the gap cannot close via normal replication, so the replicant's logfill keeps retrying and its request-failure count climbs to the threshold. Phase 1 asserts: the master rejects the probe query (reproduction condition); the restarted replicant comes up; its auto-disable is driven by the rejected comdb2_transaction_logs request (not an unrelated connect blip); and after auto-disable the replicant catches up via the restored normal fill path. The expected row count is validated so a blank read cannot pass the catch-up check. Phase 2 then frees the ruleset, clears the (now writable) auto-disable tunable on the replicant at runtime, and asserts the parked logfill thread resumes instead of staying dead until a restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Hannum <mhannum@bloomberg.net>
1 parent b83d5ca commit 6da3edf

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ifeq ($(TESTSROOTDIR),)
2+
include ../testcase.mk
3+
else
4+
include $(TESTSROOTDIR)/testcase.mk
5+
endif
6+
export CHECK_DB_AT_FINISH=0
7+
ifeq ($(TEST_TIMEOUT),)
8+
export TEST_TIMEOUT=5m
9+
endif
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Enable sql_logfill (READONLY - must be set at startup)
2+
sql_logfill 1
3+
sql_logfill_debug 1
4+
5+
# Auto-disable after this many consecutive failed log requests to a saturated
6+
# master. Kept low so the test trips quickly.
7+
sql_logfill_request_fail_autodisable_threshold 3
8+
9+
# Enable the query-prioritization ruleset engine so we can make the master
10+
# reject the logfill query (simulating "all sql engines busy, queue full").
11+
prioritize_queries 1
12+
verbose_prioritize_queries 1
13+
14+
logmsg level debug
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version 1
2+
3+
rule 1 action REJECT
4+
rule 1 flags {PRINT STOP}
5+
rule 1 mode {REGEXP NOCASE}
6+
rule 1 sql .*comdb2_transaction_logs.*
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Reproduces the condition where the master's sql engines are all busy and the
4+
# dispatch queue is full: the master rejects the logfill's
5+
# comdb2_transaction_logs() request. Previously the logfill thread would keep
6+
# hammering the saturated master forever; now it auto-disables after
7+
# gbl_sql_logfill_request_fail_autodisable_threshold consecutive failed requests
8+
# and falls back to the traditional fill-request path.
9+
#
10+
# We make the rejection deterministic (instead of racing real pool saturation)
11+
# by loading a query-prioritization ruleset on the master that REJECTs any
12+
# query against comdb2_transaction_logs. A ruleset REJECT is delivered to the
13+
# client as CDB2ERR_REJECTED -- exactly the error a full sql queue produces.
14+
# That code is retryable, so the cdb2 client retries it and surfaces it to the
15+
# logfill as a connect/io error; the logfill counts those and auto-disables.
16+
#
17+
# Because send_rep_all_req()/send_rep_log_req() are suppressed while sql_logfill
18+
# is active, the gap on a behind replicant cannot close via normal replication
19+
# until logfill auto-disables. That makes the failure count climb reliably to
20+
# the threshold with no timing race.
21+
#
22+
# Phase 2 then frees the ruleset, clears the (now writable) auto-disable tunable
23+
# at runtime, and confirms the parked logfill thread resumes instead of staying
24+
# dead until a restart.
25+
26+
bash -n "$0" || exit 1
27+
28+
. ${TESTSROOTDIR}/tools/runit_common.sh
29+
. ${TESTSROOTDIR}/tools/cluster_utils.sh
30+
31+
export debug=1
32+
[[ "$debug" == "1" ]] && set -x
33+
34+
db=$1
35+
36+
RULESET_NAME="reject_logfill.ruleset"
37+
DISABLE_MSG="auto-disabling sql logfill after .* consecutive request failures"
38+
39+
cnt=$(echo $CLUSTER | wc -w)
40+
if [[ -z "$CLUSTER" || $cnt -lt 3 ]]; then
41+
failexit "This test requires a clustered installation of at least 3 nodes"
42+
fi
43+
44+
# Copy the reject ruleset to every node and load it. Only the master enforces
45+
# it (the logfill query runs there), but we load it everywhere so the test is
46+
# robust to a master change.
47+
function distribute_and_load_ruleset {
48+
for node in $CLUSTER; do
49+
if [[ "$node" == "$(hostname)" ]]; then
50+
mkdir -p "$DBDIR/rulesets/"
51+
cp "$RULESET_NAME" "$DBDIR/rulesets/" || failexit "cp ruleset failed"
52+
else
53+
ssh -n -o StrictHostKeyChecking=no "$node" "mkdir -p $DBDIR/rulesets/" </dev/null \
54+
|| failexit "mkdir rulesets on $node failed"
55+
scp -o StrictHostKeyChecking=no "$RULESET_NAME" "$node:$DBDIR/rulesets/" \
56+
|| failexit "scp ruleset to $node failed"
57+
fi
58+
done
59+
60+
for node in $CLUSTER; do
61+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$node" \
62+
"exec procedure sys.cmd.send('reload_ruleset $DBDIR/rulesets/$RULESET_NAME')" \
63+
|| failexit "Failed to load ruleset on $node"
64+
done
65+
}
66+
67+
# Confirm the reproduction condition: the master rejects a comdb2_transaction_logs
68+
# query (the exact query the logfill thread runs). The client retries the
69+
# retryable REJECTED code and ultimately reports a connect/io error, so we don't
70+
# assert on the client error string; instead we assert the probe failed and that
71+
# the master logged the ruleset rejection.
72+
function verify_master_rejects_logfill_query {
73+
local master=$1
74+
local out rc
75+
out=$($CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$master" \
76+
"select count(*) from comdb2_transaction_logs(NULL, NULL, 4)" 2>&1)
77+
rc=$?
78+
echo " transaction_logs probe: rc=$rc out=[$out]"
79+
if [[ $rc -eq 0 ]]; then
80+
stop_all_nodes
81+
failexit "Expected master to REJECT the comdb2_transaction_logs query, but it succeeded"
82+
fi
83+
84+
# verbose_prioritize_queries logs the ruleset rejection on the master.
85+
local mlog="${TESTDIR}/logs/${DBNAME}.${master}.db"
86+
local elapsed=0
87+
while [[ $elapsed -lt 20 ]]; do
88+
if grep -qiE "REJECTED.*comdb2_transaction_logs|Rejected due to rule" "$mlog" 2>/dev/null; then
89+
echo " OK: master's ruleset rejects the logfill query (reproduction condition set up)"
90+
return 0
91+
fi
92+
sleep 2
93+
elapsed=$((elapsed + 2))
94+
done
95+
stop_all_nodes
96+
failexit "Probe failed (rc=$rc) but master log shows no ruleset rejection: $mlog"
97+
}
98+
99+
# Start a single (already-stopped) cluster node. Mirrors kill_restart_node's
100+
# start branch; we need to start the victim *after* advancing the log so it comes
101+
# back with a gap.
102+
function start_node {
103+
local node=$1
104+
local LOGDIR=$TESTDIR/logs
105+
local REP_ENV_VARS="${DBDIR}/replicant_env_vars"
106+
local PARAMS="--no-global-lrl --lrl $DBDIR/${DBNAME}.lrl --pidfile ${TMPDIR}/${DBNAME}.${node}.pid"
107+
108+
pushd "$DBDIR" >/dev/null
109+
mv --backup=numbered "$LOGDIR/${DBNAME}.${node}.db" "$LOGDIR/${DBNAME}.${node}.db.1" 2>/dev/null
110+
if [[ "$node" == "$(hostname)" ]]; then
111+
$COMDB2_EXE ${DBNAME} ${PARAMS} &> "$LOGDIR/${DBNAME}.${node}.db" &
112+
else
113+
local CMD="cd ${DBDIR}; source ${REP_ENV_VARS} ; $COMDB2_EXE ${DBNAME} ${PARAMS}"
114+
ssh -n -o StrictHostKeyChecking=no -tt "$node" ${CMD} &> "$LOGDIR/${DBNAME}.${node}.db" &
115+
echo $! > "${TMPDIR}/${DBNAME}.${node}.pid"
116+
fi
117+
popd >/dev/null
118+
}
119+
120+
# Advance the log on the master well past the (down) victim, creating a real gap.
121+
function advance_log {
122+
local master=$1
123+
echo "Advancing the log on $master to open a gap"
124+
# Keep each insert under the default maxosqltransfer (50000 record
125+
# modifications per transaction), or the master rejects it with
126+
# "transaction too big" before we ever reach the logfill saturation path.
127+
for i in $(seq 1 5); do
128+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$master" \
129+
"insert into t1 select * from generate_series(1, 40000)" >/dev/null \
130+
|| failexit "insert to advance log failed"
131+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$master" \
132+
"exec procedure sys.cmd.send('flush')" >/dev/null 2>&1
133+
done
134+
}
135+
136+
# Poll the victim's log for the request-failure auto-disable message.
137+
function wait_for_reject_autodisable {
138+
local node=$1
139+
local timeout=${2:-120}
140+
local logfile="${TESTDIR}/logs/${DBNAME}.${node}.db"
141+
local elapsed=0
142+
echo "Waiting up to ${timeout}s for auto-disable on $node ($logfile)"
143+
while [[ $elapsed -lt $timeout ]]; do
144+
if grep -qE "$DISABLE_MSG" "$logfile" 2>/dev/null; then
145+
echo " FOUND auto-disable message:"
146+
grep -E "$DISABLE_MSG" "$logfile" | tail -1
147+
return 0
148+
fi
149+
sleep 2
150+
elapsed=$((elapsed + 2))
151+
done
152+
return 1
153+
}
154+
155+
# After auto-disable, normal replication resumes and the victim should catch up.
156+
function wait_for_victim_catchup {
157+
local node=$1
158+
local expected=$2
159+
local timeout=${3:-120}
160+
local elapsed=0
161+
echo "Waiting up to ${timeout}s for $node to catch up to $expected rows"
162+
while [[ $elapsed -lt $timeout ]]; do
163+
local got
164+
got=$($CDB2SQL_EXE -tabs ${CDB2_OPTIONS} $db --host "$node" \
165+
"select count(*) from t1" 2>/dev/null)
166+
if [[ "$got" =~ ^[0-9]+$ ]] && [[ "$got" == "$expected" ]]; then
167+
echo " $node caught up ($got rows)"
168+
return 0
169+
fi
170+
echo " $node has $got / $expected rows..."
171+
sleep 3
172+
elapsed=$((elapsed + 3))
173+
done
174+
return 1
175+
}
176+
177+
# Phase 2: prove the auto-disable is recoverable at runtime. Free the ruleset
178+
# so the master will service logfill again, clear the (now non-READONLY)
179+
# gbl_sql_logfill_auto_disabled tunable on the victim, and confirm the parked
180+
# logfill thread resumes rather than staying dead until a restart.
181+
function run_resume_test {
182+
local victim=$1
183+
echo "=== Phase 2: clearing auto-disable and verifying resume on $victim ==="
184+
185+
# Master should stop rejecting the logfill query.
186+
for node in $CLUSTER; do
187+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$node" \
188+
"exec procedure sys.cmd.send('free_ruleset')" >/dev/null 2>&1
189+
done
190+
191+
local val
192+
val=$($CDB2SQL_EXE -tabs ${CDB2_OPTIONS} $db --host "$victim" \
193+
"select value from comdb2_tunables where name='sql_logfill_auto_disabled'" 2>/dev/null)
194+
echo " victim sql_logfill_auto_disabled before clear: [$val]"
195+
196+
# Clear it at runtime -- this only succeeds because the fix made the tunable
197+
# writable (it was previously READONLY).
198+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$victim" \
199+
"put tunable 'sql_logfill_auto_disabled' 0" \
200+
|| { stop_all_nodes; failexit "failed to clear sql_logfill_auto_disabled on $victim"; }
201+
202+
# The authoritative proof of resume: the parked thread logs this only when it
203+
# observes the flag cleared and re-enters its work loop. If the flag had not
204+
# actually cleared, the thread would stay parked and never log it.
205+
local vlog="${TESTDIR}/logs/${DBNAME}.${victim}.db"
206+
local elapsed=0
207+
while [[ $elapsed -lt 60 ]]; do
208+
if grep -qE "resuming sql-logfill after auto-disable was cleared" "$vlog" 2>/dev/null; then
209+
echo " OK: parked sql-logfill thread resumed after the flag was cleared"
210+
return 0
211+
fi
212+
sleep 2
213+
elapsed=$((elapsed + 2))
214+
done
215+
stop_all_nodes
216+
failexit "sql-logfill did not resume on $victim after clearing auto-disable: $vlog"
217+
}
218+
219+
function run_test {
220+
echo "=== sql_logfill reject auto-disable test ==="
221+
222+
local master=$(get_master)
223+
echo "Initial master: $master"
224+
225+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$master" "drop table if exists t1" >/dev/null 2>&1
226+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db --host "$master" "create table t1 (id int)" \
227+
|| failexit "Failed to create table t1"
228+
$CDB2SQL_EXE ${CDB2_OPTIONS} $db default \
229+
"insert into t1 select * from generate_series(1, 1000)" \
230+
|| failexit "Failed to seed t1"
231+
wait_for_cluster
232+
233+
echo "=== Loading reject ruleset on all nodes ==="
234+
distribute_and_load_ruleset
235+
236+
master=$(get_master)
237+
verify_master_rejects_logfill_query "$master"
238+
239+
# Pick a non-master victim replicant.
240+
local victim=""
241+
for node in $CLUSTER; do
242+
if [[ "$node" != "$master" ]]; then
243+
victim=$node
244+
break
245+
fi
246+
done
247+
[[ -z "$victim" ]] && failexit "Could not pick a non-master victim node"
248+
echo "Victim replicant: $victim"
249+
250+
echo "=== Stopping victim, advancing log, restarting victim behind ==="
251+
kill_by_pidfile "${TMPDIR}/${DBNAME}.${victim}.pid"
252+
253+
advance_log "$master"
254+
local expected
255+
expected=$($CDB2SQL_EXE -tabs ${CDB2_OPTIONS} $db --host "$master" \
256+
"select count(*) from t1")
257+
echo "Master row count after advance: $expected"
258+
# Guard against an empty/garbage read: a blank $expected would let the
259+
# catch-up check below match a blank victim read ("" == "") and pass falsely.
260+
if ! [[ "$expected" =~ ^[0-9]+$ ]] || (( expected <= 1000 )); then
261+
stop_all_nodes
262+
failexit "Unexpected master row count after advance: [$expected]"
263+
fi
264+
265+
start_node "$victim"
266+
waitmach "$victim"
267+
268+
echo "=== Waiting for victim logfill to auto-disable on rejections ==="
269+
if ! wait_for_reject_autodisable "$victim" 120; then
270+
stop_all_nodes
271+
failexit "victim $victim did not auto-disable sql_logfill on rejections"
272+
fi
273+
274+
# Prove the auto-disable was driven by the master rejecting the logfill's
275+
# comdb2_transaction_logs request (not by an unrelated connect blip): with
276+
# sql_logfill_debug on, request_logs_from_master logs the failed run of that
277+
# query. (select 1 is not matched by the ruleset, so the connect succeeds.)
278+
local vlog="${TESTDIR}/logs/${DBNAME}.${victim}.db"
279+
if ! grep -qE "request_logs_from_master: cdb2_run_statement failed" "$vlog" 2>/dev/null; then
280+
stop_all_nodes
281+
failexit "victim auto-disabled but log shows no failed comdb2_transaction_logs request: $vlog"
282+
fi
283+
echo " OK: auto-disable was driven by the rejected logfill request"
284+
285+
echo "=== Verifying victim recovers via normal replication after auto-disable ==="
286+
if ! wait_for_victim_catchup "$victim" "$expected" 120; then
287+
stop_all_nodes
288+
failexit "victim $victim did not catch up after logfill auto-disabled"
289+
fi
290+
291+
run_resume_test "$victim"
292+
293+
echo "=== Test completed successfully ==="
294+
}
295+
296+
run_test
297+
stop_all_nodes
298+
echo "Success"

0 commit comments

Comments
 (0)