Repository navigation
perf(engine/mysql): stop enrollments contending over a shared step - #141
Conversation
A step enqueued to many enrollments is completed by whichever of them reports last. Each settled that question inside its own transaction, against rows the others owned, so the whole fan-out serialized on the one steps row and held it until commit. Enrollments that timed out waiting were rolled back, discarding a command result the device had already reported and MDM will not resend. The step is now collected after the completion transaction commits: a non-locking reference check, then a FOR UPDATE SKIP LOCKED claim and point deletes. Completing a step touches only the reporting enrollment's own id_commands rows. The trade is that a step can be left uncollected -- inert, but not reclaimed. Three latent defects in the completion path are fixed alongside: two needless joins under FOR UPDATE, one of which took the shared lock; a step deleted while its step_commands rows still referenced it; and a completion count read outside the transaction that acted on it. CancelSteps collects the steps it orphaned rather than scanning the whole steps table. No DELETE in query.sql joins any more; all are plain single-table statements. Collection runs after the transaction that mattered has committed, so its error is logged rather than returned. New WithLogger option, defaulting to log.NopLogger. Tested on MySQL 8.0 at the server defaults, at READ-COMMITTED with innodb_lock_wait_timeout=2, and with the foreign keys dropped. Assisted-by: Claude Code:claude-opus-5
|
Supporting detail for the description above: the query as it stood, how this was measured, the numbers, and what was not covered. The query before-- GetIDCommandsByStepIDAndLock
SELECT ic.command_uuid, ic.request_type, ic.result
FROM id_commands ic
INNER JOIN steps s ON ic.step_id = s.id
LEFT JOIN step_commands sc ON sc.step_id = s.id
WHERE ic.enrollment_id = ? AND s.id = ? AND ic.completed != 0
FOR UPDATE;and the cleanup at the end of the same transaction: -- DeleteWorkflowStepHavingNoCommandsByStepID
DELETE s FROM steps s
LEFT JOIN id_commands c ON s.id = c.step_id
WHERE c.step_id IS NULL AND s.id = ?;The anti-join is the question that made the shared lock necessary; the On reachability of the two How this was testedMySQL 8.0.46 in a container, schema from
Reproduce with a MySQL server like the CI job's server:
Three tests are added:
ResultsFan-out contentionOne step enqueued to N enrollments, all reporting simultaneously, stricter server, connection pool capped at 128. The control gives each enrollment its own step — same work, nothing shared — and so bounds what the shared case could possibly achieve.
The shared case now lands within the same range as the unshared control, where it used to be roughly 8–35x worse. Raising the pool to 256 raises the current code's failures to 165 of 300 and leaves this change at zero. Cancel against completionTwenty enrollments cancelled while fifty others complete a shared step, with forty unrelated steps present for the unscoped sweeps to scan. Three runs each:
Concurrent commands of one enrollmentForty enrollments, each answering its four commands at once. Five runs, across both isolation levels:
Cost on the uncontended pathCollecting the step is a second transaction where completing it used to fold the cleanup in, so a workload with nothing to contend over pays for the extra commit. Fifty enrollments answering serially, each step going to one enrollment:
Roughly 0.5–1.5 ms per completed step, across this and the control column above. This container's commits are fsync-bound, so treat these as pessimistic. Limitations
|
Summary
A workflow step enqueued to many enrollments is completed by whichever of them reports last. Deciding which one that is happened inside each enrollment's completion transaction, against rows the others owned, so the whole fan-out serialized on the shared
stepsrow and held it until commit. Enrollments that lost the race insideinnodb_lock_wait_timeoutwere rolled back and their command result discarded — after the device had already reported it, and MDM does not redeliver.This moves the step collection after the completion transaction commits: a non-locking reference check, then a
FOR UPDATE SKIP LOCKEDclaim and point deletes. Completing a step now touches only the reporting enrollment's ownid_commandsrows.One step fanned out to 300 enrollments all reporting at once, at
READ-COMMITTEDwith a 2s lock timeout: the current code fails 104 of 300 withERROR 1205, strands 104id_commandsrows, and takes 5.2s. This change completes all 300, strands nothing, in 0.26s. Measurements, reproduction and limitations are in a comment below.Three further defects are fixed along the way, all latent. No schema change. Other engine storage backends are untouched.
Problem
GetIDCommandsByStepIDAndLockjoinedstepsunderFOR UPDATE. Becauseic.step_id = s.id AND s.id = ?is justic.step_id = ?, that join selected nothing and filtered nothing — its only effect was an exclusive lock on the onestepsrow the whole fan-out shares, taken early in the transaction and held to commit. That is the contention.It was needed because of what came at the end of the same transaction: deciding whether the step could now be deleted meant anti-joining
id_commands, a question about rows every other enrollment owns.CancelStepsthen finished by scanning for any step anywhere with no commands left, and anystep_commandsrow anywhere whose command was gone — O(total steps) on every call, to cancel one enrollment's.Three further defects. Each needs a state the normal lifecycle does not reach, so all three are latent rather than live, but the SQL is wrong on its own terms and the storage should not depend on guarantees it neither states nor enforces:
LEFT JOIN step_commandsread no column and could not narrow the match, but repeated each result once per raw command held for the step — a three-command step returned seven results.step_commandsrows still referenced it, which the foreign key rejects (Error 1451); schemas without the foreign key orphan them silently instead.CountOutstandingIDWorkflowStepCommands, read outside the transaction that acted on it. MDM reports an enrollment's command results one at a time, so no sibling response can make that count stale — but that guarantee comes from the caller, and the worker does not inherit it:RetrieveTimedOutStepsdeletes the same rows on a timer, in a transaction of its own.Change
Collection moves after the commit. Once the completion transaction has committed,
SelectUnreferencedStepIDsasks — no transaction, no locks — whether anything still refers to the step. If nothing does, a second short transaction claims it withFOR UPDATE SKIP LOCKEDand deletes the step and its raw commands by key. Asked afterwards, with this enrollment's deletes already visible, the question needs no lock on another enrollment's rows and none on the step they share. This is the same split as nanomdm's command deleter (d7b3ac5), applied to the same shape of problem.Two things make it safe. "Unreferenced" is terminal:
id_commandsrows are only ever inserted byStoreStep, which always creates a newstepsrow first, so no existing step can gain a reference. And the check must run in autocommit — inside a transaction,REPEATABLE READwould serve it from a snapshot predating the peers' deletes, so every enrollment would see the others' rows still present and none would ever collect.Completion is decided inside the transaction. The locking read that gathers the sibling results now also counts what is outstanding, so the decision is made from rows the transaction holds. Only this enrollment's own rows are locked, so it costs nothing in contention.
Both joins are dropped; raw commands are deleted with the step that owns them, before it, satisfying the foreign key; and
CancelStepsnotes the affected step IDs before orphaning them and collects exactly those. NoDELETEinquery.sqljoins any more — every one is now a plain single-tableDELETE FROM, in place of MySQL’s proprietaryDELETE <alias> FROM <joined tables>form.A logger, so the collection is not silent. Its error must not be returned —
engine.go:468discards a non-nil step result if the storage errors, andengine.go:621skips theClearWorkflowStatusthat follows a cancel — so in both cases returning it would abandon work that succeeded in order to report a row that was not reclaimed. Both are logged instead, via a newWithLoggeroption following the same pattern asengine.WithLoggerand defaulting tolog.NopLogger. Additive: no existing caller changes.