Skip to content

feat(triggers): do nothing while netbox-branching merges, reverts or syncs - #161

Merged
marcinpsk merged 13 commits into
developfrom
feat/143-replay-suppression
Oct 1, 2026
Merged

marcinpsk merged 13 commits into
developfrom
feat/143-replay-suppression

Conversation

@marcinpsk

@marcinpsk marcinpsk commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Part of #143, slice 6, the last slice (design: docs/design/netbox-branching.md r6, ADR 0016).

Why

netbox-branching merges, reverts and syncs a branch by replaying NetBox's change records. A replayed update is a model save (netbox_branching/utilities.py:513), and a replayed create is a raw save (models/changes.py:131). So the plugin's rename triggers ran for each replayed module and device:

  • After a merge, the reapply ran at the commit with the rules of main and outside event tracking. It renamed on main with no change record.
  • During a sync, it renamed in the branch with no change record, so a merge never carried the rename.
  • A merge or revert started with a branch active raised at the trigger's alias check (the carry from slice 4).

Changes

  • branching.py:
    • ready() runs the version check, then wraps Branch.merge, Branch.revert and Branch.sync once (functools.wraps, marked with REPLAY_MARK).
    • Each wrapper sets a ContextVar and resets the token in finally, so the mark ends on a return, an early return and an error.
    • replay_in_progress() reads the mark.
    • netbox-branching's jobs call these methods (jobs.py:132, 217, 243), and the merge strategies run inside them.
  • rename_triggers.py: before_save and after_save return at once while a replay runs: no previous-state read, no alias check, no plan.
  • Docs:
    • docs/configuration.md: a new netbox-branching section covers:
      • the supported release, 1.2.x on NetBox 4.7;
      • what runs in a branch;
      • the script transaction on the interface write connection;
      • the 10 s lock_timeout;
      • merge, revert and sync;
      • the accepted limits: the replayed channel cascade, background bulk REST requests, a swallowed branch activation, no cross-connection atomicity, and PgBouncer in transaction mode.
    • docs/installation.md: netbox-branching as an optional requirement, and the upgrade note: let queued plugin jobs finish first, because slice 5 changed what a job stores.
    • README, index and examples: updated to match.
    • Agent instructions: they now name transactions.py and branching.py.

Tests

The tests start each operation on netbox-branching's page and run its job from the queue, as a worker does.

  • Merge (iterative and squash): an install and a move in the branch give main the branch's names. Main logs only replayed changes. The rule exists on main only, so a trigger would rename.

  • Revert (both strategies): main gets the names from before.

  • Sync: no rename lands in the branch. The rule exists in the branch only.

  • Early exits: after a merge with no changes, a dry run, a failed merge, and a dry run followed by another worker's merge, an install on main still gets the rule's names.

  • Active branch: a merge and a revert started from a shell with the branch active no longer raise.

  • Accepted limit: NetBox's channel cascade (dcim/models/mixins.py:302-337) renames a kept channel again at the commit of a merge or a sync. A revert gives back the names from before.

  • Contract: each method is wrapped once and keeps its signature (self, user, commit=True), its name and alters_data.

  • Docs: the supported series in the configuration guide, the installation guide, the README and the index is pinned to the version gate's constant. The replay statement, the cascade limit and the upgrade note are pinned too.

  • Rejected alternatives from the design, each caught by a test when mutated in:

    • no mark;
    • a reset without finally;
    • the pre_/post_ signals with a status-based reset (netbox-branching sends no post_ signal on the "No changes found" return or on an error);
    • the signals alone;
    • skipping raw saves;
    • a second wrap.
  • Results:

    Environment Passed Skipped
    NetBox 4.7.0 without netbox-branching 1595 97
    NetBox 4.7.0 with netbox-branching 1.2.1 1671 21
    NetBox 4.5.3 1209 460
    NetBox 4.3.7 (before the review fixes) 1197 467

    The combined coverage of the 4.5.3 leg and the branch leg, as CI computes it, is 98.93%. query_counts.json is unchanged.

Review

  • Adversarial rounds (codex gpt-6-astra, high): 2 rounds, no findings.
  • Two-axis review (standards and spec): no blocking findings. The spec review checked netbox-branching 1.2.1: every replay path (ObjectChange.apply/undo, update_object, the merge, revert and sync jobs, recover) runs inside the three wrapped methods.
    • Fixed: the docstrings, one source for the supported series, two shared test helpers, the ContextVar name, the marker constant, Simplified English in the docs, and pointers to the kept-channel limit.
    • Declined: revert and sync early-exit tests (one shared wrapper; the merge exit tests catch a missing finally), and a patch-level pin (the design pins 1.2.x).

netbox-branching behaviour (not the plugin's)

With its own branch active, netbox-branching still refuses some replays:

  • Its full_clean validates a replayed create against the active branch (models/changes.py:110).
  • The revert of a module move fails at COMMIT on the AppliedChange foreign key (utilities.py:525). It fails with the plugin's receivers disconnected too.

The active-branch test therefore uses a virtual-chassis position change.

Follow-ups done in this PR

  • netbox-branching replay contract (tests/test_branching.py):
    • The installed netbox-branching must be the reviewed release (REVIEWED_NETBOX_BRANCHING = "1.2.1"). Any other release fails until its replay paths are re-reviewed. The production version gate still accepts 1.2.x; branching.installed_version() is the one reader of the version.
    • To help that review, an AST scan of the installed package pins every reference to a replay primitive, with its receiver, its scope and a count. Two allow-lists map each reference to the wrapped method that reaches it. A fingerprint of each reviewed scope shows which ones a new release changed.
    • Four codex rounds found ways to bypass the scan: aliases, getattr, a second call next to an allowed one, lambdas, generators, and dispatch through a variable. The version pin closes that family; the scan stays as the checklist for the next release.
  • Shared test fixtures:
    • The 56 names that one test module imported from another now live once, under public names. They are in tests/helpers.py and two new modules, tests/trigger_cases.py and tests/branch_cases.py. This removed the six PLAIN_TYPE copies and the other duplicate constants.
    • Collection is unchanged (1695 test IDs, identical list).
    • A new guard in test_module_boundaries.py refuses an import of a test_* module anywhere in the test package, in any spelling, and refuses a wildcard import from the package, so this debt cannot come back.
  • Checks on the final head (6414a1c):
    • NetBox 4.7.0 without netbox-branching: 1605 passed.
    • NetBox 4.7.0 with netbox-branching 1.2.1: 1685 passed.
    • pre-commit is clean.
    • 4.5.3 and 4.3.7 passed after the fixture move. The commits after it change test files and one version reader that runs only with netbox-branching.

Summary by CodeRabbit

  • New Features

    • Added support for interface renaming in the active branch with netbox-branching 1.2.x on NetBox 4.7. Merge, revert, and sync operations preserve replayed names, except for channels the plugin keeps at their old names.
  • Bug Fixes

    • Prevented saves routed to an incompatible database from proceeding, helping avoid unintended writes during branch operations.
  • Documentation

    • Added compatibility details, branch-operation limitations, and guidance on lock timeouts and queued jobs when upgrading or running scripts.

…syncs

netbox-branching merges, reverts and syncs a branch: it replays the
changes that NetBox logged. A replayed update is a model save
(netbox_branching/utilities.py:513), and a replayed create is a raw
save (models/changes.py:131). So the plugin's receivers ran for each
replayed module and device. After a merge, the reapply ran at the
commit with the rules of main and outside event tracking: it renamed
on main with no change record. During a sync, it renamed in the branch
with no change record, so a merge never carried the rename.

At startup, after the version check, the plugin now wraps Branch.merge,
Branch.revert and Branch.sync once. Each wrapper marks its context with
a ContextVar and resets the token in a finally, so the mark ends on a
return, an early return and an error. While the mark is set,
before_save and after_save return at once: no previous-state read, no
trigger and no plan. The netbox-branching jobs call these methods
(jobs.py:132, 217, 243), and the merge strategies run inside them
(models/branches.py:1135-1139, 1211-1214).

Rejected alternatives:
- The pre_ and post_ signals. netbox-branching sends no post_ signal
  on the "No changes found" return (models/branches.py:923-924,
  1120-1121, 1196-1197) or on an error (988-993, 1144-1150,
  1219-1225).
- A reset from the branch status. It reads a status that other workers
  share, and it adds one query for each trigger.
- Skipping raw saves. It changes a fixture load on main.

A merge or revert started in a shell with the branch active no longer
raises at the alias check of the trigger. netbox-branching itself still
refuses some replays there: its full_clean validates a replayed create
against the active branch (models/changes.py:110), and the revert of a
module move fails at COMMIT on the AppliedChange foreign key
(utilities.py:525). That revert fails also with the plugin's receivers
disconnected.

The tests start each operation on netbox-branching's page and run its
job from the queue, as a worker does:
- A merge of an install and a move gives main the names of the branch,
  and main logs only replayed changes. A revert gives back the names
  from before. The rule exists on main only, so a trigger would rename.
  Both with the iterative and the squash strategy.
- A sync writes no rename into the branch. The rule exists in the
  branch only.
- After a merge without changes, a dry run, a failed merge, and a dry
  run followed by another worker's merge of the branch, an install on
  main still gets the names of the rule.
- NetBox's channel cascade (dcim/models/mixins.py:302-337) renames a
  kept channel again at the commit of a merge and of a sync. A revert
  gives back the names from before. This pins the accepted limit.
- A contract test checks that each method is wrapped once and keeps its
  signature, name and alters_data.
The branch write tests now share one reader of the interfaces of a bay,
on the active branch or on main.

Each mutation turns tests red: no mark (the merges, the reverts, the
sync and the active-branch test), a reset without a finally (the dry
run and the failed merge), the signals with the status reset (the
other-worker test), the signals alone (the three exit tests), skipped
raw saves (the fixture load on main), and a second wrap (the contract
test).

Slice 6 of #143 (ADR 0016).
The configuration guide now has a netbox-branching section. It states
the supported release, 1.2.x on NetBox 4.7, and that NetBox does not
start with another release. It describes what the plugin does in a
branch: the rename triggers, Apply Rules, the conversion and the jobs
run in the branch. A script installs a module in a transaction on the
interface write connection, as the Apply Rules section already says.
In a branch, each plugin operation sets a lock_timeout of 10 seconds on
both connections. An engine function that a script calls inside its own
transaction leaves the lock waits of the commit callbacks to the
script.

The section describes merge, revert and sync. The rename triggers do
nothing while netbox-branching replays changes. A merge gives main the
names of the branch, a revert gives back the names from before, and a
sync writes no rename without a change record.

It lists the accepted limits of the design record:
- NetBox's channel cascade runs again at the commit of a replay
  (dcim/models/mixins.py:302-337).
- A bulk REST request with background=true runs on main, because the
  background job does not keep the cookies of the request
  (netbox/api/viewsets/mixins.py:307, 345-347). The rule endpoints
  refuse it in a branch; the other endpoints keep the gap.
- NetBox ignores a failed branch activation
  (utilities/request.py:132-141).
- There is no atomicity across the two connections.
- PgBouncer in transaction mode is not supported.

The installation guide lists netbox-branching as an optional
requirement. Its upgrade note says to let the queued plugin jobs finish
before the upgrade, because a job that an earlier release queued fails:
the jobs now store their branch. The engine example states the lock
timeout of a call inside a transaction of the caller, and tells the
script to set lock_timeout for the commit callbacks. The README and the
index list the feature. They and the merge and sync statements point at
the limit of a kept channel.

Documentation tests pin the supported series in the configuration
guide, the installation guide, the README and the index to the constant
of the version gate. They also pin the replay statement with the
cascade limit, and the upgrade note. A helper reads a whole page with
its whitespace collapsed, at each site in the documentation tests.

Slice 6 of #143 (ADR 0016).
…tions

The architecture list named no owner of the database connections and
no module for netbox-branching. It now names transactions.py, the one
owner of connections and transaction state, and branching.py, the one
module that imports netbox-branching: the version check, the branch of
a job, and the mark that stops the rename triggers during a merge,
revert or sync.

Part of #143 (ADR 0016).
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 7a0e1986-cd5d-4417-b425-31c06fa7d05c

📥 Commits

Reviewing files that changed from the base of the PR and between a22ef3d and 660895e.

📒 Files selected for processing (32)
  • docs/design/netbox-branching.md
  • netbox_interface_name_rules/branching.py
  • netbox_interface_name_rules/models.py
  • netbox_interface_name_rules/tests/branch_cases.py
  • netbox_interface_name_rules/tests/helpers.py
  • netbox_interface_name_rules/tests/test_bay_edit_trigger.py
  • netbox_interface_name_rules/tests/test_branch_jobs.py
  • netbox_interface_name_rules/tests/test_branch_replay.py
  • netbox_interface_name_rules/tests/test_branch_transactions.py
  • netbox_interface_name_rules/tests/test_branch_triggers.py
  • netbox_interface_name_rules/tests/test_branch_writes.py
  • netbox_interface_name_rules/tests/test_branching.py
  • netbox_interface_name_rules/tests/test_breakout_mode.py
  • netbox_interface_name_rules/tests/test_bulk_families.py
  • netbox_interface_name_rules/tests/test_change_log.py
  • netbox_interface_name_rules/tests/test_channelization.py
  • netbox_interface_name_rules/tests/test_channelized_mode.py
  • netbox_interface_name_rules/tests/test_conversion.py
  • netbox_interface_name_rules/tests/test_installed_families.py
  • netbox_interface_name_rules/tests/test_module_boundaries.py
  • netbox_interface_name_rules/tests/test_module_move_trigger.py
  • netbox_interface_name_rules/tests/test_naming_point_sequences.py
  • netbox_interface_name_rules/tests/test_prospective_families.py
  • netbox_interface_name_rules/tests/test_raw_base.py
  • netbox_interface_name_rules/tests/test_rename_triggers.py
  • netbox_interface_name_rules/tests/test_rule_validation_agreement.py
  • netbox_interface_name_rules/tests/test_structural_families.py
  • netbox_interface_name_rules/tests/test_transactions.py
  • netbox_interface_name_rules/tests/test_type_change_trigger.py
  • netbox_interface_name_rules/tests/test_vc_drift.py
  • netbox_interface_name_rules/tests/test_views.py
  • netbox_interface_name_rules/tests/trigger_cases.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The plugin now wraps netbox-branching merge, revert, and sync operations, and skips rename triggers during replay. Rule saves check database routing outside replay. Shared test helpers replace duplicated fixtures, and documentation describes compatibility, branch operations, and limitations.

Changes

Branch replay and write routing

Layer / File(s) Summary
Mark replay operations and update save handling
netbox_interface_name_rules/__init__.py, netbox_interface_name_rules/branching.py, netbox_interface_name_rules/rename_triggers.py, netbox_interface_name_rules/models.py
Startup checks the installed branching version and wraps merge, revert, and sync methods. Save handlers return early during replay. Rule saves check that the selected write database matches the router database unless replay is active.
Verify replay and write-routing behavior
netbox_interface_name_rules/tests/test_branching.py, netbox_interface_name_rules/tests/test_branch_replay.py, netbox_interface_name_rules/tests/test_branch_transactions.py, netbox_interface_name_rules/tests/test_rule_validation_agreement.py
Tests cover replay methods and outcomes, branch operations, kept-channel names, wrapper contracts, and rule saves explicitly routed to another database.
Provide shared branch test fixtures
netbox_interface_name_rules/tests/branch_cases.py, netbox_interface_name_rules/tests/test_branch_*.py
Branch fixtures provision schemas, establish branch-authenticated clients, and provide helpers for branch writes and interface-name checks. Branch tests use these shared cases.
Document branch behavior and compatibility
docs/configuration.md, docs/examples.md, docs/installation.md, README.md, docs/index.md, docs/design/netbox-branching.md, netbox_interface_name_rules/tests/test_documentation.py, .github/copilot-instructions.md, CONTEXT.md
Guides describe supported versions, branch operations, replay behavior, lock timeouts, limitations, and upgrade notes. Documentation tests check the updated guidance.

Shared test support

Layer / File(s) Summary
Add reusable builders and trigger fixtures
netbox_interface_name_rules/tests/helpers.py, netbox_interface_name_rules/tests/trigger_cases.py
Shared helpers build devices and module types, provide test cases and request context, and support trigger tests for moves, edits, and injected failures.
Use shared helpers across trigger tests
netbox_interface_name_rules/tests/test_bay_edit_trigger.py, netbox_interface_name_rules/tests/test_module_move_trigger.py, netbox_interface_name_rules/tests/test_rename_triggers.py, netbox_interface_name_rules/tests/test_type_change_trigger.py, netbox_interface_name_rules/tests/test_naming_point_sequences.py
Trigger tests use shared fixtures and probes instead of local helpers and private imports. Existing scenarios and assertions remain represented.
Use shared helpers in fixture-heavy tests
netbox_interface_name_rules/tests/test_breakout_mode.py, netbox_interface_name_rules/tests/test_bulk_families.py, netbox_interface_name_rules/tests/test_change_log.py, netbox_interface_name_rules/tests/test_channelization.py, netbox_interface_name_rules/tests/test_channelized_mode.py, netbox_interface_name_rules/tests/test_conversion.py, netbox_interface_name_rules/tests/test_installed_families.py, netbox_interface_name_rules/tests/test_prospective_families.py, netbox_interface_name_rules/tests/test_raw_base.py, netbox_interface_name_rules/tests/test_structural_families.py, netbox_interface_name_rules/tests/test_vc_drift.py, netbox_interface_name_rules/tests/test_views.py
These tests use shared setup helpers and constants. Their scenarios and assertions remain in place.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant BranchMethods as Branch.merge/revert/sync
  participant ReplayWrapper as branching wrapper
  participant ReplayFlag as replay context flag
  participant SaveHandlers as rename_triggers
  BranchMethods->>ReplayWrapper: call replay method
  ReplayWrapper->>ReplayFlag: set replaying
  ReplayWrapper->>BranchMethods: run original replay
  BranchMethods->>SaveHandlers: invoke save handlers
  SaveHandlers->>ReplayFlag: check replay_in_progress
  SaveHandlers-->>BranchMethods: return without rename-trigger work
  ReplayWrapper->>ReplayFlag: restore prior flag
Loading

Merge Risk: ⚪ Minimal · up to 66089

The replay handling preserves branch operations while ordinary saves retain routing checks. No merge-blocking defect is established; merge after normal checks, observing the documented kept-channel and cross-connection limitations.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 66089

The replay exception is deliberately scoped to branch operations and is cleared when they return or fail. Ordinary rule saves gain stronger routing checks. No exploitable security regression was established, but authorization and failure behavior in the branching dependency were not fully verified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed control can affect rule persistence and interface naming on main and branch schemas during replay. It applies to saves executed inside the wrapped operation, rather than carrying a per-row replay identity. Maximum unauthorized exposure cannot be established without the dependency's caller authorization and destination-selection implementation.

Trust Boundaries and Controls

  • observed — InterfaceNameRule.save reads the replay mark but does not activate it. The integration activates the mark around branch lifecycle methods. Outside replay, mismatched database aliases are rejected before save-option processing; the inspected tests assert rejection and unchanged stored data for full saves.
  • inferred — The strongest supported distinction is between an intentional replay-routing exception and an established security bypass. Full-save scope enforcement was already absent in the base behavior, and validation updates retain alias membership checks. The inspected evidence does not establish a newly attacker-controlled alias source or unauthorized replay entry.

Resilience and Maintainability Implications

  • inferred — Token restoration bounds the weakened routing and trigger controls to the synchronous operation and preserves the enclosing value for nested calls. Replay-originated callbacks are not registered. This does not prove the behavior of upstream recovery, deferred execution, or callbacks queued before the operation began.

Hardening Proposals

  • proposed — For future dependency upgrades or custom replay participants, verify authorized entry, permitted write destinations, and synchronous completion explicitly before relying on the operation-wide exception. Preserve the existing call-site and fingerprint checks as drift detection.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 519 functions across 34 files. (1 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: suppressing rename triggers during netbox-branching merge, revert, and sync operations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 519 functions across 34 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the branch at dawn
While merge and sync replay along
The names pause, the tests take note
Shared helpers keep each case afloat
Docs map the paths through every burrow
Then all the carrots sprout tomorrow

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The plugin marks a replay only inside Branch.merge, Branch.revert and
Branch.sync. The version gate accepts any 1.2.x release, so a later
release could add a replay path outside those methods, and the rename
triggers would then act during that replay.

The test parses the source of the installed netbox-branching with ast.
It finds each call of a replay primitive and the function that holds
the call, and compares the set with an allow-list. Each entry names the
wrapped method that reaches it.
- The calls that save a replayed object: ObjectChange.apply and .undo
  (merge_strategies/iterative.py:34, 53; merge_strategies/squash.py:223,
  271; models/branches.py:743-759, 877), and update_object and
  deserialize_object inside them (models/changes.py:107-140, 182-186).
- The calls that start a replay: the strategy in Branch.merge and
  Branch.revert (models/branches.py:1139, 1214), the two sync helpers in
  Branch.sync (966, 975), and the jobs that call the wrapped methods
  (jobs.py:217, 243).
The scan does not pin save() and delete(): netbox-branching calls them
for its own Branch and ChangeDiff rows, and each save of a replayed
object runs inside ObjectChange.apply, ObjectChange.undo or
update_object.

A copy of the installed package with one added function that calls
change.apply() turns the first test red and names the new call site. A
copy with an added caller of strategy.merge() turns the second test red.
The installed 1.2.1 tree passes. A third test checks the scan on a
constructed package on every leg.

Follow-up of #143 (ADR 0016).
…st modules

Test modules imported constants, builders and test case bases from
other test modules, and several modules kept their own copy of a shared
constant: PLAIN_TYPE had six definitions.

Each name that a test module imported from another test module now
lives in one shared module, under a public name:
- helpers.py: the interface types, the breakout modes, TEST_PASSWORD,
  PLUGIN_LOGGER, the skip reasons, the builders of devices and module
  types, install_form, names_of, request_context, WriteInterfacesTo,
  ChannelizationTestCase and VcDriftTestCase.
- trigger_cases.py: the move fixture, ModuleMoveTestCase,
  BayEditTestCase, flat_rule, the journal reasons, and the probes that
  count reapplies, read the journal and inject database failures.
- branch_cases.py: BranchTestCase, the branch write cases, the branch
  values of lock_timeout and FLAT_NAMES. Like helpers.py, it imports
  netbox-branching only inside functions.

The copies go: PLAIN_TYPE, CHANNEL_TYPE, PARENT_TYPE, PLUGIN_LOGGER,
TEST_PASSWORD, FLAT, CHANNELIZED, REQUIRES_CHANNELIZATION,
REQUIRES_NO_CHANNELIZATION, _journal, _module_reapplies and
_reject_interface_updates. The breakout modes come from
BreakoutModeChoices. Two skip reasons that lacked "(4.7+)" now have it.
A local variable that would hide module_reapplies is renamed, and a
moved two-line comment is one line.

The shared modules do not match test_*.py, so pytest collects nothing
from them. A base that a test module imports defines no test, so it adds
no item. On the branch stack, --co collects the same 1695 test IDs
before and after. No guard test or allow-list needed a change.

Follow-up of #143.
…ackage

Test modules imported constants, builders and test case bases from one
another, and copies of shared constants grew next to them. The previous
commit moved every shared name into helpers.py, trigger_cases.py and
branch_cases.py. This guard keeps it that way.

It parses each module of the test package and reports each import
statement that names a test_* module of the package: import a.tests.test_x,
from a.tests.test_x import y, from a.tests import test_x, and the
relative from .test_x import y and from . import test_x, also inside a
function. It reads import statements only, so a dotted-path string such
as a MIDDLEWARE setting is not an import. A shared module, a test_* name
imported from a shared module, and a package with a similar name are not
reported.

With "from .test_channelization import CHANNEL_TYPE" added to
test_bulk_families.py, the guard fails and names that file and
statement. The tree passes.

Follow-up of #143.
@marcinpsk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…cope

The replay call-site scan recorded only direct calls, as a set of
(name, module, function). A later 1.2.x release could add an unwrapped
replay path and both allow-list tests would stay green:
- A call through an indirect reference: replay = change.apply, then
  replay(branch); getattr(change, "apply")(branch); or an import
  alias such as from .utilities import update_object as u.
- A second receiver in an allowed scope: strategy.merge(...) next to
  the allowed branch.merge(...) in MergeBranchJob.run gave the same
  tuple.
- A deferred call: transaction.on_commit(lambda: strategy.merge(...))
  in Branch.merge ran after the wrapper reset its mark and gave the same
  tuple.

The scan now records each reference to a tracked name: an attribute, a
name, an imported name, and the string of a getattr, setattr or hasattr
call. Each site is (name, receiver, module, scope). The receiver is the
object of the attribute or the call, the source of the import, or
empty. A lambda opens a scope of its own, as a function does. The tests
compare a Counter, so a second occurrence in an allowed scope changes
the result. A failure lists each added or missing site.

The allow-lists now hold a count and a note for each site. The real
1.2.1 tree adds these references, each read in the source:
- apply.alters_data and undo.alters_data (models/changes.py:156, 228),
  merge.alters_data and revert.alters_data (models/branches.py:1171,
  1247): attribute flags, no call.
- The imports of deserialize_object (models/changes.py:15) and
  update_object (models/changes.py:17-22).
- hasattr(model, 'deserialize_object') in ObjectChange.apply
  (models/changes.py:106).
- The revert flag of ObjectChange.migrate (models/changes.py:67-74),
  a parameter, not the method.

Each finding has a regression case in the scan self-test. The three
cases failed against the earlier scan and pass now. On copies of the
installed package, the earlier scan stays green and the new one fails
naming the added site for each of: an indirect reference, a getattr
string, an import alias, a second receiver in MergeBranchJob.run, and a
lambda in Branch.merge. Both scans fail on the two earlier mutations,
and both pass on the installed 1.2.1 tree.

Follow-up of #143 (ADR 0016).
The reference scan cannot see every way to defer a replay past the
wrapper. A stored reference called from a lambda (replay = change.apply,
then on_commit(lambda: replay(branch))) and a generator
((change.apply(branch) for change in changes)) inside an allowed
function give the same references as the direct call. Each new syntax
form needs its own rule, and a missed form stays green.

So the test now fails closed. It records a fingerprint of each scope
that the allow-lists name: a hash of the source of the function, or of
the statements of the class or the module outside its nested scopes,
from the AST, without positions or comments. A change to the body of
any of those 17 scopes on a netbox-branching update fails the test,
and the message tells the reader to read the scope again and to update
its fingerprint and the allow-lists together. A reference outside the
allowed scopes still fails as a new site, and the reference multiset
still checks the receiver and the count. The scan and the fingerprint
name scopes through one walk.

The scan self-test has a case for each deferral: the references stay
equal and the fingerprint changes. With the scan of the previous
commit, the same two programs give equal Counters, so that check stays
green. On copies of the installed package, the deferral by a stored
reference in IterativeMergeStrategy.merge and the generator in
Branch._handle_sync_delete leave the references unchanged: the earlier
checks pass, and the new test fails naming the changed scope. The
installed 1.2.1 tree passes.

Follow-up of #143 (ADR 0016).
…__.py included

The guard against imports of test modules read only the top-level
files of the test package and skipped __init__.py. It resolved each
relative import against the test package, and it checked only the first
component under it. So these stayed green:
- from .test_views import ViewTest in tests/__init__.py;
- an import in a module of a test subpackage;
- import netbox_interface_name_rules.tests.sub.test_views as views;
- from netbox_interface_name_rules.tests.sub import test_views;
- from ..test_views import ViewTest in tests/sub/shared.py, which
  resolved against the wrong package.

The guard now reads each .py file under the test package, __init__.py
included. It derives the package of each module from its path and
resolves a relative import against that package. It reports an import
whose target has a test_* component at any depth under the test
package, and the name of a from-import counts as part of the target.
So a test_* name imported from a shared module is refused too: pytest
collects a test function where it is imported.

With temporary imports in tests/__init__.py, in a new tests/sub
subpackage and in helpers.py, the earlier guard passed and the new one
fails naming the four statements. The new detector subtests for the two
absolute subpackage spellings and the test_* name failed with the
earlier detector. New tests cover a relative import from a subpackage,
and __init__.py and subpackage modules in a temporary test package.
The tree passes.

Follow-up of #143.
A new upstream function that dispatches with getattr(change, action)
(branch), where action is a variable, adds a replay path outside every
reviewed scope. Neither the reference counts nor the fingerprints
change. This is the third finding of one family: each scan rule
misses the next form of dispatch.

So the contract now answers the question at its source: is the
installed netbox-branching the release that was reviewed? A test
asserts that the installed version equals REVIEWED_NETBOX_BRANCHING,
1.2.1. When it differs, the message says what to re-review: run the
scan, read every new reference, every changed fingerprint and any
dynamic dispatch such as getattr with a variable name, then update the
release, the fingerprints and the allow-lists together. The scan and
the fingerprints stay, as the aid for that review.

branching.installed_version() now reads the version, for the version
gate and for this test. The gate still accepts any 1.2.x release.

With the constant set to 1.2.2, the test fails with that message. With
1.2.1, it passes on the branch leg.

Follow-up of #143 (ADR 0016).
With __all__ = ["test_views"] in tests/__init__.py, from . import * in
another module of the test package loads tests.test_views, and the
guard reported nothing: the import names no test module.

The guard now refuses each wildcard import whose source resolves inside
the test package. It does not read __all__. No module of the test
package has such an import; the one wildcard import under tests, in
isolated_settings.py, imports netbox.settings.

The detector reports from . import *, from .helpers import * and
from netbox_interface_name_rules.tests import *. A wildcard import from
outside the test package is not reported. The earlier detector missed
the three spellings, and the __all__ case in a temporary test package.

Follow-up of #143.
InterfaceNameRule.save() compared the save alias with the alias that the
router gives for a rule only when update_fields named a validated field.
A full save and a targeted save of other fields (for example
description) skipped the check. In a netbox-branching branch,
rule.save(using="default") on a fully loaded rule therefore wrote the
main row, while the same save of name_template raised.

The comparison now runs before the update_fields branching, so every
save refuses an alias that is not the routed write alias, before any
query. The write scope membership check stays inside the locked block.

Tests cover a full save and a description save through default in a
branch, and through an unknown alias on main.
A merge or revert started from a shell with the branch active replays
the logged changes on default, while the router gives the branch alias.
netbox-branching replays a rule update through ObjectChange.apply(),
update_object() and instance.save(using="default"). The rule save now
checks the write alias on every save, so this replay raised
RuntimeError, the same failure that the rename triggers had before they
returned early during a replay.

InterfaceNameRule.save() now skips its write-alias comparison while
replay_in_progress() is true. The write scope check inside the locked
block stays: default is always in the scope.

The new test changes a rule through the REST API in the branch, then
merges and reverts it with the branch active.
@sonarqubecloud

sonarqubecloud Bot commented Oct 1, 2026

Copy link
Copy Markdown

@marcinpsk
marcinpsk merged commit a8a668f into develop Oct 1, 2026
15 checks passed
@marcinpsk
marcinpsk deleted the feat/143-replay-suppression branch October 1, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant