bear/async

Async/parallel resource execution library for BEAR.Sunday

Maintainers

Package info

github.com/bearsunday/BEAR.Async

pkg:composer/bear/async

Transparency log

Statistics

Installs: 350

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.4.0 2026-08-28 16:40 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 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.

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:

  1. LinkCrawlerInterfaceAsyncLinkCrawler: 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. EmbedInterceptorInterfaceAsyncEmbedInterceptor: 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).

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

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] ?? 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)