navidrome/navidrome · error

schedule ID %q already exists

Error message

schedule ID %q already exists

What it means

HostScheduler.ScheduleOneTime registers a one-shot timer under a caller-supplied scheduleID. Before registering it locks the map and rejects any ID that is already present in s.schedules, returning this error instead of overwriting. Duplicate IDs are never silently replaced — the caller must pick a unique ID.

Source

Thrown at plugins/host_scheduler.go:72

func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) *schedulerServiceImpl {
	return &schedulerServiceImpl{
		pluginName: pluginName,
		manager:    manager,
		scheduler:  sched,
		schedules:  make(map[string]*scheduleEntry),
	}
}

func (s *schedulerServiceImpl) ScheduleOneTime(ctx context.Context, delaySeconds int32, payload string, scheduleID string) (string, error) {
	if scheduleID == "" {
		scheduleID = id.NewRandom()
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, exists := s.schedules[scheduleID]; exists {
		return "", fmt.Errorf("schedule ID %q already exists", scheduleID)
	}

	capturedID := scheduleID
	timer := timeAfterFunc(time.Duration(delaySeconds)*time.Second, func() {
		s.invokeCallback(context.Background(), capturedID)
		// Clean up the entry after firing
		s.mu.Lock()
		delete(s.schedules, capturedID)
		s.mu.Unlock()
	})

	s.schedules[scheduleID] = &scheduleEntry{
		pluginName:  s.pluginName,
		payload:     payload,
		isRecurring: false,
		timer:       timer,
	}

View on GitHub (pinned to 4ed7494a32)

Solutions

  1. Generate unique IDs (e.g. fmt.Sprintf("job-%d", time.Now().UnixNano()) or a UUID) instead of hardcoded names.
  2. Check-and-skip: treat the error as 'already scheduled' if re-registration is expected and harmless.
  3. Cancel the existing schedule (CancelSchedule with the old ID) before re-registering the same ID.
  4. Namespace IDs per component/plugin feature to avoid collisions ("report-daily" vs "cleanup-daily").
  5. On restart, rebuild schedule state or use CancelAll/Shutdown before re-registering.

Example fix

// before
scheduler.ScheduleOneTime(ctx, "report", payload, 60)
// after
id := fmt.Sprintf("report-%d", time.Now().UnixNano())
scheduler.ScheduleOneTime(ctx, id, payload, 60)
Defensive patterns

Strategy: try-catch

Validate before calling

// best-effort: cancel any prior registration with the same ID first
_ = scheduler.CancelSchedule(ctx, scheduleID) // no-op if absent
if id, err := scheduler.ScheduleOneTime(ctx, scheduleID, payload, delay); err != nil {
    return err
} else {
    _ = id
}

Try / catch

id, err := scheduler.ScheduleOneTime(ctx, scheduleID, payload, delaySeconds)
if err != nil {
    if strings.Contains(err.Error(), "already exists") {
        return nil // idempotent: already scheduled
    }
    return err
}

Prevention

When it happens

Trigger: Calling ScheduleOneTime(ctx, scheduleID, ...) with a scheduleID that was previously registered (by ScheduleOneTime or ScheduleRecurring) and has not yet fired or been cancelled.

Common situations: Re-registering the same static ID on plugin restart without clearing prior state; retry logic calling ScheduleOneTime with the same ID after a transient failure elsewhere; two components in one plugin hardcoding the same schedule name; a one-time schedule that has not fired yet being scheduled again.

Related errors


AI-assisted analysis of navidrome/navidrome@4ed7494a32 (2026-09-01). Data as JSON: /api/errors/7ceba8342d33a7ee. Report an issue: GitHub.