diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf9831582f..78d959779c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -704,15 +704,13 @@ jobs:
- name: Benchmark before
run: |
rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt
- docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "/tmp/appwrite-benchmark-before:/scripts" -w /scripts grafana/k6 run --quiet \
- --summary-export benchmark-before-summary.json \
+ docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \
-e APPWRITE_ENDPOINT=http://localhost/v1 \
-e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \
-e APPWRITE_BENCHMARK_ITERATIONS=1 \
-e APPWRITE_BENCHMARK_VUS=1 \
-e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \
- tests/benchmarks/http.js | tee benchmark-before.txt
- cp /tmp/appwrite-benchmark-before/benchmark-before-summary.json benchmark-before-summary.json
+ ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark-before.txt
- name: Stop before Appwrite
if: always()
@@ -730,14 +728,14 @@ jobs:
- name: Benchmark after
run: |
- docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \
+ docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \
-e APPWRITE_ENDPOINT=http://localhost/v1 \
-e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \
-e APPWRITE_BENCHMARK_ITERATIONS=1 \
-e APPWRITE_BENCHMARK_VUS=1 \
- -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=/scripts/benchmark-before-summary.json \
+ -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \
-e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \
- tests/benchmarks/http.js | tee benchmark.txt
+ ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark.txt
- name: Stop after Appwrite
if: always()
@@ -745,68 +743,83 @@ jobs:
- name: Prepare comment
run: |
- node <<'NODE' > benchmark-comment.txt
- const fs = require('fs');
+ docker run --rm -i -v "$PWD:/scripts" -w /scripts ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt
+ data.metrics?.[metric]?.values?.[stat];
- const value = (data, metric, stat) => data.metrics?.[metric]?.values?.[stat];
- const counter = (data, metric) => value(data, metric, 'count');
- const delta = (beforeValue, afterValue, suffix = '') => {
- if (beforeValue === undefined || afterValue === undefined) {
- return 'n/a';
- }
+ function metric_value(?array $data, string $metric, string $stat): mixed
+ {
+ return $data['metrics'][$metric]['values'][$stat] ?? null;
+ }
- const difference = afterValue - beforeValue;
- return `${difference > 0 ? '+' : ''}${formatNumber(difference)}${suffix}`;
- };
- const format = (value, suffix = '') => value === undefined ? 'n/a' : `${formatNumber(value)}${suffix}`;
- const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
- const row = (label, beforeValue, afterValue, suffix = '') => `| ${label} | ${format(beforeValue, suffix)} | ${format(afterValue, suffix)} | ${delta(beforeValue, afterValue, suffix)} |`;
+ function format_number(mixed $value): string
+ {
+ $value = round((float) $value, 2);
+ return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
+ }
- const rows = [
- row('HTTP total p95', trend(before, 'http_req_duration', 'p(95)'), trend(after, 'http_req_duration', 'p(95)'), 'ms'),
- row('API endpoints p95', trend(before, 'appwrite_api_duration', 'p(95)'), trend(after, 'appwrite_api_duration', 'p(95)'), 'ms'),
- row('Database worker p95', trend(before, 'appwrite_worker_database_duration', 'p(95)'), trend(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'),
- row('TablesDB worker p95', trend(before, 'appwrite_worker_tables_duration', 'p(95)'), trend(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'),
- row('Mail worker p95', trend(before, 'appwrite_worker_mails_duration', 'p(95)'), trend(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'),
- row('Messaging worker p95', trend(before, 'appwrite_worker_messaging_duration', 'p(95)'), trend(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'),
- row('Flow failures', counter(before, 'appwrite_benchmark_flow_failures'), counter(after, 'appwrite_benchmark_flow_failures')),
- row('Check failures', value(before, 'checks', 'fails'), value(after, 'checks', 'fails')),
+ function format_value(mixed $value, string $suffix = ''): string
+ {
+ return $value === null ? 'n/a' : format_number($value) . $suffix;
+ }
+
+ function delta(mixed $beforeValue, mixed $afterValue, string $suffix = ''): string
+ {
+ if ($beforeValue === null || $afterValue === null) {
+ return 'n/a';
+ }
+
+ $difference = round((float) $afterValue - (float) $beforeValue, 2);
+ return ($difference > 0 ? '+' : '') . format_number($difference) . $suffix;
+ }
+
+ function row(string $label, mixed $beforeValue, mixed $afterValue, string $suffix = ''): string
+ {
+ return '| ' . $label . ' | ' . format_value($beforeValue, $suffix) . ' | ' . format_value($afterValue, $suffix) . ' | ' . delta($beforeValue, $afterValue, $suffix) . ' |';
+ }
+
+ function detail(array $after, string $label, string $metric, string $suffix = 'ms'): string
+ {
+ $values = $after['metrics'][$metric]['values'] ?? null;
+ if (!is_array($values)) {
+ return '- **' . $label . ':** no samples';
+ }
+
+ return '- **' . $label . ':** avg=' . format_value($values['avg'] ?? null, $suffix)
+ . ' p90=' . format_value($values['p(90)'] ?? null, $suffix)
+ . ' p95=' . format_value($values['p(95)'] ?? null, $suffix)
+ . ' max=' . format_value($values['max'] ?? null, $suffix);
+ }
+
+ $rows = [
+ row('HTTP total p95', metric_value($before, 'http_req_duration', 'p(95)'), metric_value($after, 'http_req_duration', 'p(95)'), 'ms'),
+ row('API endpoints p95', metric_value($before, 'appwrite_api_duration', 'p(95)'), metric_value($after, 'appwrite_api_duration', 'p(95)'), 'ms'),
+ row('Database worker p95', metric_value($before, 'appwrite_worker_database_duration', 'p(95)'), metric_value($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'),
+ row('TablesDB worker p95', metric_value($before, 'appwrite_worker_tables_duration', 'p(95)'), metric_value($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'),
+ row('Mail worker p95', metric_value($before, 'appwrite_worker_mails_duration', 'p(95)'), metric_value($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'),
+ row('Messaging worker p95', metric_value($before, 'appwrite_worker_messaging_duration', 'p(95)'), metric_value($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'),
+ row('Flow failures', metric_value($before, 'appwrite_benchmark_flow_failures', 'count'), metric_value($after, 'appwrite_benchmark_flow_failures', 'count')),
+ row('Check failures', metric_value($before, 'checks', 'fails'), metric_value($after, 'checks', 'fails')),
];
- const detail = (label, metric, suffix = 'ms') => {
- const values = after.metrics?.[metric]?.values;
- if (!values) {
- return `- **${label}:** no samples`;
- }
-
- return `- **${label}:** avg=${format(values.avg, suffix)} p90=${format(values['p(90)'], suffix)} p95=${format(values['p(95)'], suffix)} max=${format(values.max, suffix)}`;
- };
-
- console.log('');
- console.log('## :sparkles: Benchmark results');
- console.log();
- console.log(`Comparing \`${{ github.event.pull_request.base.ref }}\` (before) to \`${{ github.event.pull_request.head.ref }}\` (after).`);
- console.log();
- console.log('| Metric | Before | After | Delta |');
- console.log('| --- | ---: | ---: | ---: |');
- console.log(rows.join('\n'));
- console.log();
- console.log('');
- console.log('Current run details
');
- console.log();
- console.log(detail('HTTP total', 'http_req_duration'));
- console.log(detail('API endpoints', 'appwrite_api_duration'));
- console.log(detail('Database worker schema jobs', 'appwrite_worker_database_duration'));
- console.log(detail('TablesDB worker schema jobs', 'appwrite_worker_tables_duration'));
- console.log(detail('Mail worker delivery', 'appwrite_worker_mails_duration'));
- console.log(detail('Messaging worker delivery', 'appwrite_worker_messaging_duration'));
- console.log();
- console.log(' ');
- NODE
+ echo "\n";
+ echo "## :sparkles: Benchmark results\n\n";
+ echo 'Comparing `${{ github.event.pull_request.base.ref }}` (before) to `${{ github.event.pull_request.head.ref }}` (after).' . "\n\n";
+ echo "| Metric | Before | After | Delta |\n";
+ echo "| --- | ---: | ---: | ---: |\n";
+ echo implode("\n", $rows) . "\n\n";
+ echo "\n";
+ echo "Current run details
\n\n";
+ echo detail($after, 'HTTP total', 'http_req_duration') . "\n";
+ echo detail($after, 'API endpoints', 'appwrite_api_duration') . "\n";
+ echo detail($after, 'Database worker schema jobs', 'appwrite_worker_database_duration') . "\n";
+ echo detail($after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration') . "\n";
+ echo detail($after, 'Mail worker delivery', 'appwrite_worker_mails_duration') . "\n";
+ echo detail($after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration') . "\n\n";
+ echo " \n";
+ PHP
- name: Save results
uses: actions/upload-artifact@v7
diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js
deleted file mode 100644
index 6ce5afd661..0000000000
--- a/tests/benchmarks/http.js
+++ /dev/null
@@ -1,996 +0,0 @@
-import http from 'k6/http';
-import { check, group, sleep } from 'k6';
-import encoding from 'k6/encoding';
-import { Counter, Trend } from 'k6/metrics';
-
-const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, '');
-const MAILDEV_ENDPOINT = __ENV.APPWRITE_MAILDEV_ENDPOINT || 'http://localhost:9503/email';
-const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console';
-const REGION = __ENV.APPWRITE_REGION || 'default';
-const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost';
-const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!';
-const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000);
-const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000);
-const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1);
-const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1);
-const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json';
-const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH;
-const PREVIOUS_SUMMARY = loadPreviousSummary();
-
-export const apiDuration = new Trend('appwrite_api_duration', true);
-export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true);
-export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true);
-export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true);
-export const messagingWorkerDuration = new Trend('appwrite_worker_messaging_duration', true);
-export const flowFailures = new Counter('appwrite_benchmark_flow_failures');
-
-export const options = {
- scenarios: {
- curated_flows: {
- executor: 'shared-iterations',
- exec: 'curatedFlows',
- vus: VUS,
- iterations: ITERATIONS,
- maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m',
- },
- },
- thresholds: {
- http_req_failed: ['rate<0.05'],
- appwrite_api_duration: ['p(95)<2000'],
- appwrite_benchmark_flow_failures: ['count<1'],
- },
-};
-
-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',
-];
-
-const BASE_PERMISSIONS = [
- 'read("any")',
- 'create("any")',
- 'update("any")',
- 'delete("any")',
-];
-
-const ITEM_PERMISSIONS = [
- 'read("any")',
- 'update("any")',
- 'delete("any")',
-];
-
-export function setup() {
- const runId = unique('run');
- const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`;
- const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD;
-
- const consoleHeaders = {
- 'Content-Type': 'application/json',
- 'X-Appwrite-Project': CONSOLE_PROJECT,
- };
-
- const account = rawRequest('POST', '/account', {
- userId: unique('admin'),
- email: consoleEmail,
- password: consolePassword,
- name: 'Benchmark Admin',
- }, consoleHeaders, 'setup.account.create');
-
- if (![201, 409].includes(account.status)) {
- failResponse(account, 'Unable to create or reuse the benchmark console account');
- }
-
- const session = rawRequest('POST', '/account/sessions/email', {
- email: consoleEmail,
- password: consolePassword,
- }, consoleHeaders, 'setup.account.session');
-
- assertStatus(session, [201], 'console session created');
-
- const consoleSessionHeaders = {
- ...consoleHeaders,
- Cookie: cookieHeader(session),
- };
-
- const team = api('POST', '/teams', {
- teamId: unique('team'),
- name: `Benchmark Team ${runId}`,
- }, consoleSessionHeaders, [201], 'setup.teams.create');
-
- const teamId = team.json('$id');
- const project = api('POST', '/projects', {
- projectId: unique('project'),
- name: `Benchmark Project ${runId}`,
- teamId,
- region: REGION,
- }, consoleSessionHeaders, [201], 'setup.projects.create');
-
- const projectId = project.json('$id');
- const key = api('POST', `/projects/${projectId}/keys`, {
- keyId: unique('key'),
- name: 'Benchmark API key',
- scopes: API_SCOPES,
- }, consoleSessionHeaders, [201], 'setup.projects.keys.create');
-
- const apiHeaders = {
- 'Content-Type': 'application/json',
- 'X-Appwrite-Project': projectId,
- 'X-Appwrite-Key': key.json('secret'),
- };
-
- const platform = api('POST', '/project/platforms/web', {
- platformId: unique('web'),
- name: 'Benchmark web',
- hostname: hostnameFromUrl(REDIRECT_URL),
- }, apiHeaders, [201, 409], 'setup.project.platforms.web.create');
-
- const smtp = rawRequest('PATCH', `/projects/${projectId}/smtp`, {
- enabled: true,
- senderName: 'Benchmark',
- senderEmail: 'benchmark@appwrite.io',
- replyTo: 'benchmark@appwrite.io',
- host: __ENV.APPWRITE_SMTP_HOST || 'maildev',
- port: Number(__ENV.APPWRITE_SMTP_PORT || 1025),
- username: __ENV.APPWRITE_SMTP_USERNAME || 'user',
- password: __ENV.APPWRITE_SMTP_PASSWORD || 'password',
- ...(String(__ENV.APPWRITE_SMTP_SECURE || '') !== '' ? { secure: __ENV.APPWRITE_SMTP_SECURE } : {}),
- }, consoleSessionHeaders, 'setup.projects.smtp.update');
-
- if (smtp.status !== 200) {
- console.warn(`Custom SMTP was not enabled (${smtp.status}). Mail worker timings may be unavailable.`);
- }
-
- return {
- runId,
- teamId,
- projectId,
- consoleSessionHeaders,
- apiHeaders,
- platformStatus: platform.status,
- };
-}
-
-export function curatedFlows(data) {
- const ctx = { ...data };
-
- try {
- group('account and mail worker', () => accountFlow(ctx));
- group('databases documents flow', () => databasesFlow(ctx));
- group('tablesdb rows flow', () => tablesDbFlow(ctx));
- group('storage files and tokens flow', () => storageFlow(ctx));
- group('messaging worker flow', () => messagingFlow(ctx));
- group('functions and sites control-plane flow', () => computeFlow(ctx));
- group('health and queue probes', () => healthFlow(ctx));
- } catch (error) {
- flowFailures.add(1);
- throw error;
- }
-}
-
-export function teardown(data) {
- if (data && data.projectId && data.consoleSessionHeaders) {
- rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete');
- }
-
- if (data && data.teamId && data.consoleSessionHeaders) {
- rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete');
- }
-}
-
-function accountFlow(ctx) {
- const userId = unique('user');
- const email = `bench-user-${unique('mail')}@example.com`;
- const headers = projectHeaders(ctx.projectId);
-
- api('POST', '/account', {
- userId,
- email,
- password: PASSWORD,
- name: 'Benchmark User',
- }, headers, [201], 'account.create');
-
- const session = api('POST', '/account/sessions/email', {
- email,
- password: PASSWORD,
- }, headers, [201], 'account.sessions.email.create');
-
- const sessionHeaders = {
- ...headers,
- Cookie: cookieHeader(session),
- };
-
- ctx.userId = userId;
- ctx.userEmail = email;
- ctx.sessionHeaders = sessionHeaders;
-
- const jwt = api('POST', '/account/jwts', null, sessionHeaders, [201], 'account.jwts.create');
- ctx.jwtHeaders = {
- ...headers,
- 'X-Appwrite-JWT': jwt.json('jwt'),
- };
-
- api('GET', '/account', null, sessionHeaders, [200], 'account.get');
- api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list');
- api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update');
- api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update');
- api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update');
-
- const verificationStarted = Date.now();
- api('POST', '/account/verifications/email', { url: REDIRECT_URL }, sessionHeaders, [201], 'account.emailVerification.create');
- const verificationEmail = waitForEmail(email, (message) => {
- return includes(message.subject, 'verify')
- || includes(message.subject, 'verification')
- || includes(message.html, 'verify')
- || includes(message.html, 'verification')
- || includes(message.text, 'verify')
- || includes(message.text, 'verification');
- }, MAIL_TIMEOUT_MS);
- mailsWorkerDuration.add(Date.now() - verificationStarted, { job: 'email_verification' });
-
- const verification = extractQueryParams(verificationEmail);
- if (verification.userId && verification.secret) {
- api('PUT', '/account/verifications/email', {
- userId: verification.userId,
- secret: verification.secret,
- }, sessionHeaders, [200], 'account.emailVerification.update');
- }
-
- const recoveryStarted = Date.now();
- api('POST', '/account/recovery', { email, url: REDIRECT_URL }, headers, [201], 'account.recovery.create');
- const recoveryEmail = waitForEmail(email, (message) => {
- return includes(message.subject, 'recovery')
- || includes(message.subject, 'recover')
- || includes(message.subject, 'reset')
- || includes(message.html, 'recovery')
- || includes(message.html, 'recover')
- || includes(message.html, 'reset')
- || includes(message.text, 'recovery')
- || includes(message.text, 'recover')
- || includes(message.text, 'reset');
- }, MAIL_TIMEOUT_MS);
- mailsWorkerDuration.add(Date.now() - recoveryStarted, { job: 'password_recovery' });
-
- const recovery = extractQueryParams(recoveryEmail);
- if (recovery.userId && recovery.secret) {
- api('DELETE', '/account/sessions/current', null, sessionHeaders, [204], 'account.sessions.current.delete');
-
- api('PUT', '/account/recovery', {
- userId: recovery.userId,
- secret: recovery.secret,
- password: `${PASSWORD}3`,
- }, headers, [200], 'account.recovery.update');
-
- const recoveredSession = api('POST', '/account/sessions/email', {
- email,
- password: `${PASSWORD}3`,
- }, headers, [201], 'account.sessions.email.recovered');
-
- ctx.sessionHeaders = {
- ...headers,
- Cookie: cookieHeader(recoveredSession),
- };
-
- const recoveredJwt = api('POST', '/account/jwts', null, ctx.sessionHeaders, [201], 'account.jwts.recovered');
- ctx.jwtHeaders = {
- ...headers,
- 'X-Appwrite-JWT': recoveredJwt.json('jwt'),
- };
- }
-}
-
-function databasesFlow(ctx) {
- const databaseId = unique('db');
- const collectionId = unique('col');
- const documentId = unique('doc');
- const indexKey = unique('idx');
-
- api('POST', '/databases', { databaseId, name: 'Benchmark DB' }, ctx.apiHeaders, [201], 'databases.create');
- api('POST', `/databases/${databaseId}/collections`, {
- collectionId,
- name: 'Benchmark Collection',
- permissions: BASE_PERMISSIONS,
- documentSecurity: false,
- }, ctx.apiHeaders, [201], 'databases.collections.create');
-
- const 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', {}],
- ];
-
- for (const [type, key, extra] of attributes) {
- const started = Date.now();
- api('POST', `/databases/${databaseId}/collections/${collectionId}/attributes/${type}`, {
- key,
- required: false,
- array: false,
- ...extra,
- }, ctx.apiHeaders, [202], `databases.attributes.${type}.create`);
- waitForStatus(`/databases/${databaseId}/collections/${collectionId}/attributes/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS);
- databaseWorkerDuration.add(Date.now() - started, { job: `attribute_${type}` });
- }
-
- const indexStarted = Date.now();
- api('POST', `/databases/${databaseId}/collections/${collectionId}/indexes`, {
- key: indexKey,
- type: 'key',
- attributes: ['title'],
- orders: ['asc'],
- }, ctx.apiHeaders, [202], 'databases.indexes.create');
- waitForStatus(`/databases/${databaseId}/collections/${collectionId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS);
- databaseWorkerDuration.add(Date.now() - indexStarted, { job: 'index' });
-
- api('POST', `/databases/${databaseId}/collections/${collectionId}/documents`, {
- documentId,
- data: documentPayload(),
- permissions: ITEM_PERMISSIONS,
- }, ctx.apiHeaders, [201], 'databases.documents.create');
- api('GET', `/databases/${databaseId}/collections/${collectionId}/documents`, null, ctx.apiHeaders, [200], 'databases.documents.list');
- api('GET', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [200], 'databases.documents.get');
- api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, {
- data: { title: 'Benchmark Document Updated' },
- }, ctx.apiHeaders, [200], 'databases.documents.update');
- api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/increment`, {
- value: 1,
- }, ctx.apiHeaders, [200], 'databases.documents.increment');
- api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/decrement`, {
- value: 1,
- }, ctx.apiHeaders, [200], 'databases.documents.decrement');
- api('DELETE', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [204], 'databases.documents.delete');
- api('DELETE', `/databases/${databaseId}`, null, ctx.apiHeaders, [204], 'databases.delete');
-}
-
-function tablesDbFlow(ctx) {
- const databaseId = unique('tdb');
- const tableId = unique('tbl');
- const rowId = unique('row');
- const indexKey = unique('tidx');
-
- api('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, ctx.apiHeaders, [201], 'tablesdb.create');
- api('POST', `/tablesdb/${databaseId}/tables`, {
- tableId,
- name: 'Benchmark Table',
- permissions: BASE_PERMISSIONS,
- rowSecurity: false,
- }, ctx.apiHeaders, [201], 'tablesdb.tables.create');
-
- const columns = [
- ['string', 'title', { size: 128 }],
- ['integer', 'count', { min: 0, max: 100000 }],
- ['email', 'email', {}],
- ['boolean', 'active', {}],
- ];
-
- for (const [type, key, extra] of columns) {
- const started = Date.now();
- api('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, {
- key,
- required: false,
- array: false,
- ...extra,
- }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`);
- waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS);
- tablesWorkerDuration.add(Date.now() - started, { job: `column_${type}` });
- }
-
- const indexStarted = Date.now();
- api('POST', `/tablesdb/${databaseId}/tables/${tableId}/indexes`, {
- key: indexKey,
- type: 'key',
- columns: ['title'],
- orders: ['asc'],
- }, ctx.apiHeaders, [202], 'tablesdb.indexes.create');
- waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS);
- tablesWorkerDuration.add(Date.now() - indexStarted, { job: 'index' });
-
- api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, {
- rowId,
- data: tablePayload(),
- permissions: ITEM_PERMISSIONS,
- }, ctx.sessionHeaders, [201], 'tablesdb.rows.create');
- api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list');
- api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get');
- api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
- data: { title: 'Benchmark Row Updated' },
- }, ctx.sessionHeaders, [200], 'tablesdb.rows.update');
- api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/increment`, {
- value: 1,
- }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment');
- api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/decrement`, {
- value: 1,
- }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement');
- api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete');
- api('DELETE', `/tablesdb/${databaseId}`, null, ctx.apiHeaders, [204], 'tablesdb.delete');
-}
-
-function storageFlow(ctx) {
- const bucketId = unique('bucket');
- const fileId = unique('file');
-
- api('POST', '/storage/buckets', {
- bucketId,
- name: 'Benchmark Bucket',
- permissions: BASE_PERMISSIONS,
- fileSecurity: false,
- enabled: true,
- maximumFileSize: 30000000,
- allowedFileExtensions: [],
- compression: 'none',
- encryption: false,
- antivirus: false,
- }, ctx.apiHeaders, [201], 'storage.buckets.create');
-
- const multipartHeaders = { ...ctx.sessionHeaders };
- delete multipartHeaders['Content-Type'];
-
- const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, {
- fileId,
- file: http.file(onePixelPng(), 'benchmark.png', 'image/png'),
- ...flattenMultipartArray('permissions', ITEM_PERMISSIONS),
- }, {
- headers: multipartHeaders,
- tags: { name: 'storage.files.create' },
- });
-
- apiDuration.add(upload.timings.duration, { name: 'storage.files.create' });
- assertStatus(upload, [201], 'storage file created');
-
- api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list');
- api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get');
- api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view');
- api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download');
- api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview');
- api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, {
- name: 'benchmark-renamed.png',
- permissions: ITEM_PERMISSIONS,
- }, ctx.sessionHeaders, [200], 'storage.files.update');
-
- const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create');
- api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list');
- api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get');
- api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update');
- api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete');
-
- api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete');
- api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete');
-}
-
-function messagingFlow(ctx) {
- const providerId = unique('smtp');
- let targetId = unique('target');
- const topicId = unique('topic');
- const subscriberId = unique('sub');
- const messageId = unique('msg');
-
- api('POST', '/messaging/providers/smtp', {
- providerId,
- name: 'Benchmark SMTP',
- host: __ENV.APPWRITE_SMTP_HOST || 'maildev',
- port: Number(__ENV.APPWRITE_SMTP_PORT || 1025),
- username: __ENV.APPWRITE_SMTP_USERNAME || 'user',
- password: __ENV.APPWRITE_SMTP_PASSWORD || 'password',
- encryption: __ENV.APPWRITE_SMTP_ENCRYPTION || 'none',
- autoTLS: false,
- fromName: 'Benchmark',
- fromEmail: 'benchmark@appwrite.io',
- replyToName: 'Benchmark',
- replyToEmail: 'benchmark@appwrite.io',
- enabled: true,
- }, ctx.apiHeaders, [201], 'messaging.providers.smtp.create');
-
- const targets = api('GET', `/users/${ctx.userId}/targets`, null, ctx.apiHeaders, [200], 'users.targets.list');
- const existingTarget = (targets.json('targets') || []).find((target) => {
- return target.providerType === 'email' && target.identifier === ctx.userEmail;
- });
-
- if (existingTarget) {
- targetId = existingTarget.$id;
- api('PATCH', `/users/${ctx.userId}/targets/${targetId}`, {
- providerId,
- name: 'Benchmark email target',
- }, ctx.apiHeaders, [200], 'users.targets.update');
- } else {
- api('POST', `/users/${ctx.userId}/targets`, {
- targetId,
- providerType: 'email',
- identifier: ctx.userEmail,
- providerId,
- name: 'Benchmark email target',
- }, ctx.apiHeaders, [201], 'users.targets.create');
- }
-
- api('POST', '/messaging/topics', {
- topicId,
- name: 'Benchmark Topic',
- subscribe: ['users'],
- }, ctx.apiHeaders, [201], 'messaging.topics.create');
-
- api('POST', `/messaging/topics/${topicId}/subscribers`, {
- subscriberId,
- targetId,
- }, ctx.sessionHeaders, [201], 'messaging.subscribers.create');
-
- const started = Date.now();
- api('POST', '/messaging/messages/email', {
- messageId,
- subject: `Benchmark message ${ctx.runId}`,
- content: `Benchmark messaging worker probe ${ctx.runId}`,
- targets: [targetId],
- draft: false,
- html: false,
- }, ctx.apiHeaders, [201], 'messaging.messages.email.create');
-
- waitForMessage(messageId, ctx.apiHeaders, WORKER_TIMEOUT_MS);
- waitForEmail(ctx.userEmail, (message) => includes(message.subject, `Benchmark message ${ctx.runId}`), MAIL_TIMEOUT_MS, true);
- messagingWorkerDuration.add(Date.now() - started, { job: 'email_message' });
-
- api('GET', '/messaging/messages', null, ctx.apiHeaders, [200], 'messaging.messages.list');
- api('GET', `/messaging/messages/${messageId}/logs`, null, ctx.apiHeaders, [200], 'messaging.messages.logs.list');
- api('GET', `/messaging/messages/${messageId}/targets`, null, ctx.apiHeaders, [200], 'messaging.messages.targets.list');
- api('GET', `/messaging/providers/${providerId}/logs`, null, ctx.apiHeaders, [200], 'messaging.providers.logs.list');
- api('GET', `/messaging/topics/${topicId}/logs`, null, ctx.apiHeaders, [200], 'messaging.topics.logs.list');
- api('GET', `/messaging/subscribers/${subscriberId}/logs`, null, ctx.apiHeaders, [200], 'messaging.subscribers.logs.list');
- api('DELETE', `/messaging/topics/${topicId}/subscribers/${subscriberId}`, null, ctx.sessionHeaders, [204], 'messaging.subscribers.delete');
- api('DELETE', `/messaging/topics/${topicId}`, null, ctx.apiHeaders, [204], 'messaging.topics.delete');
- api('DELETE', `/messaging/messages/${messageId}`, null, ctx.apiHeaders, [204], 'messaging.messages.delete');
- api('DELETE', `/messaging/providers/${providerId}`, null, ctx.apiHeaders, [204], 'messaging.providers.delete');
-}
-
-function computeFlow(ctx) {
- const functionId = unique('fn');
- let functionVariableId;
- const siteId = unique('site');
- let siteVariableId;
-
- api('POST', '/functions', {
- functionId,
- name: 'Benchmark Function',
- runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22',
- execute: ['any'],
- events: [],
- schedule: '',
- timeout: 15,
- enabled: true,
- logging: true,
- entrypoint: 'index.js',
- commands: 'npm install',
- scopes: ['users.read'],
- }, ctx.apiHeaders, [201], 'functions.create');
- api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list');
- api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list');
- const functionVariable = api('POST', `/functions/${functionId}/variables`, {
- key: 'BENCHMARK',
- value: 'true',
- secret: false,
- }, ctx.apiHeaders, [201], 'functions.variables.create');
- functionVariableId = functionVariable.json('$id');
-
- api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, {
- key: 'BENCHMARK',
- value: 'updated',
- secret: false,
- }, ctx.apiHeaders, [200], 'functions.variables.update');
- api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get');
- api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete');
- api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete');
-
- api('POST', '/sites', {
- siteId,
- name: 'Benchmark Site',
- framework: 'other',
- adapter: 'static',
- buildRuntime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22',
- buildCommand: '',
- outputDirectory: '.',
- installCommand: '',
- fallbackFile: 'index.html',
- providerRootDirectory: '.',
- specification: '',
- }, ctx.apiHeaders, [201], 'sites.create');
- api('GET', '/sites/frameworks', null, ctx.sessionHeaders, [200], 'sites.frameworks.list');
- api('GET', '/sites/specifications', null, ctx.apiHeaders, [200], 'sites.specifications.list');
- const siteVariable = api('POST', `/sites/${siteId}/variables`, {
- key: 'BENCHMARK',
- value: 'true',
- secret: false,
- }, ctx.apiHeaders, [201], 'sites.variables.create');
- siteVariableId = siteVariable.json('$id');
-
- api('PUT', `/sites/${siteId}/variables/${siteVariableId}`, {
- key: 'BENCHMARK',
- value: 'updated',
- secret: false,
- }, ctx.apiHeaders, [200], 'sites.variables.update');
- api('GET', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [200], 'sites.variables.get');
- api('DELETE', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [204], 'sites.variables.delete');
- api('DELETE', `/sites/${siteId}`, null, ctx.apiHeaders, [204], 'sites.delete');
-}
-
-function healthFlow(ctx) {
- const 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',
- ];
-
- for (const path of probes) {
- api('GET', path, null, ctx.apiHeaders, [200], `health${path.replace(/\//g, '.')}`);
- }
-}
-
-function api(method, path, body, headers, expected, name) {
- const response = rawRequest(method, path, body, headers, name);
- apiDuration.add(response.timings.duration, { name });
- assertStatus(response, expected, name);
- return response;
-}
-
-function rawRequest(method, path, body, headers, name) {
- const params = {
- headers,
- tags: { name },
- };
- const payload = body === null || body === undefined ? null : JSON.stringify(body);
- return http.request(method, `${ENDPOINT}${path}`, payload, params);
-}
-
-function waitForStatus(path, headers, wantedStatus, timeoutMs) {
- const started = Date.now();
-
- while (Date.now() - started < timeoutMs) {
- const response = rawRequest('GET', path, null, headers, `wait${path}`);
- if (response.status === 200 && response.json('status') === wantedStatus) {
- return response;
- }
- sleep(0.5);
- }
-
- throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`);
-}
-
-function waitForMessage(messageId, headers, timeoutMs) {
- const started = Date.now();
-
- while (Date.now() - started < timeoutMs) {
- const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll');
- const status = response.status === 200 ? response.json('status') : null;
-
- if (['sent', 'failed'].includes(status)) {
- if (status === 'failed') {
- throw new Error(`Messaging worker marked message ${messageId} as failed`);
- }
- return response;
- }
-
- sleep(0.5);
- }
-
- throw new Error(`Timed out waiting for messaging worker to send message ${messageId}`);
-}
-
-function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) {
- const started = Date.now();
-
- while (Date.now() - started < timeoutMs) {
- const response = http.get(MAILDEV_ENDPOINT, { tags: { name: 'maildev.email.list' } });
- if (response.status === 200) {
- const emails = response.json();
- for (let i = emails.length - 1; i >= 0; i--) {
- const message = emails[i];
- if ((emailMatches(message, address) || (allowMissingRecipient && emailRecipientMissing(message))) && predicate(message)) {
- return message;
- }
- }
- }
- sleep(0.5);
- }
-
- throw new Error(`Timed out waiting for email to ${address}`);
-}
-
-function emailMatches(message, address) {
- const recipients = message.to || [];
- return recipients.some((recipient) => recipient.address === address);
-}
-
-function emailRecipientMissing(message) {
- const recipients = message.to || [];
- return recipients.length === 0 || recipients.every((recipient) => !recipient.address);
-}
-
-function extractQueryParams(message) {
- const content = `${message.html || ''}\n${message.text || ''}`;
- const links = [];
- const hrefPattern = /href="([^"]+)"/g;
- let hrefMatch = hrefPattern.exec(content);
-
- while (hrefMatch !== null) {
- links.push(hrefMatch[1]);
- hrefMatch = hrefPattern.exec(content);
- }
-
- if (links.length === 0) {
- links.push(content);
- }
-
- for (const link of links) {
- const queryStart = link.indexOf('?');
- if (queryStart === -1) {
- continue;
- }
-
- const query = link.slice(queryStart + 1).split('#')[0].replace(/&/g, '&');
- const params = {};
-
- for (const pair of query.split('&')) {
- const [key, value] = pair.split('=');
- params[decodeURIComponent(key)] = decodeURIComponent(value || '');
- }
-
- if (params.userId && params.secret) {
- return params;
- }
- }
-
- return {};
-}
-
-function assertStatus(response, expected, name) {
- const ok = check(response, {
- [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status),
- });
-
- if (!ok) {
- failResponse(response, `${name} returned an unexpected status`);
- }
-}
-
-function failResponse(response, message) {
- throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`);
-}
-
-function cookieHeader(response) {
- return response.headers['Set-Cookie'] || response.headers['set-cookie'] || '';
-}
-
-function projectHeaders(projectId) {
- return {
- 'Content-Type': 'application/json',
- 'X-Appwrite-Project': projectId,
- };
-}
-
-function documentPayload() {
- return {
- title: 'Benchmark Document',
- count: 1,
- email: 'document@example.com',
- active: true,
- publishedAt: new Date().toISOString(),
- score: 10.5,
- url: 'https://appwrite.io',
- ip: '127.0.0.1',
- };
-}
-
-function tablePayload() {
- return {
- title: 'Benchmark Row',
- count: 1,
- email: 'row@example.com',
- active: true,
- };
-}
-
-function onePixelPng() {
- return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'std', 'b');
-}
-
-function flattenMultipartArray(key, values) {
- const output = {};
- values.forEach((value, index) => {
- output[`${key}[${index}]`] = value;
- });
- return output;
-}
-
-function unique(prefix) {
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, '-')
- .slice(0, 36);
-}
-
-function includes(value, needle) {
- return String(value || '').toLowerCase().includes(String(needle).toLowerCase());
-}
-
-function hostnameFromUrl(value) {
- return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0];
-}
-
-export function handleSummary(data) {
- const lines = [
- 'Appwrite curated benchmark review',
- '',
- 'Before/after comparison',
- '',
- comparisonTable(PREVIOUS_SUMMARY, data),
- '',
- 'Current run details',
- '',
- metricLine(data, 'http_req_duration', 'HTTP total'),
- metricLine(data, 'appwrite_api_duration', 'API endpoints'),
- metricLine(data, 'appwrite_worker_database_duration', 'Database worker schema jobs'),
- metricLine(data, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'),
- metricLine(data, 'appwrite_worker_mails_duration', 'Mail worker delivery'),
- metricLine(data, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'),
- counterLine(data, 'appwrite_benchmark_flow_failures', 'Flow failures'),
- '',
- `Endpoint: ${ENDPOINT}`,
- `Maildev API: ${MAILDEV_ENDPOINT}`,
- '',
- ];
-
- return {
- stdout: `${lines.filter(Boolean).join('\n')}\n`,
- [SUMMARY_PATH]: JSON.stringify(data, null, 2),
- };
-}
-
-function loadPreviousSummary() {
- try {
- return JSON.parse(open(PREVIOUS_SUMMARY_PATH));
- } catch (error) {
- return null;
- }
-}
-
-function comparisonTable(before, after) {
- const rows = [
- ['HTTP total p95', trendMetric(before, 'http_req_duration', 'p(95)'), trendMetric(after, 'http_req_duration', 'p(95)'), 'ms'],
- ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'],
- ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'],
- ['TablesDB worker p95', trendMetric(before, 'appwrite_worker_tables_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'],
- ['Mail worker p95', trendMetric(before, 'appwrite_worker_mails_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'],
- ['Messaging worker p95', trendMetric(before, 'appwrite_worker_messaging_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'],
- ['Flow failures', counterMetric(before, 'appwrite_benchmark_flow_failures'), counterMetric(after, 'appwrite_benchmark_flow_failures'), ''],
- ['Check failures', checkFailures(before), checkFailures(after), ''],
- ];
-
- return [
- '| Metric | Before | After | Delta |',
- '| --- | ---: | ---: | ---: |',
- ...rows.map(([label, beforeValue, afterValue, unit]) => {
- return `| ${label} | ${formatValue(beforeValue, unit)} | ${formatValue(afterValue, unit)} | ${formatDelta(beforeValue, afterValue, unit)} |`;
- }),
- ].join('\n');
-}
-
-function trendMetric(data, metric, stat) {
- return data && data.metrics[metric] && data.metrics[metric].values
- ? data.metrics[metric].values[stat]
- : null;
-}
-
-function counterMetric(data, metric) {
- return data && data.metrics[metric] && data.metrics[metric].values
- ? data.metrics[metric].values.count
- : null;
-}
-
-function checkFailures(data) {
- return data && data.metrics.checks && data.metrics.checks.values
- ? data.metrics.checks.values.fails
- : null;
-}
-
-function formatValue(value, unit) {
- if (value === null || value === undefined || Number.isNaN(value)) {
- return 'n/a';
- }
-
- return `${round(value)}${unit}`;
-}
-
-function formatDelta(before, after, unit) {
- if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
- return 'n/a';
- }
-
- const delta = round(after - before);
- const sign = delta > 0 ? '+' : '';
- return `${sign}${delta}${unit}`;
-}
-
-function metricLine(data, metric, label) {
- const values = data.metrics[metric] && data.metrics[metric].values;
- if (!values || values.count === 0) {
- return `${label}: no samples`;
- }
-
- return `${label}: avg=${round(values.avg)}ms p90=${round(values['p(90)'])}ms p95=${round(values['p(95)'])}ms max=${round(values.max)}ms`;
-}
-
-function counterLine(data, metric, label) {
- const values = data.metrics[metric] && data.metrics[metric].values;
- return `${label}: ${values ? values.count : 0}`;
-}
-
-function round(value) {
- return Math.round((value || 0) * 100) / 100;
-}
diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php
new file mode 100644
index 0000000000..1d91246352
--- /dev/null
+++ b/tests/benchmarks/http.php
@@ -0,0 +1,1299 @@
+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 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
+
+ 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());