Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions app/Commands/SeedMessages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

namespace App\Commands;

use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
use Config\Services;
use Throwable;

final class SeedMessages extends BaseCommand
{
private const BENCHMARK_USER_COUNT = 100;
private const BENCHMARK_PASSWORD = 'Benchmark123!';
private const BATCH_SIZE = 1_000;
private const MAX_MESSAGES = 1_000_000;

protected $group = 'Chat';
protected $name = 'seed:messages';
protected $description = 'Replaces local benchmark data with a deterministic number of chat messages.';
protected $usage = 'seed:messages <count>';
protected $arguments = [
'count' => 'Number of benchmark messages to create (1-1,000,000).',
];

public function run(array $params): int
{
if (ENVIRONMENT === 'production') {
CLI::error('Benchmark seeding is disabled in production.');

return 1;
}

if (! filter_var(env('BENCHMARK_MODE', false), FILTER_VALIDATE_BOOL)) {
CLI::error('Set BENCHMARK_MODE=true to seed benchmark data.');

return 1;
}

$count = self::parseCount($params[0] ?? null);

if ($count === null) {
CLI::error('Count must be an integer between 1 and ' . number_format(self::MAX_MESSAGES) . '.');

return 1;
}

$db = db_connect();

try {
$this->replaceBenchmarkData($db, $count);
Services::cache()->clean();
} catch (Throwable $exception) {
CLI::error('Unable to seed benchmark messages: ' . $exception->getMessage());

return 1;
}

CLI::write(sprintf(
'Seeded %s messages and %d benchmark users.',
number_format($count),
self::BENCHMARK_USER_COUNT,
), 'green');

return 0;
}

public static function parseCount(mixed $value): ?int
{
if (! is_string($value) || ! ctype_digit($value)) {
return null;
}

$count = (int) $value;

return $count >= 1 && $count <= self::MAX_MESSAGES ? $count : null;
}

private function replaceBenchmarkData(BaseConnection $db, int $count): void
{
$db->transException(true)->transStart();

$this->seedUsers($db);

$messages = $db->table('messages');
$messages->like('user', 'k6-user-', 'after')->delete();

for ($offset = 0; $offset < $count; $offset += self::BATCH_SIZE) {
$batchSize = min(self::BATCH_SIZE, $count - $offset);
$rows = [];

for ($index = 0; $index < $batchSize; $index++) {
$sequence = $offset + $index + 1;
$rows[] = [
'user' => self::usernameFor($sequence),
'msg' => sprintf('Benchmark message %08d', $sequence),
'time' => 1_700_000_000 + $sequence,
];
}

$messages->insertBatch($rows);
}

$db->transComplete();
}

private function seedUsers(BaseConnection $db): void
{
$users = $db->table('users');
$password = password_hash(self::BENCHMARK_PASSWORD, PASSWORD_DEFAULT);
$now = date('Y-m-d H:i:s');

for ($sequence = 1; $sequence <= self::BENCHMARK_USER_COUNT; $sequence++) {
$username = self::usernameFor($sequence);
$existing = $users->select('id')->where('username', $username)->get()->getRowArray();
$row = [
'email' => sprintf('k6-user-%03d@example.test', $sequence),
'password' => $password,
'updated_at' => $now,
];

if ($existing === null) {
$users->insert($row + [
'username' => $username,
'created_at' => $now,
]);
} else {
$users->where('id', $existing['id'])->update($row);
}
}
}

private static function usernameFor(int $sequence): string
{
return sprintf('k6-user-%03d', (($sequence - 1) % self::BENCHMARK_USER_COUNT) + 1);
}
}
14 changes: 14 additions & 0 deletions app/Config/RateLimit.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,18 @@ class RateLimit extends BaseConfig
'authenticated' => [30, 60],
],
];

public function __construct()
{
parent::__construct();

if (ENVIRONMENT !== 'production' && filter_var(env('BENCHMARK_MODE', false), FILTER_VALIDATE_BOOL)) {
foreach ($this->profiles as $profile => $limits) {
$this->profiles[$profile] = [
'anonymous' => [1_000_000, $limits['anonymous'][1]],
'authenticated' => [1_000_000, $limits['authenticated'][1]],
];
}
}
}
}
53 changes: 53 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Local performance benchmarks

These k6 scenarios exercise the application through its public HTTP and WebSocket interfaces. They are intentionally local-only and never run in CI.

## Prerequisites

- PHP and project dependencies from `composer install`
- [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
- SQLite support, or a configured MySQL database
- The application migrations applied to the benchmark database

Prepare a reproducible dataset. This replaces messages owned by the reserved `k6-user-*` accounts and creates 100 local benchmark users with password `Benchmark123!`:

```shell
DB_DRIVER=SQLite3 php spark migrate --all
BENCHMARK_MODE=true DB_DRIVER=SQLite3 php spark seed:messages 10000
```

The seed command refuses to run in production or without `BENCHMARK_MODE=true` because it creates known local-only credentials and replaces messages owned by the reserved accounts.

Start the HTTP server with multiple workers and the local-only benchmark rate-limit profile. `BENCHMARK_MODE` is ignored in production:

```shell
CI_ENVIRONMENT=development PHP_CLI_SERVER_WORKERS=8 BENCHMARK_MODE=true DB_DRIVER=SQLite3 app_baseURL=http://127.0.0.1:8080/ app_CSPEnabled=false cookie_secure=false php spark serve --host 127.0.0.1 --port 8080
```

For the WebSocket fanout scenario, start the WebSocket server in another terminal:

```shell
DB_DRIVER=SQLite3 php spark chat:websocket --port 8081
```

Keep the WebSocket server stopped while measuring message posts. Post requests attempt a best-effort broadcast, so running the standalone server would mix WebSocket handshake behavior into the HTTP persistence measurement. The dedicated fanout scenario measures WebSocket delivery separately.

## Run scenarios

From the project root:

```shell
pnpm bench:post
pnpm bench:read
pnpm bench:ws
```

Override endpoints or the deterministic password when needed:

```shell
BASE_URL=http://127.0.0.1:8080 WS_URL=ws://127.0.0.1:8081 BENCH_PASSWORD='Benchmark123!' pnpm bench:ws
```

The post scenario runs 10 virtual users for 30 seconds and expects p95 message-post latency below 200 ms. The read scenario runs 50 polling users for 30 seconds and expects p99 read latency below 200 ms. The fanout scenario connects 100 users, lets every client broadcast once, and expects p95 delivery below 500 ms.

Treat thresholds as local smoke targets, not universal service-level objectives. Compare results only on a similar machine, database, dataset, and server configuration. A failed check usually indicates authentication, CSRF, rate-limit, or server setup trouble; a failed duration threshold indicates a performance result worth investigating.
83 changes: 83 additions & 0 deletions bench/lib/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import http from 'k6/http';
import { check, fail } from 'k6';
import exec from 'k6/execution';

export const baseUrl = (__ENV.BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
export const websocketUrl = (__ENV.WS_URL || 'ws://127.0.0.1:8081').replace(/\/$/, '');

const password = __ENV.BENCH_PASSWORD || 'Benchmark123!';

export function login() {
return loginForUser(((exec.vu.idInTest - 1) % 100) + 1);
}

export function loginForUser(sequence) {
const username = `k6-user-${String(sequence).padStart(3, '0')}`;
const loginPage = http.get(`${baseUrl}/auth/login`, { tags: { name: 'GET /auth/login' } });
const loginCsrf = metaValue(loginPage.body, 'csrf-token');

if (!loginCsrf) {
fail(`Could not read the CSRF token from ${baseUrl}/auth/login`);
}

const response = http.post(`${baseUrl}/auth/processLogin`, {
username,
password,
csrf_test_name: loginCsrf,
}, {
redirects: 5,
tags: { name: 'POST /auth/processLogin' },
});
const authenticated = check(response, {
'login succeeds': (result) => result.status === 200 && result.url.includes('/chat'),
});

if (!authenticated) {
fail(`Login failed for ${username} with status ${response.status}`);
}

const chatPage = http.get(`${baseUrl}/chat/vue`, { tags: { name: 'GET /chat/vue' } });
const userId = scriptValue(chatPage.body, 'CURRENT_USER_ID');
const websocketToken = scriptValue(chatPage.body, 'WEBSOCKET_TOKEN');

if (!userId || !websocketToken) {
fail(`Could not read WebSocket credentials for ${username}`);
}

return {
username,
userId,
websocketToken,
};
}

export function csrfToken() {
const cookies = http.cookieJar().cookiesForURL(baseUrl);
const values = cookies.csrf_cookie_name || [];

if (values.length === 0) {
fail('The authenticated session does not contain a CSRF cookie');
}

return values[0];
}

function metaValue(html, name) {
if (typeof html !== 'string') {
return '';
}

const match = html.match(new RegExp(`<meta[^>]+name=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i'));

return match ? match[1] : '';
}

function scriptValue(html, name) {
if (typeof html !== 'string') {
return '';
}

const match = html.match(new RegExp(`${name}\\s*=\\s*["']?([^"';\\s]+)`));

return match ? match[1] : '';
}
42 changes: 42 additions & 0 deletions bench/post-messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { baseUrl, csrfToken, login } from './lib/auth.js';

const failures = new Rate('post_message_failures');
const latency = new Trend('post_message_duration', true);
let session;

export const options = {
noCookiesReset: true,
scenarios: {
post_messages: {
executor: 'constant-vus',
vus: 10,
duration: '30s',
},
},
thresholds: {
checks: ['rate>0.99'],
post_message_failures: ['rate<0.01'],
post_message_duration: ['p(95)<200'],
},
};

export default function () {
session ||= login();
const response = http.post(`${baseUrl}/api/v1/messages`, {
message: `k6 post ${session.username} ${Date.now()}`,
csrf_test_name: csrfToken(),
}, {
headers: { Accept: 'application/json' },
tags: { name: 'POST /api/v1/messages' },
});
const succeeded = check(response, {
'message post succeeds': (result) => result.status === 200 && result.json('success') === true,
});

failures.add(!succeeded);
latency.add(response.timings.duration);
sleep(0.5);
}
39 changes: 39 additions & 0 deletions bench/read-messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { baseUrl, login } from './lib/auth.js';

const failures = new Rate('read_message_failures');
const latency = new Trend('read_message_duration', true);
let session;

export const options = {
noCookiesReset: true,
scenarios: {
read_messages: {
executor: 'constant-vus',
vus: 50,
duration: '30s',
},
},
thresholds: {
checks: ['rate>0.99'],
read_message_failures: ['rate<0.01'],
read_message_duration: ['p(99)<200'],
},
};

export default function () {
session ||= login();
const response = http.get(`${baseUrl}/api/v1/messages?page=1&per_page=50`, {
headers: { Accept: 'application/json' },
tags: { name: 'GET /api/v1/messages' },
});
const succeeded = check(response, {
'message read succeeds': (result) => result.status === 200 && Array.isArray(result.json('messages')),
});

failures.add(!succeeded);
latency.add(response.timings.duration);
sleep(0.5);
}
Loading
Loading