diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fcd425..f8a7048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fcbd8c6..a506998 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru.md b/README.ru.md index acbd679..5d49918 100644 --- a/README.ru.md +++ b/README.ru.md @@ -22,6 +22,7 @@ > Используете AI-ассистента? В [llms.txt](llms.txt) — компактный API-справочник, > которым можно поделиться с моделью. +> Проекты с Composer-плагином [llm/skills](https://github.com/roxblnfk/skills) дополнительно получают agent-скилл этого пакета в `.agents/skills/` автоматически при установке. ## Требования diff --git a/composer.json b/composer.json index a53f1f3..55836ab 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -66,6 +66,11 @@ }, "sort-packages": true }, + "extra": { + "skills": { + "source": "resources/skills" + } + }, "scripts": { "bc-check": "roave-backward-compatibility-check", "bench": "testo --suite=Benchmarks -vv", diff --git a/resources/skills/rasuvaeff-bulkhead/SKILL.md b/resources/skills/rasuvaeff-bulkhead/SKILL.md new file mode 100644 index 0000000..272a959 --- /dev/null +++ b/resources/skills/rasuvaeff-bulkhead/SKILL.md @@ -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. diff --git a/tests/InMemoryBulkheadStoreTest.php b/tests/InMemoryBulkheadStoreTest.php index 5f36328..facc344 100644 --- a/tests/InMemoryBulkheadStoreTest.php +++ b/tests/InMemoryBulkheadStoreTest.php @@ -113,7 +113,7 @@ public function neverGrantsMoreThanMaxConcurrent(int $max, int $attempts): void } /** @return array */ - private function neverGrantsMoreThanMaxConcurrentGenerators(): array + public static function neverGrantsMoreThanMaxConcurrentGenerators(): array { return [ 'max' => Gen::intBetween(1, 20), @@ -143,7 +143,7 @@ public function releasingEveryTokenRestoresFullCapacity(int $max): void } /** @return array */ - private function releasingEveryTokenRestoresFullCapacityGenerators(): array + public static function releasingEveryTokenRestoresFullCapacityGenerators(): array { return ['max' => Gen::intBetween(1, 30)]; } @@ -168,7 +168,7 @@ public function interleavedAcquireAndReleaseTrackTheModel(CommandSequence $seque } /** @return array */ - private function interleavedAcquireAndReleaseTrackTheModelGenerators(): array + public static function interleavedAcquireAndReleaseTrackTheModelGenerators(): array { $max = 3; diff --git a/tests/SharedBulkheadTest.php b/tests/SharedBulkheadTest.php index b634133..07cedbc 100644 --- a/tests/SharedBulkheadTest.php +++ b/tests/SharedBulkheadTest.php @@ -456,7 +456,7 @@ public function waitBudgetNeverExceedsMaxWait(int $maxWaitMillis, int $pollMilli } /** @return array */ - private function waitBudgetNeverExceedsMaxWaitGenerators(): array + public static function waitBudgetNeverExceedsMaxWaitGenerators(): array { return [ 'maxWaitMillis' => Gen::intBetween(0, 2000),