ci: add OTLP trace ingestion regression testing (#8631)

* chore: update CI config

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* chore: add CI

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* chore: update CI config

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* ci: report otelgen runner diagnostics

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* chore: add script to draw result diagram

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

---------

Signed-off-by: shuiyisong <xixing.sys@gmail.com>
This commit is contained in:
shuiyisong
2026-07-26 23:08:27 +08:00
committed by GitHub
parent cbabf8a53d
commit f5f5d468bb
15 changed files with 924 additions and 23 deletions
@@ -1,3 +1,17 @@
FROM golang:1.23@sha256:e87b2a5f6df2dff71ea330d55d54f4979eb380ae58a7e3aabc9d53121243e689 AS otelgen
ARG OTELGEN_COMMIT=863a3f395d062c7322cc1de08a38774b7fdaa6c8
RUN git init /tmp/otelgen \
&& git -C /tmp/otelgen remote add origin https://github.com/WenyXu/otelgen.git \
&& git -C /tmp/otelgen fetch --depth=1 origin "${OTELGEN_COMMIT}" \
&& test "$(git -C /tmp/otelgen rev-parse FETCH_HEAD)" = "${OTELGEN_COMMIT}" \
&& git -C /tmp/otelgen checkout --detach FETCH_HEAD \
&& cd /tmp/otelgen \
&& CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${OTELGEN_COMMIT} -X main.commit=${OTELGEN_COMMIT}" \
-o /usr/local/bin/otelgen ./cmd/otelgen
FROM ghcr.io/astral-sh/uv@sha256:3d868e555f8f1dbc324afa005066cd11e1053fc4743b9808ca8025283e65efa5 AS uv
FROM ghcr.io/actions/actions-runner@sha256:08c30b0a7105f64bddfc485d2487a22aa03932a791402393352fdf674bda2c29
@@ -43,6 +57,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=uv /uv /uvx /usr/local/bin/
COPY --from=otelgen /usr/local/bin/otelgen /usr/local/bin/otelgen
RUN curl --fail --location --silent --show-error \
--output /tmp/sccache.tar.gz \
@@ -68,10 +68,15 @@ operator before any canary.
## Runner image and workflow tools
Build and push the derived runner image; it preserves the official
`/home/runner/run.sh` entrypoint and supplies CI tools needed at runtime:
`/home/runner/run.sh` entrypoint and supplies CI tools needed at runtime. The
image builds `otelgen` from
[`WenyXu/otelgen`](https://github.com/WenyXu/otelgen) commit
[`863a3f395d062c7322cc1de08a38774b7fdaa6c8`](https://github.com/WenyXu/otelgen/commit/863a3f395d062c7322cc1de08a38774b7fdaa6c8)
so trace cases do not download or compile tools during a benchmark run:
```bash
docker build \
--platform linux/amd64 \
-f .github/runner-scale-sets/query-regression/Dockerfile \
-t greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner:latest \
.github/runner-scale-sets/query-regression
@@ -79,9 +84,11 @@ docker build \
docker push greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner:latest
```
Deploy by digest, not mutable tag, by updating `values-8-cores.yaml` after a
rebuild. If the registry is private, use a dedicated read-only pull secret only
as `imagePullSecrets`; never expose registry credentials to runner containers.
Deploy by digest, not mutable tag, by updating both image references in
`values-8-cores.yaml` after a rebuild. Update `RUNNER_IMAGE_DIGEST` and bump
`RUNNER_IMAGE_EPOCH` in `query-regression.yml` at the same time. If the registry
is private, use a dedicated read-only pull secret only as `imagePullSecrets`;
never expose registry credentials to runner containers.
Both digest-pinned init and runner containers use `IfNotPresent`: the immutable
digest makes a cached image safe and avoids adding a registry dependency to every
runner startup.
@@ -94,7 +101,8 @@ credentials or secrets must never be placed in a ConfigMap.
Before builds, the workflow asserts UID/GID 1001 and exact image tool versions:
`libprotoc 3.21.12`, `uv 0.11.26`, `mold 2.30.0`, `Python 3.12.3`, `sccache
0.16.0`, root-owned `rustup 1.29.0`, and the image-baked
0.16.0`, `otelgen` commit `863a3f395d062c7322cc1de08a38774b7fdaa6c8`,
root-owned `rustup 1.29.0`, and the image-baked
`nightly-2026-03-21` Rust toolchain. Rustup, Cargo, and Rustc must resolve from
`/opt/cargo/bin`; the runner cannot write `/opt/rustup` or `/opt/cargo/bin`.
Protobuf well-known includes, including `google/protobuf/any.proto` and
@@ -27,7 +27,7 @@ template:
claimName: query-regression-build-cache
initContainers:
- name: initialize-build-cache
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:2436278bf0756700c718927907cdbedac64d55c593f80b515acb44dec8b02cc1
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -65,7 +65,7 @@ template:
mountPath: /cache
containers:
- name: runner
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:2436278bf0756700c718927907cdbedac64d55c593f80b515acb44dec8b02cc1
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11
imagePullPolicy: IfNotPresent
command: ["/home/runner/run.sh"]
env:
+3
View File
@@ -118,6 +118,8 @@ def run_case(args: argparse.Namespace, case_path: Path, work_dir: Path) -> int:
]
if parse_bool(args.allow_large_fixture):
cmd.append("--allow-large-fixture")
if args.otelgen_bin is not None:
cmd.extend(["--otelgen-bin", str(args.otelgen_bin)])
print(f"::group::Query regression case: {case_path}", flush=True)
try:
@@ -159,6 +161,7 @@ def main() -> int:
type=Path,
default=configured_path(os.environ.get("FIXTURE_GENERATOR")),
)
parser.add_argument("--otelgen-bin", type=Path, default=configured_path(os.environ.get("OTELGEN_BIN")))
parser.add_argument("--cargo-profile", default=os.environ.get("CARGO_PROFILE", "nightly"))
parser.add_argument("--work-dir", default=Path("query-regression-work"), type=Path)
parser.add_argument("--http-timeout", default=os.environ.get("HTTP_TIMEOUT", "300"))
+63 -4
View File
@@ -32,15 +32,25 @@ def fmt_ms(value: Any) -> str:
return "N/A"
def fmt_pct(value: Any) -> str:
formatted = fmt_ms(value)
return formatted if formatted == "N/A" else f"{formatted}%"
def esc(value: Any) -> str:
text = "N/A" if value is None else str(value)
return text.replace("|", "\\|").replace("\n", " ")
def status_emoji(status: str | None) -> str:
return {"ok": "", "measured": "", "failed": "", "planned": "📝", "fixture-ready": "🧪"}.get(
status or "", "⚠️"
)
return {
"ok": "",
"passed": "",
"measured": "",
"failed": "",
"planned": "📝",
"fixture-ready": "🧪",
}.get(status or "", "⚠️")
def measurement_map(target: dict[str, Any]) -> dict[str, dict[str, Any]]:
@@ -114,6 +124,50 @@ def comparison_table(targets: list[dict[str, Any]], thresholds: list[dict[str, A
return "\n".join(rows)
def otlp_comparison_table(targets: list[dict[str, Any]], thresholds: list[dict[str, Any]]) -> str:
rows = [
"| Target | Accepted spans | Table rows | Spans/s | Mean HTTP latency ms | Failures |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
]
for target in targets:
metrics = target.get("metrics") or {}
visibility = target.get("visibility") or {}
rows.append(
"| {target} | {accepted} | {stored} | {rate} | {latency} | {failures} |".format(
target=esc(target.get("name")),
accepted=esc(metrics.get("accepted_spans")),
stored=esc(visibility.get("observed_rows")),
rate=fmt_ms(metrics.get("accepted_spans_per_second")),
latency=fmt_ms(metrics.get("mean_http_latency_ms")),
failures=esc(metrics.get("failure_count")),
)
)
rows.extend(
[
"",
"| Threshold | Scope | Actual | Limit | Status | Reason |",
"| --- | --- | ---: | ---: | --- | --- |",
]
)
for threshold in thresholds:
is_pct = "actual_pct" in threshold or "limit_pct" in threshold
actual = fmt_pct(threshold.get("actual_pct")) if is_pct else esc(threshold.get("actual"))
limit = fmt_pct(threshold.get("limit_pct")) if is_pct else esc(threshold.get("limit"))
rows.append(
"| {threshold} | {scope} | {actual} | {limit} | {status} {raw} | {reason} |".format(
threshold=esc(threshold.get("threshold")),
scope=esc(threshold.get("target") or "base vs candidate"),
actual=actual,
limit=limit,
status=status_emoji(threshold.get("status")),
raw=esc(threshold.get("status")),
reason=esc(threshold.get("reason")),
)
)
return "\n".join(rows)
def build_markdown(
report: dict[str, Any],
report_path: Path,
@@ -141,7 +195,12 @@ def build_markdown(
targets = report.get("targets") or []
lines.extend(["", "### Targets", "", target_table(targets)])
lines.extend(["", "### Query comparison", "", comparison_table(targets, report.get("thresholds") or [])])
if (report.get("scenario") or {}).get("kind") == "otlp_trace_load":
lines.extend(
["", "### OTLP trace comparison", "", otlp_comparison_table(targets, report.get("thresholds") or [])]
)
else:
lines.extend(["", "### Query comparison", "", comparison_table(targets, report.get("thresholds") or [])])
not_enforced = [t for t in report.get("thresholds") or [] if t.get("status") == "not_enforced"]
if not_enforced:
+20 -5
View File
@@ -286,6 +286,15 @@ jobs:
mold_version="$(mold --version)"
[[ "${mold_version}" =~ ^mold[[:space:]]2\.30\.0([[:space:]]|$) ]]
[[ "$(python3 --version)" == "Python 3.12.3" ]]
otelgen_path="$(command -v otelgen || true)"
otelgen_version="<unavailable>"
if [[ -n "${otelgen_path}" ]]; then
otelgen_version="$("${otelgen_path}" --version 2>&1 || true)"
fi
printf 'otelgen path: %s\n' "${otelgen_path:-<missing>}"
printf 'otelgen version: %s\n' "${otelgen_version}"
[[ "${otelgen_path}" == "/usr/local/bin/otelgen" ]]
[[ "${otelgen_version}" == *"863a3f395d062c7322cc1de08a38774b7fdaa6c8"* ]]
sccache_version="$(sccache --version)"
[[ "${sccache_version}" =~ ^sccache[[:space:]]0\.16\.0([[:space:]]|$) ]]
[[ "$(command -v rustup)" == "/opt/cargo/bin/rustup" ]]
@@ -324,8 +333,8 @@ jobs:
readonly EXPECTED_CACHE_META="/home/runner/query-regression-cache-meta"
readonly EXPECTED_SCCACHE_DIR="/home/runner/.cache/sccache"
readonly EXPECTED_RUSTC_WRAPPER="/usr/local/bin/sccache"
readonly RUNNER_IMAGE_DIGEST="sha256:2436278bf0756700c718927907cdbedac64d55c593f80b515acb44dec8b02cc1"
readonly RUNNER_IMAGE_EPOCH="4"
readonly RUNNER_IMAGE_DIGEST="sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11"
readonly RUNNER_IMAGE_EPOCH="5"
require_expected_root() {
local name="$1"
@@ -558,9 +567,12 @@ jobs:
git reset --hard "${VERIFIED_CANDIDATE_SHA}"
git clean -ffdx
- name: Test query regression runner lifecycle
- name: Test query regression tooling
working-directory: src
run: uv run --no-project python tests/perf/test_query_regression_runner_compaction_toctou.py
run: |
uv run --no-project python tests/perf/test_query_regression_runner_compaction_toctou.py
uv run --no-project python tests/perf/test_query_regression_runner_otlp_trace_load.py
uv run --no-project python tests/perf/test_query_regression_summary_otlp.py
- name: Build candidate greptime and fixture generators
working-directory: src
@@ -594,11 +606,13 @@ jobs:
BASE_BIN: ${{ github.workspace }}/query-regression-bins/base/greptime
CANDIDATE_BIN: ${{ github.workspace }}/query-regression-bins/candidate/greptime
FIXTURE_GENERATOR: ${{ github.workspace }}/query-regression-bins/candidate/query_perf_fixture
OTELGEN_BIN: /usr/local/bin/otelgen
SUMMARY_SCRIPT: ${{ github.event_name == 'pull_request' && 'query-regression-trusted-scripts/query-regression-summary.py' || 'src/.github/scripts/query-regression-summary.py' }}
run: >-
uv run --no-project python src/.github/scripts/query-regression-run.py
--base-src src
--candidate-src src
--summary-script query-regression-trusted-scripts/query-regression-summary.py
--summary-script "${SUMMARY_SCRIPT}"
- name: Write PR metadata for trusted comment workflow
if: ${{ always() && github.event_name == 'pull_request' }}
@@ -622,6 +636,7 @@ jobs:
query-regression-work/**/base/report.json
query-regression-work/**/candidate/report.json
query-regression-work/**/logs/**
query-regression-work/**/otelgen/**
query-regression-summary.md
if-no-files-found: warn
retention-days: 7
+31 -1
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::num::{NonZeroU64, NonZeroUsize};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
@@ -30,6 +30,8 @@ pub(super) enum Scenario {
DirectReadableSst(DirectReadableSstScenario),
#[serde(rename = "prom_remote_write_then_query")]
PromRemoteWriteThenQuery(PromRemoteWriteThenQueryScenario),
#[serde(rename = "otlp_trace_load")]
OtlpTraceLoad(OtlpTraceLoadScenario),
}
#[derive(Debug, Deserialize, Serialize)]
@@ -49,6 +51,33 @@ pub(super) struct PromRemoteWriteThenQueryScenario {
pub(super) remote_write: PromRemoteWritePlan,
}
#[derive(Debug, Deserialize, Serialize)]
pub(super) struct OtlpTraceLoadScenario {
pub(super) load: OtlpTraceLoadPlan,
}
#[derive(Debug, Deserialize, Serialize)]
pub(super) struct OtlpTraceLoadPlan {
pub(super) database: String,
pub(super) table: String,
pub(super) pipeline: String,
pub(super) duration_seconds: NonZeroU64,
pub(super) warmup_seconds: u64,
pub(super) rate: NonZeroU64,
pub(super) workers: NonZeroUsize,
pub(super) exporter_shards: NonZeroUsize,
pub(super) workload: String,
pub(super) visibility_timeout_seconds: NonZeroU64,
pub(super) thresholds: OtlpTraceLoadThresholds,
}
#[derive(Debug, Deserialize, Serialize)]
pub(super) struct OtlpTraceLoadThresholds {
pub(super) max_candidate_throughput_regression_pct: f64,
pub(super) max_candidate_mean_latency_regression_pct: f64,
pub(super) max_failure_count: u64,
}
#[derive(Debug, Deserialize, Serialize)]
pub(super) struct PromRemoteWritePlan {
#[serde(default = "default_database")]
@@ -412,6 +441,7 @@ impl Scenario {
match self {
Scenario::DirectReadableSst(_) => "direct_readable_sst",
Scenario::PromRemoteWriteThenQuery(_) => "prom_remote_write_then_query",
Scenario::OtlpTraceLoad(_) => "otlp_trace_load",
}
}
+22
View File
@@ -125,6 +125,28 @@ fn run_plan(args: PlanArgs) -> Result<(), Box<dyn std::error::Error>> {
return Err("scenario.remote_write.read_bench requires scenario.remote_write.storage.inspect = true".into());
}
}
if let Scenario::OtlpTraceLoad(s) = &case.scenario {
if s.load.warmup_seconds >= s.load.duration_seconds.get() {
return Err("scenario.load.warmup_seconds must be less than duration_seconds".into());
}
for (name, value) in [
(
"max_candidate_throughput_regression_pct",
s.load.thresholds.max_candidate_throughput_regression_pct,
),
(
"max_candidate_mean_latency_regression_pct",
s.load.thresholds.max_candidate_mean_latency_regression_pct,
),
] {
if !value.is_finite() || value < 0.0 {
return Err(format!(
"scenario.load.thresholds.{name} must be a finite non-negative number"
)
.into());
}
}
}
println!(
"{}",
serde_json::to_string_pretty(&json!({"schema_version": 1, "scenario": case.scenario}))?
+61
View File
@@ -208,6 +208,67 @@ in 1440-sample daily time chunks, flushing after each chunk before running 1d/7d
TQL selectors. It is not included in the default case set because ingestion cost
dominates routine CI validation.
## OTLP trace load scenario
`scenario.kind = "otlp_trace_load"` runs a bounded native `otelgen` process
against each local distributed cluster. The runner excludes the configured
warmup window, derives throughput and mean request latency from GreptimeDB's
OTLP-specific metrics, flushes the trace table, and verifies its row count
against the accepted-span counter. The case is intentionally outside the
default set until its variance is known.
Build base/candidate `greptime` binaries with the same profile, build the
candidate `query_perf_fixture`, then run the case explicitly:
```bash
WORK_DIR="$(mktemp -d /tmp/query-perf-otlp.XXXXXX)"
REPORT="$WORK_DIR/trace-report.json"
python3 tests/perf/query_regression_runner.py \
--case tests/perf/query_cases/otlp_trace_load/case.toml \
--base-bin /path/to/base/target/nightly/greptime \
--candidate-bin /path/to/candidate/target/nightly/greptime \
--fixture-generator /path/to/candidate/target/nightly/query_perf_fixture \
--otelgen-bin /path/to/otelgen \
--work-dir "$WORK_DIR" \
--output "$REPORT"
```
Install [YouPlot](https://github.com/red-data-tools/YouPlot) once, then render
all comparison metrics and threshold results in the terminal:
```bash
brew install youplot
tests/perf/plot_otlp_trace_report.sh "$REPORT"
```
For each target, `accepted_spans` should equal `table_rows`, and `failures`
should stay within `max_failure_count`. Throughput is better when
`spans_per_second` is higher; its `actual_pct` is
`(base - candidate) / base * 100`. Latency is better when `mean_latency_ms` is
lower; its `actual_pct` is `(candidate - base) / base * 100`. A positive
`actual_pct` is a candidate regression, while a negative value is an
improvement. The case passes when every `actual_pct` is at or below its
`limit_pct` and every failure-count threshold passes. For local results, run
the case at least three times on an otherwise idle machine and compare the
median regressions rather than relying on one run.
The CI runner image includes the pinned `otelgen` binary. Until this case is
added to the default set, run it explicitly with `workflow_dispatch`:
```bash
gh workflow run query-regression.yml \
--ref <workflow-branch> \
-f case=tests/perf/query_cases/otlp_trace_load/case.toml \
-f base_ref=<full-base-sha> \
-f candidate_ref=<full-candidate-sha> \
-f cargo_profile=nightly \
-f http_timeout=300 \
-f runner=perf-regression-8-cores
```
The selected ARC scale set must already be deployed with the runner-image
digest built from the current query-regression Dockerfile.
## Generator contract
The direct-SST generator should accept a case definition with:
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
printf 'Usage: %s REPORT.json\n' "${0##*/}" >&2
exit 2
fi
report="$1"
[[ -f "${report}" ]] || {
printf 'Report does not exist: %s\n' "${report}" >&2
exit 1
}
command -v jq >/dev/null || {
printf 'jq is required\n' >&2
exit 1
}
command -v uplot >/dev/null || {
printf 'YouPlot is required; install it with: brew install youplot\n' >&2
exit 1
}
jq -e '
(.status | type == "string") and
(.targets | type == "array" and length > 0) and
all(.targets[];
(.name | type == "string") and
(.metrics.accepted_spans | type == "number") and
(.visibility.observed_rows | type == "number") and
(.metrics.accepted_spans_per_second | type == "number") and
(.metrics.mean_http_latency_ms | type == "number") and
(.metrics.failure_count | type == "number")
)
' "${report}" >/dev/null || {
printf 'Report is not a completed OTLP trace comparison: %s\n' "${report}" >&2
exit 1
}
plot_metric() {
local title="$1"
local filter="$2"
local color="$3"
printf '\n'
jq -r ".targets[] | [.name, ${filter}] | @tsv" "${report}" |
uplot bar -t "${title}" -w 80 -c "${color}" -o -
}
jq -r '"Status: \(.status)"' "${report}"
row_count_status=0
jq -r '
"\nAccepted spans vs visible table rows",
(.targets[] |
" [\(if .metrics.accepted_spans == .visibility.observed_rows then "passed" else "failed" end)] \(.name): accepted=\(.metrics.accepted_spans), visible=\(.visibility.observed_rows)"
)
' "${report}"
jq -e 'all(.targets[]; .metrics.accepted_spans == .visibility.observed_rows)' "${report}" >/dev/null || row_count_status=1
plot_metric "Accepted spans" ".metrics.accepted_spans" blue
plot_metric "Visible table rows" ".visibility.observed_rows" green
plot_metric "Throughput (spans/s, higher is better)" ".metrics.accepted_spans_per_second" cyan
plot_metric "Mean HTTP latency (ms, lower is better)" ".metrics.mean_http_latency_ms" yellow
plot_metric "Failures (lower is better)" ".metrics.failure_count" red
jq -r '
"\nThresholds",
(.thresholds[] |
if has("actual_pct") then
" [\(.status)] \(.threshold): \(.actual_pct)% (limit \(.limit_pct)%; base \(.base), candidate \(.candidate))"
elif has("actual") then
" [\(.status)] \(.threshold) [\(.target)]: \(.actual) (limit \(.limit))"
else
" [\(.status)] \(.threshold): \(.reason // "no measured value")"
end
)
' "${report}"
exit "${row_count_status}"
@@ -0,0 +1,23 @@
[case]
name = "otlp_trace_load"
description = "OTLP trace v1 ingestion performance regression"
[scenario]
kind = "otlp_trace_load"
[scenario.load]
database = "public"
table = "opentelemetry_traces"
pipeline = "greptime_trace_v1"
duration_seconds = 120
warmup_seconds = 60
rate = 50000
workers = 4
exporter_shards = 4
workload = "microservices"
visibility_timeout_seconds = 120
[scenario.load.thresholds]
max_candidate_throughput_regression_pct = 20
max_candidate_mean_latency_regression_pct = 20
max_failure_count = 0
+257 -6
View File
@@ -21,6 +21,7 @@ import argparse
import fcntl
import hashlib
import json
import math
import os
import re
import shutil
@@ -39,6 +40,13 @@ from typing import Any
FICLONE = 0x40049409
OTLP_TRACE_METRICS = {
"greptime_frontend_otlp_traces_rows",
"greptime_frontend_otlp_traces_failure_count",
"greptime_servers_http_otlp_traces_elapsed_sum",
"greptime_servers_http_otlp_traces_elapsed_count",
}
PROMETHEUS_SAMPLE_RE = re.compile(r"^([A-Za-z_:][A-Za-z0-9_:]*)(?:\{.*\})?\s+([^\s]+)(?:\s+.*)?$")
@dataclass(frozen=True)
@@ -66,9 +74,11 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--base-bin", required=True, type=Path)
p.add_argument("--candidate-bin", required=True, type=Path)
p.add_argument("--fixture-generator", type=Path)
p.add_argument("--otelgen-bin", type=Path)
p.add_argument("--remote-write-generator", type=Path, help="deprecated: use --fixture-generator query_perf_fixture")
p.add_argument("--storage-inspector", type=Path, help="deprecated: use --fixture-generator query_perf_fixture")
p.add_argument("--work-dir", required=True, type=Path)
p.add_argument("--output", type=Path, help="write the final JSON report to this file instead of stdout")
p.add_argument("--fixture-cache-dir", type=Path, help="persistent directory for generated fixtures, keyed by case content")
p.add_argument("--reuse-fixture", action="store_true")
p.add_argument("--allow-large-fixture", action="store_true")
@@ -158,15 +168,19 @@ def column_sql(col: dict[str, Any]) -> str:
def scenario(case: dict[str, Any]) -> dict[str, Any]:
value = case.get("scenario")
if not isinstance(value, dict):
raise ValueError("case requires [scenario] with kind = 'direct_readable_sst' or 'prom_remote_write_then_query'")
raise ValueError("case requires [scenario] with a supported kind")
kind = value.get("kind")
if kind not in ("direct_readable_sst", "prom_remote_write_then_query"):
raise ValueError(f"unsupported scenario kind {kind!r}; supported: 'direct_readable_sst', 'prom_remote_write_then_query'")
supported = ("direct_readable_sst", "prom_remote_write_then_query", "otlp_trace_load")
if kind not in supported:
raise ValueError(f"unsupported scenario kind {kind!r}; supported: {', '.join(supported)}")
return value
def case_tables(case: dict[str, Any]) -> list[dict[str, Any]]:
value = scenario(case)
if value.get("kind") == "otlp_trace_load":
load = value["load"]
return [{"database": load["database"], "name": load["table"], "engine": "trace", "validate_show_create_engine": False}]
if value.get("kind") == "prom_remote_write_then_query":
remote = value["remote_write"]
metric = remote["metric"]
@@ -1028,6 +1042,171 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def output_report(report: dict[str, Any], output: Path | None) -> None:
if output is None:
print(json.dumps(report, indent=2, sort_keys=True))
else:
write_json(output, report)
def parse_prometheus_metrics(text: str, names: set[str] = OTLP_TRACE_METRICS) -> dict[str, float]:
values: dict[str, float] = {}
for line in text.splitlines():
match = PROMETHEUS_SAMPLE_RE.match(line.strip())
if not match or match.group(1) not in names:
continue
value = float(match.group(2))
if not math.isfinite(value):
raise ValueError(f"non-finite Prometheus sample for {match.group(1)}")
values[match.group(1)] = values.get(match.group(1), 0.0) + value
return values
def fetch_otlp_metrics(target: RunTarget, http_timeout: float) -> dict[str, Any]:
with urllib.request.urlopen(f"http://127.0.0.1:{target.http_port}/metrics", timeout=http_timeout) as response:
text = response.read().decode()
return {"captured_monotonic_seconds": time.monotonic(), "values": parse_prometheus_metrics(text)}
def metric_delta(after: dict[str, Any], before: dict[str, Any], name: str) -> float:
delta = float(after["values"].get(name, 0.0)) - float(before["values"].get(name, 0.0))
if delta < 0:
raise RuntimeError(f"metric {name} decreased by {-delta}")
return delta
def otelgen_command(otelgen_bin: Path, target: RunTarget, load: dict[str, Any]) -> list[str]:
return [
str(otelgen_bin),
"--protocol", "http",
"--otel-exporter-otlp-endpoint", f"127.0.0.1:{target.http_port}",
"--otel-exporter-otlp-url-path", "/v1/otlp/v1/traces",
"--header", f"x-greptime-pipeline-name={load['pipeline']}",
"--header", f"x-greptime-db-name={load['database']}",
"--header", f"x-greptime-trace-table-name={load['table']}",
"--log-level", "error",
"--insecure",
"--duration", str(int(load["duration_seconds"])),
"--rate", str(int(load["rate"])),
"traces", "multi",
"--workers", str(int(load["workers"])),
"--scenarios", str(load["workload"]),
"--exporter-shards", str(int(load["exporter_shards"])),
]
def run_otelgen_load(otelgen_bin: Path | None, target: RunTarget, load: dict[str, Any], http_timeout: float, *, dry_run: bool) -> dict[str, Any]:
binary = otelgen_bin or Path("otelgen")
cmd = otelgen_command(binary, target, load)
if dry_run:
return {"status": "dry-run", "cmd": cmd}
initial = fetch_otlp_metrics(target, http_timeout)
log_dir = target.work_dir / "otelgen"
log_dir.mkdir(parents=True, exist_ok=True)
stdout_path = log_dir / "stdout.log"
stderr_path = log_dir / "stderr.log"
duration = int(load["duration_seconds"])
warmup = int(load["warmup_seconds"])
timed_out = False
started = time.monotonic()
with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr:
proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
try:
if warmup:
try:
proc.wait(timeout=warmup)
except subprocess.TimeoutExpired:
pass
warmed = fetch_otlp_metrics(target, http_timeout)
if proc.poll() is None:
try:
proc.wait(timeout=max(60, duration - warmup + 60))
except subprocess.TimeoutExpired:
timed_out = True
proc.kill()
proc.wait(timeout=20)
final = fetch_otlp_metrics(target, http_timeout)
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=20)
elapsed = time.monotonic() - started
return {
"status": "ok" if proc.returncode == 0 and not timed_out and elapsed + 1 >= duration else "failed",
"cmd": cmd,
"returncode": proc.returncode,
"timed_out": timed_out,
"elapsed_seconds": elapsed,
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"snapshots": {"initial": initial, "warmup": warmed, "final": final},
}
def summarize_otlp_metrics(run: dict[str, Any]) -> dict[str, Any]:
initial = run["snapshots"]["initial"]
warmed = run["snapshots"]["warmup"]
final = run["snapshots"]["final"]
rows = "greptime_frontend_otlp_traces_rows"
failures = "greptime_frontend_otlp_traces_failure_count"
elapsed_sum = "greptime_servers_http_otlp_traces_elapsed_sum"
elapsed_count = "greptime_servers_http_otlp_traces_elapsed_count"
missing = sorted(name for name in (rows, elapsed_sum, elapsed_count) if name not in final["values"])
accepted_spans = int(round(metric_delta(final, initial, rows)))
measurement_accepted_spans = int(round(metric_delta(final, warmed, rows)))
http_requests = int(round(metric_delta(final, warmed, elapsed_count)))
latency_seconds = metric_delta(final, warmed, elapsed_sum)
measurement_seconds = final["captured_monotonic_seconds"] - warmed["captured_monotonic_seconds"]
return {
"accepted_spans": accepted_spans,
"measurement_accepted_spans": measurement_accepted_spans,
"accepted_spans_per_second": measurement_accepted_spans / measurement_seconds if measurement_seconds > 0 else None,
"http_requests": http_requests,
"mean_http_latency_ms": latency_seconds / http_requests * 1000.0 if http_requests else None,
"failure_count": int(round(metric_delta(final, initial, failures))),
"measurement_seconds": measurement_seconds,
"missing_metrics": missing,
}
def planned_otlp_thresholds(load: dict[str, Any]) -> list[dict[str, Any]]:
thresholds = load["thresholds"]
return [
{"threshold": "max_candidate_throughput_regression_pct", "status": "planned", "limit_pct": thresholds["max_candidate_throughput_regression_pct"]},
{"threshold": "max_candidate_mean_latency_regression_pct", "status": "planned", "limit_pct": thresholds["max_candidate_mean_latency_regression_pct"]},
{"target": "each", "threshold": "max_failure_count", "status": "planned", "limit": thresholds["max_failure_count"]},
]
def enforce_otlp_thresholds(load: dict[str, Any], base: dict[str, Any], candidate: dict[str, Any]) -> list[dict[str, Any]]:
thresholds = load["thresholds"]
results: list[dict[str, Any]] = []
for target_name, metrics in (("base", base), ("candidate", candidate)):
failures = metrics.get("failure_count")
limit = int(thresholds["max_failure_count"])
results.append({"target": target_name, "threshold": "max_failure_count", "status": "passed" if failures is not None and failures <= limit else "failed", "actual": failures, "limit": limit})
base_rate = base.get("accepted_spans_per_second")
candidate_rate = candidate.get("accepted_spans_per_second")
throughput_limit = float(thresholds["max_candidate_throughput_regression_pct"])
if base_rate in (None, 0) or candidate_rate is None:
results.append({"threshold": "max_candidate_throughput_regression_pct", "status": "failed", "reason": "missing or zero throughput", "base": base_rate, "candidate": candidate_rate})
else:
actual = (base_rate - candidate_rate) / base_rate * 100.0
results.append({"threshold": "max_candidate_throughput_regression_pct", "status": "passed" if actual <= throughput_limit else "failed", "actual_pct": actual, "limit_pct": throughput_limit, "base": base_rate, "candidate": candidate_rate})
base_latency = base.get("mean_http_latency_ms")
candidate_latency = candidate.get("mean_http_latency_ms")
latency_limit = float(thresholds["max_candidate_mean_latency_regression_pct"])
if base_latency in (None, 0) or candidate_latency is None:
results.append({"threshold": "max_candidate_mean_latency_regression_pct", "status": "failed", "reason": "missing or zero mean latency", "base": base_latency, "candidate": candidate_latency})
else:
actual = (candidate_latency - base_latency) / base_latency * 100.0
results.append({"threshold": "max_candidate_mean_latency_regression_pct", "status": "passed" if actual <= latency_limit else "failed", "actual_pct": actual, "limit_pct": latency_limit, "base": base_latency, "candidate": candidate_latency})
return results
def require_fresh_work_dirs(targets: list[RunTarget], *, reuse_work_dir: bool, dry_run: bool, fixture_only: bool) -> None:
if dry_run or reuse_work_dir or fixture_only:
return
@@ -1292,6 +1471,68 @@ def run_remote_write_scenario(args: argparse.Namespace, case: dict[str, Any], ca
cluster.stop_all()
def run_otlp_trace_load_scenario(args: argparse.Namespace, case: dict[str, Any], targets: list[RunTarget], report: dict[str, Any]) -> None:
load = scenario(case)["load"]
if args.fixture_only:
raise ValueError("--fixture-only is not supported for otlp_trace_load; use --dry-run for planning")
if args.otelgen_bin is not None:
require_binary(args.otelgen_bin, "otelgen", dry_run=args.dry_run)
elif not args.dry_run:
raise ValueError("--otelgen-bin is required for otlp_trace_load")
clusters: list[DistributedCluster] = []
metrics_results: list[dict[str, Any]] = []
try:
for target in targets:
target.work_dir.mkdir(parents=True, exist_ok=True)
cluster = DistributedCluster(target)
clusters.append(cluster)
if not args.dry_run:
cluster.start_all()
create_database: dict[str, Any] = {"status": "dry-run", "database": load["database"]}
if not args.dry_run:
create_database = http_post_sql(target.http_port, f"CREATE DATABASE IF NOT EXISTS {sql_ident(load['database'])}", "public", args.http_timeout)
otelgen = run_otelgen_load(args.otelgen_bin, target, load, args.http_timeout, dry_run=args.dry_run)
metrics: dict[str, Any] = {"status": "dry-run"}
flush: dict[str, Any] = {"status": "dry-run", "table": load["table"]}
visibility: dict[str, Any] = {"status": "dry-run"}
if not args.dry_run:
metrics = summarize_otlp_metrics(otelgen)
flush = http_post_sql(target.http_port, f"ADMIN FLUSH_TABLE({sql_string(load['table'])})", load["database"], args.http_timeout)
visibility = poll_expected_count(target, load["table"], load["database"], int(metrics["accepted_spans"]), float(load["visibility_timeout_seconds"]), args.http_timeout)
tr = {
"name": target.name,
"binary": str(target.binary),
"work_dir": str(target.work_dir),
"components": cluster.component_report(),
"create_database": create_database,
"otelgen": otelgen,
"metrics": metrics,
"flush": flush,
"visibility": visibility,
}
checks_ok = args.dry_run or (
create_database.get("ok")
and otelgen.get("status") == "ok"
and not metrics.get("missing_metrics")
and metrics.get("accepted_spans", 0) > 0
and metrics.get("http_requests", 0) > 0
and flush.get("ok")
and visibility.get("ok")
and visibility.get("row_count_ok")
)
tr["status"] = "planned" if args.dry_run else ("measured" if checks_ok else "failed")
write_json(target.report_path, tr)
report["targets"].append(tr)
metrics_results.append(metrics)
cluster.stop_all()
report["thresholds"] = planned_otlp_thresholds(load) if args.dry_run else enforce_otlp_thresholds(load, metrics_results[0], metrics_results[1])
report["status"] = "planned" if args.dry_run else ("failed" if any(t["status"] == "failed" for t in report["thresholds"]) or any(t["status"] == "failed" for t in report["targets"]) else "ok")
finally:
for cluster in reversed(clusters):
cluster.stop_all()
def main() -> int:
args = parse_args()
case_path = args.case.resolve()
@@ -1314,6 +1555,16 @@ def main() -> int:
reuse_fixture = args.reuse_fixture or args.fixture_cache_dir is not None
report: dict[str, Any] = {"case_path": str(case_path), "case": case.get("case", {}), "scenario": scenario_config, "queries": planned_queries(case), "dry_run": args.dry_run, "fixture_only": args.fixture_only, "query_mode": "fixture-only" if args.fixture_only else "distributed", "reuse_work_dir": args.reuse_work_dir, "reuse_fixture": reuse_fixture, "fixture_cache_dir": str(args.fixture_cache_dir.resolve()) if args.fixture_cache_dir is not None else None, "fixture_dir": str(fixture_dir), "http_timeout": args.http_timeout, "targets": [], "thresholds": [], "status": "planned" if args.dry_run else "running"}
if scenario_kind == "otlp_trace_load":
try:
run_otlp_trace_load_scenario(args, case, targets, report)
except Exception as e: # noqa: BLE001 - write machine-readable failure report
report["status"] = "failed"
report["error"] = repr(e)
write_json(work_root / "query-regression-report.json", report)
output_report(report, args.output)
return 1 if report["status"] == "failed" else 0
if scenario_kind == "prom_remote_write_then_query":
try:
run_remote_write_scenario(args, case, case_path, targets, report)
@@ -1321,7 +1572,7 @@ def main() -> int:
report["status"] = "failed"
report["error"] = repr(e)
write_json(work_root / "query-regression-report.json", report)
print(json.dumps(report, indent=2, sort_keys=True))
output_report(report, args.output)
return 1 if report["status"] == "failed" else 0
if args.fixture_only or args.dry_run:
@@ -1341,7 +1592,7 @@ def main() -> int:
report["targets"].append(tr)
report["status"] = "planned" if args.dry_run else "fixture-ready"
write_json(work_root / "query-regression-report.json", report)
print(json.dumps(report, indent=2, sort_keys=True))
output_report(report, args.output)
return 0
clusters: list[DistributedCluster] = []
@@ -1404,7 +1655,7 @@ def main() -> int:
for cluster in reversed(clusters):
cluster.stop_all()
write_json(work_root / "query-regression-report.json", report)
print(json.dumps(report, indent=2, sort_keys=True))
output_report(report, args.output)
return 1 if report["status"] == "failed" else 0
@@ -17,6 +17,7 @@
import argparse
import importlib.util
import json
import sys
import tempfile
import unittest
@@ -32,6 +33,23 @@ sys.modules[SPEC.name] = runner
SPEC.loader.exec_module(runner)
class ReportOutputTest(unittest.TestCase):
def test_final_report_output_defaults_to_stdout_or_writes_to_file(self) -> None:
report = {"status": "ok"}
expected = json.dumps(report, indent=2, sort_keys=True)
with patch("builtins.print") as print_mock:
runner.output_report(report, None)
print_mock.assert_called_once_with(expected)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "nested" / "report.json"
with patch("builtins.print") as print_mock:
runner.output_report(report, output)
print_mock.assert_not_called()
self.assertEqual(output.read_text(), expected + "\n")
class RemoteWriteCompactionToctouTest(unittest.TestCase):
def test_quiesces_each_target_before_storage_inspection_and_read_bench(self) -> None:
lifecycle: dict[str, list[str]] = {"base": [], "candidate": []}
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed 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.
"""Regression coverage for the local OTLP trace load lifecycle and metrics."""
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
RUNNER_PATH = Path(__file__).with_name("query_regression_runner.py")
PLOTTER_PATH = Path(__file__).with_name("plot_otlp_trace_report.sh")
SPEC = importlib.util.spec_from_file_location("query_regression_runner_otlp_under_test", RUNNER_PATH)
assert SPEC is not None and SPEC.loader is not None
runner = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = runner
SPEC.loader.exec_module(runner)
class OtlpTraceReportPlotTest(unittest.TestCase):
def test_plots_all_comparison_metrics_and_thresholds(self) -> None:
report = {
"status": "ok",
"targets": [
{
"name": name,
"metrics": {
"accepted_spans": spans,
"accepted_spans_per_second": rate,
"mean_http_latency_ms": latency,
"failure_count": 0,
},
"visibility": {"observed_rows": spans},
}
for name, spans, rate, latency in (("base", 100, 10.0, 3.0), ("candidate", 120, 12.0, 2.5))
],
"thresholds": [
{"target": "base", "threshold": "max_failure_count", "status": "passed", "actual": 0, "limit": 0},
{
"threshold": "max_candidate_throughput_regression_pct",
"status": "passed",
"actual_pct": -20.0,
"limit_pct": 20.0,
"base": 10.0,
"candidate": 12.0,
},
],
}
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
report_path = root / "report.json"
report_path.write_text(json.dumps(report))
fake_bin = root / "bin"
fake_bin.mkdir()
uplot = fake_bin / "uplot"
uplot.write_text('#!/usr/bin/env bash\nprintf "CALL %s\\n" "$*" >>"$UPLOT_LOG"\ncat >>"$UPLOT_LOG"\n')
uplot.chmod(0o755)
log_path = root / "uplot.log"
env = os.environ.copy()
env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}"
env["UPLOT_LOG"] = str(log_path)
result = subprocess.run([str(PLOTTER_PATH), str(report_path)], env=env, text=True, capture_output=True, check=False)
log = log_path.read_text()
report["targets"][1]["visibility"]["observed_rows"] = 119
report_path.write_text(json.dumps(report))
mismatch = subprocess.run([str(PLOTTER_PATH), str(report_path)], env=env, text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(log.count("CALL "), 5)
for metric in ("Accepted spans", "Visible table rows", "Throughput", "Mean HTTP latency", "Failures"):
self.assertIn(metric, log)
self.assertIn("base\t100", log)
self.assertIn("candidate\t12", log)
self.assertIn("Status: ok", result.stdout)
self.assertIn("[passed] candidate: accepted=120, visible=120", result.stdout)
self.assertIn("max_failure_count", result.stdout)
self.assertIn("max_candidate_throughput_regression_pct", result.stdout)
self.assertEqual(mismatch.returncode, 1, mismatch.stderr)
self.assertIn("[failed] candidate: accepted=120, visible=119", mismatch.stdout)
class OtlpTraceLoadTest(unittest.TestCase):
def test_stops_base_cluster_before_creating_candidate_cluster(self) -> None:
events = []
class FakeCluster:
def __init__(self, target):
self.target = target
self.stopped = False
events.append(f"create:{target.name}")
def component_report(self):
return {}
def stop_all(self):
if not self.stopped:
self.stopped = True
events.append(f"stop:{self.target.name}")
load = {
"database": "public",
"table": "opentelemetry_traces",
"pipeline": "greptime_trace_v1",
"duration_seconds": 120,
"warmup_seconds": 60,
"rate": 50_000,
"workers": 4,
"workload": "microservices",
"exporter_shards": 4,
"visibility_timeout_seconds": 1,
"thresholds": {
"max_candidate_throughput_regression_pct": 20,
"max_candidate_mean_latency_regression_pct": 20,
"max_failure_count": 0,
},
}
args = runner.argparse.Namespace(fixture_only=False, otelgen_bin=None, dry_run=True, http_timeout=1.0)
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
targets = [
runner.make_target("base", Path("/bin/true"), root, list(range(10_000, 10_008))),
runner.make_target("candidate", Path("/bin/true"), root, list(range(10_008, 10_016))),
]
with patch.object(runner, "DistributedCluster", FakeCluster):
runner.run_otlp_trace_load_scenario(
args,
{"scenario": {"kind": "otlp_trace_load", "load": load}},
targets,
{"targets": []},
)
self.assertEqual(events, ["create:base", "stop:base", "create:candidate", "stop:candidate"])
def test_warmup_lifecycle_and_labeled_metric_deltas(self) -> None:
class FakeProcess:
def __init__(self, *_args, **_kwargs):
self.returncode = None
self.wait_timeouts = []
def wait(self, timeout):
self.wait_timeouts.append(timeout)
if len(self.wait_timeouts) == 1:
raise subprocess.TimeoutExpired("otelgen", timeout)
self.returncode = 0
return 0
def poll(self):
return self.returncode
def kill(self):
self.returncode = -9
def snapshot(text: str, captured: float):
return {"captured_monotonic_seconds": captured, "values": runner.parse_prometheus_metrics(text)}
snapshots = [
snapshot("greptime_frontend_otlp_traces_rows 10\n", 0.0),
snapshot(
'greptime_frontend_otlp_traces_rows 110\n'
'greptime_servers_http_otlp_traces_elapsed_sum{db="public"} 1\n'
'greptime_servers_http_otlp_traces_elapsed_count{db="public"} 10\n',
5.0,
),
snapshot(
'greptime_frontend_otlp_traces_rows 310\n'
'greptime_frontend_otlp_traces_failure_count{label="decode"} 1\n'
'greptime_frontend_otlp_traces_failure_count{label="write"} 2\n'
'greptime_servers_http_otlp_traces_elapsed_sum{db="public"} 3\n'
'greptime_servers_http_otlp_traces_elapsed_count{db="public"} 30\n',
15.0,
),
]
load = {
"database": "public",
"table": "opentelemetry_traces",
"pipeline": "greptime_trace_v1",
"duration_seconds": 120,
"warmup_seconds": 60,
"rate": 50_000,
"workers": 4,
"workload": "microservices",
"exporter_shards": 4,
}
with tempfile.TemporaryDirectory() as tmpdir:
target = runner.make_target("base", Path("/bin/true"), Path(tmpdir), list(range(10_000, 10_008)))
process = FakeProcess()
with (
patch.object(runner, "fetch_otlp_metrics", side_effect=snapshots),
patch.object(runner.subprocess, "Popen", return_value=process),
patch.object(runner.time, "monotonic", side_effect=[0.0, 120.0]),
):
result = runner.run_otelgen_load(Path("/bin/otelgen"), target, load, 1.0, dry_run=False)
self.assertEqual(result["status"], "ok")
self.assertEqual(process.wait_timeouts, [60, 120])
metrics = runner.summarize_otlp_metrics(result)
self.assertEqual(metrics["accepted_spans"], 300)
self.assertEqual(metrics["measurement_accepted_spans"], 200)
self.assertEqual(metrics["accepted_spans_per_second"], 20.0)
self.assertEqual(metrics["http_requests"], 20)
self.assertEqual(metrics["mean_http_latency_ms"], 100.0)
self.assertEqual(metrics["failure_count"], 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed 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.
"""Regression coverage for the OTLP trace GitHub summary."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SUMMARY = Path(__file__).parents[2] / ".github/scripts/query-regression-summary.py"
class OtlpTraceSummaryTest(unittest.TestCase):
def test_renders_ingestion_metrics_and_thresholds(self) -> None:
report = {
"status": "ok",
"case": {"name": "otlp_trace_load"},
"scenario": {"kind": "otlp_trace_load"},
"targets": [
{
"name": "base",
"status": "measured",
"metrics": {
"accepted_spans": 100,
"accepted_spans_per_second": 200.0,
"mean_http_latency_ms": 10.0,
"failure_count": 0,
},
"visibility": {"observed_rows": 100},
},
{
"name": "candidate",
"status": "measured",
"metrics": {
"accepted_spans": 90,
"accepted_spans_per_second": 180.0,
"mean_http_latency_ms": 11.0,
"failure_count": 0,
},
"visibility": {"observed_rows": 90},
},
],
"thresholds": [
{
"threshold": "max_candidate_throughput_regression_pct",
"actual_pct": 10.0,
"limit_pct": 20.0,
"status": "passed",
}
],
}
with tempfile.TemporaryDirectory() as tmpdir:
report_path = Path(tmpdir) / "query-regression-report.json"
report_path.write_text(json.dumps(report))
result = subprocess.run(
[sys.executable, str(SUMMARY), "--report", str(report_path)],
check=True,
capture_output=True,
text=True,
)
self.assertIn("### OTLP trace comparison", result.stdout)
self.assertIn("| base | 100 | 100 | 200.00 | 10.00 | 0 |", result.stdout)
self.assertIn(
"| max_candidate_throughput_regression_pct | base vs candidate | 10.00% | 20.00% | ✅ passed | N/A |",
result.stdout,
)
self.assertNotIn("### Query comparison", result.stdout)
if __name__ == "__main__":
unittest.main()