oficina / calendar Public

Reschedule reminder jobs atomically with event edits and splits

Paul Campbell pushed by paul@keymail.dev 6166596001d9535c38003ef478d222fbfc1e1036 parent cb3f043
3 files changed, +201 −3
  • internal/calendar/store_event_write.go +1 −1
  • internal/calendar/store_override.go +5 −2
  • internal/calendartest/edit_reminder_reschedule_test.go +195 −0
diff --git a/internal/calendar/store_event_write.go b/internal/calendar/store_event_write.go
index f6e7607..b703f44 100644
--- a/internal/calendar/store_event_write.go
+++ b/internal/calendar/store_event_write.go
@@ -115,7 +115,7 @@ func (s *Store) updateEventOn(tx *gorm.DB, existing Event, in EventInput, now ti
Where("id = ?", stored.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("calendar: update event: %w", err)
}
- return nil
+ return s.rescheduleAllForTx(tx, stored.ID)
}
// DeleteEvent soft-deletes an event and, when it is a series master,
diff --git a/internal/calendar/store_override.go b/internal/calendar/store_override.go
index ee78c39..5f84de2 100644
--- a/internal/calendar/store_override.go
+++ b/internal/calendar/store_override.go
@@ -189,9 +189,12 @@ func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, origi
// Overrides after the cut belong to the tail, not the head.
// Left on the head they would be orphans: rows nothing renders
// and nothing can reach.
- return tx.Model(&Event{}).
+ if err := tx.Model(&Event{}).
Where("series_id = ? AND start_utc >= ?", seriesID, cut.Unix()).
- Update("series_id", ev.ID).Error
+ Update("series_id", ev.ID).Error; err != nil {
+ return err
+ }
+ return s.rescheduleAllForTx(tx, seriesID)
})
}
diff --git a/internal/calendartest/edit_reminder_reschedule_test.go b/internal/calendartest/edit_reminder_reschedule_test.go
new file mode 100644
index 0000000..b690a67
--- /dev/null
+++ b/internal/calendartest/edit_reminder_reschedule_test.go
@@ -0,0 +1,195 @@
+package calendartest
+
+import (
+ "database/sql"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+type editReminderFixture struct {
+ app *calendar.App
+ session *http.Cookie
+ path string
+ id, cal int64
+ start time.Time
+ form url.Values
+}
+type editReminderJob struct {
+ Due int64
+ Key string
+ Claim, Retry sql.NullString
+ Attempts int
+}
+
+func newEditReminderFixture(t *testing.T, repeat string) editReminderFixture {
+ t.Helper()
+ app, session := signedIn(t, "edit-reminder@example.test")
+ start := time.Now().UTC().AddDate(0, 0, 3).Truncate(time.Minute)
+ form := url.Values{"title": {"Future fixture"}, "start": {start.Format(calendar.LayoutLocal)}, "end": {start.Add(time.Hour).Format(calendar.LayoutLocal)}, "tz": {"UTC"}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {repeat}}
+ path := createEvent(t, app.Handler(), session, form)
+ id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cal int64
+ if err = app.DBForTest().QueryRow("SELECT calendar_id FROM events WHERE id=?", id).Scan(&cal); err != nil {
+ t.Fatal(err)
+ }
+ return editReminderFixture{app, session, path, id, cal, start, form}
+}
+func reminderJob(t *testing.T, app *calendar.App, id int64) (editReminderJob, bool) {
+ t.Helper()
+ var q editReminderJob
+ err := app.DBForTest().QueryRow(`SELECT q.due_at,q.occurrence_key,q.claim_id,q.retry_at,q.attempts FROM reminder_queues q JOIN reminders r ON r.id=q.reminder_id WHERE r.event_id=?`, id).Scan(&q.Due, &q.Key, &q.Claim, &q.Retry, &q.Attempts)
+ if err == sql.ErrNoRows {
+ return q, false
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return q, true
+}
+func markReminderClaim(t *testing.T, f editReminderFixture) {
+ t.Helper()
+ _, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET claim_id='old-worker',claimed_at=?,retry_at=?,attempts=2 WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, time.Now().Unix(), f.start.Unix(), f.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+func unrelatedReminder(t *testing.T, f editReminderFixture) (int64, editReminderJob) {
+ t.Helper()
+ form := url.Values{"title": {"Unrelated fixture"}, "start": {f.start.AddDate(0, 0, 1).Format(calendar.LayoutLocal)}, "end": {f.start.AddDate(0, 0, 1).Add(time.Hour).Format(calendar.LayoutLocal)}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}}
+ path := createEvent(t, f.app.Handler(), f.session, form)
+ id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64)
+ if err != nil {
+ t.Fatal(err)
+ }
+ q, ok := reminderJob(t, f.app, id)
+ if !ok {
+ t.Fatal("unrelated job absent")
+ }
+ return id, q
+}
+func TestOrdinaryEditsReplaceReminderJobs(t *testing.T) {
+ for _, kind := range []string{"earlier", "later", "cancelled"} {
+ t.Run(kind, func(t *testing.T) {
+ f := newEditReminderFixture(t, "none")
+ other, oldOther := unrelatedReminder(t, f)
+ old, ok := reminderJob(t, f.app, f.id)
+ if !ok {
+ t.Fatal("original job absent")
+ }
+ markReminderClaim(t, f)
+ next := f.start.Add(2 * time.Hour)
+ if kind == "earlier" {
+ next = f.start.Add(-2 * time.Hour)
+ }
+ f.form.Set("start", next.Format(calendar.LayoutLocal))
+ f.form.Set("end", next.Add(time.Hour).Format(calendar.LayoutLocal))
+ if kind == "cancelled" {
+ f.form.Set("status", "cancelled")
+ }
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusSeeOther {
+ t.Fatalf("edit status=%d", r.Code)
+ }
+ q, found := reminderJob(t, f.app, f.id)
+ if kind == "cancelled" {
+ if found {
+ t.Fatalf("cancelled event still queued: %+v", q)
+ }
+ } else if !found || q.Due != next.Unix()-600 || q.Due == old.Due || q.Claim.Valid || q.Retry.Valid || q.Attempts != 0 {
+ t.Fatalf("replacement job=%+v found=%v", q, found)
+ }
+ after, found := reminderJob(t, f.app, other)
+ if !found || after != oldOther {
+ t.Fatal("unrelated reminder changed")
+ }
+ res, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET fired_at=1 WHERE claim_id='old-worker' AND reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, f.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n, _ := res.RowsAffected()
+ if n != 0 {
+ t.Fatal("old worker retained completion authority")
+ }
+ })
+ }
+}
+func TestSplitSeriesRemovesFinishedHeadReminder(t *testing.T) {
+ f := newEditReminderFixture(t, "daily")
+ other, oldOther := unrelatedReminder(t, f)
+ markReminderClaim(t, f)
+ f.form.Set("scope", "following")
+ f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal))
+ f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal))
+ f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal))
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusSeeOther {
+ t.Fatalf("split status=%d", r.Code)
+ }
+ if q, found := reminderJob(t, f.app, f.id); found {
+ t.Fatalf("finished head queued: %+v", q)
+ }
+ q, found := reminderJob(t, f.app, other)
+ if !found || q != oldOther {
+ t.Fatal("unrelated reminder changed")
+ }
+ var count int
+ if err := f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=? AND rrule<>''", f.cal).Scan(&count); err != nil || count != 2 {
+ t.Fatalf("head/tail count=%d err=%v", count, err)
+ }
+}
+func TestReminderFailureRollsBackEditAndSplit(t *testing.T) {
+ for _, split := range []bool{false, true} {
+ t.Run(strconv.FormatBool(split), func(t *testing.T) {
+ repeat := "none"
+ operation := "UPDATE"
+ if split {
+ repeat = "daily"
+ operation = "DELETE"
+ }
+ f := newEditReminderFixture(t, repeat)
+ old, ok := reminderJob(t, f.app, f.id)
+ if !ok {
+ t.Fatal("job missing")
+ }
+ _, err := f.app.DBForTest().Exec("CREATE TRIGGER reject_reminder_change BEFORE " + operation + " ON reminder_queues BEGIN SELECT RAISE(ABORT,'fixture refusal'); END")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal))
+ f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal))
+ if split {
+ f.form.Set("scope", "following")
+ f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal))
+ }
+ r := postAs(t, f.app.Handler(), f.session, f.path, f.form)
+ if r.Code != http.StatusInternalServerError {
+ t.Fatalf("refusal status=%d", r.Code)
+ }
+ q, found := reminderJob(t, f.app, f.id)
+ if !found || q != old {
+ t.Fatal("job changed despite rollback")
+ }
+ var start int64
+ var rule string
+ var count int
+ if err = f.app.DBForTest().QueryRow("SELECT start_utc,rrule FROM events WHERE id=?", f.id).Scan(&start, &rule); err != nil {
+ t.Fatal(err)
+ }
+ if start != f.start.Unix() || (split && strings.Contains(rule, "UNTIL")) {
+ t.Fatalf("event changed despite rollback: %d %s", start, rule)
+ }
+ if err = f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=?", f.cal).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("partial tail count=%d err=%v", count, err)
+ }
+ })
+ }
+}