Skip to content

Commit 0df4c75

Browse files
committed
global-rebalance/cleanup-mode: add CLI-based scripted test
* the script, with detailed inline comments: - creates a bucket with generated TAR shards - moves a random target into maintenance - waits for rebalance - brings the target back - runs cleanup-mode rebalance - verifies that rejoin-induced misplaced copies are removed * TODO: remove `wait_reb` workaround --- * separately, `actionX` note in re future non-verbose (or quiet) option Signed-off-by: Alex Aizman <alex.aizman@gmail.com>
1 parent 9aaaf63 commit 0df4c75

5 files changed

Lines changed: 177 additions & 14 deletions

File tree

.gitlab-ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ stages:
112112
stage: test-short
113113
tags:
114114
- $RUNNER_TAG
115-
timeout: 49m
115+
timeout: 53m
116116
rules:
117117
<<: *gather_logs_def
118118
<<: *default_test_rules
@@ -121,15 +121,15 @@ stages:
121121
stage: test-short
122122
tags:
123123
- $RUNNER_TAG
124-
timeout: 49m
124+
timeout: 53m
125125
<<: *gather_logs_def
126126
<<: *skip_scheduled_rules
127127

128128
.test_short_optional_template: &test_short_optional_def
129129
stage: test-short
130130
tags:
131131
- $RUNNER_TAG
132-
timeout: 49m
132+
timeout: 53m
133133
rules:
134134
- !reference [.default_test_rules, rules]
135135
- if: '$CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"'
@@ -299,7 +299,7 @@ test:short:
299299

300300
test:short:aisloader:
301301
<<: *test_short_def
302-
timeout: 30m
302+
timeout: 20m
303303
rules:
304304
- !reference [.test_short_template, rules]
305305
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_LABELS =~ /.*python-tests-only.*/'

ais/test/scripted_cli_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,15 @@ func TestSpecialSymbols(t *testing.T) {
377377
}
378378
tassert.CheckFatal(t, err)
379379
}
380+
381+
// 'ais start rebalance --cleanup' against a cluster with rejoin-induced misplacements
382+
func TestRebalanceCleanupUsingScript(t *testing.T) {
383+
tools.CheckSkip(t, &tools.SkipTestArgs{MinTargets: 3})
384+
cmd := exec.Command("./scripts/reb-cleanup-mode.sh")
385+
386+
out, err := cmd.CombinedOutput()
387+
if len(out) > 0 {
388+
tlog.Logln(string(out))
389+
}
390+
tassert.CheckFatal(t, err)
391+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/bin/bash
2+
3+
## Prerequisites: #################################################################################
4+
# - aistore cluster with at least 3 active targets
5+
# (cleanup mode requires CountActiveTs >= 2; we take one target down, so need 3)
6+
# - ais (CLI)
7+
#
8+
## What this tests:
9+
# - 'ais start rebalance --cleanup' on a cluster where rebalance has left
10+
# misplaced copies behind (via maintenance + rejoin).
11+
# - Asserts (after every rebalance phase): logical object count is preserved
12+
# end-to-end (no data loss); physical count grows after rejoin-rebalance
13+
# (misplaced copies exist) and returns to baseline after cleanup (copies
14+
# reclaimed); 'ais storage summary' agrees with 'ais ls --summary'.
15+
#
16+
## Usage:
17+
## reb-cleanup-mode.sh [--bucket BUCKET] [--num-shards N]
18+
#
19+
## Example:
20+
## ./ais/test/scripts/reb-cleanup-mode.sh
21+
## ./ais/test/scripts/reb-cleanup-mode.sh --num-shards 9999
22+
23+
if ! [ -x "$(command -v ais)" ]; then
24+
echo "Error: ais (CLI) not installed" >&2
25+
exit 1
26+
fi
27+
28+
## defaults
29+
bucket="ais://reb-cleanup-$RANDOM"
30+
num_shards=9999
31+
32+
while (( "$#" )); do
33+
case "${1}" in
34+
--bucket) bucket=$2; shift; shift;;
35+
--num-shards) num_shards=$2; shift; shift;;
36+
*) echo "fatal: unknown argument '${1}'"; exit 1;;
37+
esac
38+
done
39+
40+
## uncomment for verbose output
41+
## set -x
42+
43+
## state to be restored on exit
44+
node=""
45+
bucket_created=false
46+
47+
cleanup() {
48+
rc=$?
49+
## bring node out of maintenance if we left it there mid-test
50+
[[ -z "$node" ]] || ais cluster add-remove-nodes stop-maintenance "$node" --yes 1>/dev/null 2>&1
51+
[[ "$bucket_created" == "false" ]] || ais rmb "$bucket" --yes 1>/dev/null 2>&1
52+
exit $rc
53+
}
54+
trap cleanup EXIT INT TERM
55+
56+
## --- measurement helpers (all use stable user-facing CLI surface) ---
57+
58+
## logical count: number of unique objects in BMD (data-integrity invariant)
59+
get_logical() { ais ls "$bucket" --count-only | awk '{print $2}' | tr -d ','; }
60+
61+
## physical count: LOMs across all mountpaths, including misplaced copies
62+
get_physical() { ais ls "$bucket" --summary -H | awk '{print $3}'; }
63+
64+
## storage summary: cached object count (cross-validates get_physical)
65+
get_usage() { ais storage summary "$bucket" -H | awk '{print $2}'; }
66+
67+
assert_eq() {
68+
local label="$1" actual="$2" expected="$3"
69+
[[ "$actual" == "$expected" ]] || \
70+
{ echo "FAIL: $label: got $actual, expected $expected"; exit 1; }
71+
}
72+
73+
assert_gt() {
74+
local label="$1" actual="$2" floor="$3"
75+
[[ $actual -gt $floor ]] || \
76+
{ echo "FAIL: $label: got $actual, expected > $floor"; exit 1; }
77+
}
78+
79+
## 'ais wait rebalance' has a race window where it returns before a just-launched
80+
## reb is registered as running. A second wait, after a brief pause, picks up
81+
## the actual running xaction.
82+
## TODO: fix 'ais wait rebalance' to wait for next state change (separate CLI work).
83+
wait_reb() {
84+
ais wait rebalance
85+
sleep 1
86+
ais wait rebalance
87+
}
88+
89+
## --- precheck: need >= 3 active targets ---
90+
nat=$(ais show cluster target | awk '/^t\[/ && /online/' | wc -l)
91+
[[ $nat -ge 3 ]] || { echo "FAIL: need >= 3 active targets, got $nat"; exit 1; }
92+
93+
## --- step 1: create bucket and fill with shards ---
94+
ais create $bucket 1>/dev/null || exit $?
95+
bucket_created=true
96+
ais archive gen-shards "${bucket}/shard-{00001..$(printf %05d $num_shards)}.tar" \
97+
--fcount 1 --num-workers 10 1>/dev/null || exit $?
98+
99+
## --- step 2: baseline (logical and physical must agree initially) ---
100+
logical_initial=$(get_logical)
101+
physical_initial=$(get_physical)
102+
usage_initial=$(get_usage)
103+
assert_eq "initial logical" "$logical_initial" "$num_shards"
104+
assert_eq "initial physical" "$physical_initial" "$num_shards"
105+
assert_eq "initial storage-summary" "$usage_initial" "$num_shards"
106+
107+
## --- step 3: pick a random target and put it in maintenance ---
108+
node=$(ais advanced random-node)
109+
ais cluster add-remove-nodes start-maintenance "$node" --yes 1>/dev/null || exit $?
110+
wait_reb
111+
112+
## --- step 4: post-maintenance invariants ---
113+
## logical preserved (rebalance redistributed; nothing lost)
114+
## physical preserved (redistribution, not duplication)
115+
assert_eq "post-maint logical" "$(get_logical)" "$logical_initial"
116+
assert_eq "post-maint physical" "$(get_physical)" "$physical_initial"
117+
assert_eq "post-maint usage" "$(get_usage)" "$usage_initial"
118+
119+
## --- step 5: bring node back; triggers rejoin-rebalance ---
120+
ais cluster add-remove-nodes stop-maintenance "$node" --yes 1>/dev/null || exit $?
121+
node="" ## node is back; clear so trap doesn't try to stop-maintenance again
122+
wait_reb
123+
124+
## --- step 6: post-rejoin invariants ---
125+
## logical still preserved (full maintenance cycle without data loss)
126+
## physical MUST exceed initial (rejoin-reb left misplaced copies behind —
127+
## that's exactly what cleanup is for; if this assertion fails, the test is
128+
## not exercising cleanup work and any subsequent "no-op cleanup" PASS would
129+
## be meaningless)
130+
assert_eq "post-rejoin logical" "$(get_logical)" "$logical_initial"
131+
physical_rejoin=$(get_physical)
132+
usage_rejoin=$(get_usage)
133+
assert_gt "post-rejoin physical" "$physical_rejoin" "$physical_initial"
134+
assert_gt "post-rejoin usage" "$usage_rejoin" "$usage_initial"
135+
136+
## --- step 7: run cleanup ---
137+
ais start rebalance --cleanup 1>/dev/null || exit $?
138+
wait_reb
139+
140+
## --- step 8: post-cleanup invariants ---
141+
## logical preserved (cleanup must never remove the last copy of an object)
142+
## physical back to initial (all misplaced copies reclaimed)
143+
## usage agrees with physical
144+
assert_eq "post-cleanup logical" "$(get_logical)" "$logical_initial"
145+
assert_eq "post-cleanup physical" "$(get_physical)" "$physical_initial"
146+
assert_eq "post-cleanup usage" "$(get_usage)" "$usage_initial"
147+
148+
echo "PASS: cleanup reclaimed $((physical_rejoin - physical_initial)) misplaced copies"

cmd/cli/cli/utils.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import (
3232
"github.com/NVIDIA/aistore/cmn/debug"
3333
"github.com/NVIDIA/aistore/cmn/feat"
3434
"github.com/NVIDIA/aistore/core/meta"
35-
"github.com/NVIDIA/aistore/xact"
3635

3736
jsoniter "github.com/json-iterator/go"
3837
"github.com/urfave/cli"
@@ -1073,15 +1072,6 @@ func actionWarnf(c *cli.Context, format string, a ...any) {
10731072

10741073
func actionNote(c *cli.Context, msg string) { fmt.Fprintln(c.App.ErrWriter, fblue("Note: ")+msg) }
10751074

1076-
func actionX(c *cli.Context, xargs *xact.ArgsMsg, s string) {
1077-
if flagIsSet(c, nonverboseFlag) {
1078-
fmt.Fprintln(c.App.Writer, xargs.ID)
1079-
return
1080-
}
1081-
msg := fmt.Sprintf("Started %s%s. %s", xact.Cname(xargs.Kind, xargs.ID), s, toMonitorMsg(c, xargs.ID, ""))
1082-
actionDone(c, msg)
1083-
}
1084-
10851075
func actionCptn(c *cli.Context, prefix string, msgs ...any) {
10861076
if prefix == "" {
10871077
msgs[0] = fcyan(msgs[0])

cmd/cli/cli/xact.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ type (
4141
}
4242
)
4343

44+
// TODO -- FIXME: consolidate job-starting cases into actionX (which already encapsulates
45+
// "Started X[xid]. To monitor..."). Would also enable --non-verbose to work uniformly
46+
// across xstart paths, and is a natural place to add a script-friendly short-output flag.
47+
48+
func actionX(c *cli.Context, xargs *xact.ArgsMsg, s string) {
49+
if flagIsSet(c, nonverboseFlag) {
50+
fmt.Fprintln(c.App.Writer, xargs.ID)
51+
return
52+
}
53+
msg := fmt.Sprintf("Started %s%s. %s", xact.Cname(xargs.Kind, xargs.ID), s, toMonitorMsg(c, xargs.ID, ""))
54+
actionDone(c, msg)
55+
}
56+
4457
func toMonitorMsg(c *cli.Context, xjid, suffix string) (out string) {
4558
out = toShowMsg(c, xjid, "To monitor the progress", false)
4659
if suffix != "" && out != "" {

0 commit comments

Comments
 (0)