mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+74
-55
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user