diff --git a/README.md b/README.md index 69d8288..16f7c0f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,34 @@ Deadline, cancellation and request-scoped values for PHP composer require rasuvaeff/context ``` +## Limitations + +The context **stops work; it does not interrupt it.** It guarantees two +things: code that checks the context does not *start* a step after the budget +is gone, and a caller does not *wait* past the budget when the transport maps +`remainingMs()` to its own timeout. Aborting a call that is already in flight +is possible only where the transport supports it: + +| Transport | Budget → timeout | Abort an in-flight call | +|---|---|---| +| Retries, pipelines, batches, your own loops | — | Yes: `assertActive()` between steps and attempts | +| Queue consumers | Message TTR → `withTimeout()` | Yes, in your own loop | +| HTTP via Guzzle | Per-request `timeout` = remaining budget ([`rasuvaeff/context-http`](https://github.com/rasuvaeff/context-http)) | No for a synchronous call — it returns at its own timeout | +| HTTP via a generic PSR-18 client | No: PSR-18 has no per-request options — configure the client | No | +| ClickHouse over HTTP | `max_execution_time` ([`rasuvaeff/clickhouse-toolkit`](https://github.com/rasuvaeff/clickhouse-toolkit)) | The server enforces `max_execution_time` | +| MySQL / PostgreSQL via PDO | No: PDO has no per-query timeout (connect only) | Only out of band (`KILL QUERY`, `pg_cancel_backend()`) — not provided | +| Redis | Only by lowering the client's read timeout | No for blocking commands (`BLPOP`) | + +Deadlines are **lazy**: there is no background timer. An expired deadline is +discovered by the next `assertActive()`, `remainingMs()`, `isTerminated()` or +`isExpired()`; if nothing observes the context, its `onTerminate()` listeners +never run. Check before every step, not once at the top. + +`cancel()` runs listeners synchronously in the code that calls it. A +single-threaded PHP request blocked inside a call cannot cancel itself at the +same time, so a listener helps only once control returns — after a transport +timeout, between steps, or under a fiber/event loop. + ## Usage A `Context` is an immutable, read-only view passed down the call graph: a diff --git a/README.ru.md b/README.ru.md index 31d7c05..05edd67 100644 --- a/README.ru.md +++ b/README.ru.md @@ -23,6 +23,34 @@ composer require rasuvaeff/context ``` +## Ограничения + +Контекст **останавливает работу, а не прерывает её.** Он гарантирует две +вещи: код, проверяющий контекст, не *начнёт* шаг после исчерпания бюджета, и +вызывающий не будет *ждать* дольше бюджета, если транспорт переводит +`remainingMs()` в собственный таймаут. Прервать уже идущий вызов можно только +там, где это умеет транспорт: + +| Транспорт | Бюджет → таймаут | Прервать идущий вызов | +|---|---|---| +| Ретраи, конвейеры, батчи, собственные циклы | — | Да: `assertActive()` между шагами и попытками | +| Потребители очередей | TTR сообщения → `withTimeout()` | Да, в собственном цикле | +| HTTP через Guzzle | Per-request `timeout` = остаток бюджета ([`rasuvaeff/context-http`](https://github.com/rasuvaeff/context-http)) | Нет для синхронного вызова — он вернётся по собственному таймауту | +| HTTP через произвольный PSR-18 клиент | Нет: у PSR-18 нет per-request опций — настройте клиент | Нет | +| ClickHouse по HTTP | `max_execution_time` ([`rasuvaeff/clickhouse-toolkit`](https://github.com/rasuvaeff/clickhouse-toolkit)) | Сервер сам соблюдает `max_execution_time` | +| MySQL / PostgreSQL через PDO | Нет: у PDO нет таймаута на запрос (только на подключение) | Только вне основного соединения (`KILL QUERY`, `pg_cancel_backend()`) — не реализовано | +| Redis | Только уменьшением read timeout клиента | Нет для блокирующих команд (`BLPOP`) | + +Дедлайны **ленивые**: фонового таймера нет. Истёкший дедлайн обнаруживает +следующий вызов `assertActive()`, `remainingMs()`, `isTerminated()` или +`isExpired()`; если контекст никто не проверяет, его `onTerminate()`-listener'ы +не запускаются никогда. Проверяйте перед каждым шагом, а не один раз в начале. + +`cancel()` запускает listener'ы синхронно, в вызывающем коде. +Однопоточный PHP-запрос, заблокированный внутри вызова, не может одновременно +отменить сам себя, поэтому listener полезен только после возврата управления — +по таймауту транспорта, между шагами или под fiber/event loop. + ## Использование `Context` — неизменяемое представление только для чтения, которое передаётся diff --git a/tests/ContextLifecycleTest.php b/tests/ContextLifecycleTest.php index 2805cd3..7c1f013 100644 --- a/tests/ContextLifecycleTest.php +++ b/tests/ContextLifecycleTest.php @@ -5,7 +5,9 @@ namespace Rasuvaeff\Context\Tests; use Rasuvaeff\Context\Context; +use Rasuvaeff\Context\ContextCancelledException; use Rasuvaeff\Context\ContextController; +use Rasuvaeff\Context\ContextException; use Rasuvaeff\Context\ContextExpiredException; use Rasuvaeff\Context\Internal\ContextState; use Rasuvaeff\Context\TerminationCause; @@ -26,6 +28,7 @@ #[Covers(ContextState::class)] #[Covers(TerminationCause::class)] #[Covers(ContextExpiredException::class)] +#[Covers(ContextCancelledException::class)] final class ContextLifecycleTest { public function cancelledChildrenAreDetachedFromTheParent(): void @@ -288,6 +291,170 @@ public static function aValueLookupFindsTheNearestAncestorExamples(): iterable yield 'only the root level' => [[true, false, false]]; } + /** + * @param non-empty-list $nodes [parent pick, own deadline in ms or 0 for none] + * @param list $operations [0 advance | 1 cancel | 2 late listener, argument] + */ + #[Property(runs: 300)] + public function terminationIsExclusiveStableAndNotifiesOnce(array $nodes, array $operations): void + { + $clock = new FakeTimeSource(); + $startNs = $clock->nowNs(); + $contexts = []; + $controllers = []; + $parents = []; + $calls = []; + + foreach ($nodes as $i => [$pick, $deadlineMs]) { + $parent = $i === 0 ? -1 : $pick % $i; + $base = $parent === -1 ? Context::background($clock) : $contexts[$parent]; + [$context, $controller] = $deadlineMs === 0 + ? $base->withCancel() + : $base->withDeadline($startNs + $deadlineMs * 1_000_000); + $calls[$i] = 0; + $context->onTerminate(static function () use (&$calls, $i): void { + $calls[$i]++; + }); + $contexts[] = $context; + $controllers[] = $controller; + $parents[] = $parent; + } + + $kinds = []; + $expiredDeadlines = []; + /** @var list $late */ + $late = []; + $cancelledAfterExpiry = false; + $this->assertTerminationInvariants($contexts, $parents, $calls, $late, $kinds, $expiredDeadlines); + + foreach ($operations as [$operation, $argument]) { + $node = $argument % \count($contexts); + + if ($operation === 0) { + $clock->advanceNs(($argument % 40) * 1_000_000); + } elseif ($operation === 1) { + $cancelledAfterExpiry = $cancelledAfterExpiry || $contexts[$node]->isExpired(); + $controllers[$node]->cancel(); + } else { + $terminal = $contexts[$node]->isTerminated(); + $index = \count($late); + $late[] = ['node' => $node, 'calls' => 0]; + $contexts[$node]->onTerminate(static function () use (&$late, $index): void { + $late[$index]['calls']++; + }); + + Assert::same($late[$index]['calls'], $terminal ? 1 : 0); + } + + $this->assertTerminationInvariants($contexts, $parents, $calls, $late, $kinds, $expiredDeadlines); + } + + Classify::cover(\in_array('expired', $kinds, strict: true), 'a deadline expired', 20); + Classify::cover(\in_array('cancelled', $kinds, strict: true), 'a node was cancelled', 20); + Classify::cover($cancelledAfterExpiry, 'cancelled after expiry', 5); + } + + /** @return array */ + public static function terminationIsExclusiveStableAndNotifiesOnceGenerators(): array + { + return [ + 'nodes' => Gen::nonEmptyArrayOf( + Gen::tuple( + Gen::intBetween(0, 1_000), + Gen::frequency([[1, Gen::constant(value: 0)], [2, Gen::intBetween(1, 100)]]), + ), + 8, + ), + 'operations' => Gen::arrayOf( + Gen::tuple( + Gen::frequency([ + [2, Gen::constant(value: 0)], + [1, Gen::constant(value: 1)], + [1, Gen::constant(value: 2)], + ]), + Gen::intBetween(0, 1_000), + ), + 0, + 12, + ), + ]; + } + + /** @return iterable, list}> */ + public static function terminationIsExclusiveStableAndNotifiesOnceExamples(): iterable + { + yield 'a passed deadline' => [[[0, 1]], [[0, 1]]]; + yield 'cancel after expiry is a no-op' => [[[0, 5]], [[0, 10], [1, 0]]]; + yield 'expiry after cancel is a no-op' => [[[0, 5]], [[1, 0], [0, 10]]]; + yield 'cancel twice' => [[[0, 0]], [[1, 0], [1, 0]]]; + yield 'a three-level cascade' => [[[0, 0], [0, 0], [1, 0]], [[1, 0]]]; + yield 'a late listener on a terminal context' => [[[0, 0]], [[1, 0], [2, 0]]]; + yield 'a late listener before termination' => [[[0, 5]], [[2, 0], [0, 10]]]; + } + + /** + * Observes every node and checks the termination laws: exactly one cause, + * never changing; assertActive() throws the matching exception with a + * memoized deadline; each listener has run once iff its node terminated; + * a terminated parent implies terminated children. The counters are + * taken by reference because observing a node runs its listeners. + * + * @param list $contexts + * @param list $parents + * @param array $calls + * @param list $late + * @param array $kinds + * @param array $expiredDeadlines + */ + private function assertTerminationInvariants( + array $contexts, + array $parents, + array &$calls, + array &$late, + array &$kinds, + array &$expiredDeadlines, + ): void { + foreach ($contexts as $i => $context) { + $terminated = $context->isTerminated(); + $cancelled = $context->isCancelled(); + $expired = $context->isExpired(); + + Assert::false($cancelled && $expired); + Assert::same($terminated, $cancelled || $expired); + Assert::same($calls[$i], $terminated ? 1 : 0); + + if (isset($kinds[$i])) { + Assert::true($terminated); + Assert::same($cancelled ? 'cancelled' : 'expired', $kinds[$i]); + } elseif ($terminated) { + $kinds[$i] = $cancelled ? 'cancelled' : 'expired'; + } + + $thrown = null; + + try { + $context->assertActive(); + } catch (ContextException $exception) { + $thrown = $exception; + } + + Assert::same($thrown instanceof ContextCancelledException, $cancelled); + Assert::same($thrown instanceof ContextExpiredException, $expired); + if ($thrown instanceof ContextExpiredException) { + $expiredDeadlines[$i] ??= $thrown->deadlineNs(); + Assert::same($thrown->deadlineNs(), $expiredDeadlines[$i]); + } + + if ($parents[$i] !== -1 && $contexts[$parents[$i]]->isTerminated()) { + Assert::true($terminated); + } + } + + foreach ($late as $listener) { + Assert::same($listener['calls'], $contexts[$listener['node']]->isTerminated() ? 1 : 0); + } + } + private function childCount(Context $context): int { return \count($this->stateProperty($context, 'children')); diff --git a/tests/ContextTest.php b/tests/ContextTest.php index d786a9f..41c7cee 100644 --- a/tests/ContextTest.php +++ b/tests/ContextTest.php @@ -10,10 +10,12 @@ use Rasuvaeff\Context\Tests\Support\FakeTimeSource; use Rasuvaeff\Context\Tests\Support\RequestId; use Rasuvaeff\PropertyTesting\ArbitraryInterface; +use Rasuvaeff\PropertyTesting\Classify; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\Property; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Expect; use Testo\Test; @@ -216,10 +218,18 @@ public function valuesAreNearestAndTyped(): void Assert::same($child->value(RequestId::class)?->value, 'child'); } - public function invalidTimeoutsAreRejected(): void + #[DataProvider('invalidTimeoutProvider')] + public function invalidTimeoutsAreRejected(float $seconds): void { Expect::exception(\InvalidArgumentException::class); - Context::background()->withTimeout(INF); + Context::background()->withTimeout($seconds); + } + + public static function invalidTimeoutProvider(): iterable + { + yield 'positive infinity' => [INF]; + yield 'negative infinity' => [-INF]; + yield 'not a number' => [NAN]; } public function equalChildDeadlineKeepsAncestorAsOwner(): void @@ -252,10 +262,13 @@ public function listenerFailureDoesNotStopCascade(): void }); Expect::exception(\RuntimeException::class); - $controller->cancel(); - Assert::true($childCalled); - Assert::true($child->isCancelled()); + try { + $controller->cancel(); + } finally { + Assert::true($childCalled); + Assert::true($child->isCancelled()); + } } #[Property(runs: 200)] @@ -297,4 +310,190 @@ public static function derivedDeadlineNeverExpandsParentGenerators(): array 'childMs' => Gen::intBetween(1, 2_000), ]; } + + /** + * @param non-empty-list $nodes [parent pick, own deadline in ms or 0 for none] + */ + #[Property(runs: 300)] + public function observingANodeTerminatesExactlyTheSubtreeOwningItsDeadline( + array $nodes, + int $advanceMs, + int $observePick, + ): void { + $clock = new FakeTimeSource(); + $startNs = $clock->nowNs(); + $contexts = []; + $parents = []; + $effectiveMs = []; + $owners = []; + $terminated = []; + + foreach ($nodes as $i => [$pick, $deadlineMs]) { + $parent = $i === 0 ? -1 : $pick % $i; + $base = $parent === -1 ? Context::background($clock) : $contexts[$parent]; + $inheritedMs = $effectiveMs[$parent] ?? null; + $inheritedOwner = $owners[$parent] ?? null; + + if ($deadlineMs === 0) { + [$context] = $base->withCancel(); + $effectiveMs[$i] = $inheritedMs; + $owners[$i] = $inheritedOwner; + } else { + [$context] = $base->withDeadline($startNs + $deadlineMs * 1_000_000); + $ownsDeadline = $inheritedMs === null || $deadlineMs < $inheritedMs; + $effectiveMs[$i] = $ownsDeadline ? $deadlineMs : $inheritedMs; + $owners[$i] = $ownsDeadline ? $i : $inheritedOwner; + } + + $context->onTerminate(static function () use (&$terminated, $i): void { + $terminated[] = $i; + }); + $contexts[] = $context; + $parents[] = $parent; + } + + $observed = $observePick % \count($contexts); + $clock->advanceNs($advanceMs * 1_000_000); + $contexts[$observed]->isTerminated(); + + $deadline = $effectiveMs[$observed]; + $owner = $owners[$observed]; + $expected = $deadline !== null && $owner !== null && $advanceMs >= $deadline + ? $this->subtree($parents, $owner) + : []; + sort($terminated); + + Assert::same($terminated, $expected); + + Classify::cover($expected !== [] && $owner === $observed, 'own deadline expired', 10); + Classify::cover($expected !== [] && $owner !== $observed, 'an ancestor deadline expired', 10); + Classify::cover($expected === [], 'nothing expired', 20); + } + + /** @return array */ + public static function observingANodeTerminatesExactlyTheSubtreeOwningItsDeadlineGenerators(): array + { + return [ + 'nodes' => Gen::nonEmptyArrayOf( + Gen::tuple( + Gen::intBetween(0, 1_000), + Gen::frequency([[1, Gen::constant(value: 0)], [2, Gen::intBetween(1, 100)]]), + ), + 10, + ), + 'advanceMs' => Gen::intBetween(0, 120), + 'observePick' => Gen::intBetween(0, 1_000), + ]; + } + + /** @return iterable, int, int}> */ + public static function observingANodeTerminatesExactlyTheSubtreeOwningItsDeadlineExamples(): iterable + { + yield 'own deadline leaves the parent active' => [[[0, 0], [0, 10]], 10, 1]; + yield 'an ancestor deadline reaches a sibling' => [[[0, 10], [0, 0], [0, 0]], 20, 2]; + yield 'an equal deadline belongs to the ancestor' => [[[0, 10], [0, 10]], 10, 1]; + yield 'one millisecond before the deadline' => [[[0, 10]], 9, 0]; + } + + /** + * @param non-empty-list $nodes [parent pick, has a throwing listener] + */ + #[Property(runs: 300)] + public function aThrowingListenerNeverStopsTheCascade(array $nodes, int $cancelPick): void + { + [$root] = Context::background()->withCancel(); + $contexts = []; + $controllers = []; + $parents = []; + $notified = []; + $throwing = []; + + foreach ($nodes as $i => [$pick, $throws]) { + $parent = $i === 0 ? -1 : $pick % $i; + [$context, $controller] = ($parent === -1 ? $root : $contexts[$parent])->withCancel(); + + if ($throws) { + // Registered first, so the recording listener after it proves isolation. + $context->onTerminate(static function () use ($i): void { + throw new \RuntimeException('listener ' . $i); + }); + $throwing[] = $i; + } + $context->onTerminate(static function () use (&$notified, $i): void { + $notified[] = $i; + }); + $contexts[] = $context; + $controllers[] = $controller; + $parents[] = $parent; + } + + $target = $cancelPick % \count($contexts); + $subtree = $this->subtree($parents, $target); + $failing = array_values(array_intersect($throwing, $subtree)); + $error = null; + + try { + $controllers[$target]->cancel(); + } catch (\RuntimeException $caught) { + $error = $caught; + } + sort($notified); + + Assert::same($notified, $subtree); + foreach ($contexts as $i => $context) { + Assert::same($context->isCancelled(), \in_array($i, $subtree, strict: true)); + } + Assert::same($error instanceof \RuntimeException, $failing !== []); + if (\count($failing) === 1) { + Assert::same($error?->getMessage(), 'listener ' . $failing[0]); + } + + Classify::cover(\count($failing) > 1, 'several listeners failed', 5); + Classify::cover(\count($failing) === 1, 'one listener failed', 10); + Classify::cover($failing === [], 'no listener failed', 20); + } + + /** @return array */ + public static function aThrowingListenerNeverStopsTheCascadeGenerators(): array + { + return [ + 'nodes' => Gen::nonEmptyArrayOf( + Gen::tuple( + Gen::intBetween(0, 1_000), + Gen::frequency([[3, Gen::constant(value: false)], [1, Gen::constant(value: true)]]), + ), + 10, + ), + 'cancelPick' => Gen::intBetween(0, 1_000), + ]; + } + + /** @return iterable, int}> */ + public static function aThrowingListenerNeverStopsTheCascadeExamples(): iterable + { + yield 'two failing listeners on the cancelled node' => [[[0, true], [0, true], [1, false]], 0]; + yield 'a failing child does not stop its sibling' => [[[0, false], [0, true], [0, false]], 0]; + yield 'a failure outside the subtree is not raised' => [[[0, false], [0, true], [0, false]], 2]; + } + + /** + * @param list $parents + * + * @return list + */ + private function subtree(array $parents, int $root): array + { + $nodes = []; + foreach (array_keys($parents) as $node) { + $ancestor = $node; + while ($ancestor !== -1 && $ancestor !== $root) { + $ancestor = $parents[$ancestor]; + } + if ($ancestor === $root) { + $nodes[] = $node; + } + } + + return $nodes; + } }