Skip to content

Fix model defaults - #1277

Open
mxsrc wants to merge 2 commits into
R26.3from
fix-model-defaults
Open

Fix model defaults#1277
mxsrc wants to merge 2 commits into
R26.3from
fix-model-defaults

Conversation

@mxsrc

@mxsrc mxsrc commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

In Python, {} and [] create an instance. When this is used as a default value for an attribute, that is shared among all instances. If multiple instances are established from these default values, modification to one of them will apply to all others, which corrupts the database when they are written back. This went unspotted because we disable lots of linter rules that are violated across the codebase. This PR enables the rule that would have caught this and fixes it consistently. A The second commit fixes a similar bug class that might lead to sharing of specific attributes across instances if they are instantiated from the same payload or model dump. This might happen when using a base entity as a template for multiple others.

@mxsrc
mxsrc force-pushed the fix-model-defaults branch from 39c645d to de5df37 Compare August 26, 2026 15:20
mxsrc added 2 commits August 27, 2026 11:45
BaseModel resolves a field's default with getattr on the class, so a
literal `nodes: List[str] = []` handed every instance the same list.
One `lvol.nodes.append(...)` was visible on every other lvol and on
every instance built later in the process, and the next write_to_db
persisted the result. 56 fields across 12 model modules declared their
defaults this way.

Declare them with `default_factory(list)` / `default_factory(dict)`,
mirroring dataclasses.field(default_factory=): the class holds only a
marker and get_attrs_map calls the factory once per instance, so
from_dict keeps merging into existing instance state by identity. The
helper's declared return type is the factory's result, so mypy still
sees the field's real type.

Class attributes that are shared on purpose (_STATUS_CODE_MAP,
Singleton._instances, RPC_NO_PRINT_OUTPUT, ServerHandler.server_session)
are annotated ClassVar instead, and _annotated_attrs now skips ClassVar
so a public constant such as Cluster.STATUS_CODE_MAP is not treated as a
field and serialized into the FDB record. RUF012 enforces the
distinction from here on.

Collect the model tests under tests/unit/models/. The tier-wide guard
there constructs every model twice and fails on any field that hands out
the same container, whatever form the declaration takes.
BaseModel.from_dict stored the caller's own containers on the model: the
non-model List branch keeps data[attr] verbatim, Dict[...] and bare List
match no branch and fall through to it, and the remaining fallback only
shallow-copies. Model and payload then aliased each other, so an append
on either side showed up on the other and on every further model built
from the same dict — including the copy a caller kept and mutated after
handing it over.

Detach the value before storing it. _detached recurses into plain lists
and dicts (so the nested dicts of a List[dict] are copied too) and stops
at BaseModel children, which from_dict already builds fresh: the fat
StorageNode path pays one list allocation, not a deep copy of 97 device
dicts.

This is the payload-level half of the sharing the default_factory change
closed at class level; the tier-wide guard there only constructs models
with no data, so it could not see it.
@mxsrc
mxsrc force-pushed the fix-model-defaults branch from de5df37 to 72365f6 Compare August 27, 2026 09:55

@Hamdy-khader Hamdy-khader left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

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.

2 participants