mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-08 22:48:58 +00:00
ci: backport Rust query regression and ephemeral ECS runners
Backport #8651, #8937, #8986 and #9005. Preserve the five v1.2 routine cases and map tooling checks into the release CI workflow. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
@@ -1,321 +1,184 @@
|
||||
# Query regression self-hosted runners
|
||||
|
||||
The `Query Regression` workflow uses the dedicated ARC runner scale set
|
||||
`perf-regression-8-cores`. ARC runner Pods run in the target Kubernetes cluster
|
||||
and connect outbound to GitHub. The live scale set is currently **paused**:
|
||||
`minRunners=0`, `maxRunners=0`, and no runner Pods. Do not resume it without
|
||||
explicit approval.
|
||||
The `Query Regression` workflow runs on **Aliyun ECS ephemeral runners**
|
||||
(`aliyun-ecs`, the default and only automated path): a `provision` job on
|
||||
`ubuntu-latest` creates one pay-as-you-go ECS instance per run, and the
|
||||
instance registers itself as an ephemeral GitHub runner with a per-run
|
||||
label. A `teardown` job (`if: always()`) deletes the instance;
|
||||
`query-regression-janitor.yml` sweeps tagged leftovers older than 4 hours
|
||||
daily.
|
||||
|
||||
## Prerequisites and trust admission
|
||||
Build caches live on the instance's system disk, so **every run compiles
|
||||
cold**; only the within-run reuse (base build warms the candidate build
|
||||
through the shared target dir and sccache) applies. There is no retained
|
||||
data disk.
|
||||
|
||||
Install the ARC scale set controller if it is not already installed:
|
||||
Dispatching with any other `runner` value uses it as a literal self-hosted
|
||||
runner label, which is how a manually prepared host (see
|
||||
`ecs-image/bootstrap-runner-host.sh`) runs the workflow. For PR labels, set
|
||||
the repository variable `QUERY_REGRESSION_PR_RUNNER` to such a label to
|
||||
redirect PR runs away from ECS.
|
||||
|
||||
The office ARC scale set `perf-regression-8-cores` that previously ran this
|
||||
workflow is retired; see git history for its values files and pause/deploy
|
||||
procedures. Tearing down the office cluster (helm release, runner namespace,
|
||||
and the `query-regression-build-cache` PVC) is a manual operator action
|
||||
outside this repository. This directory keeps its historical
|
||||
`runner-scale-sets` name for path stability.
|
||||
|
||||
## Nightly vs previous nightly
|
||||
|
||||
`query-regression-nightly.yml` runs after a successful `GreptimeDB Nightly
|
||||
Build` (`workflow_run`). It resolves that run's `head_sha` as the candidate
|
||||
and the previous successful nightly (same branch, typically Friday when
|
||||
Monday's nightly fires) as the base, then calls `query-regression.yml` with
|
||||
those immutable SHAs. Both binaries are still compiled on the ECS runner;
|
||||
this is not an artifact-download path. `workflow_dispatch` can pass explicit
|
||||
`base_ref` / `candidate_ref` or a nightly run id. If there is no previous
|
||||
nightly, or both nightlies built the same commit, the comparison is skipped.
|
||||
|
||||
## Aliyun ECS path
|
||||
|
||||
Configuration lives in repository variables/secrets:
|
||||
|
||||
| Kind | Name | Purpose |
|
||||
| --- | --- | --- |
|
||||
| secret | `ALICLOUD_ECS_ACCESS_KEY_ID` / `ALICLOUD_ECS_ACCESS_KEY_SECRET` | RAM user scoped to ECS RunInstances/DeleteInstances/Describe*/CreateImage/RunCommand. Used only by provision/teardown jobs on `ubuntu-latest`; never reaches the ECS instance. |
|
||||
| secret | `GH_PERSONAL_ACCESS_TOKEN` | Creates the short-lived runner registration token (shared with the jsonbench EC2 path). |
|
||||
| vars | `ALIYUN_ECS_REGION_ID` / `ALIYUN_ECS_VSWITCH_ID` / `ALIYUN_ECS_SECURITY_GROUP_ID` | Network placement. The security group should allow egress only; no inbound rules are needed. The vSwitch pins the zone. |
|
||||
| vars | `ALIYUN_ECS_INSTANCE_TYPE` | Dedicated (non-burstable, non-shared) instance family. Prefer 32 GiB (e.g. `ecs.g8i.2xlarge`); `ecs.c9i.2xlarge` is 8c16g and nightly thin-LTO of greptime peaks above that. Both base and candidate clusters run on the same machine, so noisy neighbors break thresholds. |
|
||||
| vars | `QUERY_REGRESSION_ECS_IMAGE_ID` | Custom image built by `ecs-image/build-ecs-image.py`. |
|
||||
|
||||
The system disk is 50 GiB, which covers the image, a 16 GiB swapfile, the
|
||||
checkout, and cold build caches (target dir, cargo registry, sccache). ENOSPC
|
||||
stops the runner itself from writing logs, which GitHub reports as `The
|
||||
operation was canceled` with no telemetry, indistinguishable from a
|
||||
platform-side cancellation.
|
||||
cloud-init masks `systemd-oomd`, disables `unattended-upgrades` /
|
||||
`apt-daily-upgrade`, creates `/swapfile`, and sets `OOMPolicy=continue`
|
||||
on the runner unit. Ubuntu 24.04 defaults to `DefaultOOMPolicy=stop`,
|
||||
which SIGTERM-s the whole unit when rustc is OOM-killed and GitHub
|
||||
reports `The operation was canceled` with no telemetry. Unattended
|
||||
upgrades can do the same via `systemctl restart` of the runner after a
|
||||
library update; GitHub then records `UserCancelled` even though the job
|
||||
was still valid. Swap is a safety net for 16 GiB types, not a substitute
|
||||
for 32 GiB; linking on swap is slow.
|
||||
|
||||
When a run dies with an unexplained `The operation was canceled` (no
|
||||
"Canceled by" banner, healthy machine), re-dispatch with `keep_instance`
|
||||
checked: the teardown job is skipped and the instance survives for
|
||||
post-mortem inspection. The security group is egress-only, so inspect via
|
||||
Cloud Assistant (`RunCommand`) or VNC: the runner's `_diag` logs under the
|
||||
runner home record reconnects and worker crashes, `journalctl -u
|
||||
ephemeral-github-runner.service` mirrors the console stream, `dmesg -T`
|
||||
shows kernel OOM kills, and `machine-telemetry.log` in the job workspace
|
||||
has the 30 s sampler history. The janitor sweep still deletes the instance
|
||||
after its TTL, so finish the inspection within that window.
|
||||
|
||||
Trust model on the ECS path: the instance receives only the one-hour runner
|
||||
registration token via user data and holds no cloud credentials; the Aliyun
|
||||
AK/SK exist only in the control-plane jobs. Instance tags
|
||||
(`managed-by=query-regression-ci`, `query-regression-run-id`) feed the janitor
|
||||
sweep.
|
||||
|
||||
### Building and updating the ECS image
|
||||
|
||||
The runner `Dockerfile` in the parent directory stays the single source of the
|
||||
tool contract. Build a new ECS image from it:
|
||||
|
||||
```bash
|
||||
helm upgrade --install arc \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
|
||||
--namespace arc-systems \
|
||||
--create-namespace \
|
||||
--version 0.14.2
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID=... ALIBABA_CLOUD_ACCESS_KEY_SECRET=... \
|
||||
uv run .github/runner-scale-sets/query-regression/ecs-image/build-ecs-image.py \
|
||||
--region-id <region> --vswitch-id <vsw-...> --security-group-id <sg-...> \
|
||||
--base-image-id <ubuntu-24.04-image-id>
|
||||
```
|
||||
|
||||
Create the GitHub App secret in the runner namespace. Prefer an App limited to
|
||||
`GreptimeTeam/greptimedb`:
|
||||
The script boots a temporary builder instance, `docker build`s the runner
|
||||
image, materializes `/opt/rustup`, `/opt/cargo`, `/usr/local/bin` tools, and
|
||||
`/home/runner` (actions-runner) onto the host, installs the ephemeral-runner
|
||||
systemd unit from `ecs-image/`, and snapshots a custom image. It prints the
|
||||
image id; set it as `QUERY_REGRESSION_ECS_IMAGE_ID`, and bump
|
||||
`RUNNER_IMAGE_EPOCH` in `query-regression.yml` at the same time so the target
|
||||
cache invalidates. Builder sentinel polling requires the Cloud Assistant
|
||||
agent, which Aliyun public Ubuntu images include.
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners create secret generic greptimedb-arc-github-app \
|
||||
--from-literal=github_app_id=<app-id> \
|
||||
--from-literal=github_app_installation_id=<installation-id> \
|
||||
--from-file=github_app_private_key=<private-key.pem>
|
||||
```
|
||||
|
||||
The values files here reference that secret by name.
|
||||
## Trust admission for PR runs
|
||||
|
||||
A maintainer applying the `query-regression` or `heavy-regression` label is
|
||||
**trust admission for that exact PR revision**. `query-regression` runs the five
|
||||
routine default cases; `heavy-regression` runs only the high-cardinality
|
||||
`prom_remote_write_7913` remote-write case. The admitted job may use this scale
|
||||
set's dedicated, writable persistent cache. `pull_request: labeled` is the only
|
||||
PR trigger: the label event snapshots its merge, head, and base SHAs. A queued
|
||||
job fetches that immutable event merge SHA directly, verifies it is a two-parent
|
||||
merge whose parents include the snapshotted head exactly once, and uses its
|
||||
other parent as the actual base build revision. The snapshotted event base is
|
||||
retained for audit only, so a difference from the merge's non-head parent is
|
||||
not a failure. The job never follows a newer mutable PR merge ref. An
|
||||
unavailable event merge, or one that does not contain exactly one snapshotted
|
||||
head parent, fails closed. A later PR head change does not retarget an already
|
||||
queued run: it may execute only its previously trusted event revision if that
|
||||
revision remains fetchable. To run the new revision, the maintainer must review
|
||||
it, remove the label, and re-add the desired regression label; cancel the old
|
||||
run if it is no longer wanted. An existing label does not automatically rerun
|
||||
the benchmark.
|
||||
**trust admission for that exact PR revision**. `query-regression` runs the
|
||||
five routine default cases; `heavy-regression` runs only the high-cardinality
|
||||
`prom_remote_write_7913` remote-write case. `pull_request: labeled` is the only PR
|
||||
trigger: the label event snapshots its merge, head, and base SHAs. A queued
|
||||
job fetches that immutable event merge SHA directly, verifies it is a
|
||||
two-parent merge whose parents include the snapshotted head exactly once, and
|
||||
uses its other parent as the actual base build revision. The snapshotted
|
||||
event base is retained for audit only, so a difference from the merge's
|
||||
non-head parent is not a failure. The job never follows a newer mutable PR
|
||||
merge ref. An unavailable event merge, or one that does not contain exactly
|
||||
one snapshotted head parent, fails closed. A later PR head change does not
|
||||
retarget an already queued run: it may execute only its previously trusted
|
||||
event revision if that revision remains fetchable. To run the new revision,
|
||||
the maintainer must review it, remove the label, and re-add the desired
|
||||
regression label; cancel the old run if it is no longer wanted. An existing
|
||||
label does not automatically rerun the benchmark.
|
||||
|
||||
Admission does not relax runner hardening or GitHub permissions. Keep
|
||||
service-account token mounting disabled; do not mount host paths, the Docker
|
||||
socket, kubeconfig, or long-lived credentials. The runner and cache initializer
|
||||
use UID/GID 1001, disallow privilege escalation, drop all capabilities, and use
|
||||
the RuntimeDefault seccomp profile. Keep GitHub tokens least-privilege and
|
||||
review workflow changes before admission. Where the CNI supports it, restrict
|
||||
egress to required GitHub Actions, artifact/cache, Rust/crate/toolchain, DNS,
|
||||
and image-registry endpoints; block unrelated cluster services, private ranges,
|
||||
and metadata endpoints unless a case requires them.
|
||||
|
||||
### Network routing prerequisite
|
||||
|
||||
Required split routing is an **external environment-specific prerequisite**. The
|
||||
responsible network operator must route GitHub Actions, GitHub content,
|
||||
artifact/cache, crates.io, Rust toolchain, and image-registry traffic through
|
||||
the approved path rather than the VPN where applicable. Neither this repository
|
||||
nor Kubernetes configures that route. Verify it with the responsible network
|
||||
operator before any canary.
|
||||
Admission does not relax runner hardening or GitHub permissions. Keep the ECS
|
||||
instance free of cloud credentials and long-lived tokens, keep the security
|
||||
group egress-only, keep GitHub tokens least-privilege, and review workflow
|
||||
changes before admission.
|
||||
|
||||
## 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. The
|
||||
image builds `otelgen` from
|
||||
The runner `Dockerfile` 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:
|
||||
so trace cases do not download or compile tools during a benchmark run. It is
|
||||
built only as a toolchain factory: `build-ecs-image.py` and
|
||||
`bootstrap-runner-host.sh` both materialize its contents onto a host, and the
|
||||
benchmark itself runs host-native.
|
||||
|
||||
```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
|
||||
|
||||
docker push greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner:latest
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The runner optionally imports only the non-sensitive `HTTP_PROXY`,
|
||||
`HTTPS_PROXY`, and `NO_PROXY` variables from the
|
||||
`query-regression-runner-local-env` ConfigMap. Manage that ConfigMap locally in
|
||||
the target namespace; private endpoint configuration must not be committed, and
|
||||
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`, `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
|
||||
`google/protobuf/empty.proto`, are an image contract and must compile with
|
||||
`protoc`.
|
||||
Before builds, the workflow asserts the runner UID/GID (1001 in the ECS image;
|
||||
overridable via `QUERY_REGRESSION_RUNNER_UID`/`QUERY_REGRESSION_RUNNER_GID`)
|
||||
and exact tool versions: `libprotoc 3.21.12`, `uv 0.11.26`, `mold 2.40.4`,
|
||||
`Python 3.14.4`, `sccache 0.16.0`, `otelgen` commit
|
||||
`863a3f395d062c7322cc1de08a38774b7fdaa6c8`, root-owned `rustup 1.29.0`, and
|
||||
the image-baked `nightly-2026-03-21` Rust toolchain. `mold` and `python3`
|
||||
come from apt at image-build time (not Ubuntu 24.04's default 3.12); bump
|
||||
the Verify pins together with `QUERY_REGRESSION_ECS_IMAGE_ID` when the
|
||||
image is rebuilt. 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 `google/protobuf/empty.proto`, are an image
|
||||
contract and must compile with `protoc`.
|
||||
`actions-rust-lang/setup-rust-toolchain@v1` is intentionally removed. The
|
||||
workflow sets its warning-denying mold `RUSTFLAGS` directly, disables automatic
|
||||
Rustup installation, and performs no runtime toolchain downloads.
|
||||
workflow sets its warning-denying mold `RUSTFLAGS` directly, disables
|
||||
automatic Rustup installation, and performs no runtime toolchain downloads.
|
||||
|
||||
The workflow no longer uses GitHub `rust-cache`, `setup-protoc`, `setup-uv`, or
|
||||
runtime Rust setup: the image establishes immutable executable state and the PVC
|
||||
supplies only reusable Cargo data. Do not reintroduce those actions unless the
|
||||
corresponding cache or image contract changes.
|
||||
The workflow no longer uses GitHub `rust-cache`, `setup-protoc`, `setup-uv`,
|
||||
or runtime Rust setup: the image establishes immutable executable state and
|
||||
each instance's system disk holds only that run's Cargo data. Do not
|
||||
reintroduce those actions unless the image contract changes.
|
||||
|
||||
## Capacity and persistent cache
|
||||
## Capacity
|
||||
|
||||
`values-8-cores.yaml` is normal operation: `minRunners=0`, `maxRunners=1`.
|
||||
`values-paused.yaml` is the mandatory pause overlay: `minRunners=0`,
|
||||
`maxRunners=0`. The job uses group `query-regression-persistent-cache-v1`,
|
||||
`queue: max`, and `cancel-in-progress: false`; admitted jobs queue rather than
|
||||
replacing older pending jobs. During maintenance, cancel admitted queued runs as
|
||||
well as pausing ARC. Runner Pods have `activeDeadlineSeconds=12600`.
|
||||
The runner requests 6 CPU and limits at 8 CPU to preserve `minipc-3`
|
||||
allocatable-capacity scheduling headroom; do not reset the request to 8 CPU
|
||||
without revalidating scheduling capacity.
|
||||
Each run provisions its own ECS instance, so overlapping dispatches proceed
|
||||
in parallel. There is no workflow `concurrency` group. All Cargo state
|
||||
(`CARGO_HOME` including registry/git, `CARGO_TARGET_DIR`, sccache, cache
|
||||
metadata) lives on the 50 GiB system disk and is discarded with the VM.
|
||||
`RUSTUP_HOME=/opt/rustup` and `/opt/cargo/bin` are image-owned. The runner
|
||||
sets `RUSTC_WRAPPER=/usr/local/bin/sccache`,
|
||||
`SCCACHE_DIR=/home/runner/.cache/sccache`, `SCCACHE_CACHE_SIZE=10G`, and
|
||||
`CARGO_INCREMENTAL=0`. sccache uses its local disk backend and self-evicts
|
||||
at 10G. Base and candidate builds share the target on that disk; Cargo
|
||||
fingerprints invalidate source and dependency changes.
|
||||
|
||||
The cache claim `query-regression-build-cache` is a nominal 600Gi `local-path`
|
||||
PVC in `arc-runners`. It is `ReadWriteOnce`; `local-path` uses
|
||||
WaitForFirstConsumer binding and Delete reclaim behavior, produces a
|
||||
node-affine local PV, is non-expandable, and the 600Gi request is not a hard
|
||||
storage quota. The runner's `minipc-3` selector is its only consumer candidate.
|
||||
|
||||
The initializer mounts the PVC root at `/cache`, creates and write-tests these
|
||||
versioned subpaths as non-root UID/GID 1001, and the runner mounts them as:
|
||||
|
||||
| Persistent state | PVC subpath | Runner mount |
|
||||
| --- | --- | --- |
|
||||
| Ephemeral Cargo home | `emptyDir` | `/home/runner/.cargo` |
|
||||
| Cargo registry data | `cargo-registry-v1` | `/home/runner/.cargo/registry` |
|
||||
| Cargo Git data | `cargo-git-v1` | `/home/runner/.cargo/git` |
|
||||
| Cargo target | `query-regression-target-v1` | `/home/runner/query-regression-target` |
|
||||
| Cache metadata | `meta-v1` | `/home/runner/query-regression-cache-meta` |
|
||||
| sccache local disk cache | `sccache-v1` | `/home/runner/.cache/sccache` |
|
||||
| Immutable Rust toolchain | image-owned | `/opt/rustup`, `/opt/cargo/bin` |
|
||||
|
||||
The Pod security context uses UID/GID and `fsGroup` 1001 with
|
||||
`fsGroupChangePolicy: OnRootMismatch`; no privileged `chown` or raw `hostPath`
|
||||
is used. `CARGO_HOME` is a per-Pod `emptyDir`; only its nested `registry` and
|
||||
`git` mounts are persistent. `RUSTUP_HOME=/opt/rustup` and `/opt/cargo/bin` are
|
||||
image-owned immutable paths, while `CARGO_TARGET_DIR`, cache metadata, and
|
||||
`SCCACHE_DIR` are persistent absolute paths. The runner sets
|
||||
`RUSTC_WRAPPER=/usr/local/bin/sccache`,
|
||||
`SCCACHE_DIR=/home/runner/.cache/sccache`, `SCCACHE_CACHE_SIZE=40G`, and
|
||||
`CARGO_INCREMENTAL=0`. sccache uses its local PVC disk backend and self-evicts
|
||||
at 40G; do not add runtime downloads, object storage, or a shared backend.
|
||||
|
||||
The repository's `.cargo/config.toml` remains a trusted per-revision build input.
|
||||
In contrast, `$CARGO_HOME/config*`, credentials, installed bins, and Cargo
|
||||
metadata outside the persistent `registry` and `git` data mounts are ephemeral
|
||||
and cannot survive to another Pod.
|
||||
|
||||
The local disk backend has a one-server constraint. `maxRunners=1` and the
|
||||
unchanged `query-regression-persistent-cache-v1` workflow concurrency group
|
||||
serialize runs; do not increase runner capacity or relax that serialization
|
||||
while this backend is in use. Base and candidate builds share the target; Cargo
|
||||
fingerprints invalidate source and dependency changes. The workflow records the
|
||||
sccache version and relevant environment in the target ABI marker, starts and
|
||||
zeros sccache after cache and toolchain checks, shows initial/base/candidate
|
||||
statistics, and resets statistics between base and candidate builds.
|
||||
|
||||
### Disk preflight and cleanup contract
|
||||
|
||||
Before applying or unpausing, verify the backing filesystem on `minipc-3` has
|
||||
at least 900GiB free. The current local-path provisioner source is
|
||||
`/opt/local-path-provisioner`; measure the filesystem containing it:
|
||||
|
||||
```bash
|
||||
df -PB1G /opt/local-path-provisioner
|
||||
```
|
||||
|
||||
The workflow reports `du`, `df -P`, human-readable free space, and inode
|
||||
availability before builds and in an always-run report. Its cleanup is narrow
|
||||
and non-destructive:
|
||||
|
||||
- warn at target size 400GiB; at 450GiB clear only the complete target root;
|
||||
- warn at Cargo registry-plus-Git data size 60GiB; at 80GiB remove only
|
||||
`registry/src` and `git/checkouts`, then abort if that persistent data remains
|
||||
at least 80GiB;
|
||||
- below 300GiB backing free space, clear the complete target root first,
|
||||
remeasure, then remove only those Cargo extracted trees and checkouts; abort
|
||||
if free space is still below 300GiB;
|
||||
- never automatically remove Cargo registry cache/index, Git database, the
|
||||
image-owned Cargo bin or Rustup toolchain, cache metadata, the self-evicting
|
||||
sccache directory, or the PVC.
|
||||
|
||||
The target clear uses fixed absolute roots and removes all entries, including
|
||||
dotfiles. After migration validation, remove obsolete `cargo-home-v1` and
|
||||
`rustup-home-v1` only in explicit maintenance while ARC is 0/0 and no runner Pod
|
||||
exists; they are not mounted by the current configuration.
|
||||
|
||||
## Deploy and pause safely
|
||||
|
||||
First verify the configured external network route and the disk preflight. Apply the PVC; while
|
||||
the scale set is paused, it is expected to remain `Pending` because
|
||||
WaitForFirstConsumer has no scheduled runner:
|
||||
|
||||
```bash
|
||||
kubectl apply --dry-run=server \
|
||||
-f .github/runner-scale-sets/query-regression/cache-pvc.yaml
|
||||
kubectl apply -f .github/runner-scale-sets/query-regression/cache-pvc.yaml
|
||||
```
|
||||
|
||||
Render normal and paused configurations. Normal values are always first; the
|
||||
pause overlay is always last:
|
||||
|
||||
```bash
|
||||
helm template perf-regression-8-cores \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
|
||||
--namespace arc-runners --version 0.14.2 \
|
||||
--set controllerServiceAccount.name=arc-gha-rs-controller \
|
||||
--set controllerServiceAccount.namespace=arc-systems \
|
||||
-f .github/runner-scale-sets/query-regression/values-8-cores.yaml
|
||||
|
||||
helm template perf-regression-8-cores \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
|
||||
--namespace arc-runners --version 0.14.2 \
|
||||
--set controllerServiceAccount.name=arc-gha-rs-controller \
|
||||
--set controllerServiceAccount.namespace=arc-systems \
|
||||
-f .github/runner-scale-sets/query-regression/values-8-cores.yaml \
|
||||
-f .github/runner-scale-sets/query-regression/values-paused.yaml
|
||||
```
|
||||
|
||||
The **first post-merge Helm deployment must reconcile the release in paused
|
||||
mode**. Keep the pause overlay last:
|
||||
|
||||
```bash
|
||||
# First post-merge deployment and every return to paused mode: 0/0.
|
||||
helm upgrade --install perf-regression-8-cores \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
|
||||
--namespace arc-runners --create-namespace --version 0.14.2 \
|
||||
--reset-values --wait \
|
||||
-f .github/runner-scale-sets/query-regression/values-8-cores.yaml \
|
||||
-f .github/runner-scale-sets/query-regression/values-paused.yaml
|
||||
|
||||
# Expect 0/0 and no runner resources before considering normal mode.
|
||||
kubectl -n arc-runners get autoscalingrunnerset perf-regression-8-cores \
|
||||
-o jsonpath='{.spec.minRunners}{"/"}{.spec.maxRunners}{"\n"}'
|
||||
kubectl -n arc-runners get ephemeralrunners,pods \
|
||||
-l actions.github.com/scale-set-name=perf-regression-8-cores
|
||||
```
|
||||
|
||||
Only after that verification and separate explicit approval, apply normal 0/1
|
||||
operation without the pause overlay:
|
||||
|
||||
```bash
|
||||
helm upgrade --install perf-regression-8-cores \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
|
||||
--namespace arc-runners --create-namespace --version 0.14.2 \
|
||||
--reset-values --wait \
|
||||
-f .github/runner-scale-sets/query-regression/values-8-cores.yaml
|
||||
```
|
||||
|
||||
Do not use bare `helm rollback`, `--atomic`, or `--reuse-values`: a stored
|
||||
revision can restore nonzero runner capacity. Inspect rendered manifests for
|
||||
capacity, the `minipc-3` selector, cache claim and mounts, initializer,
|
||||
security context, and resources. After approved normal mode receives its first
|
||||
canary, the PVC binds to `minipc-3`.
|
||||
|
||||
For local-PV node loss, cache recovery is intentionally disposable: return to
|
||||
0/0, recreate the PVC on a healthy node, cold-fill it, and run a new canary.
|
||||
|
||||
## Canary and rollback
|
||||
|
||||
With explicit approval, run two identical `workflow_dispatch` canaries on
|
||||
`perf-regression-8-cores`, using immutable full base and candidate commit SHAs
|
||||
and `cargo_profile=nightly`. The first is the cold fill; the second verifies warm
|
||||
reuse. Record the workflow's base/candidate build elapsed logs and cache
|
||||
ABI-marker output, initial/base/candidate sccache statistics, and cache report.
|
||||
Confirm the image tool contract (root-owned Rustup/Cargo paths, baked nightly,
|
||||
and non-writable `/opt` roots) and that the ephemeral Cargo home contains only
|
||||
the mounted registry/Git data before Cargo creates per-Pod state.
|
||||
Obtain dependency and tool network byte counters from the configured
|
||||
environment counter source, filtered to `minipc-3` and the dependency/tool
|
||||
destinations.
|
||||
|
||||
Accept the canary only when all of the following hold:
|
||||
|
||||
- exactly one runner Pod runs on `minipc-3`, and both jobs use the same bound PV;
|
||||
- UID/GID 1001 cache mounts are writable; the warm run does not invalidate the
|
||||
target or bulk-redownload crates or toolchains; sccache reports separate base
|
||||
and candidate build statistics without server or cache-path errors; immutable
|
||||
Rustup/Cargo roots remain non-writable and only registry/Git data persists;
|
||||
- warm base build time is at most 50% of cold base build time;
|
||||
- warm dependency/tool network bytes are at most 10% of cold fill bytes;
|
||||
- cache sizes remain below soft watermarks, node free space remains at least
|
||||
300GiB, and the benchmark is correct without TLS EOFs or timeouts;
|
||||
- the configured environment counter source confirms this traffic is outside VPN
|
||||
accounting.
|
||||
|
||||
Immediately return to 0/0 after either canary unless ongoing normal operation
|
||||
has been explicitly approved; return immediately on any traffic, cache, disk,
|
||||
TLS, or correctness failure. To roll back, use the paused Helm upgrade above,
|
||||
or another explicit `helm upgrade --install` with known-good values followed by
|
||||
`values-paused.yaml`, `--reset-values`, and `--wait`. Do not delete the PVC
|
||||
automatically; preserve it for diagnosis unless intentionally discarding cache.
|
||||
The workflow reports `du` / `df` in telemetry. It does not try to reclaim
|
||||
space across runs: a cold 50 GiB disk that fills up fails the build.
|
||||
|
||||
## Future optional phases
|
||||
|
||||
The current phase uses a digest-pinned image with sccache 0.16.0 and no shared
|
||||
cache service. Optional follow-ups are an image additionally seeded with the
|
||||
exact Rust toolchain and `cargo fetch --locked`; or an internal read/write
|
||||
sccache backend or Cargo/Git mirror. Evaluate them only if persistent PVC reuse
|
||||
is insufficient.
|
||||
The current phase uses a materialized runner toolchain with sccache 0.16.0 and
|
||||
no shared cache service. Optional follow-ups are an image additionally seeded
|
||||
with `cargo fetch --locked` results, or an internal read/write sccache
|
||||
backend or Cargo/Git mirror. Evaluate them only if cold compile time on the
|
||||
system disk becomes the bottleneck.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: query-regression-build-cache
|
||||
namespace: arc-runners
|
||||
labels:
|
||||
app.kubernetes.io/part-of: perf-regression-8-cores
|
||||
spec:
|
||||
volumeMode: Filesystem
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 600Gi
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
# Bootstrap an Ubuntu 24.04 host as a query-regression self-hosted runner.
|
||||
#
|
||||
# This is the host-native equivalent of the ECS image contract: it
|
||||
# installs docker-ce from Docker's official repository, builds the runner
|
||||
# image from the sibling Dockerfile (the single source of the tool contract),
|
||||
# materializes the toolchain onto the host, and optionally registers the
|
||||
# runner and installs its systemd service.
|
||||
#
|
||||
# Large state lives under --data-root (a data disk): docker's data-root, the
|
||||
# runner home, and the rustup/cargo toolchain roots, symlinked back to the
|
||||
# hard-coded contract paths (/home/runner, /opt/rustup, /opt/cargo).
|
||||
#
|
||||
# The script is idempotent: re-running refreshes the toolchain in place, and
|
||||
# the runner registration (.runner/.credentials) and _work survive. A live
|
||||
# runner service is stopped first and restarted at the end.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash bootstrap-runner-host.sh --repo-dir /path/to/greptimedb
|
||||
# sudo RUNNER_TOKEN=<token> bash bootstrap-runner-host.sh --repo-dir . \
|
||||
# --register --repo-url https://github.com/<owner>/<repo>
|
||||
#
|
||||
# Options:
|
||||
# --repo-dir PATH greptimedb checkout containing the runner Dockerfile (required)
|
||||
# --data-root PATH data disk mount point (default: /data)
|
||||
# --uid / --gid N runner user id (default: 3141; the ECS image contract is 1001)
|
||||
# --register also register the runner and install the systemd service
|
||||
# --repo-url URL repository URL for registration (required with --register)
|
||||
# --runner-name N runner name (default: qreg-host)
|
||||
# --labels L runner labels (default: perf-regression-8-cores)
|
||||
#
|
||||
# With --register, provide a fresh registration token via RUNNER_TOKEN.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR=""
|
||||
DATA_ROOT="/data"
|
||||
RUNNER_UID="3141"
|
||||
RUNNER_GID="3141"
|
||||
REGISTER="false"
|
||||
REPO_URL=""
|
||||
RUNNER_NAME="qreg-host"
|
||||
RUNNER_LABELS="perf-regression-8-cores"
|
||||
IMAGE_TAG="qreg-runner:manual"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo-dir) REPO_DIR="$2"; shift 2 ;;
|
||||
--data-root) DATA_ROOT="$2"; shift 2 ;;
|
||||
--uid) RUNNER_UID="$2"; shift 2 ;;
|
||||
--gid) RUNNER_GID="$2"; shift 2 ;;
|
||||
--register) REGISTER="true"; shift ;;
|
||||
--repo-url) REPO_URL="$2"; shift 2 ;;
|
||||
--runner-name) RUNNER_NAME="$2"; shift 2 ;;
|
||||
--labels) RUNNER_LABELS="$2"; shift 2 ;;
|
||||
-h | --help) sed -n '17,40p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "Run as root, e.g. sudo bash $0 ..." >&2; exit 1; }
|
||||
[[ -n "${REPO_DIR}" ]] || { echo "--repo-dir is required" >&2; exit 2; }
|
||||
DOCKERFILE="${REPO_DIR}/.github/runner-scale-sets/query-regression/Dockerfile"
|
||||
[[ -f "${DOCKERFILE}" ]] || { echo "Dockerfile not found at ${DOCKERFILE}" >&2; exit 1; }
|
||||
if [[ "${REGISTER}" == "true" ]]; then
|
||||
[[ -n "${REPO_URL}" ]] || { echo "--repo-url is required with --register" >&2; exit 2; }
|
||||
[[ -n "${RUNNER_TOKEN:-}" ]] || { echo "RUNNER_TOKEN env is required with --register" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
# A re-run refreshes files under the (possibly live) runner service, so stop
|
||||
# it first and restart it at the end (unless --register re-creates it).
|
||||
RUNNER_SERVICE="$(systemctl list-units --type=service --all --no-legend 'actions.runner.*' 2>/dev/null | awk '{print $1}' | head -n 1 || true)"
|
||||
RUNNER_WAS_ACTIVE="false"
|
||||
if [[ -n "${RUNNER_SERVICE}" ]] && systemctl is-active --quiet "${RUNNER_SERVICE}"; then
|
||||
step "Stop running runner service ${RUNNER_SERVICE}"
|
||||
systemctl stop "${RUNNER_SERVICE}"
|
||||
RUNNER_WAS_ACTIVE="true"
|
||||
fi
|
||||
|
||||
step "Install docker-ce from Docker's official repository"
|
||||
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
|
||||
apt-get remove -y "${pkg}" 2>/dev/null || true
|
||||
done
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
|
||||
|
||||
step "Move docker data-root to ${DATA_ROOT}/docker"
|
||||
systemctl stop docker containerd
|
||||
mkdir -p "${DATA_ROOT}/docker" "${DATA_ROOT}/containerd"
|
||||
if [[ -f /etc/docker/daemon.json ]] && ! grep -q '"data-root"' /etc/docker/daemon.json; then
|
||||
echo "ERROR: /etc/docker/daemon.json exists without data-root; merge manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "{ \"data-root\": \"${DATA_ROOT}/docker\" }" > /etc/docker/daemon.json
|
||||
# containerd keeps its own content store (where build-time image data lands);
|
||||
# move it off the system disk too.
|
||||
mkdir -p /etc/containerd
|
||||
containerd config default | sed "s|root = \"/var/lib/containerd\"|root = \"${DATA_ROOT}/containerd\"|" > /etc/containerd/config.toml
|
||||
systemctl start containerd docker
|
||||
docker info | grep "Docker Root Dir"
|
||||
|
||||
step "Build the runner image"
|
||||
docker build --platform linux/amd64 -f "${DOCKERFILE}" -t "${IMAGE_TAG}" "${REPO_DIR}"
|
||||
|
||||
step "Materialize the toolchain onto the host"
|
||||
mkdir -p "${DATA_ROOT}/opt" "${DATA_ROOT}/runner" /opt /home
|
||||
container="$(docker create "${IMAGE_TAG}")"
|
||||
trap 'docker rm -f "${container}" >/dev/null 2>&1 || true' EXIT
|
||||
# The `/.` suffix copies directory *contents*, so re-running over an existing
|
||||
# materialization refreshes it in place instead of nesting runner/runner.
|
||||
# Files absent from the image (runner registration, _work) are preserved.
|
||||
docker cp "${container}:/home/runner/." "${DATA_ROOT}/runner"
|
||||
docker cp "${container}:/opt/rustup/." "${DATA_ROOT}/opt/rustup"
|
||||
docker cp "${container}:/opt/cargo/." "${DATA_ROOT}/opt/cargo"
|
||||
for tool in uv uvx otelgen sccache; do
|
||||
docker cp "${container}:/usr/local/bin/${tool}" "/usr/local/bin/${tool}"
|
||||
done
|
||||
docker rm "${container}" > /dev/null
|
||||
trap - EXIT
|
||||
ln -sfn "${DATA_ROOT}/runner" /home/runner
|
||||
ln -sfn "${DATA_ROOT}/opt/rustup" /opt/rustup
|
||||
ln -sfn "${DATA_ROOT}/opt/cargo" /opt/cargo
|
||||
docker image rm "${IMAGE_TAG}" > /dev/null
|
||||
|
||||
step "Install system packages"
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential clang cmake git gzip jq libprotobuf-dev libssl-dev mold \
|
||||
openssh-client pkg-config protobuf-compiler python3 sudo tar unzip wget xz-utils zip zstd
|
||||
|
||||
step "Create runner user (${RUNNER_UID}:${RUNNER_GID}) and environment"
|
||||
getent group "${RUNNER_GID}" > /dev/null || groupadd -g "${RUNNER_GID}" runner
|
||||
id -u runner > /dev/null 2>&1 || useradd -u "${RUNNER_UID}" -g "${RUNNER_GID}" -d /home/runner -s /bin/bash runner
|
||||
chown -R "${RUNNER_UID}:${RUNNER_GID}" "${DATA_ROOT}/runner"
|
||||
echo 'PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin' > "${DATA_ROOT}/runner/.env"
|
||||
chown "${RUNNER_UID}:${RUNNER_GID}" "${DATA_ROOT}/runner/.env"
|
||||
|
||||
if [[ "${REGISTER}" == "true" ]]; then
|
||||
step "Register runner ${RUNNER_NAME} (labels: ${RUNNER_LABELS})"
|
||||
if [[ -n "${RUNNER_SERVICE}" ]]; then
|
||||
(cd /home/runner && ./svc.sh uninstall) || true
|
||||
fi
|
||||
runuser -u runner -- bash -c "cd /home/runner && HOME=/home/runner ./config.sh \
|
||||
--url '${REPO_URL}' --token '${RUNNER_TOKEN}' --name '${RUNNER_NAME}' \
|
||||
--labels '${RUNNER_LABELS}' --unattended --replace --disableupdate"
|
||||
|
||||
step "Install and start the runner service"
|
||||
cd /home/runner
|
||||
./svc.sh install runner
|
||||
./svc.sh start
|
||||
./svc.sh status
|
||||
else
|
||||
step "Skipping registration (pass --register --repo-url ... with RUNNER_TOKEN to enable)"
|
||||
if [[ "${RUNNER_WAS_ACTIVE}" == "true" ]]; then
|
||||
step "Restart runner service ${RUNNER_SERVICE}"
|
||||
systemctl start "${RUNNER_SERVICE}"
|
||||
systemctl --no-pager status "${RUNNER_SERVICE}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
echo "Runner home: /home/runner -> ${DATA_ROOT}/runner"
|
||||
echo "Before each workflow run on this persistent host, clean transient cargo state:"
|
||||
echo " sudo rm -f /home/runner/.cargo/.package-cache"
|
||||
echo " sudo rm -rf /home/runner/.cargo/.global-cache"
|
||||
@@ -0,0 +1,392 @@
|
||||
#!/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.
|
||||
|
||||
# PEP 723 inline metadata (see .github/scripts/aliyun-ecs-runner-provision.py
|
||||
# for the convention).
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "alibabacloud_ecs20140526>=4.1.0,<6",
|
||||
# "alibabacloud_tea_openapi>=0.3.12,<1",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""Build the query-regression ECS custom image (manual ops tool).
|
||||
|
||||
Boots a temporary pay-as-you-go ECS instance from a public Ubuntu 24.04 image,
|
||||
builds the existing runner container image (the Dockerfile in the parent
|
||||
directory remains the single source of the tool contract), materializes the
|
||||
tool directories onto the host filesystem so the workflow's "Verify runner
|
||||
image tools" step holds unchanged, installs the ephemeral-runner systemd unit,
|
||||
and snapshots the result as a custom image. The temporary instance is deleted
|
||||
afterwards.
|
||||
|
||||
Sentinel polling reads the instance's serial console output
|
||||
(GetInstanceConsoleOutput) and looks for marker lines the user data writes to
|
||||
/dev/console. This has no in-guest agent dependency.
|
||||
|
||||
Usage:
|
||||
uv run .github/runner-scale-sets/query-regression/ecs-image/build-ecs-image.py \
|
||||
--region-id cn-hangzhou --vswitch-id vsw-... --security-group-id sg-... \
|
||||
--base-image-id ubuntu_24_04_x64_20G_alibase_*.vhd
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent
|
||||
DONE_MARKER = "QREG_IMAGE_BUILD_DONE"
|
||||
FAILED_MARKER = "QREG_IMAGE_BUILD_FAILED"
|
||||
POLL_INTERVAL_SECONDS = 15
|
||||
CONSOLE_POLL_INTERVAL_SECONDS = 30
|
||||
BUILD_TIMEOUT_SECONDS = 60 * 60
|
||||
|
||||
# Same apt package contract as the runner Dockerfile; the base
|
||||
# actions-runner image is Ubuntu 24.04, so an Ubuntu 24.04 host resolves the
|
||||
# same tool versions (protoc 3.21.12, mold 2.40.4, Python 3.14.4).
|
||||
# Docker itself comes from Docker's official repository (docker-ce), not the
|
||||
# distribution-packaged docker.io.
|
||||
APT_PACKAGES = [
|
||||
"build-essential",
|
||||
"ca-certificates",
|
||||
"clang",
|
||||
"cmake",
|
||||
"curl",
|
||||
"git",
|
||||
"gpg",
|
||||
"gzip",
|
||||
"jq",
|
||||
"libprotobuf-dev",
|
||||
"libssl-dev",
|
||||
"mold",
|
||||
"openssh-client",
|
||||
"pkg-config",
|
||||
"protobuf-compiler",
|
||||
"python3",
|
||||
"sudo",
|
||||
"tar",
|
||||
"unzip",
|
||||
"wget",
|
||||
"xz-utils",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
DOCKER_CE_PACKAGES = "docker-ce docker-ce-cli containerd.io docker-buildx-plugin"
|
||||
|
||||
|
||||
def render_user_data(dockerfile: str, start_runner: str, unit: str) -> str:
|
||||
dockerfile_b64 = base64.b64encode(dockerfile.encode()).decode()
|
||||
start_runner_b64 = base64.b64encode(start_runner.encode()).decode()
|
||||
unit_b64 = base64.b64encode(unit.encode()).decode()
|
||||
packages = " ".join(APT_PACKAGES)
|
||||
return f"""#!/bin/bash
|
||||
set -euo pipefail
|
||||
trap 'echo "{FAILED_MARKER} at line $LINENO" > /dev/console' ERR
|
||||
# Stream the full build log to the serial console so the poller (and anyone
|
||||
# watching GetInstanceConsoleOutput) sees real progress, not a silent login
|
||||
# prompt for the whole build.
|
||||
exec > >(tee -a /dev/console) 2>&1
|
||||
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {packages}
|
||||
|
||||
# Docker from the official repository, not the distribution-packaged docker.io.
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${{VERSION_CODENAME}} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {DOCKER_CE_PACKAGES}
|
||||
|
||||
base64 -d > /tmp/Dockerfile <<'EOF'
|
||||
{dockerfile_b64}
|
||||
EOF
|
||||
mkdir -p /tmp/image-context
|
||||
docker build --platform linux/amd64 -f /tmp/Dockerfile -t qreg-runner:local /tmp/image-context
|
||||
|
||||
# Materialize the tool contract onto the host filesystem.
|
||||
container="$(docker create qreg-runner:local)"
|
||||
trap 'docker rm -f "${{container}}" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "${{container}}:/opt/rustup" /opt/rustup
|
||||
docker cp "${{container}}:/opt/cargo" /opt/cargo
|
||||
for tool in uv uvx otelgen sccache; do
|
||||
docker cp "${{container}}:/usr/local/bin/${{tool}}" "/usr/local/bin/${{tool}}"
|
||||
done
|
||||
docker cp "${{container}}:/home/runner" /home/runner
|
||||
|
||||
# Runner identity mirrors the container image (UID/GID 1001).
|
||||
groupadd --gid 1001 runner 2>/dev/null || true
|
||||
useradd --uid 1001 --gid 1001 --home-dir /home/runner --shell /bin/bash runner 2>/dev/null || true
|
||||
chown -R 1001:1001 /home/runner
|
||||
|
||||
mkdir -p /opt/ephemeral-github-runner
|
||||
base64 -d > /opt/ephemeral-github-runner/start-runner.sh <<'EOF'
|
||||
{start_runner_b64}
|
||||
EOF
|
||||
chmod 0755 /opt/ephemeral-github-runner/start-runner.sh
|
||||
base64 -d > /etc/systemd/system/ephemeral-github-runner.service <<'EOF'
|
||||
{unit_b64}
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable ephemeral-github-runner.service
|
||||
|
||||
# Keep the image free of the build-time docker state (also shrinks the
|
||||
# snapshot: buildkit cache is several GB).
|
||||
docker rm -f "${{container}}" >/dev/null
|
||||
docker system prune -af >/dev/null
|
||||
trap - EXIT
|
||||
|
||||
echo "{DONE_MARKER}" > /dev/console
|
||||
"""
|
||||
|
||||
|
||||
def make_ecs_client(region_id: str):
|
||||
from alibabacloud_ecs20140526.client import Client as EcsClient
|
||||
from alibabacloud_tea_openapi.models import Config as OpenApiConfig
|
||||
|
||||
return EcsClient(
|
||||
OpenApiConfig(
|
||||
access_key_id=os.environ["ALIBABA_CLOUD_ACCESS_KEY_ID"],
|
||||
access_key_secret=os.environ["ALIBABA_CLOUD_ACCESS_KEY_SECRET"],
|
||||
region_id=region_id,
|
||||
endpoint=f"ecs.{region_id}.aliyuncs.com",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def wait_for_instance_status(client, region_id: str, instance_id: str, wanted: str, deadline: float) -> None:
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
import json
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
response = call_api_with_retry(
|
||||
lambda: client.describe_instances(
|
||||
ecs_models.DescribeInstancesRequest(
|
||||
region_id=region_id, instance_ids=json.dumps([instance_id])
|
||||
)
|
||||
),
|
||||
"DescribeInstances",
|
||||
)
|
||||
instances = response.body.instances.instance
|
||||
if instances and instances[0].status == wanted:
|
||||
return
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
raise TimeoutError(f"Instance {instance_id} did not reach status {wanted} in time")
|
||||
|
||||
|
||||
# Aliyun error codes worth retrying: throttling and server-side faults. Client
|
||||
# errors (4xx: permissions, bad parameters) are configuration problems and must
|
||||
# fail fast instead of being retried.
|
||||
TRANSIENT_ERROR_CODES = {"Throttling", "Throttling.User", "InternalError", "ServiceUnavailable"}
|
||||
|
||||
|
||||
def is_transient(error: Exception) -> bool:
|
||||
# UnretryableException comes from the darabonba network layer: connection
|
||||
# reset, read timeout, DNS blip.
|
||||
if type(error).__name__ == "UnretryableException":
|
||||
return True
|
||||
code = getattr(error, "code", "") or ""
|
||||
status = getattr(error, "statusCode", None)
|
||||
return code in TRANSIENT_ERROR_CODES or (isinstance(status, int) and status >= 500)
|
||||
|
||||
|
||||
def call_api_with_retry(fn, description: str, attempts: int = 5):
|
||||
"""Retry transient API/network failures; the build is too long to die on a blip.
|
||||
|
||||
Only call this with idempotent or safely-repeatable requests (reads,
|
||||
StopInstance, CreateImage). Never with RunInstances: if the request
|
||||
succeeded but the response was lost, a retry double-creates instances.
|
||||
"""
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as error: # noqa: BLE001
|
||||
if attempt == attempts or not is_transient(error):
|
||||
raise
|
||||
print(f"{description} failed (attempt {attempt}/{attempts}): {error}", flush=True)
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
raise RuntimeError("unreachable: retry loop exited without returning")
|
||||
|
||||
|
||||
def read_console_output(client, region_id: str, instance_id: str) -> str:
|
||||
"""Fetch the instance's serial console output; no in-guest agent needed."""
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
response = call_api_with_retry(
|
||||
lambda: client.get_instance_console_output(
|
||||
ecs_models.GetInstanceConsoleOutputRequest(region_id=region_id, instance_id=instance_id)
|
||||
),
|
||||
"GetInstanceConsoleOutput",
|
||||
)
|
||||
return base64.b64decode(response.body.console_output or "").decode("utf-8", "replace")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--region-id", default=os.environ.get("ALIYUN_ECS_REGION_ID"))
|
||||
parser.add_argument("--vswitch-id", default=os.environ.get("ALIYUN_ECS_VSWITCH_ID"))
|
||||
parser.add_argument("--security-group-id", default=os.environ.get("ALIYUN_ECS_SECURITY_GROUP_ID"))
|
||||
parser.add_argument("--base-image-id", default=os.environ.get("ALIYUN_ECS_BASE_IMAGE_ID"))
|
||||
parser.add_argument("--resource-group-id", default=os.environ.get("ALIYUN_ECS_RESOURCE_GROUP_ID"))
|
||||
parser.add_argument("--instance-type", default="ecs.g7.xlarge")
|
||||
parser.add_argument("--image-name", default=None, help="Defaults to a timestamped name.")
|
||||
args = parser.parse_args()
|
||||
|
||||
for name in ("region_id", "vswitch_id", "security_group_id", "base_image_id"):
|
||||
if not getattr(args, name):
|
||||
raise SystemExit(f"Missing required configuration: --{name.replace('_', '-')}")
|
||||
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
client = make_ecs_client(args.region_id)
|
||||
image_name = args.image_name or time.strftime(
|
||||
"greptimedb-query-regression-runner-%Y%m%d%H%M%S", time.gmtime()
|
||||
)
|
||||
|
||||
user_data = base64.b64encode(
|
||||
render_user_data(
|
||||
(ASSETS_DIR.parent / "Dockerfile").read_text(),
|
||||
(ASSETS_DIR / "start-runner.sh").read_text(),
|
||||
(ASSETS_DIR / "ephemeral-github-runner.service").read_text(),
|
||||
).encode()
|
||||
).decode()
|
||||
|
||||
instance_id = None
|
||||
try:
|
||||
response = client.run_instances(
|
||||
ecs_models.RunInstancesRequest(
|
||||
region_id=args.region_id,
|
||||
image_id=args.base_image_id,
|
||||
resource_group_id=args.resource_group_id,
|
||||
instance_type=args.instance_type,
|
||||
v_switch_id=args.vswitch_id,
|
||||
security_group_id=args.security_group_id,
|
||||
instance_name=f"build-{image_name}",
|
||||
description="Temporary builder for the query-regression ECS image",
|
||||
amount=1,
|
||||
instance_charge_type="PostPaid",
|
||||
spot_strategy="NoSpot",
|
||||
internet_charge_type="PayByTraffic",
|
||||
internet_max_bandwidth_out=100,
|
||||
# Peak usage is ~18G (OS+apt, docker image, and the materialized
|
||||
# toolchain coexist briefly): deliberately tight, and a smaller
|
||||
# disk makes the image snapshot faster. Instances created from
|
||||
# the image get a larger system disk from the provision side.
|
||||
system_disk=ecs_models.RunInstancesRequestSystemDisk(
|
||||
category="cloud_essd", size="20"
|
||||
),
|
||||
user_data=user_data,
|
||||
tag=[
|
||||
ecs_models.RunInstancesRequestTag(key="managed-by", value="query-regression-ci"),
|
||||
ecs_models.RunInstancesRequestTag(key="role", value="image-builder"),
|
||||
],
|
||||
)
|
||||
)
|
||||
instance_id = response.body.instance_id_sets.instance_id_set[0]
|
||||
print(f"Builder instance: {instance_id}", flush=True)
|
||||
wait_for_instance_status(client, args.region_id, instance_id, "Running", time.monotonic() + 10 * 60)
|
||||
|
||||
deadline = time.monotonic() + BUILD_TIMEOUT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
console = read_console_output(client, args.region_id, instance_id)
|
||||
if DONE_MARKER in console:
|
||||
break
|
||||
if FAILED_MARKER in console:
|
||||
tail = "\n".join(console.splitlines()[-20:])
|
||||
raise RuntimeError(f"Image build failed on the builder; console tail:\n{tail}")
|
||||
lines = console.splitlines()
|
||||
print(f"Build in progress; last console line: {lines[-1] if lines else '(none yet)'}", flush=True)
|
||||
time.sleep(CONSOLE_POLL_INTERVAL_SECONDS)
|
||||
else:
|
||||
raise TimeoutError("Image build did not finish in time")
|
||||
|
||||
print("Stopping builder before image creation", flush=True)
|
||||
try:
|
||||
call_api_with_retry(
|
||||
lambda: client.stop_instance(
|
||||
ecs_models.StopInstanceRequest(
|
||||
instance_id=instance_id,
|
||||
# The instance is deleted right after the snapshot, but
|
||||
# there is no reason to keep billing vCPU during the stop.
|
||||
stopped_mode="StopCharging",
|
||||
)
|
||||
),
|
||||
"StopInstance",
|
||||
)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# Instance families with local disks do not support StopCharging.
|
||||
print(f"StopCharging unavailable ({error}); stopping with default mode", flush=True)
|
||||
call_api_with_retry(
|
||||
lambda: client.stop_instance(ecs_models.StopInstanceRequest(instance_id=instance_id)),
|
||||
"StopInstance",
|
||||
)
|
||||
wait_for_instance_status(client, args.region_id, instance_id, "Stopped", time.monotonic() + 10 * 60)
|
||||
|
||||
image = call_api_with_retry(
|
||||
lambda: client.create_image(
|
||||
ecs_models.CreateImageRequest(
|
||||
region_id=args.region_id, instance_id=instance_id, image_name=image_name
|
||||
)
|
||||
),
|
||||
"CreateImage",
|
||||
)
|
||||
image_id = image.body.image_id
|
||||
deadline = time.monotonic() + 60 * 60
|
||||
while time.monotonic() < deadline:
|
||||
description = call_api_with_retry(
|
||||
lambda: client.describe_images(
|
||||
ecs_models.DescribeImagesRequest(region_id=args.region_id, image_id=image_id)
|
||||
),
|
||||
"DescribeImages",
|
||||
)
|
||||
images = description.body.images.image
|
||||
if images:
|
||||
status = images[0].status
|
||||
print(f"Image {image_id} status: {status} (progress {images[0].progress})", flush=True)
|
||||
if status == "Available":
|
||||
break
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Image {image_id} did not become Available in time. The snapshot "
|
||||
f"continues server-side: check its status in the console and reuse it "
|
||||
f"once Available instead of rebuilding."
|
||||
)
|
||||
|
||||
print(f"Custom image ready: {image_id} ({image_name})", flush=True)
|
||||
print(f"Set the repo variable QUERY_REGRESSION_ECS_IMAGE_ID={image_id}", flush=True)
|
||||
return 0
|
||||
finally:
|
||||
if instance_id:
|
||||
try:
|
||||
client.delete_instance(
|
||||
ecs_models.DeleteInstanceRequest(instance_id=instance_id, force=True)
|
||||
)
|
||||
print(f"Deleted builder instance {instance_id}", flush=True)
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"Failed to delete builder instance {instance_id}: {error}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
|
||||
[Unit]
|
||||
Description=Ephemeral GitHub Actions runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# cloud-final runs the provision user data, which writes
|
||||
# /etc/ephemeral-github-runner.env and the drop-ins below. Ordering after it
|
||||
# makes the runner come up configured on first boot, no restart needed.
|
||||
# (cloud-init's `systemctl restart` in user data remains as the bridge for
|
||||
# images built before this ordering existed.)
|
||||
After=cloud-final.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# The toolchain lives in the image at /opt/cargo/bin (the Dockerfile's ENV
|
||||
# PATH does not survive materialization onto the host). Publish it here so
|
||||
# every runner job inherits it. /etc/ephemeral-github-runner.env currently
|
||||
# carries the same PATH line as a bridge for images built before this
|
||||
# directive existed; EnvironmentFile is applied after Environment=, and the
|
||||
# two values are kept identical, so there is no conflict.
|
||||
Environment=PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
EnvironmentFile=/etc/ephemeral-github-runner.env
|
||||
ExecStart=/opt/ephemeral-github-runner/start-runner.sh
|
||||
Restart=no
|
||||
# If the kernel OOM killer kills a job child (rustc/cargo), keep the runner
|
||||
# service alive so the job is reported as a normal failure with logs instead
|
||||
# of the runner vanishing and GitHub reporting a cancellation. cloud-init's
|
||||
# oom.conf drop-in carries the same directive for images built before this.
|
||||
OOMPolicy=continue
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# 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.
|
||||
|
||||
# Configure and start the ephemeral GitHub Actions runner on an ECS instance.
|
||||
# Baked into the custom image at /opt/ephemeral-github-runner/ by
|
||||
# build-ecs-image.py; the per-run environment is written by cloud-init to
|
||||
# /etc/ephemeral-github-runner.env.
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/ephemeral-github-runner.env
|
||||
|
||||
export HOME=/home/runner
|
||||
cd /home/runner
|
||||
|
||||
if [[ ! -f /home/runner/.runner ]]; then
|
||||
runuser -u runner -- ./config.sh \
|
||||
--url "${REPO_URL}" \
|
||||
--token "${RUNNER_TOKEN}" \
|
||||
--name "${RUNNER_NAME}" \
|
||||
--labels "${RUNNER_LABELS}" \
|
||||
--ephemeral --unattended --replace --disableupdate
|
||||
fi
|
||||
|
||||
exec runuser -u runner -- ./run.sh
|
||||
@@ -1,146 +0,0 @@
|
||||
githubConfigUrl: "https://github.com/GreptimeTeam/greptimedb"
|
||||
githubConfigSecret: greptimedb-arc-github-app
|
||||
|
||||
runnerScaleSetName: "perf-regression-8-cores"
|
||||
minRunners: 0
|
||||
maxRunners: 1
|
||||
|
||||
template:
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
activeDeadlineSeconds: 12600
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: minipc-3
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: cargo-home
|
||||
emptyDir: {}
|
||||
- name: build-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: query-regression-build-cache
|
||||
initContainers:
|
||||
- name: initialize-build-cache
|
||||
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
umask 0002
|
||||
for directory in cargo-registry-v1 cargo-git-v1 query-regression-target-v1 meta-v1 sccache-v1; do
|
||||
cache_directory="/cache/${directory}"
|
||||
mkdir -p "${cache_directory}"
|
||||
test -w "${cache_directory}"
|
||||
probe_file="${cache_directory}/.write-probe"
|
||||
: > "${probe_file}"
|
||||
test -f "${probe_file}"
|
||||
rm "${probe_file}"
|
||||
done
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
ephemeral-storage: 1Gi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 1Gi
|
||||
volumeMounts:
|
||||
- name: build-cache
|
||||
mountPath: /cache
|
||||
containers:
|
||||
- name: runner
|
||||
image: greptime-registry.cn-hangzhou.cr.aliyuncs.com/greptime/greptimedb-query-regression-runner@sha256:e713b294e23b7e15184e558866c90025e59930033e72c97650dbc7f1ca022d11
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/home/runner/run.sh"]
|
||||
env:
|
||||
- name: CARGO_HOME
|
||||
value: /home/runner/.cargo
|
||||
- name: HTTP_PROXY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: query-regression-runner-local-env
|
||||
key: HTTP_PROXY
|
||||
optional: true
|
||||
- name: HTTPS_PROXY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: query-regression-runner-local-env
|
||||
key: HTTPS_PROXY
|
||||
optional: true
|
||||
- name: NO_PROXY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: query-regression-runner-local-env
|
||||
key: NO_PROXY
|
||||
optional: true
|
||||
- name: UV_CACHE_DIR
|
||||
value: /home/runner/.cargo/uv-cache
|
||||
- name: RUSTUP_HOME
|
||||
value: /opt/rustup
|
||||
- name: RUSTUP_TOOLCHAIN
|
||||
value: nightly-2026-03-21
|
||||
- name: RUSTUP_AUTO_INSTALL
|
||||
value: "0"
|
||||
- name: CARGO_TARGET_DIR
|
||||
value: /home/runner/query-regression-target
|
||||
- name: QUERY_REGRESSION_CACHE_META
|
||||
value: /home/runner/query-regression-cache-meta
|
||||
- name: RUSTC_WRAPPER
|
||||
value: /usr/local/bin/sccache
|
||||
- name: SCCACHE_DIR
|
||||
value: /home/runner/.cache/sccache
|
||||
- name: SCCACHE_CACHE_SIZE
|
||||
value: 40G
|
||||
- name: CARGO_INCREMENTAL
|
||||
value: "0"
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
resources:
|
||||
requests:
|
||||
cpu: "6"
|
||||
memory: 24Gi
|
||||
ephemeral-storage: 80Gi
|
||||
limits:
|
||||
cpu: "8"
|
||||
memory: 24Gi
|
||||
ephemeral-storage: 80Gi
|
||||
volumeMounts:
|
||||
- name: cargo-home
|
||||
mountPath: /home/runner/.cargo
|
||||
- name: build-cache
|
||||
mountPath: /home/runner/.cargo/registry
|
||||
subPath: cargo-registry-v1
|
||||
- name: build-cache
|
||||
mountPath: /home/runner/.cargo/git
|
||||
subPath: cargo-git-v1
|
||||
- name: build-cache
|
||||
mountPath: /home/runner/query-regression-target
|
||||
subPath: query-regression-target-v1
|
||||
- name: build-cache
|
||||
mountPath: /home/runner/query-regression-cache-meta
|
||||
subPath: meta-v1
|
||||
- name: build-cache
|
||||
mountPath: /home/runner/.cache/sccache
|
||||
subPath: sccache-v1
|
||||
@@ -1,2 +0,0 @@
|
||||
minRunners: 0
|
||||
maxRunners: 0
|
||||
@@ -0,0 +1,488 @@
|
||||
#!/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.
|
||||
|
||||
# This is the first `.github/scripts/` user of PEP 723 inline script metadata:
|
||||
# `uv run` installs the pinned Aliyun SDK before executing. The SDK import stays
|
||||
# lazy (inside `make_ecs_client`) so unit tests can import this module with a
|
||||
# plain stdlib interpreter.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# # Pin exactly after the first live run against the Aliyun API.
|
||||
# "alibabacloud_ecs20140526>=4.1.0,<6",
|
||||
# "alibabacloud_tea_openapi>=0.3.12,<1",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""Provision an ephemeral Aliyun ECS query-regression runner.
|
||||
|
||||
Creates one pay-as-you-go ECS instance from the query-regression custom image
|
||||
and waits until the instance registers itself as an ephemeral GitHub Actions
|
||||
runner with a per-run label. Build caches live on the instance system disk
|
||||
and disappear with the VM. The instance id is written to job outputs as soon
|
||||
as the VM exists so the teardown job is the single DeleteInstance caller (a
|
||||
timeout here used to delete and then teardown deleted again). On runner
|
||||
online timeout the console is dumped and the job fails; teardown releases
|
||||
the instance.
|
||||
|
||||
The Aliyun credentials are used only in this control-plane job (ubuntu-latest)
|
||||
and are never passed to the ECS instance; the instance receives only a
|
||||
short-lived GitHub runner registration token via user data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
RUNNER_NAME_PREFIX = "qreg-ecs"
|
||||
RUNNER_LABEL_PREFIX = "query-regression-ecs"
|
||||
MANAGED_BY_TAG_KEY = "managed-by"
|
||||
MANAGED_BY_TAG_VALUE = "query-regression-ci"
|
||||
RUN_TAG_KEY = "query-regression-run-id"
|
||||
|
||||
# Runner cache paths on the instance system disk. They are created empty
|
||||
# every provision and deleted with the VM.
|
||||
CACHE_PATHS = (
|
||||
"/home/runner/.cargo/registry",
|
||||
"/home/runner/.cargo/git",
|
||||
"/home/runner/query-regression-target",
|
||||
"/home/runner/query-regression-cache-meta",
|
||||
"/home/runner/.cache/sccache",
|
||||
)
|
||||
|
||||
RUNNER_ONLINE_TIMEOUT_SECONDS = 10 * 60
|
||||
POLL_INTERVAL_SECONDS = 5
|
||||
|
||||
# Nightly thin-LTO of greptime peaks above ecs.c9i.2xlarge's 16 GiB. A swap
|
||||
# file plus masking systemd-oomd lets the kernel reclaim rustc pages instead
|
||||
# of SIGTERM-ing the runner service cgroup (GitHub then reports the step as
|
||||
# "The operation was canceled"). Sized to match that 16 GiB instance; a
|
||||
# 32 GiB type still benefits as a safety net. Lives on the system disk next
|
||||
# to the cold build caches.
|
||||
SWAP_FILE = "/swapfile"
|
||||
SWAP_SIZE_GIB = 16
|
||||
# Image (~12G), 16G swap, checkout, base+candidate data homes, and cold
|
||||
# build caches. Deleted with the instance.
|
||||
SYSTEM_DISK_GIB = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvisionConfig:
|
||||
region_id: str
|
||||
vswitch_id: str
|
||||
security_group_id: str
|
||||
image_id: str
|
||||
instance_type: str
|
||||
repo: str
|
||||
run_id: str
|
||||
github_token: str
|
||||
# Optional resource group; required when the RAM grant is scoped to one.
|
||||
resource_group_id: str | None = None
|
||||
# Runner identity inside the image; the workflow asserts the same values.
|
||||
runner_uid: str = "1001"
|
||||
runner_gid: str = "1001"
|
||||
|
||||
|
||||
def runner_name_for_run(run_id: str) -> str:
|
||||
return f"{RUNNER_NAME_PREFIX}-{run_id}"
|
||||
|
||||
|
||||
def runner_label_for_run(run_id: str) -> str:
|
||||
return f"{RUNNER_LABEL_PREFIX}-{run_id}"
|
||||
|
||||
|
||||
def render_user_data(
|
||||
runner_name: str,
|
||||
runner_label: str,
|
||||
runner_token: str,
|
||||
repo: str,
|
||||
runner_uid: str = "1001",
|
||||
runner_gid: str = "1001",
|
||||
) -> str:
|
||||
"""Render the cloud-init shell script for the runner instance."""
|
||||
destinations = " ".join(f'"{dst}"' for dst in CACHE_PATHS)
|
||||
cache_setup = f"""# Caches live on the system disk, are deleted with the instance, and every
|
||||
# run compiles cold. Within-run reuse (base warming candidate via the shared
|
||||
# target dir and sccache) still applies.
|
||||
mkdir -p {destinations}
|
||||
chown -R {runner_uid}:{runner_gid} /home/runner"""
|
||||
swap_setup = f"""# Mask systemd-oomd before enabling swap: Ubuntu 24.04 kills the whole
|
||||
# service cgroup on PSI pressure, and swap thrashing looks like pressure.
|
||||
systemctl disable --now systemd-oomd.socket systemd-oomd.service || true
|
||||
systemctl mask systemd-oomd.socket systemd-oomd.service || true
|
||||
# apt-daily-upgrade / unattended-upgrades can `systemctl restart` services
|
||||
# whose libraries were updated. That SIGTERM-s the runner mid-job; GitHub
|
||||
# records UserCancelled (job still valid, child exit 143). These VMs live
|
||||
# ~1h; security updates belong in the image, not at job time.
|
||||
systemctl disable --now unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service || true
|
||||
systemctl mask unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service || true
|
||||
systemctl stop unattended-upgrades.service || true
|
||||
killall -9 unattended-upgr || true
|
||||
cat > /etc/apt/apt.conf.d/99disable-auto-updates <<'APTEOF'
|
||||
APT::Periodic::Update-Package-Lists "0";
|
||||
APT::Periodic::Unattended-Upgrade "0";
|
||||
APT::Periodic::Download-Upgradeable-Packages "0";
|
||||
APTEOF
|
||||
if [[ ! -f "{SWAP_FILE}" ]]; then
|
||||
fallocate --length {SWAP_SIZE_GIB}G "{SWAP_FILE}"
|
||||
chmod 600 "{SWAP_FILE}"
|
||||
mkswap "{SWAP_FILE}"
|
||||
fi
|
||||
swapon "{SWAP_FILE}"
|
||||
sysctl --write vm.swappiness=10
|
||||
swapon --show
|
||||
free --human"""
|
||||
return f"""#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
{cache_setup}
|
||||
|
||||
{swap_setup}
|
||||
|
||||
cat > /etc/ephemeral-github-runner.env <<'ENVEOF'
|
||||
RUNNER_NAME={runner_name}
|
||||
RUNNER_LABELS={runner_label}
|
||||
RUNNER_TOKEN={runner_token}
|
||||
REPO_URL=https://github.com/{repo}
|
||||
# The container image baked /opt/cargo/bin into PATH via Dockerfile ENV; on
|
||||
# the host nothing inherits that, so publish it through the unit's
|
||||
# EnvironmentFile: runner job processes inherit the runner's environment.
|
||||
PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ENVEOF
|
||||
|
||||
# Stream the runner service's own logs (config output, job lifecycle, crash
|
||||
# messages) to the serial console, next to the kernel's OOM-killer records.
|
||||
# Teardown dumps the console tail before deleting the instance, so the
|
||||
# witness of what killed the runner survives the machine.
|
||||
mkdir -p /etc/systemd/system/ephemeral-github-runner.service.d
|
||||
cat > /etc/systemd/system/ephemeral-github-runner.service.d/console.conf <<'CONFEOF'
|
||||
[Service]
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
CONFEOF
|
||||
# systemd's DefaultOOMPolicy=stop SIGTERM-s the whole unit when *any*
|
||||
# process in the cgroup is OOM-killed (typically rustc during thin LTO).
|
||||
# That is the "Session terminated, killing shell..." / job Canceled path:
|
||||
# runuser dies, GitHub deletes the ephemeral registration, always() steps
|
||||
# never run. continue keeps the runner up so cargo can report signal 9.
|
||||
cat > /etc/systemd/system/ephemeral-github-runner.service.d/oom.conf <<'CONFEOF'
|
||||
[Service]
|
||||
OOMPolicy=continue
|
||||
CONFEOF
|
||||
systemctl daemon-reload
|
||||
# restart (not start): the image enables this unit, so it may already
|
||||
# have come up without the drop-ins if it raced cloud-init. restart
|
||||
# applies OOMPolicy=continue to the running cgroup. --no-block matters:
|
||||
# newer image units carry After=cloud-final.service, and this script IS
|
||||
# cloud-final — a blocking restart would deadlock until TimeoutStartSec.
|
||||
systemctl restart --no-block ephemeral-github-runner.service
|
||||
"""
|
||||
|
||||
|
||||
def encode_user_data(script: str) -> str:
|
||||
return base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def github_api(token: str, method: str, path: str, body: dict | None = None) -> dict:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"https://api.github.com{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
# GitHub's error body says exactly why (e.g. "Must have admin rights to
|
||||
# Repository" for a PAT without the required scope); surface it instead
|
||||
# of a bare "HTTP Error 403".
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
raise SystemExit(
|
||||
f"GitHub API {method} {path} failed: HTTP {error.code}: {body}\n"
|
||||
"The token comes from the GH_PERSONAL_ACCESS_TOKEN secret; it needs "
|
||||
"'repo' scope (classic PAT) or 'Administration: write' on the "
|
||||
"repository (fine-grained PAT)."
|
||||
) from error
|
||||
|
||||
|
||||
def create_registration_token(github_token: str, repo: str) -> str:
|
||||
response = github_api(
|
||||
github_token,
|
||||
"POST",
|
||||
f"/repos/{repo}/actions/runners/registration-token",
|
||||
body={},
|
||||
)
|
||||
return response["token"]
|
||||
|
||||
|
||||
def find_runner_by_name(github_token: str, repo: str, name: str) -> dict | None:
|
||||
page = 1
|
||||
while True:
|
||||
response = github_api(
|
||||
github_token,
|
||||
"GET",
|
||||
f"/repos/{repo}/actions/runners?per_page=100&page={page}",
|
||||
)
|
||||
runners = response.get("runners", [])
|
||||
for runner in runners:
|
||||
if runner.get("name") == name:
|
||||
return runner
|
||||
if len(runners) < 100:
|
||||
return None
|
||||
page += 1
|
||||
|
||||
|
||||
def make_ecs_client(config: ProvisionConfig):
|
||||
from alibabacloud_ecs20140526.client import Client as EcsClient
|
||||
from alibabacloud_tea_openapi.models import Config as OpenApiConfig
|
||||
|
||||
access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID", "")
|
||||
access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "")
|
||||
if not access_key_id or not access_key_secret:
|
||||
raise SystemExit(
|
||||
"ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET are empty; in CI they come from the "
|
||||
"ALICLOUD_ECS_ACCESS_KEY_ID/SECRET repository secrets (a missing or "
|
||||
"misnamed secret expands to an empty string)."
|
||||
)
|
||||
return EcsClient(
|
||||
OpenApiConfig(
|
||||
access_key_id=access_key_id,
|
||||
access_key_secret=access_key_secret,
|
||||
region_id=config.region_id,
|
||||
endpoint=f"ecs.{config.region_id}.aliyuncs.com",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def wait_for_instance_status(client, region_id: str, instance_id: str, wanted: str, deadline: float) -> None:
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
response = client.describe_instances(
|
||||
ecs_models.DescribeInstancesRequest(
|
||||
region_id=region_id, instance_ids=json.dumps([instance_id])
|
||||
)
|
||||
)
|
||||
instances = response.body.instances.instance
|
||||
if instances and instances[0].status == wanted:
|
||||
return
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
raise TimeoutError(f"Instance {instance_id} did not reach status {wanted} in time")
|
||||
|
||||
|
||||
def fetch_console_output(client, region_id: str, instance_id: str) -> str:
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
response = client.get_instance_console_output(
|
||||
ecs_models.GetInstanceConsoleOutputRequest(region_id=region_id, instance_id=instance_id)
|
||||
)
|
||||
return base64.b64decode(response.body.console_output or "").decode("utf-8", "replace")
|
||||
|
||||
|
||||
class ConsoleTailer:
|
||||
"""Incrementally prints new serial-console lines so cloud-init progress is
|
||||
visible in the CI log while waiting, not only after a failure dump."""
|
||||
|
||||
def __init__(self, client, region_id: str, instance_id: str) -> None:
|
||||
self.client = client
|
||||
self.region_id = region_id
|
||||
self.instance_id = instance_id
|
||||
self.printed_lines = 0
|
||||
|
||||
def poll(self) -> None:
|
||||
try:
|
||||
lines = fetch_console_output(self.client, self.region_id, self.instance_id).splitlines()
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"[console] unable to fetch console output: {error}", flush=True)
|
||||
return
|
||||
# The API returns a bounded tail; if it ever shrinks, restart from the
|
||||
# beginning of the new buffer rather than skipping lines.
|
||||
if len(lines) < self.printed_lines:
|
||||
self.printed_lines = 0
|
||||
for line in lines[self.printed_lines :]:
|
||||
print(f"[console] {line}", flush=True)
|
||||
self.printed_lines = len(lines)
|
||||
|
||||
|
||||
def dump_console_output(client, region_id: str, instance_id: str) -> None:
|
||||
try:
|
||||
output = fetch_console_output(client, region_id, instance_id)
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"Unable to fetch console output for {instance_id}: {error}", flush=True)
|
||||
return
|
||||
tail = "\n".join(output.splitlines()[-80:])
|
||||
print(f"::group::Console output tail for {instance_id}\n{tail}\n::endgroup::", flush=True)
|
||||
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary_path:
|
||||
with open(summary_path, "a", encoding="utf-8") as summary:
|
||||
summary.write(f"<details><summary>ECS console output ({instance_id})</summary>\n\n```\n")
|
||||
summary.write(tail)
|
||||
summary.write("\n```\n</details>\n")
|
||||
|
||||
|
||||
def append_github_output(name: str, value: str) -> None:
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
with open(output_path, "a", encoding="utf-8") as output:
|
||||
output.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def run_instance(client, config: ProvisionConfig, user_data: str) -> str:
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
request = ecs_models.RunInstancesRequest(
|
||||
region_id=config.region_id,
|
||||
image_id=config.image_id,
|
||||
resource_group_id=config.resource_group_id,
|
||||
instance_type=config.instance_type,
|
||||
v_switch_id=config.vswitch_id,
|
||||
security_group_id=config.security_group_id,
|
||||
instance_name=runner_name_for_run(config.run_id),
|
||||
host_name=runner_name_for_run(config.run_id),
|
||||
description=f"Ephemeral query-regression runner for run {config.run_id}",
|
||||
amount=1,
|
||||
instance_charge_type="PostPaid",
|
||||
spot_strategy="NoSpot",
|
||||
internet_charge_type="PayByTraffic",
|
||||
internet_max_bandwidth_out=100,
|
||||
system_disk=ecs_models.RunInstancesRequestSystemDisk(
|
||||
category="cloud_essd",
|
||||
size=str(SYSTEM_DISK_GIB),
|
||||
),
|
||||
user_data=user_data,
|
||||
tag=[
|
||||
ecs_models.RunInstancesRequestTag(key=MANAGED_BY_TAG_KEY, value=MANAGED_BY_TAG_VALUE),
|
||||
ecs_models.RunInstancesRequestTag(key=RUN_TAG_KEY, value=config.run_id),
|
||||
],
|
||||
)
|
||||
response = client.run_instances(request)
|
||||
instance_id = response.body.instance_id_sets.instance_id_set[0]
|
||||
return instance_id
|
||||
|
||||
|
||||
def provision(config: ProvisionConfig) -> int:
|
||||
client = make_ecs_client(config)
|
||||
runner_name = runner_name_for_run(config.run_id)
|
||||
runner_label = runner_label_for_run(config.run_id)
|
||||
|
||||
registration_token = create_registration_token(config.github_token, config.repo)
|
||||
user_data = encode_user_data(
|
||||
render_user_data(
|
||||
runner_name,
|
||||
runner_label,
|
||||
registration_token,
|
||||
config.repo,
|
||||
config.runner_uid,
|
||||
config.runner_gid,
|
||||
)
|
||||
)
|
||||
|
||||
print(f"::group::Run instance {runner_name}", flush=True)
|
||||
instance_id = run_instance(client, config, user_data)
|
||||
print(f"Instance id: {instance_id}", flush=True)
|
||||
# Teardown is the only DeleteInstance caller. Write outputs immediately
|
||||
# so a later status/online failure still releases this VM.
|
||||
append_github_output("label", runner_label)
|
||||
append_github_output("instance_id", instance_id)
|
||||
append_github_output("runner_name", runner_name)
|
||||
wait_for_instance_status(client, config.region_id, instance_id, "Running", time.monotonic() + 5 * 60)
|
||||
print("::endgroup::", flush=True)
|
||||
|
||||
print("::group::Wait for runner online", flush=True)
|
||||
deadline = time.monotonic() + RUNNER_ONLINE_TIMEOUT_SECONDS
|
||||
tailer = ConsoleTailer(client, config.region_id, instance_id)
|
||||
last_console_poll = 0.0
|
||||
while time.monotonic() < deadline:
|
||||
runner = find_runner_by_name(config.github_token, config.repo, runner_name)
|
||||
if runner is not None:
|
||||
status = runner.get("status")
|
||||
print(f"Runner {runner_name} status: {status}", flush=True)
|
||||
if status == "online":
|
||||
print("::endgroup::", flush=True)
|
||||
print(f"Runner {runner_name} is online with label {runner_label}", flush=True)
|
||||
return 0
|
||||
# The serial console lags the guest by a minute or so; 30s polling is
|
||||
# enough to follow cloud-init without tripping API throttling.
|
||||
if time.monotonic() - last_console_poll >= 30:
|
||||
tailer.poll()
|
||||
last_console_poll = time.monotonic()
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
print("::endgroup::", flush=True)
|
||||
print(
|
||||
f"Runner {runner_name} did not come online in time; "
|
||||
"leaving instance for the teardown job to delete",
|
||||
flush=True,
|
||||
)
|
||||
dump_console_output(client, config.region_id, instance_id)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--region-id", default=os.environ.get("ALIYUN_ECS_REGION_ID"))
|
||||
parser.add_argument("--vswitch-id", default=os.environ.get("ALIYUN_ECS_VSWITCH_ID"))
|
||||
parser.add_argument("--security-group-id", default=os.environ.get("ALIYUN_ECS_SECURITY_GROUP_ID"))
|
||||
parser.add_argument("--image-id", default=os.environ.get("QUERY_REGRESSION_ECS_IMAGE_ID"))
|
||||
parser.add_argument("--instance-type", default=os.environ.get("ALIYUN_ECS_INSTANCE_TYPE"))
|
||||
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
|
||||
parser.add_argument("--run-id", default=os.environ.get("GITHUB_RUN_ID"))
|
||||
parser.add_argument("--github-token", default=os.environ.get("GH_PERSONAL_ACCESS_TOKEN"))
|
||||
parser.add_argument("--runner-uid", default=os.environ.get("QUERY_REGRESSION_RUNNER_UID", "1001"))
|
||||
parser.add_argument("--runner-gid", default=os.environ.get("QUERY_REGRESSION_RUNNER_GID", "1001"))
|
||||
parser.add_argument("--resource-group-id", default=os.environ.get("ALIYUN_ECS_RESOURCE_GROUP_ID"))
|
||||
args = parser.parse_args()
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name, value in vars(args).items()
|
||||
if name not in ("resource_group_id",)
|
||||
and (value is None or (isinstance(value, str) and not value))
|
||||
]
|
||||
if missing:
|
||||
flags = ", ".join(f"--{name.replace('_', '-')}" for name in missing)
|
||||
raise SystemExit(f"Missing required configuration: {flags} (or their env defaults)")
|
||||
|
||||
config = ProvisionConfig(
|
||||
region_id=args.region_id,
|
||||
vswitch_id=args.vswitch_id,
|
||||
security_group_id=args.security_group_id,
|
||||
image_id=args.image_id,
|
||||
instance_type=args.instance_type,
|
||||
repo=args.repo,
|
||||
run_id=args.run_id,
|
||||
github_token=args.github_token,
|
||||
resource_group_id=args.resource_group_id or None,
|
||||
runner_uid=args.runner_uid,
|
||||
runner_gid=args.runner_gid,
|
||||
)
|
||||
return provision(config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/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.
|
||||
|
||||
# PEP 723 inline metadata (see aliyun-ecs-runner-provision.py for the
|
||||
# convention). The SDK import stays lazy so unit tests run on a plain
|
||||
# stdlib interpreter.
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "alibabacloud_ecs20140526>=4.1.0,<6",
|
||||
# "alibabacloud_tea_openapi>=0.3.12,<1",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""Tear down ephemeral Aliyun ECS query-regression runners.
|
||||
|
||||
Two modes:
|
||||
|
||||
- Targeted (per workflow run): delete one instance by id and deregister its
|
||||
runner by name. Both steps are idempotent and best-effort; a missing
|
||||
instance or runner is not an error.
|
||||
- Sweep (scheduled janitor): delete every instance tagged as managed by
|
||||
query-regression CI whose creation time is older than the given TTL, and
|
||||
deregister the matching runners. This is the safety net for runs whose
|
||||
teardown job never executed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Reuse the GitHub API client, runner lookup, and tag constants from the
|
||||
# provision script.
|
||||
_PROVISION_SPEC = importlib.util.spec_from_file_location(
|
||||
"aliyun_ecs_runner_provision",
|
||||
Path(__file__).resolve().parent / "aliyun-ecs-runner-provision.py",
|
||||
)
|
||||
assert _PROVISION_SPEC is not None and _PROVISION_SPEC.loader is not None
|
||||
provision = importlib.util.module_from_spec(_PROVISION_SPEC)
|
||||
sys.modules[_PROVISION_SPEC.name] = provision
|
||||
_PROVISION_SPEC.loader.exec_module(provision)
|
||||
|
||||
SWEEP_TTL = timedelta(hours=4)
|
||||
# ECS DescribeInstances creation_time is ISO8601 UTC, e.g. "2026-08-17T02:13Z".
|
||||
CREATION_TIME_FORMATS = ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%MZ")
|
||||
|
||||
|
||||
def parse_creation_time(value: str) -> datetime:
|
||||
for fmt in CREATION_TIME_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(value, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"Unparsable ECS creation time: {value}")
|
||||
|
||||
|
||||
def expired_instance_names(
|
||||
instances: list[tuple[str, str, str]], now: datetime, ttl: timedelta
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Pick (instance_id, instance_name) pairs whose creation is older than ttl.
|
||||
|
||||
`instances` items are (instance_id, instance_name, creation_time).
|
||||
"""
|
||||
expired = []
|
||||
for instance_id, instance_name, creation_time in instances:
|
||||
age = now - parse_creation_time(creation_time)
|
||||
if age >= ttl:
|
||||
expired.append((instance_id, instance_name))
|
||||
return expired
|
||||
|
||||
|
||||
def make_ecs_client(region_id: str):
|
||||
from alibabacloud_ecs20140526.client import Client as EcsClient
|
||||
from alibabacloud_tea_openapi.models import Config as OpenApiConfig
|
||||
|
||||
access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID", "")
|
||||
access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "")
|
||||
if not access_key_id or not access_key_secret:
|
||||
raise SystemExit(
|
||||
"ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET are empty; in CI they come from the "
|
||||
"ALICLOUD_ECS_ACCESS_KEY_ID/SECRET repository secrets (a missing or "
|
||||
"misnamed secret expands to an empty string)."
|
||||
)
|
||||
return EcsClient(
|
||||
OpenApiConfig(
|
||||
access_key_id=access_key_id,
|
||||
access_key_secret=access_key_secret,
|
||||
region_id=region_id,
|
||||
endpoint=f"ecs.{region_id}.aliyuncs.com",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_instance(client, instance_id: str, region_id: str | None = None) -> bool:
|
||||
import time
|
||||
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
# Export the serial console before destroying the evidence: a cancelled
|
||||
# run still runs this teardown job, and the console tail (cloud-init,
|
||||
# runner service logs, kernel OOM records) is the only witness of what
|
||||
# happened on the machine.
|
||||
if region_id is not None:
|
||||
provision.dump_console_output(client, region_id, instance_id)
|
||||
|
||||
# DeleteInstance rejects instances that are still Initializing (or in
|
||||
# another transitional status). Teardown runs right after a cancelled
|
||||
# run, when the instance may be only seconds old, so retry the transient
|
||||
# status errors for a few minutes instead of leaking the instance.
|
||||
deadline = time.monotonic() + 5 * 60
|
||||
while True:
|
||||
try:
|
||||
client.delete_instance(ecs_models.DeleteInstanceRequest(instance_id=instance_id, force=True))
|
||||
print(f"Deleted instance {instance_id}", flush=True)
|
||||
return True
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error)
|
||||
if "InvalidInstanceId.NotFound" in message:
|
||||
print(f"Instance {instance_id} already gone", flush=True)
|
||||
return True
|
||||
if "IncorrectInstanceStatus" in message and time.monotonic() < deadline:
|
||||
print(f"Instance {instance_id} is in a transitional status; retrying delete", flush=True)
|
||||
time.sleep(15)
|
||||
continue
|
||||
print(f"Failed to delete instance {instance_id}: {error}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def deregister_runner(token: str, repo: str, runner_name: str) -> bool:
|
||||
runner = provision.find_runner_by_name(token, repo, runner_name)
|
||||
if runner is None:
|
||||
print(f"Runner {runner_name} is not registered", flush=True)
|
||||
return True
|
||||
try:
|
||||
provision.github_api(token, "DELETE", f"/repos/{repo}/actions/runners/{runner['id']}")
|
||||
print(f"Deregistered runner {runner_name} (id {runner['id']})", flush=True)
|
||||
return True
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"Failed to deregister runner {runner_name}: {error}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def list_managed_instances(client, region_id: str) -> list[tuple[str, str, str]]:
|
||||
from alibabacloud_ecs20140526 import models as ecs_models
|
||||
|
||||
result: list[tuple[str, str, str]] = []
|
||||
next_token = None
|
||||
while True:
|
||||
request = ecs_models.DescribeInstancesRequest(
|
||||
region_id=region_id,
|
||||
tag=[
|
||||
ecs_models.DescribeInstancesRequestTag(
|
||||
key=provision.MANAGED_BY_TAG_KEY, value=provision.MANAGED_BY_TAG_VALUE
|
||||
)
|
||||
],
|
||||
max_results=100,
|
||||
next_token=next_token,
|
||||
)
|
||||
response = client.describe_instances(request)
|
||||
for instance in response.body.instances.instance:
|
||||
result.append((instance.instance_id, instance.instance_name, instance.creation_time))
|
||||
next_token = response.body.next_token
|
||||
if not next_token:
|
||||
return result
|
||||
|
||||
|
||||
def sweep(client, region_id: str, repo: str, github_token: str, ttl: timedelta) -> int:
|
||||
instances = list_managed_instances(client, region_id)
|
||||
print(f"Found {len(instances)} managed instance(s) in {region_id}", flush=True)
|
||||
expired = expired_instance_names(instances, datetime.now(timezone.utc), ttl)
|
||||
ok = True
|
||||
for instance_id, instance_name in expired:
|
||||
print(f"Instance {instance_id} ({instance_name}) exceeds TTL {ttl}; deleting", flush=True)
|
||||
# Runner names mirror instance names by construction in the provision
|
||||
# script (both are qreg-ecs-<run_id>).
|
||||
ok &= delete_instance(client, instance_id, region_id)
|
||||
ok &= deregister_runner(github_token, repo, instance_name)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--region-id", default=os.environ.get("ALIYUN_ECS_REGION_ID"))
|
||||
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
|
||||
parser.add_argument("--github-token", default=os.environ.get("GH_PERSONAL_ACCESS_TOKEN"))
|
||||
parser.add_argument("--instance-id", default=os.environ.get("QUERY_REGRESSION_ECS_INSTANCE_ID"))
|
||||
parser.add_argument("--runner-name", default=os.environ.get("QUERY_REGRESSION_ECS_RUNNER_NAME"))
|
||||
parser.add_argument(
|
||||
"--sweep",
|
||||
action="store_true",
|
||||
help="Janitor mode: delete all managed instances older than --sweep-ttl-hours.",
|
||||
)
|
||||
parser.add_argument("--sweep-ttl-hours", type=float, default=SWEEP_TTL.total_seconds() / 3600)
|
||||
args = parser.parse_args()
|
||||
|
||||
for name in ("region_id", "repo", "github_token"):
|
||||
if not getattr(args, name):
|
||||
raise SystemExit(f"Missing required configuration: --{name.replace('_', '-')}")
|
||||
|
||||
client = make_ecs_client(args.region_id)
|
||||
|
||||
if args.sweep:
|
||||
return sweep(
|
||||
client, args.region_id, args.repo, args.github_token, timedelta(hours=args.sweep_ttl_hours)
|
||||
)
|
||||
|
||||
ok = True
|
||||
if args.instance_id:
|
||||
ok &= delete_instance(client, args.instance_id, args.region_id)
|
||||
else:
|
||||
print("No instance id given; skipping instance deletion", flush=True)
|
||||
if args.runner_name:
|
||||
ok &= deregister_runner(args.github_token, args.repo, args.runner_name)
|
||||
else:
|
||||
print("No runner name given; skipping runner deregistration", flush=True)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/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.
|
||||
|
||||
"""Resolve consecutive successful Nightly Build SHAs for query-regression.
|
||||
|
||||
Query regression builds both binaries from git; this script only picks the
|
||||
head SHAs of two Nightly Build workflow runs (today vs the previous success).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
NIGHTLY_WORKFLOW = "nightly-build.yml"
|
||||
NIGHTLY_EVENTS = frozenset({"schedule", "workflow_dispatch"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowRun:
|
||||
id: int
|
||||
head_sha: str
|
||||
head_branch: str
|
||||
html_url: str
|
||||
created_at: str
|
||||
conclusion: str
|
||||
event: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RefPair:
|
||||
base: WorkflowRun | None
|
||||
candidate: WorkflowRun | None
|
||||
skip: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def parse_run(payload: dict[str, Any]) -> WorkflowRun:
|
||||
return WorkflowRun(
|
||||
id=int(payload["id"]),
|
||||
head_sha=str(payload["head_sha"]),
|
||||
head_branch=str(payload.get("head_branch") or ""),
|
||||
html_url=str(payload.get("html_url") or ""),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
conclusion=str(payload.get("conclusion") or ""),
|
||||
event=str(payload.get("event") or ""),
|
||||
)
|
||||
|
||||
|
||||
def github_get(token: str, path: str, query: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
encoded = f"?{urllib.parse.urlencode(query)}" if query else ""
|
||||
request = urllib.request.Request(
|
||||
f"https://api.github.com{path}{encoded}",
|
||||
method="GET",
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
raise SystemExit(
|
||||
f"GitHub API GET {path} failed: HTTP {error.code}: {body}"
|
||||
) from error
|
||||
|
||||
|
||||
def list_successful_nightly_runs(
|
||||
token: str,
|
||||
repo: str,
|
||||
*,
|
||||
branch: str | None = None,
|
||||
per_page: int = 30,
|
||||
) -> list[WorkflowRun]:
|
||||
query = {
|
||||
"status": "success",
|
||||
"per_page": str(per_page),
|
||||
}
|
||||
if branch:
|
||||
query["branch"] = branch
|
||||
payload = github_get(
|
||||
token,
|
||||
f"/repos/{repo}/actions/workflows/{NIGHTLY_WORKFLOW}/runs",
|
||||
query,
|
||||
)
|
||||
runs = [parse_run(item) for item in payload.get("workflow_runs") or []]
|
||||
return [
|
||||
run
|
||||
for run in runs
|
||||
if run.conclusion == "success" and run.event in NIGHTLY_EVENTS and run.head_sha
|
||||
]
|
||||
|
||||
|
||||
def fetch_run(token: str, repo: str, run_id: int) -> WorkflowRun:
|
||||
payload = github_get(token, f"/repos/{repo}/actions/runs/{run_id}")
|
||||
return parse_run(payload)
|
||||
|
||||
|
||||
def select_base_and_candidate(
|
||||
runs: list[WorkflowRun],
|
||||
*,
|
||||
candidate_run_id: int | None = None,
|
||||
candidate: WorkflowRun | None = None,
|
||||
) -> RefPair:
|
||||
"""Pick candidate (newest / requested) and the previous successful run.
|
||||
|
||||
`runs` must be newest-first. Same-SHA consecutive nightlies are skipped
|
||||
(no commits that day).
|
||||
"""
|
||||
if candidate is None:
|
||||
if candidate_run_id is not None:
|
||||
candidate = next((run for run in runs if run.id == candidate_run_id), None)
|
||||
if candidate is None:
|
||||
return RefPair(
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
f"candidate nightly run {candidate_run_id} was not a successful "
|
||||
f"{NIGHTLY_WORKFLOW} run",
|
||||
)
|
||||
elif runs:
|
||||
candidate = runs[0]
|
||||
else:
|
||||
return RefPair(None, None, True, "no successful Nightly Build runs")
|
||||
|
||||
branch = candidate.head_branch
|
||||
older = [
|
||||
run
|
||||
for run in runs
|
||||
if run.id != candidate.id
|
||||
and (not branch or not run.head_branch or run.head_branch == branch)
|
||||
and (not candidate.created_at or run.created_at <= candidate.created_at)
|
||||
]
|
||||
if not older:
|
||||
return RefPair(
|
||||
None,
|
||||
candidate,
|
||||
True,
|
||||
"no previous successful Nightly Build to compare against",
|
||||
)
|
||||
base = older[0]
|
||||
if base.head_sha.lower() == candidate.head_sha.lower():
|
||||
return RefPair(
|
||||
base,
|
||||
candidate,
|
||||
True,
|
||||
f"previous nightly SHA {base.head_sha} matches candidate; nothing to compare",
|
||||
)
|
||||
return RefPair(base, candidate, False, "")
|
||||
|
||||
|
||||
def _override_run(sha: str) -> WorkflowRun:
|
||||
return WorkflowRun(
|
||||
id=0,
|
||||
head_sha=sha,
|
||||
head_branch="",
|
||||
html_url="",
|
||||
created_at="",
|
||||
conclusion="success",
|
||||
event="workflow_dispatch",
|
||||
)
|
||||
|
||||
|
||||
def override_pair(base_ref: str, candidate_ref: str) -> RefPair:
|
||||
return RefPair(_override_run(base_ref), _override_run(candidate_ref), False, "explicit refs")
|
||||
|
||||
|
||||
def write_outputs(pair: RefPair) -> None:
|
||||
values = {
|
||||
"skip": "true" if pair.skip else "false",
|
||||
"reason": pair.reason,
|
||||
"base_sha": pair.base.head_sha if pair.base else "",
|
||||
"candidate_sha": pair.candidate.head_sha if pair.candidate else "",
|
||||
"base_run_url": pair.base.html_url if pair.base else "",
|
||||
"candidate_run_url": pair.candidate.html_url if pair.candidate else "",
|
||||
"base_run_id": str(pair.base.id) if pair.base and pair.base.id else "",
|
||||
"candidate_run_id": str(pair.candidate.id) if pair.candidate and pair.candidate.id else "",
|
||||
}
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
for key, value in values.items():
|
||||
handle.write(f"{key}={value}\n")
|
||||
for key, value in values.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"),
|
||||
)
|
||||
parser.add_argument("--branch", default=os.environ.get("NIGHTLY_BRANCH") or "")
|
||||
parser.add_argument(
|
||||
"--candidate-run-id",
|
||||
default=os.environ.get("CANDIDATE_RUN_ID") or "",
|
||||
)
|
||||
parser.add_argument("--base-ref", default=os.environ.get("BASE_REF") or "")
|
||||
parser.add_argument("--candidate-ref", default=os.environ.get("CANDIDATE_REF") or "")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
base_ref = args.base_ref.strip()
|
||||
candidate_ref = args.candidate_ref.strip()
|
||||
if base_ref and candidate_ref:
|
||||
write_outputs(override_pair(base_ref, candidate_ref))
|
||||
return 0
|
||||
if bool(base_ref) != bool(candidate_ref):
|
||||
print("Provide both --base-ref and --candidate-ref, or neither.", file=sys.stderr)
|
||||
return 2
|
||||
if not args.repo or not args.token:
|
||||
print("--repo and --token (or GITHUB_REPOSITORY / GITHUB_TOKEN) are required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
candidate_run_id = int(args.candidate_run_id) if str(args.candidate_run_id).strip() else None
|
||||
candidate: WorkflowRun | None = None
|
||||
branch = args.branch.strip() or None
|
||||
if candidate_run_id is not None:
|
||||
candidate = fetch_run(args.token, args.repo, candidate_run_id)
|
||||
if candidate.conclusion != "success":
|
||||
write_outputs(
|
||||
RefPair(
|
||||
None,
|
||||
candidate,
|
||||
True,
|
||||
f"nightly run {candidate_run_id} conclusion is {candidate.conclusion}",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
branch = branch or candidate.head_branch or None
|
||||
|
||||
runs = list_successful_nightly_runs(args.token, args.repo, branch=branch)
|
||||
pair = select_base_and_candidate(
|
||||
runs,
|
||||
candidate_run_id=candidate_run_id if candidate is None else None,
|
||||
candidate=candidate,
|
||||
)
|
||||
write_outputs(pair)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -18,10 +18,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_CASES = [
|
||||
@@ -99,38 +106,479 @@ def append_step_summary(summary: Path) -> None:
|
||||
out.write(summary.read_text())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunTarget:
|
||||
name: str
|
||||
binary: Path
|
||||
work_dir: Path
|
||||
http_port: int
|
||||
grpc_port: int
|
||||
mysql_port: int
|
||||
postgres_port: int
|
||||
metasrv_rpc_port: int
|
||||
metasrv_http_port: int
|
||||
datanode_rpc_port: int
|
||||
datanode_http_port: int
|
||||
datanode_data_dir: Path
|
||||
frontend_config: Path | None = None
|
||||
|
||||
|
||||
def allocate_ports(n: int) -> list[int]:
|
||||
socks = []
|
||||
try:
|
||||
for _ in range(n):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
socks.append(sock)
|
||||
return [sock.getsockname()[1] for sock in socks]
|
||||
finally:
|
||||
for sock in socks:
|
||||
sock.close()
|
||||
|
||||
|
||||
def make_target(
|
||||
name: str,
|
||||
binary: Path,
|
||||
root: Path,
|
||||
ports: list[int],
|
||||
frontend_config: Path | None = None,
|
||||
) -> RunTarget:
|
||||
work_dir = root / name
|
||||
return RunTarget(
|
||||
name=name,
|
||||
binary=binary,
|
||||
work_dir=work_dir,
|
||||
http_port=ports[4],
|
||||
grpc_port=ports[5],
|
||||
mysql_port=ports[6],
|
||||
postgres_port=ports[7],
|
||||
metasrv_rpc_port=ports[0],
|
||||
metasrv_http_port=ports[1],
|
||||
datanode_rpc_port=ports[2],
|
||||
datanode_http_port=ports[3],
|
||||
datanode_data_dir=work_dir / "datanode-0" / "data",
|
||||
frontend_config=frontend_config,
|
||||
)
|
||||
|
||||
|
||||
def wait_health(port: int, timeout_s: float = 60.0) -> None:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) as response:
|
||||
if response.status < 500:
|
||||
return
|
||||
except Exception as err: # noqa: BLE001 - retain the last health diagnostic
|
||||
last = err
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"health check timed out on port {port}: {last}")
|
||||
|
||||
|
||||
def component_log_dir(target: RunTarget, name: str) -> Path:
|
||||
return target.work_dir / "logs" / ("datanode-0" if name == "datanode" else name)
|
||||
|
||||
|
||||
def component_command(target: RunTarget, name: str) -> list[str]:
|
||||
log_dir = component_log_dir(target, name)
|
||||
if name == "metasrv":
|
||||
return [
|
||||
str(target.binary), "metasrv", "start",
|
||||
"--grpc-bind-addr", f"127.0.0.1:{target.metasrv_rpc_port}",
|
||||
"--grpc-server-addr", f"127.0.0.1:{target.metasrv_rpc_port}",
|
||||
"--http-addr", f"127.0.0.1:{target.metasrv_http_port}",
|
||||
"--backend", "memory-store", "--enable-region-failover", "false",
|
||||
"--log-dir", str(log_dir),
|
||||
]
|
||||
if name == "datanode":
|
||||
return [
|
||||
str(target.binary), "datanode", "start",
|
||||
"--grpc-bind-addr", f"127.0.0.1:{target.datanode_rpc_port}",
|
||||
"--grpc-server-addr", f"127.0.0.1:{target.datanode_rpc_port}",
|
||||
"--http-addr", f"127.0.0.1:{target.datanode_http_port}",
|
||||
"--data-home", str(target.datanode_data_dir), "--log-dir", str(log_dir),
|
||||
"--node-id", "0", "--metasrv-addrs", f"127.0.0.1:{target.metasrv_rpc_port}",
|
||||
]
|
||||
if name == "frontend":
|
||||
command = [str(target.binary), "frontend", "start"]
|
||||
if target.frontend_config is not None:
|
||||
command.extend(["--config-file", str(target.frontend_config)])
|
||||
command.extend([
|
||||
"--metasrv-addrs", f"127.0.0.1:{target.metasrv_rpc_port}",
|
||||
"--http-addr", f"127.0.0.1:{target.http_port}",
|
||||
"--grpc-bind-addr", f"127.0.0.1:{target.grpc_port}",
|
||||
"--grpc-server-addr", f"127.0.0.1:{target.grpc_port}",
|
||||
"--mysql-addr", f"127.0.0.1:{target.mysql_port}",
|
||||
"--postgres-addr", f"127.0.0.1:{target.postgres_port}",
|
||||
"--log-dir", str(log_dir),
|
||||
])
|
||||
return command
|
||||
raise ValueError(f"unknown component: {name}")
|
||||
|
||||
|
||||
def start_component(
|
||||
target: RunTarget,
|
||||
name: str,
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]],
|
||||
) -> None:
|
||||
if name != "metasrv":
|
||||
metasrv = procs.get((target.name, "metasrv"))
|
||||
if metasrv is None or metasrv.poll() is not None:
|
||||
raise RuntimeError("metasrv exited; memory-store metadata is no longer valid")
|
||||
if name == "datanode":
|
||||
target.datanode_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
logs = component_log_dir(target, name)
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
with (logs / "stdout.log").open("ab") as out, (logs / "stderr.log").open("ab") as err:
|
||||
procs[(target.name, name)] = subprocess.Popen(
|
||||
component_command(target, name),
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
start_new_session=True,
|
||||
)
|
||||
wait_health({
|
||||
"metasrv": target.metasrv_http_port,
|
||||
"datanode": target.datanode_http_port,
|
||||
"frontend": target.http_port,
|
||||
}[name])
|
||||
|
||||
|
||||
def stop_component(
|
||||
target: RunTarget,
|
||||
name: str,
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]],
|
||||
) -> None:
|
||||
proc = procs.pop((target.name, name), None)
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
proc.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
proc.wait(timeout=20)
|
||||
|
||||
|
||||
def restart_component(
|
||||
target: RunTarget,
|
||||
name: str,
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]],
|
||||
) -> None:
|
||||
stop_component(target, name, procs)
|
||||
start_component(target, name, procs)
|
||||
|
||||
|
||||
def stop_all(targets: list[RunTarget], procs: dict[tuple[str, str], subprocess.Popen[bytes]]) -> None:
|
||||
for target in reversed(targets):
|
||||
for name in ("frontend", "datanode", "metasrv"):
|
||||
stop_component(target, name, procs)
|
||||
|
||||
|
||||
def load_plan(fixture_generator: Path, case_path: Path) -> dict[str, Any]:
|
||||
result = subprocess.run(
|
||||
[str(fixture_generator), "plan", "--case", str(case_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"query_perf_fixture plan failed: {result.stderr[:2000]}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_direct_case(
|
||||
args: argparse.Namespace,
|
||||
case_path: Path,
|
||||
work_dir: Path,
|
||||
base_bin: Path,
|
||||
candidate_bin: Path,
|
||||
fixture_generator: Path,
|
||||
runner: Path,
|
||||
) -> int:
|
||||
ports = allocate_ports(16)
|
||||
targets = [
|
||||
make_target("base", base_bin, work_dir, ports[:8]),
|
||||
make_target("candidate", candidate_bin, work_dir, ports[8:]),
|
||||
]
|
||||
for target in targets:
|
||||
if target.work_dir.exists() and any(target.work_dir.iterdir()):
|
||||
raise RuntimeError(f"target work_dir exists and is non-empty: {target.work_dir}")
|
||||
target.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]] = {}
|
||||
try:
|
||||
for target in targets:
|
||||
start_component(target, "metasrv", procs)
|
||||
start_component(target, "datanode", procs)
|
||||
start_component(target, "frontend", procs)
|
||||
|
||||
prepare = [
|
||||
str(runner), "prepare-direct", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--base-http-port", str(targets[0].http_port),
|
||||
"--candidate-http-port", str(targets[1].http_port),
|
||||
"--fixture-dir", str(work_dir / "fixture"),
|
||||
"--output", str(work_dir / "prepare-direct.json"),
|
||||
"--http-timeout", str(args.http_timeout),
|
||||
]
|
||||
if parse_bool(args.allow_large_fixture):
|
||||
prepare.append("--allow-large-fixture")
|
||||
prepare_status = subprocess.run(prepare, check=False).returncode
|
||||
if prepare_status != 0:
|
||||
return prepare_status
|
||||
|
||||
for target in targets:
|
||||
stop_component(target, "datanode", procs)
|
||||
|
||||
prepared = json.loads((work_dir / "prepare-direct.json").read_text(encoding="utf-8"))
|
||||
fixtures = prepared.get("fixtures")
|
||||
if not isinstance(fixtures, list):
|
||||
raise RuntimeError("prepare-direct report has no fixtures array")
|
||||
for target in targets:
|
||||
destination = target.work_dir / "materialize-destination.toml"
|
||||
destination.write_text(
|
||||
f"data_home = {json.dumps(str(target.datanode_data_dir))}\n"
|
||||
"object_store = { type = \"File\" }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for fixture in fixtures:
|
||||
fixture_dir = fixture.get("fixture_dir") if isinstance(fixture, dict) else None
|
||||
if not isinstance(fixture_dir, str):
|
||||
raise RuntimeError("prepare-direct fixture record has no fixture_dir")
|
||||
materialize = [
|
||||
str(runner), "materialize", "--fixture-dir", fixture_dir,
|
||||
"--destination", str(destination),
|
||||
]
|
||||
materialize_status = subprocess.run(materialize, check=False).returncode
|
||||
if materialize_status != 0:
|
||||
return materialize_status
|
||||
|
||||
for target in targets:
|
||||
start_component(target, "datanode", procs)
|
||||
|
||||
measure = [
|
||||
str(runner), "measure", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--base-http-port", str(targets[0].http_port),
|
||||
"--candidate-http-port", str(targets[1].http_port),
|
||||
"--output", str(work_dir / "query-regression-report.json"),
|
||||
"--http-timeout", str(args.http_timeout),
|
||||
]
|
||||
status = subprocess.run(measure, check=False).returncode
|
||||
if status != 0:
|
||||
for target in targets:
|
||||
restart_component(target, "frontend", procs)
|
||||
status = subprocess.run(measure, check=False).returncode
|
||||
return status
|
||||
finally:
|
||||
stop_all(targets, procs)
|
||||
|
||||
|
||||
def run_remote_case(
|
||||
args: argparse.Namespace,
|
||||
case_path: Path,
|
||||
work_dir: Path,
|
||||
base_bin: Path,
|
||||
candidate_bin: Path,
|
||||
fixture_generator: Path,
|
||||
runner: Path,
|
||||
) -> int:
|
||||
ports = allocate_ports(16)
|
||||
targets = [
|
||||
make_target(
|
||||
"base",
|
||||
base_bin,
|
||||
work_dir,
|
||||
ports[:8],
|
||||
work_dir / "base" / "frontend-prom-store.toml",
|
||||
),
|
||||
make_target(
|
||||
"candidate",
|
||||
candidate_bin,
|
||||
work_dir,
|
||||
ports[8:],
|
||||
work_dir / "candidate" / "frontend-prom-store.toml",
|
||||
),
|
||||
]
|
||||
for target in targets:
|
||||
if target.work_dir.exists() and any(target.work_dir.iterdir()):
|
||||
raise RuntimeError(f"target work_dir exists and is non-empty: {target.work_dir}")
|
||||
target.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
if target.frontend_config is None:
|
||||
raise RuntimeError(f"remote target has no frontend config path: {target.name}")
|
||||
render = [
|
||||
str(runner), "render-remote-config", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--output", str(target.frontend_config),
|
||||
]
|
||||
render_status = subprocess.run(render, check=False).returncode
|
||||
if render_status != 0:
|
||||
return render_status
|
||||
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]] = {}
|
||||
report = work_dir / "query-regression-report.json"
|
||||
try:
|
||||
for target in targets:
|
||||
start_component(target, "metasrv", procs)
|
||||
start_component(target, "datanode", procs)
|
||||
start_component(target, "frontend", procs)
|
||||
|
||||
prepare = [
|
||||
str(runner), "prepare-remote", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--base-http-port", str(targets[0].http_port),
|
||||
"--candidate-http-port", str(targets[1].http_port),
|
||||
"--output", str(work_dir / "prepare-remote.json"),
|
||||
"--http-timeout", str(args.http_timeout),
|
||||
]
|
||||
prepare_status = subprocess.run(prepare, check=False).returncode
|
||||
if prepare_status != 0:
|
||||
return prepare_status
|
||||
|
||||
measure = [
|
||||
str(runner), "measure", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--base-http-port", str(targets[0].http_port),
|
||||
"--candidate-http-port", str(targets[1].http_port),
|
||||
"--output", str(report), "--http-timeout", str(args.http_timeout),
|
||||
]
|
||||
measure_status = subprocess.run(measure, check=False).returncode
|
||||
if measure_status != 0 and not report.exists():
|
||||
return measure_status
|
||||
|
||||
for target in targets:
|
||||
stop_component(target, "datanode", procs)
|
||||
|
||||
finalize = [
|
||||
str(runner), "finalize-remote", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--candidate-bin", str(candidate_bin),
|
||||
"--base-data-home", str(targets[0].datanode_data_dir),
|
||||
"--candidate-data-home", str(targets[1].datanode_data_dir),
|
||||
"--report", str(report),
|
||||
]
|
||||
finalize_status = subprocess.run(finalize, check=False).returncode
|
||||
if finalize_status != 0:
|
||||
return finalize_status
|
||||
final_report = json.loads(report.read_text(encoding="utf-8"))
|
||||
return 1 if measure_status != 0 or final_report.get("status") == "failed" else 0
|
||||
finally:
|
||||
stop_all(targets, procs)
|
||||
|
||||
|
||||
def run_otlp_case(
|
||||
args: argparse.Namespace,
|
||||
case_path: Path,
|
||||
work_dir: Path,
|
||||
base_bin: Path,
|
||||
candidate_bin: Path,
|
||||
fixture_generator: Path,
|
||||
runner: Path,
|
||||
) -> int:
|
||||
if args.otelgen_bin is None:
|
||||
raise ValueError("--otelgen-bin is required for otlp_trace_load")
|
||||
ports = allocate_ports(16)
|
||||
targets = [
|
||||
make_target("base", base_bin, work_dir, ports[:8]),
|
||||
make_target("candidate", candidate_bin, work_dir, ports[8:]),
|
||||
]
|
||||
for target in targets:
|
||||
if target.work_dir.exists() and any(target.work_dir.iterdir()):
|
||||
raise RuntimeError(f"target work_dir exists and is non-empty: {target.work_dir}")
|
||||
target.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
procs: dict[tuple[str, str], subprocess.Popen[bytes]] = {}
|
||||
try:
|
||||
for target in targets:
|
||||
try:
|
||||
start_component(target, "metasrv", procs)
|
||||
start_component(target, "datanode", procs)
|
||||
start_component(target, "frontend", procs)
|
||||
command = [
|
||||
str(runner), "run-otlp-target", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--otelgen-bin", str(args.otelgen_bin),
|
||||
"--http-port", str(target.http_port),
|
||||
"--target-name", target.name,
|
||||
"--work-dir", str(target.work_dir),
|
||||
"--output", str(target.work_dir / "report.json"),
|
||||
"--http-timeout", str(args.http_timeout),
|
||||
]
|
||||
status = subprocess.run(command, check=False).returncode
|
||||
finally:
|
||||
stop_all([target], procs)
|
||||
if status != 0:
|
||||
return status
|
||||
|
||||
output = work_dir / "query-regression-report.json"
|
||||
finalize = [
|
||||
str(runner), "finalize-otlp", "--case", str(case_path),
|
||||
"--fixture-generator", str(fixture_generator),
|
||||
"--base-result", str(targets[0].work_dir / "report.json"),
|
||||
"--candidate-result", str(targets[1].work_dir / "report.json"),
|
||||
"--output", str(output),
|
||||
]
|
||||
status = subprocess.run(finalize, check=False).returncode
|
||||
if status != 0:
|
||||
return status
|
||||
report = json.loads(output.read_text(encoding="utf-8"))
|
||||
return 1 if report.get("status") == "failed" else 0
|
||||
finally:
|
||||
stop_all(targets, procs)
|
||||
|
||||
|
||||
def run_case(args: argparse.Namespace, case_path: Path, work_dir: Path) -> int:
|
||||
target_dir = profile_dir(args.cargo_profile)
|
||||
base_bin = args.base_bin or args.base_src / "target" / target_dir / "greptime"
|
||||
candidate_bin = args.candidate_bin or args.candidate_src / "target" / target_dir / "greptime"
|
||||
fixture_generator = args.fixture_generator or args.candidate_src / "target" / target_dir / "query_perf_fixture"
|
||||
cmd = [
|
||||
"uv",
|
||||
"run",
|
||||
"--no-project",
|
||||
"python",
|
||||
str(args.candidate_src / "tests/perf/query_regression_runner.py"),
|
||||
"--case",
|
||||
str(case_path),
|
||||
"--base-bin",
|
||||
str(base_bin),
|
||||
"--candidate-bin",
|
||||
str(candidate_bin),
|
||||
"--fixture-generator",
|
||||
str(fixture_generator),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
"--http-timeout",
|
||||
str(args.http_timeout),
|
||||
]
|
||||
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)])
|
||||
|
||||
runner = args.runner or args.candidate_src / "target" / target_dir / "query_regression_runner"
|
||||
plan = load_plan(fixture_generator, case_path)
|
||||
scenario = plan.get("scenario")
|
||||
kind = scenario.get("kind") if isinstance(scenario, dict) else None
|
||||
print(f"::group::Query regression case: {case_path}", flush=True)
|
||||
try:
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
if kind == "direct_readable_sst":
|
||||
return run_direct_case(
|
||||
args,
|
||||
case_path,
|
||||
work_dir,
|
||||
base_bin,
|
||||
candidate_bin,
|
||||
fixture_generator,
|
||||
runner,
|
||||
)
|
||||
if kind == "prom_remote_write_then_query":
|
||||
return run_remote_case(
|
||||
args,
|
||||
case_path,
|
||||
work_dir,
|
||||
base_bin,
|
||||
candidate_bin,
|
||||
fixture_generator,
|
||||
runner,
|
||||
)
|
||||
if kind == "otlp_trace_load":
|
||||
return run_otlp_case(
|
||||
args,
|
||||
case_path,
|
||||
work_dir,
|
||||
base_bin,
|
||||
candidate_bin,
|
||||
fixture_generator,
|
||||
runner,
|
||||
)
|
||||
raise ValueError(
|
||||
f"unsupported scenario kind {kind!r}; supported: "
|
||||
"'direct_readable_sst', 'prom_remote_write_then_query', 'otlp_trace_load'"
|
||||
)
|
||||
finally:
|
||||
print("::endgroup::", flush=True)
|
||||
|
||||
@@ -156,6 +604,27 @@ def write_summary(args: argparse.Namespace, reports: list[Path]) -> int:
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
|
||||
|
||||
def write_failed_report(work_dir: Path, case_path: Path, error: Exception) -> None:
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = work_dir / "query-regression-report.json"
|
||||
try:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(report, dict) or not isinstance(report.get("targets"), list) or not isinstance(report.get("thresholds"), list):
|
||||
raise ValueError("existing report is malformed")
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
report = {
|
||||
"case_path": str(case_path),
|
||||
"targets": [],
|
||||
"thresholds": [],
|
||||
}
|
||||
report["status"] = "failed"
|
||||
report["error"] = repr(error)
|
||||
path.write_text(
|
||||
json.dumps(report, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cases", action="append", help="'all', 'heavy', or comma/space separated case paths")
|
||||
@@ -169,6 +638,7 @@ def main() -> int:
|
||||
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("--runner", type=Path, default=configured_path(os.environ.get("QUERY_REGRESSION_RUNNER")))
|
||||
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"))
|
||||
@@ -198,7 +668,12 @@ def main() -> int:
|
||||
case_path = resolve_case_path(args.candidate_src, case)
|
||||
work_dir = args.work_dir / case_slug(case_path)
|
||||
reports.append(work_dir / "query-regression-report.json")
|
||||
case_status = run_case(args, case_path, work_dir)
|
||||
try:
|
||||
case_status = run_case(args, case_path, work_dir)
|
||||
except Exception as err: # noqa: BLE001 - preserve an aggregate report for the summary step
|
||||
print(f"error: query regression case {case_path}: {err}", flush=True)
|
||||
write_failed_report(work_dir, case_path, err)
|
||||
case_status = 1
|
||||
if case_status != 0:
|
||||
status = case_status or 1
|
||||
|
||||
|
||||
@@ -71,6 +71,14 @@ jobs:
|
||||
version: "latest"
|
||||
- name: Run check-version script tests
|
||||
run: uv run --no-project python .github/scripts/check-version-test.py
|
||||
- name: Run query-regression Python tooling tests
|
||||
run: |
|
||||
uv run --no-project --python 3.12 python tests/perf/test_query_regression_runner_compaction_toctou.py
|
||||
uv run --no-project --python 3.12 python tests/perf/test_query_regression_runner_otlp_trace_load.py
|
||||
uv run --no-project --python 3.12 python tests/perf/test_query_regression_summary_otlp.py
|
||||
uv run --no-project --python 3.12 python tests/perf/test_query_regression_case_selection.py
|
||||
uv run --no-project --python 3.12 python tests/perf/test_query_regression_nightly_refs.py
|
||||
uv run --no-project --python 3.12 python tests/perf/test_aliyun_ecs_runner_scripts.py
|
||||
|
||||
check:
|
||||
if: ${{ github.repository == 'GreptimeTeam/greptimedb' }}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Query Regression ECS Janitor
|
||||
|
||||
# Safety net for the aliyun-ecs query-regression path: deletes managed ECS
|
||||
# instances (and their runner registrations) that outlived their workflow run,
|
||||
# so a failed teardown never leaks pay-as-you-go spend.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout teardown script
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Sweep expired ECS runners
|
||||
env:
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_ID }}
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_SECRET }}
|
||||
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
|
||||
ALIYUN_ECS_REGION_ID: ${{ vars.ALIYUN_ECS_REGION_ID }}
|
||||
run: >-
|
||||
uv run .github/scripts/aliyun-ecs-runner-teardown.py
|
||||
--sweep --sweep-ttl-hours 4
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Query Regression Nightly
|
||||
|
||||
# After a successful GreptimeDB Nightly Build, compare that commit against
|
||||
# the previous successful nightly. The reusable Query Regression workflow
|
||||
# still compiles both SHAs; this wrapper only resolves which two SHAs.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- GreptimeDB Nightly Build
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_ref:
|
||||
description: Base ref/SHA (empty = previous successful nightly)
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
candidate_ref:
|
||||
description: Candidate ref/SHA (empty = latest successful nightly)
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
candidate_run_id:
|
||||
description: Nightly Build run id to treat as candidate (empty = latest)
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
case:
|
||||
description: Query perf case path(s), all, or heavy
|
||||
required: false
|
||||
type: string
|
||||
default: all
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
resolve-refs:
|
||||
name: Resolve previous vs current nightly SHAs
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
skip: ${{ steps.refs.outputs.skip }}
|
||||
reason: ${{ steps.refs.outputs.reason }}
|
||||
base_sha: ${{ steps.refs.outputs.base_sha }}
|
||||
candidate_sha: ${{ steps.refs.outputs.candidate_sha }}
|
||||
steps:
|
||||
- name: Checkout ref resolver
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve nightly SHAs
|
||||
id: refs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
CANDIDATE_RUN_ID: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || inputs.candidate_run_id || '' }}
|
||||
BASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.base_ref || '' }}
|
||||
CANDIDATE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_ref || '' }}
|
||||
run: python3 .github/scripts/query-regression-nightly-refs.py
|
||||
|
||||
- name: Summarize comparison
|
||||
if: always()
|
||||
env:
|
||||
SKIP: ${{ steps.refs.outputs.skip }}
|
||||
REASON: ${{ steps.refs.outputs.reason }}
|
||||
BASE_SHA: ${{ steps.refs.outputs.base_sha }}
|
||||
CANDIDATE_SHA: ${{ steps.refs.outputs.candidate_sha }}
|
||||
BASE_RUN_URL: ${{ steps.refs.outputs.base_run_url }}
|
||||
CANDIDATE_RUN_URL: ${{ steps.refs.outputs.candidate_run_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
if [[ "${SKIP}" == "true" ]]; then
|
||||
printf 'Skipping query-regression nightly: %s\n' "${REASON}"
|
||||
else
|
||||
printf 'Comparing previous nightly `%s` -> current nightly `%s`\n' \
|
||||
"${BASE_SHA}" "${CANDIDATE_SHA}"
|
||||
if [[ -n "${BASE_RUN_URL}" ]]; then
|
||||
printf -- '- Previous Nightly Build: %s\n' "${BASE_RUN_URL}"
|
||||
fi
|
||||
if [[ -n "${CANDIDATE_RUN_URL}" ]]; then
|
||||
printf -- '- Current Nightly Build: %s\n' "${CANDIDATE_RUN_URL}"
|
||||
fi
|
||||
fi
|
||||
} | tee -a "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
query-regression:
|
||||
name: Query regression nightly
|
||||
needs: [resolve-refs]
|
||||
if: ${{ needs.resolve-refs.outputs.skip != 'true' && needs.resolve-refs.outputs.base_sha != '' && needs.resolve-refs.outputs.candidate_sha != '' }}
|
||||
uses: ./.github/workflows/query-regression.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
case: ${{ github.event_name == 'workflow_dispatch' && inputs.case || 'all' }}
|
||||
base_ref: ${{ needs.resolve-refs.outputs.base_sha }}
|
||||
candidate_ref: ${{ needs.resolve-refs.outputs.candidate_sha }}
|
||||
cargo_profile: nightly
|
||||
runner: aliyun-ecs
|
||||
@@ -32,10 +32,10 @@ on:
|
||||
type: string
|
||||
default: nightly
|
||||
runner:
|
||||
description: Self-hosted runner label or ARC runner scale set for this query regression run
|
||||
description: Self-hosted runner label; aliyun-ecs provisions a fresh ECS instance per run
|
||||
required: false
|
||||
type: string
|
||||
default: perf-regression-8-cores
|
||||
default: aliyun-ecs
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
case:
|
||||
@@ -68,12 +68,20 @@ on:
|
||||
- release
|
||||
- dev
|
||||
runner:
|
||||
description: Self-hosted runner label or ARC runner scale set for this query regression run
|
||||
description: >-
|
||||
Self-hosted runner label; aliyun-ecs provisions a fresh ECS instance
|
||||
per run, any other value is used as a literal runner label
|
||||
required: true
|
||||
type: choice
|
||||
default: perf-regression-8-cores
|
||||
options:
|
||||
- perf-regression-8-cores
|
||||
type: string
|
||||
default: aliyun-ecs
|
||||
keep_instance:
|
||||
description: >-
|
||||
Debug: keep the ECS instance after the run (skip teardown) so its
|
||||
runner _diag logs, journal, and telemetry can be inspected; the
|
||||
janitor still sweeps it after the TTL
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
@@ -81,19 +89,99 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
query-regression:
|
||||
test-tooling:
|
||||
# Stdlib unittests for the workflow Python (case selection, report
|
||||
# helpers, nightly SHA picking, and rendered ECS user-data). They do
|
||||
# not talk to Aliyun or the Actions runner process; running them on
|
||||
# ubuntu-latest fails fast before any ECS spend. Not gated on the
|
||||
# regression labels: those labels boot a VM, these tests should not.
|
||||
# Ordinary PRs also run the same tests from checks.yml, because this
|
||||
# workflow only starts on `labeled` (or dispatch / workflow_call).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test query regression tooling
|
||||
run: |
|
||||
python3 tests/perf/test_query_regression_runner_compaction_toctou.py
|
||||
python3 tests/perf/test_query_regression_runner_otlp_trace_load.py
|
||||
python3 tests/perf/test_query_regression_summary_otlp.py
|
||||
python3 tests/perf/test_query_regression_case_selection.py
|
||||
python3 tests/perf/test_query_regression_nightly_refs.py
|
||||
python3 tests/perf/test_aliyun_ecs_runner_scripts.py
|
||||
|
||||
provision:
|
||||
# Runs when the aliyun-ecs path is selected: explicitly via the runner
|
||||
# input, or for PR labels by default (a QUERY_REGRESSION_PR_RUNNER
|
||||
# repository variable set to another value redirects PRs to that literal
|
||||
# runner label instead). Uses trusted scripts from the PR base (or the
|
||||
# dispatched ref), never from candidate code. Waits for test-tooling so
|
||||
# a broken user-data template does not still create a VM.
|
||||
needs: [test-tooling]
|
||||
if: >-
|
||||
${{ github.event_name != 'pull_request' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
!github.event.pull_request.draft &&
|
||||
(github.event.label.name == 'query-regression' ||
|
||||
github.event.label.name == 'heavy-regression')) }}
|
||||
runs-on: ${{ github.event_name != 'pull_request' && inputs.runner || 'perf-regression-8-cores' }}
|
||||
${{ !failure() && !cancelled() &&
|
||||
((github.event_name != 'pull_request' && inputs.runner == 'aliyun-ecs') ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
!github.event.pull_request.draft &&
|
||||
(github.event.label.name == 'query-regression' ||
|
||||
github.event.label.name == 'heavy-regression') &&
|
||||
(vars.QUERY_REGRESSION_PR_RUNNER || 'aliyun-ecs') == 'aliyun-ecs')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
label: ${{ steps.provision.outputs.label }}
|
||||
instance_id: ${{ steps.provision.outputs.instance_id }}
|
||||
runner_name: ${{ steps.provision.outputs.runner_name }}
|
||||
steps:
|
||||
- name: Checkout trusted provisioning scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Provision ECS runner
|
||||
id: provision
|
||||
env:
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_ID }}
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_SECRET }}
|
||||
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
|
||||
ALIYUN_ECS_REGION_ID: ${{ vars.ALIYUN_ECS_REGION_ID }}
|
||||
ALIYUN_ECS_VSWITCH_ID: ${{ vars.ALIYUN_ECS_VSWITCH_ID }}
|
||||
ALIYUN_ECS_SECURITY_GROUP_ID: ${{ vars.ALIYUN_ECS_SECURITY_GROUP_ID }}
|
||||
ALIYUN_ECS_INSTANCE_TYPE: ${{ vars.ALIYUN_ECS_INSTANCE_TYPE }}
|
||||
ALIYUN_ECS_RESOURCE_GROUP_ID: ${{ vars.ALIYUN_ECS_RESOURCE_GROUP_ID }}
|
||||
QUERY_REGRESSION_ECS_IMAGE_ID: ${{ vars.QUERY_REGRESSION_ECS_IMAGE_ID }}
|
||||
QUERY_REGRESSION_RUNNER_UID: ${{ vars.QUERY_REGRESSION_RUNNER_UID || '1001' }}
|
||||
QUERY_REGRESSION_RUNNER_GID: ${{ vars.QUERY_REGRESSION_RUNNER_GID || '1001' }}
|
||||
run: >-
|
||||
uv run .github/scripts/aliyun-ecs-runner-provision.py
|
||||
|
||||
query-regression:
|
||||
needs: [provision, test-tooling]
|
||||
# `!failure() && !cancelled()` both suppresses the implicit success() and
|
||||
# lets the job run when provision was intentionally skipped because a
|
||||
# literal runner label was selected; a failed or cancelled provision still
|
||||
# blocks the run because no ECS runner would be waiting.
|
||||
if: >-
|
||||
${{ !failure() && !cancelled() &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
!github.event.pull_request.draft &&
|
||||
(github.event.label.name == 'query-regression' ||
|
||||
github.event.label.name == 'heavy-regression'))) }}
|
||||
runs-on: >-
|
||||
${{ needs.provision.outputs.label ||
|
||||
(github.event_name != 'pull_request' && inputs.runner ||
|
||||
(vars.QUERY_REGRESSION_PR_RUNNER || 'aliyun-ecs')) }}
|
||||
timeout-minutes: 180
|
||||
concurrency:
|
||||
group: query-regression-persistent-cache-v1
|
||||
queue: max
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
CARGO_PROFILE: ${{ github.event_name == 'pull_request' && 'nightly' || inputs.cargo_profile }}
|
||||
CARGO_HOME: /home/runner/.cargo
|
||||
@@ -105,7 +193,8 @@ jobs:
|
||||
QUERY_REGRESSION_CACHE_META: /home/runner/query-regression-cache-meta
|
||||
RUSTC_WRAPPER: /usr/local/bin/sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 40G
|
||||
# Caps the local sccache on the system disk next to target and cargo.
|
||||
SCCACHE_CACHE_SIZE: 10G
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -D warnings -C link-arg=-fuse-ld=mold
|
||||
QUERY_REGRESSION_CACHE_EPOCH: "1"
|
||||
@@ -114,7 +203,60 @@ jobs:
|
||||
EVENT_MERGE_SHA: ${{ github.event_name == 'pull_request' && github.sha || '' }}
|
||||
EVENT_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
||||
EVENT_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
|
||||
# Runner identity contract. The ECS image uses 1001; a manually prepared
|
||||
# host may override via repo variables when 1001 is already taken.
|
||||
EXPECTED_RUNNER_UID: ${{ vars.QUERY_REGRESSION_RUNNER_UID || '1001' }}
|
||||
EXPECTED_RUNNER_GID: ${{ vars.QUERY_REGRESSION_RUNNER_GID || '1001' }}
|
||||
steps:
|
||||
- name: Report provisioned ECS runner
|
||||
if: ${{ needs.provision.outputs.instance_id != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
printf -- '- ECS instance: `%s` (type `%s`, image `%s`, region `%s`)\n' \
|
||||
"${{ needs.provision.outputs.instance_id }}" \
|
||||
"${{ vars.ALIYUN_ECS_INSTANCE_TYPE }}" \
|
||||
"${{ vars.QUERY_REGRESSION_ECS_IMAGE_ID }}" \
|
||||
"${{ vars.ALIYUN_ECS_REGION_ID }}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Start machine telemetry sampler
|
||||
shell: bash
|
||||
# The ECS instance is deleted when the run ends, so machine state must
|
||||
# be captured while the job runs. A background sampler appends load,
|
||||
# memory, disk, and top-memory processes to a log every 30s; the
|
||||
# "Dump machine telemetry" step (if: always()) prints and uploads it.
|
||||
# Note: when the job is *cancelled*, even always() steps are killed —
|
||||
# telemetry covers failures, not cancellations.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
log="${GITHUB_WORKSPACE}/machine-telemetry.log"
|
||||
{
|
||||
echo "== baseline $(date -u +%Y-%m-%dT%H:%M:%SZ) =="
|
||||
nproc
|
||||
free -h
|
||||
df -h / /home/runner
|
||||
} >> "${log}"
|
||||
{
|
||||
printf '## Disk (start)\n\n```\n'
|
||||
df -hP / /home/runner
|
||||
printf '```\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
nohup bash -c "
|
||||
while true; do
|
||||
{
|
||||
date -u '+== %Y-%m-%dT%H:%M:%SZ =='
|
||||
uptime
|
||||
free -m
|
||||
df -h / /home/runner
|
||||
ps -eo pid,comm,%mem,%cpu --sort=-%mem | head -6
|
||||
} >> '${log}' 2>&1
|
||||
sleep 30
|
||||
done
|
||||
" >/dev/null 2>&1 &
|
||||
echo "Telemetry sampler started (pid $!), logging to ${log}"
|
||||
|
||||
- name: Checkout base source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -263,14 +405,64 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$(id -u)" == "1001" ]]
|
||||
[[ "$(id -g)" == "1001" ]]
|
||||
[[ "${UV_CACHE_DIR}" == "/home/runner/.cargo/uv-cache" ]]
|
||||
[[ "$(protoc --version)" == "libprotoc 3.21.12" ]]
|
||||
errors=()
|
||||
record() {
|
||||
printf 'FAIL %s\n' "$*"
|
||||
errors+=("$*")
|
||||
}
|
||||
require_eq() {
|
||||
local name="$1" actual="$2" expected="$3"
|
||||
printf 'check %s: %s\n' "${name}" "${actual}"
|
||||
[[ "${actual}" == "${expected}" ]] || record "${name}: expected '${expected}', got '${actual}'"
|
||||
}
|
||||
require_match() {
|
||||
local name="$1" actual="$2" pattern="$3"
|
||||
printf 'check %s: %s\n' "${name}" "${actual}"
|
||||
[[ "${actual}" =~ $pattern ]] || record "${name}: expected to match ${pattern}, got '${actual}'"
|
||||
}
|
||||
require() {
|
||||
local name="$1"
|
||||
shift
|
||||
printf 'check %s\n' "${name}"
|
||||
"$@" || record "${name}"
|
||||
}
|
||||
capture() {
|
||||
local out
|
||||
if out="$("$@" 2>&1)"; then
|
||||
printf '%s' "${out}"
|
||||
else
|
||||
printf '<failed to run: %s>' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
printf 'uid=%s gid=%s PATH=%s CARGO_HOME=%s\n' "$(id -u)" "$(id -g)" "${PATH}" "${CARGO_HOME}"
|
||||
ls -la "${CARGO_HOME}" 2>&1 || printf '(CARGO_HOME missing)\n'
|
||||
|
||||
# Image/host hygiene: CARGO_HOME must be empty of config and
|
||||
# install-state before cargo/rustup run. Those tools create
|
||||
# .package-cache / bin on first use, so this cannot come after.
|
||||
cargo_home_dirty=()
|
||||
for entry in config config.toml credentials credentials.toml bin .crates.toml .crates2.json .global-cache .package-cache; do
|
||||
if [[ -e "${CARGO_HOME}/${entry}" ]]; then
|
||||
cargo_home_dirty+=("${entry}")
|
||||
record "CARGO_HOME must not contain ${entry}"
|
||||
fi
|
||||
done
|
||||
if (( ${#cargo_home_dirty[@]} > 0 )); then
|
||||
printf 'CARGO_HOME contents:\n' >&2
|
||||
ls -la "${CARGO_HOME}" >&2 || true
|
||||
fi
|
||||
|
||||
require_eq uid "$(id -u)" "${EXPECTED_RUNNER_UID}"
|
||||
require_eq gid "$(id -g)" "${EXPECTED_RUNNER_GID}"
|
||||
require_eq UV_CACHE_DIR "${UV_CACHE_DIR}" "/home/runner/.cargo/uv-cache"
|
||||
require_eq protoc "$(capture protoc --version)" "libprotoc 3.21.12"
|
||||
temporary_proto_dir="$(mktemp --directory)"
|
||||
trap 'rm -rf "${temporary_proto_dir}"' EXIT
|
||||
test -r /usr/include/google/protobuf/any.proto
|
||||
test -r /usr/include/google/protobuf/empty.proto
|
||||
require "readable /usr/include/google/protobuf/any.proto" \
|
||||
test -r /usr/include/google/protobuf/any.proto
|
||||
require "readable /usr/include/google/protobuf/empty.proto" \
|
||||
test -r /usr/include/google/protobuf/empty.proto
|
||||
printf '%s\n' \
|
||||
'syntax = "proto3";' \
|
||||
'package smoke;' \
|
||||
@@ -278,47 +470,51 @@ jobs:
|
||||
'import "google/protobuf/empty.proto";' \
|
||||
'message Smoke { google.protobuf.Any any = 1; google.protobuf.Empty empty = 2; }' \
|
||||
> "${temporary_proto_dir}/smoke.proto"
|
||||
protoc --proto_path="${temporary_proto_dir}" --proto_path=/usr/include \
|
||||
if protoc --proto_path="${temporary_proto_dir}" --proto_path=/usr/include \
|
||||
--descriptor_set_out="${temporary_proto_dir}/smoke.pb" \
|
||||
"${temporary_proto_dir}/smoke.proto"
|
||||
test -s "${temporary_proto_dir}/smoke.pb"
|
||||
[[ "$(uv --version)" =~ ^uv[[:space:]]0\.11\.26([[:space:]]|$) ]]
|
||||
mold_version="$(mold --version)"
|
||||
[[ "${mold_version}" =~ ^mold[[:space:]]2\.30\.0([[:space:]]|$) ]]
|
||||
[[ "$(python3 --version)" == "Python 3.12.3" ]]
|
||||
"${temporary_proto_dir}/smoke.proto"; then
|
||||
require "protoc smoke descriptor is non-empty" test -s "${temporary_proto_dir}/smoke.pb"
|
||||
else
|
||||
record "protoc smoke compile failed"
|
||||
fi
|
||||
require_match uv "$(capture uv --version)" '^uv[[:space:]]0\.11\.26([[:space:]]|$)'
|
||||
require_match mold "$(capture mold --version)" '^mold[[:space:]]2\.40\.4([[:space:]]|$)'
|
||||
require_eq python3 "$(capture python3 --version)" "Python 3.14.4"
|
||||
otelgen_path="$(command -v otelgen || true)"
|
||||
otelgen_version="<unavailable>"
|
||||
if [[ -n "${otelgen_path}" ]]; then
|
||||
otelgen_version="$("${otelgen_path}" --version 2>&1 || true)"
|
||||
otelgen_version="$(capture "${otelgen_path}" --version)"
|
||||
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" ]]
|
||||
[[ "$(command -v cargo)" == "/opt/cargo/bin/cargo" ]]
|
||||
[[ "$(command -v rustc)" == "/opt/cargo/bin/rustc" ]]
|
||||
[[ "$(rustup --version)" =~ ^rustup[[:space:]]1\.29\.0([[:space:]]|$) ]]
|
||||
cargo_version="$(cargo --version)"
|
||||
[[ "${cargo_version}" =~ ^cargo[[:space:]]1\.96\.0-nightly[[:space:]]\(cbb9bb8bd[[:space:]][0-9]{4}-[0-9]{2}-[0-9]{2}\)$ ]]
|
||||
rustc_version="$(rustc --version)"
|
||||
[[ "${rustc_version}" =~ ^rustc[[:space:]]1\.96\.0-nightly[[:space:]]\(ac7f9ec7d[[:space:]][0-9]{4}-[0-9]{2}-[0-9]{2}\)$ ]]
|
||||
active_toolchain="$(rustup show active-toolchain)"
|
||||
[[ "${active_toolchain}" =~ ^nightly-2026-03-21-x86_64-unknown-linux-gnu([[:space:]]|$) ]]
|
||||
[[ "${RUSTUP_HOME}" == "/opt/rustup" ]]
|
||||
[[ "${RUSTUP_TOOLCHAIN}" == "nightly-2026-03-21" ]]
|
||||
[[ "${RUSTUP_AUTO_INSTALL}" == "0" ]]
|
||||
test -r /opt/rustup && test -x /opt/rustup
|
||||
test ! -w /opt/rustup
|
||||
test ! -w /opt/cargo/bin
|
||||
for entry in config config.toml credentials credentials.toml bin .crates.toml .crates2.json .global-cache .package-cache; do
|
||||
test ! -e "${CARGO_HOME}/${entry}"
|
||||
done
|
||||
require_eq otelgen_path "${otelgen_path}" "/usr/local/bin/otelgen"
|
||||
printf 'check otelgen_version: %s\n' "${otelgen_version}"
|
||||
[[ "${otelgen_version}" == *"863a3f395d062c7322cc1de08a38774b7fdaa6c8"* ]] \
|
||||
|| record "otelgen_version: expected commit 863a3f395d062c7322cc1de08a38774b7fdaa6c8, got '${otelgen_version}'"
|
||||
require_match sccache "$(capture sccache --version)" '^sccache[[:space:]]0\.16\.0([[:space:]]|$)'
|
||||
require_eq rustup_path "$(command -v rustup || true)" "/opt/cargo/bin/rustup"
|
||||
require_eq cargo_path "$(command -v cargo || true)" "/opt/cargo/bin/cargo"
|
||||
require_eq rustc_path "$(command -v rustc || true)" "/opt/cargo/bin/rustc"
|
||||
require_match rustup "$(capture rustup --version)" '^rustup[[:space:]]1\.29\.0([[:space:]]|$)'
|
||||
require_match cargo "$(capture cargo --version)" \
|
||||
'^cargo[[:space:]]1\.96\.0-nightly[[:space:]]\(cbb9bb8bd[[:space:]][0-9]{4}-[0-9]{2}-[0-9]{2}\)$'
|
||||
require_match rustc "$(capture rustc --version)" \
|
||||
'^rustc[[:space:]]1\.96\.0-nightly[[:space:]]\(ac7f9ec7d[[:space:]][0-9]{4}-[0-9]{2}-[0-9]{2}\)$'
|
||||
require_match active_toolchain "$(capture rustup show active-toolchain)" \
|
||||
'^nightly-2026-03-21-x86_64-unknown-linux-gnu([[:space:]]|$)'
|
||||
require_eq RUSTUP_HOME "${RUSTUP_HOME}" "/opt/rustup"
|
||||
require_eq RUSTUP_TOOLCHAIN "${RUSTUP_TOOLCHAIN}" "nightly-2026-03-21"
|
||||
require_eq RUSTUP_AUTO_INSTALL "${RUSTUP_AUTO_INSTALL}" "0"
|
||||
require "readable /opt/rustup" test -r /opt/rustup
|
||||
require "executable /opt/rustup" test -x /opt/rustup
|
||||
require "runner cannot write /opt/rustup" test ! -w /opt/rustup
|
||||
require "runner cannot write /opt/cargo/bin" test ! -w /opt/cargo/bin
|
||||
mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git"
|
||||
if (( ${#errors[@]} > 0 )); then
|
||||
printf '\nVerify runner image tools failed (%d checks):\n' "${#errors[@]}" >&2
|
||||
printf ' - %s\n' "${errors[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare persistent query regression cache
|
||||
- name: Prepare query regression cache
|
||||
shell: bash
|
||||
working-directory: src
|
||||
run: |
|
||||
@@ -362,24 +558,6 @@ jobs:
|
||||
find "${root}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
}
|
||||
|
||||
clear_cargo_extracted_trees() {
|
||||
require_expected_root CARGO_REGISTRY "${CARGO_HOME}/registry" "${EXPECTED_CARGO_REGISTRY}"
|
||||
require_expected_root CARGO_GIT "${CARGO_HOME}/git" "${EXPECTED_CARGO_GIT}"
|
||||
rm -rf -- "${CARGO_HOME}/registry/src" "${CARGO_HOME}/git/checkouts"
|
||||
}
|
||||
|
||||
cargo_size_kib() {
|
||||
du -sk -- "${CARGO_HOME}/registry" "${CARGO_HOME}/git" | awk '{ total += $1 } END { print total }'
|
||||
}
|
||||
|
||||
target_size_kib() {
|
||||
du -sk -- "${CARGO_TARGET_DIR}" | cut -f1
|
||||
}
|
||||
|
||||
free_kib() {
|
||||
df -Pk "${CARGO_TARGET_DIR}" | awk 'NR == 2 { print $4 }'
|
||||
}
|
||||
|
||||
report_cache_usage() {
|
||||
du -sh -- "${CARGO_HOME}" "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \
|
||||
"${RUSTUP_HOME}" "${CARGO_TARGET_DIR}" "${QUERY_REGRESSION_CACHE_META}" "${SCCACHE_DIR}"
|
||||
@@ -400,7 +578,7 @@ jobs:
|
||||
printf 'Refusing unexpected RUSTC_WRAPPER: %s\n' "${RUSTC_WRAPPER}" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "${SCCACHE_CACHE_SIZE}" == "40G" ]] || {
|
||||
[[ "${SCCACHE_CACHE_SIZE}" == "10G" ]] || {
|
||||
printf 'Refusing unexpected SCCACHE_CACHE_SIZE: %s\n' "${SCCACHE_CACHE_SIZE}" >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -487,46 +665,10 @@ jobs:
|
||||
|
||||
report_cache_usage
|
||||
|
||||
target_size="$(target_size_kib)"
|
||||
if (( target_size >= 400 * 1024 * 1024 )); then
|
||||
printf 'Warning: target cache is at least 400 GiB (%s KiB)\n' "${target_size}" >&2
|
||||
fi
|
||||
if (( target_size >= 450 * 1024 * 1024 )); then
|
||||
printf 'Target cache is at least 450 GiB; clearing complete target root\n' >&2
|
||||
clear_directory "${CARGO_TARGET_DIR}"
|
||||
fi
|
||||
|
||||
cargo_size="$(cargo_size_kib)"
|
||||
if (( cargo_size >= 60 * 1024 * 1024 )); then
|
||||
printf 'Warning: Cargo cache is at least 60 GiB (%s KiB)\n' "${cargo_size}" >&2
|
||||
fi
|
||||
if (( cargo_size >= 80 * 1024 * 1024 )); then
|
||||
printf 'Cargo cache is at least 80 GiB; removing extracted sources and checkouts\n' >&2
|
||||
clear_cargo_extracted_trees
|
||||
cargo_size="$(cargo_size_kib)"
|
||||
if (( cargo_size >= 80 * 1024 * 1024 )); then
|
||||
printf 'Cargo cache remains at least 80 GiB after cleanup (%s KiB)\n' "${cargo_size}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
free_space="$(free_kib)"
|
||||
if (( free_space < 300 * 1024 * 1024 )); then
|
||||
printf 'Free space is below 300 GiB; clearing complete target root\n' >&2
|
||||
clear_directory "${CARGO_TARGET_DIR}"
|
||||
free_space="$(free_kib)"
|
||||
if (( free_space < 300 * 1024 * 1024 )); then
|
||||
printf 'Free space remains below 300 GiB; removing Cargo extracted sources and checkouts\n' >&2
|
||||
clear_cargo_extracted_trees
|
||||
free_space="$(free_kib)"
|
||||
if (( free_space < 300 * 1024 * 1024 )); then
|
||||
printf 'Free space remains below 300 GiB after cleanup (%s KiB)\n' "${free_space}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
report_cache_usage
|
||||
# Every run starts from a fresh system disk, so size/free-space
|
||||
# watermarks from the retired retained-disk era are not restored.
|
||||
# A run that overflows the disk fails the build outright, which the
|
||||
# telemetry step makes diagnosable.
|
||||
|
||||
sccache --start-server
|
||||
sccache --zero-stats
|
||||
@@ -567,24 +709,17 @@ jobs:
|
||||
git reset --hard "${VERIFIED_CANDIDATE_SHA}"
|
||||
git clean -ffdx
|
||||
|
||||
- name: Test query regression tooling
|
||||
working-directory: src
|
||||
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
|
||||
uv run --no-project python tests/perf/test_query_regression_case_selection.py
|
||||
|
||||
- name: Build candidate greptime and fixture generators
|
||||
- name: Build candidate greptime and query regression helpers
|
||||
working-directory: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SECONDS=0
|
||||
cargo build --profile "${CARGO_PROFILE}" -p cmd --bin greptime
|
||||
cargo build --profile "${CARGO_PROFILE}" -p cmd --bin query_perf_fixture --features dev-tools
|
||||
cargo build --profile "${CARGO_PROFILE}" -p cmd --bin query_regression_runner --features dev-tools
|
||||
candidate_build_elapsed="${SECONDS}"
|
||||
printf 'Candidate greptime and fixture generator cargo builds elapsed: %s seconds\n' "${candidate_build_elapsed}"
|
||||
printf -- '- Candidate greptime and fixture generator cargo builds: %s seconds\n' "${candidate_build_elapsed}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
printf 'Candidate greptime and query regression helper cargo builds elapsed: %s seconds\n' "${candidate_build_elapsed}"
|
||||
printf -- '- Candidate greptime and query regression helper cargo builds: %s seconds\n' "${candidate_build_elapsed}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
sccache --show-stats
|
||||
target_dir="${CARGO_PROFILE}"
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]]; then
|
||||
@@ -595,6 +730,8 @@ jobs:
|
||||
"${GITHUB_WORKSPACE}/query-regression-bins/candidate/greptime"
|
||||
cp "${CARGO_TARGET_DIR}/${target_dir}/query_perf_fixture" \
|
||||
"${GITHUB_WORKSPACE}/query-regression-bins/candidate/query_perf_fixture"
|
||||
cp "${CARGO_TARGET_DIR}/${target_dir}/query_regression_runner" \
|
||||
"${GITHUB_WORKSPACE}/query-regression-bins/candidate/query_regression_runner"
|
||||
|
||||
- name: Run query regression
|
||||
id: run
|
||||
@@ -609,6 +746,7 @@ jobs:
|
||||
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' }}
|
||||
QUERY_REGRESSION_RUNNER: ${{ github.workspace }}/query-regression-bins/candidate/query_regression_runner
|
||||
run: >-
|
||||
uv run --no-project python src/.github/scripts/query-regression-run.py
|
||||
--base-src src
|
||||
@@ -639,6 +777,7 @@ jobs:
|
||||
query-regression-work/**/logs/**
|
||||
query-regression-work/**/otelgen/**
|
||||
query-regression-summary.md
|
||||
machine-telemetry.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
@@ -653,7 +792,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Report persistent cache usage
|
||||
- name: Report cache usage
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -687,12 +826,76 @@ jobs:
|
||||
df -P "${report_root}"
|
||||
df -hP "${report_root}"
|
||||
df -Pi "${report_root}"
|
||||
{
|
||||
printf '## Disk (end)\n\n```\n'
|
||||
df -hP / /home/runner "${report_root}"
|
||||
printf '\n'
|
||||
du -sh -- \
|
||||
/home/runner/.cargo \
|
||||
/home/runner/.cargo/registry \
|
||||
/home/runner/.cargo/git \
|
||||
/opt/rustup \
|
||||
/home/runner/query-regression-target \
|
||||
/home/runner/query-regression-cache-meta \
|
||||
/home/runner/.cache/sccache \
|
||||
2>/dev/null || true
|
||||
printf '```\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
if command -v sccache >/dev/null 2>&1; then
|
||||
sccache --show-stats || printf 'Unable to show sccache statistics (report only)\n' >&2
|
||||
else
|
||||
printf 'sccache is unavailable (report only)\n' >&2
|
||||
fi
|
||||
|
||||
- name: Dump machine telemetry
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
run: |
|
||||
log="${GITHUB_WORKSPACE}/machine-telemetry.log"
|
||||
if [[ -f "${log}" ]]; then
|
||||
echo "::group::Machine telemetry (last 200 lines)"
|
||||
tail -n 200 "${log}"
|
||||
echo "::endgroup::"
|
||||
else
|
||||
echo "No telemetry log found (sampler never started?)"
|
||||
fi
|
||||
echo "::group::dmesg tail (OOM killer records)"
|
||||
sudo dmesg -T 2>/dev/null | tail -n 50 || dmesg -T 2>/dev/null | tail -n 50 || \
|
||||
echo "dmesg unavailable without root"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Fail on regression failure
|
||||
if: ${{ steps.run.outputs.status != '0' }}
|
||||
run: exit 1
|
||||
|
||||
teardown:
|
||||
# Releases the dynamically provisioned ECS runner. Runs even when the
|
||||
# benchmark job fails or is cancelled; skipped when a literal runner label
|
||||
# was selected because the provision outputs are empty, or when the
|
||||
# dispatch set keep_instance to preserve the machine for post-mortem
|
||||
# debugging (the janitor sweep still reclaims it after the TTL).
|
||||
if: ${{ always() && needs.provision.outputs.instance_id != '' && !inputs.keep_instance }}
|
||||
needs: [provision, query-regression]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout trusted teardown scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Teardown ECS runner
|
||||
env:
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_ID }}
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUD_ECS_ACCESS_KEY_SECRET }}
|
||||
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
|
||||
ALIYUN_ECS_REGION_ID: ${{ vars.ALIYUN_ECS_REGION_ID }}
|
||||
QUERY_REGRESSION_ECS_INSTANCE_ID: ${{ needs.provision.outputs.instance_id }}
|
||||
QUERY_REGRESSION_ECS_RUNNER_NAME: ${{ needs.provision.outputs.runner_name }}
|
||||
run: >-
|
||||
uv run .github/scripts/aliyun-ecs-runner-teardown.py
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
base_ref: ${{ needs.prepare-release-validation.outputs.previous-release-tag }}
|
||||
candidate_ref: ${{ needs.prepare-release-validation.outputs.candidate-ref }}
|
||||
cargo_profile: nightly
|
||||
runner: perf-regression-8-cores
|
||||
runner: aliyun-ecs
|
||||
|
||||
release-images-to-dockerhub:
|
||||
name: Build and push images to DockerHub
|
||||
|
||||
Generated
+2
@@ -2195,6 +2195,8 @@ dependencies = [
|
||||
"mito2",
|
||||
"moka",
|
||||
"object-store",
|
||||
"object_store",
|
||||
"object_store_opendal",
|
||||
"parquet",
|
||||
"plugins",
|
||||
"pprof",
|
||||
|
||||
@@ -14,6 +14,11 @@ name = "query_perf_fixture"
|
||||
path = "src/bin/query_perf_fixture.rs"
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[[bin]]
|
||||
name = "query_regression_runner"
|
||||
path = "src/bin/query_regression_runner.rs"
|
||||
required-features = ["dev-tools"]
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"servers/pprof",
|
||||
@@ -68,6 +73,7 @@ common-wal.workspace = true
|
||||
datafusion.workspace = true
|
||||
datafusion-common.workspace = true
|
||||
datafusion-physical-plan.workspace = true
|
||||
datafusion_object_store.workspace = true
|
||||
datanode.workspace = true
|
||||
datatypes.workspace = true
|
||||
either = "1.15"
|
||||
@@ -87,6 +93,7 @@ mito-codec.workspace = true
|
||||
mito2.workspace = true
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
object-store.workspace = true
|
||||
object_store_opendal.workspace = true
|
||||
parquet = { workspace = true, features = ["object_store"] }
|
||||
plugins.workspace = true
|
||||
prometheus.workspace = true
|
||||
|
||||
@@ -13,17 +13,39 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, File};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Args as ClapArgs;
|
||||
use parquet::file::reader::{FileReader, SerializedFileReader};
|
||||
use serde::Serialize;
|
||||
use datafusion_object_store::path::Path as StorePath;
|
||||
use datafusion_object_store::{ObjectMeta, ObjectStore};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use object_store::config::ObjectStoreConfig;
|
||||
use object_store::factory::new_raw_object_store;
|
||||
use object_store::services::Fs;
|
||||
use parquet::arrow::async_reader::ParquetObjectReader;
|
||||
use parquet::file::metadata::ParquetMetaDataReader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Same shape as `query_regression_runner::model::DestinationConfig`. The two
|
||||
/// binaries are separate crate roots, so the serde-compatible struct is defined
|
||||
/// here instead of being shared; it reuses the `object-store` crate's
|
||||
/// `ObjectStoreConfig` deserialization.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DestinationConfig {
|
||||
data_home: String,
|
||||
object_store: ObjectStoreConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub(super) struct InspectFooterArgs {
|
||||
/// Local data home (convenience shortcut for a File destination).
|
||||
#[arg(long)]
|
||||
root: PathBuf,
|
||||
root: Option<PathBuf>,
|
||||
/// TOML file: data_home = "..." and object_store = { type = "File" | "S3" | ... }.
|
||||
#[arg(long)]
|
||||
destination: Option<PathBuf>,
|
||||
#[arg(long, default_value = "greptime_value")]
|
||||
column: String,
|
||||
#[arg(long)]
|
||||
@@ -68,64 +90,140 @@ struct FooterColumnChunkReport {
|
||||
num_values: i64,
|
||||
}
|
||||
|
||||
pub(super) fn run_inspect_footer(
|
||||
/// A parquet data file discovered by listing the store, with the metadata needed
|
||||
/// to read its footer without any extra stat/head call.
|
||||
#[derive(Debug)]
|
||||
struct ListedFile {
|
||||
location: StorePath,
|
||||
size: u64,
|
||||
path: String,
|
||||
relative_path: String,
|
||||
}
|
||||
|
||||
pub(super) async fn run_inspect_footer(
|
||||
args: InspectFooterArgs,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let files = collect_parquet_files(&args.root, args.include_metadata_files)?;
|
||||
let reports = files
|
||||
.iter()
|
||||
.map(|p| inspect_file(&args.root, p, &args.column))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (destination, root) = match (args.destination, args.root) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("--destination and --root are mutually exclusive; pass exactly one".into());
|
||||
}
|
||||
(None, None) => return Err("one of --destination or --root is required".into()),
|
||||
(Some(path), None) => {
|
||||
let destination: DestinationConfig = toml::from_str(&fs::read_to_string(path)?)?;
|
||||
(Some(destination), None)
|
||||
}
|
||||
(None, root) => (None, root),
|
||||
};
|
||||
let store = build_store(destination.as_ref(), root.as_deref()).await?;
|
||||
let root_display = root
|
||||
.as_deref()
|
||||
.map(|root| root.display().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
destination
|
||||
.as_ref()
|
||||
.expect("destination is set when root is not")
|
||||
.data_home
|
||||
.clone()
|
||||
});
|
||||
let files =
|
||||
collect_parquet_files(store.as_ref(), root.as_deref(), args.include_metadata_files).await?;
|
||||
let mut reports = futures::stream::iter(files.iter())
|
||||
.map(|file| inspect_file(Arc::clone(&store), file, &args.column))
|
||||
.buffer_unordered(8)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
reports.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&FooterReport {
|
||||
root: args.root.display().to_string(),
|
||||
root: root_display,
|
||||
summary: summarize_footer(&args.column, &reports),
|
||||
files: reports
|
||||
})?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn collect_parquet_files(
|
||||
root: &Path,
|
||||
|
||||
/// Builds an arrow `object_store::ObjectStore` for the given destination. The
|
||||
/// File backend (and the `--root` shortcut) uses the opendal `Fs` builder
|
||||
/// directly to avoid the create_dir_all / clean_temp_dir side effects of
|
||||
/// `object_store::factory::new_fs_object_store`; every other backend goes
|
||||
/// through `object_store::factory::new_raw_object_store`.
|
||||
async fn build_store(
|
||||
destination: Option<&DestinationConfig>,
|
||||
root: Option<&Path>,
|
||||
) -> Result<Arc<dyn ObjectStore>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let operator = match destination {
|
||||
Some(destination) => match &destination.object_store {
|
||||
ObjectStoreConfig::File(_) => {
|
||||
object_store::ObjectStore::new(Fs::default().root(&destination.data_home))?.finish()
|
||||
}
|
||||
_ => new_raw_object_store(&destination.object_store, &destination.data_home).await?,
|
||||
},
|
||||
None => {
|
||||
let root = root.expect("root must be set when destination is None");
|
||||
object_store::ObjectStore::new(Fs::default().root(&root.to_string_lossy()))?.finish()
|
||||
}
|
||||
};
|
||||
Ok(Arc::new(object_store_opendal::OpendalStore::new(operator)))
|
||||
}
|
||||
|
||||
async fn collect_parquet_files(
|
||||
store: &dyn ObjectStore,
|
||||
root: Option<&Path>,
|
||||
include_metadata_files: bool,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
|
||||
) -> Result<Vec<ListedFile>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let metas = store.list(None).try_collect::<Vec<ObjectMeta>>().await?;
|
||||
let mut files = Vec::new();
|
||||
collect_parquet_files_inner(root, root, include_metadata_files, &mut files)?;
|
||||
files.sort();
|
||||
for meta in metas {
|
||||
if !is_parquet_data_file(&meta) {
|
||||
continue;
|
||||
}
|
||||
let is_metadata = meta
|
||||
.location
|
||||
.parts()
|
||||
.any(|part| part.as_ref() == "metadata");
|
||||
if !include_metadata_files && is_metadata {
|
||||
continue;
|
||||
}
|
||||
let relative_path = meta.location.to_string();
|
||||
let path = match root {
|
||||
Some(root) => root.join(&relative_path).display().to_string(),
|
||||
None => relative_path.clone(),
|
||||
};
|
||||
files.push(ListedFile {
|
||||
location: meta.location,
|
||||
size: meta.size,
|
||||
path,
|
||||
relative_path,
|
||||
});
|
||||
}
|
||||
files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
|
||||
Ok(files)
|
||||
}
|
||||
fn collect_parquet_files_inner(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
include_metadata_files: bool,
|
||||
files: &mut Vec<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if path.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
collect_parquet_files_inner(root, &entry?.path(), include_metadata_files, files)?;
|
||||
}
|
||||
} else if path.extension().is_some_and(|ext| ext == "parquet")
|
||||
&& (include_metadata_files
|
||||
|| !path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.components()
|
||||
.any(|c| c.as_os_str() == "metadata"))
|
||||
{
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
Ok(())
|
||||
|
||||
/// Keeps only real parquet data files: `.parquet` keys with a non-zero size.
|
||||
/// Zero-size keys (and keys ending in `/`) are directory markers emitted by
|
||||
/// some backends and are skipped even when they happen to end in `.parquet`,
|
||||
/// since an empty parquet file has no readable footer anyway.
|
||||
fn is_parquet_data_file(meta: &ObjectMeta) -> bool {
|
||||
meta.location.extension() == Some("parquet")
|
||||
&& meta.size > 0
|
||||
&& !meta.location.as_ref().ends_with('/')
|
||||
}
|
||||
fn inspect_file(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
|
||||
async fn inspect_file(
|
||||
store: Arc<dyn ObjectStore>,
|
||||
file: &ListedFile,
|
||||
column: &str,
|
||||
) -> Result<FooterFileReport, Box<dyn std::error::Error>> {
|
||||
let file_size = fs::metadata(path)?.len();
|
||||
let reader = SerializedFileReader::new(File::open(path)?)?;
|
||||
let metadata = reader.metadata().file_metadata();
|
||||
let row_groups = reader.metadata().row_groups();
|
||||
) -> Result<FooterFileReport, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut reader =
|
||||
ParquetObjectReader::new(store, file.location.clone()).with_file_size(file.size);
|
||||
let metadata = ParquetMetaDataReader::new()
|
||||
.load_and_finish(&mut reader, file.size)
|
||||
.await?;
|
||||
let file_metadata = metadata.file_metadata();
|
||||
let row_groups = metadata.row_groups();
|
||||
let mut columns = Vec::new();
|
||||
for (row_group_index, rg) in row_groups.iter().enumerate() {
|
||||
for (column_index, chunk) in rg.columns().iter().enumerate() {
|
||||
@@ -145,14 +243,10 @@ fn inspect_file(
|
||||
}
|
||||
}
|
||||
Ok(FooterFileReport {
|
||||
path: path.display().to_string(),
|
||||
relative_path: path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string(),
|
||||
file_size,
|
||||
num_rows: metadata.num_rows(),
|
||||
path: file.path.clone(),
|
||||
relative_path: file.relative_path.clone(),
|
||||
file_size: file.size,
|
||||
num_rows: file_metadata.num_rows(),
|
||||
num_row_groups: row_groups.len(),
|
||||
columns,
|
||||
})
|
||||
@@ -179,3 +273,199 @@ fn summarize_footer(column: &str, files: &[FooterFileReport]) -> FooterSummary {
|
||||
s.unique_encodings = encodings.into_iter().collect();
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs::{self, File};
|
||||
|
||||
use parquet::column::writer::ColumnWriter;
|
||||
use parquet::file::properties::WriterProperties;
|
||||
use parquet::file::writer::SerializedFileWriter;
|
||||
use parquet::schema::parser::parse_message_type;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Writes a minimal valid parquet file with a single `value` Int64 column
|
||||
/// and `rows` values, so the footer can be read back through the object
|
||||
/// store path.
|
||||
fn write_parquet_file(path: &Path, rows: i64) {
|
||||
let schema = Arc::new(
|
||||
parse_message_type("message schema { REQUIRED INT64 value; }").expect("valid schema"),
|
||||
);
|
||||
let props = Arc::new(WriterProperties::builder().build());
|
||||
let file = File::create(path).expect("create parquet file");
|
||||
let mut writer = SerializedFileWriter::new(file, schema, props).expect("init writer");
|
||||
let mut row_group = writer.next_row_group().expect("next row group");
|
||||
let mut column = row_group
|
||||
.next_column()
|
||||
.expect("next column")
|
||||
.expect("has column");
|
||||
match column.untyped() {
|
||||
ColumnWriter::Int64ColumnWriter(typed) => {
|
||||
typed
|
||||
.write_batch(&(1..=rows).collect::<Vec<_>>(), None, None)
|
||||
.expect("write batch");
|
||||
}
|
||||
_ => panic!("unexpected column writer"),
|
||||
}
|
||||
column.close().expect("close column");
|
||||
row_group.close().expect("close row group");
|
||||
writer.close().expect("close writer");
|
||||
}
|
||||
|
||||
fn fixture_tree(dir: &Path) {
|
||||
// Nested data tree with a `metadata` subdirectory, a non-parquet file
|
||||
// and a zero-size fake file.
|
||||
let table = dir.join("data/table");
|
||||
fs::create_dir_all(&table).expect("create table dir");
|
||||
fs::create_dir_all(table.join("metadata")).expect("create metadata dir");
|
||||
write_parquet_file(&table.join("0001.parquet"), 3);
|
||||
write_parquet_file(&table.join("0002.parquet"), 5);
|
||||
write_parquet_file(&table.join("metadata/0001.parquet"), 7);
|
||||
fs::write(table.join("notes.txt"), b"not parquet").expect("write notes");
|
||||
File::create(table.join("empty.parquet")).expect("create empty file");
|
||||
}
|
||||
|
||||
/// Runs the full list -> filter -> footer-read path against a File backend
|
||||
/// rooted at `dir` and returns the collected file reports.
|
||||
async fn inspect_fixture(dir: &Path, include_metadata_files: bool) -> Vec<FooterFileReport> {
|
||||
let root = Some(dir.to_path_buf());
|
||||
let store = build_store(None, root.as_deref())
|
||||
.await
|
||||
.expect("build store");
|
||||
let files = collect_parquet_files(store.as_ref(), root.as_deref(), include_metadata_files)
|
||||
.await
|
||||
.expect("collect files");
|
||||
let mut reports = futures::stream::iter(files.iter())
|
||||
.map(|file| inspect_file(Arc::clone(&store), file, "value"))
|
||||
.buffer_unordered(8)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("inspect files");
|
||||
reports.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
|
||||
reports
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_and_inspects_footer_files_without_metadata_by_default() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fixture_tree(dir.path());
|
||||
|
||||
let reports = inspect_fixture(dir.path(), false).await;
|
||||
|
||||
let relative_paths = reports
|
||||
.iter()
|
||||
.map(|report| report.relative_path.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
// metadata/0001.parquet excluded, notes.txt and empty.parquet excluded.
|
||||
assert_eq!(
|
||||
relative_paths,
|
||||
vec!["data/table/0001.parquet", "data/table/0002.parquet"]
|
||||
);
|
||||
assert!(reports.iter().all(|report| report.file_size > 0));
|
||||
assert!(reports.iter().all(|report| report.num_row_groups == 1));
|
||||
assert!(reports.iter().all(|report| report.num_rows > 0));
|
||||
assert!(
|
||||
reports
|
||||
.iter()
|
||||
.all(|report| report.columns.len() == 1 && report.columns[0].column_path == "value")
|
||||
);
|
||||
// Default writer properties use dictionary encoding, so the column chunk
|
||||
// reports PLAIN (dictionary page) plus RLE_DICTIONARY (data pages).
|
||||
assert!(
|
||||
reports
|
||||
.iter()
|
||||
.all(|report| report.columns[0].encodings.contains(&"PLAIN".to_string()))
|
||||
);
|
||||
assert!(
|
||||
reports
|
||||
.iter()
|
||||
.all(|report| report.columns[0].compression == "UNCOMPRESSED")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn includes_metadata_files_when_requested() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fixture_tree(dir.path());
|
||||
|
||||
let reports = inspect_fixture(dir.path(), true).await;
|
||||
|
||||
let relative_paths = reports
|
||||
.iter()
|
||||
.map(|report| report.relative_path.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
relative_paths,
|
||||
vec![
|
||||
"data/table/0001.parquet",
|
||||
"data/table/0002.parquet",
|
||||
"data/table/metadata/0001.parquet"
|
||||
]
|
||||
);
|
||||
let metadata_report = reports
|
||||
.iter()
|
||||
.find(|report| report.relative_path == "data/table/metadata/0001.parquet")
|
||||
.expect("metadata report");
|
||||
assert_eq!(metadata_report.num_rows, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_a_file_destination_toml() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fixture_tree(dir.path());
|
||||
let destination = dir.path().join("destination.toml");
|
||||
fs::write(
|
||||
&destination,
|
||||
format!(
|
||||
"data_home = \"{}\"\n[object_store]\ntype = \"File\"\n",
|
||||
dir.path().display()
|
||||
),
|
||||
)
|
||||
.expect("write destination toml");
|
||||
|
||||
let destination: DestinationConfig =
|
||||
toml::from_str(&fs::read_to_string(&destination).expect("read toml"))
|
||||
.expect("parse toml");
|
||||
let store = build_store(Some(&destination), None)
|
||||
.await
|
||||
.expect("build store");
|
||||
let files = collect_parquet_files(store.as_ref(), None, false)
|
||||
.await
|
||||
.expect("collect files");
|
||||
|
||||
// Destination mode has no root prefix: paths are store-relative keys.
|
||||
assert_eq!(
|
||||
files
|
||||
.iter()
|
||||
.map(|file| file.relative_path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["data/table/0001.parquet", "data/table/0002.parquet"]
|
||||
);
|
||||
assert!(files.iter().all(|file| file.path == file.relative_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_exactly_one_of_root_or_destination() {
|
||||
use futures::FutureExt;
|
||||
|
||||
let both = run_inspect_footer(InspectFooterArgs {
|
||||
root: Some(PathBuf::from("/tmp/root")),
|
||||
destination: Some(PathBuf::from("/tmp/destination.toml")),
|
||||
column: "value".to_string(),
|
||||
include_metadata_files: false,
|
||||
})
|
||||
.now_or_never();
|
||||
assert!(matches!(both, Some(Err(_))));
|
||||
|
||||
let neither = run_inspect_footer(InspectFooterArgs {
|
||||
root: None,
|
||||
destination: None,
|
||||
column: "value".to_string(),
|
||||
include_metadata_files: false,
|
||||
})
|
||||
.now_or_never();
|
||||
assert!(matches!(neither, Some(Err(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,9 @@ pub async fn run() {
|
||||
Some(Command::PromRemoteWrite(rw)) => run_prom_remote_write(rw)
|
||||
.await
|
||||
.expect("prom remote write failed"),
|
||||
Some(Command::InspectFooter(inspect)) => {
|
||||
run_inspect_footer(inspect).expect("inspect footer failed")
|
||||
}
|
||||
Some(Command::InspectFooter(inspect)) => run_inspect_footer(inspect)
|
||||
.await
|
||||
.expect("inspect footer failed"),
|
||||
Some(Command::Plan(plan)) => run_plan(plan).expect("plan failed"),
|
||||
None => run_direct_sst(args.legacy).await,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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.
|
||||
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
|
||||
#[path = "query_regression_runner/mod.rs"]
|
||||
mod query_regression_runner;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
query_regression_runner::run().await;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// 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.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::model::{Scenario, Table};
|
||||
use crate::query_regression_runner::plan::{load_plan, validate_direct_tables};
|
||||
use crate::query_regression_runner::sql::{
|
||||
extract_rows, http_post_sql, row_u64, row_value, sql_ident, sql_string, value_text,
|
||||
};
|
||||
use crate::query_regression_runner::{PrepareDirectArgs, Result};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct Discovery {
|
||||
table: String,
|
||||
catalog: String,
|
||||
schema: String,
|
||||
table_id: u64,
|
||||
region_id: u64,
|
||||
region_seq: u64,
|
||||
table_dir: String,
|
||||
region_dir: String,
|
||||
peer_id: u64,
|
||||
peer_addr: Value,
|
||||
is_leader: Value,
|
||||
status: Value,
|
||||
discovery_queries: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedTarget {
|
||||
creates: Vec<Value>,
|
||||
discoveries: Vec<Discovery>,
|
||||
}
|
||||
|
||||
pub(super) async fn run_prepare_direct(args: PrepareDirectArgs) -> Result<()> {
|
||||
if !args.http_timeout.is_finite() || args.http_timeout < 0.0 {
|
||||
return Err("--http-timeout must be a non-negative finite number".into());
|
||||
}
|
||||
let case_path = args.case.canonicalize()?;
|
||||
let plan = load_plan(&args.fixture_generator, &case_path)?;
|
||||
let scenario_value = plan
|
||||
.get("scenario")
|
||||
.cloned()
|
||||
.ok_or("fixture plan has no scenario")?;
|
||||
let scenario: Scenario = serde_json::from_value(scenario_value)?;
|
||||
let tables = direct_tables(scenario)?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs_f64(args.http_timeout))
|
||||
.build()?;
|
||||
let base = prepare_target("base", args.base_http_port, &tables, &client).await?;
|
||||
let candidate = prepare_target("candidate", args.candidate_http_port, &tables, &client).await?;
|
||||
if base.discoveries.len() != candidate.discoveries.len()
|
||||
|| base
|
||||
.discoveries
|
||||
.iter()
|
||||
.zip(&candidate.discoveries)
|
||||
.any(|(base, candidate)| {
|
||||
base.table != candidate.table
|
||||
|| base.region_id != candidate.region_id
|
||||
|| base.table_dir != candidate.table_dir
|
||||
})
|
||||
{
|
||||
return Err(format!(
|
||||
"base/candidate metadata mismatch: base={:?}, candidate={:?}",
|
||||
base.discoveries, candidate.discoveries
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut fixtures = Vec::with_capacity(tables.len());
|
||||
for (index, (table, discovery)) in tables.iter().zip(&base.discoveries).enumerate() {
|
||||
let fixture_dir = table_fixture_dir(&args.fixture_dir, &tables, table, index);
|
||||
fixtures.push(generate_direct_fixture(
|
||||
&args.fixture_generator,
|
||||
&case_path,
|
||||
&fixture_dir,
|
||||
table,
|
||||
discovery,
|
||||
tables.len() > 1,
|
||||
args.allow_large_fixture,
|
||||
)?);
|
||||
}
|
||||
|
||||
let report = json!({
|
||||
"case_path": case_path,
|
||||
"scenario": "direct_readable_sst",
|
||||
"base": { "create_table": base.creates, "discovery": base.discoveries },
|
||||
"candidate": { "create_table": candidate.creates, "discovery": candidate.discoveries },
|
||||
"fixtures": fixtures,
|
||||
});
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&report)?);
|
||||
if let Some(path) = args.output {
|
||||
fs::write(path, &text)?;
|
||||
}
|
||||
print!("{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn direct_tables(scenario: Scenario) -> Result<Vec<Table>> {
|
||||
match scenario {
|
||||
Scenario::DirectReadableSst { tables, layout, .. } => {
|
||||
validate_direct_tables(tables, layout)
|
||||
}
|
||||
Scenario::PromRemoteWriteThenQuery { .. } => {
|
||||
Err("prepare-direct requires scenario kind direct_readable_sst".into())
|
||||
}
|
||||
Scenario::OtlpTraceLoad { .. } => {
|
||||
Err("prepare-direct requires scenario kind direct_readable_sst".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_target(
|
||||
name: &str,
|
||||
port: u16,
|
||||
tables: &[Table],
|
||||
client: &Client,
|
||||
) -> Result<PreparedTarget> {
|
||||
let mut creates = Vec::with_capacity(tables.len());
|
||||
let mut discoveries = Vec::with_capacity(tables.len());
|
||||
for table in tables {
|
||||
let sql = create_table_sql(table)?;
|
||||
let result = http_post_sql(client, port, &sql, &table.database).await;
|
||||
if !result["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(format!("CREATE TABLE {} failed for {name}: {result}", table.name).into());
|
||||
}
|
||||
creates.push(json!({ "table": table.name, "sql": sql, "result": result }));
|
||||
discoveries.push(discover_region(client, port, table).await?);
|
||||
}
|
||||
Ok(PreparedTarget {
|
||||
creates,
|
||||
discoveries,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_table_sql(table: &Table) -> Result<String> {
|
||||
if table.engine != "mito" {
|
||||
return Err("prepare-direct requires table engine mito".into());
|
||||
}
|
||||
let time_index = table
|
||||
.time_index
|
||||
.as_deref()
|
||||
.ok_or("direct-SST table requires time_index")?;
|
||||
if table.columns.is_empty() {
|
||||
return Err("direct-SST table requires columns".into());
|
||||
}
|
||||
let columns = table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| format!("{} {}", sql_ident(&column.name), column.data_type))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ");
|
||||
let primary_key = table
|
||||
.primary_key
|
||||
.iter()
|
||||
.map(|column| sql_ident(column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mut options = Vec::new();
|
||||
if let Some(append_mode) = table.append_mode {
|
||||
options.push(("append_mode", append_mode.to_string()));
|
||||
}
|
||||
if let Some(sst_format) = table
|
||||
.sst_format
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
options.push(("sst_format", sst_format.to_string()));
|
||||
}
|
||||
let with = (!options.is_empty()).then(|| {
|
||||
format!(
|
||||
"\nWITH ({})",
|
||||
options
|
||||
.iter()
|
||||
.map(|(key, value)| format!("'{key}'='{value}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
});
|
||||
Ok(format!(
|
||||
"CREATE TABLE {} (\n {columns},\n TIME INDEX ({}),\n PRIMARY KEY ({primary_key})\n) ENGINE=mito{};",
|
||||
sql_ident(&table.name),
|
||||
sql_ident(time_index),
|
||||
with.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
async fn discover_region(client: &Client, port: u16, table: &Table) -> Result<Discovery> {
|
||||
let schema = sql_string(&table.database);
|
||||
let table_name = sql_string(&table.name);
|
||||
let table_sql = format!(
|
||||
"SELECT table_id FROM information_schema.tables WHERE table_schema = {schema} AND table_name = {table_name}"
|
||||
);
|
||||
let table_result = http_post_sql(client, port, &table_sql, &table.database).await;
|
||||
if !table_result["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(format!("table_id discovery failed: {table_result}").into());
|
||||
}
|
||||
let table_rows = extract_rows(table_result.get("response").unwrap_or(&Value::Null));
|
||||
if table_rows.len() != 1 {
|
||||
return Err(format!(
|
||||
"expected one information_schema.tables row, got {}: {table_result}",
|
||||
table_rows.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let table_id = row_u64(&table_rows[0], 0, "table_id")?;
|
||||
|
||||
let region_sql = format!(
|
||||
"SELECT region_id, peer_id, peer_addr, is_leader, status FROM information_schema.region_peers WHERE table_schema = {schema} AND table_name = {table_name}"
|
||||
);
|
||||
let region_result = http_post_sql(client, port, ®ion_sql, &table.database).await;
|
||||
if !region_result["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(format!("region_peers discovery failed: {region_result}").into());
|
||||
}
|
||||
let region_rows = extract_rows(region_result.get("response").unwrap_or(&Value::Null));
|
||||
if region_rows.len() != 1 {
|
||||
return Err(format!(
|
||||
"expected one information_schema.region_peers row, got {}: {region_result}",
|
||||
region_rows.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let row = ®ion_rows[0];
|
||||
let region_id = row_u64(row, 0, "region_id")?;
|
||||
let peer_id = row_u64(row, 1, "peer_id")?;
|
||||
let peer_addr = row_value(row, 2, "peer_addr")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let is_leader = row_value(row, 3, "is_leader")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let status = row_value(row, 4, "status").cloned().unwrap_or(Value::Null);
|
||||
if !matches!(
|
||||
value_text(&is_leader).to_ascii_lowercase().as_str(),
|
||||
"yes" | "true"
|
||||
) {
|
||||
return Err(format!(
|
||||
"expected leader region peer, got is_leader={is_leader}: {region_result}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !value_text(&status).eq_ignore_ascii_case("ALIVE") {
|
||||
return Err(
|
||||
format!("expected ALIVE region peer, got status={status}: {region_result}").into(),
|
||||
);
|
||||
}
|
||||
if peer_id != 0 {
|
||||
return Err(format!("expected region leader on datanode peer_id=0, got {peer_id}").into());
|
||||
}
|
||||
if region_id >> 32 != table_id {
|
||||
return Err(format!(
|
||||
"table_id mismatch: tables={table_id}, region_id-derived={}",
|
||||
region_id >> 32
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let region_seq = region_id & u32::MAX as u64;
|
||||
let (table_dir, region_dir) = storage_paths(&table.database, table_id, region_seq);
|
||||
Ok(Discovery {
|
||||
table: table.name.clone(),
|
||||
catalog: "greptime".to_string(),
|
||||
schema: table.database.clone(),
|
||||
table_id,
|
||||
region_id,
|
||||
region_seq,
|
||||
table_dir: table_dir.clone(),
|
||||
region_dir,
|
||||
peer_id,
|
||||
peer_addr,
|
||||
is_leader,
|
||||
status,
|
||||
discovery_queries: json!({ "table": table_result, "region": region_result }),
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_paths(database: &str, table_id: u64, region_seq: u64) -> (String, String) {
|
||||
let table_dir = format!("data/greptime/{database}/{table_id}/");
|
||||
let region_dir = format!("{table_dir}{table_id}_{region_seq:010}");
|
||||
(table_dir, region_dir)
|
||||
}
|
||||
|
||||
fn table_fixture_dir(root: &Path, tables: &[Table], table: &Table, index: usize) -> PathBuf {
|
||||
if tables.len() == 1 {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(fixture_subdir(table, index))
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_subdir(table: &Table, index: usize) -> String {
|
||||
let raw = format!("{index:02}_{}_{}", table.database, table.name);
|
||||
let mut safe = String::new();
|
||||
let mut replaced = false;
|
||||
for character in raw.chars() {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-') {
|
||||
safe.push(character);
|
||||
replaced = false;
|
||||
} else if !replaced {
|
||||
safe.push('_');
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
let safe = safe.trim_matches(['.', '_', '-']);
|
||||
if safe.is_empty() {
|
||||
format!("table_{index:02}")
|
||||
} else {
|
||||
safe.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_direct_fixture(
|
||||
generator: &Path,
|
||||
case_path: &Path,
|
||||
fixture_dir: &Path,
|
||||
table: &Table,
|
||||
discovery: &Discovery,
|
||||
multi_table: bool,
|
||||
allow_large_fixture: bool,
|
||||
) -> Result<Value> {
|
||||
if fixture_dir.exists() {
|
||||
fs::remove_dir_all(fixture_dir)?;
|
||||
}
|
||||
fs::create_dir_all(fixture_dir)?;
|
||||
let mut command = vec![
|
||||
generator.to_string_lossy().to_string(),
|
||||
"direct-sst".to_string(),
|
||||
"--case".to_string(),
|
||||
case_path.to_string_lossy().to_string(),
|
||||
"--out-dir".to_string(),
|
||||
fixture_dir.to_string_lossy().to_string(),
|
||||
];
|
||||
if multi_table {
|
||||
command.extend(["--table".to_string(), table.name.clone()]);
|
||||
}
|
||||
command.extend([
|
||||
"--region-id".to_string(),
|
||||
discovery.region_id.to_string(),
|
||||
"--table-dir".to_string(),
|
||||
discovery.table_dir.clone(),
|
||||
]);
|
||||
if allow_large_fixture {
|
||||
command.push("--allow-large".to_string());
|
||||
}
|
||||
let started = Instant::now();
|
||||
let output = Command::new(generator).args(&command[1..]).output()?;
|
||||
let elapsed_seconds = started.elapsed().as_secs_f64();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"fixture generator failed: command={command:?}, returncode={:?}, elapsed_seconds={elapsed_seconds:.3}, stderr={:.2000}",
|
||||
output.status.code(), stderr
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let summary: Value = serde_json::from_slice(&fs::read(fixture_dir.join("summary.json"))?)?;
|
||||
assert_fixture_summary(&summary, table, discovery)?;
|
||||
Ok(json!({
|
||||
"status": "ok",
|
||||
"fixture_dir": fixture_dir,
|
||||
"command": command,
|
||||
"returncode": output.status.code(),
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn assert_fixture_summary(summary: &Value, table: &Table, discovery: &Discovery) -> Result<()> {
|
||||
if summary.get("table").and_then(Value::as_str) != Some(&table.name) {
|
||||
return Err(format!(
|
||||
"fixture table mismatch: {:?} != {}",
|
||||
summary.get("table"),
|
||||
table.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if summary.get("database").and_then(Value::as_str) != Some(&table.database) {
|
||||
return Err(format!(
|
||||
"fixture database mismatch: {:?} != {}",
|
||||
summary.get("database"),
|
||||
table.database
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if summary.get("region_id").and_then(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str().and_then(|value| value.parse().ok()))
|
||||
}) != Some(discovery.region_id)
|
||||
{
|
||||
return Err(format!(
|
||||
"fixture region_id mismatch: {:?} != {}",
|
||||
summary.get("region_id"),
|
||||
discovery.region_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if summary.get("table_dir").and_then(Value::as_str) != Some(&discovery.table_dir) {
|
||||
return Err(format!(
|
||||
"fixture table_dir mismatch: {:?} != {}",
|
||||
summary.get("table_dir"),
|
||||
discovery.table_dir
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if summary
|
||||
.get("region_dir")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.trim_matches('/'))
|
||||
!= Some(discovery.region_dir.trim_matches('/'))
|
||||
{
|
||||
return Err(format!(
|
||||
"fixture region_dir mismatch: {:?} != {}",
|
||||
summary.get("region_dir"),
|
||||
discovery.region_dir
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::query_regression_runner::model::Column;
|
||||
|
||||
#[test]
|
||||
fn creates_exact_direct_sst_table_sql() {
|
||||
let table = Table {
|
||||
database: "public".to_string(),
|
||||
name: "metric\"name".to_string(),
|
||||
engine: "mito".to_string(),
|
||||
columns: vec![
|
||||
Column {
|
||||
name: "host".to_string(),
|
||||
data_type: "STRING".to_string(),
|
||||
},
|
||||
Column {
|
||||
name: "ts".to_string(),
|
||||
data_type: "TIMESTAMP(9)".to_string(),
|
||||
},
|
||||
],
|
||||
primary_key: vec!["host".to_string()],
|
||||
time_index: Some("ts".to_string()),
|
||||
append_mode: Some(true),
|
||||
sst_format: Some("flat".to_string()),
|
||||
validate_show_create_engine: true,
|
||||
};
|
||||
assert_eq!(
|
||||
create_table_sql(&table).unwrap(),
|
||||
"CREATE TABLE \"metric\"\"name\" (\n \"host\" STRING,\n \"ts\" TIMESTAMP(9),\n TIME INDEX (\"ts\"),\n PRIMARY KEY (\"host\")\n) ENGINE=mito\nWITH ('append_mode'='true', 'sst_format'='flat');"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_named_and_positional_discovery_rows() {
|
||||
let rows = extract_rows(&json!({"output": [{"data": [{"TABLE_ID": "7"}]}]}));
|
||||
assert_eq!(rows, vec![json!({"TABLE_ID": "7"})]);
|
||||
assert_eq!(row_u64(&rows[0], 0, "table_id").unwrap(), 7);
|
||||
let rows = extract_rows(&json!({"data": [[30064771073_u64, 0, "addr", "YES", "ALIVE"]]}));
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(row_u64(&rows[0], 0, "region_id").unwrap(), 30_064_771_073);
|
||||
assert_eq!(row_u64(&rows[0], 1, "peer_id").unwrap(), 0);
|
||||
assert_eq!(
|
||||
value_text(row_value(&rows[0], 3, "is_leader").unwrap()),
|
||||
"YES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_paths_include_the_table_directory() {
|
||||
assert_eq!(
|
||||
storage_paths("public", 1024, 0),
|
||||
(
|
||||
"data/greptime/public/1024/".to_string(),
|
||||
"data/greptime/public/1024/1024_0000000000".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use futures::{AsyncWriteExt as _, TryStreamExt};
|
||||
use object_store::ObjectStore;
|
||||
use object_store::factory::new_raw_object_store;
|
||||
use object_store::services::Fs;
|
||||
|
||||
use crate::query_regression_runner::model::{
|
||||
CopyCounts, DestinationConfig, FixtureSummary, MaterializeResult,
|
||||
};
|
||||
use crate::query_regression_runner::{MaterializeArgs, Result};
|
||||
|
||||
pub(super) async fn run_materialize(args: MaterializeArgs) -> Result<()> {
|
||||
let destination: DestinationConfig = toml::from_str(&fs::read_to_string(args.destination)?)?;
|
||||
let result = materialize(&args.fixture_dir, destination).await?;
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn materialize(
|
||||
fixture_dir: &Path,
|
||||
destination: DestinationConfig,
|
||||
) -> Result<MaterializeResult> {
|
||||
let summary: FixtureSummary =
|
||||
serde_json::from_slice(&fs::read(fixture_dir.join("summary.json"))?)?;
|
||||
let region_dir = validate_region_dir(&summary.region_dir)?;
|
||||
let object_source = fs_operator(&fixture_dir.join("object-store"))?;
|
||||
let manifest_source = fs_operator(&fixture_dir.join("manifest"))?;
|
||||
let destination =
|
||||
new_raw_object_store(&destination.object_store, &destination.data_home).await?;
|
||||
|
||||
let region_prefix = format!("{region_dir}/");
|
||||
destination
|
||||
.delete_with(®ion_prefix)
|
||||
.recursive(true)
|
||||
.await?;
|
||||
|
||||
let object_store = copy_tree(&object_source, &destination, "/", "").await?;
|
||||
let manifest = copy_tree(
|
||||
&manifest_source,
|
||||
&destination,
|
||||
"/",
|
||||
&format!("{region_dir}/manifest/"),
|
||||
)
|
||||
.await?;
|
||||
Ok(MaterializeResult {
|
||||
region_dir,
|
||||
object_store,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
fn fs_operator(root: &Path) -> Result<ObjectStore> {
|
||||
Ok(ObjectStore::new(Fs::default().root(&root.to_string_lossy()))?.finish())
|
||||
}
|
||||
|
||||
fn validate_region_dir(region_dir: &str) -> Result<String> {
|
||||
let region_dir = region_dir.trim_end_matches('/');
|
||||
if region_dir.is_empty()
|
||||
|| region_dir.starts_with('/')
|
||||
|| region_dir.contains('\\')
|
||||
|| region_dir
|
||||
.split('/')
|
||||
.any(|component| component.is_empty() || matches!(component, "." | ".."))
|
||||
{
|
||||
return Err(
|
||||
"region_dir must be a non-empty relative OpenDAL key without dot components".into(),
|
||||
);
|
||||
}
|
||||
Ok(region_dir.to_string())
|
||||
}
|
||||
|
||||
async fn copy_tree(
|
||||
source: &ObjectStore,
|
||||
destination: &ObjectStore,
|
||||
source_prefix: &str,
|
||||
destination_prefix: &str,
|
||||
) -> Result<CopyCounts> {
|
||||
let mut lister = source.lister_with(source_prefix).recursive(true).await?;
|
||||
let mut counts = CopyCounts::default();
|
||||
while let Some(entry) = lister.try_next().await? {
|
||||
if entry.metadata().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let source_path = entry.path().to_string();
|
||||
let reader = source
|
||||
.reader(&source_path)
|
||||
.await?
|
||||
.into_futures_async_read(0..entry.metadata().content_length())
|
||||
.await?;
|
||||
let mut writer = destination
|
||||
.writer(&format!("{destination_prefix}{source_path}"))
|
||||
.await?
|
||||
.into_futures_async_write();
|
||||
let bytes = futures::io::copy(reader, &mut writer).await?;
|
||||
writer.close().await?;
|
||||
counts.files += 1;
|
||||
counts.bytes += bytes;
|
||||
}
|
||||
Ok(counts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use object_store::config::ObjectStoreConfig;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn region_dir_must_be_a_relative_opendal_key() {
|
||||
assert_eq!(
|
||||
validate_region_dir("data/db/region/").unwrap(),
|
||||
"data/db/region"
|
||||
);
|
||||
for region_dir in [
|
||||
"",
|
||||
"/region",
|
||||
".",
|
||||
"a/../region",
|
||||
"a/./region",
|
||||
"a\\region",
|
||||
"a//region",
|
||||
] {
|
||||
assert!(validate_region_dir(region_dir).is_err(), "{region_dir}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materializes_fixture_from_fs_to_fs() {
|
||||
let fixture = tempfile::tempdir().unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let region_dir = "data/public/metrics/00000000000000000001";
|
||||
let sst = fixture
|
||||
.path()
|
||||
.join("object-store")
|
||||
.join(region_dir)
|
||||
.join("00000000000000000001.parquet");
|
||||
fs::create_dir_all(sst.parent().unwrap()).unwrap();
|
||||
fs::write(&sst, b"sst").unwrap();
|
||||
let checkpoint = fixture
|
||||
.path()
|
||||
.join("manifest/00000000000000000001.checkpoint");
|
||||
fs::create_dir_all(checkpoint.parent().unwrap()).unwrap();
|
||||
fs::write(&checkpoint, b"checkpoint").unwrap();
|
||||
fs::write(fixture.path().join("manifest/_last_checkpoint"), b"last").unwrap();
|
||||
fs::write(
|
||||
fixture.path().join("summary.json"),
|
||||
format!(r#"{{"region_dir":"{region_dir}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(fixture.path().join("files.jsonl"), "metadata").unwrap();
|
||||
fs::create_dir_all(destination.path().join(region_dir)).unwrap();
|
||||
fs::write(destination.path().join(region_dir).join("stale"), "stale").unwrap();
|
||||
fs::write(destination.path().join("unrelated"), "keep").unwrap();
|
||||
|
||||
let result = materialize(
|
||||
fixture.path(),
|
||||
DestinationConfig {
|
||||
data_home: destination.path().to_string_lossy().to_string(),
|
||||
object_store: ObjectStoreConfig::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.object_store.files, 1);
|
||||
assert_eq!(result.object_store.bytes, 3);
|
||||
assert_eq!(result.manifest.files, 2);
|
||||
assert_eq!(result.manifest.bytes, 14);
|
||||
assert_eq!(
|
||||
fs::read(
|
||||
destination
|
||||
.path()
|
||||
.join(region_dir)
|
||||
.join("00000000000000000001.parquet")
|
||||
)
|
||||
.unwrap(),
|
||||
b"sst"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(
|
||||
destination
|
||||
.path()
|
||||
.join(region_dir)
|
||||
.join("manifest/00000000000000000001.checkpoint")
|
||||
)
|
||||
.unwrap(),
|
||||
b"checkpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(
|
||||
destination
|
||||
.path()
|
||||
.join(region_dir)
|
||||
.join("manifest/_last_checkpoint")
|
||||
)
|
||||
.unwrap(),
|
||||
b"last"
|
||||
);
|
||||
assert!(!destination.path().join(region_dir).join("stale").exists());
|
||||
assert_eq!(
|
||||
fs::read(destination.path().join("unrelated")).unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
assert!(!destination.path().join("summary.json").exists());
|
||||
assert!(!destination.path().join("files.jsonl").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::{fs, io};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::query_regression_runner::model::{Measurement, Query, QueryResult, Scenario, Table};
|
||||
use crate::query_regression_runner::plan::{load_plan, normalize_scenario};
|
||||
use crate::query_regression_runner::sql::{http_post_sql, sql_ident};
|
||||
use crate::query_regression_runner::{MeasureArgs, Result};
|
||||
|
||||
pub(super) async fn run_measure(args: MeasureArgs) -> Result<()> {
|
||||
if !args.http_timeout.is_finite() || args.http_timeout < 0.0 {
|
||||
return Err("--http-timeout must be a non-negative finite number".into());
|
||||
}
|
||||
|
||||
let case_path = args.case.canonicalize()?;
|
||||
let plan = load_plan(&args.fixture_generator, &case_path)?;
|
||||
let case_text = fs::read_to_string(&case_path)?;
|
||||
let raw_case: toml::Value = toml::from_str(&case_text)?;
|
||||
let case_metadata = raw_case
|
||||
.get("case")
|
||||
.cloned()
|
||||
.unwrap_or(toml::Value::Table(toml::map::Map::new()));
|
||||
let case_metadata = serde_json::to_value(case_metadata)?;
|
||||
|
||||
let scenario_value = plan
|
||||
.get("scenario")
|
||||
.cloned()
|
||||
.ok_or("fixture plan has no scenario")?;
|
||||
let scenario: Scenario = serde_json::from_value(scenario_value.clone())?;
|
||||
let (tables, configured_queries) = normalize_scenario(scenario)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs_f64(args.http_timeout))
|
||||
.build()?;
|
||||
let base = run_target(args.base_http_port, &tables, &configured_queries, &client).await;
|
||||
let candidate = run_target(
|
||||
args.candidate_http_port,
|
||||
&tables,
|
||||
&configured_queries,
|
||||
&client,
|
||||
)
|
||||
.await;
|
||||
let thresholds = enforce_thresholds(&configured_queries, &base, &candidate)?;
|
||||
let status = if base.status == "failed"
|
||||
|| candidate.status == "failed"
|
||||
|| thresholds
|
||||
.iter()
|
||||
.any(|threshold| threshold["status"] == "failed")
|
||||
{
|
||||
"failed"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
let report = json!({
|
||||
"case_path": case_path,
|
||||
"case": case_metadata,
|
||||
"scenario": scenario_value,
|
||||
"queries": configured_queries,
|
||||
"query_mode": "endpoint",
|
||||
"http_timeout": args.http_timeout,
|
||||
"targets": [target_report("base", args.base_http_port, base), target_report("candidate", args.candidate_http_port, candidate)],
|
||||
"thresholds": thresholds,
|
||||
"status": status,
|
||||
});
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&report)?);
|
||||
if let Some(path) = args.output {
|
||||
fs::write(path, &text)?;
|
||||
}
|
||||
print!("{text}");
|
||||
if status == "failed" {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_report(name: &str, http_port: u16, result: QueryResult) -> Value {
|
||||
let status = if result.status == "ok" {
|
||||
"measured"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
json!({
|
||||
"name": name,
|
||||
"http_port": http_port,
|
||||
"validation": result.validation,
|
||||
"validation_errors": result.validation_errors,
|
||||
"measurements": result.measurements,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_target(
|
||||
port: u16,
|
||||
tables: &[Table],
|
||||
configured_queries: &[Query],
|
||||
client: &Client,
|
||||
) -> QueryResult {
|
||||
let mut queries = configured_queries.to_vec();
|
||||
if queries.is_empty() {
|
||||
queries.push(Query {
|
||||
name: Some("count_all".to_string()),
|
||||
kind: Some("sql".to_string()),
|
||||
query: format!("SELECT count(*) FROM {}", sql_ident(&tables[0].name)),
|
||||
warmup: 0,
|
||||
iterations: 1,
|
||||
thresholds: Map::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let db = &tables[0].database;
|
||||
let mut validation = Vec::new();
|
||||
let mut validation_errors = Vec::new();
|
||||
for table in tables {
|
||||
let sql = format!("SHOW CREATE TABLE {}", sql_ident(&table.name));
|
||||
let sample = http_post_sql(client, port, &sql, &table.database).await;
|
||||
if !sample["ok"].as_bool().unwrap_or(false) {
|
||||
validation_errors.push(json!({
|
||||
"sql": sql,
|
||||
"error": sample.get("error"),
|
||||
"response": sample.get("response"),
|
||||
}));
|
||||
} else {
|
||||
for error in validate_show_create(&sample, table) {
|
||||
validation_errors.push(json!({
|
||||
"sql": sql,
|
||||
"error": error,
|
||||
"response": sample.get("response"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
validation.push(sample);
|
||||
}
|
||||
let first = http_post_sql(client, port, &queries[0].query, db).await;
|
||||
if !first["ok"].as_bool().unwrap_or(false) {
|
||||
validation_errors.push(json!({
|
||||
"sql": queries[0].query,
|
||||
"error": first.get("error"),
|
||||
"response": first.get("response"),
|
||||
}));
|
||||
}
|
||||
validation.push(first);
|
||||
|
||||
let mut measurements = Vec::with_capacity(queries.len());
|
||||
for query in &queries {
|
||||
for _ in 0..query.warmup {
|
||||
let warmup = http_post_sql(client, port, &query.query, db).await;
|
||||
if !warmup["ok"].as_bool().unwrap_or(false) {
|
||||
validation_errors.push(json!({
|
||||
"sql": query.query,
|
||||
"phase": "warmup",
|
||||
"error": warmup.get("error"),
|
||||
"response": warmup.get("response"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
let mut samples = Vec::with_capacity(query.iterations);
|
||||
let mut good_latencies = Vec::with_capacity(query.iterations);
|
||||
for _ in 0..query.iterations {
|
||||
let mut sample = http_post_sql(client, port, &query.query, db).await;
|
||||
let execution_time = sample
|
||||
.get("response")
|
||||
.and_then(extract_execution_time)
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
sample
|
||||
.as_object_mut()
|
||||
.expect("HTTP samples are objects")
|
||||
.insert("execution_time_ms".to_string(), execution_time);
|
||||
if sample["ok"].as_bool().unwrap_or(false) {
|
||||
good_latencies.push(sample["latency_ms"].as_f64().unwrap_or_default());
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
let median = (!good_latencies.is_empty()).then(|| median(&good_latencies));
|
||||
let p95 = (!good_latencies.is_empty()).then(|| percentile(&good_latencies, 95.0));
|
||||
let status = if good_latencies.len() == samples.len() {
|
||||
"ok"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
measurements.push(Measurement {
|
||||
name: query.name.clone(),
|
||||
kind: query.kind.clone(),
|
||||
iterations: samples.len(),
|
||||
samples,
|
||||
latency_ms_median: median,
|
||||
latency_ms_p95: p95,
|
||||
status: status.to_string(),
|
||||
});
|
||||
}
|
||||
let failed = !validation_errors.is_empty() || measurements.iter().any(|m| m.status == "failed");
|
||||
QueryResult {
|
||||
validation,
|
||||
validation_errors,
|
||||
measurements,
|
||||
status: if failed { "failed" } else { "ok" }.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_show_create(result: &Value, table: &Table) -> Vec<&'static str> {
|
||||
let text = result
|
||||
.get("response")
|
||||
.map(response_text)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let mut errors = Vec::new();
|
||||
if !text.contains(&table.name.to_lowercase()) {
|
||||
errors.push("SHOW CREATE output does not contain table name");
|
||||
}
|
||||
if table.validate_show_create_engine && (!text.contains("engine") || !text.contains("mito")) {
|
||||
errors.push("SHOW CREATE output does not mention ENGINE=mito");
|
||||
}
|
||||
if table.append_mode.is_some() && !text.contains("append_mode") {
|
||||
errors.push("SHOW CREATE output does not mention append_mode");
|
||||
}
|
||||
if table.sst_format.is_some() && !text.contains("sst_format") {
|
||||
errors.push("SHOW CREATE output does not mention sst_format");
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
fn response_text(body: &Value) -> String {
|
||||
body.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| serde_json::to_string(body).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn extract_execution_time(body: &Value) -> Option<&Value> {
|
||||
match body {
|
||||
Value::Object(map) => {
|
||||
for key in ["execution_time_ms", "execution_time", "elapsed"] {
|
||||
if let Some(value) = map.get(key) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
map.values().find_map(extract_execution_time)
|
||||
}
|
||||
Value::Array(values) => values.iter().find_map(extract_execution_time),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn median(values: &[f64]) -> f64 {
|
||||
let mut ordered = values.to_vec();
|
||||
ordered.sort_by(f64::total_cmp);
|
||||
let middle = ordered.len() / 2;
|
||||
if ordered.len().is_multiple_of(2) {
|
||||
(ordered[middle - 1] + ordered[middle]) / 2.0
|
||||
} else {
|
||||
ordered[middle]
|
||||
}
|
||||
}
|
||||
|
||||
fn percentile(values: &[f64], pct: f64) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut ordered = values.to_vec();
|
||||
ordered.sort_by(f64::total_cmp);
|
||||
let index = round_ties_even((pct / 100.0) * (ordered.len() - 1) as f64)
|
||||
.clamp(0, ordered.len() as isize - 1) as usize;
|
||||
ordered[index]
|
||||
}
|
||||
|
||||
pub(super) fn round_ties_even(value: f64) -> isize {
|
||||
let floor = value.floor();
|
||||
let fraction = value - floor;
|
||||
if fraction < 0.5 {
|
||||
floor as isize
|
||||
} else if fraction > 0.5 {
|
||||
floor as isize + 1
|
||||
} else if (floor as isize) % 2 == 0 {
|
||||
floor as isize
|
||||
} else {
|
||||
floor as isize + 1
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_thresholds(
|
||||
queries: &[Query],
|
||||
base: &QueryResult,
|
||||
candidate: &QueryResult,
|
||||
) -> Result<Vec<Value>> {
|
||||
let base_by_name: HashMap<_, _> = base
|
||||
.measurements
|
||||
.iter()
|
||||
.map(|measurement| (measurement.name.as_deref(), measurement))
|
||||
.collect();
|
||||
let mut results = Vec::new();
|
||||
for candidate_measurement in &candidate.measurements {
|
||||
let query = queries
|
||||
.iter()
|
||||
.find(|query| query.name == candidate_measurement.name);
|
||||
let thresholds = query.map_or_else(Map::new, |query| query.thresholds.clone());
|
||||
let base_measurement = base_by_name.get(&candidate_measurement.name.as_deref());
|
||||
if let Some(limit) = thresholds
|
||||
.get("max_candidate_latency_regression_pct")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(value_as_f64)
|
||||
.transpose()?
|
||||
{
|
||||
let result = match base_measurement {
|
||||
None => {
|
||||
json!({"query": candidate_measurement.name, "threshold": "max_candidate_latency_regression_pct", "status": "failed", "reason": "missing base measurement"})
|
||||
}
|
||||
Some(base) if matches!(base.latency_ms_median, None | Some(0.0)) => {
|
||||
json!({"query": candidate_measurement.name, "threshold": "max_candidate_latency_regression_pct", "status": "failed", "reason": "base median latency is missing or zero", "base_latency_ms_median": base.latency_ms_median})
|
||||
}
|
||||
Some(_) if candidate_measurement.latency_ms_median.is_none() => {
|
||||
json!({"query": candidate_measurement.name, "threshold": "max_candidate_latency_regression_pct", "status": "failed", "reason": "missing candidate measurement"})
|
||||
}
|
||||
Some(base) => {
|
||||
let actual = (candidate_measurement.latency_ms_median.unwrap()
|
||||
- base.latency_ms_median.unwrap())
|
||||
/ base.latency_ms_median.unwrap()
|
||||
* 100.0;
|
||||
json!({"query": candidate_measurement.name, "threshold": "max_candidate_latency_regression_pct", "status": if actual <= limit { "passed" } else { "failed" }, "actual_pct": actual, "limit_pct": limit})
|
||||
}
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
for key in thresholds
|
||||
.keys()
|
||||
.filter(|key| *key != "max_candidate_latency_regression_pct")
|
||||
{
|
||||
results.push(json!({"query": candidate_measurement.name, "threshold": key, "status": "failed", "reason": "unsupported threshold"}));
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn value_as_f64(value: &Value) -> Result<f64> {
|
||||
value.as_f64().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "threshold must be numeric").into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn median_and_p95_match_python() {
|
||||
assert_eq!(median(&[1.0, 8.0, 3.0, 4.0]), 3.5);
|
||||
assert_eq!(
|
||||
percentile(
|
||||
&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0],
|
||||
95.0
|
||||
),
|
||||
10.0
|
||||
);
|
||||
assert_eq!(percentile(&[], 95.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_nested_execution_time_in_priority_order() {
|
||||
let body = json!({"output": [{"elapsed": 3}], "execution_time": 2});
|
||||
assert_eq!(extract_execution_time(&body), Some(&json!(2)));
|
||||
assert_eq!(
|
||||
extract_execution_time(&json!({"output": [{"elapsed": 3}]})),
|
||||
Some(&json!(3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_rejects_unknown_keys_and_zero_base() {
|
||||
let query = Query {
|
||||
name: Some("q".to_string()),
|
||||
kind: None,
|
||||
query: "SELECT 1".to_string(),
|
||||
warmup: 0,
|
||||
iterations: 1,
|
||||
thresholds: Map::from_iter([
|
||||
("max_candidate_latency_regression_pct".to_string(), json!(0)),
|
||||
("other".to_string(), json!(1)),
|
||||
]),
|
||||
};
|
||||
let measurement = |median| Measurement {
|
||||
name: Some("q".to_string()),
|
||||
kind: None,
|
||||
iterations: 1,
|
||||
samples: vec![],
|
||||
latency_ms_median: median,
|
||||
latency_ms_p95: median,
|
||||
status: "ok".to_string(),
|
||||
};
|
||||
let base = QueryResult {
|
||||
validation: vec![],
|
||||
validation_errors: vec![],
|
||||
measurements: vec![measurement(Some(0.0))],
|
||||
status: "ok".to_string(),
|
||||
};
|
||||
let candidate = QueryResult {
|
||||
validation: vec![],
|
||||
validation_errors: vec![],
|
||||
measurements: vec![measurement(Some(1.0))],
|
||||
status: "ok".to_string(),
|
||||
};
|
||||
let results = enforce_thresholds(&[query], &base, &candidate).unwrap();
|
||||
assert_eq!(
|
||||
results[0]["reason"],
|
||||
"base median latency is missing or zero"
|
||||
);
|
||||
assert_eq!(results[1]["reason"], "unsupported threshold");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 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.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod direct;
|
||||
mod materialize;
|
||||
mod measure;
|
||||
mod model;
|
||||
mod otlp;
|
||||
mod plan;
|
||||
mod remote;
|
||||
mod sql;
|
||||
|
||||
pub(super) type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Measure query endpoints or materialize a direct-SST fixture")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: RunnerCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum RunnerCommand {
|
||||
/// Run normalized query regression cases against two HTTP endpoints.
|
||||
Measure(MeasureArgs),
|
||||
/// Create direct-SST tables, discover their regions, and generate fixtures.
|
||||
PrepareDirect(PrepareDirectArgs),
|
||||
/// Render the frontend Prometheus store configuration for a remote-write case.
|
||||
RenderRemoteConfig(RenderRemoteConfigArgs),
|
||||
/// Create a database and ingest a normalized Prometheus remote-write case.
|
||||
PrepareRemote(PrepareRemoteArgs),
|
||||
/// Inspect stopped remote-write storage and finalize the aggregate report.
|
||||
FinalizeRemote(FinalizeRemoteArgs),
|
||||
/// Run one externally managed OTLP trace-load target.
|
||||
RunOtlpTarget(RunOtlpTargetArgs),
|
||||
/// Combine externally managed OTLP trace-load target results.
|
||||
FinalizeOtlp(FinalizeOtlpArgs),
|
||||
/// Copy a direct-SST fixture into an object store destination.
|
||||
Materialize(MaterializeArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct MeasureArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long)]
|
||||
base_http_port: u16,
|
||||
#[arg(long)]
|
||||
candidate_http_port: u16,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long, default_value_t = 120.0)]
|
||||
http_timeout: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct PrepareDirectArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long)]
|
||||
base_http_port: u16,
|
||||
#[arg(long)]
|
||||
candidate_http_port: u16,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_dir: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long, default_value_t = 120.0)]
|
||||
http_timeout: f64,
|
||||
#[arg(long)]
|
||||
allow_large_fixture: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct RenderRemoteConfigArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct PrepareRemoteArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long)]
|
||||
base_http_port: u16,
|
||||
#[arg(long)]
|
||||
candidate_http_port: u16,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long, default_value_t = 120.0)]
|
||||
http_timeout: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct FinalizeRemoteArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
candidate_bin: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
base_data_home: Option<PathBuf>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
candidate_data_home: Option<PathBuf>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
base_destination: Option<PathBuf>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
candidate_destination: Option<PathBuf>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
report: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct RunOtlpTargetArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
otelgen_bin: PathBuf,
|
||||
#[arg(long)]
|
||||
http_port: u16,
|
||||
#[arg(long)]
|
||||
target_name: String,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
work_dir: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: PathBuf,
|
||||
#[arg(long, default_value_t = 120.0)]
|
||||
http_timeout: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct FinalizeOtlpArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
case: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_generator: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
base_result: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
candidate_result: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct MaterializeArgs {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
fixture_dir: PathBuf,
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
help = "TOML file: data_home = \"...\" and object_store = { type = \"File\" }; object_store uses object_store::config::ObjectStoreConfig"
|
||||
)]
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) async fn run() {
|
||||
if let Err(error) = run_inner().await {
|
||||
eprintln!("query_regression_runner: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
RunnerCommand::Measure(args) => measure::run_measure(args).await,
|
||||
RunnerCommand::PrepareDirect(args) => direct::run_prepare_direct(args).await,
|
||||
RunnerCommand::RenderRemoteConfig(args) => remote::run_render_remote_config(args).await,
|
||||
RunnerCommand::PrepareRemote(args) => remote::run_prepare_remote(args).await,
|
||||
RunnerCommand::FinalizeRemote(args) => remote::run_finalize_remote(args).await,
|
||||
RunnerCommand::RunOtlpTarget(args) => otlp::run_otlp_target(args).await,
|
||||
RunnerCommand::FinalizeOtlp(args) => otlp::run_finalize_otlp(args).await,
|
||||
RunnerCommand::Materialize(args) => materialize::run_materialize(args).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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.
|
||||
|
||||
use object_store::config::ObjectStoreConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct DestinationConfig {
|
||||
pub(super) data_home: String,
|
||||
pub(super) object_store: ObjectStoreConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FixtureSummary {
|
||||
pub(super) region_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(super) struct CopyCounts {
|
||||
pub(super) files: u64,
|
||||
pub(super) bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct MaterializeResult {
|
||||
pub(super) region_dir: String,
|
||||
pub(super) object_store: CopyCounts,
|
||||
pub(super) manifest: CopyCounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub(super) enum Scenario {
|
||||
#[serde(rename = "direct_readable_sst")]
|
||||
DirectReadableSst {
|
||||
tables: Vec<Table>,
|
||||
layout: Layout,
|
||||
#[serde(default)]
|
||||
queries: Vec<Query>,
|
||||
},
|
||||
#[serde(rename = "prom_remote_write_then_query")]
|
||||
PromRemoteWriteThenQuery {
|
||||
remote_write: RemoteWrite,
|
||||
#[serde(default)]
|
||||
queries: Vec<Query>,
|
||||
},
|
||||
#[serde(rename = "otlp_trace_load")]
|
||||
OtlpTraceLoad { load: OtlpTraceLoad },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct Layout {
|
||||
pub(super) regions: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct Table {
|
||||
pub(super) database: String,
|
||||
pub(super) name: String,
|
||||
#[serde(default)]
|
||||
pub(super) engine: String,
|
||||
#[serde(default)]
|
||||
pub(super) columns: Vec<Column>,
|
||||
#[serde(default)]
|
||||
pub(super) primary_key: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) time_index: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) append_mode: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(super) sst_format: Option<String>,
|
||||
#[serde(default = "default_show_create_engine")]
|
||||
pub(super) validate_show_create_engine: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct Column {
|
||||
pub(super) name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub(super) data_type: String,
|
||||
}
|
||||
|
||||
const fn default_show_create_engine() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct RemoteWrite {
|
||||
pub(super) database: String,
|
||||
pub(super) metric: String,
|
||||
pub(super) physical_table: String,
|
||||
pub(super) series_count: u64,
|
||||
pub(super) samples_per_series: u64,
|
||||
pub(super) start_unix_millis: i64,
|
||||
pub(super) step_millis: i64,
|
||||
pub(super) chunk_series_count: u64,
|
||||
pub(super) timeout_seconds: u64,
|
||||
pub(super) sample_chunk_size: Option<u64>,
|
||||
pub(super) flush_every_sample_chunks: u64,
|
||||
pub(super) visibility_timeout_seconds: u64,
|
||||
pub(super) prom_store: PromStore,
|
||||
pub(super) value: RemoteValue,
|
||||
pub(super) storage: Option<StorageConfig>,
|
||||
pub(super) read_bench: Option<ReadBenchConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct PromStore {
|
||||
pub(super) pending_rows_flush_interval: String,
|
||||
pub(super) max_batch_rows: u64,
|
||||
pub(super) max_concurrent_flushes: u64,
|
||||
pub(super) worker_channel_capacity: u64,
|
||||
pub(super) max_inflight_requests: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct RemoteValue {
|
||||
pub(super) pattern: String,
|
||||
pub(super) base: f64,
|
||||
pub(super) step: f64,
|
||||
pub(super) cardinality: u64,
|
||||
pub(super) seed: u64,
|
||||
pub(super) run_length: u64,
|
||||
pub(super) stall_every: u64,
|
||||
pub(super) stall_length: u64,
|
||||
pub(super) mixed_every: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct OtlpTraceLoad {
|
||||
pub(super) load: OtlpLoad,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct OtlpLoad {
|
||||
pub(super) database: String,
|
||||
pub(super) table: String,
|
||||
pub(super) pipeline: String,
|
||||
pub(super) duration_seconds: u64,
|
||||
pub(super) warmup_seconds: u64,
|
||||
pub(super) rate: u64,
|
||||
pub(super) workers: usize,
|
||||
pub(super) exporter_shards: usize,
|
||||
pub(super) workload: String,
|
||||
pub(super) visibility_timeout_seconds: u64,
|
||||
pub(super) thresholds: OtlpThresholds,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct OtlpThresholds {
|
||||
pub(super) max_candidate_throughput_regression_pct: f64,
|
||||
pub(super) max_candidate_mean_latency_regression_pct: f64,
|
||||
pub(super) max_failure_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct StorageConfig {
|
||||
pub(super) inspect: bool,
|
||||
pub(super) column: String,
|
||||
pub(super) root_suffix: Option<String>,
|
||||
pub(super) include_metadata_files: bool,
|
||||
pub(super) min_files: u64,
|
||||
pub(super) min_files_with_column: u64,
|
||||
pub(super) require_encodings: Vec<String>,
|
||||
pub(super) forbid_encodings: Vec<String>,
|
||||
pub(super) max_total_file_size_bytes: Option<u64>,
|
||||
pub(super) max_column_compressed_size_bytes: Option<u64>,
|
||||
pub(super) max_column_uncompressed_size_bytes: Option<u64>,
|
||||
pub(super) max_candidate_total_file_size_regression_pct: Option<f64>,
|
||||
pub(super) max_candidate_column_compressed_size_regression_pct: Option<f64>,
|
||||
pub(super) max_candidate_column_uncompressed_size_regression_pct: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct ReadBenchConfig {
|
||||
pub(super) enabled: bool,
|
||||
pub(super) parquetbench: bool,
|
||||
pub(super) scanbench: bool,
|
||||
pub(super) iterations: u64,
|
||||
pub(super) projection: Vec<String>,
|
||||
pub(super) parquet_reader: String,
|
||||
pub(super) scan_scanner: String,
|
||||
pub(super) parallelism: u64,
|
||||
pub(super) max_files: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct Query {
|
||||
#[serde(default)]
|
||||
pub(super) name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) kind: Option<String>,
|
||||
pub(super) query: String,
|
||||
#[serde(default)]
|
||||
pub(super) warmup: usize,
|
||||
#[serde(default = "one")]
|
||||
pub(super) iterations: usize,
|
||||
#[serde(default)]
|
||||
pub(super) thresholds: Map<String, Value>,
|
||||
}
|
||||
|
||||
const fn one() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct QueryResult {
|
||||
pub(super) validation: Vec<Value>,
|
||||
pub(super) validation_errors: Vec<Value>,
|
||||
pub(super) measurements: Vec<Measurement>,
|
||||
pub(super) status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct Measurement {
|
||||
pub(super) name: Option<String>,
|
||||
pub(super) kind: Option<String>,
|
||||
pub(super) iterations: usize,
|
||||
pub(super) samples: Vec<Value>,
|
||||
pub(super) latency_ms_median: Option<f64>,
|
||||
pub(super) latency_ms_p95: Option<f64>,
|
||||
pub(super) status: String,
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::measure::round_ties_even;
|
||||
use crate::query_regression_runner::model::{OtlpLoad, OtlpThresholds};
|
||||
use crate::query_regression_runner::plan::normalized_otlp_load;
|
||||
use crate::query_regression_runner::sql::{
|
||||
extract_count_value, http_post_sql, sql_ident, sql_string, value_f64, value_u64,
|
||||
};
|
||||
use crate::query_regression_runner::{FinalizeOtlpArgs, Result, RunOtlpTargetArgs};
|
||||
|
||||
const OTLP_ROWS: &str = "greptime_frontend_otlp_traces_rows";
|
||||
const OTLP_FAILURES: &str = "greptime_frontend_otlp_traces_failure_count";
|
||||
const OTLP_ELAPSED_SUM: &str = "greptime_servers_http_otlp_traces_elapsed_sum";
|
||||
const OTLP_ELAPSED_COUNT: &str = "greptime_servers_http_otlp_traces_elapsed_count";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct OtlpSnapshot {
|
||||
captured_monotonic_seconds: f64,
|
||||
values: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
pub(super) async fn run_otlp_target(args: RunOtlpTargetArgs) -> Result<()> {
|
||||
if !args.http_timeout.is_finite() || args.http_timeout < 0.0 {
|
||||
return Err("--http-timeout must be a non-negative finite number".into());
|
||||
}
|
||||
let (_, load) = normalized_otlp_load(&args.fixture_generator, &args.case)?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs_f64(args.http_timeout))
|
||||
.build()?;
|
||||
let result = match run_otlp_target_inner(&args, &load, &client).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
json!({ "name": args.target_name, "status": "failed", "error": error.to_string() })
|
||||
}
|
||||
};
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&result)?);
|
||||
fs::write(&args.output, &text)?;
|
||||
print!("{text}");
|
||||
if result["status"] == "failed" {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_otlp_target_inner(
|
||||
args: &RunOtlpTargetArgs,
|
||||
load: &OtlpLoad,
|
||||
client: &Client,
|
||||
) -> Result<Value> {
|
||||
let create_database = http_post_sql(
|
||||
client,
|
||||
args.http_port,
|
||||
&format!(
|
||||
"CREATE DATABASE IF NOT EXISTS {}",
|
||||
sql_ident(&load.database)
|
||||
),
|
||||
"public",
|
||||
)
|
||||
.await;
|
||||
if !create_database["ok"].as_bool().unwrap_or(false) {
|
||||
return Ok(
|
||||
json!({ "name": args.target_name, "create_database": create_database, "status": "failed" }),
|
||||
);
|
||||
}
|
||||
let otelgen = run_otelgen_load(
|
||||
&args.otelgen_bin,
|
||||
args.http_port,
|
||||
&args.work_dir,
|
||||
load,
|
||||
client,
|
||||
)
|
||||
.await?;
|
||||
let metrics = summarize_otlp_metrics(&otelgen)?;
|
||||
let flush = http_post_sql(
|
||||
client,
|
||||
args.http_port,
|
||||
&format!("ADMIN FLUSH_TABLE({})", sql_string(&load.table)),
|
||||
&load.database,
|
||||
)
|
||||
.await;
|
||||
let visibility = poll_otlp_visibility(
|
||||
client,
|
||||
args.http_port,
|
||||
&load.table,
|
||||
&load.database,
|
||||
metrics["accepted_spans"].as_u64().unwrap_or_default(),
|
||||
load.visibility_timeout_seconds,
|
||||
)
|
||||
.await?;
|
||||
let checks_ok = otelgen["status"] == "ok"
|
||||
&& metrics["missing_metrics"]
|
||||
.as_array()
|
||||
.is_some_and(Vec::is_empty)
|
||||
&& metrics["accepted_spans"].as_u64().unwrap_or_default() > 0
|
||||
&& metrics["http_requests"].as_u64().unwrap_or_default() > 0
|
||||
&& flush["ok"].as_bool().unwrap_or(false)
|
||||
&& visibility["ok"].as_bool().unwrap_or(false)
|
||||
&& visibility["row_count_ok"].as_bool().unwrap_or(false);
|
||||
Ok(json!({
|
||||
"name": args.target_name,
|
||||
"otelgen": otelgen,
|
||||
"metrics": metrics,
|
||||
"create_database": create_database,
|
||||
"flush": flush,
|
||||
"visibility": visibility,
|
||||
"status": if checks_ok { "measured" } else { "failed" },
|
||||
}))
|
||||
}
|
||||
|
||||
fn otelgen_command(otelgen_bin: &Path, http_port: u16, load: &OtlpLoad) -> Vec<String> {
|
||||
vec![
|
||||
otelgen_bin.to_string_lossy().to_string(),
|
||||
"--protocol".to_string(),
|
||||
"http".to_string(),
|
||||
"--otel-exporter-otlp-endpoint".to_string(),
|
||||
format!("127.0.0.1:{http_port}"),
|
||||
"--otel-exporter-otlp-url-path".to_string(),
|
||||
"/v1/otlp/v1/traces".to_string(),
|
||||
"--header".to_string(),
|
||||
format!("x-greptime-pipeline-name={}", load.pipeline),
|
||||
"--header".to_string(),
|
||||
format!("x-greptime-db-name={}", load.database),
|
||||
"--header".to_string(),
|
||||
format!("x-greptime-trace-table-name={}", load.table),
|
||||
"--log-level".to_string(),
|
||||
"error".to_string(),
|
||||
"--insecure".to_string(),
|
||||
"--duration".to_string(),
|
||||
load.duration_seconds.to_string(),
|
||||
"--rate".to_string(),
|
||||
load.rate.to_string(),
|
||||
"traces".to_string(),
|
||||
"multi".to_string(),
|
||||
"--workers".to_string(),
|
||||
load.workers.to_string(),
|
||||
"--scenarios".to_string(),
|
||||
load.workload.clone(),
|
||||
"--exporter-shards".to_string(),
|
||||
load.exporter_shards.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
async fn run_otelgen_load(
|
||||
otelgen_bin: &Path,
|
||||
http_port: u16,
|
||||
work_dir: &Path,
|
||||
load: &OtlpLoad,
|
||||
client: &Client,
|
||||
) -> Result<Value> {
|
||||
let command = otelgen_command(otelgen_bin, http_port, load);
|
||||
let clock = Instant::now();
|
||||
let initial = fetch_otlp_metrics(client, http_port, &clock).await?;
|
||||
let log_dir = work_dir.join("otelgen");
|
||||
fs::create_dir_all(&log_dir)?;
|
||||
let stdout_path = log_dir.join("stdout.log");
|
||||
let stderr_path = log_dir.join("stderr.log");
|
||||
let mut child = Command::new(otelgen_bin)
|
||||
.args(&command[1..])
|
||||
.stdout(Stdio::from(fs::File::create(&stdout_path)?))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr_path)?))
|
||||
.spawn()?;
|
||||
let started = Instant::now();
|
||||
if load.warmup_seconds > 0 {
|
||||
let _ = wait_for_child(&mut child, Duration::from_secs(load.warmup_seconds)).await?;
|
||||
}
|
||||
let warmed = fetch_otlp_metrics(client, http_port, &clock).await?;
|
||||
let mut timed_out = false;
|
||||
if child.try_wait()?.is_none() {
|
||||
let remaining = load
|
||||
.duration_seconds
|
||||
.saturating_sub(load.warmup_seconds)
|
||||
.saturating_add(60)
|
||||
.max(60);
|
||||
if !wait_for_child(&mut child, Duration::from_secs(remaining)).await? {
|
||||
timed_out = true;
|
||||
child.kill()?;
|
||||
let _ = child.wait()?;
|
||||
}
|
||||
}
|
||||
let final_snapshot = fetch_otlp_metrics(client, http_port, &clock).await?;
|
||||
let returncode = match child.try_wait()? {
|
||||
Some(status) => status.code(),
|
||||
None => {
|
||||
child.kill()?;
|
||||
child.wait()?.code()
|
||||
}
|
||||
};
|
||||
let elapsed_seconds = started.elapsed().as_secs_f64();
|
||||
Ok(json!({
|
||||
"status": if returncode == Some(0) && !timed_out && elapsed_seconds + 1.0 >= load.duration_seconds as f64 { "ok" } else { "failed" },
|
||||
"cmd": command,
|
||||
"returncode": returncode,
|
||||
"timed_out": timed_out,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"stdout_path": stdout_path,
|
||||
"stderr_path": stderr_path,
|
||||
"snapshots": { "initial": initial, "warmup": warmed, "final": final_snapshot },
|
||||
}))
|
||||
}
|
||||
|
||||
async fn wait_for_child(child: &mut std::process::Child, timeout: Duration) -> Result<bool> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_otlp_metrics(
|
||||
client: &Client,
|
||||
http_port: u16,
|
||||
clock: &Instant,
|
||||
) -> Result<OtlpSnapshot> {
|
||||
let text = client
|
||||
.get(format!("http://127.0.0.1:{http_port}/metrics"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
Ok(OtlpSnapshot {
|
||||
captured_monotonic_seconds: clock.elapsed().as_secs_f64(),
|
||||
values: parse_prometheus_metrics(&text)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_prometheus_metrics(text: &str) -> Result<HashMap<String, f64>> {
|
||||
let sample = Regex::new(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(\S+)")?;
|
||||
let mut values = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let Some(captures) = sample.captures(line.trim()) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(name), Some(value)) = (captures.get(1), captures.get(2)) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.as_str();
|
||||
if !matches!(
|
||||
name,
|
||||
OTLP_ROWS | OTLP_FAILURES | OTLP_ELAPSED_SUM | OTLP_ELAPSED_COUNT
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let value: f64 = value.as_str().parse()?;
|
||||
if !value.is_finite() {
|
||||
return Err(format!("non-finite Prometheus sample for {name}").into());
|
||||
}
|
||||
*values.entry(name.to_string()).or_default() += value;
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn metric_delta(after: &OtlpSnapshot, before: &OtlpSnapshot, name: &str) -> Result<f64> {
|
||||
let delta = after.values.get(name).copied().unwrap_or_default()
|
||||
- before.values.get(name).copied().unwrap_or_default();
|
||||
if delta < 0.0 {
|
||||
return Err(format!("metric {name} decreased by {}", -delta).into());
|
||||
}
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
fn summarize_otlp_metrics(run: &Value) -> Result<Value> {
|
||||
let snapshots = run
|
||||
.get("snapshots")
|
||||
.ok_or("otelgen result has no snapshots")?;
|
||||
let initial: OtlpSnapshot = serde_json::from_value(snapshots["initial"].clone())?;
|
||||
let warmed: OtlpSnapshot = serde_json::from_value(snapshots["warmup"].clone())?;
|
||||
let final_snapshot: OtlpSnapshot = serde_json::from_value(snapshots["final"].clone())?;
|
||||
let missing_metrics = [OTLP_ROWS, OTLP_ELAPSED_SUM, OTLP_ELAPSED_COUNT]
|
||||
.into_iter()
|
||||
.filter(|name| !final_snapshot.values.contains_key(*name))
|
||||
.collect::<Vec<_>>();
|
||||
let accepted_spans =
|
||||
round_ties_even(metric_delta(&final_snapshot, &initial, OTLP_ROWS)?) as u64;
|
||||
let measurement_accepted_spans =
|
||||
round_ties_even(metric_delta(&final_snapshot, &warmed, OTLP_ROWS)?) as u64;
|
||||
let http_requests =
|
||||
round_ties_even(metric_delta(&final_snapshot, &warmed, OTLP_ELAPSED_COUNT)?) as u64;
|
||||
let latency_seconds = metric_delta(&final_snapshot, &warmed, OTLP_ELAPSED_SUM)?;
|
||||
let measurement_seconds =
|
||||
final_snapshot.captured_monotonic_seconds - warmed.captured_monotonic_seconds;
|
||||
Ok(json!({
|
||||
"accepted_spans": accepted_spans,
|
||||
"measurement_accepted_spans": measurement_accepted_spans,
|
||||
"accepted_spans_per_second": if measurement_seconds > 0.0 { Some(measurement_accepted_spans as f64 / measurement_seconds) } else { None },
|
||||
"http_requests": http_requests,
|
||||
"mean_http_latency_ms": if http_requests > 0 { Some(latency_seconds / http_requests as f64 * 1000.0) } else { None },
|
||||
"failure_count": round_ties_even(metric_delta(&final_snapshot, &initial, OTLP_FAILURES)?) as u64,
|
||||
"measurement_seconds": measurement_seconds,
|
||||
"missing_metrics": missing_metrics,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn poll_otlp_visibility(
|
||||
client: &Client,
|
||||
port: u16,
|
||||
table_name: &str,
|
||||
database: &str,
|
||||
expected_rows: u64,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<Value> {
|
||||
let sql = format!("SELECT count(*) FROM {}", sql_ident(table_name));
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
attempts += 1;
|
||||
let mut result = http_post_sql(client, port, &sql, database).await;
|
||||
let observed_rows = extract_count_value(&result);
|
||||
let row_count_ok =
|
||||
result["ok"].as_bool().unwrap_or(false) && observed_rows == Some(expected_rows);
|
||||
result
|
||||
.as_object_mut()
|
||||
.ok_or("count result must be an object")?
|
||||
.extend([
|
||||
("expected_rows".to_string(), json!(expected_rows)),
|
||||
("observed_rows".to_string(), json!(observed_rows)),
|
||||
("attempts".to_string(), json!(attempts)),
|
||||
("row_count_ok".to_string(), json!(row_count_ok)),
|
||||
]);
|
||||
if row_count_ok {
|
||||
return Ok(result);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
result["ok"] = json!(false);
|
||||
result["row_count_ok"] = json!(false);
|
||||
result["error"] = json!(format!(
|
||||
"expected {expected_rows} rows but observed {observed_rows:?} after {attempts} attempts"
|
||||
));
|
||||
return Ok(result);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_finalize_otlp(args: FinalizeOtlpArgs) -> Result<()> {
|
||||
let (case_path, load) = normalized_otlp_load(&args.fixture_generator, &args.case)?;
|
||||
let raw_case: toml::Value = toml::from_str(&fs::read_to_string(&case_path)?)?;
|
||||
let case_metadata = serde_json::to_value(
|
||||
raw_case
|
||||
.get("case")
|
||||
.cloned()
|
||||
.unwrap_or(toml::Value::Table(toml::map::Map::new())),
|
||||
)?;
|
||||
let base: Value = serde_json::from_slice(&fs::read(&args.base_result)?)?;
|
||||
let candidate: Value = serde_json::from_slice(&fs::read(&args.candidate_result)?)?;
|
||||
let thresholds = enforce_otlp_thresholds(
|
||||
&load.thresholds,
|
||||
base.get("metrics").unwrap_or(&Value::Null),
|
||||
candidate.get("metrics").unwrap_or(&Value::Null),
|
||||
);
|
||||
let failed = thresholds
|
||||
.iter()
|
||||
.any(|threshold| threshold["status"] == "failed")
|
||||
|| [base.get("status"), candidate.get("status")]
|
||||
.into_iter()
|
||||
.any(|status| status.and_then(Value::as_str) == Some("failed"));
|
||||
let report = json!({
|
||||
"case_path": case_path,
|
||||
"case": case_metadata,
|
||||
"scenario": { "kind": "otlp_trace_load", "load": load },
|
||||
"targets": [base, candidate],
|
||||
"thresholds": thresholds,
|
||||
"status": if failed { "failed" } else { "ok" },
|
||||
});
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&report)?);
|
||||
fs::write(&args.output, &text)?;
|
||||
print!("{text}");
|
||||
if failed {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enforce_otlp_thresholds(
|
||||
thresholds: &OtlpThresholds,
|
||||
base: &Value,
|
||||
candidate: &Value,
|
||||
) -> Vec<Value> {
|
||||
let mut results = Vec::new();
|
||||
for (target, metrics) in [("base", base), ("candidate", candidate)] {
|
||||
let failures = value_u64(metrics.get("failure_count"));
|
||||
results.push(json!({
|
||||
"target": target,
|
||||
"threshold": "max_failure_count",
|
||||
"status": if failures.is_some_and(|failures| failures <= thresholds.max_failure_count) { "passed" } else { "failed" },
|
||||
"actual": failures,
|
||||
"limit": thresholds.max_failure_count,
|
||||
}));
|
||||
}
|
||||
for (name, base_value, candidate_value, limit, inverse) in [
|
||||
(
|
||||
"max_candidate_throughput_regression_pct",
|
||||
value_f64(base.get("accepted_spans_per_second")),
|
||||
value_f64(candidate.get("accepted_spans_per_second")),
|
||||
thresholds.max_candidate_throughput_regression_pct,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"max_candidate_mean_latency_regression_pct",
|
||||
value_f64(base.get("mean_http_latency_ms")),
|
||||
value_f64(candidate.get("mean_http_latency_ms")),
|
||||
thresholds.max_candidate_mean_latency_regression_pct,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let (Some(base_value), Some(candidate_value)) =
|
||||
(base_value.filter(|value| *value != 0.0), candidate_value)
|
||||
else {
|
||||
results.push(json!({
|
||||
"threshold": name,
|
||||
"status": "failed",
|
||||
"reason": if inverse { "missing or zero throughput" } else { "missing or zero mean latency" },
|
||||
"base": base_value,
|
||||
"candidate": candidate_value,
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
let actual = if inverse {
|
||||
(base_value - candidate_value) / base_value * 100.0
|
||||
} else {
|
||||
(candidate_value - base_value) / base_value * 100.0
|
||||
};
|
||||
results.push(json!({
|
||||
"threshold": name,
|
||||
"status": if actual <= limit { "passed" } else { "failed" },
|
||||
"actual_pct": actual,
|
||||
"limit_pct": limit,
|
||||
"base": base_value,
|
||||
"candidate": candidate_value,
|
||||
}));
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn otlp_load_for_test() -> OtlpLoad {
|
||||
serde_json::from_value(json!({
|
||||
"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": 30,
|
||||
"thresholds": {
|
||||
"max_candidate_throughput_regression_pct": 20.0,
|
||||
"max_candidate_mean_latency_regression_pct": 20.0,
|
||||
"max_failure_count": 0
|
||||
}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_labeled_otlp_metrics_and_rejects_non_finite_samples() {
|
||||
let metrics = parse_prometheus_metrics(
|
||||
"greptime_frontend_otlp_traces_rows 10\n\
|
||||
greptime_frontend_otlp_traces_failure_count{kind=\"a\"} 1\n\
|
||||
greptime_frontend_otlp_traces_failure_count{kind=\"b\"} 2\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(metrics[OTLP_ROWS], 10.0);
|
||||
assert_eq!(metrics[OTLP_FAILURES], 3.0);
|
||||
assert!(parse_prometheus_metrics("greptime_frontend_otlp_traces_rows NaN").is_err());
|
||||
let before = OtlpSnapshot {
|
||||
captured_monotonic_seconds: 0.0,
|
||||
values: HashMap::from([(OTLP_ROWS.to_string(), 2.0)]),
|
||||
};
|
||||
let after = OtlpSnapshot {
|
||||
captured_monotonic_seconds: 1.0,
|
||||
values: HashMap::from([(OTLP_ROWS.to_string(), 1.0)]),
|
||||
};
|
||||
assert!(metric_delta(&after, &before, OTLP_ROWS).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_otlp_deltas_and_builds_exact_command() {
|
||||
let snapshot = |captured: f64, values: Vec<(String, f64)>| OtlpSnapshot {
|
||||
captured_monotonic_seconds: captured,
|
||||
values: values.into_iter().collect(),
|
||||
};
|
||||
let initial = snapshot(0.0, vec![(OTLP_ROWS.to_string(), 10.0)]);
|
||||
let warmed = snapshot(
|
||||
5.0,
|
||||
vec![
|
||||
(OTLP_ROWS.to_string(), 110.0),
|
||||
(OTLP_ELAPSED_SUM.to_string(), 1.0),
|
||||
(OTLP_ELAPSED_COUNT.to_string(), 10.0),
|
||||
],
|
||||
);
|
||||
let final_snapshot = snapshot(
|
||||
15.0,
|
||||
vec![
|
||||
(OTLP_ROWS.to_string(), 310.0),
|
||||
(OTLP_FAILURES.to_string(), 3.0),
|
||||
(OTLP_ELAPSED_SUM.to_string(), 3.0),
|
||||
(OTLP_ELAPSED_COUNT.to_string(), 30.0),
|
||||
],
|
||||
);
|
||||
let metrics = summarize_otlp_metrics(&json!({
|
||||
"snapshots": { "initial": initial, "warmup": warmed, "final": final_snapshot }
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(metrics["accepted_spans"], 300);
|
||||
assert_eq!(metrics["measurement_accepted_spans"], 200);
|
||||
assert_eq!(metrics["accepted_spans_per_second"], 20.0);
|
||||
assert_eq!(metrics["http_requests"], 20);
|
||||
assert_eq!(metrics["mean_http_latency_ms"], 100.0);
|
||||
assert_eq!(metrics["failure_count"], 3);
|
||||
let missing = summarize_otlp_metrics(&json!({
|
||||
"snapshots": {
|
||||
"initial": snapshot(0.0, vec![(OTLP_ROWS.to_string(), 0.0)]),
|
||||
"warmup": snapshot(1.0, vec![(OTLP_ROWS.to_string(), 1.0)]),
|
||||
"final": snapshot(2.0, vec![(OTLP_ROWS.to_string(), 2.0)])
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
missing["missing_metrics"],
|
||||
json!([OTLP_ELAPSED_SUM, OTLP_ELAPSED_COUNT])
|
||||
);
|
||||
let command = otelgen_command(Path::new("/bin/otelgen"), 4000, &otlp_load_for_test());
|
||||
assert_eq!(
|
||||
command,
|
||||
vec![
|
||||
"/bin/otelgen",
|
||||
"--protocol",
|
||||
"http",
|
||||
"--otel-exporter-otlp-endpoint",
|
||||
"127.0.0.1:4000",
|
||||
"--otel-exporter-otlp-url-path",
|
||||
"/v1/otlp/v1/traces",
|
||||
"--header",
|
||||
"x-greptime-pipeline-name=greptime_trace_v1",
|
||||
"--header",
|
||||
"x-greptime-db-name=public",
|
||||
"--header",
|
||||
"x-greptime-trace-table-name=opentelemetry_traces",
|
||||
"--log-level",
|
||||
"error",
|
||||
"--insecure",
|
||||
"--duration",
|
||||
"120",
|
||||
"--rate",
|
||||
"50000",
|
||||
"traces",
|
||||
"multi",
|
||||
"--workers",
|
||||
"4",
|
||||
"--scenarios",
|
||||
"microservices",
|
||||
"--exporter-shards",
|
||||
"4",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otlp_thresholds_handle_zero_metrics_and_pass_fail_cases() {
|
||||
let load = otlp_load_for_test();
|
||||
let base = json!({ "failure_count": 0, "accepted_spans_per_second": 20.0, "mean_http_latency_ms": 100.0 });
|
||||
let candidate = json!({ "failure_count": 0, "accepted_spans_per_second": 18.0, "mean_http_latency_ms": 110.0 });
|
||||
let results = enforce_otlp_thresholds(&load.thresholds, &base, &candidate);
|
||||
assert!(results.iter().all(|result| result["status"] == "passed"));
|
||||
let failed = enforce_otlp_thresholds(
|
||||
&load.thresholds,
|
||||
&json!({ "failure_count": 0, "accepted_spans_per_second": 0.0, "mean_http_latency_ms": 100.0 }),
|
||||
&json!({ "failure_count": 3, "accepted_spans_per_second": 20.0, "mean_http_latency_ms": 130.0 }),
|
||||
);
|
||||
assert!(
|
||||
failed
|
||||
.iter()
|
||||
.any(|result| result["threshold"] == "max_failure_count"
|
||||
&& result["target"] == "candidate"
|
||||
&& result["status"] == "failed")
|
||||
);
|
||||
assert!(failed.iter().any(|result| result["threshold"]
|
||||
== "max_candidate_throughput_regression_pct"
|
||||
&& result["reason"] == "missing or zero throughput"));
|
||||
assert!(failed.iter().any(|result| result["threshold"]
|
||||
== "max_candidate_mean_latency_regression_pct"
|
||||
&& result["status"] == "failed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::query_regression_runner::Result;
|
||||
use crate::query_regression_runner::model::{
|
||||
Layout, OtlpLoad, Query, RemoteWrite, Scenario, Table,
|
||||
};
|
||||
|
||||
pub(super) fn load_plan(generator: &PathBuf, case_path: &PathBuf) -> Result<Value> {
|
||||
let output = Command::new(generator)
|
||||
.args(["plan", "--case"])
|
||||
.arg(case_path)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("query_perf_fixture plan failed: {:.2000}", stderr).into());
|
||||
}
|
||||
Ok(serde_json::from_slice(&output.stdout)?)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_scenario(scenario: Scenario) -> Result<(Vec<Table>, Vec<Query>)> {
|
||||
match scenario {
|
||||
Scenario::DirectReadableSst {
|
||||
tables,
|
||||
layout,
|
||||
queries,
|
||||
} => Ok((validate_direct_tables(tables, layout)?, queries)),
|
||||
Scenario::PromRemoteWriteThenQuery {
|
||||
remote_write,
|
||||
queries,
|
||||
} => Ok((
|
||||
vec![Table {
|
||||
database: remote_write.database,
|
||||
name: remote_write.metric,
|
||||
engine: "metric".to_string(),
|
||||
columns: vec![],
|
||||
primary_key: vec![],
|
||||
time_index: None,
|
||||
append_mode: None,
|
||||
sst_format: None,
|
||||
validate_show_create_engine: false,
|
||||
}],
|
||||
queries,
|
||||
)),
|
||||
Scenario::OtlpTraceLoad { .. } => {
|
||||
Err("measure requires a query scenario, not otlp_trace_load".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_direct_tables(tables: Vec<Table>, layout: Layout) -> Result<Vec<Table>> {
|
||||
if tables.is_empty() || layout.regions != 1 {
|
||||
return Err("runner supports one or more tables and exactly one region per table".into());
|
||||
}
|
||||
let mut pairs = HashSet::new();
|
||||
let mut names = HashSet::new();
|
||||
for table in &tables {
|
||||
if !pairs.insert((&table.database, &table.name)) {
|
||||
return Err("duplicate (database, name) table entries are not supported".into());
|
||||
}
|
||||
if !names.insert(&table.name) {
|
||||
return Err("duplicate table names are not supported".into());
|
||||
}
|
||||
}
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
pub(super) fn normalized_remote_write(
|
||||
generator: &PathBuf,
|
||||
case: &Path,
|
||||
) -> Result<(PathBuf, RemoteWrite)> {
|
||||
let case_path = case.canonicalize()?;
|
||||
let plan = load_plan(generator, &case_path)?;
|
||||
let scenario = plan
|
||||
.get("scenario")
|
||||
.cloned()
|
||||
.ok_or("fixture plan has no scenario")?;
|
||||
match serde_json::from_value(scenario)? {
|
||||
Scenario::PromRemoteWriteThenQuery { remote_write, .. } => Ok((case_path, remote_write)),
|
||||
Scenario::DirectReadableSst { .. } => {
|
||||
Err("remote command requires scenario kind prom_remote_write_then_query".into())
|
||||
}
|
||||
Scenario::OtlpTraceLoad { .. } => {
|
||||
Err("remote command requires scenario kind prom_remote_write_then_query".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalized_otlp_load(
|
||||
generator: &PathBuf,
|
||||
case: &Path,
|
||||
) -> Result<(PathBuf, OtlpLoad)> {
|
||||
let case_path = case.canonicalize()?;
|
||||
let plan = load_plan(generator, &case_path)?;
|
||||
let scenario = plan
|
||||
.get("scenario")
|
||||
.cloned()
|
||||
.ok_or("fixture plan has no scenario")?;
|
||||
match serde_json::from_value(scenario)? {
|
||||
Scenario::OtlpTraceLoad { load } => Ok((case_path, load.load)),
|
||||
Scenario::DirectReadableSst { .. } | Scenario::PromRemoteWriteThenQuery { .. } => {
|
||||
Err("OTLP command requires scenario kind otlp_trace_load".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// 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.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::model::{PromStore, RemoteWrite};
|
||||
use crate::query_regression_runner::plan::normalized_remote_write;
|
||||
use crate::query_regression_runner::sql::{
|
||||
extract_count_value, http_post_sql, sql_ident, sql_string, value_f64, value_u64,
|
||||
};
|
||||
use crate::query_regression_runner::{PrepareRemoteArgs, RenderRemoteConfigArgs, Result};
|
||||
|
||||
pub(super) async fn run_render_remote_config(args: RenderRemoteConfigArgs) -> Result<()> {
|
||||
let (_, remote) = normalized_remote_write(&args.fixture_generator, &args.case)?;
|
||||
fs::write(args.output, frontend_prom_config(&remote.prom_store)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frontend_prom_config(prom: &PromStore) -> Result<String> {
|
||||
Ok(format!(
|
||||
"[prom_store]\nenable = true\nwith_metric_engine = true\npending_rows_flush_interval = {}\nmax_batch_rows = {}\nmax_concurrent_flushes = {}\nworker_channel_capacity = {}\nmax_inflight_requests = {}\n",
|
||||
serde_json::to_string(&prom.pending_rows_flush_interval)?,
|
||||
prom.max_batch_rows,
|
||||
prom.max_concurrent_flushes,
|
||||
prom.worker_channel_capacity,
|
||||
prom.max_inflight_requests,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn run_prepare_remote(args: PrepareRemoteArgs) -> Result<()> {
|
||||
if !args.http_timeout.is_finite() || args.http_timeout < 0.0 {
|
||||
return Err("--http-timeout must be a non-negative finite number".into());
|
||||
}
|
||||
let (case_path, remote) = normalized_remote_write(&args.fixture_generator, &args.case)?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs_f64(args.http_timeout))
|
||||
.build()?;
|
||||
let base = prepare_remote_target(
|
||||
"base",
|
||||
args.base_http_port,
|
||||
&args.fixture_generator,
|
||||
&remote,
|
||||
&client,
|
||||
)
|
||||
.await?;
|
||||
let candidate = prepare_remote_target(
|
||||
"candidate",
|
||||
args.candidate_http_port,
|
||||
&args.fixture_generator,
|
||||
&remote,
|
||||
&client,
|
||||
)
|
||||
.await?;
|
||||
let report = json!({
|
||||
"case_path": case_path,
|
||||
"scenario": "prom_remote_write_then_query",
|
||||
"base": base,
|
||||
"candidate": candidate,
|
||||
"status": "ok",
|
||||
});
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&report)?);
|
||||
if let Some(path) = args.output {
|
||||
fs::write(path, &text)?;
|
||||
}
|
||||
print!("{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_remote_target(
|
||||
name: &str,
|
||||
port: u16,
|
||||
generator: &Path,
|
||||
remote: &RemoteWrite,
|
||||
client: &Client,
|
||||
) -> Result<Value> {
|
||||
let create_database = http_post_sql(
|
||||
client,
|
||||
port,
|
||||
&format!(
|
||||
"CREATE DATABASE IF NOT EXISTS {}",
|
||||
sql_ident(&remote.database)
|
||||
),
|
||||
"public",
|
||||
)
|
||||
.await;
|
||||
if !create_database["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"CREATE DATABASE {} failed for {name}: {create_database}",
|
||||
remote.database
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let (remote_write, flushes) = ingest_remote_write(generator, port, remote, client).await?;
|
||||
let expected_rows = remote
|
||||
.series_count
|
||||
.checked_mul(remote.samples_per_series)
|
||||
.ok_or("expected remote-write row count overflows u64")?;
|
||||
let visibility = poll_expected_count(
|
||||
client,
|
||||
port,
|
||||
&remote.metric,
|
||||
&remote.database,
|
||||
expected_rows,
|
||||
remote.visibility_timeout_seconds,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"name": name,
|
||||
"create_database": create_database,
|
||||
"remote_write": remote_write,
|
||||
"flushes": flushes,
|
||||
"visibility": visibility,
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct SampleChunk {
|
||||
index: u64,
|
||||
offset: u64,
|
||||
samples_per_series: u64,
|
||||
start_unix_millis: i64,
|
||||
}
|
||||
|
||||
async fn ingest_remote_write(
|
||||
generator: &Path,
|
||||
port: u16,
|
||||
remote: &RemoteWrite,
|
||||
client: &Client,
|
||||
) -> Result<(Value, Vec<Value>)> {
|
||||
let Some(chunks) = sample_chunks(remote)? else {
|
||||
let remote_write = run_remote_write(generator, port, remote, None)?;
|
||||
let flush = flush_remote_table(client, port, remote, "final", None).await?;
|
||||
return Ok((remote_write, vec![flush]));
|
||||
};
|
||||
let mut results = Vec::with_capacity(chunks.len());
|
||||
let mut flushes = Vec::new();
|
||||
let scheduled_flushes = scheduled_flushes(&chunks, remote.flush_every_sample_chunks);
|
||||
for chunk in &chunks {
|
||||
let mut result = run_remote_write(generator, port, remote, Some(chunk))?;
|
||||
result
|
||||
.as_object_mut()
|
||||
.ok_or("remote-write result must be an object")?
|
||||
.extend([
|
||||
("sample_offset".to_string(), json!(chunk.offset)),
|
||||
(
|
||||
"samples_per_series".to_string(),
|
||||
json!(chunk.samples_per_series),
|
||||
),
|
||||
("chunk_index".to_string(), json!(chunk.index)),
|
||||
]);
|
||||
results.push(result);
|
||||
if let Some((_, reason)) = scheduled_flushes
|
||||
.iter()
|
||||
.find(|(index, _)| *index == chunk.index)
|
||||
{
|
||||
flushes
|
||||
.push(flush_remote_table(client, port, remote, reason, Some(chunk.index)).await?);
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
json!({
|
||||
"status": "ok",
|
||||
"mode": "sample-chunked",
|
||||
"sample_chunk_size": remote.sample_chunk_size,
|
||||
"flush_every_sample_chunks": remote.flush_every_sample_chunks,
|
||||
"chunks": results,
|
||||
"aggregate": summarize_remote_chunks(&results),
|
||||
}),
|
||||
flushes,
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_chunks(remote: &RemoteWrite) -> Result<Option<Vec<SampleChunk>>> {
|
||||
let Some(chunk_samples) = remote.sample_chunk_size else {
|
||||
return Ok(None);
|
||||
};
|
||||
if chunk_samples == 0 {
|
||||
return Err("scenario.remote_write.sample_chunk_size must be positive".into());
|
||||
}
|
||||
if remote.flush_every_sample_chunks == 0 {
|
||||
return Err("scenario.remote_write.flush_every_sample_chunks must be positive".into());
|
||||
}
|
||||
let mut chunks = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < remote.samples_per_series {
|
||||
let samples_per_series = chunk_samples.min(remote.samples_per_series - offset);
|
||||
chunks.push(SampleChunk {
|
||||
index: chunks.len() as u64 + 1,
|
||||
offset,
|
||||
samples_per_series,
|
||||
start_unix_millis: remote.start_unix_millis + offset as i64 * remote.step_millis,
|
||||
});
|
||||
offset += samples_per_series;
|
||||
}
|
||||
Ok(Some(chunks))
|
||||
}
|
||||
|
||||
fn scheduled_flushes(chunks: &[SampleChunk], flush_every: u64) -> Vec<(u64, &'static str)> {
|
||||
let mut scheduled = chunks
|
||||
.iter()
|
||||
.filter(|chunk| chunk.index % flush_every == 0)
|
||||
.map(|chunk| (chunk.index, "periodic"))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(chunk) = chunks.last()
|
||||
&& chunk.index % flush_every != 0
|
||||
{
|
||||
scheduled.push((chunk.index, "final"));
|
||||
}
|
||||
scheduled
|
||||
}
|
||||
|
||||
fn summarize_remote_chunks(chunks: &[Value]) -> Value {
|
||||
let mut rows = 0;
|
||||
let mut samples_written = 0;
|
||||
let mut batches = 0;
|
||||
let mut elapsed_seconds = 0.0;
|
||||
for chunk in chunks {
|
||||
let summary = chunk.get("summary").unwrap_or(&Value::Null);
|
||||
let chunk_rows = value_u64(summary.get("rows")).unwrap_or(0);
|
||||
rows += chunk_rows;
|
||||
samples_written += value_u64(summary.get("samples_written")).unwrap_or(chunk_rows);
|
||||
batches += value_u64(summary.get("batches")).unwrap_or(0);
|
||||
elapsed_seconds += value_f64(summary.get("elapsed_seconds"))
|
||||
.or_else(|| value_f64(chunk.get("elapsed_seconds")))
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
json!({ "rows": rows, "samples_written": samples_written, "batches": batches, "elapsed_seconds": elapsed_seconds })
|
||||
}
|
||||
|
||||
fn run_remote_write(
|
||||
generator: &Path,
|
||||
port: u16,
|
||||
remote: &RemoteWrite,
|
||||
chunk: Option<&SampleChunk>,
|
||||
) -> Result<Value> {
|
||||
let command = remote_write_command(generator, port, remote, chunk);
|
||||
let started = Instant::now();
|
||||
let output = Command::new(generator).args(&command[1..]).output()?;
|
||||
let elapsed_seconds = started.elapsed().as_secs_f64();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"remote-write generator failed: command={command:?}, returncode={:?}, elapsed_seconds={elapsed_seconds:.3}, stderr={:.2000}",
|
||||
output.status.code(), stderr
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut result = json!({
|
||||
"status": "ok",
|
||||
"command": command,
|
||||
"returncode": output.status.code(),
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
});
|
||||
match serde_json::from_str(result["stdout"].as_str().unwrap_or_default()) {
|
||||
Ok(summary) => result["summary"] = summary,
|
||||
Err(_) => result["summary_parse_error"] = result["stdout"].clone(),
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn remote_write_command(
|
||||
generator: &Path,
|
||||
port: u16,
|
||||
remote: &RemoteWrite,
|
||||
chunk: Option<&SampleChunk>,
|
||||
) -> Vec<String> {
|
||||
let (samples_per_series, start_unix_millis, sample_offset, total_samples_per_series) = chunk
|
||||
.map(|chunk| {
|
||||
(
|
||||
chunk.samples_per_series,
|
||||
chunk.start_unix_millis,
|
||||
Some(chunk.offset),
|
||||
Some(remote.samples_per_series),
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
remote.samples_per_series,
|
||||
remote.start_unix_millis,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let mut command = vec![
|
||||
generator.to_string_lossy().to_string(),
|
||||
"prom-remote-write".to_string(),
|
||||
"--endpoint".to_string(),
|
||||
format!("http://127.0.0.1:{port}/v1/prometheus/write"),
|
||||
"--database".to_string(),
|
||||
remote.database.clone(),
|
||||
"--metric".to_string(),
|
||||
remote.metric.clone(),
|
||||
"--physical-table".to_string(),
|
||||
remote.physical_table.clone(),
|
||||
"--series-count".to_string(),
|
||||
remote.series_count.to_string(),
|
||||
"--samples-per-series".to_string(),
|
||||
samples_per_series.to_string(),
|
||||
"--start-unix-millis".to_string(),
|
||||
start_unix_millis.to_string(),
|
||||
"--step-millis".to_string(),
|
||||
remote.step_millis.to_string(),
|
||||
"--chunk-series-count".to_string(),
|
||||
remote.chunk_series_count.to_string(),
|
||||
"--timeout-seconds".to_string(),
|
||||
remote.timeout_seconds.to_string(),
|
||||
"--value-pattern".to_string(),
|
||||
remote.value.pattern.clone(),
|
||||
"--value-base".to_string(),
|
||||
json_number(remote.value.base),
|
||||
"--value-step".to_string(),
|
||||
json_number(remote.value.step),
|
||||
"--value-cardinality".to_string(),
|
||||
remote.value.cardinality.to_string(),
|
||||
"--value-seed".to_string(),
|
||||
remote.value.seed.to_string(),
|
||||
"--value-run-length".to_string(),
|
||||
remote.value.run_length.to_string(),
|
||||
"--value-stall-every".to_string(),
|
||||
remote.value.stall_every.to_string(),
|
||||
"--value-stall-length".to_string(),
|
||||
remote.value.stall_length.to_string(),
|
||||
"--value-mixed-every".to_string(),
|
||||
remote.value.mixed_every.to_string(),
|
||||
];
|
||||
if let Some(sample_offset) = sample_offset {
|
||||
command.extend([
|
||||
"--value-sample-offset".to_string(),
|
||||
sample_offset.to_string(),
|
||||
]);
|
||||
}
|
||||
if let Some(total_samples_per_series) = total_samples_per_series {
|
||||
command.extend([
|
||||
"--value-total-samples-per-series".to_string(),
|
||||
total_samples_per_series.to_string(),
|
||||
]);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn flush_remote_table(
|
||||
client: &Client,
|
||||
port: u16,
|
||||
remote: &RemoteWrite,
|
||||
reason: &str,
|
||||
chunk_index: Option<u64>,
|
||||
) -> Result<Value> {
|
||||
let mut result = http_post_sql(
|
||||
client,
|
||||
port,
|
||||
&format!("ADMIN FLUSH_TABLE({})", sql_string(&remote.physical_table)),
|
||||
&remote.database,
|
||||
)
|
||||
.await;
|
||||
if !result["ok"].as_bool().unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"ADMIN FLUSH_TABLE {} failed: {result}",
|
||||
remote.physical_table
|
||||
)
|
||||
.into());
|
||||
}
|
||||
result
|
||||
.as_object_mut()
|
||||
.ok_or("flush result must be an object")?
|
||||
.extend([
|
||||
("physical_table".to_string(), json!(remote.physical_table)),
|
||||
("reason".to_string(), json!(reason)),
|
||||
("chunk_index".to_string(), json!(chunk_index)),
|
||||
]);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn poll_expected_count(
|
||||
client: &Client,
|
||||
port: u16,
|
||||
table_name: &str,
|
||||
database: &str,
|
||||
expected_rows: u64,
|
||||
visibility_timeout_seconds: u64,
|
||||
) -> Result<Value> {
|
||||
let sql = format!("SELECT count(*) FROM {}", sql_ident(table_name));
|
||||
let deadline = Instant::now() + Duration::from_secs(visibility_timeout_seconds);
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
attempts += 1;
|
||||
let mut result = http_post_sql(client, port, &sql, database).await;
|
||||
let observed_rows = extract_count_value(&result);
|
||||
let row_count_ok =
|
||||
result["ok"].as_bool().unwrap_or(false) && observed_rows == Some(expected_rows);
|
||||
result
|
||||
.as_object_mut()
|
||||
.ok_or("count result must be an object")?
|
||||
.extend([
|
||||
("expected_rows".to_string(), json!(expected_rows)),
|
||||
("observed_rows".to_string(), json!(observed_rows)),
|
||||
("attempts".to_string(), json!(attempts)),
|
||||
("row_count_ok".to_string(), json!(row_count_ok)),
|
||||
]);
|
||||
if row_count_ok {
|
||||
return Ok(result);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"expected {expected_rows} rows but observed {observed_rows:?} after {attempts} attempts"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn json_number(value: f64) -> String {
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::query_regression_runner::model::{PromStore, RemoteValue};
|
||||
|
||||
#[test]
|
||||
fn schedules_remote_sample_chunks_and_flushes() {
|
||||
let remote = RemoteWrite {
|
||||
database: "public".to_string(),
|
||||
metric: "metric".to_string(),
|
||||
physical_table: "physical".to_string(),
|
||||
series_count: 2,
|
||||
samples_per_series: 5,
|
||||
start_unix_millis: 100,
|
||||
step_millis: 10,
|
||||
chunk_series_count: 1,
|
||||
timeout_seconds: 60,
|
||||
sample_chunk_size: Some(2),
|
||||
flush_every_sample_chunks: 2,
|
||||
visibility_timeout_seconds: 30,
|
||||
prom_store: PromStore {
|
||||
pending_rows_flush_interval: "1s".to_string(),
|
||||
max_batch_rows: 1,
|
||||
max_concurrent_flushes: 1,
|
||||
worker_channel_capacity: 1,
|
||||
max_inflight_requests: 1,
|
||||
},
|
||||
value: RemoteValue {
|
||||
pattern: "linear".to_string(),
|
||||
base: 0.0,
|
||||
step: 1.0,
|
||||
cardinality: 1,
|
||||
seed: 0,
|
||||
run_length: 1,
|
||||
stall_every: 0,
|
||||
stall_length: 0,
|
||||
mixed_every: 0,
|
||||
},
|
||||
storage: None,
|
||||
read_bench: None,
|
||||
};
|
||||
let chunks = sample_chunks(&remote).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
chunks,
|
||||
vec![
|
||||
SampleChunk {
|
||||
index: 1,
|
||||
offset: 0,
|
||||
samples_per_series: 2,
|
||||
start_unix_millis: 100,
|
||||
},
|
||||
SampleChunk {
|
||||
index: 2,
|
||||
offset: 2,
|
||||
samples_per_series: 2,
|
||||
start_unix_millis: 120,
|
||||
},
|
||||
SampleChunk {
|
||||
index: 3,
|
||||
offset: 4,
|
||||
samples_per_series: 1,
|
||||
start_unix_millis: 140,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
scheduled_flushes(&chunks, remote.flush_every_sample_chunks),
|
||||
vec![(2, "periodic"), (3, "final")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use self::read_bench::run_read_bench;
|
||||
use self::storage::{enforce_storage_thresholds, run_storage_inspection};
|
||||
use crate::query_regression_runner::model::DestinationConfig;
|
||||
use crate::query_regression_runner::plan::normalized_remote_write;
|
||||
use crate::query_regression_runner::{
|
||||
FinalizeRemoteArgs, PrepareRemoteArgs, RenderRemoteConfigArgs, Result,
|
||||
};
|
||||
|
||||
mod ingest;
|
||||
mod read_bench;
|
||||
mod storage;
|
||||
|
||||
pub(super) async fn run_render_remote_config(args: RenderRemoteConfigArgs) -> Result<()> {
|
||||
ingest::run_render_remote_config(args).await
|
||||
}
|
||||
|
||||
pub(super) async fn run_prepare_remote(args: PrepareRemoteArgs) -> Result<()> {
|
||||
ingest::run_prepare_remote(args).await
|
||||
}
|
||||
|
||||
pub(super) async fn run_finalize_remote(args: FinalizeRemoteArgs) -> Result<()> {
|
||||
let (_, remote) = normalized_remote_write(&args.fixture_generator, &args.case)?;
|
||||
let storage = remote.storage.as_ref().filter(|storage| storage.inspect);
|
||||
let mut report: Value = serde_json::from_slice(&fs::read(&args.report)?)?;
|
||||
let bench_root = args.report.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut inspections = Vec::new();
|
||||
let target_failed = {
|
||||
let targets = report
|
||||
.get_mut("targets")
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("report has no targets array")?;
|
||||
for (name, data_home, destination) in [
|
||||
(
|
||||
"base",
|
||||
args.base_data_home.as_deref(),
|
||||
args.base_destination.as_deref(),
|
||||
),
|
||||
(
|
||||
"candidate",
|
||||
args.candidate_data_home.as_deref(),
|
||||
args.candidate_destination.as_deref(),
|
||||
),
|
||||
] {
|
||||
let (data_home, destination) = match (data_home, destination) {
|
||||
(Some(data_home), None) => (data_home.to_path_buf(), None),
|
||||
(None, Some(path)) => {
|
||||
let destination: DestinationConfig =
|
||||
toml::from_str(&fs::read_to_string(path)?)?;
|
||||
(
|
||||
PathBuf::from(destination.data_home),
|
||||
Some(path.to_path_buf()),
|
||||
)
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(format!(
|
||||
"{name}: --{name}-data-home and --{name}-destination are mutually exclusive"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(format!(
|
||||
"{name}: one of --{name}-data-home or --{name}-destination is required"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let target = targets
|
||||
.iter_mut()
|
||||
.find(|target| target.get("name").and_then(Value::as_str) == Some(name))
|
||||
.ok_or_else(|| format!("report has no {name} target"))?;
|
||||
let Some(storage) = storage else {
|
||||
target
|
||||
.as_object_mut()
|
||||
.ok_or("report target must be an object")?
|
||||
.insert(
|
||||
"read_bench".to_string(),
|
||||
json!({"status": "skipped", "reason": "storage inspection disabled"}),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let inspection = run_storage_inspection(
|
||||
&args.fixture_generator,
|
||||
&data_home,
|
||||
destination.as_deref(),
|
||||
storage,
|
||||
)?;
|
||||
let bench_dir = bench_root.join(name).join("read_bench");
|
||||
let read_bench = run_read_bench(
|
||||
&args.candidate_bin,
|
||||
&data_home,
|
||||
&bench_dir,
|
||||
remote.read_bench.as_ref(),
|
||||
&inspection,
|
||||
)?;
|
||||
let inspection_failed = inspection["status"] == "failed";
|
||||
let bench_failed = read_bench["status"] == "failed";
|
||||
let target = target
|
||||
.as_object_mut()
|
||||
.ok_or("report target must be an object")?;
|
||||
target.insert("storage_inspection".to_string(), inspection.clone());
|
||||
target.insert("read_bench".to_string(), read_bench);
|
||||
if inspection_failed || bench_failed {
|
||||
target.insert("status".to_string(), json!("failed"));
|
||||
}
|
||||
inspections.push(inspection);
|
||||
}
|
||||
targets.iter().any(|target| {
|
||||
target["status"] == "failed"
|
||||
|| target["storage_inspection"]["status"] == "failed"
|
||||
|| target["read_bench"]["status"] == "failed"
|
||||
})
|
||||
};
|
||||
let storage_thresholds = storage
|
||||
.map(|storage| {
|
||||
enforce_storage_thresholds(
|
||||
storage,
|
||||
inspections.first().unwrap_or(&Value::Null),
|
||||
inspections.get(1).unwrap_or(&Value::Null),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let threshold_failed = {
|
||||
let thresholds = report
|
||||
.get_mut("thresholds")
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("report has no thresholds array")?;
|
||||
thresholds.retain(|threshold| !is_storage_threshold_entry(threshold));
|
||||
thresholds.extend(storage_thresholds);
|
||||
thresholds
|
||||
.iter()
|
||||
.any(|threshold| threshold["status"] == "failed")
|
||||
};
|
||||
let failed = target_failed || threshold_failed;
|
||||
report["status"] = json!(if failed { "failed" } else { "ok" });
|
||||
let text = format!("{}\n", serde_json::to_string_pretty(&report)?);
|
||||
fs::write(&args.report, &text)?;
|
||||
print!("{text}");
|
||||
if failed {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_storage_threshold_entry(threshold: &Value) -> bool {
|
||||
let Some(name) = threshold.get("threshold").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
name,
|
||||
"max_candidate_total_file_size_regression_pct"
|
||||
| "max_candidate_column_compressed_size_regression_pct"
|
||||
| "max_candidate_column_uncompressed_size_regression_pct"
|
||||
) || (threshold.get("target").is_some()
|
||||
&& matches!(
|
||||
name,
|
||||
"min_files"
|
||||
| "min_files_with_column"
|
||||
| "max_total_file_size_bytes"
|
||||
| "max_column_compressed_size_bytes"
|
||||
| "max_column_uncompressed_size_bytes"
|
||||
| "require_encodings"
|
||||
| "forbid_encodings"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// 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.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::Result;
|
||||
use crate::query_regression_runner::measure::median;
|
||||
use crate::query_regression_runner::model::ReadBenchConfig;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct BenchTarget {
|
||||
relative_path: String,
|
||||
table_dir: String,
|
||||
region_id: String,
|
||||
path_type: String,
|
||||
file_id: String,
|
||||
}
|
||||
|
||||
pub(super) fn run_read_bench(
|
||||
candidate_bin: &Path,
|
||||
data_home: &Path,
|
||||
bench_dir: &Path,
|
||||
read_bench: Option<&ReadBenchConfig>,
|
||||
inspection: &Value,
|
||||
) -> Result<Value> {
|
||||
let Some(read_bench) = read_bench.filter(|config| config.enabled) else {
|
||||
return Ok(json!({ "status": "skipped", "reason": "read_bench disabled" }));
|
||||
};
|
||||
if !read_bench.parquetbench && !read_bench.scanbench {
|
||||
return Ok(json!({ "status": "skipped", "reason": "parquetbench and scanbench disabled" }));
|
||||
}
|
||||
let report = inspection
|
||||
.get("summary")
|
||||
.filter(|summary| summary.is_object());
|
||||
let files = report
|
||||
.and_then(|report| report.get("files"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let root = report
|
||||
.and_then(|report| report.get("root"))
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
inspection
|
||||
.get("root")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.ok_or("storage inspection has no root")?;
|
||||
let mut targets = files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
file.get("relative_path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| path.ends_with(".parquet"))
|
||||
&& file
|
||||
.get("columns")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|columns| !columns.is_empty())
|
||||
})
|
||||
.filter_map(|file| inspected_bench_target(data_home, &root, file).transpose())
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if let Some(max_files) = read_bench.max_files {
|
||||
targets.truncate(max_files);
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Ok(
|
||||
json!({ "status": "failed", "reason": "no inspected data SST files available for read_bench" }),
|
||||
);
|
||||
}
|
||||
fs::create_dir_all(bench_dir)?;
|
||||
let config_toml = bench_dir.join("bench.toml");
|
||||
let scan_json = bench_dir.join("scan.json");
|
||||
fs::write(
|
||||
&config_toml,
|
||||
format!(
|
||||
"[storage]\ndata_home = \"{}\"\ntype = \"File\"\n\n[[region_engine]]\n[region_engine.mito]\n",
|
||||
data_home.display()
|
||||
),
|
||||
)?;
|
||||
fs::write(
|
||||
&scan_json,
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&json!({ "projection_names": read_bench.projection }))?
|
||||
),
|
||||
)?;
|
||||
let mut parquet_runs = Vec::new();
|
||||
if read_bench.parquetbench {
|
||||
for target in &targets {
|
||||
let command = vec![
|
||||
candidate_bin.to_string_lossy().to_string(),
|
||||
"datanode".to_string(),
|
||||
"parquetbench".to_string(),
|
||||
"--config".to_string(),
|
||||
config_toml.to_string_lossy().to_string(),
|
||||
"--region-id".to_string(),
|
||||
target.region_id.clone(),
|
||||
"--table-dir".to_string(),
|
||||
target.table_dir.clone(),
|
||||
"--file-id".to_string(),
|
||||
target.file_id.clone(),
|
||||
"--scan-config".to_string(),
|
||||
scan_json.to_string_lossy().to_string(),
|
||||
"--path-type".to_string(),
|
||||
target.path_type.clone(),
|
||||
"--iterations".to_string(),
|
||||
read_bench.iterations.to_string(),
|
||||
"--reader".to_string(),
|
||||
read_bench.parquet_reader.clone(),
|
||||
];
|
||||
parquet_runs.push(run_bench_command(command, bench_target_value(target))?);
|
||||
}
|
||||
}
|
||||
let mut scan_runs = Vec::new();
|
||||
if read_bench.scanbench {
|
||||
for (table_dir, region_id, path_type, files) in group_scan_paths(&targets) {
|
||||
let command = vec![
|
||||
candidate_bin.to_string_lossy().to_string(),
|
||||
"datanode".to_string(),
|
||||
"scanbench".to_string(),
|
||||
"--config".to_string(),
|
||||
config_toml.to_string_lossy().to_string(),
|
||||
"--region-id".to_string(),
|
||||
region_id.clone(),
|
||||
"--table-dir".to_string(),
|
||||
table_dir.clone(),
|
||||
"--scan-config".to_string(),
|
||||
scan_json.to_string_lossy().to_string(),
|
||||
"--path-type".to_string(),
|
||||
path_type.clone(),
|
||||
"--scanner".to_string(),
|
||||
read_bench.scan_scanner.clone(),
|
||||
"--parallelism".to_string(),
|
||||
read_bench.parallelism.to_string(),
|
||||
"--iterations".to_string(),
|
||||
read_bench.iterations.to_string(),
|
||||
];
|
||||
scan_runs.push(run_bench_command(command, json!({ "table_dir": table_dir, "region_id": region_id, "path_type": path_type, "files": files }))?);
|
||||
}
|
||||
}
|
||||
let failed = parquet_runs
|
||||
.iter()
|
||||
.chain(&scan_runs)
|
||||
.any(|run| run["status"] == "failed");
|
||||
Ok(json!({
|
||||
"status": if failed { "failed" } else { "ok" },
|
||||
"config_path": config_toml,
|
||||
"scan_config_path": scan_json,
|
||||
"parquetbench": parquet_runs,
|
||||
"scanbench": scan_runs,
|
||||
"aggregate": {
|
||||
"parquetbench_median_average_ms": bench_median(&parquet_runs),
|
||||
"scanbench_median_average_ms": bench_median(&scan_runs),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn inspected_bench_target(
|
||||
data_home: &Path,
|
||||
root: &Path,
|
||||
file: &Value,
|
||||
) -> Result<Option<BenchTarget>> {
|
||||
let relative_path = file
|
||||
.get("relative_path")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if relative_path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let root_suffix = root.strip_prefix(data_home).map_err(|_| {
|
||||
format!(
|
||||
"storage inspection root {} is not under datanode data home {}",
|
||||
root.display(),
|
||||
data_home.display()
|
||||
)
|
||||
})?;
|
||||
let full_relative = root_suffix.join(relative_path);
|
||||
let parts = full_relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if !parts.last().is_some_and(|part| part.ends_with(".parquet")) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((region_index, table_id, region_seq)) =
|
||||
parts.iter().enumerate().find_map(|(index, part)| {
|
||||
parse_region_dir(part).map(|(table_id, region_seq)| (index, table_id, region_seq))
|
||||
})
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if region_index == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let file_index = parts.len() - 1;
|
||||
let path_type = if region_index + 1 < file_index
|
||||
&& matches!(parts[region_index + 1].as_str(), "data" | "metadata")
|
||||
{
|
||||
parts[region_index + 1].clone()
|
||||
} else {
|
||||
"bare".to_string()
|
||||
};
|
||||
if path_type == "metadata" {
|
||||
return Ok(None);
|
||||
}
|
||||
let file_id = parts[file_index].trim_end_matches(".parquet").to_string();
|
||||
Ok(Some(BenchTarget {
|
||||
relative_path: full_relative.to_string_lossy().to_string(),
|
||||
table_dir: format!("{}/", parts[..region_index].join("/")),
|
||||
region_id: format!("{table_id}:{region_seq}"),
|
||||
path_type,
|
||||
file_id,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_region_dir(part: &str) -> Option<(u64, u64)> {
|
||||
let (table_id, region_seq) = part.split_once('_')?;
|
||||
(region_seq.len() == 10
|
||||
&& table_id.chars().all(|character| character.is_ascii_digit())
|
||||
&& region_seq
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit()))
|
||||
.then(|| Some((table_id.parse().ok()?, region_seq.parse().ok()?)))?
|
||||
}
|
||||
|
||||
fn bench_target_value(target: &BenchTarget) -> Value {
|
||||
json!({
|
||||
"relative_path": target.relative_path,
|
||||
"table_dir": target.table_dir,
|
||||
"region_id": target.region_id,
|
||||
"path_type": target.path_type,
|
||||
"file_id": target.file_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn group_scan_paths(targets: &[BenchTarget]) -> Vec<(String, String, String, Vec<String>)> {
|
||||
let mut groups: Vec<(String, String, String, Vec<String>)> = Vec::new();
|
||||
for target in targets {
|
||||
if let Some((_, _, _, paths)) =
|
||||
groups
|
||||
.iter_mut()
|
||||
.find(|(table_dir, region_id, path_type, _)| {
|
||||
table_dir == &target.table_dir
|
||||
&& region_id == &target.region_id
|
||||
&& path_type == &target.path_type
|
||||
})
|
||||
{
|
||||
paths.push(target.relative_path.clone());
|
||||
} else {
|
||||
groups.push((
|
||||
target.table_dir.clone(),
|
||||
target.region_id.clone(),
|
||||
target.path_type.clone(),
|
||||
vec![target.relative_path.clone()],
|
||||
));
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
fn run_bench_command(command: Vec<String>, mut run: Value) -> Result<Value> {
|
||||
let started = Instant::now();
|
||||
let output = Command::new(&command[0]).args(&command[1..]).output()?;
|
||||
let elapsed_seconds = started.elapsed().as_secs_f64();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let run = run.as_object_mut().ok_or("bench run must be an object")?;
|
||||
run.extend([
|
||||
("command".to_string(), json!(command)),
|
||||
("returncode".to_string(), json!(output.status.code())),
|
||||
("elapsed_seconds".to_string(), json!(elapsed_seconds)),
|
||||
("stdout".to_string(), json!(stdout)),
|
||||
("stderr".to_string(), json!(stderr)),
|
||||
(
|
||||
"status".to_string(),
|
||||
json!(if output.status.success() {
|
||||
"ok"
|
||||
} else {
|
||||
"failed"
|
||||
}),
|
||||
),
|
||||
]);
|
||||
if let Some(average_ms) = parse_average_duration(run["stdout"].as_str().unwrap_or_default()) {
|
||||
run.insert("average_ms".to_string(), json!(average_ms));
|
||||
}
|
||||
Ok(Value::Object(run.clone()))
|
||||
}
|
||||
|
||||
fn parse_average_duration(stdout: &str) -> Option<f64> {
|
||||
Regex::new(r"(?i)Average duration[^0-9]*([0-9.]+)\s*ms")
|
||||
.ok()?
|
||||
.captures(stdout)?
|
||||
.get(1)?
|
||||
.as_str()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn bench_median(runs: &[Value]) -> Option<f64> {
|
||||
let values = runs
|
||||
.iter()
|
||||
.filter_map(|run| run.get("average_ms").and_then(Value::as_f64))
|
||||
.collect::<Vec<_>>();
|
||||
(!values.is_empty()).then(|| median(&values))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_average_duration_and_groups_scan_paths() {
|
||||
assert_eq!(
|
||||
parse_average_duration("work\nAverage duration: 12.5 ms\n"),
|
||||
Some(12.5)
|
||||
);
|
||||
assert_eq!(parse_average_duration("completed"), None);
|
||||
let targets = vec![
|
||||
BenchTarget {
|
||||
relative_path: "data/a.parquet".to_string(),
|
||||
table_dir: "data/".to_string(),
|
||||
region_id: "1:2".to_string(),
|
||||
path_type: "data".to_string(),
|
||||
file_id: "a".to_string(),
|
||||
},
|
||||
BenchTarget {
|
||||
relative_path: "data/b.parquet".to_string(),
|
||||
table_dir: "data/".to_string(),
|
||||
region_id: "1:2".to_string(),
|
||||
path_type: "data".to_string(),
|
||||
file_id: "b".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
group_scan_paths(&targets),
|
||||
vec![(
|
||||
"data/".to_string(),
|
||||
"1:2".to_string(),
|
||||
"data".to_string(),
|
||||
vec!["data/a.parquet".to_string(), "data/b.parquet".to_string()],
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::Result;
|
||||
use crate::query_regression_runner::model::StorageConfig;
|
||||
use crate::query_regression_runner::sql::{value_f64, value_u64};
|
||||
|
||||
pub(super) fn run_storage_inspection(
|
||||
generator: &Path,
|
||||
data_home: &Path,
|
||||
destination: Option<&Path>,
|
||||
storage: &StorageConfig,
|
||||
) -> Result<Value> {
|
||||
let mut command = vec![
|
||||
generator.to_string_lossy().to_string(),
|
||||
"inspect-footer".to_string(),
|
||||
"--column".to_string(),
|
||||
storage.column.clone(),
|
||||
];
|
||||
let root = if let Some(destination) = destination {
|
||||
command.push("--destination".to_string());
|
||||
command.push(destination.to_string_lossy().to_string());
|
||||
data_home.to_path_buf()
|
||||
} else {
|
||||
let root = storage
|
||||
.root_suffix
|
||||
.as_deref()
|
||||
.map_or_else(|| data_home.to_path_buf(), |suffix| data_home.join(suffix));
|
||||
command.push("--root".to_string());
|
||||
command.push(root.to_string_lossy().to_string());
|
||||
root
|
||||
};
|
||||
if storage.include_metadata_files {
|
||||
command.push("--include-metadata-files".to_string());
|
||||
}
|
||||
let started = Instant::now();
|
||||
let output = Command::new(generator).args(&command[1..]).output()?;
|
||||
let elapsed_seconds = started.elapsed().as_secs_f64();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"storage inspector failed: command={command:?}, returncode={:?}, elapsed_seconds={elapsed_seconds:.3}, stderr={:.2000}",
|
||||
output.status.code(), stderr
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut result = json!({
|
||||
"status": "ok",
|
||||
"command": command,
|
||||
"root": root,
|
||||
"returncode": output.status.code(),
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
});
|
||||
match serde_json::from_str(result["stdout"].as_str().unwrap_or_default()) {
|
||||
Ok(summary) => result["summary"] = summary,
|
||||
Err(_) => {
|
||||
result["summary_parse_error"] = result["stdout"].clone();
|
||||
result["status"] = json!("failed");
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn storage_summary(inspection: &Value) -> Value {
|
||||
inspection
|
||||
.get("summary")
|
||||
.and_then(|summary| summary.get("summary"))
|
||||
.filter(|summary| summary.is_object())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
pub(super) fn enforce_storage_thresholds(
|
||||
storage: &StorageConfig,
|
||||
base_inspection: &Value,
|
||||
candidate_inspection: &Value,
|
||||
) -> Vec<Value> {
|
||||
let base = storage_summary(base_inspection);
|
||||
let candidate = storage_summary(candidate_inspection);
|
||||
let targets = [("base", &base), ("candidate", &candidate)];
|
||||
let mut results = Vec::new();
|
||||
for (threshold, field, limit) in [
|
||||
("min_files", "file_count", storage.min_files),
|
||||
(
|
||||
"min_files_with_column",
|
||||
"files_with_column",
|
||||
storage.min_files_with_column,
|
||||
),
|
||||
] {
|
||||
for (target, summary) in &targets {
|
||||
let actual = summary.get(field).cloned().unwrap_or(Value::Null);
|
||||
let ok = value_u64(Some(&actual)).is_some_and(|actual| actual >= limit);
|
||||
results.push(json!({ "target": target, "threshold": threshold, "status": if ok { "passed" } else { "failed" }, "actual": actual, "limit": limit }));
|
||||
}
|
||||
}
|
||||
for (threshold, field, limit) in [
|
||||
(
|
||||
"max_total_file_size_bytes",
|
||||
"total_file_size",
|
||||
storage.max_total_file_size_bytes,
|
||||
),
|
||||
(
|
||||
"max_column_compressed_size_bytes",
|
||||
"column_compressed_size",
|
||||
storage.max_column_compressed_size_bytes,
|
||||
),
|
||||
(
|
||||
"max_column_uncompressed_size_bytes",
|
||||
"column_uncompressed_size",
|
||||
storage.max_column_uncompressed_size_bytes,
|
||||
),
|
||||
] {
|
||||
let Some(limit) = limit else { continue };
|
||||
for (target, summary) in &targets {
|
||||
let actual = summary.get(field).cloned().unwrap_or(Value::Null);
|
||||
let ok = value_f64(Some(&actual)).is_some_and(|actual| actual <= limit as f64);
|
||||
results.push(json!({ "target": target, "threshold": threshold, "status": if ok { "passed" } else { "failed" }, "actual": actual, "limit": limit }));
|
||||
}
|
||||
}
|
||||
for (target, summary) in &targets {
|
||||
let encodings = summary
|
||||
.get("unique_encodings")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for encoding in &storage.require_encodings {
|
||||
results.push(json!({ "target": target, "threshold": "require_encodings", "encoding": encoding, "status": if encodings.iter().any(|value| value.as_str() == Some(encoding)) { "passed" } else { "failed" } }));
|
||||
}
|
||||
for encoding in &storage.forbid_encodings {
|
||||
results.push(json!({ "target": target, "threshold": "forbid_encodings", "encoding": encoding, "status": if encodings.iter().all(|value| value.as_str() != Some(encoding)) { "passed" } else { "failed" } }));
|
||||
}
|
||||
}
|
||||
for (threshold, field, limit) in [
|
||||
(
|
||||
"max_candidate_total_file_size_regression_pct",
|
||||
"total_file_size",
|
||||
storage.max_candidate_total_file_size_regression_pct,
|
||||
),
|
||||
(
|
||||
"max_candidate_column_compressed_size_regression_pct",
|
||||
"column_compressed_size",
|
||||
storage.max_candidate_column_compressed_size_regression_pct,
|
||||
),
|
||||
(
|
||||
"max_candidate_column_uncompressed_size_regression_pct",
|
||||
"column_uncompressed_size",
|
||||
storage.max_candidate_column_uncompressed_size_regression_pct,
|
||||
),
|
||||
] {
|
||||
let Some(limit) = limit else { continue };
|
||||
let base_value = base.get(field).cloned().unwrap_or(Value::Null);
|
||||
let candidate_value = candidate.get(field).cloned().unwrap_or(Value::Null);
|
||||
let (Some(base_number), Some(candidate_number)) = (
|
||||
value_f64(Some(&base_value)).filter(|value| *value != 0.0),
|
||||
value_f64(Some(&candidate_value)),
|
||||
) else {
|
||||
results.push(json!({ "threshold": threshold, "status": "failed", "reason": "missing or zero base/candidate value", "base": base_value, "candidate": candidate_value }));
|
||||
continue;
|
||||
};
|
||||
let actual = (candidate_number - base_number) / base_number * 100.0;
|
||||
results.push(json!({ "threshold": threshold, "status": if actual <= limit { "passed" } else { "failed" }, "actual_pct": actual, "limit_pct": limit, "base": base_value, "candidate": candidate_value }));
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::is_storage_threshold_entry;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enforces_storage_thresholds_for_each_target_and_regression() {
|
||||
let storage: StorageConfig = serde_json::from_value(json!({
|
||||
"inspect": true,
|
||||
"column": "value",
|
||||
"root_suffix": null,
|
||||
"include_metadata_files": false,
|
||||
"min_files": 2,
|
||||
"min_files_with_column": 1,
|
||||
"require_encodings": ["PLAIN"],
|
||||
"forbid_encodings": ["DELTA"],
|
||||
"max_total_file_size_bytes": 105,
|
||||
"max_column_compressed_size_bytes": null,
|
||||
"max_column_uncompressed_size_bytes": null,
|
||||
"max_candidate_total_file_size_regression_pct": 5.0,
|
||||
"max_candidate_column_compressed_size_regression_pct": null,
|
||||
"max_candidate_column_uncompressed_size_regression_pct": null
|
||||
}))
|
||||
.unwrap();
|
||||
let base = json!({"summary": {"summary": {"file_count": 2, "files_with_column": 1, "total_file_size": 100, "unique_encodings": ["PLAIN"]}}});
|
||||
let candidate = json!({"summary": {"summary": {"file_count": 2, "files_with_column": 1, "total_file_size": 110, "unique_encodings": ["PLAIN"]}}});
|
||||
let results = enforce_storage_thresholds(&storage, &base, &candidate);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.any(|result| result["threshold"] == "max_total_file_size_bytes"
|
||||
&& result["target"] == "candidate"
|
||||
&& result["status"] == "failed")
|
||||
);
|
||||
assert!(results.iter().any(|result| result["threshold"]
|
||||
== "max_candidate_total_file_size_regression_pct"
|
||||
&& result["actual_pct"] == 10.0
|
||||
&& result["status"] == "failed"));
|
||||
assert!(results.iter().any(
|
||||
|result| result["threshold"] == "require_encodings" && result["status"] == "passed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_prior_storage_threshold_entries() {
|
||||
assert!(is_storage_threshold_entry(
|
||||
&json!({"target": "base", "threshold": "min_files"})
|
||||
));
|
||||
assert!(is_storage_threshold_entry(&json!({
|
||||
"threshold": "max_candidate_total_file_size_regression_pct"
|
||||
})));
|
||||
assert!(!is_storage_threshold_entry(&json!({
|
||||
"threshold": "max_candidate_latency_regression_pct"
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// 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.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::query_regression_runner::Result;
|
||||
|
||||
pub(super) fn sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
pub(super) fn extract_rows(body: &Value) -> Vec<Value> {
|
||||
fn visit(body: &Value, rows: &mut Vec<Value>) {
|
||||
match body {
|
||||
Value::Object(object) => {
|
||||
for key in ["data", "rows", "records", "output"] {
|
||||
let Some(value) = object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(key, "data" | "rows") && value.is_array() {
|
||||
rows.extend(value.as_array().unwrap().iter().cloned());
|
||||
} else {
|
||||
visit(value, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
if !values.is_empty()
|
||||
&& values
|
||||
.iter()
|
||||
.all(|value| !value.is_object() && !value.is_array())
|
||||
{
|
||||
rows.push(body.clone());
|
||||
} else {
|
||||
for value in values {
|
||||
visit(value, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
visit(body, &mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
pub(super) fn row_value<'a>(row: &'a Value, index: usize, name: &str) -> Option<&'a Value> {
|
||||
match row {
|
||||
Value::Object(values) => [
|
||||
name.to_string(),
|
||||
name.to_ascii_uppercase(),
|
||||
name.to_ascii_lowercase(),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|key| values.get(&key)),
|
||||
Value::Array(values) => values.get(index),
|
||||
_ => Some(row),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn row_u64(row: &Value, index: usize, name: &str) -> Result<u64> {
|
||||
let value =
|
||||
row_value(row, index, name).ok_or_else(|| format!("missing {name} in row {row}"))?;
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str().and_then(|value| value.parse().ok()))
|
||||
.ok_or_else(|| format!("invalid {name} in row {row}").into())
|
||||
}
|
||||
|
||||
pub(super) fn value_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value.clone(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::Null => "None".to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_count_value(result: &Value) -> Option<u64> {
|
||||
let row = result
|
||||
.get("response")?
|
||||
.get("data")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.as_object()?;
|
||||
row.iter()
|
||||
.find(|(key, _)| {
|
||||
let key = key.to_ascii_lowercase();
|
||||
key == "count(*)" || key.starts_with("count(")
|
||||
})
|
||||
.and_then(|(_, value)| value_u64(Some(value)))
|
||||
}
|
||||
|
||||
pub(super) fn value_u64(value: Option<&Value>) -> Option<u64> {
|
||||
value?.as_u64().or_else(|| value?.as_str()?.parse().ok())
|
||||
}
|
||||
|
||||
pub(super) fn value_f64(value: Option<&Value>) -> Option<f64> {
|
||||
value?.as_f64().or_else(|| value?.as_str()?.parse().ok())
|
||||
}
|
||||
|
||||
pub(super) async fn http_post_sql(client: &Client, port: u16, sql: &str, db: &str) -> Value {
|
||||
let started = Instant::now();
|
||||
let request = client
|
||||
.post(format!("http://127.0.0.1:{port}/v1/sql"))
|
||||
.form(&[("sql", sql), ("db", db), ("format", "json")]);
|
||||
match request.send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status().as_u16();
|
||||
match response.text().await {
|
||||
Ok(raw) => {
|
||||
let body = serde_json::from_str(&raw).unwrap_or_else(|_| json!({"raw": raw}));
|
||||
let ok = status < 400 && !response_has_error(&body);
|
||||
let mut sample = json!({
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"latency_ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
"response": body,
|
||||
"sql": sql,
|
||||
});
|
||||
if status >= 400 {
|
||||
sample
|
||||
.as_object_mut()
|
||||
.expect("HTTP samples are objects")
|
||||
.insert("error".to_string(), Value::String(format!("HTTP {status}")));
|
||||
}
|
||||
sample
|
||||
}
|
||||
Err(error) => json!({
|
||||
"ok": false,
|
||||
"status": status,
|
||||
"latency_ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
"error": error.to_string(),
|
||||
"sql": sql,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(error) => json!({
|
||||
"ok": false,
|
||||
"status": Value::Null,
|
||||
"latency_ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
"error": error.to_string(),
|
||||
"sql": sql,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_has_error(body: &Value) -> bool {
|
||||
let Some(body) = body.as_object() else {
|
||||
return false;
|
||||
};
|
||||
["error", "err_msg", "error_msg"]
|
||||
.into_iter()
|
||||
.any(|key| body.get(key).is_some_and(is_truthy))
|
||||
|| body
|
||||
.get("error_code")
|
||||
.is_some_and(|value| !is_success_code(value))
|
||||
|| (!body.contains_key("output")
|
||||
&& body
|
||||
.get("code")
|
||||
.is_some_and(|value| !is_success_code(value)))
|
||||
}
|
||||
|
||||
fn is_truthy(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::Bool(value) => *value,
|
||||
Value::Number(value) => value.as_f64().is_none_or(|value| value != 0.0),
|
||||
Value::String(value) => !value.is_empty(),
|
||||
Value::Array(value) => !value.is_empty(),
|
||||
Value::Object(value) => !value.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_success_code(value: &Value) -> bool {
|
||||
let code = match value {
|
||||
Value::String(value) => value.clone(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::Null => "None".to_string(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
matches!(code.to_lowercase().as_str(), "" | "0" | "success")
|
||||
}
|
||||
|
||||
pub(super) fn sql_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn top_level_errors_do_not_inspect_rows() {
|
||||
assert!(response_has_error(&json!({"error_code": 7})));
|
||||
assert!(response_has_error(&json!({"code": "bad"})));
|
||||
assert!(!response_has_error(
|
||||
&json!({"output": [{"code": 7, "error": "row value"}]})
|
||||
));
|
||||
assert!(!response_has_error(
|
||||
&json!({"error_code": "success", "output": []})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_remote_write_count_from_data_map() {
|
||||
assert_eq!(
|
||||
extract_count_value(&json!({"response": {"data": [{"COUNT(*)": "12"}]}})),
|
||||
Some(12)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_count_value(&json!({"response": {"data": [{"count(value)": 7}]}})),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_count_value(&json!({"response": {"data": [[]]}})),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-6
@@ -3,22 +3,39 @@
|
||||
- Keep GitHub Actions YAML thin. Put non-trivial control flow, case expansion,
|
||||
report generation, and metadata writing in scripts under `.github/scripts/`;
|
||||
workflow steps should mostly invoke those scripts.
|
||||
- Runner lifecycle: the default path provisions one ephemeral Aliyun ECS
|
||||
instance per run via `.github/scripts/aliyun-ecs-runner-provision.py` and
|
||||
always releases it via `aliyun-ecs-runner-teardown.py`; a scheduled janitor
|
||||
workflow sweeps leftovers. Build caches live on that instance's 50 GiB system
|
||||
disk and are discarded with the VM. Runs do not share a workflow concurrency
|
||||
group. The ECS custom image is built from the runner Dockerfile by
|
||||
`.github/runner-scale-sets/query-regression/ecs-image/build-ecs-image.py`;
|
||||
keep the Dockerfile the single source of the tool contract. Dispatching with
|
||||
any other `runner` value treats it as a literal self-hosted runner label
|
||||
(see `ecs-image/bootstrap-runner-host.sh` for preparing such a host).
|
||||
- Scheduled nightly comparison lives in `query-regression-nightly.yml`: it
|
||||
waits for a successful Nightly Build, then calls `query-regression.yml`
|
||||
with the previous vs current nightly SHAs. Keep SHA selection in
|
||||
`.github/scripts/query-regression-nightly-refs.py`.
|
||||
- Query regression PR runs should build base/candidate binaries once, then run
|
||||
the default case set. Do not hard-code a single case such as
|
||||
`promql_pushdown_7913` into the workflow path.
|
||||
- The case DSL is not required to keep compatibility inside this PR. When the
|
||||
DSL changes, update TOML cases, the Python runner, Rust fixture generator, and
|
||||
DSL changes, update TOML cases, the outer lifecycle script, Rust helpers, and
|
||||
docs together.
|
||||
- `[case]` is report metadata only. `[scenario]` is the executable regression
|
||||
configuration and must include `kind`, data layout, tables, queries, and
|
||||
thresholds. Rust owns case schema, defaults, validation, and normalized plan
|
||||
output through `query_perf_fixture plan`; `tests/perf/query_regression_runner.py`
|
||||
should only orchestrate from that normalized JSON.
|
||||
output through `query_perf_fixture plan`. `.github/scripts/query-regression-run.py`
|
||||
owns process lifecycle; `query_regression_runner` consumes normalized plans,
|
||||
frontend endpoints, direct-SST materialization requests, and OTLP target/finalize
|
||||
requests.
|
||||
- Keep the direct-SST generator generic. Issue-specific behavior belongs in case
|
||||
files and thresholds, not in Rust generator logic.
|
||||
- Before pushing perf harness changes, run at least:
|
||||
- `uv run --no-project python -m py_compile .github/scripts/query-regression-run.py .github/scripts/query-regression-summary.py .github/scripts/query-regression-pr-metadata.py tests/perf/query_regression_runner.py`
|
||||
- `uv run --no-project python -m py_compile .github/scripts/query-regression-run.py .github/scripts/query-regression-summary.py .github/scripts/query-regression-pr-metadata.py tests/perf/test_query_regression_runner_compaction_toctou.py tests/perf/test_query_regression_runner_otlp_trace_load.py`
|
||||
- `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`
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo build -p cmd --bin query_perf_fixture --features dev-tools`
|
||||
- dry-run the Python runner and Rust fixture generator against all built-in
|
||||
cases when the DSL or workflow case selection changes.
|
||||
- exercise the outer lifecycle script and Rust fixture generator against all
|
||||
built-in cases when the DSL or workflow case selection changes.
|
||||
|
||||
+64
-142
@@ -41,7 +41,7 @@ release-level realism.
|
||||
|
||||
## Prometheus remote-write scenario
|
||||
|
||||
The runner also supports `scenario.kind = "prom_remote_write_then_query"` for a
|
||||
The outer driver supports `scenario.kind = "prom_remote_write_then_query"` for a
|
||||
bounded write-path smoke/regression flow. This path is explicit and separate from
|
||||
the direct-SST fixture path: it starts the base and candidate distributed clusters,
|
||||
writes deterministic Prometheus remote-write v1 samples through
|
||||
@@ -111,11 +111,10 @@ helper so non-linear value patterns use a stable global/per-series ordinal acros
|
||||
chunks.
|
||||
|
||||
Case schema, value distribution defaults, storage defaults, and read-bench
|
||||
defaults are owned by Rust. The Python runner calls
|
||||
`query_perf_fixture plan --case <case.toml>` and orchestrates the normalized JSON.
|
||||
The same helper exposes `direct-sst`, `prom-remote-write`, and `inspect-footer`
|
||||
subcommands; the old direct invocation (`query_perf_fixture --case ... --out-dir
|
||||
...`) remains compatible.
|
||||
defaults are owned by Rust. The outer CI driver calls
|
||||
`query_perf_fixture plan --case <case.toml>` once, then uses the normalized plan
|
||||
to select the Rust runner lifecycle. The fixture helper exposes `direct-sst`,
|
||||
`prom-remote-write`, and `inspect-footer` subcommands.
|
||||
|
||||
Remote-write cases that need to validate storage output can add
|
||||
`[scenario.remote_write.storage]`. When present, the scenario body becomes:
|
||||
@@ -163,17 +162,15 @@ measures region scan cost, and query measurements still exercise the SQL/TQL
|
||||
frontend path. Treat all performance conclusions as release-only; debug builds
|
||||
are suitable only for command wiring and correctness checks.
|
||||
|
||||
The runner creates the configured database if needed, writes a per-target
|
||||
`prepare-remote` creates the configured database if needed. The outer driver writes a per-target
|
||||
frontend config enabling `[prom_store]` with metric engine storage and a non-zero
|
||||
`pending_rows_flush_interval`, and validates that the logical metric table reaches
|
||||
`series_count * samples_per_series` rows before trusting the query measurements.
|
||||
Use `--fixture-generator /path/to/query_perf_fixture` to provide the Rust helper.
|
||||
Deprecated `--remote-write-generator` and `--storage-inspector` options are kept
|
||||
only for CLI compatibility. `--fixture-only` is rejected for remote-write cases;
|
||||
use `--dry-run` for planning.
|
||||
Use `--fixture-generator /path/to/query_perf_fixture` to provide the Rust helper
|
||||
to the outer driver.
|
||||
|
||||
Large manual remote-write cases can set `sample_chunk_size` to split ingestion by
|
||||
time. For each chunk, the runner invokes `query_perf_fixture prom-remote-write` with the
|
||||
time. For each chunk, `prepare-remote` invokes `query_perf_fixture prom-remote-write` with the
|
||||
same series cardinality but a shorter `--samples-per-series` and an advanced
|
||||
`--start-unix-millis`. `flush_every_sample_chunks` controls periodic
|
||||
`ADMIN FLUSH_TABLE('<physical_table>')` calls; with `flush_every_sample_chunks = 1`,
|
||||
@@ -213,34 +210,27 @@ workflow dispatch accepts the `heavy` token to select this case.
|
||||
## 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.
|
||||
against each local distributed cluster. The outer driver runs the base target
|
||||
alone with Rust `run-otlp-target`, fully stops it, then runs the candidate alone
|
||||
and calls Rust `finalize-otlp` to aggregate metrics, thresholds, and the final
|
||||
report. 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:
|
||||
Build base/candidate `greptime` binaries with the same profile and build the
|
||||
candidate `query_perf_fixture` and `query_regression_runner`, then run the outer
|
||||
driver 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 \
|
||||
uv run --no-project python .github/scripts/query-regression-run.py \
|
||||
--cases 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 \
|
||||
--runner /path/to/candidate/target/nightly/query_regression_runner \
|
||||
--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"
|
||||
--work-dir "$WORK_DIR"
|
||||
REPORT="$WORK_DIR/otlp_trace_load/query-regression-report.json"
|
||||
```
|
||||
|
||||
For each target, `accepted_spans` should equal `table_rows`, and `failures`
|
||||
@@ -323,8 +313,8 @@ seed = 12345
|
||||
# query, warmups, iterations, thresholds
|
||||
```
|
||||
|
||||
The runner currently supports `direct_readable_sst` and
|
||||
`prom_remote_write_then_query`.
|
||||
The outer driver currently supports `direct_readable_sst`,
|
||||
`prom_remote_write_then_query`, and `otlp_trace_load`.
|
||||
|
||||
## Metrics
|
||||
|
||||
@@ -340,120 +330,49 @@ Primary gates should compare query work rather than plan text:
|
||||
Plan details such as pushed filters are useful diagnostics, but should not be the
|
||||
main pass/fail signal.
|
||||
|
||||
## Runner MVP
|
||||
## Runner lifecycle
|
||||
|
||||
`query_regression_runner.py` is the base-vs-candidate orchestration layer. The
|
||||
current MVP parses a case, creates per-target work directories, and in real query
|
||||
mode starts a local distributed cluster for each target: metasrv (memory-store,
|
||||
region failover disabled), one datanode (`node_id=0`), and one frontend. It
|
||||
creates the configured Mito table(s) through frontend HTTP SQL, discovers the
|
||||
real one-region-per-table metadata via `information_schema`, stops only the
|
||||
owning datanode, generates one shared direct-SST fixture per table using the
|
||||
discovered `--region-id`, `--table-dir`, and `--table`, injects those region
|
||||
subtrees into the datanode data home, restarts the datanode, then validates and
|
||||
measures through frontend. Reports are written as JSON under the work directory.
|
||||
`.github/scripts/query-regression-run.py` is the outer CI driver. It resolves the
|
||||
base and candidate `greptime` binaries, candidate `query_perf_fixture`, and
|
||||
candidate `query_regression_runner`; allocates disjoint localhost
|
||||
meta/datanode/frontend HTTP, gRPC, MySQL, and Postgres ports for each target; and
|
||||
writes component logs below `<work-dir>/<target>/logs/`.
|
||||
|
||||
The runner intentionally keeps metasrv alive for the whole target run because
|
||||
memory-store metadata would otherwise be lost. It replaces only the discovered
|
||||
datanode region directory under `data/greptime/<schema>/<table_id>/...` with
|
||||
generated SST files and a manifest checkpoint. For multi-table cases this is
|
||||
repeated per table, enabling true JOIN fixtures while still requiring exactly one
|
||||
region per table. Base and candidate must discover identical per-table
|
||||
`table_dir` and `region_id`; otherwise the run fails.
|
||||
The Rust runner consumes the normalized plan and endpoint ports. For
|
||||
`direct_readable_sst`, the driver starts both clusters, runs `prepare-direct`,
|
||||
stops only both datanodes, writes File-object-store destination TOMLs, invokes
|
||||
`materialize` for every fixture and target, restarts datanodes, then runs
|
||||
`measure` (with one frontend restart retry). `materialize` uses OpenDAL and only
|
||||
replaces the fixture's exact region prefix in each target data home.
|
||||
|
||||
Multi-table direct-SST cases must use unique table names as well as unique
|
||||
`(database, name)` pairs because the generator currently selects a table with
|
||||
`--table <name>`. Per-table fixture directories are derived from table index,
|
||||
database, and table name with path-unsafe characters sanitized.
|
||||
For `prom_remote_write_then_query`, it renders a target frontend configuration
|
||||
with `render-remote-config`, starts both clusters, runs `prepare-remote` and
|
||||
`measure`, stops both datanodes, then runs `finalize-remote`. Finalization owns
|
||||
storage inspection and read-bench against the quiescent data homes. Both paths
|
||||
write `query-regression-report.json` in the case work directory and always clean
|
||||
up the remaining components.
|
||||
|
||||
Currently enforced threshold:
|
||||
|
||||
- `max_candidate_latency_regression_pct`, based on client-side median latency.
|
||||
|
||||
Server-side scan thresholds such as file ranges and scanned rows are planned for
|
||||
a follow-up PR that extracts them from structured `EXPLAIN ANALYZE VERBOSE`
|
||||
output. Do not add those threshold keys until the runner enforces them.
|
||||
|
||||
Dry-run example:
|
||||
For local orchestration after building the three binaries, invoke the outer
|
||||
driver directly:
|
||||
|
||||
```bash
|
||||
uv run --no-project python tests/perf/query_regression_runner.py \
|
||||
--case tests/perf/query_cases/promql_pushdown_7913/case.toml \
|
||||
--base-bin /path/to/base/greptime \
|
||||
--candidate-bin /path/to/candidate/greptime \
|
||||
--work-dir /tmp/query-perf-work \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
With a fixture generator:
|
||||
|
||||
```bash
|
||||
uv run --no-project python tests/perf/query_regression_runner.py \
|
||||
--case tests/perf/query_cases/promql_pushdown_7913/case.toml \
|
||||
uv run --no-project python .github/scripts/query-regression-run.py \
|
||||
--cases tests/perf/query_cases/smoke_direct_sst/case.toml \
|
||||
--base-bin /path/to/base/greptime \
|
||||
--candidate-bin /path/to/candidate/greptime \
|
||||
--fixture-generator /path/to/query_perf_fixture \
|
||||
--fixture-cache-dir /mnt/query-regression-fixtures \
|
||||
--allow-large-fixture \
|
||||
--work-dir /tmp/query-perf-work
|
||||
--runner /path/to/query_regression_runner \
|
||||
--work-dir /tmp/query-regression-work
|
||||
```
|
||||
|
||||
This mode launches metasrv, datanode, and frontend for each target with explicit
|
||||
localhost HTTP/gRPC/MySQL/Postgres ports and writes component stdout/stderr under
|
||||
each target's `logs/` directory.
|
||||
|
||||
By default query mode requires fresh base/candidate work directories and fails if
|
||||
either target directory already exists with contents. Use `--reuse-work-dir` only
|
||||
when intentionally debugging an existing run directory. SQL HTTP requests default
|
||||
to a 120 second timeout; override with `--http-timeout <seconds>` for slow lab
|
||||
runs.
|
||||
|
||||
For large direct-SST fixtures, pass `--fixture-cache-dir <dir>` to store generated
|
||||
fixtures in a persistent content-addressed cache keyed by case name and fixture
|
||||
data configuration. Query and threshold edits reuse the same cached data as long
|
||||
as the scenario layout and table definitions do not change. Cached fixtures are
|
||||
reused automatically when their `summary.json`
|
||||
matches the discovered table/region metadata; incompatible entries are
|
||||
regenerated instead of reused. Fixture materialization keeps base and candidate
|
||||
data directories isolated, but copies files efficiently by trying filesystem
|
||||
reflinks first, hardlinks for immutable SST/object files next, and normal copies
|
||||
as a fallback. Manifest files are reflinked or copied, not hardlinked.
|
||||
|
||||
Fixture generator smoke test:
|
||||
The Rust runner subcommands are also useful for focused diagnostics:
|
||||
|
||||
```bash
|
||||
cargo run -p cmd --bin query_perf_fixture --features dev-tools -- \
|
||||
direct-sst \
|
||||
--case tests/perf/query_cases/smoke_direct_sst/case.toml \
|
||||
--out-dir /tmp/query-perf-smoke
|
||||
```
|
||||
|
||||
Runner smoke test with fixture generation only:
|
||||
|
||||
```bash
|
||||
uv run --no-project python tests/perf/query_regression_runner.py \
|
||||
--case tests/perf/query_cases/smoke_direct_sst/case.toml \
|
||||
--base-bin /path/to/query_perf_fixture \
|
||||
--candidate-bin /path/to/query_perf_fixture \
|
||||
--fixture-generator /path/to/query_perf_fixture \
|
||||
--work-dir /tmp/query-perf-runner-smoke \
|
||||
--fixture-only
|
||||
```
|
||||
|
||||
`--fixture-only` preserves the earlier smoke behavior: it does not start
|
||||
standalone servers, and it materializes the generated fixture into base and
|
||||
candidate data directories for plumbing validation.
|
||||
|
||||
Remote-write runner dry-run:
|
||||
|
||||
```bash
|
||||
uv run --no-project python tests/perf/query_regression_runner.py \
|
||||
--case tests/perf/query_cases/prom_remote_write_seeded_random/case.toml \
|
||||
--base-bin /path/to/base/greptime \
|
||||
--candidate-bin /path/to/candidate/greptime \
|
||||
--fixture-generator /path/to/query_perf_fixture \
|
||||
--work-dir /tmp/query-perf-remote-write \
|
||||
--dry-run
|
||||
query_regression_runner prepare-direct --case <case.toml> --fixture-generator <fixture> \
|
||||
--base-http-port <port> --candidate-http-port <port> --fixture-dir <dir> --output <json>
|
||||
query_regression_runner materialize --fixture-dir <dir> --destination <toml>
|
||||
query_regression_runner measure --case <case.toml> --fixture-generator <fixture> \
|
||||
--base-http-port <port> --candidate-http-port <port> --output <report.json>
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
@@ -462,14 +381,17 @@ uv run --no-project python tests/perf/query_regression_runner.py \
|
||||
query regression runs. It builds its own binaries for now:
|
||||
|
||||
- base `greptime` from the PR base commit, or `workflow_dispatch` `base_ref`
|
||||
- candidate `greptime` and `query_perf_fixture` from the PR merge ref/current
|
||||
candidate checkout
|
||||
- runner and summary formatter from the candidate checkout
|
||||
- candidate `greptime`, `query_perf_fixture`, and `query_regression_runner` from
|
||||
the PR merge ref/current candidate checkout
|
||||
- outer driver and summary formatter from the candidate checkout
|
||||
|
||||
The workflow builds base and candidate `greptime` as normal release-equivalent
|
||||
binaries. Candidate `query_perf_fixture` is the extra head-side helper binary;
|
||||
the runner uses candidate `greptime datanode parquetbench/scanbench` as the
|
||||
read-bench tool against each target's data directory.
|
||||
The default `aliyun-ecs` runner provisions one ephemeral 50 GiB-system-disk
|
||||
Aliyun ECS instance per run and tears it down afterwards; the scheduled janitor
|
||||
reclaims leftovers. The workflow builds base and candidate `greptime` as normal
|
||||
release-equivalent binaries. Candidate `query_perf_fixture` and
|
||||
`query_regression_runner` are the extra head-side helpers; `finalize-remote`
|
||||
uses candidate `greptime datanode parquetbench/scanbench` as the read-bench tool
|
||||
against each target's data directory.
|
||||
|
||||
The workflow runs automatically only when `query-regression` or `heavy-regression`
|
||||
is added to a non-draft PR; it does not rerun on pushes, ready-for-review, or
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
#!/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.
|
||||
|
||||
"""Coverage for the pure parts of the Aliyun ECS runner provision/teardown scripts."""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).parents[2] / ".github/scripts"
|
||||
|
||||
|
||||
def load_module(name: str, filename: str):
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPTS_DIR / filename)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
provision = load_module("aliyun_ecs_runner_provision_under_test", "aliyun-ecs-runner-provision.py")
|
||||
teardown = load_module("aliyun_ecs_runner_teardown_under_test", "aliyun-ecs-runner-teardown.py")
|
||||
|
||||
|
||||
class ProvisionNamingTest(unittest.TestCase):
|
||||
def test_runner_name_and_label_derive_from_run_id(self) -> None:
|
||||
self.assertEqual(provision.runner_name_for_run("12345"), "qreg-ecs-12345")
|
||||
self.assertEqual(provision.runner_label_for_run("12345"), "query-regression-ecs-12345")
|
||||
|
||||
|
||||
class ProvisionUserDataTest(unittest.TestCase):
|
||||
def render(self) -> str:
|
||||
return provision.render_user_data(
|
||||
runner_name="qreg-ecs-12345",
|
||||
runner_label="query-regression-ecs-12345",
|
||||
runner_token="TOKEN",
|
||||
repo="GreptimeTeam/greptimedb",
|
||||
)
|
||||
|
||||
def test_user_data_creates_cache_paths_on_the_system_disk(self) -> None:
|
||||
script = self.render()
|
||||
self.assertNotIn("DISK_SERIAL", script)
|
||||
self.assertNotIn("mount --bind", script)
|
||||
self.assertNotIn("mkfs.ext4", script)
|
||||
for destination in provision.CACHE_PATHS:
|
||||
self.assertIn(f'"{destination}"', script)
|
||||
|
||||
def test_user_data_wires_runner_registration(self) -> None:
|
||||
script = self.render()
|
||||
self.assertIn("RUNNER_NAME=qreg-ecs-12345", script)
|
||||
self.assertIn("RUNNER_LABELS=query-regression-ecs-12345", script)
|
||||
self.assertIn("RUNNER_TOKEN=TOKEN", script)
|
||||
self.assertIn("REPO_URL=https://github.com/GreptimeTeam/greptimedb", script)
|
||||
self.assertIn("PATH=/opt/cargo/bin:", script)
|
||||
self.assertIn("systemctl restart --no-block ephemeral-github-runner.service", script)
|
||||
|
||||
def test_encode_user_data_round_trips(self) -> None:
|
||||
script = self.render()
|
||||
self.assertEqual(
|
||||
base64.b64decode(provision.encode_user_data(script)).decode("utf-8"), script
|
||||
)
|
||||
|
||||
def test_user_data_enables_swap_and_masks_oomd(self) -> None:
|
||||
script = self.render()
|
||||
self.assertIn("systemctl mask systemd-oomd.socket systemd-oomd.service", script)
|
||||
self.assertIn("systemctl mask unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer", script)
|
||||
self.assertIn('APT::Periodic::Unattended-Upgrade "0"', script)
|
||||
self.assertIn(f'fallocate --length {provision.SWAP_SIZE_GIB}G "{provision.SWAP_FILE}"', script)
|
||||
self.assertIn(f'swapon "{provision.SWAP_FILE}"', script)
|
||||
self.assertIn("sysctl --write vm.swappiness=10", script)
|
||||
self.assertIn("OOMPolicy=continue", script)
|
||||
self.assertNotIn("OOMScoreAdjust", script)
|
||||
self.assertLess(script.index("swapon"), script.index("systemctl restart --no-block ephemeral-github-runner.service"))
|
||||
|
||||
|
||||
class TeardownExpiryTest(unittest.TestCase):
|
||||
NOW = datetime(2026, 8, 17, 6, 0, tzinfo=timezone.utc)
|
||||
TTL = timedelta(hours=4)
|
||||
|
||||
def test_parse_creation_time_formats(self) -> None:
|
||||
self.assertEqual(
|
||||
teardown.parse_creation_time("2026-08-17T01:02:03Z"),
|
||||
datetime(2026, 8, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
teardown.parse_creation_time("2026-08-17T01:02Z"),
|
||||
datetime(2026, 8, 17, 1, 2, tzinfo=timezone.utc),
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
teardown.parse_creation_time("not-a-time")
|
||||
|
||||
def test_expired_instance_names_selects_only_old_instances(self) -> None:
|
||||
instances = [
|
||||
("i-old", "qreg-ecs-1", "2026-08-17T01:00Z"), # 5h old: expired
|
||||
("i-edge", "qreg-ecs-2", "2026-08-17T02:00Z"), # exactly TTL: expired
|
||||
("i-fresh", "qreg-ecs-3", "2026-08-17T05:30Z"), # 30m old: kept
|
||||
]
|
||||
self.assertEqual(
|
||||
teardown.expired_instance_names(instances, self.NOW, self.TTL),
|
||||
[("i-old", "qreg-ecs-1"), ("i-edge", "qreg-ecs-2")],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/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.
|
||||
|
||||
"""Unit tests for consecutive Nightly Build SHA selection."""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).parents[2] / ".github/scripts"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"query_regression_nightly_refs_under_test",
|
||||
SCRIPTS_DIR / "query-regression-nightly-refs.py",
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
refs = load_module()
|
||||
|
||||
|
||||
def run(*, run_id: int, sha: str, branch: str = "main", created_at: str = "2026-08-24T00:00:00Z"):
|
||||
return refs.WorkflowRun(
|
||||
id=run_id,
|
||||
head_sha=sha,
|
||||
head_branch=branch,
|
||||
html_url=f"https://github.com/example/run/{run_id}",
|
||||
created_at=created_at,
|
||||
conclusion="success",
|
||||
event="schedule",
|
||||
)
|
||||
|
||||
|
||||
class SelectNightlyRefsTest(unittest.TestCase):
|
||||
def test_picks_newest_and_previous_on_same_branch(self) -> None:
|
||||
pair = refs.select_base_and_candidate(
|
||||
[
|
||||
run(run_id=3, sha="ccc", created_at="2026-08-22T00:00:00Z"),
|
||||
run(run_id=2, sha="bbb", created_at="2026-08-21T00:00:00Z"),
|
||||
run(run_id=1, sha="aaa", created_at="2026-08-20T00:00:00Z"),
|
||||
]
|
||||
)
|
||||
self.assertFalse(pair.skip)
|
||||
assert pair.candidate is not None and pair.base is not None
|
||||
self.assertEqual(pair.candidate.head_sha, "ccc")
|
||||
self.assertEqual(pair.base.head_sha, "bbb")
|
||||
|
||||
def test_candidate_run_id_uses_that_run_and_the_next_older(self) -> None:
|
||||
pair = refs.select_base_and_candidate(
|
||||
[
|
||||
run(run_id=3, sha="ccc", created_at="2026-08-22T00:00:00Z"),
|
||||
run(run_id=2, sha="bbb", created_at="2026-08-21T00:00:00Z"),
|
||||
run(run_id=1, sha="aaa", created_at="2026-08-20T00:00:00Z"),
|
||||
],
|
||||
candidate_run_id=2,
|
||||
)
|
||||
self.assertFalse(pair.skip)
|
||||
assert pair.candidate is not None and pair.base is not None
|
||||
self.assertEqual(pair.candidate.id, 2)
|
||||
self.assertEqual(pair.base.id, 1)
|
||||
|
||||
def test_skips_when_consecutive_nightlies_share_a_sha(self) -> None:
|
||||
pair = refs.select_base_and_candidate(
|
||||
[
|
||||
run(run_id=2, sha="same", created_at="2026-08-22T00:00:00Z"),
|
||||
run(run_id=1, sha="same", created_at="2026-08-21T00:00:00Z"),
|
||||
]
|
||||
)
|
||||
self.assertTrue(pair.skip)
|
||||
self.assertIn("matches candidate", pair.reason)
|
||||
|
||||
def test_skips_other_branches_when_picking_previous(self) -> None:
|
||||
pair = refs.select_base_and_candidate(
|
||||
[
|
||||
run(run_id=3, sha="ccc", branch="main", created_at="2026-08-22T00:00:00Z"),
|
||||
run(run_id=2, sha="other", branch="feat", created_at="2026-08-21T12:00:00Z"),
|
||||
run(run_id=1, sha="aaa", branch="main", created_at="2026-08-21T00:00:00Z"),
|
||||
]
|
||||
)
|
||||
self.assertFalse(pair.skip)
|
||||
assert pair.base is not None
|
||||
self.assertEqual(pair.base.head_sha, "aaa")
|
||||
|
||||
def test_skips_when_only_one_nightly_exists(self) -> None:
|
||||
pair = refs.select_base_and_candidate(
|
||||
[run(run_id=1, sha="aaa")],
|
||||
)
|
||||
self.assertTrue(pair.skip)
|
||||
self.assertIn("no previous", pair.reason)
|
||||
|
||||
def test_explicit_refs_bypass_github(self) -> None:
|
||||
pair = refs.override_pair("base-sha", "cand-sha")
|
||||
self.assertFalse(pair.skip)
|
||||
assert pair.base is not None and pair.candidate is not None
|
||||
self.assertEqual(pair.base.head_sha, "base-sha")
|
||||
self.assertEqual(pair.candidate.head_sha, "cand-sha")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,9 +13,8 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Regression coverage for quiescing remote-write storage before read benches."""
|
||||
"""Regression coverage for the outer remote-write lifecycle."""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
@@ -25,151 +24,90 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
RUNNER_PATH = Path(__file__).with_name("query_regression_runner.py")
|
||||
SPEC = importlib.util.spec_from_file_location("query_regression_runner_under_test", RUNNER_PATH)
|
||||
RUNNER_PATH = Path(__file__).resolve().parents[2] / ".github/scripts/query-regression-run.py"
|
||||
SPEC = importlib.util.spec_from_file_location("query_regression_outer_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 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)
|
||||
class RemoteWriteLifecycleTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.args = type("Args", (), {"http_timeout": "1"})()
|
||||
self.ports = list(range(10000, 10016))
|
||||
|
||||
with patch("builtins.print") as print_mock:
|
||||
runner.output_report(report, None)
|
||||
print_mock.assert_called_once_with(expected)
|
||||
def test_measure_report_is_finalized_after_datanodes_stop(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
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")
|
||||
def run(command, **_kwargs):
|
||||
phase = command[1]
|
||||
events.append(phase)
|
||||
if phase == "measure":
|
||||
output = Path(command[command.index("--output") + 1])
|
||||
output.write_text(json.dumps({"targets": [], "thresholds": [], "status": "ok"}))
|
||||
return type("Result", (), {"returncode": 0})()
|
||||
|
||||
def start(target, component, _procs):
|
||||
events.append(f"start:{target.name}:{component}")
|
||||
|
||||
class RemoteWriteCompactionToctouTest(unittest.TestCase):
|
||||
def test_quiesces_each_target_before_storage_inspection_and_read_bench(self) -> None:
|
||||
lifecycle: dict[str, list[str]] = {"base": [], "candidate": []}
|
||||
clusters = []
|
||||
def stop(target, component, _procs):
|
||||
events.append(f"stop:{target.name}:{component}")
|
||||
|
||||
class RecordingCluster:
|
||||
def __init__(self, target):
|
||||
self.target = target
|
||||
self.active = set()
|
||||
self.stop_all_calls = 0
|
||||
clusters.append(self)
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch.object(runner, "allocate_ports", return_value=self.ports),
|
||||
patch.object(runner, "start_component", side_effect=start),
|
||||
patch.object(runner, "stop_component", side_effect=stop),
|
||||
patch.object(runner.subprocess, "run", side_effect=run),
|
||||
):
|
||||
self.assertEqual(
|
||||
runner.run_remote_case(
|
||||
self.args, Path("case.toml"), Path(tmpdir), Path("base"), Path("candidate"), Path("fixture"), Path("runner")
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.assertTrue((Path(tmpdir) / "query-regression-report.json").exists())
|
||||
|
||||
def start_all(self, _config_path):
|
||||
self.active.update(("metasrv", "datanode", "frontend"))
|
||||
measure = events.index("measure")
|
||||
finalize = events.index("finalize-remote")
|
||||
self.assertLess(measure, events.index("stop:base:datanode"))
|
||||
self.assertLess(events.index("stop:base:datanode"), finalize)
|
||||
self.assertLess(events.index("stop:candidate:datanode"), finalize)
|
||||
self.assertIn("stop:base:frontend", events[finalize + 1:])
|
||||
self.assertIn("stop:candidate:metasrv", events[finalize + 1:])
|
||||
|
||||
def stop_component(self, name):
|
||||
if name in self.active:
|
||||
lifecycle[self.target.name].append(f'cluster.stop_component("{name}")')
|
||||
self.active.remove(name)
|
||||
def test_phase_exception_still_cleans_up_without_processes_or_network(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
def stop_all(self):
|
||||
self.stop_all_calls += 1
|
||||
for name in ("frontend", "datanode", "metasrv"):
|
||||
self.stop_component(name)
|
||||
def run(command, **_kwargs):
|
||||
phase = command[1]
|
||||
events.append(phase)
|
||||
if phase == "prepare-remote":
|
||||
raise RuntimeError("prepare exploded")
|
||||
return type("Result", (), {"returncode": 0})()
|
||||
|
||||
def component_report(self):
|
||||
return {}
|
||||
def start(target, component, _procs):
|
||||
events.append(f"start:{target.name}:{component}")
|
||||
|
||||
def run_queries(target, _case, _tables, _http_timeout):
|
||||
lifecycle[target.name].append("query measurement completes")
|
||||
return {"validation": [], "validation_errors": [], "measurements": [], "status": "ok"}
|
||||
def stop(target, component, _procs):
|
||||
events.append(f"stop:{target.name}:{component}")
|
||||
|
||||
def inspect_storage(_helper, target, _storage, *, dry_run):
|
||||
self.assertFalse(dry_run)
|
||||
lifecycle[target.name].append("storage inspection")
|
||||
return {"status": "ok", "summary": {"summary": {}}}
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch.object(runner, "allocate_ports", return_value=self.ports),
|
||||
patch.object(runner, "start_component", side_effect=start),
|
||||
patch.object(runner, "stop_component", side_effect=stop),
|
||||
patch.object(runner.subprocess, "run", side_effect=run),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "prepare exploded"):
|
||||
runner.run_remote_case(
|
||||
self.args, Path("case.toml"), Path(tmpdir), Path("base"), Path("candidate"), Path("fixture"), Path("runner")
|
||||
)
|
||||
|
||||
def read_bench(_binary, target, _read_bench, _inspection, *, dry_run):
|
||||
self.assertFalse(dry_run)
|
||||
lifecycle[target.name].append("read bench")
|
||||
return {"status": "ok"}
|
||||
|
||||
remote_write = {
|
||||
"database": "public",
|
||||
"metric": "cpu_usage",
|
||||
"physical_table": "cpu_usage_physical",
|
||||
"series_count": 1,
|
||||
"samples_per_series": 1,
|
||||
"visibility_timeout_seconds": 1,
|
||||
"storage": {
|
||||
"inspect": True,
|
||||
"min_files": None,
|
||||
"min_files_with_column": None,
|
||||
"max_total_file_size_bytes": None,
|
||||
"max_column_compressed_size_bytes": None,
|
||||
"max_column_uncompressed_size_bytes": None,
|
||||
"require_encodings": [],
|
||||
"forbid_encodings": [],
|
||||
},
|
||||
"read_bench": {"enabled": True},
|
||||
"prom_store": {
|
||||
"pending_rows_flush_interval": "1s",
|
||||
"max_batch_rows": 1,
|
||||
"max_concurrent_flushes": 1,
|
||||
"worker_channel_capacity": 1,
|
||||
"max_inflight_requests": 1,
|
||||
},
|
||||
}
|
||||
case = {
|
||||
"scenario": {
|
||||
"kind": "prom_remote_write_then_query",
|
||||
"remote_write": remote_write,
|
||||
"queries": [],
|
||||
}
|
||||
}
|
||||
args = argparse.Namespace(
|
||||
fixture_only=False,
|
||||
fixture_generator=Path("/bin/true"),
|
||||
remote_write_generator=None,
|
||||
storage_inspector=None,
|
||||
candidate_bin=Path("/bin/true"),
|
||||
dry_run=False,
|
||||
http_timeout=1.0,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
targets = [
|
||||
runner.make_target("base", Path("/bin/true"), root, list(range(10000, 10008))),
|
||||
runner.make_target("candidate", Path("/bin/true"), root, list(range(10008, 10016))),
|
||||
]
|
||||
report = {"targets": [], "thresholds": []}
|
||||
with (
|
||||
patch.object(runner, "DistributedCluster", RecordingCluster),
|
||||
patch.object(runner, "run_remote_write_ingestion", return_value=({"status": "ok"}, [{"ok": True}])),
|
||||
patch.object(runner, "http_post_sql", return_value={"ok": True}),
|
||||
patch.object(runner, "poll_expected_count", return_value={"ok": True, "row_count_ok": True}),
|
||||
patch.object(runner, "run_queries", side_effect=run_queries),
|
||||
patch.object(runner, "run_storage_inspection", side_effect=inspect_storage),
|
||||
patch.object(runner, "run_read_bench", side_effect=read_bench),
|
||||
):
|
||||
runner.run_remote_write_scenario(args, case, root / "case.toml", targets, report)
|
||||
|
||||
for cluster in clusters:
|
||||
self.assertEqual(cluster.stop_all_calls, 1)
|
||||
self.assertEqual(cluster.active, set())
|
||||
cluster.stop_all()
|
||||
self.assertEqual(cluster.stop_all_calls, 2)
|
||||
self.assertEqual(cluster.active, set())
|
||||
|
||||
expected = [
|
||||
"query measurement completes",
|
||||
'cluster.stop_component("datanode")',
|
||||
"storage inspection",
|
||||
"read bench",
|
||||
]
|
||||
relevant = {name: [event for event in events if event in expected] for name, events in lifecycle.items()}
|
||||
for target_name in ("base", "candidate"):
|
||||
with self.subTest(target=target_name):
|
||||
self.assertEqual(relevant[target_name], expected)
|
||||
self.assertIn("prepare-remote", events)
|
||||
self.assertIn("stop:base:metasrv", events)
|
||||
self.assertIn("stop:candidate:frontend", events)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -13,215 +13,80 @@
|
||||
# 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."""
|
||||
"""Regression coverage for the outer OTLP lifecycle."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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)
|
||||
RUNNER_PATH = Path(__file__).resolve().parents[2] / ".github/scripts/query-regression-run.py"
|
||||
SPEC = importlib.util.spec_from_file_location("query_regression_outer_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,
|
||||
},
|
||||
],
|
||||
}
|
||||
class OtlpTraceOuterLifecycleTest(unittest.TestCase):
|
||||
def test_stops_base_before_candidate_and_wires_rust_commands(self) -> None:
|
||||
events: list[str] = []
|
||||
commands: list[list[str]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
def run(command, **_kwargs):
|
||||
commands.append(command)
|
||||
phase = command[1]
|
||||
events.append(phase)
|
||||
output = Path(command[command.index("--output") + 1])
|
||||
output.write_text(json.dumps({"status": "ok", "targets": []}))
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
def start(target, component, _procs):
|
||||
events.append(f"start:{target.name}:{component}")
|
||||
|
||||
def stop(target, component, _procs):
|
||||
events.append(f"stop:{target.name}:{component}")
|
||||
|
||||
args = SimpleNamespace(http_timeout="1", otelgen_bin=Path("otelgen"))
|
||||
ports = list(range(10000, 10016))
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch.object(runner, "allocate_ports", return_value=ports),
|
||||
patch.object(runner, "start_component", side_effect=start),
|
||||
patch.object(runner, "stop_component", side_effect=stop),
|
||||
patch.object(runner.subprocess, "run", side_effect=run),
|
||||
):
|
||||
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))),
|
||||
self.assertEqual(
|
||||
runner.run_otlp_case(args, Path("case.toml"), root, Path("base-bin"), Path("candidate-bin"), Path("fixture"), Path("runner")),
|
||||
0,
|
||||
)
|
||||
expected = [
|
||||
[
|
||||
"runner", "run-otlp-target", "--case", "case.toml", "--fixture-generator", "fixture",
|
||||
"--otelgen-bin", "otelgen", "--http-port", "10004", "--target-name", "base",
|
||||
"--work-dir", str(root / "base"), "--output", str(root / "base" / "report.json"), "--http-timeout", "1",
|
||||
],
|
||||
[
|
||||
"runner", "run-otlp-target", "--case", "case.toml", "--fixture-generator", "fixture",
|
||||
"--otelgen-bin", "otelgen", "--http-port", "10012", "--target-name", "candidate",
|
||||
"--work-dir", str(root / "candidate"), "--output", str(root / "candidate" / "report.json"), "--http-timeout", "1",
|
||||
],
|
||||
[
|
||||
"runner", "finalize-otlp", "--case", "case.toml", "--fixture-generator", "fixture",
|
||||
"--base-result", str(root / "base" / "report.json"),
|
||||
"--candidate-result", str(root / "candidate" / "report.json"),
|
||||
"--output", str(root / "query-regression-report.json"),
|
||||
],
|
||||
]
|
||||
with patch.object(runner, "DistributedCluster", FakeCluster):
|
||||
runner.run_otlp_trace_load_scenario(
|
||||
args,
|
||||
{"scenario": {"kind": "otlp_trace_load", "load": load}},
|
||||
targets,
|
||||
{"targets": []},
|
||||
)
|
||||
self.assertEqual(commands, expected)
|
||||
|
||||
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)
|
||||
self.assertLess(events.index("stop:base:metasrv"), events.index("start:candidate:metasrv"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user