Add cron.Machine state machine.
It is subset of engine.StateMachine that deals exclusively with time. Unlike
engine.StateMachine, it doesn't try to keep track of how individual invocations
travel through task queue.
Will be eventually used to power cron jobs by periodically emitting trigger
events based on some schedule.
R=tandrii@chromium.org
BUG=
Review-Url: https://codereview.chromium.org/2980943002
diff --git a/scheduler/appengine/engine/cron/demo/app.yaml b/scheduler/appengine/engine/cron/demo/app.yaml
new file mode 100644
index 0000000..cd32807
--- /dev/null
+++ b/scheduler/appengine/engine/cron/demo/app.yaml
@@ -0,0 +1,14 @@
+application: local-cron-machine-adhoc-demo
+module: default
+runtime: go
+api_version: go1
+
+handlers:
+- url: /internal.*
+ script: _go_app
+ secure: always
+ login: admin
+
+- url: /.*
+ script: _go_app
+ secure: always
diff --git a/scheduler/appengine/engine/cron/demo/main.go b/scheduler/appengine/engine/cron/demo/main.go
new file mode 100644
index 0000000..e24f1be
--- /dev/null
+++ b/scheduler/appengine/engine/cron/demo/main.go
@@ -0,0 +1,167 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package demo shows how cron.Machines can be hosted with Datastore and TQ.
+package demo
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "golang.org/x/net/context"
+
+ "github.com/luci/gae/service/datastore"
+ "github.com/luci/gae/service/taskqueue"
+
+ "github.com/luci/luci-go/appengine/gaemiddleware"
+ "github.com/luci/luci-go/common/clock"
+ "github.com/luci/luci-go/common/data/rand/mathrand"
+ "github.com/luci/luci-go/common/logging"
+ "github.com/luci/luci-go/server/router"
+
+ "github.com/luci/luci-go/scheduler/appengine/engine/cron"
+ "github.com/luci/luci-go/scheduler/appengine/schedule"
+)
+
+type CronState struct {
+ _extra datastore.PropertyMap `gae:"-,extra"`
+
+ ID string `gae:"$id"`
+ State cron.State `gae:",noindex"`
+}
+
+func (s *CronState) schedule() *schedule.Schedule {
+ parsed, err := schedule.Parse(s.ID, 0)
+ if err != nil {
+ panic(err)
+ }
+ return parsed
+}
+
+// evolve instantiates cron.Machine, calls the callback and submits emitted
+// actions.
+func evolve(c context.Context, id string, cb func(context.Context, *cron.Machine) error) error {
+ err := datastore.RunInTransaction(c, func(c context.Context) error {
+ entity := CronState{ID: id}
+ if err := datastore.Get(c, &entity); err != nil && err != datastore.ErrNoSuchEntity {
+ return err
+ }
+
+ machine := &cron.Machine{
+ Now: clock.Now(c),
+ Schedule: entity.schedule(),
+ Nonce: func() int64 { return mathrand.Get(c).Int63() + 1 },
+ State: entity.State,
+ }
+
+ if err := cb(c, machine); err != nil {
+ return err
+ }
+
+ for _, action := range machine.Actions {
+ var task *taskqueue.Task
+ switch a := action.(type) {
+ case cron.TickLaterAction:
+ logging.Infof(c, "Scheduling tick %d after %s", a.TickNonce, a.When.Sub(time.Now()))
+ task = &taskqueue.Task{
+ Path: fmt.Sprintf("/tick/%s/%d", id, a.TickNonce),
+ ETA: a.When,
+ }
+ case cron.StartInvocationAction:
+ task = &taskqueue.Task{
+ Path: fmt.Sprintf("/invocation/%s", id),
+ Delay: time.Second, // give the transaction time to land
+ }
+ default:
+ panic("unknown action type")
+ }
+ if err := taskqueue.Add(c, "default", task); err != nil {
+ return err
+ }
+ }
+
+ entity.State = machine.State
+ return datastore.Put(c, &entity)
+ }, nil)
+
+ if err != nil {
+ logging.Errorf(c, "FAIL - %s", err)
+ }
+ return err
+}
+
+func startJob(c context.Context, id string) error {
+ return evolve(c, id, func(c context.Context, m *cron.Machine) error {
+ // Forcefully restart the chain of tasks.
+ m.Disable()
+ m.Enable()
+ return nil
+ })
+}
+
+func handleTick(c context.Context, id string, nonce int64) error {
+ return evolve(c, id, func(c context.Context, m *cron.Machine) error {
+ return m.OnTimerTick(nonce)
+ })
+}
+
+func handleInvocation(c context.Context, id string) error {
+ logging.Infof(c, "INVOCATION of job %q has finished!", id)
+ return evolve(c, id, func(c context.Context, m *cron.Machine) error {
+ m.RewindIfNecessary()
+ return nil
+ })
+}
+
+func init() {
+ r := router.New()
+ gaemiddleware.InstallHandlers(r)
+
+ // Kick-start a bunch of jobs by visiting:
+ //
+ // http://localhost:8080/start/with 10s interval
+ // http://localhost:8080/start/with 5s interval
+ // http://localhost:8080/start/0 * * * * * * *
+ //
+ // And the look at the logs.
+
+ r.GET("/start/:JobID", gaemiddleware.BaseProd(), func(c *router.Context) {
+ jobID := c.Params.ByName("JobID")
+ if err := startJob(c.Context, jobID); err != nil {
+ panic(err)
+ }
+ })
+
+ r.POST("/tick/:JobID/:TickNonce", gaemiddleware.BaseProd(), func(c *router.Context) {
+ jobID := c.Params.ByName("JobID")
+ nonce, err := strconv.ParseInt(c.Params.ByName("TickNonce"), 10, 64)
+ if err != nil {
+ panic(err)
+ }
+ if err := handleTick(c.Context, jobID, nonce); err != nil {
+ panic(err)
+ }
+ })
+
+ r.POST("/invocation/:JobID", gaemiddleware.BaseProd(), func(c *router.Context) {
+ jobID := c.Params.ByName("JobID")
+ if err := handleInvocation(c.Context, jobID); err != nil {
+ panic(err)
+ }
+ })
+
+ http.DefaultServeMux.Handle("/", r)
+}
diff --git a/scheduler/appengine/engine/cron/machine.go b/scheduler/appengine/engine/cron/machine.go
new file mode 100644
index 0000000..792b7b0
--- /dev/null
+++ b/scheduler/appengine/engine/cron/machine.go
@@ -0,0 +1,240 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cron
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/luci/luci-go/scheduler/appengine/schedule"
+)
+
+// State stores serializable state of the cron machine.
+//
+// Whoever hosts the cron machine is supposed to store this state in some
+// persistent store between events. It's mutated by Machine. So the usage
+// pattern is:
+// * Deserialize State, construct Machine instance with it.
+// * Invoke some Machine method (e.g Enable()) to advance the state.
+// * Acknowledge all actions emitted by the machine (see Machine.Actions).
+// * Serialize the mutated state (available in Machine.State).
+//
+// If appropriate, all of the above should be done in a transaction.
+//
+// Machine assumes that whoever hosts it handles TickLaterAction with following
+// semantics:
+// * A scheduled tick can't be "unscheduled".
+// * A scheduled tick may come more than one time.
+//
+// So the machine just ignores ticks it doesn't expect.
+//
+// It supports "absolute" and "relative" schedules, see 'schedule' package for
+// definitions.
+type State struct {
+ // Enabled is true if the cron machine is running.
+ //
+ // A disabled cron machine ignores all events except 'Enable'.
+ Enabled bool
+
+ // LastRewind is a time when the cron machine was restarted last time.
+ //
+ // For relative schedules, it's a time RewindIfNecessary() was called. For
+ // absolute schedules it is last time invocation happened (cron machines on
+ // absolute schedules auto-rewind themselves).
+ LastRewind time.Time
+
+ // LastTick is last emitted tick request (or empty struct).
+ //
+ // It may be scheduled for "distant future" for paused cron machines.
+ LastTick TickLaterAction
+}
+
+// IsSuspended returns true if the cron machine is not waiting for a tick.
+//
+// This happens for paused cron machines (they technically are scheduled for
+// a tick in a distant future) and for cron machines on relative schedule that
+// wait for 'RewindIfNecessary' to be called to start ticking again.
+//
+// A disabled cron machine is also considered suspended.
+func (s *State) IsSuspended() bool {
+ return !s.Enabled || s.LastTick.When.IsZero() || s.LastTick.When == schedule.DistantFuture
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+// Action is a particular action to perform when switching the state.
+//
+// Can be type cast to some concrete *Action struct. Intended to be handled by
+// whoever hosts the cron machine.
+type Action interface {
+ IsAction() bool
+}
+
+// TickLaterAction schedules an OnTimerTick call at given moment in time.
+//
+// TickNonce is used by cron machine to skip canceled or repeated ticks.
+type TickLaterAction struct {
+ When time.Time
+ TickNonce int64
+}
+
+// IsAction makes TickLaterAction implement Action interface.
+func (a TickLaterAction) IsAction() bool { return true }
+
+// StartInvocationAction is emitted when the scheduled moment comes.
+//
+// A handler is expected to call RewindIfNecessary() at some later time to
+// restart the cron machine if it's running on a relative schedule (e.g. "with
+// 10 sec interval"). Cron machines on relative schedules are "one shot". They
+// need to be rewound to start counting time again.
+//
+// Cron machines on absolute schedules (regular crons, like "at 12 AM every
+// day") don't need rewinding, they'll start counting time until next invocation
+// automatically. Calling RewindIfNecessary() for them won't hurt though, it
+// will be noop.
+type StartInvocationAction struct{}
+
+// IsAction makes StartInvocationAction implement Action interface.
+func (a StartInvocationAction) IsAction() bool { return true }
+
+////////////////////////////////////////////////////////////////////////////////
+
+// Machine advances the state of the cron machine.
+//
+// It gracefully handles various kinds of external events (like pauses and
+// schedule changes) and emits actions that's supposed to handled by whoever
+// hosts it.
+type Machine struct {
+ // Inputs.
+ Now time.Time // current time
+ Schedule *schedule.Schedule // knows when to emit invocation action
+ Nonce func() int64 // produces nonces on demand
+
+ // Mutated.
+ State State // state of the cron machine, mutated by its methods
+ Actions []Action // all emitted actions (if any)
+}
+
+// Enable makes the cron machine start counting time.
+//
+// Does nothing if already enabled.
+func (m *Machine) Enable() {
+ if !m.State.Enabled {
+ m.State = State{Enabled: true, LastRewind: m.Now} // reset state
+ m.scheduleTick()
+ }
+}
+
+// Disable stops any pending timer ticks, resets state.
+//
+// The cron machine will ignore any events until Enable is called to turn it on.
+func (m *Machine) Disable() {
+ m.State = State{Enabled: false}
+}
+
+// RewindIfNecessary is called to restart the cron after it has fired the
+// invocation action.
+//
+// Does nothing if the cron is disabled or already ticking.
+func (m *Machine) RewindIfNecessary() {
+ if m.State.Enabled && m.State.LastTick.When.IsZero() {
+ m.State.LastRewind = m.Now
+ m.scheduleTick()
+ }
+}
+
+// OnScheduleChange happens when cron's schedule changes.
+//
+// In particular, it handles switches between absolute and relative schedules.
+func (m *Machine) OnScheduleChange() {
+ // Do not touch timers on disabled cron machines.
+ if !m.State.Enabled {
+ return
+ }
+
+ // The following condition is true for cron machines on a relative schedule
+ // that have already "fired", and currently wait for manual RewindIfNecessary
+ // call to start ticking again. When such cron machines switch to an absolute
+ // schedule, we need to rewind them right away (since machines on absolute
+ // schedules always tick!). If the new schedule is also relative, do nothing:
+ // RewindIfNecessary() should be called manually by the host at some later
+ // time (as usual for relative schedules).
+ if m.State.LastTick.When.IsZero() {
+ if m.Schedule.IsAbsolute() {
+ m.RewindIfNecessary()
+ }
+ } else {
+ // In this branch, the cron machine has a timer tick scheduled. It means it
+ // is either in a relative or absolute schedule, and this schedule may have
+ // changed, so we may need to move the tick to reflect the change. Note that
+ // we are not resetting LastRewind here, since we want the new schedule to
+ // take into account real last RewindIfNecessary call. For example, if the
+ // last rewind happened at moment X, current time is Now, and the new
+ // schedule is "with 10s interval", we want the tick to happen at "X+10",
+ // not "Now+10".
+ m.scheduleTick()
+ }
+}
+
+// OnTimerTick happens when a scheduled timer tick (added with TickLaterAction)
+// occurs.
+//
+// Returns an error if the tick happened too soon.
+func (m *Machine) OnTimerTick(tickNonce int64) error {
+ // Silently skip unexpected, late or canceled ticks. This is fine.
+ switch {
+ case m.State.IsSuspended():
+ return nil
+ case m.State.LastTick.TickNonce != tickNonce:
+ return nil
+ }
+
+ // Report error (to trigger a retry) if the tick happened unexpectedly soon.
+ // Absolute schedules may report "wrong" next tick time if asked for a next
+ // tick before previous one has happened.
+ if delay := m.Now.Sub(m.State.LastTick.When); delay < 0 {
+ return fmt.Errorf("tick happened %.1f sec before it was expected", -delay.Seconds())
+ }
+
+ // The scheduled time has come!
+ m.Actions = append(m.Actions, StartInvocationAction{})
+ m.State.LastTick = TickLaterAction{}
+
+ // Start waiting for a new tick right away if on an absolute schedule or just
+ // keep the tick state clear for relative schedules: new tick will be set when
+ // RewindIfNecessary() is manually called by whoever handles the cron.
+ if m.Schedule.IsAbsolute() {
+ m.RewindIfNecessary()
+ }
+
+ return nil
+}
+
+// scheduleTick emits TickLaterAction action according to the schedule, current
+// time, and last time RewindIfNecessary was called.
+//
+// Does nothing if such tick has already been scheduled.
+func (m *Machine) scheduleTick() {
+ nextTickTime := m.Schedule.Next(m.Now, m.State.LastRewind)
+ if nextTickTime != m.State.LastTick.When {
+ m.State.LastTick = TickLaterAction{
+ When: nextTickTime,
+ TickNonce: m.Nonce(),
+ }
+ if nextTickTime != schedule.DistantFuture {
+ m.Actions = append(m.Actions, m.State.LastTick)
+ }
+ }
+}
diff --git a/scheduler/appengine/engine/cron/machine_test.go b/scheduler/appengine/engine/cron/machine_test.go
new file mode 100644
index 0000000..e0cbb00
--- /dev/null
+++ b/scheduler/appengine/engine/cron/machine_test.go
@@ -0,0 +1,356 @@
+// Copyright 2017 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cron
+
+import (
+ "testing"
+ "time"
+
+ "github.com/luci/luci-go/scheduler/appengine/schedule"
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestMachine(t *testing.T) {
+ t.Parallel()
+
+ at0min, _ := schedule.Parse("0 * * * *", 0)
+ at45min, _ := schedule.Parse("45 * * * *", 0)
+ each30min, _ := schedule.Parse("with 30m interval", 0)
+ each10min, _ := schedule.Parse("with 10m interval", 0)
+ never, _ := schedule.Parse("triggered", 0)
+
+ Convey("Absolute schedule", t, func() {
+ tm := testMachine{
+ Now: parseTime("00:15"),
+ Schedule: at0min,
+ }
+
+ // Enabling the job schedules the first tick based on the schedule.
+ err := tm.roll(func(m *Machine) error {
+ m.Enable()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("01:00"),
+ TickNonce: 1,
+ },
+ })
+
+ // RewindIfNecessary does nothing, the tick is already set.
+ err = tm.roll(func(m *Machine) error {
+ m.RewindIfNecessary()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // Early tick is ignored with an error.
+ tm.Now = parseTime("00:59")
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(1) })
+ So(err.Error(), ShouldEqual, "tick happened 60.0 sec before it was expected")
+ So(tm.Actions, ShouldEqual, nil)
+
+ tm.Now = parseTime("01:01") // acceptable tick time (slightly late)!
+
+ // A tick with wrong nonce is silently skipped.
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(123) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldEqual, nil)
+
+ // The correct tick comes. Invocation is started and new tick is scheduled.
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(1) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ StartInvocationAction{},
+ TickLaterAction{
+ When: parseTime("02:00"),
+ TickNonce: 2,
+ },
+ })
+
+ // Disabling the job.
+ err = tm.roll(func(m *Machine) error {
+ m.Disable()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // It silently skips the tick now.
+ tm.Now = parseTime("02:00")
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(2) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldEqual, nil)
+ })
+
+ Convey("Relative schedule", t, func() {
+ tm := testMachine{
+ Now: parseTime("00:00"),
+ Schedule: each30min,
+ }
+
+ // Enabling the job schedules the first tick based on the schedule.
+ err := tm.roll(func(m *Machine) error {
+ m.Enable()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("00:30"),
+ TickNonce: 1,
+ },
+ })
+
+ // RewindIfNecessary does nothing, the tick is already set.
+ err = tm.roll(func(m *Machine) error {
+ m.RewindIfNecessary()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // Tick arrives (slightly late). The invocation is started, but the next
+ // tick is _not_ set.
+ tm.Now = parseTime("00:31")
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(1) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ StartInvocationAction{},
+ })
+
+ // Some time later (when invocation has presumably finished), rewind the
+ // clock. It sets a new tick 30min from now.
+ tm.Now = parseTime("00:40")
+ err = tm.roll(func(m *Machine) error {
+ m.RewindIfNecessary()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("01:10"), // 40min + 30min
+ TickNonce: 2,
+ },
+ })
+ })
+
+ Convey("Relative schedule, distant future", t, func() {
+ tm := testMachine{
+ Now: parseTime("00:00"),
+ Schedule: never,
+ }
+
+ // Enabling the job does nothing.
+ err := tm.roll(func(m *Machine) error {
+ m.Enable()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // Rewinding does nothing.
+ err = tm.roll(func(m *Machine) error {
+ m.RewindIfNecessary()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // Ticking does nothing.
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(1) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+ })
+
+ Convey("Schedule changes", t, func() {
+ // Start with absolute.
+ tm := testMachine{
+ Now: parseTime("00:00"),
+ Schedule: at0min,
+ }
+
+ // The first tick is scheduled to 1h from now.
+ err := tm.roll(func(m *Machine) error {
+ m.Enable()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("01:00"),
+ TickNonce: 1,
+ },
+ })
+
+ // 10 min later switch to the relative schedule. It reschedules the tick
+ // to 30 min since the _previous action_ (which was 'Enable').
+ tm.Now = parseTime("00:10")
+ tm.Schedule = each30min
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("00:30"),
+ TickNonce: 2,
+ },
+ })
+
+ // The operation is idempotent. No new tick is scheduled when we try again.
+ tm.Now = parseTime("00:15")
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // The scheduled tick comes. Since it is a relative schedule, no new tick
+ // is scheduled.
+ tm.Now = parseTime("00:30")
+ err = tm.roll(func(m *Machine) error { return m.OnTimerTick(2) })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ StartInvocationAction{},
+ })
+
+ // Some time later we switch it to another relative schedule. Nothing
+ // happens, since we are waiting for a rewind now anyway.
+ tm.Now = parseTime("00:40")
+ tm.Schedule = each10min
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+
+ // Now we switch back to the absolute schedule. It schedules a new tick.
+ tm.Now = parseTime("01:30")
+ tm.Schedule = at0min
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("02:00"),
+ TickNonce: 3,
+ },
+ })
+
+ // Changing the absolute schedule moves the tick accordingly.
+ tm.Schedule = at45min
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("01:45"),
+ TickNonce: 4,
+ },
+ })
+
+ // Switching to 'triggered' schedule "disarms" the current tick by replacing
+ // it with "tick in the distant future". This doesn't emit any actions,
+ // since we can't actually schedule tick in the distant future.
+ tm.Schedule = never
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+ So(tm.State.LastTick, ShouldResemble, TickLaterAction{
+ When: schedule.DistantFuture,
+ TickNonce: 5,
+ })
+
+ // Enabling back absolute schedule places a new tick.
+ tm.Now = parseTime("01:30")
+ tm.Schedule = at0min
+ err = tm.roll(func(m *Machine) error {
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldResemble, []Action{
+ TickLaterAction{
+ When: parseTime("02:00"),
+ TickNonce: 6,
+ },
+ })
+
+ // Schedule changes do nothing to disabled jobs.
+ tm.Schedule = at45min
+ err = tm.roll(func(m *Machine) error {
+ m.Disable()
+ m.OnScheduleChange()
+ return nil
+ })
+ So(err, ShouldBeNil)
+ So(tm.Actions, ShouldBeNil)
+ })
+
+ Convey("Petty code coverage", t, func() {
+ // Just to get 100% code coverage...
+ So((StartInvocationAction{}).IsAction(), ShouldBeTrue)
+ So((TickLaterAction{}).IsAction(), ShouldBeTrue)
+ })
+}
+
+func parseTime(str string) time.Time {
+ t, err := time.Parse(time.RFC822, "01 Jan 17 "+str+" UTC")
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+type testMachine struct {
+ State State
+ Schedule *schedule.Schedule
+ Now time.Time
+ Nonces int64
+ Actions []Action
+}
+
+func (t *testMachine) roll(cb func(*Machine) error) error {
+ m := Machine{
+ Now: t.Now,
+ Schedule: t.Schedule,
+ Nonce: func() int64 {
+ t.Nonces++
+ return t.Nonces
+ },
+ State: t.State,
+ }
+
+ if err := cb(&m); err != nil {
+ return err
+ }
+
+ t.State = m.State
+ t.Actions = m.Actions
+ return nil
+}