Skip to content

Commit 72be898

Browse files
author
Your Name
committed
feat: add rust concurrent speedtest runner service
Add a Rust sidecar service that runs multiple speedtests concurrently, replacing the sequential PHP job batch for improved throughput. Rust service (rust-speedtest/): - axum + tokio web framework with graceful shutdown - Semaphore-based rate limiting (default 3 concurrent tests) - Auto-detects Ookla CLI vs speedtest-cli (Python) - Endpoints: POST /speedtest, GET /speedtest/:id, GET /health, GET /stats - Callback URL support for Laravel integration - 9 unit tests covering models and routes Laravel integration: - TriggerRustSpeedtest action with fallback to PHP jobs - SpeedtestCallbackController for receiving results - New config: RUST_SPEEDTEST_URL Docker: - Multi-stage Dockerfile with Ookla CLI - Added rust-speedtest service to compose.yaml
1 parent 7d74172 commit 72be898

File tree

14 files changed

+1567
-0
lines changed

14 files changed

+1567
-0
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
namespace App\Actions\Ookla;
4+
5+
use App\Enums\ResultService;
6+
use App\Enums\ResultStatus;
7+
use App\Events\SpeedtestWaiting;
8+
use App\Models\Result;
9+
use Illuminate\Support\Facades\Http;
10+
use Illuminate\Support\Facades\Log;
11+
use Lorisleiva\Actions\Concerns\AsAction;
12+
13+
class TriggerRustSpeedtest
14+
{
15+
use AsAction;
16+
17+
/**
18+
* Trigger a speedtest via the Rust service.
19+
* Falls back to PHP job batch if Rust service unavailable.
20+
*/
21+
public function handle(bool $scheduled = false, ?int $serverId = null, ?int $dispatchedBy = null): mixed
22+
{
23+
$rustServiceUrl = config('speedtest.rust_service_url');
24+
25+
// Fall back to PHP if Rust service not configured
26+
if (empty($rustServiceUrl)) {
27+
Log::debug('rust service not configured, falling back to php');
28+
return RunSpeedtest::run($scheduled, $serverId, $dispatchedBy);
29+
}
30+
31+
// Check if Rust service is healthy
32+
if (! $this->isRustServiceHealthy($rustServiceUrl)) {
33+
Log::warning('rust service unhealthy, falling back to php');
34+
return RunSpeedtest::run($scheduled, $serverId, $dispatchedBy);
35+
}
36+
37+
// Create the result record
38+
$result = Result::create([
39+
'data->server->id' => $serverId,
40+
'service' => ResultService::Ookla,
41+
'status' => ResultStatus::Waiting,
42+
'scheduled' => $scheduled,
43+
'dispatched_by' => $dispatchedBy,
44+
]);
45+
46+
SpeedtestWaiting::dispatch($result);
47+
48+
// Trigger speedtest via Rust service
49+
try {
50+
$callbackUrl = route('api.v1.speedtest.callback');
51+
52+
$response = Http::timeout(10)
53+
->post("{$rustServiceUrl}/speedtest", [
54+
'server_id' => $serverId,
55+
'callback_url' => $callbackUrl,
56+
'metadata' => [
57+
'result_id' => $result->id,
58+
'scheduled' => $scheduled,
59+
],
60+
]);
61+
62+
if ($response->successful()) {
63+
$data = $response->json();
64+
Log::info('speedtest triggered via rust service', [
65+
'result_id' => $result->id,
66+
'test_id' => $data['test_id'] ?? null,
67+
]);
68+
69+
// Store the Rust test_id for tracking
70+
$result->update([
71+
'data->rust_test_id' => $data['test_id'] ?? null,
72+
'status' => ResultStatus::Checking,
73+
]);
74+
75+
return $result;
76+
}
77+
78+
Log::error('rust service returned error', [
79+
'status' => $response->status(),
80+
'body' => $response->body(),
81+
]);
82+
} catch (\Exception $e) {
83+
Log::error('failed to contact rust service', [
84+
'error' => $e->getMessage(),
85+
]);
86+
}
87+
88+
// Fall back to PHP on any error
89+
Log::warning('rust service request failed, falling back to php');
90+
91+
// Delete the created result and use PHP flow
92+
$result->delete();
93+
94+
return RunSpeedtest::run($scheduled, $serverId, $dispatchedBy);
95+
}
96+
97+
/**
98+
* Check if the Rust service is healthy.
99+
*/
100+
protected function isRustServiceHealthy(string $baseUrl): bool
101+
{
102+
try {
103+
$response = Http::timeout(5)->get("{$baseUrl}/health");
104+
105+
return $response->successful() &&
106+
($response->json('status') === 'healthy');
107+
} catch (\Exception $e) {
108+
Log::debug('rust service health check failed', [
109+
'error' => $e->getMessage(),
110+
]);
111+
112+
return false;
113+
}
114+
}
115+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Enums\ResultStatus;
6+
use App\Events\SpeedtestCompleted;
7+
use App\Events\SpeedtestFailed;
8+
use App\Events\SpeedtestRunning;
9+
use App\Http\Controllers\Controller;
10+
use App\Jobs\Ookla\BenchmarkSpeedtestJob;
11+
use App\Models\Result;
12+
use Illuminate\Http\JsonResponse;
13+
use Illuminate\Http\Request;
14+
use Illuminate\Support\Arr;
15+
use Illuminate\Support\Facades\Log;
16+
17+
class SpeedtestCallbackController extends Controller
18+
{
19+
/**
20+
* Handle callback from Rust speedtest service.
21+
*/
22+
public function __invoke(Request $request): JsonResponse
23+
{
24+
Log::debug('received speedtest callback', [
25+
'test_id' => $request->input('test_id'),
26+
'status' => $request->input('status'),
27+
]);
28+
29+
$resultId = $request->input('metadata.result_id');
30+
31+
if (! $resultId) {
32+
Log::warning('callback missing result_id in metadata');
33+
34+
return response()->json(['error' => 'missing result_id'], 400);
35+
}
36+
37+
$result = Result::find($resultId);
38+
39+
if (! $result) {
40+
Log::warning('callback for unknown result', ['result_id' => $resultId]);
41+
42+
return response()->json(['error' => 'result not found'], 404);
43+
}
44+
45+
$status = $request->input('status');
46+
47+
if ($status === 'failed') {
48+
return $this->handleFailed($result, $request);
49+
}
50+
51+
if ($status === 'completed') {
52+
return $this->handleCompleted($result, $request);
53+
}
54+
55+
// Running status update
56+
if ($status === 'running') {
57+
$result->update(['status' => ResultStatus::Running]);
58+
SpeedtestRunning::dispatch($result);
59+
60+
return response()->json(['status' => 'acknowledged']);
61+
}
62+
63+
return response()->json(['status' => 'ignored']);
64+
}
65+
66+
/**
67+
* Handle completed speedtest callback.
68+
*/
69+
protected function handleCompleted(Result $result, Request $request): JsonResponse
70+
{
71+
Log::info('speedtest completed via rust service', [
72+
'result_id' => $result->id,
73+
'test_id' => $request->input('test_id'),
74+
]);
75+
76+
// Extract data from Rust service response
77+
$rawOutput = $request->input('raw_output', []);
78+
79+
// Update result with speedtest data
80+
$result->update([
81+
'ping' => $request->input('ping'),
82+
'download' => $this->mbpsToBytes($request->input('download')),
83+
'upload' => $this->mbpsToBytes($request->input('upload')),
84+
'download_bytes' => Arr::get($rawOutput, 'download.bytes'),
85+
'upload_bytes' => Arr::get($rawOutput, 'upload.bytes'),
86+
'data' => $rawOutput,
87+
'status' => ResultStatus::Benchmarking,
88+
]);
89+
90+
// Dispatch benchmark job
91+
BenchmarkSpeedtestJob::dispatch($result);
92+
93+
// Complete the speedtest
94+
$result->update(['status' => ResultStatus::Completed]);
95+
SpeedtestCompleted::dispatch($result);
96+
97+
return response()->json(['status' => 'processed']);
98+
}
99+
100+
/**
101+
* Handle failed speedtest callback.
102+
*/
103+
protected function handleFailed(Result $result, Request $request): JsonResponse
104+
{
105+
Log::error('speedtest failed via rust service', [
106+
'result_id' => $result->id,
107+
'error' => $request->input('error'),
108+
]);
109+
110+
$result->update([
111+
'data->type' => 'log',
112+
'data->level' => 'error',
113+
'data->message' => $request->input('error', 'Unknown error from Rust service'),
114+
'status' => ResultStatus::Failed,
115+
]);
116+
117+
SpeedtestFailed::dispatch($result);
118+
119+
return response()->json(['status' => 'processed']);
120+
}
121+
122+
/**
123+
* Convert Mbps to bytes per second (Ookla format).
124+
*/
125+
protected function mbpsToBytes(?float $mbps): ?int
126+
{
127+
if ($mbps === null) {
128+
return null;
129+
}
130+
131+
// Convert Mbps to bytes/s (Ookla stores bandwidth in bytes/s)
132+
return (int) (($mbps * 1_000_000) / 8);
133+
}
134+
}

compose.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ services:
1616
LARAVEL_SAIL: 1
1717
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
1818
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
19+
RUST_SPEEDTEST_URL: 'http://rust-speedtest:3000'
1920
volumes:
2021
- '.:/var/www/html'
2122
networks:
@@ -24,6 +25,30 @@ services:
2425
- pgsql
2526
- mailpit
2627
- apprise
28+
- rust-speedtest
29+
rust-speedtest:
30+
build:
31+
context: ./rust-speedtest
32+
dockerfile: Dockerfile
33+
image: speedtest-tracker/rust-speedtest
34+
ports:
35+
- '${FORWARD_RUST_SPEEDTEST_PORT:-3000}:3000'
36+
environment:
37+
PORT: 3000
38+
MAX_CONCURRENT: '${RUST_MAX_CONCURRENT:-3}'
39+
RUST_LOG: '${RUST_LOG:-rust_speedtest=info,tower_http=info}'
40+
networks:
41+
- sail
42+
healthcheck:
43+
test:
44+
- CMD
45+
- curl
46+
- '-f'
47+
- 'http://localhost:3000/health'
48+
interval: 30s
49+
timeout: 10s
50+
start_period: 5s
51+
retries: 3
2752
pgsql:
2853
image: 'postgres:18-alpine'
2954
ports:

config/speedtest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
/**
2222
* Speedtest settings.
2323
*/
24+
'rust_service_url' => env('RUST_SPEEDTEST_URL'),
25+
2426
'schedule' => env('SPEEDTEST_SCHEDULE', false),
2527

2628
'servers' => env('SPEEDTEST_SERVERS'),

routes/api/v1/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use App\Http\Controllers\Api\V1\OoklaController;
44
use App\Http\Controllers\Api\V1\ResultsController;
5+
use App\Http\Controllers\Api\V1\SpeedtestCallbackController;
56
use App\Http\Controllers\Api\V1\SpeedtestController;
67
use App\Http\Controllers\Api\V1\StatsController;
78
use Illuminate\Support\Facades\Route;
@@ -19,6 +20,9 @@
1920
Route::post('/speedtests/run', SpeedtestController::class)
2021
->name('speedtests.run');
2122

23+
Route::post('/speedtest/callback', SpeedtestCallbackController::class)
24+
->name('speedtest.callback');
25+
2226
Route::get('/ookla/list-servers', OoklaController::class)
2327
->name('ookla.list-servers');
2428

rust-speedtest/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target/
2+
Cargo.lock

rust-speedtest/Cargo.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "rust-speedtest"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Concurrent speedtest runner service"
6+
7+
[dependencies]
8+
axum = "0.8"
9+
tokio = { version = "1.43", features = ["full"] }
10+
serde = { version = "1.0", features = ["derive"] }
11+
serde_json = "1.0"
12+
tracing = "0.1"
13+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
14+
reqwest = { version = "0.12", features = ["json"] }
15+
uuid = { version = "1.11", features = ["v4", "serde"] }
16+
chrono = { version = "0.4", features = ["serde"] }
17+
tower-http = { version = "0.6", features = ["cors", "trace"] }
18+
anyhow = "1.0"
19+
thiserror = "2.0"
20+
21+
[dev-dependencies]
22+
tokio-test = "0.4"
23+
axum-test = "18"
24+
25+
[profile.release]
26+
opt-level = 3
27+
lto = true

rust-speedtest/Dockerfile

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Build stage
2+
FROM rust:1.84-bookworm AS builder
3+
4+
WORKDIR /app
5+
6+
# Copy manifests
7+
COPY Cargo.toml Cargo.lock* ./
8+
9+
# Create dummy src for dependency caching
10+
RUN mkdir src && echo "fn main() {}" > src/main.rs
11+
12+
# Build dependencies (cached layer)
13+
RUN cargo build --release && rm -rf src target/release/deps/rust_speedtest*
14+
15+
# Copy actual source
16+
COPY src ./src
17+
18+
# Build application
19+
RUN cargo build --release
20+
21+
# Runtime stage
22+
FROM debian:bookworm-slim
23+
24+
# Install Ookla Speedtest CLI
25+
RUN apt-get update && apt-get install -y --no-install-recommends \
26+
ca-certificates \
27+
curl \
28+
gnupg \
29+
&& curl -s https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh | bash \
30+
&& apt-get install -y speedtest \
31+
&& rm -rf /var/lib/apt/lists/*
32+
33+
WORKDIR /app
34+
35+
# Copy binary from builder
36+
COPY --from=builder /app/target/release/rust-speedtest /app/rust-speedtest
37+
38+
# Create non-root user
39+
RUN useradd -r -s /bin/false speedtest && chown -R speedtest:speedtest /app
40+
USER speedtest
41+
42+
ENV PORT=3000
43+
ENV MAX_CONCURRENT=3
44+
ENV RUST_LOG=rust_speedtest=info,tower_http=info
45+
46+
EXPOSE 3000
47+
48+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
49+
CMD curl -f http://localhost:3000/health || exit 1
50+
51+
CMD ["/app/rust-speedtest"]

0 commit comments

Comments
 (0)