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') {