delivery log: measure the sweep before deciding its fate (audit H3, 0007)

Migration 0007 (DDL only): delivery_log_days, a row per UTC day, kept a
year, shown nowhere - read off a copy of the database after a month by the
decision rule written in the audit plan.

- reconcile_closed: the sweep adds the rows it actually closed.
- reconcile_contradicted: store.UpdateStatus, in one transaction, counts the
  rows with reason "reconcile" a delivery line now speaks of, writes the
  log's status and clears the reason - so a re-read line counts once.
- tailer_blind_seconds: follow() tracks stretches in which the file is gone
  or reopening fails (logtail.blindness); stored when a stretch ends and
  every minute while it lasts; under 3 s not counted (the rotation's tick
  between rename and create loses nothing).
- queued_over_lifetime: every sweep overwrites the day's level with the rows
  queued past LongestLifetime + MaximalBackoff (a message Postfix still
  works on stays queued until its first retry after the lifetime).
- retentionLoop prunes days older than a year.

Tests: store (sums per day, the level overwritten, under a second nothing,
prune; a contradicted sweep counted once and its reason cleared; count of
queued rows); logtail (the sweep counts what it closed; countStuck records
the level; blindness - a one-tick gap not counted, 75 s stored as 60 + 15
from where the last part ended). Schema tests at user_version 7.

schema-migrations.md: chain head 7 (it still said 5 since 2.4.0), row 7.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 10/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows); the server run of the stage
follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:20:44 +03:00
co-authored by Claude Opus 5.5
parent 9adb44845c
commit ffefa9d6df
13 changed files with 459 additions and 14 deletions
+32
View File
@@ -132,6 +132,38 @@ func TestCloseUnreportedClosesOnlyQueuedRows(t *testing.T) {
}
}
// A delivery line about a row the sweep closed proves the sweep guessed what
// the log could have said: the log's status wins, the reason goes, and the day
// counts one contradiction - once, however often the line is read again.
func TestUpdateStatusCountsAContradictedSweep(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
t.Fatal(err)
}
if _, err := st.CloseUnreported("Q1", "a@example.net"); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if n, err := st.UpdateStatus("Q1", "A@example.net", StatusSent); err != nil || n != 1 {
t.Fatalf("UpdateStatus = %d, %v", n, err)
}
}
var status, reason string
if err := st.db.QueryRow(`SELECT status, reason FROM send_log WHERE queue_id = 'Q1'`).Scan(&status, &reason); err != nil {
t.Fatal(err)
}
if status != StatusSent || reason != "" {
t.Fatalf("status %q reason %q, want sent and no reason", status, reason)
}
days, err := st.DeliveryLogDays()
if err != nil {
t.Fatal(err)
}
if len(days) != 1 || days[0].Contradicted != 1 {
t.Fatalf("days = %+v, want one day with one contradiction", days)
}
}
// The reconcile sweep asks for the rows old enough that Postfix should have
// reported on them by now. A message accepted moments ago is simply in flight,
// and one the milter refused never reached the queue at all, so neither is the