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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` — неизменяемое представление только для чтения, которое передаётся
Expand Down
167 changes: 167 additions & 0 deletions tests/ContextLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +28,7 @@
#[Covers(ContextState::class)]
#[Covers(TerminationCause::class)]
#[Covers(ContextExpiredException::class)]
#[Covers(ContextCancelledException::class)]
final class ContextLifecycleTest
{
public function cancelledChildrenAreDetachedFromTheParent(): void
Expand Down Expand Up @@ -288,6 +291,170 @@ public static function aValueLookupFindsTheNearestAncestorExamples(): iterable
yield 'only the root level' => [[true, false, false]];
}

/**
* @param non-empty-list<array{int, int}> $nodes [parent pick, own deadline in ms or 0 for none]
* @param list<array{int, int}> $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<array{node: int, calls: int}> $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<string, ArbitraryInterface> */
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<string, array{non-empty-list<array{int, int}>, list<array{int, int}>}> */
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<Context> $contexts
* @param list<int> $parents
* @param array<int, int> $calls
* @param list<array{node: int, calls: int}> $late
* @param array<int, string> $kinds
* @param array<int, int> $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'));
Expand Down
Loading
Loading