dml_write_exception SQLSTATE 40001 on deadlock: retrying delegated transactions without duplicate writes
0 reputation · 05 Jul 2023, 05:51 UTC
0 reputation · 05 Jul 2023, 05:51 UTC
I am writing a Moodle 4.5 plugin whose scheduled task performs several related writes inside $DB->start_delegated_transaction(). Under concurrent load, MySQL can abort the transaction with a dml_write_exception carrying SQLSTATE 40001 (deadlock). As far as I can tell, Moodle's DML layer throws this exception but does not retry the transaction automatically, so the retry loop has to live in my own code.
The complication is that the task's execute() method mixes inserts and updates. If the deadlock hits partway through and I simply re-run the whole method, I risk duplicate rows for the inserts that already committed in a prior attempt or in a nested transaction scope. The scheduled task retry flag has the same problem: it re-executes the entire method.
My constraints: I must stay on core DML APIs (no raw driver calls), the task may run on both MySQL and PostgreSQL, and I cannot add unique indexes for every logical write because some tables legitimately allow repeated values.
Is catching dml_write_exception and checking SQLSTATE 40001 the correct, portable way to detect a retryable deadlock in Moodle's DML? What is the recommended pattern for making a multi-write delegated transaction idempotent so a retry cannot duplicate inserts? Should the retry wrap the whole execute() or only the transaction block?
32275 reputation · 05 Jul 2023, 12:31 UTC
dml_write_exception and checking getSQLState() === '40001' is the correct, portable way to detect a retryable deadlock on both MySQL and PostgreSQL.INSERT that could run twice with a duplicate‑key catch (SQLSTATE 23505 Postgres, 23000/1062 MySQL) or an existence check. Naturally idempotent updates need no extra code.start_delegated_transaction() … allow_commit()) in the retry loop, not the whole execute() method.const int MAX_RETRIES = 3;
$attempt = 0;
while (true) {
try {
$DB->start_delegated_transaction();
// … all writes that must be atomic …
$DB->allow_commit();
break;
} catch (dml_write_exception $e) {
if ($e->getSQLState() === '40001' && ++$attempt < MAX_RETRIES) {
// deadlock – loop retries the whole transaction
continue;
}
throw; // non‑retryable or retries exhausted
}
}Each logical insert needs one of two guards:
try {
$DB->insert_record('mytable', $record);
} catch (dml_exception $e) {
if (in_array($e->getSQLState(), ['23505', '23000'], true) || $e->getCode() === 1062) {
// row already exists – treat as success
} else {
throw;
}
}if (!$DB->get_record('mytable', ['unique_col' => $value])) {
$DB->insert_record('mytable', $record);
}Requirement: the table must have a unique key (primary or unique index) on the columns that define logical uniqueness; otherwise the duplicate‑key exception never fires.
Code before start_delegated_transaction() (logging, cache warm-up, etc.) has already executed and must not run again. Code after allow_commit() runs only on success. Re‑executing the whole execute() would repeat those side effects.
Do any of your inserts target tables that lack a unique constraint on the columns you consider logically unique? If yes, the duplicate‑key guard cannot work and you must add a unique index or redesign the key.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.