Skip to content

feat(apply): eliminate per-m2m serialize queries in buffered change logging#177

Merged
mfiedorowicz merged 7 commits into
developfrom
prs/buffered-change-logging
Jun 1, 2026
Merged

feat(apply): eliminate per-m2m serialize queries in buffered change logging#177
mfiedorowicz merged 7 commits into
developfrom
prs/buffered-change-logging

Conversation

@mfiedorowicz

@mfiedorowicz mfiedorowicz commented May 29, 2026

Copy link
Copy Markdown
Member

Problem

Change logging on the apply path is expensive. NetBox's to_objectchange runs Django's serializers.serialize('json', [obj]), whose handle_m2m_field issues one SELECT per many-to-many relation on the model. For a model like dcim.Interface (three m2m relations) that is three DB round-trips on every save. Profiling the apply path attributed roughly 40% of per-request cost to this single call, almost entirely to those m2m round-trips (measured ~9-12ms per round-trip; the JSON round-trip itself and tag resolution were minor by comparison).

An earlier iteration of this PR moved the ObjectChange write to background RQ workers. That did not move throughput, because the cost was never the write - it was building the row. This revision targets the actual cost.

Change

Three pieces, all gated by apply_buffer_change_logging (default off):

  • Fast serialisation. While a diode apply buffer is active, ChangeLoggingMixin.serialize_object is routed through _fast_serialize_object, which restricts Django's serializer to the model's local non-m2m fields via the fields= allowlist. Django skips handle_m2m_field entirely, so no per-relation SELECT is issued, while its own field rendering is preserved (FKs and scalars unchanged). The route is gated on the buffer contextvar, so all non-apply change logging (UI, REST API, other receivers) is byte-for-byte identical to upstream.

  • Bulk m2m enrichment. The omitted m2m fields are re-added at flush. Buffered rows are grouped by model and one through-table query per relation resolves the relation for every object at once, turning O(saves x relations) round-trips into O(models x relations) per request. Membership matches what the upstream serializer records (recorded as sorted PK lists; identical membership, order may differ from queryset order).

  • Inline flush. The buffer is flushed as a single bulk_create at on_commit, with post_save re-emitted so receivers connected to post_save(sender=ObjectChange) still fire. The audit trail stays synchronous and immediately consistent.

The separate worker write path (async_change_logging.py) and its eventual-consistency trade-off are removed.

Behaviour and safety

  • Output parity: _fast_serialize_object(obj) equals the upstream serializer output minus m2m fields; after enrichment, m2m membership matches upstream. Covered by tests on two core versions.
  • The serialize_object route is a process-wide class patch but inert unless the buffer contextvar is set, which only happens inside a diode apply. The contextvar isolates per request/thread and is reset in finally. Non-apply paths pay only a contextvar check.
  • apply_bypass_change_logging still takes precedence: with both enabled, no rows are produced.
  • Rollback drops buffered rows (append registered via on_commit); a failed entity contributes nothing to the request batch.

Testing

Rewritten test suite covers fast-serialise parity, the buffer-active gate, bulk enrichment (asserts one query per relation), and end-to-end apply behaviour (setting off/on, rollback, bypass precedence, request-level batching, failed-entity exclusion).

  • NetBox v4.5.5: 354 tests, green
  • NetBox v4.6.0: 359 tests, green

Pending

Throughput gain to be confirmed on a load-test deployment (expected to lift the per-save serialise cost from tens of milliseconds to sub-millisecond plus tag resolution).

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
6082 5534 91% 0% 🟢

New Files

File Coverage Status
netbox_diode_plugin/api/change_log_buffer.py 91% 🟢
netbox_diode_plugin/tests/v4.6.x/tests/test_change_log_buffer.py 100% 🟢
TOTAL 95% 🟢

Modified Files

File Coverage Status
netbox_diode_plugin/init.py 100% 🟢
netbox_diode_plugin/api/applier.py 94% 🟢
netbox_diode_plugin/api/views.py 81% 🟢
TOTAL 92% 🟢

updated for commit: 16ff24f by action🐍

@mfiedorowicz mfiedorowicz changed the title feat(apply): buffered ObjectChange writes with bulk_create flush feat(apply): async ObjectChange writes via RQ workers May 29, 2026
@mfiedorowicz mfiedorowicz changed the title feat(apply): async ObjectChange writes via RQ workers perf(apply): eliminate per-m2m serialize queries in buffered change logging May 31, 2026
…ingle bulk_create

NetBox's handle_changed_object receiver writes one core_objectchange
row per saved model (and for m2m_changed events does a SELECT + UPDATE
to merge into any prior entry for the same instance in the request).
A 50-entity bulk-plan-apply with a couple of m2m fields per entity is
~150-300 DB round-trips for change logging alone; the existing
apply_bypass_change_logging shortcut sheds that cost by dropping the
audit trail entirely.

This change adds a third option: collect ObjectChange instances in a
request-scoped contextvar buffer during apply, with in-memory m2m
dedup (replacing the per-row SELECT), and flush them as one
ObjectChange.objects.bulk_create on successful exit. post_save is
manually re-emitted for each flushed row so plugins that consume the
signal (netbox-branching's record_change_diff, eventsink) still fire.
The events_queue receives appends exactly as upstream, so webhook
emission is unchanged.

Gated by a new plugin setting apply_buffer_change_logging (default
False). If apply_bypass_change_logging is also active the bypass
wins (predictable combined semantics). If the apply transaction
rolls back the flush is skipped and any in-memory ObjectChange rows
are dropped.

Tests cover: setting-off pass-through, setting-on bulk_create flush,
row equivalence vs unbuffered, rollback drops buffered rows,
post_save re-emit fires per flushed row, bypass-wins precedence.
…t cascades

The previous flush reset the buffer contextvar BEFORE calling
bulk_create and re-emitting post_save. Receivers connected to
post_save sender=ObjectChange (notably NetBox's
update_denormalized_fields) save related models during the re-emit;
those cascading saves fired _buffered_handler with buffer=None and
fell through to upstream handle_changed_object, producing single-row
INSERTs into core_objectchange after every bulk_create. Observed in
production as ~90% of ObjectChange writes still being single-row
despite the buffer setting being on.

Keep the contextvar active across the flush and drain to fixpoint:
clear the buffer dict in place, bulk_create + re-emit, then loop
while the dict has new entries (cascading saves landing back in the
same buffer). Bounded to 5 iterations as a safety; NetBox
denormalisation in practice cascades once.

Add a regression test that connects a fake receiver simulating the
denormalisation cascade and asserts the cascading save lands as an
ObjectChange row via bulk_create rather than via the upstream
single-row INSERT path.
…nchronous bulk_create

The synchronous flush replaced individual INSERTs with a single
bulk_create, but profiling showed that wasn't where the cost lived.
The cost of change-logging is dominated by the per-save Python work
in handle_changed_object and to_objectchange (FK lookups, JSON
serialisation of pre/post state, fan-out across all post_save
receivers, Postgres FK locks acquired during each ObjectChange
INSERT). Batching INSERTs did not measurably move the apply
throughput needle.

Move the entire ObjectChange creation off the apply request critical
path:

- Buffer still collects ObjectChange instances in memory during apply
  (cheap, in-process, in-memory).
- At successful end of apply, the buffer is serialised to a plain
  dict payload and an RQ job is enqueued via transaction.on_commit
  using django_rq.get_queue('default').enqueue with rq.Retry for
  exponential backoff. Apply transaction commits without paying any
  ObjectChange write cost.
- New async_change_logging.write_object_changes_async runs in the
  worker: rebuilds ObjectChange instances from the payload,
  bulk_creates them, and re-emits post_save for each row so any
  receiver connected to post_save(sender=ObjectChange) still fires.
  active_branch contextvar is re-established from the payload when
  netbox-branching is installed.
- Rollback semantics preserved: transaction.on_commit only fires on
  successful commit, so a failed apply discards the payload.
- Worker failures retry 3x with 5s/30s/120s backoff then land on the
  RQ failed queue.

django_rq.enqueue chosen over JobRunner because at expected apply
volumes (~50k applies/day at 30 e/s) JobRunner's per-execution
core_job row would bloat the table without operational value;
django_rq matches the pattern NetBox already uses for webhook
delivery in extras/events.py.

Trade-off: audit log becomes eventually consistent (typical lag <1s
with a healthy queue). Reads of core_objectchange immediately after
apply may miss the just-applied changes. Gated behind
apply_buffer_change_logging (default False) so this is opt-in.

Tests cover: setting-off pass-through, setting-on enqueue with
payload shape verification, rollback does not enqueue, bypass takes
precedence, and direct unit tests of the worker entry point (build
ObjectChange from payload, bulk_create, signal re-emit, empty
payload no-op).
Previously every entity in a bulk-plan-apply / bulk-apply request
enqueued its own RQ job carrying that entity's buffered ObjectChange
rows. With ~1 row per entity that meant the worker ran one
bulk_create([1]) per entity - same single-row INSERT shape as a
direct Model.save(), and one RQ job per entity (50x per typical
request).

Add a new request-scoped context manager
`request_change_logging_batch` that wraps the entity loop in the
bulk endpoints. Per-entity `buffered_change_logging` now appends
its payload to a shared list via `transaction.on_commit` instead of
enqueueing directly. On exit, the wrapper consolidates all
appended payloads into one job and enqueues it. The worker now
runs a single `bulk_create([N])` for the whole request, using the
UNNEST shape and a single Postgres round-trip for all
ObjectChange writes.

Per-entity rollback semantics preserved: each entity's append is
registered via its own atomic block's `on_commit`. A failed
entity's atomic rolls back and Django discards its callback, so
the batch contains only rows from entities that genuinely
committed. Wrapping registration of the consolidate-and-enqueue in
`finally` ensures partial batches still land when a later entity
raises an unhandled exception - prevents committed model writes
from going un-audited.

The fallback per-entity enqueue path remains in place for callers
that don't wrap their loop (single-changeset endpoint).

Tests: 3-entity request enqueues once with a 3-row payload; bad
entity in the middle of a batch is excluded from the consolidated
payload while the good entity's row is preserved. Mirrored across
v4.4.x / v4.5.x / v4.6.x.
…ogging

The buffered change-logging path served as a buffer in front of the
synchronous ObjectChange write, but the throughput cost was never the
write. NetBox's `to_objectchange` runs Django's
`serializers.serialize('json', [obj])`, which issues one SELECT per
many-to-many relation on the model (its `handle_m2m_field` walks each
m2m manager). For a model with three m2m relations that is three DB
round-trips on every save, dominating the apply request critical path.

Address the actual cost in three pieces:

- Fast serialisation: while a diode apply buffer is active,
  `ChangeLoggingMixin.serialize_object` is routed through
  `_fast_serialize_object`, which restricts Django's serializer to the
  model's local non-m2m fields via the `fields=` allowlist. Django then
  skips `handle_m2m_field` entirely, so no per-relation SELECT is
  issued, while its own field rendering is preserved unchanged. Gated on
  the buffer contextvar, so all non-apply change logging is byte-for-byte
  identical to upstream.

- Bulk m2m enrichment: the omitted m2m fields are re-added at flush. Rows
  are grouped by model and one through-table query per relation resolves
  the relation for every buffered object at once, turning
  O(saves x relations) round-trips into O(models x relations) per
  request. Membership matches what the upstream serializer records.

- Inline flush: the buffer is flushed as a single `bulk_create` at
  `on_commit`, with `post_save` re-emitted so receivers connected to
  `post_save(sender=ObjectChange)` still fire. The audit trail stays
  synchronous and immediately consistent.

This removes the separate worker write path and its eventual-consistency
trade-off entirely.

Tests rewritten to cover fast-serialise parity (output equals the
upstream serializer minus m2m), the buffer-active gate, bulk enrichment
(one query per relation), and the end-to-end apply behaviour (setting
off/on, rollback, bypass precedence, request batching, failed-entity
exclusion). Verified against NetBox v4.5.5 (354 tests) and v4.6.0 (359
tests).
…save

`_fast_serialize_object` resolved tags with `obj.tags.all()` on every
save, which under bulk apply is one query per saved object (and twice
for objects that also fire m2m_changed). The per-relation m2m queries
were already moved to a single batched flush; tags were the remaining
per-save round-trip in serialisation.

Leave an empty `tags` placeholder during serialisation - added only for
taggable models, which also marks the row as needing tag enrichment -
and fill it in `_enrich_tags` at flush: rows are grouped by content type
and one query over `extras_taggeditem` resolves every buffered object's
tag names at once, written back as a sorted list. Output matches the
upstream serializer (non-taggable rows keep no `tags` key). Reads run
post-commit, so they observe the final tag assignments.

Tests cover per-object tag resolution, single-query batching, the empty
and no-placeholder cases, and full serialiser parity (fast serialize +
m2m + tag enrichment equals the upstream output). Verified on NetBox
v4.5.5 (358 tests).
@mfiedorowicz mfiedorowicz changed the title perf(apply): eliminate per-m2m serialize queries in buffered change logging feat(apply): eliminate per-m2m serialize queries in buffered change logging May 31, 2026
@mfiedorowicz mfiedorowicz force-pushed the prs/buffered-change-logging branch from 373a482 to d93bba8 Compare May 31, 2026 13:58
@mfiedorowicz mfiedorowicz self-assigned this May 31, 2026
NetBox captures the pre-change state of an object by calling
`instance.snapshot()` in its view and DRF viewset layers
(`get_object_with_snapshot`, the bulk update/destroy mixins). The apply
path applies through a plain APIView and a direct `serializer.save()`,
so it bypassed those entirely and recorded no `prechange_data` for
updates - the audit log showed only post-change state, unlike every
update made through NetBox's own UI or REST API. Pre-existing gap,
independent of the buffered change-logging work.

Add `snapshot_for_apply` and call it after fetching the instance and
before saving, in both update paths (update by id, and the
find-existing-then-update path used for pre-save-match and auto-created
component types). The create-then-update-in-one-changeset path is left
alone: the instance is freshly created, so there is no prior state to
snapshot.

The prechange must be format-consistent with the postchange or the
changelog diff reports spurious changes:

  - Buffer inactive: defer to NetBox's `snapshot()` (full serialiser),
    which matches the unbuffered postchange exactly.
  - Buffer active: build the prechange the same way the buffered
    postchange is built - scalar fields via the fast serialiser, m2m and
    tags resolved now and sorted to match the flush-time enrichment. They
    must be read at snapshot time because the before-state is gone once
    the update commits, so enrichment cannot recover them. This is one
    read per m2m relation plus one for tags, per updated object - paid
    only on updates, only for the before-state.

Tests assert the buffer-active snapshot records sorted m2m + tags, that
prechange equals postchange for an unmodified object (no spurious diff),
and that the buffer-inactive path delegates to NetBox's snapshot.
Verified on NetBox v4.5.5 (361 tests).
@mfiedorowicz mfiedorowicz merged commit dc64913 into develop Jun 1, 2026
8 checks passed
@mfiedorowicz mfiedorowicz deleted the prs/buffered-change-logging branch June 1, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants