feat(plugins): add TimeNow function to SchedulerService (#4337)

* feat: add TimeNow function to SchedulerService plugin

Added new TimeNow RPC method to the SchedulerService host service that returns
the current time in two formats: RFC3339Nano string and Unix milliseconds int64.
This provides plugins with a standardized way to get current time information
from the host system.

The implementation includes:
- TimeNowRequest/TimeNowResponse protobuf message definitions
- Go host service implementation using time.Now()
- Complete test coverage with format validation
- Generated WASM interface code for plugin communication

* feat: add LocalTimeZone field to TimeNow response

Added LocalTimeZone field to TimeNowResponse message in the SchedulerService
plugin host service. This field contains the server's local timezone name
(e.g., 'America/New_York', 'UTC') providing plugins with timezone context
alongside the existing RFC3339Nano and Unix milliseconds timestamps.

The implementation includes:
- New local_time_zone protobuf field definition
- Go implementation using time.Now().Location().String()
- Updated test coverage with timezone validation
- Generated protobuf serialization/deserialization code

* docs: update plugin README with TimeNow function documentation

Updated the plugins README.md to document the new TimeNow function in the
SchedulerService. The documentation includes detailed descriptions of the
three return formats (RFC3339Nano, UnixMilli, LocalTimeZone), practical
use cases, and a comprehensive Go code example showing how plugins can
access current time information for logging, calculations, and timezone-aware
operations.

* docs: remove wrong comment from InitRequest

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add missing TimeNow method to namedSchedulerService

Added TimeNow method implementation to namedSchedulerService struct to satisfy the scheduler.SchedulerService interface contract. This method was recently added to the interface but the namedSchedulerService wrapper was not updated, causing compilation failures in plugin tests. The implementation is a simple pass-through to the underlying scheduler service since TimeNow doesn't require any special handling for named callbacks.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-07-13 15:23:58 -03:00
committed by GitHub
parent 1de84dbd0c
commit 5b73a4d5b7
11 changed files with 503 additions and 4 deletions
+41 -2
View File
@@ -196,7 +196,7 @@ See the [cache.proto](host/cache/cache.proto) file for the full API definition.
#### SchedulerService
The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API.
The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API.
```protobuf
service SchedulerService {
@@ -208,11 +208,50 @@ service SchedulerService {
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
// Get current time in multiple formats
rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
}
```
**Key Features:**
- **One-time scheduling**: Schedule a callback to be executed once after a specified delay.
- **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression.
- **Current time access**: Get the current time in standardized formats for time-based operations.
**TimeNow Function:**
The `TimeNow` function returns the current time in three formats:
```protobuf
message TimeNowResponse {
string rfc3339_nano = 1; // RFC3339 format with nanosecond precision
int64 unix_milli = 2; // Unix timestamp in milliseconds
string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York")
}
```
This allows plugins to:
- Get high-precision timestamps for logging and event correlation
- Perform time-based calculations using Unix timestamps
- Handle timezone-aware operations by knowing the server's local timezone
Example usage:
```go
// Get current time information
timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{})
if err != nil {
return err
}
// Use the different time formats
timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z"
unixMs := timeResp.UnixMilli // 1705312245123
timezone := timeResp.LocalTimeZone // "UTC"
```
Plugins using this service must implement the `SchedulerCallback` interface:
@@ -433,7 +472,7 @@ If no permissions are needed, use an empty permissions object: `"permissions": {
The following permission keys correspond to host services:
| Permission | Host Service | Description | Required Fields |
|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------|
| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- |
| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` |
| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` |
| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` |
-1
View File
@@ -903,7 +903,6 @@ type InitRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Empty for now
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin
}
-1
View File
@@ -194,7 +194,6 @@ service LifecycleManagement {
}
message InitRequest {
// Empty for now
map<string, string> config = 1; // Configuration specific to this plugin
}
@@ -88,3 +88,7 @@ func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *sch
request.ScheduleId = key
return n.svc.CancelSchedule(ctx, request)
}
func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
return n.svc.TimeNow(ctx, request)
}
+47
View File
@@ -154,6 +154,51 @@ func (x *CancelResponse) GetError() string {
return ""
}
type TimeNowRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *TimeNowRequest) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
type TimeNowResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format
UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp
LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC")
}
func (x *TimeNowResponse) ProtoReflect() protoreflect.Message {
panic(`not implemented`)
}
func (x *TimeNowResponse) GetRfc3339Nano() string {
if x != nil {
return x.Rfc3339Nano
}
return ""
}
func (x *TimeNowResponse) GetUnixMilli() int64 {
if x != nil {
return x.UnixMilli
}
return 0
}
func (x *TimeNowResponse) GetLocalTimeZone() string {
if x != nil {
return x.LocalTimeZone
}
return ""
}
// go:plugin type=host version=1
type SchedulerService interface {
// One-time event scheduling
@@ -162,4 +207,6 @@ type SchedulerService interface {
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
// Cancel any scheduled job
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
// Get current time in multiple formats
TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error)
}
+13
View File
@@ -14,6 +14,9 @@ service SchedulerService {
// Cancel any scheduled job
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
// Get current time in multiple formats
rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
}
message ScheduleOneTimeRequest {
@@ -40,3 +43,13 @@ message CancelResponse {
bool success = 1; // Whether cancellation was successful
string error = 2; // Error message if cancellation failed
}
message TimeNowRequest {
// Empty request - no parameters needed
}
message TimeNowResponse {
string rfc3339_nano = 1; // Current time in RFC3339Nano format
int64 unix_milli = 2; // Current time as Unix milliseconds timestamp
string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC")
}
@@ -44,6 +44,11 @@ func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerS
WithParameterNames("offset", "size").
Export("cancel_schedule")
envBuilder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}).
WithParameterNames("offset", "size").
Export("time_now")
_, err := envBuilder.Instantiate(ctx)
return err
}
@@ -134,3 +139,32 @@ func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, st
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
// Get current time in multiple formats
func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) {
offset, size := uint32(stack[0]), uint32(stack[1])
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
if err != nil {
panic(err)
}
request := new(TimeNowRequest)
err = request.UnmarshalVT(buf)
if err != nil {
panic(err)
}
resp, err := h.TimeNow(ctx, request)
if err != nil {
panic(err)
}
buf, err = resp.MarshalVT()
if err != nil {
panic(err)
}
ptr, err := wasm.WriteMemory(ctx, m, buf)
if err != nil {
panic(err)
}
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
stack[0] = ptrLen
}
@@ -88,3 +88,26 @@ func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelReq
}
return response, nil
}
//go:wasmimport env time_now
func _time_now(ptr uint32, size uint32) uint64
func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) {
buf, err := request.MarshalVT()
if err != nil {
return nil, err
}
ptr, size := wasm.ByteToPtr(buf)
ptrSize := _time_now(ptr, size)
wasm.Free(ptr)
ptr = uint32(ptrSize >> 32)
size = uint32(ptrSize)
buf = wasm.PtrToByte(ptr, size)
response := new(TimeNowResponse)
if err = response.UnmarshalVT(buf); err != nil {
return nil, err
}
return response, nil
}
@@ -256,6 +256,91 @@ func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
return len(dAtA) - i, nil
}
func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
return len(dAtA) - i, nil
}
func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) {
if m == nil {
return nil, nil
}
size := m.SizeVT()
dAtA = make([]byte, size)
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) {
size := m.SizeVT()
return m.MarshalToSizedBufferVT(dAtA[:size])
}
func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
if m == nil {
return 0, nil
}
i := len(dAtA)
_ = i
var l int
_ = l
if m.unknownFields != nil {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.LocalTimeZone) > 0 {
i -= len(m.LocalTimeZone)
copy(dAtA[i:], m.LocalTimeZone)
i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone)))
i--
dAtA[i] = 0x1a
}
if m.UnixMilli != 0 {
i = encodeVarint(dAtA, i, uint64(m.UnixMilli))
i--
dAtA[i] = 0x10
}
if len(m.Rfc3339Nano) > 0 {
i -= len(m.Rfc3339Nano)
copy(dAtA[i:], m.Rfc3339Nano)
i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano)))
i--
dAtA[i] = 0xa
}
return len(dAtA) - i, nil
}
func encodeVarint(dAtA []byte, offset int, v uint64) int {
offset -= sov(v)
base := offset
@@ -355,6 +440,37 @@ func (m *CancelResponse) SizeVT() (n int) {
return n
}
func (m *TimeNowRequest) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
n += len(m.unknownFields)
return n
}
func (m *TimeNowResponse) SizeVT() (n int) {
if m == nil {
return 0
}
var l int
_ = l
l = len(m.Rfc3339Nano)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
if m.UnixMilli != 0 {
n += 1 + sov(uint64(m.UnixMilli))
}
l = len(m.LocalTimeZone)
if l > 0 {
n += 1 + l + sov(uint64(l))
}
n += len(m.unknownFields)
return n
}
func sov(x uint64) (n int) {
return (bits.Len64(x|1) + 6) / 7
}
@@ -915,6 +1031,191 @@ func (m *CancelResponse) UnmarshalVT(dAtA []byte) error {
}
return nil
}
func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Rfc3339Nano = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
case 2:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType)
}
m.UnixMilli = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.UnixMilli |= int64(b&0x7F) << shift
if b < 0x80 {
break
}
}
case 3:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.LocalTimeZone = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skip(dAtA[iNdEx:])
if err != nil {
return err
}
if (skippy < 0) || (iNdEx+skippy) < 0 {
return ErrInvalidLength
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skip(dAtA []byte) (n int, err error) {
l := len(dAtA)
+15
View File
@@ -45,6 +45,10 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul
return s.ss.cancelSchedule(ctx, s.pluginID, req)
}
func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
return s.ss.timeNow(ctx, req)
}
type schedulerService struct {
// Map of schedule IDs to their callback info
schedules map[string]*ScheduledCallback
@@ -260,6 +264,17 @@ func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, re
}, nil
}
// timeNow returns the current time in multiple formats
func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
now := time.Now()
return &scheduler.TimeNowResponse{
Rfc3339Nano: now.Format(time.RFC3339Nano),
UnixMilli: now.UnixMilli(),
LocalTimeZone: now.Location().String(),
}, nil
}
// runOneTimeSchedule handles the one-time schedule execution and callback
func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) {
tmr := time.NewTimer(delay)
+25
View File
@@ -2,6 +2,7 @@ package plugins
import (
"context"
"time"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/plugins/host/scheduler"
@@ -164,4 +165,28 @@ var _ = Describe("SchedulerService", func() {
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
})
})
Describe("TimeNow", func() {
It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() {
now := time.Now()
req := &scheduler.TimeNowRequest{}
resp, err := ss.timeNow(context.Background(), req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli()))
Expect(resp.LocalTimeZone).ToNot(BeEmpty())
// Validate RFC3339Nano format can be parsed
parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano)
Expect(parseErr).ToNot(HaveOccurred())
// Validate that Unix milliseconds is reasonably close to the RFC3339Nano time
expectedMillis := parsedTime.UnixMilli()
Expect(resp.UnixMilli).To(Equal(expectedMillis))
// Validate local timezone matches the current system timezone
expectedTimezone := now.Location().String()
Expect(resp.LocalTimeZone).To(Equal(expectedTimezone))
})
})
})