bear / async
Async/parallel resource execution library for BEAR.Sunday
Requires
- php: ^8.2
- aura/sql: ^5.0 || ^6.0
- bear/app-meta: ^1.6
- bear/package: ^1.14
- bear/resource: ^1.32
- ray/di: ^2.18
Requires (Dev)
- ext-pdo: *
- bamarni/composer-bin-plugin: ^1.4
- brlabrussia/parallel-stub: ^1.1
- phpunit/phpunit: ^11.0
Suggests
- ext-parallel: For parallel thread execution (requires ZTS PHP)
- ext-swoole: For Swoole coroutine support
This package is auto-updated.
Last update: 2026-08-29 13:27:02 UTC
README
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.
#[Embed(rel: 'profile', src: 'app://self/user/profile?id={user_id}')] #[Embed(rel: 'posts', src: 'app://self/user/posts?user_id={user_id}')] #[Embed(rel: 'notifications', src: 'app://self/notifications?user_id={user_id}')] public function onGet(int $user_id): static
These 3 embeds execute in parallel instead of sequentially.
Installation
composer require bear/async
Demo and Benchmarks
A runnable demo application lives in 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.
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:
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 for setup details and benchmark results 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. 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:
bin/async.php → vendor/bear/async/bootstrap.php → AppModule + runtime overlay
<?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:
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
AppModuleare 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
queryarray) and return values ($ro->bodyor the rendered string) must be scalar / null / nested arrays of those. Objects, closures, and resources will fail fast viaNonCopyablePayloadException. - 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.
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 PackageModule()); $this->install(new AsyncSwooleModule()); $this->install(new PdoPoolEnvModule( 'PDO_DSN', 'PDO_USER', 'PDO_PASSWORD', )); // Connection pool required } }
Pool sizing and hardening
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 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 |
|---|---|---|
| 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:
LinkCrawlerInterface→AsyncLinkCrawler: parallelizeslinkCrawl()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.EmbedInterceptorInterface→AsyncEmbedInterceptor: parallelizes#[Embed]resources. Each embed is wrapped in anAsyncRequest/DeferredRequestand registered withPendingRequests, 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).
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.
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.
Documentation
- Demo Guide - Docker-based demo for Sync, ext-parallel, and Swoole
- Benchmark Results - Measured cold CLI and steady-state HTTP results with adapter selection guidance
- Parallel Execution Architecture and Performance Analysis - Deep dive into architecture, AWS instance recommendations, and cost savings projections
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
(#[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):
composer require ray/media-query
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; } }
- SQL stays in
var/sql/*.sql(Ray.MediaQuery convention) - Domain objects are immutable snapshots; no
$results['user'][0] ?? nullplumbing at the call site AsyncEmbedInterceptorruns 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
PdoPoolModuleso each coroutine borrows a pooled PDO connection, and keep DB-using dependencies prototype-scoped (see the pool-sizing warning above)