Skip to content

test#878

Closed
premtsd-code wants to merge 1 commit into
mainfrom
perf/getcollection-memo
Closed

test#878
premtsd-code wants to merge 1 commit into
mainfrom
perf/getcollection-memo

Conversation

@premtsd-code
Copy link
Copy Markdown
Contributor

@premtsd-code premtsd-code commented May 15, 2026

Summary

Adds a per-instance in-process cache for collection metadata in Database::getCollection(). Today, every authenticated read path calls getCollection() many times — directly and through internal helpers like convertQuery, validators, and relationship resolution — and each call goes through getDocument(METADATA, $id) which incurs a Redis round trip even when the metadata is unchanged.

For a relationship-heavy request that touches one collection 50 times via cascade resolution, that's currently 50 redundant Redis reads. With this change it becomes 1 read, with the remaining 49 served from in-process memory.

How invalidation works

We don't add new invalidation call sites. The library already fires EVENT_DOCUMENT_PURGE with $collection=METADATA after every metadata mutation (createAttribute, deleteAttribute, createIndex, deleteRelationship, updateCollection, etc.). A new listener registered in the constructor catches those events and drops the corresponding cache entry.

For Appwrite Cloud, this also stays correct across regions for free: cloud/app/controllers/general.php already listens on the same event and propagates purges via RegionManager. The receiving region's purgeCachedDocument(METADATA, $id) call re-fires the event on its own Database instance, which invalidates the in-process cache there too.

Full cache wipes are added to the four context-changing setters (setNamespace, setDatabase, setSharedTables, setTenant) and delete(), since those alter the cache key shape or invalidate the underlying data wholesale.

Memory safety

The cache stores the array form of each collection, not the Document instance. The recursive arrayifyDocuments helper unwraps nested attribute/index Document references too. On every cache hit, a fresh Document is rebuilt from the stored array — so callers can mutate the returned collection (e.g. setAttribute('attributes', $new, SET_TYPE_APPEND) as createAttribute does) without any risk of poisoning the cache.

Cache key shape

{database}:{namespace}:{tenant}:{collectionId} — multi-tenant safe. withTenant() doesn't need a clear because the tenant is part of the key.

Predicted impact

  • 15-25% p99 latency reduction on relationship-heavy reads (listMemberships, listDocuments with selects, cascade fetches)
  • 20-40% Dragonfly/Redis cache command rate reduction
  • No MySQL impact (this is a cache-layer fix, not a query-layer one)
  • Per-pod memory: ~5MB at steady state for a typical project (typically dozens of collections)

Test plan

  • Existing PHPUnit suite passes
  • Manual: call getCollection('foo') → mutate the result → call again, verify second call returns the original (isolation test)
  • Manual: call getCollection('foo')createAttribute('foo', ...) → call getCollection('foo'), verify new attribute appears (invalidation test)
  • Manual: under withTenant($t1) populate cache, switch to withTenant($t2), verify isolation
  • Staging: confirm Dragonfly command rate drops on canary region post-deploy

Summary by CodeRabbit

  • Refactor
    • Database operations involving relationships now execute more efficiently through optimized metadata handling.

Review Change Stack

Every find/getDocument/cascade currently hits Redis via getDocument(METADATA, $id)
for the same collection metadata, often 20-50 times per request through
relationship resolution and internal helpers.

This adds a per-instance cache keyed on (database, namespace, tenant, id) that
stores the array form so callers can mutate the returned Document without
poisoning the cache. The recursive arrayifyDocuments helper ensures nested
attribute/index Documents are also fully isolated.

Invalidation rides the existing EVENT_DOCUMENT_PURGE plumbing — every metadata
mutation in the library already fires this event, and cloud's cross-region
propagation listener (cloud/app/controllers/general.php) re-fires it on
receiving regions, so the new in-process invalidator stays in sync without
consumer code changes.

Full cache wipes are added to the context-changing setters (setNamespace,
setDatabase, setSharedTables, setTenant) and delete() since those alter the
cache key shape or invalidate the underlying data.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 27c397a6-65ab-47c3-a32f-71e11e039fc8

📥 Commits

Reviewing files that changed from the base of the PR and between 3391c97 and 6e20ffd.

📒 Files selected for processing (1)
  • src/Database/Database.php
👮 Files not reviewed due to content moderation or server errors (1)
  • src/Database/Database.php

📝 Walkthrough

Walkthrough

The Database class now maintains an in-process, per-context collection metadata cache to avoid repeated metadata reads in hot paths. getCollection() checks the cache first, returning a reconstructed Document on hit; the cache is invalidated on document purge events and whenever context-mutating setters or delete() modify the cache-key inputs.

Changes

Collection metadata caching

Layer / File(s) Summary
Cache property and utility helpers
src/Database/Database.php
$collectionCache stores arrayified collection metadata keyed by database/namespace/tenant/id. Private helpers build cache keys, invalidate single entries, clear the entire cache, and recursively arrayify nested Document objects before storage.
getCollection() cache-aware implementation
src/Database/Database.php
getCollection() now consults the in-process cache first; on hit it reconstructs a fresh Document from cached raw data, and on miss it fetches metadata and caches the fully arrayified form.
Cache lifecycle: purge events and context invalidation
src/Database/Database.php
EVENT_DOCUMENT_PURGE listener invalidates cached entries when metadata is purged. Context setters (setNamespace, setDatabase, setSharedTables, setTenant) and delete() clear the cache when their inputs change to invalidate stale entries.

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 A cache is born, fresh and clean,
Metadata read no more, serene!
Purge events clear, context shifts reset,
Collection queries now serve their best. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The pull request title "test" is unrelated to the actual changeset, which implements an in-process cache for collection metadata in the Database class. Use a descriptive title that reflects the main change, such as 'perf(database): add in-process cache for getCollection metadata' to clearly communicate the purpose and scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 perf/getcollection-memo

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 and usage tips.

@premtsd-code premtsd-code changed the title perf(database): add in-process cache for getCollection metadata test May 15, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 15, 2026

Greptile Summary

This PR introduces a per-instance in-process cache for getCollection() metadata reads, keyed by database:namespace:tenant:collectionId, to eliminate redundant Redis round-trips on relationship-heavy read paths. Invalidation is wired to the existing EVENT_DOCUMENT_PURGE plumbing via a constructor-registered listener, and context-changing setters plus delete() perform full cache wipes.

  • The cache architecture, key design, and isolation (fresh Document per hit via arrayifyDocuments) are sound, and createAttribute/deleteAttribute/createIndex/deleteIndex/deleteRelationship all fire an explicit EVENT_DOCUMENT_PURGE outside any silent() scope, so those paths invalidate correctly.
  • updateCollection wraps updateDocument(METADATA, ...) in $this->silent(...) with no listener list, setting silentListeners = null and causing trigger() to return immediately — leaving updated permissions and documentSecurity invisible to subsequent getCollection() calls on the same instance.
  • deleteCollection has the same gap: deleteDocument(METADATA, $id) is called inside a fully-silent scope and the only post-delete cleanup is purgeCachedCollection($id), which clears Redis but not the in-process cache — a phantom collection remains cached until the next context-switch or process restart.

Confidence Score: 3/5

Not safe to merge as-is: two mutation paths leave stale data in the new in-process cache, which could return deleted or pre-update collection metadata within the same request.

The cache wiring for attribute/index/relationship mutations is correct, but updateCollection and deleteCollection both call their internal document operations inside a fully-silent scope and omit the explicit trigger(EVENT_DOCUMENT_PURGE) that all the other mutation paths add after their silent wrappers. This means permissions changes from updateCollection and deletions from deleteCollection are invisible to any getCollection() call on the same Database instance for the remainder of the request.

src/Database/Database.php — specifically the updateCollection and deleteCollection methods, which need explicit post-silent EVENT_DOCUMENT_PURGE triggers (or direct invalidateCollectionCache calls) matching the pattern already used by createAttribute/deleteAttribute.

Important Files Changed

Filename Overview
src/Database/Database.php Adds per-instance in-process collection metadata cache with EVENT_DOCUMENT_PURGE-based invalidation; two mutation paths (updateCollection, deleteCollection) call their internal document operations inside silent() and never emit an explicit EVENT_DOCUMENT_PURGE outside that scope, leaving stale cache entries after those operations.

Comments Outside Diff (2)

  1. src/Database/Database.php, line 1944-1952 (link)

    P1 updateCollection leaves stale data in the in-process cache

    updateDocument is called inside $this->silent(...) with no second argument, which sets $this->silentListeners = null. When trigger() is called inside that silent scope it returns immediately — so the collection-cache-invalidator listener never fires. There is no explicit trigger(EVENT_DOCUMENT_PURGE) after the silent wrapper (unlike createAttribute/deleteAttribute, which add one explicitly). After updateCollection("foo", ...), any subsequent getCollection("foo") call on the same instance returns pre-mutation permissions and documentSecurity from the in-process cache. The createAttribute/deleteAttribute pattern — purgeCachedDocumentInternal(METADATA, id) then an explicit trigger(EVENT_DOCUMENT_PURGE, ...) outside silent mode — should be applied here too.

  2. src/Database/Database.php, line 2193-2219 (link)

    P1 deleteCollection does not evict the deleted collection from the in-process cache

    deleteDocument(METADATA, $id) is called inside $this->silent(...) (line 2193), which fully silences all triggers. The purgeCachedDocument called internally by deleteDocument tries to fire EVENT_DOCUMENT_PURGE, but trigger() returns early because silentListeners === null. No explicit trigger(EVENT_DOCUMENT_PURGE) with $collection = METADATA is issued after the silent block — unlike createAttribute/deleteAttribute, which always fire it explicitly. The only post-delete cleanup is purgeCachedCollection($id) (line 2217), which clears the Redis document cache but does not touch the in-process collection cache.

    As a result, any getCollection("foo") call made on the same Database instance after deleteCollection("foo") returns the cached, now-dead Document rather than an empty one. Code that relies on $collection->isEmpty() to gate subsequent operations would then proceed against a phantom collection.

Reviews (1): Last reviewed commit: "perf(database): add in-process cache for..." | Re-trigger Greptile

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 15, 2026

CI failure root cause

testEvents failed first with NotFoundException: Index not found at deleteIndex, leaving its EVENT_ALL 'test' listener attached. That listener then asserted on every subsequent event in the suite, producing the 250+ Failed asserting that 'document_create' matches expected null cascade.

The bug was in this PR's in-process collection cache. The invalidation strategy hooked EVENT_DOCUMENT_PURGE, but every metadata write goes through updateMetadata()silent(fn () => updateDocument(METADATA, …)), which suppresses listeners. So after createIndex, the in-process cache still held the pre-index collection. deleteIndex read the stale cache, didn't find the new index, and threw.

Fix

Moved invalidation into the lower-level purgeCachedDocumentInternal (filtered to METADATA) and purgeCachedCollection. Both run unconditionally regardless of silent() state, and every metadata mutation already calls them. Dropped the now-redundant EVENT_DOCUMENT_PURGE listener.

@premtsd-code
Copy link
Copy Markdown
Contributor Author

Closing this in favor of keeping the existing selective caching pattern. Captured here so the trade-off is recorded for future reference.

Why close

The existing utopia/database cache (Dragonfly via utopia/cache) is selective by design: it caches documents that are repeatedly read with the same key (getDocument hits), and explicitly does not cache things that vary per-query (find/count/sum results, _perms lookups). This PR violated that principle by caching every collection touched in a request, indiscriminately — including collections that are accessed once and never re-read.

Per-request lifetime bounds the memory growth, so it isn't catastrophic. But the principle "cache only what benefits from being cached" matters more than the few-ms p99 win this PR predicted.

What we don't have evidence for

The PR's predicted impact (15-25% p99 reduction on relationship-heavy reads, 20-40% Dragonfly load reduction) was based on an estimate of ~30-50 getCollection calls per request via cascade resolution. That estimate was never measured. PR #877 (the EXISTS rewrite) has measured production evidence (~600 rows/SELECT baseline); this PR doesn't.

When to revisit

If after PR #877 lands in staging and the rows-per-SELECT ratio drops as predicted, MySQL CPU should improve materially. If at that point Dragonfly cache load is still substantial or relationship-heavy endpoint p99 remains above target, revisit with a selective L1: only cache collections accessed ≥2 times in the current request, so we match the existing pattern of "cache only what's getting re-read."

For now, the EXISTS rewrite in #877 is the higher-confidence, higher-impact change to ship first.

@premtsd-code premtsd-code deleted the perf/getcollection-memo branch May 15, 2026 13:23
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