Address PR #1825 review follow-ups: CAML edit lock ordering + description follow-up tracking (#1848)#1855
Conversation
…1848) - caml_article: acquire a row lock on the Corpus inside the atomic CAML edit block (select_for_update) so a concurrent corpus delete or readme_caml_document cache refresh cannot race between the Document lock and import_document. Documents the Document -> Corpus -> DocumentPath lock order (issue #1848 item 5). - descriptions/signals: replace bare follow-up prose with TODO(#1848) references for threading the live caller through get_corpus_description and for the readme-document signal-guard hardening (items 1 and 3).
Code ReviewSummarySmall, focused PR addressing three items from the #1825 review. The only functional change is the Functional change —
|
- Restore opencontractserver/corpuses/migrations/0018_* to its pre-#1825 contents (the no-op RunPython appended in #1825 mutated an already-shipped migration, which can diverge migration hash-checks in CI). The op was a no-op so reverting has no schema/data effect (issue #1848 item 6). - Rename test class AapplyCamlArticleEditCreatesVersionTreeSiblingTest -> ApplyCamlArticleEditCreatesVersionTreeSiblingTest (merge-artifact typo).
Code Review — PR #1855OverviewThis PR addresses three of the six reviewer items from the PR #1825 follow-up review (#1848): lock ordering in ✅ Approved ChangesLock ordering — Signal-guard comment —
Test-class rename — 🔴 Critical Issue — Migration 0018The migration change introduces a fresh-install breakage. The removed code inlined
The replacement imports from the live model module: upload_to=opencontractserver.corpuses.models.calculate_description_filepath,But The original defensive inline was the correct approach. The PR description lists Item 6 ("verify/handle the historical Recommendation: Revert the migration 0018 change and restore the inlined function, or if there is a reason to reference the models module, add
|
| Change | Verdict |
|---|---|
select_for_update() on Corpus |
✅ Correct fix |
Signal guard TODO(#1848) |
✅ |
get_corpus_description TODO(#1848) |
✅ |
| Test-class typo fix | ✅ |
| Migration 0018 rewrite | 🔴 Breaks fresh-install migrations — calculate_description_filepath is not in models.py |
Block on the migration issue; the rest is ready to merge.
The prior revert (b504705) reintroduced a dangling reference to opencontractserver.corpuses.models.calculate_description_filepath, which the canonical-CAML refactor (#1825) removed. Django cannot import the historical migration, breaking the test suite. Restore the inlined calculate_description_filepath callable so 0018 stays importable on fresh installs. https://claude.ai/code/session_01Wa4U1t8WG2uAff2JY9d7z1
Code ReviewOverviewThis is a focused follow-up to PR #1825 addressing two medium-priority items from the prior review. The diff is small (13 additions, 4 deletions across 4 files) and the PR body is admirably transparent about what is and is not addressed here. Lock Ordering Fix —
|
Code ReviewOverviewSmall, focused follow-up to PR #1825. Four files changed: one correctness fix (lock ordering), two comment-precision improvements (vague prose → trackable TODOs), and one test-class typo fix. The PR description is thorough and transparent about what was and wasn't changed and why — good practice. Lock ordering fix —
|
| Transaction step | Lock acquired |
|---|---|
Document.objects.select_for_update().get(pk=doc.pk) |
Document row (exclusive) |
Corpus.objects.select_for_update().get(pk=corpus_id) ← new |
Corpus row (exclusive) |
Inside import_document: DocumentPath.objects.filter(...).select_for_update().first() |
DocumentPath row (exclusive) |
Checked codebase-wide: Corpus.objects.select_for_update() is only called here. No other path acquires Corpus → Document (opposite order), so the new Document → Corpus → DocumentPath order introduces no deadlock risk.
One minor wording point in the comment at line 526:
DocumentPath (locked inside
import_document)
Inside import_document (versioning.py:196), DocumentPath is locked via .select_for_update().first() on a filtered queryset, which can return None if the path doesn't yet exist (new-document case). The comment implies a guaranteed lock; slightly more accurate would be "DocumentPath locked inside import_document when the path already exists." Non-blocking nit.
Also worth noting: the signal handler (refresh_description_cache_for_corpus) is called via post_save inside the same transaction.atomic() block (through Django savepoints from import_document's nested atomic). It does a plain Corpus.objects.filter(pk).update(...) on the same row — this is safe; PostgreSQL allows a transaction to UPDATE a row it already holds SELECT FOR UPDATE on.
TODO(#1848) comment updates — signals.py and descriptions.py
Both conversions from "left as a follow-up" to TODO(#1848) are an improvement: the issue is now searchable and won't silently rot. No concerns.
Test class typo — test_caml_review_tools.py
AapplyCaml... → ApplyCaml...: correct, and the PR body confirms this is the only reference in the repo.
Test coverage
No new tests for the lock ordering. This is understandable — deterministic concurrent tests for row locking require two threads/connections coordinated through barriers, which is non-trivial in Django's test runner. The existing suite validates the functional behaviour of _apply_caml_article_edit; the lock is a correctness-under-concurrency guarantee rather than a functional change.
If a concurrent test is added later (e.g. as part of #1848), TransactionTestCase (already used by the sibling test class in this file) would be the right base since it doesn't wrap each test in a transaction.
Summary
- Lock ordering fix: correct, well-documented, no new deadlock risk. Minor comment wording nit (non-blocking).
- TODO comments: good hygiene, improves traceability.
- Typo fix: straightforward.
- Remaining nit on
noop_reverse(noted in PR body as intentionally deferred): reasonable not to churn an already-applied data migration for style.
LGTM with the comment-wording nit flagged for awareness.
The lock-order comment implied DocumentPath is always locked inside import_document; it is locked via .select_for_update().first() only when the path already exists (the new-document case has no row to lock). Comment-only; no behavior change.
Code ReviewSummarySmall, focused follow-up to PR #1848 — three code changes (Corpus lock ordering, two TODO annotations) and one test typo fix. The remaining three review items were assessed as no-change-needed with clear justification. What Looks GoodLock ordering fix ( TODO(#1848) references in Test class rename ( Migration 0018 — inlined No-change justifications — the reasoning on Items 2 (N+1 storage read), 4 (migration dependency graph), and 6 (historical migration) is accurate against the actual code; correctly left untouched. IssuesBlocker:
|
Addresses #1848 — the follow-up review of the Canonical-CAML corpus description refactor (PR #1825).
Each of the six reported issues was evaluated against the actual code before deciding whether to change anything. Three warranted a code change; the others were assessed as already-correct or premise-incorrect, and the reasoning is recorded below.
Changes made
Item 5 (Medium) — lock ordering in
_apply_caml_article_editopencontractserver/llms/tools/core_tools/caml_article.py. The function locked the headDocument(select_for_update) insidetransaction.atomic()but then fetchedCorpus.objects.get(pk=corpus_id)unlocked right beforeimport_document. It now fetches the Corpus withselect_for_update()too, with a comment documenting the lock order (Document → Corpus → DocumentPath) so the read-modify-write can't act on a stale Corpus and concurrent writers acquire locks in a consistent order. (The identical construct is already used onmaininopencontractserver/agents/memory.py.)Item 1 (Medium) — fragile
_is_readme_caml_documentsignal guardopencontractserver/corpuses/signals.py. The guard keys ontitle == CAML_ARTICLE_TITLE and file_type == MARKDOWN_MIME_TYPE. A false positive is already harmless (the deferred refresh re-derives the owning corpus via the canonicalDocumentPathhead and no-ops when there is none). The bare "left as a follow-up" prose is replaced with a concreteTODO(#1848)pointing at theDocument.is_corpus_readmehardening.Item 3 (Low) —
get_corpus_descriptionscopes the CAML lookup ascorpus.creatoropencontractserver/llms/tools/core_tools/descriptions.py. The accepted-risk comment becomes aTODO(#1848)so "thread the live caller through once the agent framework injects it, and drop thecorpus.creatorfallback" is tracked.Nit — test class typo
opencontractserver/tests/test_caml_review_tools.py:AapplyCamlArticleEditCreatesVersionTreeSiblingTest→ApplyCamlArticleEditCreatesVersionTreeSiblingTest.CI fixes on this branch
Migration 0018 — an intermediate commit on this branch had reverted #1825's inlining of
calculate_description_filepathinto the historical0018migration, reintroducing a danglingopencontractserver.corpuses.models.calculate_description_filepathreference (removed in the refactor) and breaking Django's migration import. Commitc17b59drestores the inlined helper;git diff main HEAD -- .../0018_*.pyis now empty (identical to main, importable on fresh installs). This resolves the earlier 🔴 review note.pytest (
test_caml_review_tools.py) — the branch forked frommainat8f53b24, beforemain's commit26a864b("Fix CI on main: route CAML test fixture through import_document (#1838 follow-up)"). #1838 added a leading-/precondition toCorpusPathService.disambiguate_path; the test helper_create_caml_docstill built its fixture viacorpus.add_document(path="Readme.CAML"), which now correctly rejects the slashless sentinel — failing all 48 tests in the file insetUp. This was a pre-existing breakage already fixed onmain, unrelated to this branch's edits (production codepaths.py/versioning.py/models.pyis byte-identical tomain). Mergedorigin/main(14425eb) to pick up the fixture fix. Verified locally:test_caml_review_tools.py+test_agent_memory.pypass (pytestexit 0), including the tests that directly drive the new Corpus lock.Assessed — no code change needed
Item 2 (Low) — N+1 storage read in
CorpusDescriptionRevisionType.resolve_snapshotThe suggestion was to pre-read every revision body in the list resolver. Doing so would regress the common case:
resolve_description_revisionsreturns the revision list, butsnapshotis resolved lazily per field. A query that lists revisions without selectingsnapshotcurrently performs zero storage reads; pre-reading all bodies in the list resolver would force N reads even when the client wants none. The lazy per-field read (read_caml_bodyinresolve_snapshot) is the correct trade-off. No change.Item 4 (Low) — migration dependency on the
documentsappThe premise doesn't hold against the actual files.
0053_add_readme_caml_fkalready declares("documents", "0039_add_preferred_enrichers_to_pipeline_settings"), and0054_canonical_caml_backfill(which actually creates Documents/DocumentPaths) also pins it.0055_drop_legacy_description_storagereferences nodocumentsobjects and inherits the dependency transitively via 0053 → 0054 → 0055. The graph is already correctly ordered. No change.Item 6 — historical
0018migration editPR #1825 correctly inlined
calculate_description_filepathinto0018because the model-level helper was removed in the refactor; inlining keeps the historical migration importable on fresh installs. This is necessary and correct, and the branch preserves it (see CI fixes above).Remaining nits (non-blocking, not changed)
noop_reverse→migrations.RunPython.noopin0054: cosmetic and behaviour-identical. Left as-is to avoid churning an already-applied data migration for a style preference.https://claude.ai/code/session_01Wa4U1t8WG2uAff2JY9d7z1