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 |



( run in 2.153 seconds using v1.01-cache-2.11-cpan-5c0b1e786e0 )