Skip to content

Commit 6e9560d

Browse files
committed
✨ feat(pool): add graceful shutdown with busy runner grace period
Query Gitea API to detect busy runners during shutdown and give them a 2-minute grace period to complete their jobs before forceful stop. Idle runners are stopped immediately while busy runners are polled every 5 seconds until completion or timeout. - Add BusyRunnerGracePeriod constant (2 minutes) - Check Gitea API busy field instead of internal state - Add stopRunner() helper for consistent cleanup - Fix monitorRunner() to yield to Stop() on shutdown - Align TimeoutStopSec (150s) with grace period across all services
1 parent 07604d1 commit 6e9560d

4 files changed

Lines changed: 159 additions & 42 deletions

File tree

fireteact/internal/pool/pool.go

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ const (
2626
RunnerStateFailed RunnerState = "failed"
2727
)
2828

29+
const (
30+
// BusyRunnerGracePeriod is the time to wait for busy runners to complete
31+
// their job before forcefully stopping them during shutdown.
32+
// NOTE: This must be less than systemd's TimeoutStopSec (default 150s in services.nix)
33+
// to allow time for cleanup after the grace period expires.
34+
BusyRunnerGracePeriod = 2 * time.Minute
35+
)
36+
2937
// RunnerInfo contains information about a single runner VM.
3038
type RunnerInfo struct {
3139
ID string `json:"id"`
@@ -138,45 +146,119 @@ func (p *Pool) Start(ctx context.Context) error {
138146
// Stop gracefully stops the pool and all runners.
139147
// This includes unregistering active runners from Gitea and destroying VMs.
140148
// Runners that already completed are deregistered by monitorRunner, so we skip them here.
149+
// Busy runners (checked via Gitea API) are given a grace period to complete their job.
141150
func (p *Pool) Stop() error {
142151
p.cancel()
143152
if p.scaleTicker != nil {
144153
p.scaleTicker.Stop()
145154
}
146155
p.wg.Wait()
147156

148-
// Stop all runners
149-
p.mu.Lock()
150-
defer p.mu.Unlock()
151-
152157
// Create a context with timeout for shutdown operations
153158
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
154159
defer cancel()
155160

156-
for id, runner := range p.runners {
161+
// Collect runners to stop (take a snapshot to avoid holding lock during operations)
162+
p.mu.Lock()
163+
var activeRunners []*RunnerInfo
164+
for _, runner := range p.runners {
157165
// Skip runners that already completed - monitorRunner already deregistered them from Gitea
158166
if runner.Status == RunnerStateStopped || runner.Status == RunnerStateFailed {
159-
p.log.Debugf("Skipping cleanup for completed runner %s (status: %s)", id, runner.Status)
167+
p.log.Debugf("Skipping cleanup for completed runner %s (status: %s)", runner.ID, runner.Status)
160168
continue
161169
}
170+
activeRunners = append(activeRunners, runner)
171+
}
172+
p.mu.Unlock()
162173

163-
// For active runners, try to unregister from Gitea
164-
// This prevents the runner from showing as offline in Gitea UI
165-
if runner.Name != "" {
166-
p.log.Infof("Unregistering active runner %s from Gitea", runner.Name)
167-
if err := p.gitea.DeleteRunnerByName(shutdownCtx, runner.Name); err != nil {
168-
p.log.Warnf("Failed to unregister runner %s from Gitea: %v", runner.Name, err)
169-
// Continue with VM destruction even if unregistration fails
174+
if len(activeRunners) == 0 {
175+
// Close the VM manager
176+
if p.vmManager != nil {
177+
if err := p.vmManager.Close(); err != nil {
178+
p.log.Errorf("Failed to close VM manager: %v", err)
170179
}
171180
}
181+
return nil
182+
}
183+
184+
// Check Gitea API to determine which runners are actually busy
185+
var idleRunners, busyRunners []*RunnerInfo
186+
for _, runner := range activeRunners {
187+
giteaRunner, err := p.gitea.GetRunnerByName(shutdownCtx, runner.Name)
188+
if err != nil {
189+
p.log.Warnf("Failed to check runner %s status from Gitea: %v, treating as idle", runner.Name, err)
190+
idleRunners = append(idleRunners, runner)
191+
continue
192+
}
193+
if giteaRunner == nil {
194+
// Runner not found in Gitea - already deregistered or never registered
195+
p.log.Debugf("Runner %s not found in Gitea, treating as idle", runner.Name)
196+
idleRunners = append(idleRunners, runner)
197+
continue
198+
}
199+
if giteaRunner.Busy {
200+
p.log.WithFields(logrus.Fields{
201+
"runner_name": runner.Name,
202+
"gitea_id": giteaRunner.ID,
203+
}).Info("Runner is busy, will wait for job completion")
204+
busyRunners = append(busyRunners, runner)
205+
} else {
206+
idleRunners = append(idleRunners, runner)
207+
}
208+
}
172209

173-
// Destroy the VM if it's still running
174-
if runner.VMID != "" {
175-
p.log.Infof("Stopping runner %s (VM: %s)", id, runner.VMID)
176-
if err := p.vmManager.DestroyVM(runner.VMID); err != nil {
177-
p.log.Errorf("Failed to destroy VM %s: %v", runner.VMID, err)
210+
// Immediately stop idle runners
211+
for _, runner := range idleRunners {
212+
p.stopRunner(shutdownCtx, runner)
213+
}
214+
215+
// Give busy runners a grace period to complete their job
216+
if len(busyRunners) > 0 {
217+
p.log.WithField("count", len(busyRunners)).Infof(
218+
"Waiting up to %v for busy runners to complete their jobs", BusyRunnerGracePeriod)
219+
220+
// Wait for busy runners with a grace period
221+
graceCtx, graceCancel := context.WithTimeout(context.Background(), BusyRunnerGracePeriod)
222+
defer graceCancel()
223+
224+
// Check periodically if busy runners have completed (via Gitea API)
225+
ticker := time.NewTicker(5 * time.Second)
226+
defer ticker.Stop()
227+
228+
waitLoop:
229+
for {
230+
select {
231+
case <-graceCtx.Done():
232+
// Grace period expired
233+
p.log.Warn("Grace period expired, forcefully stopping remaining busy runners")
234+
break waitLoop
235+
case <-ticker.C:
236+
// Check Gitea API to see if runners are still busy
237+
stillBusy := 0
238+
for _, runner := range busyRunners {
239+
giteaRunner, err := p.gitea.GetRunnerByName(shutdownCtx, runner.Name)
240+
if err != nil {
241+
p.log.Warnf("Failed to check runner %s status: %v", runner.Name, err)
242+
stillBusy++ // Assume still busy on error
243+
continue
244+
}
245+
if giteaRunner != nil && giteaRunner.Busy {
246+
stillBusy++
247+
}
248+
}
249+
250+
if stillBusy == 0 {
251+
p.log.Info("All busy runners completed their jobs")
252+
break waitLoop
253+
}
254+
p.log.WithField("remaining", stillBusy).Debug("Still waiting for busy runners")
178255
}
179256
}
257+
258+
// Stop all busy runners (whether completed or grace period expired)
259+
for _, runner := range busyRunners {
260+
p.stopRunner(shutdownCtx, runner)
261+
}
180262
}
181263

182264
// Close the VM manager
@@ -189,6 +271,33 @@ func (p *Pool) Stop() error {
189271
return nil
190272
}
191273

274+
// stopRunner unregisters a runner from Gitea and destroys its VM.
275+
// Note: This does not hold the pool mutex, so it's safe to call from Stop().
276+
func (p *Pool) stopRunner(ctx context.Context, runner *RunnerInfo) {
277+
// Try to unregister from Gitea
278+
// This prevents the runner from showing as offline in Gitea UI
279+
if runner.Name != "" {
280+
p.log.Infof("Unregistering runner %s from Gitea", runner.Name)
281+
if err := p.gitea.DeleteRunnerByName(ctx, runner.Name); err != nil {
282+
p.log.Warnf("Failed to unregister runner %s from Gitea: %v", runner.Name, err)
283+
// Continue with VM destruction even if unregistration fails
284+
}
285+
}
286+
287+
// Destroy the VM if it's still running
288+
if runner.VMID != "" {
289+
p.log.Infof("Stopping runner %s (VM: %s)", runner.ID, runner.VMID)
290+
if err := p.vmManager.DestroyVM(runner.VMID); err != nil {
291+
p.log.Errorf("Failed to destroy VM %s: %v", runner.VMID, err)
292+
}
293+
}
294+
295+
// Update status with lock
296+
p.mu.Lock()
297+
runner.Status = RunnerStateStopped
298+
p.mu.Unlock()
299+
}
300+
192301
// Pause pauses the pool. Pausing prevents the pool from scaling.
193302
func (p *Pool) Pause() {
194303
p.mu.Lock()
@@ -487,28 +596,34 @@ func (p *Pool) getRunnerName(runnerID string) string {
487596
// monitorRunner watches a runner VM and cleans up when it exits.
488597
// When a job completes, act_runner (with --once flag) exits, causing the VM to terminate.
489598
// This triggers cleanup including Gitea deregistration, and the scaling loop spawns a replacement.
599+
// If context is cancelled (shutdown), cleanup is skipped - Stop() handles it.
490600
func (p *Pool) monitorRunner(runnerID, vmID string, startTime time.Time) {
491601
// Wait for VM to exit - act_runner with --once flag exits after completing a job
492602
err := p.vmManager.WaitForExit(p.ctx, vmID)
493603

494604
lifetime := time.Since(startTime)
495605

606+
// If context was cancelled, Stop() will handle all cleanup
607+
// Just log and return - don't race with Stop()
608+
if p.ctx.Err() != nil {
609+
p.log.WithFields(logrus.Fields{
610+
"runner_id": runnerID,
611+
"vm_id": vmID,
612+
"lifetime": lifetime.Round(time.Second),
613+
}).Debug("Monitor exiting due to shutdown, Stop() will handle cleanup")
614+
return
615+
}
616+
496617
// Get runner name for Gitea cleanup before updating status
497618
runnerName := p.getRunnerName(runnerID)
498619

499-
if err != nil && p.ctx.Err() == nil {
620+
if err != nil {
500621
p.log.WithFields(logrus.Fields{
501622
"runner_id": runnerID,
502623
"vm_id": vmID,
503624
"lifetime": lifetime.Round(time.Second),
504625
"error": err,
505626
}).Error("Runner VM exited with error")
506-
} else if p.ctx.Err() != nil {
507-
p.log.WithFields(logrus.Fields{
508-
"runner_id": runnerID,
509-
"vm_id": vmID,
510-
"lifetime": lifetime.Round(time.Second),
511-
}).Info("Runner stopped due to shutdown signal")
512627
} else {
513628
p.log.WithFields(logrus.Fields{
514629
"runner_id": runnerID,
@@ -518,8 +633,7 @@ func (p *Pool) monitorRunner(runnerID, vmID string, startTime time.Time) {
518633
}
519634

520635
// Deregister runner from Gitea (not using --ephemeral, so manual cleanup required)
521-
// Skip if shutting down - Stop() handles cleanup for active runners
522-
if p.ctx.Err() == nil && runnerName != "" {
636+
if runnerName != "" {
523637
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
524638
defer cancel()
525639
if err := p.gitea.DeleteRunnerByName(cleanupCtx, runnerName); err != nil {
@@ -550,15 +664,12 @@ func (p *Pool) monitorRunner(runnerID, vmID string, startTime time.Time) {
550664
}).Info("VM resources cleaned up, runner slot available for replacement")
551665
}
552666

553-
// Signal immediate scaling if not shutting down
554-
// This avoids waiting for the next scaling loop tick (up to 10s delay)
555-
if p.ctx.Err() == nil {
556-
select {
557-
case p.scaleSignal <- struct{}{}:
558-
// Signal sent successfully
559-
default:
560-
// Channel already has a signal pending, no need to send another
561-
}
667+
// Signal immediate scaling to spawn replacement runner
668+
select {
669+
case p.scaleSignal <- struct{}{}:
670+
// Signal sent successfully
671+
default:
672+
// Channel already has a signal pending, no need to send another
562673
}
563674
}
564675

modules/fireactions/services.nix

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,10 @@ in
449449
Restart = "on-failure";
450450
RestartSec = 5;
451451

452-
# Allow enough time for graceful shutdown (runner unregistration from GitHub API)
453-
TimeoutStopSec = "60s";
452+
# Allow enough time for graceful shutdown:
453+
# - BusyRunnerGracePeriod (2min) for busy runners to complete jobs
454+
# - Plus 30s for cleanup (GitHub deregistration, VM destruction)
455+
TimeoutStopSec = "150s";
454456

455457
# Cleanup stale socket files when service stops
456458
ExecStopPost = "${pkgs.findutils}/bin/find ${cfg.dataDir}/pools -name '*.sock' -delete";

modules/fireglab/services.nix

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,10 @@ in
434434
Restart = "always";
435435
RestartSec = "10s";
436436

437-
# Allow enough time for graceful shutdown (runner unregistration from GitLab API)
438-
TimeoutStopSec = "60s";
437+
# Allow enough time for graceful shutdown:
438+
# - BusyRunnerGracePeriod (2min) for busy runners to complete jobs
439+
# - Plus 30s for cleanup (GitLab deregistration, VM destruction)
440+
TimeoutStopSec = "150s";
439441

440442
# Cleanup stale socket files when service stops
441443
ExecStopPost = "${pkgs.findutils}/bin/find ${cfg.dataDir}/pools -name '*.sock' -delete";

modules/fireteact/services.nix

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,10 @@ in
434434
Restart = "always";
435435
RestartSec = "10s";
436436

437-
# Allow enough time for graceful shutdown (runner unregistration from Gitea API)
438-
TimeoutStopSec = "60s";
437+
# Allow enough time for graceful shutdown:
438+
# - BusyRunnerGracePeriod (2min) for busy runners to complete jobs
439+
# - Plus 30s for cleanup (Gitea deregistration, VM destruction)
440+
TimeoutStopSec = "150s";
439441

440442
# Cleanup stale socket files when service stops
441443
ExecStopPost = "${pkgs.findutils}/bin/find ${cfg.dataDir}/pools -name '*.sock' -delete";

0 commit comments

Comments
 (0)