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
16 changes: 16 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,25 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist

- name: Restore property regression corpus
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: build/property-db
key: property-db-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: property-db-

- name: Test with coverage
env:
PROPERTY_DB: ${{ github.workspace }}/build/property-db
run: composer test:coverage:ci

- name: Save property regression corpus
if: ${{ !cancelled() }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: build/property-db
key: property-db-${{ github.run_id }}-${{ github.run_attempt }}

- name: Mutation testing
run: composer mutation

Expand Down
43 changes: 17 additions & 26 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,37 +52,28 @@ make release-check
`ContextController` at the owner of a cancellable branch.
- Deadline observation is lazy. Observing a child whose effective deadline is
owned by an ancestor terminates that ancestor and its complete subtree.
- `withValue()` accepts only an object matching its class-string key.
- `withValue()` accepts only an object matching its class-string key, and the
derived context **shares its parent's state** (no controller of its own) —
never give it a fresh `ContextState`, or a long-lived parent retains one
child per `withValue()`.
- **A terminated state detaches from its parent** and drops its listeners and
children. `onTerminate()` returns an unsubscribe closure. Contexts that are
never terminated (an uncancelled `withCancel()`, an unobserved expired
deadline) stay attached by design — owners cancel their controller in
`finally`.
- **`withTimeout()` saturates**: a non-positive timeout is an immediate
deadline (`now`), an oversized one is `PHP_INT_MAX`; never compute
`now + (int) ($seconds * 1e9)` unguarded (float → int overflow is undefined).
- Deadlines are monotonic nanoseconds and meaningless in another process: to
cross a boundary, send `remainingMs()` and rebuild with
`withTimeout($ms / 1000)` on the other side.
- Code: `declare(strict_types=1)`, `final readonly class`, `#[\Override]`,
explicit types.
- **Validation patterns anchor with `\z`, never `$`.** In PCRE `$` also matches
immediately before a trailing newline, so `/^…$/` silently accepts one `\n` —
the value the whitelist was supposed to reject then travels on as if it were
clean. `/D` does the same job. Cover it with a data-provider case carrying
`"value\n"`, or the regression comes back unnoticed.
<!-- If this package ships yiisoft/db-migration migrations, keep these too;
delete otherwise. Full rationale: "Пакеты с миграциями" in the monorepo
AGENTS.md. -->
- **Migrations live in `src/Migration/` under the package namespace**, never as
a global class in `migrations/`: a class outside PSR-4 is not autoloadable, so
any DI definition mentioning it makes `Yiisoft\Di\Container` fatal at build
time in every request. Register with
`MigrationService::setSourceNamespaces()`, not `setSourcePaths()`.
- **The table name is a typed value object, never a scalar.**
`yiisoft/db-migration` builds migrations through `Injector::make()`, which
resolves arguments by name or by type and never reads a container definition
keyed by the migration's own class — a `string $table` has no type to resolve,
so `M...::class => ['__construct()' => ['table' => …]]` silently yields the
default. `config/di.php` builds the VO from params and passes it to the
migration *and* the runtime class, so the two cannot disagree; index names are
derived from it (PostgreSQL scopes index names per schema, not per table).
- **Test the migration through the real `Injector`**, with and without a
container binding, and assert the created column set plus each index's
columns — migrations under `src/` are mutated by infection, and
`ArrayItemRemoval` in `createTable`/`createIndex` escapes otherwise.
- Changing a migration's FQCN is a **major** and needs `UPGRADE.md`
(`UPDATE migration SET name = …` before the first `migrate:up`). `bc-check`
stays silent — the old class was outside PSR-4, so roave never saw it.
- `examples/` is part of the public contract: keep scripts runnable and update
`examples/README.md` when example usage changes.
- **CI workflows are SHA-pinned.** Every `uses:` in `.github/workflows/*.yml`
Expand All @@ -96,7 +87,7 @@ make release-check

## When you finish

- Update `README.md` (and `examples/` if usage changed); update `CHANGELOG.md`
when releasing.
- Update `README.md` **and `README.ru.md`** (both languages, same commit; and
`examples/` if usage changed); update `CHANGELOG.md` when releasing.
- Re-run `composer build`; if the change affects the public API or release
process, also run `make release-check`. Paste the output.
38 changes: 33 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
# Changelog

## 0.1.0 — 2026-10-10

- Clarified the per-message queue-worker recipe and cooperative deadline
semantics in the documentation.

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).

## 0.2.0 — 2026-10-11

- `Context::onTerminate()` returns an unsubscribe closure
(`\Closure(): void`) instead of `void`; calling it removes the listener,
and it is a no-op once the listener ran. Source-compatible: callers that
ignore the return value are unaffected (#1).
- A terminated context state detaches from its parent and releases its
listeners and children, so a long-lived parent no longer accumulates every
cancelled or expired child it derived. `withValue()` now shares its
parent's state instead of attaching a new child per call (#1).
- `withTimeout()` no longer overflows: a timeout beyond the representable
range saturates at a `PHP_INT_MAX` deadline, and a zero or negative timeout
yields an immediate deadline (the context is expired on first
observation) (#2).
- `ContextExpiredException` accepts an optional `$previous` throwable, so a
transport adapter can keep the underlying timeout as the cause.
- Documented propagating a budget across a process boundary: send
`remainingMs()`, rebuild with `withTimeout($ms / 1000)` on the receiving
side (#4).
- README/llms.txt placeholders replaced with the real API reference, Russian
README added, property tests for subtree cancellation and nearest-ancestor
value lookup, regression-corpus caching in CI (#3).

## 0.1.0 — 2026-10-10

- Initial release: `Context` (`background()`, `withCancel()`,
`withTimeout()`, `withDeadline()`, `withValue()`), `ContextController`,
monotonic `TimeSource`/`HrTimeSource`, `TerminationCause`,
`ContextException` with `ContextCancelledException` and
`ContextExpiredException`; lazy deadline observation that terminates the
owning ancestor's subtree; `onTerminate()` listeners with aggregated
listener failures; queue-worker recipe in the documentation.
130 changes: 94 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ Deadline, cancellation and request-scoped values for PHP
## Requirements

- PHP 8.3+
<!-- Add runtime dependencies here, e.g.:
- `yiisoft/db` ^2.0
-->
- No runtime dependencies

## Installation

Expand All @@ -27,27 +25,69 @@ composer require rasuvaeff/context

## Usage

<!-- Add usage examples here. For each public class, show:
- How to create/construct
- Key methods with examples
- Table of public API
-->
A `Context` is an immutable, read-only view passed down the call graph: a
deadline, a cancellation signal and request-scoped values. The owner of a
cancellable branch keeps the matching `ContextController`. Cancellation is
cooperative: code checks the context between steps; nothing is interrupted
mid-call.

### Create and derive

```php
use Rasuvaeff\Context\Context;

[$context, $controller] = Context::background()->withTimeout(3.0);
$context->assertActive();
$context->onTerminate(static function (): void {
$root = Context::background(); // never terminates by itself

[$request, $requestController] = $root->withTimeout(3.0); // seconds
[$step, $stepController] = $request->withCancel(); // cancellable branch
[$capped] = $request->withDeadline($absoluteNs); // monotonic hrtime() ns

$traced = $request->withValue(RequestId::class, new RequestId('r-42'));
$traced->value(RequestId::class)?->value; // 'r-42' (nearest ancestor wins)
```

A child deadline is never later than its parent's: `withTimeout()` and
`withDeadline()` take the earlier of the two. A zero or negative timeout is
an immediate deadline; an oversized one saturates at `PHP_INT_MAX` instead of
overflowing.

### Check

```php
$request->assertActive(); // throws ContextCancelledException / ContextExpiredException
$request->isTerminated(); // cancelled or expired
$request->isCancelled(); // explicit cancellation
$request->isExpired(); // deadline passed
$request->remainingMs(); // null without a deadline; >= 1.0 while active, 0.0 once expired
```

Deadlines use monotonic `hrtime()` and are observed lazily: the next
`assertActive()`, `remainingMs()`, `isTerminated()` or `isExpired()` call
discovers an expired deadline. If the deadline belongs to an ancestor, the
ancestor and its whole subtree are terminated. A controller cancellation
propagates to every descendant immediately.

### Cancel and clean up

```php
$unsubscribe = $request->onTerminate(static function (TerminationCause $cause): void {
// release a resource owned by this operation
});
$controller->cancel();

$unsubscribe(); // the resource was released early: drop the listener
$requestController->cancel($reason); // $reason becomes the exception's previous
```

For queue workers, create a context for each message and use the job's TTR as
the deadline. Check it between external calls and acknowledge only when the
job completes; an expired context should be retried or dead-lettered according
to the queue's policy:
`onTerminate()` runs the listener once when the context terminates
(immediately if it already has) and returns a closure that unregisters it.
A terminated context detaches from its parent and releases its listeners, so
a long-lived parent does not accumulate per-request children. Always cancel
the controller you own in `finally`.

### Queue workers

Create a context per message with the job's TTR as the deadline, check it
between external calls and acknowledge only when the job completes:

```php
[$jobContext, $jobController] = Context::background()->withTimeout($ttrSeconds);
Expand All @@ -64,36 +104,54 @@ try {
}
```

Deadlines use monotonic `hrtime()`. The core discovers an expired deadline on
the next `assertActive()`, `remainingMs()`, `isTerminated()` or `isExpired()`
call. If that deadline belongs to an ancestor, the ancestor and all its
descendants are terminated. A controller cancellation propagates immediately.
### Crossing a process boundary

Deadlines are monotonic nanoseconds of the current process and mean nothing
elsewhere. Send the remaining budget and rebuild it on the other side:

```php
// sender: an HTTP header or a queue message attribute
$headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs();

// receiver: never longer than its own limit
[$context] = Context::background()->withTimeout(min($limitSeconds, $headerMs / 1000));
```

Validate the incoming value (an integer in a bounded range) before trusting
it. `rasuvaeff/context-http` implements this for PSR-15/PSR-18.

### Testing

Inject a `TimeSource` to drive deadlines deterministically:

```php
$clock = new FakeTimeSource(); // your TimeSource returning nowNs()
[$context] = Context::background($clock)->withTimeout(1.0);
$clock->advanceNs(2_000_000_000);
$context->isExpired(); // true
```

### Public API

| Class | Description |
|---|---|
| `Context` | Immutable view with derivation, values, deadline and checks |
| `ContextController` | Owner handle for explicit cancellation |
| `TimeSource` | Monotonic clock seam for deterministic tests |
| `Context` | Immutable view: `background()`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `onTerminate()` |
| `ContextController` | Owner handle: `cancel(?Throwable $reason = null)` |
| `TimeSource` | Monotonic clock seam (`nowNs(): int`) for deterministic tests |
| `HrTimeSource` | Production `hrtime()` implementation |
| `TerminationCause` | Cancellation or deadline cause passed to listeners |
| `ContextException` | Base context exception |
| `ContextCancelledException` | Explicit cancellation detected by `assertActive()` |
| `ContextExpiredException` | Deadline expiration detected by `assertActive()` |
| `ContextException` | Base context exception (`RuntimeException`) |
| `ContextCancelledException` | Explicit cancellation detected by `assertActive()`; the reason is `previous` |
| `ContextExpiredException` | Deadline expiration detected by `assertActive()`; `deadlineNs()`, optional `previous` |

## Security

<!-- Describe what is safe and what requires caution.
For SQL/query packages:
- User values are bound as parameters (safe)
- Identifiers (table/column names) must be trusted/hard-coded
- Raw SQL only in trusted context
-->

Context values are request-scoped metadata, not a service locator. The package
does not interrupt a blocking syscall; transport adapters must map the
remaining budget or register an `onTerminate()` cleanup listener.
Context values are request-scoped metadata, not a service locator; keys are
class strings and a value must be an instance of its key. The package never
interrupts a blocking syscall: transport adapters must map the remaining
budget to their own timeouts or register an `onTerminate()` cleanup
listener. A budget received from another process is untrusted input —
validate and cap it before building a context from it.

## Examples

Expand Down
Loading
Loading