Files
selfpost/internal/logtail/retention_test.go
T
mixandClaude Opus 5.5 65d916f26a
test / test (push) Canceled after 0s
logtail: a refusal the panel did not get to write is written from mail.log (stage 2b)
The sender check answers 550 5.7.1 and writes the refused row through a
buffer a moment later; a panel stopped in between (a rollout, a kill) left
an application refused and a log that did not know it. A refusal at MAIL
FROM needs nothing: the journal milter writes its row before it answers.

- logtail.refusalLines remembers each queue id's login from smtpd's
  "<id>: client=..., sasl_username=<login>" (4096 ids at most) and, on
  cleanup's "<id>: milter-reject: END-OF-MESSAGE ...: 5.7.1 From: <line> -
  <why>; from=<...> to=<...>", waits 30 s and hands it to
  store.RecoverRefusal - an INSERT ... WHERE NOT EXISTS a refused row of that
  queue id within two minutes: the row the panel wrote, or one recovered
  before, means nothing is written. It is read from the saved offset on, so
  a kill is covered as well as a clean stop - which a flush of the buffer at
  shutdown would not.
- Migration 0009 (not deployed yet) gains send_log.recovered; the row is
  written with the line's time, the login's domain id, reason "from", the
  From: line as the check printed it (to the last " - " before ";").
- The message page: a fact "Written: from mail.log after a restart: the
  panel stopped before it wrote the refusal".

Tests: store (not written when the panel wrote it; written once however
often); logtail (the login from client=, the From: line with a " - " in it,
nothing before the wait is over); view (the fact). e2e
refusal_recovered_from_the_log: a From: refusal, the panel stopped, its row
deleted and the tailer's saved place moved before the message's first line
(python3 in the container, as the panel's user - a kill cannot be timed to
that moment from outside); started again, the row is back, recovered, once.

Also fixed: handlers TestMessagePageOfARefusedMessage still expected the
old sentence; it failed on the server after e166a2e, whose run showed only
the schema failures - now it wants the new one.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 26/62.
Evidence on the test server for this tree but the handler-test fix: make e2e
exit 0 (the new case 37 s); go test 22/23 ok, the 23rd that test - fixed,
passes locally; the full run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:09:05 +03:00

109 lines
3.1 KiB
Go

package logtail
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
type retentionProbeStore struct {
mu sync.Mutex
cutoff time.Time
}
func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error) {
return 0, nil
}
func (s *retentionProbeStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
return nil, nil
}
func (s *retentionProbeStore) CloseUnreported(int64) (int64, error) {
return 0, nil
}
func (s *retentionProbeStore) AddReconcileMissed(time.Time, int64) error { return nil }
func (s *retentionProbeStore) AddReconcileClosed(time.Time, int64) error { return nil }
func (s *retentionProbeStore) AddTailerBlind(time.Time, time.Duration) error { return nil }
func (s *retentionProbeStore) CountQueuedOlderThan(time.Time) (int64, error) { return 0, nil }
func (s *retentionProbeStore) SetQueuedOverLifetime(time.Time, int64) error { return nil }
func (s *retentionProbeStore) PruneDeliveryLogDays(time.Time) error { return nil }
func (s *retentionProbeStore) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
s.mu.Lock()
s.cutoff = cutoff
s.mu.Unlock()
return 0, nil
}
func (s *retentionProbeStore) InsertSendLogLines([]store.SendLogLine, bool) (int, error) {
return 0, nil
}
func (s *retentionProbeStore) PruneSendLogLines(time.Time) (int64, error) { return 0, nil }
func (s *retentionProbeStore) LogtailState(string) (store.LogtailState, bool, error) {
return store.LogtailState{}, false, nil
}
func (s *retentionProbeStore) SaveLogtailState(string, store.LogtailState) error {
return nil
}
func (s *retentionProbeStore) lastCutoff() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.cutoff
}
// retentionLoop must re-read the configured window every cycle so a panel
// change takes effect without restarting the process.
func TestRetentionLoopUsesUpdatedValue(t *testing.T) {
old := retentionInterval
retentionInterval = 20 * time.Millisecond
t.Cleanup(func() { retentionInterval = old })
var days atomic.Int32
days.Store(30)
st := &retentionProbeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go retentionLoop(ctx, st, func() int { return int(days.Load()) })
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 31*24*time.Hour && age > 29*24*time.Hour {
break
}
time.Sleep(5 * time.Millisecond)
}
if age := time.Since(st.lastCutoff()); age < 29*24*time.Hour || age > 31*24*time.Hour {
t.Fatalf("first prune cutoff age %v, want about 30 days", age)
}
days.Store(7)
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 8*24*time.Hour && age > 6*24*time.Hour {
return
}
time.Sleep(5 * time.Millisecond)
}
age := time.Since(st.lastCutoff())
t.Fatalf("second prune cutoff age %v, want about 7 days", age)
}
func (s *retentionProbeStore) AttachRefusalLine(string, string, time.Time, string) (bool, error) {
return true, nil
}
func (s *retentionProbeStore) RecoverRefusal(store.SendLogEntry, time.Time) (bool, error) {
return false, nil
}