mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
1300 lines
53 KiB
PHP
1300 lines
53 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class BenchmarkResponse
|
|
{
|
|
private mixed $json = null;
|
|
private bool $jsonParsed = false;
|
|
|
|
public function __construct(
|
|
public readonly int $status,
|
|
public readonly string $body,
|
|
public readonly array $headers,
|
|
public readonly float $duration,
|
|
) {
|
|
}
|
|
|
|
public function json(?string $key = null): mixed
|
|
{
|
|
if (!$this->jsonParsed) {
|
|
$this->json = json_decode($this->body, true);
|
|
$this->jsonParsed = true;
|
|
}
|
|
|
|
if ($key === null) {
|
|
return $this->json;
|
|
}
|
|
|
|
return is_array($this->json) ? ($this->json[$key] ?? null) : null;
|
|
}
|
|
|
|
public function header(string $name): string
|
|
{
|
|
$key = strtolower($name);
|
|
return isset($this->headers[$key]) ? implode(', ', $this->headers[$key]) : '';
|
|
}
|
|
|
|
public function cookieHeader(): string
|
|
{
|
|
$cookies = [];
|
|
|
|
foreach ($this->headers['set-cookie'] ?? [] as $cookie) {
|
|
$cookies[] = explode(';', $cookie, 2)[0];
|
|
}
|
|
|
|
return implode('; ', $cookies);
|
|
}
|
|
}
|
|
|
|
final class BenchmarkMetrics
|
|
{
|
|
private array $trends = [];
|
|
private array $counters = [
|
|
'appwrite_benchmark_flow_failures' => 0,
|
|
];
|
|
private int $checksPassed = 0;
|
|
private int $checksFailed = 0;
|
|
|
|
public function addTrend(string $name, float $value): void
|
|
{
|
|
$this->trends[$name] ??= [];
|
|
$this->trends[$name][] = $value;
|
|
}
|
|
|
|
public function addCounter(string $name, int $value = 1): void
|
|
{
|
|
$this->counters[$name] ??= 0;
|
|
$this->counters[$name] += $value;
|
|
}
|
|
|
|
public function addCheck(bool $passed): void
|
|
{
|
|
if ($passed) {
|
|
$this->checksPassed++;
|
|
return;
|
|
}
|
|
|
|
$this->checksFailed++;
|
|
}
|
|
|
|
public function summary(): array
|
|
{
|
|
$metrics = [];
|
|
|
|
foreach ($this->trends as $name => $values) {
|
|
$metrics[$name] = [
|
|
'type' => 'trend',
|
|
'contains' => 'time',
|
|
'values' => $this->trendValues($values),
|
|
];
|
|
}
|
|
|
|
foreach ($this->counters as $name => $count) {
|
|
$metrics[$name] = [
|
|
'type' => 'counter',
|
|
'contains' => 'default',
|
|
'values' => [
|
|
'count' => $count,
|
|
],
|
|
];
|
|
}
|
|
|
|
$totalChecks = $this->checksPassed + $this->checksFailed;
|
|
$metrics['checks'] = [
|
|
'type' => 'rate',
|
|
'contains' => 'default',
|
|
'values' => [
|
|
'rate' => $totalChecks > 0 ? $this->checksPassed / $totalChecks : 1,
|
|
'passes' => $this->checksPassed,
|
|
'fails' => $this->checksFailed,
|
|
],
|
|
];
|
|
|
|
return ['metrics' => $metrics];
|
|
}
|
|
|
|
public function failedChecks(): int
|
|
{
|
|
return $this->checksFailed;
|
|
}
|
|
|
|
public function flowFailures(): int
|
|
{
|
|
return $this->counters['appwrite_benchmark_flow_failures'] ?? 0;
|
|
}
|
|
|
|
private function trendValues(array $values): array
|
|
{
|
|
sort($values, SORT_NUMERIC);
|
|
$count = count($values);
|
|
|
|
if ($count === 0) {
|
|
return [
|
|
'count' => 0,
|
|
'min' => null,
|
|
'avg' => null,
|
|
'med' => null,
|
|
'max' => null,
|
|
'p(90)' => null,
|
|
'p(95)' => null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'count' => $count,
|
|
'min' => $values[0],
|
|
'avg' => array_sum($values) / $count,
|
|
'med' => $this->percentile($values, 50),
|
|
'max' => $values[$count - 1],
|
|
'p(90)' => $this->percentile($values, 90),
|
|
'p(95)' => $this->percentile($values, 95),
|
|
];
|
|
}
|
|
|
|
private function percentile(array $sortedValues, int $percentile): float
|
|
{
|
|
$count = count($sortedValues);
|
|
|
|
if ($count === 1) {
|
|
return (float) $sortedValues[0];
|
|
}
|
|
|
|
$rank = ($percentile / 100) * ($count - 1);
|
|
$lower = (int) floor($rank);
|
|
$upper = (int) ceil($rank);
|
|
|
|
if ($lower === $upper) {
|
|
return (float) $sortedValues[$lower];
|
|
}
|
|
|
|
$weight = $rank - $lower;
|
|
return (float) ($sortedValues[$lower] + (($sortedValues[$upper] - $sortedValues[$lower]) * $weight));
|
|
}
|
|
}
|
|
|
|
final class HttpBenchmark
|
|
{
|
|
private const API_SCOPES = [
|
|
'sessions.write',
|
|
'users.read',
|
|
'users.write',
|
|
'teams.read',
|
|
'teams.write',
|
|
'databases.read',
|
|
'databases.write',
|
|
'collections.read',
|
|
'collections.write',
|
|
'tables.read',
|
|
'tables.write',
|
|
'attributes.read',
|
|
'attributes.write',
|
|
'columns.read',
|
|
'columns.write',
|
|
'indexes.read',
|
|
'indexes.write',
|
|
'documents.read',
|
|
'documents.write',
|
|
'rows.read',
|
|
'rows.write',
|
|
'files.read',
|
|
'files.write',
|
|
'buckets.read',
|
|
'buckets.write',
|
|
'functions.read',
|
|
'functions.write',
|
|
'sites.read',
|
|
'sites.write',
|
|
'log.read',
|
|
'log.write',
|
|
'execution.read',
|
|
'execution.write',
|
|
'locale.read',
|
|
'avatars.read',
|
|
'health.read',
|
|
'providers.read',
|
|
'providers.write',
|
|
'messages.read',
|
|
'messages.write',
|
|
'topics.read',
|
|
'topics.write',
|
|
'subscribers.read',
|
|
'subscribers.write',
|
|
'targets.read',
|
|
'targets.write',
|
|
'rules.read',
|
|
'rules.write',
|
|
'migrations.read',
|
|
'migrations.write',
|
|
'vcs.read',
|
|
'vcs.write',
|
|
'assistant.read',
|
|
'tokens.read',
|
|
'tokens.write',
|
|
'platforms.read',
|
|
'platforms.write',
|
|
];
|
|
|
|
private const BASE_PERMISSIONS = [
|
|
'read("any")',
|
|
'create("any")',
|
|
'update("any")',
|
|
'delete("any")',
|
|
];
|
|
|
|
private const ITEM_PERMISSIONS = [
|
|
'read("any")',
|
|
'update("any")',
|
|
'delete("any")',
|
|
];
|
|
|
|
private const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';
|
|
|
|
private BenchmarkMetrics $metrics;
|
|
private string $endpoint;
|
|
private string $maildevEndpoint;
|
|
private string $consoleProject;
|
|
private string $region;
|
|
private string $redirectUrl;
|
|
private string $password;
|
|
private int $mailTimeoutMs;
|
|
private int $workerTimeoutMs;
|
|
private int $iterations;
|
|
private int $vus;
|
|
private string $summaryPath;
|
|
private ?array $previousSummary;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->metrics = new BenchmarkMetrics();
|
|
$this->endpoint = rtrim($this->env('APPWRITE_ENDPOINT', 'http://localhost/v1'), '/');
|
|
$this->maildevEndpoint = $this->env('APPWRITE_MAILDEV_ENDPOINT', 'http://localhost:9503/email');
|
|
$this->consoleProject = $this->env('APPWRITE_CONSOLE_PROJECT', 'console');
|
|
$this->region = $this->env('APPWRITE_REGION', 'default');
|
|
$this->redirectUrl = $this->env('APPWRITE_BENCHMARK_REDIRECT_URL', 'http://localhost');
|
|
$this->password = $this->env('APPWRITE_BENCHMARK_PASSWORD', 'Password123!');
|
|
$this->mailTimeoutMs = (int) $this->env('APPWRITE_MAIL_TIMEOUT_MS', '20000');
|
|
$this->workerTimeoutMs = (int) $this->env('APPWRITE_WORKER_TIMEOUT_MS', '60000');
|
|
$this->iterations = max(1, (int) $this->env('APPWRITE_BENCHMARK_ITERATIONS', '1'));
|
|
$this->vus = max(1, (int) $this->env('APPWRITE_BENCHMARK_VUS', '1'));
|
|
$this->summaryPath = $this->env('APPWRITE_BENCHMARK_SUMMARY_PATH', 'tests/benchmarks/http-summary.json');
|
|
$this->previousSummary = $this->loadPreviousSummary($this->env('APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH', $this->summaryPath));
|
|
}
|
|
|
|
public function run(): int
|
|
{
|
|
$context = null;
|
|
$exitCode = 0;
|
|
|
|
try {
|
|
$context = $this->setup();
|
|
|
|
for ($i = 0; $i < $this->iterations * $this->vus; $i++) {
|
|
$this->curatedFlows($context);
|
|
}
|
|
} catch (Throwable $error) {
|
|
$exitCode = 1;
|
|
fwrite(STDERR, $error->getMessage() . PHP_EOL);
|
|
} finally {
|
|
if (is_array($context)) {
|
|
$this->teardown($context);
|
|
}
|
|
|
|
$summary = $this->metrics->summary();
|
|
$this->writeSummary($summary);
|
|
echo $this->renderSummary($summary);
|
|
}
|
|
|
|
if ($this->metrics->failedChecks() > 0 || $this->metrics->flowFailures() > 0) {
|
|
$exitCode = 1;
|
|
}
|
|
|
|
return $exitCode;
|
|
}
|
|
|
|
private function setup(): array
|
|
{
|
|
$runId = $this->unique('run');
|
|
$consoleEmail = $this->env('APPWRITE_ADMIN_EMAIL', "bench-admin-{$runId}@example.com");
|
|
$consolePassword = $this->env('APPWRITE_ADMIN_PASSWORD', $this->password);
|
|
$consoleHeaders = [
|
|
'Content-Type' => 'application/json',
|
|
'X-Appwrite-Project' => $this->consoleProject,
|
|
];
|
|
|
|
$account = $this->rawRequest('POST', '/account', [
|
|
'userId' => $this->unique('admin'),
|
|
'email' => $consoleEmail,
|
|
'password' => $consolePassword,
|
|
'name' => 'Benchmark Admin',
|
|
], $consoleHeaders, 'setup.account.create');
|
|
|
|
if (!in_array($account->status, [201, 409], true)) {
|
|
$this->failResponse($account, 'Unable to create or reuse the benchmark console account');
|
|
}
|
|
|
|
$session = $this->rawRequest('POST', '/account/sessions/email', [
|
|
'email' => $consoleEmail,
|
|
'password' => $consolePassword,
|
|
], $consoleHeaders, 'setup.account.session');
|
|
$this->assertStatus($session, [201], 'console session created');
|
|
|
|
$consoleSessionHeaders = [
|
|
...$consoleHeaders,
|
|
'Cookie' => $session->cookieHeader(),
|
|
];
|
|
|
|
$team = $this->api('POST', '/teams', [
|
|
'teamId' => $this->unique('team'),
|
|
'name' => "Benchmark Team {$runId}",
|
|
], $consoleSessionHeaders, [201], 'setup.teams.create');
|
|
|
|
$teamId = (string) $team->json('$id');
|
|
$project = $this->api('POST', '/projects', [
|
|
'projectId' => $this->unique('project'),
|
|
'name' => "Benchmark Project {$runId}",
|
|
'teamId' => $teamId,
|
|
'region' => $this->region,
|
|
], $consoleSessionHeaders, [201], 'setup.projects.create');
|
|
|
|
$projectId = (string) $project->json('$id');
|
|
$key = $this->api('POST', "/projects/{$projectId}/keys", [
|
|
'keyId' => $this->unique('key'),
|
|
'name' => 'Benchmark API key',
|
|
'scopes' => self::API_SCOPES,
|
|
], $consoleSessionHeaders, [201], 'setup.projects.keys.create');
|
|
|
|
$apiHeaders = [
|
|
'Content-Type' => 'application/json',
|
|
'X-Appwrite-Project' => $projectId,
|
|
'X-Appwrite-Key' => (string) $key->json('secret'),
|
|
];
|
|
|
|
$platform = $this->api('POST', '/project/platforms/web', [
|
|
'platformId' => $this->unique('web'),
|
|
'name' => 'Benchmark web',
|
|
'hostname' => $this->hostnameFromUrl($this->redirectUrl),
|
|
], $apiHeaders, [201, 409], 'setup.project.platforms.web.create');
|
|
|
|
$smtpBody = [
|
|
'enabled' => true,
|
|
'senderName' => 'Benchmark',
|
|
'senderEmail' => 'benchmark@appwrite.io',
|
|
'replyTo' => 'benchmark@appwrite.io',
|
|
'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'),
|
|
'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'),
|
|
'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'),
|
|
'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'),
|
|
];
|
|
|
|
if ($this->env('APPWRITE_SMTP_SECURE', '') !== '') {
|
|
$smtpBody['secure'] = $this->env('APPWRITE_SMTP_SECURE', '');
|
|
}
|
|
|
|
$smtp = $this->rawRequest('PATCH', "/projects/{$projectId}/smtp", $smtpBody, $consoleSessionHeaders, 'setup.projects.smtp.update');
|
|
if ($smtp->status !== 200) {
|
|
fwrite(STDERR, "Custom SMTP was not enabled ({$smtp->status}). Mail worker timings may be unavailable." . PHP_EOL);
|
|
}
|
|
|
|
return [
|
|
'runId' => $runId,
|
|
'teamId' => $teamId,
|
|
'projectId' => $projectId,
|
|
'consoleSessionHeaders' => $consoleSessionHeaders,
|
|
'apiHeaders' => $apiHeaders,
|
|
'platformStatus' => $platform->status,
|
|
];
|
|
}
|
|
|
|
private function curatedFlows(array &$context): void
|
|
{
|
|
try {
|
|
$this->accountFlow($context);
|
|
$this->databasesFlow($context);
|
|
$this->tablesDbFlow($context);
|
|
$this->storageFlow($context);
|
|
$this->messagingFlow($context);
|
|
$this->computeFlow($context);
|
|
$this->healthFlow($context);
|
|
} catch (Throwable $error) {
|
|
$this->metrics->addCounter('appwrite_benchmark_flow_failures');
|
|
throw $error;
|
|
}
|
|
}
|
|
|
|
private function teardown(array $context): void
|
|
{
|
|
if (($context['projectId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) {
|
|
$this->rawRequest('DELETE', "/projects/{$context['projectId']}", null, $context['consoleSessionHeaders'], 'teardown.projects.delete');
|
|
}
|
|
|
|
if (($context['teamId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) {
|
|
$this->rawRequest('DELETE', "/teams/{$context['teamId']}", null, $context['consoleSessionHeaders'], 'teardown.teams.delete');
|
|
}
|
|
}
|
|
|
|
private function accountFlow(array &$context): void
|
|
{
|
|
$userId = $this->unique('user');
|
|
$email = 'bench-user-' . $this->unique('mail') . '@example.com';
|
|
$headers = $this->projectHeaders($context['projectId']);
|
|
|
|
$this->api('POST', '/account', [
|
|
'userId' => $userId,
|
|
'email' => $email,
|
|
'password' => $this->password,
|
|
'name' => 'Benchmark User',
|
|
], $headers, [201], 'account.create');
|
|
|
|
$session = $this->api('POST', '/account/sessions/email', [
|
|
'email' => $email,
|
|
'password' => $this->password,
|
|
], $headers, [201], 'account.sessions.email.create');
|
|
|
|
$sessionHeaders = [
|
|
...$headers,
|
|
'Cookie' => $session->cookieHeader(),
|
|
];
|
|
|
|
$context['userId'] = $userId;
|
|
$context['userEmail'] = $email;
|
|
$context['sessionHeaders'] = $sessionHeaders;
|
|
|
|
$jwt = $this->api('POST', '/account/jwts', null, $sessionHeaders, [201], 'account.jwts.create');
|
|
$context['jwtHeaders'] = [
|
|
...$headers,
|
|
'X-Appwrite-JWT' => (string) $jwt->json('jwt'),
|
|
];
|
|
|
|
$this->api('GET', '/account', null, $sessionHeaders, [200], 'account.get');
|
|
$this->api('GET', '/account/logs', null, $sessionHeaders, [200], 'account.logs.list');
|
|
$this->api('PATCH', '/account/prefs', ['prefs' => ['benchmark' => true, 'runId' => $context['runId']]], $sessionHeaders, [200], 'account.prefs.update');
|
|
$this->api('PATCH', '/account/name', ['name' => 'Benchmark User Updated'], $sessionHeaders, [200], 'account.name.update');
|
|
$this->api('PATCH', '/account/password', ['password' => $this->password . '2', 'oldPassword' => $this->password], $sessionHeaders, [200], 'account.password.update');
|
|
|
|
$verificationStarted = $this->nowMs();
|
|
$this->api('POST', '/account/verifications/email', ['url' => $this->redirectUrl], $sessionHeaders, [201], 'account.emailVerification.create');
|
|
$verificationEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['verify', 'verification']), $this->mailTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $verificationStarted);
|
|
|
|
$verification = $this->extractQueryParams($verificationEmail);
|
|
if (($verification['userId'] ?? null) && ($verification['secret'] ?? null)) {
|
|
$this->api('PUT', '/account/verifications/email', [
|
|
'userId' => $verification['userId'],
|
|
'secret' => $verification['secret'],
|
|
], $sessionHeaders, [200], 'account.emailVerification.update');
|
|
}
|
|
|
|
$recoveryStarted = $this->nowMs();
|
|
$this->api('POST', '/account/recovery', ['email' => $email, 'url' => $this->redirectUrl], $headers, [201], 'account.recovery.create');
|
|
$recoveryEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['recovery', 'recover', 'reset']), $this->mailTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $recoveryStarted);
|
|
|
|
$recovery = $this->extractQueryParams($recoveryEmail);
|
|
if (($recovery['userId'] ?? null) && ($recovery['secret'] ?? null)) {
|
|
$this->api('DELETE', '/account/sessions/current', null, $sessionHeaders, [204], 'account.sessions.current.delete');
|
|
$this->api('PUT', '/account/recovery', [
|
|
'userId' => $recovery['userId'],
|
|
'secret' => $recovery['secret'],
|
|
'password' => $this->password . '3',
|
|
], $headers, [200], 'account.recovery.update');
|
|
|
|
$recoveredSession = $this->api('POST', '/account/sessions/email', [
|
|
'email' => $email,
|
|
'password' => $this->password . '3',
|
|
], $headers, [201], 'account.sessions.email.recovered');
|
|
|
|
$context['sessionHeaders'] = [
|
|
...$headers,
|
|
'Cookie' => $recoveredSession->cookieHeader(),
|
|
];
|
|
|
|
$recoveredJwt = $this->api('POST', '/account/jwts', null, $context['sessionHeaders'], [201], 'account.jwts.recovered');
|
|
$context['jwtHeaders'] = [
|
|
...$headers,
|
|
'X-Appwrite-JWT' => (string) $recoveredJwt->json('jwt'),
|
|
];
|
|
}
|
|
}
|
|
|
|
private function databasesFlow(array $context): void
|
|
{
|
|
$databaseId = $this->unique('db');
|
|
$collectionId = $this->unique('col');
|
|
$documentId = $this->unique('doc');
|
|
$indexKey = $this->unique('idx');
|
|
|
|
$this->api('POST', '/databases', ['databaseId' => $databaseId, 'name' => 'Benchmark DB'], $context['apiHeaders'], [201], 'databases.create');
|
|
$this->api('POST', "/databases/{$databaseId}/collections", [
|
|
'collectionId' => $collectionId,
|
|
'name' => 'Benchmark Collection',
|
|
'permissions' => self::BASE_PERMISSIONS,
|
|
'documentSecurity' => false,
|
|
], $context['apiHeaders'], [201], 'databases.collections.create');
|
|
|
|
$attributes = [
|
|
['string', 'title', ['size' => 128]],
|
|
['integer', 'count', ['min' => 0, 'max' => 100000]],
|
|
['email', 'email', []],
|
|
['boolean', 'active', []],
|
|
['datetime', 'publishedAt', []],
|
|
['float', 'score', ['min' => 0, 'max' => 1000]],
|
|
['url', 'url', []],
|
|
['ip', 'ip', []],
|
|
];
|
|
|
|
foreach ($attributes as [$type, $key, $extra]) {
|
|
$started = $this->nowMs();
|
|
$this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/attributes/{$type}", [
|
|
'key' => $key,
|
|
'required' => false,
|
|
'array' => false,
|
|
...$extra,
|
|
], $context['apiHeaders'], [202], "databases.attributes.{$type}.create");
|
|
$this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/attributes/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $started);
|
|
}
|
|
|
|
$indexStarted = $this->nowMs();
|
|
$this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/indexes", [
|
|
'key' => $indexKey,
|
|
'type' => 'key',
|
|
'attributes' => ['title'],
|
|
'orders' => ['asc'],
|
|
], $context['apiHeaders'], [202], 'databases.indexes.create');
|
|
$this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $indexStarted);
|
|
|
|
$this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/documents", [
|
|
'documentId' => $documentId,
|
|
'data' => $this->documentPayload(),
|
|
'permissions' => self::ITEM_PERMISSIONS,
|
|
], $context['apiHeaders'], [201], 'databases.documents.create');
|
|
$this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents", null, $context['apiHeaders'], [200], 'databases.documents.list');
|
|
$this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [200], 'databases.documents.get');
|
|
$this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", ['data' => ['title' => 'Benchmark Document Updated']], $context['apiHeaders'], [200], 'databases.documents.update');
|
|
$this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/increment", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.increment');
|
|
$this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/decrement", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.decrement');
|
|
$this->api('DELETE', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [204], 'databases.documents.delete');
|
|
$this->api('DELETE', "/databases/{$databaseId}", null, $context['apiHeaders'], [204], 'databases.delete');
|
|
}
|
|
|
|
private function tablesDbFlow(array $context): void
|
|
{
|
|
$databaseId = $this->unique('tdb');
|
|
$tableId = $this->unique('tbl');
|
|
$rowId = $this->unique('row');
|
|
$indexKey = $this->unique('tidx');
|
|
|
|
$this->api('POST', '/tablesdb', ['databaseId' => $databaseId, 'name' => 'Benchmark TablesDB'], $context['apiHeaders'], [201], 'tablesdb.create');
|
|
$this->api('POST', "/tablesdb/{$databaseId}/tables", [
|
|
'tableId' => $tableId,
|
|
'name' => 'Benchmark Table',
|
|
'permissions' => self::BASE_PERMISSIONS,
|
|
'rowSecurity' => false,
|
|
], $context['apiHeaders'], [201], 'tablesdb.tables.create');
|
|
|
|
$columns = [
|
|
['string', 'title', ['size' => 128]],
|
|
['integer', 'count', ['min' => 0, 'max' => 100000]],
|
|
['email', 'email', []],
|
|
['boolean', 'active', []],
|
|
];
|
|
|
|
foreach ($columns as [$type, $key, $extra]) {
|
|
$started = $this->nowMs();
|
|
$this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$type}", [
|
|
'key' => $key,
|
|
'required' => false,
|
|
'array' => false,
|
|
...$extra,
|
|
], $context['apiHeaders'], [202], "tablesdb.columns.{$type}.create");
|
|
$this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $started);
|
|
}
|
|
|
|
$indexStarted = $this->nowMs();
|
|
$this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/indexes", [
|
|
'key' => $indexKey,
|
|
'type' => 'key',
|
|
'columns' => ['title'],
|
|
'orders' => ['asc'],
|
|
], $context['apiHeaders'], [202], 'tablesdb.indexes.create');
|
|
$this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs);
|
|
$this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $indexStarted);
|
|
|
|
$this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", [
|
|
'rowId' => $rowId,
|
|
'data' => $this->tablePayload(),
|
|
'permissions' => self::ITEM_PERMISSIONS,
|
|
], $context['sessionHeaders'], [201], 'tablesdb.rows.create');
|
|
$this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", null, $context['sessionHeaders'], [200], 'tablesdb.rows.list');
|
|
$this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [200], 'tablesdb.rows.get');
|
|
$this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", ['data' => ['title' => 'Benchmark Row Updated']], $context['sessionHeaders'], [200], 'tablesdb.rows.update');
|
|
$this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/increment", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.increment');
|
|
$this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/decrement", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.decrement');
|
|
$this->api('DELETE', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [204], 'tablesdb.rows.delete');
|
|
$this->api('DELETE', "/tablesdb/{$databaseId}", null, $context['apiHeaders'], [204], 'tablesdb.delete');
|
|
}
|
|
|
|
private function storageFlow(array $context): void
|
|
{
|
|
$bucketId = $this->unique('bucket');
|
|
$fileId = $this->unique('file');
|
|
|
|
$this->api('POST', '/storage/buckets', [
|
|
'bucketId' => $bucketId,
|
|
'name' => 'Benchmark Bucket',
|
|
'permissions' => self::BASE_PERMISSIONS,
|
|
'fileSecurity' => false,
|
|
'enabled' => true,
|
|
'maximumFileSize' => 30000000,
|
|
'allowedFileExtensions' => [],
|
|
'compression' => 'none',
|
|
'encryption' => false,
|
|
'antivirus' => false,
|
|
], $context['apiHeaders'], [201], 'storage.buckets.create');
|
|
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'appwrite-benchmark-');
|
|
if ($tmpFile === false) {
|
|
throw new RuntimeException('Unable to create temporary PNG fixture');
|
|
}
|
|
|
|
file_put_contents($tmpFile, base64_decode(self::PNG_1X1, true));
|
|
|
|
try {
|
|
$fields = [
|
|
'fileId' => $fileId,
|
|
'file' => new CURLFile($tmpFile, 'image/png', 'benchmark.png'),
|
|
...$this->flattenMultipartArray('permissions', self::ITEM_PERMISSIONS),
|
|
];
|
|
$multipartHeaders = $context['sessionHeaders'];
|
|
unset($multipartHeaders['Content-Type']);
|
|
|
|
$upload = $this->rawMultipartRequest('POST', "/storage/buckets/{$bucketId}/files", $fields, $multipartHeaders, 'storage.files.create');
|
|
$this->metrics->addTrend('appwrite_api_duration', $upload->duration);
|
|
$this->assertStatus($upload, [201], 'storage file created');
|
|
} finally {
|
|
@unlink($tmpFile);
|
|
}
|
|
|
|
$this->api('GET', "/storage/buckets/{$bucketId}/files", null, $context['sessionHeaders'], [200], 'storage.files.list');
|
|
$this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [200], 'storage.files.get');
|
|
$this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/view", null, $context['sessionHeaders'], [200], 'storage.files.view');
|
|
$this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/download", null, $context['sessionHeaders'], [200], 'storage.files.download');
|
|
$this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/preview", null, $context['sessionHeaders'], [200], 'storage.files.preview');
|
|
$this->api('PUT', "/storage/buckets/{$bucketId}/files/{$fileId}", [
|
|
'name' => 'benchmark-renamed.png',
|
|
'permissions' => self::ITEM_PERMISSIONS,
|
|
], $context['sessionHeaders'], [200], 'storage.files.update');
|
|
|
|
$token = $this->api('POST', "/tokens/buckets/{$bucketId}/files/{$fileId}", (object) [], $context['apiHeaders'], [201], 'tokens.files.create');
|
|
$tokenId = (string) $token->json('$id');
|
|
$this->api('GET', "/tokens/buckets/{$bucketId}/files/{$fileId}", null, $context['apiHeaders'], [200], 'tokens.files.list');
|
|
$this->api('GET', "/tokens/{$tokenId}", null, $context['apiHeaders'], [200], 'tokens.get');
|
|
$this->api('PATCH', "/tokens/{$tokenId}", ['expire' => null], $context['apiHeaders'], [200], 'tokens.update');
|
|
$this->api('DELETE', "/tokens/{$tokenId}", null, $context['apiHeaders'], [204], 'tokens.delete');
|
|
|
|
$this->api('DELETE', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [204], 'storage.files.delete');
|
|
$this->api('DELETE', "/storage/buckets/{$bucketId}", null, $context['apiHeaders'], [204], 'storage.buckets.delete');
|
|
}
|
|
|
|
private function messagingFlow(array $context): void
|
|
{
|
|
$providerId = $this->unique('smtp');
|
|
$targetId = $this->unique('target');
|
|
$existingTarget = false;
|
|
$topicId = $this->unique('topic');
|
|
$subscriberId = $this->unique('sub');
|
|
$messageId = $this->unique('msg');
|
|
|
|
$this->api('POST', '/messaging/providers/smtp', [
|
|
'providerId' => $providerId,
|
|
'name' => 'Benchmark SMTP',
|
|
'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'),
|
|
'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'),
|
|
'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'),
|
|
'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'),
|
|
'encryption' => $this->env('APPWRITE_SMTP_ENCRYPTION', 'none'),
|
|
'autoTLS' => false,
|
|
'fromName' => 'Benchmark',
|
|
'fromEmail' => 'benchmark@appwrite.io',
|
|
'replyToName' => 'Benchmark',
|
|
'replyToEmail' => 'benchmark@appwrite.io',
|
|
'enabled' => true,
|
|
], $context['apiHeaders'], [201], 'messaging.providers.smtp.create');
|
|
|
|
$targets = $this->api('GET', "/users/{$context['userId']}/targets", null, $context['apiHeaders'], [200], 'users.targets.list');
|
|
foreach ($targets->json('targets') ?? [] as $target) {
|
|
if (($target['providerType'] ?? '') === 'email' && ($target['identifier'] ?? '') === $context['userEmail']) {
|
|
$targetId = (string) $target['$id'];
|
|
$existingTarget = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($existingTarget) {
|
|
$this->api('PATCH', "/users/{$context['userId']}/targets/{$targetId}", [
|
|
'providerId' => $providerId,
|
|
'name' => 'Benchmark email target',
|
|
], $context['apiHeaders'], [200], 'users.targets.update');
|
|
} else {
|
|
$this->api('POST', "/users/{$context['userId']}/targets", [
|
|
'targetId' => $targetId,
|
|
'providerType' => 'email',
|
|
'identifier' => $context['userEmail'],
|
|
'providerId' => $providerId,
|
|
'name' => 'Benchmark email target',
|
|
], $context['apiHeaders'], [201], 'users.targets.create');
|
|
}
|
|
|
|
$this->api('POST', '/messaging/topics', [
|
|
'topicId' => $topicId,
|
|
'name' => 'Benchmark Topic',
|
|
'subscribe' => ['users'],
|
|
], $context['apiHeaders'], [201], 'messaging.topics.create');
|
|
|
|
$this->api('POST', "/messaging/topics/{$topicId}/subscribers", [
|
|
'subscriberId' => $subscriberId,
|
|
'targetId' => $targetId,
|
|
], $context['sessionHeaders'], [201], 'messaging.subscribers.create');
|
|
|
|
$started = $this->nowMs();
|
|
$this->api('POST', '/messaging/messages/email', [
|
|
'messageId' => $messageId,
|
|
'subject' => "Benchmark message {$context['runId']}",
|
|
'content' => "Benchmark messaging worker probe {$context['runId']}",
|
|
'targets' => [$targetId],
|
|
'draft' => false,
|
|
'html' => false,
|
|
], $context['apiHeaders'], [201], 'messaging.messages.email.create');
|
|
|
|
$this->waitForMessage($messageId, $context['apiHeaders'], $this->workerTimeoutMs);
|
|
$this->waitForEmail($context['userEmail'], fn (array $message): bool => $this->includes($message['subject'] ?? '', "Benchmark message {$context['runId']}"), $this->mailTimeoutMs, true);
|
|
$this->metrics->addTrend('appwrite_worker_messaging_duration', $this->nowMs() - $started);
|
|
|
|
$this->api('GET', '/messaging/messages', null, $context['apiHeaders'], [200], 'messaging.messages.list');
|
|
$this->api('GET', "/messaging/messages/{$messageId}/logs", null, $context['apiHeaders'], [200], 'messaging.messages.logs.list');
|
|
$this->api('GET', "/messaging/messages/{$messageId}/targets", null, $context['apiHeaders'], [200], 'messaging.messages.targets.list');
|
|
$this->api('GET', "/messaging/providers/{$providerId}/logs", null, $context['apiHeaders'], [200], 'messaging.providers.logs.list');
|
|
$this->api('GET', "/messaging/topics/{$topicId}/logs", null, $context['apiHeaders'], [200], 'messaging.topics.logs.list');
|
|
$this->api('GET', "/messaging/subscribers/{$subscriberId}/logs", null, $context['apiHeaders'], [200], 'messaging.subscribers.logs.list');
|
|
$this->api('DELETE', "/messaging/topics/{$topicId}/subscribers/{$subscriberId}", null, $context['sessionHeaders'], [204], 'messaging.subscribers.delete');
|
|
$this->api('DELETE', "/messaging/topics/{$topicId}", null, $context['apiHeaders'], [204], 'messaging.topics.delete');
|
|
$this->api('DELETE', "/messaging/messages/{$messageId}", null, $context['apiHeaders'], [204], 'messaging.messages.delete');
|
|
$this->api('DELETE', "/messaging/providers/{$providerId}", null, $context['apiHeaders'], [204], 'messaging.providers.delete');
|
|
}
|
|
|
|
private function computeFlow(array $context): void
|
|
{
|
|
$functionId = $this->unique('fn');
|
|
$siteId = $this->unique('site');
|
|
$runtime = $this->env('APPWRITE_BENCHMARK_RUNTIME', 'node-22');
|
|
|
|
$this->api('POST', '/functions', [
|
|
'functionId' => $functionId,
|
|
'name' => 'Benchmark Function',
|
|
'runtime' => $runtime,
|
|
'execute' => ['any'],
|
|
'events' => [],
|
|
'schedule' => '',
|
|
'timeout' => 15,
|
|
'enabled' => true,
|
|
'logging' => true,
|
|
'entrypoint' => 'index.js',
|
|
'commands' => 'npm install',
|
|
'scopes' => ['users.read'],
|
|
], $context['apiHeaders'], [201], 'functions.create');
|
|
$this->api('GET', '/functions/runtimes', null, $context['sessionHeaders'], [200], 'functions.runtimes.list');
|
|
$this->api('GET', '/functions/specifications', null, $context['apiHeaders'], [200], 'functions.specifications.list');
|
|
|
|
$functionVariable = $this->api('POST', "/functions/{$functionId}/variables", [
|
|
'key' => 'BENCHMARK',
|
|
'value' => 'true',
|
|
'secret' => false,
|
|
], $context['apiHeaders'], [201], 'functions.variables.create');
|
|
$functionVariableId = (string) $functionVariable->json('$id');
|
|
$this->api('PUT', "/functions/{$functionId}/variables/{$functionVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'functions.variables.update');
|
|
$this->api('GET', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [200], 'functions.variables.get');
|
|
$this->api('DELETE', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [204], 'functions.variables.delete');
|
|
$this->api('DELETE', "/functions/{$functionId}", null, $context['apiHeaders'], [204], 'functions.delete');
|
|
|
|
$this->api('POST', '/sites', [
|
|
'siteId' => $siteId,
|
|
'name' => 'Benchmark Site',
|
|
'framework' => 'other',
|
|
'adapter' => 'static',
|
|
'buildRuntime' => $runtime,
|
|
'buildCommand' => '',
|
|
'outputDirectory' => '.',
|
|
'installCommand' => '',
|
|
'fallbackFile' => 'index.html',
|
|
'providerRootDirectory' => '.',
|
|
'specification' => '',
|
|
], $context['apiHeaders'], [201], 'sites.create');
|
|
$this->api('GET', '/sites/frameworks', null, $context['sessionHeaders'], [200], 'sites.frameworks.list');
|
|
$this->api('GET', '/sites/specifications', null, $context['apiHeaders'], [200], 'sites.specifications.list');
|
|
|
|
$siteVariable = $this->api('POST', "/sites/{$siteId}/variables", ['key' => 'BENCHMARK', 'value' => 'true', 'secret' => false], $context['apiHeaders'], [201], 'sites.variables.create');
|
|
$siteVariableId = (string) $siteVariable->json('$id');
|
|
$this->api('PUT', "/sites/{$siteId}/variables/{$siteVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'sites.variables.update');
|
|
$this->api('GET', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [200], 'sites.variables.get');
|
|
$this->api('DELETE', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [204], 'sites.variables.delete');
|
|
$this->api('DELETE', "/sites/{$siteId}", null, $context['apiHeaders'], [204], 'sites.delete');
|
|
}
|
|
|
|
private function healthFlow(array $context): void
|
|
{
|
|
$probes = [
|
|
'/health',
|
|
'/health/db',
|
|
'/health/cache',
|
|
'/health/pubsub',
|
|
'/health/storage',
|
|
'/health/storage/local',
|
|
'/health/time',
|
|
'/health/queue/databases',
|
|
'/health/queue/mails',
|
|
'/health/queue/messaging',
|
|
'/health/queue/functions',
|
|
'/health/queue/builds',
|
|
'/health/queue/deletes',
|
|
'/health/queue/webhooks',
|
|
'/health/queue/stats-resources',
|
|
'/health/queue/stats-usage',
|
|
'/health/queue/failed/v1-mails',
|
|
];
|
|
|
|
foreach ($probes as $path) {
|
|
$this->api('GET', $path, null, $context['apiHeaders'], [200], 'health' . str_replace('/', '.', $path));
|
|
}
|
|
}
|
|
|
|
private function api(string $method, string $path, mixed $body, array $headers, array $expected, string $name): BenchmarkResponse
|
|
{
|
|
$response = $this->rawRequest($method, $path, $body, $headers, $name);
|
|
$this->metrics->addTrend('appwrite_api_duration', $response->duration);
|
|
$this->assertStatus($response, $expected, $name);
|
|
return $response;
|
|
}
|
|
|
|
private function rawRequest(string $method, string $path, mixed $body, array $headers, string $name): BenchmarkResponse
|
|
{
|
|
return $this->send($method, str_starts_with($path, 'http') ? $path : $this->endpoint . $path, $body, $headers, $name, false);
|
|
}
|
|
|
|
private function rawMultipartRequest(string $method, string $path, array $fields, array $headers, string $name): BenchmarkResponse
|
|
{
|
|
return $this->send($method, $this->endpoint . $path, $fields, $headers, $name, true);
|
|
}
|
|
|
|
private function send(string $method, string $url, mixed $body, array $headers, string $name, bool $multipart): BenchmarkResponse
|
|
{
|
|
$handle = curl_init($url);
|
|
if ($handle === false) {
|
|
throw new RuntimeException("Unable to initialize curl for {$url}");
|
|
}
|
|
|
|
$headerLines = [];
|
|
foreach ($headers as $key => $value) {
|
|
$headerLines[] = "{$key}: {$value}";
|
|
}
|
|
|
|
curl_setopt_array($handle, [
|
|
CURLOPT_CUSTOMREQUEST => $method,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HEADER => true,
|
|
CURLOPT_HTTPHEADER => $headerLines,
|
|
CURLOPT_TIMEOUT => 120,
|
|
]);
|
|
|
|
if ($body !== null) {
|
|
curl_setopt($handle, CURLOPT_POSTFIELDS, $multipart ? $body : json_encode($body, JSON_UNESCAPED_SLASHES));
|
|
} elseif (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
|
curl_setopt($handle, CURLOPT_POSTFIELDS, '');
|
|
}
|
|
|
|
$started = hrtime(true);
|
|
$raw = curl_exec($handle);
|
|
$duration = (hrtime(true) - $started) / 1_000_000;
|
|
$this->metrics->addTrend('http_req_duration', $duration);
|
|
|
|
if ($raw === false) {
|
|
$error = curl_error($handle);
|
|
throw new RuntimeException("{$name} curl error: {$error}");
|
|
}
|
|
|
|
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
|
$headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE);
|
|
|
|
return new BenchmarkResponse(
|
|
$status,
|
|
substr($raw, $headerSize),
|
|
$this->parseHeaders(substr($raw, 0, $headerSize)),
|
|
$duration,
|
|
);
|
|
}
|
|
|
|
private function waitForStatus(string $path, array $headers, string $wantedStatus, int $timeoutMs): BenchmarkResponse
|
|
{
|
|
$started = $this->nowMs();
|
|
|
|
while ($this->nowMs() - $started < $timeoutMs) {
|
|
$response = $this->rawRequest('GET', $path, null, $headers, "wait{$path}");
|
|
if ($response->status === 200 && $response->json('status') === $wantedStatus) {
|
|
return $response;
|
|
}
|
|
|
|
usleep(500_000);
|
|
}
|
|
|
|
throw new RuntimeException("Timed out waiting for {$path} to become {$wantedStatus}");
|
|
}
|
|
|
|
private function waitForMessage(string $messageId, array $headers, int $timeoutMs): BenchmarkResponse
|
|
{
|
|
$started = $this->nowMs();
|
|
|
|
while ($this->nowMs() - $started < $timeoutMs) {
|
|
$response = $this->rawRequest('GET', "/messaging/messages/{$messageId}", null, $headers, 'messaging.messages.poll');
|
|
$status = $response->status === 200 ? $response->json('status') : null;
|
|
|
|
if (in_array($status, ['sent', 'failed'], true)) {
|
|
if ($status === 'failed') {
|
|
throw new RuntimeException("Messaging worker marked message {$messageId} as failed");
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
usleep(500_000);
|
|
}
|
|
|
|
throw new RuntimeException("Timed out waiting for messaging worker to send message {$messageId}");
|
|
}
|
|
|
|
private function waitForEmail(string $address, callable $predicate, int $timeoutMs, bool $allowMissingRecipient = false): array
|
|
{
|
|
$started = $this->nowMs();
|
|
|
|
while ($this->nowMs() - $started < $timeoutMs) {
|
|
$response = $this->rawRequest('GET', $this->maildevEndpoint, null, [], 'maildev.email.list');
|
|
|
|
if ($response->status === 200) {
|
|
$emails = $response->json();
|
|
if (is_array($emails)) {
|
|
for ($i = count($emails) - 1; $i >= 0; $i--) {
|
|
$message = $emails[$i];
|
|
if (!is_array($message)) {
|
|
continue;
|
|
}
|
|
|
|
if (($this->emailMatches($message, $address) || ($allowMissingRecipient && $this->emailRecipientMissing($message))) && $predicate($message)) {
|
|
return $message;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
usleep(500_000);
|
|
}
|
|
|
|
throw new RuntimeException("Timed out waiting for email to {$address}");
|
|
}
|
|
|
|
private function assertStatus(BenchmarkResponse $response, array $expected, string $name): void
|
|
{
|
|
$passed = in_array($response->status, $expected, true);
|
|
$this->metrics->addCheck($passed);
|
|
|
|
if (!$passed) {
|
|
$this->failResponse($response, "{$name} returned an unexpected status");
|
|
}
|
|
}
|
|
|
|
private function failResponse(BenchmarkResponse $response, string $message): never
|
|
{
|
|
throw new RuntimeException("{$message}. Status: {$response->status}. Body: {$response->body}");
|
|
}
|
|
|
|
private function parseHeaders(string $rawHeaders): array
|
|
{
|
|
$blocks = preg_split("/\r\n\r\n|\n\n/", trim($rawHeaders)) ?: [];
|
|
$headerBlock = end($blocks) ?: '';
|
|
$headers = [];
|
|
|
|
foreach (preg_split("/\r\n|\n|\r/", $headerBlock) ?: [] as $line) {
|
|
if (!str_contains($line, ':')) {
|
|
continue;
|
|
}
|
|
|
|
[$name, $value] = explode(':', $line, 2);
|
|
$headers[strtolower(trim($name))][] = trim($value);
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
private function emailMatches(array $message, string $address): bool
|
|
{
|
|
foreach ($message['to'] ?? [] as $recipient) {
|
|
if (($recipient['address'] ?? null) === $address) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function emailRecipientMissing(array $message): bool
|
|
{
|
|
$recipients = $message['to'] ?? [];
|
|
if ($recipients === []) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($recipients as $recipient) {
|
|
if ($recipient['address'] ?? null) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function extractQueryParams(array $message): array
|
|
{
|
|
$content = ($message['html'] ?? '') . "\n" . ($message['text'] ?? '');
|
|
preg_match_all('/href="([^"]+)"/', $content, $matches);
|
|
$links = $matches[1] ?: [$content];
|
|
|
|
foreach ($links as $link) {
|
|
$query = parse_url(html_entity_decode($link), PHP_URL_QUERY);
|
|
if (!is_string($query)) {
|
|
continue;
|
|
}
|
|
|
|
parse_str($query, $params);
|
|
if (($params['userId'] ?? null) && ($params['secret'] ?? null)) {
|
|
return $params;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function projectHeaders(string $projectId): array
|
|
{
|
|
return [
|
|
'Content-Type' => 'application/json',
|
|
'X-Appwrite-Project' => $projectId,
|
|
];
|
|
}
|
|
|
|
private function documentPayload(): array
|
|
{
|
|
return [
|
|
'title' => 'Benchmark Document',
|
|
'count' => 1,
|
|
'email' => 'document@example.com',
|
|
'active' => true,
|
|
'publishedAt' => gmdate('c'),
|
|
'score' => 10.5,
|
|
'url' => 'https://appwrite.io',
|
|
'ip' => '127.0.0.1',
|
|
];
|
|
}
|
|
|
|
private function tablePayload(): array
|
|
{
|
|
return [
|
|
'title' => 'Benchmark Row',
|
|
'count' => 1,
|
|
'email' => 'row@example.com',
|
|
'active' => true,
|
|
];
|
|
}
|
|
|
|
private function flattenMultipartArray(string $key, array $values): array
|
|
{
|
|
$output = [];
|
|
|
|
foreach (array_values($values) as $index => $value) {
|
|
$output["{$key}[{$index}]"] = $value;
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
private function messageIncludes(array $message, array $needles): bool
|
|
{
|
|
$content = implode("\n", [
|
|
(string) ($message['subject'] ?? ''),
|
|
(string) ($message['html'] ?? ''),
|
|
(string) ($message['text'] ?? ''),
|
|
]);
|
|
|
|
foreach ($needles as $needle) {
|
|
if ($this->includes($content, $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function includes(string $value, string $needle): bool
|
|
{
|
|
return str_contains(strtolower($value), strtolower($needle));
|
|
}
|
|
|
|
private function hostnameFromUrl(string $value): string
|
|
{
|
|
$host = parse_url($value, PHP_URL_HOST);
|
|
if (is_string($host) && $host !== '') {
|
|
return $host;
|
|
}
|
|
|
|
return explode(':', explode('/', preg_replace('/^https?:\/\//', '', $value) ?? '')[0])[0];
|
|
}
|
|
|
|
private function unique(string $prefix): string
|
|
{
|
|
$id = strtolower($prefix . '-' . base_convert((string) ((int) (microtime(true) * 1000)), 10, 36) . '-' . bin2hex(random_bytes(4)));
|
|
return substr(preg_replace('/[^a-z0-9-]/', '-', $id) ?? $id, 0, 36);
|
|
}
|
|
|
|
private function nowMs(): float
|
|
{
|
|
return hrtime(true) / 1_000_000;
|
|
}
|
|
|
|
private function env(string $name, string $default): string
|
|
{
|
|
$value = getenv($name);
|
|
return $value === false || $value === '' ? $default : $value;
|
|
}
|
|
|
|
private function loadPreviousSummary(string $path): ?array
|
|
{
|
|
if (!is_file($path)) {
|
|
return null;
|
|
}
|
|
|
|
$summary = json_decode((string) file_get_contents($path), true);
|
|
return is_array($summary) ? $summary : null;
|
|
}
|
|
|
|
private function writeSummary(array $summary): void
|
|
{
|
|
$directory = dirname($this->summaryPath);
|
|
if ($directory !== '.' && !is_dir($directory)) {
|
|
mkdir($directory, 0777, true);
|
|
}
|
|
|
|
file_put_contents($this->summaryPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
private function renderSummary(array $summary): string
|
|
{
|
|
$lines = [
|
|
'Appwrite curated benchmark review',
|
|
'',
|
|
'Before/after comparison',
|
|
'',
|
|
$this->comparisonTable($this->previousSummary, $summary),
|
|
'',
|
|
'Current run details',
|
|
'',
|
|
$this->metricLine($summary, 'http_req_duration', 'HTTP total'),
|
|
$this->metricLine($summary, 'appwrite_api_duration', 'API endpoints'),
|
|
$this->metricLine($summary, 'appwrite_worker_database_duration', 'Database worker schema jobs'),
|
|
$this->metricLine($summary, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'),
|
|
$this->metricLine($summary, 'appwrite_worker_mails_duration', 'Mail worker delivery'),
|
|
$this->metricLine($summary, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'),
|
|
$this->counterLine($summary, 'appwrite_benchmark_flow_failures', 'Flow failures'),
|
|
'',
|
|
];
|
|
|
|
return implode(PHP_EOL, array_filter($lines, fn (string $line): bool => $line !== '')) . PHP_EOL;
|
|
}
|
|
|
|
private function comparisonTable(?array $before, array $after): string
|
|
{
|
|
$rows = [
|
|
['HTTP total p95', $this->trendMetric($before, 'http_req_duration', 'p(95)'), $this->trendMetric($after, 'http_req_duration', 'p(95)'), 'ms'],
|
|
['API endpoints p95', $this->trendMetric($before, 'appwrite_api_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_api_duration', 'p(95)'), 'ms'],
|
|
['Database worker p95', $this->trendMetric($before, 'appwrite_worker_database_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'],
|
|
['TablesDB worker p95', $this->trendMetric($before, 'appwrite_worker_tables_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'],
|
|
['Mail worker p95', $this->trendMetric($before, 'appwrite_worker_mails_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'],
|
|
['Messaging worker p95', $this->trendMetric($before, 'appwrite_worker_messaging_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'],
|
|
['Flow failures', $this->counterMetric($before, 'appwrite_benchmark_flow_failures'), $this->counterMetric($after, 'appwrite_benchmark_flow_failures'), ''],
|
|
['Check failures', $this->checkFailures($before), $this->checkFailures($after), ''],
|
|
];
|
|
|
|
$table = [
|
|
'| Metric | Before | After | Delta |',
|
|
'| --- | ---: | ---: | ---: |',
|
|
];
|
|
|
|
foreach ($rows as [$label, $beforeValue, $afterValue, $unit]) {
|
|
$table[] = "| {$label} | {$this->formatValue($beforeValue, $unit)} | {$this->formatValue($afterValue, $unit)} | {$this->formatDelta($beforeValue, $afterValue, $unit)} |";
|
|
}
|
|
|
|
return implode(PHP_EOL, $table);
|
|
}
|
|
|
|
private function trendMetric(?array $data, string $metric, string $stat): ?float
|
|
{
|
|
return $data['metrics'][$metric]['values'][$stat] ?? null;
|
|
}
|
|
|
|
private function counterMetric(?array $data, string $metric): ?float
|
|
{
|
|
return $data['metrics'][$metric]['values']['count'] ?? null;
|
|
}
|
|
|
|
private function checkFailures(?array $data): ?float
|
|
{
|
|
return $data['metrics']['checks']['values']['fails'] ?? null;
|
|
}
|
|
|
|
private function metricLine(array $data, string $metric, string $label): string
|
|
{
|
|
$values = $data['metrics'][$metric]['values'] ?? null;
|
|
if (!is_array($values) || ($values['count'] ?? 0) === 0) {
|
|
return "{$label}: no samples";
|
|
}
|
|
|
|
return "{$label}: avg={$this->round($values['avg'])}ms p90={$this->round($values['p(90)'])}ms p95={$this->round($values['p(95)'])}ms max={$this->round($values['max'])}ms";
|
|
}
|
|
|
|
private function counterLine(array $data, string $metric, string $label): string
|
|
{
|
|
return "{$label}: " . ($data['metrics'][$metric]['values']['count'] ?? 0);
|
|
}
|
|
|
|
private function formatValue(?float $value, string $unit): string
|
|
{
|
|
return $value === null || is_nan($value) ? 'n/a' : $this->round($value) . $unit;
|
|
}
|
|
|
|
private function formatDelta(?float $before, ?float $after, string $unit): string
|
|
{
|
|
if ($before === null || $after === null || is_nan($before) || is_nan($after)) {
|
|
return 'n/a';
|
|
}
|
|
|
|
$delta = $this->round($after - $before);
|
|
return ($delta > 0 ? '+' : '') . $delta . $unit;
|
|
}
|
|
|
|
private function round(float|int|null $value): string
|
|
{
|
|
$rounded = round((float) ($value ?? 0), 2);
|
|
return rtrim(rtrim(number_format($rounded, 2, '.', ''), '0'), '.');
|
|
}
|
|
}
|
|
|
|
exit((new HttpBenchmark())->run());
|