Scheduler reservations phase 6: affinity model and the 3565 disposition¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the codebase thoroughly. Read the scheduler's
candidate pipeline end to end (shakenfist/scheduler.py,
find_candidates() especially), the functional affinity test,
and the audit events the scheduler publishes -- those events are
the primary evidence for everything this phase decides, and they
are readable from a live cluster. Ground answers in what the code
does today rather than in what the plan documents say it does:
three of this phase's inputs turned out to be stale, and two of
them were disproved by their own quoted numbers.
Do not treat the 507 ... sufficient_idle_cpu CI failures as
this phase's evidence. They come from the admission stage and
belong to PLAN-ci-cloud-sizing. See F6.
Plan file conventions (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/plan-file-conventions.md):
- All planning documents live in
docs/plans/. - Detailed planning gets one plan file per phase. Phase files are
named for their master plan, sit in the same directory as it,
and append
-phase-NN-descriptivebefore the.mdextension. - The master plan tracks its phases in a table under its Execution section:
| Phase | Plan | Status |
|---|---|---|
| 1. Schema migration | PLAN-thing-phase-01-schema.md | Not started |
| 2. Public API | PLAN-thing-phase-02-api.md | Not started |
- One commit per logical change, and at minimum one commit per phase. Unrelated changes are not batched into a single commit. Each commit is self-contained: it builds, passes tests, and has a message explaining what changed and why.
Planning effort¶
Planned at high effort, with a high-effort review expected on the model change (steps 3 and 4). The survey did most of the work and found that the phase's central question had already been answered by a traced CI run nobody had folded back into the plan documents. What remains is a judgement about what a soft guarantee is allowed to promise, which is cheap to decide here and expensive to relitigate once an API has shipped it.
Situation¶
Phase 6 has carried the same shape since the plan was cut in May:
adopt a binary affinity model, deprecate arbitrary numeric
weights, and close issue #3565, the test_affinity flake whose
title is "soft affinity loses to resource filters under suite
concurrency".
Two corrections have accumulated on it since. The 2026-08-16 correction to decision D6 established that the ranking precedence D6 asked for had already landed (PR 3722), that #3565 had recurred five times since, and that closing it therefore needed a decision the plan had not taken: may a soft affinity preference bid against a hard admission ceiling? Three positions were put up for this phase to choose between. The 2026-08-19 correction, restated on 2026-08-22, added a competing explanation -- issue
3813's demand guard destroying the scheduler's ability to spread¶
a burst -- and instructed this phase to rule it out before spending its decision budget.
Both corrections are honest and both are now overtaken. Phase 4a fixed #3813 on 2026-08-24. On 2026-08-26 a fully traced occurrence of #3565 was posted to the issue, and it shows a third mechanism which is neither of the two the plan documents argue about. That trace is the single most important input to this phase and it is summarised in finding 3 below.
Mission and problem statement¶
Decide what soft affinity promises, make test_affinity assert
that promise rather than a stronger one the product does not
make, and build the binary affinity model D6 asked for and nobody
has yet written.
Separate the two things phase 6 has always had tangled: the affinity model is a design change with an API surface, and
3565 is a test asserting a guarantee that does not exist. They¶
share a subject and nothing else.
Scope¶
In scope:
- Binary affinity:
require_with_tag/require_without_tagas filters,prefer_with_tag/prefer_without_tagas a +/-1 ranking term. - A mechanical mapping from the existing weighted form, for one transition release.
- The disposition of #3565, and the
test_affinityrewrite that implements it. - The audit-event surface those assertions read, if it needs widening.
- Documentation of what soft affinity does and does not promise.
Out of scope:
- The
507 ... sufficient_idle_cpurefusal family (#3772). Admission, not ranking.PLAN-ci-cloud-sizingowns it. F6. - The CI topology. It lives in
shakenfist/actionsand is demonstrably undersized; D6 said no scheduler change will maketest_affinityreliable while it stands, and that is still true.PLAN-ci-cloud-sizingowns it. - Softening the admission ceiling. Declined rather than deferred; see F7.
- The forced-candidate retry defect found by the survey. Recorded as an issue, see finding 6 and F8.
What the survey found (2026-08-29)¶
Surveyed against shakenfist 5af155827. Five of the eight
findings contradict something a plan document currently asserts.
1. The binary affinity model does not exist, at all.
require_with_tag, require_without_tag, prefer_with_tag and
prefer_without_tag appear nowhere in the codebase -- not in
the scheduler, the API, the schema or the client. Affinity today
is inst.affinity, a dict of tag to number, summed per candidate
into a score (scheduler.py:529-600): for each instance already
on the node, each requested tag it carries contributes its
requested value. So D6's headline deliverable is entirely
unbuilt, and this phase is a first implementation rather than a
migration. The plan's phase stub is accurate about this; it is
recorded because the volume of correction elsewhere in this
document might otherwise suggest the whole stub is stale.
2. D6's line references have all moved. D6 cites
scheduler.py:473-481 for the CPU admission filter,
scheduler.py:611-631 for the ranking precedence PR 3722
landed, and scheduler.py:260 for the hard ceiling. The current
locations are :489-502, :637-658 and :187-256. The claims
those references support are still true -- admission does run
before affinity scoring, and hard_max_cpus is still absolute
-- but anyone checking D6 against the tree will bounce off the
line numbers first.
Two of those three were got wrong in an earlier draft of this
finding, which is worth recording in a finding that exists to
complain about stale references. :487 is the tail of the
preceding cpu_max_per_instance call, not the start of the
idle-CPU block; and :611-631 is D6's citation for the
ranking precedence -- the load-shed-after-affinity ordering,
now the narrowed block at :637-658 -- not for affinity
scoring, which is a different block at :529-600. The second
mistake is the instructive one: the range had drifted and the
thing it pointed at was not what the replacement named, so
re-deriving a line number from what the citing text says, rather
than from the nearest plausible block, is the actual check.
3. #3565's traced mechanism is neither of the two the plan argues about. This is the finding that reshapes the phase.
A fully traced occurrence was posted to the issue on 2026-08-26,
after phase 4a fixed #3813 on 2026-08-24, so it is evidence from
a cluster where the spreader works. In it the candidate set had
already collapsed to one node by the time affinity was
scored. inst3, which requested anti-affinity ({'first-node':
-100}), scored that node -100 -- correctly, having found and
matched inst1's tag -- and placed there anyway, because a scorer
given one candidate has nothing to do. The issue's own summary is
exact:
the mechanism is slightly stronger than "soft affinity loses to resource filters under load": here it did not lose a tiebreak, it was never consulted in any meaningful sense.
The other half matters just as much. inst2 landing on inst1's node -- the half of the assertion that passed -- was the same forced choice. The test's pass and its failure had the identical cause, so the run tells us nothing about affinity in either direction.
That rules out the competing explanation the 2026-08-19 correction asked this phase to eliminate: it is not lost spreading, and it is not affinity losing a tiebreak to load ordering. It is the candidate set emptying to one before ranking begins.
4. The two CI topologies fail differently, and the difference
is diagnostic. slim-primary produces the wrong-node signature
(#3565 proper -- instances that should share a node do not).
slim-tier produces total refusal (507 ...
sufficient_idle_cpu, #3772), where the create never happens.
Sampling merge-queue runs on 2026-08-28 and 2026-08-29 shows both
live: in run 33208761280, Debian 12 cluster passed
test_affinity while Debian 12 tier failed it with
schedule at stage sufficient_idle_cpu; run 33226077736 shows
the tier passing it while the same stage refuses other creates.
PLAN-ci-cloud-sizing reaches the same split independently and
calls #3772 an umbrella hiding two distinct causes.
Two consequences. A green test_affinity on one topology is not
evidence the scheduler is right, and a red one on the other is
not evidence it is wrong. And while the tier can produce total
refusals, no measurement campaign run in CI can characterise
3565, because the failure that reaches the assertion is not the¶
failure under study.
5. The binding stage has moved from CPU to memory.
PLAN-ci-cloud-sizing records, from a 2026-08-26 comment on
3565, that the most recent fully traced occurrence has the¶
affinity target surviving sufficient_idle_cpu and being dropped
at sufficient_idle_memory. D6's analysis is written entirely
around the CPU stage. The mechanism is unchanged -- an admission
filter empties the candidate set before ranking -- but any fix
aimed specifically at CPU admission would now miss.
6. A retry against an already-refused node, whose site the tree does not confirm. From the same traced run: both instances were first refused by the capacity guard on the single surviving candidate, and the retry went to that same node. An earlier draft of this finding described that as "re-forcing the candidate set rather than reopening it", and that description does not survive a reading of the code; the correction is recorded here rather than quietly dropped, because the finding is the input to an issue somebody else will have to act on.
Neither retry in the tree behaves that way. The create path's
second walk does re-walk the same list, but only when at least
one candidate was refused on demand alone, and it waives the
demand guard when it does (external_api/instance.py:924-940)
-- so retrying the same node is the designed behaviour, it can
succeed, and the first refusal did buy something. The preflight
path does the opposite: its redirect rebuilds the list from every
node except the current one
(operations/node_inst_netdesc_op.py:172-180). The one site that
forces a node an earlier pass has already chosen is preflight's
opening call, find_candidates(inst,
candidates=[config.NODE_UUID]) (:156), which runs on the node
the create path just placed on.
That is a candidate mechanism, not a confirmed one. This is the only finding here whose evidence is an issue comment rather than the tree, and the trace has not been re-read against those three sites -- step 2 must do that before filing. Out of scope for this phase either way, per F8.
7. test_affinity is itself a user of the deprecated weighted
form. It requests {'first-node': 100} and {'first-node':
-100} (cluster_ci_tests/test_scheduler.py:80,99). D6 asked
whether anything beyond the CI suite uses numeric weights and got
no answer; the CI suite is unambiguously a user, so the
transition mapping has a test exercising it from day one, and the
weighted form cannot be removed in the same change that adds the
binary one.
8. The audit events already carry what the test needs. The
2026-08-26 trace makes this point explicitly -- the events
"contain enough to distinguish 'the scheduler got affinity wrong'
from 'the scheduler had one candidate'". schedule final
candidates publishes the ordered list, schedule have highest
affinity publishes the per-candidate scores and the winning
tier, and each filter stage publishes its dropped map. The test
already fetches exactly these: _add_scheduler_detail()
(test_scheduler.py:19-35) pulls every event whose message
starts with schedule and attaches it. So the audit surface
needs no widening for the rewrite, which makes F2 much cheaper
than it looked.
Corrections made at source¶
As part of the planning commit:
- The master plan's phase 6 stub gains the traced mechanism and a dated note marking the 2026-08-19/08-22 correction's instruction to rule out the lost-spreading mechanism as discharged. The superseded instruction is kept, not deleted, for the same reason D6's three positions are kept: a plan document should show what was believed at the time. An earlier draft of this bullet said the stub "loses" the instruction, and the definition-of-done bullet said no document still carries it -- neither described the tree, and a criterion that is already false at merge cannot be used as a check later.
- D6's stage note is added: a dated note records that the stage
which binds is now memory rather than CPU. Two of D6's line
references are updated with it -- the CPU admission filter and
the hard ceiling -- and, per finding 2, so is the
:611-631citation for PR 3722's ranking precedence.
Not corrected here, deliberately: D6's three positions stay as written. F2 and F7 dispose of them, and a decision record should show what was on offer.
Decisions¶
F1. The phase is two independent pieces, and they ship separately. The affinity model (findings 1 and 7) is a design change with an API surface and a deprecation window. #3565 (finding 3) is a test asserting a guarantee the product does not make. They have been tangled since the plan was cut because they share a subject, but neither blocks the other: the test rewrite is correct against today's weighted model and stays correct against the binary one, and the model change is worth making whether or not the test ever flaked. Steps 1 and 2 are the test; steps 4 to 6 are the model. Either can land first. Step 3 -- the validator 500 -- belongs to neither and is gated by neither; it is a one-clause fix to a live fault, split out of the model so it does not wait behind an API surface and a back-brief gate. Gated by neither is meant literally, so step 3 files its own issue and closes it: routing that through step 2's triage pass would have made a live public-API 500 wait on step 1 merging, which is a cluster-CI rewrite that has to be driven against sfcbr by hand first. An earlier draft did exactly that while this paragraph claimed otherwise.
F2. #3565 is closed by the issue's candidate fix 2 -- assert from the audit events -- and not by any of D6's three positions. This is the decision most likely to be argued with, because it declines to change the scheduler in response to a scheduler bug report.
The argument is finding 3. In the traced run the candidate set was one node. Ask what each of D6's positions would have done there. Position 1 (hard require only) turns the create into a 507, which the issue itself identifies as "the same ejection with a different traceback" -- a placement flake traded for a #3772 refusal. Position 2 (soften the ceiling above a threshold affinity score) has nothing to soften: the affinity target was the surviving candidate and the instance was placed on it. Position 3 (accept that co-location is not guaranteed under concurrency, and change what the test asserts) is candidate fix 2 under another name, and is the one the evidence supports.
So the test asserts what soft affinity actually promises: that the scheduler scored the affine node highest among the candidates it had, or legitimately had no choice. It stops asserting an outcome -- final co-location -- that no documentation claims and the code has never guaranteed.
The honest framing is that #3565 was mostly a specification bug. The product never promised co-location under contention; the test assumed it did, and every investigation since has been looking for the scheduler defect behind an assertion that was too strong.
F3. The test skips, rather than passes, on a degenerate candidate set -- measured at affinity-scoring time. If the scheduler had one candidate, the run carries no information about affinity and must not be counted as evidence that affinity works.
Where the count comes from matters, and two obvious answers are both wrong. Each was proposed in turn and each would have skipped the test on every healthy run. Recorded because the failure is silent -- a skip is green -- and because the second survived a round of review.
schedule final candidates is published at the end of the
pipeline, after candidates = narrowed (scheduler.py:658)
has reduced the list to the winning affinity tier. On a healthy
cluster where inst2's affinity works exactly as intended that
tier is the single node carrying the tag, so the final list
holds one candidate. That is exact for the inst2 assertion and
only for it: inst3 requests {'first-node': -100}, whose winning
tier is every node not carrying the tag -- two of the three the
test requires. A source that is right for one of the two
assertions and wrong for the other is unusable, which is why the
event is ruled out rather than qualified.
schedule have highest affinity fixes that and then fails the
same way through a different door, because it is published once
per find_candidates() call and that function runs more than
once per instance on the ordinary happy path. The create path
calls it unforced (external_api/instance.py:867); the
preflight operation then calls it again against the assigned
node alone (operations/node_inst_netdesc_op.py:156,
candidates=[config.NODE_UUID]); and the create path forces a
single candidate itself whenever the instance is already placed
(:869). The affinity loop runs over whatever candidates
holds, so a forced call yields an affinity_detail of exactly
one entry -- on every successful create, on a healthy cluster.
A test taking "the" event, or the most recent one, which is
what a descending-order fetch hands back first, would skip
forever.
The count therefore comes from len(affinity_detail) in the
schedule have highest affinity event belonging to the
unforced scheduling pass. The scheduler publishes a clean
discriminator: schedule inputs carries 'forced_candidates':
bool(candidates) (scheduler.py:438), so the test finds the
schedule inputs whose forced_candidates is false and takes
the schedule have highest affinity carrying the same
request_id, rather than any event matching the message.
Take the oldest such event rather than assuming there is
only one. That flag is bool(candidates), which is list
emptiness and not "was an argument supplied", and the preflight
redirect builds its list by excluding the current node
(node_inst_netdesc_op.py:172-179) -- so on a single node
cluster the exclusion empties the list and publishes a second
event reading unforced. test_affinity skips below three nodes,
so this is not reachable in the topology the test runs on, but
the uniqueness is a consequence of the node count rather than a
property of the flag, and a test which reads it as a property
breaks quietly the day either changes. The create path's pass is
always the oldest of them.
Pairing on request_id rather than on adjacency matters: the
rows come back ordered by float timestamps which can tie, whereas
the create path takes exactly one of its two find_candidates()
branches per request (external_api/instance.py:866-870), so the
id identifies the unforced pass outright -- and the preflight
call, running in the queue daemon with no flask request, carries
no request_id at all (eventlog.py:82-86).
A third wrong source sits inside the event that fixes the other
two. schedule have highest affinity publishes 'candidates':
preferred (scheduler.py:604,609), and preferred is the
winning affinity tier, not the input candidate set. The key
name is the whole trap: schedule final candidates and
schedule forced candidates use candidates to mean a candidate
list, so an implementer who has been sent to the right event and
needs a count will find the wrong number sitting under the
obvious key. On a healthy cluster inst2's winning tier is the
single node carrying the tag, so len(extra['candidates']) is 1
and the test skips forever -- the identical failure, now inside
the chosen event. The count is len(affinity_detail), and
never len(extra['candidates']).
The same key is the right source for a different question, and
this plan owes it the same naming. The two tier assertions ask
whether inst1's node is in the winning tier, and the winning
tier is exactly what extra['candidates'] holds. So read tier
membership from it, and the count from affinity_detail. Saying
only "never extra['candidates']" three times over would push an
implementer into rebuilding the tier from by_affinity or from
the affinity_detail scores -- more code, and code that can
disagree with the scheduler's own sorted(by_affinity,
reverse=True)[0] choice, which is the disagreement a test read
from the scheduler's events exists to avoid.
A second skip condition, for a different degeneracy. Count is
not the only way a run can carry no information. Finding 5's
mechanism is an admission filter ejecting the affinity target
specifically: in the traced run inst1's node survived
sufficient_idle_cpu and was dropped at
sufficient_idle_memory. If that happens while two or three
other candidates remain, the count guard does not fire, and step
1's inst2 assertion fails -- because inst1's node is not in
affinity_detail at all. The rewritten test would then go on
failing for exactly the reason F2 holds is not a scheduler defect
and F7 declines to soften, and the failure would read as an
affinity bug: the precise confusion this phase exists to end.
F2's own wording already allows for it ("or legitimately had no
choice"); it has to become a check rather than a clause.
This skip is all-or-nothing across both assertions, and that is
accepted. Because the two assertions live in one test method,
inst3's anti-affinity coverage is lost on every run where the
skip fires -- which is every slim-tier run until
PLAN-ci-cloud-sizing lands. The tempting fix is to let inst3's
assertion run anyway, on the grounds that if inst1's node was
never a candidate then inst3 provably was not placed there. That
is exactly why it must not run: an assertion that cannot fail is
not coverage, and a green from a trivially-true assertion is the
same false pass finding 3 describes, one assertion over.
Splitting test_affinity into two methods would recover the
coverage honestly, but each method needs the three-instance
setup, and doubling a cluster-CI instance create is a real cost
against a runner fleet that is already the binding constraint on
merge throughput. So: accept the loss, and make it visible
rather than silent -- which is what step 6's requirement to
record the expected skip per topology is for.
And "the unforced pass" is two passes, not one. inst2 and
inst3 are separate creates, scheduled by separate
find_candidates() calls at different moments against candidate
sets that need not match -- and the traced #3565 run is precisely
a case where one had collapsed and the other had not. So both
skip conditions are evaluated per instance, against inst2's
unforced pass for the inst2 assertion and inst3's for the inst3
assertion, and the test skips if either pass is degenerate. The
singular reading is the dangerous one: guards computed from
inst2's events alone leave the inst3 assertion running against a
pass that may have had one candidate, or may have had inst1's
node already ejected, and it then fails for exactly the mechanism
F2 holds is not a scheduler defect. That is the same failure this
whole finding exists to prevent, reached through the one door
left open by reading a singular noun.
So the test also skips when inst1's node is absent from that
instance's unforced pass's affinity_detail keys, with a message
that tells the two skips apart in CI output -- affine node not a
candidate against only N candidates. Expect this one to fire on
slim-tier until PLAN-ci-cloud-sizing lands, and not to fire
on slim-primary; a test that skips permanently on one topology
reads as green, so step 6 records which skip is expected where.
A pass on a single-candidate run is exactly the false green
finding 3 describes -- inst2's co-location "passing" for the
same reason inst3's anti-affinity failed. skipTest with the
candidate count in the message keeps the failure visible in CI
output without asserting something the run cannot support.
This is deliberately stricter than a test that merely stops failing. Making the flake go away is easy and would leave the suite claiming coverage it does not have.
F4. Build the binary model, and keep the weighted form working
through a mechanical mapping for one release. Per D6:
require_* become filters applied with the hard admission
filters, prefer_* contribute +/-1 per matching co-located
instance in the ranking term that already exists. Weighted specs
map positive to prefer_with_tag and negative to
prefer_without_tag, losing the magnitude.
The scoring is count-proportional, not set-membership, and the
rationale is corrected to match (2026-08-29). This decision
originally justified dropping caller magnitudes on the grounds
that a magnitude is "summed across an unbounded number of
co-located instances" -- but +/-1 per matching instance is
summed across that same unbounded number, so the stated reason
argued against the mechanism the decision adopts. The genuinely
bounded alternative is set-membership: +/-1 if any matching
instance is present, regardless of count.
Count-proportional is chosen. A node carrying five instances of an affinity group really is more "with the group" than one carrying a single instance, and for the anti-affinity direction a node carrying five instances you asked to avoid really is worse than one carrying one. Set-membership discards exactly that signal, which is the one a pack-or-spread request is about.
The corrected rationale for dropping caller magnitudes is
therefore not the summation but the scaling: a caller choosing
100 rather than 1 is choosing a multiplier on a neighbour
count they cannot predict, so the two are not comparable to each
other or to anything else. Removing the multiplier leaves a
quantity that is defined -- how many matching neighbours a node
has -- which is what the ranking should be comparing. Step 1's
inst3 mutation test is written against this choice.
One consequence to write down, because prefer_without_tag
does not read like what it is. The score is a sum, over
neighbours and over tags alike, so an avoid match can be outvoted
by neighbour count on the other axis. Ask for prefer_with_tag:
['a'] and prefer_without_tag: ['b'] together: a node hosting
three a instances and one b scores +2 and beats a node
hosting one a and no b at +1. That follows directly from
count-proportional scoring and is intended -- but an operator
reads prefer_without_tag as a soft veto, not as one term in a
sum, so step 6 documents it rather than leaving them to discover
it from a placement.
Finding 7 means the mapping is exercised from the moment it
exists, because test_affinity is a weighted caller. Whether
that test moves to the binary form in this phase or stays on the
weighted one as living proof the mapping works is left to step 5;
there is a real argument for the latter.
F5. No new measurement campaign. The 2026-08-19 correction asked this phase to establish which mechanism operates before spending its decision budget. Finding 3 establishes it, from a trace taken after phase 4a's fix, at a level of detail no sampling campaign would improve on -- it has the per-candidate affinity scores and the placement decision for a single run. Finding 4 adds that CI cannot produce the measurement anyway, because on one topology the failure under study never reaches the assertion.
Spending a week of sfcbr sampling to re-derive a conclusion already sitting in the issue would be the same mistake this plan has now made twice in a fortnight, in the other direction: preferring a document's account of the evidence to the evidence.
F6. The 507 family is not adopted. sufficient_idle_cpu
refusals come from an admission stage that runs before ranking
and reads the capacity counters. When it refuses every candidate,
no ranking model would have helped. #3772 and the CI topology
belong to PLAN-ci-cloud-sizing, which has the better analysis
of both. This is written down because the family is the loudest
scheduler-shaped signal in CI and is repeatedly mistaken for
evidence about ranking -- including by this session, before the
survey.
F7. Softening the admission ceiling is declined, not
deferred. D6 offered it as the only position that makes the
co-location case work, at the cost of admission no longer being a
pure capacity question, and with a bound nobody had chosen.
Declining it: the property that admission answers "does this fit"
and nothing else is what makes the capacity counters trustworthy,
and phase 3 spent considerable effort making a single guarded
UPDATE the sole authority on that question. A preference that can
raise a ceiling would put a ranking input inside a transaction
whose correctness argument depends on it being arithmetic. If a
future workload genuinely needs guaranteed co-location, the
honest mechanism is a hard require_with_tag plus enough
capacity, not a soft preference with leverage.
Recorded as declined so a later phase does not rediscover it as an open question.
F8. The retry behaviour gets an issue, not a fix -- and the issue has to be pinned first. Whatever finding 6 turns out to be, it is in the placement retry paths rather than the affinity model, and fixing it inside this phase would widen a phase that is already two phases wearing one number. But finding 6's mechanism is not confirmed against the tree, so step 2 pins it to a call site before filing and files only what the trace substantiates: a wrong issue is worse than none, and this plan's standard everywhere else is grounding claims in what the code does today. Reference it from #3565's closure so the connection is not lost, and leave it.
F9. A hard require that ejects every candidate answers 409,
not 507. Placing the require_* stage among the admission
filters means it publishes its dropped map through
_log_and_raise_on_error(), which raises LowResourceException
once the candidate list empties (scheduler.py:386-392). The
create path catches exactly that and answers 507 "insufficient
resources" after enqueuing a delete
(external_api/instance.py:872-877, the
LowResourceException handler). Left alone, a
require_with_tag naming a tag no co-located instance carries
would tell the caller the cluster is full -- which is false, and
which is precisely the complaint F2 makes against D6's position
1, where a 507 is "the same ejection with a different traceback".
The definition-of-done bullet, phrased as "a no-candidate refusal
naming that stage", would have been satisfied by that wrong
answer, so it is tightened below to name the code.
The helper has to grow a parameter for this to be possible at
all. _log_and_raise_on_error() raises
exceptions.LowResourceException unconditionally
(scheduler.py:391) with a message it builds itself, so
"publish the dropped map through the helper like every other
filter" and "raise AffinityConstraintUnsatisfiable" cannot
both be true of the code as it stands. Give it an optional
exception_class=exceptions.LowResourceException argument,
defaulted so no existing call site changes, and have the require
stage pass the subclass. The message is built in the helper too,
so if the 409 body is to name the constraint and not just the
stage, the constraint detail has to reach the helper as well --
either as another optional argument or by having the stage raise
directly and use the helper only for the event. Decide that in
step 4 and say which, rather than discovering it while writing
the response body.
So the stage raises AffinityConstraintUnsatisfiable, a
subclass of LowResourceException defined beside it in
shakenfist/exceptions.py -- named because every other new
symbol in this plan is pinned to a file, and because
node_inst_netdesc_op.py has to import it, so defining it in
scheduler.py would make an operation module import the
scheduler for an exception type. The create path gains
an except clause for it placed before the existing one.
Python matches except clauses in order, so that ordering is the
entire mechanism and is the easy thing to get wrong. It answers
409, naming the constraint and the stage: the request is
well-formed and conflicts with the current state of the cluster,
which is how this API already uses 409 for lifecycle refusals
(external_api/instance.py:1228 and five siblings). The instance
is still deleted -- it exists only because it is created before
scheduling runs, and leaving it would leak.
Subclassing rather than introducing a sibling exception is
deliberate. Preflight runs this stage too, and catches
LowResourceException to redirect to other nodes
(node_inst_netdesc_op.py:159-180). Redirecting is exactly right
for a constraint that another node may satisfy, so that path
should keep working untouched; a sibling exception would escape
it as a traceback.
There are three capacity-shaped aborts on that path, not two.
Between the two step 4 already names sits
AbortInstanceStart(self, 'Requested node lacks resources')
(node_inst_netdesc_op.py:169-171), reached when
inst.requested_placement is set and the forced call at :156
raised -- so under step 4 an operator who pinned a node is told
it lacks resources when what it lacks is a matching tag. Same
fault, third door.
And they are not one change, because two of the three cannot
see the exception. An earlier draft of this decision said "the
exception is already bound by the except clause at :159, so
all three are the same one-line change". That is wrong, and
wrong in a way that would have compiled. The
except LowResourceException as e: suite is :159-162 only;
:164 onwards is dedented back to the level of the try, so
:166-167 and :169-171 are outside it -- and Python deletes
the as target when the suite exits (PEP 3110), so e is
unbound there. Checked at the interpreter: reading it raises
NameError: cannot access local variable 'e' where it is not
associated with a value. Only :276 is genuinely inside an
except suite and can test the exception directly.
Which is an instruction, not just an observation. An
earlier draft said :276 "is a different matter" and left it
there, so the site with the easiest fix was the one
with no fix specified. Inside that suite, branch on
isinstance(e, AffinityConstraintUnsatisfiable) and raise
AbortInstanceStart(self, 'No node satisfies the requested
affinity constraints: %s' % e) in place of 'Unable to find
suitable node', with a matching audit event. All three
sites, not two and an aside.
So the mechanism is a carried flag, not a rebound exception:
inside the suite at :159, set
affinity_failure = isinstance(e, AffinityConstraintUnsatisfiable),
and test that local at :166 and :170. This is the plan's own
house rule about guards sitting where the exception is raised,
and it is worth spelling out because the failure mode is
narrow: the reschedule path runs only under cluster CI in the
merge queue (docs/developer_guide/coding_rules.md:341-352), so
a NameError written here would pass the PR and land in the
queue.
Initialise that flag before the try, not only inside the
suite. As the code stands :157 returns on success, so :166
is reachable only by way of the except suite and an assignment
made solely inside it would in fact always have run. That is a
property of today's control flow, not of the guards that read
the flag: any later edit adding a non-returning path through the
try reintroduces exactly the unbound-local failure this
finding exists to describe, on the one path a pull request does
not exercise. affinity_failure = False immediately before the
try costs a line and removes the dependency.
The three aborts also need a test, because nothing else here
will catch them. The rest of this phase's scheduler work is
testable at the unit level, but the abort path runs in the queue
daemon under cluster CI, so a mistake in it passes the pull
request and fails in the merge queue -- the same asymmetry that
makes the NameError above worth this much text. Step 4
therefore owes a unit test over the abort path exercising the
flag at both :166 and :170, and an API-level test that the
create path answers 409 and not 507: the except ordering
is called the entire mechanism above, and a scheduler-level test
asserting the exception type cannot observe which clause
caught it.
F10. require_* is enforced on every reschedule, not only at
create -- deliberately, and the user guide currently says the
opposite. find_candidates() is not a create-time function.
Preflight calls it on every restart and reschedule
(node_inst_netdesc_op.py:156 and :180), so a stage placed
inside it runs then too. The consequence is a lifecycle change
this plan had not stated: an instance placed under
require_with_tag: ['database'] whose tagged neighbour is later
deleted can be refused when it next restarts, and that refusal
ends in AbortInstanceStart.
Enforce it. A constraint which applies at create and then
stops is not a constraint, it is a create-time hint with a
misleading name -- and the placement it would then permit is the
one the binary model exists to prevent, arriving later and more
quietly. require_without_tag is the clearer case: an instance
which must not share a hypervisor with batch work has not
stopped needing that on its second boot. Suppressing the stage
for forced and preflight calls would also make the constraint
depend on how an instance came to be scheduled, which is not
something a user can reason about or observe.
Three things make this survivable, and each is already in the
plan for another reason. Preflight catches
LowResourceException and redirects, and
AffinityConstraintUnsatisfiable is a subclass, so a reschedule
refused on the current node tries every other node before
aborting -- an abort means no node in the cluster satisfies the
constraint, not that one node did not. F9's three abort messages
then say so in as many words rather than reporting a full
cluster. And require_* is opt-in: no weighted specification
maps onto it, so nothing existing acquires this behaviour.
The user guide contradicts this, and was already wrong before
this phase. docs/user_guide/affinity.md says, in the same
admonition step 6 is told to fix, that "this decision is only
made on the original start up of an instance, and does not apply
later. That is, if you change the tags or affinity of an
instance after instance creation it will not affect that
instance in any way". Neither half holds. Instance.affinity
reads self.metadata live (instance.py:738-740), and metadata
is read from MariaDB on each access rather than snapshotted at
create (baseobject.py:659-667), so a changed specification has
always applied to the next reschedule of that same instance.
What this phase changes is the consequence: a re-read could
previously only re-rank, and now it can refuse. The sentence
moves from misleading to dangerous, so step 6 fixes both halves
of that admonition and not only the "filters candidate
hypervisors" half.
Design: where the binary form lives¶
Affinity is not a first-class instance field. It is instance
metadata under the reserved key affinity
(instance.py:247, the affinity property at :739), validated at
external_api/instance.py:1395-1406 as a JSON dict whose values
must be integers. The scheduler reads it as inst.affinity and
sums per-tag contributions.
The binary form therefore has to be a new value shape under the same key, not a new key, because a second key would let a caller supply both and mean nothing coherent. The shapes are distinguishable by value type without ambiguity:
# weighted (existing, deprecated for one release)
{'affinity': {'first-node': 100, 'db': -50}}
# binary (new)
{'affinity': {'prefer_with_tag': ['first-node'],
'prefer_without_tag': ['database-tier'],
'require_with_tag': ['web-frontend'],
'require_without_tag': ['batch-worker']}}
Every tag named here is an instance tag -- the tags
metadata key of instances already placed on a candidate node
(instance.py:786-787, the tags property, consumed at
scheduler.py:568-587) -- and not a property of the node.
Shaken Fist has no node capability tags, so an example like
require_with_tag: ['ssd'] would teach a model that does not
exist. This is why
test_affinity has to create inst1 with {'tags':
['first-node']} before any affinity request means anything.
A dict whose values are integers is the old form; a dict whose keys are the four reserved names and whose values are lists is the new one. Anything else must be a 400. That check is mechanical, which matters because the validator is the only place a caller gets told they got it wrong.
It cannot tell them today. The existing validator coerces with
int(dv) inside a try that catches only ValueError, and
int() raises TypeError -- not ValueError -- for a list, a
dict or None. Confirmed at the interpreter: int(['a']),
int({'x': 1}) and int(None) all raise TypeError. So the new
binary shape, whose values are lists, posted against today's
server produces an uncaught TypeError and a 500, and
_validate_instance_metadata is shared by instance create
(:797) and both metadata endpoints (:1375, :1425), so all
three paths reach it -- today, for any caller who guesses the
new syntax early. Step 3 widens the except clause rather than
layering a new shape on top of the hole, and it is its own step
for that reason: the fix depends on none of this phase's
decisions and can land ahead of the model. Step 3 also files the
issue it closes, rather than step 2, so that nothing about this
fix waits on the test rewrite; it is a live 500 on a public API
and should be tracked whether or not this phase lands.
One related imprecision worth carrying: int() also accepts
floats and numeric strings, so "values must be integers"
describes the intent rather than the coercion.
The mapping in F4 is then: for each tag: n in a weighted spec,
emit prefer_with_tag: [tag] when n > 0 and
prefer_without_tag: [tag] when n < 0. n == 0 maps to
nothing, which is what it already means.
The hard filters keep the scorer's namespace scope. Today's
scorer skips co-located instances in another namespace
(scheduler.py:574-580, 'skipped': 'different namespace'),
which for a preference is plainly right -- you cannot prefer
what you cannot see. For require_without_tag the choice is
sharper, because inheriting the scope means "never place me
beside an instance tagged noisy" actually means "never beside
one of my own", which is weaker than the name suggests.
It inherits the scope anyway. The alternative makes a placement
outcome depend on other tenants' instance tags, which is a
probe: a caller could learn what tags exist in namespaces they
cannot read by watching which creates get refused. That is a
change to the trust boundary described in
docs/developer_guide/security_model.md, and it is a much
larger decision than an affinity model should be allowed to
make in passing. If cross-tenant isolation is ever wanted, it
should be built as isolation, with its own threat model, and
not fall out of a scheduling filter.
Step 6 must therefore document require_without_tag as a
within-namespace constraint in as many words, because the first
operator to reach for it will read it as isolation.
Execution¶
Five of the seven briefs live in ### Step N brief sections below
this table rather than inside their cells, which is a deliberate
deviation from PLAN-TEMPLATE.md and is recorded here so it is not
read as an oversight. The template puts the brief in the cell, and
that works at the ~300 character briefs the sibling phase plans in
this series carry. Steps 1 and 4 here reached 8,700 and 11,100
characters, twenty times the house norm, at which point the row is a
single unwrapped line in the source and one unreadable cell in
mkdocs -- so the container was defeating the content it exists to
carry. The cells keep a real one-or-two-sentence brief and a link;
nothing was shortened in the move, and the sections carry the text
verbatim. The ### Step N heading level is already used by sibling
plans for their post-execution implementation notes, so this reuses
a shape a reader of these plans knows rather than inventing one.
Steps 1 and 4 end in a numbered checklist of their hard
requirements. The prose carries the reasoning and the checklist
carries nothing new; it exists because this plan's own stated risk is
an implementer silently omitting one requirement out of a dozen, and a
list is cheaper to check a diff against than nine thousand characters
of argument are to re-read.
| Step | Effort | Model | Isolation | Brief for sub-agent | Status |
|---|---|---|---|---|---|
| 1 | high | opus | worktree | Rewrite test_affinity onto the scheduler's audit events per F2 and F3, asserting affinity tier membership rather than final co-location, and skipping (not passing) on a degenerate run. Full brief: Step 1. |
Complete |
| 2 | low | sonnet | none | (GitHub) The #3565 disposition, once step 1 has merged. Comment on #3565 recording F2: the traced 2026-08-26 run shows a single-candidate set, so affinity was never consulted, and both halves of the assertion had the same cause; the issue is closed by candidate fix 2 rather than by a scheduler change; D6's three positions are disposed of by F2 and F7. Close it. Then deal with the retry behaviour from finding 6, and pin it to a call site before filing (F8). Re-read that comment's event payloads against the three sites finding 6 names: the create path's demand-waiving second walk (external_api/instance.py:924-940), the preflight redirect that rebuilds the candidate list excluding the current node (operations/node_inst_netdesc_op.py:172-180), and preflight's opening forced call against config.NODE_UUID (:156), which runs on the node the create path has just chosen. There is a fourth mechanism, at the second of those sites rather than at a new one, and it is the only one of the four whose code path demonstrably re-offers a node it has just refused: find_candidates() tests if candidates: (scheduler.py:400, :441), so an empty forced list is treated as unforced and reverts to the whole cluster -- including the node the redirect built the list to exclude. The redirect means "every node except this one" and silently means "every node" whenever that set comes out empty. Check the trace against this as well as the three above; if it is not the mechanism, it is still worth recording as its own observation. File the issue against whichever the trace matches, quoting the payloads and naming the file and line. If none of them matches, file it as an observation that says so in as many words rather than asserting a mechanism -- a wrong issue is worse than none. Cross-reference it from #3565's closing comment. Do not file the validator defect here: step 3 files and closes its own issue, deliberately, so that a live public-API 500 does not wait on this step, which waits on step 1 (F1). If step 3 has already landed, reference its issue from #3565's closure alongside the retry one. Also comment on PLAN-ci-cloud-sizing's tracking of #3565 that its "needs a disposition in phase 0 before phase 4" is now satisfied. Include (Triage assisted by Claude Code). |
Complete |
| 3 | low | sonnet | worktree | The validator 500, on its own branch and its own issue: widen the int() coercion handler to (TypeError, ValueError, OverflowError) and refuse booleans. Depends on nothing else here. Full brief: Step 3. |
Complete |
| 4 | high | opus | worktree | Build the binary affinity model: the four reserved keys, the hard require_* filter stage, the 409, and the three preflight abort messages. Full brief: Step 4. |
Complete |
| 5 | medium | sonnet | worktree | Map weighted specifications onto the binary form where the scheduler reads them, and emit a deprecation event where a specification is accepted. Full brief: Step 5. | Complete |
| 6 | medium | sonnet | worktree | Documentation: rewrite docs/user_guide/affinity.md, put the diagnostic and discovery recipes in docs/operator_guide/scheduler.md, and record which skip is expected on which CI topology. Full brief: Step 6. |
Complete |
| 7 | low | sonnet | worktree | (shakenfist) Close-out. Set phase 6 to Complete in the master plan Execution table, confirm docs/plans/index.md's arithmetic, and record in the phase status notes that #3565 closed on a test change rather than a scheduler change, with F2's one-line reason so a later reader does not reopen it looking for the missing fix. Also settle GOALS.md:38, "I am not confident that the affinity rules for instances work as expected" -- the project-level form of the uncertainty this phase exists to resolve, and the one artifact that should not still say it afterwards. Rewrite it to what is now known (affinity ranks, it does not admit; a single-candidate placement is neither honoured nor violated; hard constraints are the way to ask for admission) or delete it, but do not leave it standing. Commit subject: scheduler: close out phase 6. |
Complete |
Step 1 brief: rewrite test_affinity onto the events¶
(shakenfist) Rewrite test_affinity onto the audit events, per F2 and F3.
In shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_scheduler.py,
keep the three-instance setup unchanged and replace the two placement
assertions (:124-131, whose ['node'] arguments are on :125 and
:129) with assertions read from the scheduler's own events. The events
are already fetched by _add_scheduler_detail() (:19-35), which filters
get_instance_events() to messages starting with schedule -- factor its
fetch out into a helper returning the events so both the detail-attachment
and the assertions use one read, rather than fetching twice. Assert, for
inst2: that schedule have highest affinity scored inst1's node in the
winning tier. For inst3: that inst1's node was not in the winning tier.
Then skip, do not pass, when the scorer had fewer than two candidates
to choose among, with the count in the skip message (F3). Take that count
from len(affinity_detail) in the schedule have highest affinity event
of the unforced scheduling pass, located by finding the schedule
inputs event whose forced_candidates is false (scheduler.py:438) and
taking the affinity event carrying the same request_id. Take the
oldest unforced schedule inputs, not the first the fetch hands back:
the flag is bool(candidates), so an empty forced list also reads as
unforced (F3), and the fetch is newest-first. Sort ascending rather than
indexing into the API's order. Pair on request_id, not on adjacency:
it is a field of EventReadRow (schema/event.py:84), populated
from FLASK_REQUEST_ID (eventlog.py:82-86) and returned to
clients by row.model_dump()
(external_api/base.py:1479-1484); the create path takes exactly one of
its two find_candidates() branches per request
(external_api/instance.py:866-870), so the id identifies the unforced
pass outright, while the preflight call runs in the queue daemon with no
flask request and no request_id at all. Guard the join against a null
key on both sides. eventlog.py:84 reads the environ with a bare
.get(), so an absent key yields request_id = None -- and if the
create-path events carried None too, an equality match would return the
preflight events as well, and taking the first or last could select the
forced pass, whose affinity_detail has exactly one entry: a silent
permanent skip, the failure F3 exists to prevent, reached by a fourth
route. In practice the key is always set on the create path --
RequestID(app) (external_api/app.py:63) is WSGI middleware that does
environ["FLASK_REQUEST_ID"] = req_id on every request, generating a uuid4
when the client sends no X-Request-ID header -- which is why the
pairing is sound, and is worth stating because this plan had been asserting
the pairing without saying what guarantees it. (The .get(..., 'none')
defaults elsewhere -- external_api/base.py:1274, app.py:227,262,614 --
are defensive logging, not evidence of absence.) Use the pairing only when
the unforced schedule inputs event's request_id is truthy; if it is
falsy, fall back to adjacency, and self.fail() rather than skip when
adjacency cannot identify a pass either. Define adjacency exactly, rather
than leaving the word to be interpreted: sort the instance's events
ascending by timestamp, find the chosen schedule inputs event in that
order, and take the first schedule have highest affinity strictly after
it; self.fail() if there is none. It is fragile precisely because those
timestamps are floats that can tie, which is why it is the fallback and not
the mechanism. It is not dead code, though it is close to it.
RequestID(app) sets the id on every request, so a current cluster always
pairs -- but request_id is a recent addition to EventReadRow, and this
test runs against whatever sf-api the CI cluster is running, so an older
API that does not publish the field is the reachable path. Left undefined
it would be a latent flake with no test behind it; defined, it is a
documented degradation. Three wrong sources are ruled out in F3 and
must not be reintroduced: schedule final candidates (post-narrowing,
holds one node whenever affinity works for inst2); any schedule have
highest affinity matched by message alone (find_candidates() runs
several times per create, and the forced calls each publish one with a
single entry); and -- inside the correct event -- extra['candidates'],
which is preferred, the post-scoring winning tier
(scheduler.py:604,609), and not the input set. That third one is the
nearest to hand and the easiest to reach for, because candidates means an
actual candidate list in the two sibling events. The count is
len(affinity_detail), full stop. The same key is the right source for
the other question, and is named here so that being told three times not
to touch it does not send you rebuilding the tier by hand: the two tier
assertions ask whether inst1's node is in the winning tier, and
extra['candidates'] is the winning tier. Tier membership from
extra['candidates'], count from affinity_detail. Rebuilding the tier
from by_affinity or from the affinity_detail scores is more code and
can disagree with the scheduler's own sorted(by_affinity,
reverse=True)[0] choice. The lookup itself needs no translation:
inst1['node'] and the keys of affinity_detail are both node UUIDs drawn
from the same get_active_node_metrics() keyspace -- confirmed at planning
time, and worth stating because if it were false the test would skip
permanently and silently. The fetch must also be widened.
_add_scheduler_detail() calls get_instance_events() with no limit, and
that endpoint defaults to limit=100 (external_api/instance.py:1203)
over rows ordered by timestamp descending (mariadb.py:6139-6146) -- so it
returns the newest hundred, while the create-path scheduling events are
the oldest an instance has, behind all its networking, image, boot and
agent events. Pass event_type='audit', limit=1000 explicitly, and put
that pair in the helper's body rather than in its signature -- it takes
the instance uuid and nothing else. The truncation this instruction removes
was reintroducible precisely because the widening lived at the call site; a
helper that hardcodes both gives every reader the widened fetch by
construction. Both, not one: 1000 is the endpoint's hard cap (:1193-1195,
{'minimum': 1, 'maximum': 1000}), so the audit filter is what keeps a
busy create inside that ceiling rather than an optional tidiness -- and
since the brief also requires self.fail() when the unforced pair is
missing, a create that emits more than 1000 events would otherwise become a
test failure blamed on affinity. Two call sites in the suite already use
this idiom: get_instance_events(inst['uuid'], event_type='mutate',
limit=1000) (cluster_ci_tests/test_events.py:136-137), and
cluster_ci_tests/test_namespace_claims.py:415-421, whose docstring makes
exactly this argument about a default read pushing an old event off the
end. If the unforced pair is not found, self.fail() with that reason
rather than skipping: a missing event means the read was wrong, not that
the run was degenerate, and a skip would hide it. affinity_detail has two
shapes -- the normal {'score', 'instance_count', 'considered'}
(:596-600) and {'score': 0, 'reason': 'node row not found'}
(:548-551) for a candidate whose node row could not be read -- so count
its entries but do not index inside them unconditionally, or a transient
node-row failure becomes a KeyError and an unreadable test error instead
of a diagnosable skip -- a single-candidate run carries no information and
a green result there is the false pass finding 3 describes. Skip on a
second condition too, per F3: when inst1's node is not among the unforced
pass's affinity_detail keys, the affinity target was ejected by an
admission filter before scoring, so the run says nothing about affinity in
either direction -- and without this guard step 1's inst2 assertion fails
on precisely the mechanism F2 holds is not a scheduler defect and F7
declines to soften. Give the two skips different messages (affine node not
a candidate against only N candidates) so CI output separates them; a
single message would merge the degenerate case this phase accepts with the
one it declines to fix. Read the 2026-08-26 comment on issue #3565 first:
it contains the exact event payloads from a failing run, including an
affinity_detail that scored a node -100 and placed there anyway, and it
is the specification for what these assertions must distinguish. Do not
assert final co-location anywhere -- that is the guarantee F2 establishes
the product does not make. This runs only on merge_group
(docs/developer_guide/coding_rules.md:341-352), so exercise it against
sfcbr before proposing the commit. Commit subject: tests: assert what soft
affinity actually promises.
Hard requirements, as a checklist. The prose above argues each of these; this is the self-check, because the failure mode this step guards against is silent omission and re-reading nine thousand characters is not one.
- Both placement assertions are replaced by tier membership read
from
schedule have highest affinity. No assertion of final co-location survives anywhere in the test. - There is exactly one fetch, in a helper taking the instance uuid
alone and hardcoding
event_type='audit', limit=1000in its body. - The unforced pass is the oldest
schedule inputswhoseforced_candidatesis false, paired to its affinity event byrequest_id. - The pairing runs only when that id is truthy. The fallback is
adjacency as defined above -- sorted ascending, strictly after --
and
self.fail()when neither identifies a pass. - The candidate count comes from
len(affinity_detail)and tier membership fromextra['candidates']. Neither is derived from the other, and the tier is not rebuilt fromby_affinity. - Two skip conditions, with different messages: fewer than two
candidates, and inst1's node absent from
affinity_detail. Both are evaluated per instance, against inst2's own pass and inst3's. - A missing unforced pair is a
self.fail(), never a skip. - Nothing indexes inside an
affinity_detailentry unconditionally; the node-row-not-found shape has neitherinstance_countnorconsidered. - The test is exercised against sfcbr before the commit is
proposed, because it runs only on
merge_group.
Step 3 brief: the validator 500¶
(shakenfist) The validator 500, on its own. Widen
_validate_instance_metadata's except ValueError to except (TypeError,
ValueError, OverflowError) (external_api/instance.py:1403-1406). int()
raises TypeError, not ValueError, for a list, a dict or None, so an
affinity dictionary value of that shape escapes the handler and returns
500 today from instance create (:797) and from both metadata
endpoints (:1375, :1425). OverflowError is a fourth case and is not
theoretical: int(float('inf')) raises it, and flask hands a bare
Infinity literal straight through because req.get_json(force=True,
silent=True) (external_api/base.py:129) uses json.loads defaults,
which accept Infinity and NaN as JSON. So {'affinity': {'a':
Infinity}} 500s exactly like the other three, and a two-exception fix
leaves that hole open behind a ticked box. This trap is already known in
this codebase and handled one file over -- external_api/base.py:308-320
refuses non-finite durations with a comment saying in as many words that
json.loads hands the bare literals through. (NaN needs nothing:
int(float('nan')) raises ValueError, which is already caught.) Refuse
booleans as well, mirroring external_api/base.py:299-301:
isinstance(True, int) is true in Python so int(True) is 1, and
{'affinity': {'a': true}} is accepted today as weight 1 -- which under
F4's mapping would silently become prefer_with_tag: ['a']. Record that
half honestly in the issue, the commit message and the release notes --
which today means docs/release_notes/v07-v08.md, the only file in that
directory; name whichever is current when this is written rather than
leaving "the release notes" to be located -- because it is the one part of
this step that changes a request that succeeds today into a 400, and
the validator is shared, so the break has three entry points -- instance
create and both metadata endpoints; it is worth doing here rather than
later precisely because F4 is about to give true a meaning nobody asked
for. Do not replace the coercion with a shape test (isinstance(dv, int)
and not isinstance(dv, bool)), tempting as it looks: int('3') succeeds
today, so a shape test would also refuse string-encoded integers, and this
step is a bugfix for a 500, not a tightening of what the API accepts. Get
the level right too: a malformed outer affinity value is already
refused correctly -- a list by isinstance(value, dict) (:1398-1400) and
None by if not value (:1387-1388) -- and only the inner per-tag
coercion leaks. Unit tests in shakenfist/tests/ for the list-valued,
dict-valued, None-valued and Infinity-valued inner cases, which are the
four that 500 now, plus the boolean case which is the one that changes
answer. This step is deliberately separate from the binary model below and
depends on none of this phase's decisions: it is a live 500 on a public
API, the fix is one except clause, and it should not wait behind an API
surface, a new scheduler stage and a back-brief gate. File its own issue
and close it with the fix (Fixes #NNNN), rather than waiting for step
2's triage pass -- step 2 waits on step 1, which is a cluster-CI rewrite
that has to be driven against sfcbr by hand, and a live 500 on a public API
should not sit behind that. Be precise about the level in the issue as well
as in the code: a malformed outer affinity value is already refused
correctly, and only the inner per-tag coercion leaks. This step waits for
nothing and can go first. Two commits on that branch, not one. The
except widening is a pure bugfix for a live 500 and should stay
trivially revertible and backportable; the boolean refusal changes a
request that succeeds today into a 400. Batching them would put a
deliberate compatibility break into history under a bugfix subject,
which is what Unrelated changes are not batched into a single commit
exists to prevent, and it would make the fix hostage to the break if
either has to be reverted. Commit subjects: Refuse malformed affinity
values with a 400. then Refuse boolean affinity values: a break.
Step 4 brief: the binary model¶
(shakenfist) The binary model. Per F4 and the Design section: accept the
new value shape under the affinity metadata key, validate it at
external_api/instance.py:1395-1406 alongside the weighted form (four
reserved keys, list-of-string values, 400 on anything else), and consume it
in scheduler.py. Step 3 has already widened the except clause in that
same function; build the new-shape validation beside it rather than
repeating it, and place it above the per-value coercion loop, not
below. The binary form's values are lists, which is exactly the shape
step 3 has just taught that loop to refuse with a 400, so a branch added
underneath it ships a validator that rejects the whole new API surface -- a
step breaking the feature the next step adds, which no test written inside
either step would catch. Discriminate on the keys: a dictionary any of
whose keys are one of the four reserved names is the binary form, so
validate that branch and return, and let only the weighted branch reach
int(). A dictionary mixing the two shapes is refused rather than guessed
at, because either way of resolving it silently discards half of what the
caller asked for. require_with_tag / require_without_tag become a
filter stage placed as the last of the admission filters -- after
sufficient_free_disk and immediately before the affinity block at
scheduler.py:529 -- and so before affinity scoring, publishing a dropped
map through _log_and_raise_on_error() like every other filter, with its
own stage name so a refusal says which constraint ejected the node. That
helper cannot raise the subclass as it stands: it raises
exceptions.LowResourceException unconditionally at scheduler.py:391
with a message it builds itself, so give it an optional
exception_class=exceptions.LowResourceException argument, defaulted so no
existing call site changes, and pass AffinityConstraintUnsatisfiable from
the require stage. The message is built in the helper too, so decide in
this step how the constraint detail reaches the 409 body -- a second
optional argument, or the stage raising directly and using the helper only
for the event -- and record which. Do not leave it to be discovered while
writing the response body. The position is what keeps the placements
memo cheap, and it is the reason to choose it. Matching co-located
instance tags means calling _placed_instances() (scheduler.py:169-185),
one Node.from_db() plus one Instance.from_db() per placed instance per
candidate node; today that is paid exactly once because the memo is a local
created inside the affinity block (:541). A separate stage cannot see
that local, so the memo has to move above it -- but a hard filter is
order-independent for correctness, only the stage name in the refusal event
changes, so there is a free choice about how far above. Putting the stage
among the earlier admission filters would drag the memo in front of the
CPU, RAM and disk pruning, where it reads placements for the full candidate
set: strictly more expensive than the pass that exists today, and paid on a
hot path to save nothing. Putting it last instead moves the memo by two
statements over an already-pruned set, and the filter and the scorer share
one read. Take the second. Keep one guard even so: the stage must return
immediately when the instance requests neither require_with_tag nor
require_without_tag, because it still runs ahead of the load-shedding
filters, and without it every create walks candidates it has no constraint
to test them against. Confirm the result against
shakenfist/data/database_load_budget.yaml before proposing the commit --
the same requirement step 5 carries for a far smaller addition, on the same
instance-create hot path. While you are there, take the free win the same
reading exposes: the affinity loop calls _placed_instances()
unconditionally today (scheduler.py:544) with no short-circuit on an
empty inst.affinity, so every create already pays a full Node.from_db()
plus one Instance.from_db() per placed instance per candidate for a
scorer that has nothing to score. Skipping the walk when there is nothing
to score reduces the measured load rather than holding it flat, which is
a better outcome for the budget check than a wash. But the skip has to
establish what the code after the walk expects, or it breaks every create
that requests no affinity -- which is most of them. The walk is what
populates by_affinity, and the next statement is highest_affinity =
sorted(by_affinity, reverse=True)[0] (scheduler.py:603) over a
defaultdict(list): empty if the loop body never ran, so sorted(...)[0]
raises IndexError and preferred never reaches the unconditional
load-shed block at :637-658. Seed by_affinity[0] = list(candidates) on
the skipped path, so everything downstream sees what it saw before -- one
tier, every candidate, score zero. Skip on whether there are any tags to
score (the prefer_* lists, after mapping) rather than on
inst.affinity being empty, or a specification of nothing but require_*
constraints walks anyway. Which makes step 5 a hard prerequisite of the
short-circuit, and the two must not be separated. The prefer_* lists of
a weighted specification are empty until step 5's mapping populates them,
so a short-circuit landing in step 4 alone would skip the walk for every
existing caller -- silently, since no create fails and test_affinity
would merely skip green in cluster CI. Land the short-circuit in step 5
beside the mapping, not here; step 4 leaves the walk unconditional. If for
some reason they must be separated, the predicate has to be "no prefer_*
tags and not a weighted specification" instead, which is worse code
written to survive an ordering nothing needs. Say what the event carries
there too. schedule have highest affinity is still published on the
skipped path, because F3 makes it the record step 1 reads and step 6 makes
it the recipe an operator reads; its candidates, highest_affinity and
by_affinity are all populated, and its affinity_detail is {} with no
instance_count anywhere. That shape is the walk correctly declining to
run, not events going missing, and step 6's recipe has to cover it -- a
no-affinity create is exactly the create an operator diagnosing an
unexpected placement will look at first. Per F9 the status code is 409,
not 507. _log_and_raise_on_error() raises LowResourceException
(scheduler.py:386-392) and the create path answers that with 507
'insufficient resources' plus a delete (external_api/instance.py:872-877,
the LowResourceException handler) -- which would tell the caller the
cluster is full when it is not. So raise AffinityConstraintUnsatisfiable,
a subclass of LowResourceException, and add an except clause for it
to the create path before the existing one; except clauses match in
order, so that ordering is the whole mechanism. Answer 409 naming the
constraint and the stage, and add the 409 to that endpoint's
swagger_helper response list. Keep the delete. Leave preflight's
redirect untouched: it catches LowResourceException and tries the other
nodes (node_inst_netdesc_op.py:159-180), which is the right behaviour for
a constraint another node may satisfy, and the subclass keeps that working
unchanged. Its abort message is a different matter. When no node can
satisfy the constraint -- a require_with_tag naming a tag nothing in the
namespace carries -- the redirect exhausts its candidates and raises
AbortInstanceStart(self, 'Unable to find suitable node') (:276), and
repeated preflight cycles then hit AbortInstanceStart(self, 'Too many
start attempts') (:166-167) after burning three placement attempts.
There is a third between them: AbortInstanceStart(self, 'Requested
node lacks resources') (:169-171), taken when inst.requested_placement
is set and the forced call at :156 raised -- so an operator who pinned a
node is told it lacks resources when it lacks a matching tag. All three
messages are capacity-shaped, which is the same "tells the caller the
cluster is full when it is not" fault F9 exists to prevent, one path over;
the create path escapes it only because the instance is deleted before
preflight runs, so this is the restart and reschedule path. They are not
the same fix, and the difference is a trap. The except
LowResourceException as e: suite is :159-162 only; :164 onwards is
dedented back to the try level, so :166-167 and :169-171 sit outside
it and e has been deleted by then (PEP 3110) -- reading it there raises
NameError: cannot access local variable 'e' where it is not associated
with a value, confirmed at the interpreter. Only :276 is inside an
except suite and can test the exception directly. For the other two,
capture a local inside the suite at :159 -- affinity_failure =
isinstance(e, AffinityConstraintUnsatisfiable) -- and test that flag at
:166 and :170. Initialise it to False immediately before the try
rather than only inside the suite: today :157 returns on success so the
assignment always runs, but that is a property of the current control flow
and not of the guards, and the next edit which adds a non-returning path
through the try puts the unbound local back on the one path a pull
request does not exercise. Get this wrong and the NameError lands in the
merge queue rather than failing the PR, because the reschedule path runs
only under cluster CI (docs/developer_guide/coding_rules.md:341-352). Do
not change the redirect behaviour itself. The filter matches co-located
instance tags, exactly as the scorer already does -- and "exactly"
here means calling the scorer's neighbour predicate, not restating
it. That predicate is the whole of scheduler.py:555-580, which
skips four kinds of row and not one: an instance whose row has gone
away (:556-561), the rescheduling instance itself (:562-567,
'skipped': 'self'), an untagged neighbour (:568-573) and one in a
different namespace (:574-580). Reimplementing from the namespace
skip alone -- which is what an earlier draft of this brief cited --
gives a filter that counts the instance as its own neighbour, so a
restart under require_without_tag: ['x'] on an instance carrying x
refuses the node it is already running on and then every other node
too, and a missing instance row becomes an AttributeError inside
find_candidates(), which the create path does not catch: a 500,
which is the failure class step 3 exists to remove. Factor the four
conditions into one helper returning the audit record or None and
have both the require stage and the scoring loop call it, per the
coding_rules.md headline "Never restate a visibility predicate". See
the Design section for why the namespace scope is inherited rather
than crossed, and note that this makes require_without_tag a
within-namespace constraint and not an isolation primitive.
prefer_with_tag / prefer_without_tag contribute +1 / -1 per
matching co-located instance into the existing scoring loop
(:529-600), which already has the per-candidate affinity_detail shape
the events publish -- extend it rather than replacing it, because step 1's
assertions read it. Nothing may reach an int() on a value whose shape
has not been checked. That loop coerces every tag's value today
(scheduler.py:583-587), and this step teaches the same metadata key to
hold lists, so a binary specification arriving at the weighted coercion
raises TypeError inside find_candidates() -- which the create path does
not catch (external_api/instance.py:872-878 handles only
LowResourceException and CandidateNodeNotFoundException), making it a
500 on instance create: exactly the failure class step 3 exists to
remove, one layer down and reintroduced by the step which fixes it. Two
rules, and both are needed. Dispatch on shape before any coercion, so a
binary specification never enters the weighted path at all. And make the
weighted path itself skip a value it cannot coerce rather than raise,
because the scheduler reads specifications validated when they were
accepted -- which for anything stored before step 3 landed means not
validated at all -- and an instance whose stored metadata predates a
validation rule should place somewhere rather than become permanently
unschedulable. Unit tests in shakenfist/tests/test_scheduler.py beside
the existing ordering cases, plus two things that suite cannot assert.
First, an API-level test that the create path answers 409 and not 507:
the except ordering is the entire mechanism per F9, and a scheduler test
asserting the exception type passes whichever clause catches it, so the one
mistake the ordering exists to prevent is invisible to it. Second, a unit
test over the preflight abort path exercising affinity_failure at both
:166 and :170 -- that path runs only in the queue daemon under cluster
CI, so an error there passes the pull request and lands in the merge queue.
Also assert that a create requesting no affinity at all still schedules,
which is the cheapest possible guard on the short-circuit above. Commit
subject: Add binary affinity constraints to the scheduler.
Hard requirements, as a checklist. Same purpose as step 1's: the prose argues each of these, and this is what an implementer checks their diff against.
- The binary branch is validated above the per-value
int()coercion andreturns there, so a list-valued specification never reaches the loop step 3 taught to 400 it. - The shape is discriminated on the four reserved key names. A dictionary mixing the shapes is refused, not resolved.
- The require stage is the last admission filter, immediately
before the affinity block, so the
placementsmemo moves by two statements and is shared with the scorer. - It returns immediately when neither
require_*list is present. - It calls the scorer's neighbour predicate rather than restating it, and that predicate skips all four of missing row, self, untagged and different namespace.
_log_and_raise_on_error()grows a defaultedexception_classand no existing call site changes. How the constraint detail reaches the 409 body is decided in this step and recorded.AffinityConstraintUnsatisfiable'sexceptclause sits above theLowResourceExceptionone on the create path, with a comment saying why.affinity_failureis initialised before thetry, not only inside theexceptsuite (PEP 3110).- The affinity walk stays unconditional here. The short-circuit lands in step 5 with the mapping.
- Tests: the 409 asserted at the API level, the preflight abort
guards at
:166and:170, a create requesting no affinity, and both directions of the namespace scope. - The result is checked against
shakenfist/data/database_load_budget.yamlbefore the commit is proposed.
Step 5 brief: the transition mapping¶
(shakenfist) The transition mapping, per F4. Map weighted specs
mechanically at the point the scheduler reads them: positive value to
prefer_with_tag, negative to prefer_without_tag, zero to nothing. Emit
a deprecation event (not a log line -- this needs to reach an operator)
where the spec is accepted, not where it is consumed, once per
acceptance. Accept-time needs no durable marker at all, which is the point:
the alternatives are an attribute write on the scheduling hot path or a
read of the instance's own event history on that same path, and both are
the kind of addition the budget check below exists to catch. It also puts
the warning where the caller can act on it, at the moment they submit the
deprecated form, rather than at some later reschedule. Do not make it
per-process (it would reset on every daemon restart) or per-schedule (the
scheduler runs this path on every create and every reschedule). The
emission site is not _validate_instance_metadata itself, and an earlier
draft of this plan said it was. That function is module level with the
signature (key, value) (external_api/instance.py:1384), so it has no
object to call add_event() on; and on the create path it runs at
:792-799, under the comment 'Validate metadata before instance creation',
which is before Instance.new() at :810. Emitting there would have
produced no event at all on the create path -- the path test_affinity
exercises and the path an operator using the weighted form almost certainly
takes -- leaving a deprecation warning that silently reaches nobody, which
defeats the one requirement it exists to meet. Split a predicate out of the
validator instead, _affinity_spec_is_weighted(value) beside it so the
shape test lives in one place, and emit at the three sites where an
instance object is in scope: on the create path in the
metadata-initialisation loop that already holds inst (:835-838), and in
both metadata endpoints via instance_from_db.add_event() beside the
existing 'set metadata key request from REST API' events (:1378-1380 and
:1428-1430). Record the limit this choice accepts, in the step's commit
message and in step 6's documentation: accept-time covers new acceptances
only, so every instance already carrying a weighted spec when this lands
warns nobody, ever. That is tolerable because the event is not the
migration mechanism -- but F4 commits to removing the weighted form in a
later release, and an operator will need to find those instances then, so
step 6 owes them a way to. Confirm the change against
shakenfist/data/database_load_budget.yaml before proposing the commit: an
unbounded per-schedule event on a still-supported path is exactly the kind
of addition that moves a measurement CI enforces, and the weighted form is
expected to survive at least one more release. Decide and record whether
test_affinity moves to the binary form or stays weighted: there is a real
argument for staying, since finding 7 makes it the only automated proof the
mapping works, and a separate binary case can be added beside it. Add
that separate case rather than leaving it optional. As the rest of this
plan stands, all four constraints could ship with unit coverage only,
against a project standard that prefers functional tests to unit tests
where only one is possible (CLAUDE.md). Add a cluster-CI method in
cluster_ci_tests/test_scheduler.py reusing step 1's event helper: a
prefer_with_tag create asserted through the same tier check, and a
require_with_tag create naming a tag nothing carries, asserted to fail
with a 409. The second needs no successful create at all and is therefore
cheap -- and it is the one case a unit test with mocked placements
exercises least convincingly, since the bootstrapping dead end and the
namespace scope are both properties of a real cluster's instance
population. Drive it against sfcbr before proposing the commit, per the
merge-queue-only rule. Do not remove the weighted form; that is a later
release, and the removal needs its own deprecation window. Unit tests for
the mapping including the zero and mixed-sign cases. Commit subject: Map
weighted affinity onto the binary form.
Step 6 brief: documentation¶
(shakenfist) Documentation. The page to rewrite is
docs/user_guide/affinity.md (registered under the Affinity key
in mkdocs.yml's nav -- cite the key rather than a line number, which
finding 2 asks for everywhere and which an earlier draft of this brief
got wrong by two), and the diagnostic material goes in
docs/operator_guide/scheduler.md. Naming them matters because affinity.md
as it stands contradicts F2 in an admonition: it says Shaken Fist
"filters possible candidate hypervisors based on the affinity coefficients
specified", which is the exact over-strong reading F2 identifies as the
root of #3565, and it teaches the weighted form as the only form with a
-100..100 recommendation. A step told only "in docs/" can satisfy itself
by adding a new page and leave that one teaching the deprecated form and
the wrong guarantee, which is worse than before: two pages disagreeing.
Rewrite it. Note the claim is not simply false any more -- require_*
really does filter -- so the fix is to say which half filters and which
half ranks, not to delete the sentence. The second half of that same
admonition is wrong too, and per F10 it is the more dangerous half: "this
decision is only made on the original start up of an instance, and does not
apply later", and that changing an instance's tags or affinity "will not
affect that instance in any way". find_candidates() runs on every
reschedule and Instance.affinity reads metadata live, so neither has ever
been true -- and now that require_* can refuse rather than merely
re-rank, a reader who believes it can lose a restart they were told was
safe. Say what actually happens: the specification is re-read on every
restart and reschedule, a changed one applies from then on, and a hard
constraint no node can satisfy refuses the restart after trying every node.
In it, state what soft affinity promises and -- more importantly -- what it
does not: a preference is consulted when there is a choice, and a
single-candidate placement is not a preference being honoured or violated.
Document the four binary constraints in one table, with the hard/soft
distinction and the namespace scope in that same view rather than as prose
paragraphs elsewhere on the page. The four names share a _with_tag /
_without_tag suffix family and carry two separate counterintuitive
readings -- prefer_without_tag is a term in a sum and not a soft veto,
and require_without_tag is scoped to the requesting namespace and is
therefore not an isolation primitive -- so an operator who reads about one
name must see both caveats without hunting. Document the weighted form's
deprecation and its mapping, and the fact that require_* can make a
create fail with no candidates where the weighted form would silently place
anywhere. Document the bootstrapping dead end explicitly, because it is
the first thing an operator hits and it reads as a bug: the constraints
match tags on instances already placed on a candidate node, so the first
instance of a group requesting require_with_tag: ['web'] ejects every
candidate and gets a 409, and stays that way for as long as nothing in the
namespace carries the tag -- including any instance created under the same
constraint. State the workaround in the same breath: create the seed
instance carrying the tag and without the require_* constraint, or use
prefer_with_tag, which degrades to a ranking that nothing to rank leaves
alone. Document that prefer_* terms sum, across neighbours and across
tags, with the worked two-tag example from F4: with prefer_with_tag:
['a'] and prefer_without_tag: ['b'] both requested, a node hosting three
a instances and one b scores +2 and beats a node hosting one a and no
b at +1. prefer_without_tag reads as a soft veto and is not one; it is
a term in a sum, and an operator who learns that from a placement rather
than from the documentation will read it as a bug. Include the diagnostic
recipe, which is the durable output of this whole investigation: read
schedule have highest affinity and schedule final candidates from the
instance's events to tell "scored wrong" from "had no choice". Cover both
shapes of that event, per step 4: a create which requested affinity
carries a populated affinity_detail with a per-candidate score,
instance_count and considered breakdown, while a create which requested
none carries affinity_detail: {} with candidates, highest_affinity: 0
and by_affinity still populated. The second is the scorer correctly
declining to run and not a diagnostic gap, and it is the shape an operator
meets most often, so a recipe written only against the first sends them
looking for events that were never going to exist. Include a discovery
recipe for existing weighted specs. Specify it as a client-side loop
over instance list calling instance show per instance, and accept
that cost: there is no bulk or filtered metadata read behind it.
mariadb.get_object_metadata() is per-object, and no sf-ctl subcommand
exposes a query. So the honest options are an N+1 loop now or a new
server-side capability, and a new capability is not something a
medium-effort documentation step should be discovering halfway through --
it would be its own step, and this phase does not need it. The loop is a
one-off run before an upgrade, not a monitoring query, so N+1 is the right
trade here; say so in the documentation rather than leaving a reader to
wonder why it is shaped that way. Two traps to write into the recipe:
instance show renders metadata as Python literals and not JSON
({'static-runner': -10}, single quotes), so it parses with
ast.literal_eval and a json.loads version silently reports a clean
cluster; and the weighted/binary test is the same one the server uses, a
dictionary using none of the four reserved names. The recipe lists
instances whose affinity value is the weighted shape, because step 5's
deprecation event covers new acceptances only, and the removal release will
need that list. Run the recipe once against a real cluster and record
what it returned. It is the only thing standing between the removal release
and a silent breakage, and a recipe that has never been executed is not a
mitigation -- everything else this plan commits to is falsifiable, and this
should be too. Say explicitly that the skip expectation is documentation,
not enforcement. A skip is green, so nothing fails if slim-primary
starts skipping too, and the "does not skip on a healthy three-node run"
criterion is checked once by hand rather than continuously -- which is a
standing gap in the middle of a phase whose stated fear is that a silent
skip reads as a pass. It is a deliberate trade and not an oversight: the
obvious automation, failing rather than skipping when get_nodes() returns
three or more, would fail every slim-tier run, and slim-tier is
expected to skip until PLAN-ci-cloud-sizing lands. Record the trade where
a reader meets the expectation, and record what would let it be enforced:
once that plan lands and slim-tier is expected not to skip either, the
guard becomes a fail rather than a skip on any topology with three or more
nodes. Record which of test_affinity's two skips is expected on which CI
topology (F3): affine node not a candidate is expected on slim-tier
until PLAN-ci-cloud-sizing lands, and neither skip is expected on
slim-primary, so a permanently-skipping test cannot pass for green. Two
existing pipeline descriptions go stale the moment step 4 lands, and both
must be updated here. docs/operator_guide/scheduler.md narrates the
filter and scoring order around :40-76, and ARCHITECTURE.md:170 carries
the same order in one sentence; inserting a hard require_* stage between
sufficient_free_disk and the affinity block makes both incomplete. This
plan pins every other artifact to a path, so guessing that
ARCHITECTURE.md is "unlikely to need touching" was a gap in its own
standard rather than a judgement. AGENTS.md genuinely is unlikely to need
touching -- no convention changes here -- but check rather than assume.
Commit subject: docs: say what soft affinity promises.
Risks and mitigations¶
F2 reads as closing a bug by changing the test. It is the shape of an excuse, and a reviewer should push on it. The defence is finding 3 and only finding 3: a traced run where the candidate set was one node, the anti-affinity score was computed correctly, and the placement happened anyway because there was nowhere else. If that trace is wrong the decision falls. It is linked from the issue and the payloads are in the comment, so this is checkable rather than assertable. Mitigated further by F3, which makes the rewritten test stricter than a flake-suppressing change would be -- it refuses to report a pass it cannot support.
The rewritten test could pass on a cluster where affinity is broken. If the assertions only check that the affine node was in the winning tier, a scheduler that scored everything equally would pass. Mitigated by asserting the inst3 case as well -- inst1's node must be outside the winning tier for a request that scored it negative -- which a degenerate scorer fails. Checked in step 1 by mutation: make the scorer ignore negative contributions and confirm the inst3 assertion, and only that one, fails.
A hard require becomes a new way to make creates fail. A
require_with_tag for which no candidate node hosts a matching
instance ejects every candidate. That is what it is for, but it
converts a class of silent mis-placement into visible refusal,
and an operator who adopts it casually will see creates fail
that used to succeed. Mitigated by
step 6 documenting the difference explicitly and by require_*
being opt-in -- no existing spec maps onto it, since the
weighted mapping produces only prefer_* forms.
require_with_tag has a bootstrapping dead end, and it looks
exactly like a bug. The constraint matches tags on instances
already placed on a candidate node, so the first member of a
group cannot be created under it: nothing in the namespace
carries the tag, every candidate is ejected, and the answer is a
409 which repeats forever -- including for an attempt to create
the very instance that would satisfy it. This is the model
working, not a defect, but it is the first thing an operator
adopting require_with_tag will meet and it arrives as a
refusal with no obvious way forward. Mitigated by step 6
documenting it beside the constraint table with the workaround
stated in the same place: seed the group with an instance that
carries the tag and not the constraint, or use
prefer_with_tag, where having nothing to rank is harmless.
Not mitigated by a code change: a require which silently
relaxes itself when it is inconvenient is not a require, and
that is the same softening F7 declines.
The weighted form outlives its deprecation. Nothing forces its removal, and finding 7 gives a standing reason to keep it (the CI suite uses it). Mitigated by step 5 recording the removal as needing its own release and its own window, rather than implying this phase's mapping is the whole migration. Accepted: one more release of a deprecated form is cheap.
CI cannot verify step 1 on a pull request. The (collection)
matrix is skipped on pull_request. Mitigated by the step's
brief requiring the rewritten test to be driven against sfcbr
first, which is the same rule and the same reason as phase 4b
step 4.
Definition of done¶
- No assertion in
test_affinitycomparesinst2['node']orinst3['node']to anything. Checked withgrep -n "inst[23]\['node'\]" cluster_ci_tests/test_scheduler.py, which today returns lines 125 and 129 and must return nothing inside an assertion afterwards. Stated this way rather than as a ban on['node']outright, because step 1 needsinst1['node']as the lookup value it checks against the winning affinity tier -- that is not a placement assertion, and a blanket grep would either fail a correct implementation or push step 1 into deriving inst1's node from events to satisfy a check. F2's guarantee is about inst2 and inst3. (Run at planning time: the more obviousinst\['node'\]matches nothing at all, because the assertions nameinst1,inst2andinst3.) -
test_affinityskips, with a message naming the candidate count, when the unforced pass'sschedule have highest affinityevent holds fewer than twoaffinity_detailentries. Three wrong sources are ruled out and none may reappear:schedule final candidates(post-narrowing), any affinity event matched by message alone (the forcedfind_candidates()calls each publish one with a single entry), andextra['candidates']within the correct event, which is the post-scoring winning tier and not the input set. -
test_affinityalso skips, with a different message, when inst1's node is absent from the unforced pass'saffinity_detailkeys -- the affinity target was ejected by an admission filter before scoring. Falsifiable by grepping the two skip messages: one names a count, the other names the missing node, and CI output tells them apart. Without this the test fails on the one mechanism F2 holds is not a scheduler defect. -
test_affinitydoes not skip on a healthy three-node run. This is the check that catches the whole family of gate mistakes above, both of which were caught in review rather than by a criterion, and it is falsifiable in one run against sfcbr. -
test_affinityfails, rather than skipping, when the unforcedschedule inputs/schedule have highest affinitypair cannot be found -- a missing event means the read was wrong, not that the run was degenerate. - Mutating the scorer to ignore negative affinity contributions fails the inst3 assertion and no other test.
- A create with
require_with_tagnaming a tag no co-located instance carries fails with 409, naming the constraint and the stage -- not 507, not a traceback and not a silent placement. 507 is the specific wrong answer here (F9): it tells the caller the cluster is full when it is not, and an earlier phrasing of this bullet ("a no-candidate refusal naming that stage") was satisfied by it. (Instance tags, not node properties -- Shaken Fist has no node capability tags.) - A create carrying a weighted affinity spec emits the
deprecation event, checked by reading that instance's own
events after a create. Stated against the create path
deliberately: the two metadata endpoints are the easy half,
and an implementation that covers only them warns nobody
who used the form the way
test_affinitydoes. -
test_affinity's event read passesevent_type='audit'andlimit=1000explicitly, and pairs the unforced scheduling events byrequest_id. -
docs/says in as many words thatrequire_without_tagis scoped to the requesting namespace and is not an isolation primitive. - A weighted spec and its mapped binary equivalent produce
the same candidate ordering for specs in which every
weight has the same magnitude (which includes every
single-tag spec; positive scaling is order-preserving,
which is why magnitude and not tag count is the real
condition), asserted
by a unit test that builds both and compares. Mixed
magnitudes are expected to diverge and this is asserted
too, not left as an unstated exception:
{'a': 100, 'b': 1}maps toprefer_with_tag: ['a', 'b'], so a node carrying onlyband a node carrying onlyatie at +1 where the weighted form ranked them 1 against 100. F4 discards the magnitude deliberately, so a criterion demanding identical ordering in every case would be a gate step 5 cannot pass, and the only way to pass it would be to abandon F4. - The discovery recipe for existing weighted specs exists in
docs/, and has been run once against a real cluster with its output recorded. Step 5's deprecation event reaches new acceptances only, so this recipe is the whole mitigation for every instance already carrying a weighted spec when the removal release lands -- and it is the one load-bearing deliverable in this plan that would otherwise have no falsifiable criterion at all. - #3565 is closed with the F2 reasoning recorded on it, and the forced-candidate retry defect exists as its own issue, referenced from #3565.
- No document still tells phase 6 to rule out the lost-spreading mechanism without saying, at that point, that it is already ruled out. The superseded instruction stays in the master plan stub, immediately under a dated note discharging it, exactly as D6's three positions stay under F2 and F7. Phrased this way because the earlier phrasing -- "no document still tells phase 6 to rule it out" -- was false in the same commit that asserted it, and a criterion false at merge time is not a criterion.
-
docs/states that a single-candidate placement is neither a preference honoured nor violated. - An affinity dictionary value which is a list, a dict,
NoneorInfinityis refused with a 400 from instance create and from both metadata endpoints -- that is,{'affinity': {'first-node': ['a']}}, not{'affinity': ['a']}. Today all four return 500.Infinityis listed because it is the one that a two-exception fix misses:int()raisesOverflowErrorfor it, notTypeError, and flask hands the bare JSON literal through (external_api/base.py:129, and the comment at:308-320documenting the same trap). A bullet naming only the first three would have been signed off with that hole open. Stated at this level deliberately: the outer cases are already handled, since anaffinityvalue which is a list is refused byisinstance(value, dict)(:1398-1400) and one which isNonebyif not value(:1387-1388). Only the inner coercion leaks. -
{'affinity': {'first-node': true}}is refused with a- This one is a deliberate compatibility break, not
a 500 being fixed:
int(True)is 1, so it is accepted today, and under F4's mapping it would quietly becomeprefer_with_tag: ['first-node']. Recorded as a break in the step 3 issue and commit message.{'affinity': {'first-node': '3'}}still succeeds -- a shape test would have refused it too, which is why step 3 widens theexceptrather than replacing the coercion.
- This one is a deliberate compatibility break, not
a 500 being fixed:
- Nothing in the preflight abort path reads the
exceptclause's exception variable outside its suite. Falsifiable by greppingnode_inst_netdesc_op.pyfor a bareebelow:162: the guards at:166and:170must test a local captured inside the suite, and only:276may test the exception itself. ANameErrorhere passes the PR and fails in the merge queue, because that path runs only under cluster CI. -
_log_and_raise_on_error()grows a defaultedexception_classargument and every pre-existing call site is unchanged, checked by grep. Without it the require stage cannot both publish through the helper and raiseAffinityConstraintUnsatisfiable, which two parts of this plan had asked for at once. - A create requesting no
require_*constraint performs no more_placed_instances()reads than it does today, measured againstshakenfist/data/database_load_budget.yaml. Hoisting the memo alone does not achieve this; the require stage must return before touching it. Stated as "no more than today" rather than "no more than before the hoist" because the affinity walk's unconditional call is the existing baseline, which step 4 leaves alone: the short-circuit that would reduce it belongs to step 5, so this bullet asks step 4 for a wash and not for a saving. - After step 5, a create requesting no affinity at all
performs fewer
_placed_instances()reads than it does today, measured the same way. This is the criterion the short-circuit is for, and it is separate from the bullet above because the two steps promise different things: step 4 promises to add no cost, step 5 promises to remove some. Step 5 is where the mapping lands, and the short-circuit cannot be separated from the mapping -- see the step 4 brief for why a short-circuit landing alone would silently stop scoring affinity for every existing caller. -
test_affinityuses therequest_idpairing only when that id is truthy, and fails rather than skips when neither the pairing nor adjacency identifies a pass. A match whose key is null on both sides would select the forced preflight pass and skip forever, which is the fourth route to the failure F3 exists to prevent. - A create requesting no affinity at all still schedules.
This is the cheap guard on the short-circuit: the affinity
walk is what populates
by_affinity, andsorted(by_affinity, reverse=True)[0]over an emptydefaultdictis anIndexErroron the majority path. Falsifiable by any scheduler test that places an instance with noaffinitymetadata, which is most of the existing suite -- the criterion is here because a load reduction that breaks every ordinary create is not a load reduction. - A well-formed binary specification is accepted by all
three entry points -- instance create and both metadata
endpoints -- and not refused by the widened
int()loop step 3 installs. The binary form's values are lists, which is precisely what that loop now 400s, so shape discrimination has to run above it. Two steps each correct in isolation compose into a validator that rejects the feature, and only a criterion spanning both catches it. - The 409 is asserted at the API level, not only as an
exception type in the scheduler suite. F9 calls the
except-clause ordering the entire mechanism, and a test which assertsAffinityConstraintUnsatisfiablewas raised passes identically whether the create path answered 409 or 507 -- so the scheduler suite cannot see the one mistake the ordering exists to prevent. - The preflight abort guards at
node_inst_netdesc_op.py:166and:170are exercised by a unit test. Both run only in the queue daemon under cluster CI, so anything wrong with them -- theNameErrorabove, or a guard testing the wrong flag -- passes the pull request and fails in the merge queue. -
docs/states therequire_with_tagbootstrapping dead end and its workaround in the same place as the constraint table. The first member of a group cannot be placed under the constraint that defines the group, which is the model working and reads as a bug; an operator who meets it undocumented files one. - A weighted affinity specification still produces a
populated
affinity_detail, asserted by a unit test. This is the guard on the short-circuit's other edge: theprefer_*lists of a weighted spec are empty until step 5's mapping fills them, so a short-circuit landing without the mapping stops scoring affinity for every existing caller -- silently, since nothing fails, andtest_affinitywould skip green. The no-affinity bullet above does not catch it, and neither does the weighted-vs-binary ordering test, which belongs to the same step as the mapping. - Both of
test_affinity's skip conditions are evaluated per instance, against inst2's unforced pass and inst3's, and the test skips if either is degenerate. Falsifiable by reading the two assertion call sites: each passes that instance's own events. Guards computed once from inst2 leave the inst3 assertion running against a pass that may have had no choice. -
docs/user_guide/affinity.mdno longer says that affinity filters candidate hypervisors on the strength of the weights. It says so today, in an admonition, and that is the exact over-strong reading F2 identifies as the root of #3565 -- so shipping this phase without touching it leaves the plan's central finding contradicted on the project's own user-facing page, next to the new section saying the opposite. Note the word is not simply wrong now:require_*does filter. The criterion is that the page says which half filters and which half ranks. - All three preflight abort messages name affinity when the
constraint is what failed,
:276included. Falsifiable by grepping the threeAbortInstanceStartsites for a branch onAffinityConstraintUnsatisfiable.:276is the one inside anexceptsuite, so it is the easiest of the three -- which is how an earlier draft came to describe it without instructing it. - The binary model has cluster-CI coverage, not unit
coverage alone: a
prefer_with_tagtier assertion and arequire_with_tagrefusal asserted as a 409. The project prefers functional tests where only one is possible, and the two behaviours least convincing under mocked placements -- the bootstrapping dead end and the namespace scope -- are properties of a real cluster's instance population. - A well-formed binary specification schedules without
raising, asserted by a unit test that drives
find_candidates()with the binary shape -- and so does a stored specification the validator would refuse today, such as a weighted entry whose value is a list. The first is the new API surface meeting the old coercion; the second is every instance whose metadata was written before step 3 landed. Either one raising insidefind_candidates()is a 500 on instance create, because that path catches onlyLowResourceExceptionandCandidateNodeNotFoundException. The weighted-spec bullet above guards the opposite direction and does not cover this. - Both directions of the namespace scope are asserted by unit
tests: a node hosting a tagged instance in another
namespace neither satisfies
require_with_tagnor tripsrequire_without_tag. The plan decides this scope as a trust boundary and requires it documented, but a leak in therequire_without_tagdirection is a cross-tenant probe -- a caller learns another namespace's tags by watching which nodes refuse to take their instances -- so it wants a regression guard and not only a sentence. Cheap with mocked placements, and falsifiable by deleting the namespace skip and watching both fail. - The require stage reuses the scorer's neighbour skips rather
than restating them, asserted by a test that a placed
instance carrying tag
xcan still be rescheduled underrequire_without_tag: ['x']. Preflight callsfind_candidates()on every restart, by which time the instance is one of its own node's neighbours, so a filter which counted itself would refuse the node it is running on and then every other node too. The scorer's own self-skip has no test today, so this criterion closes a gap on both sides of the shared predicate. -
docs/user_guide/affinity.mdsays that a hard constraint is re-evaluated on every restart and reschedule, not only at create, and that a changed specification applies from then on. Both halves of the admonition it replaces are wrong today, and F10 explains why the second half is the more dangerous one now that a re-read can refuse rather than merely re-rank. - The stage-ordering narrative in
docs/operator_guide/scheduler.mdand the one-line pipeline summary inARCHITECTURE.md:170both name the hard affinity stage in its place between disk capacity and affinity scoring. Falsifiable by grep. Step 4 inserts a pipeline stage, and a pipeline described in two places is wrong in two places. -
ARCHITECTURE.md's one-line summary no longer lists queue health or disk bandwidth among the hard pre-filters applied before affinity is scored. It does today, and both are wrong:queue_state(scheduler.py:624) andsufficient_idle_disk(:634) run after the scoring block ends at:603, deliberately and asdocs/operator_guide/scheduler.md:58-72explains at length. An insert-only edit satisfies the bullet above while leaving the summary contradicting both the code and the operator guide, which is why this is its own criterion. -
pre-commit run --all-filespasses.
Close-out (2026-09-01)¶
Steps 1, 4, 5 and 6 merged as #3971 and step 3 as #3972, on
2026-09-01 and 2026-08-31 respectively (merge times in UTC).
#3957 was this plan file itself and carried no step work --
its three changed files are all under docs/plans/ -- so an
earlier draft of this paragraph, which credited it with
implementation, was wrong; the master plan's phase status note had
it right. Steps 2 and 7 ran on 2026-09-01, a day after the code
landed, which is why every status column in this file said Not
started against merged work for a day: step 7 edits the plan file
that step 1 merges, so it cannot run until that merge has
happened, and nothing enforced the follow-up.
#3565 was closed on 2026-08-31 with no comment. Step 2's
disposition was posted afterwards, as comment 5489559241 on
3565. Recorded because a silent close is the failure mode this step¶
exists to prevent, and it happened anyway.
The placement retry behaviour of finding 6, and #4001¶
Step 2 filed #4001 per F8, "Preflight redirect silently
re-offers the node it excluded when the exclusion set is empty".
It was fixed and closed on 2026-09-01, by 9d7fbc24c on the
issue-fix workflow, while this close-out was being written -- so
it is recorded here rather than carried as future work. The fix
tests candidates is not None (scheduler.py:594) in place of
the truth test, and an empty forced list now reaches
pre_schedule and raises LowResourceException instead of
falling through to the else at :607 which rebuilds the list
from every key in self.metrics. The schedule inputs event's
forced_candidates flag (scheduler.py:583) reports the same
distinction, so the audit trail no longer says false for a call
that was forced.
Note what the pinning exercise actually concluded, because that
part is unaffected by the fix: the trace does not show a defect.
What looked like a retry landing on a just-refused node is the
create path's designed demand waiver
(external_api/instance.py:951-958) admitting a node the guard
had refused on demand alone, followed by preflight re-validating
that placement through its opening forced call
(node_inst_netdesc_op.py:159) eighteen seconds later on the
chosen node. The create path's second walk re-walks the list it
already holds and never calls find_candidates() again, so it
cannot publish a forced event at all. #4001 was the fourth
mechanism, filed on a reading of the code -- said in as many words
on the issue -- rather than on the trace, and the fix's own commit
message agrees that only the empty-list case changes. The step 2
brief above still cites scheduler.py:400 and :441 for that
branch; those are the line numbers as they stood when the brief
was written and no longer resolve.
What the definition of done actually verified¶
Thirty-eight of the thirty-nine criteria are ticked against evidence gathered at close-out rather than against recollection. The three worth naming:
-
"
test_affinitydoes not skip on a healthy three-node run" is the criterion this phase's whole test rewrite turns on, and it is now discharged with a citation: run33452092452, job99684426210,Debian 12 cluster, whereTestAffinity.test_affinity ... okin 29.8s alongsidetest_binary_affinity_prefers_the_tagged_node ... okandtest_unsatisfiable_require_with_tag_is_refused ... ok, 102 passed and 0 failed. The same run's stage census showsaffinity_constraintsdropping 5 candidates for "no co-located instance carries a required tag", so the hard stage is demonstrably firing in cluster CI and not merely present. -
The two
_placed_instances()read-count criteria were measured, not reasoned about. A create requesting no affinity performs 3 reads on98d15817d(the develop commit before this phase's implementation merged, one read per candidate node) and 0 onb54f126e5. Measured by counting calls throughmock.patch.objectunder the existing mocked-mariadb harness in both trees, three candidates each. Step 4's criterion asked for a wash and step 5's for a reduction; the reduction is total, becauseif not scoring_tags(scheduler.py:738) skips the walk outright and the require stage returns before touching the memo (:694). -
One criterion is left unticked, and deliberately: "mutating the scorer to ignore negative affinity contributions fails the inst3 assertion and no other test". That mutation was step 1's check at implementation time. Re-running it at close-out needs a cluster CI cycle per mutation, since the inst3 assertion lives in the cluster suite, and this close-out did not spend one. It is recorded as unverified rather than ticked on the strength of somebody having probably done it.
Future work¶
- The one unticked done-criterion. "Mutating the scorer to
ignore negative affinity contributions fails the inst3
assertion and no other test" is recorded above as unverified,
and is the only criterion guarding the negative half of the
rewritten test. It is carried here rather than closed out with
the phase, because marking the phase
Completeotherwise retires it silently. Discharging it costs one cluster CI cycle per mutation and is worth folding into the next change that spends one ontest_scheduler.pyanyway. - Removing the weighted affinity form. Needs its own release and deprecation window; see F4 and the risk above.
- Whether an activity metric belongs in the ranking at all.
Phase 00a's surviving observation:
cpu_load_1measures activity, not occupancy, so a node packed with idle instances ranks ahead of a busier node with more room. Now that the capacity counters supply an occupancy measure the ranking could use instead, this is answerable -- but it is a ranking-model change on top of a ranking-model change, and doing both at once would make neither reviewable. Deliberately left. - Guaranteed co-location, if a workload ever needs it. F7
names the honest mechanism: a hard
require_with_tagplus enough capacity, never a soft preference with leverage. - The
valueparameter declaration on the metadata endpoints. Every metadata setter in the tree declares('value', 'body', 'string', ...)while its handler accepts dicts and lists, and step 4 widens what the instance one carries again, to a dict of lists.CLAUDE.md's parameter rules say the token should match what the handler accepts, so the declaration is wrong -- but it is wrong in fourteen places across seven modules (external_api/instance.py,interface.py,auth.py,artifact.py,node.py,blob.py,network.py), and correcting the instance one alone would leave seven sibling endpoints disagreeing about the same parameter.ARGTYPEShas adicttoken but no any-JSON one, and metadata values genuinely are any JSON, so the fix needs a token before it needs a patch. Out of scope here because it is pre-existing and repo-wide, not because it is not real. - mypy coverage for the scheduler and the instance API.
Neither
shakenfist/scheduler.pynorshakenfist/external_api/instance.pyis in the incremental rollout (tox.ini:69-91), so this phase's new symbols -- the exception class, the validator branch, the filter stage and_log_and_raise_on_error()'sexception_classargument -- land in modules nothing type checks. Adding either module is a change to those modules entire and not to what this phase adds, which is why no step here promises it: annotating four new functions inside an unchecked module buys an appearance of coverage and no enforcement.docs/developer_guide/mypy.mdhas the rollout strategy;scheduler.pyis the better candidate of the two, being the smaller and the one with fewer flask-shaped signatures.
Back brief¶
Two gates, both cheap to agree and expensive to redo.
Before step 1 is written, agree F2. It closes a scheduler bug report without changing the scheduler, and if that is wrong the whole first half of this phase is wrong. The argument is one traced run; read it before agreeing.
Before step 4 is written, agree the Design section's value
shape -- the four reserved keys under the existing affinity
metadata key, distinguished from the weighted form by value type.
It is an API surface, so it is permanent from the moment it
ships, and it is cheap to change now and tedious once a
validator, a mapping, tests and documentation all reference it.