Skip to content

fix: defer cache purge until the outermost transaction commits#910

Closed
ChiragAgg5k wants to merge 1 commit into
mainfrom
fix/defer-cache-purge-until-outermost-commit
Closed

fix: defer cache purge until the outermost transaction commits#910
ChiragAgg5k wants to merge 1 commit into
mainfrom
fix/defer-cache-purge-until-outermost-commit

Conversation

@ChiragAgg5k

Copy link
Copy Markdown
Member

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:

$document = $this->withTransaction(fn () => { ...write...; $this->purgeCachedDocument(...); });
// "Purge again after commit so readers cannot re-cache the pre-commit version"
$this->purgeCachedDocumentInternal($collection->getId(), $id);

That second purge only lands after the real COMMIT when the write owns the outermost transaction. Transactions here are reference-counted (Adapter::$inTransaction, SAVEPOINTs for nested frames) — the real COMMIT only fires when the outermost frame closes.

When a caller wraps the write in its own withTransaction() (common — e.g. a read-modify-write with forUpdate: true for lost-update safety), the inner updateDocument's "after commit" purge runs while inTransaction > 0before 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 the purgeAfterCommit queue (keyed collectionId:id, deduped) + queueCachePurge() / dequeueCachePurges(). It lives on the adapter because transaction depth and the cache are shared there across Database wrappers — notably Mirror, 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.
  • Removed the two now-redundant "Purge again after commit" band-aids in 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 hammering getDocument through shared Redis to poison the cache. Single writer, so every non-matching read is unambiguously stale (the previous value).

Version Writes Concurrent poison reads Stale reads Stale rate
Before 3000 585,933 1219 40.6%
After 3000 467,647 0 0%

Notes

  • Pint ✓. PHPStan level 7 introduces no new findings (pre-existing Cache::getGeneration/saveWithLease errors are unrelated, from an out-of-date utopia-php/cache in the local vendor).
  • Motivating downstream case: flaky Appwrite email-template read-after-write tests, where the project config is written inside an endpoint-level withTransaction and read back from the cached project document. Callers currently work around this with manual purgeCachedDocument() calls after the transaction; this fix removes that need.

Draft pending the repo's own test suite run.

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.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ef83d19c-38a4-4d09-8c8b-6b2cf8b2cf02

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/defer-cache-purge-until-outermost-commit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant