DBIO
view release on metacpan or search on metacpan
docs/adr/0030-async-explicit-per-connection-mode.md view on Meta::CPAN
# ADR 0030 â Async is an explicit, per-connection mode over one core orchestration
- Status: accepted
- Date: 2026-06-30
- Tags: async, future, storage, drivers, pluggable, per-instance-mode, supersedes-0014, replaces-0028, replaces-0029
## Context
ADRs 0014, 0028 and 0029 grew the async story in three layers that, taken
together, became hard to reason about:
- **0014** made `*_async` universal: every storage answers it, degrading on sync
storage to an immediately-resolved `DBIO::Future::Immediate`. The degrade is *silent*
and *always on*.
- **0028** made a real non-blocking driver a pluggable **embedded backend**,
selected by an `async_backend` **class-data** accessor declared per driver.
- **0029** added a universal **auto-fallback**: `async_fallback`, defaulting to
`DBIO::Forked::Storage`, so any driver gains fork-based async automatically once
`dbio-forked` is installed.
Three problems surfaced once the real drivers (`dbio-async` via Future::IO,
`dbio-postgresql-ev`/`dbio-mysql-ev` via EV) existed side by side:
1. **Async is not a transparent feature â it is an execution model.** A
connection-based backend (Future::IO, EV) requires the application to run an
event loop; `forked` requires none (fork + blocking `waitpid`); the degrade
requires none and isn't async at all. These are three different runtime
contracts with the application. Whichever one answers is a property of the
*application's architecture*, not of the set of installed packages. Under 0029,
`cpanm DBIO::Forked` silently turns `select_async` from "immediate" into "forks
a process" â spooky action at a distance, exactly the footgun an execution-model
choice must not be.
2. **Three selection mechanisms for one decision.** `async_backend` (class
default, native), `async_fallback` (auto chain, forked) and the silent degrade
overlap and interact. A schema is *either* sync *or* a single class-chosen async
flavour; you cannot hold a forked schema and an EV schema of the same class at
once.
3. **The Model-B orchestration is written three times.** `dbio-async`,
`dbio-postgresql-ev` and `dbio-mysql-ev` each re-implement the same
loop-agnostic machinery â connect-info normalisation, pool wiring, `_run_crud`
and its runners, `txn_do_async`, the pipeline scaffold, the TransactionContext.
The only genuinely different part is the **query transport** (`_query_async`:
Future::IO vs EV::Pg vs EV::MariaDB) plus a little DB-specific SQL. `dbio-async`
already generalised that machinery behind seam hooks, but no driver inherits it.
The shaping requirement: async must be a thing the application **declares
explicitly, per connection**, and the same schema class must be runnable in
several async modes at once (one forked instance, one Future::IO instance, one EV
instance â all alive together). And the shared orchestration must live in exactly
one place.
## Decision
### 1. Async is an explicit, per-connection mode
The async backend is chosen **at `connect` time, per instance**, by a named
string and is fixed for the life of that instance:
```perl
my $sync = MyApp::Schema->connect($dsn, $u, $p); # sync
my $forked = MyApp::Schema->connect($dsn, $u, $p, { async => 'forked' });
my $futio = MyApp::Schema->connect($dsn, $u, $p, { async => 'future_io' });
my $pgev = MyApp::Schema->connect($dsn, $u, $p, { async => 'ev' });
```
All four instances live in parallel, each with its own connection/pool. The
**schema class carries no async declaration** â the choice is a runtime/deployment
concern, where it belongs. There is no runtime mode-switch on a live instance
(thin value, fat complexity around in-flight transactions and open pools); to run
another mode you `connect` another instance.
### 2. Modes are named, registered against the driver
A small **mode registry** maps a name to a backend class. Generic modes register
themselves globally; native modes are registered by the driver:
| mode | provider | scope | loop |
|---|---|---|---|
| `forked` | `dbio-forked` (`DBIO::Forked::Storage`) | any driver | none |
| `future_io` | `dbio-async`: abstract `DBIO::Async::Storage` base + a per-driver `DBIO::X::Storage::Async` adapter (convention-resolved) | any driver that exposes its own async binding (`DBD::Pg`, `DBD::mysql`) | yes |
| `ev` | the per-driver EV add-on, **driver-resolved** | one DB | yes |
| `immediate` | core (`DBIO::Future::Immediate`) | any | none |
`ev` is a *logical* name resolved through the driver: on a PostgreSQL schema it
binds `DBIO::PostgreSQL::EV::Storage`, on MySQL `DBIO::MySQL::EV::Storage`. The
same `async => 'ev'` works on both schemas; each driver declares what its `ev`
maps to. `immediate` is the former silent degrade, now a deliberately-named mode
(the mock `DBIO::Test::Storage` defaults to it so mock tests run with no loop).
**`future_io` resolves its transport class by convention, not by a generic
registry entry** (refined post-acceptance, karr #65). `dbio-async` ships only the
abstract `DBIO::Async::Storage` base â the loop-agnostic Future::IO machinery with
croaking DB-seams â and registers *no* generic `future_io` mode. Because a storage
is always driver-specific, the core resolver derives each driver's transport
adapter deterministically from the concrete storage class: `ref($storage) .
'::Async'`, i.e. `DBIO::PostgreSQL::Storage` â `DBIO::PostgreSQL::Storage::Async`,
loaded on demand, with an early, clear croak (`driver ... does not support
future_io â no ...::Async`) when a driver ships none. An explicit per-driver
`register_async_mode('future_io' => ...)` still wins as an override; a *generic*
base-class registration is deliberately ignored (so merely loading `dbio-async`
can no longer claim `future_io` for every driver and then die deep in the abstract
base's `_submit_query` seam). The *mode* stays explicit; only the *class* is
discovered â this is not the banned 0029 mode auto-fallback.
**Second refinement (karr #67): the convention walks the storage MRO.** An
extension component that sets `storage_type` to a storage *subclass*
(`DBIO::PostgreSQL::Age::Storage`, `DBIO::PostgreSQL::PostGIS::Storage`) left
the single-candidate rule with no adapter â `...Age::Storage::Async` does not
exist â so loading an extension killed `future_io` outright, plain CRUD
( run in 0.631 second using v1.01-cache-2.11-cpan-14f38c9f855 )