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:
+24
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(¤t)
|
||||
err = s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user