Repository navigation
fix(activesync): hold no DB transaction across backend calls in State_Sql - #110
Merged
ralflang merged 1 commit intoNov 2, 2026
Merged
Conversation
…_Sql State_Sql kept the collection-lock transaction (and the SELECT ... FOR UPDATE state row lock inside it) open from loadState() until save(). All backend work of a Sync request - importing client changes into Turba/Kronolith/Nag/IMAP, getSyncStamp(), getChanges() - therefore ran inside the ActiveSync state transaction on the shared Horde_Db connection. On PostgreSQL a single failed backend statement (e.g. a Turba UPDATE rejected with SQLSTATE 22001) aborts that transaction; every following state query fails with SQLSTATE 25P02, the request ends with HTTP 500, and the client retries the same change forever. The token-clearing UPDATE in _releaseCollectionLock() failed as well, leaving the lock row to the stale timeout. Horde_Db nests transactions by counter only (no savepoints), so a backend rollback would also silently drop the lock. - _acquireCollectionLock(): commit the check-and-set transaction before returning; the committed token row is the lock. - _releaseCollectionLock(): clear the token in autocommit mode. If the UPDATE fails while a (foreign, aborted) transaction is open, roll it back and retry once. - Read the state row FOR UPDATE in a short, immediately committed transaction (_readStateRow()); _saveSyncStateRow() always runs in its own short transaction. Drop the _stateRowLockHeld/_stateRowLockTxnOwner and _collectionLockTxnOwner machinery that only carried the long transaction. - Unit tests for the new transaction boundaries, including an end-to-end SQLite test where a backend statement fails between loadState() and save(). - Document the locking model in doc/architecture.md. Fixes #109
🔍 CI ResultsOverall: ❌ 12/12 lanes failed TL;DR: ❌ Quality issues: PHP-CS-Fixer: 46 files. Summary by PHP Version
Quality Metrics
❌ Failed Lanesphp8.1-dev
php8.1-stable
php8.2-dev
php8.2-stable
php8.3-dev
php8.3-stable
php8.4-dev
php8.4-stable
php8.5-dev
php8.5-stable
php8.6-dev
php8.6-stable
CI powered by horde-components • View full results |
2 of 3 tasks
ralflang
approved these changes
Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Horde_ActiveSync_State_Sqlno longer keeps aHorde_Dbtransaction open while the request hands control to the backend (imports,getSyncStamp(),getChanges()).FOR UPDATEin a short, immediately committed transaction;save()writes in its own short transaction. The_stateRowLockHeld/_stateRowLockTxnOwner/_collectionLockTxnOwnermachinery is removed.Fixes #109.
Motivation
With the lock transaction open across the whole Sync request, a single failed backend statement on PostgreSQL (observed: a Turba
UPDATErejected withSQLSTATE[22001]) aborted the shared transaction. Every following state query failed withSQLSTATE[25P02], the request returned HTTP 500, the client retried the same change on the next Sync, and the collection lock row could only be freed by the stale timeout because the token-clearingUPDATEfailed too. In one deployment this produced 285 identical crashes in 18 days.Horde_Dbnests transactions by counter only (no savepoints), so holding a transaction across backend code is unsafe independent of PostgreSQL: a backendrollbackDbTransaction()in its own error handling would silently drop the ActiveSync lock rows and row lock.Changes
lib/Horde/ActiveSync/State/Sql.php_acquireCollectionLock():commitDbTransaction()right after the tokenUPDATEwhen this instance started the transaction._releaseCollectionLock(): token clear in autocommit; onHorde_Db_ExceptionwithtransactionStarted(), log atwarn, roll back via the existing_rollbackDbOnFailure(), retry theUPDATEonce. The$commitparameter stays for theState_Basecontract._acquireStateRowLock()→_readStateRow(): sameSELECT … FOR UPDATE, committed immediately after the read;StateGoneis thrown after the commit._saveSyncStateRow(): always own short transaction unless a caller already opened one.__destruct(),_loadState(),updateSyncStamp(): row-lock release calls removed.Caught exception types in new/changed catch blocks:
Horde_Db_Exceptiononly — on the token-clearUPDATE(logged; triggers the rollback+retry when a transaction is open) and on the commit in_readStateRow()(logged, rethrown asHorde_ActiveSync_Exception).Tests
RowLockTest.phprewritten: read commits immediately, joins an open caller transaction without closing it, rolls back on query failure, commits beforeStateGone; save runs in its own transaction / joins the caller's / rolls back its own on failure;updateSyncStamp()below threshold touches no transaction.CollectionLockTest.php: acquire commits before returning (mock), save releases the lock in autocommit (mock), release rolls back an aborted transaction and retries (mock), SQLite acquire/release leaves no transaction open, and an end-to-end SQLite test where a backend statement fails betweenloadState()andsave()— the save and lock release still succeed.ReadonlyLoadStateTest.php: asserts no transaction is open instead of the removed property.Docs
doc/architecture.md: new paragraph on the locking model and why no transaction may be open while the backend runs.doc/todo.md: stability list entry.Not in scope: the Turba
VARCHAR(10)postal-code columns that triggered the backend failure (separate issue).Test plan
phpunit test/unit/Horde/ActiveSync/StateTest— 68 tests passtest/unitsuite: no new failures (4 pre-existing, unrelated failures inAppointmentTest,FindRequestTest,Rfc822Testfail identically onFRAMEWORK_6_0)25P02follows in the log