Skip to content

fix(ci): replace racy staging-box wait with an atomic lock-slot semaphore - #197

Merged
rfay merged 6 commits into
mainfrom
20260808_rfay_ci_staging_box_lock
Aug 8, 2026
Merged

fix(ci): replace racy staging-box wait with an atomic lock-slot semaphore#197
rfay merged 6 commits into
mainfrom
20260808_rfay_ci_staging_box_lock

Conversation

@rfay

@rfay rfay commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

ci-wait-for-staging-box.sh serialized CI workspace creation on the shared staging box by polling coder list for a zero ci-bot workspace count, then creating — a check-then-act race. Jobs come from three workflow files and both self-hosted and GitHub-hosted runners, all hitting the same remote Coder server, so multiple jobs could observe "0 workspaces" in the same ~30s poll window and all create at once.

This happened in production: three ci-bot workspaces ran concurrently on a box sized for about two, and the job racing into that window failed its agent connection ("Agent doesn't exist with that id" — see run 31230219543).

  • Replaces the racy poll with a real atomic semaphore: a new ci-lock template (no Docker/Sysbox, just a no-op resource) provisions N fixed-name slot workspaces (ci-slot-1..ci-slot-N). Coder enforces a unique workspace name per owner, so claiming a slot via coder create ci-slot-<i> is a genuine compare-and-swap instead of a poll.
  • Abandoned slots self-heal via a staleness check in the new acquire script, and — as a backstop — the existing ci-reap-staging.sh janitor, which already reaps any stale ci-bot workspace by age with no changes needed there.
  • Full design, and the git-ref-based alternative that was considered and rejected, is in openspec/changes/add-ci-staging-lock/.

Changing how many CI workspaces can run at once

The limit N is the CI_LOCK_SLOTS repository variable (GitHub → Settings → Secrets and variables → Actions → Variables) — no code change needed to retune it. Unset defaults to 2. Now documented in docs/admin/server-setup.md (it wasn't documented anywhere before this PR):

  • Raise it if staging has spare CPU/RAM headroom and jobs are spending a lot of time queued waiting for a slot.
  • Lower it if jobs are failing with agent-connection errors that trace back to the box being oversubscribed — each workspace's default request is 4 CPU / 8GB, so pick N against the box's actual cores/RAM.

Takes effect on the next workflow run; no restart or redeploy required.

Test plan

  • make validate (all four templates, including the new ci-lock)
  • make test-templates
  • terraform fmt -check -recursive
  • YAML syntax validated on all three modified workflow files
  • ci-acquire-staging-lock.sh / ci-release-staging-lock.sh exercised locally against a mocked coder CLI: confirmed only N of several concurrent contenders acquire distinct slots and the rest wait/retry; confirmed release→acquire handoff to a waiting contender; confirmed a stale abandoned slot gets reclaimed
  • Watch the next few staging runs of integration-test.yml / drupal-integration-test.yml / drupal-contrib-integration-test.yml for clean acquire/release behavior under real load

Note

Every self-hosted (sysbox) job in all three workflows is currently disabled (if: false) — only the GitHub-hosted -gh variants are active, and those are the only ones wired into the lock here (they're what was actually racing). If the self-hosted jobs are ever re-enabled, they'll need the same acquire/release wiring added.

rfay and others added 3 commits August 8, 2026 03:09
Matches the YYYYMMDD_<username>_<short_description> convention already
used in ../ddev/CLAUDE.md, so branches created here follow the same
org-wide standard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hore

ci-wait-for-staging-box.sh polled `coder list` for a zero ci-bot workspace
count before creating a workspace -- a check-then-act race. Jobs come from
three workflow files and both self-hosted and GitHub-hosted runners, all
hitting the same remote Coder server, so multiple jobs could observe "0
workspaces" in the same poll window and all create at once. This happened
in production: three ci-bot workspaces ran concurrently on a box sized for
about two, and the job racing into that window failed its agent connection
("Agent doesn't exist with that id").

Replace it with a real semaphore: a new ci-lock template (no Docker/Sysbox,
just a no-op resource) provisions N fixed-name slot workspaces
(ci-slot-1..N). Coder enforces a unique workspace name per owner, so
claiming a slot via `coder create ci-slot-<i>` is a genuine atomic
compare-and-swap instead of a poll. Abandoned slots self-heal via a
staleness check in the acquire script and, as a backstop, the existing
ci-reap-staging.sh janitor (which already reaps any stale ci-bot workspace
by age, with no changes needed there).

See openspec/changes/add-ci-staging-lock/ for the full design and the
git-ref-based alternative that was considered and rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was only mentioned in the OpenSpec design notes; operators need it in
docs/admin/server-setup.md alongside the other CI repository variables
to know it exists and how to tune it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-08 13:17 UTC

rfay and others added 3 commits August 8, 2026 04:15
…h step

All five PR #197 integration-test jobs failed after burning the full
45-minute retry budget: ci-lock had never been pushed to
staging-coder.ddev.com, so every `coder create ci-slot-<i>` failed with
"no template found", which the acquire script indistinguishably treated
as "slot busy, keep retrying".

Add a cheap `coder templates list` preflight so a missing template fails
in under a second with an actionable message instead of after 45 minutes.
Document the one-time `make push-all-templates` step this depends on in
server-setup.md. Also pushed ci-lock (and refreshed the other three) to
staging-coder.ddev.com directly and smoke-tested a real create/delete
cycle against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`coder templates list --output json` wraps template fields under
`.Template.*` (unlike `coder list` for workspaces, which is flat) --
the previous commit's preflight checked `.name` instead of
`.Template.name`, so it always reported ci-lock as missing even
though it exists, failing every job immediately. Verified the fix
directly against staging-coder.ddev.com: the corrected filter finds
ci-lock, a live acquire/release cycle succeeds end to end, and the
old filter's false negative is confirmed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real bugs surfaced by staging runs at CI_LOCK_SLOTS=3:

1. ci-reap-staging.sh archived every "unused" template version on each
   15-minute sweep, with no age check. A job that pushes its version and
   then waits behind ci-acquire-staging-lock.sh for a free slot can have
   that version swept up as "unused" before it ever gets to create a
   workspace from it -- confirmed directly: the janitor ran mid-wait for
   two contrib jobs and both then failed with "Archived template versions
   cannot be used to make a workspace." Now enumerates versions itself and
   only archives ones older than VERSION_AGE_MINUTES (default 60, well
   above the lock's 45-minute worst-case wait).

2. The box turns out to tolerate many concurrent workspaces fine -- what it
   can't take is several hitting their heaviest startup work (Docker image
   builds, composer installs) at the same instant. Add STAGGER_SECONDS
   (default 90) to ci-acquire-staging-lock.sh: after claiming a slot, sleep
   out the remainder of that window since the most recently started sibling
   slot, so simultaneous starts get spread out even when slots are free.
   This lets CI_LOCK_SLOTS stay higher without recreating the original
   startup-burst failures.

Both changes verified directly against staging-coder.ddev.com (age-gated
dry-run confirmed against real version ages; stagger confirmed with two
live acquire cycles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@rfay
rfay merged commit d5e648d into main Aug 8, 2026
16 checks passed
@rfay
rfay deleted the 20260808_rfay_ci_staging_box_lock branch August 8, 2026 13:17
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