fix: workflow neuro timeouts
runner ubuntu smoke / ubuntu-latest label smoke (push) Successful in 0s
runner nix smoke / nix label and flake smoke (push) Failing after 8s

This commit is contained in:
2026-09-10 10:00:28 +00:00
parent a100ff2ca7
commit 1665aaf90a
9 changed files with 202 additions and 13 deletions
+3 -2
View File
@@ -14,7 +14,7 @@ jobs:
name: deploy neuro from performance node name: deploy neuro from performance node
if: ${{ gitea.ref == 'refs/heads/master' }} if: ${{ gitea.ref == 'refs/heads/master' }}
runs-on: gross-nix-x86-perf runs-on: gross-nix-x86-perf
timeout-minutes: 60 timeout-minutes: 435
env: env:
NIX_CONFIG: | NIX_CONFIG: |
fallback = true fallback = true
@@ -39,7 +39,8 @@ jobs:
- name: Deploy neuro - name: Deploy neuro
env: env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
WITH_ATTIC_BUILD_TIMEOUT: "2700" WITH_ATTIC_BUILD_TIMEOUT: "21600"
WITH_ATTIC_DRAIN_TIMEOUT: "3600"
WITH_ATTIC_UPLOAD_TIMEOUT: "600" WITH_ATTIC_UPLOAD_TIMEOUT: "600"
WITH_ATTIC_BATCH_SIZE: "8" WITH_ATTIC_BATCH_SIZE: "8"
NEURO_SSH_PRIVATE_KEY: ${{ secrets.NEURO_SSH_PRIVATE_KEY }} NEURO_SSH_PRIVATE_KEY: ${{ secrets.NEURO_SSH_PRIVATE_KEY }}
+28 -5
View File
@@ -196,12 +196,20 @@ minutes for the wrapped command, 10 minutes for the final drain, and three
`WITH_ATTIC_UPLOAD_TIMEOUT`, `WITH_ATTIC_UPLOAD_RETRIES`, and `WITH_ATTIC_UPLOAD_TIMEOUT`, `WITH_ATTIC_UPLOAD_RETRIES`, and
`WITH_ATTIC_BATCH_SIZE` (positive integer seconds/counts without leading zeros). `WITH_ATTIC_BATCH_SIZE` (positive integer seconds/counts without leading zeros).
The heavier `deploy-neuro` workflow overrides these defaults: 45 minutes for the The heavier `deploy-neuro` workflow overrides these defaults: 6 hours for the
command, batches of at most 8 paths, and 600 seconds per upload attempt. The build/deploy command, batches of at most 8 paths, and 600 seconds per upload attempt. The
upload deadline covers the **whole batch**, not each individual path. Its final upload deadline covers the **whole batch**, not each individual path. Its final
drain remains bounded at 10 minutes; the 60-minute job budget leaves 5 minutes drain is bounded at 1 hour; the 435-minute job budget leaves 15 minutes for setup
for setup. A prolonged cache outage can still exhaust that drain before every and cleanup. The `gross-nix-x86-perf` runner limit and Gitea's endless-task
queued path is uploaded. watchdog are 8 hours. The VM lifetime starts at allocation and includes the
controller's additional 10-minute grace. A prolonged cache outage can still
exhaust the drain before every queued path is uploaded.
The build timeout covers the entire wrapped command, not each derivation.
Completed outputs can be reused from the cache, but an interrupted CUDA/Magma
compilation does not produce a cacheable output or resume on the next ephemeral
runner. Exit code 124 with `interrupted by the user` can therefore mean the
wrapper deadline expired, not that someone manually cancelled the job.
The workflow also sets `fallback = true` in `NIX_CONFIG`, inherited by nested The workflow also sets `fallback = true` in `NIX_CONFIG`, inherited by nested
Nix commands. If substitution fails, Nix can build the affected derivation from Nix commands. If substitution fails, Nix can build the affected derivation from
@@ -268,6 +276,21 @@ Usually means:
- timeout/reverse proxy issue - timeout/reverse proxy issue
- bad token permissions - bad token permissions
On `hectic-lab`, the upload API has separate nginx locations for
`/_api/v1/upload-path` and `/next/_api/v1/upload-path`. Requests stream to Attic
without whole-body buffering, using HTTP/1.1 upstream and 600-second
`proxy_send_timeout` and `proxy_read_timeout` values. These are inactivity
timeouts, not an upload throughput guarantee. The CI wrapper still enforces its
own whole-batch deadline. The legacy `/previous/` endpoint stays read-only.
The host's Attic package also restricts its AWS SDK rustls connector to HTTP/1.1
after observed S3 `REFUSED_STREAM` failures. This is a reproducible, host-scoped
derived Cargo vendor tree; the pinned input tree and Cargo.lock are unchanged.
TLS certificate verification remains enabled, and client-to-nginx HTTP/2 is not
disabled. The pinned crate path makes upstream changes fail visibly during a
future upgrade. This mitigates the observed transport error, not every possible
Hetzner S3 timeout.
### Cache pulls do not work ### Cache pulls do not work
Check: Check:
+4 -2
View File
@@ -20,8 +20,10 @@ read availability is fixed.
All migration, verification, seeding, and watcher jobs have been stopped for All migration, verification, seeding, and watcher jobs have been stopped for
user-controlled load testing. Do not automatically restart bulk verification. user-controlled load testing. Do not automatically restart bulk verification.
The current generation is The transport-fix generation is
`/nix/store/s7x1n9zprjzagb9pvkl0k4igdgnbbchh-nixos-system-hectic-lab-25.11.20260526.25f5383`. `/nix/store/mjx3gzrh03vq22j5nmrzwa56kfhdn702-nixos-system-hectic-lab-25.11.20260526.25f5383`
with the HTTP/1.1 S3 transport and upload-proxy timeout correction. Check
`/run/current-system` to confirm the active deployment.
The pinned rollback generation remains at The pinned rollback generation remains at
`/var/lib/attic-repack/rollback-system`; backups remain private under `/var/lib/attic-repack/rollback-system`; backups remain private under
`/var/lib/attic-repack/backups` and include the cache signing key. `/var/lib/attic-repack/backups` and include the cache signing key.
+26
View File
@@ -557,6 +557,32 @@ Org-level (preferred) or per-repo, on `https://gitea.hectic-lab.com`:
- Secret: value of `gitea/hectic-lab/controller/webhook-secret` - Secret: value of `gitea/hectic-lab/controller/webhook-secret`
- Trigger events: `Workflow jobs` only (`workflow_job`) - Trigger events: `Workflow jobs` only (`workflow_job`)
### Long CUDA/Magma deployment time budgets
The `deploy-neuro` workflow uses these nested limits:
| Layer | Limit |
| --- | --- |
| Wrapped build/deploy command | 6 hours (`WITH_ATTIC_BUILD_TIMEOUT=21600`) |
| Final cache drain | 1 hour (`WITH_ATTIC_DRAIN_TIMEOUT=3600`) |
| Workflow job | 435 minutes, including 15 minutes of setup/cleanup margin |
| `gross-nix-x86-perf` runner | 480 minutes |
| Gitea `actions.ENDLESS_TASK_TIMEOUT` | 8 hours |
| VM hard lifetime from allocation | 480 minutes plus 10-minute controller grace |
Other runner labels keep their existing 180-minute limits. Deploy the controller
and Gitea watchdog settings before dispatching the longer workflow. Already
allocated VMs retain the TTL and runner configuration assigned when they were
created; updating the controller does not extend a running job.
These are maximum lifetimes: terminal jobs still trigger immediate VM teardown.
The controller's budget reservation uses the full label TTL, so a long-running
label reserves more of the existing monthly budget. Do not raise that budget or
disable timeout safeguards just to bypass a refused allocation.
After changing any timeout, verify the complete chain rather than only
`timeout-minutes`; a shorter wrapper, runner, server watchdog, or VM TTL wins.
### Pre-flight verification (before first real job) ### Pre-flight verification (before first real job)
```sh ```sh
+30
View File
@@ -10,6 +10,14 @@
repackedActive = true; repackedActive = true;
migrationWriteFreeze = false; migrationWriteFreeze = false;
uploadProxyConfig = ''
# Stream large NARs and tolerate S3 backpressure while Attic reads them.
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
'';
repackedSettings = config.services.atticd.settings // { repackedSettings = config.services.atticd.settings // {
listen = "127.0.0.1:8082"; listen = "127.0.0.1:8082";
allowed-hosts = [ "cache.${domain}" ]; allowed-hosts = [ "cache.${domain}" ];
@@ -54,6 +62,20 @@ in {
# Slow S3 chunk reads can exceed the SDK's default 20-second stall grace. # Slow S3 chunk reads can exceed the SDK's default 20-second stall grace.
services.atticd.package = pkgs.attic-server.overrideAttrs (old: { services.atticd.package = pkgs.attic-server.overrideAttrs (old: {
# Restrict the SDK TLS connector to HTTP/1.1 after S3 REFUSED_STREAM errors.
cargoDeps = pkgs.runCommand "attic-cargo-vendor-http1" { } ''
mkdir "$out"
shopt -s dotglob
for entry in ${old.cargoDeps}/*; do
ln -s "$entry" "$out/$(basename "$entry")"
done
crate=aws-smithy-http-client-1.0.6
rm "$out/$crate"
cp -rL ${old.cargoDeps}/"$crate" "$out/$crate"
chmod -R u+w "$out/$crate"
substituteInPlace "$out/$crate/src/client/tls.rs" \
--replace-fail '.enable_http2()' ""
'';
postPatch = (old.postPatch or "") + '' postPatch = (old.postPatch or "") + ''
substituteInPlace server/src/storage/s3.rs \ substituteInPlace server/src/storage/s3.rs \
--replace-fail 'let mut builder = S3ConfigBuilder::from(&shared_config);' \ --replace-fail 'let mut builder = S3ConfigBuilder::from(&shared_config);' \
@@ -111,6 +133,14 @@ in {
proxy_read_timeout 300s; proxy_read_timeout 300s;
''; '';
}; };
locations."= /_api/v1/upload-path" = lib.mkIf (repackedActive || !migrationWriteFreeze) {
proxyPass = if repackedActive then "http://127.0.0.1:8082" else "http://127.0.0.1:8081";
extraConfig = uploadProxyConfig;
};
locations."= /next/_api/v1/upload-path" = {
proxyPass = "http://127.0.0.1:8082/_api/v1/upload-path";
extraConfig = uploadProxyConfig;
};
locations."/previous/" = { locations."/previous/" = {
proxyPass = "http://127.0.0.1:8081/"; proxyPass = "http://127.0.0.1:8081/";
extraConfig = '' extraConfig = ''
+2
View File
@@ -355,6 +355,8 @@ in {
package = pkgs.hectic.gitea-heatmap; package = pkgs.hectic.gitea-heatmap;
settings.service.DISABLE_REGISTRATION = true; settings.service.DISABLE_REGISTRATION = true;
settings.actions.ENABLED = true; settings.actions.ENABLED = true;
# Long CUDA builds must not hit Gitea's default three-hour task watchdog.
settings.actions.ENDLESS_TASK_TIMEOUT = "8h";
settings.server = { settings.server = {
HTTP_PORT = 11011; HTTP_PORT = 11011;
SSH_PORT = sshPort; SSH_PORT = sshPort;
+1 -1
View File
@@ -28,7 +28,7 @@ gcr_label_ttl() {
gross-mixed-econ) printf '180' ;; gross-mixed-econ) printf '180' ;;
gross-nix-x86) printf '180' ;; gross-nix-x86) printf '180' ;;
gross-nix-arm) printf '180' ;; gross-nix-arm) printf '180' ;;
gross-nix-x86-perf) printf '180' ;; gross-nix-x86-perf) printf '480' ;;
gross-nix-mixed-econ) printf '180' ;; gross-nix-mixed-econ) printf '180' ;;
*) return 1 ;; *) return 1 ;;
esac esac
+30 -3
View File
@@ -45,6 +45,33 @@ if !pkgs.stdenv.hostPlatform.isLinux then {} else let
${builtins.readFile ./launch.sh} ${builtins.readFile ./launch.sh}
mkdir -p "$out" mkdir -p "$out"
''; '';
in lib.mapAttrs' (name: drv:
lib.nameValuePair "with-attic-cache-${name}" (mkTest name drv) timeBudgets = pkgs.runCommand "with-attic-cache-time-budgets"
) testDrvs {
nativeBuildInputs = [
(pkgs.python3.withPackages (p: [ p.pyyaml ]))
pkgs.dash
];
DASH = "${pkgs.dash}/bin/dash";
WORKFLOW_FILE = ../../../.gitea/workflows/deploy-neuro.yaml;
DECIDE_SH = ../../../package/gitea-runner-controller/decide.sh;
CONTROLLER_SH = ../../../package/gitea-runner-controller/controller.sh;
HCLOUD_SH = ../../../package/gitea-runner-controller/hcloud.sh;
GCR_GITEA_URL = "https://example.invalid";
GCR_NIX_VERSION = "2.24.0";
GCR_NIX_TARBALL_SHA256 = "dummy-x86-nix-sha256";
GCR_ARM_NIX_TARBALL_SHA256 = "dummy-arm-nix-sha256";
GCR_ACT_RUNNER_VERSION = "0.2.11";
GCR_ACT_RUNNER_SHA256 = "dummy-runner-sha256";
GITEA_WATCHDOG = self.nixosConfigurations."hectic-lab|x86_64-linux".config.services.gitea.settings.actions.ENDLESS_TASK_TIMEOUT;
} ''
python ${./time-budgets.py}
mkdir -p "$out"
'';
discoveredTests = lib.mapAttrs' (name: drv:
lib.nameValuePair "with-attic-cache-${name}" (mkTest name drv)
) testDrvs;
in discoveredTests // {
with-attic-cache-time-budgets = timeBudgets;
}
@@ -0,0 +1,78 @@
import os
import re
import subprocess
import yaml
def need(condition, message):
if not condition:
raise AssertionError(message)
def minutes(value):
text = str(value)
match = re.fullmatch(r"(\d+)([smh])?", text)
if match is None:
raise AssertionError(f"invalid duration: {text}")
amount = int(match.group(1))
unit = match.group(2) or "m"
return amount // 60 if unit == "s" else amount * 60 if unit == "h" else amount
def dash(script):
return subprocess.check_output(
[os.environ["DASH"], "-eu", "-c", script],
text=True,
env=os.environ,
).strip()
def workflow_budget(path):
with open(path, encoding="utf-8") as handle:
data = yaml.safe_load(handle)
job = data["jobs"]["deploy"]
step = next(s for s in job["steps"] if s.get("name") == "Deploy neuro")
env = step["env"]
return {
"label": job["runs-on"],
"workflow": int(job["timeout-minutes"]),
"build": int(env["WITH_ATTIC_BUILD_TIMEOUT"]) // 60,
"drain": int(env["WITH_ATTIC_DRAIN_TIMEOUT"]) // 60,
"upload": int(env["WITH_ATTIC_UPLOAD_TIMEOUT"]) // 60,
}
def shell_budgets(label):
source = f'. "$DECIDE_SH"; . "$CONTROLLER_SH"; . "$HCLOUD_SH"; '
ttl = int(dash(source + f'gcr_label_ttl "{label}"'))
profile = dash(source + f'gcr_label_profile "{label}"')
grace = int(dash(source + 'gcr_ttl_grace_sec')) // 60
script = source + f'gcr_bootstrap_script "{label}" token {ttl} runner'
rendered = dash(script)
match = re.search(r"runner:\n(?: .*\n)* timeout: (\d+)m\n", rendered)
if match is None:
raise AssertionError("runner timeout missing in rendered bootstrap")
label_line = f' - "{label}:host"'
need(label_line in rendered, "runner label missing in rendered bootstrap")
labels = "gross-x86 gross-arm gross-x86-perf gross-mixed-econ " \
"gross-nix-x86 gross-nix-arm gross-nix-mixed-econ"
others = dash(source + f"for l in {labels}; do "
"printf '%s=%s\\n' \"$l\" \"$(gcr_label_ttl \"$l\")\"; done")
return ttl, profile, grace, int(match.group(1)), others.splitlines()
budget = workflow_budget(os.environ["WORKFLOW_FILE"])
need(budget["label"] == "gross-nix-x86-perf", "unexpected deployment runner label")
need(budget["build"] > 0 and budget["drain"] > 0 and budget["upload"] > 0, "non-positive timeout")
need(budget["build"] + budget["drain"] + 15 <= budget["workflow"], "workflow too short for build+drain")
ttl, profile, grace, runner_timeout, other_ttls = shell_budgets(budget["label"])
need(ttl == 480, f"expected 480m ttl, got {ttl}")
need(profile.split()[1] == "480", f"profile ttl drifted: {profile}")
need(all(line.endswith("=180") for line in other_ttls), f"default ttl drift: {other_ttls}")
need(budget["workflow"] < runner_timeout, "workflow must be below runner timeout")
need(minutes(os.environ["GITEA_WATCHDOG"]) >= budget["workflow"], "Gitea watchdog too short")
need(ttl + grace >= budget["workflow"] + 15, "VM ttl lacks bootstrap allowance")
print(f"PASS build={budget['build']}m drain={budget['drain']}m upload={budget['upload']}m workflow={budget['workflow']}m runner={runner_timeout}m ttl={ttl}m grace={grace}m watchdog={os.environ['GITEA_WATCHDOG']}")