cron

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package cron 提供多实例动态 cron 装配面(#159):运行期增删改 cron 任务 (指纹 diff 增量重装,不重置未变化项触发点)+ 多实例执行互斥(租约型, distlock 可选注入)+ 运行台账(执行历史/结果可观测)。

触发引擎复用同模块 local.Scheduler(表达式解析/catch-up/并发度/假时钟 都是既有基建),本包在其上补三块缺口:DB 计划的期望集收敛、跨实例 触发互斥、运行台账——此前下游 3 处各自手搓调度器,锁 TTL 预算公式这类 易错口径散落各处(见 RetryBudget)。

装配示例(调度平台形态,多实例共用 DB 期望集):

sched := cron.New(exec,
    cron.WithMutex(distAdapter{locker}),        // redisx/distlock 等,见 Mutex
    cron.WithInstance("task-1"),                // 台账/互斥归因
)
// 库替你管循环:启动清扫 → 首轮对账 → 按周期收敛,ctx 取消停机
if err := sched.StartReconcile(ctx, 30*time.Second, listFromDB); err != nil {
    return err
}
defer sched.Close(context.Background())

也可自带循环周期调 Reconcile(同样自动拉起触发引擎):

sched.Reconcile(ctx, []cron.Plan{{ID: "t1", Spec: "@every 1m", Enabled: true}})

互斥语义(触发点恰好一次)

互斥键 = `timertask/cron/exec/<planID>/<trigger>`(trigger 为触发点 RFC3339 标识,秒精度)——同一触发点跨实例同键裁决,只跑一次;让位方 记台账(Skipped)。持有即租约(TTL = RetryBudget.LockTTL(),到期自动 接管,崩溃实例不尸占),认领租约不随执行完成释放——兼作「slot 已认领」 标记,迟到重复触发在租约有效期内一律让位;互斥设施故障 fail-closed 跳过 (宁可漏跑一轮可补发,不可双跑)。

与 local 包的关系

本包是 github.com/oekit/timertask 的同模块子包,import local 复用触发 引擎;需要裸 cron 触发(无计划表/无互斥/无台账)直接用 local.Scheduler。

Index

Constants

View Source
const (
	// DefaultBudgetAttempts 默认总尝试次数(含首次)。
	DefaultBudgetAttempts = 3
	// DefaultBudgetTimeout 默认单次尝试上限。
	DefaultBudgetTimeout = 60 * time.Second
	// DefaultBudgetBackoff 默认相邻尝试间退避。
	DefaultBudgetBackoff = 5 * time.Second
	// DefaultBudgetSlack 默认余量(时钟漂移/调度延迟)。
	DefaultBudgetSlack = 30 * time.Second
)

默认重试预算:对齐根包延迟任务的默认量级(WithRetry 默认 3 次 / 5s 间隔、 WithVisibilityTimeout 默认 60s 租约),沿用同一族默认值让「锁 TTL 预算」 与「任务执行预算」在同一量纲上起步。

View Source
const DefaultRunCapacity = 256

DefaultRunCapacity 是内置运行台账(MemLedger)的默认容量(有界环形, 超容最旧记录被覆盖——台账是可观测面不是持久化,持久化走 WithLedger)。

Variables

View Source
var ErrLockNotAcquired = errors.New("cron: lock not acquired")

ErrLockNotAcquired 表示锁被其他实例持有(非阻塞争抢失败)。 Mutex.TryLock 的占用哨兵:errors.Is 判定;与「互斥设施故障」(其它 error) 区分——占用是常态(跳过本次触发即可),故障要进台账告警。

适配 redisx/distlock 时把 distlock.ErrLockNotAcquired 映射到本哨兵 (见 Mutex 的 godoc 适配示例)。

View Source
var ErrSchedulerStopped = errors.New("cron: scheduler stopped")

ErrSchedulerStopped 表示实例已停机(Close 已调用或 StartReconcile 的 ctx 已取消),进入终态——对齐根包 ErrBackendStopped 的实例一次性纪律(#124): 停机后 Reconcile/Upsert/StartReconcile 返回本错误,不静默哑火。需要重启 请重建实例。

Functions

This section is empty.

Types

type CronOption

type CronOption func(*cronOptions)

CronOption 配置 Scheduler。命名遵循仓 AGENTS 的 XxxOption 纪律 (禁止裸 Option,多包 co-import 冲突)——issue 建议形态的 `...Option` 相应加前缀。

func WithCatchUp

func WithCatchUp(p local.CatchUpPolicy) CronOption

WithCatchUp 设置错过触发点的补偿策略(透传 local.CatchUpPolicy)。

func WithClock

func WithClock(clock local.Clock) CronOption

WithClock 注入时钟(触发点计算/租约到期/台账时刻同源),nil 取 local.SystemClock{};测试注入 local.FakeClock 确定性驱动(与 local.Scheduler 的 Clock 注入同一形态,同一假时钟两层共用)。

func WithConcurrency

func WithConcurrency(n int) CronOption

WithConcurrency 设置全局任务并发度(透传 local.Config.Concurrency, <=0 取 1 严格串行)。

func WithInstance

func WithInstance(id string) CronOption

WithInstance 设置实例标识(台账归因/排障),缺省取主机名。

func WithLedger

func WithLedger(l Ledger) CronOption

WithLedger 注入外置运行台账(DB 落库/审计流等),与内置 MemLedger 并联 记录;nil = 只用内置(显式回退默认值,不算非法)。

func WithLocation

func WithLocation(loc *time.Location) CronOption

WithLocation 设置 cron 表达式时区(透传 local.Config.Location,nil 取 time.Local)。

func WithLogger

func WithLogger(l *slog.Logger) CronOption

WithLogger 注入日志器,nil 取 slog.Default()(根模块纯 stdlib,与 local.Scheduler 同口径)。

func WithMutex

func WithMutex(m Mutex) CronOption

WithMutex 注入跨实例执行互斥(distlock 等,见 Mutex 的适配示例)。 不设置时只有进程内 slot 互斥(单实例部署即够;多实例部署不注入会双跑—— 这是显式接线项,不给静默默认)。

func WithRetryBudget

func WithRetryBudget(b RetryBudget) CronOption

WithRetryBudget 设置执行重试预算(互斥锁 TTL 预算公式的唯一出处, 见 RetryBudget)。

func WithRunCapacity

func WithRunCapacity(n int) CronOption

WithRunCapacity 设置内置运行台账容量(有界环形,<=0 取 DefaultRunCapacity)。

func WithStaleSweep

func WithStaleSweep(fn StaleSweepFunc) CronOption

WithStaleSweep 设置启动清扫钩子(见 StaleSweepFunc)。

type Executor

type Executor func(ctx context.Context, planID string, trigger string) error

Executor 是计划触发的执行函数(#159 建议形态原样交付)。

planID 为计划 ID;trigger 为触发点标识(RFC3339,秒精度)——幂等键/ 互斥键/台账键三同源(execLockKey/RunRecord 同值),业务可直接拿去做 幂等去重。返回错误只记台账与日志(local.Scheduler「任务失败不重试」 同口径——需要重试语义请在任务内部实现)。

type Ledger

type Ledger interface {
	Record(ctx context.Context, rec RunRecord)
}

Ledger 是运行台账的最小契约(外置落库缝:DB 表/审计流等)。

Record 不返回错误:台账写失败不得影响主链路(外置实现自行兜底重试); Scheduler 侧对 panic 也做收编(台账故障不得打死触发)。每次触发一条 记录(成功/失败/让位都记——「谁在什么时候跑了哪一档、结果如何」是排障 与「恰好一次」核对面)。

type LocalMutex

type LocalMutex struct {
	// contains filtered or unexported fields
}

LocalMutex 是进程内 Mutex 实现(租约表 + TTL 惰性回收,零外部依赖):

  • 单实例部署的默认互斥(Scheduler 内建一份守触发 slot——摘旧装新的在飞 旧触发与新条目补跑可撞同一 slot,进程内先裁决,不劳分布式锁);
  • 多实例测试的共享假件:两个 Scheduler 共享同一个 LocalMutex 即可复现 跨实例仲裁(见 cron_test 的互斥并发场景)。

多实例生产部署请注入分布式实现(redisx/distlock 等,经 Mutex 缝)。

func NewLocalMutex

func NewLocalMutex(clock local.Clock) *LocalMutex

NewLocalMutex 创建进程内互斥。clock 为租约到期判定的时钟,nil 取 local.SystemClock{}(测试注入 FakeClock 可确定性推进 TTL)。

func (*LocalMutex) TryLock

func (m *LocalMutex) TryLock(_ context.Context, key string, ttl time.Duration) (string, error)

TryLock 实现 Mutex:同键已有未过期租约 ⇒ ErrLockNotAcquired;过期租约 惰性回收(TTL 语义:崩溃持有者不尸占,到期即接管)。顺手清扫全部过期 租约——执行互斥按「认领到 TTL 到期」使用(见 Mutex 的 slot 认领注释), 同键之外的旧租约不会再被触碰,不扫会无界增长;规模 = 触发率 × TTL, 遍历开销可忽略。

func (*LocalMutex) Unlock

func (m *LocalMutex) Unlock(_ context.Context, key string, ownerID string) error

Unlock 实现 Mutex:ownerID 匹配才删除;不存在/已易主返回错误(与 distlock.ErrLockNotHeld 同义——调用方不得继续假装持有)。

type MemLedger

type MemLedger struct {
	// contains filtered or unexported fields
}

MemLedger 是内置有界运行台账(环形覆盖,New 缺省挂载):RunHistory 的 读取面,永不无界增长。

func NewMemLedger

func NewMemLedger(capacity int) *MemLedger

NewMemLedger 创建容量为 capacity 的有界台账(<=0 取 DefaultRunCapacity)。

func (*MemLedger) Recent

func (m *MemLedger) Recent() []RunRecord

Recent 返回全部记录(时间序旧→新)。

func (*MemLedger) RecentFor

func (m *MemLedger) RecentFor(planID string) []RunRecord

RecentFor 返回指定计划的记录(时间序旧→新)。

func (*MemLedger) Record

func (m *MemLedger) Record(_ context.Context, rec RunRecord)

Record 实现 Ledger(环形覆盖最旧记录)。

type Mutex

type Mutex interface {
	// TryLock 非阻塞争抢 key 的执行租约,ttl 为租约时长。
	//
	//   - 成功 ⇒ (ownerID, nil),ownerID 非空(后续 Unlock 凭它释放);
	//   - 被占用 ⇒ ("", ErrLockNotAcquired)(errors.Is 判定);
	//   - 其它 error ⇒ 互斥设施故障(调度面 fail-closed 跳过本次执行,
	//     台账记错——宁可漏跑一轮(可补发)不可多实例双跑)。
	TryLock(ctx context.Context, key string, ttl time.Duration) (ownerID string, err error)

	// Unlock 释放 key 的租约,ownerID 必须匹配。锁不存在或已易主(TTL 过期
	// 后被接管)按各自后端语义返回错误——调度面只记日志不改执行结果
	// (执行已结束,租约易主是租约模型的正常演进)。
	Unlock(ctx context.Context, key string, ownerID string) error
}

Mutex 是多实例执行互斥的最小契约(#159):**可选依赖 distlock 的缝**—— 仓内不引 distlock/redisx 依赖(根模块 require 只允许 pkg),分布式锁 实现经本接口注入。语义与仓内 at-least-once 租约同型(visibility timeout 租约集):持有即租约,TTL 到期自动可被他实例接管(崩溃实例不尸占), Unlock 提前释放。

执行互斥按「slot 认领」使用(fire 的消费形态):触发点的租约**不**在 执行完成时提前释放,认领标记保留到 TTL 到期——同触发点的迟到重复触发 在租约有效期内一律让位(「触发点恰好一次」),执行完成即放锁会让迟到 方重跑同一 slot。确需提前释放的消费方(长租约省存储)仍可用 Unlock, 但那会重新打开「迟到触发重跑」的口子。

TryLock/Unlock 与 redisx/distlock.Locker 同形(其 Lock 阻塞形态调度面 用不上,不进本契约)——*distlock.RedisLocker 结构上即满足本接口的 方法集,但占用哨兵不同名,语义适配包一层即可:

type distAdapter struct{ l distlock.Locker }

func (a distAdapter) TryLock(ctx context.Context, key string, ttl time.Duration) (string, error) {
    id, err := a.l.TryLock(ctx, key, ttl)
    if errors.Is(err, distlock.ErrLockNotAcquired) {
        return "", cron.ErrLockNotAcquired
    }
    return id, err
}

func (a distAdapter) Unlock(ctx context.Context, key, owner string) error {
    return a.l.Unlock(ctx, key, owner)
}

type Plan

type Plan struct {
	// ID 计划唯一 ID(业务任务 ID)。空 ID 使 Reconcile 整体 fail-closed。
	ID string
	// Spec cron 表达式(交 oekit/pkg/timex/cron 解析:5/6 字段 + @every
	// 等宏,与 local.Scheduler 同口径)。
	Spec string
	// Fingerprint 变更指纹——增量重装的唯一 diff 单元(对齐 zeus/task 的
	// taskFingerprint:表达式+状态+updatedAt 的哈希):
	//
	//   - 指纹未变 ⇒ 幂等空操作(不重装、不重置触发基准——防 @every 被
	//     周期重装不断后移);
	//   - 指纹变化 ⇒ 摘旧装新(计划被编辑过,从新基准起算);
	//   - 留空 ⇒ 派生指纹 = Spec(库侧可见的行为面只有表达式)。
	//
	// 注意指纹构成是调用方的契约选择:含 updatedAt 时「改任务元数据」
	// 也会重装、@every 触发基准随之重置(刻意语义——编辑过就该从新基准
	// 起算);只想「行为变更才重装」就把指纹定为 表达式+状态 的哈希。
	Fingerprint string
	// Enabled false = 停用:出现在期望集里时按注销收敛(不出现在期望集里
	// 同效——两种停用表达都收敛为不触发)。
	Enabled bool
}

Plan 是一条期望计划(DB 计划表单条的装配面镜像,#159 建议形态原样交付)。

type RetryBudget

type RetryBudget struct {
	// Attempts 总尝试次数(含首次),<1 归一为 1。
	Attempts int
	// Timeout 单次尝试(执行 + 尝试内等待)上限,<=0 归 0。
	Timeout time.Duration
	// Backoff 相邻尝试间的固定退避,<=0 归 0。
	Backoff time.Duration
	// Slack 余量(时钟漂移/调度延迟),<=0 取 DefaultBudgetSlack。
	Slack time.Duration
}

RetryBudget 是任务执行的重试预算,也是**互斥锁 TTL 预算公式的唯一出处** (#159:与 zeus/task 的 execLockTTL 同口径收编):

锁 TTL = 全部尝试 + 全部退避 + 余量
       = Attempts*Timeout + TotalBackoff() + Slack

此前该预算散在下游各自手算,口径漂移的后果是互斥提前过期——同 slot 被 他实例接管、双跑破坏「触发点恰好一次」。公式必须单点:改预算只改这里。

零值可用(Normalized 归一:Attempts < 1 取 1、负时长归 0、Slack <= 0 取 DefaultBudgetSlack——零值透传的 Slack 才是缺省,不是「不要余量」;确需 零余量显式给 1ns 这类正值)。

func (RetryBudget) LockTTL

func (b RetryBudget) LockTTL() time.Duration

LockTTL 返回互斥锁的租约时长(唯一出处,见类型注释): Attempts*Timeout + TotalBackoff() + Slack。

func (RetryBudget) Normalized

func (b RetryBudget) Normalized() RetryBudget

Normalized 返回归一后的预算(负值钳制 min/max 内建;Slack 零值/负值取 DefaultBudgetSlack——显式更小的正值照用)。

func (RetryBudget) TotalBackoff

func (b RetryBudget) TotalBackoff() time.Duration

TotalBackoff 返回全部退避之和 = (Attempts-1)*Backoff(首次与末次之间 共 Attempts-1 个退避间隔)。

type RunRecord

type RunRecord struct {
	// PlanID 计划 ID。
	PlanID string
	// Trigger 触发点标识(RFC3339 秒精度,与互斥键同源——幂等键/互斥键/
	// 台账键三同源)。
	Trigger string
	// Instance 执行实例标识(WithInstance,缺省主机名)——多实例归因。
	Instance string
	// StartedAt 执行开始时刻(跳过记录为跳过判定时刻)。
	StartedAt time.Time
	// EndedAt 执行结束时刻(跳过记录同 StartedAt)。
	EndedAt time.Time
	// Err 执行结果(nil=成功),语义见类型注释。
	Err error
	// Skipped 本次触发是否未执行(互斥让位/故障 fail-closed)。
	Skipped bool
}

RunRecord 是一次触发的运行台账记录(#159「运行台账:执行历史/结果可观测」)。

状态读法(Skipped/Err 组合语义):

  • Skipped=false、Err=nil ⇒ 执行成功;
  • Skipped=false、Err!=nil ⇒ 执行失败(含 executor panic 收编);
  • Skipped=true、Err=nil ⇒ 互斥让位(同触发点由他实例执行,跨实例 恰好一次的正常形态);
  • Skipped=true、Err!=nil ⇒ 互斥设施故障,fail-closed 未执行(告警面)。

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler 是多实例动态 cron 装配面(#159):local.Scheduler 触发引擎 + 指纹 diff 增量装配 + 触发互斥 + 运行台账。零值不可用,New 构造。

func New

func New(exec Executor, opts ...CronOption) *Scheduler

New 创建调度器。构造期 fail-fast(#97 同哲学):nil Executor、非法 CatchUp 等配置错误在启动期炸响,不带病运行。

func (*Scheduler) Close

func (s *Scheduler) Close(ctx context.Context) error

Close 停止调度并等待在飞触发收尾(组件生命周期统一 Close(ctx) error + sync.Once 幂等):对账循环与触发引擎同停,正在执行的任务不打断(等待到 ctx 截止,local.Stop 同语义)。ctx 超时返回 ctx.Err(),任务继续跑完。 可重复调用;未启动过触发引擎时立即返回。

func (*Scheduler) Reconcile

func (s *Scheduler) Reconcile(ctx context.Context, plans []Plan) error

Reconcile 把期望计划集收敛到触发引擎(#159「指纹 diff 增量重装」): 期望集里的启用条目按指纹增量 upsert(指纹未变幂等空操作、变化摘旧装新), 停用条目与已装但不在期望集里的条目注销。多实例共用同一 DB 期望集、 周期对账的场景下,期望集未变时 Reconcile 是**空操作**(不重装、不重置 @every 触发基准)。

对账语义(与 local.Reconcile 同骨架):

  • 启停/变更由期望集表达:启用的条目出现在期望集里(Enabled=true), 停用的不出现或 Enabled=false(同效注销);
  • 单条失败(坏 spec 等)**不阻断**其余条目收敛,失败逐条汇总进返回 错误(errors.Join);
  • 期望集内 ID 重复 / 空 ID ⇒ 「以哪条为准」存在歧义,fail-closed: 本次调用不做任何变更并返回错误;
  • 首次调用自动拉起触发引擎(StartReconcile 形态亦然)。

errors 全部成功时返回 nil;调用方通常应串行化对账(单 goroutine 周期 触发)——并发调用无数据竞争,但收敛次序不保证。停机后返回 ErrSchedulerStopped(实例一次性,#124)。

func (*Scheduler) Remove

func (s *Scheduler) Remove(_ context.Context, id string) bool

Remove 按计划 ID 注销(运行期删),返回是否确实移除了触发条目。正在 执行的触发不打断(执行已进台账),未开始的触发丢弃(local.UnscheduleFor 同语义)。

func (*Scheduler) RunHistory

func (s *Scheduler) RunHistory(planID string) []RunRecord

RunHistory 返回内置台账记录(时间序旧→新);planID 非空时只返回该计划的。

func (*Scheduler) StartReconcile

func (s *Scheduler) StartReconcile(ctx context.Context, interval time.Duration, listFn func() ([]Plan, error)) error

StartReconcile 启动「启动清扫 + 周期对账 + 触发引擎」并阻塞(#159 `StartReconcile`;返回 error 为生命周期纪律,退出语义与 local.Start 同款——#99):

  1. 启动清扫(WithStaleSweep)先于首次对账——zeus/task 的 FailStaleRunning 启动清扫 + 清扫后补发事件同序,补发在钩子内完成;
  2. 立即 listFn() → Reconcile 首轮对账,随后按 interval 周期收敛 (listFn 失败/单条收敛失败只记日志不中断循环——多实例对账要韧性);
  3. ctx 取消 ⇒ 停机(触发引擎同停)返回 ctx.Err();Close 触发的正常 停机返回 nil。

周期等待走注入时钟(clock.NewTimer 可停 timer,#174)——FakeClock 测试下 对账节奏确定性可控,不真睡。自带循环的调用方直接周期调 Reconcile 即可。

func (*Scheduler) Upsert

func (s *Scheduler) Upsert(ctx context.Context, p Plan) error

Upsert 按单计划 upsert(运行期增/改):指纹未变幂等空操作、变化摘旧 装新;Enabled=false 等价 Remove。不触碰其它已装计划(与 Reconcile 的 期望集收敛相对——单条管理面,供管理 API/事件驱动改单条)。停机后返回 ErrSchedulerStopped。

type StaleSweepFunc

type StaleSweepFunc func(ctx context.Context) (swept int, err error)

StaleSweepFunc 是启动清扫钩子(对齐 zeus/task 的 FailStaleRunning 形态): 实例启动时把上次崩溃遗留的「执行中」记录收敛为失败(清扫后补发事件也 在钩子内完成),返回清扫条数。StartReconcile 在首次对账**之前**调用一次; 清扫失败只记日志不阻断启动(清扫不该挡住整个调度面,下轮对账兜底)。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL