django-logic 0.13.0
A small, deliberate release triaged from what the 0.12.0 review passes filed
(#185, #187, #189) and what a consumer's August release review raised (#190,
#191). Two additions make coexistence and generic error handling first-class
for consumers; two fixes close honesty gaps on the failure paths; and the
0.12.0 multi-database routing fix is finally pinned by tests. An adversarial
review pass over the release's own diff then confirmed five gaps in the first
cut — each fixed and mutation-pinned before shipping (they are folded into
the bullets below). Deliberately not here: the lock-key identity rework
(#184, #186) — changing the key's shape breaks lock recognition between old
and new processes during a rolling deploy, so it gets a dedicated release
with a migration note rather than a quiet ride in a mixed minor.
Added
-
TransitionTemporarilyUnavailable(#191,django_logic.exceptions) — a
shared base for the transient concurrency refusals, slotted between
AlreadyInProgress/SourceStateChangedandTransitionNotAllowed. "Busy,
retry shortly" and "you may not do this" were indistinguishable at a generic
top-levelexcept TransitionNotAllowed, so whatever answer that handler gave
was wrong for one of them — typically telling a user who clicked during an
in-flight background transition that the action is not allowed. One
except TransitionTemporarilyUnavailableahead of the base now answers
409/retry, without importing the background subpackage or enumerating
subclasses per call site. The sync gate that rejects a transition while an
uncompletedTransitionMessageexists raises it too — that was the
motivating scenario, and it resolves exactly when the flight completes.
Existing catches keep working (the base still
subclassesTransitionNotAllowed); the hook runner's WARNING-vs-ERROR
distinction (#154) now branches on this type. Lock contention ("State is
locked") deliberately stays plainTransitionNotAllowed— a TTL-stuck lock
is not "retry shortly". -
DJANGO_LOGIC['LEGACY_EXCEPTION_BASE'](#190) — first-class coexistence
with a differently-named fork during a migration. Declare the fork's
TransitionNotAllowedby dotted path and it is mixed into this engine's
TransitionNotAllowed.__bases__atAppConfig.ready(), so shared handlers
that catch the fork's class keep answering gracefully while apps migrate one
at a time. The only prior option was a local patch toexceptions.py— a
patch that re-vendoring has now silently destroyed twice, each time turning
graceful refusals into HTTP 500s with no test failing. Zero cost when unset;
every failure mode (unimportable path, non-exception class, MRO conflict)
raisesImproperlyConfiguredat boot, because a broken bridge must never be
silent — including a fork class whose non-message__init__would have
serviced every denial's construction through the new MRO: the installer
smoke-constructs the bridged class and unwinds the mutation if that fails.
Fixed
-
Action.fail_transitioncould clobber an in-flight transition's state
(#185). Thefailed_statewrite was guarded byis_locked()and then
performed non-atomically, so aTransition(or a background phase 1)
acquiring the lock in the window between check and write had its state
overwritten by the Action's stale write — against a background flight, the
phase-2 state guard would then supersede the whole flight. The write now
happens only under an atomically-acquired lock (cacheadd) — and, because
the cache lock is free for a background flight's entire queued/phase-2 span,
the durable marker is consulted under it too: while an uncompleted
TransitionMessageexists the write is skipped and logged, since phase 2
owns the state field until the row completes. The lock releases through the
shared path, so it emits theLock/Unlocklifecycle lines (#188) and
honoursDEFER_UNLOCK_UNTIL_COMMITwhen the write landed (#141). Actions
still run their side-effects lock-free; the acquire is scoped to the one
UPDATE. -
A silently-discarded
failed_statewrite logged a falseSET_STATE
(#189, completing therequire_commitfix from 0.12.0'sbd1445a). The
terminal failure paths —_handle_failureand the watchdog/detect-stuck
finalizer — wrotefailed_statethrough_run_in_savepointwithout
require_commit, so a consumer receiver that raises a database error and
suppresses it (the receiver-authoredtry: save() except IntegrityError: passidiom, no nestedatomic()) made the savepoint roll back silently
while the trace said the write landed. Both sites now pass
require_commit=True, routing the rollback into the existing honest
except-branch: log the failure, record it on the row where an operator will
see it, complete the row anyway. Thefailure_side_effectsbundle was the
third terminal savepoint with the same hole — a silently discarded cleanup
bundle is now returned and recorded on the row instead of vanishing behind
success-shaped bookkeeping.
Tests
- The 0.12.0 multi-database alias routing is now pinned (#187). A second
in-memory SQLite alias in the test settings plus routed fixtures assert that
State.get_persisted_state()reads the instance's own database and that the
engine's savepoints open on the instance's alias — the sync failure path,
the background attempt, and both terminalfailed_statewriters
(_handle_failureand the watchdog finalizer, the latter via a veto-shaped
test, because only a rolled-back write can tell the aliases apart).
Previously mutation testing showed all of it could be reverted to
default-only with the whole suite staying green.