Files
Maven/internal/store/reminders.go
T
claude 0b057df2a3 Give reminder cancellation its own store and IPC path (V-719)
CancelReminder replaces the cancelled half of MarkReminder, which stays
delivery-only. Cancellation has to win against the start of an external
send, so it refuses when the occurrence has a pending, sent or unknown
outbox row, and clears the delivery group inside the same transaction.
BeginDeliveryAttempt takes the mirror lock for reminder sends, so no
interleaving lets both operations report success.

Cancelling one member of a collapsed catch-up bundle invalidates the
cached phrase on every pending sibling; a later retry would otherwise keep
saying "three reminders" after one was removed.

Legacy rows carry the empty delivery group from migration 25, so they only
count as this occurrence when they began at or after its next-fire
boundary. Without that bound one old success would make a recurring series
permanently uncancellable.

ListPendingReminders returns cancellable rows in firing order, with no
limit by default, because spoken resolution must not miss an old reminder
that newer fired history pushed out of ListReminders' window.

Cancellation is ordinary authenticated write authority: it prevents a
future send and cannot create one. cmd/e2eprobe drives both from outside.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:13 +04:00

802 lines
28 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// Reminder — user-stated future intent. fires once or recurring (if cron set).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
// DeliveryGroup and Phrase* are the durable presentation for this exact
// occurrence. A collapsed catch-up bundle stores the same group and phrase
// on every original reminder, so a retry (including after restart) says the
// same thing without asking the model again. Rescheduling a recurring
// reminder clears them for its next occurrence.
DeliveryGroup string
PhraseBody string
PhraseSummary string
PhraseMood string
DeliveryAttempts int
NextAttemptTs time.Time
DeliveryBlockedTs time.Time
DeliveryBlockedError string
// Collapsed — set only on a synthetic digest reminder (ID=0): the original
// due reminders it stands in for. Not persisted. The dispatcher completes
// (marks fired / reschedules) each of these after the digest delivers.
Collapsed []Reminder
}
// Text — what the user actually asked for, out of the raw-JSON payload.
//
// The router's reminder slot extraction owns the payload shape and the
// conventional field is "text". A payload that is not JSON, or that lacks the
// field, is returned as-is: he said it, so they are his words, and showing
// them beats showing nothing.
//
// Here rather than in a caller because there is more than one caller and they
// disagreed. The phraser unwrapped the payload; the day plan did not, so
// "какие у меня планы на сегодня" recited a reminder as the literal string
// {"text":"..."} on the deployed daemon, 01-08-2026.
func (r Reminder) Text() string { return ReminderText(r.Payload) }
// ReminderText — Reminder.Text for callers holding a bare payload string.
func ReminderText(payload string) string {
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if t, ok := m["text"].(string); ok && t != "" {
return t
}
if t, ok := m["text"]; ok {
return fmt.Sprintf("%v", t)
}
}
return strings.TrimSpace(payload)
}
// Reminder lifecycle states. Named for the same reason DigestStatus is: a
// caller filtering on the string literal "pending" is one typo away from a
// filter that silently matches nothing.
const (
ReminderPending = "pending"
ReminderFired = "fired"
ReminderCancelled = "cancelled"
// ReminderRetryBase and ReminderRetryMax bound the retry cadence. Attempts
// continue indefinitely because a reminder must not disappear during a
// long transport outage; only the delay stops growing.
ReminderRetryBase = time.Minute
ReminderRetryMax = time.Hour
)
var (
ErrReminderNotFound = errors.New("store: reminder not found")
ErrReminderState = errors.New("store: reminder not in a mutable state")
ErrReminderInFlight = errors.New("store: reminder delivery already started")
ErrReminderPhrase = errors.New("store: reminder delivery phrase invalid")
)
const reminderColumns = `id, created_ts, fire_ts, next_fire_ts, payload, status, cron,
delivery_group, phrase_body, phrase_summary, phrase_mood, delivery_attempts, next_attempt_ts,
delivery_blocked_ts, delivery_blocked_error`
func scanReminder(sc scanner) (Reminder, error) {
var r Reminder
var created, fire, nextFire int64
var cron *string
var nextAttempt *int64
var blocked *int64
if err := sc.Scan(
&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron,
&r.DeliveryGroup, &r.PhraseBody, &r.PhraseSummary, &r.PhraseMood,
&r.DeliveryAttempts, &nextAttempt, &blocked, &r.DeliveryBlockedError,
); err != nil {
return Reminder{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
r.NextFireTs = time.UnixMilli(nextFire).UTC()
if cron != nil {
r.Cron = *cron
}
if nextAttempt != nil {
r.NextAttemptTs = time.UnixMilli(*nextAttempt).UTC()
}
if blocked != nil {
r.DeliveryBlockedTs = time.UnixMilli(*blocked).UTC()
}
return r, nil
}
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here. cron is a cron expression for recurring
// reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create reminder: last insert id: %w", err)
}
return id, nil
}
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
FROM reminders
WHERE status = 'pending' AND next_fire_ts <= ?
AND (next_attempt_ts IS NULL OR next_attempt_ts <= ?)
AND delivery_blocked_ts IS NULL
AND NOT EXISTS (
SELECT 1
FROM delivery_attempts AS attempt
WHERE attempt.kind = 'reminder'
AND attempt.delivery_group = reminders.delivery_group
AND reminders.delivery_group <> ''
AND attempt.status IN ('pending', 'sent', 'unknown')
)
ORDER BY next_fire_ts ASC, id ASC`, now.UnixMilli(), now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// PendingReminders returns the pending reminders whose next fire time falls in
// [from, to), earliest first.
//
// The day plan used to take the newest 500 rows out of ListReminders, which
// orders by creation, and then filter them by day. A reminder stated long ago
// for today fell off the end of that scan while a reminder stated this morning
// for next year stayed on it. Bounding by fire time drops what is out of range
// instead of what is old.
func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
FROM reminders
WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ?
ORDER BY next_fire_ts ASC, id ASC`,
ReminderPending, from.UnixMilli(), to.UnixMilli())
if err != nil {
return nil, fmt.Errorf("pending reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkReminder records successful completion of a reminder delivery. The only
// valid transition is pending→fired. Cancellation has stronger outbox and
// collapsed-group invariants and must go through CancelReminder; accepting the
// same status here would leave a legacy bypass around those invariants.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != ReminderFired {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// The old read-then-write transition allowed two callers to both observe
// pending and both report success. Keeping the source state in the UPDATE
// predicate makes pending → fired one atomic contest (V-678).
res, err := s.db.ExecContext(ctx,
"UPDATE reminders SET status = ? WHERE id = ? AND status = ?",
status, id, ReminderPending)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("mark reminder: rows affected: %w", err)
}
if n == 1 {
return nil
}
// Preserve the public distinction between a missing id and a completed
// state without weakening the atomic transition above.
var current string
err = s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(&current)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return err
}
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
// CancelReminder atomically wins against the beginning of external delivery.
// A pending/sent/unknown outbox row means the presentation may already be
// outside the process, so claiming cancellation would be false. Definite
// failures do not block cancellation.
//
// A collapsed catch-up bundle shares one cached phrase. Cancelling any member
// invalidates that presentation on every still-pending sibling; otherwise the
// next retry could continue saying "three reminders" after one was removed.
func (s *Store) CancelReminder(ctx context.Context, id int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("cancel reminder: begin: %w", err)
}
defer tx.Rollback()
var status, group string
var nextFire int64
err = tx.QueryRowContext(ctx,
`SELECT status, delivery_group, next_fire_ts FROM reminders WHERE id = ?`, id,
).Scan(&status, &group, &nextFire)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return fmt.Errorf("cancel reminder %d: read: %w", id, err)
}
if status != ReminderPending {
return fmt.Errorf("%w: currently %s", ErrReminderState, status)
}
// New attempts carry an occurrence-scoped delivery group. Migration 25
// assigned older rows the empty group, so those can only be related to this
// occurrence when they began at or after its next-fire boundary. Without
// that bound, one successful delivery from a recurring reminder's history
// would make the whole series permanently uncancellable after an upgrade.
var live int
err = tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM delivery_attempts
WHERE kind = 'reminder'
AND status IN ('pending', 'sent', 'unknown')
AND ((delivery_group <> '' AND delivery_group = ?)
OR (delivery_group = '' AND reminder_id = ? AND created_ts >= ?))`,
group, id, nextFire).Scan(&live)
if err != nil {
return fmt.Errorf("cancel reminder %d: inspect delivery: %w", id, err)
}
if live > 0 {
return ErrReminderInFlight
}
if group != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE reminders
SET delivery_group = '', phrase_body = '', phrase_summary = '', phrase_mood = ''
WHERE delivery_group = ? AND status = ?`, group, ReminderPending); err != nil {
return fmt.Errorf("cancel reminder %d: invalidate group: %w", id, err)
}
}
res, err := tx.ExecContext(ctx, `
UPDATE reminders
SET status = ?, delivery_group = '', phrase_body = '', phrase_summary = '', phrase_mood = '',
next_attempt_ts = NULL, delivery_blocked_ts = NULL, delivery_blocked_error = ''
WHERE id = ? AND status = ?`, ReminderCancelled, id, ReminderPending)
if err != nil {
return fmt.Errorf("cancel reminder %d: %w", id, err)
}
if err := requireOneReminderRow(res, id); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("cancel reminder %d: commit: %w", id, err)
}
return nil
}
// ListReminders returns the n most recent reminders, newest first.
func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("list reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// ListPendingReminders returns cancellable reminders in firing order. n <= 0
// means all pending rows: spoken resolution must not miss an old reminder just
// because newer fired history filled ListReminders' window.
func (s *Store) ListPendingReminders(ctx context.Context, n int) ([]Reminder, error) {
query := `SELECT ` + reminderColumns + `
FROM reminders
WHERE status = ?
ORDER BY next_fire_ts ASC, id ASC`
args := []any{ReminderPending}
if n > 0 {
query += ` LIMIT ?`
args = append(args, n)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list pending reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// HasDeliveryPhrase reports whether this occurrence already has a durable
// presentation. Summary may intentionally be empty (the delivery boundary has
// a generic privacy-preserving fallback), so Body is the readiness marker.
func (r Reminder) HasDeliveryPhrase() bool {
return r.DeliveryGroup != "" && r.PhraseBody != ""
}
// ReminderRetryDelay returns the delay after attempt (one-based). It grows
// exponentially from one minute and stays at one hour; retries do not stop.
func ReminderRetryDelay(attempt int) time.Duration {
if attempt <= 1 {
return ReminderRetryBase
}
delay := ReminderRetryBase
for i := 1; i < attempt && delay < ReminderRetryMax; i++ {
if delay >= ReminderRetryMax/2 {
return ReminderRetryMax
}
delay *= 2
}
if delay > ReminderRetryMax {
return ReminderRetryMax
}
return delay
}
// CacheReminderPhrase stores one presentation on every original represented
// by a reminder delivery. For a collapsed bundle, originals contains every
// row in Reminder.Collapsed and group is shared across all of them.
//
// The occurrence timestamp and empty-body predicates keep this from attaching
// an old phrase to a recurring reminder's next occurrence or overwriting a
// phrase another delivery already claimed. The transaction prevents a partial
// bundle cache: after a crash either every original can reconstruct the bundle
// or none can.
func (s *Store) CacheReminderPhrase(
ctx context.Context,
originals []Reminder,
group, body, summary, mood string,
) error {
if len(originals) == 0 || group == "" || body == "" {
return ErrReminderPhrase
}
if mood == "" {
mood = "neutral"
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("cache reminder phrase: begin: %w", err)
}
defer tx.Rollback()
seen := make(map[int64]struct{}, len(originals))
for _, r := range originals {
if r.ID <= 0 || r.NextFireTs.IsZero() {
return ErrReminderPhrase
}
if _, ok := seen[r.ID]; ok {
return ErrReminderPhrase
}
seen[r.ID] = struct{}{}
res, err := tx.ExecContext(ctx, `
UPDATE reminders
SET delivery_group = ?, phrase_body = ?, phrase_summary = ?, phrase_mood = ?
WHERE id = ? AND status = ? AND next_fire_ts = ? AND phrase_body = ''`,
group, body, summary, mood, r.ID, ReminderPending, r.NextFireTs.UnixMilli())
if err != nil {
return fmt.Errorf("cache reminder phrase %d: %w", r.ID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("cache reminder phrase %d: rows affected: %w", r.ID, err)
}
if n != 1 {
return fmt.Errorf("%w: reminder %d is no longer an unphrased pending occurrence", ErrReminderState, r.ID)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("cache reminder phrase: commit: %w", err)
}
return nil
}
// ScheduleReminderRetry moves every still-pending original into a retry wait.
// Terminal rows are skipped: cancellation winning while a send was in flight
// must not be resurrected. A recurring row whose occurrence changed is skipped
// for the same reason. All remaining originals get their own persisted attempt
// count; normally a collapsed bundle keeps those counts in lockstep.
func (s *Store) ScheduleReminderRetry(ctx context.Context, originals []Reminder, now time.Time) error {
if len(originals) == 0 {
return ErrReminderNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("schedule reminder retry: begin: %w", err)
}
defer tx.Rollback()
seen := make(map[int64]struct{}, len(originals))
for _, expected := range originals {
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
return ErrReminderNotFound
}
if _, ok := seen[expected.ID]; ok {
continue
}
seen[expected.ID] = struct{}{}
var status string
var nextFire int64
var attempts int
var blocked *int64
err := tx.QueryRowContext(ctx,
`SELECT status, next_fire_ts, delivery_attempts, delivery_blocked_ts FROM reminders WHERE id = ?`,
expected.ID).Scan(&status, &nextFire, &attempts, &blocked)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return fmt.Errorf("schedule reminder retry %d: read: %w", expected.ID, err)
}
if status != ReminderPending || nextFire != expected.NextFireTs.UnixMilli() || blocked != nil {
continue
}
if expected.DeliveryGroup != "" {
var ambiguous int
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM delivery_attempts
WHERE kind = 'reminder' AND delivery_group = ?
AND status IN ('pending', 'sent', 'unknown')
)`, expected.DeliveryGroup).Scan(&ambiguous); err != nil {
return fmt.Errorf("schedule reminder retry %d: inspect outbox: %w", expected.ID, err)
}
if ambiguous != 0 {
// The sink may have accepted this presentation. Leave the reminder
// pending but ineligible for automatic retry; operator resolution
// must not be replaced with a guessed duplicate.
continue
}
}
attempts++
nextAttempt := now.Add(ReminderRetryDelay(attempts)).UnixMilli()
res, err := tx.ExecContext(ctx, `
UPDATE reminders
SET delivery_attempts = ?, next_attempt_ts = ?
WHERE id = ? AND status = ? AND next_fire_ts = ? AND delivery_attempts = ?`,
attempts, nextAttempt, expected.ID, ReminderPending, nextFire, attempts-1)
if err != nil {
return fmt.Errorf("schedule reminder retry %d: %w", expected.ID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("schedule reminder retry %d: rows affected: %w", expected.ID, err)
}
if n != 1 {
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, expected.ID)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("schedule reminder retry: commit: %w", err)
}
return nil
}
// BlockReminderDelivery records a permanent transport/configuration refusal on
// every original represented by one presentation. Blocked rows stay pending and
// visible, but never retry automatically. UnblockReminderDelivery is the
// deliberate recovery path after credentials or policy are repaired.
func (s *Store) BlockReminderDelivery(ctx context.Context, originals []Reminder, now time.Time, reason string) error {
if len(originals) == 0 || strings.TrimSpace(reason) == "" {
return ErrReminderState
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("block reminder delivery: begin: %w", err)
}
defer tx.Rollback()
seen := make(map[int64]struct{}, len(originals))
for _, expected := range originals {
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
return ErrReminderNotFound
}
if _, ok := seen[expected.ID]; ok {
continue
}
seen[expected.ID] = struct{}{}
res, err := tx.ExecContext(ctx, `
UPDATE reminders
SET delivery_blocked_ts = ?, delivery_blocked_error = ?, next_attempt_ts = NULL
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
now.UnixMilli(), reason, expected.ID, ReminderPending, expected.NextFireTs.UnixMilli())
if err != nil {
return fmt.Errorf("block reminder %d: %w", expected.ID, err)
}
if err := requireOneReminderRow(res, expected.ID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("block reminder delivery: commit: %w", err)
}
return nil
}
func (s *Store) UnblockReminderDelivery(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `
UPDATE reminders
SET delivery_blocked_ts = NULL, delivery_blocked_error = '', next_attempt_ts = NULL
WHERE id = ? AND status = ? AND delivery_blocked_ts IS NOT NULL`, id, ReminderPending)
if err != nil {
return fmt.Errorf("unblock reminder %d: %w", id, err)
}
return requireOneReminderRow(res, id)
}
// CompleteReminderDelivery applies the bookkeeping for every original covered
// by one successful external send in a single transaction. One-shot reminders
// become fired; recurring reminders advance to their next occurrence and shed
// the old presentation/retry state. The all-or-nothing boundary prevents a
// collapsed digest from becoming half-fired and then being repeated.
func (s *Store) CompleteReminderDelivery(ctx context.Context, originals []Reminder, now time.Time) error {
return s.completeReminderDeliveryIn(ctx, 0, originals, now, time.Local)
}
// CompleteSuccessfulReminderAttempt atomically closes a definitely successful
// external attempt and advances every reminder occurrence it represented. If
// this transaction cannot commit, the attempt remains pending; startup then
// reconciles it to unknown, and the occurrence is held from automatic replay.
func (s *Store) CompleteSuccessfulReminderAttempt(ctx context.Context, attemptID int64, originals []Reminder, now time.Time) error {
if attemptID <= 0 {
return fmt.Errorf("complete reminder delivery: invalid attempt id %d", attemptID)
}
return s.completeReminderDeliveryIn(ctx, attemptID, originals, now, time.Local)
}
func (s *Store) completeReminderDeliveryIn(ctx context.Context, attemptID int64, originals []Reminder, now time.Time, loc *time.Location) error {
if len(originals) == 0 {
return ErrReminderNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("complete reminder delivery: begin: %w", err)
}
defer tx.Rollback()
if attemptID != 0 {
res, err := tx.ExecContext(ctx,
`UPDATE delivery_attempts SET status = ?, completed_ts = ? WHERE id = ? AND status = ?`,
DeliverySent, now.UnixMilli(), attemptID, DeliveryPending)
if err != nil {
return fmt.Errorf("complete reminder delivery attempt %d: %w", attemptID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("complete reminder delivery attempt %d: rows affected: %w", attemptID, err)
}
if n != 1 {
return fmt.Errorf("complete reminder delivery attempt %d: not pending", attemptID)
}
}
seen := make(map[int64]struct{}, len(originals))
for _, expected := range originals {
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
return ErrReminderNotFound
}
if _, ok := seen[expected.ID]; ok {
return fmt.Errorf("%w: duplicate reminder %d in one delivery", ErrReminderState, expected.ID)
}
seen[expected.ID] = struct{}{}
row := tx.QueryRowContext(ctx, `SELECT `+reminderColumns+` FROM reminders WHERE id = ?`, expected.ID)
current, err := scanReminder(row)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return fmt.Errorf("complete reminder %d: read: %w", expected.ID, err)
}
if current.Status != ReminderPending || !current.NextFireTs.Equal(expected.NextFireTs) {
return fmt.Errorf("%w: reminder %d occurrence changed", ErrReminderState, expected.ID)
}
if expected.DeliveryGroup != "" && current.DeliveryGroup != expected.DeliveryGroup {
return fmt.Errorf("%w: reminder %d delivery group changed", ErrReminderState, expected.ID)
}
if current.Cron == "" {
if err := completeOneShotReminderTx(ctx, tx, current); err != nil {
return err
}
continue
}
next, err := nextReminderOccurrence(current, now, loc)
if err != nil {
return err
}
if next.IsZero() {
if err := completeOneShotReminderTx(ctx, tx, current); err != nil {
return err
}
continue
}
res, err := tx.ExecContext(ctx, `
UPDATE reminders
SET next_fire_ts = ?, delivery_group = '', phrase_body = '',
phrase_summary = '', phrase_mood = '', delivery_attempts = 0,
next_attempt_ts = NULL, delivery_blocked_ts = NULL,
delivery_blocked_error = ''
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
next.UnixMilli(), current.ID, ReminderPending, current.NextFireTs.UnixMilli())
if err != nil {
return fmt.Errorf("complete recurring reminder %d: %w", current.ID, err)
}
if err := requireOneReminderRow(res, current.ID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("complete reminder delivery: commit: %w", err)
}
return nil
}
func completeOneShotReminderTx(ctx context.Context, tx *sql.Tx, r Reminder) error {
res, err := tx.ExecContext(ctx,
`UPDATE reminders SET status = ? WHERE id = ? AND status = ? AND next_fire_ts = ?`,
ReminderFired, r.ID, ReminderPending, r.NextFireTs.UnixMilli())
if err != nil {
return fmt.Errorf("complete reminder %d: %w", r.ID, err)
}
return requireOneReminderRow(res, r.ID)
}
func requireOneReminderRow(res sql.Result, id int64) error {
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("complete reminder %d: rows affected: %w", id, err)
}
if n != 1 {
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, id)
}
return nil
}
// RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If the schedule yields no further fire time at all,
// marks it fired.
//
// Two things the first version got wrong, both fixed 06-08-2026 (V-616).
//
// The cron expression is a WALL CLOCK statement — "0 9 * * *" is nine in the
// morning where the owner stands — but scanReminder hands back instants in UTC,
// and robfig's Next walks the calendar in the location of the time it is given.
// Computing from a UTC instant therefore produced the next 09:00 UTC, so the
// second occurrence of a daily reminder landed one UTC offset late and stayed
// there: 12:00 for a Moscow owner. Everything is converted to loc first, which
// also makes the walk DST-correct — the schedule keeps its wall-clock hour
// across a changeover instead of drifting an hour with the offset.
//
// And a missed occurrence used to KILL the reminder: any next fire earlier than
// now marked it fired, so a daemon down overnight ended a daily standup forever.
// Occurrences in the past are skipped instead, so the reminder rolls forward to
// the first one strictly after now. Skipping and not replaying is deliberate:
// the same no-backlog rule routine.DueAccepted follows.
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return s.rescheduleReminderIn(ctx, id, now, time.Local)
}
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
row := s.db.QueryRowContext(ctx, `SELECT `+reminderColumns+`
FROM reminders WHERE id = ?`, id)
r, err := scanReminder(row)
if err != nil {
return err
}
if r.Cron == "" {
return fmt.Errorf("%w: not a recurring reminder", ErrReminderState)
}
if r.Status != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status)
}
next, err := nextReminderOccurrence(r, now, loc)
if err != nil {
return err
}
if next.IsZero() {
return s.MarkReminder(ctx, id, ReminderFired)
}
res, err := s.db.ExecContext(ctx, `
UPDATE reminders
SET next_fire_ts = ?, delivery_group = '', phrase_body = '',
phrase_summary = '', phrase_mood = '', delivery_attempts = 0,
next_attempt_ts = NULL, delivery_blocked_ts = NULL,
delivery_blocked_error = ''
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
next.UnixMilli(), id, ReminderPending, r.NextFireTs.UnixMilli())
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("reschedule reminder: rows affected: %w", err)
}
if n != 1 {
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, id)
}
return nil
}
func nextReminderOccurrence(r Reminder, now time.Time, loc *time.Location) (time.Time, error) {
sched, err := cron.ParseStandard(r.Cron)
if err != nil {
return time.Time{}, fmt.Errorf("parse cron %q: %w", r.Cron, err)
}
// Next is strictly after the time it is given, so the last fire cannot be
// returned again and no fudge minute is needed. The bound stops a schedule
// that somehow yields a non-advancing time from spinning here.
next := sched.Next(r.NextFireTs.In(loc))
for i := 0; i < 4096 && !next.IsZero() && !next.After(now); i++ {
next = sched.Next(next)
}
if next.IsZero() || !next.After(now) {
return time.Time{}, nil
}
return next, nil
}