fix: keep chat attachments while a linking chat exists

Fixes https://linear.app/codercom/issue/CODAGT-616/keep-chat-attachments-while-chats-remain-unarchived

Chat attachments could disappear even though the chat was still available. This happened when a message was saved without recording which attachments it used, or when cleanup deleted attachments before an archived chat itself was removed.

Creating a chat, sending or queuing a message, and editing a message now record both the message and which attachments it uses as one operation. If the chat is already at the 50-attachment limit, the chat change fails without being partially saved.

Concurrent attachment writes serialize the 50-file cap per chat. Cleanup locks candidates and checks again for new links before deleting. If a file becomes unavailable after input validation, create, send, and edit return a clear client error and roll back the chat change.

An attachment stays available while any chat that uses it still exists. After an archived chat reaches the end of its retention period and is deleted, an old attachment that no remaining chat uses can be cleaned up. The retention guide and unavailable-attachment UI text document this lifecycle. This change cannot restore attachments that were already deleted.

The database migration adds two indexes so attachment cleanup stays fast as attachments accumulate.

> This PR was authored by Mux (AI) on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-11 13:53:15 +02:00
committed by GitHub
parent 72ad835330
commit 57f38b5c24
33 changed files with 1130 additions and 368 deletions
+59
View File
@@ -0,0 +1,59 @@
package database
import (
"context"
"golang.org/x/xerrors"
)
type (
DeleteOldChatFilesParams = GetOldUnlinkedChatFileIDsParams
LinkChatFilesParams = LinkChatFilesAfterLockParams
)
func (q *sqlQuerier) LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error) {
var rejected int32
err := q.InTx(func(tx Store) error {
if _, err := tx.LockChatByID(ctx, arg.ChatID); err != nil {
return xerrors.Errorf("lock chat: %w", err)
}
var err error
rejected, err = tx.LinkChatFilesAfterLock(ctx, arg)
if err != nil {
return xerrors.Errorf("link chat files after lock: %w", err)
}
return nil
}, DefaultTXOptions().WithID("link_chat_files"))
if err != nil {
return 0, err
}
return rejected, nil
}
func (q *sqlQuerier) DeleteOldChatFiles(ctx context.Context, arg DeleteOldChatFilesParams) (int64, error) {
// Recheck candidates because links may commit during row-lock waits.
var deleted int64
err := q.InTx(func(tx Store) error {
ids, err := tx.GetOldUnlinkedChatFileIDs(ctx, arg)
if err != nil {
return xerrors.Errorf("get old unlinked chat files: %w", err)
}
if len(ids) == 0 {
return nil
}
deleted, err = tx.DeleteUnlinkedChatFilesByIDs(ctx, DeleteUnlinkedChatFilesByIDsParams{
IDs: ids,
BeforeTime: arg.BeforeTime,
})
if err != nil {
return xerrors.Errorf("delete old unlinked chat files: %w", err)
}
return nil
}, DefaultTXOptions().WithID("delete_old_chat_files"))
if err != nil {
return 0, err
}
return deleted, nil
}
+41
View File
@@ -0,0 +1,41 @@
package database_test
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
)
func TestDeleteOldChatFilesRechecksSelectedCandidates(t *testing.T) {
t.Parallel()
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
before := time.Now().UTC()
fileID := uuid.New()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta("SELECT cf.id FROM chat_files cf")).
WithArgs(before, int32(100)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(fileID))
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM chat_files cf")).
WithArgs(sqlmock.AnyArg(), before).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
deleted, err := database.New(sqlDB).DeleteOldChatFiles(context.Background(), database.DeleteOldChatFilesParams{
BeforeTime: before,
LimitCount: 100,
})
require.NoError(t, err)
require.Zero(t, deleted)
require.NoError(t, mock.ExpectationsWereMet())
}
+42 -14
View File
@@ -2382,13 +2382,6 @@ func (q *querier) DeleteOldChatDebugRuns(ctx context.Context, arg database.Delet
return q.db.DeleteOldChatDebugRuns(ctx, arg)
}
func (q *querier) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return 0, err
}
return q.db.DeleteOldChatFiles(ctx, arg)
}
func (q *querier) DeleteOldChats(ctx context.Context, arg database.DeleteOldChatsParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return 0, err
@@ -2522,6 +2515,13 @@ func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams)
return q.db.DeleteTask(ctx, arg)
}
func (q *querier) DeleteUnlinkedChatFilesByIDs(ctx context.Context, arg database.DeleteUnlinkedChatFilesByIDsParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return 0, err
}
return q.db.DeleteUnlinkedChatFilesByIDs(ctx, arg)
}
func (q *querier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
// Removing a user's AI budget override affects both the user (clearing
// their per-user spend cap) and the group it was attributed to.
@@ -4205,6 +4205,13 @@ func (q *querier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid
return q.db.GetOAuth2ProviderAppsByUserID(ctx, userID)
}
func (q *querier) GetOldUnlinkedChatFileIDs(ctx context.Context, arg database.GetOldUnlinkedChatFileIDsParams) ([]uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetOldUnlinkedChatFileIDs(ctx, arg)
}
func (q *querier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
return fetch(q.log, q.auth, q.db.GetOrganizationByID)(ctx, id)
}
@@ -6697,15 +6704,11 @@ func (q *querier) IsChatHeartbeatStale(ctx context.Context, arg database.IsChatH
return q.db.IsChatHeartbeatStale(ctx, arg)
}
func (q *querier) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
func (q *querier) LinkChatFilesAfterLock(ctx context.Context, arg database.LinkChatFilesAfterLockParams) (int32, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.LinkChatFiles(ctx, arg)
return q.db.LinkChatFilesAfterLock(ctx, arg)
}
func (q *querier) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
@@ -6911,6 +6914,13 @@ func (q *querier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid.UU
return q.db.LockChatAndBumpSnapshotVersion(ctx, id)
}
func (q *querier) LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return uuid.Nil, err
}
return q.db.LockChatByID(ctx, id)
}
func (q *querier) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
resource := rbac.ResourceInboxNotification.WithOwner(arg.UserID.String())
@@ -9317,6 +9327,24 @@ func (q *querier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg
return q.db.ListAuthorizedAIBridgeSessionThreads(ctx, arg, prepared)
}
func (q *querier) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return 0, err
}
return q.db.DeleteOldChatFiles(ctx, arg)
}
func (q *querier) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.LinkChatFiles(ctx, arg)
}
func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
return q.GetChats(ctx, arg)
}
+20
View File
@@ -632,6 +632,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return([]database.Chat{chat}, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.Chat{chat})
}))
s.Run("LockChatByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
chatID := uuid.New()
dbm.EXPECT().LockChatByID(gomock.Any(), chatID).Return(chatID, nil).AnyTimes()
check.Args(chatID).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(chatID)
}))
s.Run("LinkChatFilesAfterLock", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.LinkChatFilesAfterLockParams{}
dbm.EXPECT().LinkChatFilesAfterLock(gomock.Any(), arg).Return(int32(0), nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(int32(0))
}))
s.Run("LinkChatFiles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.LinkChatFilesParams{
@@ -930,6 +940,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), database.DeleteOldChatDebugRunsParams{}).Return(int64(0), nil).AnyTimes()
check.Args(database.DeleteOldChatDebugRunsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
}))
s.Run("GetOldUnlinkedChatFileIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetOldUnlinkedChatFileIDsParams{}
dbm.EXPECT().GetOldUnlinkedChatFileIDs(gomock.Any(), arg).Return([]uuid.UUID{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete).Returns([]uuid.UUID{})
}))
s.Run("DeleteUnlinkedChatFilesByIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.DeleteUnlinkedChatFilesByIDsParams{}
dbm.EXPECT().DeleteUnlinkedChatFilesByIDs(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete)
}))
s.Run("DeleteOldChatFiles", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().DeleteOldChatFiles(gomock.Any(), database.DeleteOldChatFilesParams{}).Return(int64(0), nil).AnyTimes()
check.Args(database.DeleteOldChatFilesParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
+44 -12
View File
@@ -713,14 +713,6 @@ func (m queryMetricsStore) DeleteOldChatDebugRuns(ctx context.Context, arg datab
return r0, r1
}
func (m queryMetricsStore) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteOldChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteOldChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldChatFiles").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteOldChats(ctx context.Context, arg database.DeleteOldChatsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteOldChats(ctx, arg)
@@ -857,6 +849,14 @@ func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTa
return r0, r1
}
func (m queryMetricsStore) DeleteUnlinkedChatFilesByIDs(ctx context.Context, arg database.DeleteUnlinkedChatFilesByIDsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteUnlinkedChatFilesByIDs(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteUnlinkedChatFilesByIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUnlinkedChatFilesByIDs").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
start := time.Now()
r0, r1 := m.s.DeleteUserAIBudgetOverride(ctx, userID)
@@ -2489,6 +2489,14 @@ func (m queryMetricsStore) GetOAuth2ProviderAppsByUserID(ctx context.Context, us
return r0, r1
}
func (m queryMetricsStore) GetOldUnlinkedChatFileIDs(ctx context.Context, arg database.GetOldUnlinkedChatFileIDsParams) ([]uuid.UUID, error) {
start := time.Now()
r0, r1 := m.s.GetOldUnlinkedChatFileIDs(ctx, arg)
m.queryLatencies.WithLabelValues("GetOldUnlinkedChatFileIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetOldUnlinkedChatFileIDs").Inc()
return r0, r1
}
func (m queryMetricsStore) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
start := time.Now()
r0, r1 := m.s.GetOrganizationByID(ctx, id)
@@ -4633,11 +4641,11 @@ func (m queryMetricsStore) IsChatHeartbeatStale(ctx context.Context, arg databas
return r0, r1
}
func (m queryMetricsStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
func (m queryMetricsStore) LinkChatFilesAfterLock(ctx context.Context, arg database.LinkChatFilesAfterLockParams) (int32, error) {
start := time.Now()
r0, r1 := m.s.LinkChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("LinkChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LinkChatFiles").Inc()
r0, r1 := m.s.LinkChatFilesAfterLock(ctx, arg)
m.queryLatencies.WithLabelValues("LinkChatFilesAfterLock").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LinkChatFilesAfterLock").Inc()
return r0, r1
}
@@ -4841,6 +4849,14 @@ func (m queryMetricsStore) LockChatAndBumpSnapshotVersion(ctx context.Context, i
return r0, r1
}
func (m queryMetricsStore) LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
start := time.Now()
r0, r1 := m.s.LockChatByID(ctx, id)
m.queryLatencies.WithLabelValues("LockChatByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LockChatByID").Inc()
return r0, r1
}
func (m queryMetricsStore) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
start := time.Now()
r0 := m.s.MarkAllInboxNotificationsAsRead(ctx, arg)
@@ -6705,6 +6721,22 @@ func (m queryMetricsStore) ListAuthorizedAIBridgeSessionThreads(ctx context.Cont
return r0, r1
}
func (m queryMetricsStore) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteOldChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteOldChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldChatFiles").Inc()
return r0, r1
}
func (m queryMetricsStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
start := time.Now()
r0, r1 := m.s.LinkChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("LinkChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LinkChatFiles").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedChats(ctx, arg, prepared)
+60
View File
@@ -1454,6 +1454,21 @@ func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg)
}
// DeleteUnlinkedChatFilesByIDs mocks base method.
func (m *MockStore) DeleteUnlinkedChatFilesByIDs(ctx context.Context, arg database.DeleteUnlinkedChatFilesByIDsParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteUnlinkedChatFilesByIDs", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteUnlinkedChatFilesByIDs indicates an expected call of DeleteUnlinkedChatFilesByIDs.
func (mr *MockStoreMockRecorder) DeleteUnlinkedChatFilesByIDs(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUnlinkedChatFilesByIDs", reflect.TypeOf((*MockStore)(nil).DeleteUnlinkedChatFilesByIDs), ctx, arg)
}
// DeleteUserAIBudgetOverride mocks base method.
func (m *MockStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
m.ctrl.T.Helper()
@@ -4620,6 +4635,21 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppsByUserID(ctx, userID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppsByUserID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppsByUserID), ctx, userID)
}
// GetOldUnlinkedChatFileIDs mocks base method.
func (m *MockStore) GetOldUnlinkedChatFileIDs(ctx context.Context, arg database.GetOldUnlinkedChatFileIDsParams) ([]uuid.UUID, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOldUnlinkedChatFileIDs", ctx, arg)
ret0, _ := ret[0].([]uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOldUnlinkedChatFileIDs indicates an expected call of GetOldUnlinkedChatFileIDs.
func (mr *MockStoreMockRecorder) GetOldUnlinkedChatFileIDs(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOldUnlinkedChatFileIDs", reflect.TypeOf((*MockStore)(nil).GetOldUnlinkedChatFileIDs), ctx, arg)
}
// GetOrganizationByID mocks base method.
func (m *MockStore) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
m.ctrl.T.Helper()
@@ -8683,6 +8713,21 @@ func (mr *MockStoreMockRecorder) LinkChatFiles(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkChatFiles", reflect.TypeOf((*MockStore)(nil).LinkChatFiles), ctx, arg)
}
// LinkChatFilesAfterLock mocks base method.
func (m *MockStore) LinkChatFilesAfterLock(ctx context.Context, arg database.LinkChatFilesAfterLockParams) (int32, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LinkChatFilesAfterLock", ctx, arg)
ret0, _ := ret[0].(int32)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LinkChatFilesAfterLock indicates an expected call of LinkChatFilesAfterLock.
func (mr *MockStoreMockRecorder) LinkChatFilesAfterLock(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkChatFilesAfterLock", reflect.TypeOf((*MockStore)(nil).LinkChatFilesAfterLock), ctx, arg)
}
// ListAIBridgeClients mocks base method.
func (m *MockStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
m.ctrl.T.Helper()
@@ -9118,6 +9163,21 @@ func (mr *MockStoreMockRecorder) LockChatAndBumpSnapshotVersion(ctx, id any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockChatAndBumpSnapshotVersion", reflect.TypeOf((*MockStore)(nil).LockChatAndBumpSnapshotVersion), ctx, id)
}
// LockChatByID mocks base method.
func (m *MockStore) LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LockChatByID", ctx, id)
ret0, _ := ret[0].(uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LockChatByID indicates an expected call of LockChatByID.
func (mr *MockStoreMockRecorder) LockChatByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockChatByID", reflect.TypeOf((*MockStore)(nil).LockChatByID), ctx, id)
}
// MarkAllInboxNotificationsAsRead mocks base method.
func (m *MockStore) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
m.ctrl.T.Helper()
+124 -10
View File
@@ -2606,6 +2606,129 @@ func TestDeleteOldChatFiles(t *testing.T) {
require.NoError(t, err, "file near 30d boundary should be retained")
},
},
{
name: "LinkedFileRetainedWhileChatExists",
run: func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
deps := setupChatDeps(t, db)
fileID := createChatFile(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, now.Add(-31*24*time.Hour))
chat := createChat(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, deps.modelConfig.ID, true, now.Add(-31*24*time.Hour))
_, err := db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: 100,
FileIds: []uuid.UUID{fileID},
})
require.NoError(t, err)
deleted, err := db.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{
BeforeTime: now.Add(-30 * 24 * time.Hour),
LimitCount: 100,
})
require.NoError(t, err)
require.Zero(t, deleted)
_, err = db.GetChatFileByID(ctx, fileID)
require.NoError(t, err)
_, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
_, err = rawDB.ExecContext(ctx, "DELETE FROM chats WHERE id = $1", chat.ID)
require.NoError(t, err)
deleted, err = db.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{
BeforeTime: now.Add(-30 * 24 * time.Hour),
LimitCount: 100,
})
require.NoError(t, err)
require.EqualValues(t, 1, deleted)
_, err = db.GetChatFileByID(ctx, fileID)
require.ErrorIs(t, err, sql.ErrNoRows)
},
},
{
name: "DeleteCandidatesRecheckLinks",
run: func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
deps := setupChatDeps(t, db)
fileID := createChatFile(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, now.Add(-31*24*time.Hour))
chat := createChat(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, deps.modelConfig.ID, false, now)
var candidateIDs []uuid.UUID
err := db.InTx(func(tx database.Store) error {
var err error
candidateIDs, err = tx.GetOldUnlinkedChatFileIDs(ctx, database.GetOldUnlinkedChatFileIDsParams{
BeforeTime: now.Add(-30 * 24 * time.Hour),
LimitCount: 100,
})
return err
}, database.DefaultTXOptions())
require.NoError(t, err)
require.Equal(t, []uuid.UUID{fileID}, candidateIDs)
_, err = db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: 100,
FileIds: []uuid.UUID{fileID},
})
require.NoError(t, err)
deleted, err := db.DeleteUnlinkedChatFilesByIDs(ctx, database.DeleteUnlinkedChatFilesByIDsParams{
IDs: candidateIDs,
BeforeTime: now.Add(-30 * 24 * time.Hour),
})
require.NoError(t, err)
require.Zero(t, deleted)
_, err = db.GetChatFileByID(ctx, fileID)
require.NoError(t, err)
},
},
{
name: "ConcurrentLinkRetainsFile",
run: func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
deps := setupChatDeps(t, db)
fileID := createChatFile(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, now.Add(-31*24*time.Hour))
chat := createChat(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, deps.modelConfig.ID, false, now)
linkTx := dbtestutil.StartTx(t, db, database.DefaultTXOptions())
linkCommitted := false
t.Cleanup(func() {
if !linkCommitted {
_ = linkTx.Done()
}
})
_, err := linkTx.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: 100,
FileIds: []uuid.UUID{fileID},
})
require.NoError(t, err)
deleted, err := db.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{
BeforeTime: now.Add(-30 * 24 * time.Hour),
LimitCount: 100,
})
require.NoError(t, err)
require.Zero(t, deleted)
commitErr := linkTx.Done()
linkCommitted = true
require.NoError(t, commitErr)
_, err = db.GetChatFileByID(ctx, fileID)
require.NoError(t, err)
files, err := db.GetChatFileMetadataByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, fileID, files[0].ID)
},
},
{
name: "ArchivedChatFilesDeleted",
run: func(t *testing.T) {
@@ -2620,7 +2743,6 @@ func TestDeleteOldChatFiles(t *testing.T) {
err := db.UpsertChatRetentionDays(ctx, int32(30))
require.NoError(t, err)
// File D: 31 days old, in a chat archived 31 days ago -> should be deleted.
fileD := createChatFile(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, now.Add(-31*24*time.Hour))
oldArchivedChat := createChat(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, deps.modelConfig.ID, true, now.Add(-31*24*time.Hour))
_, err = db.LinkChatFiles(ctx, database.LinkChatFilesParams{
@@ -2634,7 +2756,6 @@ func TestDeleteOldChatFiles(t *testing.T) {
now.Add(-31*24*time.Hour), oldArchivedChat.ID)
require.NoError(t, err)
// File E: 31 days old, in a chat archived 10 days ago -> should be retained.
fileE := createChatFile(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, now.Add(-31*24*time.Hour))
recentArchivedChat := createChat(ctx, t, db, rawDB, deps.user.ID, deps.org.ID, deps.modelConfig.ID, true, now.Add(-10*24*time.Hour))
_, err = db.LinkChatFiles(ctx, database.LinkChatFilesParams{
@@ -2684,12 +2805,8 @@ func TestDeleteOldChatFiles(t *testing.T) {
},
},
{
name: "UnarchiveAfterFilePurge",
name: "DirectFileDeletionCascadesLinks",
run: func(t *testing.T) {
// Validates that when dbpurge deletes chat_files rows,
// the FK cascade on chat_file_links automatically
// removes the stale links. Unarchiving a chat after
// file purge should show only surviving files.
ctx := testutil.Context(t, testutil.WaitLong)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
deps := setupChatDeps(t, db)
@@ -2711,9 +2828,6 @@ func TestDeleteOldChatFiles(t *testing.T) {
_, err = db.ArchiveChatByID(ctx, chat.ID)
require.NoError(t, err)
// Simulate dbpurge deleting files A and B. The FK
// cascade on chat_file_links_file_id_fkey should
// automatically remove the corresponding link rows.
_, err = rawDB.ExecContext(ctx, "DELETE FROM chat_files WHERE id = ANY($1)", pq.Array([]uuid.UUID{fileA, fileB}))
require.NoError(t, err)
+4
View File
@@ -4777,6 +4777,10 @@ CREATE INDEX idx_chat_diff_statuses_url_lower ON chat_diff_statuses USING btree
CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links USING btree (chat_id);
CREATE INDEX idx_chat_file_links_file_id ON chat_file_links USING btree (file_id);
CREATE INDEX idx_chat_files_created_at ON chat_files USING btree (created_at);
CREATE INDEX idx_chat_files_org ON chat_files USING btree (organization_id);
CREATE INDEX idx_chat_files_owner ON chat_files USING btree (owner_id);
@@ -0,0 +1,2 @@
DROP INDEX idx_chat_file_links_file_id;
DROP INDEX idx_chat_files_created_at;
@@ -0,0 +1,2 @@
CREATE INDEX idx_chat_file_links_file_id ON chat_file_links (file_id);
CREATE INDEX idx_chat_files_created_at ON chat_files (created_at);
+2
View File
@@ -751,6 +751,8 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
}
type chatQuerier interface {
DeleteOldChatFiles(ctx context.Context, arg DeleteOldChatFilesParams) (int64, error)
LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error)
GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error)
GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]Chat, error)
}
+7 -23
View File
@@ -188,16 +188,6 @@ type sqlcQuerier interface {
// Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows
// older than the cutoff are also purged.
DeleteOldChatDebugRuns(ctx context.Context, arg DeleteOldChatDebugRunsParams) (int64, error)
// TODO(cian): Add indexes on chats(archived, updated_at) and
// chat_files(created_at) for purge query performance.
// See: https://github.com/coder/internal/issues/1438
// Deletes chat files that are older than the given threshold and are
// not referenced by any chat that is still active or was archived
// within the same threshold window. This covers two cases:
// 1. Orphaned files not linked to any chat.
// 2. Files whose every referencing chat has been archived for longer
// than the retention period.
DeleteOldChatFiles(ctx context.Context, arg DeleteOldChatFilesParams) (int64, error)
// Deletes chats that have been archived for longer than the given
// threshold. Active (non-archived) chats are never deleted.
// All chat-scoped child tables are removed via ON DELETE CASCADE.
@@ -231,6 +221,7 @@ type sqlcQuerier interface {
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error)
DeleteUnlinkedChatFilesByIDs(ctx context.Context, arg DeleteUnlinkedChatFilesByIDsParams) (int64, error)
DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAIBudgetOverride, error)
DeleteUserAIProviderKey(ctx context.Context, arg DeleteUserAIProviderKeyParams) error
DeleteUserAIProviderKeysByProviderID(ctx context.Context, aiProviderID uuid.UUID) error
@@ -690,6 +681,8 @@ type sqlcQuerier interface {
// app_secret_id, since app_secret_id is NULL for public (secretless) clients
// and would silently exclude their tokens from this listing.
GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error)
// Locks candidate rows against foreign-key inserts for the transaction.
GetOldUnlinkedChatFileIDs(ctx context.Context, arg GetOldUnlinkedChatFileIDsParams) ([]uuid.UUID, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error)
// Returns AI spend limits and aggregate spend for groups in @group_ids that
@@ -1207,15 +1200,9 @@ type sqlcQuerier interface {
// time. chatstate calls this in a single query so the staleness check
// is atomic and does not depend on the caller's local clock.
IsChatHeartbeatStale(ctx context.Context, arg IsChatHeartbeatStaleParams) (bool, error)
// LinkChatFiles inserts file associations into the chat_file_links
// join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
// is conditional: it only proceeds when the total number of links
// (existing + genuinely new) does not exceed max_file_links. Returns
// the number of genuinely new file IDs that were NOT inserted due to
// the cap. A return value of 0 means all files were linked (or were
// already linked). A positive value means the cap blocked that many
// new links.
LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error)
// LinkChatFilesAfterLock requires the chat row lock.
// The lock serializes cap checks. The result counts rejected new links.
LinkChatFilesAfterLock(ctx context.Context, arg LinkChatFilesAfterLockParams) (int32, error)
ListAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams) ([]string, error)
// Finds all unique AI Bridge interception telemetry summaries combinations
// (provider, model, client) in the given timeframe for telemetry reporting.
@@ -1292,6 +1279,7 @@ type sqlcQuerier interface {
// entry point ChatMachine.Update uses to acquire the row lock and
// allocate a new snapshot version in one round trip.
LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid.UUID) (Chat, error)
LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
// Flips active, already-hydrated chats for an agent to dirty when the
// agent's latest snapshot hash differs from the chat's pinned hash. The
@@ -1395,10 +1383,6 @@ type sqlcQuerier interface {
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error)
// Unarchives a chat (and its children). Stale file references are
// handled automatically by FK cascades on chat_file_links: when
// dbpurge deletes a chat_files row, the corresponding
// chat_file_links rows are cascade-deleted by PostgreSQL.
UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error)
// This will always work regardless of the current state of the template version.
UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error
+43
View File
@@ -2011,6 +2011,49 @@ func TestGetAuthorizedChatsByChatFileIDACLSharing(t *testing.T) {
require.Empty(t, rows[0].GroupACL)
}
func TestLinkChatFilesDeduplicatesInput(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := testutil.Context(t, testutil.WaitMedium)
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
})
file, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: "duplicate.txt",
Mimetype: "text/plain",
Data: []byte("duplicate"),
})
require.NoError(t, err)
rejected, err := db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chat.ID,
FileIds: []uuid.UUID{file.ID, file.ID},
MaxFileLinks: 1,
})
require.NoError(t, err)
require.Zero(t, rejected)
files, err := db.GetChatFileMetadataByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, file.ID, files[0].ID)
}
func TestGetChatFileDataPrefixesByIDs(t *testing.T) {
t.Parallel()
if testing.Short() {
+74 -55
View File
@@ -5784,48 +5784,22 @@ func (q *sqlQuerier) UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebu
return i, err
}
const deleteOldChatFiles = `-- name: DeleteOldChatFiles :execrows
WITH kept_file_ids AS (
-- NOTE: This uses updated_at as a proxy for archive time
-- because there is no archived_at column. Correctness
-- requires that updated_at is never backdated on archived
-- chats. See ArchiveChatByID.
SELECT DISTINCT cfl.file_id
FROM chat_file_links cfl
JOIN chats c ON c.id = cfl.chat_id
WHERE c.archived = false
OR c.updated_at >= $1::timestamptz
),
deletable AS (
SELECT cf.id
FROM chat_files cf
LEFT JOIN kept_file_ids k ON cf.id = k.file_id
WHERE cf.created_at < $1::timestamptz
AND k.file_id IS NULL
ORDER BY cf.created_at ASC
LIMIT $2
)
DELETE FROM chat_files
USING deletable
WHERE chat_files.id = deletable.id
const deleteUnlinkedChatFilesByIDs = `-- name: DeleteUnlinkedChatFilesByIDs :execrows
DELETE FROM chat_files cf
WHERE cf.id = ANY($1::uuid[])
AND cf.created_at < $2::timestamptz
AND NOT EXISTS (
SELECT 1 FROM chat_file_links cfl WHERE cfl.file_id = cf.id
)
`
type DeleteOldChatFilesParams struct {
BeforeTime time.Time `db:"before_time" json:"before_time"`
LimitCount int32 `db:"limit_count" json:"limit_count"`
type DeleteUnlinkedChatFilesByIDsParams struct {
IDs []uuid.UUID `db:"ids" json:"ids"`
BeforeTime time.Time `db:"before_time" json:"before_time"`
}
// TODO(cian): Add indexes on chats(archived, updated_at) and
// chat_files(created_at) for purge query performance.
// See: https://github.com/coder/internal/issues/1438
// Deletes chat files that are older than the given threshold and are
// not referenced by any chat that is still active or was archived
// within the same threshold window. This covers two cases:
// 1. Orphaned files not linked to any chat.
// 2. Files whose every referencing chat has been archived for longer
// than the retention period.
func (q *sqlQuerier) DeleteOldChatFiles(ctx context.Context, arg DeleteOldChatFilesParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteOldChatFiles, arg.BeforeTime, arg.LimitCount)
func (q *sqlQuerier) DeleteUnlinkedChatFilesByIDs(ctx context.Context, arg DeleteUnlinkedChatFilesByIDsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUnlinkedChatFilesByIDs, pq.Array(arg.IDs), arg.BeforeTime)
if err != nil {
return 0, err
}
@@ -5985,6 +5959,47 @@ func (q *sqlQuerier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]
return items, nil
}
const getOldUnlinkedChatFileIDs = `-- name: GetOldUnlinkedChatFileIDs :many
SELECT cf.id
FROM chat_files cf
WHERE cf.created_at < $1::timestamptz
AND NOT EXISTS (
SELECT 1 FROM chat_file_links cfl WHERE cfl.file_id = cf.id
)
ORDER BY cf.created_at ASC
LIMIT $2
FOR UPDATE OF cf SKIP LOCKED
`
type GetOldUnlinkedChatFileIDsParams struct {
BeforeTime time.Time `db:"before_time" json:"before_time"`
LimitCount int32 `db:"limit_count" json:"limit_count"`
}
// Locks candidate rows against foreign-key inserts for the transaction.
func (q *sqlQuerier) GetOldUnlinkedChatFileIDs(ctx context.Context, arg GetOldUnlinkedChatFileIDsParams) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, getOldUnlinkedChatFileIDs, arg.BeforeTime, arg.LimitCount)
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertChatFile = `-- name: InsertChatFile :one
INSERT INTO chat_files (owner_id, organization_id, name, mimetype, data)
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::bytea)
@@ -10439,14 +10454,14 @@ func (q *sqlQuerier) IsChatHeartbeatStale(ctx context.Context, arg IsChatHeartbe
return stale, err
}
const linkChatFiles = `-- name: LinkChatFiles :one
const linkChatFilesAfterLock = `-- name: LinkChatFilesAfterLock :one
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = $1::uuid
),
new_links AS (
SELECT $1::uuid AS chat_id, unnest($2::uuid[]) AS file_id
SELECT DISTINCT $1::uuid AS chat_id, unnest($2::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
@@ -10469,22 +10484,16 @@ SELECT
(SELECT COUNT(*)::int FROM inserted) AS rejected_new_files
`
type LinkChatFilesParams struct {
type LinkChatFilesAfterLockParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
FileIds []uuid.UUID `db:"file_ids" json:"file_ids"`
MaxFileLinks int32 `db:"max_file_links" json:"max_file_links"`
}
// LinkChatFiles inserts file associations into the chat_file_links
// join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
// is conditional: it only proceeds when the total number of links
// (existing + genuinely new) does not exceed max_file_links. Returns
// the number of genuinely new file IDs that were NOT inserted due to
// the cap. A return value of 0 means all files were linked (or were
// already linked). A positive value means the cap blocked that many
// new links.
func (q *sqlQuerier) LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error) {
row := q.db.QueryRowContext(ctx, linkChatFiles, arg.ChatID, pq.Array(arg.FileIds), arg.MaxFileLinks)
// LinkChatFilesAfterLock requires the chat row lock.
// The lock serializes cap checks. The result counts rejected new links.
func (q *sqlQuerier) LinkChatFilesAfterLock(ctx context.Context, arg LinkChatFilesAfterLockParams) (int32, error) {
row := q.db.QueryRowContext(ctx, linkChatFilesAfterLock, arg.ChatID, pq.Array(arg.FileIds), arg.MaxFileLinks)
var rejected_new_files int32
err := row.Scan(&rejected_new_files)
return rejected_new_files, err
@@ -10660,6 +10669,20 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid
return i, err
}
const lockChatByID = `-- name: LockChatByID :one
SELECT id
FROM chats
WHERE id = $1::uuid
FOR UPDATE
`
func (q *sqlQuerier) LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, lockChatByID, id)
var id_2 uuid.UUID
err := row.Scan(&id_2)
return id_2, err
}
const markChatsContextDirtyByAgent = `-- name: MarkChatsContextDirtyByAgent :many
UPDATE chats
SET context_dirty_since = $1
@@ -10990,10 +11013,6 @@ FROM chats_expanded
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
`
// Unarchives a chat (and its children). Stale file references are
// handled automatically by FK cascades on chat_file_links: when
// dbpurge deletes a chat_files row, the corresponding
// chat_file_links rows are cascade-deleted by PostgreSQL.
func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error) {
rows, err := q.db.QueryContext(ctx, unarchiveChatByID, id)
if err != nil {
+19 -33
View File
@@ -27,36 +27,22 @@ JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = @chat_id::uuid
ORDER BY cf.created_at ASC;
-- TODO(cian): Add indexes on chats(archived, updated_at) and
-- chat_files(created_at) for purge query performance.
-- See: https://github.com/coder/internal/issues/1438
-- name: DeleteOldChatFiles :execrows
-- Deletes chat files that are older than the given threshold and are
-- not referenced by any chat that is still active or was archived
-- within the same threshold window. This covers two cases:
-- 1. Orphaned files not linked to any chat.
-- 2. Files whose every referencing chat has been archived for longer
-- than the retention period.
WITH kept_file_ids AS (
-- NOTE: This uses updated_at as a proxy for archive time
-- because there is no archived_at column. Correctness
-- requires that updated_at is never backdated on archived
-- chats. See ArchiveChatByID.
SELECT DISTINCT cfl.file_id
FROM chat_file_links cfl
JOIN chats c ON c.id = cfl.chat_id
WHERE c.archived = false
OR c.updated_at >= @before_time::timestamptz
),
deletable AS (
SELECT cf.id
FROM chat_files cf
LEFT JOIN kept_file_ids k ON cf.id = k.file_id
WHERE cf.created_at < @before_time::timestamptz
AND k.file_id IS NULL
ORDER BY cf.created_at ASC
LIMIT @limit_count
)
DELETE FROM chat_files
USING deletable
WHERE chat_files.id = deletable.id;
-- name: GetOldUnlinkedChatFileIDs :many
-- Locks candidate rows against foreign-key inserts for the transaction.
SELECT cf.id
FROM chat_files cf
WHERE cf.created_at < @before_time::timestamptz
AND NOT EXISTS (
SELECT 1 FROM chat_file_links cfl WHERE cfl.file_id = cf.id
)
ORDER BY cf.created_at ASC
LIMIT @limit_count
FOR UPDATE OF cf SKIP LOCKED;
-- name: DeleteUnlinkedChatFilesByIDs :execrows
DELETE FROM chat_files cf
WHERE cf.id = ANY(@ids::uuid[])
AND cf.created_at < @before_time::timestamptz
AND NOT EXISTS (
SELECT 1 FROM chat_file_links cfl WHERE cfl.file_id = cf.id
);
+10 -14
View File
@@ -64,10 +64,6 @@ FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;
-- name: UnarchiveChatByID :many
-- Unarchives a chat (and its children). Stale file references are
-- handled automatically by FK cascades on chat_file_links: when
-- dbpurge deletes a chat_files row, the corresponding
-- chat_file_links rows are cascade-deleted by PostgreSQL.
WITH updated_chats AS (
UPDATE chats SET
archived = false,
@@ -1663,22 +1659,16 @@ SELECT * FROM chat_context_resources
WHERE chat_id = @chat_id::uuid
ORDER BY source ASC;
-- name: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
-- is conditional: it only proceeds when the total number of links
-- (existing + genuinely new) does not exceed max_file_links. Returns
-- the number of genuinely new file IDs that were NOT inserted due to
-- the cap. A return value of 0 means all files were linked (or were
-- already linked). A positive value means the cap blocked that many
-- new links.
-- name: LinkChatFilesAfterLock :one
-- LinkChatFilesAfterLock requires the chat row lock.
-- The lock serializes cap checks. The result counts rejected new links.
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = @chat_id::uuid
),
new_links AS (
SELECT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
SELECT DISTINCT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
@@ -1989,6 +1979,12 @@ ORDER BY
LIMIT
1;
-- name: LockChatByID :one
SELECT id
FROM chats
WHERE id = @id::uuid
FOR UPDATE;
-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
SELECT *
+41 -143
View File
@@ -1304,7 +1304,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
})
return
}
contentBlocks, titleSource, fileIDs, inputError := createChatInputFromRequest(ctx, api.Database, req)
contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
@@ -1465,6 +1465,9 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
if writeChatHookErr(ctx, rw, err, "Chat creation denied by lifecycle hook.") {
return
}
if writeChatFileError(ctx, rw, err) {
return
}
if xerrors.Is(err, chatd.ErrInvalidModelConfigID) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
@@ -1504,25 +1507,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
linkFileIDs := fileIDs
if len(fileIDs) > 0 {
initialUser, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chat.ID,
Role: database.ChatMessageRoleUser,
})
if err != nil {
api.Logger.Warn(ctx, "load initial message for file linking",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
linkFileIDs = api.linkedFileIDsFromContent(ctx, initialUser, fileIDs)
}
}
unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, linkFileIDs)
// Re-read the chat so the response reflects the authoritative
// database state (file links are deduped in the join table).
chat, err = api.Database.GetChatByID(ctx, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -1541,13 +1525,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
response := db2sdk.Chat(chat, nil, chatFiles)
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusCreated, response)
}
@@ -2731,7 +2708,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, _, fileIDs, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -2831,6 +2808,9 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
if writeChatHookErr(ctx, rw, sendErr, "Chat message denied by lifecycle hook.") {
return
}
if writeChatFileError(ctx, rw, sendErr) {
return
}
if xerrors.Is(sendErr, chatd.ErrChatArchived) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot send messages to an archived chat.",
@@ -2882,19 +2862,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
linkFileIDs := fileIDs
if sendResult.Queued {
if sendResult.QueuedMessage != nil {
linkFileIDs = api.linkedFileIDsFromContent(ctx, database.ChatMessage{
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
Content: pqtype.NullRawMessage{RawMessage: sendResult.QueuedMessage.Content, Valid: true},
}, fileIDs)
}
} else {
linkFileIDs = api.linkedFileIDsFromContent(ctx, sendResult.Message, fileIDs)
}
unlinked, capExceeded := api.linkFilesToChat(ctx, chatID, linkFileIDs)
response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued}
if sendResult.Queued {
if sendResult.QueuedMessage != nil {
@@ -2912,13 +2879,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
}
response.Messages = append(response.Messages, convertChatMessage(inserted))
}
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
@@ -2982,7 +2942,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, _, fileIDs, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -3018,6 +2978,9 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
if writeChatHookErr(ctx, rw, editErr, "Chat message denied by lifecycle hook.") {
return
}
if writeChatFileError(ctx, rw, editErr) {
return
}
switch {
case xerrors.Is(editErr, chatd.ErrChatArchived):
@@ -3059,7 +3022,6 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, api.linkedFileIDsFromContent(ctx, editResult.Message, fileIDs))
response := codersdk.EditChatMessageResponse{Message: convertChatMessage(editResult.Message)}
// Synthetic cancellations precede the replacement with lower IDs;
// clients that seed their transcript cache from this response need
@@ -3072,13 +3034,6 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
response.Messages = append(response.Messages, convertChatMessage(inserted))
}
response.DeletedMessageIDs = editResult.DeletedMessageIDs
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
@@ -6026,12 +5981,11 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]codersdk.ChatMessagePart,
string,
[]uuid.UUID,
*codersdk.Response,
) {
content, pasteData, fileIDs, inputError := createChatInputFromParts(ctx, db, req.Content, "content")
content, pasteData, inputError := createChatInputFromParts(ctx, db, req.Content, "content")
if inputError != nil {
return nil, "", nil, inputError
return nil, "", inputError
}
// Derive titleSource through the same chatprompt.TitleText used at
// generation time; auto-titling gates on that equality. Paste blobs
@@ -6044,7 +5998,7 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code
}
titleSource = chatprompt.TitleText(content, pasteText)
}
return content, titleSource, fileIDs, nil
return content, titleSource, nil
}
// createChatInputFromParts validates input parts and converts them to
@@ -6056,15 +6010,14 @@ func createChatInputFromParts(
db database.Store,
parts []codersdk.ChatInputPart,
fieldName string,
) ([]codersdk.ChatMessagePart, map[uuid.UUID][]byte, []uuid.UUID, *codersdk.Response) {
) ([]codersdk.ChatMessagePart, map[uuid.UUID][]byte, *codersdk.Response) {
if len(parts) == 0 {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: "Content cannot be empty.",
}
}
var fileIDs []uuid.UUID
content := make([]codersdk.ChatMessagePart, 0, len(parts))
var pasteData map[uuid.UUID][]byte
for i, part := range parts {
@@ -6072,7 +6025,7 @@ func createChatInputFromParts(
case string(codersdk.ChatInputPartTypeText):
text := strings.TrimSpace(part.Text)
if text == "" {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i),
}
@@ -6080,7 +6033,7 @@ func createChatInputFromParts(
content = append(content, codersdk.ChatMessageText(text))
case string(codersdk.ChatInputPartTypeFile):
if part.FileID == uuid.Nil {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i),
}
@@ -6092,24 +6045,23 @@ func createChatInputFromParts(
chatFile, err := db.GetChatFileByID(ctx, part.FileID)
if err != nil {
if httpapi.Is404Error(err) {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i),
}
}
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Internal error.",
Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i),
}
}
if !chatfiles.IsAllowedPromptInputMediaType(chatFile.Mimetype) {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file type that cannot be used as prompt input. Allowed types: %s.", fieldName, i, chatfiles.AllowedPromptInputMediaTypesString()),
}
}
content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name))
fileIDs = append(fileIDs, part.FileID)
// Retain blob references for create-time title derivation;
// send and edit paths discard the map.
if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) {
@@ -6122,14 +6074,14 @@ func createChatInputFromParts(
// files. They have no FileID and are excluded from file tracking.
case string(codersdk.ChatInputPartTypeFileReference):
if part.FileName == "" {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageFileReference(part.FileName, part.StartLine, part.EndLine, part.Content))
default:
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf(
"%s[%d].type %q is not supported.",
@@ -6142,84 +6094,30 @@ func createChatInputFromParts(
}
if len(content) == 0 {
return nil, nil, nil, &codersdk.Response{
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName),
}
}
return content, pasteData, fileIDs, nil
return content, pasteData, nil
}
// A prompt override may remove file parts, so derive links from persisted
// content. Fall back to request IDs if parsing fails.
func (api *API) linkedFileIDsFromContent(ctx context.Context, msg database.ChatMessage, requestFileIDs []uuid.UUID) []uuid.UUID {
if len(requestFileIDs) == 0 {
return nil
func writeChatFileError(ctx context.Context, rw http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, chatstate.ErrChatFileCapExceeded):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment limit reached.",
Detail: fmt.Sprintf("A chat can reference at most %d attachments. Remove some attachments or start a new chat.", codersdk.MaxChatFileIDs),
})
case errors.Is(err, chatstate.ErrChatFileUnavailable):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment unavailable.",
Detail: "An attachment is no longer available. Upload it again and retry.",
})
default:
return false
}
parts, err := chatprompt.ParseContent(msg)
if err != nil {
api.Logger.Warn(ctx, "parse persisted message for file linking",
slog.F("message_id", msg.ID),
slog.Error(err),
)
return requestFileIDs
}
var ids []uuid.UUID
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid {
ids = append(ids, part.FileID.UUID)
}
}
return ids
}
// linkFilesToChat inserts file-link rows into the chat_file_links
// join table. Cap enforcement and dedup are handled atomically in
// SQL. On success returns (nil, false). On failure returns the full
// input fileIDs slice — linking is all-or-nothing because the
// SQL operates on the batch atomically. capExceeded indicates
// whether the failure was due to the cap being exceeded (true)
// or a database error (false).
// Failures are logged but never block the caller.
func (api *API) linkFilesToChat(ctx context.Context, chatID uuid.UUID, fileIDs []uuid.UUID) (unlinked []uuid.UUID, capExceeded bool) {
if len(fileIDs) == 0 {
return nil, false
}
rejected, err := api.Database.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: fileIDs,
})
if err != nil {
api.Logger.Error(ctx, "failed to link files to chat",
slog.F("chat_id", chatID),
slog.F("file_ids", fileIDs),
slog.Error(err),
)
return fileIDs, false
}
if rejected > 0 {
api.Logger.Warn(ctx, "file cap reached, files not linked",
slog.F("chat_id", chatID),
slog.F("file_ids", fileIDs),
slog.F("max_file_links", codersdk.MaxChatFileIDs),
)
return fileIDs, true
}
return nil, false
}
// fileLinkCapWarning builds a user-facing warning when a batch
// of file IDs was atomically rejected because the resulting
// array would exceed the per-chat file cap.
func fileLinkCapWarning(count int) string {
return fmt.Sprintf("file linking skipped: batch of %d file(s) would exceed limit of %d", count, codersdk.MaxChatFileIDs)
}
// fileLinkErrorWarning builds a user-facing warning when a
// database error prevented linking files to a chat.
func fileLinkErrorWarning(count int) string {
return fmt.Sprintf("%d file(s) could not be linked due to a server error", count)
return true
}
// fetchChatFileMetadata returns metadata for all files linked to
+16
View File
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -343,6 +344,21 @@ func TestValidateChatModelConfigProviderModel(t *testing.T) {
}
}
func TestWriteChatFileErrorUnavailable(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
rec := httptest.NewRecorder()
handled := writeChatFileError(ctx, rec, xerrors.Errorf("link files: %w", chatstate.ErrChatFileUnavailable))
require.True(t, handled)
require.Equal(t, http.StatusBadRequest, rec.Code)
var response codersdk.Response
require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
require.Equal(t, "Chat attachment unavailable.", response.Message)
require.Equal(t, "An attachment is no longer available. Upload it again and retry.", response.Detail)
}
func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) {
t.Parallel()
+39 -31
View File
@@ -8241,14 +8241,13 @@ func TestChatMessageWithFiles(t *testing.T) {
require.NoError(t, err)
// Send another message with the SAME file.
msgResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "same file again"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
require.Empty(t, msgResp.Warnings, "dedup below cap should not produce warnings")
// GET — should have exactly 1 file (deduped by SQL DISTINCT).
chatResult, err := client.GetChat(ctx, chat.ID)
@@ -8284,42 +8283,41 @@ func TestChatMessageWithFiles(t *testing.T) {
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{OrganizationID: firstUser.OrganizationID, Content: parts})
require.NoError(t, err)
require.Empty(t, chat.Warnings, "creating a chat at exactly the cap should not warn")
require.Len(t, chat.Files, codersdk.MaxChatFileIDs, "all files should be linked on creation")
// Upload one more file.
extraResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "one-too-many.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Sending a message with the extra file should succeed
// (message goes through) but the file should NOT be linked
// (cap enforced in SQL). The response includes a warning.
msgResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
messagesBefore, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "one too many"},
{Type: codersdk.ChatInputPartTypeFile, FileID: extraResp.ID},
},
})
require.NoError(t, err)
require.NotEmpty(t, msgResp.Warnings, "response should warn about unlinked files")
require.Contains(t, msgResp.Warnings[0], "file linking skipped")
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "attachment limit")
// The extra file should NOT appear in the chat's files.
messagesAfter, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, messagesAfter.Messages, len(messagesBefore.Messages), "rejected send should not persist a message")
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs,
"file count should not exceed the cap")
// Sending a message referencing an already-linked file
// should succeed with no warnings (dedup, no array growth).
msgResp2, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "re-reference existing"},
{Type: codersdk.ChatInputPartTypeFile, FileID: fileIDs[0]},
},
})
require.NoError(t, err)
require.Empty(t, msgResp2.Warnings, "re-referencing an existing file should not warn")
require.NoError(t, err, "re-referencing an already-linked file must not count against the cap")
})
t.Run("FileCapOnCreate", func(t *testing.T) {
@@ -8347,17 +8345,16 @@ func TestChatMessageWithFiles(t *testing.T) {
for _, fid := range fileIDs {
parts = append(parts, codersdk.ChatInputPart{Type: codersdk.ChatInputPartTypeFile, FileID: fid})
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{OrganizationID: firstUser.OrganizationID, Content: parts})
require.NoError(t, err, "chat creation should succeed even when cap is exceeded")
require.NotEmpty(t, chat.Warnings, "response should warn about unlinked files")
require.Contains(t, chat.Warnings[0], "file linking skipped")
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{OrganizationID: firstUser.OrganizationID, Content: parts})
require.Error(t, err, "chat creation over the cap should fail")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "attachment limit")
// Only MaxChatFileIDs files should actually be linked.
// With SQL-level batch rejection, ALL files are rejected
// when the result would exceed the cap.
chatResult, err := client.GetChat(ctx, chat.ID)
chats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
require.Empty(t, chatResult.Files, "no files should be linked when batch exceeds cap")
require.Empty(t, chats, "rejected create should not persist a chat")
})
}
@@ -8770,7 +8767,7 @@ func TestPatchChatMessage(t *testing.T) {
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{OrganizationID: firstUser.OrganizationID, Content: parts})
require.NoError(t, err)
require.Empty(t, chat.Warnings, "all files should link on create")
require.Len(t, chat.Files, codersdk.MaxChatFileIDs)
// Find the user message.
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
@@ -8787,17 +8784,28 @@ func TestPatchChatMessage(t *testing.T) {
// Upload one more file and try to link via edit.
extra, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "one-too-many.png", bytes.NewReader(pngData))
require.NoError(t, err)
edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
_, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "edit with extra file"},
{Type: codersdk.ChatInputPartTypeFile, FileID: extra.ID},
},
})
require.NoError(t, err)
require.NotEmpty(t, edited.Warnings, "edit should surface cap warning")
require.Contains(t, edited.Warnings[0], "file linking skipped")
require.Error(t, err, "edit over the cap should fail")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "attachment limit")
// Verify the cap is still enforced.
messagesResult, err = client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var found bool
for _, msg := range messagesResult.Messages {
if msg.ID == userMessageID {
found = true
break
}
}
require.True(t, found, "original user message should survive a rejected edit")
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs,
+2
View File
@@ -47,6 +47,8 @@ There is other data that is held in the database and is associated with a chat,
We call it **metadata**. The core state machine concerns itself with **execution state**. As a general guideline, a piece of data is execution state if the core state machine needs it to decide what the next state transition may be, or if it's directly modified by a state transition. For example, a queued message is part of the execution state because it impacts what the next action of the agent loop can be. If the agent loop finishes processing a user message and would otherwise stop, but there's a queued message, the agent loop will start processing the queued message instead. On the other hand, a chat's title does not impact the agent loop at all - it's just a label that helps the user identify the chat.
File links are metadata, but one invariant is enforced at transition time: if a transition persists message content that references uploaded files (chat create, message send, queued send, or message edit), it records the file links in the same transaction. If linking would exceed the per-chat attachment cap, the whole transition is rejected. File retention skips files that are still linked to existing chats, so a persisted message must never reference a file without a link.
If the distinction isn't completely clear to you at this point, don't worry. It should become clearer as you learn more about the core state machine.
## Execution states
+9
View File
@@ -1378,6 +1378,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
},
ClientType: opts.ClientType,
InitialMessages: initialMessages,
FileIDs: chatprompt.FileIDs(contentParts),
})
if err != nil {
return database.Chat{}, err
@@ -1557,6 +1558,11 @@ func (p *Server) SendMessage(
// previous queue head into history; report those inserts so
// clients can update their caches.
result.InsertedMessages = sendResult.InsertedMessages
// File-link errors must roll back the message.
if err := chatstate.LinkFiles(ctx, store, opts.ChatID, chatprompt.FileIDs(contentParts)); err != nil {
return err
}
// Capture the post-transition chat inside the same
// transaction so the returned chat and the watch event
// reflect the snapshot bump and status change produced by
@@ -1866,6 +1872,9 @@ func (p *Server) EditMessage(
inserted = append(inserted, editResult.SuffixMessages...)
result.InsertedMessages = inserted
result.DeletedMessageIDs = editResult.DeletedMessageIDs
if err := chatstate.LinkFiles(ctx, store, opts.ChatID, chatprompt.FileIDs(contentParts)); err != nil {
return err
}
// Capture the post-edit chat inside the same transaction so
// the returned chat and the debug-cleanup cutoff use the
// snapshot bump and updated_at stamped by the transition.
+208
View File
@@ -1646,6 +1646,214 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
require.Len(t, messages, 1)
}
func TestMessageFileLinking(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
replica := newTestServer(t, db, ps, uuid.New())
ctx := testutil.Context(t, testutil.WaitLong)
user, org, model := seedChatDependencies(t, db)
insertFile := func(name string) uuid.UUID {
t.Helper()
row, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: name,
Mimetype: "image/png",
Data: []byte("png-bytes"),
})
require.NoError(t, err)
return row.ID
}
linkedFileIDs := func(chatID uuid.UUID) []uuid.UUID {
t.Helper()
rows, err := db.GetChatFileMetadataByChatID(ctx, chatID)
require.NoError(t, err)
ids := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids
}
fileCreate := insertFile("create.png")
fileSend := insertFile("send.png")
fileQueued := insertFile("queued.png")
fileEdit := insertFile("edit.png")
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "file-linking",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("with attachment"),
codersdk.ChatMessageFile(fileCreate, "image/png", "create.png"),
},
})
require.NoError(t, err)
require.Contains(t, linkedFileIDs(chat.ID), fileCreate)
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusWaiting,
})
require.NoError(t, err)
sendResult, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("another attachment"),
codersdk.ChatMessageFile(fileSend, "image/png", "send.png"),
},
})
require.NoError(t, err)
require.False(t, sendResult.Queued)
require.Contains(t, linkedFileIDs(chat.ID), fileSend)
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusWaiting,
})
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
require.NoError(t, err)
var userMessageID int64
for _, msg := range messages {
if msg.Role == database.ChatMessageRoleUser {
userMessageID = msg.ID
break
}
}
require.NotZero(t, userMessageID)
_, err = replica.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
EditedMessageID: userMessageID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("edited attachment"),
codersdk.ChatMessageFile(fileEdit, "image/png", "edit.png"),
},
})
require.NoError(t, err)
editedLinks := linkedFileIDs(chat.ID)
require.Contains(t, editedLinks, fileEdit)
require.Contains(t, editedLinks, fileCreate)
// Queued files must be linked before promotion to prevent purge.
_, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusRunning,
WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
})
require.NoError(t, err)
queuedResult, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued attachment"),
codersdk.ChatMessageFile(fileQueued, "image/png", "queued.png"),
},
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
})
require.NoError(t, err)
require.True(t, queuedResult.Queued)
require.Contains(t, linkedFileIDs(chat.ID), fileQueued)
}
func TestMessageFileLinkingCapRollsBack(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
replica := newTestServer(t, db, ps, uuid.New())
ctx := testutil.Context(t, testutil.WaitLong)
user, org, model := seedChatDependencies(t, db)
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "cap-rollback",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
capFileIDs := make([]uuid.UUID, 0, codersdk.MaxChatFileIDs)
for i := range codersdk.MaxChatFileIDs {
row, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: fmt.Sprintf("cap-%d.png", i),
Mimetype: "image/png",
Data: []byte("png-bytes"),
})
require.NoError(t, err)
capFileIDs = append(capFileIDs, row.ID)
}
rejected, err := db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: capFileIDs,
})
require.NoError(t, err)
require.Zero(t, rejected)
extra, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: "extra.png",
Mimetype: "image/png",
Data: []byte("png-bytes"),
})
require.NoError(t, err)
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusWaiting,
})
require.NoError(t, err)
messagesBefore, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
require.NoError(t, err)
_, err = replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("one too many"),
codersdk.ChatMessageFile(extra.ID, "image/png", "extra.png"),
},
})
require.ErrorIs(t, err, chatstate.ErrChatFileCapExceeded)
messagesAfter, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
require.NoError(t, err)
require.Len(t, messagesAfter, len(messagesBefore), "rejected send must not persist a message")
files, err := db.GetChatFileMetadataByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, files, codersdk.MaxChatFileIDs)
sendResult, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("re-reference"),
codersdk.ChatMessageFile(capFileIDs[0], "image/png", "cap-0.png"),
},
})
require.NoError(t, err)
require.False(t, sendResult.Queued)
}
func TestPlanTurnPromptContract(t *testing.T) {
t.Parallel()
+12
View File
@@ -94,6 +94,18 @@ func ExtractFileID(raw json.RawMessage) (uuid.UUID, error) {
return uuid.Parse(envelope.Data.FileID)
}
// FileIDs returns the valid file IDs referenced by file parts.
func FileIDs(parts []codersdk.ChatMessagePart) []uuid.UUID {
var ids []uuid.UUID
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeFile || !part.FileID.Valid {
continue
}
ids = append(ids, part.FileID.UUID)
}
return ids
}
// ConvertMessagesWithFiles converts persisted chat messages into LLM
// prompt messages, resolving user file references via the provided
// resolver. Missing-data placeholders are emitted only for replayed
+6
View File
@@ -49,6 +49,12 @@ var (
// wraps this sentinel.
ErrMessageQueueFull = xerrors.New("chat message queue is full")
// ErrChatFileCapExceeded reports a [LinkFiles] cap rejection.
ErrChatFileCapExceeded = xerrors.New("chat attachment cap exceeded")
// ErrChatFileUnavailable reports a missing file passed to [LinkFiles].
ErrChatFileUnavailable = xerrors.New("chat attachment unavailable")
// ErrToolResultDuplicate is returned by [Tx.CompleteRequiresAction]
// when the same tool_call_id appears more than once in the
// submitted results.
+37
View File
@@ -0,0 +1,37 @@
package chatstate
import (
"context"
"errors"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
// LinkFiles links files, returning [ErrChatFileCapExceeded] for cap rejections
// and [ErrChatFileUnavailable] for missing files. Use the caller's transaction
// so failures roll back related writes; existing links use no additional slots.
func LinkFiles(ctx context.Context, store database.Store, chatID uuid.UUID, fileIDs []uuid.UUID) error {
if len(fileIDs) == 0 {
return nil
}
rejected, err := store.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: fileIDs,
})
if err != nil {
wrapped := xerrors.Errorf("link chat files: %w", err)
if database.IsForeignKeyViolation(err, database.ForeignKeyChatFileLinksFileID) {
return errors.Join(ErrChatFileUnavailable, wrapped)
}
return wrapped
}
if rejected > 0 {
return ErrChatFileCapExceeded
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package chatstate_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
)
func TestLinkFilesUnavailable(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
fileID := uuid.New()
foreignKeyErr := &pq.Error{
Code: pq.ErrorCode("23503"),
Constraint: string(database.ForeignKeyChatFileLinksFileID),
}
store.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileID},
}).Return(int32(0), foreignKeyErr)
err := chatstate.LinkFiles(context.Background(), store, chatID, []uuid.UUID{fileID})
require.ErrorIs(t, err, chatstate.ErrChatFileUnavailable)
require.ErrorIs(t, err, foreignKeyErr)
}
+5
View File
@@ -35,6 +35,8 @@ type CreateChatInput struct {
DynamicTools pqtype.NullRawMessage
ClientType database.ChatClientType
InitialMessages []Message
// FileIDs are linked atomically with the initial messages.
FileIDs []uuid.UUID
}
// CreateChatResult is the value returned by [CreateChat]. It carries
@@ -131,6 +133,9 @@ func insertChat(
if err != nil {
return xerrors.Errorf("insert initial messages: %w", err)
}
if err := LinkFiles(ctx, store, chat.ID, input.FileIDs); err != nil {
return err
}
refreshed, err := store.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("reload chat after initial messages: %w", err)
+14 -1
View File
@@ -477,7 +477,15 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) {
t.Cleanup(consumer.Close)
server := newHookTestServer(t, db, ps, consumer)
upload := codersdk.ChatMessageFile(uuid.New(), "image/png", "edited.png")
chatFile, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: "edited.png",
Mimetype: "image/png",
Data: []byte("png-bytes"),
})
require.NoError(t, err)
upload := codersdk.ChatMessageFile(chatFile.ID, chatFile.Mimetype, chatFile.Name)
reference := codersdk.ChatMessageFileReference("main.go", 1, 3, "package main")
result, err := server.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
@@ -490,6 +498,11 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) {
},
})
require.NoError(t, err)
linkedFiles, err := db.GetChatFileMetadataByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, linkedFiles, 1)
require.Equal(t, chatFile.ID, linkedFiles[0].ID)
parts, err := chatprompt.ParseContent(result.Message)
require.NoError(t, err)
require.Equal(t, []codersdk.ChatMessagePart{
+7 -10
View File
@@ -2,11 +2,13 @@ package chatd
import (
"context"
"errors"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/codersdk"
@@ -91,16 +93,11 @@ func storeLinkedChatFileTx(
return chattool.AttachmentMetadata{}, xerrors.Errorf("insert chat file: %w", err)
}
rejected, err := tx.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{row.ID},
})
if err != nil {
return chattool.AttachmentMetadata{}, xerrors.Errorf("link chat file: %w", err)
}
if rejected > 0 {
return chattool.AttachmentMetadata{}, xerrors.Errorf("chat already has the maximum of %d linked files", codersdk.MaxChatFileIDs)
if err := chatstate.LinkFiles(ctx, tx, chatID, []uuid.UUID{row.ID}); err != nil {
if errors.Is(err, chatstate.ErrChatFileCapExceeded) {
return chattool.AttachmentMetadata{}, xerrors.Errorf("chat already has the maximum of %d linked files", codersdk.MaxChatFileIDs)
}
return chattool.AttachmentMetadata{}, err
}
return chattool.AttachmentMetadata{
@@ -10,9 +10,12 @@ import (
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestStoreChatAttachment_Success(t *testing.T) {
@@ -34,7 +37,7 @@ func TestStoreChatAttachment_Success(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
@@ -80,7 +83,7 @@ func TestStoreChatAttachment_UsesDetectNameForClassification(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
@@ -121,7 +124,7 @@ func TestStoreChatAttachment_AllowsUnsupportedPromptInputType(t *testing.T) {
}
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`)
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
@@ -175,7 +178,7 @@ func TestStoreChatAttachment_WorkspaceLookupError(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{}, context.DeadlineExceeded)
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
@@ -199,7 +202,7 @@ func TestStoreChatAttachment_InsertError(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: uuid.New()}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{}, context.DeadlineExceeded)
@@ -228,7 +231,7 @@ func TestStoreChatAttachment_StrictCapError(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).Return(database.InsertChatFileRow{ID: fileID}, nil)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
@@ -261,7 +264,7 @@ func TestStoreChatAttachment_LinkError(t *testing.T) {
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}
expectStoreChatAttachmentTx(t, db, tx)
expectStoreChatAttachmentInTx(t, db, tx)
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{ID: fileID}, nil)
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
@@ -276,7 +279,126 @@ func TestStoreChatAttachment_LinkError(t *testing.T) {
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
}
func expectStoreChatAttachmentTx(t *testing.T, db, tx *dbmock.MockStore) {
func TestStoreChatAttachment_SerializesCapCheck(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t)
user, _, model := seedInternalChatDeps(t, db)
workspace, _, _ := seedWorkspaceBinding(t, db, user.ID)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: workspace.OrganizationID,
OwnerID: user.ID,
WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true},
LastModelConfigID: model.ID,
})
for i := range codersdk.MaxChatFileIDs - 1 {
insertLinkedChatFile(
ctx,
t,
db,
chat.ID,
user.ID,
workspace.OrganizationID,
fmt.Sprintf("existing-%02d.txt", i),
"text/plain",
[]byte("existing"),
)
}
lockKey := int64(uuid.New().ID())
_, err := rawDB.ExecContext(ctx, fmt.Sprintf(`
CREATE FUNCTION test_block_chat_file_link() RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(%d);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER test_block_chat_file_link
BEFORE INSERT ON chat_file_links
FOR EACH ROW EXECUTE FUNCTION test_block_chat_file_link();
`, lockKey))
require.NoError(t, err)
barrierConn, err := rawDB.Conn(ctx)
require.NoError(t, err)
barrierReleased := false
t.Cleanup(func() {
if !barrierReleased {
_, _ = barrierConn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", lockKey)
}
_ = barrierConn.Close()
})
_, err = barrierConn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", lockKey)
require.NoError(t, err)
server := &Server{db: db}
attachmentResults := make(chan error, 2)
for i := range 2 {
go func() {
_, err := server.storeChatAttachment(
ctx,
chat,
fmt.Sprintf("concurrent-%d.txt", i),
"attachment.txt",
[]byte("attachment"),
)
attachmentResults <- err
}()
}
var linkWaits, chatLockWaits int
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
err := rawDB.QueryRowContext(ctx, `
SELECT
COUNT(*) FILTER (
WHERE query LIKE '%-- name: LinkChatFilesAfterLock%'
AND wait_event = 'advisory'
),
COUNT(*) FILTER (
WHERE query LIKE '%-- name: LockChatByID%'
AND wait_event_type = 'Lock'
)
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
`).Scan(&linkWaits, &chatLockWaits)
return err == nil && linkWaits >= 1 && linkWaits+chatLockWaits == 2
}, testutil.IntervalFast, "wait for both attachment transactions")
require.NoError(t, ctx.Err(), "waiting for attachment transactions")
_, err = barrierConn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockKey)
barrierReleased = true
require.NoError(t, err)
var successes, capRejections int
for range 2 {
select {
case err := <-attachmentResults:
if err == nil {
successes++
continue
}
require.ErrorContains(t, err, fmt.Sprintf("chat already has the maximum of %d linked files", codersdk.MaxChatFileIDs))
capRejections++
case <-ctx.Done():
require.Failf(t, "attachment store did not finish", "context ended: %v", ctx.Err())
}
}
require.Equal(t, 1, successes)
require.Equal(t, 1, capRejections)
files, err := db.GetChatFileMetadataByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, files, codersdk.MaxChatFileIDs)
var fileCount int
require.NoError(t, rawDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM chat_files").Scan(&fileCount))
require.Equal(t, codersdk.MaxChatFileIDs, fileCount)
}
func expectStoreChatAttachmentInTx(t *testing.T, db, tx *dbmock.MockStore) {
t.Helper()
db.EXPECT().InTx(gomock.Any(), gomock.AssignableToTypeOf(&database.TxOptions{})).DoAndReturn(
@@ -17,10 +17,10 @@ A background process runs approximately every 10 minutes to remove expired
conversation data. Only archived conversations are eligible for deletion —
active (non-archived) conversations are never purged.
When an archived conversation exceeds the retention period, it is deleted along
with its messages, diff statuses, and queued messages via cascade. Orphaned
files (not referenced by any active or recently-archived conversation) are also
deleted. Both operations run in batches of 1,000 rows per cycle.
When an archived conversation exceeds the retention period, Coder deletes it along with its messages, diff statuses, and queued messages.
Coder retains an attached file while any conversation references it, regardless of whether the conversation is active or archived.
A file that exceeds the retention period becomes eligible for deletion only after no conversations reference it.
Conversation and file cleanup operations run in batches of 1,000 rows per cycle.
## Configuration
@@ -37,13 +37,12 @@ PUT /api/experimental/chats/config/retention-days
## What gets deleted
| Data | Condition | Cascade |
|------------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| Archived conversations | Archived longer than retention period | Messages, diff statuses, queued messages deleted via CASCADE. |
| Conversation files | Older than retention period AND not referenced by any active or recently-archived conversation | — |
| Data | Condition | Cascade |
|------------------------|--------------------------------------------------------------------|---------------------------------------------------------------|
| Archived conversations | Archived longer than retention period | Messages, diff statuses, queued messages deleted via CASCADE. |
| Conversation files | Older than retention period and not referenced by any conversation | None |
## Unarchive safety
If a user unarchives a conversation whose files were purged, stale file
references are automatically cleaned up by FK cascades. The conversation
remains usable but previously attached files are no longer available.
Archiving a conversation does not make its attached files eligible for deletion.
If you unarchive a conversation before Coder purges it, its attachments remain available, even when the files exceed the retention period.
@@ -221,7 +221,7 @@ const AttachmentFallbackTile: FC<{
// browser exposes nothing useful) stays a plain tile.
const tooltipBody =
state.kind === "expired"
? "Chat attachments are deleted after the retention window set for this deployment."
? "Attachments are kept while any chat references them. After all references are removed, they are deleted once they are older than this deployment's retention window."
: state.detail;
if (!tooltipBody) {
return tile;
@@ -736,7 +736,7 @@ export const UserMessageWithExpiredImage: Story = {
// copy survives any operator-chosen retention window.
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
/kept while any chat references them/i,
);
},
};
@@ -1057,7 +1057,7 @@ export const UserMessageWithExpiredTextAttachment: Story = {
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
/kept while any chat references them/i,
);
},
};