Skip to content

Commit 1564b78

Browse files
committed
working (but messy) lottery queue with mercure example
1 parent 004d275 commit 1564b78

File tree

13 files changed

+178
-168
lines changed

13 files changed

+178
-168
lines changed

Caddyfile.dev

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
:80 {
22
root * {$SERVER_ROOT}
33
file_server
4+
reverse_proxy /.well-known/mercure* mercure:80
45
php_server
56
}

README.md

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

33
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/cdc12dbceac04dc8bbece4012222cd3d)](https://app.codacy.com/gh/clegginabox/airlock-php/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
44
[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/cdc12dbceac04dc8bbece4012222cd3d)](https://app.codacy.com/gh/clegginabox/airlock-php/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
5-
![PHPCS](https://img.shields.io/github/actions/workflow/status/clegginabox/airlock-php/tests.yaml?label=phpcs)
6-
![PHPUnit](https://img.shields.io/github/actions/workflow/status/clegginabox/airlock-php/tests.yaml?label=tests)
7-
![E2E](https://img.shields.io/github/actions/workflow/status/clegginabox/airlock-php/tests.yaml?label=e2e)
85
![PHPStan Level 8](https://img.shields.io/badge/phpstan%20level-8%20of%209-green?style=flat-square&logo=php)
96

107
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=clegginabox_airlock-php&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=clegginabox_airlock-php)

compose.yaml

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,30 @@ services:
1313
REDIS_HOST: redis
1414
REDIS_PORT: 6379
1515
SERVER_ROOT: /app/examples/public
16+
MERCURE_HUB_URL: http://mercure/.well-known/mercure
17+
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-airlock-mercure-secret-32chars-minimum}
18+
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-http://localhost/.well-known/mercure}
1619
env_file:
1720
- .env
1821
tty: true
1922
depends_on:
2023
- redis
24+
- mercure
2125
ports:
2226
- "80:80" # HTTP
2327

24-
worker:
25-
build:
26-
context: .
27-
dockerfile: Dockerfile
28-
working_dir: /app
29-
volumes:
30-
- ./:/app
28+
mercure:
29+
image: dunglas/mercure
30+
ports:
31+
- "3000:80"
3132
environment:
32-
REDIS_HOST: redis
33-
REDIS_PORT: 6379
34-
env_file:
35-
- .env
36-
depends_on:
37-
- redis
38-
command: php examples/bin/console app:worker
33+
SERVER_NAME: :80
34+
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-airlock-mercure-secret-32chars-minimum}
35+
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-airlock-mercure-secret-32chars-minimum}
36+
MERCURE_PUBLISHER_JWT_ALG: HS256
37+
MERCURE_SUBSCRIBER_JWT_ALG: HS256
38+
MERCURE_ALLOW_ANONYMOUS: 1
39+
MERCURE_CORS_ALLOWED_ORIGINS: "*"
3940

4041
redis:
4142
image: redis:8.4.0
@@ -54,7 +55,6 @@ services:
5455
env_file:
5556
- .env
5657
depends_on:
57-
- worker
5858
- redis
5959
command: >
6060
sh -c "npm install && npx playwright test"

examples/bin/console

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ declare(strict_types=1);
66
use App\Kernel;
77
use Symfony\Bundle\FrameworkBundle\Console\Application;
88

9-
require dirname(__DIR__) . '/vendor/autoload.php';
9+
require_once dirname(__DIR__) . '/vendor/autoload.php';
1010

1111
$env = $_SERVER['APP_ENV'] ?? 'dev';
1212
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? true);

examples/composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"symfony/semaphore": "^8.0",
2424
"symfony/console": "^8.0",
2525
"symfony/dotenv": "^8.0",
26-
"symfony/yaml": "^8.0"
26+
"symfony/yaml": "^8.0",
27+
"symfony/mercure": "^0.7.2",
28+
"lcobucci/jwt": "^5.6"
2729
},
2830
"repositories": [
2931
{

examples/config/framework.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
framework:
22
secret: S0ME_SECRET
3+
session:
4+
enabled: true

examples/src/Command/WorkerCommand.php

Lines changed: 0 additions & 100 deletions
This file was deleted.

examples/src/Factory/AirlockFactory.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use App\GlobalLock\GlobalLock;
88
use App\RedisLotteryQueue\RedisLotteryQueue;
9+
use Clegginabox\Airlock\Bridge\Mercure\MercureAirlockNotifier;
10+
use Clegginabox\Airlock\Bridge\Symfony\Mercure\SymfonyMercureHubFactory;
911
use Clegginabox\Airlock\Bridge\Symfony\Seal\SymfonyLockSeal;
1012
use Clegginabox\Airlock\Bridge\Symfony\Seal\SymfonySemaphoreSeal;
1113
use Clegginabox\Airlock\Notifier\NullAirlockNotifier;
@@ -81,4 +83,34 @@ public function redisLotteryQueue(
8183

8284
return new QueueAirlock($seal, $queue, new NullAirlockNotifier());
8385
}
86+
87+
public function redisLotteryQueueWithMercure(
88+
int $limit = 3,
89+
int $ttl = 60,
90+
int $claimWindow = 10,
91+
): QueueAirlock {
92+
$seal = new SymfonySemaphoreSeal(
93+
factory: new SemaphoreFactory(new SemaphoreRedisStore($this->redis)),
94+
resource: RedisLotteryQueue::RESOURCE->value,
95+
limit: $limit,
96+
weight: 1,
97+
ttlInSeconds: $ttl,
98+
autoRelease: false,
99+
);
100+
101+
$queue = new LotteryQueue(
102+
new RedisLotteryQueueStore(
103+
redis: $this->redis,
104+
setKey: RedisLotteryQueue::SET_KEY->value,
105+
candidateKey: RedisLotteryQueue::CANDIDATE_KEY->value,
106+
candidateTtlSeconds: $claimWindow,
107+
),
108+
);
109+
110+
$hubUrl = getenv('MERCURE_HUB_URL') ?: 'http://localhost:3000/.well-known/mercure';
111+
$jwtSecret = getenv('MERCURE_JWT_SECRET') ?: 'airlock-mercure-secret';
112+
$hub = SymfonyMercureHubFactory::create($hubUrl, $jwtSecret);
113+
114+
return new QueueAirlock($seal, $queue, new MercureAirlockNotifier($hub));
115+
}
84116
}

examples/src/RedisLotteryQueue/Controller.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
use App\Infrastructure\ClientIdCookieSubscriber;
88
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
9+
use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
910
use Symfony\Component\HttpFoundation\JsonResponse;
1011
use Symfony\Component\HttpFoundation\Request;
1112
use Symfony\Component\HttpFoundation\Response;
1213
use Symfony\Component\Routing\Attribute\Route;
1314

1415
class Controller extends AbstractController
1516
{
17+
private const SESSION_TOKEN_KEY = 'airlock.redis_lottery.token';
18+
1619
#[Route('/redis-lottery-queue', methods: [Request::METHOD_GET])]
1720
public function index(): Response
1821
{
@@ -39,17 +42,51 @@ public function success(): Response
3942
);
4043
}
4144

45+
#[Route('/redis-lottery-queue/release', methods: [Request::METHOD_POST])]
46+
public function release(Request $request, RedisLotteryQueueService $service): Response
47+
{
48+
try {
49+
$session = $request->getSession();
50+
} catch (SessionNotFoundException) {
51+
return new JsonResponse(['ok' => false, 'error' => 'Session not available'], Response::HTTP_BAD_REQUEST);
52+
}
53+
54+
$serializedToken = $session->get(self::SESSION_TOKEN_KEY);
55+
if (!is_string($serializedToken) || $serializedToken === '') {
56+
return new JsonResponse(['ok' => false, 'error' => 'Missing airlock token'], Response::HTTP_BAD_REQUEST);
57+
}
58+
59+
$service->release($serializedToken);
60+
$session->remove(self::SESSION_TOKEN_KEY);
61+
62+
return new JsonResponse(['ok' => true]);
63+
}
64+
4265
#[Route('/redis-lottery-queue/start', methods: [Request::METHOD_POST])]
4366
public function start(Request $request, RedisLotteryQueueService $service): JsonResponse
4467
{
4568
$clientId = (string) $request->attributes->get(ClientIdCookieSubscriber::ATTRIBUTE);
69+
4670
$result = $service->start($clientId);
4771

4872
if ($result->isAdmitted()) {
73+
try {
74+
$session = $request->getSession();
75+
$token = $result->getToken();
76+
if ($token !== null) {
77+
$session->set(self::SESSION_TOKEN_KEY, (string) $token);
78+
}
79+
} catch (SessionNotFoundException) {
80+
// Session not available; continue without persisting token.
81+
}
82+
4983
return new JsonResponse([
5084
'ok' => true,
5185
'status' => 'admitted',
5286
'clientId' => $clientId,
87+
'topic' => $service->getTopic($clientId),
88+
'hubUrl' => $service->getHubUrl(),
89+
'token' => $service->getSubscriberToken($clientId),
5390
]);
5491
}
5592

@@ -58,6 +95,9 @@ public function start(Request $request, RedisLotteryQueueService $service): Json
5895
'status' => 'queued',
5996
'position' => $result->getPosition(),
6097
'clientId' => $clientId,
98+
'topic' => $service->getTopic($clientId),
99+
'hubUrl' => $service->getHubUrl(),
100+
'token' => $service->getSubscriberToken($clientId),
61101
]);
62102
}
63103
}

examples/src/RedisLotteryQueue/RedisLotteryQueueService.php

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
namespace App\RedisLotteryQueue;
66

77
use App\Factory\AirlockFactory;
8+
use Clegginabox\Airlock\Bridge\Symfony\Seal\SymfonySemaphoreToken;
89
use Clegginabox\Airlock\EntryResult;
910
use Clegginabox\Airlock\QueueAirlock;
11+
use Symfony\Component\Mercure\Jwt\LcobucciFactory;
12+
use Symfony\Component\Semaphore\Key;
1013

1114
final class RedisLotteryQueueService
1215
{
@@ -15,27 +18,43 @@ final class RedisLotteryQueueService
1518
public function __construct(
1619
private readonly AirlockFactory $airlockFactory,
1720
) {
18-
$this->airlock = $this->airlockFactory->redisLotteryQueue();
21+
$this->airlock = $this->airlockFactory->redisLotteryQueueWithMercure(
22+
limit: 1, // Only allow one person "inside" at a time
23+
ttl: 60, // 60 second time limit
24+
claimWindow: 10, // 10 seconds to claim the place
25+
);
1926
}
2027

21-
public function start(string $clientId, int $holdSeconds = 15): EntryResult
28+
public function start(string $clientId): EntryResult
2229
{
23-
return $this->tryAdmit($clientId, $holdSeconds);
30+
return $this->airlock->enter($clientId);
2431
}
2532

26-
public function check(string $clientId, int $holdSeconds = 15): EntryResult
33+
public function release(string $serializedToken): void
2734
{
28-
return $this->tryAdmit($clientId, $holdSeconds);
35+
$key = unserialize($serializedToken, ['allowed_classes' => [Key::class]]);
36+
if (!$key instanceof Key) {
37+
return;
38+
}
39+
40+
$this->airlock->release(new SymfonySemaphoreToken($key));
2941
}
3042

31-
private function tryAdmit(string $clientId, int $holdSeconds = 15): EntryResult
43+
public function getTopic(string $clientId): string
3244
{
33-
$result = $this->airlock->enter($clientId);
45+
return $this->airlock->getTopic($clientId);
46+
}
3447

35-
if ($result->isAdmitted()) {
36-
return $result;
37-
}
48+
public function getHubUrl(): string
49+
{
50+
return getenv('MERCURE_PUBLIC_URL') ?: 'http://localhost:3000/.well-known/mercure';
51+
}
52+
53+
public function getSubscriberToken(string $clientId): string
54+
{
55+
$jwtSecret = getenv('MERCURE_JWT_SECRET') ?: 'airlock-mercure-secret-32chars-minimum';
3856

39-
return $result;
57+
return new LcobucciFactory($jwtSecret)
58+
->create(subscribe: [$this->getTopic($clientId)]);
4059
}
4160
}

0 commit comments

Comments
 (0)