fix: gate Firefox focus sentinel on changeStarted; preserve file row on storage delete failure

- collectDirectoryFiles Firefox fallback now tracks whether the change
  event has started handling files and clears the window-focus
  sentinel timer as soon as it has. Previously the 500 ms timer could
  fire mid-hash for a large selection and resolve the Promise early
  with a partial files array — which in sync mode would misclassify
  unhashed files as removed and delete them from the KB even though
  the user actually selected them. Empty-picker cancellation still
  resolves cleanly because changeStarted stays false in that case.
- remove_file_from_knowledge_by_id no longer drops the file DB row
  when Storage.delete_file fails. A transient S3/GCS failure would
  otherwise leave an orphan blob with no metadata row to retry
  against. The KB association and vector entries are already gone, so
  the caller sees the file removed from the KB; the 500 response
  surfaces the storage failure and a later retry (manual or automated)
  can complete the cleanup once storage recovers.
This commit is contained in:
DrMelone 2026-04-13 01:09:15 +02:00
parent a2238f17b6
commit 8f5e6a439d
2 changed files with 35 additions and 5 deletions

View file

@ -1057,14 +1057,24 @@ async def remove_file_from_knowledge_by_id(
pass
# Delete the object-storage blob before dropping the DB row so we
# still have file.path available; previously this endpoint only
# removed the DB record and orphaned the blob in S3/GCS/local.
# still have file.path available. If storage deletion fails we
# must NOT drop the DB row: a transient S3/GCS failure would
# otherwise leave an orphan blob with no metadata to retry
# against. Keeping the file row lets an operator re-issue the
# delete once the backend recovers.
try:
Storage.delete_file(file.path)
except Exception as storage_err:
log.warning(
log.exception(
f'Failed to delete storage blob for {form_data.file_id}: {storage_err}'
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
'File unlinked from knowledge base, but deleting the '
'stored blob failed; file record kept for retry.'
),
)
# Delete file from database
await Files.delete_file_by_id(form_data.file_id, db=db)

View file

@ -481,9 +481,15 @@
// files, or we resolve with an empty list and the caller's
// existing empty-check toasts.
let settled = false;
let changeStarted = false;
let focusTimer: ReturnType<typeof setTimeout> | null = null;
const finish = (err?: unknown) => {
if (settled) return;
settled = true;
if (focusTimer !== null) {
clearTimeout(focusTimer);
focusTimer = null;
}
if (input.parentNode) {
input.parentNode.removeChild(input);
}
@ -496,11 +502,25 @@
};
const onFocus = () => {
// 'change' fires after 'focus' returns to the window, so
// wait briefly before deciding the user cancelled.
setTimeout(() => finish(), 500);
// wait briefly before deciding the user cancelled. If a
// change event actually started handling files before the
// timer expires, changeStarted gates finish() so we don't
// resolve in the middle of hashing a large batch with a
// partial files array.
focusTimer = setTimeout(() => {
focusTimer = null;
if (!changeStarted) {
finish();
}
}, 500);
};
input.onchange = async () => {
changeStarted = true;
if (focusTimer !== null) {
clearTimeout(focusTimer);
focusTimer = null;
}
try {
const inputFiles = Array.from(input.files || []).filter(
(file) => !hasHiddenFolder(file.webkitRelativePath) && !file.name.startsWith('.')