Skip to content

Releases

v2.22.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 17:36
Immutable release. Only release title and notes can be modified.
7153fe5

Added

  • A guard on every cascade child archives a child created or re-pointed under an archived parent (#91, ADR 0044). An archive locks the parent row FOR NO KEY UPDATE, which the foreign-key check of an insert (FOR KEY SHARE) does not wait for, and the cascade only reaches rows that exist when its statement starts. So a child committed while another session archived its parent stayed live under it, in either order, at READ COMMITTED and REPEATABLE READ, and the same for a child moved onto an archived parent and a leaf added to a tree being archived. This was true of the rule the arms replaced too; it was measured with two sessions for the first time. Each cascade child table now carries soft_delete_guard_on_<n>_<table>, a BEFORE INSERT OR UPDATE row trigger that reads the parent FOR SHARE when the key arrives or moves, waits for an uncommitted archive, and copies the parent's _deleted_at; an archive in turn waits for an uncommitted insert and takes its child. A key declared on an MTI descendant archives the ancestor's row from an AFTER trigger. The stamp is the parent's own, so restoring the parent brings the late child back.
  • GUITARS_CASCADE_GUARD (default True): False retires every recorded guard.
  • tests/test_concurrency_arms.py: the interleavings, stepped by hand with two connections, and the limits that remain.

Changed

  • A child created under an archived parent is archived with it, where it was live. Code that relied on a live child under an archived parent needs the parent restored first.
  • Existing projects: makemigrations writes one enforcement migration per app that hosts a cascade child, creating the guards. A guard reads the parent row on every insert and on every update that moves the key, and holds FOR SHARE on it until commit, so an insert waits for a concurrent update of that parent and a bulk load pays a lookup per row, and two transactions that each insert a child and then update the same parent (a counter, a denormalised total) now deadlock (40P01) where they serialised. If that pattern is common in your code, set GUITARS_CASCADE_GUARD = False. It runs as the invoker: FOR SHARE needs UPDATE on the parent table, and a role without it gets a plain read, so no insert fails but the race stays open for that role. session_replication_role = replica skips it.
  • Not closed, and pinned by tests: an archive in a REPEATABLE READ transaction whose snapshot predates a child's commit still misses that child (two SERIALIZABLE sessions are refused with 40001), and a child restored by hand under an archived parent stays live.
  • RetireEnforcement takes the guard, with a column or a whole table: its body names the key and _deleted_at.
  • Tests only, for corners the suite did not reach: a self key on a model with no _updated_at (Twig), an MTI child's redirect rule and parent trigger across a drop and a rename in the scan, and --adopt over a recorded self-cascade trigger.

v2.21.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 15:57
Immutable release. Only release title and notes can be modified.
6cdfaad

Fixed

  • A model deleted and later recreated on the same table, a renamed model's old table retaken by a new model, and a model moved to another app and back are no longer read as covered (#66, ADR 0043). The scan read every app's files in registry order and never reacted to a DeleteModel, so a recreated or retaking table got no soft-delete rule, no _updated_at trigger and, tenanted, no policy (its rows deleted outright by a raw DELETE), and a moved-back model's owned sweep kept naming the intermediate table, failing every UPDATE of its owner, all with makeguitarmigrations --check green. It now replays the migrations in the order migrate runs them, each file's table events (create, rename, drop) before its headers: a drop voids what lived on the table, a rename moves every family with it and forwards a later header that still names the old name, and a name a new model takes is a new table. A file whose tables were since renamed away or dropped no longer vouches for its [DIGEST], so an identical operation set is written again. live_tables, _rekey, _chain_ends and keep_existing go, and so do graph.renamed_tables, renames_by_migration, moves_between_apps_by_migration and retired_enforcement. Existing projects: makemigrations (or makeguitarmigrations) writes the missing coverage for a history of any of the three shapes: a plain CREATE for the table, a re-emitted sweep and owner trigger for a model moved back. Two migrations the graph leaves unordered are replayed in migrate's order; --check names the missing edge (ADR 0036).

v2.20.1

Choose a tag to compare

@github-actions github-actions released this 09 Oct 13:45
Immutable release. Only release title and notes can be modified.
9ff309b

Changed

  • Tests only: strict-xfail repros for the shapes #66 still has open on 2.20.0, from its re-scope. Pinned: a plain cascade child deleted and later recreated on the same db_table, and a model retaking a renamed model's old table, each left with no soft-delete rule or _updated_at trigger and --check green, so a raw DELETE removes their rows; and a model moved to another app and back, whose owned sweep still names the intermediate table, so every UPDATE of its owner's table fails. The recreated MTI, tenanted and primary-key-changed variants are on the issue, not pinned. Four fixture apps (tests/issue66_*) carry the histories. No library change.

v2.20.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 12:49
Immutable release. Only release title and notes can be modified.
5a58827

Changed

  • A cycle of CASCADE keys through several tables is enforced (#85, ADR 0041). It was refused while the cascade was an ON UPDATE rule, which PostgreSQL rewrites into itself; since 2.19.0 it is an arm of the owner's trigger, and an arm stops at _deleted_at IS NULL. Every project that declared one gets the arms: one archive stamps the whole loop it reaches, and a restore matches that stamp, so it travels up the loop too. A joined key into its own MTI root is enforced as well. soft_delete() and the .delete() fast path no longer decline a cycle edge. A cycle holding an owned key is still refused, with a note that no longer cites rule recursion. CascadeKind.CYCLE is gone and classify_cascade takes no cycle_edges. Existing projects: makemigrations (or makeguitarmigrations) writes a migration for each app hosting a cycle key, which replaces the owner's soft_delete_cascade_on_* trigger with one carrying the arms.
  • A self-referential CASCADE key is an arm of the owner's trigger (#85, ADR 0042). It took a trigger of its own since 2.8.0, which stamped NOW() and never revived. It now carries the parent's _deleted_at and a restore revives the subtree archived with it; a child archived earlier keeps its own stamp. The primary-key-rewrite refusal is the union of the two (wider than the old trigger's for a statement that re-keys an archived row and also touches a live sibling with children), and a $$ in a name no longer refuses the key. makemigrations writes, for each app hosting a self key, a migration that replaces the owner's trigger and retires the self one, whose reverse rebuilds it (the owner's trigger, re-emitted, reverses to a DROP as every re-emission does). Existing projects: DROP TRIGGER takes ACCESS EXCLUSIVE on those tables until the migration commits, so set lock_timeout. Dropping a self key's column now fails an archive or a restore of the table until re-generated, not every UPDATE.

v2.19.1

Choose a tag to compare

@github-actions github-actions released this 09 Oct 10:40
Immutable release. Only release title and notes can be modified.
7f0d8fd

Changed

  • The MTI parent _updated_at trigger skips an ancestor this transaction already stamped (#84, ADR 0040). A full save() of a multi-table-inheritance child updates the ancestor first, so the follow-up UPDATE rewrote the row with the same value: two writes became one. A child-only update_fields save still stamps the ancestor. The change is a body replaced in place, CREATE OR REPLACE FUNCTION set_parent_updated_at(): no child table is migrated or locked, and the WHEN (pg_trigger_depth() = 0) stays.

Existing projects: run makemigrations (or makeguitarmigrations). It writes one migration in TRIGGER_FUNCTION_APP that replaces the function body; unapplying it puts the old body back.

v2.19.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 09:58
Immutable release. Only release title and notes can be modified.
1398768

Changed

  • _updated_at is a BEFORE UPDATE … FOR EACH ROW trigger that assigns NEW, not a statement trigger that issues a second UPDATE (#80, ADR 0038). The old form re-UPDATEd every row through dynamic SQL, so each row was written twice, the plan was never cached, and the follow-up was itself rewritten by the table's ON UPDATE rules: on a consumer's staging database one single-row UPDATE of a table with six cascade rules became 35 statements over 13 tables, 466 of 576 ms spent in updated_at_trigger. It now assigns NEW._updated_at := NOW() on the row being written, so one UPDATE writes each row once and RETURNING _updated_at reads the stamped value. Always assigned, as before: a caller still cannot set the column. Existing projects: the next makeguitarmigrations emits one function migration (stamp_updated_at(), in TRIGGER_FUNCTION_APP) and one replacement migration per app that swaps each updated_at_trigger with CREATE OR REPLACE TRIGGER (SHARE ROW EXCLUSIVE, not ACCESS EXCLUSIVE, on the app's tables until it commits: writes block, reads do not, so set lock_timeout on a large app), reversing to the statement trigger; makemigrations --check is red until it does. The frozen set_updated_at() stays in a migrated database, called by nothing, and a table not yet regenerated keeps the old trigger and works. The MTI parent trigger (set_parent_updated_at) is unchanged.
  • The self-referential cascade no longer leaves _updated_at stale below its first level. The new trigger carries no WHEN (pg_trigger_depth() = 0), so the cascade children of every level a raw DELETE reaches are stamped, as the ORM collector already did; the two paths now agree. delete() therefore takes its fast path for a self-referential key, which it used to stand aside for on this account.
  • The cascade and the owned rule are no longer ON UPDATE rules (#80, ADR 0039). A rule is expanded and planned on every UPDATE of its table, whether or not _deleted_at moves: one rename of a Band was twelve statements over eight tables, and each cascade level multiplies it. Each cascade key is now an archive arm of its owner's existing statement trigger, beside its revive arm, behind a first EXISTS that a plain UPDATE stops at, so the same rename is one statement. Flat and joined forms; it reads the owner's before image for the key and the after image for the stamp, keeping _deleted_at IS NULL and the parent's own timestamp. The owned rule is retired and its sweep, which stamped a superset, carries the work alone. Existing projects: the next makeguitarmigrations emits, in each app hosting an owner, the owner's new soft_delete_cascade_on_* trigger (cascade arms and revive arms in one), a retirement of the revive-only soft_delete_revive_on_* trigger it supersedes, and a retirement of every cascade rule and owned rule it recorded, each reversing to what it dropped; makemigrations --check is red until it does. A rule is dropped only where the trigger carries its arm, so one whose arm is refused keeps cascading. The migration takes ACCESS EXCLUSIVE on each owner table (the revive-only trigger's DROP) and SHARE ROW EXCLUSIVE on the others until it commits: set lock_timeout on a large app.
  • A statement archiving a row while rewriting its primary key is refused (feature_not_supported) where a live child holds a key of a live row the statement read (so a pk reused inside it is caught too), as the self-referential cascade has been since 2.8.0: the rule read old. per row, the arm pairs a row across the statement on its primary key. Rewrite the key and archive in separate statements; a key rewrite with nothing live below it, or with no archive, is untouched.
  • Without FORCE ROW LEVEL SECURITY the cascade no longer reaches a child the scope cannot see. A rule's action ran as the table owner, who is exempt from row-level security unless forced, so it archived cross-tenant children; the arm runs as the invoker and leaves them live, as under FORCE (the default) both always did. Only GUITARS_RLS_FORCE = False, the staged-retrofit state, differs.
  • What a plpgsql arm gives up that a rule recorded. A rule depended on every table and column it named, so a RemoveField or DeleteModel of a child failed at migrate. An arm records no dependency: dropping or renaming a child's table or key column leaves the owner's trigger naming what is gone and fails an archive of that owner until makeguitarmigrations re-emits it (--check is red). DISABLE TRIGGER and session_replication_role = replica now switch a cascade off, where a rule survived both.
  • A table or column named with $$ gets another dollar-quote tag in the owner trigger and the owned sweep ($guitars$), where an arm spliced into $$ would close it early. No key is left without its cascade, and no recorded rule is retired for want of one; every migration written so far keeps $$.
  • sweepowned follows a relation the database holds an owned rule or a sweep for, since a 2.19.0 database holds no owned rule. A scan no longer flags an app for the digest guard when it reads a trigger-family retirement, which would have waived it for ever after the one-time retirement of every owned rule (ADR 0021 point 9).

v2.18.1

Choose a tag to compare

@github-actions github-actions released this 08 Oct 16:18
Immutable release. Only release title and notes can be modified.
39d762b

Changed

  • Documentation only: ADRs 0036 and 0037 are accepted, and CLAUDE.md describes the 2.18.0 behaviour it had gone stale on (the sparing closure reads a GenericRelation child; migration edges also run forward, and a model moved between apps is read as a rename).

v2.18.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 16:13
Immutable release. Only release title and notes can be modified.
5661cf8

Fixed

  • A fresh migrate could run a rename or delete of a table before an older enforcement migration naming it (#61, ADR 0036). The SQL of a cascade rule or an MTI redirect rule names a table; nothing ordered it before a later migration of another app that renames that table away or drops it, so a fresh database failed with relation "…" does not exist where an incrementally migrated one never did. The makemigrations override now writes the dependency onto the rename or delete it has just built (not the leaf --update rewrites), and makemigrations --check names any pair already on disk with the migration to edit and the tuple to paste. Column-level changes are not read. Upgrade step: a history already holding such a pair turns --check red until the named dependency is added to the rename or delete migration.
  • A model moved between apps (SeparateDatabaseAndState renaming the table on its database side and deleting the model on its state side) is read as the rename it is (#66): its coverage follows the table, instead of the new name reading as uncovered and colliding on the plain CREATE. What the destination's app filed under the new name is kept whichever app is scanned first, and what another app filed under the old name is carried to the end of its chain once the scan is done, through later renames or moves, before any retirement is settled; a rename chain that began in another app is joined to this one's, so the old rule is dropped rather than left live (ADR 0037). Known limits: a model moved back keeps a stale rule name, and the migration doing the move's database half has to be ordered before the enforcement naming its table by hand.
  • hard_delete() aborted at COMMIT for a GenericRelation child (#76). Sparing now reads what points at a generic child, as it does a cascade child's, and spares the target instead.
  • A tenant lookup spelled as the column (tenanted_manager(label='label_id')) read as an ancestor's field: the policy joined the table to itself and no autofill trigger was written. It is the model's own field now. Upgrade step: such a project regenerates its tenant policy (makemigrations).
  • The retirement reverse of a revive trigger refuses, rather than rebuilds, a body naming $$, as the forward path has always skipped it.
  • Instance hard_delete() asks guitars.E005 of every model it can seed from before Phase 1, owned targets included; it used to meet one mid-walk, after signals had fired.

Changed

  • The owned fixpoint reads each cascade closure once (#60). _OwnedScan keeps each root's closure and narrows it for a shrinking candidate set and for a spared target asked again next round; a single WITH RECURSIVE cannot express the shrinking fixpoint, PostgreSQL refusing the recursion under NOT EXISTS. The delta-only rounds shipped in 2.14.3.

v2.17.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 08:55
Immutable release. Only release title and notes can be modified.
da67221

Added

  • guitars.E005 refuses an MTI child that declares a primary key of its own beside its parent link (#64, ADR 0034). Every join the kit writes from such a child up to an ancestor holding a kit column or tenant dimension read its key as that link: a DELETE of the child archived an unrelated ancestor row, the parent _updated_at trigger and owned arms touched or matched one, and a tenant policy's owner join matched another tenant's row. manage.py check and migrate now stop on it, with a hint: drop the explicit key, or make the model its own with a foreign key to its parent. The generator re-asks the question for a project that skips checks, and writes no MTI redirect rule, parent trigger, owned arm, owner-join policy or cascade rule into such a model, naming E005 in a note except for the owned arm, which is dropped quietly; hard_delete(), which runs no check, raises ImproperlyConfigured for it, for its whole tree in the queryset form and for any such child the instance walk reaches, where it removed an unrelated ancestor row. A tenanted model with no kit column is covered too. A child holding its own column or tenant dimension is not: it joins nothing.

Fixed

  • A CASCADE key through to_field matched on the owner's primary key (#59, ADR 0035). With an integer column the rule archived the wrong rows (a seat holding number 2 matched the ticket whose pk was 2); with a char column CREATE RULE compared varchar with bigint and migrate failed. The cascade rule, its revive arm and the self-cascade trigger now match children on the to_field column, pairing a row across a statement on the pk. A key into the primary key renders as it did: nothing re-emits for it. A column declared on a descendant, below the table holding _deleted_at, is a column the rule cannot read, and is refused with a note instead.
  • soft_delete() and the .delete() fast path take a to_field key now: it was a refusal, and a decline, while the rule matched the wrong rows.

Changed

  • Upgrade step. A project with a to_field cascade key regenerates one enforcement migration (makemigrations); --check is red until it does. A project with the E005 shape cannot start until it restructures the model.

v2.16.0

Choose a tag to compare

@github-actions github-actions released this 07 Oct 17:40
Immutable release. Only release title and notes can be modified.
66d7a97

Changed

  • One revive trigger per owner table (#70, ADR 0033). Since 2.11.0 every cascade key carried its own statement-level revive trigger on the owner table, each running a full UPDATE … FROM join over the transition tables on every UPDATE of that table: one consumer table had 16. The generator now emits one trigger and function per owner, soft_delete_revive_on_<owner>, with every key's revive as an arm, joined forms included, behind one test of whether the statement revived a row at all. On 16 keys scripts/bench_revive.py measures +0.245 ms an UPDATE per key against +0.047 ms per owner. Upgrade step: makemigrations (or makeguitarmigrations) emits one migration in each app hosting an owner that retires every per-key revive whose key still cascades, IF EXISTS over every spelling its tables held, and creates the per-owner triggers; --check fails until it is generated. Its reverse rebuilds the per-key triggers.
  • A child table dropped without its retirement (#63) now breaks only an UPDATE that revives a row of the owner, where it broke every UPDATE. Quieter, so keep makemigrations --check in CI: deleting the child's model moves the owner trigger's digest, and --check in a run including the trigger's host app fails until the trigger is regenerated without that arm. A run scoped away from that app names the trigger.
  • An MTI parent and its child both keyed from one model no longer clash on a revive name: their revives are two arms of one function. The cascade rule clash on that shape is unchanged.

Added

  • scripts/bench_revive.py, timing a single-row UPDATE with N keys' revives per key and per owner. Not run in CI.