Skip to content

fix(maintenance): make disk-cleanup CI-aware and stack-safe - #405

Merged
acamarata merged 4 commits into
mainfrom
fix/disk-cleanup-ci-aware
Sep 11, 2026
Merged

acamarata merged 4 commits into
mainfrom
fix/disk-cleanup-ci-aware

Conversation

@acamarata

Copy link
Copy Markdown
Collaborator

Summary

nSelf staging (167.235.233.65) hit 100% disk on 2026-09-11 while nself-disk-cleanup.timer ran daily and reclaimed nothing that mattered. Two defects:

  1. DiskCleanup() only did docker system prune -af --volumes=false + journalctl --vacuum-time=7d — neither touches what actually grows on a CI box (runner _work job dirs 7.8G, ~/.cache 12G, ~/go/pkg 4.7G, pnpm stores 6.8G combined). A wrapper's global pgrep -f Runner.Worker busy check also meant one busy runner out of four skipped cleanup for all four runners.
  2. docker system prune -af is dangerous on an nself box: -a removes all unused images, including ones a stopped stack container still depends on — a past run of this exact command deleted ntask's postgres/redis images and caused a 3-day Hasura outage.

Changes

  • Runner workspace reclaim: discovers runner roots via systemd unit introspection (actions.runner.*), falling back to a documented glob list, with an NSELF_MAINTENANCE_RUNNER_ROOTS env override. Only direct children of <root>/_work are candidates; _actions, _tool, _temp, _PipelineMapping are permanently excluded (deleting _actions under a live job caused the incident).
  • Cache reclaim: ~/go/pkg/mod, ~/pnpm-store, /opt/pnpm-store, and ~/.cache/* — except go-build, grype, trivy, which a running compile/scan reads live (deleting these mid-job broke plugins-pro#113/fix(embedded-pg): correct sha256 pin and add CDN fallback for pglite WASM #114 previously).
  • Docker safety fix: docker system prune -af replaced with dangling-image prune + build-cache prune + anonymous-volume-only prune (64-hex names via filterAnonymousVolumes, never a named *_data volume).
  • Per-runner busy detection: matches each runner's own worker process path against its own root, instead of one global pgrep.
  • Disk-pressure escalation: default 85% threshold (DiskCleanupOptions.PressureThreshold), above which the idle-preferred shared-cache tier runs regardless of busy state. Runner job workspaces stay idle-gated unconditionally — deleting one breaks that job outright regardless of pressure.
  • Reporting: CleanupResult now carries BytesReclaimed, Reclaimed []ReclaimEntry, Skipped []SkipEntry so a timer run is diagnosable.
  • Dry-run: DiskCleanupDryRun() / DiskCleanupOptions{DryRun: true} reports what would be removed without removing anything.
  • Windows: kept green with a parallel DiskCleanupWithOptions that applies the same docker-prune safety fix; runner-farm reclaim is POSIX-only and reported as skipped on Windows rather than attempted.

Test plan

  • gofmt -l internal/maintenance/ — empty
  • make vet — clean
  • go test ./internal/maintenance/... -v — 12/12 pass, covering: protected runner subdirs preserved, go-build/grype/trivy preserved (including under pressure escalation), per-runner busy detection cleans the idle runner and skips the busy one, pressure escalation triggers above threshold, dry-run removes nothing, anonymous-volume filtering excludes named volumes
  • GOOS=windows go build ./internal/maintenance/... — clean
  • go build ./... — clean
  • Tests use t.TempDir() fixtures and injectable hooks (listRunnerWorkerProcesses, dockerReclaimFunc, logRotationFunc, journalVacuumFunc) — no real docker daemon, runner, or system paths are touched.

nSelf staging hit 100% disk on 2026-09-11 while nself-disk-cleanup.timer
ran daily: DiskCleanup() only pruned docker (unsafely, with -af) and
vacuumed 7-day-old journal logs, neither of which touches what actually
grows on a runner farm (runner _work job dirs, go-build/module caches,
pnpm stores). A wrapper's global `pgrep -f Runner.Worker` busy check also
meant one busy runner out of four skipped cleanup for all four.

- Reclaim GitHub Actions runner job workspaces under "<root>/_work",
  discovered via systemd unit introspection with a glob fallback and an
  env var override (NSELF_MAINTENANCE_RUNNER_ROOTS). _actions/_tool/_temp/
  _PipelineMapping are never touched — deleting _actions under a live job
  is what caused the incident.
- Reclaim go/pkg/mod, pnpm-store, and ~/.cache (minus go-build/grype/
  trivy, which a running compile or scan reads live).
- Replace `docker system prune -af --volumes=false` with dangling-image
  prune, build-cache prune, and anonymous-volume-only prune (64-hex names
  via filterAnonymousVolumes) — this can no longer remove an image a
  stopped stack container depends on, unlike the command that caused the
  3-day ntask Hasura outage.
- Detect busy state per runner root (worker process path contains that
  runner's own directory) instead of globally.
- Add a disk-pressure threshold (default 85%, overridable) above which
  the idle-preferred shared-cache tier runs regardless of busy state —
  runner job workspaces stay idle-gated at every tier since deleting one
  breaks its job outright.
- CleanupResult now carries BytesReclaimed/Reclaimed/Skipped so a timer
  run is diagnosable, plus a DryRun mode via DiskCleanupDryRun().
- Keep the Windows build green with a parallel, guarded implementation
  that gets the same docker-prune fix; runner-farm reclaim is POSIX-only.
A per-runner busy check is necessary but not sufficient: the check and the
RemoveAll are not atomic. Cleaning workspaces on runners that reported idle
destroyed three live jobs on nSelf staging on 2026-09-11, which failed with
'Directory .../_work/web/web does not exist' — jobs had started in the window
between the scan and the removal.

A running job writes into its workspace constantly, which is the signal a
process scan cannot give. Require a workspace to have gone untouched for
workspaceStaleAfter (30m) before removing it. A merely slow job still touches
its files; a finished one cannot. Existing tests build fresh fixtures and so
opt out via withoutStalenessGuard.

Also drops DefaultPressureThreshold from 85 to 75. That number has to keep a
different check satisfied: 'nself doctor --deep' fails the host at 80% used
('Disk free: /: N% free (<20%)'). Escalating at 85 leaves a band where cleanup
is content and doctor is red, which is the state staging was in that day — the
dogfood gate failing on disk while the cleanup timer reported nothing to do.
A test pins the two thresholds in the correct order.
golangci-lint fails the build on it: 'const tierAlways is unused'. go vet does
not flag unused constants, which is why it passed locally.

The always-safe reclaims (docker dangling images and build cache, anonymous
volumes, old compressed logs, journald history) genuinely have no ReclaimEntry
records to tag. Those commands report their own freed space through
CleanupResult's DockerPruneOut / LogRotationOut / JournalVacuumOut and give no
per-path byte attribution, so synthesising zero-byte entries purely to carry a
tier label would make the reclaimed list read as though nothing was freed.

Keeps the tier documented in prose and says why it has no constant.
The glob fallback covered /opt/actions-runner*, /home/*/actions-runner* and
/home/*/*/actions-runner*. nSelf staging installs the runner serving the web
repo at /home/runner/github-runner, which matches none of them.

That install held 5.1G, 2.3G of it job workspaces, and was invisible to
cleanup: not a candidate at any tier, under any pressure, because discovery
never returned it. The box filled to 100% on 2026-09-11 with that space sitting
unreclaimable.

Adds the github-runner naming in both /home and /opt, and a test that pins
every convention actually in use. A runner discovery never returns is a runner
cleanup can never reclaim, so the test asserts the glob set matches each real
install path rather than asserting the list's contents.
@acamarata
acamarata merged commit 0036b91 into main Sep 11, 2026
31 checks passed
@acamarata
acamarata deleted the fix/disk-cleanup-ci-aware branch September 11, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant