Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7b95f85

Browse files
authored
chore: purge identified terraform modules via dbpurge (#28802) (#28814)
Cherry-pick of [#28802](#28802) (`e2a856d42b`), matching [#28810](#28810) for `release/2.37`. Deletes cached Terraform module archives ingested during the identified window and clears the template version references to them. Runs from `dbpurge` rather than a migration, because migrations cannot be backported: the version table records a single high-water mark, so a migration cherry-picked here would cause later upgrades to skip every migration in between. ## Conflict resolution The commit did not apply cleanly. This branch predates the chat search work on `main`, so the incoming hunks carried unrelated context that was dropped: - `dbpurge.go`: took only the module cache block, the `ranModuleCachePurge` latch, the window constants, the `identified_module_files` log field and metric, and the `identifiedModuleCachePurged` instance field. Dropped the `chat_messages.search_tsv` backfill and stale reindex, along with `staleDrained` and the `chatSearch*` fields, none of which exist on this branch. - `dbpurge_test.go`: took `TestDeleteIdentifiedModuleCacheFiles` and the `awaitDoTicks` helper it depends on. Dropped `TestBackfillChatMessagesSearchTsv`. In the two `TestMetrics` mock setups, added only the `DeleteCachedModuleFilesCreatedBetween` expectation. - Generated files (`querier.go`, `queries.sql.go`, `dbmetrics`, `dbmock`, and the `dbauthz` stub) were reset to the branch state and regenerated from `queries/files.sql`, rather than taking the diff from `main`. Taking `main`'s versions would have introduced methods for queries that do not exist on this branch. ## Testing `coderd/database/dbpurge` and `TestMethodTestSuite` in `coderd/database/dbauthz` pass against Postgres. `make gen` is clean and pre-commit hooks pass. --- Opened by Coder Agents on behalf of @Emyrk.
1 parent d8e69d3 commit 7b95f85

10 files changed

Lines changed: 364 additions & 0 deletions

File tree

coderd/database/dbauthz/dbauthz.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2059,6 +2059,13 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u
20592059
return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
20602060
}
20612061

2062+
func (q *querier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) {
2063+
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
2064+
return 0, err
2065+
}
2066+
return q.db.DeleteCachedModuleFilesCreatedBetween(ctx, arg)
2067+
}
2068+
20622069
func (q *querier) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error {
20632070
chat, err := q.db.GetChatByID(ctx, chatID)
20642071
if err != nil {

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5143,6 +5143,11 @@ func (s *MethodTestSuite) TestSystemFunctions() {
51435143
dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(int64(0), nil).AnyTimes()
51445144
check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete)
51455145
}))
5146+
s.Run("DeleteCachedModuleFilesCreatedBetween", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
5147+
arg := database.DeleteCachedModuleFilesCreatedBetweenParams{}
5148+
dbm.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
5149+
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete)
5150+
}))
51465151
s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
51475152
arg := database.InsertWorkspaceAgentStatsParams{}
51485153
dbm.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), arg).Return(xerrors.New("any error")).AnyTimes()

coderd/database/dbmetrics/querymetrics.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbpurge/dbpurge.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ const (
4848
chatDebugRunsBatchSize = 1000
4949
)
5050

51+
// Terraform module archives ingested during this window may contain the
52+
// identified upstream module.
53+
//
54+
// This is a one-off cleanup, not a recurring purge. It runs once per coderd
55+
// process because the window is fixed in the past: after a successful pass
56+
// there is nothing left to match. It lives here rather than in a migration
57+
// because migrations cannot be backported. The version table records a single
58+
// high-water mark, so a migration cherry-picked onto a release branch would
59+
// cause later upgrades to skip every migration in between.
60+
var (
61+
identifiedModuleCacheStart = time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC)
62+
identifiedModuleCacheEnd = time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC)
63+
)
64+
5165
type Option func(*instance)
5266

5367
// WithClock overrides the clock used by the purger. Defaults to
@@ -151,6 +165,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
151165

152166
chatConfigErr := errors.Join(chatRetentionErr, chatDebugRetentionErr)
153167

168+
// Latched after a successful commit so the one-off module cache cleanup
169+
// is attempted again if the transaction rolls back.
170+
ranModuleCachePurge := false
171+
154172
// Start a transaction to grab advisory lock, we don't want to run
155173
// multiple purges at the same time (multiple replicas).
156174
err := db.InTx(func(tx database.Store) error {
@@ -296,6 +314,20 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
296314
}
297315
}
298316

317+
// One-off cleanup of the identified Terraform module cache. Skipped
318+
// once this process has completed a pass.
319+
var purgedIdentifiedModuleFiles int64
320+
if !i.identifiedModuleCachePurged {
321+
purgedIdentifiedModuleFiles, err = tx.DeleteCachedModuleFilesCreatedBetween(ctx, database.DeleteCachedModuleFilesCreatedBetweenParams{
322+
CreatedAtAfter: identifiedModuleCacheStart,
323+
CreatedAtBefore: identifiedModuleCacheEnd,
324+
})
325+
if err != nil {
326+
return xerrors.Errorf("failed to delete identified module cache files: %w", err)
327+
}
328+
ranModuleCachePurge = true
329+
}
330+
299331
i.logger.Debug(ctx, "purged old database entries",
300332
slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs),
301333
slog.F("expired_api_keys", expiredAPIKeys),
@@ -307,6 +339,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
307339
slog.F("chats", purgedChats),
308340
slog.F("chat_files", purgedChatFiles),
309341
slog.F("chat_debug_runs", purgedChatDebugRuns),
342+
slog.F("identified_module_files", purgedIdentifiedModuleFiles),
310343
slog.F("duration", i.clk.Since(start)),
311344
)
312345

@@ -321,6 +354,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
321354
i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats))
322355
i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns))
323356
i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles))
357+
i.recordsPurged.WithLabelValues("identified_module_files").Add(float64(purgedIdentifiedModuleFiles))
324358
}
325359

326360
// chatConfigErr is returned after the tx, so do not record this
@@ -336,6 +370,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
336370
return err
337371
}
338372

373+
if ranModuleCachePurge {
374+
i.identifiedModuleCachePurged = true
375+
}
376+
339377
// Surface the deferred chat-config error so doTick records
340378
// the failed iteration metric.
341379
if chatConfigErr != nil {
@@ -353,6 +391,11 @@ type instance struct {
353391
clk quartz.Clock
354392
iterationDuration *prometheus.HistogramVec
355393
recordsPurged *prometheus.CounterVec
394+
395+
// identifiedModuleCachePurged latches once this process has completed a
396+
// pass of the one-off module cache cleanup. The window is fixed in the
397+
// past, so a completed pass leaves nothing to match on later ticks.
398+
identifiedModuleCachePurged bool
356399
}
357400

358401
func (i *instance) Close() error {

coderd/database/dbpurge/dbpurge_internal_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ func TestDBPurgeAuthorization(t *testing.T) {
4343
err := inst.purgeTick(ctx, db, now)
4444
require.NoError(t, err)
4545
}
46+
47+
// The behavior of the one-off module cache cleanup is covered by
48+
// TestDeleteIdentifiedModuleCacheFiles, which supplies its own window. This
49+
// guards the production constants themselves, which that test no longer reads.
50+
func TestIdentifiedModuleCacheWindow(t *testing.T) {
51+
t.Parallel()
52+
53+
require.True(t, identifiedModuleCacheStart.Before(identifiedModuleCacheEnd),
54+
"window start must precede window end")
55+
require.Equal(t, time.UTC, identifiedModuleCacheStart.Location(),
56+
"window bounds must be UTC so they do not shift with the host timezone")
57+
require.Equal(t, time.UTC, identifiedModuleCacheEnd.Location(),
58+
"window bounds must be UTC so they do not shift with the host timezone")
59+
}

coderd/database/dbpurge/dbpurge_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"crypto/sha256"
78
"database/sql"
89
"encoding/json"
910
"fmt"
@@ -248,6 +249,7 @@ func TestMetrics(t *testing.T) {
248249
mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
249250
mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
250251
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
252+
mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes()
251253
mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1)
252254
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
253255
DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error {
@@ -298,6 +300,7 @@ func TestMetrics(t *testing.T) {
298300
mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
299301
mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
300302
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
303+
mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes()
301304
mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1)
302305
mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1)
303306
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
@@ -2714,3 +2717,176 @@ func TestDeleteOldChatFiles(t *testing.T) {
27142717
})
27152718
}
27162719
}
2720+
2721+
func awaitDoTicks(ctx context.Context, t *testing.T, clk *quartz.Mock, n int) func() {
2722+
t.Helper()
2723+
completed := make(chan struct{})
2724+
advance := make(chan struct{})
2725+
trapNow := clk.Trap().Now()
2726+
trapStop := clk.Trap().TickerStop()
2727+
trapReset := clk.Trap().TickerReset()
2728+
go func() {
2729+
defer close(completed)
2730+
defer trapReset.Close()
2731+
defer trapStop.Close()
2732+
defer trapNow.Close()
2733+
trapNow.MustWait(ctx).MustRelease(ctx)
2734+
trapReset.MustWait(ctx).MustRelease(ctx)
2735+
select {
2736+
case completed <- struct{}{}:
2737+
case <-ctx.Done():
2738+
return
2739+
}
2740+
for i := 1; i < n; i++ {
2741+
select {
2742+
case <-advance:
2743+
case <-ctx.Done():
2744+
return
2745+
}
2746+
d, w := clk.AdvanceNext()
2747+
if !assert.Equal(t, 10*time.Minute, d) {
2748+
return
2749+
}
2750+
w.MustWait(ctx)
2751+
trapStop.MustWait(ctx).MustRelease(ctx)
2752+
trapReset.MustWait(ctx).MustRelease(ctx)
2753+
select {
2754+
case completed <- struct{}{}:
2755+
case <-ctx.Done():
2756+
return
2757+
}
2758+
}
2759+
}()
2760+
first := true
2761+
return func() {
2762+
t.Helper()
2763+
if !first {
2764+
testutil.RequireSend(ctx, t, advance, struct{}{})
2765+
}
2766+
first = false
2767+
testutil.TryReceive(ctx, t, completed)
2768+
}
2769+
}
2770+
2771+
//nolint:paralleltest // It uses LockIDDBPurge.
2772+
func TestDeleteIdentifiedModuleCacheFiles(t *testing.T) {
2773+
ctx := testutil.Context(t, testutil.WaitShort)
2774+
clk := quartz.NewMock(t)
2775+
clk.Set(dbtime.Now()).MustWait(ctx)
2776+
2777+
// The window under test is supplied explicitly rather than copied from the
2778+
// production constants, so revising the incident timestamps cannot silently
2779+
// invalidate these boundary assertions.
2780+
windowStart := time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC)
2781+
windowEnd := time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC)
2782+
inWindow := windowStart.Add(time.Minute)
2783+
2784+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
2785+
org := dbgen.Organization(t, db, database.Organization{})
2786+
user := dbgen.User(t, db, database.User{})
2787+
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
2788+
2789+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
2790+
2791+
mkFile := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) database.File {
2792+
file, err := db.InsertFile(ctx, database.InsertFileParams{
2793+
ID: uuid.New(),
2794+
Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(name))),
2795+
CreatedBy: createdBy,
2796+
CreatedAt: createdAt,
2797+
Mimetype: mimetype,
2798+
Data: []byte{},
2799+
})
2800+
require.NoError(t, err, "insert file %q", name)
2801+
return file
2802+
}
2803+
2804+
// mkVersion creates a template version whose cached module files point at a
2805+
// file with the given properties. InsertFile is used directly because
2806+
// dbgen.File treats uuid.Nil as unset and substitutes a random creator,
2807+
// while uuid.Nil is exactly what identifies a provisionerd module archive.
2808+
mkVersion := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) (database.File, database.TemplateVersion) {
2809+
file := mkFile(name, createdBy, mimetype, createdAt)
2810+
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
2811+
Name: name,
2812+
OrganizationID: org.ID,
2813+
CreatedBy: user.ID,
2814+
})
2815+
_ = dbgen.TemplateVersionTerraformValues(t, db, database.TemplateVersionTerraformValue{
2816+
TemplateVersionID: tv.ID,
2817+
CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true},
2818+
})
2819+
return file, tv
2820+
}
2821+
2822+
// Identified: a provisionerd module archive cached inside the window.
2823+
identified, identifiedTV := mkVersion("identified", uuid.Nil, "application/x-tar", inWindow)
2824+
// The lower bound is inclusive.
2825+
atStart, atStartTV := mkVersion("at-start", uuid.Nil, "application/x-tar", windowStart)
2826+
// The upper bound is exclusive, so this archive is known good.
2827+
atEnd, atEndTV := mkVersion("at-end", uuid.Nil, "application/x-tar", windowEnd)
2828+
// Cached before and after the window.
2829+
before, beforeTV := mkVersion("before", uuid.Nil, "application/x-tar", windowStart.Add(-time.Hour))
2830+
after, afterTV := mkVersion("after", uuid.Nil, "application/x-tar", windowEnd.Add(time.Hour))
2831+
// A user-uploaded template tarball shares the mimetype but has a real
2832+
// creator, so it must survive even though it is inside the window.
2833+
userUpload, userUploadTV := mkVersion("user-upload", user.ID, "application/x-tar", inWindow)
2834+
2835+
// An unreferenced archive inside the window. Only archives referenced by a
2836+
// template version are in scope.
2837+
orphan := mkFile("orphan", uuid.Nil, "application/x-tar", inWindow)
2838+
2839+
// when dbpurge runs
2840+
tick := awaitDoTicks(ctx, t, clk, 2)
2841+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk))
2842+
defer closer.Close()
2843+
tick() // doTick() has now run.
2844+
2845+
assertFileDeleted := func(id uuid.UUID, name string) {
2846+
t.Helper()
2847+
_, err := db.GetFileByID(ctx, id)
2848+
require.ErrorIs(t, err, sql.ErrNoRows, "%s should be deleted", name)
2849+
}
2850+
assertFileExists := func(id uuid.UUID, name string) {
2851+
t.Helper()
2852+
_, err := db.GetFileByID(ctx, id)
2853+
require.NoError(t, err, "%s should be retained", name)
2854+
}
2855+
// assertCacheRef checks the template version still exists and that its
2856+
// module cache reference was cleared only when the file was deleted.
2857+
assertCacheRef := func(tv database.TemplateVersion, wantFile uuid.UUID, wantValid bool, name string) {
2858+
t.Helper()
2859+
values, err := db.GetTemplateVersionTerraformValues(ctx, tv.ID)
2860+
require.NoError(t, err, "%s: terraform values row must be retained", name)
2861+
require.Equal(t, wantValid, values.CachedModuleFiles.Valid, "%s: cache reference validity", name)
2862+
if wantValid {
2863+
require.Equal(t, wantFile, values.CachedModuleFiles.UUID, "%s: cache reference target", name)
2864+
}
2865+
}
2866+
2867+
// then the identified archives are deleted and their references cleared
2868+
assertFileDeleted(identified.ID, "archive inside the window")
2869+
assertCacheRef(identifiedTV, uuid.Nil, false, "archive inside the window")
2870+
assertFileDeleted(atStart.ID, "archive at the inclusive lower bound")
2871+
assertCacheRef(atStartTV, uuid.Nil, false, "archive at the inclusive lower bound")
2872+
2873+
// and everything else is untouched
2874+
assertFileExists(atEnd.ID, "archive at the exclusive upper bound")
2875+
assertCacheRef(atEndTV, atEnd.ID, true, "archive at the exclusive upper bound")
2876+
assertFileExists(before.ID, "archive cached before the window")
2877+
assertCacheRef(beforeTV, before.ID, true, "archive cached before the window")
2878+
assertFileExists(after.ID, "archive cached after the window")
2879+
assertCacheRef(afterTV, after.ID, true, "archive cached after the window")
2880+
assertFileExists(userUpload.ID, "user-uploaded tarball")
2881+
assertCacheRef(userUploadTV, userUpload.ID, true, "user-uploaded tarball")
2882+
assertFileExists(orphan.ID, "unreferenced archive")
2883+
2884+
// The cleanup is one-off, not a recurring purge. A second tick must not
2885+
// repeat it, so an archive inserted into the window after the first pass
2886+
// survives. This documents the latch: the window is fixed in the past and
2887+
// nothing can legitimately land in it again.
2888+
late, lateTV := mkVersion("late", uuid.Nil, "application/x-tar", inWindow)
2889+
tick()
2890+
assertFileExists(late.ID, "archive inserted after the one-off pass")
2891+
assertCacheRef(lateTV, late.ID, true, "archive inserted after the one-off pass")
2892+
}

coderd/database/querier.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)