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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 1.1.0 — 2026-07-25

- Ship an AI agent skill (`resources/skills/rasuvaeff-bulkhead/SKILL.md` +
`extra.skills` in composer.json): projects using the `llm/skills` Composer
plugin get the skill synced into `.agents/skills/` automatically on install.
- Bump `rasuvaeff/property-testing` dev dependency to `^2.6`.
- Property-generator methods in tests are now `public static` (private ones are
removed by rector's `RemoveUnusedPrivateMethodRector` — they are only called
via reflection).

## 1.0.1 — 2026-07-03

- `PredisScriptRunner` issues EVALSHA/EVAL through the typed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ its own worker. Complements a circuit breaker (which decides *whether* to
try) — a bulkhead decides *how many at once*.

> Using an AI coding assistant? [llms.txt](llms.txt) contains a compact API reference you can share with the model.
> Projects using the [llm/skills](https://github.com/roxblnfk/skills) Composer plugin also get this package's agent skill synced into `.agents/skills/` automatically on install.

## Requirements

Expand Down
1 change: 1 addition & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

> Используете AI-ассистента? В [llms.txt](llms.txt) — компактный API-справочник,
> которым можно поделиться с моделью.
> Проекты с Composer-плагином [llm/skills](https://github.com/roxblnfk/skills) дополнительно получают agent-скилл этого пакета в `.agents/skills/` автоматически при установке.

## Требования

Expand Down
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"infection/infection": "^0.33 || ^0.34",
"maglnet/composer-require-checker": "^4.17",
"predis/predis": "^2.2 || ^3.0",
"rasuvaeff/property-testing": "^2.2",
"rasuvaeff/property-testing": "^2.6",
"rector/rector": "^2.4",
"roave/backward-compatibility-check": "^8.0",
"testo/bridge-infection": "^0.1.6",
Expand Down Expand Up @@ -66,6 +66,11 @@
},
"sort-packages": true
},
"extra": {
"skills": {
"source": "resources/skills"
}
},
"scripts": {
"bc-check": "roave-backward-compatibility-check",
"bench": "testo --suite=Benchmarks -vv",
Expand Down
78 changes: 78 additions & 0 deletions resources/skills/rasuvaeff-bulkhead/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: rasuvaeff-bulkhead
description: >-
Cap simultaneous calls to a dependency across a whole PHP-FPM worker pool with
rasuvaeff/bulkhead — SharedBulkhead over a shared counter in Redis
(RedisBulkheadStore, multi-host) or APCu (ApcuBulkheadStore, single host),
with BulkheadFullException on rejection. Use when writing, reviewing or
debugging concurrency limiting, downstream overload protection or
BulkheadFullException handling in a project that has this package installed.
---

# rasuvaeff/bulkhead

Cross-process concurrency limiter for PHP-FPM: `SharedBulkhead` admits at most
`maxConcurrent` simultaneous `call()`s counted across every worker that shares
a `BulkheadStore`. Namespace `Rasuvaeff\Bulkhead`.

## Safety rules — verify these on every change

1. **Pick the store that matches the deployment.** `RedisBulkheadStore` is the
only cross-host limiter; `ApcuBulkheadStore` coordinates workers on ONE host
(APCu shared memory doesn't span machines); `InMemoryBulkheadStore` is
single-process (tests/CLI) — never a pool limiter in FPM.

2. **`lease` must exceed the longest possible callback runtime** (downstream
timeout + margin). The lease TTL is what reclaims a dead worker's slot; if
the callback outlives it, the slot is reclaimed mid-call and real
concurrency exceeds `maxConcurrent`.

3. **Never hold a permit past the call.** `SharedBulkhead::call()` releases in
`finally` for you — prefer it. If you use `BulkheadStore::tryAcquire()`
directly, `release($name, $token)` must sit in a `finally` block, or the
slot stays taken until the lease expires.

4. **Rejection, not a queue.** `maxWait: Duration::zero()` fast-fails; a
non-zero `maxWait` polls (approximate budget, NOT FIFO — waiters can
starve). Always catch `BulkheadFullException` and degrade gracefully; don't
build an unbounded retry loop around it.

5. **Acquire must stay atomic.** The Redis Lua script (prune expired → check
`ZCARD` < max → `ZADD`, TTL never shrinks) must remain ONE script; APCu gets
the same guarantee from an `apcu_add` spinlock (`=== true`, not `!== true`).
Splitting either reintroduces the check-then-incr race the package exists
to prevent.

6. **`name` must match `/^[A-Za-z0-9_.:-]+$/`** (it becomes a Redis/APCu key),
and on Redis `availableSlots()`/`activeCount()` WRITE (they prune expired
members) — don't point the store at a read-only replica.

## Canonical usage

```php
use Predis\Client;
use Rasuvaeff\Bulkhead\{BulkheadFullException, RedisBulkheadStore, SharedBulkhead};
use Rasuvaeff\Bulkhead\Redis\PredisScriptRunner;
use Rasuvaeff\Duration\Duration;

$bulkhead = new SharedBulkhead(
name: 'legacy-api',
maxConcurrent: 10,
store: new RedisBulkheadStore(new PredisScriptRunner(new Client(['host' => '127.0.0.1']))),
lease: Duration::seconds(5), // > longest callback runtime
maxWait: Duration::millis(200), // Duration::zero() = fast-fail
);

try {
$value = $bulkhead->call(static fn(): string => callDownstream());
} catch (BulkheadFullException $e) {
// $e->name, $e->maxConcurrent — degrade gracefully
}
```

## Full API

The complete reference — every constructor option (`pollInterval`, `pollJitter`,
`onAccepted`/`onRejected`), `PhpRedisScriptRunner`, APCu/in-memory setup and the
`BulkheadStore` contract — ships with the package: read
`vendor/rasuvaeff/bulkhead/llms.txt` before guessing a method name.
6 changes: 3 additions & 3 deletions tests/InMemoryBulkheadStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function neverGrantsMoreThanMaxConcurrent(int $max, int $attempts): void
}

/** @return array<string, ArbitraryInterface> */
private function neverGrantsMoreThanMaxConcurrentGenerators(): array
public static function neverGrantsMoreThanMaxConcurrentGenerators(): array
{
return [
'max' => Gen::intBetween(1, 20),
Expand Down Expand Up @@ -143,7 +143,7 @@ public function releasingEveryTokenRestoresFullCapacity(int $max): void
}

/** @return array<string, ArbitraryInterface> */
private function releasingEveryTokenRestoresFullCapacityGenerators(): array
public static function releasingEveryTokenRestoresFullCapacityGenerators(): array
{
return ['max' => Gen::intBetween(1, 30)];
}
Expand All @@ -168,7 +168,7 @@ public function interleavedAcquireAndReleaseTrackTheModel(CommandSequence $seque
}

/** @return array<string, ArbitraryInterface> */
private function interleavedAcquireAndReleaseTrackTheModelGenerators(): array
public static function interleavedAcquireAndReleaseTrackTheModelGenerators(): array
{
$max = 3;

Expand Down
2 changes: 1 addition & 1 deletion tests/SharedBulkheadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ public function waitBudgetNeverExceedsMaxWait(int $maxWaitMillis, int $pollMilli
}

/** @return array<string, ArbitraryInterface> */
private function waitBudgetNeverExceedsMaxWaitGenerators(): array
public static function waitBudgetNeverExceedsMaxWaitGenerators(): array
{
return [
'maxWaitMillis' => Gen::intBetween(0, 2000),
Expand Down
Loading