// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 // Package driver_asynq_cron provides the Asynq cron schedule driver plugin for Cordis. package driver_asynq_cron import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/pkg/config" "context" "embed" "encoding/json" "fmt" "sync" "time" "github.com/hibiken/asynq" ) //go:embed migrations/*/*.sql var cronMigrations embed.FS // Option configures the Asynq cron scheduler driver plugin. type Option func(*Plugin) // WithRedisOpt sets the Redis connection options for Asynq scheduler. func WithRedisOpt(opt asynq.RedisConnOpt) Option { return func(p *Plugin) { p.redisOpt = opt } } // WithLocation sets the timezone location for the cron scheduler. func WithLocation(loc *time.Location) Option { return func(p *Plugin) { p.location = loc } } // WithSchedulerOpts sets custom Asynq SchedulerOpts. func WithSchedulerOpts(opts *asynq.SchedulerOpts) Option { return func(p *Plugin) { p.schedulerOpts = opts } } // WithScheduler injects a pre-configured Asynq Scheduler instance. func WithScheduler(sched *asynq.Scheduler) Option { return func(p *Plugin) { p.scheduler = sched } } // Plugin implements core.Plugin and core.Driver for Asynq Cron Scheduler. type Plugin struct { mu sync.RWMutex redisOpt asynq.RedisConnOpt location *time.Location schedulerOpts *asynq.SchedulerOpts scheduler *asynq.Scheduler running bool coreCtx *core.Context } // New creates a new Asynq Cron Scheduler driver plugin. func New(opts ...Option) *Plugin { p := &Plugin{ redisOpt: RedisOpt, location: time.Local, } for _, opt := range opts { if opt != nil { opt(p) } } return p } // Name returns the unique plugin identifier. func (p *Plugin) Name() string { return "driver_asynq_cron" } // Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context. func (p *Plugin) Apply(ctx *core.Context) error { p.mu.Lock() p.coreCtx = ctx p.mu.Unlock() // Bind DBService if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { setDBService(db) } else { core.When[contracts.DBService](ctx, func(db contracts.DBService) { setDBService(db) }) } // Bind TaskService if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { setTaskService(taskSvc) } else { core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { setTaskService(taskSvc) }) } ctx.OnDispose(func() error { setDBService(nil) setTaskService(nil) return nil }) // Register migrations for w_schedules table ctx.Migrations().Register("driver_asynq_cron", cronMigrations) ctx.OnDispose(func() error { return p.Stop(context.Background()) }) return ctx.RegisterDriver(p) } // Type returns DriverTypeScheduler. func (p *Plugin) Type() core.DriverType { return core.DriverTypeScheduler } // Start registers scheduled cron entries from Context and boots the Asynq scheduler. func (p *Plugin) Start(_ context.Context) error { p.mu.Lock() defer p.mu.Unlock() if p.running { return nil } if p.scheduler == nil { p.scheduler = p.initScheduler() } if p.coreCtx != nil && p.coreCtx.Schedules() != nil { for _, sd := range p.coreCtx.Schedules().Schedules() { payloadBytes, err := encodePayload(sd.Payload) if err != nil { return fmt.Errorf("driver_asynq_cron: encode payload for task %q failed: %w", sd.TaskType, err) } task := asynq.NewTask(sd.TaskType, payloadBytes) asynqOpts := buildAsynqOptions(sd.Options) if _, err := p.scheduler.Register(sd.Spec, task, asynqOpts...); err != nil { return fmt.Errorf("driver_asynq_cron: register schedule %q for task %q failed: %w", sd.Spec, sd.TaskType, err) } } } if err := p.scheduler.Start(); err != nil { return fmt.Errorf("driver_asynq_cron: start scheduler failed: %w", err) } p.running = true return nil } // Stop gracefully shuts down the Asynq cron scheduler. func (p *Plugin) Stop(_ context.Context) error { p.mu.Lock() defer p.mu.Unlock() if !p.running { return nil } p.running = false if p.scheduler != nil { p.scheduler.Shutdown() p.scheduler = nil } return nil } // IsRunning returns whether the scheduler is running. func (p *Plugin) IsRunning() bool { p.mu.RLock() defer p.mu.RUnlock() return p.running } // Scheduler returns the underlying Asynq scheduler instance. func (p *Plugin) Scheduler() *asynq.Scheduler { p.mu.RLock() defer p.mu.RUnlock() return p.scheduler } func encodePayload(payload any) ([]byte, error) { if payload == nil { return nil, nil } switch val := payload.(type) { case []byte: return val, nil case string: return []byte(val), nil default: return json.Marshal(val) } } func buildAsynqOptions(opts map[string]any) []asynq.Option { if len(opts) == 0 { return nil } var res []asynq.Option if q, ok := opts["queue"].(string); ok && q != "" { res = append(res, asynq.Queue(q)) } if retry, ok := opts["retry"].(int); ok { res = append(res, asynq.MaxRetry(retry)) } else if maxRetry, ok := opts["max_retry"].(int); ok { res = append(res, asynq.MaxRetry(maxRetry)) } if timeout, ok := opts["timeout"].(time.Duration); ok { res = append(res, asynq.Timeout(timeout)) } if retention, ok := opts["retention"].(time.Duration); ok { res = append(res, asynq.Retention(retention)) } return res } func (p *Plugin) initScheduler() *asynq.Scheduler { opts := p.schedulerOpts if opts == nil { opts = &asynq.SchedulerOpts{ Location: p.location, } } else if opts.Location == nil && p.location != nil { opts.Location = p.location } opt := p.resolveRedisOpt() return asynq.NewScheduler(opt, opts) } func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt { if p.redisOpt != nil { return p.redisOpt } if RedisOpt != nil { return RedisOpt } redisCfg := config.Config.Redis addr := "127.0.0.1:6379" if len(redisCfg.Addrs) > 0 && redisCfg.Addrs[0] != "" { addr = redisCfg.Addrs[0] } return asynq.RedisClientOpt{ Addr: addr, Username: redisCfg.Username, Password: redisCfg.Password, DB: redisCfg.DB, } }