local

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: 10 Imported by: 0

Documentation

Overview

Package local 提供纯本地的 cron 调度器:按 cron 表达式触发任务。

零外部中间件、零 Redis 依赖——依赖面只有标准库与 github.com/oekit/pkg/timex/cron(cron 表达式解析复用 oekit 矩阵的实现, 不重复造轮子)。适合纯 Go 嵌入式服务形态(CGO_ENABLED=0 静态链接、 SQLite 落盘、零外部中间件)——这类形态用不了 Redis 后端, 但仍需要「到点自动干活」的能力。

与根包的关系

本包是 github.com/oekit/timertask 的子包(同模块),import 根包仅限 Clock 别名归一(#78,clock.go 嵌入根包定义——单一事实源)与模块内 单源 internal/gosafe(#168);根包不 import 本包。需要「延迟队列」 (投递到未来某时刻)而不是「cron 触发」时,用根包的 LocalBackend。

语义(明确约定)

并发度:

  • Config.Concurrency 是全局并发度(同时执行的任务数上限),默认 1(严格串行)。
  • 同一 spec 的触发始终串行(自身永不重叠):上一轮没跑完时, 新的触发进入该 spec 的待执行队列。

错过触发点(进程停机、时钟跳变、上一轮未跑完):

  • Config.CatchUp = CatchUpSkip(默认):只执行最近的一个触发点, 其余错过点全部跳过;待执行队列里的旧触发会被新触发替换 (同一 spec 最多积压 1 个)——不会因为任务慢而无限堆积。
  • Config.CatchUp = CatchUpAll:所有错过的触发点按时间顺序逐一补跑。

进程退出:

  • Stop 不打断正在执行的任务:任务的 ctx 不会被取消,Stop 等待它们结束; Stop 的等待上限由调用方给的 ctx 决定,超时返回 ctx.Err(), 任务继续跑完(Go 无法安全中断 goroutine)。
  • Start 的 ctx 被取消时:调度立刻停止,任务的 ctx 同时被取消 (任务可自行感知并尽快退出),未开始的触发被丢弃。
  • 两种情况下「未开始的触发」都会被丢弃——本地调度器不持久化。

重复注册:

  • 同一个 spec 重复 Schedule 返回 ErrDuplicateSpec(不静默覆盖: 覆盖会悄悄丢掉原处理函数)。要换处理函数请先 Unschedule。

业务键形态(动态条目管理,#129)

spec 键形态是**静态注册表**:键即表达式、重复注册报错、改表达式要先 Unschedule 旧键——调用方得自己记 spec→业务标识的映射。调度平台形态的 消费方(按任务 ID upsert、任务启停/改表达式时同步调度计划、多实例按 DB 期望集对账)请用业务键形态(与 spec 键形态并存不互通):

	sched.ScheduleFor("task-42", "0 9 * * 1-5", fn)  // 按 key upsert
	sched.UnscheduleFor("task-42")                   // 按 key 注销
	sched.Reconcile([]local.Entry{                   // 收敛到 DB 启用集
		{Key: "task-42", Spec: "0 9 * * 1-5", Task: fn},
		{Key: "task-43", Spec: "@every 30s", Task: fn2},
	})

  - 同 key 同 spec 重复 upsert 是**幂等空操作**(不重装、不重置触发基准)
    ——@every 解析为相对间隔(ConstantDelaySchedule),周期性重装会把
    下次触发时间不断后移,间隔长于重装周期的任务永不执行。换任务函数
    请先 UnscheduleFor(Task 不可比,指纹只含 spec);
  - Task 与 Reconcile 同口径(#155):spec 未变的幂等空操作忽略 Task
    (ScheduleFor(key, spec, nil) 的纯表达式 upsert 可用),需要安装/
    重装(新 key / 改 spec)才要求 Task 非 nil;
  - 同 key 改 spec ⇒ 摘旧装新(旧表达式立即停止触发);
  - Reconcile 单条 spec 失败不阻断其余条目的收敛(错误逐条汇总),
    期望集内 key 重复 fail-closed(不做任何变更并报错)。

示例

sched, err := local.New(local.Config{Concurrency: 2})
if err != nil {
	return err
}
if err := sched.Schedule("0 9 * * 1-5", func(ctx context.Context, at time.Time) error {
	return report(ctx)
}); err != nil {
	return err
}
go func() { _ = sched.Start(ctx) }()
defer sched.Stop(context.Background())

Index

Constants

This section is empty.

Variables

View Source
var ErrDuplicateSpec = errors.New("local: cron spec already scheduled")

ErrDuplicateSpec 表示同一个 cron 表达式被重复注册。

View Source
var ErrUnsatisfiableSpec = errors.New("local: cron spec can never fire")

ErrUnsatisfiableSpec 表示 cron 表达式语法合法但**永无触发点**(#123), 例如 `0 0 31 2 *`(2 月永无 31 日)。

pkg/timex/cron 的 SpecSchedule.Next 对这类表达式静默返回零值——解析不报错, 但调度器永远算不出首个触发点:Schedule 返回 nil、Start 正常、日志安静、 任务永不执行,排障时唯一线索是「什么都没发生」。调度器正是 cron 库注释里 说的「消费 Next 前应判 IsZero」的那个消费方,必须把不可满足上升为用户可见 信号(与 ErrDuplicateSpec 的「不静默覆盖」同一防呆哲学)。

判定口径 = 「从注册时刻起 5 年内求不出首个触发点即报错」:这与调度循环 实际消费 Next 的搜索上限一致——超出该上限的表达式对本调度器而言**事实上** 永不触发(如「2 月 29 日且逢周日」这类超长间隔表达式,返回错误是如实 描述调度行为,而非过度拒绝)。

Functions

This section is empty.

Types

type CatchUpPolicy

type CatchUpPolicy int

CatchUpPolicy 是错过触发点的补偿策略。

const (
	// CatchUpSkip(默认):只执行最近的一个触发点。
	//
	// 适用于「状态收敛型」任务(对账、刷新、巡检)——历史触发点已过时,
	// 补跑没有意义,甚至有害(重复处理旧状态)。
	CatchUpSkip CatchUpPolicy = iota
	// CatchUpAll:所有错过的触发点按时间顺序逐一补跑。
	//
	// 适用于「计数型」任务(每次触发都代表一次必须发生的动作)。
	// 注意补跑会积压:任务执行慢于触发周期时,积压会持续增长。
	CatchUpAll
)

type Clock

type Clock interface {
	timertask.Clock
	// After 返回一个通道:在 d 之后收到一个时间信号。
	// d <= 0 时必须返回「已就绪」的通道(调用方据此立即重算,不空等)。
	// 注意:After 形态的 timer 不可停,循环等待 + 可被唤醒打断的场景请用
	// NewTimer(#174)。
	After(d time.Duration) <-chan time.Time
	// NewTimer 返回可停 timer(#174):C 通道在 d 之后收到信号(d <= 0 时
	// 立即就绪),Stop 停止并回收 timer。调度循环醒来即停,不再遗弃挂起的
	// timer。
	NewTimer(d time.Duration) Timer
}

Clock 是时钟抽象,便于注入假时钟做确定性测试。

调度器的所有时间判断都走本接口:注册时的基准时间、触发点计算、等待下一个 触发点。注入 FakeClock 后,整个调度过程不需要 sleep 即可断言触发序列。 Clock 是时钟抽象(别名归一到根包 timertask.Clock——单一事实源,#78)。 本调度器还需要 After 与 NewTimer(等待下一个触发点),根包接口不含这两个 方法 ⇒ 保留本包扩展接口,嵌入根包定义。

NewTimer(#174)是循环等待场景的必用形态:After 每次调用都遗弃一个不可停 timer——调度循环被 wake(注册/注销任务)打断重算时,旧 timer 全部挂到原 到期点堆积(堆积量 = 打断频率 × 剩余等待);NewTimer 返回可停面,醒来即停。

type Config

type Config struct {
	// Concurrency 全局任务并发度(同时执行的任务数上限),<=0 取 1(严格串行)。
	//
	// 同一 spec 的触发始终串行(自身不重叠);不同 spec 之间按此上限并发。
	Concurrency int
	// CatchUp 错过触发点的补偿策略,零值取 CatchUpSkip。
	CatchUp CatchUpPolicy
	// Clock 时钟注入,nil 取 SystemClock{};测试可注入 FakeClock。
	Clock Clock
	// Logger 日志器,nil 取 slog.Default()。
	Logger *slog.Logger
	// Location cron 表达式的时区,nil 取 time.Local。
	Location *time.Location
	// MaxCatchUp 单次补跑的最大触发点数,<=0 取 1000。
	MaxCatchUp int
}

Config 是 Scheduler 配置。

type Entry added in v0.3.0

type Entry struct {
	Key  string
	Spec string
	Task Task
}

Entry 是业务键形态的期望条目(Reconcile 的输入,#129)。

Key 是业务侧的任务标识(任务 ID),同一调度器内唯一;Spec 是 cron 表达式 (与 Schedule 同口径:5/6 字段 + @every 等宏);Task 在安装新 key / 重装 变更的 key 时使用,条目 spec 未变(幂等空操作)时被忽略——对账只需保 spec 与触发行为,任务函数以首次注册为准。

type FakeClock

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

FakeClock 是可手动推进的假时钟,用于确定性测试(不依赖 sleep)。

典型用法:

clock := local.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
sched, _ := local.New(local.Config{Clock: clock, Location: time.UTC})
sched.Schedule("*/5 * * * * *", fn)
go sched.Start(ctx)
clock.Advance(5 * time.Second) // 触发点到达,fn 被调用

func NewFakeClock

func NewFakeClock(t time.Time) *FakeClock

NewFakeClock 创建以 t 为当前时间的假时钟。

func (*FakeClock) Advance

func (c *FakeClock) Advance(d time.Duration)

Advance 推进假时钟 d,并唤醒所有已到期的等待者。

func (*FakeClock) After

func (c *FakeClock) After(d time.Duration) <-chan time.Time

After 返回等待通道:假时钟推进到该等待的截止时间时,通道收到信号。

d <= 0 时立即返回已就绪的通道(不注册等待者)。 经 NewTimer 实现(#174 单源);After 形态的 timer 不可停是该形态的 固有限制,循环等待场景请直接用 NewTimer。

func (*FakeClock) NewTimer added in v0.5.0

func (c *FakeClock) NewTimer(d time.Duration) Timer

NewTimer 返回可停 timer(#174):Stop 把等待者从唤醒队列摘除并保证 C 永不再发信号。

func (*FakeClock) Now

func (c *FakeClock) Now() time.Time

Now 返回假时钟的当前时间。

type Scheduler

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

Scheduler 是基于 cron 表达式的本地调度器。

零外部中间件、零 Redis 依赖:只依赖标准库与 github.com/oekit/pkg/timex/cron (cron 表达式解析复用 oekit 矩阵的实现,不重复造轮子)。

典型用法:

sched, err := local.New(local.Config{Concurrency: 4})
if err != nil {
	return err
}
if err := sched.Schedule("*/5 * * * * *", func(ctx context.Context, at time.Time) error {
	return doSomething(ctx)
}); err != nil {
	return err
}
go func() { _ = sched.Start(ctx) }()
defer sched.Stop(context.Background())

func New

func New(cfg Config) (*Scheduler, error)

New 创建调度器。

func (*Scheduler) Reconcile added in v0.3.0

func (s *Scheduler) Reconcile(entries []Entry) error

Reconcile 把业务键形态的任务收敛到期望集(#129):期望集里的条目按 ScheduleFor 语义增量 upsert(spec 变化重装、未变幂等空操作),已注册但 不在期望集里的 key 注销。多实例共用同一 DB 期望集、周期对账的场景下, 期望集未变时 Reconcile 是**空操作**(不重装、不重置 @every 触发基准)。

对账语义(与调度平台消费方对齐):

  • 启停 / 变更的表达式由期望集表达:启用的任务出现在期望集里,停用的 不出现(自动注销)——调用方无须 diff,库侧收敛;
  • 单条 spec 解析失败 / 不可满足**不阻断**其余条目的收敛(一个坏任务 不应阻止其余任务对账),失败逐条汇总进返回错误(errors.Join);
  • 期望集内 key 重复 ⇒ 「以哪条为准」存在歧义,fail-closed:本次调用 不做任何变更并返回错误;
  • 收敛原子:期间并发的 ScheduleFor/UnscheduleFor 不会看到中间态 (全部变更在单次持锁内完成)。

errors 全部成功时返回 nil;调用方通常应串行化对账(单 goroutine 周期 触发),与多实例并发对账同一期望集是安全的(收敛结果一致)。

func (*Scheduler) Schedule

func (s *Scheduler) Schedule(spec string, fn Task) error

Schedule 注册一个 cron 任务。

spec 交给 github.com/oekit/pkg/timex/cron 解析:支持 5 字段(分 时 日 月 周) 与 6 字段(秒 分 时 日 月 周)格式,以及 @every/@daily/@hourly 等宏。

同一个 spec 重复注册返回 ErrDuplicateSpec(不静默覆盖:覆盖会悄悄丢掉 原处理函数,且调用方难以察觉)。要换处理函数请先 Unschedule。

语法合法但永无触发点的表达式(如 `0 0 31 2 *`)返回 ErrUnsatisfiableSpec(#123,不静默接受):此前这类 spec 注册成功后永不 触发且无任何信号。判定方式是注册前用注入时钟试算一次首个触发点—— 求不出(Next 返回零值)即报错,与调度循环实际的消费口径一致。

func (*Scheduler) ScheduleFor added in v0.3.0

func (s *Scheduler) ScheduleFor(key, spec string, fn Task) error

ScheduleFor 按业务键 upsert 一个 cron 任务(#129)。

与 spec 键形态(Schedule/Unschedule,键即表达式、重复注册 ErrDuplicateSpec) 并存不互通,面向「调度平台」的动态形态:按任务 ID 管理条目,任务改表达式 时同步调度计划,调用方无须自己维护 spec→taskID 映射。

语义(指纹幂等,zeus 实证的同款防呆):

  • key 未注册 ⇒ 安装(Task 必填);
  • key 已注册且 spec 未变 ⇒ **幂等空操作**——不重装、不重置触发基准。 重装会把 entry 的 lastFire 重置到此刻:@every 解析为 ConstantDelaySchedule(相对基准的固定间隔),「周期性重装」会把间隔 长于重装周期的任务的下次触发时间不断往后推——任务永不执行。因此 **换任务函数(Task 不可比)请先 UnscheduleFor 再 ScheduleFor**, 同 spec 重复调用本函数不会生效;
  • key 已注册且 spec 变化 ⇒ 摘旧装新(Task 必填):旧表达式立即停止 触发(正在执行的旧任务不打断,与 Unschedule 语义一致),新表达式从 注册时刻起算。

Task 与 Reconcile 同口径(#155):仅在「需要安装/重装」时使用,spec 未变 的幂等空操作忽略 Task(fn 为 nil 亦放行,任务函数以首次注册为准)——纯 表达式 upsert(ScheduleFor(key, spec, nil))由此可用;需要安装/重装而 fn 为 nil 时返回错误。

同一 spec 可挂多个业务键(两个任务共用同一表达式,各自触发);key 键与 spec 键互不可见——同一 spec 在两种形态下各注册一次会各自触发一次,混用 前请确认这正是想要的。

spec 解析失败 / 语法合法但永无触发点(ErrUnsatisfiableSpec)/ 调度器已 停止时返回错误,此时该 key 的既有注册不受影响。Start 之前调用亦可: 条目先入表,Start 时随全部条目一起启动。

func (*Scheduler) Start

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

Start 启动调度循环并阻塞,直到 ctx 取消或 Stop 被调用。

返回时调度器已停止:不再产生新触发;正在执行的任务不会被强制中断 (任务可通过 ctx 感知取消并自行退出)。重复调用返回错误。

返回值(#99):Stop 触发的正常停机返回 nil——此前任何路径都返回非 nil (Stop 路径硬编码 context.Canceled),按惯用 `if err := sched.Start(ctx); err != nil { log.Fatal }` 写的调用方会把 正常停机当失败处理。仅 ctx 取消时返回 ctx.Err()。

func (*Scheduler) Stop

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

Stop 停止调度并等待正在执行的任务结束。

语义:

  • 未开始的触发被丢弃;
  • 正在执行的任务不被打断(任务的 ctx 不被取消),Stop 最多等待到 ctx 截止;
  • ctx 超时返回 ctx.Err(),任务继续跑完(Go 无法安全中断 goroutine)。

可重复调用。

func (*Scheduler) Unschedule

func (s *Scheduler) Unschedule(spec string) bool

Unschedule 注销一个 cron 任务,返回是否确实移除了该任务。

正在执行的任务不会被中断(任务体可自行感知 ctx 取消);未开始的触发被丢弃。

func (*Scheduler) UnscheduleFor added in v0.3.0

func (s *Scheduler) UnscheduleFor(key string) bool

UnscheduleFor 按业务键注销一个 cron 任务,返回是否确实移除了该任务。 语义与 Unschedule 一致:正在执行的任务不会被中断(任务体可自行感知 ctx 取消);未开始的触发被丢弃。

type SystemClock

type SystemClock struct{}

SystemClock 是真实时钟(time.Now + time.Timer)。

func (SystemClock) After

func (SystemClock) After(d time.Duration) <-chan time.Time

After 返回一个在 d 之后收到信号的通道。

func (SystemClock) NewTimer added in v0.5.0

func (SystemClock) NewTimer(d time.Duration) Timer

NewTimer 返回可停 timer(#174)。d <= 0 时标准库立即触发,与 After 的 「已就绪通道」契约一致。

func (SystemClock) Now

func (SystemClock) Now() time.Time

Now 返回当前时间。

type Task

type Task func(ctx context.Context, at time.Time) error

Task 是被 cron 触发的任务函数。

at 为本次触发的计划时间(cron 计算出的到期点),可用于日志与幂等键。 ctx 是调度器的运行上下文(Start 的 ctx),取消后任务应尽快返回。

任务返回错误只记日志、不重试:需要重试语义请在任务内部实现, 或改用 github.com/oekit/timertask 的 LocalBackend 延迟队列。

type Timer added in v0.5.0

type Timer interface {
	// C 返回到期信号通道。
	C() <-chan time.Time
	// Stop 停止 timer:未触发过返回 true(并保证 C 永不再发信号);已触发
	// 或已停止返回 false。Stop 幂等可重入。
	Stop() bool
}

Timer 是 Clock.NewTimer 返回的可停 timer 面(#174)。

Jump to

Keyboard shortcuts

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