retention: compose the audit detail in the store with the real body count

RetainWithAudit now takes only the actor; the store builds the detail after
body clearing so bodies_cleared is a real number, and still writes the row
before the cap.

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

View File

@@ -346,9 +346,7 @@ func runRetain(cfg *config.Config, logger *slog.Logger) error {
} }
// The audit row is written inside the same transaction, before the cap, so // The audit row is written inside the same transaction, before the cap, so
// the cap counts it. This is what keeps actions_log bounded across runs. // the cap counts it. This is what keeps actions_log bounded across runs.
detail := fmt.Sprintf("bodies_cleared=%%d retention_days=%d max_rows=%d", res, err := st.RetainWithAudit(ctx, policy, "scheduler")
cfg.RetentionDays, cfg.ActionsLogMaxRows)
res, err := st.RetainWithAudit(ctx, policy, "scheduler", detail)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -49,14 +49,19 @@ type RetentionResult struct {
// pass never leaves the database in a mixed state. It is safe to run on a // pass never leaves the database in a mixed state. It is safe to run on a
// schedule: every statement is idempotent. // schedule: every statement is idempotent.
func (s *Store) Retain(ctx context.Context, p RetentionPolicy) (RetentionResult, error) { func (s *Store) Retain(ctx context.Context, p RetentionPolicy) (RetentionResult, error) {
return s.RetainWithAudit(ctx, p, "", "") return s.retain(ctx, p, false, "")
} }
// RetainWithAudit is Retain plus one actions_log row (FR-13), appended inside // RetainWithAudit is Retain plus one 'retain' row appended to actions_log
// the same transaction before the cap is applied so the audit row is itself // (FR-13). The row is written inside the same transaction before the cap is
// counted by the cap. An empty detail writes no audit row. This keeps the // applied, so the cap counts it and the log stays bounded even though every
// action log bounded even though every run records that it ran. // run records that it ran.
func (s *Store) RetainWithAudit(ctx context.Context, p RetentionPolicy, actor, detail string) (RetentionResult, error) { func (s *Store) RetainWithAudit(ctx context.Context, p RetentionPolicy, actor string) (RetentionResult, error) {
return s.retain(ctx, p, true, actor)
}
// retain is the shared implementation. audit=false writes no audit row.
func (s *Store) retain(ctx context.Context, p RetentionPolicy, audit bool, actor string) (RetentionResult, error) {
var res RetentionResult var res RetentionResult
tx, err := s.db.BeginTx(ctx, nil) tx, err := s.db.BeginTx(ctx, nil)
@@ -82,7 +87,9 @@ WHERE body_text IS NOT NULL AND body_text <> '' AND received_at < ?`,
} }
} }
if detail != "" { if audit {
detail := fmt.Sprintf("retention_days=%d max_rows=%d bodies_cleared=%d",
int(p.BodyMaxAge.Hours()/24), p.ActionsLogMaxRows, res.BodiesCleared)
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO actions_log (message_id, action, actor, detail) INSERT INTO actions_log (message_id, action, actor, detail)
VALUES (NULL, 'retain', ?, ?)`, nullString(actor), detail); err != nil { VALUES (NULL, 'retain', ?, ?)`, nullString(actor), detail); err != nil {

View File

@@ -130,7 +130,7 @@ func TestRetainWithAuditCountsItsOwnRow(t *testing.T) {
} }
} }
res, err := s.RetainWithAudit(ctx, RetentionPolicy{ActionsLogMaxRows: 3}, "scheduler", "bodies_cleared=0") res, err := s.RetainWithAudit(ctx, RetentionPolicy{ActionsLogMaxRows: 3}, "scheduler")
if err != nil { if err != nil {
t.Fatalf("RetainWithAudit() error = %v", err) t.Fatalf("RetainWithAudit() error = %v", err)
} }