feat(scheduler): implement Scheduler service with one-time and recurring scheduling capabilities

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-23 23:32:42 -05:00
parent b84089cea4
commit f4c6461c0a
13 changed files with 1072 additions and 15 deletions

View File

@ -12,6 +12,10 @@ const (
// CapabilityScrobbler indicates the plugin can receive scrobble events.
// Detected when the plugin exports at least one of the scrobbler functions.
CapabilityScrobbler Capability = "Scrobbler"
// CapabilityScheduler indicates the plugin can receive scheduled event callbacks.
// Detected when the plugin exports the scheduler callback function.
CapabilityScheduler Capability = "Scheduler"
)
// capabilityFunctions maps each capability to its required/optional functions.
@ -32,6 +36,9 @@ var capabilityFunctions = map[Capability][]string{
FuncScrobblerNowPlaying,
FuncScrobblerScrobble,
},
CapabilityScheduler: {
FuncSchedulerCallback,
},
}
// functionExistsChecker is an interface for checking if a function exists in a plugin.

View File

@ -50,8 +50,8 @@ type SchedulerScheduleRecurringResponse struct {
// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
func SchedulerScheduleOneTime(delaySeconds int32, payload []byte, scheduleID string) (*SchedulerScheduleOneTimeResponse, error) {
payloadMem := pdk.AllocateBytes(payload)
func SchedulerScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (*SchedulerScheduleOneTimeResponse, error) {
payloadMem := pdk.AllocateString(payload)
defer payloadMem.Free()
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
@ -81,10 +81,10 @@ func SchedulerScheduleOneTime(delaySeconds int32, payload []byte, scheduleID str
// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
func SchedulerScheduleRecurring(cronExpression string, payload []byte, scheduleID string) (*SchedulerScheduleRecurringResponse, error) {
func SchedulerScheduleRecurring(cronExpression string, payload string, scheduleID string) (*SchedulerScheduleRecurringResponse, error) {
cronExpressionMem := pdk.AllocateString(cronExpression)
defer cronExpressionMem.Free()
payloadMem := pdk.AllocateBytes(payload)
payloadMem := pdk.AllocateString(payload)
defer payloadMem.Free()
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
@ -112,12 +112,12 @@ func SchedulerScheduleRecurring(cronExpression string, payload []byte, scheduleI
// any future events.
//
// Returns an error if the schedule ID is not found or if cancellation fails.
func SchedulerCancelSchedule(scheduelID string) error {
scheduelIDMem := pdk.AllocateString(scheduelID)
defer scheduelIDMem.Free()
func SchedulerCancelSchedule(scheduleID string) error {
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
// Call the host function
responsePtr := scheduler_cancelschedule(scheduelIDMem.Offset())
responsePtr := scheduler_cancelschedule(scheduleIDMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)

View File

@ -18,7 +18,7 @@ type SchedulerService interface {
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
//nd:hostfunc
ScheduleOneTime(ctx context.Context, delaySeconds int32, payload []byte, scheduleID string) (newScheduleID string, err error)
ScheduleOneTime(ctx context.Context, delaySeconds int32, payload string, scheduleID string) (newScheduleID string, err error)
// ScheduleRecurring schedules a recurring event using a cron expression.
//
@ -29,7 +29,7 @@ type SchedulerService interface {
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
//nd:hostfunc
ScheduleRecurring(ctx context.Context, cronExpression string, payload []byte, scheduleID string) (newScheduleID string, err error)
ScheduleRecurring(ctx context.Context, cronExpression string, payload string, scheduleID string) (newScheduleID string, err error)
// CancelSchedule cancels a scheduled job identified by its schedule ID.
//
@ -38,5 +38,5 @@ type SchedulerService interface {
//
// Returns an error if the schedule ID is not found or if cancellation fails.
//nd:hostfunc
CancelSchedule(ctx context.Context, scheduelID string) error
CancelSchedule(ctx context.Context, scheduleID string) error
}

View File

@ -37,7 +37,7 @@ func newSchedulerScheduleOneTimeHostFunction(service SchedulerService) extism.Ho
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
delaySeconds := extism.DecodeI32(stack[0])
payload, err := p.ReadBytes(stack[1])
payload, err := p.ReadString(stack[1])
if err != nil {
return
}
@ -72,7 +72,7 @@ func newSchedulerScheduleRecurringHostFunction(service SchedulerService) extism.
if err != nil {
return
}
payload, err := p.ReadBytes(stack[1])
payload, err := p.ReadString(stack[1])
if err != nil {
return
}
@ -103,13 +103,13 @@ func newSchedulerCancelScheduleHostFunction(service SchedulerService) extism.Hos
"scheduler_cancelschedule",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read parameters from stack
scheduelID, err := p.ReadString(stack[0])
scheduleID, err := p.ReadString(stack[0])
if err != nil {
return
}
// Call the service method
err = service.CancelSchedule(ctx, scheduelID)
err = service.CancelSchedule(ctx, scheduleID)
if err != nil {
// Write error string to plugin memory
if ptr, err := p.WriteString(err.Error()); err == nil {

361
plugins/host_scheduler.go Normal file
View File

@ -0,0 +1,361 @@
package plugins
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
)
const FuncSchedulerCallback = "nd_scheduler_callback"
// scheduleEntry stores metadata about a scheduled task.
type scheduleEntry struct {
pluginName string
payload string
isRecurring bool
entryID int // Internal scheduler entry ID
}
// callbackRecord stores information about a callback that was invoked (for testing).
type callbackRecord struct {
ScheduleID string
Payload string
IsRecurring bool
Count int
}
// schedulerServiceImpl implements host.SchedulerService.
// It provides plugins with scheduling capabilities and invokes callbacks when schedules fire.
type schedulerServiceImpl struct {
pluginName string
manager *Manager
scheduler scheduler.Scheduler
mu sync.Mutex
schedules map[string]*scheduleEntry
// Callback tracking (for testing) - tracks callbacks invoked on host side
callbackMu sync.Mutex
callbackRecords map[string]*callbackRecord
callbackCount int
}
// newSchedulerService creates a new SchedulerService for a plugin.
func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) host.SchedulerService {
return &schedulerServiceImpl{
pluginName: pluginName,
manager: manager,
scheduler: sched,
schedules: make(map[string]*scheduleEntry),
callbackRecords: make(map[string]*callbackRecord),
}
}
func (s *schedulerServiceImpl) ScheduleOneTime(ctx context.Context, delaySeconds int32, payload string, scheduleID string) (string, error) {
if scheduleID == "" {
scheduleID = uuid.New().String()
}
s.mu.Lock()
if _, exists := s.schedules[scheduleID]; exists {
s.mu.Unlock()
return "", fmt.Errorf("schedule ID %q already exists", scheduleID)
}
entry := &scheduleEntry{
pluginName: s.pluginName,
payload: payload,
isRecurring: false,
}
s.schedules[scheduleID] = entry
s.mu.Unlock()
// Use @every syntax for one-time delay
cronExpr := fmt.Sprintf("@every %ds", delaySeconds)
// Create callback that will fire once and then cancel itself
schedID := scheduleID // capture for closure
callback := func() {
s.invokeCallback(schedID)
// One-time schedules cancel themselves after firing
_ = s.CancelSchedule(context.Background(), schedID)
}
entryID, err := s.scheduler.Add(cronExpr, callback)
if err != nil {
s.mu.Lock()
delete(s.schedules, scheduleID)
s.mu.Unlock()
return "", fmt.Errorf("failed to schedule one-time task: %w", err)
}
s.mu.Lock()
entry.entryID = entryID
s.mu.Unlock()
log.Debug(ctx, "Scheduled one-time task", "plugin", s.pluginName, "scheduleID", scheduleID, "delay", delaySeconds)
return scheduleID, nil
}
func (s *schedulerServiceImpl) ScheduleRecurring(ctx context.Context, cronExpression string, payload string, scheduleID string) (string, error) {
if scheduleID == "" {
scheduleID = uuid.New().String()
}
s.mu.Lock()
if _, exists := s.schedules[scheduleID]; exists {
s.mu.Unlock()
return "", fmt.Errorf("schedule ID %q already exists", scheduleID)
}
entry := &scheduleEntry{
pluginName: s.pluginName,
payload: payload,
isRecurring: true,
}
s.schedules[scheduleID] = entry
s.mu.Unlock()
schedID := scheduleID // capture for closure
callback := func() {
s.invokeCallback(schedID)
}
entryID, err := s.scheduler.Add(cronExpression, callback)
if err != nil {
s.mu.Lock()
delete(s.schedules, scheduleID)
s.mu.Unlock()
return "", fmt.Errorf("failed to schedule recurring task: %w", err)
}
s.mu.Lock()
entry.entryID = entryID
s.mu.Unlock()
log.Debug(ctx, "Scheduled recurring task", "plugin", s.pluginName, "scheduleID", scheduleID, "cron", cronExpression)
return scheduleID, nil
}
func (s *schedulerServiceImpl) CancelSchedule(ctx context.Context, scheduleID string) error {
s.mu.Lock()
entry, exists := s.schedules[scheduleID]
if !exists {
s.mu.Unlock()
return fmt.Errorf("schedule ID %q not found", scheduleID)
}
delete(s.schedules, scheduleID)
entryID := entry.entryID
s.mu.Unlock()
s.scheduler.Remove(entryID)
log.Debug(ctx, "Cancelled schedule", "plugin", s.pluginName, "scheduleID", scheduleID)
return nil
}
// CancelAllForPlugin cancels all schedules for this plugin.
// This is called when the plugin is unloaded.
func (s *schedulerServiceImpl) CancelAllForPlugin() {
s.mu.Lock()
schedules := make(map[string]*scheduleEntry, len(s.schedules))
for k, v := range s.schedules {
schedules[k] = v
}
s.schedules = make(map[string]*scheduleEntry)
s.mu.Unlock()
for scheduleID, entry := range schedules {
s.scheduler.Remove(entry.entryID)
log.Debug(context.Background(), "Cancelled schedule on plugin unload", "plugin", s.pluginName, "scheduleID", scheduleID)
}
}
// schedulerCallbackInput is the input format for the nd_scheduler_callback function.
type schedulerCallbackInput struct {
ScheduleID string `json:"schedule_id"`
Payload string `json:"payload"`
IsRecurring bool `json:"is_recurring"`
}
// schedulerCallbackOutput is the output format for the nd_scheduler_callback function.
type schedulerCallbackOutput struct {
Error string `json:"error,omitempty"`
}
// invokeCallback calls the plugin's nd_scheduler_callback function.
func (s *schedulerServiceImpl) invokeCallback(scheduleID string) {
ctx := context.Background()
log.Debug(ctx, "Scheduler callback invoked", "plugin", s.pluginName, "scheduleID", scheduleID)
s.mu.Lock()
entry, exists := s.schedules[scheduleID]
if !exists {
s.mu.Unlock()
log.Warn(ctx, "Schedule entry not found during callback", "plugin", s.pluginName, "scheduleID", scheduleID)
return
}
payload := entry.payload
isRecurring := entry.isRecurring
s.mu.Unlock()
// Get the plugin instance from the manager
s.manager.mu.RLock()
instance, ok := s.manager.plugins[s.pluginName]
s.manager.mu.RUnlock()
if !ok {
log.Warn(ctx, "Plugin not loaded when scheduler callback fired", "plugin", s.pluginName, "scheduleID", scheduleID)
return
}
// Check if plugin has the scheduler capability
if !hasCapability(instance.capabilities, CapabilityScheduler) {
log.Warn(ctx, "Plugin does not have scheduler capability", "plugin", s.pluginName, "scheduleID", scheduleID)
return
}
// Prepare callback input
input := schedulerCallbackInput{
ScheduleID: scheduleID,
Payload: payload,
IsRecurring: isRecurring,
}
start := time.Now()
result, err := callPluginFunction[schedulerCallbackInput, schedulerCallbackOutput](ctx, instance, FuncSchedulerCallback, input)
if err != nil {
log.Error(ctx, "Scheduler callback failed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start), err)
return
}
if result.Error != "" {
log.Error(ctx, "Scheduler callback returned error", "plugin", s.pluginName, "scheduleID", scheduleID, "error", result.Error, "duration", time.Since(start))
return
}
// Track callback invocation on host side (for testing)
s.trackCallback(scheduleID, payload, isRecurring)
log.Debug(ctx, "Scheduler callback completed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start))
}
// trackCallback records a callback invocation (for testing).
func (s *schedulerServiceImpl) trackCallback(scheduleID, payload string, isRecurring bool) {
s.callbackMu.Lock()
defer s.callbackMu.Unlock()
s.callbackCount++
if record, exists := s.callbackRecords[scheduleID]; exists {
record.Count++
} else {
s.callbackRecords[scheduleID] = &callbackRecord{
ScheduleID: scheduleID,
Payload: payload,
IsRecurring: isRecurring,
Count: 1,
}
}
}
// GetCallbackCount returns the total number of callbacks invoked for this service.
// This is primarily used for testing.
func (s *schedulerServiceImpl) GetCallbackCount() int {
s.callbackMu.Lock()
defer s.callbackMu.Unlock()
return s.callbackCount
}
// GetCallbackRecords returns the callback records for this service.
// This is primarily used for testing.
func (s *schedulerServiceImpl) GetCallbackRecords() map[string]*callbackRecord {
s.callbackMu.Lock()
defer s.callbackMu.Unlock()
// Return a copy
records := make(map[string]*callbackRecord, len(s.callbackRecords))
for k, v := range s.callbackRecords {
records[k] = &callbackRecord{
ScheduleID: v.ScheduleID,
Payload: v.Payload,
IsRecurring: v.IsRecurring,
Count: v.Count,
}
}
return records
}
// ResetCallbackRecords clears the callback tracking state.
// This is primarily used for testing.
func (s *schedulerServiceImpl) ResetCallbackRecords() {
s.callbackMu.Lock()
defer s.callbackMu.Unlock()
s.callbackRecords = make(map[string]*callbackRecord)
s.callbackCount = 0
}
// GetScheduleCount returns the number of active schedules for this service.
// This is primarily used for testing.
func (s *schedulerServiceImpl) GetScheduleCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.schedules)
}
// Verify interface implementation
var _ host.SchedulerService = (*schedulerServiceImpl)(nil)
// schedulerServiceRegistry keeps track of scheduler services per plugin for cleanup.
type schedulerServiceRegistry struct {
mu sync.RWMutex
services map[string]*schedulerServiceImpl
}
var schedulerRegistry = &schedulerServiceRegistry{
services: make(map[string]*schedulerServiceImpl),
}
// registerSchedulerService registers a scheduler service for a plugin.
func registerSchedulerService(pluginName string, service *schedulerServiceImpl) {
schedulerRegistry.mu.Lock()
defer schedulerRegistry.mu.Unlock()
schedulerRegistry.services[pluginName] = service
}
// unregisterSchedulerService unregisters and cancels all schedules for a plugin.
func unregisterSchedulerService(pluginName string) {
schedulerRegistry.mu.Lock()
service, exists := schedulerRegistry.services[pluginName]
if exists {
delete(schedulerRegistry.services, pluginName)
}
schedulerRegistry.mu.Unlock()
if exists && service != nil {
service.CancelAllForPlugin()
}
}
// getSchedulerService returns the scheduler service for a plugin.
func getSchedulerService(pluginName string) *schedulerServiceImpl {
schedulerRegistry.mu.RLock()
defer schedulerRegistry.mu.RUnlock()
return schedulerRegistry.services[pluginName]
}
// CreateSchedulerHostFunctions creates scheduler host functions for a plugin.
// This should be called during plugin load if the plugin has the scheduler permission.
func CreateSchedulerHostFunctions(pluginName string, manager *Manager) []func() {
sched := scheduler.GetInstance()
service := newSchedulerService(pluginName, manager, sched).(*schedulerServiceImpl)
registerSchedulerService(pluginName, service)
// Return a cleanup function
return []func(){
func() { unregisterSchedulerService(pluginName) },
}
}

View File

@ -0,0 +1,362 @@
//go:build !windows
package plugins
import (
"context"
"os"
"path/filepath"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/scheduler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SchedulerService", Ordered, func() {
var (
manager *Manager
tmpDir string
mockSched *mockScheduler
)
BeforeAll(func() {
// Create temp directory
var err error
tmpDir, err = os.MkdirTemp("", "scheduler-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the fake-scheduler plugin
srcPath := filepath.Join(testdataDir, "fake-scheduler.wasm")
destPath := filepath.Join(tmpDir, "fake-scheduler.wasm")
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Create mock scheduler
mockSched = newMockScheduler()
// Create and start manager
manager = &Manager{
plugins: make(map[string]*pluginInstance),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
// Replace the scheduler in the service with our mock
service := getSchedulerService("fake-scheduler")
if service != nil {
service.scheduler = mockSched
}
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
// Reset state between tests
BeforeEach(func() {
mockSched.Reset()
service := getSchedulerService("fake-scheduler")
if service != nil {
service.ResetCallbackRecords()
// Clear any pending schedules
service.mu.Lock()
for id := range service.schedules {
delete(service.schedules, id)
}
service.mu.Unlock()
}
})
Describe("Plugin Loading", func() {
It("should detect scheduler capability", func() {
names := manager.PluginNames(string(CapabilityScheduler))
Expect(names).To(ContainElement("fake-scheduler"))
})
It("should register scheduler service for plugin", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
})
})
Describe("ScheduleOneTime", func() {
It("should schedule a one-time callback", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule a callback
scheduleID, err := service.ScheduleOneTime(GinkgoT().Context(), 1, "test-payload", "test-id")
Expect(err).ToNot(HaveOccurred())
Expect(scheduleID).To(Equal("test-id"))
// Verify schedule was registered
Expect(service.GetScheduleCount()).To(Equal(1))
Expect(mockSched.GetCallbackCount()).To(Equal(1))
// Manually trigger the callback
mockSched.TriggerAll()
// Verify callback was invoked
Expect(service.GetCallbackCount()).To(Equal(1))
})
It("should pass payload to callback", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule with specific payload
scheduleID, err := service.ScheduleOneTime(GinkgoT().Context(), 1, "my-test-data", "custom-id")
Expect(err).ToNot(HaveOccurred())
Expect(scheduleID).To(Equal("custom-id"))
// Trigger callback
mockSched.TriggerAll()
// Verify payload was received
records := service.GetCallbackRecords()
Expect(records).To(HaveKey("custom-id"))
Expect(records["custom-id"].Payload).To(Equal("my-test-data"))
Expect(records["custom-id"].IsRecurring).To(BeFalse())
})
It("should reject duplicate schedule ID", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule first
_, err := service.ScheduleOneTime(GinkgoT().Context(), 60, "data", "dup-id")
Expect(err).ToNot(HaveOccurred())
// Try to schedule with same ID
_, err = service.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "dup-id")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("already exists"))
})
It("should clean up one-time schedule after firing", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule a callback
_, err := service.ScheduleOneTime(GinkgoT().Context(), 1, "cleanup-test", "cleanup-id")
Expect(err).ToNot(HaveOccurred())
// Verify schedule exists
Expect(service.GetScheduleCount()).To(Equal(1))
// Trigger callback (one-time schedules self-cancel)
mockSched.TriggerAll()
// Schedule should be cleaned up
Expect(service.GetScheduleCount()).To(Equal(0))
})
It("should auto-generate schedule ID when empty", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule without providing ID
scheduleID, err := service.ScheduleOneTime(GinkgoT().Context(), 1, "data", "")
Expect(err).ToNot(HaveOccurred())
Expect(scheduleID).ToNot(BeEmpty())
// UUID format
Expect(scheduleID).To(HaveLen(36))
})
})
Describe("ScheduleRecurring", func() {
It("should schedule recurring callbacks", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule recurring task
scheduleID, err := service.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "recurring", "recurring-id")
Expect(err).ToNot(HaveOccurred())
Expect(scheduleID).To(Equal("recurring-id"))
// Trigger multiple times
mockSched.TriggerAll()
mockSched.TriggerAll()
// Verify callback count
Expect(service.GetCallbackCount()).To(Equal(2))
// Verify records show recurring
records := service.GetCallbackRecords()
Expect(records).To(HaveKey("recurring-id"))
Expect(records["recurring-id"].IsRecurring).To(BeTrue())
Expect(records["recurring-id"].Count).To(Equal(2))
})
It("should not self-cancel recurring schedules", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule recurring task
_, err := service.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "persist-id")
Expect(err).ToNot(HaveOccurred())
// Trigger multiple times
mockSched.TriggerAll()
mockSched.TriggerAll()
// Schedule should still exist (recurring doesn't self-cancel)
Expect(service.GetScheduleCount()).To(Equal(1))
})
It("should reject invalid cron expression", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Note: The mock scheduler doesn't validate cron expressions,
// but the real scheduler would. This test verifies behavior
// when the scheduler returns an error.
// For now, just verify the method works with a valid expression
_, err := service.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("CancelSchedule", func() {
It("should cancel a scheduled task", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule recurring task
_, err := service.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "cancel-test", "cancel-id")
Expect(err).ToNot(HaveOccurred())
Expect(service.GetScheduleCount()).To(Equal(1))
// Cancel
err = service.CancelSchedule(GinkgoT().Context(), "cancel-id")
Expect(err).ToNot(HaveOccurred())
Expect(service.GetScheduleCount()).To(Equal(0))
// Trigger should not invoke callback
mockSched.TriggerAll()
Expect(service.GetCallbackCount()).To(Equal(0))
})
It("should return error for non-existent schedule", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
err := service.CancelSchedule(GinkgoT().Context(), "non-existent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
})
Describe("Plugin Unload", func() {
It("should cancel all schedules when plugin is unloaded", func() {
service := getSchedulerService("fake-scheduler")
Expect(service).ToNot(BeNil())
// Schedule multiple tasks
_, err := service.ScheduleRecurring(GinkgoT().Context(), "@every 10s", "data1", "unload-1")
Expect(err).ToNot(HaveOccurred())
_, err = service.ScheduleRecurring(GinkgoT().Context(), "@every 10s", "data2", "unload-2")
Expect(err).ToNot(HaveOccurred())
Expect(service.GetScheduleCount()).To(Equal(2))
// Unload plugin
err = manager.UnloadPlugin("fake-scheduler")
Expect(err).ToNot(HaveOccurred())
// Verify scheduler service was cleaned up
Expect(getSchedulerService("fake-scheduler")).To(BeNil())
})
})
})
// mockScheduler implements scheduler.Scheduler for testing without timing dependencies.
// It allows tests to manually trigger callbacks.
type mockScheduler struct {
mu sync.Mutex
callbacks map[int]func()
nextID int
}
func newMockScheduler() *mockScheduler {
return &mockScheduler{
callbacks: make(map[int]func()),
nextID: 1,
}
}
func (s *mockScheduler) Run(_ context.Context) {
// No-op for mock - we trigger callbacks manually
}
func (s *mockScheduler) Add(_ string, cmd func()) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
id := s.nextID
s.nextID++
s.callbacks[id] = cmd
return id, nil
}
func (s *mockScheduler) Remove(id int) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.callbacks, id)
}
// TriggerCallback manually triggers a callback by its entry ID.
func (s *mockScheduler) TriggerCallback(id int) bool {
s.mu.Lock()
cb, exists := s.callbacks[id]
s.mu.Unlock()
if exists && cb != nil {
cb()
return true
}
return false
}
// TriggerAll triggers all registered callbacks.
func (s *mockScheduler) TriggerAll() {
s.mu.Lock()
callbacks := make([]func(), 0, len(s.callbacks))
for _, cb := range s.callbacks {
callbacks = append(callbacks, cb)
}
s.mu.Unlock()
for _, cb := range callbacks {
cb()
}
}
// GetCallbackCount returns the number of registered callbacks.
func (s *mockScheduler) GetCallbackCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.callbacks)
}
// Reset clears all callbacks and resets the ID counter.
func (s *mockScheduler) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.callbacks = make(map[int]func())
s.nextID = 1
}
var _ scheduler.Scheduler = (*mockScheduler)(nil)

View File

@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/rjeczalik/notify"
"github.com/tetratelabs/wazero"
@ -341,6 +342,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
// if those functions aren't available at compile time. We use a stub service that
// returns an error - the real service will be registered during recompilation.
stubHostFunctions := host.RegisterSubsonicAPIHostFunctions(nil)
stubHostFunctions = append(stubHostFunctions, host.RegisterSchedulerHostFunctions(nil)...)
// Create initial compiled plugin with stub host functions
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions)
@ -400,6 +402,14 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
}
}
// Register Scheduler host functions if permission is granted
if manifest.Permissions != nil && manifest.Permissions.Scheduler != nil {
service := newSchedulerService(name, m, scheduler.GetInstance())
hostFunctions = append(hostFunctions, host.RegisterSchedulerHostFunctions(service)...)
registerSchedulerService(name, service.(*schedulerServiceImpl))
needsRecompile = true
}
// Recompile if needed (AllowedHosts or host functions)
if needsRecompile {
compiled.Close(m.ctx)
@ -441,6 +451,9 @@ func (m *Manager) UnloadPlugin(name string) error {
delete(m.plugins, name)
m.mu.Unlock()
// Cancel all scheduled tasks for this plugin
unregisterSchedulerService(name)
// Close the compiled plugin outside the lock with a grace period
// to allow in-flight requests to complete
if instance.compiled != nil {

View File

@ -49,6 +49,9 @@
},
"subsonicapi": {
"$ref": "#/$defs/SubsonicAPIPermission"
},
"scheduler": {
"$ref": "#/$defs/SchedulerPermission"
}
}
},
@ -103,6 +106,17 @@
"default": false
}
}
},
"SchedulerPermission": {
"type": "object",
"description": "Scheduler service permissions for scheduling tasks",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why scheduler access is needed"
}
}
}
}
}

View File

@ -83,10 +83,19 @@ type Permissions struct {
// Http corresponds to the JSON schema field "http".
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
// Scheduler corresponds to the JSON schema field "scheduler".
Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
}
// Scheduler service permissions for scheduling tasks
type SchedulerPermission struct {
// Explanation for why scheduler access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// SubsonicAPI service permissions
type SubsonicAPIPermission struct {
// If false, reject calls where the u is an admin

View File

@ -0,0 +1,5 @@
module fake-scheduler
go 1.23
require github.com/extism/go-pdk v1.1.3

View File

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

153
plugins/testdata/fake-scheduler/main.go vendored Normal file
View File

@ -0,0 +1,153 @@
// Fake scheduler plugin for Navidrome plugin system integration tests.
// Build with: tinygo build -o ../fake-scheduler.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"encoding/json"
"strconv"
"github.com/extism/go-pdk"
)
// Manifest types
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
Permissions *Permissions `json:"permissions,omitempty"`
}
type Permissions struct {
Scheduler *SchedulerPermission `json:"scheduler,omitempty"`
}
type SchedulerPermission struct {
Reason string `json:"reason,omitempty"`
}
// Scheduler callback input
type SchedulerCallbackInput struct {
ScheduleID string `json:"schedule_id"`
Payload string `json:"payload"`
IsRecurring bool `json:"is_recurring"`
}
// Scheduler callback output
type SchedulerCallbackOutput struct {
Error string `json:"error,omitempty"`
}
// CallRecord stores information about a callback that was received
type CallRecord struct {
ScheduleID string `json:"schedule_id"`
Payload string `json:"payload"`
IsRecurring bool `json:"is_recurring"`
CallCount int `json:"call_count"`
}
// Global state for tracking callbacks (var stores persist in wasm memory between calls)
var callRecords = make(map[string]*CallRecord)
var totalCallCount = 0
//go:wasmexport nd_manifest
func ndManifest() int32 {
reason := "For testing scheduler callbacks"
manifest := Manifest{
Name: "Fake Scheduler",
Author: "Navidrome Test",
Version: "1.0.0",
Description: "A fake scheduler plugin for integration testing",
Permissions: &Permissions{
Scheduler: &SchedulerPermission{
Reason: reason,
},
},
}
out, err := json.Marshal(manifest)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
//go:wasmexport nd_scheduler_callback
func ndSchedulerCallback() int32 {
var input SchedulerCallbackInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
// Payload is now a plain string, no decoding needed
payload := input.Payload
// Check for configured error response
errCfg, hasErr := pdk.GetConfig("callback_error")
if hasErr && errCfg != "" {
output := SchedulerCallbackOutput{Error: errCfg}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
// Track the callback
totalCallCount++
if record, exists := callRecords[input.ScheduleID]; exists {
record.CallCount++
} else {
callRecords[input.ScheduleID] = &CallRecord{
ScheduleID: input.ScheduleID,
Payload: payload,
IsRecurring: input.IsRecurring,
CallCount: 1,
}
}
// Log the callback for debugging
pdk.Log(pdk.LogInfo, "Scheduler callback received: "+input.ScheduleID+" payload="+payload)
// Success
output := SchedulerCallbackOutput{}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return 1
}
return 0
}
// Helper function to get call records (for testing)
//
//go:wasmexport nd_get_call_records
func ndGetCallRecords() int32 {
out, err := json.Marshal(callRecords)
if err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(out)
return 0
}
// Helper function to get total call count (for testing)
//
//go:wasmexport nd_get_total_call_count
func ndGetTotalCallCount() int32 {
pdk.Output([]byte(strconv.Itoa(totalCallCount)))
return 0
}
// Helper function to reset call records (for testing)
//
//go:wasmexport nd_reset_call_records
func ndResetCallRecords() int32 {
callRecords = make(map[string]*CallRecord)
totalCallCount = 0
return 0
}
func main() {}

View File

@ -0,0 +1,131 @@
// Code generated by hostgen. DO NOT EDIT.
//
// This file contains client wrappers for the Scheduler host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package main
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
// scheduler_scheduleonetime is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user scheduler_scheduleonetime
func scheduler_scheduleonetime(int32, uint64, uint64) uint64
// scheduler_schedulerecurring is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user scheduler_schedulerecurring
func scheduler_schedulerecurring(uint64, uint64, uint64) uint64
// scheduler_cancelschedule is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user scheduler_cancelschedule
func scheduler_cancelschedule(uint64) uint64
// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime.
type SchedulerScheduleOneTimeResponse struct {
NewScheduleID string `json:"newScheduleID,omitempty"`
Error string `json:"error,omitempty"`
}
// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring.
type SchedulerScheduleRecurringResponse struct {
NewScheduleID string `json:"newScheduleID,omitempty"`
Error string `json:"error,omitempty"`
}
// SchedulerScheduleOneTime calls the scheduler_scheduleonetime host function.
// ScheduleOneTime schedules a one-time event to be triggered after the specified delay.
//
// Parameters:
// - delaySeconds: Number of seconds to wait before triggering the event
// - payload: Data to be passed to the scheduled event handler
// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
func SchedulerScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (*SchedulerScheduleOneTimeResponse, error) {
payloadMem := pdk.AllocateString(payload)
defer payloadMem.Free()
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
// Call the host function
responsePtr := scheduler_scheduleonetime(delaySeconds, payloadMem.Offset(), scheduleIDMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response SchedulerScheduleOneTimeResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// SchedulerScheduleRecurring calls the scheduler_schedulerecurring host function.
// ScheduleRecurring schedules a recurring event using a cron expression.
//
// Parameters:
// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight)
// - payload: Data to be passed to each scheduled event handler invocation
// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
//
// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
func SchedulerScheduleRecurring(cronExpression string, payload string, scheduleID string) (*SchedulerScheduleRecurringResponse, error) {
cronExpressionMem := pdk.AllocateString(cronExpression)
defer cronExpressionMem.Free()
payloadMem := pdk.AllocateString(payload)
defer payloadMem.Free()
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
// Call the host function
responsePtr := scheduler_schedulerecurring(cronExpressionMem.Offset(), payloadMem.Offset(), scheduleIDMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response SchedulerScheduleRecurringResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
return &response, nil
}
// SchedulerCancelSchedule calls the scheduler_cancelschedule host function.
// CancelSchedule cancels a scheduled job identified by its schedule ID.
//
// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger
// any future events.
//
// Returns an error if the schedule ID is not found or if cancellation fails.
func SchedulerCancelSchedule(scheduleID string) error {
scheduleIDMem := pdk.AllocateString(scheduleID)
defer scheduleIDMem.Free()
// Call the host function
responsePtr := scheduler_cancelschedule(scheduleIDMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
errStr := string(responseMem.ReadBytes())
if errStr != "" {
return errors.New(errStr)
}
return nil
}