From 56eb705b8cc5e38bdc07c6772e687592dda846df Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 18 Jun 2026 22:44:02 +0200 Subject: [PATCH] fix(coderd/x/chatd): reset auto archive ticker after runs (#26512) Prevents slow chat auto-archive runs from causing a constant archival loop by resetting the ticker only after each run completes. Also documents the UTC midnight cutoff used for archive eligibility so chats with activity on the same UTC calendar date stay eligible or ineligible for the full day. Addresses deferred review comments: - https://github.com/coder/coder/pull/26109#discussion_r3380197310 - https://github.com/coder/coder/pull/26109#discussion_r3380219922 Generated by Coder Agents and closely reviewed by Hugo. --- coderd/x/chatd/auto_archive.go | 18 ++++++++++++---- coderd/x/chatd/auto_archive_internal_test.go | 22 +++++++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/auto_archive.go b/coderd/x/chatd/auto_archive.go index e045447632..6283aa3afe 100644 --- a/coderd/x/chatd/auto_archive.go +++ b/coderd/x/chatd/auto_archive.go @@ -33,13 +33,19 @@ type autoArchivedChat struct { } func (w *chatWorker) archiveLoop(ctx context.Context) { + run := func(start time.Time) { + w.archiveOnce(ctx, dbtime.Time(start).UTC()) + } + run(w.opts.Clock.Now("chatworker", "auto-archive")) + ticker := w.opts.Clock.NewTicker(w.opts.ArchiveInterval, "chatworker", "auto-archive") - defer ticker.Stop() - w.archiveOnce(ctx, dbtime.Time(w.opts.Clock.Now("chatworker", "auto-archive")).UTC()) + defer ticker.Stop("chatworker", "auto-archive") for { select { case tick := <-ticker.C: - w.archiveOnce(ctx, dbtime.Time(tick).UTC()) + ticker.Stop("chatworker", "auto-archive") + run(tick) + ticker.Reset(w.opts.ArchiveInterval, "chatworker", "auto-archive") case <-ctx.Done(): return } @@ -65,7 +71,11 @@ func (w *chatWorker) archiveOnce(ctx context.Context, start time.Time) { return } - archiveCutoff := dbtime.StartOfDay(start).Add(-time.Duration(autoArchiveDays) * 24 * time.Hour) + // Anchor the cutoff at 00:00 UTC so every chat with activity on the + // same UTC calendar date stays eligible or ineligible for the whole + // day. This avoids trickling chats into auto-archive as wall-clock + // time advances. + archiveCutoff := dbtime.StartOfDay(start.UTC()).Add(-time.Duration(autoArchiveDays) * 24 * time.Hour) rows, err := w.opts.Store.GetAutoArchiveInactiveChatCandidates(ctx, database.GetAutoArchiveInactiveChatCandidatesParams{ ArchiveCutoff: archiveCutoff, LimitCount: w.opts.ArchiveBatchSize, diff --git a/coderd/x/chatd/auto_archive_internal_test.go b/coderd/x/chatd/auto_archive_internal_test.go index 8c2e68b924..0d50bfb7f0 100644 --- a/coderd/x/chatd/auto_archive_internal_test.go +++ b/coderd/x/chatd/auto_archive_internal_test.go @@ -668,8 +668,14 @@ func TestWorker_AutoArchiveLoopRunsImmediatelyAndOnTick(t *testing.T) { opts.ArchiveInterval = time.Minute worker := f.newArchiveWorkerWithOptions(t, opts) - trap := mClock.Trap().NewTicker("chatworker", "auto-archive") - defer trap.Close() + nowTrap := mClock.Trap().Now("chatworker", "auto-archive") + defer nowTrap.Close() + tickerTrap := mClock.Trap().NewTicker("chatworker", "auto-archive") + defer tickerTrap.Close() + tickerStopTrap := mClock.Trap().TickerStop("chatworker", "auto-archive") + defer tickerStopTrap.Close() + tickerResetTrap := mClock.Trap().TickerReset("chatworker", "auto-archive") + defer tickerResetTrap.Close() loopCtx, cancel := context.WithCancel(ctx) done := make(chan struct{}) @@ -678,20 +684,26 @@ func TestWorker_AutoArchiveLoopRunsImmediatelyAndOnTick(t *testing.T) { worker.archiveLoop(loopCtx) }() - // archiveLoop creates the ticker before the immediate startup tick. - trap.MustWait(ctx).MustRelease(ctx) + nowTrap.MustWait(ctx).MustRelease(ctx) testutil.Eventually(ctx, t, func(context.Context) bool { return f.archived(t, first.ID) }, testutil.IntervalFast, "immediate startup tick should archive the first candidate") + tickerTrap.MustWait(ctx).MustRelease(ctx) // A second candidate is only archived once the interval ticker fires. second := f.createArchiveCandidate(t, now.Add(-120*24*time.Hour)) - mClock.Advance(time.Minute).MustWait(ctx) + advanced := mClock.Advance(time.Minute) + tickerStopTrap.MustWait(ctx).MustRelease(ctx) testutil.Eventually(ctx, t, func(context.Context) bool { return f.archived(t, second.ID) }, testutil.IntervalFast, "interval tick should archive the second candidate") + resetCall := tickerResetTrap.MustWait(ctx) + require.Equal(t, time.Minute, resetCall.Duration) + resetCall.MustRelease(ctx) + advanced.MustWait(ctx) cancel() + tickerStopTrap.MustWait(ctx).MustRelease(ctx) select { case <-done: case <-ctx.Done():