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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.

### Fixed

- Install `AsyncSwooleModule` before `PackageModule`; the documented reverse order lost the async bindings.
- Document installing `AsyncSwooleModule` from a `swoole` context module; the `AppModule` example lost the async bindings.
- `AsyncEmbedInterceptor` resolves `PendingRequests` per invocation, so Swoole coroutines no longer share one instance.
- `SwoolePendingRequestsProvider` throws `NotInCoroutineException` outside a coroutine.

Expand All @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file.
- Removed mysqli-based async batch execution: `Mysqli\MysqliBatchExecutor`, `Mysqli\SyncBatchExecutor`, `Mysqli\MysqliConnectionFactory`, `Mysqli\MysqliParamBinder`.
- Removed `Module\MysqliBatchModule`, `Module\MysqliEnvModule`, and `Exception\MysqliConnectionException`.
- Removed the `BEAR\Projection\` namespace (`QueryBatchCoordinator`, `QueryResourceObject`, `Exception\SqlFileNotFoundException`).
- Migration: split each SQL into its own `ResourceObject` and let `#[Embed]` parallelize them. Pair with Ray.MediaQuery's `#[DbQuery]` BDR pattern for the underlying Repository. See "SQL Resources with BDR + `#[Embed]`" in `README.md`.
- Migration: split each SQL into its own `ResourceObject` and let `#[Embed]` parallelize them. Pair with Ray.MediaQuery's `#[DbQuery]` BDR pattern for the underlying Repository. See [SQL resources with BDR + `#[Embed]`](https://bearsunday.github.io/manuals/1.0/en/async.html#sql-resources-with-bdr--embed) in the manual.
- Removed `AsyncInterface::isAvailable()`; a missing extension fails fast with `Exception\ExtensionNotLoadedException` from the owning module's `configure()` instead of falling back at runtime.
- Removed the dead `Async` facade, `AsyncLinker`, `Adapter\Linker`, and `Exception\PoolNotInitializedException`.
- `PendingRequests::getResult()` now takes the `AsyncRequest` instead of its URI.
Expand Down
329 changes: 22 additions & 307 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
# BEAR.Async

Async/parallel resource execution library for BEAR.Sunday

## Why BEAR.Async?

BEAR.Async preserves your resource code. You choose an async execution mode at the application boundary — no async/await, no Promise, no yield, no rewrites of existing `#[Embed]` graphs.
Parallel execution of `#[Embed]` resources for BEAR.Sunday.

```php
#[Embed(rel: 'profile', src: 'app://self/user/profile?id={user_id}')]
Expand All @@ -13,324 +9,43 @@ BEAR.Async preserves your resource code. You choose an async execution mode at t
public function onGet(int $user_id): static
```

These 3 embeds execute **in parallel** instead of sequentially.
These three embeds run in parallel. The resource code does not change; you pick
the execution mode at the application boundary.

## Installation

```bash
composer require bear/async
```

## Demo and Benchmarks

A runnable demo application lives in [`demo/`](demo/). It builds separate
Docker images for ext-parallel and ext-swoole, starts MySQL, seeds a dashboard
resource graph with 8 independent SQL-backed GET embeds, and exposes Sync,
ext-parallel, and Swoole entrypoints.

```bash
cd demo
docker compose up -d --wait parallel
docker compose exec parallel composer install
docker compose exec parallel composer app -- get 'app://self/dashboard?user_id=1'
docker compose exec parallel composer async -- get 'app://self/dashboard?user_id=1'
```

The demo also includes cold one-shot CLI benchmarks and steady-state HTTP
benchmarks with `wrk`:

```bash
docker compose exec parallel composer parallel-benchmark
docker compose exec parallel composer steady-state-parallel
docker compose up -d --wait swoole
docker compose exec swoole composer swoole-benchmark
docker compose exec swoole composer steady-state-swoole
```

See the [demo guide](demo/README.md) for setup details and
[benchmark results](docs/benchmark-results.md) for measured numbers and
adapter selection guidance.

## Execution Modes

### Parallel execution (ext-parallel)

Recommended for typical PHP-FPM / Apache web applications with embedded
resources — with one important caveat: the `parallel\Runtime` pool lives in
userland process state (inside the singleton `ParallelAsync` adapter), so
under classic PHP-FPM/Apache the pool is rebuilt for each request that needs
it, so the cold thread, autoload, and DI container cost is paid per request.
Steady-state, low-latency benefits require a resident process that keeps the
pool warm across requests — see
[`demo/bin/parallel-server.php`](demo/bin/parallel-server.php). `ParallelAsync`
also warms one worker synchronously before dispatching any task, so the
expensive cold DI container build happens once instead of once per pool
thread.

Add `bin/async.php` next to `bin/app.php`. It hands off to the library
bootstrap, which overlays the ext-parallel runtime on the normal AppModule:

```text
bin/async.php → vendor/bear/async/bootstrap.php → AppModule + runtime overlay
```

```php
<?php // bin/async.php

declare(strict_types=1);

require dirname(__DIR__) . '/autoload.php';

$bootstrap = dirname(__DIR__) . '/vendor/bear/async/bootstrap.php';
if (! file_exists($bootstrap)) {
throw new LogicException('"bear/async" is not installed.');
}

$defaultContext = PHP_SAPI === 'cli' ? 'cli-hal-api-app' : 'hal-api-app';
$context = getenv('APP_CONTEXT') ?: $defaultContext;

exit((require $bootstrap)(
$context,
'MyVendor\MyApp',
dirname(__DIR__),
$GLOBALS,
$_SERVER,
));
```

Do not install the parallel runtime in `AppModule` directly. The bootstrap
is the only supported install path so the same `AppModule` works under
`bin/app.php` (sync) and `bin/async.php` (parallel) unchanged.

To override the worker pool size (default = CPU cores), pass it as the
optional 6th argument:

```php
exit((require $bootstrap)($context, 'MyVendor\MyApp', dirname(__DIR__), $GLOBALS, $_SERVER, 8));
```

#### Constraints

Worker Runtimes are separate threads with their own zend memory. Embedded
resources executed via this module must satisfy:

- **Pure / idempotent** — same input must yield same output. Workers do not
share request-scoped state (no shared session, no shared logger context).
- **Each worker holds its own DI container** — singletons in `AppModule`
are not the same instance across threads. Avoid relying on
"same-instance" guarantees inside parallelizable embeds.
- **Payload copyability** — arguments passed across the thread boundary
(currently the `query` array) and return values (`$ro->body` or the
rendered string) must be scalar / null / nested arrays of those.
Objects, closures, and resources will fail fast via
`NonCopyablePayloadException`.
- **Interceptors that mutate request-local state will misbehave** across
worker boundaries. Keep cross-cutting concerns idempotent or scope them
outside the parallelized embed graph.

#### Scope

This module targets PHP-FPM / Apache style request-per-process runtimes.
For long-running Swoole HTTP Server use `AsyncSwooleModule` instead — its
coroutines share the same process memory and do not have the cross-thread
copyability constraint.

### Swoole execution (ext-swoole)

For applications already running on Swoole HTTP Server with high concurrency
requirements.

ext-parallel uses worker runtimes, so it is selected by a separate entrypoint.
ext-swoole runs inside one server process, so it is installed as an application
module.

```php
use BEAR\Async\Module\AsyncSwooleModule;
use BEAR\Async\Module\PdoPoolEnvModule;
use Ray\Di\AbstractModule;

class AppModule extends AbstractModule
{
protected function configure(): void
{
$this->install(new AsyncSwooleModule());
$this->install(new PackageModule());
$this->install(new PdoPoolEnvModule(
'PDO_DSN',
'PDO_USER',
'PDO_PASSWORD',
)); // Connection pool required
}
}
```

#### Pool sizing and hardening
## Execution modes

`PdoPoolModule`/`RedisPoolModule` (and their env-driven counterparts) accept
a `borrowTimeout` (default 5.0s, bound as `pdo_pool_borrow_timeout` /
`redis_pool_borrow_timeout`). Waiting for a free slot in an exhausted pool
fails fast with `PoolTimeoutException` instead of blocking forever. Every
checkout is pinged first (`SELECT 1` for PDO, `PING` for Redis); a dead
connection (e.g. after a server restart or idle timeout) is discarded and
retried once, so the pool self-heals instead of handing out connections
that will fail on first use — if the retry is also dead, it throws
`StalePooledConnectionException` with the driver error attached as the
previous exception. Redis connections are cached per coroutine the same
way PDO connections are, so repeated injections within one coroutine reuse
the same checkout instead of exhausting the pool.

Size the pool to roughly `embed_count * request_concurrency` so that one
dashboard-style request with several embeds does not starve concurrent
requests; see [benchmark results](docs/benchmark-results.md) for measured
throughput and connection counts at different pool sizes.

**WARNING**: never inject `ExtendedPdoInterface` (or `PDO`/`Redis`) into a
`SINGLETON`-scoped class. The provider hands out a connection borrowed for
*one* coroutine, and a singleton would capture that single connection for
the lifetime of the process — defeating the pool and causing cross-coroutine
corruption. Keep DB-using dependencies prototype-scoped (the default), or
inject a provider (`ProviderInterface<ExtendedPdoInterface>`) and call
`get()` per use instead of caching the connection.

## Which execution mode should I use?

| Use Case | Entrypoint | Runtime setup |
| Server | Requires | Application change |
|---|---|---|
| PHP-FPM / Apache with embedded resources | `bin/async.php` | library bootstrap overlay |
| Swoole HTTP Server | `bin/swoole.php` | `AsyncSwooleModule` (in AppModule) |

### Comparison

| | ext-parallel | ext-swoole |
|---|---|---|
| Concurrency | Thread pool (CPU cores) | Coroutines (thousands) |
| Memory | Separate per worker | Shared (process-level) |
| PDO handling | Isolated per thread | Connection pool required |
| Server | PHP-FPM / Apache | Swoole HTTP Server |
| Setup | Add `bin/async.php` | Add `bin/swoole.php` |

## How It Works

BEAR.Async replaces two bear/resource bindings to parallelize resource
requests:

1. **`LinkCrawlerInterface` → `AsyncLinkCrawler`**: parallelizes `linkCrawl()`
graphs level by level. Requests at each level are batched, deduplicated by
URI+query hash, and executed together; results are cached and distributed
to all requesters.
2. **`EmbedInterceptorInterface` → `AsyncEmbedInterceptor`**: parallelizes
`#[Embed]` resources. Each embed is wrapped in an `AsyncRequest`/
`DeferredRequest` and registered with `PendingRequests`, which collects all
pending embeds and dispatches them as one batch the first time any of them
is rendered (そうめん流し方式).

Both paths hand the batched work to the configured `AsyncInterface` adapter
(`ParallelAsync`, `SwooleAsync`, or `SyncAsync`).

```text
Level 1: Users → all user requests execute in parallel
Level 2: Posts for each user → all post requests execute in parallel
Level 3: Comments for each post → all comment requests execute in parallel
```

### Failure semantics

One failing embed or crawl task does not kill the Swoole worker process or
abort its sibling tasks — every task/coroutine runs to completion, and only
then is the first exception encountered rethrown to the caller, producing a
500 for that request alone. `ParallelAsync` follows the same rule for
ext-parallel: every dispatched worker `Future` is joined before the first
`Throwable` is rethrown.
| PHP-FPM / Apache | ZTS PHP + ext-parallel | add `bin/async.php` (see [manual](https://bearsunday.github.io/manuals/1.0/en/async.html#parallel-execution-ext-parallel)) |
| Swoole HTTP Server | ext-swoole / ext-openswoole | add a `swoole` context module that installs `AsyncSwooleModule`; boot on the compiled injector (see [manual](https://bearsunday.github.io/manuals/1.0/en/async.html#swoole-execution-ext-swoole)) |

There is no silent fallback: if the required extension (`ext-parallel` or
`ext-swoole`) is not loaded, the owning module throws
`ExtensionNotLoadedException` at `configure()` time rather than degrading
quietly to `SyncAsync`.
Without either extension the same code runs sequentially. A missing extension
fails at `configure()` with `ExtensionNotLoadedException`; there is no silent
fallback.

## Documentation

- [Demo Guide](demo/README.md) - Docker-based demo for Sync, ext-parallel, and Swoole
- [Benchmark Results](docs/benchmark-results.md) - Measured cold CLI and steady-state HTTP results with adapter selection guidance
- [Parallel Execution Architecture and Performance Analysis](https://bearsunday.github.io/BEAR.Async/parallel-execution-architecture.html) - Deep dive into architecture, AWS instance recommendations, and cost savings projections
- [Manual: Parallel Resource Execution](https://bearsunday.github.io/manuals/1.0/en/async.html) — setup, constraints, pool sizing, failure semantics, how it works ([日本語](https://bearsunday.github.io/manuals/1.0/ja/async.html))
- [Demo guide](demo/README.md) — Docker-based demo for sequential, ext-parallel, and Swoole
- [Benchmark results](docs/benchmark-results.md) — cold CLI and steady-state HTTP measurements
- [Parallel execution architecture](https://bearsunday.github.io/BEAR.Async/parallel-execution-architecture.html)

## Requirements

PHP 8.2+ for the library itself. Each execution mode adds its own runtime
requirement:

| Mode | Requires | Application change |
|---|---|---|
| ext-parallel | ZTS PHP + ext-parallel | add `bin/async.php` |
| ext-swoole | ext-swoole | install `AsyncSwooleModule`, use `bin/swoole.php` |

## SQL Resources with BDR + `#[Embed]`

To run multiple SQL queries for one page, split each query into its own
`ResourceObject` and let `#[Embed]` parallelize them via `AsyncEmbedInterceptor`.
Combined with Ray.MediaQuery's [BDR pattern](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN.md)
(`#[DbQuery]` interface + factory + immutable domain object), SQL stays in
`var/sql/*.sql`, the call site reads as plain objects, and the resource graph
itself is what gets parallelized.

Recipe dependency (not bundled with BEAR.Async):
## Demo

```bash
composer require ray/media-query
cd demo
docker compose up -d --wait parallel
docker compose exec parallel composer install
docker compose exec parallel composer app -- get 'app://self/dashboard?user_id=1'
docker compose exec parallel composer async -- get 'app://self/dashboard?user_id=1'
```

```php
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;
use Ray\MediaQuery\Annotation\DbQuery;

// Domain object — immutable snapshot
final class UserAccount
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {
}
}

// Repository — SQL lives in var/sql/user.sql.
// UserFactory hydrates the row into UserAccount; see BDR_PATTERN.md for factory details.
interface UserRepositoryInterface
{
#[DbQuery('user', factory: UserFactory::class)]
public function getUser(int $id): UserAccount;
}

// Resource — one resource per SQL
class User extends ResourceObject
{
public function __construct(private UserRepositoryInterface $repo)
{
}

public function onGet(int $id): static
{
$this->body = ['user' => $this->repo->getUser($id)];

return $this;
}
}

// Aggregate — Embeds parallelize automatically via AsyncEmbedInterceptor
class UserDashboard extends ResourceObject
{
#[Embed(rel: 'user', src: 'app://self/user{?id}')]
#[Embed(rel: 'posts', src: 'app://self/user/posts{?id}')]
#[Embed(rel: 'comments', src: 'app://self/user/comments{?id}')]
public function onGet(int $id): static
{
return $this;
}
}
```
## Requirements

- SQL stays in `var/sql/*.sql` (Ray.MediaQuery convention)
- Domain objects are immutable snapshots; no `$results['user'][0] ?? null` plumbing at the call site
- `AsyncEmbedInterceptor` runs the three embeds in parallel via ext-parallel (PHP-FPM / Apache) or Swoole coroutines
- Without ext-parallel and without Swoole the same code runs synchronously per request, which is the expected sequential behavior.
- For Swoole, install `PdoPoolModule` so each coroutine borrows a pooled PDO connection, and keep DB-using dependencies prototype-scoped (see the pool-sizing warning above)
PHP 8.2+. Each execution mode adds its own extension requirement (table above).
Loading
Loading