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.

This commit is contained in:
ArnabChatterjee20k
2026-04-30 18:33:20 +05:30
parent e03218d8fa
commit 18ae81bbb0
5 changed files with 363 additions and 12 deletions
+74 -8
View File
@@ -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));
}
}
@@ -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);
@@ -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);
@@ -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) {
@@ -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') {