PLAN: attribute field masks everywhere, and un-pack the node instances list¶
Background¶
Merge CI for PR #3425 (run 29557132804, 2026-07-17) failed on
cluster_ci_tests.test_scheduler.TestAffinity.test_affinity. The scheduler
audit events showed the affinity pass scoring every node zero because the
tagged neighbour instance had vanished from its node's
node_attributes.instances list, roughly 55 seconds after
place_instance() had written it there.
The mechanism is a cross-attribute lost update. Node._save_attributes()
writes the entire node_attributes row, and several writers do unlocked
read-modify-write cycles against that same row:
Node.observe_this_node()is called every 15 seconds by both sentinel-first and sentinel-last on every node, holds no lock, and saves the full row to updatelast_seenand the role flags.- The resources daemon's
process_metrics,dependency_versions,qemu_version,libvirt_version,python_versionandpython_implementationsetters save the full row every 300 seconds. Thedependency_versionssave reuses a row snapshot loaded before adpkg-querysubprocess ran, widening the stale window to seconds. add_instance()/remove_instance()(lock:instances) andregister_daemon()/deregister_daemon()(lock:daemons) are individually locked, but the locks are per-attribute while the write is whole-row, so they can clobber each other as well as being clobbered by the unlocked writers above.
Any interleaving where a full-row writer loads before, and saves after,
another writer's commit silently reverts that commit. This is the same
bug class as the agent-operations enqueue lost update fixed previously,
which introduced the fields parameter (a field mask) on
update_instance_attributes, update_artifact_attributes and
update_network_attributes. Node, namespace and blob never got that
conversion.
Audit: remaining full-row RMW exposure¶
| Attributes row | Multi-writer fields | Field mask? | Risk |
|---|---|---|---|
node_attributes |
instances (list), daemons (list), last_seen/roles, version + metrics fields |
no | High — unlocked 15s writers race API-side list updates; source of the test_affinity flake |
namespace_attributes |
keys (dict, lock keys), trust (list, lock trust) |
no | Medium-low — cross-attribute clobber possible between key and trust operations |
blob_attributes |
expires_at, info, size full-row writes can revert a concurrent update_blob_last_used() column write |
no | Low — last_used is advisory |
instance_attributes |
many | yes | converted (agent-await fix) |
artifact_attributes |
few | yes | converted |
network_attributes |
few | yes | converted |
network_interface_attributes |
single mutable field | n/a | safe |
agent_operation_attributes |
single mutable field, locked fresh RMW | n/a | safe |
Lists still packed into attribute rows: node_attributes.instances
(relational data, high write rate — this plan un-packs it),
node_attributes.daemons (derivable from node_daemon_states, see
follow-ups), namespace_attributes.trust (tiny, low write rate — field
mask is sufficient).
Policy¶
Callers of update_*_attributes must pass a field mask naming exactly
the fields they changed. Full-row writes (fields=None) are reserved
for row creation and pydantic-upgrade persistence, and must say so at
the call site. To give this teeth, the fields argument becomes
required (still Optional[...], but with no default) on every
multi-field update function, so a new caller cannot silently take the
full-row path. Single-field rows (network_interface_attributes,
agent_operation_attributes) are exempt.
This policy gets recorded in CLAUDE.md and AGENTS.md.
Phase 1: field masks for node, namespace and blob attributes¶
- Protos: add
repeated string fields = 2;toUpdateNodeAttributesRequest,UpdateNamespaceAttributesRequestandUpdateBlobAttributesRequest, with the same comment style asUpdateInstanceAttributesRequest. Regenerate withtox -e genprotos. mariadb.py: threadfieldsthrough the public functions, the_direct_*implementations (targetedUPDATE ... SETof only the named columns) and the_grpc_*clients, mirroring the instance implementation. Update the database daemon handlers to honour the mask.- Convert writers:
Node.observe_this_node()→fields=['last_seen', 'installed_version', 'is_etcd_master', 'is_hypervisor', 'is_network_node', 'is_eventlog_node', 'is_database_node']Node.add_instance()/remove_instance()→fields=['instances'](interim; removed entirely by Phase 2)Node.register_daemon()/deregister_daemon()→fields=['daemons']- resources daemon setters → each names exactly its own field
Namespacekey writers →fields=['keys']; trust writers →fields=['trust']Blobsetters → name their fields so they cannot revertupdate_blob_last_used()- Make
fieldsrequired (no default) on all six multi-field update functions; annotate the remaining intentionalfields=Nonecall sites (creation, upgrade persistence).
Phase 1 alone kills the observed flake: observe_this_node() can no
longer touch the instances column.
Phase 2: un-pack node_attributes.instances into object_references¶
The node→instance edge is relational data and belongs in a table with
atomic per-edge rows, not a JSON list subject to read-modify-write.
object_references already models exactly this shape and has a
precedent: BLOB_LOCATION rows record node→blob placement, queried via
Node.blobs.
- Add
RelationshipType.INSTANCE_LOCATION(string='instance_location',proto_id=8). Key the source by node UUID (as a string), not fqdn — the fqdn keying ofBLOB_LOCATIONis a legacy of the pre-UUID era and should not be copied. Instance.place_instance(): replace the node-cache bookkeeping with reference ops — delete the old node's reference, create the new node's — still under the existingplacementattribute lock so the placement value and the edge move together.Node.instancesproperty: queryget_references_from(ObjectType.NODE, str(self.uuid), RelationshipType.INSTANCE_LOCATION).add_instance()/remove_instance()and theinstancesattribute lock go away (single-row INSERT/DELETE needs no application lock).Instance.hard_delete(): remove the instance's INSTANCE_LOCATION reference (project rule: objects clean up inhard_delete()). Verify the cleaner's evacuate/resurrect paths (daemons/cleaner/scheduled_tasks.pycallsplace_instance) behave.- Migration in
sf-ctl ensure-mariadb-schema: seedobject_referencesfrom existingnode_attributes.instanceslists (idempotent INSERT IGNORE semantics), consistent with the schema version bump. - The
instancescolumn onnode_attributesis no longer read or written but remains for one release cycle as a rollback fallback, matching thedaemon_statesprecedent. - Confirm
object_referencesindexes cover the new query pattern (source_object_type, source_uuidcompound index exists; verify a relationship-filtered lookup uses it).
Testing¶
- Unit: per-object tests that an update with a field mask writes only
the named columns (mirror the existing instance field-mask tests);
tests for
Node.instancesbacked by references; migration seeding test. - Functional:
test_affinityalready covers the user-visible behaviour. The flake is timing-dependent so CI passing once is not proof, but Phase 2 removes the racing list entirely. - Events: preserve the existing audit events (
schedule have highest affinitydetails were what made this diagnosable — keepinstance_countandconsideredin the affinity detail).
Code review outcomes¶
A high-effort review of the implementation raised ten findings; all were addressed except one accepted limitation:
- The startup reconcile (formerly a
Node.instancessetter working from a snapshot taken before minutes of restore work) now re-checks each removal candidate's authoritativeInstance.placementbefore unrecording it, so a concurrently placed instance is never removed. - Accepted limitation: during the rolling-upgrade window, a
not-yet-restarted
sf-databaseignores the newfieldsproto field and performs a full-row write. This degrades to exactly the previous release's semantics until the daemon rolls, matching how the instance/artifact/network masks shipped; no guard is added. - The v3 migration guards against a missing
node_attributestable (older databases; table-ensure ordering runs object_references first). - Instead of freezing the legacy column at migration time, the new code dual-writes it (masked, locked) and unions it into reads for one transition release, so mid-roll placements from old nodes stay visible to upgraded schedulers and a rollback reads fresh data.
- The v3 version stamp is withheld when the seeding hit retryable write errors, so a re-run of ensure-mariadb-schema retries them. Corrupt (non-list) values are logged and skipped without blocking.
- The migration skips nodes in deleted or error states so stale lost-update residue does not become permanent phantom references.
- The public
update_*_attributesfunctions validate the field mask before dispatch, so a bad field name raises ValueError on the gRPC path exactly as it does on the direct path, instead of becoming a silently discarded StatusReply failure. Instance.hard_delete()removes any INSTANCE_LOCATION references targeting the instance as a backstop for delete paths that could not resolve the placement node.- The mock reference store keys rows on the real table's primary key (which excludes relationship_value) and mirrors the real upsert's keep-first-value behaviour.
- The mock's remove_relationship returns True whether or not a row existed (False only means database error), matching the real contract, and only removes a row whose stored relationship_value matches.
Follow-ups (not this PR)¶
- Drop
node_attributes.daemons: registration is derivable fromnode_daemon_statesrows (register creates a state row, deregister deletes it). One release after this PR, remove the column together withinstancesand the vestigialdaemon_states/etcd columns. - Consider whether
Node.blobs/BLOB_LOCATIONshould migrate from fqdn to UUID keying as part of the wider FQDN→UUID cleanup.