ci: run a composition's ci-cleanup workflow after every mzcompose job - #38681
ci: run a composition's ci-cleanup workflow after every mzcompose job#38681ggevay wants to merge 13 commits into
Conversation
fd006f8 to
a8f8420
Compare
a8f8420 to
968adcd
Compare
d5ba734 to
45b4984
Compare
Cancelling or timing out a Buildkite job ends the mzcompose process with SIGTERM, which skips Python `finally` blocks, so a composition that holds resources outside of Docker never gets to release them. For cluster-spec-sheet this meant a cancelled staging shard never ran `mz region disable --hard`, and its region stayed up until the same E2E account came around again in the 7-of-20 rotation, which can take weeks. On 2026-09-03 two cancelled builds left nine staging regions behind; one of them, at 20,000 materialized views, kept the staging eu-west-1 CockroachDB cluster at 75-85% CPU for over two days and stalled the staging release rollout. The mzcompose plugin now runs a composition's `ci-cleanup` workflow, if it defines one, from its exit trap: after the main workflow has exited for any reason, before the Docker teardown, with the main workflow's arguments. A failure is written to run.log, where ci-annotate-errors now matches it into the error annotation and fails an otherwise green job. Two details keep the cleanup run from interfering with the main run. It writes no JUnit report: it runs under the same BUILDKITE_JOB_ID, so its report would overwrite the main run's, which for a normally failing job is the only source of the failure annotation. And the hook kills the main run's containers first: SIGTERM ends the mzcompose process, but a `docker compose run` container it started keeps going, and an in-flight `mz region enable` could otherwise re-create the region after the cleanup deleted it. No composition defines the workflow yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pure move: the `--target` argument and the per-target construction of the bench target and the `mz` service go into `add_target_arguments` and `make_target`, so that another workflow can build the same target from the same arguments. No behavior change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ci-cleanup` destroys the region of a Cloud target started with `--cleanup` and does nothing otherwise. The CI mzcompose plugin runs it after `default` has exited, however it exited, with the same arguments, so a cancelled or timed-out staging shard now disables its region instead of leaving it up. It also runs after a successful run, where the region is already gone; `mz region disable` reports an absent region as success. The workflow runs unattended and reads the main workflow's arguments with `parse_known_args`, so `--target` is now required instead of defaulting to cloud-production: a mistyped target used to fall through to production with cleanup enabled. Every CI step passes the target explicitly; ad-hoc local runs get an error instead of production. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…llowing errors `disable_region` caught every `UIError` to tolerate a 404, but `mz region disable` already reports an absent region as success, so the catch only hid real failures (auth, API errors) behind a green job. Remove it. `CloudTarget.cleanup` now also runs `mz region list` and fails unless the target's region is reported disabled. Returning from the disable is not proof, and a surviving region keeps costing money and CockroachDB load until someone notices. The check runs after every cleanup, including the unattended one from the CI plugin's exit trap, whose failure lands in the error annotation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…README The README named `NIGHTLY_CANARY_USERNAME` and `NIGHTLY_CANARY_APP_PASSWORD` for staging runs, which the composition does not read. A staging run uses one account from the E2E database pool, selected by `CI_CONCURRENCY_POOL_SLOT` with the matching `E2E_STAGING_TEST_FRONTEGG_DATABASE_APP_PASSWORD_<n>`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two production steps read "<what runs> (against <where>)", while the staging step said only where it runs. It runs the envd QPS and envd objects scalability groups and the cluster object limits group, so its label now follows the same pattern, with "object count" spelled out so the limit is not mistaken for a size limit. The "Cluster spec sheet" prefix stays, which is what the mzcompose plugin's services.log check matches on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
45b4984 to
517377b
Compare
antiguru
left a comment
There was a problem hiding this comment.
Reviewed the branch locally. The design is sound and the verification is unusually thorough. Two things I'd want resolved, plus some smaller points.
1. Dropping the UIError swallow in disable_region is riskier than the commit message argues
test/cluster-spec-sheet/mzcompose.py:3389
The removed comment said mz region disable "Can return: status 404 Not Found". The new comment asserts the opposite ("treats an already-disabled region as success"). Builds 40-42 verify the already-enabled-then-disabled case, which is not the same as the never-enabled case a 404 would come from. That case is reachable in two places now:
mzcompose.py:3971, the start-of-runtarget.cleanup(), which runs before anything is enabled. On a pool account whose region record does not exist yet, a 404 there fails the job outright.workflow_ci_cleanup, for a shard that dies beforeinitialize()(image pull, missing app-password env var). That would hard-failci-cleanupand produce an error annotation on a job that had nothing to clean up.
The identical except UIError: # Can return: status 404 Not Found still sits in test/cloud-canary/mzcompose.py:328 and test/mz-e2e/mzcompose.py:252, so the tree now contradicts itself. What is the evidence the 404 cannot happen? If it is an old mz version, worth saying so in the comment and removing the stale swallow in the other two files as a follow-up.
2. The disable in the sweep's retry loop is now outside the try
test/cluster-spec-sheet/mzcompose.py:3507-3508
for attempt in range(1, attempts + 1):
disable_region(target.composition, hard=True) # now raises
try:
enable_region(target, envd_cpus=envd_cpus)The loop's own comment at 3513 says "a sweep does a dozen of these calls and the staging Cloud API returns the occasional 502". That retry now covers only enable; a 502 on disable aborts a multi-hour sweep where it previously did not. Moving disable_region inside the try keeps the disable-before-retry invariant, since the loop body starts with it, and restores the protection the comment promises.
Strong suggestions
The container kill is broader than its comment claims. ci/plugins/mzcompose/hooks/command:161: the comment says "the main workflow's containers", but --filter label=com.docker.compose.project matches every compose container on the host. Scoping it to label=com.docker.compose.project=$BUILDKITE_PLUGIN_MZCOMPOSE_COMPOSITION (project name defaults to the composition name, composition.py:138) matches the stated intent and drops the dependency on one-job-per-host.
timeout 15m has no --kill-after. ci/plugins/mzcompose/hooks/command:169. run_mzcompose uses --signal=TERM --kill-after=30s deliberately. Here a cleanup that ignores SIGTERM escapes the 15-minute bound the comment above it promises.
Nits
verify_region_disabled(3399):entry["region"]/entry["status"]raiseKeyError, notUIError, if the schema shifts, while everything else in the function is careful to convert.splitlines()[-1]also breaks silently ifmzever pretty-prints; picking the first line starting with[would be sturdier.mz-e2edoes a plainjson.loads(output.stdout), so a word on why this one cannot would help.make_targetforcloud-stagingcallsstaging_version(), which doesos.environ['BUILDKITE_COMMIT'](mzcompose.py:109).ci-cleanupdoes not need the version, so a localrun ci-cleanup --target=cloud-staging --cleanupdies with aKeyErrorinstead of disabling the region.CloudTarget.regionduplicates theMzservice'sregion. They come from the same constant inmake_targetso they cannot diverge today, but it is a second source of truth for the same fact.- Pre-existing but now more exposed: the main run writes
run.logthrough a process substitution (hooks/command:464) while the trap appends withtee -a. On a cancel those two writers can overlap. Same pattern as the existingtest timed outprintf, so not new here.
Checked and fine
set -o pipefailis still in effect insidecleanup()(set +eonly clears errexit), soif ! ... | sed | teedoes catch a cleanup failure.- All three pipeline steps pass
--target; nothing else in the repo invokes this composition. - The
Cluster spec sheetprefix survives the rename, so the services.log-empty exemption (hooks/command:441) still matches, and nothing keys off the old label. parse_known_argshandles the main workflow's extra options and positional scenario names;${run_args[@]:1}drops the workflow name correctly, andCI_EXTRA_ARGSis still set whenci-cleanupruns, so it gets the same extras the main run got.- JUnit suppression does not hide failures:
raise UIError("at least one test case failed")still exits non-zero, so the run.log path fires. - No downstream exit-code analysis, so the killed containers' 137s do not create false annotations.
Generated by Claude Code
QA LLM Review1. MEDIUM -- A failing region teardown now discards the whole run's results
Details
Two concrete ways this fires. The new |
cloud_recreate_region_with_envd_cpus retries a failed `mz region enable` because the staging Cloud API returns the occasional 502 during a sweep. With disable_region no longer swallowing errors, a 502 on the disable would have aborted the sweep instead. Start each attempt inside the retry, so the disable-before-retry invariant holds and the protection covers both calls; the warning names the step generically since either call can fail. The comment no longer claims that the CLI does not retry the enable: both `mz region enable` and `mz region disable` retry their API call for up to 12 minutes, and the 502s that reach this loop are the ones that survive that. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…egion The comment claimed the CLI treats an already-disabled region as success without saying how, while two other compositions still guard the same call against a 404. Point at the CLI's mapping so the contract can be checked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pre-cleanup kill matched every compose-managed container on the host. Scope it to the compose project named after the job's composition, which is what the comment described, and drop the assumption that a host runs one job at a time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`timeout 15m` only sends SIGTERM, so a cleanup that ignores it would escape the bound the comment promises. Add `--kill-after=30s`, as run_mzcompose does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…list` output A changed field name raised KeyError instead of UIError, and a pretty-printed array would not have parsed from its last line. Parse from the first line that starts the array to the end, and turn every parse and shape surprise into the same UIError with the raw output. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…site make_target read BUILDKITE_COMMIT eagerly for cloud-staging, so a local `run ci-cleanup --target=cloud-staging --cleanup` died with a KeyError before disabling anything. Only `mz region enable` needs the version: pass it there, inside the branch that already holds the other staging-only flag, and drop the field that carried it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The end-of-run region teardown sat in the `finally` ahead of the result upload, the CSV artifact upload and the analysis. Now that the teardown can fail (disable_region no longer swallows errors and the verify raises on a region that is not reported disabled), such a failure at the end of a multi-hour sweep would have discarded all of the run's data. Run the uploads inside the outer `try` and tear the region down in its `finally`, so a teardown failure still fails the run but no longer costs its results. The CI plugin's ci-cleanup repeats the teardown afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6cc6df4 to
827327a
Compare
|
Thanks for the careful pass. The fixes are separate commits on top, one per point. 1. The 404 case. 2. Sweep retry. Fixed: the disable is inside the Container kill scope. Fixed:
Nits.
Since the trap changed, we reran the cancel test: build 45, cancelled 23 seconds into a shard's first |
|
Valid, thanks. The end-of-run teardown did sit ahead of the uploads, and this PR made it able to fail. Fixed in 827327a: the uploads, the artifact upload and the analysis now run inside the outer On the two triggers: the largest region we have torn down, the leftover with 20,000 materialized views, took 167 seconds to hard-delete (build 41), against the CLI's 600-second poll, so the timeout is unlikely. The transient lookup error making |
antiguru
left a comment
There was a problem hiding this comment.
All addressed, thanks. Spot-checked the substance rather than taking the answers on trust:
- The 404 mapping is where you say it is (
src/mz/src/command/region.rs:145-155,NOT_FOUNDtoOk(())), and the composition runs themzimage built from this tree, so dropping the swallow is safe. While I was there I also confirmed the two thingsverify_region_disabledleans on:listprintsError: {:?}for a failed provider lookup before emitting the table, and the field isregionwithenabled/disabled. Agreed on leaving the stale swallow incloud-canaryandmz-e2eto a follow-up. - Sweep retry: disable is inside the
trynow, and the corrected comment (both subcommands already retry, so what reaches this loop survived that) is more accurate than what it replaced. - Container kill: the filter can't silently no-op, since
mzcompose.py:222setsname=args.find or Path.cwd().nameandcomposition.py:138falls back to that forproject_name, and the plugin never passes--project-name. - Moving
staging_version()intoenable_region's staging branch is better than what I asked for: it retiresCloudTarget.versionand theassert (version is not None) == is_stagingwith it, so placement enforces the invariant instead of an assert. - The reordering for the QA finding reads correctly: inner
try/finallycloses the streams, uploads and analysis in the outertry, teardown in itsfinally. An upload failure now also gets a teardown, which it didn't before. CloudTarget.regionvsMz: fine as is, not worth the churn.
One thing the refactor introduced, non-blocking: staging_version() now runs per enable rather than once per run, and it shells out to cargo metadata --no-deps (mz_version.py:107). The time is irrelevant, but it puts a subprocess.CalledProcessError inside cloud_recreate_region_with_envd_cpus's retry, which catches only UIError, so a transient cargo hiccup would escape the retry it now sits inside. A functools.cache on staging_version closes that and the repetition. The version can't change within a run.
Generated by Claude Code
bosconi
left a comment
There was a problem hiding this comment.
Looks good. Some minor items here. Thank you for getting to the bottom of this!
| A composition that creates resources outside of Docker, such as a Cloud | ||
| region, can define a workflow named `ci-cleanup`. The command hook runs it | ||
| after the main workflow has exited, however it exited, and passes it the same | ||
| `args`. Cancelling or timing out a job ends the main workflow with SIGTERM, | ||
| which does not run Python `finally` blocks, so a composition must not rely on | ||
| its own cleanup path for those cases. Before the workflow runs, the hook kills | ||
| the main workflow's containers, so nothing left over from the main run can | ||
| race the cleanup; the Docker teardown proper happens afterwards. The workflow | ||
| must be idempotent: it also runs after a successful run that already cleaned | ||
| up. It writes no JUnit report, so the main workflow's report survives. Its | ||
| failure is recorded in the error annotation and fails an otherwise green job. |
There was a problem hiding this comment.
The section is missing the three things the next adopter cannot infer from the hook. test/mz-e2e and test/cloud-canary, which the description names as follow-ups, would hit all three on their first attempt:
- The workflow receives the main workflow's full
args:list plusCI_EXTRA_ARGS, so it must useparse_known_args. Withparse_argsit exits 2 on every run, the marker fires, and every green job of that composition turns red. check-mzcompose-files.shfails atest/composition with more than onedef workflow_unlessdefaultloops overc.workflows, and both of those compositions have exactly one workflow today and are not on its exclusion list. The remedy the lint prints would makedefaultrunci-cleanupmid-run. Worth fixing the lint to skipworkflow_ci_cleanupin this PR (see the review body), and saying so here either way.- "kills" is
docker kill, so SIGKILL, and it runs on green jobs too. UnderCI_COVERAGE_ENABLEDthe hook deliberately uses a gracefulmzcompose downso the.profrawfiles get written at exit; the kill would lose them silently. The 15m budget is also only in a hook comment.
| A composition that creates resources outside of Docker, such as a Cloud | |
| region, can define a workflow named `ci-cleanup`. The command hook runs it | |
| after the main workflow has exited, however it exited, and passes it the same | |
| `args`. Cancelling or timing out a job ends the main workflow with SIGTERM, | |
| which does not run Python `finally` blocks, so a composition must not rely on | |
| its own cleanup path for those cases. Before the workflow runs, the hook kills | |
| the main workflow's containers, so nothing left over from the main run can | |
| race the cleanup; the Docker teardown proper happens afterwards. The workflow | |
| must be idempotent: it also runs after a successful run that already cleaned | |
| up. It writes no JUnit report, so the main workflow's report survives. Its | |
| failure is recorded in the error annotation and fails an otherwise green job. | |
| A composition that creates resources outside of Docker, such as a Cloud | |
| region, can define a workflow named `ci-cleanup`. The command hook runs it | |
| after the main workflow has exited, however it exited, and passes it the main | |
| workflow's full argument list (the step's `args`, plus any `CI_EXTRA_ARGS`), | |
| so the workflow must parse with `parse_known_args` and must be able to find | |
| its target from those arguments alone. Cancelling or timing out a job ends | |
| the main workflow with SIGTERM, which does not run Python `finally` blocks, | |
| so a composition must not rely on its own cleanup path for those cases. | |
| Before the workflow runs, the hook stops the main run's compose-managed | |
| containers with `docker kill` (SIGKILL), so that a command still in flight | |
| from the main run, such as an `mz region enable` that outlived the cancelled | |
| process, cannot undo the cleanup once it has finished; the Docker teardown | |
| proper happens afterwards. The kill is skipped | |
| under `CI_COVERAGE_ENABLED`, since a killed process writes no `.profraw`. The | |
| workflow must be idempotent: it also runs after a successful run that already | |
| cleaned up. It has 15 minutes, so that a hung cleanup cannot eat the cancel | |
| grace period before the artifacts are uploaded. It writes no JUnit report, so | |
| the main workflow's report survives. Its failure is recorded in the error | |
| annotation and fails an otherwise green job. A composition whose `default` | |
| loops over `c.workflows` must skip `ci-cleanup` in that loop. |
| @@ -145,6 +145,33 @@ cleanup() { | |||
| printf "\n%s" "$BUILDKITE_LABEL: test timed out" >> run.log | |||
There was a problem hiding this comment.
nit: no trailing newline, and the cleanup's tee -a run.log below now appends straight after this, so on a timed-out job the marker line becomes <label>: test timed out--- <first cleanup line>. ERROR_RE still matches (.* $), so the annotation fires, but its text carries whatever the cleanup printed first.
| printf "\n%s" "$BUILDKITE_LABEL: test timed out" >> run.log | |
| printf "\n%s\n" "$BUILDKITE_LABEL: test timed out" >> run.log |
| # `mzcompose down` removes them. | ||
| docker ps --quiet --filter "label=com.docker.compose.project=$BUILDKITE_PLUGIN_MZCOMPOSE_COMPOSITION" | xargs --no-run-if-empty docker kill > /dev/null || true |
There was a problem hiding this comment.
Problem. docker kill is SIGKILL, and this runs on every job of a composition that defines ci-cleanup, successful ones included. Under CI_COVERAGE_ENABLED the hook's teardown is deliberately graceful (mzcompose down --volumes, line 289) because the LLVM runtime writes .profraw only at normal process exit. A SIGKILL before that point discards the job's coverage data, and nothing reports it: the job stays green, there is just no .profraw to collect.
Scope. cluster-spec-sheet does not run under coverage, so nothing breaks today. This is for the next composition that adopts ci-cleanup.
Fix. Skip the kill when CI_COVERAGE_ENABLED is set; the graceful down then handles the containers, as it does for every other coverage job. The race the kill guards against (an mz region enable outliving the cancelled process) is a Cloud-target concern, and coverage jobs do not run against Cloud, so nothing is lost.
| # `mzcompose down` removes them. | |
| docker ps --quiet --filter "label=com.docker.compose.project=$BUILDKITE_PLUGIN_MZCOMPOSE_COMPOSITION" | xargs --no-run-if-empty docker kill > /dev/null || true | |
| # `mzcompose down` removes them. Skipped under coverage: SIGKILL loses the | |
| # .profraw files written at exit, and the coverage teardown is graceful for | |
| # the same reason. | |
| if [ -z "${CI_COVERAGE_ENABLED:-}" ]; then | |
| docker ps --quiet --filter "label=com.docker.compose.project=$BUILDKITE_PLUGIN_MZCOMPOSE_COMPOSITION" | xargs --no-run-if-empty docker kill > /dev/null || true | |
| fi |
| if workflow_name == "ci-cleanup": | ||
| # Runs from the CI plugin's exit trap after the main workflow, under | ||
| # the same BUILDKITE_JOB_ID, so its report would overwrite the main | ||
| # run's. Its outcome is reported through run.log instead. | ||
| return False |
There was a problem hiding this comment.
Design point rather than a defect: the reason for the suppression is a property of how the plugin invokes the workflow (same BUILDKITE_JOB_ID, hence the same junit_mzcompose_<job id>.xml), but the policy lives here, keyed on a workflow name the CLI otherwise knows nothing about. Two consequences: a developer who runs bin/mzcompose run ci-cleanup by hand, or a composition whose default calls c.workflow("ci-cleanup"), silently gets no report; and a composition that names its cleanup workflow anything else gets no suppression. An env var or --no-junit-report set by the hook on this one invocation would keep the decision where the knowledge is. Fine to defer if you would rather not widen the CLI surface now.
| "--cleanup", | ||
| default=False, | ||
| action=argparse.BooleanOptionalAction, | ||
| help="Destroy the region at the end of the workflow.", |
There was a problem hiding this comment.
We can summarize the full behavior here.
| help="Destroy the region at the end of the workflow.", | |
| help="Destroy the region when the workflow ends, and from CI's ci-cleanup after a cancel or timeout.", |
| target, mz = make_target(composition, args.target) | ||
| if not isinstance(target, CloudTarget): | ||
| print(f"Nothing to clean up for --target={args.target}") | ||
| return |
There was a problem hiding this comment.
Branching on the string first keeps the no-op path free of environment requirements.
| target, mz = make_target(composition, args.target) | |
| if not isinstance(target, CloudTarget): | |
| print(f"Nothing to clean up for --target={args.target}") | |
| return | |
| if args.target == "docker": | |
| print("Nothing to clean up for --target=docker") | |
| return | |
| target, mz = make_target(composition, args.target) | |
| assert isinstance(target, CloudTarget) |
| ## Running manually in Cloud | ||
|
|
||
| To run the cloud canary test manually, you can specify either `--target=cloud-production` (which is hardcoded to aws/us-east-1) or `--target=cloud-staging` (which is hardcoded to aws/eu-west-1). For production, you need to set the environment variables `NIGHTLY_MZ_USERNAME` and `MZ_CLI_APP_PASSWORD`. For staging, you need to set the environment variables `NIGHTLY_CANARY_USERNAME` and `NIGHTLY_CANARY_APP_PASSWORD`. | ||
| To run the cloud canary test manually, you can specify either `--target=cloud-production` (which is hardcoded to aws/us-east-1) or `--target=cloud-staging` (which is hardcoded to aws/eu-west-1). For production, you need to set the environment variables `NIGHTLY_MZ_USERNAME` and `MZ_CLI_APP_PASSWORD`. For staging, the run uses one account from the E2E database pool: set `E2E_STAGING_TEST_FRONTEGG_DATABASE_APP_PASSWORD_<n>` for the pool index `<n>` and select it with `CI_CONCURRENCY_POOL_SLOT=<n>` (outside CI, index 0 is used when the slot is unset); the username is derived from the index. |
There was a problem hiding this comment.
A manual staging run also needs BUILDKITE_COMMIT, which staging_version() reads unconditionally.
| To run the cloud canary test manually, you can specify either `--target=cloud-production` (which is hardcoded to aws/us-east-1) or `--target=cloud-staging` (which is hardcoded to aws/eu-west-1). For production, you need to set the environment variables `NIGHTLY_MZ_USERNAME` and `MZ_CLI_APP_PASSWORD`. For staging, the run uses one account from the E2E database pool: set `E2E_STAGING_TEST_FRONTEGG_DATABASE_APP_PASSWORD_<n>` for the pool index `<n>` and select it with `CI_CONCURRENCY_POOL_SLOT=<n>` (outside CI, index 0 is used when the slot is unset); the username is derived from the index. | |
| To run the cloud canary test manually, you can specify either `--target=cloud-production` (which is hardcoded to aws/us-east-1) or `--target=cloud-staging` (which is hardcoded to aws/eu-west-1). For production, you need to set the environment variables `NIGHTLY_MZ_USERNAME` and `MZ_CLI_APP_PASSWORD`. For staging, the run uses one account from the E2E database pool: set `E2E_STAGING_TEST_FRONTEGG_DATABASE_APP_PASSWORD_<n>` for the pool index `<n>` and select it with `CI_CONCURRENCY_POOL_SLOT=<n>` (outside CI, index 0 is used when the slot is unset); the username is derived from the index. Staging runs also need `BUILDKITE_COMMIT`, which selects the image version to enable. |
Motivation
Cancelling or timing out a Cluster spec sheet job leaves its Cloud region running. Buildkite ends the job with SIGTERM, which kills the mzcompose process without running the composition's
finallyblock, somz region disable --hardnever happens. The region survives until the next run on the same account disables it at its start: for the production steps that is the next weekly run, for a staging shard it is when the same E2E account comes up again in the 7-of-20 rotation, which can take weeks.On 2026-09-03 two cancelled builds (37 and 38) left nine staging regions behind (slack). One of them, cancelled at 20,000 materialized views, kept the staging eu-west-1 CockroachDB cluster at 75-85% CPU for over two days and stalled the v26.41.0-rc.3 staging rollout. Build 17 did the same on a smaller scale on 2026-07-08.
Description
Best reviewed commit by commit; each commit is one concern.
ci-cleanupworkflow, if it defines one, from its exit trap: after the main workflow has exited for any reason, before the Docker teardown, with the main workflow's arguments. Before it runs, the hook kills the containers of the main run's compose project: SIGTERM ends the mzcompose process, but adocker compose runcontainer it started keeps going, and an in-flightmz region enablecould otherwise re-create the region after the cleanup deleted it. The cleanup run writes no JUnit report, since it runs under the same job id and would overwrite the main run's report, which for a normally failing job is the only source of the failure annotation. A cleanup failure is written to run.log, where a newci-annotate-errorspattern turns it into an error annotation even for a cancelled job, and fails an otherwise green job. Documented in the plugin README.cluster-spec-sheetdefinesci-cleanup. For a Cloud target started with--cleanupit destroys the region, otherwise it does nothing. It is idempotent: after a successful run the region is already gone, whichmz region disablereports as success. The target construction shared withdefaultmoves intomake_target.--targetis now required instead of defaulting tocloud-production.ci-cleanupruns unattended and reads the main workflow's arguments withparse_known_args, so a mistyped target used to fall through to production with cleanup enabled. All CI steps pass the target explicitly.CloudTarget.cleanupchecks withmz region list --format jsonthat the region is reported disabled (which also covers a deletion still in progress) and fails otherwise, anddisable_regionno longer swallows errors. A surviving region therefore shows up as a failedci-cleanupin the error annotation, however the job ended. Because the end-of-run teardown can now fail, it runs after the result uploads and the analysis, so a teardown failure fails the run without discarding its data.--target, and the staging credentials paragraph describes the E2E pool accounts the composition actually uses.Cluster spec sheet: envd Scalability + Cluster Object Count Limits (against Staging), following the "what runs (against where)" pattern of the two production steps. TheCluster spec sheetprefix, which the plugin's services.log check matches on, stays. Buildkite Test Analytics starts a new suite history under the new name.Not covered: a job that dies from outside (agent loss, host termination). The start-of-run disable on the next use of the account remains the backstop for that. A SIGTERM handler in Python was considered and dropped: it would race the plugin's container kill and
mzcompose down, which end the container the disable runs in.Verification
ci-cleanupfrom the trap, each disable took about 30 seconds and was reported disabled, and staging eu-west-1 was back to its four permanent environments within two minutes. That build correctly has no error annotation; checking how a failure would have surfaced is what found the missingci-annotate-errorspattern. A review pass after that build found the JUnit overwrite (reproduced locally, and visible in build 43's uploaded reports) and the orphaned-container race.mz region enableduring its QPS sweep, i.e. with a live enable container. All six shards cleaned up (the seventh was still waiting for an agent), the JSON check reported the region disabled, no shard uploaded a JUnit report, run.log carries the cleanup output, there is no error annotation, and staging was back to its four permanent environments 80 seconds after the cancel and still ten minutes later. Changes since that build: an unbufferedsedso the cleanup output streams to the job log, and the review fixes (the container kill scoped to the job's composition,--kill-afteron the cleanup's timeout, the QPS sweep's disable inside its retry loop, sturdiermz region listparsing, the staging image version computed on use, the results uploaded before the teardown).mz region enable. The trap's cleanup disabled and verified the region in 18 seconds, no error annotation, no JUnit artifact, and staging was back to its four permanent environments 92 seconds after the cancel and still ten minutes later.--target=dockeror without--cleanupthe workflow is a no-op,default --helpstill parses, and shellcheck, pyright andbin/fmtare clean.Out of scope
cloud-canaryandmz-e2edisable their Cloud region from atry/finallytoo, so a cancelled job leaves it up the same way. They stay as they are for now: the risk is much lower, since the next Nightly's start-of-run disable cleans up within a day rather than a week, their environments are far cheaper, and their runs last minutes rather than hours, so the window for a bad cancel is short. Historically it has not bitten: in the last two months of metrics, no cancel left either region up for more than 20 minutes, andcloud-canaryhas not been cancelled or timed out at all since December 2025. They can adoptci-cleanupin a follow-up.🤖 Generated with Claude Code