Make delivery and integration failures explicit

Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
This commit is contained in:
2026-08-13 02:50:59 +04:00
parent da9114b623
commit 35c6ff5a71
67 changed files with 3174 additions and 477 deletions
+24 -12
View File
@@ -332,12 +332,11 @@ func Load(path string) (*Config, error) {
// file committed to git.
expanded, missing := expandEnv(string(b))
if len(missing) > 0 {
// An unset variable expands to "", which every block reads as "not
// configured" and none of them complains about. That is the intended
// behaviour and it stays: CI parses this same file with no secrets
// present. What was missing is the line telling the operator which
// capability he just turned off by forgetting an env file.
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
// Expansion happens before typed validation. A disabled block may carry
// empty placeholders; an enabled block that needs one of these values is
// rejected below. Log the names too so a failed deploy says which secret
// source was absent without ever printing a value.
log.Printf("config: %s references unset environment variables %v — expanded them to empty; enabled integrations will reject missing credentials", path, missing)
}
var c Config
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
@@ -459,6 +458,12 @@ func (c *Config) validate() error {
if err := c.validateTelegram(); err != nil {
return err
}
if err := c.validateNtfy(); err != nil {
return err
}
if err := c.validateWorkstation(); err != nil {
return err
}
return nil
}
@@ -468,16 +473,23 @@ func (c *Config) validate() error {
// answers nothing — the failure is invisible from the chat. Same shape as
// validateNetScan: fail the config rather than the turn.
func (c *Config) validateTelegram() error {
if c.Telegram == nil || !c.Telegram.Intake {
if c.Telegram == nil || c.Telegram.Disabled {
return nil
}
// An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an
// empty token or chat id as telegram not being wired at all. Validating a
// block that wires nothing would fail a box that merely has no bot.
if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" {
if err := telegramsink.Validate(*c.Telegram); err != nil {
return err
}
if c.Telegram.Intake {
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
}
return nil
}
func (c *Config) validateNtfy() error {
if c.Ntfy == nil {
return nil
}
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
return ntfysink.Validate(*c.Ntfy)
}
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
+119 -1
View File
@@ -435,10 +435,21 @@ func TestNormaliseDropsAddresslessWorkstation(t *testing.T) {
}
}
func TestModelDisabledKeepsLiveSTT(t *testing.T) {
p := writeConfig(t, `{"workstation":{"model_disabled":true,"stt":{"url":"http://192.168.1.105:8081/transcribe","token":"secret"}}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Workstation == nil || c.Workstation.Stt == nil || !c.Workstation.ModelDisabled {
t.Fatalf("model-only dark state did not preserve STT: %+v", c.Workstation)
}
}
// The health endpoint defaults to the supervisor's, not llama-server's: mavgpud
// answers 503 while the card is held, and that refusal is the whole signal.
func TestNormaliseFillsWorkstationDefaults(t *testing.T) {
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}}
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/", Token: "secret"}}
c.applyDefaults()
if c.Workstation == nil {
t.Fatal("dropped a usable workstation block")
@@ -460,6 +471,7 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
c := &Config{Workstation: &WorkstationConfig{
URL: "http://192.168.1.105:8080",
Health: "http://192.168.1.105:9000/ready",
Token: "secret",
}}
c.applyDefaults()
if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want {
@@ -467,6 +479,112 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
}
}
func TestWorkstationLANEndpointsRequireSecrets(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"model", `{"workstation":{"url":"http://192.168.1.105:8080"}}`},
{"stt", `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Load(writeConfig(t, tc.body)); err == nil {
t.Fatal("Load accepted an enabled LAN endpoint with no secret")
}
})
}
}
func TestWorkstationDisabledAllowsEmptySecrets(t *testing.T) {
p := writeConfig(t, `{"workstation":{"disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Workstation != nil {
t.Fatalf("disabled workstation survived normalisation: %+v", c.Workstation)
}
}
func TestWorkstationArmsMayBeDisabledIndependently(t *testing.T) {
modelOff := writeConfig(t, `{"workstation":{"model_disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://127.0.0.1:8081/transcribe"}}}`)
if _, err := Load(modelOff); err != nil {
t.Fatalf("Load model-disabled config: %v", err)
}
sttOff := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"disabled":true,"url":"http://192.168.1.105:8081/transcribe"}}}`)
c, err := Load(sttOff)
if err != nil {
t.Fatalf("Load STT-disabled config: %v", err)
}
if c.Workstation == nil || c.Workstation.Stt != nil {
t.Fatalf("STT-only dark state changed model arm: %+v", c.Workstation)
}
}
func TestWorkstationLoopbackMayRunWithoutSecret(t *testing.T) {
p := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://localhost:8081/transcribe"}}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load loopback development endpoints: %v", err)
}
}
func TestTelegramLiveBlockRequiresBothSecrets(t *testing.T) {
for _, body := range []string{
`{"telegram":{"chat_id":"42"}}`,
`{"telegram":{"bot_token":"token"}}`,
} {
if _, err := Load(writeConfig(t, body)); err == nil {
t.Fatalf("Load accepted enabled Telegram block: %s", body)
}
}
}
func TestTelegramDisabledAllowsEmptySecrets(t *testing.T) {
if _, err := Load(writeConfig(t, `{"telegram":{"disabled":true}}`)); err != nil {
t.Fatalf("Load disabled Telegram: %v", err)
}
}
func TestNtfyLiveBlockRequiresSecret(t *testing.T) {
p := writeConfig(t, `{"ntfy":{"base_url":"https://ntfy.example","topic":"maven"}}`)
if _, err := Load(p); err == nil {
t.Fatal("Load accepted enabled ntfy block with no credential")
}
}
func TestNtfyDisabledAllowsEmptySecret(t *testing.T) {
p := writeConfig(t, `{"ntfy":{"disabled":true,"base_url":"https://ntfy.example","topic":"maven"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load disabled ntfy: %v", err)
}
}
func TestSmartHomeLiveBlockRequiresSecret(t *testing.T) {
p := writeConfig(t, `{"smarthome":{"enabled":true,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
if _, err := Load(p); err == nil {
t.Fatal("Load accepted enabled Home Assistant block with no token")
}
}
func TestSmartHomeDisabledAllowsEmptySecret(t *testing.T) {
p := writeConfig(t, `{"smarthome":{"enabled":false,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load disabled Home Assistant: %v", err)
}
}
func TestConfiguredDBKeyEnvCannotBeEmpty(t *testing.T) {
t.Setenv("MAVEN_TEST_DB_KEY", "")
c := &Config{DBKeyEnv: "MAVEN_TEST_DB_KEY"}
if _, err := c.DBEncryptionKey(); err == nil {
t.Fatal("configured empty database key did not fail startup resolution")
}
if key, err := (&Config{}).DBEncryptionKey(); err != nil || key != nil {
t.Fatalf("explicit plaintext config = (%v, %v), want (nil, nil)", key, err)
}
}
func TestTelegramIntakeRefusesNamedChat(t *testing.T) {
// The push half accepts an @channelusername and the intake half cannot use
// one, so a box with both boots clean and answers nothing. Refuse the
+57 -8
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -14,14 +15,20 @@ import (
// a config change and a code change have to agree here or the suite is red.
//
// The ${VAR} expansions come from a gitignored deploy/telegram.env that is not
// present in CI. An unset var expands to the empty string, which is exactly the
// "not configured" state every block already has to handle, so the parse is
// still meaningful without the secrets.
// present in CI. Live blocks reject empty secrets; this test supplies inert
// Telegram values and verifies that every credential-less block is explicitly
// disabled.
func TestDeployConfigLoads(t *testing.T) {
path := filepath.Join("..", "..", "deploy", "mavend.json")
if _, err := os.Stat(path); err != nil {
t.Skipf("no deploy config at %s: %v", path, err)
}
// Live blocks fail on expanded-empty credentials. CI provides inert values
// so this test exercises the committed shape; explicitly disabled blocks
// (ntfy, workstation and Home Assistant) require none.
t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
t.Setenv("TELEGRAM_CHAT_ID", "-1001234567890")
t.Setenv("MAVEN_STT_TOKEN", "test-token")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%s): %v", path, err)
@@ -46,11 +53,10 @@ func TestDeployConfigLoads(t *testing.T) {
t.Error("router threshold did not get its default")
}
// The second reach (V-649). Deleting this block is how you turn ntfy off,
// so its absence has to be loud: sev3-away nudges and away reminders route
// to ntfy and to nothing else, and a nil sink drops them with no log and no
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
// the wiring and not the credential.
// The second reach (V-649). The token is a ${VAR} that CI cannot resolve, so
// the committed deployment makes the dark state explicit. Removing disabled
// without provisioning a credential makes runtime wiring fail startup; it
// can never silently publish anonymously.
if cfg.Ntfy == nil {
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
"would have nowhere to land, and would vanish silently rather than fail")
@@ -58,4 +64,47 @@ func TestDeployConfigLoads(t *testing.T) {
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
}
if !cfg.Ntfy.Disabled {
t.Fatal("deploy ntfy reach has no checked-in credential and must remain explicitly disabled")
}
if cfg.Workstation == nil || !cfg.Workstation.ModelDisabled || cfg.Workstation.Stt == nil {
t.Fatalf("deploy must disable only its uncredentialed model arm and retain authenticated STT: %+v", cfg.Workstation)
}
}
func TestCanonicalDeployEnvExampleNamesEverySecret(t *testing.T) {
path := filepath.Join("..", "..", "deploy", "telegram.env.example")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
example := string(b)
references := map[string]bool{
// These two live outside ${...}: DBKeyEnv names an environment variable
// as JSON data, while the separately deployed Python child reads its own
// environment directly.
"CW2_TOKEN": true,
"MAVEN_DB_KEY": true,
}
for _, source := range []string{
filepath.Join("..", "..", "deploy", "mavend.json"),
filepath.Join("..", "..", "docker-compose.yml"),
} {
raw, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read deploy source %s: %v", source, err)
}
_ = os.Expand(string(raw), func(expr string) string {
// Compose supports ${NAME:-default}; os.Expand deliberately hands the
// full braced expression to this callback.
name, _, _ := strings.Cut(expr, ":-")
references[name] = true
return ""
})
}
for name := range references {
if !strings.Contains(example, name+"=") {
t.Errorf("canonical deploy env example omits %s", name)
}
}
}
+80 -11
View File
@@ -1,6 +1,8 @@
package config
import (
"fmt"
"net"
"net/url"
"strings"
"time"
@@ -18,6 +20,13 @@ import (
// Only the prompt crosses the LAN, and the workstation is not "the box". The
// rules in CLAUDE.md about what may leave still apply.
type WorkstationConfig struct {
// Disabled keeps both written workpc arms explicitly dark. ModelDisabled is
// the narrower switch: CW2 STT may remain live while the large-model
// supervisor has no provisioned client token.
Disabled bool `json:"disabled,omitempty"`
ModelDisabled bool `json:"model_disabled,omitempty"`
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
// the whole block is normalised to nil and nothing probes anything.
URL string `json:"url,omitempty"`
@@ -30,8 +39,8 @@ type WorkstationConfig struct {
// Token — the bearer credential mavgpud requires, expanded from the
// environment like every other secret here. It is what stops anything on
// the LAN spending the card, so a URL that is not loopback needs one.
// Wrong or missing reads as a workstation that is down, and Maven falls
// back to the resident model.
// A missing token on a LAN URL fails config validation. Loopback development
// endpoints may omit it.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
@@ -60,6 +69,10 @@ type WorkstationConfig struct {
// the ggml-small.bin homesrv loads
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
type WorkstationSttConfig struct {
// Disabled keeps the written CW2 endpoint dark without also disabling the
// independently authenticated model supervisor.
Disabled bool `json:"disabled,omitempty"`
// URL — the transcribe endpoint, e.g.
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
// to nil and mavsttd takes every turn.
@@ -97,29 +110,85 @@ const (
// model: an unconfigured workstation is the default deploy and must be
// indistinguishable from today.
func (c *Config) normaliseWorkstation() {
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
if c.Workstation != nil && c.Workstation.Disabled {
c.Workstation = nil
}
if c.Workstation == nil {
return
}
w := c.Workstation
if strings.TrimSpace(w.Health) == "" {
w.Health = strings.TrimRight(w.URL, "/") + "/health"
// Preserve the historical empty-block meaning. A live STT sub-block makes
// the parent non-empty; in that case omitting the model URL is an error
// unless model_disabled states the operator's intent.
if !w.ModelDisabled && strings.TrimSpace(w.URL) == "" &&
(w.Stt == nil || w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
c.Workstation = nil
return
}
if w.Probe <= 0 {
w.Probe = Duration(DefaultWorkstationProbe)
}
if w.Timeout <= 0 {
w.Timeout = Duration(DefaultWorkstationTimeout)
if !w.ModelDisabled {
if strings.TrimSpace(w.Health) == "" {
w.Health = strings.TrimRight(w.URL, "/") + "/health"
}
if w.Probe <= 0 {
w.Probe = Duration(DefaultWorkstationProbe)
}
if w.Timeout <= 0 {
w.Timeout = Duration(DefaultWorkstationTimeout)
}
}
normaliseWorkstationStt(w)
if w.ModelDisabled && w.Stt == nil {
c.Workstation = nil
}
}
// validateWorkstation rejects a live LAN endpoint without its bearer secret.
// Loopback remains useful for local development without manufacturing a secret;
// malformed or non-HTTP endpoints are rejected before any probe starts.
func (c *Config) validateWorkstation() error {
if c.Workstation == nil {
return nil
}
w := c.Workstation
if !w.ModelDisabled {
if err := validateWorkstationEndpoint("workstation.url", w.URL, w.Token); err != nil {
return err
}
if err := validateWorkstationEndpoint("workstation.health", w.Health, w.Token); err != nil {
return err
}
}
if w.Stt != nil {
if err := validateWorkstationEndpoint("workstation.stt.url", w.Stt.URL, w.Stt.Token); err != nil {
return err
}
if err := validateWorkstationEndpoint("workstation.stt.health", w.Stt.Health, w.Stt.Token); err != nil {
return err
}
}
return nil
}
func validateWorkstationEndpoint(name, raw, token string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("%s must be an absolute http(s) URL", name)
}
host := strings.TrimSpace(u.Hostname())
loopback := strings.EqualFold(host, "localhost")
if ip := net.ParseIP(host); ip != nil {
loopback = ip.IsLoopback()
}
if !loopback && strings.TrimSpace(token) == "" {
return fmt.Errorf("%s is not loopback, so its token is required while enabled", name)
}
return nil
}
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
// address, no remote: mavsttd then takes every utterance, which is today.
func normaliseWorkstationStt(w *WorkstationConfig) {
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
if w.Stt != nil && (w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
w.Stt = nil
}
if w.Stt == nil {
+14 -4
View File
@@ -20,7 +20,8 @@
//
// - reminders are a SEPARATE class — two delivery paths. reminders bypass
// the restraint gate ("wake me 7" fires in quiet hours; that's the point).
// snooze still applies. voice when present, ntfy when away. fire once.
// snooze still applies. voice when present; when away, try ntfy then
// telegram as alternatives and stop after the first success. fire once.
//
// Architecture mirrors the loop's gather/pure split: the routing table is a
// PURE function of (severity, presence); the Dispatcher holds the impure Sinks
@@ -43,6 +44,12 @@ import (
// to import the voice package.
var ErrVoiceNoSession = errors.New("delivery: voice: no live session")
// ErrPermanent is the class of transport failures that waiting cannot repair:
// a revoked credential or an endpoint that refuses this sender. Dispatchers
// may still try a different reach for the same message, but the failed reach
// must not be put on an automatic retry clock until its configuration changes.
var ErrPermanent = errors.New("delivery: permanent failure")
// Channel — one delivery transport. Drop is an explicit no-op (the routing
// table chose to suppress, which is a decision, not a failure — "a missed
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
@@ -91,8 +98,11 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate.
// "wake me 7" fires in quiet hours; that's the point. presence still routes
// reachability: voice when present, ntfy when away. fires once — no repeat
// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior).
// reachability: voice when present, then an ordered ntfy→telegram alternative
// chain when away. The dispatcher stops after the first successful alternative,
// so a reminder still fires once rather than being broadcast on both channels.
// There is no repeat (repeat-til-ack is a sev4 ops-hard behavior, not a reminder
// behavior).
//
// reminders don't carry a Severity — they're user-stated future intent, not
// loop-derived insistence. the routing is presence-only: reachability without
@@ -100,7 +110,7 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
// per-reminder override, not a table entry.
func ChannelsForReminder(presence store.Bucket) []Channel {
if presence == store.Away {
return []Channel{ChannelNtfy}
return []Channel{ChannelNtfy, ChannelTelegram}
}
return []Channel{ChannelVoice}
}
+133 -56
View File
@@ -22,13 +22,25 @@ type NudgeRecorder interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
}
// ReminderCompleter — the seam the store implements. For one-shot reminders:
// pending → fired after successful delivery. For recurring reminders (with
// cron): reschedule after successful delivery. A failed send does NOT mark or
// reschedule it (it stays pending; the next tick re-delivers).
// ReminderCompleter — the seam the store implements. Every original represented
// by one external delivery is completed in one transaction. That matters for a
// collapsed catch-up bundle: partially firing the originals would make the
// next tick repeat a presentation that the user already received.
type ReminderCompleter interface {
MarkReminder(ctx context.Context, id int64, status string) error
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
CompleteReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time) error
}
// DurableReminderCompleter closes the successful outbox attempt and advances
// every reminder occurrence in one local transaction. The external send and
// local commit cannot be one transaction, but the local half must be: a crash
// between `attempt=sent` and `reminder=fired` otherwise strands the reminder in
// a permanently suppressed state.
type DurableReminderCompleter interface {
CompleteSuccessfulReminderAttempt(ctx context.Context, attemptID int64, originals []store.Reminder, now time.Time) error
}
type ReminderBlocker interface {
BlockReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time, reason string) error
}
// Outbox — the durable delivery ledger. Begin is recorded BEFORE the external
@@ -39,7 +51,7 @@ type ReminderCompleter interface {
// disabled (existing send/record behavior, unchanged — test scenarios that
// don't care about crash recovery).
type Outbox interface {
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error)
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, bodyHash string, now time.Time) (int64, error)
CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error
}
@@ -57,11 +69,11 @@ func bodyHash(channel Channel, body string) string {
// on one attempt shouldn't block a nudge/reminder actually reaching the user
// — but it does mean this attempt can't be reconciled after a crash, so it's
// logged. Returns 0 (no-op id) when unrecorded.
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, channel Channel, body string, now time.Time) int64 {
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) int64 {
if d.cfg.Outbox == nil {
return 0
}
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, string(channel), bodyHash(channel, body), now)
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, deliveryGroup, string(channel), bodyHash(channel, body), now)
if err != nil {
log.Printf("dispatcher: outbox begin failed (send proceeds untracked): %v", err)
return 0
@@ -69,16 +81,37 @@ func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminde
return id
}
// beginReminderOutbox is stricter than the nudge helper above. A reminder may
// be retried indefinitely, so sending it without the durable attempt row would
// reopen an unobservable duplicate window after a crash. A configured but
// unhealthy outbox therefore blocks this transport attempt; a deliberately nil
// outbox still supports small isolated test/development wiring.
func (d *Dispatcher) beginReminderOutbox(ctx context.Context, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) (int64, error) {
if d.cfg.Outbox == nil {
return 0, nil
}
id, err := d.cfg.Outbox.BeginDeliveryAttempt(
ctx, "reminder", "", reminderID, deliveryGroup,
string(channel), bodyHash(channel, body), now,
)
if err != nil {
return 0, fmt.Errorf("begin reminder delivery attempt: %w", err)
}
return id, nil
}
// completeOutbox records the sink's outcome for a prior beginOutbox call.
// id==0 means either tracking is disabled or the begin failed — nothing to
// complete either way.
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) {
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) error {
if id == 0 || d.cfg.Outbox == nil {
return
return nil
}
if err := d.cfg.Outbox.CompleteDeliveryAttempt(ctx, id, status, now); err != nil {
log.Printf("dispatcher: outbox complete failed: %v", err)
return err
}
return nil
}
// PhrasedNudge — the phraser module's output for a nudge. the phraser (the
@@ -155,7 +188,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
// the same afterwards. no nudges row: that table feeds the
// ignored_rate signal, and a nudge nobody could see must not
// count as ignored.
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, pn.Summary, now)
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
c.Rule.Name, c.Severity, c.State.Presence)
@@ -176,7 +209,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
if sink == nil {
continue
}
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, messageForChannel(s), now)
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, messageForChannel(s), now)
if err := safeSend(ctx, sink, s); err != nil {
if errors.Is(err, ErrSinkPanicked) {
// one broken sink must not eat the other channels for this
@@ -226,14 +259,17 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
}
// DispatchReminder — routes a phrased reminder. reminders bypass the gate and
// fire once (pending → fired after successful delivery). voice when present,
// ntfy when away. no repeat (reminders fire once). marks the reminder fired
// only if at least one channel succeeded — a failed send leaves it pending
// for the next tick to re-deliver.
// fire once (pending → fired after successful delivery). Voice is preferred
// when present; if it has no live session, delivery falls back to the ordered
// away alternatives. Away delivery tries ntfy, then telegram, and stops after
// the first success. A failed or unwired alternative falls through to the next
// one. If every selected alternative fails, the reminder stays pending and an
// error is returned for the tick's retry path.
func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, now time.Time) ([]Dispatch, error) {
rd := pr.Decision
channels := ChannelsForReminder(rd.State.Presence)
var out []Dispatch
var failures []error
allPermanent := true
for i := 0; i < len(channels); i++ {
ch := channels[i]
s := Sendable{
@@ -245,62 +281,103 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
Ts: now,
}
s = minimalForAway(s)
sink := d.sinkFor(ch)
if sink == nil {
reminderID, deliveryGroup := reminderDeliveryIdentity(rd.Reminder)
attemptID, err := d.beginReminderOutbox(ctx, reminderID, deliveryGroup, ch, messageForChannel(s), now)
if err != nil {
allPermanent = false
failures = append(failures, err)
log.Printf("dispatcher: reminder %d delivery via %s withheld: %v", rd.Reminder.ID, ch, err)
continue
}
sink := d.sinkFor(ch)
if sink == nil {
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
err := fmt.Errorf("%s sink is not configured", ch)
failures = append(failures, err)
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
if ch == ChannelVoice {
channels = ChannelsForReminder(store.Away)
failures = nil
allPermanent = true
i = -1
}
continue
}
attemptID := d.beginOutbox(ctx, "reminder", "", rd.Reminder.ID, ch, messageForChannel(s), now)
if err := safeSend(ctx, sink, s); err != nil {
if errors.Is(err, ErrSinkPanicked) {
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
continue
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
failures = append(failures, fmt.Errorf("send %s: %w", ch, err))
if !errors.Is(err, ErrPermanent) {
allPermanent = false
}
if errors.Is(err, ErrVoiceNoSession) {
// presence guess was wrong — reroute reminder to the away
// channel (ntfy). voice is the only present channel, so nothing
// has been sent yet.
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
// Presence was stale. Voice is the only present alternative, so
// nothing has been sent and it is safe to start the away chain.
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
channels = ChannelsForReminder(store.Away)
failures = nil
allPermanent = true
i = -1
continue
}
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
return out, fmt.Errorf("send %s: %w", ch, err)
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
continue
}
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
out = append(out, Dispatch{Sendable: s})
}
if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB. Complete
// the collapsed originals it stands in for instead — only now, after
// a successful send, so a failed digest leaves them all pending.
out := []Dispatch{{Sendable: s}}
originals := []store.Reminder{rd.Reminder}
if rd.Reminder.ID == 0 {
for _, orig := range rd.Reminder.Collapsed {
if err := d.completeReminder(ctx, orig, now); err != nil {
return out, err
}
originals = rd.Reminder.Collapsed
}
if durable, ok := d.cfg.Reminders.(DurableReminderCompleter); ok && attemptID != 0 {
if err := durable.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err != nil {
return out, fmt.Errorf("commit successful reminder delivery: %w", err)
}
return out, nil
}
if err := d.completeOutbox(ctx, attemptID, store.DeliverySent, now); err != nil {
// The external sink accepted the reminder, but its durable outcome is
// ambiguous. Do not complete the reminder row: startup reconciliation
// will mark the attempt unknown and DueReminders will hold the exact
// occurrence for operator resolution rather than sending a duplicate.
return out, fmt.Errorf("record successful reminder delivery: %w", err)
}
if d.cfg.Reminders != nil {
if err := d.cfg.Reminders.CompleteReminderDelivery(ctx, originals, now); err != nil {
return out, fmt.Errorf("complete reminder delivery: %w", err)
}
}
return out, nil
}
if len(failures) == 0 {
failures = append(failures, errors.New("no delivery alternatives selected"))
allPermanent = false
}
joined := errors.Join(failures...)
if allPermanent && d.cfg.Reminders != nil {
originals := []store.Reminder{rd.Reminder}
if rd.Reminder.ID == 0 {
originals = rd.Reminder.Collapsed
}
if blocker, ok := d.cfg.Reminders.(ReminderBlocker); ok {
if err := blocker.BlockReminderDelivery(ctx, originals, now, joined.Error()); err != nil {
return nil, fmt.Errorf("block permanently undeliverable reminder %d: %w", rd.Reminder.ID, err)
}
} else if err := d.completeReminder(ctx, rd.Reminder, now); err != nil {
return out, err
}
}
return out, nil
return nil, fmt.Errorf("deliver reminder %d: %w", rd.Reminder.ID, joined)
}
// completeReminder — post-delivery bookkeeping for one reminder: recurring
// (cron set) reschedules, one-shot marks fired.
func (d *Dispatcher) completeReminder(ctx context.Context, r store.Reminder, now time.Time) error {
if r.Cron != "" {
if err := d.cfg.Reminders.RescheduleReminder(ctx, r.ID, now); err != nil {
return fmt.Errorf("reschedule reminder %d: %w", r.ID, err)
}
return nil
// reminderDeliveryIdentity gives the outbox both a human-readable real row id
// and the exact occurrence key used for crash suppression. A collapsed digest
// has synthetic ID zero, so its first original is the representative; the
// shared delivery group still identifies every original atomically.
func reminderDeliveryIdentity(r store.Reminder) (int64, string) {
if r.ID != 0 {
return r.ID, r.DeliveryGroup
}
if err := d.cfg.Reminders.MarkReminder(ctx, r.ID, "fired"); err != nil {
return fmt.Errorf("mark reminder %d fired: %w", r.ID, err)
if len(r.Collapsed) == 0 {
return 0, ""
}
return nil
return r.Collapsed[0].ID, r.Collapsed[0].DeliveryGroup
}
// RepeatUnacked — the daemon calls this each tick to re-send un-acked sev4
@@ -340,7 +417,7 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time.
Ts: now,
}
s = minimalForAway(s)
attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now)
attemptID := d.beginOutbox(ctx, "nudge", key, 0, "", ChannelTelegram, messageForChannel(s), now)
if err := safeSend(ctx, d.cfg.Telegram, s); err != nil {
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
if errors.Is(err, ErrSinkPanicked) {
+247 -10
View File
@@ -3,6 +3,7 @@ package delivery
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -61,9 +62,22 @@ type fakeReminderCompleter struct {
status string
}
rescheduled []int64
blocked []int64
blockReason string
err error
}
func (f *fakeReminderCompleter) BlockReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time, reason string) error {
if f.err != nil {
return f.err
}
for _, r := range originals {
f.blocked = append(f.blocked, r.ID)
}
f.blockReason = reason
return nil
}
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
if f.err != nil {
return f.err
@@ -83,6 +97,23 @@ func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64,
return nil
}
func (f *fakeReminderCompleter) CompleteReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time) error {
if f.err != nil {
return f.err
}
for _, r := range originals {
if r.Cron != "" {
f.rescheduled = append(f.rescheduled, r.ID)
continue
}
f.marked = append(f.marked, struct {
id int64
status string
}{r.ID, store.ReminderFired})
}
return nil
}
type fakeAck struct {
acked map[string]bool
lastSent map[string]time.Time
@@ -152,10 +183,10 @@ func TestChannelsForReminderPresentVoice(t *testing.T) {
}
}
func TestChannelsForReminderAwayNtfy(t *testing.T) {
func TestChannelsForReminderAwayAlternatives(t *testing.T) {
got := ChannelsForReminder(store.Away)
if len(got) != 1 || got[0] != ChannelNtfy {
t.Fatalf("reminder away: want [ntfy], got %v", got)
if len(got) != 2 || got[0] != ChannelNtfy || got[1] != ChannelTelegram {
t.Fatalf("reminder away: want [ntfy telegram], got %v", got)
}
}
@@ -353,8 +384,9 @@ func TestDispatchReminderPresentVoice(t *testing.T) {
func TestDispatchReminderAwayNtfy(t *testing.T) {
ntfy := &fakeSink{}
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Ntfy: ntfy, Reminders: rc})
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
rd := loop.ReminderDecision{
Reminder: store.Reminder{ID: 7, Status: "pending"},
@@ -369,6 +401,9 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
t.Fatalf("want 1 ntfy, got %+v", out)
}
if len(telegram.sends) != 0 {
t.Fatalf("ntfy succeeded; telegram must not receive a duplicate, got %d sends", len(telegram.sends))
}
// away channel gets summary, not body
if ntfy.sends[0].Summary != "wake up" {
t.Fatalf("ntfy summary: want 'wake up', got %q", ntfy.sends[0].Summary)
@@ -378,6 +413,203 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
}
}
func TestDispatchReminderAwayNtfyFailureFallsBackToTelegram(t *testing.T) {
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{}
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 8, Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "full medication detail", Summary: "take medication",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
}
if got := messageForChannel(telegram.sends[0]); got != "take medication" {
t.Fatalf("telegram fallback must retain the minimal away body, got %q", got)
}
if len(rc.marked) != 1 || rc.marked[0].id != 8 || rc.marked[0].status != "fired" {
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
}
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
t.Fatalf("want ntfy failed then telegram sent attempts, got %+v", ob.attempts)
}
}
func TestDispatchReminderAwayNilNtfyFallsBackVisibly(t *testing.T) {
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{}
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 9, Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "full detail", Summary: "short reminder",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
}
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
t.Fatalf("nil ntfy must leave a failed row before telegram succeeds, got %+v", ob.attempts)
}
if len(rc.marked) != 1 || rc.marked[0].id != 9 {
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
}
}
func TestDispatchReminderAwayAllAlternativesFailStaysPending(t *testing.T) {
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
telegram := &fakeSink{err: errors.New("telegram unavailable")}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{}
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 10, Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "full detail", Summary: "short reminder",
}, refNow())
if err == nil {
t.Fatal("all alternatives failed: want an error")
}
if len(out) != 0 || len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
t.Fatalf("failed reminder must stay pending, got out=%+v marked=%+v rescheduled=%+v", out, rc.marked, rc.rescheduled)
}
if len(ob.attempts) != 2 || ob.attempts[0].status != store.DeliveryFailed || ob.attempts[1].status != store.DeliveryFailed {
t.Fatalf("want two failed attempts, got %+v", ob.attempts)
}
}
func TestDispatchReminderAllPermanentAlternativesBlockGroup(t *testing.T) {
permanent := fmt.Errorf("%w: credentials rejected", ErrPermanent)
ntfy := &fakeSink{err: permanent}
telegram := &fakeSink{err: permanent}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
now := refNow()
originals := []store.Reminder{
{ID: 21, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
{ID: 22, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
}
_, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
State: loop.State{Now: now, Presence: store.Away},
}, Body: "details", Summary: "two reminders",
}, now)
if err == nil || !errors.Is(err, ErrPermanent) {
t.Fatalf("permanent alternatives error = %v", err)
}
if len(ntfy.sends) != 0 || len(telegram.sends) != 0 {
// fakeSink records only successes, so this also asserts neither was
// mistaken for a successful delivery.
t.Fatalf("permanent failure produced successful sends: ntfy=%d telegram=%d", len(ntfy.sends), len(telegram.sends))
}
if len(rc.blocked) != 2 || rc.blocked[0] != 21 || rc.blocked[1] != 22 || rc.blockReason == "" {
t.Fatalf("permanent bundle was not durably blocked: ids=%v reason=%q", rc.blocked, rc.blockReason)
}
}
func TestDispatchReminderWithUnrecordableOutboxDoesNotSend(t *testing.T) {
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{beginErr: errors.New("database unavailable")}
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 11, Status: "pending", DeliveryGroup: "reminder:11"},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "full detail", Summary: "short reminder",
}, refNow())
if err == nil {
t.Fatal("unrecordable reminder attempt must be withheld")
}
if len(out) != 0 || len(telegram.sends) != 0 || len(rc.marked) != 0 {
t.Fatalf("unrecordable reminder escaped durable boundary: out=%+v sends=%d completed=%+v", out, len(telegram.sends), rc.marked)
}
}
func TestDispatchReminderOutboxCompletionAmbiguityDoesNotCompleteOccurrence(t *testing.T) {
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{completeErr: errors.New("database unavailable after send")}
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 12, Status: store.ReminderPending, DeliveryGroup: "reminder:12"},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "full detail", Summary: "short reminder",
}, refNow())
if err == nil {
t.Fatal("ambiguous outbox completion must surface an error")
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
t.Fatalf("the accepted external send must be reported once: out=%+v sends=%d", out, len(telegram.sends))
}
if len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
t.Fatalf("ambiguous accepted send completed the occurrence: marked=%+v rescheduled=%+v", rc.marked, rc.rescheduled)
}
if len(ob.attempts) != 2 || ob.attempts[1].status != store.DeliveryPending {
t.Fatalf("accepted send should remain pending for startup reconciliation: %+v", ob.attempts)
}
}
func TestDispatchReminderCollapsedOutboxUsesRealIDAndSharedGroup(t *testing.T) {
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
ob := &fakeOutbox{}
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
now := refNow()
originals := []store.Reminder{
{ID: 81, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
{ID: 82, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
}
if _, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
State: loop.State{Now: now, Presence: store.Away},
},
Body: "two reminders", Summary: "two reminders",
}, now); err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ob.attempts) != 2 {
t.Fatalf("attempts=%d, want nil-ntfy plus telegram", len(ob.attempts))
}
for _, a := range ob.attempts {
if a.reminderID != 81 || a.deliveryGroup != "reminder:bundle" {
t.Fatalf("synthetic ID escaped into outbox: %+v", a)
}
}
if len(rc.marked) != 2 || rc.marked[0].id != 81 || rc.marked[1].id != 82 {
t.Fatalf("collapsed completion did not cover originals: %+v", rc.marked)
}
}
func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) {
// a failed send must not mark the reminder fired — it stays pending for
// the next tick to re-deliver. same instinct as "record after success."
@@ -486,11 +718,12 @@ func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) {
}
func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
// present reminder → [voice]. voice has no session → away = ntfy.
// present reminder → [voice]. voice has no session → away starts at ntfy.
voice := &fakeSink{err: ErrVoiceNoSession}
ntfy := &fakeSink{}
telegram := &fakeSink{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc})
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Reminders: rc})
rd := loop.ReminderDecision{
Reminder: store.Reminder{ID: 99, Status: "pending"},
@@ -505,6 +738,9 @@ func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
if len(ntfy.sends) != 1 || len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
t.Fatalf("reminder voice-no-session: want 1 ntfy, got ntfy=%d out=%+v", len(ntfy.sends), out)
}
if len(telegram.sends) != 0 {
t.Fatalf("successful ntfy fallback must stop before telegram, got %d sends", len(telegram.sends))
}
if len(rc.marked) != 1 || rc.marked[0].status != "fired" {
t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked)
}
@@ -641,6 +877,7 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
type outboxAttempt struct {
kind, rule string
reminderID int64
deliveryGroup string
channel, hash string
status string
begunAt, doneAt time.Time
@@ -659,13 +896,13 @@ type fakeOutbox struct {
completeErr error
}
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, channel, hash string, now time.Time) (int64, error) {
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, hash string, now time.Time) (int64, error) {
if f.beginErr != nil {
return 0, f.beginErr
}
f.nextID++
f.attempts = append(f.attempts, &outboxAttempt{
kind: kind, rule: rule, reminderID: reminderID, channel: channel, hash: hash,
kind: kind, rule: rule, reminderID: reminderID, deliveryGroup: deliveryGroup, channel: channel, hash: hash,
status: "pending", begunAt: now,
})
return f.nextID, nil
@@ -985,8 +1222,8 @@ func TestPanicInReminderSinkResolvesTheAttempt(t *testing.T) {
},
Body: "звонок", Summary: "звонок",
}, refNow())
if err != nil {
t.Fatalf("a panicking sink must not fail the dispatch: %v", err)
if err == nil {
t.Fatal("a panicking sole reminder sink must report delivery failure")
}
if len(out) != 0 {
t.Fatalf("nothing was delivered, want no dispatches, got %+v", out)
+2 -2
View File
@@ -122,7 +122,7 @@ func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
sink := &fakeSink{}
// the crash: intent recorded, no completion.
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
if err != nil {
t.Fatalf("begin: %v", err)
}
@@ -172,7 +172,7 @@ func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
st := openTestStore(t)
ctx := context.Background()
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
if err != nil {
t.Fatalf("begin: %v", err)
}
+42 -10
View File
@@ -37,6 +37,12 @@ import (
// the credential lives in the daemon's config (or a systemd credential),
// never in the binary.
type Config struct {
// Disabled keeps a written endpoint explicitly dark. This is distinct from
// an expanded-empty credential: the latter is a configuration error, while
// this flag records an operator decision to use another delivery reach until
// credentials are provisioned.
Disabled bool `json:"disabled,omitempty"`
// BaseURL — the ntfy server, no trailing path. Required.
BaseURL string `json:"base_url"`
@@ -71,23 +77,45 @@ type Sink struct {
hc *http.Client
}
// New validates the config and builds the sink. BaseURL and Topic are
// required; auth is optional (but deny-all servers reject unauthed publishes).
func New(cfg Config) (*Sink, error) {
if cfg.BaseURL == "" {
return nil, fmt.Errorf("ntfysink: BaseURL is required")
// Validate checks one configuration without constructing a client. A disabled
// block is the only state in which credentials may be empty. Maven's ntfy
// reach is private, and accepting an accidental anonymous configuration turns
// a missing environment variable into an endless 403 retry loop.
func Validate(cfg Config) error {
if cfg.Disabled {
return nil
}
if strings.TrimSpace(cfg.BaseURL) == "" {
return fmt.Errorf("ntfysink: BaseURL is required while enabled")
}
if _, err := url.Parse(cfg.BaseURL); err != nil {
return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
return fmt.Errorf("ntfysink: bad BaseURL: %w", err)
}
if cfg.Topic == "" {
return nil, fmt.Errorf("ntfysink: Topic is required")
if strings.TrimSpace(cfg.Topic) == "" {
return fmt.Errorf("ntfysink: Topic is required while enabled")
}
// Refuse rather than pick. Two credentials configured means someone
// intended one of them, and guessing which would send the other nowhere
// and leave a working config that is not the one they wrote.
if cfg.Token != "" && cfg.Username != "" {
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
if strings.TrimSpace(cfg.Token) != "" && (strings.TrimSpace(cfg.Username) != "" || cfg.Password != "") {
return fmt.Errorf("ntfysink: set Token or Username/Password, not both")
}
if strings.TrimSpace(cfg.Token) == "" {
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return fmt.Errorf("ntfysink: Token or Username/Password is required while enabled")
}
}
return nil
}
// New validates the config and builds the sink. Disabled configs belong at the
// daemon wiring boundary and cannot accidentally become live sinks.
func New(cfg Config) (*Sink, error) {
if cfg.Disabled {
return nil, fmt.Errorf("ntfysink: config is disabled")
}
if err := Validate(cfg); err != nil {
return nil, err
}
to := cfg.Timeout
if to == 0 {
@@ -127,6 +155,10 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w: ntfysink: credentials rejected (%d): %s",
delivery.ErrPermanent, resp.StatusCode, strings.TrimSpace(string(rb)))
}
return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
}
return nil
+61 -16
View File
@@ -2,6 +2,7 @@ package ntfysink
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -81,6 +82,10 @@ func reminderSendable(summary string) delivery.Sendable {
}
}
func tokenConfig(baseURL string) Config {
return Config{BaseURL: baseURL, Topic: "maven", Token: "scoped-write-token"}
}
// ----------------------------- config ---------------------------------------
func TestNewRejectsEmptyBaseURL(t *testing.T) {
@@ -98,7 +103,7 @@ func TestNewRejectsEmptyTopic(t *testing.T) {
}
func TestNewDefaultTimeout(t *testing.T) {
s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
s, err := New(tokenConfig("http://localhost:8085"))
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -107,6 +112,26 @@ func TestNewDefaultTimeout(t *testing.T) {
}
}
func TestNewRejectsAnonymousPublishing(t *testing.T) {
_, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
if err == nil {
t.Fatal("New accepted a topic with no credential")
}
if !strings.Contains(err.Error(), "required while enabled") {
t.Fatalf("error should identify the enabled reach: %v", err)
}
}
func TestValidateAllowsEmptyCredentialOnlyWhenDisabled(t *testing.T) {
cfg := Config{Disabled: true, BaseURL: "http://localhost:8085", Topic: "maven"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate disabled config: %v", err)
}
if _, err := New(cfg); err == nil {
t.Fatal("New built a live sink from a disabled config")
}
}
// ----------------------------- send shape ----------------------------------
func TestSendPostsToTopicPath(t *testing.T) {
@@ -114,7 +139,7 @@ func TestSendPostsToTopicPath(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, err := New(tokenConfig(srv.URL))
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -137,7 +162,7 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
t.Fatalf("Send: %v", err)
}
@@ -155,7 +180,7 @@ func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
defer srv.Close()
s := nudgeSendable(loop.Sev3, "")
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
if err := sink.Send(context.Background(), s); err != nil {
t.Fatalf("Send: %v", err)
}
@@ -173,7 +198,7 @@ func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
s := nudgeSendable(loop.Sev3, "")
s.Body = ""
s.RuleName = ""
@@ -209,18 +234,18 @@ func TestSendSetsBasicAuth(t *testing.T) {
}
}
func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
func TestTokenConfigSendsBearerAuth(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, _, auth, _, _ := rs.snapshot()
if auth != "" {
t.Fatalf("want no auth header, got %q", auth)
if auth != "Bearer scoped-write-token" {
t.Fatalf("want bearer auth header, got %q", auth)
}
}
@@ -260,7 +285,7 @@ func TestSendTitleIsMaven(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
@@ -277,7 +302,7 @@ func TestPrioritySev3IsHigh(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "4" {
@@ -290,7 +315,7 @@ func TestPrioritySev4IsMax(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "5" {
@@ -303,7 +328,7 @@ func TestPriorityReminderIsHigh(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
_ = sink.Send(context.Background(), reminderSendable("wake up"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "4" {
@@ -318,7 +343,7 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on 403")
@@ -326,6 +351,24 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
if !strings.Contains(err.Error(), "403") {
t.Fatalf("error should mention status 403, got: %v", err)
}
if !errors.Is(err, delivery.ErrPermanent) {
t.Fatalf("403 = %v; want delivery.ErrPermanent", err)
}
}
func TestSendServerFailureRemainsRetryable(t *testing.T) {
rs := newRecordingServer(t, http.StatusServiceUnavailable, `{"error":"temporarily unavailable"}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(tokenConfig(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on 503")
}
if errors.Is(err, delivery.ErrPermanent) {
t.Fatalf("503 was marked permanent: %v", err)
}
}
func TestSendContextCancelReturnsError(t *testing.T) {
@@ -333,7 +376,7 @@ func TestSendContextCancelReturnsError(t *testing.T) {
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
sink, _ := New(tokenConfig(srv.URL))
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down"))
@@ -343,7 +386,9 @@ func TestSendContextCancelReturnsError(t *testing.T) {
}
func TestSendConnectionRefusedReturnsError(t *testing.T) {
sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second})
cfg := tokenConfig("http://127.0.0.1:1")
cfg.Timeout = time.Second
sink, _ := New(cfg)
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on connection refused")
+39 -10
View File
@@ -51,6 +51,11 @@ const DefaultTimeout = 10 * time.Second
// config file; the bot token + chat id live in the daemon's config (or a
// systemd credential), never in the binary.
type Config struct {
// Disabled keeps a written Telegram block explicitly dark. A present block
// is otherwise live, so an expanded-empty credential is a configuration
// error rather than an implicit opt-out.
Disabled bool `json:"disabled,omitempty"`
// BotToken — the telegram bot token from BotFather. required. sent in the
// URL path (the only place telegram accepts it), not in the body.
BotToken string `json:"bot_token"`
@@ -91,27 +96,47 @@ type Sink struct {
base string // resolved BaseURL, no trailing slash
}
// New validates the config and builds the sink. BotToken and ChatID are
// required; Proxy and BaseURL are optional.
func New(cfg Config) (*Sink, error) {
if cfg.BotToken == "" {
return nil, fmt.Errorf("telegramsink: BotToken is required")
// Validate checks one configuration without constructing a client. A disabled
// block is valid and intentionally carries no credentials; every live block
// must carry both secrets and valid endpoint URLs.
func Validate(cfg Config) error {
if cfg.Disabled {
return nil
}
if cfg.ChatID == "" {
return nil, fmt.Errorf("telegramsink: ChatID is required")
if strings.TrimSpace(cfg.BotToken) == "" {
return fmt.Errorf("telegramsink: BotToken is required while enabled")
}
if strings.TrimSpace(cfg.ChatID) == "" {
return fmt.Errorf("telegramsink: ChatID is required while enabled")
}
base := cfg.BaseURL
if base == "" {
base = DefaultBaseURL
}
if _, err := url.Parse(base); err != nil {
return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
return fmt.Errorf("telegramsink: bad BaseURL: %w", err)
}
if cfg.Proxy != "" {
if _, err := url.Parse(cfg.Proxy); err != nil {
return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
return fmt.Errorf("telegramsink: bad Proxy: %w", err)
}
}
return nil
}
// New validates the config and builds the sink. Disabled configs belong at the
// wiring boundary and cannot accidentally become live sinks.
func New(cfg Config) (*Sink, error) {
if cfg.Disabled {
return nil, fmt.Errorf("telegramsink: config is disabled")
}
if err := Validate(cfg); err != nil {
return nil, err
}
base := cfg.BaseURL
if base == "" {
base = DefaultBaseURL
}
to := cfg.Timeout
if to == 0 {
to = DefaultTimeout
@@ -192,7 +217,11 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
var tr telegramResp
jsonErr := json.Unmarshal(rb, &tr)
if jsonErr == nil && !tr.Ok {
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
err := fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
if tr.ErrorCode == http.StatusUnauthorized || tr.ErrorCode == http.StatusForbidden {
return fmt.Errorf("%w: %v", delivery.ErrPermanent, err)
}
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
@@ -106,6 +106,16 @@ func TestNewRejectsEmptyChatID(t *testing.T) {
}
}
func TestValidateAllowsEmptySecretsOnlyWhenDisabled(t *testing.T) {
cfg := Config{Disabled: true}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate disabled config: %v", err)
}
if _, err := New(cfg); err == nil {
t.Fatal("New built a live sink from a disabled config")
}
}
func TestNewDefaultTimeout(t *testing.T) {
s, err := New(Config{BotToken: "123:abc", ChatID: "42"})
if err != nil {
@@ -289,6 +299,9 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
if !strings.Contains(err.Error(), "401") {
t.Fatalf("error should mention error_code 401, got: %v", err)
}
if !errors.Is(err, delivery.ErrPermanent) {
t.Fatalf("revoked bot credential must be permanent, got: %v", err)
}
}
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
@@ -525,9 +538,9 @@ func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) {
// ----------------------------- reminder same shape --------------------------
func TestReminderSendUsesSamePath(t *testing.T) {
// reminders away route to ntfy, not telegram — but if the daemon ever
// routes a reminder via telegram (per-reminder override), the sink must
// accept KindReminder undamaged. exercises the kind-agnostic contract.
// Telegram is the second away alternative for reminders. If ntfy is
// unavailable and the dispatcher falls through, the sink must accept
// KindReminder undamaged. exercises the kind-agnostic contract.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
+21 -15
View File
@@ -63,14 +63,15 @@ type Nudge struct {
// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
// reader; Completed is nil while the attempt is still pending.
type DeliveryAttempt struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Rule string `json:"rule,omitempty"`
ReminderID int64 `json:"reminder_id,omitempty"`
Channel string `json:"channel"`
Status string `json:"status"`
Created time.Time `json:"created"`
Completed *time.Time `json:"completed,omitempty"`
ID int64 `json:"id"`
Kind string `json:"kind"`
Rule string `json:"rule,omitempty"`
ReminderID int64 `json:"reminder_id,omitempty"`
DeliveryGroup string `json:"delivery_group,omitempty"`
Channel string `json:"channel"`
Status string `json:"status"`
Created time.Time `json:"created"`
Completed *time.Time `json:"completed,omitempty"`
}
// Note — a recall/preference item; ranked by embedding cosine on query.
@@ -85,13 +86,18 @@ type Note struct {
// Reminder — user-stated future intent; fires once or recurring (if cron set).
type Reminder struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"`
NextFireTs time.Time `json:"next_fire_ts"`
Payload string `json:"payload"`
Status string `json:"status"` // pending|fired|cancelled
Cron string `json:"cron"`
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"`
NextFireTs time.Time `json:"next_fire_ts"`
Payload string `json:"payload"`
Status string `json:"status"` // pending|fired|cancelled
Cron string `json:"cron"`
DeliveryGroup string `json:"delivery_group,omitempty"`
DeliveryAttempts int `json:"delivery_attempts,omitempty"`
NextAttemptTs time.Time `json:"next_attempt_ts,omitempty"`
DeliveryBlockedTs time.Time `json:"delivery_blocked_ts,omitempty"`
DeliveryBlockedError string `json:"delivery_blocked_error,omitempty"`
}
// Presence — the read the phraser / delivery modules need to decide channel
+3 -2
View File
@@ -42,8 +42,9 @@ var mapErrPairs = []struct {
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
// module can branch on an error is a decision, not a default.
var unmappedStoreErrors = map[string]string{
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
"ErrReminderPhrase": "daemon-internal delivery-state validation; modules neither cache reminder presentations nor branch on this verdict",
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
+13 -8
View File
@@ -149,7 +149,7 @@ func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) (
return mapRows(as, err, func(at store.DeliveryAttempt) DeliveryAttempt {
out := DeliveryAttempt{
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
Channel: at.Channel, Status: at.Status, Created: at.Created,
DeliveryGroup: at.DeliveryGroup, Channel: at.Channel, Status: at.Status, Created: at.Created,
}
if at.HasComplete {
t := at.Completed
@@ -352,13 +352,18 @@ func toTool(t store.Tool) Tool {
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
DeliveryGroup: r.DeliveryGroup,
DeliveryAttempts: r.DeliveryAttempts,
NextAttemptTs: r.NextAttemptTs,
DeliveryBlockedTs: r.DeliveryBlockedTs,
DeliveryBlockedError: r.DeliveryBlockedError,
}
}
+56 -9
View File
@@ -12,6 +12,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/kami/maven/internal/calendar"
@@ -258,16 +259,62 @@ func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool
}
// collapseReminders — when multiple reminders are due at once (e.g. after
// the daemon was offline), collapse them into a single digest reminder to
// avoid a burst of individual notifications. The synthetic digest (ID=0)
// carries the originals in Collapsed; the dispatcher completes them (mark
// fired / reschedule) only after the digest actually delivers, preserving
// the "failed send leaves the reminder pending" invariant.
// When 0 or 1 reminders are due, returns them unchanged.
// the daemon was offline), collapse them into digest reminders to avoid a
// burst of individual notifications. A previously phrased delivery group is
// kept intact and separate from newly-due reminders: otherwise one new row
// joining a failed bundle would force the old bundle through the model again.
// The synthetic digest (ID=0) carries the originals in Collapsed; the
// dispatcher completes them only after the digest actually delivers.
func collapseReminders(due []store.Reminder) []store.Reminder {
if len(due) <= 1 {
return due
}
// Every reminder without a delivery group is part of the new bundle for
// this tick. Persisted groups each retain their own bundle identity.
const ungrouped = "\x00"
type reminderGroup struct {
key string
reminders []store.Reminder
}
var groups []reminderGroup
byKey := make(map[string]int)
for _, r := range due {
key := r.DeliveryGroup
if key == "" {
key = ungrouped
}
i, ok := byKey[key]
if !ok {
i = len(groups)
byKey[key] = i
groups = append(groups, reminderGroup{key: key})
}
groups[i].reminders = append(groups[i].reminders, r)
}
// DueReminders was ordered globally. Grouping can move rows together, so
// restore earliest-first ordering between the resulting deliveries.
sort.SliceStable(groups, func(i, j int) bool {
a := groups[i].reminders[0]
b := groups[j].reminders[0]
if a.NextFireTs.Equal(b.NextFireTs) {
return a.ID < b.ID
}
return a.NextFireTs.Before(b.NextFireTs)
})
out := make([]store.Reminder, 0, len(groups))
for _, group := range groups {
if len(group.reminders) == 1 {
out = append(out, group.reminders[0])
continue
}
out = append(out, collapseReminderGroup(group.reminders))
}
return out
}
func collapseReminderGroup(due []store.Reminder) store.Reminder {
// Build a summary payload.
var items []string
earliest := due[0].FireTs
@@ -294,11 +341,11 @@ func collapseReminders(due []store.Reminder) []store.Reminder {
// Return a single synthetic digest reminder. ID=0 signals "digest" to
// the dispatcher, which completes the Collapsed originals on success.
return []store.Reminder{{
return store.Reminder{
ID: 0,
FireTs: earliest,
Payload: string(digestPayload),
Status: "pending",
Status: store.ReminderPending,
Collapsed: due,
}}
}
}
+33
View File
@@ -47,3 +47,36 @@ func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
t.Fatal("the rule must fire on a gathered per-monitor fact")
}
}
func TestCollapseRemindersKeepsPersistedBundleSeparateFromNewDueRows(t *testing.T) {
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
due := []store.Reminder{
{ID: 1, FireTs: now, NextFireTs: now, Payload: "one", DeliveryGroup: "reminder:old", PhraseBody: "old bundle"},
{ID: 2, FireTs: now.Add(time.Second), NextFireTs: now.Add(time.Second), Payload: "two", DeliveryGroup: "reminder:old", PhraseBody: "old bundle"},
{ID: 3, FireTs: now.Add(2 * time.Second), NextFireTs: now.Add(2 * time.Second), Payload: "three"},
}
got := collapseReminders(due)
if len(got) != 2 {
t.Fatalf("deliveries = %d, want old bundle plus new reminder: %+v", len(got), got)
}
if got[0].ID != 0 || len(got[0].Collapsed) != 2 || got[0].Collapsed[0].ID != 1 || got[0].Collapsed[1].ID != 2 {
t.Fatalf("old persisted bundle was changed: %+v", got[0])
}
if got[1].ID != 3 || len(got[1].Collapsed) != 0 {
t.Fatalf("new reminder joined the old phrase: %+v", got[1])
}
}
func TestCollapseRemindersMakesOneBundleForNewDueRows(t *testing.T) {
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
due := []store.Reminder{
{ID: 1, FireTs: now, NextFireTs: now, Payload: "one"},
{ID: 2, FireTs: now, NextFireTs: now, Payload: "two"},
{ID: 3, FireTs: now, NextFireTs: now, Payload: "three"},
}
got := collapseReminders(due)
if len(got) != 1 || got[0].ID != 0 || len(got[0].Collapsed) != 3 {
t.Fatalf("new reminders did not collapse together: %+v", got)
}
}
+18 -15
View File
@@ -28,14 +28,16 @@ const (
// send happens, so a crash between "sent externally" and "recorded" leaves a
// trace instead of silence. kind is "nudge" or "reminder"; rule is set for
// nudges, reminderID for reminders (the other left at its zero value).
// deliveryGroup is the exact persisted reminder occurrence or collapsed
// bundle; it is empty for nudges and legacy reminder attempts.
// bodyHash is an opaque caller-computed key (e.g. sha256 of channel+body) —
// stored for post-crash operator triage, not enforced as a uniqueness
// constraint (a rule/reminder legitimately re-sends across ticks).
func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error) {
func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, bodyHash string, now time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO delivery_attempts (kind, rule, reminder_id, channel, body_hash, status, created_ts)
VALUES (?, ?, ?, ?, ?, 'pending', ?)`,
kind, rule, reminderID, channel, bodyHash, now.UnixMilli())
`INSERT INTO delivery_attempts (kind, rule, reminder_id, delivery_group, channel, body_hash, status, created_ts)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
kind, rule, reminderID, deliveryGroup, channel, bodyHash, now.UnixMilli())
if err != nil {
return 0, fmt.Errorf("begin delivery attempt: %w", err)
}
@@ -90,15 +92,16 @@ func (s *Store) ReconcileStaleDeliveryAttempts(ctx context.Context, now time.Tim
// DeliveryAttempt — one row of the outbox, as a reader sees it.
type DeliveryAttempt struct {
ID int64
Kind string // nudge|reminder
Rule string // set for nudges
ReminderID int64 // set for reminders
Channel string
Status string // one of the Delivery* constants
Created time.Time
Completed time.Time // zero while pending
HasComplete bool
ID int64
Kind string // nudge|reminder
Rule string // set for nudges
ReminderID int64 // set for reminders
DeliveryGroup string // exact reminder occurrence/bundle; empty for nudges and legacy rows
Channel string
Status string // one of the Delivery* constants
Created time.Time
Completed time.Time // zero while pending
HasComplete bool
}
// ListDeliveryAttempts returns recent attempts, newest first. An empty status
@@ -117,7 +120,7 @@ func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit i
if limit <= 0 {
limit = 50
}
q := `SELECT id, kind, rule, reminder_id, channel, status, created_ts, completed_ts
q := `SELECT id, kind, rule, reminder_id, delivery_group, channel, status, created_ts, completed_ts
FROM delivery_attempts`
args := []any{}
if status != "" {
@@ -138,7 +141,7 @@ func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit i
var a DeliveryAttempt
var created int64
var completed *int64
if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.Channel, &a.Status, &created, &completed); err != nil {
if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.DeliveryGroup, &a.Channel, &a.Status, &created, &completed); err != nil {
return nil, fmt.Errorf("list delivery attempts: scan: %w", err)
}
a.Created = time.UnixMilli(created)
+4 -4
View File
@@ -15,7 +15,7 @@ func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
ctx := context.Background()
now := time.Now()
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "drop", "abc123", now)
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "drop", "abc123", now)
if err != nil {
t.Fatalf("BeginDeliveryAttempt: %v", err)
}
@@ -40,21 +40,21 @@ func TestListDeliveryAttempts(t *testing.T) {
s := newTestStore(t)
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "telegram", "h1", base)
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "telegram", "h1", base)
if err != nil {
t.Fatal(err)
}
if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil {
t.Fatal(err)
}
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "telegram", "h2", base.Add(time.Minute))
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "", "telegram", "h2", base.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "voice", "h3", base.Add(2*time.Minute)); err != nil {
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "reminder:test", "voice", "h3", base.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
+23
View File
@@ -351,6 +351,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
source TEXT NOT NULL DEFAULT '',
encoder_id TEXT NOT NULL DEFAULT ''
);`,
// #25 — durable reminder delivery state (V-651). A reminder can remain
// pending for a long time when every reachable transport is unhealthy. The
// phrased text belongs to that delivery occurrence, so retaining it here
// avoids spending the resident model again on every retry and across daemon
// restarts. next_attempt_ts makes transport failures wait on a bounded
// exponential backoff instead of retrying at the tick rate. delivery_group
// keeps the originals of a collapsed catch-up bundle together; a reminder
// that becomes due later must not make the old bundle get re-phrased.
`ALTER TABLE reminders ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
ALTER TABLE reminders ADD COLUMN phrase_body TEXT NOT NULL DEFAULT '';
ALTER TABLE reminders ADD COLUMN phrase_summary TEXT NOT NULL DEFAULT '';
ALTER TABLE reminders ADD COLUMN phrase_mood TEXT NOT NULL DEFAULT '';
ALTER TABLE reminders ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0);
ALTER TABLE reminders ADD COLUMN next_attempt_ts INTEGER;
ALTER TABLE delivery_attempts ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
ALTER TABLE reminders ADD COLUMN delivery_blocked_ts INTEGER;
ALTER TABLE reminders ADD COLUMN delivery_blocked_error TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_reminders_due
ON reminders (next_fire_ts, next_attempt_ts)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_reminder_group
ON delivery_attempts (delivery_group, status)
WHERE kind = 'reminder' AND delivery_group <> '';`,
}
// migrate applies every migration with a number greater than the DB's current
+453 -23
View File
@@ -22,6 +22,20 @@ type Reminder struct {
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.
@@ -62,18 +76,35 @@ 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")
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
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
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()
@@ -82,6 +113,12 @@ func scanReminder(sc scanner) (Reminder, error) {
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
}
@@ -111,11 +148,20 @@ func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cro
// 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 id, created_ts, fire_ts, next_fire_ts, payload, status, cron
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
FROM reminders
WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY next_fire_ts ASC`, now.UnixMilli())
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)
}
@@ -140,8 +186,7 @@ func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, er
// 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 id, created_ts, fire_ts, next_fire_ts, payload, status, cron
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`,
@@ -164,29 +209,42 @@ func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Rem
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
// pending→cancelled. Anything else is a programming error.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != "fired" && status != "cancelled" {
if status != ReminderFired && status != ReminderCancelled {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// pending → fired|cancelled only.
// 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|cancelled 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)
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
}
if current != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
// 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 id, created_ts, fire_ts, next_fire_ts, payload, status, cron
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)
@@ -203,6 +261,352 @@ func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
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,
@@ -229,8 +633,7 @@ func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time)
}
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
row := s.db.QueryRowContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
row := s.db.QueryRowContext(ctx, `SELECT `+reminderColumns+`
FROM reminders WHERE id = ?`, id)
r, err := scanReminder(row)
if err != nil {
@@ -243,9 +646,38 @@ func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Tim
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 fmt.Errorf("parse cron %q: %w", r.Cron, err)
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
@@ -255,9 +687,7 @@ func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Tim
next = sched.Next(next)
}
if next.IsZero() || !next.After(now) {
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
return err
return time.Time{}, nil
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id)
return err
return next, nil
}
+417
View File
@@ -0,0 +1,417 @@
package store
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
func TestReminderPhraseAndRetrySurviveRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "reminders.db")
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
id, err := s.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, "")
if err != nil {
t.Fatal(err)
}
due, err := s.DueReminders(ctx, now)
if err != nil || len(due) != 1 {
t.Fatalf("initial due reminders = %d, err=%v", len(due), err)
}
if err := s.CacheReminderPhrase(ctx, due, "reminder:one", "Позвони маме.", "Позвони маме", "warm"); err != nil {
t.Fatalf("cache phrase: %v", err)
}
if err := s.ScheduleReminderRetry(ctx, due, now); err != nil {
t.Fatalf("schedule retry: %v", err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
s, err = Open(ctx, path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
if got, err := s.DueReminders(ctx, now.Add(30*time.Second)); err != nil || len(got) != 0 {
t.Fatalf("during retry wait: got %d due, err=%v", len(got), err)
}
due, err = s.DueReminders(ctx, now.Add(ReminderRetryBase))
if err != nil || len(due) != 1 {
t.Fatalf("at retry time: got %d due, err=%v", len(due), err)
}
r := due[0]
if r.ID != id || r.DeliveryGroup != "reminder:one" || r.PhraseBody != "Позвони маме." ||
r.PhraseSummary != "Позвони маме" || r.PhraseMood != "warm" {
t.Fatalf("persisted delivery presentation = %+v", r)
}
if r.DeliveryAttempts != 1 {
t.Fatalf("attempts = %d, want 1", r.DeliveryAttempts)
}
if want := now.Add(ReminderRetryBase); !r.NextAttemptTs.Equal(want) {
t.Fatalf("next attempt = %s, want %s", r.NextAttemptTs, want)
}
}
func TestReminderRetryDelayIsExponentialAndBounded(t *testing.T) {
wants := []time.Duration{
time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute,
16 * time.Minute, 32 * time.Minute, time.Hour, time.Hour, time.Hour,
}
for i, want := range wants {
if got := ReminderRetryDelay(i + 1); got != want {
t.Errorf("attempt %d delay = %s, want %s", i+1, got, want)
}
}
}
func TestCollapsedReminderRetryStateMovesTogether(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
for _, text := range []string{"полить цветы", "позвонить врачу"} {
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
t.Fatal(err)
}
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 2 {
t.Fatalf("due originals = %d, err=%v", len(originals), err)
}
if err := s.CacheReminderPhrase(ctx, originals, "reminder:bundle", "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
t.Fatal(err)
}
if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
t.Fatal(err)
}
rows, err := s.ListReminders(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("list = %d, err=%v", len(rows), err)
}
for _, r := range rows {
if r.DeliveryGroup != "reminder:bundle" || r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
t.Errorf("bundle original did not move with group: %+v", r)
}
if !r.NextAttemptTs.Equal(now.Add(time.Minute)) {
t.Errorf("next attempt = %s, want %s", r.NextAttemptTs, now.Add(time.Minute))
}
}
}
func TestRecurringReminderClearsDeliveryStateForNextOccurrence(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
loc := time.FixedZone("MSK", 3*60*60)
fire := time.Date(2026, 8, 13, 9, 0, 0, 0, loc)
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
if err != nil {
t.Fatal(err)
}
due, err := s.DueReminders(ctx, fire)
if err != nil || len(due) != 1 {
t.Fatalf("due = %d, err=%v", len(due), err)
}
if err := s.CacheReminderPhrase(ctx, due, "reminder:occurrence", "Пора на стендап.", "Стендап", "neutral"); err != nil {
t.Fatal(err)
}
if err := s.ScheduleReminderRetry(ctx, due, fire); err != nil {
t.Fatal(err)
}
if err := s.rescheduleReminderIn(ctx, id, fire.Add(time.Minute), loc); err != nil {
t.Fatalf("reschedule: %v", err)
}
rows, err := s.ListReminders(ctx, 1)
if err != nil || len(rows) != 1 {
t.Fatalf("list = %d, err=%v", len(rows), err)
}
r := rows[0]
if r.Status != ReminderPending || r.DeliveryGroup != "" || r.PhraseBody != "" ||
r.PhraseSummary != "" || r.PhraseMood != "" || r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
t.Fatalf("next occurrence retained old delivery state: %+v", r)
}
want := time.Date(2026, 8, 14, 9, 0, 0, 0, loc)
if !r.NextFireTs.Equal(want) {
t.Fatalf("next fire = %s, want %s", r.NextFireTs.In(loc), want)
}
}
func TestReminderTerminalTransitionHasExactlyOneWinner(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
for iteration := 0; iteration < 40; iteration++ {
id, err := s.CreateReminder(ctx, now, "race", "")
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
var wg sync.WaitGroup
errs := make([]error, 2)
statuses := []string{ReminderFired, ReminderCancelled}
for i := range statuses {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
errs[i] = s.MarkReminder(ctx, id, statuses[i])
}(i)
}
close(start)
wg.Wait()
successes := 0
losers := 0
winner := ""
for i, err := range errs {
switch {
case err == nil:
successes++
winner = statuses[i]
case errors.Is(err, ErrReminderState):
losers++
default:
t.Fatalf("iteration %d transition %s: %v", iteration, statuses[i], err)
}
}
if successes != 1 || losers != 1 {
t.Fatalf("iteration %d: successes=%d losers=%d errors=%v", iteration, successes, losers, errs)
}
var stored string
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != winner {
t.Fatalf("iteration %d: stored %q, successful transition %q", iteration, stored, winner)
}
}
}
func TestAmbiguousCollapsedDeliveryIsNotAutomaticallyRepeated(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
for _, text := range []string{"полить цветы", "позвонить врачу"} {
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
t.Fatal(err)
}
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 2 {
t.Fatalf("due originals = %d, err=%v", len(originals), err)
}
const group = "reminder:ambiguous-bundle"
if err := s.CacheReminderPhrase(ctx, originals, group, "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
t.Fatal(err)
}
for i := range originals {
originals[i].DeliveryGroup = group
}
// Simulate a process dying after Begin and before it can record whether the
// external sink accepted the bundle. Synthetic reminder zero is never put
// in the outbox: a real original is the human-readable representative and
// the group is the occurrence identity shared by both originals.
attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
if err != nil {
t.Fatal(err)
}
if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
t.Fatalf("ambiguous delivered bundle was eligible for automatic repeat: due=%+v err=%v", due, err)
}
attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
if err != nil || len(attempts) != 1 {
t.Fatalf("attempts=%+v err=%v", attempts, err)
}
if attempts[0].ID != attemptID || attempts[0].Status != DeliveryUnknown || attempts[0].DeliveryGroup != group {
t.Fatalf("ambiguous occurrence lost its durable identity: %+v", attempts[0])
}
if err := s.ScheduleReminderRetry(ctx, originals, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
rows, err := s.ListReminders(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
for _, r := range rows {
if r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
t.Fatalf("ambiguous occurrence was scheduled for automatic retry: %+v", r)
}
}
}
func TestDefiniteFailedDeliveryRemainsRetryEligible(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", ""); err != nil {
t.Fatal(err)
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 1 {
t.Fatalf("due originals = %d, err=%v", len(originals), err)
}
const group = "reminder:definite-failure"
if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
t.Fatal(err)
}
id, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
if err != nil {
t.Fatal(err)
}
if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryFailed, now); err != nil {
t.Fatal(err)
}
if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 1 {
t.Fatalf("definite failure was not retry eligible: due=%+v err=%v", due, err)
}
}
func TestCollapsedCompletionRollsBackAsOneTransaction(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
for _, text := range []string{"first", "fail-second"} {
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
t.Fatal(err)
}
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 2 {
t.Fatalf("due originals = %d, err=%v", len(originals), err)
}
if _, err := s.db.ExecContext(ctx, `
CREATE TRIGGER fail_second_reminder_completion
BEFORE UPDATE OF status ON reminders
WHEN OLD.payload = 'fail-second'
BEGIN
SELECT RAISE(ABORT, 'injected second completion failure');
END`); err != nil {
t.Fatal(err)
}
if err := s.CompleteReminderDelivery(ctx, originals, now); err == nil {
t.Fatal("injected second-row failure did not fail the bundle completion")
}
rows, err := s.ListReminders(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
for _, r := range rows {
if r.Status != ReminderPending {
t.Fatalf("bundle completion partially committed reminder %d as %q", r.ID, r.Status)
}
}
}
func TestSuccessfulAttemptAndCollapsedCompletionCommitAtomically(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
for _, text := range []string{"first", "fail-second"} {
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
t.Fatal(err)
}
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 2 {
t.Fatalf("due originals = %d, err=%v", len(originals), err)
}
const group = "reminder:atomic-success"
if err := s.CacheReminderPhrase(ctx, originals, group, "Two reminders.", "Two reminders", "neutral"); err != nil {
t.Fatal(err)
}
for i := range originals {
originals[i].DeliveryGroup = group
}
attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
if err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, `
CREATE TRIGGER fail_atomic_second_completion
BEFORE UPDATE OF status ON reminders
WHEN OLD.payload = 'fail-second'
BEGIN
SELECT RAISE(ABORT, 'injected second completion failure');
END`); err != nil {
t.Fatal(err)
}
if err := s.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err == nil {
t.Fatal("injected reminder completion failure did not fail the local commit")
}
attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
if err != nil || len(attempts) != 1 || attempts[0].Status != DeliveryPending {
t.Fatalf("successful outbox marker committed without reminder state: attempts=%+v err=%v", attempts, err)
}
rows, err := s.ListReminders(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
for _, r := range rows {
if r.Status != ReminderPending {
t.Fatalf("atomic rollback partially completed reminder %d as %q", r.ID, r.Status)
}
}
if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
t.Fatalf("restart auto-replayed ambiguous accepted send: due=%+v err=%v", due, err)
}
}
func TestPermanentReminderFailureBlocksUntilDeliberatelyUnblocked(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
id, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", "")
if err != nil {
t.Fatal(err)
}
originals, err := s.DueReminders(ctx, now)
if err != nil || len(originals) != 1 {
t.Fatalf("due originals=%+v err=%v", originals, err)
}
const group = "reminder:revoked-token"
if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
t.Fatal(err)
}
originals[0].DeliveryGroup = group
if err := s.BlockReminderDelivery(ctx, originals, now, "ntfy credentials rejected"); err != nil {
t.Fatal(err)
}
if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
t.Fatal(err)
}
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
t.Fatalf("blocked reminder retried automatically: due=%+v err=%v", due, err)
}
rows, err := s.ListReminders(ctx, 1)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if rows[0].DeliveryBlockedTs.IsZero() || rows[0].DeliveryBlockedError != "ntfy credentials rejected" ||
rows[0].DeliveryAttempts != 0 || !rows[0].NextAttemptTs.IsZero() {
t.Fatalf("blocked state is not visible/durable: %+v", rows[0])
}
if err := s.UnblockReminderDelivery(ctx, id); err != nil {
t.Fatal(err)
}
due, err := s.DueReminders(ctx, now)
if err != nil || len(due) != 1 || due[0].ID != id || !due[0].DeliveryBlockedTs.IsZero() || due[0].DeliveryBlockedError != "" {
t.Fatalf("deliberate unblock did not restore eligibility: due=%+v err=%v", due, err)
}
}