mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
fix: avoid skipping calendar windows that open after startup (#16)
Co-authored-by: zabcxky-123 <[email protected]>
This commit is contained in:
1 parent
f5c99f4866
commit
6c940f4403
2 files changed
+111
-21
No files matched your search
+32
-21
@@ -15,6 +15,7 @@ type scheduleWindow struct {
|
|||||||
active bool
|
active bool
|
||||||
complete bool
|
complete bool
|
||||||
key string
|
key string
|
||||||
|
start time.Time
|
||||||
nextStart *time.Time
|
nextStart *time.Time
|
||||||
nextEnd *time.Time
|
nextEnd *time.Time
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow {
|
|||||||
if s.EndDate != "" {
|
if s.EndDate != "" {
|
||||||
key += ":" + s.EndDate
|
key += ":" + s.EndDate
|
||||||
}
|
}
|
||||||
w := scheduleWindow{active: active, complete: complete, key: key, nextEnd: end}
|
w := scheduleWindow{active: active, complete: complete, key: key, start: start, nextEnd: end}
|
||||||
if now.Before(start) {
|
if now.Before(start) {
|
||||||
w.key = ""
|
w.key = ""
|
||||||
w.nextStart = &start
|
w.nextStart = &start
|
||||||
@@ -109,6 +110,7 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow {
|
|||||||
start, end := windowBounds(day, s, loc)
|
start, end := windowBounds(day, s, loc)
|
||||||
if !now.Before(start) && (end == nil || now.Before(*end)) {
|
if !now.Before(start) && (end == nil || now.Before(*end)) {
|
||||||
w.active = true
|
w.active = true
|
||||||
|
w.start = start
|
||||||
w.key = "active:" + start.Format("2006-01-02T15:04")
|
w.key = "active:" + start.Format("2006-01-02T15:04")
|
||||||
w.nextEnd = end
|
w.nextEnd = end
|
||||||
// Do not pause between adjacent windows (e.g. Saturday + Sunday full days).
|
// Do not pause between adjacent windows (e.g. Saturday + Sunday full days).
|
||||||
@@ -124,6 +126,34 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evaluateRuntimeScheduleWindow applies startup/manual state to a calendar
|
||||||
|
// window. A plan's creation date says nothing about when its next window opens.
|
||||||
|
func evaluateRuntimeScheduleWindow(item *db.TaskSchedule, now, startedAt time.Time) scheduleWindow {
|
||||||
|
w := evaluateScheduleWindow(item, now)
|
||||||
|
if !item.Enabled || item.Status == "completed" {
|
||||||
|
w.active = false
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
// Only suppress an unrecorded window that was already open at startup.
|
||||||
|
// An explicit configuration edit clears LastWindowKey and advances UpdatedAt;
|
||||||
|
// allow such edits (as well as newly-created plans) to enter the current window.
|
||||||
|
if w.active && item.LastWindowKey == "" && item.CreatedAt.Before(startedAt) &&
|
||||||
|
!item.UpdatedAt.After(startedAt) && !w.start.IsZero() && w.start.Before(startedAt) {
|
||||||
|
w.active = false
|
||||||
|
w.key = "missed:" + w.key
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(item.LastWindowKey, "missed:") && item.LastWindowKey == "missed:"+w.key {
|
||||||
|
w.active = false
|
||||||
|
w.key = item.LastWindowKey
|
||||||
|
}
|
||||||
|
if item.LastWindowKey == "manual" && !w.active {
|
||||||
|
w.active = true
|
||||||
|
w.key = "manual"
|
||||||
|
w.complete = false
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
func (sc *Scheduler) fireTaskSchedules() {
|
func (sc *Scheduler) fireTaskSchedules() {
|
||||||
sc.s.scheduleMu.Lock()
|
sc.s.scheduleMu.Lock()
|
||||||
defer sc.s.scheduleMu.Unlock()
|
defer sc.s.scheduleMu.Unlock()
|
||||||
@@ -135,26 +165,7 @@ func (sc *Scheduler) fireTaskSchedules() {
|
|||||||
open := map[int64]bool{}
|
open := map[int64]bool{}
|
||||||
windows := map[int64]scheduleWindow{}
|
windows := map[int64]scheduleWindow{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
w := evaluateScheduleWindow(item, now)
|
w := evaluateRuntimeScheduleWindow(item, now, sc.startedAt)
|
||||||
// A service restart must not replay a window that started while the
|
|
||||||
// service was down. Newly-created schedules are allowed to enter an
|
|
||||||
// already-open window because their creation is an explicit user action.
|
|
||||||
if w.active && item.LastWindowKey == "" && item.CreatedAt.Before(sc.startedAt) {
|
|
||||||
w.active = false
|
|
||||||
w.key = "missed:" + w.key
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(item.LastWindowKey, "missed:") && item.LastWindowKey == "missed:"+w.key {
|
|
||||||
w.active = false
|
|
||||||
w.key = item.LastWindowKey
|
|
||||||
}
|
|
||||||
if item.LastWindowKey == "manual" && !w.active {
|
|
||||||
w.active = true
|
|
||||||
w.key = "manual"
|
|
||||||
w.complete = false
|
|
||||||
}
|
|
||||||
if !item.Enabled || item.Status == "completed" {
|
|
||||||
w.active = false
|
|
||||||
}
|
|
||||||
windows[item.ID] = w
|
windows[item.ID] = w
|
||||||
if w.active {
|
if w.active {
|
||||||
for _, id := range item.TaskIDs {
|
for _, id := range item.TaskIDs {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func calendarTestTime(s string) time.Time {
|
||||||
|
v, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeScheduleStartupBoundaries(t *testing.T) {
|
||||||
|
old := calendarTestTime("2026-10-15T12:11:17+08:00")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, now, boot string
|
||||||
|
change func(*db.TaskSchedule)
|
||||||
|
active, missed bool
|
||||||
|
}{
|
||||||
|
{"future weekday after restart", "2026-10-16T18:00:06+08:00", "2026-10-15T19:36:05+08:00", nil, true, false},
|
||||||
|
{"window starts exactly with service", "2026-10-16T18:00:05+08:00", "2026-10-16T18:00:00+08:00", nil, true, false},
|
||||||
|
{"already open before restart", "2026-10-16T18:10:05+08:00", "2026-10-16T18:10:00+08:00", nil, false, true},
|
||||||
|
{"new plan inside current window", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.CreatedAt = calendarTestTime("2026-10-16T18:11:00+08:00") }, true, false},
|
||||||
|
{"explicit edit inside current window", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.UpdatedAt = calendarTestTime("2026-10-16T18:11:00+08:00") }, true, false},
|
||||||
|
{"recorded window survives restart", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "active:2026-10-16T18:00" }, true, false},
|
||||||
|
{"same missed window remains blocked", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) {
|
||||||
|
s.LastWindowKey = "missed:active:2026-10-16T18:00"
|
||||||
|
s.UpdatedAt = calendarTestTime("2026-10-16T18:11:00+08:00")
|
||||||
|
}, false, true},
|
||||||
|
{"previous missed window does not block next", "2026-10-19T18:00:05+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "missed:active:2026-10-16T18:00" }, true, false},
|
||||||
|
{"disabled plan does not acquire missed state", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.Enabled = false }, false, false},
|
||||||
|
{"manual run outside window", "2026-10-16T17:00:00+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "manual" }, true, false},
|
||||||
|
{"once future boundary", "2026-10-16T18:00:05+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.ScheduleType = "once"; s.RunDate = "2026-10-16" }, true, false},
|
||||||
|
{"once missed boundary", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.ScheduleType = "once"; s.RunDate = "2026-10-16" }, false, true},
|
||||||
|
{"completed plan remains stopped", "2026-10-16T18:12:00+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.Status = "completed" }, false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "18:00:00", EndTime: "00:00:00", CreatedAt: old, UpdatedAt: old}
|
||||||
|
if tc.change != nil {
|
||||||
|
tc.change(s)
|
||||||
|
}
|
||||||
|
w := evaluateRuntimeScheduleWindow(s, calendarTestTime(tc.now), calendarTestTime(tc.boot))
|
||||||
|
if w.active != tc.active || strings.HasPrefix(w.key, "missed:") != tc.missed {
|
||||||
|
t.Fatalf("active=%v key=%q, want active=%v missed=%v", w.active, w.key, tc.active, tc.missed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeScheduleWeekendAndAdjacentWindows(t *testing.T) {
|
||||||
|
old := calendarTestTime("2026-10-15T12:11:37+08:00")
|
||||||
|
boot := calendarTestTime("2026-10-16T18:20:00+08:00")
|
||||||
|
weekend := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6, 7}, StartTime: "00:00:00", EndTime: "00:00:00", CreatedAt: old, UpdatedAt: old}
|
||||||
|
for _, at := range []string{"2026-10-17T00:00:00+08:00", "2026-10-18T00:00:00+08:00", "2026-10-18T23:59:59+08:00"} {
|
||||||
|
w := evaluateRuntimeScheduleWindow(weekend, calendarTestTime(at), boot)
|
||||||
|
if !w.active {
|
||||||
|
t.Fatalf("weekend unexpectedly inactive at %s: %+v", at, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weekday := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "00:00:00", EndTime: "09:00:00", CreatedAt: old, UpdatedAt: old}
|
||||||
|
at := calendarTestTime("2026-10-19T00:00:00+08:00")
|
||||||
|
if evaluateRuntimeScheduleWindow(weekend, at, boot).active || !evaluateRuntimeScheduleWindow(weekday, at, boot).active {
|
||||||
|
t.Fatal("weekend-to-weekday boundary must keep the shared task union open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeScheduleUsesBeijingWindowAcrossTimezones(t *testing.T) {
|
||||||
|
s := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{5}, StartTime: "18:00:00", EndTime: "00:00:00", CreatedAt: calendarTestTime("2026-10-15T12:00:00+08:00")}
|
||||||
|
w := evaluateRuntimeScheduleWindow(s, calendarTestTime("2026-10-16T06:00:05-04:00"), calendarTestTime("2026-10-15T07:36:05-04:00"))
|
||||||
|
if !w.active || w.key != "active:2026-10-16T18:00" {
|
||||||
|
t.Fatalf("unexpected timezone result: %+v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user