Skip to content

fix(database): guard getDocument() against by-id cache/adapter identity mismatch#920

Closed
abnegate wants to merge 1 commit into
mainfrom
fix/get-document-identity-guard
Closed

fix(database): guard getDocument() against by-id cache/adapter identity mismatch#920
abnegate wants to merge 1 commit into
mainfrom
fix/get-document-identity-guard

Conversation

@abnegate

@abnegate abnegate commented Jul 16, 2026

Copy link
Copy Markdown
Member

Found on Appwrite Cloud prod: GET /v1/mysql/{id} served a different database's document (wrong $id, hostname, port) for ~90s while a sibling row was being written concurrently, then self-healed. The list endpoint stayed correct the whole time — proving the underlying row was never wrong, only the by-id cache entry.

Root cause

Database::getDocument() is cache-aside and saves the adapter's returned body under a key derived from the requested id, with no assertion that body.$id === requestedId (read at Database.php ~:4916, cached via saveWithLease ~:4969). If the adapter ever returns a foreign row (observed under a concurrent-write / pooled-connection result mixup), that body is cached under the wrong key and served sticky until the entry is invalidated.

Fix

New getDocument() identity guard via isForeignDocument($document, $id) ($actual !== '' && $actual !== $id):

  • Cached entry whose body is foreign → log + purgeCachedDocument + refetch (self-heal).
  • Adapter read whose row is foreign → throw DatabaseException (fail-closed) — never cache or return it.
  • A partial select() that omits $id is not treated as foreign (no false positives on projections).

Why fail-closed (throw) rather than degrade

Cache keys are namespaced per project, so a poisoned cache entry is project-bounded. But the connection-level mixup that can seed it is not inherently bounded in a shared multi-tenant shard-pool deployment — a result mixup could copy another tenant's row into the requester's keyspace. Serving another tenant's hostname/credentials must never happen, so a foreign adapter row that survives a refetch raises loudly instead of being returned.

Tests

tests/unit/ForeignDocumentGuardTest.php (3 tests; 2 fail without the guard) — covers the cached-foreign self-heal, the adapter-foreign throw, and the partial-select() no-false-positive case. Pint + PHPStan (L7) clean; neighbouring cache/document suites green.

Consumer

Appwrite Cloud PR appwrite-labs/cloud#4827 adds a Store-level databaseId === requestedId back-reference guard on top of this, and pins this branch via a dev VCS ref pending a tagged release.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved document retrieval safeguards to prevent returning a document under the wrong ID.
    • Automatically discards corrupted cached entries and refetches the correct document.
    • Raises an error when the data source returns a document with an unexpected ID.
    • Preserved normal behavior for valid cached documents.

…ning

getDocument($id) must never return, or keep serving, a document whose $id
differs from the requested id. A production incident (DAT-1904) served a
sibling row's body under the requested key after a mismatched read was
persisted cache-aside, so every subsequent by-id read returned the wrong
document until the entry expired (~90s), while list reads stayed correct.

Two-tier guard in getDocument():
- a cached body whose $id differs from the requested id is a poisoned entry:
  purge it and fall through to the source read instead of serving it, so the
  cache self-heals on the next read rather than after TTL expiry.
- a source (adapter) row whose $id differs from the requested id is an
  identity violation (e.g. a result-set delivered on the wrong pooled
  connection): throw instead of caching or returning it, converting silent
  cross-serving into a loud, observable failure.

A partial selection that omits $id yields an empty id and is not treated as
foreign, so the check never false-positives on a legitimate projection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 16, 2026 12:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

getDocument() now validates document identity on cache hits and source reads, purges poisoned cache entries, and throws on mismatched source rows. New tests cover cache recovery, source failures, and valid cache behavior.

Changes

Foreign document identity guards

Layer / File(s) Summary
Document identity validation
src/Database/Database.php
Cached documents with a foreign non-empty $id are purged and bypassed; source rows with mismatched non-empty identifiers raise DatabaseException; empty identifiers remain allowed.
Identity guard test coverage
tests/unit/ForeignDocumentGuardTest.php
Tests verify poisoned-cache recovery, mismatched-source exceptions, and unchanged behavior for matching cached documents.

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

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: guarding getDocument() against cache/adapter identity mismatches.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/get-document-identity-guard

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.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an identity guard to Database::getDocument() that catches the case where a cached or adapter-returned document carries a $id different from the one requested — the root cause of a production incident where a concurrent write on a pooled connection seeded the wrong row under a cache key, serving it for ~90 seconds until expiry.

  • Cache-hit path: if the cached body's $id mismatches the requested id, the entry is purged and the code falls through to a fresh adapter read (self-healing).
  • Adapter path: if the row returned by the adapter carries a mismatched $id, a DatabaseException is thrown (fail-closed, no caching).
  • Partial projections: a document with an empty $id is not treated as foreign, avoiding false positives on legitimate projections.

Confidence Score: 3/5

The guard correctly blocks the production incident scenario, but if purgeCachedDocument itself throws during the self-heal, every subsequent read for that id will re-detect the poison and re-throw before reaching the adapter, leaving the cache permanently poisoned until a separate invalidation occurs.

The adapter-level foreign-row path is clean and safe. The cache-healing path has a gap: purgeCachedDocument can throw (its event trigger is unchecked), and that exception propagates before the refetch, meaning the poisoned entry persists and every read hits the same error loop rather than self-healing.

src/Database/Database.php — specifically the purgeCachedDocument call at line 4893 and the exception message at line 4942.

Security Review

  • The DatabaseException thrown when the adapter returns a foreign row includes the foreign document's $id in the message. In a multi-tenant deployment this leaks a cross-tenant identifier. Omitting it from the exception message and logging it server-side only (as done on the cache path) would eliminate the exposure.

Important Files Changed

Filename Overview
src/Database/Database.php Adds isForeignDocument guard to getDocument() — cache-hit path purges and refetches on mismatch; adapter path throws. If purgeCachedDocument itself throws, the self-heal loop stalls and the poison persists.
tests/unit/ForeignDocumentGuardTest.php New test file covering cache-poison purge+refetch, adapter-foreign throw, and normal cache-hit correctness.

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/Database/Database.php:4891-4893
**Unhandled exception from `purgeCachedDocument` prevents refetch**

If `purgeCachedDocument` throws (its `$this->trigger(EVENT_DOCUMENT_PURGE, ...)` call can throw, and the method carries no try-catch), the exception propagates to the caller before the code reaches the adapter refetch. The poisoned cache entry remains intact, and every subsequent call to `getDocument` for this id will repeat the cycle: detect poison → attempt purge → throw → never refetch. Wrapping the purge + fallthrough in a try-catch that logs the purge failure and proceeds to the adapter read would preserve the self-heal intent even when the purge layer is temporarily unavailable.

### Issue 2 of 3
src/Database/Database.php:4893
**`purgeCachedDocument` fires `EVENT_DOCUMENT_PURGE` from a read path**

`purgeCachedDocument` is the event-emitting variant; it fires `EVENT_DOCUMENT_PURGE` with the victim id. A read operation that self-heals a corrupt cache entry will emit an event indistinguishable from an explicit application-level purge, so any listener that sends webhooks, writes audit entries, or propagates invalidations to external systems will react to it. The protected `purgeCachedDocumentInternal($collection->getId(), $id)` purges the local cache layers without firing the event, which is safer for a transparent self-heal. If distributed-cache propagation is needed, a targeted silent invalidation path is preferable to reusing the full public purge event.

### Issue 3 of 3
src/Database/Database.php:4941-4942
**Foreign document `$id` is included in exception message**

The `DatabaseException` message includes `$document->getId()` — the foreign row's identifier. If this exception bubbles through Appwrite's error handling and the raw message reaches an API response or is written to a log that another tenant can read, the foreign `$id` is exposed cross-tenant. Replacing `$document->getId()` with a placeholder (e.g. `[redacted]`) in the exception message, and reserving the full detail for a server-side `Console::error` call (where you already log the collection id and both ids on the cache path), would eliminate the exposure while preserving debuggability.

Reviews (1): Last reviewed commit: "fix(database): guard by-id reads against..." | Re-trigger Greptile

Comment thread src/Database/Database.php
Comment on lines +4891 to +4893
if ($this->isForeignDocument($document, $id)) {
Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
$this->purgeCachedDocument($collection->getId(), $id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unhandled exception from purgeCachedDocument prevents refetch

If purgeCachedDocument throws (its $this->trigger(EVENT_DOCUMENT_PURGE, ...) call can throw, and the method carries no try-catch), the exception propagates to the caller before the code reaches the adapter refetch. The poisoned cache entry remains intact, and every subsequent call to getDocument for this id will repeat the cycle: detect poison → attempt purge → throw → never refetch. Wrapping the purge + fallthrough in a try-catch that logs the purge failure and proceeds to the adapter read would preserve the self-heal intent even when the purge layer is temporarily unavailable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/Database.php
Line: 4891-4893

Comment:
**Unhandled exception from `purgeCachedDocument` prevents refetch**

If `purgeCachedDocument` throws (its `$this->trigger(EVENT_DOCUMENT_PURGE, ...)` call can throw, and the method carries no try-catch), the exception propagates to the caller before the code reaches the adapter refetch. The poisoned cache entry remains intact, and every subsequent call to `getDocument` for this id will repeat the cycle: detect poison → attempt purge → throw → never refetch. Wrapping the purge + fallthrough in a try-catch that logs the purge failure and proceeds to the adapter read would preserve the self-heal intent even when the purge layer is temporarily unavailable.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment thread src/Database/Database.php
// to the source read instead of trusting the poisoned copy.
if ($this->isForeignDocument($document, $id)) {
Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
$this->purgeCachedDocument($collection->getId(), $id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 purgeCachedDocument fires EVENT_DOCUMENT_PURGE from a read path

purgeCachedDocument is the event-emitting variant; it fires EVENT_DOCUMENT_PURGE with the victim id. A read operation that self-heals a corrupt cache entry will emit an event indistinguishable from an explicit application-level purge, so any listener that sends webhooks, writes audit entries, or propagates invalidations to external systems will react to it. The protected purgeCachedDocumentInternal($collection->getId(), $id) purges the local cache layers without firing the event, which is safer for a transparent self-heal. If distributed-cache propagation is needed, a targeted silent invalidation path is preferable to reusing the full public purge event.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/Database.php
Line: 4893

Comment:
**`purgeCachedDocument` fires `EVENT_DOCUMENT_PURGE` from a read path**

`purgeCachedDocument` is the event-emitting variant; it fires `EVENT_DOCUMENT_PURGE` with the victim id. A read operation that self-heals a corrupt cache entry will emit an event indistinguishable from an explicit application-level purge, so any listener that sends webhooks, writes audit entries, or propagates invalidations to external systems will react to it. The protected `purgeCachedDocumentInternal($collection->getId(), $id)` purges the local cache layers without firing the event, which is safer for a transparent self-heal. If distributed-cache propagation is needed, a targeted silent invalidation path is preferable to reusing the full public purge event.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment thread src/Database/Database.php
Comment on lines +4941 to +4942
if ($this->isForeignDocument($document, $id)) {
throw new DatabaseException("getDocument('{$collection->getId()}', '{$id}') returned document with mismatched \$id '{$document->getId()}'");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Foreign document $id is included in exception message

The DatabaseException message includes $document->getId() — the foreign row's identifier. If this exception bubbles through Appwrite's error handling and the raw message reaches an API response or is written to a log that another tenant can read, the foreign $id is exposed cross-tenant. Replacing $document->getId() with a placeholder (e.g. [redacted]) in the exception message, and reserving the full detail for a server-side Console::error call (where you already log the collection id and both ids on the cache path), would eliminate the exposure while preserving debuggability.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/Database.php
Line: 4941-4942

Comment:
**Foreign document `$id` is included in exception message**

The `DatabaseException` message includes `$document->getId()` — the foreign row's identifier. If this exception bubbles through Appwrite's error handling and the raw message reaches an API response or is written to a log that another tenant can read, the foreign `$id` is exposed cross-tenant. Replacing `$document->getId()` with a placeholder (e.g. `[redacted]`) in the exception message, and reserving the full detail for a server-side `Console::error` call (where you already log the collection id and both ids on the cache path), would eliminate the exposure while preserving debuggability.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

@abnegate

Copy link
Copy Markdown
Member Author

Closing — after review this guard is defense-in-depth on the getDocument() by-id path only, not a root-cause fix.

The wrong-document read originates upstream: a query for document A returned document B's row (a pooled-connection / result-set mixup, PDOProxy-class). That same mixup can corrupt find()/query results too, which a getDocument()-only identity check does not cover — so this PR is partial insurance, not a fix.

The sensitive path (dedicated-DB by-id reads that can expose a foreign hostname/credentials) is already guarded in a targeted way in the consumer (appwrite cloud Store layer). Pursuing the connection-layer root cause instead of a hot-path assertion in core utopia.

@abnegate abnegate closed this Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/unit/ForeignDocumentGuardTest.php (1)

124-133: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add the partial-selection regression test.

The third test covers a normal cache hit, but no test exercises a returned document with $id omitted. Add an adapter-backed projection test that strips $id and confirms the result is accepted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/ForeignDocumentGuardTest.php` around lines 124 - 133, Add a
regression test alongside testGetDocumentServesMatchingCachedDocument that uses
the database adapter’s projection/partial-selection support to retrieve the same
document without the $id field. Assert the returned document is accepted by the
guard and verify its expected attributes, covering the omitted-identifier cache
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Database/Database.php`:
- Around line 4891-4893: Wrap the purgeCachedDocument call in the
foreign-document branch of the read flow with failure handling so purge
exceptions are logged and do not prevent refetching from the source. Preserve
the existing behavior of rejecting the foreign cached value and continuing to
the fallback fetch, regardless of purge success.

---

Nitpick comments:
In `@tests/unit/ForeignDocumentGuardTest.php`:
- Around line 124-133: Add a regression test alongside
testGetDocumentServesMatchingCachedDocument that uses the database adapter’s
projection/partial-selection support to retrieve the same document without the
$id field. Assert the returned document is accepted by the guard and verify its
expected attributes, covering the omitted-identifier cache path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5b762502-c41f-4fca-8660-8004f16ada17

📥 Commits

Reviewing files that changed from the base of the PR and between d5ebecc and 6851434.

📒 Files selected for processing (2)
  • src/Database/Database.php
  • tests/unit/ForeignDocumentGuardTest.php

Comment thread src/Database/Database.php
Comment on lines +4891 to +4893
if ($this->isForeignDocument($document, $id)) {
Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
$this->purgeCachedDocument($collection->getId(), $id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not let cache purge failures block the source fallback.

purgeCachedDocument() can throw, aborting the read instead of refetching the correct document. Log the failure and continue; the foreign cached value must still never be served.

Proposed fix
 if ($this->isForeignDocument($document, $id)) {
     Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
-    $this->purgeCachedDocument($collection->getId(), $id);
+    try {
+        $this->purgeCachedDocument($collection->getId(), $id);
+    } catch (\Throwable $e) {
+        Console::warning('Failed to purge foreign cached document: ' . $e->getMessage());
+    }
 } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($this->isForeignDocument($document, $id)) {
Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
$this->purgeCachedDocument($collection->getId(), $id);
if ($this->isForeignDocument($document, $id)) {
Console::error("Cache for '{$collection->getId()}:{$id}' held foreign document '{$document->getId()}'; purging and refetching");
try {
$this->purgeCachedDocument($collection->getId(), $id);
} catch (\Throwable $e) {
Console::warning('Failed to purge foreign cached document: ' . $e->getMessage());
}
} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Database.php` around lines 4891 - 4893, Wrap the
purgeCachedDocument call in the foreign-document branch of the read flow with
failure handling so purge exceptions are logged and do not prevent refetching
from the source. Preserve the existing behavior of rejecting the foreign cached
value and continuing to the fallback fetch, regardless of purge success.

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.

2 participants