Skip to content

Commit 44e415d

Browse files
author
Amber
committed
fix(mariadb): preserve in-flight switchover contract
1 parent 6ae183f commit 44e415d

2 files changed

Lines changed: 85 additions & 23 deletions

File tree

addons/mariadb/scripts-ut-spec/replication_switchover_spec.sh

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,33 @@ EOF
318318
The contents of file "${TEST_DIR}/calls" should not include "BUG_explicit_stage6_called"
319319
End
320320

321+
It "returns an active-marker retryable conflict without final-state probing or local fencing"
322+
syncerctl_switchover() {
323+
SWITCHOVER_SYNCERCTL_OUTCOME="retryable_conflict"
324+
log_switchover_error "Switchover failed: outcome=retryable_conflict mutation=0 reason=switchover_in_progress"
325+
return 1
326+
}
327+
switchover_final_state_already_reached() {
328+
record_call "BUG_active_conflict_final_state_probe"
329+
return 1
330+
}
331+
fence_current_primary_after_uncertain_dcs() {
332+
record_call "BUG_active_conflict_fence"
333+
return 0
334+
}
335+
336+
When call run_switchover \
337+
"mdb-mariadb-1" \
338+
"mdb-mariadb-1.mdb-mariadb-headless.demo.svc.cluster.local" \
339+
"explicit"
340+
The status should be failure
341+
The stderr should include "outcome=retryable_conflict"
342+
The stderr should include "mutation=0"
343+
The contents of file "${TEST_DIR}/calls" should not include "BUG_active_conflict_final_state_probe"
344+
The contents of file "${TEST_DIR}/calls" should not include "BUG_active_conflict_fence"
345+
The output should not include "Switchover stage fence"
346+
End
347+
321348
It "keeps empty-candidate auto mode fail-closed while the selected candidate remains secondary"
322349
wait_candidate_promoted_via_syncerctl() {
323350
record_call "auto_stage5_called"
@@ -333,6 +360,8 @@ EOF
333360
"mdb-mariadb-1.mdb-mariadb-headless.demo.svc.cluster.local" \
334361
"auto"
335362
The status should be failure
363+
The stderr should include "outcome=indeterminate_dcs_in_progress"
364+
The stderr should include "mutation=remote-partial"
336365
The contents of file "${TEST_DIR}/calls" should include "auto_stage5_called"
337366
The contents of file "${TEST_DIR}/calls" should not include "BUG_auto_stage6_called_after_stage5_failure"
338367
The output should not include "convergence=delegated"
@@ -506,9 +535,10 @@ EOF
506535
run_switchover "mdb-mariadb-1" "mdb-mariadb-1.mdb-mariadb-headless.demo.svc.cluster.local" "auto"
507536
When run cat "${SYNCERCTL_ARGS}"
508537
The status should be success
509-
# alpha.79 v2: --force added to bypass syncer's "previous switchover
510-
# unfinished" DCS record so same-cluster repeat switchovers proceed.
511-
The output should eq "--host 127.0.0.1 --port 3601 switchover --force --primary mdb-mariadb-0 --candidate mdb-mariadb-1"
538+
# r25 completion contract: an active DCS operation is never preempted.
539+
# The addon must not pass --force; syncer owns the stable retryable
540+
# conflict and mutation=0 outcome for an existing marker.
541+
The output should eq "--host 127.0.0.1 --port 3601 switchover --primary mdb-mariadb-0 --candidate mdb-mariadb-1"
512542
End
513543

514544
It "fails before switchover when mariadb client is unavailable"
@@ -673,8 +703,8 @@ EOF
673703
End
674704

675705
Context "when syncerctl cannot create DCS switchover"
676-
# H2 split-brain fix: after syncerctl (invoked with --force under a
677-
# timeout(1) wrapper) fails, the DCS record may already be persisted and
706+
# H2 split-brain fix: after syncerctl (under a timeout(1) wrapper) fails,
707+
# the DCS record may already be persisted and
678708
# syncer may be promoting the candidate. The current primary must be
679709
# fenced fail-closed (read_only=1), NOT rolled back to writable — the old
680710
# rollback path could leave two writable primaries.
@@ -2505,6 +2535,26 @@ EOF
25052535
The stderr should not include "reason=syncerctl_timeout"
25062536
End
25072537

2538+
It "r25: maps an active DCS marker to a stable retryable conflict without claiming a mutation"
2539+
cat > "${SYNCERCTL_BIN}" <<'EOF'
2540+
#!/bin/sh
2541+
echo "SWITCHOVER_IN_PROGRESS leader=mdb-mariadb-1 candidate=mdb-mariadb-1" >&2
2542+
exit 7
2543+
EOF
2544+
chmod +x "${SYNCERCTL_BIN}"
2545+
timeout() {
2546+
shift
2547+
"$@"
2548+
}
2549+
When call syncerctl_switchover "mdb-mariadb-0" "mdb-mariadb-1" 3
2550+
The status should be failure
2551+
The output should include "Switchover syncerctl output: SWITCHOVER_IN_PROGRESS"
2552+
The stderr should include "outcome=retryable_conflict"
2553+
The stderr should include "mutation=0"
2554+
The stderr should include "reason=switchover_in_progress"
2555+
The stderr should include "SWITCHOVER_IN_PROGRESS leader=mdb-mariadb-1 candidate=mdb-mariadb-1"
2556+
End
2557+
25082558
It "alpha.61 v3: returns success when syncerctl reports 'switchover success' within budget"
25092559
cat > "${SYNCERCTL_BIN}" <<'EOF'
25102560
#!/bin/sh

addons/mariadb/scripts/replication-switchover.sh

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,7 +1247,7 @@ rollback_current_primary_switchover_guard() {
12471247
fence_current_primary_after_uncertain_dcs() {
12481248
# H2 split-brain guard for the Stage-3 (DCS switchover) failure path.
12491249
#
1250-
# syncerctl_switchover is invoked with --force and wrapped by timeout(1). A
1250+
# syncerctl_switchover is wrapped by timeout(1). A
12511251
# non-zero or timeout(1) (rc 124/125/137) return does NOT prove the DCS
12521252
# switchover record was never persisted: syncer can accept and persist the
12531253
# switchover request, begin asynchronously promoting the candidate, and then
@@ -1643,31 +1643,25 @@ syncerctl_switchover() {
16431643
local output
16441644
local rc
16451645
local using_timeout=0
1646-
1647-
# alpha.79 v2 (Helen TL autopilot 22:31 westonnnn `442a5d2e`): pass --force
1648-
# so syncer overrides any leftover "previous switchover unfinished" DCS
1649-
# record from a prior successful switchover. n1ad same-cluster repeat axis
1650-
# (2026-05-14 ~14:54 UTC) exposed this: after the first GREEN switchover
1651-
# cleared chart-side state and OpsRequest reached Succeed, syncer's DCS
1652-
# record stayed in "unfinished" state. Repeat switchovers got
1653-
# `Create switchover failed: there is another switchover
1654-
# maria-5d-n1ad-mariadb-switchover unfinished`. Using --force on every
1655-
# invocation is safe: first-time switchovers have no leftover record so
1656-
# --force is a no-op; repeated switchovers proceed cleanly. This is a
1657-
# syncer-layer cleanup gap (syncer should mark its own DCS record done
1658-
# when the switchover protocol completes); --force is a chart-side
1659-
# workaround until syncer side cleans up properly.
1646+
SWITCHOVER_SYNCERCTL_OUTCOME=""
1647+
1648+
# r25 completion contract: the addon never preempts an existing DCS
1649+
# switchover. `--force` used to delete an unfinished marker without proving
1650+
# that the previous database/lease/grant side effects had rolled back. A
1651+
# second request must instead receive syncer's stable
1652+
# SWITCHOVER_IN_PROGRESS conflict and retry only after the first operation
1653+
# reaches terminal truth.
16601654
if [ -n "${stage_budget}" ] && [ "${SWITCHOVER_HAS_TIMEOUT}" = "1" ]; then
16611655
local wall="${SYNCERCTL_PER_CALL_TIMEOUT_SECONDS}"
16621656
if [ "${stage_budget}" -lt "${wall}" ]; then wall="${stage_budget}"; fi
16631657
if [ "${wall}" -lt 1 ]; then wall=1; fi
16641658
using_timeout=1
16651659
output=$(timeout "${wall}" "${SYNCERCTL_BIN}" --host "${SYNCERCTL_HOST}" --port "${SYNCERCTL_PORT}" \
1666-
switchover --force --primary "${current_name}" --candidate "${candidate_name}" 2>&1)
1660+
switchover --primary "${current_name}" --candidate "${candidate_name}" 2>&1)
16671661
rc=$?
16681662
else
16691663
output=$("${SYNCERCTL_BIN}" --host "${SYNCERCTL_HOST}" --port "${SYNCERCTL_PORT}" \
1670-
switchover --force --primary "${current_name}" --candidate "${candidate_name}" 2>&1)
1664+
switchover --primary "${current_name}" --candidate "${candidate_name}" 2>&1)
16711665
rc=$?
16721666
fi
16731667

@@ -1687,6 +1681,14 @@ syncerctl_switchover() {
16871681
esac
16881682
fi
16891683

1684+
case "${output}" in
1685+
*"SWITCHOVER_IN_PROGRESS"*)
1686+
SWITCHOVER_SYNCERCTL_OUTCOME="retryable_conflict"
1687+
log_switchover_error "Switchover failed: outcome=retryable_conflict mutation=0 reason=switchover_in_progress detail=${output}; retry after the active DCS operation reaches terminal truth"
1688+
return 1
1689+
;;
1690+
esac
1691+
16901692
if [ "${rc}" -ne 0 ]; then
16911693
log_switchover_error "Switchover failed: syncerctl exited with rc=${rc}"
16921694
return 1
@@ -2307,6 +2309,14 @@ run_switchover() {
23072309
log_switchover_info "Switchover stage dcs budget=${dcs_budget}s remaining_before=$(remaining_action_budget)s primary=${current_name} candidate=${candidate_name}"
23082310
log_switchover_info "Switchover: creating syncer DCS switchover primary=${current_name} candidate=${candidate_name}"
23092311
if ! syncerctl_switchover "${current_name}" "${candidate_name}" "${dcs_budget}"; then
2312+
if [ "${SWITCHOVER_SYNCERCTL_OUTCOME:-}" = "retryable_conflict" ]; then
2313+
# The syncer active-marker precheck promises mutation=0. Do not run the
2314+
# uncertain-DCS final-state/fence path: that path is reserved for calls
2315+
# where the client cannot prove whether this request persisted remote
2316+
# side effects. A stable active-marker conflict proves this request did
2317+
# not mutate DCS and must leave the already-running operation untouched.
2318+
return 1
2319+
fi
23102320
if switchover_final_state_already_reached "${current_name}" "${candidate_name}" "${candidate_fqdn}"; then
23112321
# The duplicate call proves that DCS/candidate convergence already
23122322
# reached the requested topology, but read_only=1 alone does not prove
@@ -2315,7 +2325,7 @@ run_switchover() {
23152325
idempotent_final_state_reached="true"
23162326
else
23172327
# H2: do NOT roll the current primary back to writable here. syncerctl runs
2318-
# with --force under a timeout(1) wrapper, so a non-zero/timeout return
2328+
# under a timeout(1) wrapper, so a non-zero/timeout return
23192329
# does not prove the DCS switchover record was not persisted — syncer may
23202330
# already be promoting the candidate. Unfencing the current primary would
23212331
# then make two writable primaries (split-brain). Fail closed by fencing
@@ -2382,6 +2392,7 @@ run_switchover() {
23822392
promoted_budget=$(stage_budget_or_exit "promote" "${CANDIDATE_PROMOTED_VIA_SYNCERCTL_WAIT_SECONDS}") || return 1
23832393
log_switchover_info "Switchover stage candidate_promoted budget=${promoted_budget}s remaining_before=$(remaining_action_budget)s"
23842394
if ! wait_candidate_promoted_via_syncerctl "${candidate_name}" "${candidate_fqdn}" "${promoted_budget}"; then
2395+
log_switchover_error "Switchover failed: outcome=indeterminate_dcs_in_progress mutation=remote-partial stage=candidate_promoted reason=post_dcs_terminal_not_proven; old primary remains fenced and the DCS operation must not be preempted"
23852396
return 1
23862397
fi
23872398
# Post-stage overrun check (mirrors prepare/dcs/fence). The promote loop is
@@ -2401,6 +2412,7 @@ run_switchover() {
24012412
ready_budget=$(stage_budget_or_exit "ready" "${CANDIDATE_REMOTE_ROOT_PRIMARY_READY_WAIT_SECONDS}") || return 1
24022413
log_switchover_info "Switchover stage candidate_primary_ready budget=${ready_budget}s remaining_before=$(remaining_action_budget)s"
24032414
if ! wait_candidate_remote_root_primary_ready "${candidate_name}" "${candidate_fqdn}" "${ready_budget}"; then
2415+
log_switchover_error "Switchover failed: outcome=indeterminate_dcs_in_progress mutation=remote-partial stage=candidate_primary_ready reason=post_dcs_terminal_not_proven; old primary remains fenced and the DCS operation must not be preempted"
24042416
return 1
24052417
fi
24062418
# Final-stage overrun check, BEFORE the success log/return. ready is the last

0 commit comments

Comments
 (0)