Repository navigation
Description
Plugin Version
1.0.0 (also reproduced on main at b34ee80)
NetBox Version
4.7.4 (netbox-branching 1.2.3)
Python Version
3.14
Steps to Reproduce
- In main, create a Custom Object Type with a text field
label, and one object withlabel = existing. - Create a branch (iterative or squash merge strategy) and activate it.
- In the branch, add a text field
notesto the type. - In the branch, edit the existing object and set
notes = merged. - Merge the branch.
Expected Behavior
In main, the existing object has notes = merged.
Observed Behavior
The merge succeeds with no error or warning. In main, the notes column exists, but the existing object has notes = NULL; the branch schema still holds merged. An object created in the branch with a value for the new field keeps its value.
Reproduced in a lab with the REST API and both merge strategies. Two test cases that fail on main (b34ee80), added next to _assert_field_added_in_branch_is_merged in tests/branching/test_merge.py:
FAIL: test_iterative_merge_updates_existing_object_with_field_added_in_branch
AssertionError: None != 'merged'
FAIL: test_squash_merge_updates_existing_object_with_field_added_in_branch
AssertionError: None != 'merged'
Suspected Cause
Two causes, one per strategy.
Iterative. The field CREATE replays through CustomObjectTypeField.save(), which calls get_model() (registering a class without the new field), adds the column, then calls clear_model_cache(). That evicts the type's entry from _model_cache, but the old class stays registered in apps.all_models. The object UPDATE then replays through netbox-branching's ObjectChange.apply(), which resolves changed_object_type.model_class(), so CustomObjectsPluginConfig.get_model(). That method returns the registered class unless it was rolled back, so the update runs on a class without the new field: update_object() sets a plain attribute and save() never writes the column. Traced in a NetBox shell during a merge of the lab reproduction, where the new field is named ticket:
APPLY create customobjecttypefield pk=27 class_id=404865456 has_ticket=False
APPLY update table7model pk=1 class_id=410558864 has_ticket=False
UPDATE_OBJECT class_id=410558864 has_ticket=False data={'ticket': 'CHG-1'}
after: instance.ticket=CHG-1
APPLY create table7model pk=2 class_id=410558864 has_ticket=False
New objects are not affected because CustomObject.deserialize_object() builds a fresh model and stashes the data for _apply_deferred_co_field().
Squash. The squash orders DELETEs, UPDATEs, then CREATEs, so the object UPDATE runs before the field CREATE unless an edge says otherwise. add_custom_object_dependencies() finds an object's field refs through _collect_co_refs(), which reads model_class._field_objects; the main class does not know a field created in the branch, so no UPDATE → field CREATE edge is added.
Proposed Fix
- In
CustomObjectsPluginConfig.get_model(), rebuild a registered dynamic model (table<id>model) throughCustomObjectType.get_model()when_model_cachehas no entry for its own context (_context_branch_id, when it is the active context) and the type now has a field that the class lacks. The cached path stays a dictionary lookup with no query; the evicted path runs oneexists()query. Rebuilding on eviction alone is not enough: model generation itself resolves other types throughContentType.model_class(), so an eviction-only check recurses during generation and replaces classes that other models still point to. - In
add_custom_object_dependencies(), map the columns of each field created in the squash (_occupied_columns()) to its key, and add an UPDATE → field CREATE edge when an object'spostchange_datawrites one of them.
Both are needed for squash; the iterative case needs the first only. This depends on #1042: with change 1, test_choice_set_choices_mutated_in_branch_merge fails until that issue is fixed.
I have a fix and tests ready on a branch. I'd be happy to open the PR if you assign this issue to me.
Activity
bctiemann commentedon Nov 10, 2026
Triage notes. The diagnosis above matches the code.
Root cause (confirmed in code):
CustomObjectTypeField.save()callsclear_model_cache()(models.py:4617), which removes the_model_cacheentry but leaves the old class registered inapps.all_models. netbox-branching'sObjectChange.apply()gets the class viachanged_object_type.model_class()(models/changes.py:112). That goes throughCustomObjectsPluginConfig.get_model()(__init__.py:680-705), which returns any registered class, so the UPDATE runs on the stale class. netbox-branching'supdate_object()then hitsFieldDoesNotExistfor the new key (utilities.py:508) and sets a plain attribute (utilities.py:532) thatsave()never writes._collect_co_refs()takes field refs frommodel_class._field_objects(branching.py:98-101). Main's class doesn't know the branch's field, soadd_custom_object_dependencies()adds no UPDATE → field-CREATE edge (branching.py:237-241). The ordering itself was observed by the reporter, not traced.Where the fix lives: custom objects. Both are CO model-cache and CO squash-dependency logic; netbox-branching correctly asks the app registry for the class.
update_object()warn or raise on a data key that matches no model field, so this kind of loss is visible for any plugin. Not filed.Dependencies: none on netbox-branching (#729 doesn't touch apply or squash). Depends on #1042: with the
get_model()fix,test_choice_set_choices_mutated_in_branch_mergefails until #1042 is fixed.Severity / size: high (silent data loss on merge, with both strategies, in a normal workflow). Small to medium: a guarded rebuild in
get_model()(careful about recursion during model generation) plus a column → field map in squash dependencies, with tests.Related: same stale-cache family as #1042. @antoinekh has a fix branch.
Release plan for this batch (#1042–#1048): the branching-side fixes (netboxlabs/netbox-branching#728 / PR netboxlabs/netbox-branching#729, netboxlabs/netbox-branching#731, netboxlabs/netbox-branching#732) go into the next netbox-branching release. The custom-objects fixes land in the custom-objects release that raises the minimum netbox-branching version to it. Suggested custom-objects order: #1048, then #1042 + #1043, then #1047, then #1044 + #1045. Line numbers above are at custom-objects
b34ee80and netbox-branching5f72279.bctiemann commentedon Nov 10, 2026
Thanks @antoinekh , assigned to you.