Skip to content

Commit 00d502f

Browse files
abnegateclaude
andcommitted
fix(sqlite): use BEGIN IMMEDIATE so writers serialise instead of deadlocking
PDO's beginTransaction() issues `BEGIN DEFERRED`, which acquires the SQLite writer lock lazily on the first write. If two transactions both started as readers and try to promote to writer at the same time, one fails immediately with SQLITE_BUSY without any busy_timeout retry — a true deadlock case. `BEGIN IMMEDIATE` reserves the writer slot at transaction start so concurrent writers queue behind it under busy_timeout. Override commitTransaction and rollbackTransaction to use raw COMMIT/ROLLBACK because the raw BEGIN IMMEDIATE bypasses PDO's internal transaction tracking and PDO::commit() / PDO::rollBack() would throw "no active transaction". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 301fbb6 commit 00d502f

1 file changed

Lines changed: 81 additions & 2 deletions

File tree

src/Database/Adapter/SQLite.php

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,28 @@ private function registerUserFunctions(): void
135135

136136
/**
137137
* @inheritDoc
138+
*
139+
* SQLite serialises writers through a single file lock. PDO's default
140+
* `BEGIN` is `DEFERRED`, which acquires the writer lock lazily on the
141+
* first write — if two transactions both started as readers and try to
142+
* promote to writer at the same time, one fails immediately with
143+
* SQLITE_BUSY without any busy_timeout retry (a real deadlock case).
144+
* `BEGIN IMMEDIATE` reserves the writer slot up-front so concurrent
145+
* writers queue behind it under busy_timeout instead.
138146
*/
139147
public function startTransaction(): bool
140148
{
141149
try {
142150
if ($this->inTransaction === 0) {
143151
if ($this->getPDO()->inTransaction()) {
144-
$this->getPDO()->rollBack();
152+
$this->getPDO()
153+
->prepare('ROLLBACK')
154+
->execute();
145155
}
146156

147-
$result = $this->getPDO()->beginTransaction();
157+
$result = $this->getPDO()
158+
->prepare('BEGIN IMMEDIATE')
159+
->execute();
148160
} else {
149161
$result = $this->getPDO()
150162
->prepare('SAVEPOINT transaction' . $this->inTransaction)
@@ -163,6 +175,73 @@ public function startTransaction(): bool
163175
return $result;
164176
}
165177

178+
/**
179+
* @inheritDoc
180+
*
181+
* Overrides the inherited PDO-driven commit because startTransaction
182+
* issues a raw `BEGIN IMMEDIATE` (rather than PDO::beginTransaction),
183+
* so PDO's internal in-transaction flag is never set and PDO::commit()
184+
* would throw "no active transaction". Mirrors that with a raw COMMIT
185+
* and SAVEPOINT release for nested levels.
186+
*/
187+
public function commitTransaction(): bool
188+
{
189+
if ($this->inTransaction === 0) {
190+
return false;
191+
}
192+
193+
try {
194+
if ($this->inTransaction > 1) {
195+
$result = $this->getPDO()
196+
->prepare('RELEASE SAVEPOINT transaction' . ($this->inTransaction - 1))
197+
->execute();
198+
$this->inTransaction--;
199+
return $result;
200+
}
201+
202+
$result = $this->getPDO()
203+
->prepare('COMMIT')
204+
->execute();
205+
$this->inTransaction = 0;
206+
} catch (PDOException $e) {
207+
throw new TransactionException('Failed to commit transaction: ' . $e->getMessage(), $e->getCode(), $e);
208+
}
209+
210+
return $result;
211+
}
212+
213+
/**
214+
* @inheritDoc
215+
*
216+
* Counterpart to commitTransaction — uses a raw ROLLBACK for the same
217+
* reason (raw BEGIN IMMEDIATE bypasses PDO's transaction tracking).
218+
*/
219+
public function rollbackTransaction(): bool
220+
{
221+
if ($this->inTransaction === 0) {
222+
return false;
223+
}
224+
225+
try {
226+
if ($this->inTransaction > 1) {
227+
$this->getPDO()
228+
->prepare('ROLLBACK TO transaction' . ($this->inTransaction - 1))
229+
->execute();
230+
$this->inTransaction--;
231+
} else {
232+
$this->getPDO()
233+
->prepare('ROLLBACK')
234+
->execute();
235+
$this->inTransaction = 0;
236+
}
237+
} catch (PDOException $e) {
238+
$this->inTransaction = 0;
239+
throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e);
240+
}
241+
242+
return true;
243+
}
244+
166245
/**
167246
* Check if Database exists
168247
* Optionally check if collection exists in Database

0 commit comments

Comments
 (0)