Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ctrlb/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Benchmark byproducts — never commit these.
run.log
# Any local engine data dir.
data/
38 changes: 38 additions & 0 deletions ctrlb/benchmark.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/bash
#
# ClickBench entry point for CtrlB (Parquet, single). Sets the harness env vars,
# runs the shared ClickBench driver, then turns its output into
# results/<UTC date>/<machine>.json — so one ./benchmark.sh yields a result file
# you can read exactly like the other engines' (see make-result.py).

export BENCH_DOWNLOAD_SCRIPT="download-hits-parquet-single"
export BENCH_RESTARTABLE=yes
export BENCH_DURABLE=yes
export BENCH_TRIES="${BENCH_TRIES:-3}"

export BENCH_CONCURRENT_DURATION="${BENCH_CONCURRENT_DURATION:-0}"

# Run the shared ClickBench driver, capturing stdout so we can BOTH show it live
# and turn it into the committed results JSON. `tee` preserves the standard text
# output (Load time / [t1,t2,t3] / Data size); PIPESTATUS keeps the driver's real
# exit code (tee's is always 0).
HERE="$(cd "$(dirname "$0")" && pwd)"
LOG="${CTRLB_RESULT_LOG:-$HERE/run.log}"

../lib/benchmark-common.sh | tee "$LOG"
status=${PIPESTATUS[0]}

# Assemble results/<UTC date>/c6a.4xlarge.json from the captured log + template.json,
# so the result is readable exactly like the other engines'. Non-fatal: if this
# can't run, the raw log ($LOG) is still there to convert by hand with
# make-result.py.
if [ "$status" -ne 0 ]; then
echo "WARN: benchmark driver exited $status — not writing results JSON." >&2
elif ! command -v python3 > /dev/null 2>&1; then
echo "WARN: python3 not found — run: python3 make-result.py \"$LOG\"" >&2
else
python3 "$HERE/make-result.py" "$LOG" --machine "${CTRLB_MACHINE:-c6a.4xlarge}" \
|| echo "WARN: results JSON not written; run: python3 make-result.py \"$LOG\"" >&2
fi

exit "$status"
11 changes: 11 additions & 0 deletions ctrlb/check
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash
# Readiness probe. Exit 0 once the engine answers a health check. This is a
# SELECT 1-level signal that does NOT require the `hits` view, so the initial
# pre-download (viewless) start still passes. bench_check_loop calls this
# repeatedly until it succeeds or times out.
set -e
# shellcheck disable=SC1091
source "$(dirname "$0")/config.sh"

curl -sf -o /dev/null "$BASE_URL/healthz" 2>/dev/null \
|| curl -sf -o /dev/null "$BASE_URL/api/health"
132 changes: 132 additions & 0 deletions ctrlb/config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Shared config + helpers for the CtrlB ClickBench system dir.
# Sourced (not executed) by every hook script: `source "$(dirname "$0")/config.sh"`.

# Absolute path to THIS system directory — where the harness downloads
# hits.parquet and runs the hook scripts from.
CTRLB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ---- lake (docker) ----------------------------------------------------------
# CtrlB runs as a Docker container (image pulled in ./install). The image serves
# the HTTP API on 5080. We publish that to the host and mount THIS system
# directory into the container at the SAME path, so the hits.parquet the harness
# downloads to $PARQUET_PATH is visible inside the container at the identical
# path and the CREATE EXTERNAL TABLE LOCATION resolves unchanged.
CTRLB_IMAGE="${CTRLB_IMAGE:-ghcr.io/ctrlb-hq/ctrlb-public:c8eead4}"
CONTAINER_NAME="${CONTAINER_NAME:-ctrlb}"
LAKE_STARTUP="${LAKE_STARTUP:-30}"

# ---- query endpoint --------------------------------------------------------
BASE_URL="${BASE_URL:-http://localhost:5080}"
ORG_ID="${ORG_ID:-default}"
SEARCH_URL="${SEARCH_URL:-${BASE_URL}/api/${ORG_ID}/_search_parquet?type=logs}"
START_TIME="${START_TIME:-1}"
END_TIME="${END_TIME:-9999999999999999}"

# ---- data ------------------------------------------------------------------
# The harness downloads into the system dir (its cwd during bench_download).
PARQUET_PATH="${PARQUET_PATH:-$CTRLB_DIR/hits.parquet}"

# post_sql <sql> -> runs a statement and prints the endpoint's server-side
# execution_time on stdout, non-zero (diagnostics to stderr) on failure. Used
# ONLY for the untimed session setup in ./start (CREATE EXTERNAL TABLE / VIEW);
# the presence of execution_time in the response is just a success signal here.
# Benchmark queries are measured by time_sql (below), not this.
post_sql() {
local sql="$1" response et
response="$(curl -s -X POST "$SEARCH_URL" \
-H 'Content-Type: application/json' \
-d "$(printf '{"sql": %s, "start_time": %s, "end_time": %s}' \
"$(printf '%s' "$sql" | jq -Rs .)" "$START_TIME" "$END_TIME")")"
et="$(printf '%s' "$response" | jq -r '.execution_time // empty' 2>/dev/null)"
if [ -z "$et" ]; then
echo "post_sql failed: $sql" >&2
echo "resp: $(printf '%s' "$response" | head -c 300)" >&2
return 1
fi
printf '%s' "$et"
}

# time_sql <sql> <response_file> -> prints the CLIENT-SIDE WALL time (seconds) of
# the full HTTP round-trip on stdout, and writes the FULL response body to
# <response_file>. The wall time (curl %{time_total}) covers sending the query,
# the server processing it, AND receiving the entire result body.
#
# The body is downloaded in full (so the result really is sent back over the wire
# — no server-side output suppression) and then VALIDATED as a genuine success
# payload: a non-200 status, OR a body that does not parse / is missing the
# expected fields (.success == true, .execution_time present) fails the try. We
# do NOT use .execution_time as the timing — it is only a structural sanity check
# so an error that somehow arrives with a 200 is not counted as a success.
# LC_ALL=C forces a '.' decimal so the harness's number parser accepts the time.
time_sql() {
local sql="$1" outfile="$2" out http_code time_total
out="$(LC_ALL=C curl -s -o "$outfile" -w '%{http_code} %{time_total}' \
-X POST "$SEARCH_URL" \
-H 'Content-Type: application/json' \
-d "$(printf '{"sql": %s, "start_time": %s, "end_time": %s}' \
"$(printf '%s' "$sql" | jq -Rs .)" "$START_TIME" "$END_TIME")")"
http_code="${out%% *}"
time_total="${out##* }"
if [ "$http_code" != "200" ]; then
echo "time_sql failed (HTTP ${http_code:-none}): $sql" >&2
return 1
fi
if ! jq -e '.success == true and (.execution_time != null)' "$outfile" > /dev/null 2>&1; then
echo "time_sql: HTTP 200 but response is not a valid success payload for: $sql" >&2
echo "resp: $(head -c 300 "$outfile" 2>/dev/null)" >&2
return 1
fi
printf '%s' "$time_total"
}

# Poll until the lake answers a health check, or LAKE_STARTUP seconds elapse.
wait_for_health() {
local waited=0
while [ "$waited" -lt "$LAKE_STARTUP" ]; do
if curl -s -o /dev/null "$BASE_URL/healthz" 2>/dev/null \
|| curl -s -o /dev/null "$BASE_URL/api/health" 2>/dev/null; then
return 0
fi
sleep 1
waited=$((waited + 1))
done
return 1
}

# Run docker the right way: directly if this user can reach the daemon, else via
# sudo (we already require passwordless sudo). This makes every docker call work
# whether or not the invoking user is in the 'docker' group — no group setup and
# no re-login needed, which matters on a fresh VM (a `usermod -aG docker` would
# not even take effect in the current session). The direct-vs-sudo decision is
# made once and cached per process in _DKR.
dkr() {
if [ -z "${_DKR:-}" ]; then
if docker info > /dev/null 2>&1; then
_DKR=direct
else
_DKR=sudo
fi
fi
if [ "$_DKR" = sudo ]; then
sudo docker "$@"
else
docker "$@"
fi
}

# Stop and remove the CtrlB container (idempotent; -f also kills a running one).
stop_container() {
dkr rm -f "$CONTAINER_NAME" > /dev/null 2>&1 || true
}

# Run the CtrlB container detached: use host networking so the lake's HTTP API on
# 5080 is reachable no matter which interface it binds inside the container, and
# mount this system dir read-only so the downloaded hits.parquet is readable
# inside at the same $PARQUET_PATH. The image has the benchmark env vars baked in.
# --rm so each restart begins from clean state.
start_container() {
dkr run -d --rm --name "$CONTAINER_NAME" \
--network host \
-v "$CTRLB_DIR":"$CTRLB_DIR":ro \
"$CTRLB_IMAGE" > /dev/null
}
2 changes: 2 additions & 0 deletions ctrlb/create.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE EXTERNAL TABLE hits_raw STORED AS PARQUET LOCATION 'hits.parquet' OPTIONS ('binary_as_string' 'true');
CREATE VIEW hits AS SELECT * EXCEPT("EventDate"), CAST(CAST("EventDate" AS INT) AS DATE) AS "EventDate" FROM hits_raw;
7 changes: 7 additions & 0 deletions ctrlb/data-size
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
# Storage size in bytes: CtrlB queries the parquet in place, so its "size" is
# the parquet file itself (same basis as the other in-place Parquet engines).
set -e
# shellcheck disable=SC1091
source "$(dirname "$0")/config.sh"
wc -c < "$PARQUET_PATH"
15 changes: 15 additions & 0 deletions ctrlb/flush-caches
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash
# Per-system cache hook, invoked by the harness's bench_flush_caches AFTER it has
# already run `sync; sudo tee /proc/sys/vm/drop_caches` itself. So the OS page
# cache is dropped either way (the harness requires passwordless sudo regardless).
#
# We just re-assert the page-cache drop for good measure. CtrlB runs as a
# `docker run --rm` container that is torn down and recreated on every per-query
# restart, so its entire on-disk state (metadata cache, sqlite, wal) lives in the
# container's ephemeral filesystem and is wiped automatically each restart — there
# is no persistent host directory for this hook to clear.
set -u

sync
echo 3 | sudo -n tee /proc/sys/vm/drop_caches > /dev/null 2>&1 || true
exit 0
46 changes: 46 additions & 0 deletions ctrlb/install
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash
# ClickBench install hook — run once, before the benchmark.
#
# REQUIRES passwordless sudo — to install Docker if absent and to drop the OS
# page cache (`echo 3 > /proc/sys/vm/drop_caches`), which needs root. The
# ClickBench driver already assumes passwordless sudo (its bench_flush_caches
# runs `sudo tee /proc/sys/vm/drop_caches`), so this is consistent with every
# other system. If sudo is missing we abort before doing anything.
set -eu
# shellcheck disable=SC1091
source "$(dirname "$0")/config.sh" # CTRLB_IMAGE is defined here (single source)

# --- passwordless sudo (needed to install Docker if missing + drop the cache) -
if ! sudo -n true 2>/dev/null; then
echo "ERROR: passwordless sudo is required (to install Docker if missing and to" >&2
echo " drop the OS page cache). Configure sudo, or run as root." >&2
exit 1
fi

# --- Docker: install if missing on this fresh VM, then verify it is usable ----
if ! command -v docker > /dev/null 2>&1; then
echo "Docker not found — installing via https://get.docker.com ..." >&2
curl -fsSL https://get.docker.com | sudo sh
fi
# Make sure the daemon is up (systemd or sysv), then confirm we can talk to it.
# `dkr` (config.sh) uses the daemon directly if this user can reach it, else via
# sudo — so being outside the 'docker' group is fine as long as sudo works.
sudo systemctl enable --now docker > /dev/null 2>&1 \
|| sudo service docker start > /dev/null 2>&1 || true
if ! dkr info > /dev/null 2>&1; then
echo "ERROR: Docker is installed but the daemon is not reachable, even via sudo." >&2
echo " Ensure the Docker service is running. Refusing to run." >&2
exit 1
fi

# 1) Pull the CtrlB image.
dkr pull "$CTRLB_IMAGE"

# 2) Right after the pull and before the container is spun up (in ./start):
# flush dirty pages to disk (sync), then drop the page cache. The pull dirties
# a large amount of memory; flushing here keeps ./load's own sync cheap so the
# load time stays honest instead of blowing up as writeback of the pull.
sync
echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null

exit 0
8 changes: 8 additions & 0 deletions ctrlb/load
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
# CtrlB reads hits.parquet in place — there is no ingestion step. The `hits`
# view is (re)created in ./start after each restart, not here. The harness wraps
# this script in its load-time timer and appends its own `sync` afterward; that
# sync (flushing the freshly-downloaded parquet to disk) is the reported
# "Load time", exactly as for the other read-in-place parquet engines.
set -e
exit 0
Loading