Skip to content

fix(documents): retire cached documents before a write the cache cannot confirm commits - #1042

Merged
abnegate merged 1 commit into
mainfrom
fix-cache-unavailable
Oct 19, 2026
Merged

abnegate merged 1 commit into
mainfrom
fix-cache-unavailable

Conversation

@abnegate

@abnegate abnegate commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Root cause

appwrite/appwrite#14642 makes utopia-php/cache's circuit breaker throw Unavailable when it cannot confirm a purge. Before it, the breaker returned false. Single document writes already let a purge exception through, and it rolls them back. Two paths still committed, and then served the old cached documents once the cache recovered:

  1. Batch writes and purgeCachedCollection(). While the circuit is open, the owner registration (a save) returns false and the epoch read misses. blockDocumentCacheEpoch() took a missing epoch to mean nothing was cached and let the write through. In fact the epoch is still in Redis.
  2. Schema changes. renameAttribute(), updateAttribute(), the index methods and so on purge the collection's documents only after the new definition has committed. The cache may refuse that purge, so the change stays and documents in the old shape are served.

Fix

Unreadable epoch. When the owner registration fails and the epoch can't be read, blockDocumentCacheEpoch() now purges the epoch key anyway. A purge the cache can't confirm throws: a batch write rolls back, and purgeCachedCollection() reports the failure instead of returning as if it had purged. A real absence returns false exactly as before.

purgeCachedCollection() after a schema change. I looked at three options:

Option Can it guarantee no stale read after recovery?
Retry after commit No. An outage longer than the retries leaves the old epoch in place.
Deferred invalidation No. It is kept in process memory: lost on restart, and other workers' readers keep serving stale documents until it replays.
Readers blocked on a marker that lapses, written after commit No. The marker write needs the same cache that just refused the purge.
The same marker, written before commit Yes. If the marker write fails, the change rolls back.

Only the last one holds: the marker has to be in place before the change commits. A write to a collection's definition in _metadata (updateMetadata(), deleteCollection(), the relationship metadata transactions) now places the collection's document tombstone inside its own transaction, next to the definition purge.

  • If the cache refuses the tombstone, the schema change rolls back through the existing rollbackOperation. You get an error, but nothing is committed.
  • Once the tombstone is stored, documents cached before the change are never served again. Readers don't cache while the collection is blocked. After commit the scope's activation publishes a fresh epoch.
  • If activation fails, the tombstone lapses into a new epoch after the writer timeout, never back to the old one.

So the purges the schema methods run after commit (now purgeCommittedCollection()) are redundant for documents. If one fails, the failure is logged instead of thrown for a change that is already stored. Query-cache invalidation keeps its existing behaviour. The relationship-update paths whose purge failure is meant to trigger restores (RelationshipSchemaTest::testAFailedJunctionPurgeRestores…) are left as they were.

The new activation is one more cache operation after commit. deleteCollection() and deleteRelationship() used to roll back on any Throwable, so a failure after commit could have recreated a dropped table, or reversed a relationship, whose metadata was already gone. They now check mayHaveCommitted() first, like createCollection() and createRelationship() already do.

Known limits:

  • A failure after the definition write commits is still thrown as a committed failure, as updateMetadata() documents.
  • A reader that refilled the definition between the in-transaction purge and the commit can serve it for up to DOCUMENT_CACHE_RECHECK (60s). Documents have no such window.

Before / after

With the cache behind the throwing breaker, Tests\Unit\Documents\CacheUnavailableTest:

Write during the outage main (8.3.9) This PR
update / update in a transaction / delete; circuit open or writes failing rolled back, fresh rolled back, fresh
batch update, writes failing rolled back ("Failed to block…"), fresh rolled back, fresh
batch update, circuit open committed, stale rolled back, fresh
update whose purge fails after commit committed, epoch moved, fresh same
purgeCachedCollection(), circuit open returns silently, stale throws
schema change, circuit open rolled back, fresh rolled back, fresh
schema change, outage starting at commit stored, documents stale after recovery stored, documents fresh (committed failure still thrown)

On main the three bold rows fail (testAWriteTheCacheCannotInvalidateIsRolledBack@batch update, open, testPurgeCachedCollectionReportsACacheThatCannotConfirmIt, testASchemaChangeRetiresCachedDocumentsBeforeItCommits), and they pass here. The other rows are guards for paths that already used the throw.

Checks: Pint passes; PHPStan passes with both configs. The unit suite passes apart from BaseAdapterStateTest::testAKeyThePatternEngineCannotFilterIsAnError and SwooleAbsentTest, which fail the same way on main on my machine because it lacks ext-redis and uses a Swoole build without the extension.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of database updates and schema changes when the cache is unavailable. Changes that cannot be safely completed are rolled back, while changes that have already been committed are handled without attempting to undo them.
    • Improved cache invalidation after database changes to help prevent stale cached data.

…ot confirm commits

utopia-php/cache's circuit breaker now throws when it cannot confirm a
purge. Single document writes already let that throw roll them back.
Two paths still committed and then served the old cached documents
once the cache recovered.

Batch writes and purgeCachedCollection(): with the circuit open, the
owner registration and the epoch read both take the breaker's
fallback. blockDocumentCacheEpoch() read a missing epoch as nothing to
invalidate and let the write go ahead. It now purges the unreadable
epoch anyway. A real absence still returns false. An unconfirmed purge
throws, so a batch write rolls back and purgeCachedCollection() reports
the failure instead of returning as if it had purged.

Schema changes: they purged the collection's documents only after the
definition committed. A purge after the commit cannot be guaranteed:
retrying or deferring it needs the same cache that just refused, and a
lapsing marker written then fails the same way. So a write to a
collection's definition now places the collection's tombstone inside
its own transaction. If the cache refuses, the change rolls back
through updateMetadata()'s existing rollbackOperation. Once the
tombstone is stored, no fill made before the commit is served again.
After the commit, activation publishes a fresh epoch, and a failed
activation lapses into a new epoch after the writer timeout. The
schema methods' own purges after the commit are therefore redundant
for documents, and they now log a failure instead of throwing for a
change that is stored. Query cache invalidation is unchanged.

That adds a cache operation after the commit, so deleteCollection()
and deleteRelationship() now check mayHaveCommitted() before rolling
back. Otherwise a failure after the commit would recreate a dropped
table, or reverse a relationship, whose metadata is already gone.

What remains: a failure after the commit of the definition write is
still thrown as a committed failure, as updateMetadata() documents. A
reader that refilled the definition between the in-transaction purge
and the commit can serve it for up to DOCUMENT_CACHE_RECHECK (60s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 39deea7f-b576-467e-a279-06480ec5788d
📥 Commits

Reviewing files that changed from the base of the PR and between b59e72b and da12ab1.

📒 Files selected for processing: 7
  • src/Database/Trait/Attributes.php
  • src/Database/Trait/Collections.php
  • src/Database/Trait/Documents.php
  • src/Database/Trait/Indexes.php
  • src/Database/Trait/Relationships.php
  • tests/unit/Cache/UnavailableCache.php
  • tests/unit/Documents/CacheUnavailableTest.php

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Collection mutations now use committed-state cache invalidation. Metadata errors that may have committed bypass rollback. New tests cover cache outages during document writes, purges, and schema changes.

Changes: Cache invalidation during mutations

Layer / File(s) Summary
Committed collection invalidation
src/Database/Trait/Documents.php
Adds purgeCommittedCollection and advances document-cache epochs during metadata mutations. If owner registration fails and the epoch is unreadable, the code purges the epoch key.
Collection mutation integration
src/Database/Trait/Attributes.php, src/Database/Trait/Collections.php, src/Database/Trait/Indexes.php, src/Database/Trait/Relationships.php
Attribute, collection, index, and relationship mutations use committed collection invalidation. Collection and relationship deletion rethrow metadata errors that may have committed without restoring schema, indexes, or junctions.
Cache outage test coverage
tests/unit/Cache/UnavailableCache.php, tests/unit/Documents/CacheUnavailableTest.php
Adds a cache test double and tests for document writes, purges, and schema changes during cache outages before and after commit.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to da12a

The change retires cached documents before a write the cache cannot confirm, and adds tests for cache outages. No merge-blocking risk was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 39.02% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly identifies the main change: retiring cached documents before writes when the cache cannot confirm commits.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@abnegate
abnegate merged commit 3859954 into main Oct 19, 2026
24 checks passed
@abnegate
abnegate deleted the fix-cache-unavailable branch October 19, 2026 04:47
sub4biz pushed a commit to sub4biz/appwrite that referenced this pull request Oct 19, 2026
Retires cached documents before a write the cache cannot confirm commits
(utopia-php/database#1042), paired with the cache purge fix in appwrite#14642.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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