- feature:
denorm.deferred(*targets)-- an opt-in context manager (also usable as a decorator) that defers the computation of the named@denormalizedfields. Inside the block theirpre_save()writescolumn = columnon UPDATE (F(column)) and the field default on INSERT. When the outermost block exits, one per-functionDirtyInstancemarker per deferred(model, pk, field)is inserted in the current transaction, soflush()/ the queue computes the value once, on final data. Typical use: wrap a Django admin change-form save, so a field derived from inline rows is not computed on the parent's save (before the inlines exist) and does not lock other tables ahead of the parent row (an ABBA deadlock againstflush_single). Targets are explicit ("app.Model.field","app.Model", a model class or a field). State lives in acontextvars.ContextVar(thread- and asyncio-task-local), blocks nest, andDENORM_ALWAYS_EAGERdefers its eager flush to the block's exit. Default behaviour outside a block is unchanged.
This is a MINOR release rather than a patch: aggregates over a
ManyToManyField are documented but have never actually worked on any
release of this package, so making them work adds a capability instead of
merely correcting a regression.
CountField/SumFielddeclared over aManyToManyFieldnow work.AggregateDenorm.m2m_triggers()— the through-table triggers, the only ones that see a row being added to or removed from an m2m relation — was reached exclusively from that configuration, and it had been dead since Django 1.8:get_related_where()calledmanager.related(gone),Query.add_count_column()(removed in 1.8) andclear_ordering(force_empty=True)(renamed in 4.0), so building the triggers raisedAttributeErroratdenorm_init/install_triggers()time — every attempt failed, on every supported Django. The subquery is now built with the currentQueryAPI (add_annotation(Count("*"), ...),clear_ordering(force=True)).SumDenorm's related increment/decrement additionally selectedself.fieldname— the denormalized column, which lives on the other model — instead of the summed columnself.sum_field.- The m2m through-table subquery is now compiled with the
NEW/OLDquoting exemption introduced in 1.13.0, so the revived code path cannot reintroduce the Django 6.1 alias-quoting breakage. The exemption is now a mixin (TriggerAliasQuotingMixin) applied on top of whichever compiler class the backend hands out, rather than a hardcodedSQLCompilersubclass.
- Added support for Django 6.1. CI now tests Django 5.2, 6.0 and 6.1; Django 6.1 (like 6.0) requires Python 3.12+.
- fix: Django 6.1 compatibility — filtered aggregates
(
CountField/SumFieldwithfilter=/exclude=) generated broken trigger DDL. Django 6.1 changedCol.as_sql()to useSQLCompiler.quote_name()instead of the now-deprecatedquote_name_unless_alias(), so the fakeNEW/OLDalias used to compile the filter came out as"NEW"."col"— a table reference, which PostgreSQL rejects withmissing FROM-clause entry for table "NEW". The newTriggerSQLCompilerkeeps those two PL/pgSQL record variables unquoted on every supported Django version. - fix:
Trigger.sql()now quotes the table name in the generatedCREATE TRIGGER ... ON <table>/DROP TRIGGER ... ON <table>DDL. It was the one identifier in the trigger generator still interpolated raw (everyTriggerAction*body already quoted its table), so a model with a reserved-word or mixed-casedb_tableproduced broken trigger DDL atdenorm_init/denorm_rebuild_triggerstime. - fix: the M2M
CacheKeyFieldtrigger now quotes the primary-key column in its<pk> IN (SELECT ...)WHEREclause. A model whose PK column needs quoting (reserved word / mixed case) produced broken SQL. - fix:
rebuildall(verbose=True)no longer truncates the post-rebuild flush.verbose(a logging flag) was passed positionally toflush(), whose first parameter isrun_once— soverbose=Truestopped the flush after a single pass and left dependency-cascade markers unprocessed. - fix:
denorm_flush_via_queueno longer crashes withTypeError: object of type 'EagerResult' has no len(). It treated the return value offlush_via_queue(the chord callback'sAsyncResult) as aGroupResult. The command now dispatches the task fire-and-forget and polls theDirtyInstancetable until it drains, bounded by a new--timeout(and--poll-interval). Multi-pass chord convergence means there is no single group to await. - fix:
denorm_queuedaemon robustness — the reconnect backoff now resets to its base after a successful (re)connection (a flapping DB no longer accumulates an ever-growing wait);select()uses a finite keepalive timeout (so a silently dead connection is surfaced and shutdown is prompt) and a signal-interruptedselect(InterruptedError) no longer crashes the daemon; theLISTENcursor is closed promptly instead of leaking one per reconnect. - feature:
DenormMiddlewareis now configurable via theDENORM_MIDDLEWARE_FLUSHsetting ("inline"(default) /"queue"/"off") and only flushes whenDirtyInstancemarkers actually exist (a cheapexists()guard, correct for bulk/raw writes too). In"queue"mode it dispatchesflush_via_queueinstead of blocking the request; in"off"mode it does nothing, deferring to thedenorm_queuedaemon.
- fix: triggers now resolve a model's
django_content_typeid with a(SELECT id FROM django_content_type WHERE app_label = ... AND model = ...)subquery evaluated at trigger-fire time, instead of baking the id as an integer literal at trigger-build time. A baked literal is only correct while the content-type table is never renumbered after the triggers are installed; that assumption breaks under Django'sTransactionTestCaseteardown (which TRUNCATEsdjango_content_typeand letspost_migraterecreate the rows with drifting ids) and, more rarely, after a restored dump that renumbered the table or aremove_stale_contenttypes+ recreate without a trigger rebuild. When the literal went stale the marker insert referenced a content type that no longer existed and the FKdenorm_dirtyinstance_content_type_id_...raisedForeignKeyViolation. The lookup is a single probe of the(app_label, model)unique index on a tiny, fully cached table, so the per-fire cost is negligible. (The scalar pk is still used for the trigger name andWHENclause, which cannot contain a subquery.) - fix:
drop_triggers()now matches the real trigger naming (d_{aft,bef}_row_{ins,upd,del}_on_<table>, plus the legacydenorm_%prefix). The oldLIKE 'denorm_%'matched none of the library's own triggers, sodrop_triggers()/denorm_drop/ the drop half ofdenorm_rebuild_triggerswere silent no-ops. Becauseinstall_triggersonlyCREATE OR REPLACE-s same-named triggers, an upgrade that renames a trigger (e.g. the 1.11 → 1.12 self-trigger rename) would orphan the old one — and nothing could clean it. A drop+install rebuild now genuinely removes the whole set first.DROP TRIGGER IF EXISTSguards against races.
- perf:
flush_via_queuenow self-converges via a Celery chord. Each dispatch fans the current backlog out intoflush_batchtasks and, when they finish, re-dispatches itself if any object was marked dirty during processing (cross-object cascades) — until theDirtyInstancetable is empty. This mirrors the inlineflush()convergence loop, so the queue path no longer depends on a freshLISTEN/NOTIFYto finish draining a cascade. Bounded by the newDENORM_MAX_QUEUE_PASSESsetting (default100), the queue analogue ofDENORM_MAX_FLUSH_PASSES. - fix:
flush_batchnow isolates per-pair failures. Ifflush_singleraises for one(content_type_id, object_id)pair (retry-exhausted serialization failure, or any non-transient error), the exception is logged atERRORlevel with full traceback and the(ct, oid)pair, and iteration continues with the next pair. The task returns normally so the chord callback (_flush_requeue) is guaranteed to run — preventing a single bad object from stalling the entire convergence round. The failing object'sDirtyInstancemarker persists (flush_single's transaction rolled back, so the marker was never deleted) and is retried automatically on the next round, bounded byDENORM_MAX_QUEUE_PASSES. - perf: marker INSERTs performed during a flush no longer emit a NOTIFY
wake-up.
flush_singlesets a transaction-local GUC (SET LOCAL denorm.flushing = 'on') and migration0019guards the notify trigger function with it, so only genuine writes (application saves, bulk updates, raw SQL) wake thedenorm_queuechannel. This removes the wasted NOTIFY→no-op-flush churn under heavy cascades. Migration0019is reversible (restores the unconditional NOTIFY from0016). - Dropped Django 4.2 support (extended support ended April 2026). Minimum is now Django 5.2 LTS. CI tests Django 5.2 and 6.0.
- fix:
flush_singlenow deletes claimedDirtyInstancemarkers at claim time. Previously, the unique-index dedup could silently swallow concurrent invalidation markers inserted between the claim and the delete, permanently losing them until the next full rebuild. - fix: merged triggers now union their watched-column lists. When two
triggers collided on a name,
TriggerSet.appendmerged only the newcomer's actions and kept the existing trigger's watch-list, silently dropping any column watched solely by the newcomer; a change to that column would not fire the trigger, causing latent missed invalidation (stale data). The watch-lists are now unioned (harmless over-fire). - feature:
@depend_on_fields(*names)— declarative same-model dependencies for@denormalizedfunctions. A declared function gets a targeted per-function database trigger that fires only when a declared column changes.@depend_on_fields()with no arguments means "reads no sibling columns" and emits no UPDATE self-trigger for that function (the INSERT marker and any@depend_on_relatedtriggers remain). - Per-function triggers replace the old catch-all self-trigger: the
library's triggers no longer emit
func_name=NULLmarkers. Conservative (undeclared) functions get an any-watched-column trigger that excludes the function's own column. Consequence: bulk-writing a denormalized column directly is unsupported and does not mark anything dirty; write source columns instead or callmark_dirty(). NULLcontract:func_name=NULLmeans "recompute every denormalized field of this object" and takes precedence over field-level markers during flush. Onlymark_dirty(*instances)andrebuildall/rebuild_instances_ofproduce NULL markers now.- feature:
denorm.mark_dirty(*instances)— explicitly marks whole objects dirty (NULL markers). - feature:
@denorm.denorm_always_dirtymodel class decorator — every usersave()of the decorated model unconditionally inserts afunc_name=NULLdirty marker, guaranteeing that all its denormalized fields are recomputed by the nextflush().flush()converges normally: it recomputes and clears the marker, and its own recompute save does not re-mark the object (a thread-local flush-in-progress guard). Intended for denorms whose value depends on inputs the trigger /@depend_on_related/@depend_on_fieldssystem cannot express (external state, time-based values, complex cross-table reads).QuerySet.update()/bulk_create()/ raw SQL bypasspost_saveand therefore bypass this decorator — usemark_dirty()explicitly for those paths. - feature:
DENORM_MAX_FLUSH_PASSESsetting (default100):flush()aborts with an error log naming the still-dirty models instead of looping forever when a denormalized function is non-deterministic. - feature: Django system checks
denorm.E001,denorm.E002,denorm.W001,denorm.W002audit@depend_on_fieldsdeclarations using an AST scanner. An incomplete declaration causes silent staleness, exactly like a missing@depend_on_related. Silence individual checks viaSILENCED_SYSTEM_CHECKS. PK reads are ignored. - Targeted flush saves now use
update_fields—auto_nowcolumns are no longer touched by targeted flushes.DENORM_DISABLE_AUTOTIME_DURING_FLUSHnow only applies to whole-object (func_name=NULL) flushes. - Upgrade note: run
manage.py denorm_rebuild_triggersafter upgrading — trigger SQL changed shape (per-function markers, ON CONFLICT, WHEN clause). - fix:
denorm_queuenow drainspg_con.notifiesafter eachpoll()call. Previously, the list grew without bound under sustained write traffic, leaking memory in this long-running daemon (spec 1.1). - fix:
denorm_queuefires oneflush_via_queue.delay()immediately afterLISTENsucceeds, before entering the select loop. PostgreSQL does not queue NOTIFYs for disconnected listeners; dirty rows accumulated during a deploy or failover are now flushed without waiting for the next unrelated write.Singletondeduplicates if a flush is already queued (spec 1.2). - fix:
DENORM_SINGLETON_LOCK_EXPIRYsetting (default600seconds) is now passed aslock_expiryto everycelery-singleton-based task. Without an expiry, a SIGKILLed worker left its Redis lock forever, permanently wedging enqueuing for the affected object (spec 1.3). - fix:
CountField/SumFieldpre_saveno longer SELECTs the current counter and writes it back. It now writescol = col(a DjangoF()expression), resolved inside theUPDATEitself. A concurrent trigger increment between any read and the UPDATE can no longer be silently overwritten. Contract note:save()leaves the in-memory attribute untouched; callrefresh_from_db()when the current value matters (spec 1.4). - perf: dirty-marker
INSERTstatements now useON CONFLICT DO NOTHINGinstead of a plpgsqlEXCEPTIONblock. TheEXCEPTIONform opened a subtransaction on every execution (even when no conflict occurred), a known PostgreSQL scalability cliff under high write concurrency (spec 2.1). Re-runmanage.py denorm_rebuild_triggersafter upgrading. - perf: UPDATE-trigger change-detection (
OLD.col IS DISTINCT FROM NEW.col) now lives in theCREATE TRIGGER … WHEN (…)clause instead of anIFinside the trigger function. PostgreSQL evaluatesWHENbefore invoking plpgsql, so rows that touch no watched column skip the function entirely (spec 2.2). Re-runmanage.py denorm_rebuild_triggersafter upgrading. - perf:
flush_singlenow resolvesContentTypeviaContentType.objects.get_for_id()(process-level cache) instead ofContentType.objects.get(pk=…). A 100k-row flush previously issued 100k identical queries (spec 2.3).flush()now iterates distinct pairs with.iterator(chunk_size=2000)(server-side cursor) instead of materialising the full list in memory (spec 2.4). - perf:
flush_via_queuenow dispatches acelery.groupofflush_batchtasks, each coveringDENORM_QUEUE_CHUNK_SIZE(default50)(content_type_id, object_id)pairs. Previously one Celery task was created per pair — a 500k-row backlog produced 500k broker messages. The legacyflush_singletask is kept as a thin wrapper for one release so tasks already sitting in brokers during a rolling deploy continue to execute (spec 2.4). - perf: marker-claim queries now use
COALESCE(object_id, -1)to match the expression index created in migration 0017, making per-object marker lookups O(log N) instead of O(N) for content-type-dominated backlogs (spec 2.6). Three redundant indexes were removed (func_name,created_on, and the automatic FK index oncontent_type); migration0018handles this. Users who filterDirtyInstancethemselves should reviewpg_stat_user_indexesand re-add needed indexes in their own apps. - perf:
flush_singlenow converges same-model denorm chains inside one transaction (spec 2.5) instead of requiring one outerflush()pass per chain link — fewer transactions and locks on the hot path. For example, a two-link chain (first_name→full_name→letterhead) is now settled in a singleflush_singlecall with two targetedupdate_fieldssaves instead of two separate transactions. New settingDENORM_MAX_CONVERGE_PASSES(default5) caps the inner loop for non-deterministic functions; on hitting the cap, remaining markers fall back to the outerflush()loop (bounded byDENORM_MAX_FLUSH_PASSES). No API change, no migration, no trigger SQL change (denorm_rebuild_triggersis not needed for this fix). - perf: ORM saves drop provably-redundant self-markers (plain-column
same-model denorm fields) in a
post_savehandler, eliminating the redundantflush()re-save for the common compute-from-own-columns pattern. Chain denorms (depending on another denorm field), related denorms (@depend_on_related), and bulk/raw writes are unaffected. denorm_flush_via_queuecommand now usesresult.get(timeout=…)instead oftime.sleep(0.5)and times progress against the number of dispatched tasks rather than rawDirtyInstancerows. A Celery result backend is required (spec 3.2).- Testing: the test suite now runs the Celery queue against a real Redis
broker and result backend (
testcontainers,RedisContainer). A handful of end-to-end tests use a real in-process Celery worker (eager OFF) to exercise the genuine broker serialization → worker round-trip →flush_batchgroup fan-out →flush_singledrain path. The rest usetask_always_eagerfor speed. Singleton lock-backend dedup is tested with direct Redis lock assertions (deterministic, no race). Contributors now need Docker for the full test suite (already required for Postgres). No runtime or API change. - Dead code removed: Django < 4.2 compatibility branches
(
add_lazy_relation, Django 1.8–1.10 try/excepts, version-guarded middleware wrapper); unusedDirtyInstancehelpers (DEFAULT_TIMEOUT,WEEK_AGO,find_similar,delete_similar,delete_this_and_similar); unuseddenorm_queue_namevariable indb/triggers.py; unusedDenorm.update()(spec 3.3). - feature:
DENORM_ALWAYS_EAGERsetting (defaultFalse) — test-only synchronous flush after everypost_save/post_delete/m2m_changedsignal. Mirrors Celery'stask_always_eager: denorm fields on dependent objects and same-model chains settle immediately without a manualdenorm.flush()call or a Celery worker. Enable per-test with@override_settings(DENORM_ALWAYS_EAGER=True). Not for production — reintroduces synchronous coupling. Bulk paths (QuerySet.update(),bulk_create(),bulk_update(),mark_dirty()) fire no per-row signals and are not auto-flushed; calldenorm.flush()explicitly after bulk writes.
- fix:
denorm.tasks.flush_singleis now keyed by the logical(content_type_id, object_id)pair instead of a representativeDirtyInstancepk. Previously, if the captured marker was deleted by a concurrent flush path betweenflush_via_queueenqueue and task execution, and a fresh marker for the same pair was inserted in the meantime, the queued task aborted onDoesNotExistand the new marker was orphaned until the next flush cycle. - fix:
denorm/templates/denorm/dirty_instances_count.htmlis now included in the built wheel via[tool.setuptools.package-data]. Without it,dirty_instances_countraisedTemplateDoesNotExistfor users installing from PyPI. - CI:
tests/test_deadlocks.py(deadlock and race-regression suite) is now executed bytoxalongside the existingtest_appsuite.
- Fix race conditions and deadlock handling in the flush pipeline
(retry on serialization failures, deletion-by-pk after
select_for_update, deduplicated subtask fan-out, no globalField.auto_nowmutation during flush). - Add
dirty_instances_countview with a configurable access policy (DENORM_DIRTY_INSTANCES_VIEW_ACCESS=staff/authenticated/public).
- Django 6.0 compatibility:
TriggerFilterQuery.JoinFieldimplementsget_joining_fields()alongside the legacyget_joining_columns(). RemovesRemovedInDjango60Warning: The usage of get_joining_columns() in Join is deprecated. Implement get_joining_fields() instead. Field.pre_save()calls are now idempotent on Django 6.0.
- packaging migrated from
setup.py/setup.cfgtopyproject.tomlwithuv, - CI updated for Python 3.10-3.13 and Django 4.2/5.2 LTS,
- GitHub Actions upgraded to Node.js 24-compatible versions; CI badge link added to README,
- README refreshed with current Python/Django versions, license info and a support matrix,
- pre-commit: added
detect-private-keyhook,pyupgradebumped to--py310-plus, .gitignoreexpanded for modern Python development,- Django 5 compatibility: removed
NullBooleanFieldand deprecated settings.
- fixes for a race condition in queue processing.
- switched from
SKIP LOCKEDtoNOWAITwhen acquiring row locks to fail fast instead of silently skipping rows.
- optimizations around the
NOWAITlocking path.
- narrowed the scope of row-level locks during queue flushing to reduce contention.
- another attempt at fixing the recurring deadlock/lock contention
issues when multiple
denorm_queueworkers run in parallel.
- skip similar, already-locked items when flushing to avoid deadlocks.
- further fixes for skipping already-locked similar items to avoid deadlocks.
- integration with
celery-singletonto prevent duplicate Celery tasks from running simultaneously.
- switched back to non-parametrized
NOTIFY, - triggers emit
NOTIFYonly onAFTER STATEMENTto wake up the flusher rather than on every row change.
- Celery tasks default to ignored results,
- queue flushing pushed to the backend (Celery worker),
- small fixes: added type to a parameter, corrected
__str__.
- new management command to flush via Celery queues,
- pop all pending NOTIFY messages before flushing.
- queue code refactored to use Celery.
- attempt to fix recurring deadlock issues.
- attempt to fix an issue with handling deletes.
- further attempts at fixing deadlocks.
- added
skip_locked=Trueto avoid deadlocks, - Django 4.2 and 5.0 support,
- tested against Python 3.11 and 3.12,
- test app migrations and general housekeeping,
- test fixes.
- updated for Django 4.2 (work in progress),
- enabled more Django configurations in CI.
- fixed
NullBooleanFieldhandling, - enabled parallel
toxtesting, - migrated CI from Travis to GitHub Actions,
- temporarily disabled Django 4.0,
- assorted build and link fixes.
- try to avoid deadlocks during queue processing,
- first stable 1.x release.
- changes to reduce the chance of multiple
denorm_queueprocesses trying to denormalize the same object
- don't wait for content_object when flushing queue, so we won't get deadlocks and Django exceptions
- select_for_update also for the updated object, so we won't get deadlocks
- include missing
confpackage.
- optimized denorms.rebuildall, using bulk_create,
- denorm_rebuild command gets 2 new command-line options, model_name and no_flush,
- ability to disable auto_now_add and auto_now fields during denorm flush, using settings -- DENORM_DISABLE_AUTOTIME_DURING_FLUSH and field names DENORM_AUTOTIME_FIELD_NAMES,
- denorms.flush works in batches now.
- first release of django-denorm-iplweb,
- based on the high-quality code of the original django-denorm
- supported versions: Python 3.8, 3.9, Django 3.0, 3.1, 3.2,
- dropped support for MySQL,
- dropped support for SQLite,
- denorm_daemon becomes denorm_queue: - removed daemonzation code, - documented need to use supervisord or similar if background process needed, - used LISTEN/NOTIFY mechanisms from PostgreSQL,
- removed six dependency and __unicode__,
- added pre-commit hooks for autopep, flake8,
- added bumpver configuration,
- automatic trigger installation after post_migrate,
- documentation updated,
- post_migration signal causes trigger rebuild,
rebuild_triggerscommand to rebuild triggers,- deprecated command
denormalizeremoved, - field names given as a parameter to
skipordenorm_always_skipare checked if they exist, - triggers and functions names, generated for
@depend_on_relatedinclude function (attribute) name, - DirtyInstance includes func_name, which is a function name to rebuild only this single parameter
- ability to run multiple
denorm_queuecommands, which (thanks to the magic of row locking) should automatically process queue in a paralell manner.