Skip to content

feat(jobs): add nextRun to JobSchedule and NEXT_RUN sort order#437

Merged
rbjornstad merged 12 commits into
mainfrom
feat/job-schedule-next-run
May 13, 2026
Merged

feat(jobs): add nextRun to JobSchedule and NEXT_RUN sort order#437
rbjornstad merged 12 commits into
mainfrom
feat/job-schedule-next-run

Conversation

@rbjornstad
Copy link
Copy Markdown
Contributor

@rbjornstad rbjornstad commented May 13, 2026

Summary

Add nextRun: Time (nullable) to JobSchedule and make jobs sortable by next scheduled run time via the NEXT_RUN order field.

Changes

  • Schema: Added nextRun: Time to JobSchedule type, added NEXT_RUN to JobOrderField enum
  • Implementation: Parse cron expression with timezone using robfig/cron/v3, compute next run from time.Now()
  • Sort: Jobs without a next run (no schedule or invalid cron) are always sorted last regardless of sort direction
  • Partition: O(n) linear stable partition ensures unscheduled jobs stay at end after framework applies direction
  • Timezone fallback: Invalid timezone falls back to UTC (still reports UTC in the response)
  • Nullable: nextRun is nil when cron expression is invalid, so the expression and timezone are still visible

- Add nextRun: Time! field to JobSchedule GraphQL type
- Compute next run time from cron expression using robfig/cron/v3
- Add NEXT_RUN to JobOrderField enum for sorting jobs by next scheduled run
- Jobs without a schedule sort last when ordering by NEXT_RUN
- Add unit tests for Schedule() and nextRunUnix()
- Include go fix changes
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a computed nextRun timestamp to job schedules and introduces a NEXT_RUN ordering option so jobs can be sorted by their next scheduled execution time.

Changes:

  • Added JobSchedule.nextRun to the GraphQL schema and updated gqlgen-generated code accordingly.
  • Computed next run time from cron expression + timezone in the job model, and added NEXT_RUN sorting support.
  • Applied Go stdlib modernizations (maps.Copy, slices.Contains) and added unit tests for schedule/next-run behavior.

Reviewed changes

Copilot reviewed 8 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/workload/job/sortfilter.go Registers NEXT_RUN sort order using a derived next-run timestamp.
internal/workload/job/schedule_test.go Adds unit tests for Job.Schedule() and nextRunUnix().
internal/workload/job/models.go Computes JobSchedule.NextRun from cron + timezone (robfig/cron).
internal/workload/config/queries.go Refactors annotation merging to use maps.Copy.
internal/kubernetes/fake/fake.go Uses maps.Copy to merge metadata maps and switches to any.
internal/graph/schema/jobs.graphqls Adds nextRun to JobSchedule and NEXT_RUN to JobOrderField.
internal/graph/gengql/root_.generated.go Regenerated schema/complexity wiring for nextRun and NEXT_RUN.
internal/graph/gengql/jobs.generated.go Regenerated job resolvers/marshaling for JobSchedule.nextRun.
internal/auth/middleware/kubernetes_serviceaccount.go Simplifies JWT-shape check using slices.Contains.
go.mod Adds robfig/cron dependency entry.
go.sum Adds robfig/cron checksums.
Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported
Comments suppressed due to low confidence (1)

internal/workload/job/models.go:356

  • nextRun may end up as the zero time.Time when cron parsing fails (err != nil). Since the GraphQL Time scalar marshaler returns null for zero values, this will surface as a field error if nextRun remains non-nullable in the schema. Consider either (a) returning nil schedule on parse errors, or (b) making NextRun a *time.Time / otherwise distinguishing "unknown" next run explicitly so GraphQL can represent it without error.
	parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
	sched, err := parser.Parse(j.Spec.Schedule)

	var nextRun time.Time
	if err == nil {
		nextRun = sched.Next(time.Now().In(loc))
	}

	return &JobSchedule{
		Expression: j.Spec.Schedule,
		TimeZone:   tz,
		NextRun:    nextRun,
	}

Comment thread internal/graph/schema/jobs.graphqls Outdated
Comment thread internal/workload/job/models.go
Comment thread internal/workload/job/sortfilter.go
Comment thread go.mod Outdated
- Return nil schedule for invalid cron expressions (avoids null Time! field error)
- Set TimeZone to UTC when LoadLocation fails (consistent with computed nextRun)
- Switch NEXT_RUN to RegisterConcurrentSort (precomputes sort key once per job)
- Mark robfig/cron/v3 as direct dependency via go mod tidy
Copy link
Copy Markdown
Contributor Author

@rbjornstad rbjornstad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review comments addressed in 8401a33.

Comment thread internal/graph/schema/jobs.graphqls Outdated
Comment thread internal/workload/job/models.go
Comment thread internal/workload/job/sortfilter.go
Comment thread go.mod
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 11 changed files in this pull request and generated 4 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported
Comments suppressed due to low confidence (1)

internal/workload/job/schedule_test.go:60

  • Same issue here: new("Invalid/Zone") won’t compile; TimeZone expects a *string, so create a proper string pointer (e.g. ptr.To(...) or &s).
		j := &Job{Spec: &nais_io_v1.NaisjobSpec{
			Schedule: "0 * * * *",
			TimeZone: new("Invalid/Zone"),
		}}

Comment thread internal/workload/job/schedule_test.go
Comment thread internal/workload/job/models.go Outdated
Comment thread internal/workload/job/sortfilter.go Outdated
Comment thread internal/graph/schema/jobs.graphqls Outdated
Schedule() now always returns a JobSchedule when an expression exists,
with nextRun: null when the cron expression fails to parse. This allows
clients to always see the expression and timezone even for invalid schedules.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 11 changed files in this pull request and generated 3 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported
Comments suppressed due to low confidence (1)

internal/workload/job/schedule_test.go:60

  • TimeZone: new("Invalid/Zone") does not compile in Go (the built-in new requires a type, not a value). Use a string pointer helper to construct *string values for NaisjobSpec.TimeZone.
		j := &Job{Spec: &nais_io_v1.NaisjobSpec{
			Schedule: "0 * * * *",
			TimeZone: new("Invalid/Zone"),
		}}

Comment thread internal/workload/job/schedule_test.go
Comment thread internal/workload/job/sortfilter.go Outdated
Comment thread internal/graph/schema/jobs.graphqls
- Memoize Schedule() with sync.Once to avoid repeated cron parsing
  and ensure consistent nextRun across sort and field resolution
- Switch NEXT_RUN to RegisterSort with explicit nil-last logic that
  keeps unscheduled jobs last regardless of sort direction (ASC/DESC)
- Update description to say 'always sorted last'
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 11 changed files in this pull request and generated 2 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported

Comment thread internal/workload/job/models.go Outdated
Comment thread internal/workload/job/sortfilter.go
The sortfilter framework applies DESC by swapping comparison arguments,
which naturally inverts null ordering (standard SQL behavior). Updated
the schema description to not promise direction-independent null ordering.
@rbjornstad rbjornstad requested a review from Copilot May 13, 2026 12:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 12 changed files in this pull request and generated 2 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported

Comment thread internal/graph/schema/jobs.graphqls Outdated
Comment thread internal/graph/gengql/root_.generated.go Outdated
Add partitionUnscheduledLast() that runs as a stable post-sort step
after NEXT_RUN ordering, ensuring jobs without a valid schedule always
appear at the end even when sorting DESC.
sync.Once makes Job contain sync.noCopy, which breaks gqlgen's generated
type switches that use value type job.Job. Cron parsing is cheap and jobs
are rebuilt from the K8s watcher per request, so memoization is unnecessary.
@rbjornstad rbjornstad requested a review from Copilot May 13, 2026 13:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 13 changed files in this pull request and generated 3 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported
Comments suppressed due to low confidence (1)

internal/workload/job/sortfilter_test.go:44

  • Same flakiness risk as the ASC case: at certain times * * * * * and 0 * * * * can have equal nextRun, so the expected strict ordering may not hold. Use a fixed reference time or schedules that guarantee different next occurrences regardless of when the test runs.
	t.Run("DESC sorts by next run time descending", func(t *testing.T) {
		jobs := []*Job{
			makeJob("every-minute", "* * * * *"),
			makeJob("hourly", "0 * * * *"),
		}

		SortFilter.Sort(context.Background(), jobs, "NEXT_RUN", model.OrderDirectionDesc)

		if jobs[0].Name != "hourly" || jobs[1].Name != "every-minute" {
			t.Fatalf("expected [hourly, every-minute], got [%s, %s]", jobs[0].Name, jobs[1].Name)
		}

Comment thread internal/workload/job/models.go
Comment thread internal/workload/job/sortfilter.go Outdated
Comment thread internal/workload/job/sortfilter_test.go
- Store Schedule() result in local var in partitionUnscheduledLast
- Use */5 instead of * * * * * to avoid hour-boundary coincidence
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 13 changed files in this pull request and generated 5 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported

Comment thread internal/workload/job/models.go
Comment thread internal/graph/schema/jobs.graphqls Outdated
Comment thread internal/workload/job/sortfilter.go Outdated
Comment thread internal/workload/job/sortfilter_test.go
Comment thread internal/workload/job/queries.go
- Replace O(n log n) SortStableFunc partition with O(n) two-slice split
- Schema description now mentions invalid cron as also sorting last
- Test asserts both invalid-cron and no-schedule end up after valid jobs
- PR description mismatch (sync.Once mention) noted for update
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 13 changed files in this pull request and generated 2 comments.

Files not reviewed (2)
  • internal/graph/gengql/jobs.generated.go: Language not supported
  • internal/graph/gengql/root_.generated.go: Language not supported

Comment thread internal/workload/job/sortfilter_test.go
Comment thread internal/workload/job/schedule_test.go
@rbjornstad rbjornstad enabled auto-merge (squash) May 13, 2026 14:05
@rbjornstad rbjornstad merged commit aa7570c into main May 13, 2026
10 checks passed
@rbjornstad rbjornstad deleted the feat/job-schedule-next-run branch May 13, 2026 14:07
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.

3 participants