Truthful cluster operation visibility¶
Prompt¶
Before responding to questions or discussion points in this document, explore the shakenfist codebase thoroughly. Read relevant source files, understand existing patterns (object lifecycle, state machines, MariaDB storage via the three-layer direct/gRPC/public pattern, Pydantic schemas, daemon architecture, operation queue system, event logging), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Where a question touches on external concepts (KVM/libvirt, VXLAN networking, MariaDB/Galera, gRPC/protobuf), research as needed to give a confident answer. Flag any uncertainty explicitly rather than guessing.
All planning documents should go into docs/plans/.
Consult ARCHITECTURE.md for the system architecture
overview, object types, and daemon structure. Consult
CLAUDE.md for build commands, project conventions, and
database access patterns. Consult GOALS.md for current
development priorities. Key references inside the repo
include shakenfist/baseobject.py (object lifecycle and state
machine), shakenfist/mariadb.py (three-layer database
access pattern), shakenfist/schema/ (Pydantic models), and
shakenfist/daemons/database/main.py (gRPC database daemon).
When we get to detailed planning, I prefer a separate plan
file per detailed phase. These separate files should be named
for the master plan, in the same directory as the master
plan, and simply have -phase-NN-descriptive appended before
the .md file extension. Tracking of these sub-phases should
be done via a table like this in this master plan under the
Execution section.
I prefer one commit per logical change, and at minimum one commit per phase. Do not batch unrelated changes into a single commit. Each commit should be self-contained: it should build, pass tests, and have a clear commit message explaining what changed and why.
Situation¶
On 2026-07-19 the smoke test
test_interface_plug_and_exec_reboot failed on PR #3462 (run
29668325279) with "Interface not found in sudo lshw -class
network output:" followed by empty output. Forensics from
the CI bundle established the following sequence:
- 01:35:54.9 -- the test POSTed the interface hot-plug. The
API handler (
external_api/instance.py,InstanceInterfacesEndpoint.post) synchronously enqueued the net reconcile op and thehot_plug_instance_interfaceop, withcluster_operation_targetsrows correctly written for the instance. - 01:35:55.0 -- the test's
_await_instance_operations_complete()helper (deploy/shakenfist_ci/base.py) polled the instance'slast_cluster_operation, saw the hot-plug opqueued, and kept waiting. Correct so far. - Between polls, the cluster daemon's per-instance sweep
(
daemons/cluster/scheduled_tasks.py,_process_per_instance_queue) enqueued a routinenode_inst_op(collect_billing_statistics,health_check_kvm_process) against the same instance. It completed within seconds and became the instance'slast_cluster_operation, shadowing the still-queued hot-plug op. - 01:36:05.0 -- the helper's second poll fetched the billing
op, saw
complete, and returned. The hot-plug chain had not even started executing (it ran at 01:36:10, delayed behind its mesh-op dependency). - 01:36:16 -- the test ran
sudo lshw -class networkin the guest. The instance is created with no NICs, so empty output with rc 0 is exactly correct for a guest whose hot-plug has not happened yet. - 01:36:18 -- one second after the test failed and tore the
instance down, the attach op finally executed and logged
libvirt: Domain Config error : Requested operation is not valid: domain is not running.
The underlying defect is the single-pointer
last_cluster_operation pattern.
PLAN-replace-last-cluster-operation.md (all phases
complete) already moved the internal gating callers --
Network.is_okay() and siblings -- onto the history-aware
has_pending_cluster_operation() query, and its docstring
(baseobject.py:685) names this exact race: "a later
terminal op against the same object would mask an earlier
in-flight op". This plan is that plan's successor: several
consumer groups were out of its scope and still use the racy
pointer:
- CI await helpers.
_await_instance_operations_complete()(deploy/shakenfist_ci/base.py:522) and_await_network_operations_complete()(base.py:546) polllast_cluster_operationand inspect only that op's state. - The "outstanding operations" REST endpoints.
GET /instances/<ref>/operations(external_api/instance.py,InstanceOutstandingOperationsEndpoint, plus network and artifact twins) are built onbaseobject.get_cluster_operations()(baseobject.py:722), which seeds fromlast_cluster_operationand walks only that op'sdepends_on/runs_afteredges. During the flake window it would have returned[]-- the endpoint lies precisely when a background op has shadowed the pointer. - The
runs_afterserialisation chains. Operations on an object are not implicitly serial; the dispatcher (daemons/queues/workitem.py) only defers behind ops listed independs_on/runs_after. Six enqueue sites opt in by passingruns_after=[obj.last_cluster_operation](network/network.py:321,instance.py:1829,instance.py:1925,external_api/artifact.py:375,external_api/instance.py:863,external_api/instance.py:1026). A background op permuting the pointer between two user mutations makes the second mutation chain behind the (instantly complete) measurement op instead of the first mutation -- a genuine server-side ordering bug, independent of CI.
One endpoint is already history-aware:
GET /clusteroperations?target_object_type=&target_uuid=
(external_api/clusteroperation.py,
ClusterOperationsEndpoint, added in phase 7 of
PLAN-network-facade.md; client wrapper
list_cluster_operations_for_target() behind the
cluster-operations-by-target capability). It joins
cluster_operation_targets against cluster_operations and
returns every op that has targeted the object, newest-first,
including state. It is, however, heavyweight for polling: it
hydrates every historical op (one DB round trip per op, with
history growing over the object's lifetime).
Design decisions already made in discussion with the operator:
- We will not serialise background measurement ops behind
the object's op chain (
runs_after). Health checks should be timely; the sweep would pile deferrals onto exactly the instances that are busiest; and enqueue-time chaining is not atomic anyway, so it cannot deliver a strict guarantee. - Instead we adopt a mutating vs observational split:
measurement-only ops (billing statistics, KVM health
checks, blob checksum verification) still write
cluster_operation_targetsrows for audit history, but are excluded fromget_latest_cluster_operation_target()andhas_pending_cluster_operation_target(). Observational ops therefore no longer permutelast_cluster_operationnor count as "pending". - We will expose a cheap
has_pending_operationsboolean inexternal_view()for operation-bearing objects, backed by the existing EXISTS query. - We will make the "outstanding operations" endpoints
truthful by reimplementing
get_cluster_operations()over the targets table.
Useful implementation reference points collected during forensics:
- Targets table definition:
mariadb._get_cluster_operation_targets_table()(mariadb.py:1085), currently schema version 2; migration in_ensure_cluster_operation_targets_schema()(mariadb.py:1136); version constantCLUSTER_OPERATION_TARGETS_VERSIONregistered inEXPECTED_SCHEMA_VERSIONS(mariadb.py:253). - Three-layer accessors for targets: direct at
mariadb.py:3605-3810, gRPC atmariadb.py:3942-4059, public atmariadb.py:4141-4248. Proto RPCs inprotos/database.proto:268-276, messageClusterOperationTargetProtoatdatabase.proto:1850. Handlers indaemons/database/main.py. Regenerate stubs withtox -e genprotos. - The only writer of target rows is the auto-write loop
in
enqueue_cluster_operation()(schema/operations/util.py:17), driven bymodel_class.target_fields.baseobject._set_last_cluster_operation()(baseobject.py:698) has no remaining callers and is dead code. - The pending query joins
object_statesand filters on_ACTIVE_OPERATION_STATES(mariadb.py:3682); it fails closed (returns True) on DB errors. - API capabilities are advertised in
external_api/app.py(see the list around line 215) and checked client-side viacheck_capability()inclient-python/shakenfist_client/apiclient.py:246. Client changes land in the siblingclient-pythonrepository.
Mission and problem statement¶
Make cluster operation visibility truthful and cheap for every consumer, so that:
- "is anything in flight for this object?" has one correct,
indexed answer (
has_pending_operations), exposed throughexternal_view()and usable by tests, clients, and internal gating alike; - measurement-only background operations stop corrupting the
last_cluster_operationpointer, which restores the intended semantics of the sixruns_afterserialisation chains and stops background sweeps from reordering user mutations; - the
/instances/<ref>/operationsfamily of endpoints reports every outstanding operation targeting the object, not just the ones reachable from the newest target row; and - the functional CI await helpers stop flaking on background
op timing (
test_interface_plug_and_exec_rebootbeing the proven victim).
Open questions¶
- Classification sweep. Which enqueue sites are
observational? Known candidates: the per-instance
billing/health sweep
(
daemons/cluster/scheduled_tasks.py:215) and the blob checksum verification sweep (verify_size_and_checksum, same file). Phase 3 must audit everycreate_and_enqueue()caller and classify each with a comment. Anything that mutates guest-visible or cluster state is mutating. - Where does the flag live? Preferred: a per-op-model
class-level marker (alongside
target_fields) so classification is declared once inshakenfist/schema/operations/*_op.py, plus a column oncluster_operation_targetsdenormalising it for the SQL filters. Confirm during phase 2 detailed planning whether the flag is per operation model (simplest, preferred) or needs to be per enqueue call. Notenode_inst_opmixes task types -- billing/health tasks are observational but the same op type may carry mutating tasks elsewhere; if so, the flag must be per enqueue call, passed throughcreate_and_enqueue()->enqueue_cluster_operation(). - Capability naming. Proposed:
object-pending-operationsfor thehas_pending_operationsfield, following the existing kebab-case convention. all=truesemantics. Onceget_cluster_operations(outstanding_only=False)is history-aware,/operations?all=truereturns genuinely full history. Confirm response-size expectations are acceptable (long-lived instances accumulate sweep rows) or whether a limit parameter is needed.- Should
hard_deleteof ops prune target rows? The cleaner already hard-deletes old terminal ops (delete_stale_cluster_operation_targetsexists). Verify retention interacts sanely with "full history" claims in the docs.
Execution¶
| Phase | Plan | Status |
|---|---|---|
| 1. CI await helper deflake | PLAN-cluster-op-visibility-phase-01-ci-deflake.md | Complete (CI green) |
| 1b. Atomic target writes + by-target JOIN fix | (folded into phase 1 PR; see below) | Complete (CI green) |
| 2. Observational flag: schema and query layer | PLAN-cluster-op-visibility-phase-02-observational-schema.md | Not started |
| 3. Classify and mark observational enqueue sites | PLAN-cluster-op-visibility-phase-03-mark-observational.md | Not started |
| 4. API surface: has_pending_operations and truthful outstanding ops | PLAN-cluster-op-visibility-phase-04-api-surface.md | Not started |
| 5. CI helper simplification, functional coverage, documentation | PLAN-cluster-op-visibility-phase-05-coverage-docs.md | Not started |
Phase 1b: Atomic cluster_operation_targets writes (done, in the phase 1 PR)¶
Discovered while verifying phase 1 in CI. Even with the
by-target endpoint fixed, test_interface_plug_and_exec_dhcp
still failed intermittently: the CI await helper's first
by-target poll, fired microseconds after the interface
hot-plug POST returned, saw [] and concluded "done" while
the hot-plug op was in fact queued. The bundle proved it -- op
"created" at T, POST returned at T+3ms, poll at T+5ms returned
[]. The cause: enqueue_cluster_operation
(schema/operations/util.py) wrote the op's state/work-queue
rows via create_and_enqueue_cluster_operation (one
transaction) and then wrote the cluster_operation_targets
rows in a separate, non-transactional loop. A by-target reader
in that gap sees an enqueued op with no target rows. This same
race undermines has_pending_cluster_operation(), the
network-node is_okay() gating, and everything phase 2/4 will
build on the by-target query -- so it is a root-cause fix, not
a test workaround.
Fix (shipped in the phase 1 PR): thread a targets list of
(ObjectType, target_uuid) pairs through
create_and_enqueue_cluster_operation (public/gRPC/direct)
and the CreateAndEnqueueClusterOperationRequest proto, and
write those rows in the same transaction as the op.
enqueue_cluster_operation now derives the list from
model_class.target_fields and passes it down instead of
looping create_cluster_operation_target after the fact.
create_cluster_operation_target remains for its other caller
(node_blob_op.py) and for _set_last_cluster_operation
(dead code, still slated for removal in phase 2). Unit tests:
_direct_create_and_enqueue_cluster_operation writes N target
inserts before a single commit; enqueue_cluster_operation
passes the derived targets and no longer calls the retired
per-row writer.
Phase 1: CI await helper deflake (plan at medium effort)¶
Requires a small server-side fix after all (see phase 1b and
the phase 1 plan's scope correction). Rewrite
_await_instance_operations_complete() and
_await_network_operations_complete() in
deploy/shakenfist_ci/base.py to call
system_client.list_cluster_operations_for_target() and wait
until no returned op is in a non-terminal state (non-terminal
being anything outside complete/deleted/abort; error
should still fail the test as today). Keep the 5-minute
timeout and the existing failure messages. Note the helpers
run against the system namespace client so the by-target
endpoint's namespace gate is not a concern. This phase is
test-only; a stestr run and a smoke CI pass are the
verification.
Phase 2: Observational flag -- schema and query layer (plan at high effort)¶
Add the mutating/observational distinction to storage and queries, default everything to mutating (no behaviour change until phase 3):
cluster_operation_targetsv3 migration: addobservationalboolean (server default false) in_ensure_cluster_operation_targets_schema(); bumpCLUSTER_OPERATION_TARGETS_VERSION. Consider whetheridx_cot_targetneeds to become(target_object_type, target_uuid, observational)-- check the query plans for the latest/pending queries.- Thread the flag through the writer:
create_cluster_operation_target()public/gRPC/direct trio,CreateClusterOperationTargetRequestandClusterOperationTargetProtoinprotos/database.proto, the database daemon handler, and the auto-write loop inenqueue_cluster_operation(). Regenerate withtox -e genprotos. - Exclude observational rows in
get_latest_cluster_operation_target()andhas_pending_cluster_operation_target()(both direct and gRPC paths -- the filter belongs in SQL, not in Python).list_cluster_operations_for_target()keeps returning everything (it is the audit/history view) but should surface the flag in its summary dicts. - Delete the dead
_set_last_cluster_operation()and its now-unusedcreate_cluster_operation_targetcall path if nothing else uses it. - Rolling upgrade note: schema must be applied via
sf-ctl ensure-mariadb-schemabefore daemons roll, per the existing verify-at-startup contract. Old daemons writing rows without the column are fine (server default false = mutating, the conservative value).
Phase 3: Classify and mark observational enqueue sites (plan at medium effort)¶
Audit every create_and_enqueue() caller (see the file list
in the phase plan), classify each as mutating or
observational with a short justification comment, and mark
the observational ones. Known observational: the
per-instance billing/health sweep and the blob checksum
verification sweep in daemons/cluster/scheduled_tasks.py.
Everything else is expected to stay mutating; the deliverable
includes the audit table in the phase plan so the decision
trail survives. Add a unit test asserting that an
observational op does not change
last_cluster_operation and does not make
has_pending_cluster_operation() true.
Phase 4: API surface (plan at high effort)¶
- Add
has_pending_operationstoexternal_view()forDatabaseBackedObjectWithOperationssubclasses that already exposelast_cluster_operation(instance, network, artifact at minimum -- audit which views include the pointer today). One extra EXISTS query per view render; verify the views that render lists of objects (e.g. instance list endpoints) do not multiply this into a hot-path problem -- if they do, restrict the field to single-object GETs. - Advertise a new capability string (proposed
object-pending-operations) inexternal_api/app.py. - Reimplement
baseobject.get_cluster_operations()overmariadb.get_cluster_operation_targets_for_object()+ per-op state checks so the/instances|networks|artifacts/<ref>/operationsendpoints report all outstanding ops, not just the latest chain. Keep newest-first ordering. - client-python (sibling repo): add
get_instance()-adjacent accessors as needed, ahas_pending_operations-aware helper, and capability checks mirroringlist_cluster_operations_for_target(). Update the OpenAPI examples/swagger helpers touched.
Phase 5: CI helper simplification, functional coverage, documentation (plan at medium effort)¶
- Switch the phase-1 CI helpers to poll
has_pending_operations(cheaper, single GET) once the deployed server advertises the capability; keep the by-target fallback for older servers. - Functional test: hot-plug an interface and assert the await helper genuinely blocks until the attach lands even when a billing sweep fires (can be provoked by simply waiting through a sweep period during the await).
- Documentation:
docs/operator_guide/database.md(schema change),docs/developer_guide/(mutating vs observational ops, when to mark an op observational),ARCHITECTURE.md/AGENTS.md/CLAUDE.mdstorage notes for the new column and field,docs/plans/index.mdstatus updates.
Agent guidance¶
Execution model¶
All implementation work is done by sub-agents, never in the management session. The management session (this conversation) is reserved for planning, review, and decision-making. This keeps the management context lean and avoids drowning it in implementation diffs.
The workflow is:
- Plan at high effort in the management session.
- Spawn a sub-agent for each implementation step with the brief from the plan, at the recommended effort level and model.
- Review the sub-agent's output in the management session. Check the actual files — the sub-agent's summary describes what it intended, not necessarily what it did.
- Fix or retry if the output is wrong. Diagnose whether the brief was insufficient (improve it) or the model was too light (upgrade it), then re-run.
- Commit once the management session is satisfied with the result.
This applies to all steps, including high-effort ones. If a sub-agent can't succeed even with a detailed brief and the right model, that's a signal the brief needs improving, not that the management session should do the implementation itself.
Use isolation: "worktree" for sub-agents when the change is
risky or experimental. The worktree is discarded if the
output is unsatisfactory. For safe, well-understood changes,
sub-agents can work directly in the main tree.
Planning effort¶
The master plan itself was created at high effort.
Recommended planning effort per phase is noted in the phase summaries above: phases 2 and 4 (schema design, cross-daemon protocol changes, API semantics) at high effort; phases 1, 3 and 5 (well-understood test changes, a classification sweep with a clear rubric, docs) at medium effort.
Step-level guidance¶
Each phase plan should include a step table using this format:
| Step | Effort | Model | Isolation | Brief for sub-agent |
|------|--------|-------|-----------|---------------------|
| 1a | medium | sonnet | none | One-sentence summary of what to do and which files to touch |
| 1b | high | opus | worktree | Why this needs high effort: requires understanding X to do Y |
Effort levels: - high — Requires reading multiple files, making judgment calls, understanding non-obvious invariants, or researching external references. The sub-agent needs to think carefully about edge cases. - medium — The plan provides enough context that the sub-agent can follow a clear brief. May need to read a few files but the approach is well-defined. - low — Purely mechanical changes (rename, reformat, add a log line, regenerate proto stubs). The brief is a complete instruction.
Model choice: opus for deep reasoning, subtle correctness
judgment (locking, state machines, migration), or steps that
must hold much of mariadb.py plus the database daemon in
context at once (context window: opus has 1M tokens, sonnet
and haiku 200K). sonnet is the default for well-briefed
implementation work. haiku only for purely mechanical tasks
with near-complete instructions. When in doubt, skew to the
more capable model — a failed implementation wastes more time
than the heavier model costs.
Brief for sub-agent: Write it as if briefing a colleague who has never seen the codebase. Include: what to change, which files to touch, what patterns to follow, and any non-obvious constraints. Front-load the research the planner already did — the Situation section of this plan contains file/line references collected during forensics precisely so briefs can cite them.
Management session review checklist¶
After a sub-agent completes, the management session should verify:
- The files that were supposed to change actually changed (read them, don't trust the summary).
- No unrelated files were modified.
- The code passes
pre-commit run --all-files(flake8, stestr unit tests, mypy). - If proto files changed, stubs were regenerated with
tox -e genprotosand committed. - The changes match the intent of the brief — not just syntactically correct but semantically right.
- Commit message follows project conventions (including the Co-Authored-By line with model, context window, effort level, and other settings).
Administration and logistics¶
Success criteria¶
We will know when this plan has been successfully implemented because the following statements will be true:
- The code passes
pre-commit run --all-files(flake8, stestr unit tests, and mypy type checking). - New code follows existing patterns: object lifecycle in
baseobject.py, MariaDB access via the three-layer pattern (direct/gRPC/public), Pydantic schemas inshakenfist/schema/. - The observational filter is pushed down to the MariaDB SQL layer (WHERE clause with index support), not applied in Python.
- An observational op enqueued against an object changes
neither
last_cluster_operationnorhas_pending_operationsfor that object, and a unit test proves it. GET /instances/<ref>/operationsreturns a queued op even when a later terminal op targets the same instance, and a test proves it.test_interface_plug_and_exec_rebootno longer races the hot-plug: the await helper blocks until the attach lands.- gRPC proto changes have been regenerated with
tox -e genprotos. docs/operator_guide/database.mddocuments thecluster_operation_targetsv3 schema, and the developer guide documents the mutating/observational distinction.ARCHITECTURE.md,README.md,AGENTS.mdandCLAUDE.mdhave been updated where they describe the affected storage or API behaviour.docs/plans/index.mdanddocs/plans/order.ymlreference this plan and its phases with current status.
Future work¶
- Client-side load balancing interactions: none expected, but the new gRPC field rides the existing database service surface — nothing to do here beyond genprotos.
- Consider whether the scheduler / cleaner should also
consult
has_pending_operationsinstead of any remaining last-op reads (audit for other legacy readers oflast_cluster_operationbeyond the sixruns_aftersites). /operations?all=truepagination if history size becomes a practical problem.- The 15-second enqueue-to-execution latency observed for the
user_waitinghot-plug chain in CI (dependency deferral polling cadence) is worth a separate look — it is latency, not correctness, but user-visible. - Issue #3278 (CI and test coverage backlog) may want a note about the deflaked helper pattern.
Bugs fixed during this work¶
GET /clusteroperations?target_object_type=&target_uuid=was broken on arrival (fixed in phase 1). The handlerClusterOperationsEndpoint.get(self)accepted neither target parameter as a keyword argument and read them fromflask.request.args, but the SF client sends parameters in a JSON body which thelog_requestdecorator injects as kwargs — so every real client call raised aTypeErrorsurfaced as HTTP 400. The endpoint's unit tests only drove the query-string path via Flask's test client and never caught it. Fixed by accepting the injected kwargs (with a query-string fallback) and adding a body-path unit test.- The by-target listing query never matched (fixed in
phase 1b).
_direct_list_cluster_operations_for_targetJOINedcluster_operation_targets.operation_uuid(String(36), dashed form) directly againstcluster_operations.uuid(aUuid()column whose stored form has no dashes / is native), so the JOIN never matched and the endpoint always returned[]even for a queued op.has_pending/get_latestwere unaffected because they JOINoperation_uuidagainstobject_states.object_uuid(both dashedString(36)). It escaped notice because the endpoint 400'd on arrival (so the query never ran) and its SQLite unit fixture usedString(36)forcluster_operations.uuidinstead of the realUuid(), so String==String matched and masked the bug. Fixed with a two-step query (select operation_uuids from targets, thenops.uuid.INthe UUID objects so theUuidbind processor converts to storage form), and the fixture was corrected to useUuid()so the existing SQL tests now guard it. This is the fix that actually made the CI hot-plug await reliable -- the atomic-write fix (phase 1b) was necessary but not sufficient on its own. - Candidate already identified:
_set_last_cluster_operationinbaseobject.pyis dead code and will be removed in phase 2.
A scan of the GitHub issue tracker on 2026-07-19 found no open issues describing this race directly; the closest adjacent reports are #3432 (lock lifetime noise) and #3278 (CI coverage tracking), neither of which this plan resolves.
Documentation index maintenance¶
When creating this master plan, update the following files in
docs/plans/:
index.md— add a row to the Plan Status table with a link to this plan, its phase breakdown, initial status, and a one-line description.order.yml— add an entry for this master plan so it appears in the documentation navigation. Phase files are linked from the Execution table andindex.mdonly.
When all phases are complete, update the status column in
docs/plans/index.md.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.