From 4c19ce29cab75477a3bd3ff665e6c55e6fa8b7ca Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 17:39:57 +0800 Subject: [PATCH] feat(jobs): acp_events_purge runner (retention-based cron cleanup) --- internal/jobs/runners/acp_events_purge.go | 41 +++++++++++++ .../jobs/runners/acp_events_purge_test.go | 60 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 internal/jobs/runners/acp_events_purge.go create mode 100644 internal/jobs/runners/acp_events_purge_test.go diff --git a/internal/jobs/runners/acp_events_purge.go b/internal/jobs/runners/acp_events_purge.go new file mode 100644 index 0000000..e44d394 --- /dev/null +++ b/internal/jobs/runners/acp_events_purge.go @@ -0,0 +1,41 @@ +package runners + +import ( + "context" + "log/slog" + "time" +) + +// AcpEventsPurgeRepo is the minimal repo surface this runner needs. +// internal/acp.Repository satisfies it via an adapter in app.go. +type AcpEventsPurgeRepo interface { + PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) +} + +// AcpEventsPurge deletes acp_events older than retention. Periodic; idempotent. +type AcpEventsPurge struct { + repo AcpEventsPurgeRepo + retention time.Duration + log *slog.Logger +} + +// NewAcpEventsPurge builds the purger. +func NewAcpEventsPurge(repo AcpEventsPurgeRepo, retention time.Duration, log *slog.Logger) *AcpEventsPurge { + if log == nil { + log = slog.Default() + } + return &AcpEventsPurge{repo: repo, retention: retention, log: log} +} + +// Run purges once. +func (r *AcpEventsPurge) Run(ctx context.Context) { + cutoff := time.Now().Add(-r.retention) + rows, err := r.repo.PurgeEventsBefore(ctx, cutoff) + if err != nil { + r.log.Error("acp_events.purge", "err", err.Error()) + return + } + if rows > 0 { + r.log.Info("acp_events.purged", "count", rows, "before", cutoff.UTC().Format(time.RFC3339)) + } +} diff --git a/internal/jobs/runners/acp_events_purge_test.go b/internal/jobs/runners/acp_events_purge_test.go new file mode 100644 index 0000000..07265f7 --- /dev/null +++ b/internal/jobs/runners/acp_events_purge_test.go @@ -0,0 +1,60 @@ +package runners_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" +) + +type fakeAcpEventsPurgeRepo struct { + purged int64 + purgeErr error + capturedCutoff time.Time + called bool +} + +func (f *fakeAcpEventsPurgeRepo) PurgeEventsBefore(_ context.Context, before time.Time) (int64, error) { + f.called = true + f.capturedCutoff = before + if f.purgeErr != nil { + return 0, f.purgeErr + } + return f.purged, nil +} + +func TestAcpEventsPurge_RunCallsPurgeWithCorrectCutoff(t *testing.T) { + t.Parallel() + repo := &fakeAcpEventsPurgeRepo{purged: 5} + retention := 168 * time.Hour + r := runners.NewAcpEventsPurge(repo, retention, discardLogger()) + before := time.Now() + r.Run(context.Background()) + after := time.Now() + require.True(t, repo.called) + expectedFloor := before.Add(-retention).Add(-time.Second) + expectedCeil := after.Add(-retention).Add(time.Second) + assert.True(t, !repo.capturedCutoff.Before(expectedFloor) && !repo.capturedCutoff.After(expectedCeil), + "cutoff %s outside [%s, %s]", repo.capturedCutoff, expectedFloor, expectedCeil) +} + +func TestAcpEventsPurge_RunErrorDoesNotPanic(t *testing.T) { + t.Parallel() + repo := &fakeAcpEventsPurgeRepo{purgeErr: errors.New("db down")} + r := runners.NewAcpEventsPurge(repo, time.Hour, discardLogger()) + require.NotPanics(t, func() { r.Run(context.Background()) }) + assert.True(t, repo.called) +} + +func TestAcpEventsPurge_NoRowsIsNoOp(t *testing.T) { + t.Parallel() + repo := &fakeAcpEventsPurgeRepo{purged: 0} + r := runners.NewAcpEventsPurge(repo, time.Hour, discardLogger()) + require.NotPanics(t, func() { r.Run(context.Background()) }) + assert.True(t, repo.called) +}