added transactions for vectordb

This commit is contained in:
ArnabChatterjee20k
2025-11-12 16:21:27 +05:30
parent c3f221f1fb
commit 954011b2c4
14 changed files with 3346 additions and 1 deletions
@@ -20,6 +20,7 @@ abstract class Action extends UtopiaAction
public function setHttpPath(string $path): UtopiaAction
{
switch (true) {
// TODO: set the getDatabaseType() from each database group instead of path matching
case str_contains($path, '/tablesdb'):
$this->context = TABLES;
$this->databaseType = TABLESDB;
@@ -29,6 +30,10 @@ abstract class Action extends UtopiaAction
$this->context = COLLECTIONS;
$this->databaseType = DOCUMENTSDB;
break;
case str_contains($path, '/vectordb'):
$this->context = COLLECTIONS;
$this->databaseType = VECTORDB;
break;
}
return parent::setHttpPath($path);
}
@@ -286,6 +286,11 @@ class Update extends Action
$data = $data->getArrayCopy();
}
// Decode JSON strings that may have been encoded when storing nested arrays/objects
if (\is_array($data)) {
$data = $this->decodeJsonStrings($data);
}
if (!isset($dbCache[$databaseInternalId])) {
$databaseDoc = Authorization::skip(fn () => $dbForProject->findOne('databases', [
Query::equal('$sequence', [$databaseInternalId])
@@ -710,6 +715,8 @@ class Update extends Action
array &$state
): int {
$count = 0;
// Decode JSON strings in bulk create data
$data = $this->decodeJsonStrings($data);
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $data, &$state, &$count) {
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
@@ -748,8 +755,11 @@ class Update extends Action
\DateTime $createdAt,
array &$state
): int {
// extracting query first then decoding the nested transaction log data
// otherwise can result error as queries would get transformed to array instead of string
$queries = Query::parseQueries($data['queries'] ?? []);
$updateData = new Document($data['data']);
$data = $this->decodeJsonStrings($data);
$dependentDocs = [];
@@ -820,6 +830,8 @@ class Update extends Action
\DateTime $createdAt,
array &$state
): int {
// Decode JSON strings in bulk upsert data
$data = $this->decodeJsonStrings($data);
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
}, $data);
@@ -895,4 +907,31 @@ class Update extends Action
return $count;
}
/**
* Recursively decode JSON strings in data array
* This handles cases where nested arrays/objects are stored as JSON strings in the database
*
* @param array $data
* @return array
*/
private function decodeJsonStrings(array $data): array
{
foreach ($data as $key => $value) {
if (\is_string($value)) {
$decoded = \json_decode($value, true);
if (\json_last_error() === JSON_ERROR_NONE && (\is_array($decoded) || \is_object($decoded))) {
$data[$key] = $decoded;
// Recursively decode nested structures
if (\is_array($decoded)) {
$data[$key] = $this->decodeJsonStrings($decoded);
}
}
} elseif (\is_array($value)) {
// Recursively process nested arrays
$data[$key] = $this->decodeJsonStrings($value);
}
}
return $data;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Create as TransactionsCreate;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Swoole\Response as SwooleResponse;
use Utopia\Validator\Range;
class Create extends TransactionsCreate
{
public static function getName(): string
{
return 'createVectorDBTransaction';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_TRANSACTION;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/vectordb/transactions')
->desc('Create transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'createTransaction',
description: '/docs/references/vectordb/create-transaction.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_CREATED,
model: UtopiaResponse::MODEL_TRANSACTION,
)
],
contentType: ContentType::JSON
))
->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->callback($this->action(...));
}
}
@@ -0,0 +1,55 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Delete as TransactionsDelete;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Validator\UID;
use Utopia\Swoole\Response as SwooleResponse;
class Delete extends TransactionsDelete
{
public static function getName(): string
{
return 'deleteVectorDBTransaction';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_NONE;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/vectordb/transactions/:transactionId')
->desc('Delete transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'deleteTransaction',
description: '/docs/references/vectordb/delete-transaction.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_NOCONTENT,
model: UtopiaResponse::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('transactionId', '', new UID(), 'Transaction ID.')
->inject('response')
->inject('dbForProject')
->inject('queueForDeletes')
->callback($this->action(...));
}
}
@@ -0,0 +1,54 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Get as TransactionsGet;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Validator\UID;
use Utopia\Swoole\Response as SwooleResponse;
class Get extends TransactionsGet
{
public static function getName(): string
{
return 'getVectorDBTransaction';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_TRANSACTION;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/vectordb/transactions/:transactionId')
->desc('Get transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.read')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'getTransaction',
description: '/docs/references/vectordb/get-transaction.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_OK,
model: UtopiaResponse::MODEL_TRANSACTION,
)
],
contentType: ContentType::JSON
))
->param('transactionId', '', new UID(), 'Transaction ID.')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
}
@@ -0,0 +1,59 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Operations;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Operations\Create as OperationsCreate;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Operation;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Validator\UID;
use Utopia\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
class Create extends OperationsCreate
{
public static function getName(): string
{
return 'createVectorDBOperations';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_TRANSACTION;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/vectordb/transactions/:transactionId/operations')
->desc('Create operations')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'createOperations',
description: '/docs/references/vectordb/create-operations.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_CREATED,
model: UtopiaResponse::MODEL_TRANSACTION,
)
],
contentType: ContentType::JSON
))
->param('transactionId', '', new UID(), 'Transaction ID.')
->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true)
->inject('response')
->inject('dbForProject')
->inject('transactionState')
->inject('plan')
->callback($this->action(...));
}
}
@@ -0,0 +1,67 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\Update as TransactionsUpdate;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Validator\UID;
use Utopia\Swoole\Response as SwooleResponse;
use Utopia\Validator\Boolean;
class Update extends TransactionsUpdate
{
public static function getName(): string
{
return 'updateVectorDBTransaction';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_TRANSACTION;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/vectordb/transactions/:transactionId')
->desc('Update transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'updateTransaction',
description: '/docs/references/vectordb/update-transaction.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_OK,
model: UtopiaResponse::MODEL_TRANSACTION,
)
],
contentType: ContentType::JSON
))
->param('transactionId', '', new UID(), 'Transaction ID.')
->param('commit', false, new Boolean(), 'Commit transaction?', true)
->param('rollback', false, new Boolean(), 'Rollback transaction?', true)
->inject('project')
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
->inject('queueForDeletes')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForRealtime')
->inject('queueForFunctions')
->inject('queueForWebhooks')
->callback($this->action(...));
}
}
@@ -0,0 +1,54 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions;
use Appwrite\Platform\Modules\Databases\Http\Databases\Transactions\XList as TransactionsList;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Transactions;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Swoole\Response as SwooleResponse;
class XList extends TransactionsList
{
public static function getName(): string
{
return 'listVectorDBTransactions';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_TRANSACTION_LIST;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/vectordb/transactions')
->desc('List transactions')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.read')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'vectorDB',
group: 'transactions',
name: 'listTransactions',
description: '/docs/references/vectordb/list-transactions.md',
auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_OK,
model: UtopiaResponse::MODEL_TRANSACTION_LIST,
)
],
contentType: ContentType::JSON
))
->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
}
@@ -26,6 +26,12 @@ use Appwrite\Platform\Modules\Databases\Http\VectorDB\Collections\XList as ListC
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Create as CreateVectorDatabase;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Delete as DeleteVectorDatabase;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Get as GetVectorDatabase;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Create as CreateTransaction;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Delete as DeleteTransaction;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Get as GetTransaction;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Operations\Create as CreateOperations;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\Update as UpdateTransaction;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Transactions\XList as ListTransactions;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Update as UpdateVectorDatabase;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Usage\Get as GetVectorDatabaseUsage;
use Appwrite\Platform\Modules\Databases\Http\VectorDB\Usage\XList as ListVectorDatabaseUsage;
@@ -41,7 +47,7 @@ class VectorDB extends Base
$this->registerIndexActions($service);
$this->registerDocumentActions($service);
$this->registerEmbeddingActions($service);
// $this->registerTransactionActions($service);
$this->registerTransactionActions($service);
}
private function registerDatabaseActions(Service $service): void
@@ -87,6 +93,16 @@ class VectorDB extends Base
$service->addAction(DeleteDocuments::getName(), new DeleteDocuments());
}
private function registerTransactionActions(Service $service): void
{
$service->addAction(CreateTransaction::getName(), new CreateTransaction());
$service->addAction(GetTransaction::getName(), new GetTransaction());
$service->addAction(UpdateTransaction::getName(), new UpdateTransaction());
$service->addAction(DeleteTransaction::getName(), new DeleteTransaction());
$service->addAction(ListTransactions::getName(), new ListTransactions());
$service->addAction(CreateOperations::getName(), new CreateOperations());
}
private function registerEmbeddingActions(Service $service): void
{
$service->addAction(CreateTextEmbeddings::getName(), new CreateTextEmbeddings());
@@ -0,0 +1,528 @@
<?php
namespace Tests\E2E\Services\Databases\VectorDB\Transactions;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
class ACIDTest extends Scope
{
use ProjectCustom;
use SideClient;
private function generateEmbeddings(int $dimensions = 3, float $value = 0.1): array
{
$vector = array_fill(0, $dimensions, $value);
$vector[0] = 1.0;
return $vector;
}
/**
* Test atomicity - all operations succeed or all fail
*/
public function testAtomicity(): void
{
// Create database
$database = $this->client->call(Client::METHOD_POST, '/vectordb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'AtomicityTestDB'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create collection for the test
$collection = $this->client->call(Client::METHOD_POST, '/vectordb/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'AtomicityTest',
'dimensions' => 3,
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Create a document outside the transaction
$existingDocumentId = 'existing_doc';
$doc1 = $this->client->call(Client::METHOD_POST, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'documentId' => $existingDocumentId,
'data' => [
'embeddings' => $this->generateEmbeddings(3),
'metadata' => ['email' => 'existing@example.com'],
],
]);
$this->assertEquals(201, $doc1['headers']['status-code']);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/vectordb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed. Response: ' . json_encode($transaction));
$this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id. Response body: ' . json_encode($transaction['body']));
$transactionId = $transaction['body']['$id'];
// Add operations - second create reuses an existing documentId and should cause the commit to fail
$response = $this->client->call(Client::METHOD_POST, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documents' => [
[
'$id' => 'txn_doc_1',
'embeddings' => $this->generateEmbeddings(3, 0.2),
'metadata' => ['email' => 'newuser@example.com'],
],
[
'$id' => $existingDocumentId,
'embeddings' => $this->generateEmbeddings(3, 0.3),
'metadata' => ['email' => 'duplicate@example.com'],
],
[
'$id' => 'txn_doc_2',
'embeddings' => $this->generateEmbeddings(3, 0.4),
'metadata' => ['email' => 'should-not-exist@example.com'],
],
],
'transactionId' => $transactionId,
]);
$this->assertEquals(200, $response['headers']['status-code'], 'Adding documents via normal route should succeed. Response: ' . json_encode($response['body']));
// Attempt to commit - should fail due to duplicate document ID
$response = $this->client->call(Client::METHOD_PATCH, "/vectordb/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(409, $response['headers']['status-code']);
// Verify NO new documents were created (atomicity)
$documents = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(1, $documents['body']['total']);
$this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']);
}
/**
* Test consistency - schema validation and constraints
*/
public function testConsistency(): void
{
// Create database
$database = $this->client->call(Client::METHOD_POST, '/vectordb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'ConsistencyTestDB'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create collection
$collection = $this->client->call(Client::METHOD_POST, '/vectordb/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'ConsistencyTest',
'dimensions' => 3,
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/vectordb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Stage operations with valid and invalid data (embedding length mismatch)
$response = $this->client->call(Client::METHOD_POST, "/vectordb/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => [
'embeddings' => $this->generateEmbeddings(3, 0.2),
'metadata' => ['name' => 'Valid User'],
],
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => [
'embeddings' => $this->generateEmbeddings(2, 0.5), // Invalid dimensions
'metadata' => ['name' => 'Invalid User'],
],
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => [
'embeddings' => $this->generateEmbeddings(3, 0.6),
'metadata' => ['name' => 'Should Not Persist'],
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Attempt to commit - should fail due to invalid embeddings
$response = $this->client->call(Client::METHOD_PATCH, "/vectordb/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertContains($response['headers']['status-code'], [400, 409, 500], 'Transaction commit should fail due to validation. Response: ' . json_encode($response['body']));
// Verify no documents were created
$documents = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(0, $documents['body']['total']);
}
/**
* Test isolation - concurrent transactions on same data
*/
public function testIsolation(): void
{
// Create database
$database = $this->client->call(Client::METHOD_POST, '/vectordb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'IsolationTestDB'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create collection
$collection = $this->client->call(Client::METHOD_POST, '/vectordb/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'IsolationTest',
'dimensions' => 3,
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create initial document with status metadata
$doc = $this->client->call(Client::METHOD_POST, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'documentId' => 'shared_doc',
'data' => [
'embeddings' => $this->generateEmbeddings(3),
'metadata' => ['status' => 'pending'],
],
]);
$this->assertEquals(201, $doc['headers']['status-code']);
// Create first transaction
$transaction1 = $this->client->call(Client::METHOD_POST, '/vectordb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(201, $transaction1['headers']['status-code'], 'Transaction 1 creation should succeed');
$this->assertArrayHasKey('$id', $transaction1['body'], 'Transaction 1 response should have $id');
$transactionId1 = $transaction1['body']['$id'];
// Transaction 1: update status to approved
$this->client->call(Client::METHOD_POST, "/vectordb/transactions/{$transactionId1}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'shared_doc',
'data' => [
'metadata' => ['status' => 'approved'],
],
],
],
]);
// Commit first transaction
$response1 = $this->client->call(Client::METHOD_PATCH, "/vectordb/transactions/{$transactionId1}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response1['headers']['status-code']);
// Document should reflect the first transaction's update
$document = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals('approved', $document['body']['metadata']['status']);
// Create second transaction after first commit
$transaction2 = $this->client->call(Client::METHOD_POST, '/vectordb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(201, $transaction2['headers']['status-code'], 'Transaction 2 creation should succeed');
$this->assertArrayHasKey('$id', $transaction2['body'], 'Transaction 2 response should have $id');
$transactionId2 = $transaction2['body']['$id'];
// Transaction 2: update status to declined
$this->client->call(Client::METHOD_POST, "/vectordb/transactions/{$transactionId2}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'shared_doc',
'data' => [
'metadata' => ['status' => 'declined'],
],
],
],
]);
// Commit second transaction and ensure isolation guarantees
$response2 = $this->client->call(Client::METHOD_PATCH, "/vectordb/transactions/{$transactionId2}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response2['headers']['status-code']);
// Final document should reflect the second transaction's update
$document = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals('declined', $document['body']['metadata']['status']);
}
/**
* Test durability - committed data persists
*/
public function testDurability(): void
{
// Create database
$database = $this->client->call(Client::METHOD_POST, '/vectordb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'DurabilityTestDB'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create collection
$collection = $this->client->call(Client::METHOD_POST, '/vectordb/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'DurabilityTest',
'dimensions' => 3,
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Create transaction with multiple operations
$transaction = $this->client->call(Client::METHOD_POST, '/vectordb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed');
$this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id');
$transactionId = $transaction['body']['$id'];
// Create two documents via normal route inside transaction
$this->client->call(Client::METHOD_POST, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documents' => [
[
'$id' => 'durable_doc_1',
'embeddings' => $this->generateEmbeddings(3, 0.3),
'metadata' => ['data' => 'Important data 1'],
],
[
'$id' => 'durable_doc_2',
'embeddings' => $this->generateEmbeddings(3, 0.5),
'metadata' => ['data' => 'Important data 2'],
],
],
'transactionId' => $transactionId,
]);
// Update first document inside the same transaction
$this->client->call(Client::METHOD_PATCH, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'data' => [
'metadata' => ['data' => 'Updated important data 1'],
],
'transactionId' => $transactionId,
]);
// Commit transaction
$response = $this->client->call(Client::METHOD_PATCH, "/vectordb/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response['headers']['status-code'], 'Commit should succeed. Response: ' . json_encode($response['body']));
$this->assertEquals('committed', $response['body']['status']);
// Verify documents exist and have correct data
$document1 = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $document1['headers']['status-code']);
$this->assertEquals('Updated important data 1', $document1['body']['metadata']['data']);
$document2 = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_2", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $document2['headers']['status-code']);
$this->assertEquals('Important data 2', $document2['body']['metadata']['data']);
// Further update outside transaction to ensure persistence
$update = $this->client->call(Client::METHOD_PATCH, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'data' => [
'metadata' => ['data' => 'Modified outside transaction'],
],
]);
$this->assertEquals(200, $update['headers']['status-code']);
// Verify the update persisted
$document1 = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals('Modified outside transaction', $document1['body']['metadata']['data']);
// List all documents to verify total count
$documents = $this->client->call(Client::METHOD_GET, "/vectordb/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(2, $documents['body']['total']);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Databases\VectorDB\Transactions;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class TransactionsConsoleClientTest extends Scope
{
use TransactionsBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Databases\VectorDB\Transactions;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
class TransactionsCustomClientTest extends Scope
{
use TransactionsBase;
use ProjectCustom;
use SideClient;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Databases\VectorDB\Transactions;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class TransactionsCustomServerTest extends Scope
{
use TransactionsBase;
use ProjectCustom;
use SideServer;
}