Repository navigation
fix(models): hard_delete() removes all it collected or rolls back; spares by closure; reads keys a WHERE cannot hold (#72, #71, #73, 2.15.0) - #75
Conversation
…ck (#72) Instance hard_delete() under another tenant's scope committed half a tree: the row policy hid the root, so its DELETE removed nothing, while the children's policy-free table let theirs go. Each DELETE the walk issues now has to remove exactly the rows collected for its table, or it raises the new HardDeleteIncompleteError and the walk rolls back. The same holds for an empty scope (a table compiling to nothing), another transaction removing a collected row first, and the MTI queryset form's own chain, where a hidden row left the chain half removed. A descendant's table, holding rows only for the keys that are one, has no count to meet. The plain queryset form's DELETE primitive now returns its row count; queryset hard_delete() returns None in every form, where the plain one returned a closed cursor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its closure (#71) _still_referenced asked which surviving rows point at an owned target, but never which point at the rows its cascade takes along. So a plain key from outside into a descendant left the target unspared, its subtree was removed, and the walk aborted at COMMIT. The closure now records, per row, which targets take it: the recursive query carries the seed it descends from, the level walk carries targets through each key, and a row reached again with a new target is walked again so a row two targets reach spares both. Sparing asks the referrers of every closure row, grouped by MTI tree, and spares exactly the targets they lead back to. One query per relation, as before. Testapp gains CueNote (a plain key into a Cue) so a closure row of a model other than the target's is covered, and an empty retirement host after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#73) The plain queryset form compiled its query as a DELETE, so a filter on a window function or a single-table aggregate landed in the DELETE's WHERE and PostgreSQL refused it, where delete() reads the keys first and succeeds. When the WHERE holds an aggregate or a window, the form now reads the matched keys (_matching_pks, as delete()'s fast path does) and removes them in batches on a bare queryset, each batch held to removing every key it names. A plain filter stays one DELETE. The new _hard_delete_by_key is denied on an unscoped tenant queryset like its siblings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Supersedes dependabot #44, which was cut against 2.11.0's lock and would have collapsed the Django 5.2 and 6.0 forks into one. Upgraded within each fork (django<6.1) instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0032 records why hard_delete() rolls back when a DELETE removes fewer rows than it collected, where it counts and why its error names no cause. The soft-deletion, owned-relations and API reference docs say what 2.15.0 changed. A test covers the key-read path reading nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the target alone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e a row already gone Round 1 of the review loop on #75. - A queryset filtered across a many-valued relation reads a key once per joined row, so the MTI form and the key-read path counted duplicates and raised HardDeleteIncompleteError over a clean delete (a regression in the MTI form). Both count distinct keys now. - _with_self_descendants had become a wrapper over the (pk, origin) CTE, so the collection walk read one row per seed above each node, quadratic in depth (1.54 s against 0.11 s on a 1500-deep chain). It reads each row once again; only the sparing closure pays for origins. - A row already gone, because its table has no soft-delete rule yet and Phase 1's delete() really removed it, is named among the causes; the walk still fails closed (maintainer's decision). ADR 0032 and the CHANGELOG say so, including that hard_delete() completed there through 2.14. - A GenericRelation child is not in the sparing closure: filed as #76, and the docs and the comment that called the gap harmless are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1Read-only reviewer over
Checked clean: row counts across MTI levels, self-referential subtrees, rule rewriting; a 300-trial differential of Gate at 8d22ea4: pytest 100% coverage, ruff, format, ty, bandit, 🤖 Generated with Claude Code |
…e walk's reader pinned Round 2 of the review loop on #75. - hard_delete()'s fallback for a model without _all_objects runs Django's collector, which removes a plain MTI child's parent rows with it; the parent's own entry, later in child-first order, then removed nothing and the row count aborted a clean walk. The fallback now credits those parent rows to their own entries. The branch is no longer `no cover`: testapp gains Rig (an owned target of its own), Roadie (owning it), and the plain chain Gear/Amp under it, with their migrations and an empty retirement host. - A test pins that the collection walk never reads origin pairs. - The error's docstring and docs/soft-deletion.md name all three causes; an owned key pointing at no row (db_constraint=False) fails closed too, kept and recorded in ADR 0032 as an accepted cost. - A test docstring overclaimed what it reproduced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2Read-only reviewer over
Loop-introduced: two (#3, #4). Checked clean: distinct keys everywhere a count is taken; the row-only and origin readers agree on 480 random graphs; Gate at a6e0871: pytest 100% coverage, ruff, format, ty, bandit, 🤖 Generated with Claude Code |
…ollector deletes Round 3 of the review loop on #75. A plain (non-soft-deletable) model under an owned row goes through Django's collector, which cascades by its own rules: a plain model reached a second time removes children whose own entries come later, and round 2's credit for MTI parents missed that shape, still aborting clean walks. Such a table is no longer counted, as the maintainer decided: no row policy hides a plain row, so the hidden-row case the count exists for cannot occur there. The credit logic goes; the Rig/Gear/Amp test stays and fails if a count comes back. ADR 0032, the CHANGELOG and the docs scope the rule to soft-deletable tables and say why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 3Read-only reviewer over
Checked clean: a three-level plain MTI chain, a plain child over a non-plain parent (impossible), Gate at ca4c034: pytest 100% coverage, ruff, format, ty, bandit, 🤖 Generated with Claude Code |
… no policy Round 4 of the review loop on #75. The comment and ADR 0032 said no row policy hides a plain row; a plain model with tenanted_manager() gets one. What holds instead: a plain row reaches the walk only through a SELECT under the same policy as its DELETE, so a hidden row is never collected; a read-only exempt role is the exception, failing at COMMIT on the deferred key. The root-already-gone test now matches the table and counts, not a word every message carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 4 and ledgerRead-only reviewer over
Also: a test's
Stop: round 4 produced no new defect in the diff's lines, and loop-introduced findings outnumber pre-existing ones. Every file in the diff has been cited. Deferred: #76. Waiting on the maintainer's read of the prose (ADR 0032, CHANGELOG 2.15.0, Gate at 47b78d2: pytest 100% coverage; at ca4c034 also ruff, format, ty, bandit, 🤖 Generated with Claude Code |
Three
hard_delete()defects the review loop on #69 left as issues, in one minor release because one of them adds a public name.#72: all or nothing. Instance
hard_delete()under another tenant's scope committed half a tree: the row policy hid the root, so itsDELETEremoved nothing, while its children, on a table with no policy, were removed for good. EachDELETEthat deletes keys collected beforehand now has to remove exactly that many rows, or it raises the newHardDeleteIncompleteError(aGuitarsError, exported fromguitars.models) and the walk rolls back. That covers every table of the instance walk (an empty scope counts as zero removed), the MTI queryset form's own table and ancestors (a descendant table holds rows only for the keys that are one), and #73's key-read path. The count comes with eachDELETE, so no extra statement. ADR 0032.#71: sparing reads the whole closure. An owned target was spared only for a key into the target itself, so a plain key into a descendant aborted the walk at
COMMIT._cascade_closurenow records which targets take each row: the recursive query carries its seed, and the level walk carries targets through each key. A row reached again with a new target is walked again, so a row two targets reach spares both._still_referencedasks the referrers of every closure row, grouped by MTI tree, and spares exactly the targets they lead back to: one read per relation into each model the closure reaches, never one per row. Testapp gainsCueNote(a plain key into aCue) and an empty retirement host after its migrations.#73: keys first where a
WHEREcannot hold the filter. A window or single-table aggregate filter made the plain queryset form'sDELETEinvalid. It now reads the matched keys (asdelete()does) and removes them in batches, each held to its count; a plain filter stays oneDELETE. The new_hard_delete_by_keyis denied on an unscoped tenant queryset.Also. Queryset
hard_delete()returnsNonein every form; the plain one returned a closed cursor. Dependencies: Django 5.2.18 and 6.0.9, sqlparse 0.6.0. This supersedes #44, whose lock, cut against 2.11.0, would have merged the two Django forks.Tests.
tests/test_hard_delete_incomplete.py(the #72 reproduction; a concurrent writer simulated mid-walk; the MTI chain; the key-read path), #71's reproduction at child and grandchild depth plus attribution unit tests (only the anchored target; a row two targets reach; a row reached along two relations; a row going anyway; a key into a non-Ledger closure row), and #73's window and aggregate filters with a one-statement check. Each guard was mutated away and a test failed.Gate. pytest at 100% coverage, ruff, format, ty, bandit,
uv lock --check,makemigrations --check, doc budget, and the Django 5.0 and 6.0 cells.Closes #72, #71, #73. Supersedes #44.
🤖 Generated with Claude Code