Skip to content

cron ​

中文

A distributed task queue system built on Asynq, designed as a scheduled task and asynchronous task processing module for the Go-Zero framework.

Features ​

  • 🚀 High Performance: High-performance distributed task queue based on Redis
  • ⏰ Scheduled Tasks: Supports Cron expression scheduled tasks
  • 🔄 Asynchronous Processing: Asynchronous task queue with delayed execution support
  • 📊 Metrics: Built-in Prometheus metrics collection
  • 🔍 Distributed Tracing: Integrated OpenTelemetry tracing
  • 🛡️ Error Recovery: Automatic panic recovery and error handling
  • 🔧 Flexible Configuration: Supports multiple Redis modes (Single, Sentinel, Cluster)

Installation ​

bash
go get github.com/lerity-yao/czt-contrib/cron

Service Generation ​

Supports one-click server code generation similar to the goctl tool. The tool is cztctl, a customized version of goctl.

You can also customize the server code generation templates.

Please refer to cztctl.

⚙️ Configuration Parameters ​

RedisConf (Basic Connection Configuration) ​

This configuration controls how to connect to Redis, supporting Single, Sentinel, and Cluster modes.

ParameterTypeDefaultDescription and Recommendations
ModestringsingleRequired. Options: single, sentinel, cluster. Determines which subsequent fields take effect.
Addrstring-Required when Mode=single. Format "host:port".
Addrs[]string-Required when Mode=cluster. List of cluster seed nodes; only some nodes are needed, and the driver will auto-discover the full topology.
MasterNamestring-Required when Mode=sentinel. The master node name monitored in Sentinel mode (usually defaults to mymaster).
SentinelAddrs[]string-Required when Mode=sentinel. List of Sentinel nodes. At least 3 are recommended for high availability.
Usernamestring-Redis 6.0+ ACL authentication username.
Passwordstring-Redis authentication password.
DBint640Redis database index. Note: this is invalid in Cluster mode.
PoolSizeint64-Maximum number of connections in the pool. Defaults to 10 * number of CPU cores. For high-concurrency tasks, increase based on concurrency.
DialTimeoutint645Connection establishment timeout in seconds. Increase appropriately in poor network environments.
ReadTimeoutint643Read timeout in seconds. Recommended to keep the default.
WriteTimeoutint643Write timeout in seconds. Recommended to keep the default.

ServerConfig (Task Processing Engine Configuration) ​

This configuration directly affects consumer processing efficiency, stability, and resource usage.

  • Namespace (string): Core logic: all keys in Redis will be prefixed with this namespace. Recommendation: use a different Namespace for each independent service. This provides physical isolation and prevents workers of different services from accidentally consuming each other's tasks.

  • Concurrency (int64): Default: 0 (automatically set to the number of CPU cores). Recommendation: if tasks involve a lot of network IO (e.g., sending SMS, calling third-party APIs), increase to 20~100; for CPU-intensive tasks, keep the default or increase slightly.

  • Queues (map[string]int): Core logic: defines which queues to listen to and their weights. Practical example: {"critical": 6, "default": 3, "low": 1} means 60% of effort goes to critical tasks.

  • StrictPriority (bool): Logic: if true, as long as the critical queue has a task, the worker will never touch the default queue. Note: enabling this may cause low-priority queues to starve (never get processed); use with caution.

  • TaskCheckInterval (int64): Logic: how long the worker rests before checking Redis again when all queues are empty. Recommendation: default 1 second. Too short increases Redis CPU load; too long causes noticeable task processing delays.

  • ShutdownTimeout (int64): Logic: maximum time the worker waits for current tasks to complete during graceful shutdown. Recommendation: default 8 seconds. If your task logic is long (e.g., processing large files), you must increase this value, otherwise tasks will be forcibly interrupted and requeued.

  • DelayedTaskCheckInterval (int64): Logic: frequency of checking whether delayed tasks and retry tasks are due. Default 5 seconds.

  • HealthCheckInterval (int64): Logic: heartbeat detection between the worker and Redis. Recommended to keep the default 15 seconds.

  • GroupGracePeriod (int64): Aggregation window (sliding window). Default 60 seconds. The timer resets each time a new task enters the group; aggregation is triggered only when no new tasks arrive during the grace period.

  • GroupMaxDelay (int64): Maximum waiting time to force aggregation (hard limit). Default 300 seconds. From the arrival of the first task in the group, aggregation is forced at this time regardless of new tasks. Prevents GracePeriod from being reset indefinitely and never aggregating.

  • GroupMaxSize (int64): When the number of tasks in the group reaches this threshold, aggregation is triggered immediately without waiting for the window. Default 0 (unlimited).

The above three conditions are in an OR relationship; aggregation is triggered when any one is met. Must be used with WithGroupAggregator; otherwise the grouping feature does not take effect.

  • JanitorInterval (int64): Interval for checking and cleaning completed/expired tasks in Redis.

  • JanitorBatchSize (int64): Maximum number of items deleted per cleanup operation. Default 100. Prevents Redis from blocking due to deleting too many at once.

Configuration Recommendations

  • Namespace must be set: this is the foundation for multiple services to coexist.
  • Set Concurrency reasonably: larger for more IO, smaller for more CPU.
  • Set ShutdownTimeout: must be greater than the longest possible execution time in your business logic.

ClientConfig (Client Configuration) ​

Contains only RedisConf, sharing the same Redis connection configuration as the Server.

ServerOption ​

OptionParameterDescription
WithServerTLS*tls.ConfigSet Redis TLS connection configuration
WithServerLoggerasynq.LoggerReplace the default logger (recommended: &cron.AsynqLogger{} to bridge to go-zero logx)
WithGroupAggregatorasynq.GroupAggregatorInject a group aggregator to enable Group functionality
WithRetryDelayFuncasynq.RetryDelayFuncCustomize retry backoff strategy (default: ExponentialRetryDelay)

ClientOption ​

OptionParameterDescription
WithClientTLS*tls.ConfigSet Redis TLS connection configuration

API Reference ​

Constructors ​

FunctionSignatureDescription
MustNewServerfunc MustNewServer(conf ServerConfig, opts ...ServerOption) ServerCreate a Server; panics on failure
NewServerfunc NewServer(conf ServerConfig, opts ...ServerOption) (Server, error)Create a Server; returns error on failure
MustNewClientfunc MustNewClient(conf ClientConfig, opts ...ClientOption) *CommonClientCreate a Client; panics on failure
NewClientfunc NewClient(conf ClientConfig, opts ...ClientOption) (*CommonClient, error)Create a Client; returns error on failure
MustNewClientFromRedisClientfunc MustNewClientFromRedisClient(rds redis.UniversalClient) *CommonClientCreate a Client from an existing Redis connection; panics on failure
NewClientFromRedisClientfunc NewClientFromRedisClient(rds redis.UniversalClient) (*CommonClient, error)Create a Client from an existing Redis connection; returns error on failure

Public Types ​

TypeDefinitionDescription
Taskstruct { Type string; Payload []byte }Task carrier received by the handler; Type is the task type, Payload is the raw byte data
HandlerFuncfunc(ctx context.Context, t *Task) errorTask handler function signature; all handlers must implement this type
AsynqLoggerstruct{}Built-in log adapter that bridges asynq logs to go-zero logx

Server Interface Methods ​

MethodSignatureDescription
AddAdd(pattern string, handler HandlerFunc)Register a task handler (consume tasks pushed externally); automatically prepends the Namespace prefix
CronAddCronAdd(spec string, pattern string, handler HandlerFunc, opts ...asynq.Option) stringOne-step scheduled task registration (self-produced and self-consumed); completes both handler registration and scheduled dispatch, automatically injects asynq.Unique deduplication, returns EntryID
SetBaseContextSetBaseContext(ctx context.Context)Inject a base context; all task handler ctxs use this as parent. Must be called before Start()
StartStart()Start Scheduler + Processor; non-blocking
StopStop()Graceful shutdown: closes Scheduler → Server → Inspector in order

Client Interface Methods ​

MethodSignatureDescription
PushPush(ctx, taskType string, payload []byte, opts ...asynq.Option) (*asynq.TaskInfo, error)Execute immediately; push raw bytes
PushJsonPushJson(ctx, taskType string, data any, opts ...asynq.Option) (*asynq.TaskInfo, error)Execute immediately; auto JSON serialization
PushInPushIn(ctx, taskType string, payload []byte, delay time.Duration, opts ...) (*asynq.TaskInfo, error)Delayed execution; specify Duration
PushInJsonPushInJson(ctx, taskType string, data any, delay time.Duration, opts ...) (*asynq.TaskInfo, error)Delayed execution; auto JSON serialization
PushAtPushAt(ctx, taskType string, payload []byte, at time.Time, opts ...) (*asynq.TaskInfo, error)Execute at a specific time; specify absolute time
PushAtJsonPushAtJson(ctx, taskType string, data any, at time.Time, opts ...) (*asynq.TaskInfo, error)Execute at a specific time; auto JSON serialization
CancelTaskCancelTask(queue, taskID string) errorWithdraw tasks in Scheduled/Pending/Retry state
RescheduleTaskRescheduleTask(ctx, queue, taskID, taskType string, data any, newDelay time.Duration, opts ...) (*asynq.TaskInfo, error)Atomic withdraw + re-dispatch; TaskID remains unchanged
CloseClose() errorClose the client connection

All Push methods default to MaxRetry=0 (no retries); this can be overridden via opts.

Utility Functions ​

FunctionSignatureDescription
ExponentialRetryDelayfunc(n int, _ error, _ *asynq.Task) time.DurationBuilt-in exponential backoff (2^n - 1 seconds); used by Server by default
GetTaskIDfunc(ctx context.Context) (string, bool)Task ID; remains unchanged across retries
GetRetryCountfunc(ctx context.Context) (int, bool)Current retry count; 0 = first execution
GetMaxRetryfunc(ctx context.Context) (int, bool)Maximum retry limit
GetQueueNamefunc(ctx context.Context) (string, bool)Queue name where the task resides

Advanced Guide ​

Namespace and TaskType Matching Rules ​

This is an important note: when pushing tasks from the client, you must understand the server's Namespace configuration.

Configuring Namespace on the server has two effects:

  1. taskType automatic concatenation: the Add method converts the pattern to the Namespace:pattern format
  2. Queue automatic creation: the server listens on queues named after the Namespace
go
// server.go Add 方法核心逻辑
func (c *CommonServer) Add(pattern string, handler HandlerFunc) {
    realPattern := pattern
    if c.conf.Namespace != "" {
        realPattern = fmt.Sprintf("%s:%s", c.conf.Namespace, pattern)
    }
    c.Mux.HandleFunc(realPattern, asynqHandler)
}

// server.go buildConfig 方法核心逻辑
func (c *CommonServer) buildConfig() {
    queues := c.conf.Queues
    if len(queues) == 0 && c.conf.Namespace != "" {
        queues = map[string]int{
            c.conf.Namespace: 1,  // 服务端监听以 Namespace 命名的队列
        }
    }
    // ...
}

In the go-zero environment, the service name is usually used as the Namespace:

go
// serviceContext.go 中常见写法
c.WorkConf.Namespace = c.Name  // 使用 go-zero 配置的服务名

When pushing tasks from the client, two conditions must be met simultaneously:

  1. taskType carries the Namespace prefix: consistent with the full pattern registered on the server
  2. Specify the queue: use asynq.Queue(namespace) to specify which queue the task is pushed to
go
import "github.com/hibiken/asynq"

// Assume server config: Namespace = "order-service", registered: Add("send_email", handler)
// Actual registered pattern on the server: "order-service:send_email"
// Queue listened by the server: "order-service"

// ✔️ Correct: taskType with namespace + queue specified
client.PushJson(ctx, "order-service:send_email", payload, asynq.Queue("order-service"))

Summary:

  • Unify Namespace naming conventions between client and server
  • Define constants to manage taskType and Namespace centrally and avoid hard-coding
  • If the server does not configure Namespace, use the original pattern directly as taskType and there is no need to specify a queue (defaults to "default")

Scheduled Task Deduplication ​

CronAdd attaches asynq.Unique(TTL) to every tick, so that when several instances of a service are running, only one of them enqueues the task for a given tick. Underneath it is a plain Redis SET NX EX lock whose key is derived from the queue name and the task type, independent of any task ID.

The TTL is not something the caller has to provide; it is derived from the spec and the effective timeout:

TTL = max(shortest trigger interval − 1s, effective timeout, 1s)

That makes both invariants hold by construction:

InvariantWhy it holds
While the previous run is still going, no second copy can be enqueuedTTL ≥ effective timeout. asynq terminates a run once its timeout is reached, so "still running" necessarily means less time has elapsed than the timeout, which means the lock is necessarily still held
A single failure never blocks later ticks foreverThe lock carries its own expiry. asynq does not clear this lock when it archives a task, so a bounded expiry is the only safety net

The "effective timeout" is read back from asynq.Timeout in opts; when it is absent, asynq's 30 minute default is assumed. The workers.go generated by cztctl always injects asynq.Timeout(RestConf.Timeout), so that default is never reached in generated services.

A few typical values:

specTimeoutTTLImpact of a failure
*/5 * * * *3s4m59sLess than one period; the next tick recovers
@every 5m10m10mAbout 2 ticks are skipped before recovery
@every 1s500ms1sThe next tick recovers

A timeout that is not smaller than the trigger interval (the second row above) is a legitimate configuration; it simply means the effective execution frequency is lower than the nominal one. That is precisely what "a run may take longer than the period" has to mean: overlapping and skipping have to be traded off against each other, and this module chooses not to overlap.

Being rejected by the dedup lock is the normal outcome of a multi-replica deployment (it happens replicas−1 times per tick), so it is only recorded as a debug log and as cron_server_scheduler_enqueue_error_total{reason="duplicate"}; only reason="other" (for example Redis being unavailable) is reported loudly. asynq itself writes no log at all when a tick fails to enqueue, which makes this metric the only entry point for detecting such failures.

Because the next tick of a scheduled task is a natural retry, asynq.MaxRetry(0) is a reasonable default for cron jobs: some tasks simply do not need a retry and can wait for the next run.

Known limitations

  • For specs with uneven trigger intervals (such as 0 9 * * 1-5) the shortest sampled interval is used: a slightly shorter lock is preferred over a lock that spans the densely packed ticks
  • When the trigger interval is below 2s and the timeout is below 1s, the TTL is raised to asynq's 1s floor, which may skip an occasional tick
  • The timeout is measured from the moment a worker starts processing, not from the moment of enqueue. "No second copy while the previous run is going" therefore also requires the queue backlog latency to stay within TTL − timeout. When the timeout is far below the interval this leaves hundreds of seconds of slack; when the timeout approaches or exceeds the interval the slack is zero and a severe backlog can still cause an overlap -- but at that point consumption capacity is already insufficient and the overlap is not the main problem
  • This assumes handlers honour ctx cancellation. A handler that ignores cancellation and runs past its timeout may coexist with a new copy
  • The dedup TTL is derived from the spec and asynq.Timeout; an asynq.Unique passed explicitly in opts is overridden
  • The lock key is derived from the queue name and the task type, not from the spec. So when a long-period job is changed to a short period while keeping the same handler name, a lock sized for the old period keeps blocking the new one -- but only if the run before the change ended in failure. A run that ended successfully leaves no lock behind (asynq deletes it in Done); if this ever bites, DEL the corresponding unique key

Retry Backoff Strategy ​

The Server uses ExponentialRetryDelay exponential backoff by default; the delay formula is 2^n - 1 seconds:

Retry CountDelay
11s
23s
37s
415s
531s
663s (~1 minute)
7127s (~2 minutes)
101023s (~17 minutes)

To customize the backoff strategy, override it via WithRetryDelayFunc:

go
server := cron.MustNewServer(c.WorkConf,
    cron.WithRetryDelayFunc(func(n int, e error, t *asynq.Task) time.Duration {
        // 固定间隔 10 秒
        return 10 * time.Second
    }),
)

Task Metadata ​

In the handler, task runtime metadata can be obtained from ctx:

go
retryCount, _ := cron.GetRetryCount(ctx)  // 当前第几次重试(0 = 首次执行)
maxRetry, _   := cron.GetMaxRetry(ctx)    // 最大重试次数
taskID, _     := cron.GetTaskID(ctx)      // 任务 ID(重试不变)
queueName, _  := cron.GetQueueName(ctx)   // 队列名

Skip Retry ​

When an error is unrecoverable (invalid parameters, business rejection, idempotency conflict), use asynq.SkipRetry to skip retries:

go
// internal/logic/demoA/gdemoalogic.go
import "github.com/hibiken/asynq"

func (l *GDemoALogic) GDemoA(req *types.Name) error {
    if req.Name == "" {
        // 参数非法,重试也没用
        return fmt.Errorf("invalid name: %w", asynq.SkipRetry)
    }
    // ...
    return nil
}

Decision criterion: if retrying will yield the same result → SkipRetry; if retrying might succeed → let it retry.

Note: must wrap with fmt.Errorf("%w", asynq.SkipRetry). github.com/pkg/errors's errors.Wrap is incompatible with the standard library errors.Is() unwrap chain, so asynq cannot recognize it.

Task Timeout Control ​

From v0.1.0: execution timeouts for scheduled tasks and pushed tasks can be injected at the per-task granularity by the business side; the ctx received by the handler carries a Deadline, which automatically triggers ctx.Done() when expired, allowing the business to exit proactively.

Design Points ​

  • asynq's default timeout is a hard-coded constant of 30 minutes, and cannot be configured globally via asynq.Config.
  • Per-task timeout is written to msg.Timeout via the enqueue metadata asynq.Timeout(d); when the processor pulls a task, it injects ctx via context.WithDeadline(baseCtx, deadline).
  • Only enqueue actions (CronAdd, Client.Push*) can carry asynq.Timeout; pure handler registration via Add does not participate in dispatch and cannot set it.

Scheduled Tasks: Managed by go-zero RestConf.Timeout ​

rest.RestConf.Timeout defaults to 3000ms and must be > 0; it can be reused directly in workers.go as the scheduled task timeout baseline:

go
// internal/handler/workers.go
import (
    "time"
    "github.com/hibiken/asynq"
)

func RegisterHandlers(server *service.ServiceGroup, serverCtx *svc.ServiceContext) {
    var taskOpts []asynq.Option
    taskOpts = append(taskOpts,
        asynq.Timeout(time.Duration(serverCtx.Config.Timeout)*time.Millisecond),
        asynq.MaxRetry(0),
    )

    serverCtx.CronServer.CronAdd("*/1 * * * *", "GDemoA",
        demoA.GDemoAHandler(serverCtx), taskOpts...)

    server.Add(serverCtx.CronServer)
}

Effect: when the scheduled time arrives and the task is enqueued, msg.Timeout is automatically set to the Timeout configured in yaml; the handler receives ctx with Deadline = now + Timeout. Changing yaml globally adjusts the timeout limit for all scheduled tasks without code changes.

The default 3 seconds is short for some IO-intensive scheduled tasks; it is recommended to configure explicitly in yaml (e.g., Timeout: 30000).

Pushed Tasks: Custom Timeout per Task on the Client ​

The Client.Push* family all support passing through asynq.Timeout(d), allowing the pusher to specify as needed based on actual task duration, not limited by the 30-minute default:

go
import "github.com/hibiken/asynq"

// Short task: 5-second timeout
client.PushJson(ctx, "order-service:send_sms", payload,
    asynq.Queue("order-service"),
    asynq.Timeout(5*time.Second))

// Long task: 1-hour timeout
client.PushJson(ctx, "order-service:export_report", payload,
    asynq.Queue("order-service"),
    asynq.Timeout(time.Hour))

If the pusher does not pass asynq.Timeout, it falls back to the asynq default of 30 minutes. In production, it is recommended to set each task explicitly according to its time budget.

Task Group Aggregation ​

Merge multiple tasks in the same group into a single batch task before processing; suitable for notification merging, batch writes, and similar scenarios.

Trigger Conditions (Any One of Three) ​

ConditionConfig ParameterDefaultTrigger Mechanism
Grace period timeoutGroupGracePeriod60sSliding window: resets the timer each time a new task arrives; aggregation is triggered only when no new tasks arrive during the grace period
Maximum delayGroupMaxDelay300sHard limit: from the arrival of the first task, aggregation is forced at this time as a safeguard
Count thresholdGroupMaxSize0 (unlimited)When the number of tasks in the group reaches the threshold, aggregate immediately

Trigger Scenario Examples ​

Assume configuration: GracePeriod=60s, MaxDelay=300s, MaxSize=50

ScenarioBehavior
3 tasks arrive and then no moreAggregated 60s after the last task arrives (hits GracePeriod)
Tasks keep arriving every 30sForced aggregation at 300s (hits MaxDelay safeguard)
50 tasks arrive in quick successionAggregate immediately (hits MaxSize)
One task arrives every 61sEach task aggregates separately (61s > 60s, GracePeriod triggers first)

Usage ​

1. Inject the aggregator on the server (required)

go
import "github.com/hibiken/asynq"

server := cron.MustNewServer(c.WorkConf,
    cron.WithGroupAggregator(asynq.GroupAggregatorFunc(func(group string, tasks []*asynq.Task) *asynq.Task {
        // 根据 group 名路由不同的聚合逻辑
        switch group {
        case "batch_email":
            return mergeEmails(tasks)
        case "batch_order":
            return mergeOrders(tasks)
        default:
            return defaultMerge(tasks)
        }
    })),
)

The whole Server has only one GroupAggregator; different group aggregation logics are distinguished by the group parameter.

2. Specify the group when pushing from the client

go
import "github.com/hibiken/asynq"

// Push to the "batch_email" group
client.PushJson(ctx, "order-service:send_email", payload,
    asynq.Queue("order-service"),
    asynq.Group("batch_email"),  // 指定分组名
)

3. Register the handler for the merged task on the server

go
// internal/handler/batch/batchSendEmailHandler.go
func BatchSendEmailHandler(svcCtx *svc.ServiceContext) cron.HandlerFunc {
    return func(ctx context.Context, t *cron.Task) error {
        var emails []types.EmailPayload
        if err := json.Unmarshal(t.Payload, &emails); err != nil {
            return err
        }
        l := batchLogic.NewBatchSendEmailLogic(ctx, svcCtx)
        return l.BatchSendEmail(emails)
    }
}
go
// internal/handler/workers.go
serverCtx.CronServer.Add("batch_send_email", batch.BatchSendEmailHandler(serverCtx))

Notes ​

  • Without passing WithGroupAggregator, the grouping feature does not work at all, and Group-related configuration parameters are ignored
  • All client Push methods support the asynq.Group() option
  • One Server has only one aggregator; multiple groups are routed via the group parameter

Distributed Tracing ​

  • Producer: In CommonClient, otel.Inject pushes TraceID into the Task header.
  • Consumer: TraceMiddleware calls otel.Extract to restore the context.
  • Result: you can see the complete timing diagram from API request to asynchronous task execution in Jaeger or Grafana Tempo.

Note: this tracing is integrated into the go-zero framework; you need to enable tracing in the go-zero project.

Logger Replacement ​

By default, asynq's built-in logger is used. Replace it via WithServerLogger:

go
cron := cron.MustNewServer(c.WorkConf, cron.WithServerLogger(&cron.AsynqLogger{}))

Redis TLS ​

Configure TLS connections via WithServerTLS / WithClientTLS:

go
tlsCfg := &tls.Config{InsecureSkipVerify: true}
server := cron.MustNewServer(conf, cron.WithServerTLS(tlsCfg))
client := cron.MustNewClient(conf, cron.WithClientTLS(tlsCfg))

Metrics ​

Metrics are integrated into the go-zero Prometheus system and exposed via the /metrics endpoint.

Server Metrics (cron_server_) ​

Task Processing Metrics (Interceptor Collection) ​
MetricTypeLabelsDescription
cron_server_consume_totalCountertask_type, statusConsumption count (status: success/fail/skip_retry)
cron_server_consume_duration_msHistogramtask_typeConsumption duration
cron_server_consume_bytesCountertask_typeConsumption bytes
cron_server_active_workersGaugetask_typeCurrent concurrency
cron_server_retry_totalCountertask_typeRetry execution count
cron_server_skip_retry_totalCountertask_typeSkip retry count
cron_server_panic_totalCountertask_typePanic count (panics are not retried)
Scheduler Metrics ​
MetricTypeLabelsDescription
cron_server_scheduler_trigger_totalCountertask_typeScheduled task trigger count
cron_server_scheduler_registeredGauge-Number of currently registered scheduled tasks
cron_server_scheduler_enqueue_error_totalCountertask_type, reasonEnqueue failure count when a tick fires (reason: duplicate / other)
Queue State Metrics (Collector Collection) ​
MetricTypeLabelsDescription
cron_server_tasks_enqueued_totalGaugequeue, stateNumber of tasks in each state (state: active/pending/scheduled/retry/archived/completed)
cron_server_queue_sizeGaugequeueTotal number of tasks in the queue
cron_server_queue_latency_secondsGaugequeueQueue latency (wait time of the oldest pending task)
cron_server_queue_memory_usage_approx_bytesGaugequeueQueue memory usage (sampled estimate)
cron_server_tasks_processed_totalCounterqueueTotal number of processed tasks (success and failure)
cron_server_tasks_failed_totalCounterqueueTotal number of failed tasks
cron_server_queue_paused_totalGaugequeueQueue paused state
cron_server_queue_groupsGaugequeueNumber of aggregation groups
cron_server_tasks_aggregating_totalGaugequeueNumber of aggregating tasks

Queue state metrics are collected via a custom QueueMetricsCollector, supporting queue whitelist filtering to avoid metric mixing when multiple services share Redis.

Client Metrics (cron_client_) ​

MetricTypeLabelsDescription
cron_client_push_totalCountertask_type, push_type, statusPush count (push_type: immediate/delayed/scheduled)
cron_client_push_duration_msHistogramtask_typePush duration
cron_client_push_bytesCountertask_typePush bytes
cron_client_cancel_totalCountertask_type, statusCancel task count

Note: these metrics require enabling Prometheus monitoring in the go-zero project.

Full Example ​

Using in go-zero ​

Server ​

Directory Structure

shell
├── etc/
│   └── etc.yaml
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── handler/
│   │   ├── demoA/
│   │   │   └── gdemoahandler.go
│   │   └── workers.go
│   ├── logic/
│   │   └── demoA/
│   │       └── gdemoalogic.go
│   ├── svc/
│   │   └── servicecontext.go
│   └── types/
│       └── types.go
└── main.go

Code

go
// internal/config/config.go
type BaseConfig struct {
    rest.RestConf
    WorkConf cron.ServerConfig
}
go
// main.go
func main() {
    flag.Parse()

    var c config.BaseConfig
    conf.MustLoad(*configFile, &c, conf.UseEnv())
    ctx := svc.NewServiceContext(c)
    serviceGroup := service.NewServiceGroup()
    defer serviceGroup.Stop()
    handler.RegisterHandlers(serviceGroup, ctx)

    fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
    serviceGroup.Start()
}
go
// internal/svc/servicecontext.go
type ServiceContext struct {
    Config     config.Config
    CronServer cron.Server
}

func NewServiceContext(c config.Config) *ServiceContext {
    c.WorkConf.Namespace = c.Name
    cronServer := cron.MustNewServer(c.WorkConf)

    return &ServiceContext{
        Config:     c,
        CronServer: cronServer,
    }
}
go
// internal/handler/workers.go
import (
    "time"
    "github.com/hibiken/asynq"
)

func RegisterHandlers(server *service.ServiceGroup, serverCtx *svc.ServiceContext) {
    // 复用 go-zero RestConf.Timeout 接管定时任务超时(参见进阶指南:任务超时控制)
    var taskOpts []asynq.Option
    taskOpts = append(taskOpts,
        asynq.Timeout(time.Duration(serverCtx.Config.Timeout)*time.Millisecond),
        asynq.MaxRetry(0),
    )

    // 定时任务:一步完成调度注册 + handler 注册
    serverCtx.CronServer.CronAdd("*/1 * * * *", "GDemoA",
        demoA.GDemoAHandler(serverCtx), taskOpts...)

    server.Add(serverCtx.CronServer)
}
go
// internal/handler/demoA/gdemoahandler.go
func GDemoAHandler(svcCtx *svc.ServiceContext) cron.HandlerFunc {
    return func(ctx context.Context, t *cron.Task) error {
        var req types.Name
        if err := json.Unmarshal(t.Payload, &req); err != nil {
            return err
        }
        l := demoA.NewGDemoALogic(ctx, svcCtx)
        return l.GDemoA(&req)
    }
}
go
// internal/logic/demoA/gdemoalogic.go
type GDemoALogic struct {
    logx.Logger
    ctx    context.Context
    svcCtx *svc.ServiceContext
}

func NewGDemoALogic(ctx context.Context, svcCtx *svc.ServiceContext) *GDemoALogic {
    return &GDemoALogic{
        Logger: logx.WithContext(ctx),
        ctx:    ctx,
        svcCtx: svcCtx,
    }
}

func (l *GDemoALogic) GDemoA(req *types.Name) error {
    logc.Infof(l.ctx, "GDemoA called, %v", req)
    return nil
}

Client ​

Important: when pushing tasks from the client, the taskType must match the full pattern registered on the server (including the Namespace prefix).

go
// internal/svc/servicecontext.go
type ServiceContext struct {
    Config     config.Config
    CronClient cron.Client
}

func NewServiceContext(c config.Config) *ServiceContext {
    cronClient := cron.MustNewClient(c.ClientConf)

    return &ServiceContext{
        Config:     c,
        CronClient: cronClient,
    }
}
go
// internal/logic/order_logic.go
import "github.com/hibiken/asynq"

func (l *OrderLogic) CreateOrder(req *types.OrderReq) error {
    payload := map[string]string{"email": "user@example.com", "content": "Welcome!"}

    // taskType 必须带 Namespace 前缀,Queue 必须指定为 Namespace
    namespace := l.svcCtx.Config.Name
    taskType := fmt.Sprintf("%s:%s", namespace, "send_email")
    _, err := l.svcCtx.CronClient.PushJson(l.ctx, taskType, payload, asynq.Queue(namespace))
    return err
}
go
// 推荐:定义常量统一管理 taskType
const (
    Namespace     = "order-service"
    TaskSendEmail = Namespace + ":send_email"
    TaskSyncData  = Namespace + ":sync_data"
)

func (l *OrderLogic) CreateOrder(req *types.OrderReq) error {
    payload := map[string]string{"email": "user@example.com"}
    _, err := l.svcCtx.CronClient.PushJson(l.ctx, TaskSendEmail, payload, asynq.Queue(Namespace))
    return err
}

Using in Standalone Scripts ​

Server ​

go
func main() {
    conf := cron.ServerConfig{
        RedisConf:   cron.RedisConf{Addr: "localhost:6379", Mode: "single"},
        Concurrency: 10,
    }
    srv := cron.MustNewServer(conf)

    srv.Add("sync_data", func(ctx context.Context, t *cron.Task) error {
        fmt.Println("正在处理同步...")
        return nil
    })

    srv.Start()
}

Client ​

go
func main() {
    clientConf := cron.ClientConfig{
        RedisConf: cron.RedisConf{Addr: "localhost:6379", Mode: "single"},
    }

    client := cron.MustNewClient(clientConf)
    defer client.Close()

    // 若服务端配置了 Namespace,taskType 必须带前缀,并指定 Queue
    client.Push(context.Background(), "data-service:raw_task", []byte("hello"), asynq.Queue("data-service"))

    // 若服务端未配置 Namespace,直接使用原始 pattern
    // client.Push(context.Background(), "raw_task", []byte("hello"))
}

Changelog ​

See CHANGELOG.md