Scheduler reservations phase 2: capacity tables and reconciler¶
Prompt¶
Before implementing any step, read the phase 0 decisions
(PLAN-scheduler-reservations-phase-00-decisions.md) — this
phase implements D2 (three-table schema), D5 (reconciler) and
the schema half of D13 (expected demand) exactly as decided
there; do not reopen them. Read the code this phase extends:
the schema-version machinery and tri-layer access pattern in
shakenfist/mariadb.py (EXPECTED_SCHEMA_VERSIONS,
ensure_schema(), and the delete_stale_transfers /
_grpc_ / _direct_ triple as the RPC template); the orphan
reconciliation queries in the same file (the index-friendly
REPLACE(dashed, '-', '') = LOWER(HEX(binary)) join is the
required idiom for every dashed/undashed uuid join in this
phase); the cluster daemon's elected-leader loop and
schedule-based task registration
(shakenfist/daemons/cluster/main.py,
daemons/cluster/scheduled_tasks.py, including the
module-level prometheus counters); the typed node_metrics
columns phase 1 added (NODE_METRICS_EXTRACTION_SPEC); the
scheduler's current capacity arithmetic
(shakenfist/scheduler.py _has_sufficient_cpu/_ram/_disk)
which the limit-derivation formulas below must mirror; and
instance placement ground truth (instance_location rows in
object_references, written by Instance.place_instance()).
Ground the work in what the code does, not what this plan says
it does.
Situation¶
Phase 0 decided the admission design (guarded single-row
UPDATEs over materialised counters, D1) and its schema (three
tables, no per-decision reservation rows, D2). Phase 1
promoted the capacity-relevant node_metrics fields to typed
columns, so limit derivation can now be done in SQL. Nothing
yet stores derived capacity, and nothing recomputes usage
from ground truth.
The master plan deliberately splits the storage-and- reconciliation layer (this phase) from admission (phase 3) so that the tables, the limit derivation and the reconciler can soak on sfcbr — observable via prometheus and logs, consulted by nothing — while phase 3 is built. Any bug found during soak costs an annoyed operator a dashboard glance, not a mis-placed instance.
Mission and problem statement¶
Create scheduler_node_capacity, namespace_claims and
cluster_capacity per D2; implement the reconciler as a
periodic task in the cluster daemon's elected-leader loop per
D5, recomputing every counter from ground truth server-side;
carry the D13 expected_demand column and its decay
recompute from day one. Nothing consumes any of it for
admission — this phase is observable-but-inert.
Design¶
Tables (D2, verbatim)¶
All uuid key columns are sa.Uuid (undashed CHAR(32) on
MariaDB, matching the static tables); every join against the
dashed-form tables (object_states, object_references)
uses the orphan-reconciliation transform idiom (pitfall 6).
Timestamp columns follow the cluster_locks TIMESTAMP idiom
so expiry guards compare against server-side NOW().
scheduler_node_capacity — one row per hypervisor:
node_uuid (PK), limit_cpus, limit_memory_mb,
limit_disk_gb, used_cpus, used_memory_mb,
used_disk_gb (all BIGINT, matching the int64 proto fields;
the cluster sums could overflow INT around 700 TB of
overcommitted RAM, and widening costs nothing while the
tables are new), expected_demand (DOUBLE), updated_at.
namespace_claims — one row per claim: uuid (PK),
namespace (type matching namespaces.name, indexed —
admission in phase 3 looks claims up by namespace),
limit_* and used_* (same three dimensions), state
(string), expires_at, updated_at. Created empty in this
phase: the claims API is phase 4. The reconciler's per-claim
recompute and expiry sweep are still implemented and
unit-tested now, so phase 4 lands onto proven machinery.
cluster_capacity — a singleton (id PK, always 1):
total_*, claimed_*, unclaimed_used_* for the three
dimensions, updated_at. The D14 admission guards run
against this row in phases 3/4; here it is only maintained.
New version constants (all 1, the new-table convention):
SCHEDULER_NODE_CAPACITY_VERSION, NAMESPACE_CLAIMS_VERSION,
CLUSTER_CAPACITY_VERSION, wired into
EXPECTED_SCHEMA_VERSIONS, ensure_schema() and the
EXPECTED_TABLE_NAMES drift guard in
test_mariadb_schema_concurrency.py. Fresh creation only —
there is no prior shape to migrate. The standard machinery
gives the rest: sf-ctl ensure-mariadb-schema creates the
tables; verify_schema_versions makes sf-database refuse
to start until it has run.
No new indexes beyond the PKs and the namespace index:
row counts are nodes, claims and one.
Limit derivation (mirrors today's scheduler arithmetic)¶
Limits are recomputed each reconciler pass from the phase 1
typed node_metrics columns. The formulas deliberately
mirror scheduler.py so phase 3's guard admits exactly what
today's Python filter admits:
limit_cpus = floor(cpu_schedulable × CPU_OVERCOMMIT_RATIO)— the_has_sufficient_cpubound.used_cpussums the allocated vCPUs of every placed, non-deleted instance. This is not parity withcpu_total_instance_vcpus, which an earlier draft of this plan claimed: the resources daemon counts only active libvirt domains, so a powered-off instance (or one placed but not yet defined in libvirt) is in the ledger and not in the measurement, andused_cpusreads higher than today's filter sees on a node with powered-off instances. The ledger is the right semantics for a reservation — a stopped instance still owns its resources — but it means phase 3 must decide explicitly which number its guard reads, and a unit test asserting the two agree would be false comfort.limit_memory_mb = floor(memory_max × RAM_OVERCOMMIT_RATIO) - memory_reserved_mb— the_has_sufficient_ramovercommit bound.used_memory_mbcounts allocated MB (not the KSM-awarememory_total_instance_actual): the counter is an allocation ledger. The actual-free and actual-usage checks remain phase 3 candidate filters over the typednode_metricscolumns per D7; the counter guard is the allocation backstop.limit_disk_gb = used_disk_gb + max(0, floor(disk_free_instances/GiB) - disk_reservation_gb), recomputed together withused_disk_gb. There is no total-disk metric, and free space is the only ground truth that survives qcow2 growth, so the limit is expressed as "current virtual drawdown plus what the filesystem says still fits". The phase 3 guardused + x <= limitis then exactly today's_has_sufficient_diskfree-space check, while accounting virtual size per D2.
Nodes gain a capacity row when they have a node_metrics row
with non-NULL capacity columns; rows are deleted when the
node is deleted. NULL-columned metrics rows (written by an
old resources daemon mid-upgrade) leave the previous limits
in place rather than zeroing them.
Capacity rows exist per schedulable hypervisor, not per
node. A row describing capacity the scheduler would never use
is worse than no row at all, because it inflates the cluster
totals that a phase 3 D14 guard reads. The invariant, learned
the hard way across five review rounds that each found the
same defect in a new guise (role, state, freshness, existence,
and — twice — filter polarity: a subtractive filter admits
whatever is in neither set), is: a capacity row exists
only where the scheduler could place, every node-level
scheduler-side filter is automatically a reconciler-side
filter too, and each filter is expressed as membership in a
positive qualifying set, never as absence from a
disqualifying one. By that rule one node-level filter remains
deliberately unmirrored: _has_reasonable_queue_state() (the
scheduler's queue_state stage, which drops a node whose
queue depth suggests it is wedged). It is excluded because
queue depth is a fast-moving liveness signal, not capacity —
mirroring it at a five-minute cadence would make rows flap on
transient queue spikes — but if phase 3's guard needs it, it
is a decision to take, not a gap to discover. Four filters
enforce the invariant today, each mirroring something
scheduler.py already does before it will consider a node:
- Role. The resources daemon runs on every cluster node
and upserts
node_metricsunconditionally, so a network-only or database-only node has a metrics row with perfectly good capacity columns.scheduler.pydrops non-hypervisor candidates before any capacity arithmetic, so phase 2 addsis_hypervisortoNODE_METRICS_EXTRACTION_SPEC(node_metricsv3 → v4) and filters on the typed column in SQL. A NULLis_hypervisor— a metrics row not yet rewritten after the upgrade — is treated as no evidence either way: it neither creates nor destroys a row, and an existing row keeps its limits until the next 60-second upsert settles the question. - State. The scheduler builds its candidate set from
Nodes([], prefilter='active'), so an errored, missing, stopping, stopped or deleted node is not a placement candidate. A hypervisor that the node-health cascade has taken out of service must stop contributing its limits, not sit there advertising capacity nothing can be placed on. The filter is expressed positively —state_value INthe active set (constants.NODE_ACTIVE_STATES, whichNode.ACTIVE_STATESis now defined from so the two cannot drift), with candidates intersected against the result — so everything outside the set is excluded by default: a state added to the state machine later, and a node with no state row at all.prefilter='active'resolves throughget_objects_by_state, which only returns objects that have a state row, so the scheduler sees neither; and stateless zombies are a real condition here (the orphan reconciler exists for them). An earlier draft subtracted aNOT IN-derived inactive set instead, and a stateless node appeared in neither set and slipped through — the fifth instance of the recurring defect (fourth review, item 1). - Existence. A node must have a row in the
nodestable, gating creation as well as removal. Gating only removal leaves a permanent phantom: anode_metricsrow that outlives its node's static and state rows reads as a fresh hypervisor, gets a row, and then no removal condition ever fires for it. - Freshness.
node_metricsrows are only deleted when the node is, so a node whose resources daemon has died would otherwise contribute its last-known limits forever. Rows older thanRECONCILE_METRICS_MAX_AGE_SECONDS(15 minutes: three reconcile cadences, fifteen publish intervals) are ignored. Deliberately not the scheduler's 120-second window, which is tuned for an on-demand cache and would sit below this pass's five-minute period, making rows flap in and out between passes and rendering the reply'snodes_added/nodes_removedcounts meaningless. Like the state filter, this is expressed positively — membership in a fresh set, and absence removes the row — because a node can also have no metrics row at all: sf-resources deletes its own node's rows at daemon startup before the first upsert, so a resources daemon dying in that window leaves a live, active node with no row, which a stale-set subtraction retained forever (fifth review, item 2 — the freshness twin of the state filter's polarity bug). The fresh set carries nois_hypervisorpredicate, so a fresh row whose flag is still NULL mid-upgrade keeps qualifying for the no-evidence retention.
A node failing any of these loses its capacity row, which is also how rows written by a release before the filters existed get cleaned up on upgrade.
The reconciler (D5)¶
A new scheduled_tasks.reconcile_scheduler_capacity()
registered in the elected-leader loop at
schedule.every(5).minutes (D5's provisional period, matching
the hardcoded cadence of its sibling tasks; tune when step 3
data lands). The task body is one public
mariadb.reconcile_scheduler_capacity() call — tri-layer,
modelled on delete_stale_transfers: a single new
ReconcileSchedulerCapacity RPC in database.proto
(regenerate with tox -e genprotos), a servicer method in
daemons/database/main.py with the standard counter and
error wrapper, and _direct_reconcile_scheduler_capacity()
doing all the work server-side in set-based SQL.
One pass, in order:
- Expire claims:
UPDATE namespace_claims SET state = 'expired' WHERE state = 'active' AND expires_at < NOW()(the D4 crash backstop; a no-op until phase 4 creates claims). - Refresh node rows: upsert limits per the formulas
above from
node_metrics; delete rows for deleted nodes. - Recompute usage: node
used_*from placed non-dead instances —instance_locationrows inobject_referencesjoined toinstances(cpus, memory) and toobject_statesexcludingdeleted(errored instances keep their placement and their resources until hard-delete, so they count). Disk sums virtual sizes from thedisk_specJSON: preferJSON_TABLE(available from the MIN_MARIADB_VERSION floor of 10.6); if it proves unworkable against real payloads, aggregate per-instance in Python inside the_direct_function — still one RPC, still server-side. Claimused_*recomputes per namespace over the same instance set. - Recompute
expected_demand(D13): per node,SUM(cpus × SCHEDULER_DEMAND_PER_VCPU × (1 - age / SCHEDULER_DEMAND_DECAY_SECONDS))over placements younger than the decay window, using theinstance_locationreference row'screatedtimestamp as placement time. Phase 3's placement-time increment becomes the fast path over this authoritative recompute. - Recompute the singleton:
total_*as sums of node limits,claimed_*as sums of active claim limits (zero for now),unclaimed_used_*as usage by instances in namespaces without an active claim, restricted to nodes that hold a capacity row. The singleton is a closed accounting over the schedulable cluster: an instance stranded on an errored, demoted, stale-metrics or deleted node contributes to neither the total nor the used side, so a drained hypervisor shrinks both together instead of showing usage exceeding capacity on the soak dashboards. Per-claimused_*is the deliberate exception — it stays namespace-wide, because a quota covers a namespace's instances wherever they are stranded.
Two claim-accounting decisions this leaves for phase 4, both
inert while namespace_claims is empty but both fixed by the
semantics being built now:
- Overage is currently invisible.
unclaimed_used_*skips any namespace with an active claim, andclaimed_*sums those claims' limits. A namespace using more than it claimed contributes its overage to neither figure, so a D14 guard shapedclaimed + unclaimed_used + request <= totalwould over-admit by exactly that overage. Phase 4 must either prevent overage at admission, or have the reconciler attribute usage above a claim's limit intounclaimed_used_*so the cluster row stays a complete accounting of the cluster. - Two active claims for one namespace would double-count.
Each active claim row is written the same full namespace
usage, and both limits sum into
claimed_*. The claims API should enforce one active claim per namespace; a unique partial index would make that structural rather than procedural. - Duplicate placements double-count into per-claim
used_*. The stale-placement edge (a lost node'sinstance_locationrow survivingplace_instance()'s best-effort removal, so one instance appears on two nodes) is benign at cluster scope only, because the unclaimed fold is restricted to nodes holding a capacity row. Per-claimused_*is deliberately namespace-wide (see above), so a duplicated placement adds that instance's resources to the claim twice, and a namespace could be denied capacity it is not using onceused_*is a quota. Phase 4 must either de-duplicate by instance uuid in the usage query (one placement row pertarget_uuid) or rely on phase 3 having eliminated stale placement rows. The_RECONCILE_USAGE_SQLcomment block carries the same note.
The reply carries a summary (per-node limits/used/demand, the cluster row, expired-claim and added/removed node counts, and per-counter deltas since the previous values) so the elected node can log and export it without a second RPC.
Because nothing else writes these tables until phase 3, the per-statement writes need no enclosing transaction; the counter deltas the pass observes are ordinary between-pass churn, not drift. The drift-is-a-bug loud-warning path (D5) activates in phase 3, when guarded UPDATEs maintain the counters between passes and the recompute becomes a correction; the plumbing (deltas in the reply) is built now.
New config: SCHEDULER_DEMAND_PER_VCPU (default 2.5, the
00a-1 seed) and SCHEDULER_DEMAND_DECAY_SECONDS (default
600), both marked revisit when step 3 data lands.
Observability (the point of the phase)¶
- Prometheus, from the elected cluster daemon (module-level
metrics in
scheduled_tasks.py, exported on the existingCLUSTER_METRICS_PORTserver): per-node gauges for limit, used and expected demand (labelled by node and resource dimension), cluster-row gauges, a reconcile-duration summary, a pass counter and a last-success timestamp gauge. - Logs: one structured
LOG.infosummary per pass (node count, deltas, expired claims, duration); failures go through the loop's existingignore_exceptionpath so a bad pass never kills maintenance, and CI's log-error checks surface any exception during cluster CI runs. - The tables themselves, queryable on the database node.
D9's events (drawdown, ceiling rejection) are admission events and land with admission (phases 3/7). No REST read surface yet: the admin capacity view migrates in phase 5.
Explicitly out of scope¶
- No admission or placement consumption (phase 3): the
scheduler,
place_instance()and the queue worker are untouched. - No claims API, claim objects or claim events (phase 4).
- No guarded-UPDATE drawdown anywhere; the reconciler is the only writer.
- No REST/API surface and no client changes.
- No new events; observability is prometheus + logs.
Execution¶
| Step | Description | Effort | Model | Isolation | Status |
|---|---|---|---|---|---|
| 1 | Schema: three tables, version constants, ensure_schema() and EXPECTED_SCHEMA_VERSIONS wiring, EXPECTED_TABLE_NAMES drift-guard update, unit tests (creation, versions, idempotent re-run) |
medium | sub-agent | worktree | Complete — 10 unit tests, pre-commit green |
| 2 | Reconcile RPC: proto + genprotos, servicer, tri-layer functions, the five-part recompute (limit formulas and demand decay as pure, unit-testable helpers), summary reply; unit tests incl. scheduler-parity cases for the limit formulas, decay arithmetic, dashed/undashed join shapes, empty-cluster and empty-claims passes | high | sub-agent | worktree | Complete — JSON_TABLE used for the disk sum (validated against real MariaDB 10.6 with the exact query; Python reference kept as _disk_spec_virtual_gb), 38 unit tests |
| 3 | Cluster daemon integration: scheduled task, prometheus metrics, structured pass logging; unit tests where practical | medium | sub-agent | worktree | Complete — scheduler_capacity_* metrics, stale node label sets removed, failure path cannot raise; 4 unit tests |
| 4 | Local validation: run the reconciler against a docker MariaDB seeded with realistic rows (incl. a multi-disk disk_spec and both uuid forms); record results in the Validation section |
medium | management session | none | Complete — 36/36 checks, 42 ms at 32 nodes / 1,205 instances; see Validation |
| 5 | Docs: docs/operator_guide/database.md (three tables), CLAUDE.md MariaDB-storage list, correct CLAUDE.md's stale shakenfist/deploy/ collection paths, ARCHITECTURE/AGENTS if warranted, master plan phase row |
low | sub-agent | worktree | Complete — all four docs updated; verification showed the collection and shakenfist_ci still live in-repo at shakenfist/deploy/, so only CLAUDE.md's directory tree (which drew deploy/ at the repo root) needed correcting |
| 6 | Management-session code review against the checklist | medium | management session | none | Complete — checklist verified 2026-08-02 |
| 7 | Operator review and PR; deploy to sfcbr and confirm gauges/rows during soak | — | operator | — | Complete — merged as PR #3614, 2026-08-08; reconciler soaking cleanly on sfcbr (5-minute passes, no drift) |
| 8 | Address automated review of PR #3614 | medium | management session | none | Complete — see Review response |
| 9 | Address second automated review of PR #3614 | medium | management session | none | Complete — see Second review response |
| 10 | Address third automated review of PR #3614 | medium | management session | none | Complete — see Third review response |
| 11 | Address fourth automated review of PR #3614 | medium | management session | none | Complete — see Fourth review response |
| 12 | Rebase onto develop; address fifth automated review of PR #3614 | medium | management session | none | Complete — see Fifth review response |
| 13 | Rebase again; record the sixth automated review's documentation batch | low | management session | none | Complete — see Sixth review response |
Validation¶
Step 4: docker-MariaDB reconcile validation (2026-08-02)¶
Run against MariaDB 11.8 with the schema created by
ensure_schema(), seeded with four nodes (live, live,
new-with-partial-metrics, deleted), five instances with
deliberately messy disk_spec payloads (string sizes, null
sizes, a non-list document), dashed-uuid placement references
and states, and two claims (one live, one past expiry). All
36 checks passed:
- Limits match the hand-computed scheduler-parity values for both overcommit ratios; the partial-metrics node is skipped without a row and the deleted node gets none.
- Usage counts the errored instance, excludes the deleted
one, and counts a stateless (zombie) instance; the
malformed
disk_speccontributes 0 without aborting the pass;20 + '8'sums to 28 GB. - Expected demand matches the decay formula (4 vCPUs placed 100 s ago at defaults → 8.333).
- The stale claim flips to
expiredand stops counting towardclaimed_*; the live claim'sused_*recomputes to the namespace's actual footprint. - The cluster singleton sums node limits, active claim limits and unclaimed usage correctly; a second pass is idempotent (zero added/expired, zero deltas).
- Duration: 14 ms for the small case, 42 ms at 32 nodes / 1,205 placed instances — far inside the watchdog budget.
Two incidental findings worth recording:
ensure_schema()cannot run on MariaDB 10.6: the pre-existingipam_reservations.address INET4column requires 10.10+, so the documentedMIN_MARIADB_VERSION = (10, 6, 0)floor is already unsatisfiable for fresh installs — not a phase 2 issue, but the constant is stale. JSON_TABLE itself was separately validated on a real 10.6 during step 2 with the exact reconcile query text. Fixed on this branch: the floor is now(10, 11, 0)— the oldest in-support LTS above INET4's 10.10 requirement, and the version cluster CI actually exercises. sfcbr runsmariadb:11.8and is unaffected.- Claim expiry must be written server-relative. The
first validation run seeded
expires_atfrom the client's local clock and the expiry sweep correctly did nothing — the timestamp was hours in the future in the server's timezone. Phase 4's claim create/update must always write expiry asNOW() + INTERVAL, thecluster_locksidiom, never a client-computed datetime.
Step 8: response to the automated review of PR #3614 (2026-08-03)¶
Ten items were raised. One was a real bug, two were documentation corrections, five were adopted improvements, one was investigated and rejected on measurement, and one was informational.
Fixed — capacity rows were being created per node, not per
hypervisor. The reconciler keyed candidate rows off the
presence of a node_metrics row, but sf-resources runs on
every node and upserts unconditionally, so network-only and
database-only nodes were getting capacity rows and their
unschedulable capacity was being summed into
cluster_capacity.total_*. Inert this release, but it would
have made the soak dashboards — the entire point of the phase
— report wrong totals, and become an admission bug in phase 3.
Fixed by projecting is_hypervisor into node_metrics
(v3 → v4) and filtering in SQL; see the limit-derivation
section above for the NULL-during-upgrade handling. Four unit
tests plus a re-run of the step 4 validation with a
non-hypervisor node carrying good capacity columns and a
pre-existing capacity row (it is deleted, which is the upgrade
path).
Rejected on measurement — restricting the disk JSON_TABLE
derived table to the placement set. The review's premise was
that the derived table expands every instance row, including
the deleted-but-not-hard-deleted backlog, before the outer
join discards them. MariaDB does not do that: EXPLAIN shows
it already resolves the derived table as LATERAL DERIVED
correlated on i.uuid, so it only expands the instances the
outer query joins to. Measured with 73,640 instance rows of
which 1,205 were placed, adding the suggested
i2.uuid IN (SELECT ... FROM object_references) restriction
turned the plan into a materialised DERIVED with a semi-join
and made the query slower (10 ms → 13 ms) for identical
results. A reconcile pass over that database took 44 ms, the
same as with no backlog at all. The comment above the query now
records the measurement so the change is not re-proposed.
Adopted:
- The reconcile RPC now uses
BOUNDED_QUERY_TIMEOUTwith one retry rather than the default 3 × 30 s budget. Its caller is the elected loop that pets the systemd watchdog itself, and its server side is one analytical query — the exact shape that SIGABRTed non-database daemons in issue 3586. A skipped pass is harmless because the next one recomputes everything. That justification is release-scoped: once phase 3's guarded UPDATEs make the recompute a drift correction, a reconciler persistently exceeding its deadline means drift accumulates indefinitely with only a counter and a frozen last-success gauge as signal. Phase 3 must pair enabling the counter guard with a persistent-failure decision — either raise the reconciler's budget aboveBOUNDED_QUERY_TIMEOUT(it is not on a watchdog-critical path, since_run_due_scheduled_jobspets before each job), or treat consecutive failures as a condition that disables the guard rather than admitting against stale counters. SCHEDULER_CAPACITY_LAST_DURATIONis now set before the failure return, so a slow-then-failing pass reports its own duration instead of the last successful one.- The task is decorated with
@util_general.recorded_methodlike its five-minute siblings. - The capacity gauges are cleared when the cluster daemon
leaves the elected loop. They describe singleton cluster
state, so a demoted node that kept publishing would
contradict its successor.
docs/operator_guide/database.mdnow tells operators to alert on the last-success timestamp going stale rather than on a gauge disappearing. - Round-trip test coverage of the servicer's reply
construction and the client's field-by-field unpacking, so a
proto/dict key rename fails in CI rather than on a live
cluster, plus the
result is Noneand unexpected-exception branches.
Documentation corrections: the used_cpus parity claim
(above) and the missing SCHEDULER_DEMAND_* entries in
docs/operator_guide/scheduler.md.
Found while fixing the gauge staleness, outside the review's
scope: the elected loop registered its schedule.every()
jobs on every election without clearing them first, and
schedule keeps a module-global job list — so a node elected
twice ran every maintenance task twice per cadence, three
times after a third election, and so on. Fixed with a
schedule.clear() before registration. Pre-existing, not
introduced by this phase, but it would have doubled up the
reconcile pass this phase adds.
Step 9: response to the second automated review (2026-08-04)¶
Nine items. One real bug of the same class as the first round's, one stale documentation reference, and seven improvements — all adopted this time.
Fixed — capacity rows for nodes the scheduler would never
consider. The only node-state exclusion was deleted, but
scheduler.py builds its candidates from
Nodes([], prefilter='active'), so errored, missing, stopping
and stopped nodes are not placement candidates either. A
hypervisor taken out of service by the node-health cascade
kept its row and its full limits stayed in
cluster_capacity.total_*. Exactly the defect the first
review found with is_hypervisor, in a second dimension — so
the fix generalises rather than patches: the filter is now
NOT IN the active set, and the active set moved to
constants.NODE_ACTIVE_STATES with Node.ACTIVE_STATES
defined from it, so the reconciler can read it in SQL without
importing shakenfist.node and the two definitions cannot
drift. See the limit-derivation section for all three filters.
Also fixed — the same class again, one dimension further
out. Review item 3 noted node_metrics rows are only
deleted when the node is, so a hypervisor whose resources
daemon has died keeps contributing its last-known limits
indefinitely while the node itself still looks healthy. The
review judged this largely subsumed by the state filter, but
it is not: a node whose resources daemon dies while its
sentinels keep reporting stays in an active state. Metrics
older than RECONCILE_METRICS_MAX_AGE_SECONDS (15 minutes)
are now ignored, with the window chosen well above the
five-minute cadence for the flapping reason the review itself
gave.
Adopted:
- README and
docs/operator_guide/installation.mdboth advertised MariaDB 10.6.0+. The review found the README one; the installation guide was a second stale reference it missed. - The
_derive_cpu_memory_limitsdocstring claimedlimit_memory_mbmirrors_has_sufficient_ram. It does not: it blends that check's overcommit ceiling with the reservation term from the separate free-memory check. Reworded here, in the docstring and in AGENTS.md. - Both
object_referencesqueries now constrainsource_object_typeandtarget_object_typeas well asrelationship. Defensive only — nothing writes anINSTANCE_LOCATIONrow with other endpoint types — but the table's indexes lead with those columns so it is free. - The servicer checks
context.is_active()before starting a pass, so a caller that has already hit its deadline does not get work done on its behalf. The docstring records why overlapping passes are benign today (sole writer, idempotent recompute) and that phase 3 ends that property. _disk_spec_virtual_gb's docstring no longer describes the JSON_TABLE fallback decision as open; it is now stated to be the executable specification, and a live test asserts it agrees with the SQL on the same payloads, making it an oracle rather than prose.- Schedule registration is hoisted out of the election loop
entirely, which was the review's preferred fix over the
schedule.clear()added in step 8.schedule.every()computes each job's next run at registration, so re-registering on every election restarted every period from zero — on a cluster whose maintenance lock changes hands more often than daily,prune_eventsmight never have run. Registering once at daemon start fixes the duplication and the timer reset together, andrun_pending()is still only called while elected.
New CI coverage (review item 7). Every reconcile test ran
against a mocked connection and asserted on compiled SQL text,
which cannot catch the failure modes that matter: the
JSON_TABLE aggregation, the dashed/undashed uuid joins, the
nullable-BOOL and NOT-IN filters, and the two upserts. All of
those fail as silently wrong numbers rather than errors —
pitfall 6 in CLAUDE.md exists precisely because a mismatched
uuid comparison matches nothing instead of raising. Step 4's
docker validation covered them once, by hand, and would not
catch a regression six months from now.
shakenfist/tests/test_mariadb_capacity_reconcile_live.py now
runs the real SQL against a real MariaDB with the step 4
fixture (11 tests), driven by
tools/ci-capacity-reconcile-test.sh from a new "Scheduler
capacity reconciler" job gated by can_merge, following the
existing schema_enum_widening pattern. Verified as a real
guard rather than a passing formality: removing the
REPLACE() transform from the usage join fails 8 of the 11.
Step 10: response to the third automated review (2026-08-04)¶
Ten items, all adopted.
Fixed — the phantom capacity row (item 4), which is the fourth
instance of one pattern. Existence in the nodes table gated
removal but not creation, so a node_metrics row that outlived its
node's static and state rows read as a fresh hypervisor: it got a
capacity row, then appeared in both previous and metrics_rows on
every later pass, so no removal condition ever fired and its limits sat
in the cluster totals permanently. candidates is now intersected
with known_nodes and the removal condition is unconditional.
That is now four rounds of the same defect — role, state, freshness, existence — each one a filter the scheduler applies that the reconciler did not. The generalisation for phase 3 is that the rule is "a capacity row exists only where the scheduler could place", and any new scheduler-side filter is automatically a reconciler-side filter too. A live test seeds an orphaned metrics row and asserts no row is created on the first pass or any later one; reverting the fix fails it.
Fixed — the alerting advice was self-defeating (item 1).
docs/operator_guide/database.md told operators to alert on
scheduler_capacity_reconcile_last_success_timestamp going stale, but
that gauge is deliberately not cleared on demotion (it records this
node's own last pass, which is useful for debugging, and being
unlabelled it cannot be removed the way a label set can). A
per-instance staleness alert would therefore fire forever on every
node that has ever held the lock. The docs now give the cluster-scoped
expression, time() - max(...) > 900, and explain why the
aggregation is load-bearing rather than stylistic.
Adopted:
- Watchdog pets between scheduled jobs, not just around the batch
(item 2). Hoisting registration in step 9 fixed the timer reset but
guaranteed that a node elected after hours of idling finds all nine
jobs due at once, including the three heaviest, inside a single
un-petted
run_pending()withWatchdogSecat 60s._run_due_scheduled_jobs()mirrorsrun_pending()'s ordering and due-check while petting before each job, bounding the exposure to one job instead of nine. DatabaseUnavailableis caught in_grpc_reconcile_scheduler_capacityand logged at WARNING (item 3). The bounded budget adopted in step 8 makes exhausted retries the expected outcome of a loaded or restarting database tier, but it escaped theexcept grpc.RpcErrorand landed in the scheduled task'signore_exception()— an ERROR with a traceback and a recorded-exception file every five minutes, for a condition the design calls harmless, against a phase whose stated functional gate is cluster CI's log-error checks._node_metric_to_bool('')now raises rather than returning False (item 10), so an empty string extracts as NULL and routes into the same no-evidence handling as a missing key. Unreachable today, but the falsey list had quietly made "no value" mean "confirmed non-hypervisor", which deletes a capacity row.- The mock router now reports the number of uuids the DELETE actually
names rather than a hardcoded 2 (item 7), so three
nodes_removedassertions test the reconciler rather than the fixture. They now assert 1, which is what their single-row fixtures can actually produce. - Both live-test CI scripts assert that tests actually ran (item 8).
Every test in those modules is
@unittest.skipUnless(DSN), so a broken export turned the job into a green tick over zero tests — the guard silently not guarding, which is precisely the failure mode these jobs exist to prevent. Fixed inci-enum-widening-test.shas well as the new script, since it has the identical shape and the same hole. Verified by running with the DSN unset: 12 skipped, and the guard fails the script. - Documented: that the scheduler mirroring covers the arithmetic but
not
_schedulable_threads()/_memory_reserved_mb()'s pre-upgrade fallbacks, so mid-upgrade a node can be schedulable with no capacity row (item 5); and that the metrics-freshness comparison spans hosts, making severe clock skew a fifth way to lose a capacity row (item 6), which is forced bynode_metrics.timestampbeing a client-written float rather than a serverTIMESTAMP.
Recorded for phase 4 (item 9). Node capacity rows are
intentionally reaped by the reconciler rather than by
Node.hard_delete(): this phase's invariant is that the reconciler is
the sole writer of these tables, and a second writer would contradict
it for at most five minutes of staleness. That trade stops being
obviously right once namespace_claims holds real rows, so when the
claims API lands, Namespace.hard_delete() should clean up its
claims — a namespace's claim outliving the namespace is a leak, not a
staleness window. Do not read the current omission as an oversight.
Step 11: response to the fourth automated review (2026-08-05)¶
Three action items and three optional suggestions. From this round on, suggestions are explicitly triaged — accepted, deferred with an owner, or declined with a reason recorded here — rather than implicitly all-adopted, so a suggestion that resurfaces in a later round can be answered by pointing at the record instead of by re-litigating it.
Fixed — a stateless node got a capacity row (item 1), the fifth
instance of the recurring defect. The state filter was expressed
negatively (subtract nodes whose state is NOT IN the active set), so
a node with a nodes row but no object_states row appeared in
neither set and slipped through, while prefilter='active' makes it
invisible to the scheduler. The previous round predicted the fifth
instance would be _has_reasonable_queue_state; it was instead the
existing state filter's polarity — the defect class is about filter
semantics, not just filter inventory. The filter is now positive
(state_value IN the active set, candidates intersected against the
result), which excludes unknown states and missing state rows by the
same mechanism the scheduler does. The design section's State bullet
records the polarity rule; mock and live tests cover the stateless
case, and reverting the intersection fails the live test.
Fixed — markdown code span split across a newline (item 4) in
database.md's clock-skew paragraph.
Documented — the usage ledger cannot see legacy placements
(item 3). During the one-release node_attributes.instances
dual-write transition, a placement written by a pre-cutover node
exists only in the legacy JSON column, which the reconciler does not
read, so mid-rolling-upgrade used_* under-counts. Recorded in the
_RECONCILE_USAGE_SQL comment block and AGENTS.md: phase 3 must not
enable the counter guard until the legacy column and its union are
removed.
Suggestions triaged — all three accepted, because each was cheapest now:
- Cluster singleton accounting (item 2): implemented, not just
documented.
unclaimed_used_*was folded from the unfiltered usage query, so an instance stranded on an errored, demoted, stale or deleted node inflated the used side of a total its node contributed nothing to — the soak dashboards could show usage exceeding capacity, and phase 3 would have inherited the open semantics silently. The fold is now restricted to nodes holding a capacity row (see the recompute step 5 above); per-claimused_*deliberately stays namespace-wide, andtest_claim_usage_is_namespace_widepins the asymmetry as a decision. The related stale-placement edge (a lost node'sinstance_locationrow survivingplace_instance()'s best-effort removal, double-counting the instance at node scope) is out of the reconciler's scope and now benign at cluster scope; it belongs to phase 3's placement work. - BIGINT counters (item 5): adopted. The proto fields are int64
and the tables are new in this release, so widening is free now and
a migration later;
cluster_capacity.total_memory_mbas INT would overflow around 700 TB of overcommitted RAM with an every-pass-fails-at-WARNING failure mode. - CancelJob (item 6): honoured.
_run_due_scheduled_jobs()now mirrorsScheduler._run_job()'s cancel handling, making its "semantics are unchanged" docstring true rather than amending the docstring to document a divergence. The raising-job behaviour (propagate, skip the rest of the batch, stay due) is pinned by a test as well.
Test-coverage suggestions triaged:
- Stateless-node live test: added (required by item 1).
- Cluster-asymmetry and namespace-wide-claim tests: added (item 2).
- Raising-job behaviour test: added (item 6).
- Registration-happens-once test: declined. Registration is
inline at the top of
_run_inner(), which never returns; asserting on it would require extracting a registration helper purely for the test's benefit, and_run_inneris only called once per process by construction (Daemon.run). The behaviour is documented in the registration comment instead. - Namespace-over-claim-limit live test: deferred to phase 4. The overage gap is already recorded as a phase 4 obligation in the claim-accounting decisions above; a test asserting today's incomplete accounting would have to be rewritten by the same change that closes the gap, and nothing consumes the numbers until then.
Step 12: rebase and the fifth automated review (2026-08-07)¶
The branch was rebased onto develop (which had gained the federation abuse-resistance work and a generalised live-MariaDB CI job). Two rebase reconciliations worth recording:
- develop added
reap_federation_recordsto the old in-election-loop schedule registration; it now registers in the hoisted once-per-daemon block with the others. - develop generalised
tools/ci-enum-widening-test.shto run everytest_mariadb_*_livemodule behind one MariaDB install, precisely so a later live module needs no new job. That made this branch's dedicated "Scheduler capacity reconciler" job an exact duplicate (same runner class, same MariaDB install, same tests twice per merge), so the job andtools/ci-capacity-reconcile-test.share removed;test_mariadb_capacity_reconcile_live.pyruns in the generalised job, which also carries the no-tests-actually-ran guard this branch added in round 3.
The review itself: two fix items (both genuinely latent bugs — the round-over-round trend is 3 → 2 and nothing this round was fallout from earlier fixes), three considers (all accepted, each cheapest now), two informational.
Fixed — enum bindings leaned on a case-insensitive collation
(item 1). _reconcile_fetch_usage() bound enum member names for
all four predicates, but only object_states.object_type (a native
sa.Enum) stores names; the object_references type and relationship
columns are plain strings written by _direct_record_relationship()
as str(member), which for these str-subclass enums is the value.
The two spellings differ only in case, so the query matched under the
default utf8mb4 collations and would silently produce zero usage under
utf8mb4_bin or any _cs collation — all of which
verify_mariadb_compat() accepts, and the same pass's demand query
already bound values, so usage and demand would have diverged
silently. The bindings now name each column's convention explicitly
(with a fourth parameter, since the object_states join and the
object_references predicate can no longer share one), and the live
suite now runs its tables under utf8mb4_bin so this whole class
fails loudly: mutating one binding back to .name fails 9 of the 15
live tests. A unit test also pins str(member) == member.value, the
property the write path and the bindings both depend on.
Fixed — a node with no node_metrics row at all kept its capacity
row forever (item 2). The sixth instance of the recurring class and
the freshness twin of round four's state-filter bug: the filter
subtracted a stale set, and a node with no metrics row was in
neither the fresh nor the stale set. Reachable because sf-resources
deletes its own node's rows at daemon startup before the first
upsert. The filter is now membership in a fresh set (derived without
the is_hypervisor predicate, so the NULL-flag mid-upgrade retention
is preserved), and the design invariant gains a third clause: filters
are expressed as membership in a positive qualifying set, never as
absence from a disqualifying one. Mock and live tests cover the
no-row case; disabling the freshness removal fails the live test.
Suggestions triaged — all three accepted:
- Per-claim recompute loop (item 3): a
# phase 4:comment now marks the one-UPDATE-per-claim loop as a shape to replace with a set-basedUPDATE ... JOINwhen claims become real, not to inherit. - Disk-size oracle rounding (item 4): probed MariaDB's
JSON-number-to-BIGINT cast on a real server — it rounds half away
from zero (10.5 → 11, -2.5 → -3), including numeric strings
('8.7' → 9), where the helper truncated integers and zeroed
fractional strings.
_disk_spec_virtual_gb()now matches viadecimal.ROUND_HALF_UP(Python'sround()is banker's and would itself diverge at .5), the messy fixture gained fractional sizes so the live oracle assertion exercises the divergence, and unit tests pin the probed values. Booleans (JSON true → 1) and JSON null (→ SQL NULL, ignored by SUM) were probed too and already agreed. - Negative
limit_memory_mb(item 5): clamped at zero, matching_derive_disk_limit_gb()'s headroom clamp, with the docstring explaining why zero is the right encoding of "admits nothing".
Also repaired in passing: a rebase-conflict resolution error left
a stray conflict marker in protos/database.proto in the rebased
history, which made grpc_tools.protoc fail — and _make_stubs.sh
has no set -e, so its sed import-fixing loops ran anyway and
stacked from shakenfist.protos prefixes onto the stale generated
files. The proto is fixed and the stubs regenerated at the head;
intermediate rebased commits retain the broken generated files, which
matters to git bisect but not to the PR diff or CI.
Step 13: the sixth automated review (2026-08-08)¶
The sixth review raised zero fix items — the landing round under the re-review exit rule. One documentation item, six considers (five taken, one already-covered), two informational, and six "what's good" entries including an endorsement of the positive-membership clause as "the clause that actually kills the class".
Documented — duplicate placements double-count into per-claim
used_* (item 1). Round four's "benign" verdict on the
stale-placement edge was scoped to cluster accounting without saying
so; per-claim used_* is deliberately namespace-wide and therefore
exactly the counter the closed-accounting fix does not protect. Now
recorded as the third phase-4 claim-accounting bullet above and
mirrored in the _RECONCILE_USAGE_SQL comment block.
Considers taken (items 2, 3, 5, 6, 7): baseobject.py now imports
NODE_ACTIVE_STATES instead of carrying the third hardcoded copy of
the set (and its comment no longer claims errored nodes are included
when they were not — a pre-existing comment/code contradiction);
node.py's comment stops instructing maintainers to hand-sync a copy
that no longer exists; the schema tests assert sa.BigInteger rather
than sa.Integer (which BigInteger satisfies, leaving round four's
deliberate widening unpinned); _reconcile_fetch_demand's docstring
records that its window comparison spans hosts like the metrics
freshness check, with the negative-age clamp as mitigation; and the
live test module docstring states the --serial requirement the
collation flip depends on.
Consider recorded rather than coded (item 8): the
BOUNDED_QUERY_TIMEOUT justification is release-scoped; the adopted
bullet above now carries the phase-3 pairing requirement (raise the
reconciler budget or disable the guard on consecutive failures).
No action (items 4, 9): the MariaDB 10.11 floor bump was verified
correct and fully documented by the reviewer; the lazy should_run
evaluation in _run_due_scheduled_jobs was judged benign and
arguably better (a job falling due mid-batch runs in the same batch).
No further re-review was requested: the round contained no fix items, and the changes above are comments, docstrings, plan text and one test-assertion tightening.
Success criteria¶
- After
sf-ctl ensure-mariadb-schemaand a deploy, the first reconcile pass populates onescheduler_node_capacityrow per hypervisor with limits matching a hand check of that node'snode_metricsvalues, andcluster_capacitysums them; a manual sfcbr spot-check confirmsused_*matches the actually-placed instances. - Prometheus gauges are visible from the elected cluster node and move when instances are created and deleted.
sf-databaserefuses to start against a schema missing the three tables;ensure-mariadb-schemais idempotent.- An exception inside the reconciler is logged and skipped without breaking the maintenance loop, and cluster CI runs clean (its log-error checks are the functional gate for this phase; a REST-visible functional test waits for the phase 5 read surface).
- Zero behaviour change anywhere else:
toxand cluster CI green, scheduler and placement code untouched. - mypy coverage of touched code does not regress.
Review checklist (management session, step 6)¶
- Every dashed/undashed uuid join uses the index-friendly transform (pitfall 6); no join compares the two forms directly.
- Limit formulas match
scheduler.pyarithmetic; deviations (allocation-ledger memory, virtual-size disk) are exactly the documented ones. - The reconciler cannot raise out of the scheduled task,
and one malformed row (bad
disk_spec, NULL metrics) cannot abort the whole pass. - Reconcile is a single RPC; its duration and reply size are bounded and sane at sfcbr scale (watchdog budget, gRPC message limits).
- NULL-columned
node_metricsrows (old resources daemon) do not zero existing limits. - Nothing outside the reconciler and its tests reads or writes the new tables.
-
in_memory_onlyobjects cannot reach the new tables (the reconciler only reads static tables and writes its own). - New config options registered with descriptions and flagged provisional pending step 3 data.
- The plan file rides in the PR branch; master plan row updated.
Back brief¶
Before executing any step of this plan, back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.