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.
This commit is contained in:
Hugo Dutka
2026-06-18 22:44:02 +02:00
committed by GitHub
parent 76fa1d4577
commit 56eb705b8c
2 changed files with 31 additions and 9 deletions
+14 -4
View File
@@ -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,
+17 -5
View File
@@ -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():