logtail: the sweep closes only queued rows, with reason "reconcile" (audit H3)
reconcile() closed a row through UpdateStatus (WHERE queue_id AND to_addr), which could overwrite a "sent" the tailer wrote between the listing and the close, and rewrite an old row of a reused queue id. store.CloseUnreported sets status bounced and reason "reconcile" (store.ReasonReconcile; the 0006 column, no schema change) WHERE ... AND status = 'queued'; the sweep counts rows actually closed. Tests: store - a queued row is closed with the reason, a sent row is left alone (0 rows); logtail - the sweep's close goes through CloseUnreported. CHANGELOG [Unreleased], audit plan line ticked, roadmap 9/61. Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail ok but for TestFollowTailsAndRotates (Windows rename of an open file, fails here always); 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:
@@ -13,6 +13,10 @@ const (
|
||||
ReasonFrom = "from" // the From: line is not the application's
|
||||
)
|
||||
|
||||
// ReasonReconcile marks a "bounced" row the reconcile sweep closed (send_log.reason):
|
||||
// its message left Postfix's queue with no delivery line ever read for it.
|
||||
const ReasonReconcile = "reconcile"
|
||||
|
||||
// Settings keys of the sender check's statistics.
|
||||
const (
|
||||
// settingSenderCheckSince is the first UTC day the statistics cover.
|
||||
|
||||
@@ -97,6 +97,25 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CloseUnreported is the reconcile sweep's close of one row: "bounced", marked
|
||||
// reason "reconcile" so it is known who decided it (architecture.md § Log
|
||||
// tailer). Only a row still "queued" is closed - a result the log wrote an
|
||||
// instant earlier stands, and a months-old row of a reused queue id is not
|
||||
// rewritten (audit 2026-10-12, H3). It returns the number of rows closed.
|
||||
func (s *Store) CloseUnreported(queueID, recipient string) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE send_log SET status = ?, reason = ?, updated_at = ?
|
||||
WHERE queue_id = ? AND to_addr = ? COLLATE NOCASE AND status = ?`,
|
||||
StatusBounced, ReasonReconcile, now, queueID, recipient, StatusQueued,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("close unreported send_log row: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// QueuedDelivery is a send-log row still waiting for a delivery result,
|
||||
// reduced to what the log-tailer's reconcile sweep needs to look it up in the
|
||||
// Postfix queue and, failing that, to close it (architecture.md § Log tailer).
|
||||
|
||||
@@ -97,6 +97,41 @@ func TestUpdateStatusRecipientCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep closes only what is still queued, and says it was the sweep: a
|
||||
// status the log wrote stands, whatever the sweep believed when it listed the
|
||||
// row.
|
||||
func TestCloseUnreportedClosesOnlyQueuedRows(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, e := range []SendLogEntry{{QueueID: "Q1", To: "Lost@example.net"}, {QueueID: "Q2", To: "done@example.net"}} {
|
||||
if err := st.InsertQueued(e); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := st.UpdateStatus("Q2", "done@example.net", StatusSent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n, err := st.CloseUnreported("Q1", "lost@example.net"); err != nil || n != 1 {
|
||||
t.Fatalf("CloseUnreported of a queued row = %d, %v; want 1", n, err)
|
||||
}
|
||||
if n, err := st.CloseUnreported("Q2", "done@example.net"); err != nil || n != 0 {
|
||||
t.Fatalf("CloseUnreported of a sent row = %d, %v; want 0", n, err)
|
||||
}
|
||||
|
||||
var status, reason string
|
||||
for _, c := range []struct{ id, status, reason string }{
|
||||
{"Q1", StatusBounced, ReasonReconcile},
|
||||
{"Q2", StatusSent, ""},
|
||||
} {
|
||||
if err := st.db.QueryRow(`SELECT status, reason FROM send_log WHERE queue_id = ?`, c.id).Scan(&status, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != c.status || reason != c.reason {
|
||||
t.Errorf("%s: status %q reason %q, want %q %q", c.id, status, reason, c.status, c.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user