blob: b47048dfdd94711958ecb147fbc1df38d1c857e0 [file]
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# G5d soak harness orchestrator.
#
# Configuration (set as env vars before running):
# WARMUP_MIN – minutes OAP has to write data before baseline snapshot (default 60)
# SOAK_HOURS – duration of the vec-on Phase 1 run in hours (default 48)
# PPROF_INTERVAL_MIN – minutes between each pprof capture (default 30)
# PARITY_INTERVAL_MIN – minutes between each replay-and-diff run (default 5)
# SMOKE – set to 1 for a quick ~30-min smoke run (overrides durations)
#
# Artefacts are written under dist/soak/<timestamp>/ relative to the repo root.
#
# Usage:
# ./scripts/soak-vectorized.sh
# SMOKE=1 ./scripts/soak-vectorized.sh
set -euo pipefail
# ── configuration ────────────────────────────────────────────────────────────
WARMUP_MIN="${WARMUP_MIN:-60}"
SOAK_HOURS="${SOAK_HOURS:-48}"
PPROF_INTERVAL_MIN="${PPROF_INTERVAL_MIN:-30}"
PARITY_INTERVAL_MIN="${PARITY_INTERVAL_MIN:-5}"
if [[ "${SMOKE:-}" == "1" ]]; then
# SMOKE skips the OAP-warmup wait — parity is driven by deterministic
# data seeded by `soak-driver seed-fixture`, so we don't depend on OAP
# propagation timing.
WARMUP_MIN=0
SOAK_HOURS=0.34 # ~20 min — fits inside a tractable smoke window
PPROF_INTERVAL_MIN=1
PARITY_INTERVAL_MIN=1
fi
SEED_ROWS="${SEED_ROWS:-1000}"
SOAK_HOURS_SEC=$(awk "BEGIN{printf \"%d\", ${SOAK_HOURS}*3600}")
WARMUP_SEC=$(( WARMUP_MIN * 60 ))
PPROF_INTERVAL_SEC=$(( PPROF_INTERVAL_MIN * 60 ))
PARITY_INTERVAL_SEC=$(( PARITY_INTERVAL_MIN * 60 ))
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${REPO_ROOT}/test/soak/docker-compose.soak.yaml"
CATALOG="${REPO_ROOT}/cmd/soak-driver/catalog/default.json"
BANYANDB_GRPC="localhost:17912"
BANYANDB_PPROF="localhost:6060"
# Pass host UID/GID to compose so the BanyanDB container writes the
# bind-mounted /data dir as the host user (otherwise root-owned files
# break snapshot/restore from the host shell).
export SOAK_UID="$(id -u)"
export SOAK_GID="$(id -g)"
RUN_TS="$(date +%Y%m%dT%H%M%S)"
DIST="${REPO_ROOT}/dist/soak/${RUN_TS}"
DATA_DIR="${REPO_ROOT}/test/soak/data"
SNAPSHOT_DIR="${DIST}/data-snapshot"
# ── helpers ──────────────────────────────────────────────────────────────────
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
compose_cmd() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
wait_banyandb_healthy() {
log "Waiting for BanyanDB to become healthy..."
local attempts=0
until curl -sf "http://localhost:17913/api/healthz" >/dev/null 2>&1; do
attempts=$(( attempts + 1 ))
if (( attempts > 120 )); then
log "ERROR: BanyanDB did not become healthy after 120 attempts"
return 1
fi
sleep 5
done
log "BanyanDB is healthy."
}
soak_driver() {
"${REPO_ROOT}/bin/soak-driver" "$@"
}
# ── cleanup trap ─────────────────────────────────────────────────────────────
cleanup() {
log "Caught signal — tearing down compose stack..."
compose_cmd down -v --remove-orphans 2>/dev/null || true
log "Cleanup complete."
}
trap cleanup INT TERM EXIT
# ── build soak-driver ────────────────────────────────────────────────────────
log "Building soak-driver..."
mkdir -p "${REPO_ROOT}/bin"
(cd "${REPO_ROOT}" && go build -o bin/soak-driver ./cmd/soak-driver)
log "soak-driver built at bin/soak-driver"
# ── prepare output dirs ───────────────────────────────────────────────────────
mkdir -p "${DIST}" "${SNAPSHOT_DIR}" "${DATA_DIR}"
# Tee everything from this point into the run log so silent failures leave
# evidence behind. Earlier output (build) is already in stdout.
exec > >(tee -a "${DIST}/run.log") 2>&1
log "Run artefacts will be written to: ${DIST}"
log "Config: WARMUP_MIN=${WARMUP_MIN} SOAK_HOURS=${SOAK_HOURS} PPROF_INTERVAL_MIN=${PPROF_INTERVAL_MIN} PARITY_INTERVAL_MIN=${PARITY_INTERVAL_MIN}"
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ PHASE 0 — Baseline (vec-off) ║
# ╚══════════════════════════════════════════════════════════════════════════╝
log "=== PHASE 0: Baseline (BANYANDB_VEC_ENABLED=false) ==="
SOAK_DATA_DIR="${DATA_DIR}" BANYANDB_VEC_ENABLED=false compose_cmd up -d
wait_banyandb_healthy
log "Waiting for OAP to become healthy (schema install + agent chain)..."
oap_attempts=0
until docker compose -f "${COMPOSE_FILE}" ps oap --format '{{.Status}}' 2>/dev/null | grep -q '(healthy)'; do
oap_attempts=$(( oap_attempts + 1 ))
if (( oap_attempts > 60 )); then
log "ERROR: OAP did not become healthy after 5 min — abort"
exit 1
fi
sleep 5
done
log "OAP healthy."
if (( WARMUP_SEC > 0 )); then
log "Warming up for ${WARMUP_MIN} minutes to let OAP populate data..."
sleep "${WARMUP_SEC}"
fi
log "Seeding deterministic fixture (${SEED_ROWS} rows into soak/soak_metric)..."
T1_MS=$(soak_driver seed-fixture --addr "${BANYANDB_GRPC}" --rows "${SEED_ROWS}" | tail -1)
if [[ -z "${T1_MS}" ]] || ! [[ "${T1_MS}" =~ ^[0-9]+$ ]]; then
log "ERROR: seed-fixture did not return a valid T1 timestamp"
exit 1
fi
log "T1 snapshot timestamp: ${T1_MS} ms"
# seed-fixture polls until the rows are visible to query, so by the
# time it returns the measure data is queryable. Schema-property has a
# 5s flush timeout — wait once more before snapshotting so the schema
# segs land on disk for Phase 1.
log "Waiting 8s for schema-server flush to persist..."
sleep 8
log "Recording baseline..."
soak_driver record-baseline \
--addr "${BANYANDB_GRPC}" \
--catalog "${CATALOG}" \
--until "${T1_MS}" \
--out "${DIST}/baseline.json"
# Verify the baseline has data points. The baseline JSON's data_points
# field is an array of protojson-encoded DataPoint messages; an empty
# slice means writes weren't visible and parity is meaningless.
baseline_dp=$(python3 -c "import json; d=json.load(open('${DIST}/baseline.json')); print(sum(len(r.get('data_points') or []) for r in d))" 2>/dev/null || echo 0)
log "Baseline data points captured: ${baseline_dp}"
if [[ "${baseline_dp}" == "0" ]]; then
log "ERROR: baseline contains zero data points despite seed of ${SEED_ROWS} rows"
exit 1
fi
log "Stopping BanyanDB to snapshot data..."
compose_cmd stop banyandb
log "Copying data to ${SNAPSHOT_DIR}..."
cp -a "${DATA_DIR}/." "${SNAPSHOT_DIR}/"
snap_size=$(du -sb "${SNAPSHOT_DIR}" 2>/dev/null | awk '{print $1}')
log "Snapshot size: ${snap_size:-0} bytes"
log "Tearing down Phase 0 stack..."
# Disable trap during intentional down so we don't double-down.
trap - EXIT
compose_cmd down -v --remove-orphans
trap cleanup INT TERM EXIT
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ PHASE 1 — Soak (vec-on) ║
# ╚══════════════════════════════════════════════════════════════════════════╝
log "=== PHASE 1: Soak (BANYANDB_VEC_ENABLED=true, duration=${SOAK_HOURS}h) ==="
log "Restoring data snapshot..."
rm -rf "${DATA_DIR:?}"/*
cp -a "${SNAPSHOT_DIR}/." "${DATA_DIR}/"
SOAK_DATA_DIR="${DATA_DIR}" BANYANDB_VEC_ENABLED=true compose_cmd up -d
wait_banyandb_healthy
# Initial pprof grab.
mkdir -p "${DIST}/pprof-start"
soak_driver pprof-grab --addr "${BANYANDB_PPROF}" --out-dir "${DIST}/pprof-start"
log "Initial pprof captured."
# Tail BanyanDB logs into persistent log files in the background.
compose_cmd logs -f banyandb 2>&1 >> "${DIST}/banyand.log" &
LOGS_PID=$!
# Grep for MemoryTracker budget exhaustion in the background.
(
tail -f "${DIST}/banyand.log" 2>/dev/null | \
grep --line-buffered -i "MemoryTracker\|budget exhausted\|memory budget" \
>> "${DIST}/memory-alerts.log" || true
) &
GREP_PID=$!
# Background pprof + parity loops.
SOAK_END=$(( $(date +%s) + SOAK_HOURS_SEC ))
(
while (( $(date +%s) < SOAK_END )); do
sleep "${PPROF_INTERVAL_SEC}"
(( $(date +%s) >= SOAK_END )) && break
INTERVAL_TS="$(date +%Y%m%dT%H%M%S)"
PPROF_DIR="${DIST}/pprof-${INTERVAL_TS}"
mkdir -p "${PPROF_DIR}"
soak_driver pprof-grab --addr "${BANYANDB_PPROF}" --out-dir "${PPROF_DIR}" || \
log "WARN: pprof-grab failed at ${INTERVAL_TS}"
log "pprof captured: ${PPROF_DIR}"
done
) &
PPROF_LOOP_PID=$!
(
while (( $(date +%s) < SOAK_END )); do
sleep "${PARITY_INTERVAL_SEC}"
(( $(date +%s) >= SOAK_END )) && break
DIFF_TS="$(date +%Y%m%dT%H%M%S)"
DIFF_REPORT="${DIST}/diff-${DIFF_TS}.json"
soak_driver replay-and-diff \
--addr "${BANYANDB_GRPC}" \
--catalog "${CATALOG}" \
--baseline "${DIST}/baseline.json" \
--report "${DIFF_REPORT}" || \
log "WARN: parity divergence detected — see ${DIFF_REPORT}"
log "parity check done: ${DIFF_REPORT}"
done
) &
PARITY_LOOP_PID=$!
log "Soak running for ${SOAK_HOURS} hours. Loops started (pids: pprof=${PPROF_LOOP_PID} parity=${PARITY_LOOP_PID})."
# Wait for soak duration.
REMAINING=$(( SOAK_END - $(date +%s) ))
if (( REMAINING > 0 )); then
sleep "${REMAINING}"
fi
log "Soak window complete. Collecting final artefacts..."
# Stop background loops gracefully.
kill "${PPROF_LOOP_PID}" "${PARITY_LOOP_PID}" 2>/dev/null || true
wait "${PPROF_LOOP_PID}" "${PARITY_LOOP_PID}" 2>/dev/null || true
# Final pprof.
mkdir -p "${DIST}/pprof-end"
soak_driver pprof-grab --addr "${BANYANDB_PPROF}" --out-dir "${DIST}/pprof-end"
log "Final pprof captured."
# Final parity check.
FINAL_DIFF="${DIST}/diff-final.json"
soak_driver replay-and-diff \
--addr "${BANYANDB_GRPC}" \
--catalog "${CATALOG}" \
--baseline "${DIST}/baseline.json" \
--report "${FINAL_DIFF}" && FINAL_PASS=true || FINAL_PASS=false
log "Final parity check: pass=${FINAL_PASS}"
# Stop log tailing.
kill "${LOGS_PID}" "${GREP_PID}" 2>/dev/null || true
# Write summary manifest.
# Goroutine count is read from the "goroutine profile: total N" header
# line that /debug/pprof/goroutine?debug=1 writes. Cheaper and more
# robust than counting per-goroutine entries.
extract_goroutine_total() {
local dir="$1"
local f
f=$(ls "${dir}"/goroutine-*.txt 2>/dev/null | head -1)
if [[ -z "${f}" ]]; then
echo 0
return
fi
awk '/^goroutine profile: total/ {print $4; exit}' "${f}" 2>/dev/null || echo 0
}
GOROUTINE_START=$(extract_goroutine_total "${DIST}/pprof-start")
GOROUTINE_END=$(extract_goroutine_total "${DIST}/pprof-end")
MEMORY_ALERTS=$(wc -l < "${DIST}/memory-alerts.log" 2>/dev/null || echo 0)
cat > "${DIST}/summary.json" <<EOF
{
"run_ts": "${RUN_TS}",
"smoke": "${SMOKE:-false}",
"warmup_min": ${WARMUP_MIN},
"soak_hours": ${SOAK_HOURS},
"t1_ms": ${T1_MS},
"final_parity_pass": ${FINAL_PASS},
"goroutine_count_start": ${GOROUTINE_START},
"goroutine_count_end": ${GOROUTINE_END},
"memory_alert_lines": ${MEMORY_ALERTS},
"artefacts_dir": "${DIST}"
}
EOF
log "Summary written to ${DIST}/summary.json"
log "=== Soak complete. Artefacts: ${DIST} ==="
# Intentional final teardown — disable trap first.
trap - EXIT INT TERM
compose_cmd down -v --remove-orphans