What's new in 4.2¶
Minor release. No breaking changes vs 4.1 — every addition is opt-in.
Contents¶
Tier 1 — DX + observability
Tier 2 — operational
- Per-template query stats
- OTel log correlation
- Bounded-lock ALTER COLUMN TYPE
- Migration rollback dry-run
Tier 3 — security
- EncryptedField key rotation
- PII auto-mask in streaming
- Append-only audit tables
- Pool saturation gauge + warning
- Read-replica circuit breaker
Tier 4 — DX
- Migration dependency graph
dorm reset(dev only)Model.objects.cached(timeout=...)sugarDEBUG_NPLUSONEauto-detection
Tier 5 — advanced
Tier 6 — sugar
F.apply()chainable transformsQuerySet.lookup(column=None)Manager.union_with()polymorphic- SQL allow-list
Polish (post-4.2 audit round)
dorm versiondorm doctorv4.2 auditsquerystatsinmetrics_response()- DataLoader
prime()/clear_all() - Async plan-drift APIs
sql_allowlistcapture-mode helpers
1. PostgreSQL advisory locks¶
from dorm.contrib.advisory import (
advisory_lock,
try_advisory_lock,
advisory_xact_lock,
)
# Block until acquired; releases on context exit.
with advisory_lock("nightly-report"):
run_nightly_report()
# Skip when contended.
with try_advisory_lock("nightly-report") as got:
if got:
run_nightly_report()
# Transaction-scoped — released automatically on commit/rollback.
with dorm.transaction.atomic():
with advisory_xact_lock(("orders", 42)):
...
Keys can be int, str (deterministic blake2b-8 hash) or a
(int, int) tuple. Async variants: aadvisory_lock /
atry_advisory_lock.
2. Slow-query EXPLAIN auto-collect¶
Every query slower than 200 ms is re-explained automatically. The
plan is logged on dorm.db.slow_explain (WARNING) and attached as
an OTel span event when a span is active. SELECT / WITH only —
mutations are skipped.
3. Real SQL from dorm sqlmigrate¶
dorm sqlmigrate <app> <migration> now captures the exact DDL via
the dry-run connection (previously it only dumped each op's
describe()).
4. Per-template query stats (Prometheus + JSON)¶
from dorm.contrib import querystats
querystats.collector().enable()
# /metrics/templates endpoint:
return Response(querystats.render_text(), media_type="text/plain")
# or JSON:
return querystats.render_json()
Exposes dorm_template_count /
dorm_template_total_ms / dorm_template_{p50,p95,p99}_ms per
SQL template. Memory-bounded reservoir (default 1000 samples).
5. OTel log correlation¶
import logging
from dorm.contrib.otel import install_log_correlation
install_log_correlation() # idempotent
logging.basicConfig(
format="%(asctime)s [trace=%(otel_trace_id)s span=%(otel_span_id)s] %(message)s"
)
Joins application logs to the trace timeline. No-op when OTel SDK
isn't installed (placeholders "-" keep the formatter alive).
6. Bounded-lock ALTER COLUMN TYPE¶
from dorm.migrations.operations import AlterColumnTypeOnline
operations = [
AlterColumnTypeOnline(
"User", "age", "BIGINT",
lock_timeout="5s",
old_type="INTEGER", # makes the op reversible
),
]
PG-only — wraps the ALTER in a short transaction with
SET LOCAL lock_timeout so the operation aborts under contention
instead of blocking writers.
7. Migration rollback dry-run¶
Prints the exact SQL the rollback would execute (uses the existing
dry-run connection). Combine with --settings=staging to audit
production rollbacks before running them.
8. EncryptedField key rotation¶
from dorm.contrib.encrypted import rotate_encryption_keys
# After prepending a new key to FIELD_ENCRYPTION_KEYS:
rotate_encryption_keys(User, batch_size=1000)
# Async:
await arotate_encryption_keys(User)
Walks the model in batches inside atomic() blocks, re-encrypting
every column with the new head key. Optional progress callable
fires after each chunk.
9. PII auto-mask in streaming¶
from dorm.contrib.streaming import stream_jsonl, astream_jsonl
for chunk in stream_jsonl(qs, mask_pii=True):
yield chunk
# Async:
async for chunk in astream_jsonl(aqs, mask_pii=True):
yield chunk
Fields flagged pii=True are replaced with the masked sentinel
before serialisation. Silent no-op for plain iterables that don't
carry a model class.
10. Append-only audit tables¶
from dorm.migrations.operations import MakeTableAppendOnly
operations = [
MakeTableAppendOnly("article_history"), # blocks UPDATE+DELETE
MakeTableAppendOnly("audit_log", allow_delete=True), # blocks only UPDATE
]
Installs a database-level trigger that rejects UPDATE / DELETE on
the table. Works on PostgreSQL (PL/pgSQL exception) and SQLite
(RAISE(ABORT, ...)); MySQL / DuckDB log a warning and skip.
11. Pool saturation gauge + warning¶
Prometheus output now carries dorm_pool_saturation{alias}
(in_use / max_size). A WARNING fires on
dorm.contrib.prometheus.pool when the threshold is crossed.
12. Read-replica circuit breaker¶
LagAwareReadRouter(
primary="primary",
replicas=["r1", "r2"],
max_lag_seconds=2.0,
failure_threshold=3, # ← new
cooldown_seconds=30.0, # ← new
)
After 3 consecutive probe failures the router opens a breaker per replica and skips even the lag probe for 30 s, avoiding the cost of re-discovering a dead replica every cache window.
13. Migration dependency graph¶
dorm migrations-graph --format=mermaid > graph.mmd
dorm migrations-graph --format=dot | dot -Tpng -o graph.png
AST-walks every app's migrations/ directory without importing
the migration modules, so missing runtime deps don't break the
visualisation.
14. dorm reset (dev only)¶
Convenience for development. Refuses to run when DATABASES looks
production-shaped (non-DEBUG, no "test"/"dev" in NAME, remote HOST)
unless --force.
15. Model.objects.cached(timeout=...) sugar¶
Equivalent to Article.objects.all().cache(timeout=60).filter(...).
16. DEBUG_NPLUSONE auto-detection¶
dorm.configure(
DEBUG=True,
DEBUG_NPLUSONE="raise", # or True / "log"
DEBUG_NPLUSONE_THRESHOLD=10,
...,
)
from dorm.contrib.nplusone import install_debug_global
install_debug_global() # idempotent — typically wired at startup
Installs a process-wide N+1 detector. "raise" aborts the request
at the first violation; True / "log" records findings without
disrupting traffic.
17. Async DataLoader¶
from dorm.contrib.dataloader import DataLoader
loader = DataLoader(
lambda pks: Author.objects.filter(pk__in=pks)
)
# N concurrent loads coalesce into ONE batched fetch:
results = await asyncio.gather(*(loader.load(pk) for pk in pks))
Same pattern as Facebook's DataLoader — GraphQL resolvers, fan-out
RPC handlers. Accepts dict / iterable / async-iterable batch
functions, configurable max_batch_size, optional caching.
18. Query plan drift detection¶
from dorm.contrib.plan_drift import record_baseline, compare, diff_text
record_baseline("orders.by_customer", sql, params=[1])
# In a healthcheck or cron:
result = compare("orders.by_customer", sql, params=[1])
if result.drifted:
alert.fire("plan drift:\n" + diff_text(result))
Volatile fragments (costs, row estimates, buffers, timings) are stripped before comparison so the alarm only fires on structural changes (node types, scan strategies).
19. LISTEN/NOTIFY broadcaster¶
from dorm.contrib.listen_notify import Broadcaster
async with Broadcaster(["orders", "shipments"]) as bcast:
async with bcast.subscribe("orders") as queue_a:
async for notification in queue_a:
...
# Many subscribers fan out from one LISTEN connection.
One pinned LISTEN connection serves N async subscribers, each with its own bounded queue.
20. F.apply() chainable transforms¶
from dorm.expressions import F
from dorm.functions import Lower, Trim, Substr
# Reads top-down:
F("name").apply(Lower).apply(Trim).apply(Substr, 1, 10)
# ↓ same as:
Substr(Trim(Lower(F("name"))), 1, 10)
.apply() lands on both F and every Func subclass.
21. QuerySet.lookup(column=None)¶
top10 = Article.objects.order_by("-score")[:10]
Comment.objects.filter(article_id__in=top10.lookup("pk"))
Sugar for Subquery(qs.values(column)).
22. Manager.union_with() polymorphic¶
Source.objects.union_with(Other.objects, all=True, order_by=["created_at"])
# With column mapping per branch:
Source.objects.union_with(
(Other.objects, {"label": F("title")}),
all=True,
)
UNION across heterogeneous models — column mapping per branch aligns SELECT lists.
23. SQL allow-list¶
from dorm.contrib.sql_allowlist import install, uninstall, rejected_templates
# Capture phase (canary):
install(allow_list, raise_on_violation=False, allow_ddl=True)
# ... run traffic ...
print(rejected_templates()) # curate
# Enforcement phase (production):
install(curated_list, raise_on_violation=True, allow_ddl=False)
Rejects every query whose template isn't on the curated list. Useful as a defence-in-depth layer when paired with input validation.
24. dorm version¶
Trivial CLI — paste the line into support tickets and CI logs.
25. dorm doctor v4.2 audits¶
dorm doctor now flags v4.2-specific misconfigurations:
DEBUG_NPLUSONEactive outsideDEBUGmode.SLOW_QUERY_EXPLAIN=Truepaired with a too-lowSLOW_QUERY_MS.POOL_SATURATION_WARNoutside the(0, 1)range.LagAwareReadRouterconfigured withcooldown_seconds=0(breaker disabled).sql_allowlistnot installed, or installed withallow_ddl=Truein production.
Run before every deploy and gate the rollout on the exit code.
26. querystats in metrics_response()¶
The per-template snapshot from dorm.contrib.querystats is now
embedded in dorm.contrib.prometheus.metrics_response() when the
collector is enabled. No separate endpoint required:
from dorm.contrib import prometheus, querystats
querystats.collector().enable() # at app startup
return Response(prometheus.metrics_response(), media_type="text/plain")
27. DataLoader prime() / clear_all()¶
loader.prime(7, fetched_value) # pre-populate without batching
loader.clear_all() # drop every cached entry
prime after a fan-out write avoids a redundant SELECT on the
follow-up resolver. clear_all is shorthand for clear(None).
28. Async plan-drift APIs¶
await plan_drift.arecord_baseline("orders.by_customer", sql, params=[1])
result = await plan_drift.acompare("orders.by_customer", sql, params=[1])
Symmetric to the sync forms — same baselines, same CompareResult
shape.
29. sql_allowlist capture-mode helpers¶
from dorm.contrib import sql_allowlist
# Canary phase:
sql_allowlist.install([], raise_on_violation=False, allow_ddl=True)
# ... run real traffic ...
sql_allowlist.dump_captured("allowlist.json")
# Enforcement phase (after curating allowlist.json):
sql_allowlist.uninstall()
sql_allowlist.load_from_file(
"allowlist.json",
raise_on_violation=True,
allow_ddl=False,
)
dump_captured writes a JSON document with allowed + rejected
sections; load_from_file reinstalls from the curated payload.
allowed_templates() returns the current allow-list snapshot.
See CHANGELOG for the full list of changes. Upgrading 4.1 → 4.2 needs no code changes.