Replace builder droplets stuck provisioning (#864)

A droplet DigitalOcean still reports as "new" never registers a runner, but
the controller counted it as one booting and created no replacement until
MAX_AGE_MINUTES. A publish job sat queued for minutes behind one in mkc1.

Delete droplets still provisioning after MAX_BOOT_MINUTES (10) and leave them
out of the live count, so the same tick creates a replacement.
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-10-08 11:59:48 -04:00
1 parent 259a3fa8b8
commit 5961a3ff5d
4 files changed
+18 -4

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ signing on merge exactly as before.
- `controller.sh` — systemd timer every minute on a small always-on droplet.
Polls for queued jobs with our label, creates one g5-32vcpu-64gb-50gb droplet per job up
to `MAX_DROPLETS`, deletes droplets that are powered off or older than
`MAX_AGE_MINUTES`. Builders go in any region DigitalOcean lists the size in
`MAX_AGE_MINUTES`, or still provisioning after `MAX_BOOT_MINUTES`. Builders go in any region DigitalOcean lists the size in
stock in (`REGIONS` only sets which to try first); a refused create, logged
with DigitalOcean's message, falls back to the next region, then the next
of `SIZES`. No inbound endpoint. Plain curl against both APIs, no
+2
View File
@@ -11,6 +11,8 @@ SIZES="g5-32vcpu-64gb-50gb g5-32vcpu-128gb-50gb"
REGIONS=
MAX_DROPLETS=6
MAX_AGE_MINUTES=200
# Delete a droplet DigitalOcean still reports as provisioning after this long.
MAX_BOOT_MINUTES=10
LOCK=/run/omarchy-controller/lock
# Operator public keys for root on every builder droplet (JSON array).
# create.sh fills this from the operators' GitHub keys.
+12 -3
View File
@@ -4,7 +4,8 @@
# Run from a systemd timer every minute on a small always-on droplet. No
# inbound endpoint: it polls GitHub for queued jobs wanting our label, creates
# one ephemeral droplet per job (up to MAX_DROPLETS), and deletes droplets
# that have powered off or exceeded MAX_AGE_MINUTES. The reaper does not
# that have powered off, exceeded MAX_AGE_MINUTES, or are still provisioning
# after MAX_BOOT_MINUTES. The reaper does not
# trust its own bookkeeping: it lists by tag and acts on what DigitalOcean
# reports.
#
@@ -30,6 +31,10 @@ REGIONS=${REGIONS:-${REGION:-}}
IMAGE=${IMAGE:-ubuntu-24-04-x64}
MAX_DROPLETS=${MAX_DROPLETS:-4}
MAX_AGE_MINUTES=${MAX_AGE_MINUTES:-200}
# A droplet DigitalOcean still reports as "new" this long after creation is
# stuck provisioning. Left alone it counts as a runner booting, and holds a
# queued job until MAX_AGE_MINUTES.
MAX_BOOT_MINUTES=${MAX_BOOT_MINUTES:-10}
RUNNER_VERSION=${RUNNER_VERSION:-2.337.0}
CLOUD_INIT=${CLOUD_INIT:-$(dirname "$0")/runner-cloud-init.yaml}
# Operator public keys authorized on every builder (JSON array of strings).
@@ -60,7 +65,8 @@ reap() {
while read -r id status created; do
[[ -n "$id" ]] || continue
age=$(( (now - $(date -d "$created" +%s)) / 60 ))
if [[ $status == off ]] || (( age > MAX_AGE_MINUTES )); then
if [[ $status == off ]] || (( age > MAX_AGE_MINUTES )) ||
{ [[ $status == new ]] && (( age > MAX_BOOT_MINUTES )); }; then
log "deleting droplet $id (status=$status age=${age}m)"
do_api "droplets/$id" -X DELETE
fi
@@ -99,7 +105,10 @@ queued_jobs() {
}
live_droplets() {
do_api "droplets?tag_name=$TAG&per_page=200" | jq '[.droplets[] | select(.status != "off")] | length'
# Not the ones reap() just deleted: DigitalOcean can list them for a while.
do_api "droplets?tag_name=$TAG&per_page=200" | jq --argjson boot "$MAX_BOOT_MINUTES" '
[.droplets[] | select(.status != "off")
| select(.status != "new" or (now - (.created_at | fromdateiso8601)) / 60 <= $boot)] | length'
}
busy_runners() {
+3
View File
@@ -15,6 +15,7 @@ CONTROLLER_LIBRARY_ONLY=1 source "$ROOT/ci/controller.sh"
CALLS_FILE=$(mktemp); trap 'rm -f "$CALLS_FILE"' EXIT
NOW=$(date -u +%FT%TZ)
OLD=$(date -u -d '5 hours ago' +%FT%TZ)
STUCK=$(date -u -d '15 minutes ago' +%FT%TZ)
# Scenario state: DROPLETS is "id status created" lines, QUEUED a count,
# BUSY a count.
@@ -54,6 +55,8 @@ DROPLETS="1 active $NOW" QUEUED=1 BUSY=1; run; check "one queued, one live but b
DROPLETS="1 active $NOW" QUEUED=1 BUSY=0; run; check "one queued, one live and idle: it will take it" 0 0
DROPLETS="1 off $NOW" QUEUED=0 BUSY=0; run; check "powered-off droplet reaped" 0 1
DROPLETS="1 active $OLD" QUEUED=0 BUSY=0; run; check "over-age droplet reaped even if active" 0 1
DROPLETS="1 new $STUCK" QUEUED=1 BUSY=0; run; check "droplet stuck provisioning reaped and replaced" 1 1
DROPLETS="1 new $NOW" QUEUED=1 BUSY=0; run; check "droplet still provisioning is left to boot" 0 0
DROPLETS=$'1 active '"$NOW"$'\n2 active '"$NOW"$'\n3 active '"$NOW"$'\n4 active '"$NOW" QUEUED=3 BUSY=4; MAX_DROPLETS=4; run; check "at cap: no creates" 0 0
DROPLETS=$'1 active '"$NOW"$'\n2 active '"$NOW" QUEUED=5 BUSY=2; MAX_DROPLETS=3; run; check "cap limits creates to remaining room" 1 0
DROPLETS="1 off $NOW" QUEUED=1 BUSY=0; MAX_DROPLETS=4; run; check "off droplet is not capacity: reaped and replaced" 1 1