fix: defer cache purge until the outermost transaction commits#910
Closed
ChiragAgg5k wants to merge 1 commit into
Closed
fix: defer cache purge until the outermost transaction commits#910ChiragAgg5k wants to merge 1 commit into
ChiragAgg5k wants to merge 1 commit into
Conversation
updateDocument()/deleteDocument() already purged the document cache a second time "after commit" so a concurrent reader could not re-cache the pre-commit version. That guard only held when the write owned the outermost transaction. When the call was nested inside a caller's withTransaction(), the "after commit" purge ran while inTransaction was still > 0 — before the real COMMIT — so any concurrent reader could read the old committed row and re-populate the cache, leaving stale data after the outer transaction finally committed. Queue every purge issued inside a transaction and replay it once the outermost frame commits, so the invalidation always lands after the rows are visible. The queue lives on the adapter because transaction depth and the cache are shared there across Database wrappers (Mirror shares its source's adapter and cache), and is dropped on full rollback.
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
3 tasks
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.
Problem
updateDocument()/deleteDocument()purge the document cache twice: once inside their transaction, then again "after commit" so a concurrent reader cannot re-cache the pre-commit version:That second purge only lands after the real
COMMITwhen the write owns the outermost transaction. Transactions here are reference-counted (Adapter::$inTransaction, SAVEPOINTs for nested frames) — the realCOMMITonly fires when the outermost frame closes.When a caller wraps the write in its own
withTransaction()(common — e.g. a read-modify-write withforUpdate: truefor lost-update safety), the innerupdateDocument's "after commit" purge runs whileinTransaction > 0— before the real commit. In that window, any concurrent reader reads the old committed row and re-populates the cache. The outer transaction then commits, and the cache is left holding the stale value.Fix
Queue every purge issued inside a transaction and replay it once the outermost frame commits, so the invalidation always lands after the rows are visible to other connections.
Adapter— holds thepurgeAfterCommitqueue (keyedcollectionId:id, deduped) +queueCachePurge()/dequeueCachePurges(). It lives on the adapter because transaction depth and the cache are shared there acrossDatabasewrappers — notablyMirror, which shares its source's adapter and cache.purgeCachedDocumentInternal()— when a transaction is open, also enqueues the key. This makes every purge (single-doc, relationship, bulk) self-healing, not just the two hand-patched spots.Database::withTransaction()— flushes the queue after the outermost commit; discards it on outermost rollback (nothing committed → nothing to invalidate).SQL::reconnect()— clears the queue defensively.updateDocument/deleteDocument.Verification
Reproduced against real MariaDB + Redis with genuine multi-process concurrency: one writer running the nested-transaction shape (
withTransaction { getDocument(forUpdate) + updateDocument }) + 16 reader processes hammeringgetDocumentthrough shared Redis to poison the cache. Single writer, so every non-matching read is unambiguously stale (the previous value).Notes
Cache::getGeneration/saveWithLeaseerrors are unrelated, from an out-of-dateutopia-php/cachein the local vendor).withTransactionand read back from the cached project document. Callers currently work around this with manualpurgeCachedDocument()calls after the transaction; this fix removes that need.Draft pending the repo's own test suite run.