Updates to these ideas

This commit is contained in:
stumpylog
2026-09-29 13:09:58 -07:00
parent cfe05a7e03
commit c8a00a05a8
3 changed files with 333 additions and 145 deletions
@@ -2,6 +2,14 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Updated 2026-09-24:** line references below are refreshed against current
> `dev`. More importantly: the motivating bugs (#12386 and the tag/m2m race)
> are **already fixed** by #12389 and #13178, independently of this plan — see
> "Status update (2026-09-24)" in the linked spec. This is now a structural
> cleanup (kill `use_overrides` dual-mode branching and the `original_file`
> parameter plumbing), not an active-bug fix. Task 7 is rescoped accordingly
> (the regression test it originally asked for already exists).
**Goal:** Replace the `use_overrides` dual-mode branching in `run_workflows` with a polymorphic `WorkflowRunContext`, and make the workflow-execution → file-rename sequence deterministic via a `ContextVar` guard.
**Architecture:** A new `documents/workflows/context.py` module defines a `WorkflowRunContext` `Protocol` and two implementations — `ConsumptionContext` (wraps `ConsumableDocument` + `DocumentMetadataOverrides`) and `PersistedContext` (wraps a real `Document`). `run_workflows` becomes a flat match-and-dispatch loop with no mode flag. A module-level `ContextVar` guard suppresses `update_filename_and_move_files` for the duration of a workflow run; the file rename is invoked once, explicitly, after the run.
@@ -144,7 +152,7 @@ git commit -m "Add ContextVar guard for workflow runner"
- Test: `src/documents/tests/test_workflow_context.py`
The `ConsumptionContext` absorbs the `use_overrides=True` branches currently in
`run_workflows` (`handlers.py:854-1010`) and `build_workflow_action_context`
`run_workflows` (`handlers.py:862-1051`) and `build_workflow_action_context`
(`actions.py:33,53-83`).
- [ ] **Step 1: Write the failing test**
@@ -446,7 +454,7 @@ git commit -m "Add WorkflowRunContext protocol and ConsumptionContext"
- Test: `src/documents/tests/test_workflow_context.py`
`PersistedContext` absorbs the `use_overrides=False` branches of `run_workflows`
(`handlers.py:896-1005`) and `build_workflow_action_context` (`actions.py:35-51`).
(`handlers.py:904-1046`) and `build_workflow_action_context` (`actions.py:35-51`).
- [ ] **Step 1: Write the failing test**
@@ -702,7 +710,7 @@ def build_workflow_context(
> Note: `build_placeholder_context` here is moved verbatim from the
> non-`use_overrides` branch of `build_workflow_action_context`
> (`actions.py:35-51`). `_WORKFLOW_SAVE_FIELDS` and `persist()` reproduce the
> save at `handlers.py:987-996` — keep the field list and the comment.
> save at `handlers.py:1028-1037` — keep the field list and the comment.
- [ ] **Step 4: Run test to verify it passes**
@@ -724,11 +732,18 @@ git commit -m "Add PersistedContext and build_workflow_context factory"
**Files:**
- Modify: `src/documents/signals/handlers.py:431-667`
- Modify: `src/documents/signals/handlers.py:437-673`
This task is a pure extraction — no behavior change yet. The guard check is
added in Task 6.
> **Note:** re-verify these line numbers against current `handlers.py` before
> starting — this file also received a checksum-based "already moved" recovery
> fix (#12389, `_path_matches_checksum` at `handlers.py:416-420` plus changes
> inside `validate_move`) since this plan was written. That logic is unrelated
> to this extraction and must be carried over unchanged inside the extracted
> body — do not simplify or remove it.
- [ ] **Step 1: Confirm the regression baseline is green**
Run: `uv run pytest documents/tests/test_file_handling.py -q`
@@ -737,8 +752,8 @@ Expected: PASS (records the pre-change baseline for the rename logic)
- [ ] **Step 2: Split the function**
In `src/documents/signals/handlers.py`, replace the `update_filename_and_move_files`
definition (currently `def update_filename_and_move_files(...)` at line 434,
through the end of its body at line 667) with two definitions:
definition (currently `def update_filename_and_move_files(...)` at line 440,
through the end of its body at line 673) with two definitions:
```python
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
@@ -758,13 +773,17 @@ def update_filename_and_move_files(
def move_files_for_document(instance: Document) -> None:
def validate_move(instance, old_path: Path, new_path: Path, root: Path) -> None:
... # body unchanged from current lines 444-463
def validate_move(
instance, old_path: Path, new_path: Path, root: Path
) -> None: ... # body unchanged from current lines 444-463
```
Move the entire body currently between line 444 (`def validate_move`) and line
667 into `move_files_for_document`, **unchanged**, with one edit: the recursive
call at the end (currently `handlers.py:660-667`) must call the new function:
Move the entire body currently between line 450 (`def validate_move`) and line
673 into `move_files_for_document`, **unchanged**, with one edit: the recursive
call at the end (currently `handlers.py:665-673`, which today calls the whole
receiver directly — `update_filename_and_move_files(Document, version_doc)`,
passing a synthetic `Document` `sender` to satisfy the receiver's signature)
must instead call the new function directly:
```python
# Keep version files in sync with root
@@ -843,7 +862,7 @@ from documents.workflows.context import build_workflow_context
and remove the now-unused `build_workflow_action_context` import.
Replace the entire `run_workflows` function (`handlers.py:854-1010`) with:
Replace the entire `run_workflows` function (`handlers.py:862-1051`) with:
```python
def run_workflows(
@@ -949,7 +968,7 @@ staged_file` is exactly the constructor `staged_file` arg, which is
- [ ] **Step 6: Update `run_workflows_added`**
`run_workflows_added` (`handlers.py:803-816`) already forwards `original_file`
`run_workflows_added` (`handlers.py:811-824`) already forwards `original_file`
to `run_workflows`. Leave it as-is — it still receives `original_file` from the
`document_consumption_finished` signal and passes it through; `run_workflows`
now routes it into `PersistedContext` construction.
@@ -985,11 +1004,14 @@ git commit -m "Refactor run_workflows around WorkflowRunContext, drop use_overri
- [ ] **Step 1: Write the failing test**
Append a new test class to `src/documents/tests/test_workflows.py`. Use the
existing helpers/fixtures already in that file for creating a `Document`, a
`Workflow` with a `DOCUMENT_UPDATED` trigger, and an ASSIGNMENT action that
assigns a correspondent. Model it on the existing `DOCUMENT_UPDATED` tests in
that file (they call `run_workflows(...)` directly). The new test:
Append a new test class to `src/documents/tests/test_workflows.py`. **The
action must assign both a tag and a correspondent/storage-path** (not
correspondent alone) — a correspondent-only assignment never fires
`m2m_changed` mid-run, so it would call `move_files_for_document` exactly once
even without the guard, and the test would pass vacuously. Use the same shape
as the existing `test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
(`test_workflows.py:3030`) for the Document/StoragePath/Workflow/ASSIGNMENT
setup, and call `run_workflows(...)` directly as that test does. The new test:
```python
class TestWorkflowRenameSequencing(TestCase):
@@ -1003,9 +1025,10 @@ class TestWorkflowRenameSequencing(TestCase):
from documents.signals import handlers
# ... set up a Document, a storage path whose template depends on
# correspondent, and a DOCUMENT_UPDATED workflow with an ASSIGNMENT
# action assigning that correspondent (reuse this file's helpers) ...
# ... set up a Document, a StoragePath whose template depends on
# correspondent, and a DOCUMENT_UPDATED workflow with one ASSIGNMENT
# action assigning BOTH a tag and that correspondent (reuse the setup
# from test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment) ...
with mock.patch.object(
handlers,
@@ -1025,7 +1048,14 @@ class TestWorkflowRenameSequencing(TestCase):
> The implementer should flesh out the document/workflow setup using the
> patterns already present in `test_workflows.py`. The assertion that matters:
> `move_files_for_document` is called exactly once, via the explicit
> `finalize_file_location()`, not once-per-`save()` from signals.
> `finalize_file_location()`, not once-per-`save()` from signals. Pre-guard,
> current code (post-#13178) calls it **twice** for this setup: once from the
> `m2m_changed` receiver when `apply_assignment_to_document` fetches a fresh
> instance to add the tag (a self-consistent no-op move against still-current
> DB state), and once from the final `document.save()`'s `post_save`. So this
> test is a genuine (if narrower) regression check, not a vacuous one — it's
> just checking call count / ordering discipline rather than a wrong final
> path, since #13178 already ensures the final path is correct either way.
- [ ] **Step 2: Run test to verify it fails**
@@ -1112,60 +1142,49 @@ git commit -m "Defer workflow file rename via ContextVar guard"
---
## Task 7: Regression test for the metadata-vs-rename race (#12386)
## Task 7: Confirm pre-existing metadata-vs-rename regression coverage still holds
**Files:**
**Files:** none (verification only — no new test)
- Test: `src/documents/tests/test_workflows.py`
> **Rescoped 2026-09-24:** this task originally asked for a new regression
> test for the #12386 metadata-vs-rename race. That race was independently
> fixed by #13178 (merged 2026-07-20, after this plan was written), whose only
> added test is
> `test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
> in `test_workflows.py`. Writing another test asserting the same final-state
> outcome would duplicate that coverage. Task 6's
> `TestWorkflowRenameSequencing` already adds the piece that test doesn't
> cover — call-count/ordering discipline on `move_files_for_document` — so
> this task is now pure verification that nothing regressed.
- [ ] **Step 1: Write the regression test**
- [ ] **Step 1: Run the pre-existing regression tests by name**
Append to `src/documents/tests/test_workflows.py` a test that reproduces the
original bug: a workflow that assigns **both** tags (firing `m2m_changed`) and a
correspondent, where the storage path template depends on the correspondent.
Before the fix the `m2m_changed`-triggered rename ran with a stale (empty)
correspondent and moved the file to the wrong path.
```python
class TestWorkflowMetadataRenameRace(TestCase):
@pytest.mark.django_db
def test_tag_and_correspondent_assignment_lands_file_at_final_path(
self,
) -> None:
"""
Regression for #12386: assigning tags (m2m_changed) plus a correspondent
used by the storage-path template must not move the file using stale
metadata. After the run the DB filename and the on-disk file agree.
"""
# ... reuse this file's helpers to:
# - create a StoragePath whose path template references {correspondent}
# - create a Document assigned to that storage path with a real file
# on disk at document.source_path
# - create a DOCUMENT_UPDATED Workflow with one ASSIGNMENT action that
# assigns BOTH a tag and a correspondent
# - call run_workflows(DOCUMENT_UPDATED, document=document)
# Assert:
document.refresh_from_db()
assert document.correspondent is not None
assert Path(document.source_path).is_file()
# the path reflects the assigned correspondent, not an empty value
assert document.correspondent.name in str(document.filename)
```
- [ ] **Step 2: Run the test**
Run: `uv run pytest documents/tests/test_workflows.py::TestWorkflowMetadataRenameRace -v`
Expected: PASS (the guard from Task 6 fixes the race; this test locks it in)
- [ ] **Step 3: Lint and commit**
Run (all three are in the `TestWorkflows` class, `test_workflows.py:78-5123`):
```bash
ruff check --fix src/documents/tests/test_workflows.py
ruff format src/documents/tests/test_workflows.py
git add src/documents/tests/test_workflows.py
git commit -m "Add regression test for workflow metadata vs rename race"
uv run pytest \
"documents/tests/test_workflows.py::TestWorkflows::test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment" \
"documents/tests/test_workflows.py::TestWorkflows::test_document_updated_workflow_assignment_persists_when_removing_trigger_tag" \
"documents/tests/test_workflows.py::TestWorkflows::test_workflow_document_updated_does_not_overwrite_filename" \
-v
```
(Confirm the exact enclosing class name for each with
`grep -n "class Test\|def test_" src/documents/tests/test_workflows.py` before
running — class boundaries may have shifted since this plan was written.)
Expected: PASS, **unchanged** — same assertions, no edits needed. If any of
these needs modification to pass after the refactor, that is a behavior
regression introduced by this plan, not an acceptable side effect — stop and
investigate before continuing.
- [ ] **Step 2: No commit**
This task makes no code changes. If Step 1 required a fix elsewhere, that fix
belongs to whichever earlier task's commit introduced the regression — amend
that task's changes (as a new commit, not `--amend`) rather than committing
here.
---
## Task 8: Full verification
@@ -1207,7 +1226,7 @@ git commit -m "Clean up after workflow runner refactor"
- **Spec coverage:** §1 Protocol → Tasks 2-3; §2 branch-free `run_workflows` →
Task 5; §3 `source_file` / staged-path relocation → Tasks 3, 5; §4 guard +
sequencing → Tasks 4, 6; deferred password-removal hook left as-is (Task 5
note); testing → Tasks 1-3, 6-8.
note); testing → Tasks 1-3, 6, 7 (rescoped), 8.
- **`update_fields` exclusion kept** (`_WORKFLOW_SAVE_FIELDS` in Task 3) — per
the design decision that it guards a cross-process hazard the guard does not
cover. No task removes it.
@@ -1216,3 +1235,37 @@ git commit -m "Clean up after workflow runner refactor"
`apply_removal`, `log_action`, `persist`, `record_run`,
`finalize_file_location`) is identical across the Protocol, both
implementations, and all call sites in the rewritten `run_workflows`.
## 2026-09-24 status update
- All line-number references throughout this plan were re-verified against
current `dev` and corrected (`handlers.py` shifted by ~6-8 lines throughout
due to unrelated intervening changes, chiefly #12389's
`_path_matches_checksum` addition).
- The two concrete bugs motivating this refactor are already fixed
independently, by different mechanisms, without this plan: #12389
(cross-process already-moved-file race, checksum-based recovery in
`validate_move`) and #13178 (intra-workflow tag/`m2m_changed` clobbering
unsaved fields, fixed by fetching a fresh `Document` instance for tag
mutation — see `mutations.py:26-31`). Neither fix uses `WorkflowRunContext`
or the `ContextVar` guard.
- Task 4 now calls out explicitly that the checksum-recovery logic inside
`validate_move` must be carried over unchanged during extraction, and that
the version-document recursive call site (`handlers.py:670-673`) needs
updating to call the extracted `move_files_for_document` directly, which the
original plan did not mention.
- Task 6's test setup was corrected — a correspondent-only assignment doesn't
exercise the guard (no `m2m_changed` fires), so the test as originally
written would have passed vacuously, guard or no guard. It now requires a
combined tag + correspondent/storage-path assignment, matching the shape of
#13178's own regression test.
- Task 7 was rescoped from "write a new regression test for #12386" to
"confirm #13178's existing regression tests still pass unchanged" — writing
a new test asserting the same final-state outcome would have duplicated
coverage that already exists and predates this plan's completion.
- **Net effect on scope:** this plan still fully replaces `use_overrides`
dual-mode branching and the `original_file` parameter plumbing (real,
unfixed structural debt) with a cleaner `WorkflowRunContext` abstraction.
The `ContextVar` guard piece is now justified as consolidating two
independent point-fixes into one general mechanism, not as fixing an active
bug — worth doing, but not urgent.
@@ -58,21 +58,30 @@ def get_pdf_first_page_size_points(
) -> tuple[float, float] | None:
"""Return the first page's (width, height) in PDF points, post-rotation.
Uses ``page.cropbox`` (pikepdf 10.2.0, pinned in uv.lock) — this is a
read-only property, not a raw dict lookup, and already implements the
Uses ``page.cropbox`` (pikepdf, pinned at 10.13.0.post1 in uv.lock) — this
is a read-only property, not a raw dict lookup, and already implements the
PDF-spec-correct fallback/inheritance to MediaBox when a page has no
``/CropBox`` of its own (confirmed against pikepdf's own
``_get_cropbox(True, False)`` implementation and by testing against
`src/documents/tests/samples/simple.pdf`, which has no ``/CropBox`` key
at all). This must match whatever box the renderer is told to use
(pdftoppm's ``-cropbox`` flag), or the computed DPI will target the
wrong box's dimensions. Swaps width/height when ``/Rotate`` is 90 or
270, since that's the orientation the page will actually be rendered
in — note ``page.rotate`` is a *mutator* method
(``rotate(angle, relative) -> None``) in this pikepdf version, not a
getter; the current rotation must be read via
``page.obj.get("/Rotate", 0)`` instead (confirmed: returns ``None``
cleanly, not an exception, when the key is absent).
``/CropBox`` of its own. Per pikepdf's release notes, box properties
(``mediabox``/``cropbox``/etc.) were reimplemented in C++ in 10.8.0 for
performance, with behavior explicitly unchanged — confirmed stable across
the range this project has used. This must match whatever box the
renderer is told to use (pdftoppm's ``-cropbox`` flag), or the computed
DPI will target the wrong box's dimensions.
Swaps width/height when the page's effective rotation is 90 or 270, since
that's the orientation the page will actually be rendered in. Uses
``page.rotation`` (added in pikepdf 10.9.0, available at the pinned
10.13.0.post1) rather than a raw ``page.obj.get("/Rotate", 0)`` dict
lookup: ``/Rotate`` is one of the PDF spec's *inheritable* page
attributes — a page can take its rotation from an ancestor ``/Pages``
node rather than setting ``/Rotate`` on its own object dict — and
``page.rotation`` is documented to resolve that inheritance and normalize
the result to ``[0, 360)``, defaulting to ``0`` when nothing is set
anywhere in the chain. A raw ``page.obj.get("/Rotate", 0)`` lookup would
silently miss an inherited rotation and compute DPI for the wrong
orientation on such a PDF. (Do not use ``page.rotate(...)`` — as of
10.9.0 that's a *mutator* method, and even before that it was never a
getter.)
Parameters
----------
@@ -100,7 +109,7 @@ def get_pdf_first_page_size_points(
height = abs(ury - lly)
if width <= 0 or height <= 0:
return None
rotate = int(page.obj.get("/Rotate", 0)) % 360
rotate = page.rotation % 360
if rotate in (90, 270):
width, height = height, width
return width, height
@@ -113,7 +122,15 @@ def get_pdf_first_page_size_points(
return None
```
`page.cropbox`/`page.mediabox` returning `pikepdf.Array` (indexable, four numeric elements) and `page.obj.get("/Rotate", 0)` behavior were both confirmed directly against the pinned version rather than assumed — no further verification needed during implementation.
> **Verify before implementing:** confirm `page.rotation` behaves as pikepdf's
> release notes describe against the pinned 10.13.0.post1 — test with a PDF
> that has no `/Rotate` at all (expect `0`, not an exception) and, if a
> sample with inherited (ancestor-node) rotation is available, confirm it
> resolves correctly. The VM (`paperless-vm`) is the only place pikepdf is
> installed per this project's Windows/Linux split — do this check there
> before relying on it.
`page.cropbox`/`page.mediabox` returning `pikepdf.Array` (indexable, four numeric elements) is confirmed stable per pikepdf's own release notes (box properties moved to a C++ implementation in 10.8.0 with behavior explicitly unchanged). `page.rotation` (used above in place of the earlier draft's raw `page.obj.get("/Rotate", 0)`) was added in 10.9.0 and is documented to resolve inherited `/Rotate` values — **this one is worth a quick empirical check against the pinned 10.13.0.post1 on the VM before implementation**, since it wasn't available to test directly when this plan was last touched.
- [ ] **Step 2: Add `rasterize_pdf_page_to_png()` to `documents/parsers.py`**
@@ -135,8 +152,12 @@ def rasterize_pdf_page_to_png(
# -singlefile suppresses that so out_path is written exactly as given.
args = [
"pdftoppm",
"-f", "1", "-l", "1",
"-r", str(dpi),
"-f",
"1",
"-l",
"1",
"-r",
str(dpi),
"-png",
"-singlefile",
]
@@ -207,7 +228,9 @@ def make_thumbnail_from_pdf(in_path: Path, temp_dir: Path, logging_group=None) -
encode_thumbnail_webp(png_path, out_path)
except ParseError as e:
logger.error(f"Unable to make thumbnail with pdftoppm: {e}")
out_path = make_thumbnail_from_pdf_qpdf_fallback(in_path, temp_dir, logging_group)
out_path = make_thumbnail_from_pdf_qpdf_fallback(
in_path, temp_dir, logging_group
)
return out_path
```
@@ -244,7 +267,7 @@ def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> int:
- [ ] **Step 5: Add tests for `get_pdf_first_page_size_points()`**
Cover: a normal PDF (returns expected width/height for a known sample), a PDF with `/Rotate 90` (width/height swapped vs. the unrotated equivalent), and a nonexistent/corrupt path (returns `None`, doesn't raise). Use existing sample PDFs under `src/paperless/tests/parsers/samples/` or `src/documents/tests/samples/` where possible rather than adding new binary fixtures.
Cover: a normal PDF (returns expected width/height for a known sample), a PDF with `/Rotate 90` set directly on the page (width/height swapped vs. the unrotated equivalent), and a nonexistent/corrupt path (returns `None`, doesn't raise). If a sample PDF with _inherited_ rotation (set on an ancestor `/Pages` node rather than the page itself) is easy to construct or already exists, add that case too — it's the specific gap `page.rotation` closes over a raw `/Rotate` dict lookup; if constructing one isn't cheap, skip it rather than manufacturing a fixture just for this, but don't skip verifying `page.rotation`'s basic no-rotation-set default (`0`) against the pinned pikepdf version. Use existing sample PDFs under `src/paperless/tests/parsers/samples/` or `src/documents/tests/samples/` where possible rather than adding new binary fixtures.
- [ ] **Step 6: Add a dimension/format assertion to `TestGetThumbnail`**
@@ -303,14 +326,15 @@ git commit -m "refactor: rasterize PDF thumbnails with pdftoppm+pikepdf+Pillow i
Replace `make_thumbnail_from_pdf_gs_fallback()` (`parsers.py:130-171`) with:
```python
def make_thumbnail_from_pdf_qpdf_fallback(in_path, temp_dir, logging_group=None) -> Path:
def make_thumbnail_from_pdf_qpdf_fallback(
in_path, temp_dir, logging_group=None
) -> Path:
png_path: Path = Path(temp_dir) / "page1_repaired.png"
out_path: Path = Path(temp_dir) / "convert_qpdf.webp"
repaired_path: Path = Path(temp_dir) / "repaired.pdf"
logger.warning(
"Thumbnail generation with pdftoppm failed, attempting qpdf "
"repair and retry.",
"Thumbnail generation with pdftoppm failed, attempting qpdf repair and retry.",
extra={"group": logging_group},
)
@@ -1,42 +1,110 @@
# Workflow Runner Refactor — Design
**Date:** 2026-05-19
**Updated:** 2026-09-24 — see "Status update (2026-09-24)" below; line references
throughout refreshed against current `dev`.
**Branch base:** `dev`
**Status:** Approved design, pending implementation plan
**Status:** Approved design, pending implementation plan. Motivating bugs are now
independently fixed (see status update) — this is a structural cleanup, not an
active-bug fix.
## Status update (2026-09-24)
The acute bugs this design was written against have since been closed by two
targeted, already-merged fixes that do **not** use the `WorkflowRunContext` /
`ContextVar` approach below:
- **#12386** (cross-process "file already moved" race, cited in cause 3) was
fixed by **#12389** ("Fix: avoid moving files if already moved", merged in
v2.20.12): `validate_move` in `update_filename_and_move_files` now detects,
via a checksum comparison (`_path_matches_checksum`), when the target file
already exists because a concurrent save already moved it, and recovers the
DB pointer instead of raising `CannotMoveFilesException`. This is a different
mechanism from anything proposed here and stays as-is; nothing in this
refactor should touch `_path_matches_checksum` or its call sites.
- **The intra-workflow tag/`m2m_changed` race** described in cause 3 and design
§4 (`add_nested_tags` firing `m2m_changed` → `refresh_from_db()` → wiping an
earlier-ordered action's unsaved `correspondent`/`storage_path`) was fixed by
**#13178** ("Fix: prevent tag assignment from reverting other pending
workflow assignments", merged 2026-07-20 — _after_ this design was written).
`apply_assignment_to_document` (`mutations.py`) now applies tag changes to a
**freshly-fetched** `Document` instance rather than the shared in-memory one,
matching the pattern `apply_removal_to_document` already used for tag
removal. Because the mid-run rename this triggers now reads the _old_,
still-current DB state (nothing else has been saved yet), it recomputes the
same path and is a no-op; the real rename happens once, correctly, at the
final `document.save()`. Regression coverage:
`test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
in `test_workflows.py` (the only test #13178 itself added). A related,
earlier fix (#12664) covers a similar but distinct case — tag removal
alongside a title assignment — via
`test_document_updated_workflow_assignment_persists_when_removing_trigger_tag`.
- The `filename`/`archive_filename` exclusion from `update_fields` (cause 3,
design §4 point 2) was already in place before this design was written (added
by #12390-adjacent work) and is unaffected — still correctly attributed here
as a load-bearing cross-process guard, not duct tape.
- **#13178's own PR description is the origin of this idea**: "Once the beta
is out, I do have some thoughts about using ContextVar to delete this class
entirely. We've run into it plenty of times." So the guard concept predates
this design; the acute pain it was meant to address has since been patched
piecemeal instead.
**What this means for scope:** causes 1 and 2 below (dual-mode branching,
staged-file parameter plumbing) are unchanged and fully present in current code
— this refactor is still worth doing for them. Cause 3's race is no longer an
_active_ bug; the `ContextVar` guard is now defense-in-depth / simplification
(it also collapses two independent workarounds — the fresh-instance tag fetch
and the per-workflow `update_fields` restriction — into one clearer mechanism)
rather than a fix for a reproducible failure. Treat any "fixes a bug" framing
below as historical motivation, not a current defect claim.
## Problem
Workflow execution and the Django signal layer have repeatedly produced fragile,
hard-to-fix bugs (see the revert/refix history around password removal: #12803,
#12814, #12716, and the filename race #12386). Three structural causes:
#12814, #12716, and the filename race #12386, now closed — see status update
above). Three structural causes:
1. **`run_workflows` is dual-mode.** A single function handles both consumption
(mutating a `DocumentMetadataOverrides`) and post-save (mutating a real
`Document`), branching on a `use_overrides` flag. The branching is
concentrated in two places — the action dispatch inside `run_workflows`
(`handlers.py:931-1001`) and `build_workflow_action_context`
(`actions.py:33-83`), each with two full code paths. The `apply_*` helpers in
`workflows/mutations.py` are _already_ split by target type
(`apply_assignment_to_document` vs `apply_assignment_to_overrides`, etc.); the
refactor unifies their callers, not the helpers themselves.
(`handlers.py:938-1014`, `use_overrides` first set at `handlers.py:881`) and
`build_workflow_action_context` (`actions.py:33-83`), each with two full code
paths. The `apply_*` helpers in `workflows/mutations.py` are _already_ split
by target type (`apply_assignment_to_document` vs
`apply_assignment_to_overrides`, etc.); the refactor unifies their callers,
not the helpers themselves. **Still fully present in current code — unchanged
by any fix since this design was written.**
2. **File location is an implicit, timing-dependent side channel.** The
`DOCUMENT_ADDED` workflow fires from `run_workflows_added`, which runs while
the consumer is still inside its transaction — _before_ the consumed file is
copied to `document.source_path` (`document_consumption_finished` is sent at
`consumer.py:654`; the file copy happens after, at `consumer.py:666+`). The
staged path is therefore threaded through as `original_file` /
`caller_supplied_original_file` parameters. Actions that read the file
(password removal, email attachments) depend on this plumbing being correct.
`DOCUMENT_ADDED` workflow fires from `run_workflows_added`
(`handlers.py:811-824`), which runs while the consumer is still inside its
transaction — _before_ the consumed file is copied to `document.source_path`
(`document_consumption_finished` is sent from `consumer.py`, the file copy
happens after). The staged path is therefore threaded through as
`original_file` / `caller_supplied_original_file` parameters
(`handlers.py:868,890-898,976-979`). Actions that read the file (password
removal, email attachments) depend on this plumbing being correct. **Still
fully present in current code.**
3. **The workflow run races the filename rename.** `update_filename_and_move_files`
is a raw `post_save` receiver on `Document`. When a workflow persists its
changes via `document.save(update_fields=[...])`, that save fires `post_save`
and runs the rename _while the workflow is still executing_. Under concurrent
Celery/UI updates the interleaved `refresh_from_db()` calls corrupt state. The
comment at `handlers.py:980-984` — deliberately excluding `filename` /
`archive_filename` from the workflow save — is a load-bearing workaround for
exactly this.
3. **The workflow run could race the filename rename — now mitigated by two
independent, narrower fixes (see "Status update" above), not eliminated
structurally.** `update_filename_and_move_files` (`handlers.py:437-439`
decorators, body `440-673`) is a raw `post_save`/`m2m_changed` receiver. When
a workflow persists its changes via `document.save(update_fields=[...])`, or
when `apply_assignment_to_document` mutates tags via a freshly-fetched
instance (`mutations.py:26-31`), that write fires the receiver _while the
workflow is still executing_. As of #13178 this no longer corrupts in-memory
state (tags are applied to a separate instance, so the mid-run rename it
triggers reads only already-committed data and is a no-op or self-consistent
move); as of #12389 a genuine cross-process "someone else already moved this
file" race recovers via checksum comparison instead of erroring. The comment
documenting the `filename`/`archive_filename` exclusion from the workflow's
own `update_fields` (`handlers.py:1019-1027`) remains a load-bearing guard
against a _different_, still-real cross-process hazard (an in-memory
`document.filename` going stale while another process moves the file) and is
unaffected by either fix above.
Note: `run_workflows_added` / `run_workflows_updated` are connected to the
_custom_ signals `document_consumption_finished` / `document_updated`, fired
@@ -155,12 +223,23 @@ let one workflow's refresh wipe a prior workflow's in-memory changes. So:
3. The rename is suppressed for the whole run and invoked **exactly once,
afterward**, against final committed state.
The actual race being fixed: `apply_assignment_to_document` assigns tags via
`document.add_nested_tags(...)`, which fires `m2m_changed` on
`Document.tags.through` _before_ the workflow's `document.save()`. The
`m2m_changed` receiver `update_filename_and_move_files` then calls
`refresh_from_db()`, wiping the workflow's in-memory correspondent/type, and
moves the file to a path computed from stale metadata. The guard prevents this.
**Historical race, already fixed by other means (see "Status update"):**
`apply_assignment_to_document` used to assign tags via
`document.add_nested_tags(...)` directly on the shared in-memory `document`,
which fired `m2m_changed` on `Document.tags.through` _before_ the workflow's
`document.save()`; the `m2m_changed` receiver `update_filename_and_move_files`
then called `refresh_from_db()` on that shared instance, wiping the workflow's
in-memory correspondent/type, and moved the file to a path computed from stale
metadata. #13178 fixed this by having `apply_assignment_to_document` (and
`apply_removal_to_document`, which already did this) mutate tags on a
**freshly-fetched** `Document.objects.get(pk=document.pk)` instead of the
shared one (`mutations.py:26-31`), so the `refresh_from_db()` triggered by
`m2m_changed` no longer touches the workflow's unsaved in-memory fields. The
guard below is not needed to fix that specific corruption anymore — it instead
gives a single, general mechanism that supersedes the fresh-instance-fetch
workaround (and the equivalent one already in `apply_removal_to_document`),
rather than requiring every future mutation path to remember to fetch a
separate instance.
To stop the rename from firing mid-workflow, a **`ContextVar` guard** is
introduced (e.g. `documents/workflows/context.py` module-level
@@ -173,8 +252,16 @@ reentrancy-safe for nested saves or nested workflow runs.
The guard must span the whole execution, not just `persist()`, because
`update_filename_and_move_files` is _also_ registered to `m2m_changed` on
`Document.tags.through` and to `post_save` on `CustomFieldInstance`
(`handlers.py:431-432`). A workflow action that assigns tags or custom fields
would otherwise trigger a rename mid-workflow through those signals.
(`handlers.py:437-438`). A workflow action that assigns tags or custom fields
would otherwise trigger a rename mid-workflow through those signals. If the
`ContextVar` guard lands, the fresh-instance-fetch workaround in
`apply_assignment_to_document`/`apply_removal_to_document` becomes redundant
but is not itself incorrect — decide during implementation whether to simplify
those two call sites back to mutating `document` directly now that the guard
covers the hazard, or leave them as extra defense-in-depth. Note either way:
`document` passed to `add_nested_tags`/`tags.clear`/`tags.remove` must still be
re-fetched or `refresh_from_db()`'d for the tags relation to reflect the
change on the in-memory instance used later in the same action.
After execution completes, `run_workflows` calls `persist()` once and then
explicitly invokes the move logic once. The `ContextVar` is set/reset in the
@@ -184,26 +271,37 @@ pools are also `contextvars`-aware — non-issues, noted for completeness.)
The move body of `update_filename_and_move_files` is extracted into a plain
callable that the runner invokes directly. The function is already invoked
directly (as a plain call, bypassing the decorator) for version documents at
`handlers.py:664-667`, so this extraction has precedent. The thin `post_save`
receiver remains as a guard-checking wrapper.
directly today for version documents — currently as
`update_filename_and_move_files(Document, version_doc)`
(`handlers.py:670-673`), i.e. the _whole receiver_ is called with a synthetic
`sender` positional arg, bypassing only the `@receiver` decorator/dispatch, not
the guard-check-then-body split this refactor introduces. Once the move body is
extracted into its own callable (`move_files_for_document(instance)` per the
implementation plan), this recursive call site must be updated to call that
extracted function directly (`move_files_for_document(version_doc)`) rather
than the thin wrapper — otherwise recursing into version documents would
re-enter the guard-check wrapper unnecessarily (harmless, since the guard
should be set during a workflow run and unset otherwise, but pointless
indirection). The thin `post_save`/`m2m_changed` receivers remain as a
guard-checking wrapper around the extracted callable.
The two `post_save` receivers on `Document` are `update_filename_and_move_files`
(`handlers.py:433`) and `update_llm_suggestions_cache` (`handlers.py:740`). The
`ContextVar` guard suppresses **only** the former — `update_llm_suggestions_cache`
keeps running normally, as do `document_consumption_finished` receivers such as
(`handlers.py:439`) and `update_llm_suggestions_cache` (`handlers.py:746-747`).
The `ContextVar` guard suppresses **only** the former —
`update_llm_suggestions_cache` keeps running normally, as do
`document_consumption_finished` receivers such as
`add_or_update_document_in_llm_index` (which is _not_ a `post_save` receiver).
This is why the guard is preferred over persisting with `.update()`, which would
silently suppress _all_ `post_save` receivers including
`update_llm_suggestions_cache`.
`WorkflowRun.objects.create(...)` is created per matching workflow as today
(`handlers.py:998-1002`); it is a separate model and is not deferred.
(`handlers.py:1039-1043`); it is a separate model and is not deferred.
The comment at `handlers.py:980-984` is updated to describe the new flow
(per-workflow save under the guard; single explicit rename afterward) but the
`filename` / `archive_filename` exclusion it documents is kept — see point 2
above.
The comment at `handlers.py:1019-1027` (added by the pre-existing `update_fields`
fix, predating this design) is updated to describe the new flow (per-workflow
save under the guard; single explicit rename afterward) but the `filename` /
`archive_filename` exclusion it documents is kept — see point 2 above.
## Testing
@@ -216,11 +314,16 @@ above.
- **ContextVar guard** — assert `update_filename_and_move_files` early-returns
while the guard is set, and that the rename runs exactly once after
`persist()`.
- **Regression: the racy case** — a workflow that reassigns metadata while the
document is subject to a filename template; assert final DB filename and file
location are consistent (the #12386 scenario).
- **Regression safety net** — the existing `test_workflows.py` suite (~100
tests; ~19 `document_consumption_finished.send` sites plus many direct
- **Regression: the racy case is already covered, not newly needed.** The
scenario this design originally asked for a new test for — a workflow that
reassigns metadata (tags + correspondent/storage path) while the document is
subject to a filename template, asserting final DB filename and file location
stay consistent — is already exercised by
`test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment`
in `test_workflows.py` (added by #13178). No new regression test is required
for this; the refactor's job is to keep it passing **unchanged**.
- **Regression safety net** — the existing `test_workflows.py` suite (~100+
tests; many `document_consumption_finished.send` sites plus many direct
`run_workflows(...)` calls for the `DOCUMENT_UPDATED` path) must stay green
**unchanged**. A test that needs editing signals a behavior change to flag
explicitly, not a silent refactor outcome.
@@ -238,16 +341,24 @@ Each step is independently reviewable and keeps the test suite green:
`run_workflows_added`); delete the `original_file` /
`caller_supplied_original_file` parameter plumbing through `run_workflows`
and the `execute_*` helpers.
3. Extract the move body from `update_filename_and_move_files` into a callable;
add the `ContextVar` guard; `run_workflows` invokes the move once after the
run completes. The `filename` / `archive_filename` exclusion in the
per-workflow save is kept; only the comment at `handlers.py:980-984` is
updated to describe the new flow.
3. Extract the move body from `update_filename_and_move_files` into a callable
(updating the version-document recursive call site at `handlers.py:670-673`
to call it directly); add the `ContextVar` guard; `run_workflows` invokes the
move once after the run completes. The `filename` / `archive_filename`
exclusion in the per-workflow save is kept; only the comment at
`handlers.py:1019-1027` is updated to describe the new flow.
## Pain points addressed
- **Dual-mode** → eliminated by the `Protocol` + two contexts; no `use_overrides`.
- **File staging** → `source_file` is a context property; side-channel args deleted.
Still an open, unfixed problem in current code — this is the refactor's main
remaining justification.
- **File staging** → `source_file` is a context property; side-channel args
deleted. Still an open, unfixed problem in current code.
- **Rename race** → per-workflow save under a `ContextVar` guard that suppresses
the mid-workflow rename; a single explicit rename runs once at the end against
final state.
final state. **No longer an active bug** — #12389 and #13178 independently
closed the two concrete failure modes (cross-process already-moved file;
intra-workflow tag/m2m clobbering unsaved fields) by narrower means. The
guard is now valuable as a single general mechanism replacing two
independent point-fixes, not as a bug fix.