store: the first repeat-til-ack send, and a digest entry that expired unswept (V-617)

LastSent scanned MAX(sent_at) over an empty ack_sends into a bare int64, so
the ordinary "nothing sent yet" case came back as a scan error rather than the
zero time its doc promises. ack_sends is written only by MarkSent, and MarkSent
runs only after a repeat has gone out, so every rule's FIRST repeat read an
empty table — and RepeatUnacked aborts its whole sweep on that error. The
repeat-til-ack loop could never take its first step. Scans into a NullInt64,
the same way OldestPendingTelegram already does two files over.

EnqueueDigestEntry deduped against any row still marked pending, including one
already past its expires_ts. The tick enqueues before it sweeps, so a suppressed
nudge arriving on the tick after an expiry was told deduped=true against an
entry PendingDigestEntries will never hand back: the caller drops the phrasing
it just paid the LLM for and nothing reaches the bundle. The dedupe now carries
the same expiry test the read side does. Its lookup also stops treating a real
read failure as "nothing there".
This commit is contained in:
2026-08-06 04:50:07 +04:00
parent 0b1efe4911
commit 76d123edf3
4 changed files with 131 additions and 6 deletions
+9 -3
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"database/sql"
"fmt"
"time"
)
@@ -38,16 +39,21 @@ func (s *Store) MarkSent(ctx context.Context, key string, ts time.Time) error {
// send goes through RecordNudge, not MarkSent, so the first MarkSent comes on
// the repeat path — LastSent may legitimately be zero until then).
func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
var millis int64
// MAX over an empty set is one row holding NULL, not zero rows, so this
// scans into a NullInt64 — the same trap OldestPendingTelegram spells out.
// A bare int64 turned the ordinary "nothing sent yet" case into a scan
// error, and RepeatUnacked aborts its whole sweep on one, so the first
// repeat could never go out for any rule.
var millis sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT MAX(sent_at) FROM ack_sends WHERE rule = ?`, key).Scan(&millis)
if err != nil {
return time.Time{}, fmt.Errorf("last sent %s: %w", key, err)
}
if millis == 0 {
if !millis.Valid {
return time.Time{}, nil
}
return time.UnixMilli(millis).UTC(), nil
return time.UnixMilli(millis.Int64).UTC(), nil
}
// MarkAcked marks ALL pending telegram nudges for the rule as "acted" —