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 )