From 18ae81bbb0fdccc67d0208873c1f3d1ccd7f453b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 30 Apr 2026 18:33:20 +0530 Subject: [PATCH] Refactor presence management by introducing caching mechanisms for list responses. Added methods for loading, saving, and purging cache fields in the PresenceState class. Updated API endpoints to utilize caching, including purge functionality in Update and Delete actions. Enhanced tests to verify cache behavior during presence updates and deletions. --- src/Appwrite/Databases/PresenceState.php | 82 ++++++- .../Modules/Presences/HTTP/Delete.php | 3 + .../Modules/Presences/HTTP/Update.php | 13 ++ .../Platform/Modules/Presences/HTTP/XList.php | 73 ++++++- tests/e2e/Services/Presence/PresenceBase.php | 204 ++++++++++++++++++ 5 files changed, 363 insertions(+), 12 deletions(-) diff --git a/src/Appwrite/Databases/PresenceState.php b/src/Appwrite/Databases/PresenceState.php index dad34b2e12..32a11ce7be 100644 --- a/src/Appwrite/Databases/PresenceState.php +++ b/src/Appwrite/Databases/PresenceState.php @@ -17,6 +17,9 @@ use Utopia\Database\Validator\Authorization; class PresenceState { + public const LIST_CACHE_FIELD_PRESENCES = 'presences'; + public const LIST_CACHE_FIELD_TOTAL = 'total'; + public const COLLECTION_ID = 'presenceLogs'; public function setPermissions(Document $document, ?array $permissions, User $user, Authorization $authorization): Document { $isAPIKey = $user->isApp($authorization->getRoles()); @@ -66,8 +69,8 @@ class PresenceState try { if ($dbForProject->getAdapter()->getSupportForUpsertOnUniqueIndex()) { - $presenceCreated = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])])->isEmpty(); - $presence = $dbForProject->upsertDocument('presenceLogs', $presenceDocument); + $presenceCreated = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userId', [$userId])])->isEmpty(); + $presence = $dbForProject->upsertDocument(self::COLLECTION_ID, $presenceDocument); } else { $presence = $this->transactionalUpsertForUser( $dbForProject, @@ -102,15 +105,15 @@ class PresenceState ?bool &$presenceCreated = null ): Document { return $dbForProject->withTransaction(function () use ($dbForProject, $presenceDocument, $presenceId, $userId, &$presenceCreated) { - $existingPresence = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])]); + $existingPresence = $dbForProject->findOne(self::COLLECTION_ID, [Query::equal('userId', [$userId])]); if ($existingPresence->isEmpty()) { $presenceCreated = true; - return $dbForProject->createDocument('presenceLogs', $presenceDocument); + return $dbForProject->createDocument(self::COLLECTION_ID, $presenceDocument); } // Lock current state to avoid races while resolving upsert by userId. - $currentPresence = $dbForProject->getDocument('presenceLogs', $existingPresence->getId(), forUpdate: true); + $currentPresence = $dbForProject->getDocument(self::COLLECTION_ID, $existingPresence->getId(), forUpdate: true); if ($currentPresence->isEmpty()) { throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$existingPresence->getId()]); @@ -118,11 +121,11 @@ class PresenceState if ($presenceId !== 'unique()' && $currentPresence->getId() !== $presenceId) { $presenceDocument->setAttribute('$id', $presenceId); - $dbForProject->deleteDocument('presenceLogs', $currentPresence->getId()); - return $dbForProject->createDocument('presenceLogs', $presenceDocument); + $dbForProject->deleteDocument(self::COLLECTION_ID, $currentPresence->getId()); + return $dbForProject->createDocument(self::COLLECTION_ID, $presenceDocument); } - return $dbForProject->updateDocument('presenceLogs', $currentPresence->getId(), $presenceDocument); + return $dbForProject->updateDocument(self::COLLECTION_ID, $currentPresence->getId(), $presenceDocument); }); } @@ -147,4 +150,67 @@ class PresenceState } } } + + private function getListCacheKey(Database $dbForProject): string + { + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $dbForProject->getCacheName(), + $dbForProject->getAdapter()->getHostname(), + $dbForProject->getNamespace(), + $dbForProject->getTenant(), + self::COLLECTION_ID + ); + } + + private function getListCacheField(array $roles, array $queries, string $type): string + { + $serialized = \array_map( + static fn ($query) => $query instanceof Query ? $query->toArray() : $query, + $queries, + ); + + return \sprintf( + '%s:%s:%s', + \md5(\json_encode($roles)), + \md5(\json_encode($serialized)), + $type, + ); + } + + public function loadListCacheField( + Database $dbForProject, + array $roles, + array $queries, + string $type, + int $ttl + ): mixed { + $cacheField = $this->getListCacheField($roles, $queries, $type); + + try { + return $dbForProject->getCache()->load($this->getListCacheKey($dbForProject), $ttl, $cacheField); + } catch (\Throwable) { + return null; + } + } + + public function saveListCacheField( + Database $dbForProject, + array $roles, + array $queries, + string $type, + mixed $value + ): void { + $cacheField = $this->getListCacheField($roles, $queries, $type); + + try { + $dbForProject->getCache()->save($this->getListCacheKey($dbForProject), $value, $cacheField); + } catch (\Throwable) { + } + } + + public function purgeListCache(Database $dbForProject): bool + { + return $dbForProject->getCache()->purge($this->getListCacheKey($dbForProject)); + } } diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php index 84877d404e..6963bda7d2 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Presences\HTTP; +use Appwrite\Databases\PresenceState; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Platform\Action as PlatformAction; @@ -73,6 +74,8 @@ class Delete extends PlatformAction throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT); } + (new PresenceState())->purgeListCache($dbForProject); + $usage->addMetric(METRIC_PRESENCE_DELETED, 1); $usage->addMetric(METRIC_USERS_PRESENCE, -1); diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php index 27f15a802e..336264730d 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php @@ -22,6 +22,7 @@ use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; +use Utopia\Validator\Boolean; use Utopia\Validator\JSON; use Utopia\Validator\Nullable; use Utopia\Validator\Text; @@ -64,6 +65,7 @@ class Update extends PlatformAction new Parameter('expiresAt', optional: true), new Parameter('metadata', optional: true), new Parameter('permissions', optional: true), + new Parameter('purge', optional: true), ], ), // Server-side SDK: `userId` is required when authenticating with API keys/JWT. @@ -86,6 +88,7 @@ class Update extends PlatformAction new Parameter('expiresAt', optional: true), new Parameter('metadata', optional: true), new Parameter('permissions', optional: true), + new Parameter('purge', optional: true), ], ), ]) @@ -99,6 +102,7 @@ class Update extends PlatformAction )), 'Presence expiry datetime.', true) ->param('metadata', null, new Nullable(new JSON()), 'Presence metadata object.', true) ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE])), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('purge', false, new Boolean(true), 'When true, purge cached responses used by list presences endpoint.', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -114,6 +118,7 @@ class Update extends PlatformAction ?string $expiresAt, ?array $metadata, ?array $permissions, + bool $purge, Response $response, Database $dbForProject, User $user, @@ -164,6 +169,9 @@ class Update extends PlatformAction } if (empty($updateData) && $permissions === null) { + if ($purge) { + $presenceState->purgeListCache($dbForProject); + } $response->dynamic($presence, Response::MODEL_PRESENCE); return; } @@ -177,6 +185,11 @@ class Update extends PlatformAction } catch (ConflictException $e) { throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, $e->getMessage(), previous: $e); } + + if ($purge) { + $presenceState->purgeListCache($dbForProject); + } + $queueForEvents->setParam('presenceId', $presence->getId()); $response->dynamic($presence, Response::MODEL_PRESENCE); diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php index eb6e794361..2d84fcfc7f 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Presences\HTTP; +use Appwrite\Databases\PresenceState; use Appwrite\Extend\Exception; use Appwrite\Platform\Action as PlatformAction; use Appwrite\SDK\AuthType; @@ -20,6 +21,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; +use Utopia\Validator\Range; class XList extends PlatformAction { @@ -53,12 +55,13 @@ class XList extends PlatformAction )) ->param('queries', [], new PresencesQueries(), 'Array of query strings generated using the Query class provided by the SDK.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) ->inject('response') ->inject('dbForProject') ->callback($this->action(...)); } - public function action(array $queries, bool $includeTotal, Response $response, Database $dbForProject): void + public function action(array $queries, bool $includeTotal, int $ttl, Response $response, Database $dbForProject): void { try { // TODO: make sure to add one more query here if not given -> send only not-expired presence -> presence will be cleared by the maintainance workers @@ -87,11 +90,73 @@ class XList extends PlatformAction $cursor->setValue($cursorDocument); } - $filterQueries = Query::groupByType($queries)['filters']; + $groupedQueries = Query::groupByType($queries); + $filterQueries = $groupedQueries['filters'] ?? []; try { - $documents = $dbForProject->find('presenceLogs', $queries); - $total = $includeTotal ? $dbForProject->count('presenceLogs', $filterQueries, APP_LIMIT_COUNT) : 0; + if ((int)$ttl > 0) { + $presenceState = new PresenceState(); + $roles = $dbForProject->getAuthorization()->getRoles(); + + $documentsCacheHit = false; + $cachedDocuments = $presenceState->loadListCacheField( + $dbForProject, + $roles, + $queries, + PresenceState::LIST_CACHE_FIELD_PRESENCES, + $ttl + ); + + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; + } else { + $documents = $dbForProject->find('presenceLogs', $queries); + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + $presenceState->saveListCacheField( + $dbForProject, + $roles, + $queries, + PresenceState::LIST_CACHE_FIELD_PRESENCES, + $documentsArray + ); + } + + if ($includeTotal) { + $cachedTotal = $presenceState->loadListCacheField( + $dbForProject, + $roles, + $filterQueries, + PresenceState::LIST_CACHE_FIELD_TOTAL, + $ttl + ); + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = (int) $cachedTotal; + } else { + $total = $dbForProject->count('presenceLogs', $filterQueries, APP_LIMIT_COUNT); + $presenceState->saveListCacheField( + $dbForProject, + $roles, + $filterQueries, + PresenceState::LIST_CACHE_FIELD_TOTAL, + $total + ); + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + } else { + $documents = $dbForProject->find('presenceLogs', $queries); + $total = $includeTotal ? $dbForProject->count('presenceLogs', $filterQueries, APP_LIMIT_COUNT) : 0; + } } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } catch (StructureException $e) { diff --git a/tests/e2e/Services/Presence/PresenceBase.php b/tests/e2e/Services/Presence/PresenceBase.php index c5f2f5409a..9131af18dd 100644 --- a/tests/e2e/Services/Presence/PresenceBase.php +++ b/tests/e2e/Services/Presence/PresenceBase.php @@ -531,6 +531,210 @@ trait PresenceBase $this->assertEquals(204, $delete['headers']['status-code']); } + public function testUpdatePresencePurgeListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-update-setup', + 'metadata' => ['cache' => 'update-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $presence = $upsert['body']; + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-update-setup', + 'metadata' => ['cache' => 'update-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $updatePayload = [ + 'status' => 'cache-update-applied', + 'purge' => true, + ]; + + if ($this->getSide() !== 'client') { + $updatePayload['userId'] = $presence['userId']; + } + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + $headers, + $updatePayload + ); + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals('cache-update-applied', $update['body']['status']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + + public function testUpdatePresencePurgeOnlyListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-purge-only-setup', + 'metadata' => ['cache' => 'purge-only-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $presence = $upsert['body']; + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-purge-only-setup', + 'metadata' => ['cache' => 'purge-only-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $updatePayload = [ + 'purge' => true, + ]; + + if ($this->getSide() !== 'client') { + $updatePayload['userId'] = $presence['userId']; + } + + $update = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presence['$id'], + $headers, + $updatePayload + ); + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals($presence['$id'], $update['body']['$id']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + + public function testDeletePresencePurgesListCache(): void + { + if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'cache-delete-setup', + 'metadata' => ['cache' => 'delete-setup'], + ] + ); + $this->assertEquals(200, $upsert['headers']['status-code']); + $presence = $upsert['body']; + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)); + } else { + $presence = $this->setupPresence([ + 'status' => 'cache-delete-setup', + 'metadata' => ['cache' => 'delete-setup'], + ]); + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getPresenceServerHeaders()); + } + + $listPayload = [ + 'queries' => [ + Query::equal('userId', [$presence['userId']])->toString(), + ], + 'ttl' => 60, + ]; + + $list1 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list1['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list1['headers']); + + $list2 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list2['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list2['headers']); + $this->assertEquals('hit', $list2['headers']['x-appwrite-cache']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presence['$id'], + $headers + ); + $this->assertEquals(204, $delete['headers']['status-code']); + + $list3 = $this->client->call(Client::METHOD_GET, '/presences', $headers, $listPayload); + $this->assertEquals(200, $list3['headers']['status-code']); + $this->assertArrayHasKey('x-appwrite-cache', $list3['headers']); + $this->assertEquals('miss', $list3['headers']['x-appwrite-cache']); + } + public function testUpdateNotFound(): void { if ($this->getSide() === 'client') {