Files
orbit/internal/storage/mysql/schedule_store.go

305 lines
11 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
driver "github.com/go-sql-driver/mysql"
)
// ScheduleStore 管理计划控制与扫描;共享任务存储的事务认证,不在事务外推进游标。
type ScheduleStore struct {
*TaskStore
node core.ID
}
func NewScheduleStore(db *sql.DB, node core.ID) (*ScheduleStore, error) {
if node.Validate() != nil {
return nil, errors.New("scheduler node id required")
}
return &ScheduleStore{TaskStore: NewTaskStore(db), node: node}, nil
}
var _ schedule.Store = (*ScheduleStore)(nil)
const scheduleColumns = `id,namespace_id,queue_id,name,revision,current_version,processing_version,paused,next_fire_at,check_at,created_at,updated_at`
func scanSchedule(row scanner) (schedule.Summary, error) {
var v schedule.Summary
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.Name, &v.Revision, &v.CurrentVersion, &v.ProcessingVersion, &v.Paused, &v.NextFireAt, &v.CheckAt, &v.CreatedAt, &v.UpdatedAt)
return v, err
}
func readSchedule(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (schedule.Summary, error) {
q := `SELECT ` + scheduleColumns + ` FROM pulse_schedules WHERE namespace_id=? AND id=? AND queue_id IS NOT NULL`
if lock {
q += ` FOR UPDATE`
}
v, err := scanSchedule(tx.QueryRowContext(ctx, q, ns, id))
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return v, err
}
func readScheduleVersion(ctx context.Context, tx *sql.Tx, ns, id core.ID, version uint64) (schedule.VersionView, error) {
var v schedule.VersionView
var rule, task []byte
err := tx.QueryRowContext(ctx, `SELECT version,effective_at,specification,task_template,misfire_policy,overlap_policy,catchup_limit,catchup_window_ms,grace_ms FROM pulse_schedule_versions WHERE namespace_id=? AND schedule_id=? AND version=?`, ns, id, version).Scan(&v.Version, &v.EffectiveAt, &rule, &task, &v.Definition.Policy.Misfire, &v.Definition.Policy.Overlap, &v.Definition.Policy.CatchupLimit, &v.Definition.Policy.CatchupWindowMS, &v.Definition.Policy.GraceMS)
if errors.Is(err, sql.ErrNoRows) {
return v, fault.NotFound
}
if err != nil {
return v, err
}
if err = json.Unmarshal(rule, &v.Definition.Rule); err != nil {
return v, err
}
err = json.Unmarshal(task, &v.Definition.Task)
return v, err
}
func insertScheduleVersion(ctx context.Context, tx *sql.Tx, ns, id core.ID, v schedule.VersionView) error {
identity, err := core.NewID()
if err != nil {
return err
}
rule, err := json.Marshal(v.Definition.Rule)
if err != nil {
return err
}
task, err := jsonwire.Marshal(v.Definition.Task)
if err != nil {
return err
}
p := v.Definition.Policy
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_schedule_versions(namespace_id,id,schedule_id,version,effective_at,timezone,specification,task_template,misfire_policy,overlap_policy,catchup_limit,catchup_window_ms,grace_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, ns, identity, id, v.Version, v.EffectiveAt, v.Definition.Rule.Timezone, string(rule), string(task), p.Misfire, p.Overlap, p.CatchupLimit, p.CatchupWindowMS, p.GraceMS)
return err
}
func scheduleView(ctx context.Context, tx *sql.Tx, ns, id core.ID) (schedule.View, error) {
summary, err := readSchedule(ctx, tx, ns, id, false)
if err != nil {
return schedule.View{}, err
}
current, err := readScheduleVersion(ctx, tx, ns, id, summary.CurrentVersion)
return schedule.View{Summary: summary, Current: current}, err
}
func authorizeSchedule(ctx context.Context, tx *sql.Tx, ns, id core.ID, p auth.Permissions) (schedule.Summary, error) {
v, err := readSchedule(ctx, tx, ns, id, false)
if err != nil {
return v, err
}
if !p.AllowsQueue(v.QueueID) {
return v, fault.NotFound
}
return v, nil
}
func (s *ScheduleStore) Create(ctx context.Context, t auth.Token, key string, in schedule.CreateInput) (schedule.Result, error) {
var out schedule.Result
err := s.transaction(ctx, t, "schedules:manage", func(tx *sql.Tx, p auth.Permissions) error {
if !p.Allows("tasks:submit") || !p.AllowsQueue(in.Definition.Task.QueueID) {
return fault.Forbidden
}
id, err := core.NewID()
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "schedule.create", "", key, "schedule", id, in)
if err != nil {
return err
}
if cached != nil {
if err := json.Unmarshal(cached, &out); err != nil {
return err
}
out.Deduplicated = true
return nil
}
var queue core.ID
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, t.NamespaceID, in.Definition.Task.QueueID).Scan(&queue)
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
if err != nil {
return err
}
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.Definition.Task.QueueID); err != nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
c, err := schedule.Compile(in.Definition.Rule)
if err != nil {
return err
}
next, err := c.AtOrAfter(now)
if err != nil {
return err
}
if next == nil {
return fault.Invalid("schedule has no future occurrence")
}
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_schedules(namespace_id,id,queue_id,name,current_version,processing_version,paused,next_fire_at,check_at,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?,?,?,?)`, t.NamespaceID, id, queue, in.Name, in.Paused, next, next, now, now)
var me *driver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return fault.Conflict
}
if err != nil {
return err
}
if err := insertScheduleVersion(ctx, tx, t.NamespaceID, id, schedule.VersionView{Version: 1, EffectiveAt: now, Definition: in.Definition}); err != nil {
return err
}
out.Schedule, err = scheduleView(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if err := emitEvent(ctx, tx, t.NamespaceID, id, 1, "schedule", "schedule.created", map[string]any{"queue_id": queue, "version": "1", "paused": in.Paused}); err != nil {
return err
}
if err := appendAudit(ctx, tx, t, identity, id, "schedule", "schedule.create", "create schedule", nil, out.Schedule.Summary); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return out, err
}
func (s *ScheduleStore) Get(ctx context.Context, t auth.Token, id core.ID) (schedule.View, error) {
var out schedule.View
err := s.transaction(ctx, t, "schedules:read", func(tx *sql.Tx, p auth.Permissions) error {
if _, err := authorizeSchedule(ctx, tx, t.NamespaceID, id, p); err != nil {
return err
}
var err error
out, err = scheduleView(ctx, tx, t.NamespaceID, id)
return err
})
return out, err
}
func (s *ScheduleStore) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in schedule.PublishInput) (schedule.Result, error) {
return s.mutate(ctx, t, id, key, "schedule.publish", in, in.ExpectedRevision, in.Reason, func(tx *sql.Tx, v schedule.Summary, now time.Time) error {
if in.Definition.Task.QueueID != v.QueueID {
return fault.Invalid("schedule queue cannot change")
}
last, err := readScheduleVersion(ctx, tx, t.NamespaceID, id, v.CurrentVersion)
if err != nil {
return err
}
if !now.After(last.EffectiveAt) {
return fault.Unavailable
}
if err := insertScheduleVersion(ctx, tx, t.NamespaceID, id, schedule.VersionView{Version: v.CurrentVersion + 1, EffectiveAt: now, Definition: in.Definition}); err != nil {
return err
}
cursor, next := v.ProcessingVersion, v.NextFireAt
// nil 游标也可能表示刚进入最新版本、尚未初始化;只有没有扫描工作才表示结束。
if (next == nil && cursor == v.CurrentVersion && v.CheckAt == nil) || (next != nil && !next.Before(now)) {
c, err := schedule.Compile(in.Definition.Rule)
if err != nil {
return err
}
next, err = c.AtOrAfter(now)
if err != nil {
return err
}
cursor = v.CurrentVersion + 1
}
check := &now
if next != nil && next.After(now) {
check = next
} else if next == nil && cursor == v.CurrentVersion+1 {
check = nil
}
_, err = tx.ExecContext(ctx, `UPDATE pulse_schedules SET current_version=current_version+1,processing_version=?,next_fire_at=?,check_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, cursor, next, check, now, t.NamespaceID, id)
return err
})
}
func (s *ScheduleStore) Control(ctx context.Context, t auth.Token, id core.ID, key string, in schedule.ControlInput, paused bool) (schedule.Result, error) {
operation := "schedule.resume"
if paused {
operation = "schedule.pause"
}
return s.mutate(ctx, t, id, key, operation, in, in.ExpectedRevision, in.Reason, func(tx *sql.Tx, v schedule.Summary, now time.Time) error {
if v.Paused == paused {
return fault.Conflict
}
_, err := tx.ExecContext(ctx, `UPDATE pulse_schedules SET paused=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, paused, now, t.NamespaceID, id)
return err
})
}
func (s *ScheduleStore) mutate(ctx context.Context, t auth.Token, id core.ID, key, operation string, input any, revision uint64, reason string, change func(*sql.Tx, schedule.Summary, time.Time) error) (schedule.Result, error) {
var out schedule.Result
err := s.transaction(ctx, t, "schedules:manage", func(tx *sql.Tx, p auth.Permissions) error {
if operation == "schedule.publish" && !p.Allows("tasks:submit") {
return fault.Forbidden
}
if _, err := authorizeSchedule(ctx, tx, t.NamespaceID, id, p); err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, operation, string(id), key, "schedule", id, input)
if err != nil {
return err
}
if cached != nil {
if err := json.Unmarshal(cached, &out); err != nil {
return err
}
out.Deduplicated = true
return nil
}
current, err := readSchedule(ctx, tx, t.NamespaceID, id, false)
if err != nil {
return err
}
if err = requireActiveQueue(ctx, tx, t.NamespaceID, current.QueueID); err != nil {
return err
}
v, err := readSchedule(ctx, tx, t.NamespaceID, id, true)
if err != nil {
return err
}
if v.Revision != revision {
return fault.Conflict
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if err := change(tx, v, now); err != nil {
return err
}
out.Schedule, err = scheduleView(ctx, tx, t.NamespaceID, id)
if err != nil {
return err
}
if err := appendAudit(ctx, tx, t, identity, id, "schedule", operation, reason, v, out.Schedule.Summary); err != nil {
return err
}
if err := emitEvent(ctx, tx, t.NamespaceID, id, out.Schedule.Revision, "schedule", operation, map[string]any{"version": fmt.Sprint(out.Schedule.CurrentVersion), "paused": out.Schedule.Paused}); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return out, err
}
func (s *ScheduleStore) Version(ctx context.Context, t auth.Token, id core.ID, version uint64) (schedule.VersionView, error) {
var out schedule.VersionView
err := s.transaction(ctx, t, "schedules:read", func(tx *sql.Tx, p auth.Permissions) error {
if _, err := authorizeSchedule(ctx, tx, t.NamespaceID, id, p); err != nil {
return err
}
var err error
out, err = readScheduleVersion(ctx, tx, t.NamespaceID, id, version)
return err
})
return out, err
}