fix(playlists): use CRON_TZ-prefixed cron expressions for per-job timezone

gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:

  CRON_TZ=America/New_York 0 3 * * *

Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.

Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.

Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 12:09:02 -04:00
parent 7fac264c73
commit c2168afbf0
+14 -5
View File
@@ -134,6 +134,12 @@ func (s *Scheduler) Refresh(ctx context.Context, userID pgtype.UUID) error {
}
// registerLocked replaces the user's job entry. Caller must hold s.mu.
//
// gocron v2 doesn't expose a per-job location option — WithLocation is
// a SchedulerOption that affects every job. To get per-user timezones
// we use a cron expression with the CRON_TZ= prefix, which the
// underlying robfig/cron parser honors. The expression "CRON_TZ=$tz
// 0 3 * * *" fires at 03:00 in the named zone every day.
func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz string) error {
if existing, ok := s.jobs[userID]; ok {
if err := s.gocron.RemoveJob(existing); err != nil {
@@ -142,18 +148,21 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s
}
delete(s.jobs, userID)
}
loc := validateTimezoneOrUTC(tz)
// Fall back to UTC if the stored tz is unparseable so a corrupted
// DB row doesn't error out registration.
resolvedTZ := "UTC"
if _, err := validateTimezone(tz); err == nil {
resolvedTZ = tz
}
cronExpr := fmt.Sprintf("CRON_TZ=%s 0 %d * * *", resolvedTZ, dailyRebuildHour)
job, err := s.gocron.NewJob(
gocron.DailyJob(1, gocron.NewAtTimes(
gocron.NewAtTime(dailyRebuildHour, 0, 0),
)),
gocron.CronJob(cronExpr, false /* withSeconds */),
gocron.NewTask(func() {
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil {
s.logger.Warn("scheduler: build failed",
"user_id", uuidStringPL(userID), "err", err)
}
}),
gocron.WithLocation(loc),
)
if err != nil {
return fmt.Errorf("register daily job: %w", err)