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>
114 lines
4.3 KiB
Go
114 lines
4.3 KiB
Go
// Retention for the data model in AGENTS.md §7: "keep message bodies for one
|
|
// month. Logs are append-only and size-capped."
|
|
//
|
|
// Retention never touches message metadata. It only blanks `messages.body_text`
|
|
// once a message is older than the window, and it trims the oldest rows from
|
|
// the append-only `actions_log` so that log cannot grow without bound. It is a
|
|
// bounded, explicit job (the CLI `-retain` flag), not something the HTTP server
|
|
// runs on a request.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// DefaultRetentionDays is the AGENTS.md §7 body window: one month.
|
|
const DefaultRetentionDays = 30
|
|
|
|
// DefaultActionsLogMaxRows caps the append-only action log. The exact number is
|
|
// a policy choice (see docs/retention.md); it is large enough to hold a long
|
|
// history of triage actions and small enough that the SQLite file stays modest.
|
|
const DefaultActionsLogMaxRows = 50000
|
|
|
|
// RetentionPolicy configures one retention pass. A non-positive field disables
|
|
// that part of the pass, so a dry run can, for example, cap only the log.
|
|
type RetentionPolicy struct {
|
|
// BodyMaxAge is how long a message body is kept. Bodies of messages with a
|
|
// received_at older than now-BodyMaxAge are set to NULL. Non-positive
|
|
// disables body clearing.
|
|
BodyMaxAge time.Duration
|
|
// ActionsLogMaxRows is the number of newest actions_log rows to keep. Older
|
|
// rows are deleted. Non-positive disables the cap.
|
|
ActionsLogMaxRows int
|
|
}
|
|
|
|
// RetentionResult reports what one retention pass changed.
|
|
type RetentionResult struct {
|
|
// BodiesCleared is the number of messages whose body_text was blanked.
|
|
BodiesCleared int64
|
|
// ActionsDeleted is the number of oldest actions_log rows removed.
|
|
ActionsDeleted int64
|
|
// BodyCutoff is the received_at cutoff used for body clearing (UTC). It is
|
|
// the zero time when body clearing is disabled.
|
|
BodyCutoff time.Time
|
|
}
|
|
|
|
// Retain enforces the retention policy in a single transaction so a partial
|
|
// 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)
|
|
if err != nil {
|
|
return res, fmt.Errorf("begin retention transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if p.BodyMaxAge > 0 {
|
|
res.BodyCutoff = time.Now().UTC().Add(-p.BodyMaxAge)
|
|
// received_at is stored in sqlTimeLayout (UTC), so compare with the
|
|
// same format. body_text is already NULL or empty for a cleared row.
|
|
r, err := tx.ExecContext(ctx, `
|
|
UPDATE messages
|
|
SET body_text = NULL
|
|
WHERE body_text IS NOT NULL AND body_text <> '' AND received_at < ?`,
|
|
res.BodyCutoff.Format(sqlTimeLayout))
|
|
if err != nil {
|
|
return res, fmt.Errorf("clear message bodies older than %s: %w", p.BodyMaxAge, err)
|
|
}
|
|
if res.BodiesCleared, err = r.RowsAffected(); err != nil {
|
|
return res, fmt.Errorf("count cleared message bodies: %w", err)
|
|
}
|
|
}
|
|
|
|
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.
|
|
r, err := tx.ExecContext(ctx, `
|
|
DELETE FROM actions_log
|
|
WHERE id NOT IN (
|
|
SELECT id FROM actions_log ORDER BY id DESC LIMIT ?
|
|
)`, p.ActionsLogMaxRows)
|
|
if err != nil {
|
|
return res, fmt.Errorf("cap actions_log at %d rows: %w", p.ActionsLogMaxRows, err)
|
|
}
|
|
if res.ActionsDeleted, err = r.RowsAffected(); err != nil {
|
|
return res, fmt.Errorf("count deleted action log rows: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return res, fmt.Errorf("commit retention: %w", err)
|
|
}
|
|
return res, nil
|
|
}
|