webapp: add per scale set job queue view - #836
Conversation
|
This looks like an amazing UX improvement! I will allocate some time to properly review, hopefully next week. Apologies for the delay! |
When all runners of a scale set are busy, it is hard to tell where a given workflow job sits in the queue, or why capacity is not being used. This adds a "Job Queue" view to the web UI that groups queued and running jobs per scale set (and per pool, matched by labels), ordered by request time, with links to the GitHub run/job pages and to the scale set/pool detail pages. To support the view: * Jobs recorded by scale set listeners now carry the garm scale set ID (new WorkflowJob.ScaleSetFkID column, exposed as scale_set_id on the Job API model). Previously the scale set ID was dropped when recording jobs. * The full RunnerScaleSetStatistic from each session message is persisted on the scale set and exposed as "statistics" on the API (previously only TotalAssignedJobs survived as desired_runner_count). The view shows GitHub's numbers (assigned jobs, busy/idle runners) next to GARM's instance counts, making divergence visible. * consolidateRunnerState syncs GitHub's per-runner view (online idle/busy, offline) onto instances that finished installing. Without this, a runner whose agent died after setup stayed "idle" in GARM forever while GitHub considered it offline and never assigned it jobs. The view updates live via the existing job/instance websocket events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
96010e5 to
5e45662
Compare
| // ScaleSetFkID is the ID of the scale set that this job was assigned to, | ||
| // if the job came in through a scale set listener. | ||
| ScaleSetFkID *uint `gorm:"index"` | ||
| ScaleSet ScaleSet `gorm:"foreignKey:ScaleSetFkID"` |
There was a problem hiding this comment.
I think this needs a:
constraint:OnDelete:SET NULL
Otherwise we'd get a foreign key constraint error when trying to remove a scaleset if a job is associated with it. We have guards to not allow a scaleset to be removed if it has a runner, but a runner may not be spun up instantly as a job is recorded.
| * @memberof Job | ||
| */ | ||
| 'runner_name'?: string; | ||
| /** |
There was a problem hiding this comment.
hmm. Did you use:
make generate
to update this file, or was it manually edited? This file is meant to be generated from the swagger definitions.
Have a look here:
https://openapi-generator.tech/docs/installation/
and here:
https://github.com/cloudbase/garm/blob/main/webapp/DEV_SETUP.md
|
|
||
| function waitingFor(job: Job): string { | ||
| if (!job.created_at) return '-'; | ||
| const seconds = Math.max(0, Math.floor((currentTime - new Date(job.created_at).getTime()) / 1000)); |
There was a problem hiding this comment.
This will not trigger on currentTime. Bellow in this file you set up a ticker in the onMount() function. That ticker updates currentTime every 5 seconds, but in the template bellow, waitingFor() only accepts a job. So unless a job is refreshed, waitingFor() is never fired.
Is that intended? If so, do we still need a ticker for currentTime?
If it's not intended, the waitingFor() function needs to accept a now: number parameter, and we can then call it like:
waitingFor(job, currentTime)
| </div> | ||
| </td> | ||
| <td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap"> | ||
| {waitingFor(job)} |
There was a problem hiding this comment.
If we want this to trigger every 5 seconds, we need to change this to:
{waitingFor(job, currentTime)}
See above comments.
| // instance counts usually means runners GitHub considers offline/gone. | ||
| githubStats?: RunnerScaleSetStatistic; | ||
| queued: Job[]; | ||
| inProgress: Job[]; |
There was a problem hiding this comment.
I may be missing it, but is inProgress ever rendered?
| if len(scaleSet.RunnerStatistics) > 0 { | ||
| var stats params.RunnerScaleSetStatistic | ||
| if err := json.Unmarshal(scaleSet.RunnerStatistics, &stats); err != nil { | ||
| return params.ScaleSet{}, fmt.Errorf("error unmarshaling runner statistics: %w", err) |
There was a problem hiding this comment.
If we ever run into a corrupted stat, listing scalesets will fail. This should probably be logged as an error, but not bail here. See:
Lines 81 to 88 in 2edcc2f
as a reference.
|
|
||
| // Jobs | ||
| async listJobs(): Promise<Job[]> { | ||
| const response = await this.jobsApi.listJobs(); |
There was a problem hiding this comment.
This might have the potential to overload the DB. If the jobs table stays small, this should be fine, but if we ever end up with many jobs, it might impact performance. Worth keeping track of.
| function runnerCounts(list: Instance[]) { | ||
| const running = list.filter((i) => i.status === 'running'); | ||
| const provisioning = list.filter((i) => | ||
| ['pending_create', 'creating', 'pending'].includes(i.status || '') |
There was a problem hiding this comment.
The valid instance states are:
The state pending is not a valid state.
| // RunnerStatistics is the last RunnerScaleSetStatistic received from | ||
| // GitHub on the message session (busy/idle/assigned counts as GitHub | ||
| // sees them). | ||
| RunnerStatistics datatypes.JSON |
There was a problem hiding this comment.
The model changes will require a migration to be added. We switched to gormigrate to do away with the custom code in sql.go (and the convoluted conditionals we used to have).
A file in database/sql/migrations/0007_scaleset_job_queue.go with something like:
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// scaleSet0007 adds the cached RunnerScaleSetStatistic received from
// GitHub on the message session.
// This is just a stub, AutoMigrate ignores everything else.
type scaleSet0007 struct {
RunnerStatistics datatypes.JSON
}
func (scaleSet0007) TableName() string {
return "scale_sets"
}
// workflowJob0007 adds the scale set attribution for jobs recorded by
// scale set listeners.
type workflowJob0007 struct {
ScaleSetFkID *uint `gorm:"index"`
}
func (workflowJob0007) TableName() string { return "workflow_jobs" }
func init() {
Register(&gormigrate.Migration{
ID: "0007_scaleset_job_queue",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&scaleSet0007{}); err != nil {
return err
}
return tx.AutoMigrate(&workflowJob0007{})
},
})
}At the end, your migrations table should look like this:
sqlite> select * from migrations;
SCHEMA_INIT
0001_baseline
0002_case_insensitive_indexes
0002_lower_indexes
0003_forge_instances
0004_garm_agent_version
0005_allow_insecure_garm_agent
0006_proxies
0007_scaleset_job_queue
sqlite>
What this does
When all runners of a scale set are busy, it is hard to tell where a given workflow job sits in the queue, or why capacity is not being used. This adds a Job Queue view to the web UI that groups queued and running jobs per scale set (and per pool, matched by labels), ordered by request time, with links to the GitHub run/job pages and to the scale set/pool detail pages. The view updates live via the existing job/instance websocket events.
Supporting changes
WorkflowJob.ScaleSetFkIDcolumn, auto-migrated; exposed asscale_set_idon theJobAPI model). Previously the scale set ID was deliberately dropped when recording jobs, so queued jobs could not be attributed to a scale set. Existing rows are backfilled on the next message for that job.RunnerScaleSetStatisticfrom each session message is persisted on the scale set and exposed asstatisticson the API (previously onlyTotalAssignedJobssurvived, asdesired_runner_count). The view shows GitHub's numbers (assigned jobs, busy/idle runners) next to GARM's instance counts. This makes two things visible that were previously invisible: jobs GitHub holds while a scale set is saturated (GitHub only delivers individual job messages on assignment), and runners GitHub considers offline while GARM thinks they are idle.consolidateRunnerStatesyncs GitHub's per-runner view (online idle/busy, offline) onto instances that finished installing. Without this, a runner whose agent died after setup stayed "idle" in GARM forever, while GitHub considered it offline and never assigned it jobs — and it counted toward the runner count, starving scale-up. We caught a real production incident (a fleet of registered-but-offline runners with a growing job backlog) within minutes thanks to this divergence being visible in the UI.Screenshot
Testing
go test -tags testing ./database/... ./workers/...passes.