PLAN: Queue performance and coalescing¶
Status¶
Complete, 11 of 11 phases.
Phase 11 merged to develop as PR #4007 on 2026-09-02, and its
measurement write-up as PR #4033 on 2026-09-05. What the plan
deliberately did not build is carried by issues rather than by this
document: #4017 (the sf-queues half of the multi-column key), #3974
(the two timestamps which would separate database queue sit from time
inside the daemon) and the fairness question step 7 decided against.
All three are described under "Follow-ups not landed here" below.
Reopened on 2026-08-25 with three further phases. The plan reached 8 of 8 while explicitly recording two things it had not proven and two follow-ups it had not built; those are now phases 9, 10 and 11 rather than issues nobody is scheduled to reach. Phases 1-8 are unchanged and remain complete.
Steps 1-6 merged to develop as PR #3194 on 2026-05-26. Step 7
measured the result and decided against explicit fairness; the
measurement, the exclusions it rests on and the decision are in
PLAN-queue-performance-phase-07-measure-and-decide.md
and summarised under "What step 7 measured" below.
Step 8 ran a PUSH-AUDIT.md audit over everything the plan changed,
and found that coalescing had never worked. Steps 4 and 5 -- the
enqueue-side dedup and the worker-side fold -- joined
cluster_operations to object_states on two columns which could
never match: an undashed uuid against a dashed one, and an enum value
against an enum name. Both primitives therefore always returned
"nothing found", and the coalesced sibling ops event had fired zero
times in seven days on sfcbr. Fixed here as #3878, with thirteen tests
in shakenfist/tests/test_mariadb_coalescing.py which execute the
real statements against an in-memory sqlite database built from
mariadb.py's own table definitions. That is enough to catch a join
which can never match, which is what #3878 was, and explicitly not
enough for the fold's FOR UPDATE half: the sqlite dialect emits
nothing at all for FOR UPDATE, so every one of those tests runs
uncontended. #3879 tracks the missing functional coverage that let
the defect sit unnoticed since 2026-05-26, and phase 9 addresses it.
The full audit, including the three headings which found nothing, is in
PLAN-queue-performance-phase-08-push-audit.md.
Review of that fix found a second defect it would have activated:
network_ensure_mesh was declared coalescible, but it does node-local
work and the fold's key is the network alone, so the network node's
survivor would have marked every other hypervisor's pending mesh op
complete without doing their work. It is no longer coalescible, and an
enqueue-time guard now enforces the invariant that made the
queue-blind fold SQL safe in the first place. #3884 tracks the
multi-column key that would let per-node tasks coalesce properly.
Two things this plan changed are deliberately still unproven:
- Step 7's numbers characterise a cluster with coalescing inert.
They were gathered on
sfcbrwhile steps 4 and 5 were doing nothing at all. That does not invalidate the conclusion -- if anything "batched dequeue alone was enough" is the stronger reading of it -- but the system measured is not the system now running. - The fold's cost was never measured.
claim_coalescible_siblingsis recorded inbaseoperation.pyas costing ~200 ms under load, and it now also takesFOR UPDATElocks onobject_statesrows and issues an UPDATE against a hot table. Both are re-measured in phase 9, alongside the functional coverage.
Execution¶
| Phase | Plan | Status |
|---|---|---|
| 1. Visibility | (in PR #3194) | Complete |
| 2. Unified batched dequeue | (in PR #3194) | Complete |
| 3. Coalescible-task metadata | (in PR #3194) | Complete |
| 4. Worker-side dedup | (in PR #3194) | Complete |
| 5. Enqueue-side dedup | (in PR #3194) | Complete |
| 6. Caller-site audit | (in PR #3194) | Complete |
| 7. Re-measure and decide on fairness | PLAN-queue-performance-phase-07-measure-and-decide.md | Complete |
| 8. Push audit | PLAN-queue-performance-phase-08-push-audit.md | Complete |
| 9. Prove coalescing works | PLAN-queue-performance-phase-09-prove-coalescing.md | Complete |
| 10. Where the pre-execution time goes | PLAN-queue-performance-phase-10-defer-latency.md | Complete |
| 11. Multi-column coalescing key | PLAN-queue-performance-phase-11-multi-column-key.md | Complete |
Problem¶
Functional CI on the network-facade branch surfaced a
cluster-wide latency tail: cluster operations -- especially
network_apply_update_dnsmasq on the elected network node --
spent >60 s queued before a worker picked them up. Six instance
starts on one network each enqueued one update_dnsmasq op; the
single-threaded sf-net worker serviced them strictly serially,
and each one paid the full state-machine round-trip
(STATE_EXECUTING write -> work -> STATE_COMPLETE write -> 100 ms
poll lag on the waiter side) even though the actual dnsmasq
restart is sub-second.
The pre-existing topology was already serialised; what changed is that the network-facade refactor moved work that used to run inline on the network node into the queue. Every change now pays the queue+state-machine overhead, and the work backed up.
Approach¶
Six discrete changes, one measurement step, and a closing audit:
-
Visibility: carry
wait_seconds,defer_countandqueue_nameon the per-op event the dispatcher emits. The dispatcher is the only place in the pipeline that observes bothop.created_at(insert time) andstart_time(when the worker is about to callop.execute()), so the per-op queue-wait latency lands directly in eventlog. As implemented, these fields ride on the existing end-of-op'execution duration'event rather than a separate'started executing'event at the pickup boundary: a second event doubles the eventlog cost on the dispatcher's critical path, which profiling identified as the largest per-op overhead this plan added. Seedocs/operator_guide/networking/overview.md. -
Unified batched dequeue: replace
dequeue_work_item(qn)and its direct/gRPC pair withdequeue_work_items(queue_names, limit), served by a single MariaDB SELECT usingORDER BY FIELD(queue_name, ...), scheduled_at. Bothsf-netandsf-queuesuse the new API; the singular method is removed (one way of doing the thing). The previous 10 sequentialDequeuegRPCs per idle poll become one. -
Coalescible-task metadata: declare which (op_type, task) combinations are safe to fold. Subclasses set
coalescible_tasks(frozenset) andcoalescible_target_columnonBaseClusterOperation; the schema module declares the same set underCOALESCIBLE_TASKS. Metadata-only commit -- no behaviour changes. -
Worker-side dedup: inside
BaseClusterOperation.execute, two passes. (a) Within-job: drop duplicate coalescible tasks fromself.tasks. (b) Cross-op: ask MariaDB (one transactional SQL statement) to fold every other pending op on the same target whose entire task list is one of our coalescible tasks -- their state transitions tocomplete, and when the dispatcher eventually surfaces theirwork_queuerow the terminal-state branch drops it cleanly. -
Enqueue-side dedup: at the top of
net_op.create_and_enqueue, look up an existing pending coalescible op on the same target. If found, return that op's uuid instead of inserting a duplicate row. Dedup is skipped when the new enqueue carriesdepends_onorruns_after(those encode an ordering constraint reusing a sibling would erase). The lookup race is bounded -- two concurrent callers that both miss the lookup produce at most one duplicate row, which the worker-side fold (step 4) catches on dispatch. -
Caller-site audit: sweep for fan-out patterns we can collapse before they hit
create_and_enqueue. See the findings section below. -
Re-measure: once steps 1-6 are deployed, the per-op wait distribution tells us whether the tail is gone or whether explicit fairness (bounded staleness, reserved-slot lottery) is still needed for lower-priority queues. Done -- see "What step 7 measured" below, PLAN-queue-performance-phase-07-measure-and-decide.md for the method, and
tools/queue-wait-report.pyfor the tool which produced the numbers. -
Push audit: run
PUSH-AUDIT.mdover the accumulated diff of every step in this plan, rather than over the last step's diff alone. Because the work is already merged, the audit's baseline is the plan's own commit range and notdevelop...HEAD; the phase plan pins the exact range. Each finding is resolved, or declined in writing, before this plan is marked complete. If the audit finds nothing, that is recorded in one sentence.
Phases 9 to 11¶
Added on 2026-08-25. Steps 1-8 answered the question the plan was written to ask -- the wait tail is gone -- but left three things behind, each of which is now a phase rather than an issue waiting for someone to notice it.
-
Prove coalescing works. Step 7's numbers were gathered while coalescing was inert, and the fold's cost has never been measured. There is also still no functional coverage anywhere that coalescing matches a row on a running cluster (#3879), which is what let #3878 sit on
developfor three months. This phase makes the fold's evidence durable enough to assert on, adds that assertion to the functional suite, instruments the fold's cost onto the eventtools/queue-wait-report.pyalready reads, and re-measures onsfcbr. All of that has landed and the measurement is written up under "What step 9 measured" below: the fold is cheap (3.7 ms median, not the ~200 ms the code asserted) and it fires very rarely (7 matches in 1,335 folds over 42 hours). #3879 is closed; the deterministic concurrency coverage the phase declined to build is #3948. The functional test has also been observed to fail withCOALESCIBLE_TASKSemptied, on a real cluster (run 33219587241), so the assertion is known to be load bearing rather than assumed to be. See PLAN-queue-performance-phase-09-prove-coalescing.md. -
Where the pre-execution time goes. This phase began as "the 15 second dependency wait" (#3863): a dependency wait re-enqueued a flat 15 seconds into the future on the queues
sf-queuesdrains, wheresf-netinstead backed off from 0.1 s to a 15 s cap. Step 7 measured that as a 15.78 s p50 on theuser_waitinglane against 0.77 s restricted to operations which never deferred.That fix landed, and phase 9's residual turned out to be the old behaviour rather than a new mystery. #3916 (
dependency_defer_delay()inshakenfist/daemons/queues/workitem.py) gavesf-queuesthe same ladder, derived statelessly from the persisteddefer_count, and #3863 was closed. Over a wholly post-fix window the ladder fires exactly as designed -- 1,391 defers at 0.1 s decaying to 11 at 12.8 s, summing to the unfiltered total exactly -- and a flat 15 second dependency defer happens once in 42 hours. That one event is the ladder reaching its ownMAX_DEFER_DELAYcap atdefer_count >= 8, not a caller takingdefer()'sdelay=15.0default; all 32 such events in the retained span come fromnode_inst_netdesc_op, andnode_blob_opdid not defer once.Phase 9's window was not wholly post-fix, which is what its unexplained population was. The pre-#3916 code emitted its flat wait as
Execution deferred for 15 seconds-- the integer form, because the call site passed no delay and took anintdefault -- while the ladder emits floats. Counting the two forms separately over phase 9's window gives 411 integer-form events, against 1 float-form; and the integer form falls to zero from 2026-08-28 onwards and stays there. So the "roughly 400 of 823 first deferrals at 15-17 s" phase 9 could not explain were #3863 itself, still in the sample. See "What step 10 measured" below, which reconstructs the window and withdraws the claim.That resolves the question this phase inherited, and leaves a different one standing.
wait_secondsisstart_time - created_atand conflates queue-sit time with deferral without separating them, and once the pre-fix population is excluded the deep tail is not deferral at all -- over 90% of the operations waiting 15 s or more never deferred once. Phase 10 decomposes the wait and characterises that tail. See PLAN-queue-performance-phase-10-defer-latency.md. -
Multi-column coalescing key. The fold keys on a single target column, which for
net_opmeans "the same network". A per-node task therefore cannot be coalesced: two hypervisors' operations look identical to both dedup paths while doing different work on different hosts. That is whynetwork_ensure_meshwas removed fromCOALESCIBLE_TASKSin phase 8. Generalising the key to a list of(column, value)pairs lets it back in. Filed as #3884. Phase 9 came first deliberately: generalising a primitive that was silently broken for three months, before anything proved it worked on a running cluster, would have repeated the mistake. See PLAN-queue-performance-phase-11-multi-column-key.md.Planning that phase corrected two things this section used to say. First, there are three guards holding the single-column key safe, not the one #3884 names: the enqueue-time
InvalidCoalescibleEnqueuecheck, the fold's ownqueue_is_cluster_wideskip inBaseClusterOperation.execute, and the task's absence fromCOALESCIBLE_TASKS. All three have to move together or the change measures as a no-op. Second,network_apply_create_hypervisoris not part of phase 11 after all. Its model already carriesnode_uuid, so it looks like the cheap half, but it is aNodeNetOpdrained bysf-queues, whose worker pool has no per-target routing key -- the partitioned-worker invariant that makes a per-node fold safe forsf-netsimply does not exist there. It is deferred to a successor issue on its own merits.The code has landed; the measurement has not. The key generalised to a tuple end to end,
network_ensure_meshis back inCOALESCIBLE_TASKSwith the key(network_uuid, node_uuid), both guards became key-aware and family-aware, and unit and functional CI both verify the per-node fold against a real database rather than by inspection. Execution surfaced two corrections beyond what planning anticipated: aNonekey value has to bindIS NULLrather than being refused, or widening the key would have silently switched off the only coalescing the cluster already does; and namingnode_uuidin the key is necessary but not sufficient, because the queue family decides which dispatcher drains the work, so both guards test the family as well. Both are recorded as decisions 8 and the family condition on decision 4 in the phase plan. What has not happened yet is step 11h, thesfcbrre-measurement -- see "What step 11 measured" below.
What step 7 measured¶
Read these numbers knowing what step 8 later found: both windows were captured with coalescing inert, because the fold and the enqueue-side dedup never matched a row until #3878 was fixed. Whatever improvement is visible below was delivered by the batched dequeue alone, and the fold's own cost does not appear in it at all.
Two windows, both through tools/queue-wait-report.py:
sfcbr, 25h58m, 17,936 operations (2026-08-22T16:33Z to 2026-08-23T18:30Z). Production steady state.- Cluster CI, 33 minutes, 1,248 operations (merge-queue run 32597511463, all five cluster nodes' journals). The site where the original >60 s waits were seen.
The tail this plan set out to remove is gone¶
net_op -- the family containing network_apply_update_dnsmasq --
against a starting point of over 60 seconds:
| Window | n | p50 | p90 | p99 | max |
|---|---|---|---|---|---|
sfcbr, 26h |
6310 | 0.78 | 1.81 | 7.77 | 27.60 |
| CI, 33m | 313 | 0.56 | 1.72 | 2.19 | 23.96 |
A p90 of 1.8 s is the dispatcher's idle poll cap
(IDLE_POLL_MAX_SECONDS = 2.0), so on both clusters the median
operation now waits roughly one poll interval and nothing else.
Explicit fairness is not needed¶
Three tails in the sfcbr window sit above that floor. None of them
is a lower-priority queue being starved by a higher-priority one, and
each was excluded on evidence rather than on argument:
user_waiting, p50 15.78 s (node_inst_netdesc_op). Entirely deferral: restricted to operations which never deferred, the same p50 is 0.77 s, and 962 of 1013 samples had deferred at least once. On the queuessf-queuesdrains, a dependency wait re-enqueues an operation a flat 15 seconds into the future (sf-netinstead backs off from 0.1 s to a 15 s cap, so this cost is specific to the dispatcher rather than general). This is the largest user-visible latency in the whole sample and it has nothing to do with queue order. Filed as #3863.background_high_io, p90 403 s (node_blob_op), with a defer count of zero, which is the shape starvation would have. It is not: during each of the eight worst waits, the same queue executed between 1,281 and 1,334 seconds of its own work, which is more than the wait itself (several workers run concurrently). The queue was saturated with its own blob transfers throughout, not held off by user-facing work, and not gated off by the disk-busy check.networknode/background, p50 4.45 s. All 103 samples are bursts of 20-40 operations arriving within seconds of each other. In the largest, the background lane ran 34.5 s of its own work in a 29 s span whilenetworknode/user_facingran 2.9 s in total. The rising wait across a burst is position in that burst, not higher-priority work crowding in ahead of it.
So the FIELD() ordering's theoretical starvation risk
(shakenfist/mariadb.py) did not materialise in 26 hours of
production traffic which included exactly the bursty contention it
would show up in. Bounded-staleness ordering and reserved slots are
not being added. If the question is reopened, reopen it with a
measurement: the tool is committed and the exclusions above are what
any future claim of starvation has to survive.
What the measurement cost us to build¶
The events could not be read back out of the database at all: an operation is hard deleted 30 seconds after it completes and takes its events' object references with it, so the numbers had to come from the log echo instead. That gap is filed as #3864 and the operator documentation, which claimed 30 day retention, is corrected.
What step 10 measured¶
Step 9 reported a user_waiting p99 of 17.18 s and roughly 400 first
deferrals sitting at 15-17 s which it could not explain. Step 10 built
tools/operation-timeline.py, which joins the Execution deferred
events to the execution duration events on the operation uuid and
splits wait_seconds into the three intervals it conflates:
Two windows on sfcbr, both through that tool:
- Window A, 2026-08-27T13:15Z to 2026-08-29T07:15Z, 42h00m, 23,362 operations and 3,585 defer events (the defer fetch runs from 60 minutes before the window, so that an operation created before it still has its early legs). This is step 9's own window, so it is the one that can confirm or refute step 9.
- Window B, 2026-08-28T13:15Z to 2026-08-30T07:15Z, 42h00m, 23,177 operations and 3,588 defer events. A trailing window ending at the measurement, retained independently in case A ages out.
The two overlap by 18 hours -- 42 hours of new traffic did not exist yet -- so they are a retention hedge and a consistency check, not two independent samples. Read them that way.
No chunk of either fetch came back at Loki's 5000 line ceiling, and
every stream was cross-checked against count_over_time: A matched
exactly on both selectors, B matched on defers and differed by 2 of
23,179 on executions, which is the window edge.
Step 9's 15-17 s population was pre-#3916 traffic¶
The claim is withdrawn, and it is corrected at source above. After
3916 there is no 15-17 s population on the user_waiting lane at all:¶
| window A | window B | |
|---|---|---|
user_waiting operations |
3,821 | 3,815 |
| of those, deferred at least once | 1,360 | 1,347 |
of those, defer_count == 1 |
473 | 438 |
defer_count == 1 in the 15-17 s band |
0 | 0 |
longest defer_count == 1 wait |
3.79 s | 2.32 s |
Step 9's dataset was reconstructed rather than guessed at. Sweeping
the window boundaries over the retained stream and re-running
tools/queue-wait-report.py reproduces every number it published, at
one and only one window -- 2026-08-27T03:03Z to 2026-08-28T21:00Z,
exactly ten hours earlier than its label:
| published by step 9 | reconstruction | |
|---|---|---|
| operations in window | 26,229 | 26,225 |
| deferred at least once | 1,568 | 1,568 |
defer_count == 1 |
823 | 823 |
defer_count == 1 at 15-17 s |
"roughly 400" | 359 (419 at >= 15 s) |
user_waiting p50 / p90 / p99 / max |
1.20 / 7.28 / 17.18 / 42.83 | 1.20 / 7.30 / 17.19 / 42.83 |
| never deferred p50 / p90 / p99 / max | 0.86 / 1.82 / 2.30 / 9.08 | 0.86 / 1.82 / 2.30 / 9.08 |
Ten hours is this cluster's UTC offset. The log records carry a ts
which is local time with a Z suffix, so a window read off the
records reads ten hours later than the window Loki was actually asked
for. 10,108 of those 26,225 samples fall before the 13:13Z redeploy
that brought #3916 in, and 493 of the 823 first deferrals are pre-fix.
Under the flat fifteen second defer a first deferral lands in the
15-17 s band by construction, and 754 of the 956 pre-fix first
deferrals do. So the population step 9 could not explain was #3863
itself, sampled after its fix had shipped.
That reconstruction is confirmed independently by the message text
itself, which does not depend on getting the window boundaries right.
The pre-#3916 code passed no delay and took an int default, so it
emitted Execution deferred for 15 seconds; the ladder computes a
float and emits 15.0. The two forms are separable in the log:
| integer form (pre-fix) | float form (ladder cap) | |
|---|---|---|
| in step 9's reconstructed window | 411 | 1 |
| 2026-08-26 | 772 | -- |
| 2026-08-27 | 632 | -- |
| 2026-08-28 onwards | 0 | -- |
411 integer-form events against step 9's "roughly 400" unexplained first deferrals, falling to zero the day after the redeploy and staying there. A search for the float form alone -- which is what the phase 10 survey ran -- sees one event and concludes the flat wait is gone, which is true of a post-fix window and false of step 9's.
The two accounts of the deep tail were about different populations¶
Step 10b's own preview found 97 of 105 operations waiting >= 15 s had never deferred at all, which reads as a flat contradiction of step 9. It is not one. Both are real and they are disjoint populations, and the split holds in both windows:
| operations waiting >= 15 s | window A | window B |
|---|---|---|
| total | 153 of 23,362 (0.7%) | 191 of 23,177 (0.8%) |
| never deferred | 141 (92%) | 173 (91%) |
| deferred | 12 | 18 |
| in the 15-17 s band | 11, all never deferred | 8, all never deferred |
Step 9 was looking only at user_waiting, which is the only lane on
which anything defers at all -- 1,360 of 1,360 deferred operations in
window A and 1,347 of 1,347 in window B are on it, because a
dependency wait is what defers and dependency-bearing work is enqueued
user_waiting. Step 10b was looking at the whole cluster, where the
deep tail is on the background lanes and never deferred once. Step 9's
error was the ten hour label, not the lane.
Where the time goes when an operation does defer¶
Deferred operations only, since an operation which never deferred has no intermediate event and therefore no decomposition:
| window A (n=1,360) | p50 | p90 | p99 | max | share of summed wait |
|---|---|---|---|---|---|
| total wait | 1.60 s | 3.17 s | 13.66 s | 42.83 s | 100.0% |
| created -> first dequeue | 0.43 s | 1.61 s | 2.03 s | 3.52 s | 30.2% |
| summed defer delay | 0.30 s | 1.50 s | 12.70 s | 40.50 s | 49.1% |
| residual | 0.19 s | 1.12 s | 3.79 s | 6.78 s | 20.7% |
| window B (n=1,347) | p50 | p90 | p99 | max | share of summed wait |
|---|---|---|---|---|---|
| total wait | 1.66 s | 3.23 s | 26.12 s | 293.80 s | 100.0% |
| created -> first dequeue | 0.44 s | 1.61 s | 1.99 s | 3.81 s | 24.7% |
| summed defer delay | 0.30 s | 1.50 s | 25.50 s | 280.50 s | 55.3% |
| residual | 0.24 s | 1.14 s | 5.16 s | 16.55 s | 20.0% |
The interval which holds the time depends entirely on how far up the ladder the operation got, and the crossover is sharp (window A):
defer_count |
n | wait p50 | dequeue share | delay share | residual share |
|---|---|---|---|---|---|
| 1 | 473 | 1.06 s | 76.4% | 8.8% | 14.8% |
| 2 | 297 | 1.34 s | 59.1% | 21.1% | 19.8% |
| 3 | 205 | 1.51 s | 28.2% | 41.5% | 30.3% |
| 4 | 259 | 2.80 s | 11.9% | 57.7% | 30.5% |
| 5 | 82 | 3.42 s | 12.6% | 67.4% | 20.1% |
| 6 | 23 | 6.96 s | 8.9% | 75.3% | 15.7% |
| 7 | 10 | 13.45 s | 3.6% | 90.4% | 6.0% |
| 8 | 10 | 30.45 s | 2.9% | 84.1% | 12.9% |
| 9 | 1 | 42.83 s | 3.6% | 94.6% | 1.8% |
At one or two deferrals the wait is dominated by the initial queue sit
before anybody looked, which at a p50 of 0.43 s and a p90 of 1.61 s is
the dispatcher's idle poll interval (IDLE_POLL_MAX_SECONDS = 2.0)
and not contention. From three deferrals up the ladder itself is the
wait, which is the ladder working: an operation that has waited eight
times is waiting on something slow, and backing further off is the
intended response.
Every deferred operation over 15 s in either window is the same thing:
12 in A and 18 in B, all node_inst_netdesc_op, all user_waiting,
all waiting_on an artifact_fetch_op, all at defer_count 7 or
more. In window A they account for 308.2 s of ladder delay against
11.7 s of initial queue sit and 46.0 s of residual. An instance
waiting on an image fetch is the operation the ladder exists for, and
nothing here needs fixing.
The ladder's rungs are served a little early, and it does not matter¶
The residual is redelivery slack: for each leg, how much longer it actually took than the delay that was asked for.
| requested delay | legs | served p50 | slack p50 | served early |
|---|---|---|---|---|
| 0.1 s | 1,360 | 0.24 s | +0.14 s | 2 |
| 0.2 s | 887 | 0.24 s | +0.04 s | 3 |
| 0.4 s | 590 | 0.63 s | +0.23 s | 175 |
| 0.8 s | 385 | 1.43 s | +0.63 s | 142 |
| 1.6 s | 126 | 1.50 s | -0.10 s | 71 |
| 3.2 s | 44 | 3.17 s | -0.03 s | 25 |
| 6.4 s | 21 | 7.06 s | +0.66 s | 0 |
| 12.8 s | 11 | 13.87 s | +1.07 s | 3 |
| 15.0 s | 1 | 15.22 s | +0.22 s | 0 |
Redelivery is not exact in either direction. 421 legs in window A came back before their delay had elapsed, by up to 0.19 s, and the 1.6 s and 3.2 s rungs are early at the median. That is a real fidelity finding and it is the reason a residual can be negative -- 122 operations in window A and 81 in window B have one, which is arithmetically correct rather than a join error.
It is not material to the question. The residual is 20% of the deferred population's summed wait in both windows, its p50 is 0.19 s and 0.24 s, and its p99 is 3.79 s and 5.16 s. Every rung is served within a poll interval of what was asked for. Nobody's latency is explained by redelivery drift.
The deep tail is queue sit on the background lanes¶
That is the answer to the question phase 10 was re-scoped around: the
time is queue sit, not deferral, and it is not on user_waiting.
Ninety-two per cent of the >= 15 s population never deferred, and
splits into two families which are not the same phenomenon:
| never-deferred, wait >= 15 s | window A | window B |
|---|---|---|
node_blob_op, per-node/background_high_io |
97 | 115 |
net_op, networknode/background |
25 | 38 |
net_op, per-node (network)/background |
6 | 13 |
net_macaddr_ip_op / net_op / net_iface_ip_op, user-facing |
13 | 7 |
| summed wait | 45,697 s | 68,577 s |
node_blob_op is a saturated pool, which is what step 7 already
concluded and this window confirms directly. For the six worst waits
in window A, the same queue executed as much of its own work during
the wait as the wait itself lasted -- ratios of 1.03, 1.18, 1.33,
2.36, 2.68 and 2.69 of queue-seconds to wait-seconds, several workers
running concurrently. The queue was full of blob transfers, and
background_high_io is where blob transfers are meant to go.
net_op on networknode/background is not. The whole 1,800+ s
end of both windows is a single incident, visible in both because it
falls in the 18 hour overlap: 12 operations created within one second
at 2026-08-28T23:30:52Z, executed 31 minutes later between
2026-08-29T00:01:42Z and 00:02:01Z, serially, at intervals matching
their own 2.3-2.5 s execution times.
Nothing was stalled while they waited. The same sf-net dispatcher
executed 85 other networknode operations during the span -- 75 on
the higher priority lanes and 10 on the background lane itself
-- for 52.3 s of work. So this is neither a saturated pool nor a
starved lane: the lane was served, and these 12 items were not.
Which leaves two mechanisms, and the retained events cannot tell them
apart. Either the rows were never claimed (networknode/background
is ninth of the ten queues in that dispatcher's FIELD() priority
order, so a batch which fills from higher queues never reaches it),
or they were claimed promptly and then held in sf-net's in-memory
worker pool, which partitions by a stable hash of the target so one
slow operation holds every later operation for that target behind it.
The serial drain at execution-time intervals is what the second looks
like; it is not proof. See the next subsection for the timestamp that
would decide it.
This is 12 operations in 42 hours on a background lane, so the
user-visible cost is nil. But step 7 excluded networknode/
background on the grounds that its waits were burst position rather
than anything structural, and a 31 minute wait on a queue which was
concurrently being served is not that. The exclusion should not be
cited as if it still held without re-deriving it.
What the join cannot say, and what would let it¶
It cannot decompose a never-deferred wait at all -- which is 92%
of the deep tail, so the paragraph above is a classification of the
tail rather than a measurement of it. The join's resolution comes
entirely from defer events, and an operation which never deferred
emits none. Between created_at and start_time there is no event,
so the whole wait is one bracket and both readings of it fit:
- the work item sat in
work_queuebecause no dispatcher asked for its queue, or asked and had no free slot; or - a dispatcher claimed it promptly and it then sat in an in-memory
worker queue --
sf-netroutes claimed items to a partitioned pool by a stable hash of the target, so a slow operation on one partition holds every later operation for that target behind it, andwait_secondscounts that as queue wait.
The 31 minute incident is exactly the case which needs telling apart,
and nothing in the retained events can. Two timestamps on the
execution duration event would settle it, and neither exists today:
dequeued_at-- when the dispatcher'sdequeue_work_itemscall returned the row.dequeued_at - created_atis time in the database queue;start_time - dequeued_atis time inside the daemon after it was claimed. This is the one that matters, and it separates "nobody asked for this queue" from "we had it and sat on it".deliveries-- how many times the work item has been handed out.defer_countcounts deliberate deferrals only, so a redelivery after a crashed or lost worker is currently indistinguishable from a first delivery.
Both are cheap: the dispatcher has the first at claim time and the work item row can carry the second. Neither should be added before somebody wants to answer this question again, which is the same sequencing argument decision 1 of the phase plan makes.
What this does not establish¶
- One cluster, and two windows which overlap by 18 hours. The
net_opincident above appears in both windows because it is the same incident. Nothing here is a second independent sample. - No before-and-after. There is no post-#3916 measurement of a workload matched to a pre-#3916 one; what step 10 has is a mislabel corrected, not an A/B.
- The tail's causes are classified, not measured. Saturation for
node_blob_opis inferred from queue-seconds against wait-seconds, which is the same argument step 7 made and has the same limits. Thenetworknode/backgroundreading rests on one incident. - No claim about dispatch, concurrency or pool sizing is made here, and none should be read into the numbers. Decision 5 of the phase plan puts that out of scope deliberately; what the data says about it belongs in a successor issue with this evidence attached, and that issue is #3974.
- Nothing about coalescing. Step 9 measured it and those numbers
stand untouched; the ten hour mislabel affects the
user_waitinglatency table only in as much as its window was wrong, and the coalescing counts were cross-checked against Prometheus.
Method note¶
- Take the window from Loki, never from the log records. The
records'
tsis local time with aZsuffix, so onsfcbrit runs ten hours ahead of ingestion. That is the whole of step 9's error and it is invisible in the output unless you look for it.tools/operation-timeline.pymeasures the offset and prints it (35999.999 s to 36000.000 s across 26,947 events in window A); a constant offset cancels out of every interval, a varying one would not, which is why it is printed rather than silently corrected. created_atisevent_ts - seconds - wait_seconds. Dropping thesecondsterm places creation after the operation's own defer events and yields negative intervals on long-running operations.- Cross-check every fetch against
count_over_time, which is a metric query and is not subject to the 5000 line ceiling. Both windows above were checked chunk by chunk as well. - The phase plan's survey attributed window A's single 15.0 s defer
event to
node_blob_op.py's bareself.defer(). That was wrong: all 33Execution deferred for 15.0 secondsevents between 2026-08-27 and 2026-08-30 come fromnode_inst_netdesc_op, and are the back-off ladder reaching its ownMAX_DEFER_DELAYcap after eight or more deferrals.node_blob_opdid not defer once in either window. Step 10a's change to that call site is still right -- a baredefer()is a latent flat fifteen seconds -- but no observed event came from it.
What step 9 measured¶
Step 7's numbers were captured while coalescing was inert. Step 9
instrumented the fold itself and re-measured on sfcbr once the
instrumented build was deployed, so for the first time these numbers
describe a cluster on which the fold actually matches rows.
One window, through the same tools/queue-wait-report.py:
sfcbr, 41h57m, 26,229 operations, published as 2026-08-27T13:03Z to 2026-08-29T07:00Z. That label is ten hours out: step 10 reconstructed the dataset and it actually spans 2026-08-27T03:03Z to 2026-08-28T21:00Z, because the log records'tsis local time carrying aZsuffix. Production steady state either way, and the coalescing counts below are unaffected -- the coalescing instrumentation was already deployed across the whole of the real span, and 147 of these operations predate it and are excluded from the coalescing tables by the tool. The latency numbers are affected, because #3916 deployed inside the real span but not inside the published one; see the phase 10 subsection below.
The fold is cheap -- the ~200 ms estimate was wrong by 50x¶
claim_coalescible_siblings, over all 1,335 executions which reached
it:
| p50 | p90 | p99 | max |
|---|---|---|---|
| 3.7 ms | 5.2 ms | 88.6 ms | 149.5 ms |
The comment in baseoperation.py asserted "~200 ms under load" from a
CI-bundle profile, and used that figure to justify the
dispatcher_batch_size == 1 guard. The measured median is 3.7 ms and
the single most expensive fold observed in nearly 42 hours did not
reach 200 ms. Both comments are corrected in place.
The estimate was not merely imprecise, it was measuring something else: it was taken while #3878 was live, so every call it timed was a query that could never match, and it inferred "under load" from a CI bundle rather than from a cluster carrying real traffic. The guard it justifies is still worth keeping -- skipping a query that cannot help is free -- but it is a tidiness optimisation, not a latency defence, and nothing else should be justified by that number.
Coalescing works, and on this workload it almost never fires¶
Of 8,661 net_op executions, the outcome breakdown was:
| Outcome | n | Share |
|---|---|---|
not_cluster_wide |
3,661 | 42% |
batch_size_one |
3,343 | 39% |
ran |
1,335 | 15% |
no_coalescible_tasks |
322 | 4% |
Of the 1,335 folds which ran, 7 folded anything, each folding
exactly one sibling: seven operations avoided in 41h56m. The seven are
spread across six separate hours on both days rather than clustered in
one burst, and all of them landed on the
networknode / user_facing_high_io class.
No other operation type coalesces at all: the remaining 17,421
instrumented operations recorded type_not_coalescible, which is the
expected shape today -- NetOp is the only type that declares a
coalescing key.
So the phase 8 fix was necessary and is confirmed working, but its practical yield on this cluster is small. Three readings are consistent with the data and this plan does not pretend to choose between them without more evidence:
sfcbrgenuinely does not generate concurrent duplicate network work very often, and the fold is correctly rare.- The two guards are too aggressive. 81% of
net_opexecutions never reach the fold, andbatch_size_onealone accounts for 39% -- a dispatcher that dequeued in slightly larger batches would expose more foldable pairs. - The single-column key is the limit, which is what #3884 already describes: the per-node tasks most likely to be duplicated are the ones the key cannot express.
Reading 3 is the one already funded as a phase. Reading 2 is new and worth a measurement before anyone acts on it.
Cross-check against the counters¶
The Prometheus counters agree with the log-derived numbers, which is the point of taking both:
| Counter | 42h increase | Log-derived |
|---|---|---|
database_claim_coalescible_siblings_total |
1,336.5 | 1,335 |
database_find_existing_coalescible_op_total |
2,364.9 | not derivable |
The 1.5 difference is increase() extrapolating across scrape
boundaries, not a discrepancy. The enqueue-side dedup runs about 1.8
times as often as the worker-side fold, which is expected -- it is
consulted on every enqueue, not only on dequeued batches.
There is no counter for enqueue-side dedup hits. We can see how
often find_existing_coalescible_op was asked and not how often it
found something, so the enqueue-side half of coalescing has no
equivalent of the coalesce_folded number above. That asymmetry is
recorded here rather than fixed, because the fix belongs with #3884's
work on the key.
What this window says about phase 10, which is less than it looks¶
3916 merged on 2026-08-27 at 10:11 and sfcbr was redeployed at¶
13:13, so this was meant to be the first window carrying the
dependency-wait back-off. The data it was read from was not that
window. Step 10 reconstructed the dataset exactly and found it runs
from 2026-08-27T03:03Z to 2026-08-28T21:00Z -- ten hours earlier than
the label above says, because the log records carry a local-time ts
stamped with a Z suffix and ten hours is this cluster's UTC offset.
10,108 of its 26,225 samples therefore predate the 13:13Z redeploy and
were captured under the flat fifteen second defer #3916 removed. What
follows is corrected in place; the reconstruction is in
What step 10 measured.
The user_waiting lane as published, with the same tool's row over
the window the label actually names beneath it:
| p50 | p90 | p99 | max | |
|---|---|---|---|---|
| as published (straddles the redeploy) | 1.20 s | 7.28 s | 17.18 s | 42.83 s |
| 13:03Z to 07:00Z, all | 1.14 s | 2.35 s | 7.36 s | 42.83 s |
| 13:03Z to 07:00Z, never deferred | 0.86 s | 1.84 s | 2.29 s | 4.59 s |
against step 7's 15.78 s p50 for the same lane. Do not read either
row as a controlled before-and-after. Three things get in the way.
Two fifths of the published row is a pre-fix workload. The step 7 window is a
different workload a week earlier, with no attempt to hold load
constant. And wait_seconds is start_time - created_at
(baseoperation.py:162) -- time since the operation was created,
not since its last deferral -- so it is not a direct reading of the
defer delay at all.
What survives the correction:
- Sub-second waits appear on operations with
defer_count == 1, which a flat fifteen second delay could not produce. The back-off is live. Step 10 sharpens this: after the redeploy the entiredefer_count == 1population tops out at 3.79 s. - Operations still defer. 1,568 did so in the published dataset and 1,360 in the window the label names.
What does not survive:
- ~~Roughly 400 of the 823 first deferrals sit at 15-17 s ... what
those operations were waiting for has not been established.~~
Withdrawn. Those 823 first deferrals include 493 from before
the redeploy, and under the flat fifteen second defer a first
deferral lands in the 15-17 s band by construction -- 754 of the
956 pre-fix first deferrals do. After the redeploy there is not one
user_waitingoperation in that band in either of step 10's 42 hour windows. The population was #3863, measured after the fix had shipped but with pre-fix samples still in the dataset. Nothing was waiting on anything unexplained.
The rest of the paragraph stands: defer_with_backoff's (15, 30,
60) transient-failure retry emits scheduling retry after transient
failure zero times in either dataset, so it was never the
explanation either.
So phase 10's subject did move, but not for the reason given here.
The flat 15 s defer is gone and the user_waiting lane's residual is
the back-off ladder doing what it was designed to do. The question
step 10 inherited turned out to be a different one -- where the deep
tail lives, which is not this lane at all. See
PLAN-queue-performance-phase-10-defer-latency.md
and the step 10 section above.
Method note¶
The invocation in tools/queue-wait-report.py's docstring did not
work as written. Loki caps a query at 5000 lines: asking for more
fails outright, and asking for exactly 5000 silently returns the most
recent 5000 lines. A 24 hour window of this cluster is past that
ceiling, so the documented invocation returned a truncated stream
which looked complete -- the first pass at this measurement
undercounted the majority outcome threefold before that was caught.
The
docstring now shows a query_range loop that pages the window in
half-hour chunks, and points at count_over_time for totals. Anyone
repeating this measurement should check that no chunk comes back at
the ceiling.
What step 11 measured¶
Two equal 19 hour sfcbr windows either side of the deployment on
2026-09-03, cut at a sharp changeover: the last event carrying the old
not_cluster_wide outcome is 01:19:01 UTC and the first carrying
key_cannot_distinguish_queue is 01:23:29 UTC, with nothing after it
on the old build. Windows were selected by Loki's ingestion timestamp,
not by the records' own ts field, which on sfcbr is local time
carrying a Z suffix and reads ten hours ahead -- the trap step 10
withdrew a finding to, and one which showed itself again here. Both
windows were paged in 38 half-hour chunks with no chunk holding the
5,000 line ceiling, and reconciled against count_over_time.
-
The guard the phase existed to remove is gone.
net_opoperations refused by the per-node-queue guard went from 2,243 in the pre-change window to zero in the post-change one. The fold went from running only on the cluster-wide lane (832 times) to running 2,146 times, of which 1,524 are on per-node network queues which could not run one at all before. -
Those 1,524 per-node folds collapsed nothing. Zero siblings. The four siblings folded in the post-change window are all on the cluster-wide
networknode-clusteroperation-user_facing_high_iolane, which folded six in the window before. The enqueue-side dedup did not quietly absorb the work either: 210 reuses before, 196 after. -
The 62% ceiling in the phase plan's survey finding 1 was a much looser bound than it read as. Arriving in the same dispatcher batch is not sharing a coalescing key.
Network.ensure_meshfans one operation per node out for one network, so a node's queue fills with mesh operations for several different networks, no two sharing anetwork_uuid. A fold needs the same network reconciled twice while the first is still queued, andsfcbrdoes not do that. This is the "measured win is small" risk in the phase plan landing exactly as written -- it landed at zero. -
The mechanism is not broken, which is a different claim and was checked separately. The same code path folded four on the cluster-wide lane in this window, and
test_per_node_mesh_work_is_coalescedasserts a per-node fold and a correct mesh after it on every merge. A fold which never runs and a fold which matches nothing are separate columns intools/queue-wait-report.pyprecisely so this could be told apart without another #3878. -
Decision 6 stands: no new index. The fold's median cost is what an index would move and it did not move -- 3.6 ms before, 4.1 ms after, against step 9's 3.7 ms, and uniform at 3.8--4.5 ms across all 13 queues issuing folds, including six per-node network queues which were not fold sites before. The tail did move (folds over 100 ms went from 3 to 29; the maximum from 143.3 ms to 785.2 ms) but it belongs to one node rather than to the key: 22 of the 29 are on the busiest node's two queues, and five of the other six per-node queues never exceeded 100 ms.
-
No lock waits and no deadlocks, which is what the #4007 review asked for beyond duration: zero
Deadlock found, zeroLock wait timeoutand zeroOperationalErrorin either window. Partial, and the limit is worth keeping attached to the claim -- this is the daemon log surface, notSHOW ENGINE INNODB STATUSorinformation_schema, which were unreachable from the workstation the measurement ran from.
The phase's throughput case is therefore unproven on this workload and the plan said in advance that it did not rest on throughput: two of the three guards existed only because the key could not tell nodes apart, and that special case is gone. See the phase 11 plan's Results section for the full tables, the build itself, and the two mid-phase corrections found while landing it.
Audit findings (step 6)¶
| Site | Pattern | Resolution |
|---|---|---|
node_inst_netdesc_op._instance_start |
Loop over net_desc; per-interface call to n.ensure_mesh() and n.update_dnsmasq() |
Track reconciled_network_uuids in a set; first interface on a network triggers reconciliation, subsequent interfaces on the same network skip. Per-interface work (state flip, floating-IP fan-out) stays inside the loop. Fixed in this commit. |
node_inst_op._instance_delete |
Loop over instance_networks; one n.update_dnsmasq() per network |
Already deduplicates network_uuid before entering the loop. No change. |
External API hot-plug (external_api/instance.py) |
Single multi-task enqueue [create_network_node, ensure_mesh] |
Not a fan-out -- one op per hot-plug call. No change. |
daemons/network/maintain.py |
Per-network reconciliation enqueues during a 30 s pass | Bounded by per-network in-flight gate (has_pending_cluster_operation_target). After step 5, parallel maintainer passes on the same node coalesce; cross-node dedup deliberately disabled because mesh is per-hypervisor. No change. |
daemons/queues/startup_tasks.py |
Per-network sequential n.create_on_hypervisor() + n.ensure_mesh() waits during node startup |
create_on_hypervisor enqueues node_net_op.network_apply_create_hypervisor, which is not currently coalescible. Multiple instances on the same network on the same node fan out at startup. See the follow-up below. |
Follow-ups not landed here¶
-
NodeNetOp.network_apply_create_hypervisorcoalescing. This task is idempotent (util_concurrency.create_vxlan_interfaceis "create if missing") and is enqueued per-instance during startup -- so a node restoring N instances on the same network enqueues N node_net_ops where one would do. Marking it coalescible needs the find/claim primitives to filter on bothnode_uuidandnetwork_uuid(same network on the same node), which the currenttarget_columnparameter did not support. Generalising to a list of(column, value)pairs is what phase 11 built, and it is straightforward on the model side --NodeNetOpalready carries both columns. It turned out not to be enough on its own:network_apply_create_hypervisoris drained bysf-queues, whose worker pool has no per-target routing key, so the partitioned-worker safety argument that makes a per-node fold sound forsf-netdoes not hold there. Phase 11 gave the wider key tonetwork_ensure_mesh(drained bysf-net) instead and deferred this task to a successor issue on its own merits -- see decision 5 and the Future work section of PLAN-queue-performance-phase-11-multi-column-key.md. Tracked as #4017; #3884, which asked for the key itself, was completed by phase 11. -
Explicit fairness for low-priority queues. The dequeue query honours strict priority order via
FIELD(); lower priorities only spill in when higher ones yield fewer rows thanlimit. Sustained heavy load onuser_facingcould in principle starvebackground. The CI signal will tell us whether to add bounded-staleness ordering (ORDER BY CASE WHEN NOW() - created_at > N THEN top ELSE priority END, ...) or a reserved-slot mechanism.