Skip to content

fix(e2e): cap scenario concurrency and narrow the transient classifier - #162

Merged
joshua-temple merged 2 commits into
mainfrom
fix/e2e-contention-and-transient-classifier
Jun 14, 2026
Merged

joshua-temple merged 2 commits into
mainfrom
fix/e2e-contention-and-transient-classifier

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

The recurring hotfix-family e2e "flake" is self-inflicted contention, not an unavoidable external transient. Each scenario stands up its own docker network + gitea + act, and act spawns nested job containers per workflow run. Under parallelism the heaviest (hotfix-family) scenarios run their full stacks concurrently and oversubscribe the docker address pool (#125), host RAM, or gitea (#121). act then exits non-zero with no parsed job failure and no cascade crash frame, lands in the classifier's catch-all default -> transient bucket, and burns the entire retry budget hitting the same ceiling.

Fix

  • Harness concurrency cap. New process-wide scenarioConcurrency semaphore in the scenario runner, acquired before each scenario stands up its docker/gitea/act stack and released after teardown - independent of go test -parallel. Sized from host CPUs and total memory (min of floor(NumCPU/2) and floor(totalGiB/3), floored at 1), with an E2E_MAX_CONCURRENT override. On a 4-core/8GB runner this caps at 2, removing the oversubscription at its root.
  • Narrowed transient classifier. A non-zero act exit is now classified transient (retryable) ONLY when a named host-saturation signature is present in the raw output: docker address-pool exhaustion, "no space left on device", "cannot allocate memory"/OOM, gitea connection reset/refused, or "Cannot connect to the Docker daemon". Every other job-less, crash-free non-zero exit is now a deterministic real failure that surfaces on attempt 1 instead of being masked. The job-failure and fix: detect runtime crashes in e2e harness and preserve retry evidence #146/fix: only treat a crash with a cascade frame as non-transient #147 crash-frame paths are intact.
  • Reduced retry budget. Scenario attempts cut from 5 to 2 (one retry), and a retry now only applies to the named-infra transients. A deterministic failure fails on attempt 1 with no retry.
  • Cleanup confirmed. Per-scenario teardown runs via defer h.Cleanup() on every path (including setup failure), so removeNetwork always runs and networks do not leak to re-cause fix(e2e): eliminate docker network address-pool exhaustion #125.

Untouched (justified) retries

gitea-405 gitea_retry.go, network-removal removeNetwork, gitea convergence polls, and the product CommitAndPushWithRetry/generated push-retry loops are unchanged - those guard genuine external transients.

Verification

  • go build ./...; e2e go build/vet; golangci-lint run ./... clean on both modules.
  • New unit tests prove the masking is gone, including "non-infra non-zero exit is NOT transient", per-signature infra matches, crash and real-job-failure paths still classify correctly, and the semaphore bounds concurrency.
  • One representative scenario passes end-to-end against real docker: --- PASS: TestMultiStepScenarios/Two_Environment_Happy_Path (53.17s).

Note: the address-pool ceiling itself is best mitigated by a daemon default-address-pools headroom change on the runner, which the repo code cannot set alone; the cap + narrowed classifier make any residual saturation deterministic and named rather than silently retried.

Each scenario stands up its own docker network + gitea + act with nested act job containers. Run the heavy hotfix-family scenarios concurrently and they oversubscribe the docker address pool, host RAM, or gitea; act then exits non-zero with no parsed job failure and no cascade crash frame, lands in the classifier catch-all transient bucket, and burns the whole retry budget against the same ceiling.

Add a host-sized scenarioConcurrency semaphore (independent of go test -parallel, E2E_MAX_CONCURRENT override) so heavy stacks serialize and stop self-inflicting the contention. Narrow the transient classifier so a non-zero act exit is retryable ONLY when a named infra-saturation signature is present (address-pool exhaustion, no space left, cannot allocate memory, gitea connection reset/refused, docker daemon unreachable); every other job-less, crash-free non-zero exit is now deterministic. Cut the retry budget from 5 to 2. Justified retries (gitea-405, network removal, convergence polls, product push) are untouched.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit f927b8a into main Jun 14, 2026
7 checks passed
joshua-temple added a commit that referenced this pull request Jun 14, 2026
#162 (#163)

* test(e2e): seed callback workflows before generation for validate and failing-rollback scenarios

PR #161 removed the inline run callback emission path and converted two scenarios to reusable workflows, but staged the referenced workflow bodies only via a step commit.files, which lands after generation runs. The harness seeds callback stubs and runs generation once at setup, so validate.yaml was missing at generation time and the failing rollback deploy used the generic non-failing stub. PR #162 narrowed the transient classifier and surfaced both.

Add a setup_workflows map on MultiStepScenario seeded into the setup commit before generation, and a validate workflow_call stub clause keyed on config.validate.workflow. Move the failing deploy-app.yaml into setup_workflows so the rollback re-deploy fails under the Rollback caller, and rely on the seeded validate stub so orchestrate.yaml generation emits the gate.

Test-infra only; no product behavior change.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>

* test(e2e): fail rollback re-deploy via dispatch env not caller workflow name

Inside a reusable workflow_call callback, $GITHUB_WORKFLOW is the callee's own name, not the caller's, on act and on real GitHub. The converted rollback scenario keyed its failing deploy on the caller name (Rollback*), which never matches, so the re-deploy always succeeded and the state-unchanged assertion broke. Both promote and rollback also dispatch via workflow_dispatch, so the event name cannot disambiguate either. Set CASCADE_E2E_ROLLBACK on the rollback dispatch only and key the deploy callback on it; act passes top-level env into the reusable callee.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>

---------

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant