Skip to content

Merging a branch drops the value of a field added in the branch on an object that existed before the branch #1043

Description

@antoinekh

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

  1. In main, create a Custom Object Type with a text field label, and one object with label = existing.
  2. Create a branch (iterative or squash merge strategy) and activate it.
  3. In the branch, add a text field notes to the type.
  4. In the branch, edit the existing object and set notes = merged.
  5. 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

  1. In CustomObjectsPluginConfig.get_model(), rebuild a registered dynamic model (table<id>model) through CustomObjectType.get_model() when _model_cache has 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 one exists() query. Rebuilding on eviction alone is not enough: model generation itself resolves other types through ContentType.model_class(), so an eviction-only check recurses during generation and replaces classes that other models still point to.
  2. 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's postchange_data writes 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

  1. added theissue type on Nov 10, 2026
  2. bctiemann commented on Nov 10, 2026

    @bctiemann
    Contributor

    Triage notes. The diagnosis above matches the code.

    Root cause (confirmed in code):

    • Iterative merge: CustomObjectTypeField.save() calls clear_model_cache() (models.py:4617), which removes the _model_cache entry but leaves the old class registered in apps.all_models. netbox-branching's ObjectChange.apply() gets the class via changed_object_type.model_class() (models/changes.py:112). That goes through CustomObjectsPluginConfig.get_model() (__init__.py:680-705), which returns any registered class, so the UPDATE runs on the stale class. netbox-branching's update_object() then hits FieldDoesNotExist for the new key (utilities.py:508) and sets a plain attribute (utilities.py:532) that save() never writes.
    • Squash merge: _collect_co_refs() takes field refs from model_class._field_objects (branching.py:98-101). Main's class doesn't know the branch's field, so add_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.

    • Optional netbox-branching hardening (not needed for this fix): have 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_merge fails 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 b34ee80 and netbox-branching 5f72279.

  3. added
    severity: highCompletely breaks certain functions, or substantially degrades performance application-wide
    on Nov 10, 2026
  4. added this to the v1.0.1 milestone on Nov 10, 2026
  5. bctiemann commented on Nov 10, 2026

    @bctiemann
    Contributor

    Thanks @antoinekh , assigned to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions