The archiver updated the document row inside a transaction and then waited on media.lock. A concurrent file move holding media.lock and updating the same row could deadlock with it, and the unique archive filename was generated outside the lock from an instance loaded before the parse.
The archiver now acquires media.lock before opening its transaction, refreshes the document under the lock, and moves the archive and thumbnail inside that block, so it never waits on media.lock while holding the row.
Tasks such as bulk_update_documents moved a document's files once for its manual post_save and again for every save its workflows made, each time taking the global media lock.
All tasks now run through FileMoveScopedTask, which opens a file move scope around the task body, including when the task raises. bulk_update_documents flushes after each document so long bulk edits still make steady progress.
A tag PATCH moved files from inside the tags.set() transaction while holding the document row lock. A concurrent save holding media.lock and waiting on that row deadlocked with it, and every later save on the instance queued behind media.lock until the webserver restarted.
FileMoveMiddleware opens a file move scope around every request, in sync or async mode, so each changed document is moved once after the view returns and never while a transaction holds row locks.
Each save of a document moved its files on the spot, so one logical change could move them several times, and moves could run inside another open transaction.
defer_file_moves() opens a ContextVar-backed scope. Inside it, move requests are recorded and the outermost scope moves each document once on exit, or on commit if a transaction is still open. A failing move is logged and does not stop the others. Outside any scope moves stay immediate.
The file move receiver refreshed the caller's own instance from the database, which wiped unsaved in-memory changes. It ran on every m2m_changed action including pre_add and pre_remove, wrote modified even when nothing moved, and its no-move branch skipped trashed documents.
The move body now lives in documents.file_moves.move_files_for_document. It skips documents without a filename before taking media.lock, then takes the lock with a 300 second timeout, loads a fresh instance by pk, writes only when a filename column changes, and returns the instance. The receiver ignores reverse and pre_* m2m actions and copies just the final filename columns back onto the caller's instance.
Every caller built its own FileLock on media.lock, so code already holding it could not call into code that takes it again without blocking itself. That made it impossible to take media.lock before opening a transaction in paths that also attach files or move files inside that transaction.
media_lock() hands out one FileLock instance per path with per-thread hold counts, so a thread re-enters while other threads and processes stay excluded. All media.lock call sites now use it.
A full save() of a Document instance loaded before another process moved its file wrote the old filename back. The DB then pointed at a path that no longer existed, and recovery only worked when the recomputed path happened to match the file's real location.
Document.save() now leaves filename and archive_filename out of full saves on existing rows. Writers that own those columns opt in with write_file_paths=True or an explicit update_fields. A full save of a row deleted elsewhere now raises instead of re-inserting it.
The vec0 default of 1024 rows per vector chunk makes each chunk a
multi-megabyte blob. On spinning disks, indexing a document took about
2.5 seconds and re-indexing one about 13 seconds. With 32 rows per
chunk the same operations take about 35 and 180 milliseconds, with the
write-ahead log a third of the size and no cost in query speed or file
size.
Pair that with 64 KiB SQLite pages, which made cold scans about 35%
faster and warm ones about 45% faster at no write cost once the chunks
are small. The page size is set before the file enters WAL mode, so it
applies to new files.
Document chat held the shared index read lock for the whole request,
including the LLM stream, which can take minutes. A compaction or
migration swap waiting for exclusive access could time out behind it
and be skipped until the next run.
Now that the answer is synthesized from the nodes already retrieved,
nothing after the vector search touches the store. Hold the lock only
while loading the index and searching, and release it before synthesis.
Document chat searched the vector store twice per question: once
explicitly to build the reference list, and again inside
RetrieverQueryEngine.query(), which retrieves before synthesizing. That
meant two query-embedding calls and two full vector searches each time.
The engine added nothing beyond the retriever call (no node
postprocessors are configured), so drop it and synthesize directly from
the nodes already retrieved. The streaming response is unchanged.
* Fix: match Apple Mail tag colors case-insensitively
* Fix: reject OAuth callbacks with an unrecognized scope
* Fix: refuse to refresh OAuth tokens for non-OAuth accounts or missing refresh tokens
* Fix: parse datetime strings in the format_datetime filter and reject invalid ones
* Fix: reject a PAPERLESS_URL without a hostname
* Fix: report a database error in the system status instead of failing with a 500
* Fix: surface the database error when refreshing a document before moving its files
* Fix: report no unapplied migrations when the applied migrations cannot be read
* Fix: return the parsed value directly in format_datetime for typing
* Chore: annotate the paperless_dirs fixture in the refresh failure test
* Allow null for the latest_migration system status type
---------
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
* Chore: Import dateparser lazily so processes that never parse dates skip it
* Chore: Import turbohtml lazily in the mail parser
* Chore: Import lxml lazily in the SVG validator
* Chore: Import paperless_ai.indexing lazily in document tasks
* Chore: Import paperless_ai helpers lazily in views
* Chore: Read the OCRmyPDF sRGB profile on first use instead of at import
* Chore: Import pikepdf and img2pdf lazily so startup does not load them
* Chore: Reuse the initialized Django ASGI application for HTTP routing
* Chore: drop get_groups_with_only_permission
It worked around get_groups_with_perms lacking only_with_perms_in, which
django-guardian added in 3.1.0. The pinned version is newer.
* Chore: consolidate direct grant lookups into get_permission_grantees
Six places listed who holds a permission on an object, each with its own
query and result shape, and one had already drifted into expanding group
members.
* Chore: drop the unreachable empty viewer grant default
get_permission_grantees returns an entry for every requested document.
* Chore: drop set_permissions_for_object in favor of the bulk helper
The same grant rules were implemented twice, and only the bulk version
validated every action before writing, so the single-object one could leave
a partial change behind.
* Chore: type the set-permissions dict
Callers passed whatever they had, including querysets of User and Group
objects that only worked because id__in accepts a subquery.