mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
feat(scheduler): implement Scheduler service with one-time and recurring scheduling capabilities
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
b84089cea4
commit
f4c6461c0a
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
361
plugins/host_scheduler.go
Normal 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) },
|
||||
}
|
||||
}
|
||||
362
plugins/host_scheduler_test.go
Normal file
362
plugins/host_scheduler_test.go
Normal 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)
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
5
plugins/testdata/fake-scheduler/go.mod
vendored
Normal file
5
plugins/testdata/fake-scheduler/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
module fake-scheduler
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/testdata/fake-scheduler/go.sum
vendored
Normal file
2
plugins/testdata/fake-scheduler/go.sum
vendored
Normal 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
153
plugins/testdata/fake-scheduler/main.go
vendored
Normal 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() {}
|
||||
131
plugins/testdata/fake-scheduler/nd_host_scheduler.go
vendored
Normal file
131
plugins/testdata/fake-scheduler/nd_host_scheduler.go
vendored
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user