retention: write the audit row before the actions_log cap

RetainWithAudit appends the 'retain' audit row inside the same transaction and
before the cap, so the cap counts it. After a run, actions_log holds at most
EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS rows, including the audit row. Adds a test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 10:45:16 +11:00
parent 17d605dfd5
commit 3766ce1f3f
3 changed files with 50 additions and 7 deletions

View File

@@ -49,6 +49,14 @@ type RetentionResult struct {
// pass never leaves the database in a mixed state. It is safe to run on a
// schedule: every statement is idempotent.
func (s *Store) Retain(ctx context.Context, p RetentionPolicy) (RetentionResult, error) {
return s.RetainWithAudit(ctx, p, "", "")
}
// RetainWithAudit is Retain plus one actions_log row (FR-13), appended inside
// the same transaction before the cap is applied so the audit row is itself
// counted by the cap. An empty detail writes no audit row. This keeps the
// action log bounded even though every run records that it ran.
func (s *Store) RetainWithAudit(ctx context.Context, p RetentionPolicy, actor, detail string) (RetentionResult, error) {
var res RetentionResult
tx, err := s.db.BeginTx(ctx, nil)
@@ -74,6 +82,14 @@ WHERE body_text IS NOT NULL AND body_text <> '' AND received_at < ?`,
}
}
if detail != "" {
if _, err := tx.ExecContext(ctx, `
INSERT INTO actions_log (message_id, action, actor, detail)
VALUES (NULL, 'retain', ?, ?)`, nullString(actor), detail); err != nil {
return res, fmt.Errorf("log retention action: %w", err)
}
}
if p.ActionsLogMaxRows > 0 {
// Keep the newest N rows and delete the rest. id is monotonic, so it
// is the stable ordering for an append-only table.

View File

@@ -121,6 +121,35 @@ func TestRetainCapsActionsLog(t *testing.T) {
}
}
func TestRetainWithAuditCountsItsOwnRow(t *testing.T) {
s, ctx := testStore(t)
for i := 0; i < 5; i++ {
if err := s.LogAction(ctx, "fetch", "scheduler", "pass"); err != nil {
t.Fatalf("LogAction() %d error = %v", i, err)
}
}
res, err := s.RetainWithAudit(ctx, RetentionPolicy{ActionsLogMaxRows: 3}, "scheduler", "bodies_cleared=0")
if err != nil {
t.Fatalf("RetainWithAudit() error = %v", err)
}
// 5 existing + 1 audit = 6; keep 3, so 3 are deleted.
if res.ActionsDeleted != 3 {
t.Errorf("ActionsDeleted = %d, want 3", res.ActionsDeleted)
}
entries, err := s.RecentActions(ctx, 100)
if err != nil {
t.Fatalf("RecentActions() error = %v", err)
}
if len(entries) != 3 {
t.Fatalf("actions_log rows = %d, want 3 (cap counts the audit row)", len(entries))
}
if entries[0].Action != "retain" {
t.Errorf("newest row action = %q, want retain", entries[0].Action)
}
}
func TestRetainDisabledPolicyIsNoOp(t *testing.T) {
s, ctx := testStore(t)