feat: purge boundary logs past retention (#24815)

Add a periodic purge job for `boundary_logs` rows past their retention
threshold, following the same pattern as the existing audit log and
connection log purge jobs in `dbpurge`.

Expose a `--boundary-log-retention` deployment flag (env
`CODER_BOUNDARY_LOG_RETENTION`, YAML `retention.boundary_logs`). Default
is `0` (keep indefinitely). When set to a positive duration, `purgeTick`
deletes rows where `captured_at` is older than the threshold in batches
of 10,000, matching other log purge operations. The `boundary_logs`
label is added to the `records_purged_total` Prometheus counter.

Also removes the random-UUID fallback for `OwnerID` in
`dbgen.BoundarySession`. The previous fallback generated a UUID that
could never satisfy the `boundary_sessions_owner_id_fkey` FK constraint,
masking test setup bugs. Callers must now provide a valid user ID or
accept NULL (the legitimate "user deleted" state).
This commit is contained in:
Sas Swart
2026-06-16 14:32:54 +02:00
committed by GitHub
parent e345e061f2
commit 2716e2181c
20 changed files with 446 additions and 7 deletions
+7
View File
@@ -2299,6 +2299,13 @@ func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.Delete
return q.db.DeleteOldBoundaryLogs(ctx, arg)
}
func (q *querier) DeleteOldBoundarySessions(ctx context.Context, arg database.DeleteOldBoundarySessionsParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceBoundaryLog); err != nil {
return 0, err
}
return q.db.DeleteOldBoundarySessions(ctx, arg)
}
func (q *querier) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return 0, err
+4
View File
@@ -482,6 +482,10 @@ func (s *MethodTestSuite) TestBoundaryLogs() {
dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes()
check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete)
}))
s.Run("DeleteOldBoundarySessions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().DeleteOldBoundarySessions(gomock.Any(), database.DeleteOldBoundarySessionsParams{}).Return(int64(0), nil).AnyTimes()
check.Args(database.DeleteOldBoundarySessionsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete)
}))
}
func (s *MethodTestSuite) TestConnectionLogs() {
+1 -1
View File
@@ -468,7 +468,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{
ID: takeFirst(seed.ID, uuid.New()),
WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()),
OwnerID: takeFirst(seed.OwnerID, uuid.NullUUID{UUID: uuid.New(), Valid: true}),
OwnerID: seed.OwnerID,
ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"),
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
+8
View File
@@ -722,6 +722,14 @@ func (m queryMetricsStore) DeleteOldBoundaryLogs(ctx context.Context, arg databa
return r0, r1
}
func (m queryMetricsStore) DeleteOldBoundarySessions(ctx context.Context, arg database.DeleteOldBoundarySessionsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteOldBoundarySessions(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteOldBoundarySessions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldBoundarySessions").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteOldChatDebugRuns(ctx, arg)
+15
View File
@@ -1206,6 +1206,21 @@ func (mr *MockStoreMockRecorder) DeleteOldBoundaryLogs(ctx, arg any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldBoundaryLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldBoundaryLogs), ctx, arg)
}
// DeleteOldBoundarySessions mocks base method.
func (m *MockStore) DeleteOldBoundarySessions(ctx context.Context, arg database.DeleteOldBoundarySessionsParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteOldBoundarySessions", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteOldBoundarySessions indicates an expected call of DeleteOldBoundarySessions.
func (mr *MockStoreMockRecorder) DeleteOldBoundarySessions(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldBoundarySessions", reflect.TypeOf((*MockStore)(nil).DeleteOldBoundarySessions), ctx, arg)
}
// DeleteOldChatDebugRuns mocks base method.
func (m *MockStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) {
m.ctrl.T.Helper()
+28
View File
@@ -29,6 +29,10 @@ const (
connectionLogsBatchSize = 10000
// Batch size for audit log deletion.
auditLogsBatchSize = 10000
// Batch size for boundary log deletion.
boundaryLogsBatchSize = 10000
// Batch size for boundary session deletion.
boundarySessionsBatchSize = 10000
// Telemetry heartbeats are used to deduplicate events across replicas. We
// don't need to persist heartbeat rows for longer than 24 hours, as they
// are only used for deduplication across replicas. The time needs to be
@@ -251,6 +255,26 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
}
}
var purgedBoundaryLogs, purgedBoundarySessions int64
boundaryLogsRetention := i.vals.Retention.BoundaryLogs.Value()
if boundaryLogsRetention > 0 {
deleteBoundaryLogsBefore := start.Add(-boundaryLogsRetention)
purgedBoundaryLogs, err = tx.DeleteOldBoundaryLogs(ctx, database.DeleteOldBoundaryLogsParams{
BeforeTime: deleteBoundaryLogsBefore,
LimitCount: boundaryLogsBatchSize,
})
if err != nil {
return xerrors.Errorf("failed to delete old boundary logs: %w", err)
}
purgedBoundarySessions, err = tx.DeleteOldBoundarySessions(ctx, database.DeleteOldBoundarySessionsParams{
BeforeTime: deleteBoundaryLogsBefore,
LimitCount: boundarySessionsBatchSize,
})
if err != nil {
return xerrors.Errorf("failed to delete old boundary sessions: %w", err)
}
}
var purgedChats, purgedChatFiles, purgedChatDebugRuns int64
if purgeChats {
purgedChats, purgedChatFiles, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays)
@@ -278,6 +302,8 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
slog.F("aibridge_records", purgedAIBridgeRecords),
slog.F("connection_logs", purgedConnectionLogs),
slog.F("audit_logs", purgedAuditLogs),
slog.F("boundary_logs", purgedBoundaryLogs),
slog.F("boundary_sessions", purgedBoundarySessions),
slog.F("chats", purgedChats),
slog.F("chat_files", purgedChatFiles),
slog.F("chat_debug_runs", purgedChatDebugRuns),
@@ -290,6 +316,8 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
i.recordsPurged.WithLabelValues("aibridge_records").Add(float64(purgedAIBridgeRecords))
i.recordsPurged.WithLabelValues("connection_logs").Add(float64(purgedConnectionLogs))
i.recordsPurged.WithLabelValues("audit_logs").Add(float64(purgedAuditLogs))
i.recordsPurged.WithLabelValues("boundary_logs").Add(float64(purgedBoundaryLogs))
i.recordsPurged.WithLabelValues("boundary_sessions").Add(float64(purgedBoundarySessions))
i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats))
i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns))
i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles))
+259
View File
@@ -1671,6 +1671,265 @@ func TestDeleteOldAuditLogs(t *testing.T) {
})
}
func TestDeleteOldBoundaryLogs(t *testing.T) {
t.Parallel()
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
retentionPeriod := 90 * 24 * time.Hour
beforeThreshold := now.Add(-retentionPeriod).Add(-24 * time.Hour) // 91 days ago (older than threshold, before the cutoff)
afterThreshold := now.Add(-15 * 24 * time.Hour) // 15 days ago (newer than threshold, after the cutoff)
testCases := []struct {
name string
retentionConfig codersdk.RetentionConfig
oldLogTime time.Time
recentLogTime *time.Time // nil means no recent log created
expectOldDeleted bool
expectedLogsRemaining int
}{
{
name: "RetentionEnabled",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(retentionPeriod),
},
oldLogTime: beforeThreshold,
recentLogTime: &afterThreshold,
expectOldDeleted: true,
expectedLogsRemaining: 1, // only recent log remains
},
{
name: "RetentionDisabled",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(0),
},
oldLogTime: now.Add(-365 * 24 * time.Hour), // 1 year ago
recentLogTime: nil,
expectOldDeleted: false,
expectedLogsRemaining: 1, // old log is kept
},
{
name: "RetentionNegative",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(-retentionPeriod),
},
oldLogTime: now.Add(-365 * 24 * time.Hour), // 1 year ago
recentLogTime: nil,
expectOldDeleted: false,
expectedLogsRemaining: 1, // old log is kept
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clk := quartz.NewMock(t)
clk.Set(now).MustWait(ctx)
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
// Create the prerequisite rows (user, org, template, workspace,
// build, agent) needed to satisfy boundary_sessions foreign keys.
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{OrganizationID: org.ID, CreatedBy: user.ID})
tmpl := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, ActiveVersionID: tv.ID, CreatedBy: user.ID})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tmpl.ID,
})
wb := mustCreateWorkspaceBuild(t, db, org, tv, ws.ID, now, 1)
agent := mustCreateAgent(t, db, wb)
session := dbgen.BoundarySession(t, db, database.BoundarySession{
WorkspaceAgentID: agent.ID,
OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true},
})
// Create old boundary log.
oldLogs := dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
SequenceNumber: 0,
CapturedAt: tc.oldLogTime,
CreatedAt: tc.oldLogTime,
}})
oldLog := oldLogs[0]
// Create recent boundary log if specified.
var recentLog database.BoundaryLog
if tc.recentLogTime != nil {
recentLogs := dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
SequenceNumber: 1,
CapturedAt: *tc.recentLogTime,
CreatedAt: *tc.recentLogTime,
}})
recentLog = recentLogs[0]
}
// Run the purge.
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
Retention: tc.retentionConfig,
}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// Verify results.
logs, err := db.ListBoundaryLogsBySessionID(ctx, database.ListBoundaryLogsBySessionIDParams{
SessionID: session.ID,
LimitOpt: 100,
})
require.NoError(t, err)
require.Len(t, logs, tc.expectedLogsRemaining, "unexpected number of boundary logs remaining")
logIDs := make([]uuid.UUID, len(logs))
for i, l := range logs {
logIDs[i] = l.ID
}
if tc.expectOldDeleted {
require.NotContains(t, logIDs, oldLog.ID, "old boundary log should be deleted")
} else {
require.Contains(t, logIDs, oldLog.ID, "old boundary log should NOT be deleted")
}
if tc.recentLogTime != nil {
require.Contains(t, logIDs, recentLog.ID, "recent boundary log should be kept")
}
})
}
}
func TestDeleteOldBoundarySessions(t *testing.T) {
t.Parallel()
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
retentionPeriod := 90 * 24 * time.Hour
// oldTime is 91 days ago (past threshold).
oldTime := now.Add(-retentionPeriod).Add(-24 * time.Hour)
// recentTime is 15 days ago (within threshold).
recentTime := now.Add(-15 * 24 * time.Hour)
testCases := []struct {
name string
retentionConfig codersdk.RetentionConfig
sessionUpdatedAt time.Time
// logTime is the captured_at for the single log inserted with the session.
// Set to nil to create a session with no logs.
logTime *time.Time
expectSessionDeleted bool
}{
{
name: "SessionDeletedWhenAllLogsExpired",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(retentionPeriod),
},
sessionUpdatedAt: oldTime,
logTime: &oldTime, // log is old; will be purged first, leaving session empty
expectSessionDeleted: true,
},
{
name: "SessionKeptWhenRecentLogExists",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(retentionPeriod),
},
sessionUpdatedAt: oldTime,
logTime: &recentTime, // recent log survives log purge, so session kept
expectSessionDeleted: false,
},
{
name: "SessionKeptWhenRetentionDisabled",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(0),
},
sessionUpdatedAt: oldTime,
logTime: &oldTime,
expectSessionDeleted: false,
},
{
name: "SessionKeptWhenRetentionNegative",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(-retentionPeriod),
},
sessionUpdatedAt: oldTime,
logTime: &oldTime,
expectSessionDeleted: false,
},
{
name: "SessionKeptWhenUpdatedAtRecent",
retentionConfig: codersdk.RetentionConfig{
BoundaryLogs: serpent.Duration(retentionPeriod),
},
sessionUpdatedAt: recentTime, // session itself is recent. NOT eligible for session purge
logTime: nil, // no logs; but updated_at guard keeps it
expectSessionDeleted: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
clk := quartz.NewMock(t)
clk.Set(now).MustWait(ctx)
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
// Create the prerequisite rows needed to satisfy boundary_sessions FKs.
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{OrganizationID: org.ID, CreatedBy: user.ID})
tmpl := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, ActiveVersionID: tv.ID, CreatedBy: user.ID})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tmpl.ID,
})
wb := mustCreateWorkspaceBuild(t, db, org, tv, ws.ID, now, 1)
agent := mustCreateAgent(t, db, wb)
session := dbgen.BoundarySession(t, db, database.BoundarySession{
WorkspaceAgentID: agent.ID,
OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedAt: tc.sessionUpdatedAt,
})
if tc.logTime != nil {
dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
SequenceNumber: 0,
CapturedAt: *tc.logTime,
CreatedAt: *tc.logTime,
}})
}
// Run the purge.
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
Retention: tc.retentionConfig,
}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// Verify session presence/absence.
_, err := db.GetBoundarySessionByID(ctx, session.ID)
if tc.expectSessionDeleted {
require.ErrorIs(t, err, sql.ErrNoRows, "session should have been deleted")
} else {
require.NoError(t, err, "session should still exist")
}
})
}
}
func TestDeleteExpiredAPIKeys(t *testing.T) {
t.Parallel()
+3
View File
@@ -175,6 +175,9 @@ type sqlcQuerier interface {
// Deletes boundary logs older than the given time, bounded by a row limit
// to avoid long-running transactions.
DeleteOldBoundaryLogs(ctx context.Context, arg DeleteOldBoundaryLogsParams) (int64, error)
// Deletes boundary sessions that have aged past retention and no longer
// have any associated logs.
DeleteOldBoundarySessions(ctx context.Context, arg DeleteOldBoundarySessionsParams) (int64, error)
// updated_at is the retention clock, so the window starts after the run
// stops being written to.
// Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows
+31
View File
@@ -3622,6 +3622,37 @@ func (q *sqlQuerier) DeleteOldBoundaryLogs(ctx context.Context, arg DeleteOldBou
return result.RowsAffected()
}
const deleteOldBoundarySessions = `-- name: DeleteOldBoundarySessions :execrows
WITH old_sessions AS (
SELECT bs.id
FROM boundary_sessions bs
WHERE bs.updated_at < $1::timestamptz
AND NOT EXISTS (
SELECT 1 FROM boundary_logs bl WHERE bl.session_id = bs.id
)
ORDER BY bs.updated_at ASC
LIMIT $2
)
DELETE FROM boundary_sessions
USING old_sessions
WHERE boundary_sessions.id = old_sessions.id
`
type DeleteOldBoundarySessionsParams struct {
BeforeTime time.Time `db:"before_time" json:"before_time"`
LimitCount int32 `db:"limit_count" json:"limit_count"`
}
// Deletes boundary sessions that have aged past retention and no longer
// have any associated logs.
func (q *sqlQuerier) DeleteOldBoundarySessions(ctx context.Context, arg DeleteOldBoundarySessionsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteOldBoundarySessions, arg.BeforeTime, arg.LimitCount)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getBoundaryLogByID = `-- name: GetBoundaryLogByID :one
SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule FROM boundary_logs WHERE id = $1
`
+17
View File
@@ -77,3 +77,20 @@ WITH old_logs AS (
DELETE FROM boundary_logs
USING old_logs
WHERE boundary_logs.id = old_logs.id;
-- name: DeleteOldBoundarySessions :execrows
-- Deletes boundary sessions that have aged past retention and no longer
-- have any associated logs.
WITH old_sessions AS (
SELECT bs.id
FROM boundary_sessions bs
WHERE bs.updated_at < @before_time::timestamptz
AND NOT EXISTS (
SELECT 1 FROM boundary_logs bl WHERE bl.session_id = bs.id
)
ORDER BY bs.updated_at ASC
LIMIT @limit_count
)
DELETE FROM boundary_sessions
USING old_sessions
WHERE boundary_sessions.id = old_sessions.id;