NAME

DBIO::Storage::Async - Base class for async storage implementations

VERSION

version 0.900004

SYNOPSIS

# Async is chosen per connection, not by naming a storage class:
my $schema = MyApp::Schema->connect(
    $dsn, $user, $pass,
    { async => 'future_io' },
);

# Async queries return Futures
$schema->resultset('Artist')->all_async->then(sub {
    my @artists = @_;
    say $_->name for @artists;
});

See t/test/09_async.t for a runnable example.

DESCRIPTION

Concrete, loop-agnostic base class for async DBIO storage drivers. Extends DBIO::Storage with async-specific infrastructure: connection pooling, transaction pinning, and Future-based query execution.

The Model-B orchestration (ADR 0030 §4) lives here: connect-info normalisation, the shared CRUD builder (_run_crud) with its pooled and pinned runners, INSERT returned-columns mapping (ADR 0031 §3), "txn_do_async" bracketing through the generic DBIO::Storage::Async::TransactionContext, the "pipeline" scaffold, and the self-healing "LISTEN / NOTIFY" machinery (ADR 0056). Everything DB- and loop-specific is factored out behind transport seam hooks a backend overrides -- how to submit an async query ("_query_async" / "_query_async_pinned"), the INSERT returned-columns suffix ("_post_insert_sql"), the connection pool ("pool"), and the Future implementation ("future_class"). Placeholder/SQL shaping is not among them: "_transform_sql" is transport-internal, called by a transport's own query seam and never by this orchestration (ADR 0032 §6). A backend therefore collapses to transport-only: it overrides the seams, not the orchestration.

Async is an explicit, per-connection mode (ADR 0030): the backend class is chosen at connect time by a named string and fixed for the life of that instance, so the same schema class runs sync and several async modes side by side. Each mode is registered against the driver ("register_async_mode" in DBIO::Storage::DBI) and resolves to a concrete subclass of this class:

  • future_io -- DBIO::Async::Storage (dist dbio-async) is the abstract, loop-agnostic Future::IO base; each driver supplies a concrete adapter DBIO::X::Storage::Async that drives its own non-blocking DBD binding. The adapter is resolved by convention off the concrete storage class (ref($storage) . '::Async', e.g. DBIO::PostgreSQL::Storage -> DBIO::PostgreSQL::Storage::Async); loading dbio-async registers no generic future_io mode, and a driver without such an adapter croaks early. The event loop is chosen by installing a Future::IO::Impl::* adapter, not by picking a different distribution: IO::Poll is the built-in default (no event loop), and IO::Async, AnyEvent, Mojo, UV or Glib take over when the matching Future::IO::Impl::* module is installed.

  • ev -- the per-driver EV add-on, driver-resolved: DBIO::PostgreSQL::EV::Storage (dist dbio-postgresql-ev, over EV::Pg) or DBIO::MySQL::EV::Storage (dist dbio-mysql-ev, over EV::MariaDB). One database per add-on, a native event-loop-bound client.

  • forked -- DBIO::Forked::Storage (dist dbio-forked): the ordinary sync driver run inside a fork(), no event loop. Universal.

  • immediate -- DBIO::Future::Immediate (core): the explicit, synchronous, immediately-resolved shim (the mock DBIO::Test::Storage defaults to it so mock tests run with no loop).

A requested mode that is not installed/registered croaks loudly; *_async on a sync instance (no mode chosen) croaks rather than degrading silently.

ATTRIBUTES

on_listen_error

$storage->on_listen_error(sub {
  my ($storage, $error) = @_;
  ...
});

Coderef called with the storage and the error text for every error the listen machinery meets: a lost or dead listen connection, a failed connect or reconnect attempt, a failed subscribe or unsubscribe, a dying channel callback, a failure to close the old connection, and the non-fatal errors a transport reports through "_listen_error". Where a Future was handed out for the same operation it carries the error too; the hook is the one place that sees all of them, which is what a daemon's log wants. Without a hook the error is warned -- the machinery never drops an error silently. A hook that dies is itself reported with warn.

on_listen_reconnect

$storage->on_listen_reconnect(sub {
  my ($storage) = @_;
  ...
});

Coderef called with the storage each time the listen connection has been rebuilt -- after self-healing or a forced "listen_reconnect" -- once every registered channel is subscribed again and watching is re-armed. Not called for the first connection. Notifications sent while the connection was down are lost (NOTIFY is not durable); this hook is where a consumer re-reads whatever state it would otherwise have learned from them. A hook that dies is reported through "on_listen_error".

listen_backoff_initial

listen_backoff_max

$storage->listen_backoff_initial(0.5);   # this instance
My::Transport->listen_backoff_max(60);   # a transport's default

Bounds of the reconnect backoff, in seconds: attempt n waits min(listen_backoff_initial * 2 ** (n - 1), listen_backoff_max). Defaults 1 and 30. Inherited class data, so a transport class can set its own defaults and an instance can override them.

METHODS

new

my $storage = $class->new($schema);

Construct an async storage bound to $schema. The schema reference is held weakly (the schema owns the storage, not the other way round).

future_class

Transport seam. Must be overridden by a backend to return the event-loop-specific Future class (e.g. 'Future' for Future.pm).

pool

Transport seam. Returns the connection pool object. Must be overridden by a backend.

A transport typically builds its pool here on first use, in the slot "disconnect" empties. So calling it is for whatever begins work -- a query, a transaction, a pipeline, a deploy -- and what begins work keeps the pool it got. What ends work talks to that pool, the one its connection came from, and never calls this again: after a "disconnect" that pool is shut down, where a release is a no-op, while a second call here would build a new pool on the disconnected storage and release into it a connection it never held. "txn_do_async", "pipeline" and "deploy_async" keep to this, and a transport's own "_query_async" must too -- see the pattern there.

_is_access_broker_connect_info

if ($self->_is_access_broker_connect_info($info)) { ... }

True when the supplied connect info is a broker-style invocation: a single-element arrayref whose sole member is a blessed DBIO::AccessBroker instance. The DBI side (DBIO::Storage::DBI::AccessBroker) also takes connect options beside the broker. Its embedded async backend is handed the broker alone, unless those options hold settings the backend applies itself and its transport declares broker_connect_options ("transport_capabilities"); then it gets [ $broker, \%options ], which "connect_info" takes through "_access_broker_call_options". This predicate stays single-element, so a transport that overrides connect_info never takes that form for the bare broker form by accident.

set_access_broker

$storage->set_access_broker($broker);
$storage->set_access_broker($broker, $mode);

Attach $broker ("set_access_broker" in DBIO::Storage, which refuses anything that is no DBIO::AccessBroker before a thing is changed) -- the one place an async storage gets a broker. Attaching is a new configuration, so everything the connect info before left is forgotten first: the stored conninfo, which must not stand beside a broker, and the pool size, the options and the connect options beside a broker, which would otherwise be those of the connect info before, standing beside the new broker. Then the per-spawn provider is installed ("_conninfo_provider").

A pool that is already built is handed the provider as well ("set_conninfo_provider" in DBIO::Storage::PoolBase), so its next connection is opened from this broker, whether it was built through a broker before or from static conninfo. Open connections and running transactions stay as they are.

A broker attached by calling this directly is therefore attached in full. Before, only the broker slot was set: no provider, and the conninfo of a static connection still stored, from which a transport went on to build its pool.

clear_access_broker

$storage->clear_access_broker;

Detach the broker ("clear_access_broker" in DBIO::Storage) -- the one place an async storage loses it. What came with the broker goes with it: the pool size and the options read from its answer, and the connect options given beside it. No conninfo is stored while a broker is attached, so the storage is left with nothing to connect from. A storage with no broker attached is not touched, as on the sync side ("clear_access_broker" in DBIO::Storage::DBI::AccessBroker).

The provider is left where it is, and refuses from now on ("_conninfo_provider"): the next pool connection fails -- as a failed Future, "acquire" in DBIO::Storage::PoolBase never throws -- saying that no broker is attached, and so does a listen connection opened through the provider. Connections that are open stay open, as the sync storage keeps its handle. "_clear_access_broker" forgets the provider as well.

_setup_access_broker

$self->_setup_access_broker($info->[0]);

Attach the broker in write mode: "set_access_broker", under the name a driver's connect_info calls when "_is_access_broker_connect_info" matched. Because attaching forgets what the connect info before left, a driver that calls this before it reads the broker's answer leaves nothing of that connect info behind when the reading throws.

_clear_access_broker

$self->_clear_access_broker;

Detach any broker ("clear_access_broker") and forget everything else a connect info left: the provider, the stored conninfo, the pool size, the options and the connect options beside a broker. The storage is then where one never given connect info is, so a subsequent non-broker connect uses the static conninfo path with nothing of the connect info before in it. Call from a driver's connect_info on the non-broker branch, before the new connect info is read.

_conninfo_provider

The per-spawn credential coderef installed by "set_access_broker", or undef on a storage that has no broker and never had one since its last connect info. Hand this to the pool as its conninfo_provider so every NEW pool connection is built from fresh credentials (see _spawn_connection in DBIO::Storage::PoolBase).

On each invocation the coderef calls the driver's "_async_broker_conninfo" to obtain freshly-refreshed, storage-native connect info for one new connection, from the broker that is attached at that moment.

With no broker attached it croaks, saying so, and asks the driver nothing. That happens after "clear_access_broker", which leaves the provider on the storage, and in a pool built with the provider, which keeps it whatever is done to the storage. Before, the refusal was each transport's to make, in "_async_broker_conninfo"; one that did not was handed whatever "_current_async_connect_info" answers without a broker, and opened the connection from no conninfo at all.

The coderef holds this storage weakly. The storage keeps the coderef and hands it to its pool; held strongly, the storage would hold itself, and a backend dropped with a broker attached would never be freed, nor its pool shut down and the connections in it closed. Called once its storage is gone, the coderef croaks.

_async_broker_conninfo

sub _async_broker_conninfo {
  my ($self, $mode) = @_;
  ...
  return $conninfo;  # one fresh, storage-native conninfo value
}

Required driver seam hook when a broker is in use: return one fresh storage-native connect-info value for a single new pool connection, in the shape the pool's _transform_conninfo expects (e.g. the libpq parameter hashref for the PostgreSQL pool). Invoked once per pool spawn by the "_conninfo_provider" coderef. Defaults to croaking; a driver that consumes the broker must override it.

Build it from "_current_async_connect_info" or from "current_access_broker_connect_info" in DBIO::Storage: both refuse a broker that answers nothing, so the connection is never opened with the conninfo "connect_info" stored instead.

connect_info

$storage->connect_info([ \%conninfo, \%opts ]);

Set connection parameters. Handles both broker-style and direct connect info, normalising each through "_normalize_conninfo" (a DB-specific seam, identity by default) and _normalize_async_connect_info (pool-size / opts extraction). Returns the stored raw connect info.

Before the DB-specific "_normalize_conninfo" seam runs, the DBIO-private connect attributes (the async mode selector, quote_char/name_sep/ quote_names, storage options incl. cursor_class, and ignore_version) are stripped out centrally (_strip_private_connect_attrs), so the seam and ultimately DBI->connect only ever see real DBD attributes. This mirrors the sync path's _normalize_connect_info in DBIO::Storage::DBI and both consume one shared name list on DBIO::Storage, so a strict DBD (e.g. DBD::MariaDB) never receives an unrecognised private attribute.

Broker-style info is [ $broker ], or [ $broker, \%options ] for a transport that declares broker_connect_options: the options are stripped the same way, a pool_size among them sizes the pool, and the DBD attributes left are kept for the transport ("_access_broker_call_options", "_access_broker_call_attributes"). A pool_size in the broker's answer sizes the pool too; given both ways, the two must be the same ("_access_broker_pool_size"). No conninfo is kept from the answer: every connection asks the broker again, through "_conninfo_provider". An answer that is neither a hash nor an array throws, naming the broker class and the shape of the answer, never a value of it.

Every call is a full reconfiguration (ADR 0050), also one that throws. Before anything of the new connect info is read, everything the call before left is forgotten: its broker and provider, its stored conninfo, its pool size, its options, what stood beside its broker, and the connect info as it was given. Reading can throw in many places -- a driver's "_normalize_conninfo", the refusal of options beside a broker, the broker itself, an answer of nothing or of no usable shape, two pool sizes that disagree -- and none of them leaves a value of the call before for a later connection. A call that throws before it reaches a broker leaves the storage where one never given connect info is. One that throws once the new broker is attached leaves that broker attached, in full: its provider, and what stood beside it in this call, so a connection opened afterwards asks this broker and is not opened without those options. Before, the conninfo of the call before was dropped but its pool size and options stayed beside the new broker, and a call that threw early left the call before in place as a whole.

_access_broker_call_options

my $beside = $storage->_access_broker_call_options([ $broker, \%options ]);
# { pool_size => 3, attributes => { pg_enable_utf8 => 1 } }

The connect options an embedding sync storage hands this backend beside the broker ("connect_info" in DBIO::Storage::DBI, ADR 0030 addendum), split into pool_size and the DBD attributes; undef for any other form. "connect_info" calls it after the DBIO-private keys are stripped centrally (_strip_private_connect_attrs), so async, the storage and SQL maker options and ignore_version are gone by then: the owning storage applies those.

Taking the options is a promise a transport makes by declaring broker_connect_options ("transport_capabilities"): pool_size sizes its pool, which "connect_info" does here, and the transport merges the DBD attributes ("_access_broker_call_attributes") into every connection it opens, as it treats the same keys in DBI-form connect info -- as far as its client library can express them. An attribute the client cannot express (the ev transports have no DBD, so none of the DBD attributes reaches it) is not applied to the pooled connections; it stays readable through "_access_broker_call_attributes" only. A transport that does not declare the capability would drop everything, so any non-empty options throw.

_access_broker_call_attributes

my $attrs = $storage->_access_broker_call_attributes;

The DBD attributes the connect options beside the broker carried ("_access_broker_call_options"), or an empty hashref. A transport declaring broker_connect_options merges them into the attributes of every connection it opens from the broker's answer, with $self->_merge_access_broker_settings($answer_attrs, $self->_access_broker_call_attributes) -- the one rule the sync storage applies too: a setting given both there and in the broker's answer must have the same value in both, or it throws.

_access_broker_pool_size

my $pool_size = $storage->_access_broker_pool_size($answer, $beside);

The pool size a broker-backed backend is given, or undef when it is given none and the pool keeps its default. $answer is the broker's answer as _current_async_connect_info returns it, $beside what "_access_broker_call_options" made of the options beside the broker, if any.

The answer gives a pool size as its own pool_size key when it is a hash of connection parameters, and among its attributes when it is DBI-form -- [ $dsn, $user, $password, \%attrs ], as DBIO::AccessBroker::Vault answers with pool_size in its dbi_attrs. The owning sync storage keeps that attribute out of its own DBI->connect once the connection has an async mode; here it sizes the pool. Given both in the answer and beside the broker, the two must be the same value, or this throws naming pool_size -- the rule every setting beside a broker follows (_merge_access_broker_settings on DBIO::Storage, ADR 0030 addendum).

"connect_info" sizes the pool with it; a transport that overrides "connect_info" calls it to size its pool the same way.

_core_read_capabilities

DBIO::Storage::Async->_core_read_capabilities;

The names in "transport_capabilities" that core reads itself and layers do not: currently broker_connect_options. The layer gate in DBIO::Storage::DBI refuses a layer whose required_transport_capabilities names one, on every transport. This is the one place the list is kept.

_takes_broker_connect_options

$transport_class->_takes_broker_connect_options;

True when the transport declares broker_connect_options in "transport_capabilities". Asked by _async_storage in DBIO::Storage::DBI before it hands the transport [ $broker, \%options ], and by "_access_broker_call_options".

_current_async_connect_info

my $info = $storage->_current_async_connect_info($mode);

The connect info a new connection is opened with, for _normalize_async_connect_info to split: with a broker attached, the broker's answer as it stands now -- asked again on every call, a hash of connection parameters paired with an empty options hash -- and otherwise the static conninfo/options pair "connect_info" stored. "connect_info" asks it, and so does a transport building each pool connection's conninfo in "_async_broker_conninfo".

With a broker attached, only its current answer will do. A broker that answers nothing -- undef, an empty hash or array, a false value -- throws, naming the broker class and the shape of the answer, never a value. Taking the stored conninfo in its place would open the connection with the credentials of an earlier answer, which the broker may just have rotated out, or of the storage's previous connection, or with none. The refusal is "current_access_broker_connect_info" in DBIO::Storage's, the one place every storage asks the broker. A broker that throws is left to throw.

_normalize_conninfo

my $info = $storage->_normalize_conninfo($info);

DB-specific connect-info conversion seam, applied before broker detection. Defaults to an identity pass-through; a driver whose connect info needs reshaping (e.g. DSN string to libpq hashref) overrides it.

async_option_names

package DBIO::MyTransport::Storage;
use base 'DBIO::Storage::Async';

sub async_option_names { qw( statement_timeout max_in_flight ) }

Class method. The names of the connect options this backend takes in addition to the core's, as a list; the default is the empty list. It is asked of the backend class of the connection's async mode when the owning storage reads the connect info, before any backend object exists and without connecting, so it answers from the class alone. Each name given is then

  • kept out of the owning storage's DBI->connect and out of the connect info the transport's reshape seam ("_normalize_conninfo") sees,

  • delivered to the backend, in the hash beside the connection, in a hash-form connection ({ dsn => ..., statement_timeout => 5 }), in the attribute hash of the DBI form, and in the options beside an AccessBroker ([ $broker, { statement_timeout => 5 } ], with no broker_connect_options to declare),

  • and readable from "async_option" under its name, the value as the caller gave it. An option given as undef counts as not given.

The backend validates its own names; core validates the core's. A transport that overrides "connect_info" keeps this by calling _strip_private_connect_attrs on the raw connect info first, as core's connect_info does.

declared_async_option_names

my @names = DBIO::MyTransport::Storage->declared_async_option_names;

Every async option the backend takes: the core's followed by its "async_option_names", each once.

async_option

my $timeout = $storage->async_option('statement_timeout');

The value the connection was given for the async option $name, or undef when it was given none. The options are those of "connect_info" as it was called last: a new connect_info starts from none. pool_size is read by the transport from the conninfo (_normalize_async_connect_info) and is not answered here.

_delay

my $f = $storage->_delay($seconds);

Transport seam: a Future that resolves after $seconds, from the event loop and never synchronously. It is the timer of acquire_timeout ("new" in DBIO::Storage::PoolBase) and of txn_timeout ("txn_do_async"). The default asks "_listen_delay", which every transport of the family implements for its reconnect backoff, so a transport that listens has a timer already; a transport with another timer overrides this one. Core cancels the Future when the wait it timed is over before the time is, if the Future can cancel; a Future that cannot be cancelled just fires with nothing left to do.

A transport with neither seam fails every acquire that has to wait, naming _listen_delay, unless it is given acquire_timeout => 0.

_owner_storage

$async->_owner_storage($sync_storage);   # setter
my $sync = $async->_owner_storage;       # getter

The sync DBIO::Storage::DBI instance that built this embedded async backend (set by _async_storage in DBIO::Storage::DBI). Held weakly -- the sync storage owns the async backend (it caches it in _async_storage_obj), not the reverse, so a strong ref here would be a cycle. This back-reference is what lets the pool resolve connect_call_* methods on the class that actually defines them.

_effective_owner_storage

my $owner = $async->_effective_owner_storage;

The storage that prepares SELECT arguments (_prepare_select_args_async) and anchors async-context ownership (DBIO::Storage::Async::TransactionContext/DBIO::Storage::Async::PipelineContext) for this backend.

When an embedding sync storage wired itself as the owner (_async_storage in DBIO::Storage::DBI, the { async => $mode } connect path), that owner is returned unchanged. But a backend used as a primary storage -- constructed directly at the driver level with no embedding sync storage, as the native ev backends are (DBIO::PostgreSQL::EV::Storage->new(undef) plus connect_info) -- has no wired owner and could otherwise neither prepare a SELECT nor prove context ownership (ADR 0051). In that case the backend provisions and owns a private sync sibling (_build_self_owner_storage).

_setup_pool_connection

$async->_setup_pool_connection($conn);

Run the owning sync storage's on_connect_call / on_connect_do against the freshly spawned pool connection $conn. Invoked centrally from _spawn_connection in DBIO::Storage::PoolBase. Resolves the actions (and any connect_call_* methods) on "_owner_storage" but executes each emitted statement against $conn via "_run_pool_connect_statement". Each statement is traced like the sync path traces a connect action: "_debug_query" with the text in its fingerprint form (a secret shows as ***[sha256:<8 hex>]) from level 1 on, query_end after the statement succeeded, nothing at level 0; the row queries of a connect_call_* (dbio_rows) are not traced. The trace sits in the runner core hands the owner, so a backend that overrides "_run_pool_connect_statement" gets it too. A no-op when no owner is wired (e.g. a standalone pool).

_teardown_pool_connection

$async->_teardown_pool_connection($conn);

Symmetric counterpart of "_setup_pool_connection": run the owning sync storage's on_disconnect_call / on_disconnect_do against $conn while it is still live. Invoked from "shutdown" in DBIO::Storage::PoolBase before the connection is closed.

_reset_pool_connection

sub _reset_pool_connection {
  my ($self, $conn) = @_;
  return $self->_query_async_pinned($conn, 'DISCARD ALL')->then(sub {
    return $self->_replay_connect_actions($conn);    # the transport's own
  });
}

Transport seam, asked by "release" in DBIO::Storage::PoolBase when a borrow ends (a statement, a transaction after COMMIT or ROLLBACK, a pipeline). Returns a Future that resolves when $conn is a connection like a freshly spawned one: every session setting of the borrower gone, and the connect actions (on_connect_do, on_connect_call, the connection's schema) in effect again. A Future that fails -- or does not resolve within reset_timeout seconds (default 10, 0 for no limit) -- retires the connection, and the next borrower gets a fresh one. Until it resolves the connection counts against the pool size and no one is given it.

The default fails: a transport without a reset of its own gets a fresh connection for every borrow. A transport whose server can reset a session overrides this seam.

_run_pool_connect_statement

$async->_run_pool_connect_statement($conn, $sql, $attrs, @bind);

Runner seam: execute one connection-action statement synchronously (a blocking do) on the pool connection $conn. The default handles the two DBD-based connection shapes -- a bare do-capable DBI handle, and the future_io wrapper { dbh => $dbh } -- and croaks on anything else. A native backend (e.g. an EV::Pg client) whose connection is neither overrides this method to drive its own synchronous execution.

The reserved key dbio_rows in $attrs asks for the rows of the statement: the runner then returns an array reference of rows (array references, as selectall_arrayref without a slice returns them), read with $dbh->selectall_arrayref($sql, undef, @bind) on the default shapes. The key is not handed to DBI. Without it the runner executes the statement and returns nothing. A backend that overrides this method returns the rows when dbio_rows is set, and nothing otherwise.

sql_maker

Returns the memoised SQLMaker instance, built from "sql_maker_class" and "_sql_maker_args".

sql_maker_class

Transport seam. The DBIO::SQLMaker subclass this backend generates SQL with. Must be overridden.

_sql_maker_args

Constructor args for "sql_maker_class". Defaults to an empty list; a backend overrides it to set e.g. quote_char / name_sep.

transport_capabilities

my @caps = $backend_class->transport_capabilities;

Class method. The list of named capabilities this transport provides -- the formal expression of "as far as the transport allows". Async storage extension layers declare what they need with required_transport_capabilities, and _async_storage in DBIO::Storage::DBI refuses to compose a layer onto a transport that is missing a required capability (it croaks naming the gap rather than silently dropping the feature). The base transport declares none; a concrete transport overrides this to advertise what it supports (e.g. on_connect_replay, listen, notify, copy, pipeline).

broker_connect_options is read by core rather than by layers (see "_core_read_capabilities"): an async layer that lists it in required_transport_capabilities is refused on every transport. A transport declaring it takes the connect options an embedding sync storage was given beside an DBIO::AccessBroker -- [ $broker, \%options ] -- and applies them to every connection it opens: pool_size to its pool (done by "connect_info"), the DBD attributes ("_access_broker_call_attributes") as it treats the same keys in DBI-form connect info, to the extent its client library can express them. A transport whose client is no DBD (the ev transports) cannot: those attributes never reach the client library and stay readable through "_access_broker_call_attributes" only. Without the capability, a broker-backed connection whose options carry DBD attributes or pool_size refuses this transport (_async_storage in DBIO::Storage::DBI, ADR 0030 addendum).

An async layer that declares no required_transport_capabilities at all is not unchecked: it is taken to require the default query seam "_query_async", because that is what a layer wraps. A transport that does not override the abstract "_query_async" -- one that does not own the query path, such as a backend overriding the six *_async entry points directly -- therefore refuses such a layer too, rather than composing it into doing nothing. A layer that genuinely needs nothing from the transport says so with an explicit empty required_transport_capabilities; only silence is read as needing the seam.

select_async

my $future = $storage->select_async($source, $select, $where, $attrs);

Execute a SELECT asynchronously on a freshly-acquired pooled connection. Returns a "future_class" that resolves with the raw result rows (arrayrefs, matching the sync cursor shape).

select_single_async

Like "select_async" but resolves with only the first row (or undef).

insert_async

my $future = $storage->insert_async($source, \%vals);

Resolves with the returned-columns hashref -- the supplied insert data overlaid with the columns the database populated (autoinc primary key plus any retrieve-on-insert columns), exactly the shape sync "insert" returns (ADR 0031 §3). This is what the core create_async / DBIO::Row->insert_async fold back into the new result object. (Unlike "select_async" and "select_single_async", which resolve raw row arrayrefs matching the sync cursor shape.)

A column marked auto_nextval => 1 that has no value in \%vals is refused with an exception, before any statement is built: the synchronous insert fetches the next sequence value for it, the asynchronous one does not, and sending the INSERT without the column would leave it NULL or end in a NOT NULL error from the server. Give the column a value (literal SQL such as \"nextval('seq')" counts), or insert synchronously.

update_async

my $future = $storage->update_async($source, \%vals, \%where);

Resolves with the affected-row count (a plain scalar) -- exactly what sync "update" returns, and what "update_async" in DBIO::Row relies on to detect a zero-row ("row not found") or multi-row ("updated more than one row") result exactly like sync _update_body does. This is a distinct resolution shape from "select_async"/"select_single_async" (raw row arrayrefs) and "insert_async" (a returned-columns hashref): update/delete have no row data to speak of, only a count. Transport authors: your "_query_async" implementation must resolve this count as its sole value for update/delete SQL -- see "_query_async"'s own POD.

delete_async

my $future = $storage->delete_async($source, \%where);

Resolves with the affected-row count, the same contract as "update_async" above.

_run_crud

my $future = $storage->_run_crud($op, $runner, @args);

The shared CRUD builder every *_async CRUD method routes through. $op is select, select_single, insert, update or delete; @args are that method's arguments; $runner is a coderef taking ($sql, \@bind, \@bind_attrs) and returning a Future of the transport's result.

It builds the SQL with "sql_maker" -- a SELECT is prepared through the owning sync storage's _select_args first, so validation, join pruning and the complex-prefetch rewrite match the sync cursor -- hands it to $runner, and shapes what the runner resolves to per op (ADR 0031 §3): select the raw row arrayrefs, sliced by offset/rows under software_limit; select_single the first row or undef; insert the returned-columns hashref sync insert returns ("_post_insert_sql" appended to the SQL, the row mapped by _insert_returned_columns); update and delete the affected-row count. An unknown $op croaks.

Each rendering of an insert, update or delete gets a flat copy of the options hash of the call, so a driver SQL maker that consumes keys of it (on_conflict, insert_or) leaves the options of the caller as given, and the rendering that resolves the bind attributes sees the same statement as the one on the wire.

The SQL reaches the runner as raw "sql_maker" output with ? placeholders; any dialect shaping is the query seam's own (ADR 0032).

The binds reach the runner as bare values. For a select the values of the bind attribute (placeholders in a literal from, select or where) stand in front of the binds of the SQL maker, in the order of the sync path. The sync convention hands a literal bind -- a subquery from as_query -- over as a pair [ \%attrs => $value ]; the builder takes the pair apart by the rule of the sync path ("_resolve_bindattrs" in DBIO::Storage::DBI) before the runner sees it: a two-element arrayref with a HASH or undef in front is a pair, and the value is its second element. A plain bind value of exactly that form is therefore read as a pair; any other shape is passed as it is.

The third argument of the runner, \@bind_attrs, is the bind attributes of the statement: an arrayref of attribute hashes parallel to \@bind, resolved by the rule of the sync path (dbic_colname, sqlt_datatype, and redact for an encode_column column), so the trace shows what the sync trace shows. They carry no value. Only a trace at level 2 or more reads them, so only then are they built, and undef stands in when they are not (or do not line up with the binds).

Two runners exist, so both paths share this one builder: the pooled runner behind the public *_async methods runs each statement through "_query_async" on a connection of its own; _run_crud_pinned($op, $conn, @args) runs it through "_query_async_pinned" on $conn and does not release it -- the entry point of DBIO::Storage::Async::TransactionContext and DBIO::Storage::Async::PipelineContext. See also "What _run_crud builds for you" in DBIO::Manual::AsyncStorage.

_debug_query

my $end = $storage->_debug_query($sql, $bind, $bind_attrs);
...
$end->() if $end;

The one trace rule of every async transport (ADR 0025). The level comes from debug ("_trace_level_of" in DBIO::Storage): 0 traces nothing and returns undef; 1 traces the statement alone; from 2 on the bind values follow. Each value is run through the redact_bind_value hook of the owning sync storage ("_owner_storage"; the class-level hook of DBIO::Storage::DBI without one) as $hook->($column_name, $value, $attrs) and the result is cut to 32 characters, with ... marking the cut; undef is traced as NULL, as the sync storage does ("redact_bind_value" in DBIO::Storage::DBI).

$bind_attrs is optional: an arrayref of bind attribute hashes parallel to $bind, as "_query_async" receives it. The column name is the dbic_colname of an attribute hash; a statement without attributes -- a raw one such as BEGIN -- gives the hook undef for name and attributes. The attributes carry no value and are not kept.

The statements of the connect and disconnect actions of a pool connection ("_setup_pool_connection") are traced through this method in the fingerprint form of their text (mask_statement_fingerprint), so a secret shows as ***[sha256:<8 hex>]; their row queries (dbio_rows) are not traced.

query_start goes to the debugobj of the owner (this storage's own without one). The return value is a coderef that calls query_end for the same statement; a transport that times its statements calls it when the statement has settled, one that does not ignores it.

_query_async

my $future = $storage->_query_async($sql, $bind, $bind_attrs);

Transport seam. Execute a query on a freshly-acquired pooled connection, releasing it once the Future is ready. Must be overridden by a backend.

For select/select_single/insert SQL, returns a "future_class" of the raw result rows (row arrayrefs for select, RETURNING row(s) for insert -- see "insert_async"). For update/delete SQL, there is no row data: this seam must instead resolve with the affected-row count as its sole value (see "update_async"/"delete_async") -- typically the same scalar a synchronous $dbh->do($sql, ..., @$bind) (or equivalent execute-and- count call) would return, not a fetched cursor result. A transport that naively tries to "fetch rows" for a plain UPDATE/DELETE with no RETURNING clause will find none, and resolving an empty list here silently breaks "update_async" in DBIO::Row's row-count check (it inspects the scalar to detect a vanished row: 0 throws "row not found", >1 throws "updated more than one row"). "delete_async" in DBIO::Row resolves the same count for interface uniformity but does not currently inspect it -- matching synchronous delete, which is a no-op on an already-absent row -- so a transport must still resolve the count scalar (not an empty list) for both ops. (See this dist's PR body/Changes for the async-parity-helpers work that first exercised this seam for update/delete; tracked as a cross-repo driver ticket, same pattern as the "insert_async" returned-columns shape in ADR 0031 §3).

Bind-value contract: $bind is an arrayref of bare bind values, one per ?, never of [ \%attrs => $value ] pairs. Core unpacks a pair (what a subquery's as_query carries) before it reaches the seam, by the rule of the sync path's _resolve_bindattrs: a two-element arrayref with a HASH or undef in front is read as a pair. A bind value of exactly that form is therefore taken as a pair too; a transport hands each element to the wire as it is and does not look inside.

Bind attributes: $bind_attrs is optional: an arrayref of bind attribute hashes parallel to $bind (one per bind; dbic_colname names the column), or undef. A statement core did not build -- BEGIN, COMMIT, ROLLBACK, DISCARD ALL, a raw call -- has none. The attributes are for "_debug_query", which a transport calls with all three arguments; they carry no value, and the bind-lifetime rule below applies to $bind alone.

Placeholder-dialect contract (ADR 0032): the $sql handed to this seam is always raw DBIO::SQLMaker output -- the sql_maker dialect with ? placeholders. A transport that needs a different placeholder syntax (e.g. $1/$N for PostgreSQL) MUST apply that shaping internally, in its own implementation of this seam, via "_transform_sql" or an equivalent. The seam contract is ?-in; dialect-out is the transport's private business. Core does not pre-shape the SQL for the transport.

Bind lifetime -- build no callback inside the then closure (ADR 0044): the connection is not in hand until "acquire" in DBIO::Storage::PoolBase resolves, so this seam's body lives inside a ->then(sub { ... }) -- which makes it a closure over $bind. Any anonymous sub created inside that closure (the completion callback, the on_ready that returns the connection to the pool) keeps a strong CvOUTSIDE pointer to it and therefore keeps its whole pad alive, $bind included, even though the callback never mentions $bind. The bind arrayref then stays reachable for the entire flight of the query.

That is not the contract. A bind arrayref's life ends when the statement is dispatched to the transport: by the time query_params / execute has returned, the values are in the transport's own wire buffer, and nothing on the Perl side may keep the arrayref reachable afterwards. So build every callback the transport will hold in a named sub that takes what it needs as arguments -- a named sub's pad is not a closure pad, so the callback cloned inside it captures only what it references:

# WRONG -- the on_ready sub pins the then closure's pad, and $bind with it
my $pool = $self->pool;
return $pool->acquire->then(sub {
  my $conn = shift;
  my $lease = $pool->_lease($conn);
  return $self->_await_query_result($conn, $sql, $bind)
    ->on_ready(sub { $pool->release($conn, $lease) });
});

# RIGHT -- built in a named sub; the then closure is freed once it has
# fired, and $bind goes with it
sub _release_on_ready {
  my ($self, $conn, $pool, $lease) = @_;
  return sub { $pool->release($conn, $lease) };
}

sub _query_async {
  my ($self, $sql, $bind) = @_;
  my $pool = $self->pool;
  return $self->_hold_derived($pool->acquire->then(sub {
    my $conn = shift;
    my $lease = $pool->_lease($conn);
    my $f = $self->_seam_future(_await_query_result => $conn, $sql, $bind);
    $f->on_ready($self->_release_on_ready($conn, $pool, $lease));
    return $f;
  }));
}

The release goes to $pool, the pool the connection was acquired from, handed to the named sub along with the connection -- not to $self->pool asked again when the query settles. "pool" builds lazily, and a query that settles after its storage was disconnected would build a new pool there, only to release into it a connection that pool never held. The same goes for everything else a settled query tells a pool, a dead connection ("_mark_connection_dead" in DBIO::Storage::PoolBase) included.

The release carries the lease of its borrow, taken with "_lease" in DBIO::Storage::PoolBase right after the acquire resolved. A release that arrives after the connection was handed to another caller -- a callback that fires twice, a cancel racing a settle -- is then told from its current holder's and is a silent no-op. Without a lease the pool cannot tell, and the second release is taken as the holder's.

Dispatching through "_seam_future" is part of the pattern. The release hangs off the query Future, and that Future exists only once dispatch has returned. A transport whose prepare or query croaks synchronously -- another operation is in progress, not connected -- never gets that far, so without it the connection stays checked out, and a pool of N is wedged for good after N such calls. As a failed Future the die takes the same on_ready release as every other outcome, and the caller sees the dispatch's own error. A dispatch that returns no Future at all fails the same way, with an error naming the seam.

The then closure still closes over $bind -- it must, to issue the query -- but it is released the moment it has fired, so the retention window is "until the acquire resolves", not "the flight of the query". A transport that genuinely needs the values after dispatch (tracing, retry-on-reconnect) takes its own copy and says so; it does not keep the caller's arrayref alive by accident, and it does not mutate it either -- the array belongs to the caller.

The Future derived from the wait on the pool holds itself: what $pool->acquire->then(sub { ... }) returns is the Future this seam hands its caller, and it is a sequence of the acquire Future, which holds it only weakly. It is kept alive until it is ready -- done, failed or cancelled -- by handing it to _hold_derived, which retains it when the Future class has retain and lets go of it once it is ready. A caller who holds the Future sees no difference; one who does not -- a statement sent in void context, one dropped inside a pipeline batch -- still gets the body run and the connection back in the pool when it is free. The rule holds for every seam that waits on the pool and hands the Future derived from that wait to a caller: "pipeline", the pooled branch of "deploy_async" and a pooled DDL run follow it in core.

_seam_future

my $f = $self->_seam_future(_await_query_result => $conn, $sql, $bind);

Call the Future-returning seam $method with @args and return its Future -- or a failed one, so a caller can always hang the connection's release off the result. A seam that dies becomes a Future failed with its error. One that returns no Future at all -- undef, a plain value -- becomes a Future failed with an error naming the backend class and the seam. The guard it replaces, eval { ... } // $fc->fail($@), failed that case with the empty $@ of an eval that did not die: an empty error under DBIO::Test::Future, and under Future, which refuses a false exception, a die in the caller that skipped the release.

A named method rather than a closure, so it keeps none of the caller's variables alive past the call (ADR 0044).

_query_async_pinned

my $future = $storage->_query_async_pinned($conn, $sql, $bind, $bind_attrs);

Transport seam. Like "_query_async" but runs on the supplied pinned connection and does not release it -- used for queries inside a pinned transaction. Must be overridden by a backend.

The same placeholder-dialect contract and per-op resolution-shape contract (raw rows for select/insert, affected-row count for update/delete) as "_query_async" apply here too: $sql arrives as raw sql_maker output with ? placeholders, and any dialect shaping is the transport's own internal responsibility.

The bind-value contract of "_query_async" (bare values, a pair already taken apart) and its optional $bind_attrs (parallel to $bind, no value, none for a raw statement) apply here too, and so does the bind-lifetime rule (ADR 0044), unchanged: the attributes carry no value, so the rule concerns $bind alone. Build no callback inside a closure that captures $bind. This seam is handed a connection rather than acquiring one, so its body need not be a then closure at all -- but the moment it wraps one, the same trap is back.

_await_query_result

Transport seam (loop-specific). Submit a query on a connection and resolve with its result rows once the wire is readable. Used by a backend's "_query_async" implementation. Must be overridden by a backend that routes through this layer.

_await_conn_ready

Transport seam (loop-specific). Resolve with $conn once it is ready for queries. Must be overridden by a backend that routes through this layer.

_transform_sql

Transport-internal helper (ADR 0032). DB-specific SQL shaping (e.g. ?>$N placeholder rewriting for PostgreSQL, identity for MySQL).

This is not a general seam any more: it is private to a transport's own implementation of the query seams ("_query_async" / "_query_async_pinned"), which receive raw sql_maker output with ? placeholders and shape it internally. No caller outside a transport implementation may invoke it -- core orchestration (_run_crud) hands the transport ?-dialect SQL and leaves the shaping to the seam. A transport that needs shaping overrides this; one that does not (its wire speaks ?) may leave the croaking default in place and never call it.

_post_insert_sql

Transport seam. SQL appended to an INSERT to retrieve the populated columns (e.g. RETURNING * for PostgreSQL, empty for a last_insert_id backend). Must be overridden.

txn_do_async

my $future = $storage->txn_do_async(sub {
    my ($txn_ctx) = @_;
    # All queries in here use the same connection
    $txn_ctx->insert_async(...)->then(sub { ... });
});

Acquire a connection from the pool, issue BEGIN, execute the coderef, and issue COMMIT on success or ROLLBACK on failure (a raised exception or a failed Future). The coderef receives a DBIO::Storage::Async::TransactionContext (see "_txn_context_class") whose CRUD methods are pinned to the transaction connection.

With an DBIO::AccessBroker attached that is not transaction-safe -- one whose credentials rotate, such as DBIO::AccessBroker::Vault -- no transaction is started ("TRANSACTION SAFETY" in DBIO::AccessBroker). The returned Future fails with the refusal the synchronous "txn_begin" in DBIO::Storage throws, before a connection is taken from the pool; the coderef does not run. DBIO_ALLOW_UNSAFE_BROKER_TRANSACTIONS=1 lets the transaction run, with a warning at each transaction start. "deploy_async" runs transactional DDL through this method and is refused with it.

A returned Future is adopted: the transaction commits when it resolves and resolves with its values. Anything else the coderef returns is the result as-is -- a plain scalar, an object that is not a Future, or an unblessed hash or array reference such as the returned-columns hashref of $txn_ctx->insert: COMMIT is sent and the transaction resolves with that value.

Success of the coderef is not enough to send COMMIT. The context keeps the Future of every statement it hands out ("_track_statement" in DBIO::Storage::Async::TransactionContext), and COMMIT goes out only once all of them have settled -- a statement nobody awaited, still in flight when the coderef's result is in, is waited for. If any of them failed, COMMIT is not sent: ROLLBACK is, and the transaction fails with the error of the first statement that failed, in the order they were issued (with the ROLLBACK's error added, should that fail too -- see below). That holds whether or not anything awaited the failed Future, and also when the coderef caught the failure and returned normally: a failed statement fails the transaction, on every engine. PostgreSQL has aborted the transaction by then anyway -- it answers a COMMIT there with the tag ROLLBACK, not with an error -- and the async layer has no savepoint that would give "carry on after a failed statement" a meaning. A statement Future that was cancelled before it was answered counts as failed: its outcome is unknown. After COMMIT, the transport may still report that the server rolled back ("_txn_commit_rolled_back"); the transaction then fails too.

After a successful acquire, every terminal transaction path converges on one finalizer: any context that was constructed is invalidated before the connection is released, and the pool's release is called exactly once. If the context constructor itself throws, there is no context to invalidate; the same finalizer still releases once and returns that throw as a failed Future. An acquire failure releases nothing. A completed context reports in_txn false and refuses direct CRUD, raw-query and nested-transaction entry points rather than issuing work on the returned connection.

The connection goes back to the pool it was acquired from. "pool" is asked once, as the transaction begins, and the finalizer talks to that pool. A transaction that ends after a "disconnect" -- it stood between two statements when the storage was disconnected, and its coderef went on -- therefore hands back to a pool that is shut down, which takes it as a no-op, and leaves the storage without a pool. Asked again at the end, "pool" built a new one on the disconnected storage.

A path that sends COMMIT or ROLLBACK ends the context earlier, as that statement goes out. A statement issued on the context from then on -- from a then callback of a statement still in flight, say -- is refused, where it would otherwise run after the transaction's end, outside it.

ROLLBACK does not wait for a statement still in flight, so a context can end before such a statement is answered. Its Future is kept until then all the same ("_track_statement" in DBIO::Storage::Async::TransactionContext): nobody else may hold it, and Future warns lost a sequence Future about one that is gone when its answer arrives.

A statement that failed with a DBIO::Exception::Server -- the transport's way of reporting a refusal by the server, with its sqlstate (ADR 0062) -- fails the transaction with that same object, whether the coderef returned the statement's Future, left it unawaited, handled it, or died with it, and whether the statement ran on the context or in a pipeline on it. Where core puts text in front of an error (the next paragraph) the object stays an object: a failed BEGIN, COMMIT or ROLLBACK that is a DBIO::Exception::Server reaches the caller as a derived copy -- the same class, sqlstate, constraint, table and detail, plus "context" in DBIO::Exception::Server (BEGIN, COMMIT, ROLLBACK) -- that stringifies with the prefix, so $@ =~ /COMMIT failed/ and eq keep working and the SQLSTATE of a serialization failure at COMMIT is readable. It is not the object the transport failed with (refaddr differs); an error that is no such object stays the string it was.

Failures keep their prefixes: a failed BEGIN Future is reported as BEGIN failed: ..., a failed COMMIT Future as COMMIT failed: ..., and a synchronous query-seam throw after acquire becomes a failed Future rather than escaping txn_do_async. A failed ROLLBACK -- its Future failed, or its dispatch threw -- does not replace the error it was cleaning up after: the transaction fails with a DBIO::Exception reading Transaction aborted: $error. Rollback failed: $rollback_error, the text the synchronous "txn_do" in DBIO::Storage reports, so /Rollback failed/ matches in every async mode, immediate included. Where the ROLLBACK's own failure is a DBIO::Exception::Server, that text is carried by a derived copy of it (context ROLLBACK) instead of a plain DBIO::Exception. A release throw replaces a successful terminal result, but is secondary to an already existing transaction failure.

A pipeline run on the transaction that has not had its sync answered when the block ends in failure holds the connection in pipeline mode. ROLLBACK is then not sent at once: it waits until that pipeline is over -- sync answered and pipeline mode left -- so it goes out on a connection that takes it and the connection returns to the pool out of pipeline mode, never idle in it. A pipeline that failed or was cancelled leaves the connection to the rules below.

One state changes that ending: a pipeline run on the transaction ("pipeline" in DBIO::Storage::Async::TransactionContext) whose "_pipeline_exit" died leaves the connection in pipeline mode, where no statement on it can be relied on to be answered. The transaction then waits for nothing more on that connection. It fails -- with the error that ended the batch, or, if the coderef handled that error and returned normally, with a DBIO::Exception saying COMMIT was not sent -- and in place of the release the finalizer sends a best-effort ROLLBACK it does not wait for and retires the connection from the pool ("_retire_in_use_connection" in DBIO::Storage::PoolBase): it is never handed out again, and its slot goes to a fresh connection.

A transaction whose Future is cancelled -- the only way a caller has to stop waiting, such as the loser of a wait_any against a timeout -- ends the same way (ADR 0055 addendum). Future runs none of the callbacks above for a cancelled Future, so the cancel is answered on its own: a ROLLBACK goes out best-effort and is not waited for, the context is invalidated, and the connection is retired from the pool, never released into it. What was in flight on it -- a statement, the BEGIN, the coderef's own work -- cannot be vouched for, and the cancel still reaches it. Each cancel costs one reconnect: the slot goes to a fresh connection. It happens once, however many ways notice the cancel (the transaction's Future, the Future of the statement in flight, the finalizer). No ROLLBACK is sent where COMMIT or ROLLBACK is out already. A cancel while COMMIT is pending leaves the outcome unknown: the transaction may have committed or not, and the connection is retired either way. Cancelling the Future the coderef returned, and not the transaction's, ends the transaction in the same way and fails it with a DBIO::Exception saying it was cancelled. A statement Future of the context cancelled on its own, while the coderef goes on, is the case described above: ROLLBACK is awaited and the connection released.

No ROLLBACK is sent on a connection the pool was told is dead ("_mark_connection_dead" in DBIO::Storage::PoolBase), where it could only come back as not sent: the transaction fails with the error that ended it, and the release retires the connection.

A COMMIT the server refused (a DBIO::Exception::Server) ends the transaction whatever the server says (a deferred constraint is checked at commit time); one the transport refused or that was cancelled does not: it fails the transaction as before, with no ROLLBACK. The context is over, a ROLLBACK goes out on the pinned connection and is waited for, and the transaction fails with the error of the COMMIT, never that of the ROLLBACK. If the ROLLBACK works the connection is released as usual; if it fails, or its dispatch throws, the connection is retired from the pool, never handed back.

A transaction may be given a time limit: the connect option txn_timeout, in seconds (a number above 0), without a default -- a transaction without the option takes as long as its coderef does. The time is counted from the call of txn_do_async, the wait for a connection included. When it runs out before the transaction is over, the transaction is cancelled through the cancel path described above: ROLLBACK goes out on its connection best-effort and is not waited for, the context ends, the connection is retired and its slot goes to a fresh connection. The Future txn_do_async returned fails with transaction timed out after N seconds; whether the work took effect is unknown, as after any cancel. The timer is "_delay". The option needs a Future class that can be cancelled (Future); a storage whose class cannot be answers every txn_do_async with a failed Future saying so, before it takes a connection. A transaction that ends first stops the timer.

_txn_commit_rolled_back

my $rolled_back = $storage->_txn_commit_rolled_back($conn, @commit_result);

Transport seam. Asked when the COMMIT of a "txn_do_async" transaction has succeeded on the wire -- from that Future's success callback, before anything else runs on $conn -- with the pinned connection and the values the COMMIT Future resolved with. Answers true when the server did not commit but rolled back. The default answers false: the COMMIT is taken at its word.

PostgreSQL is why it exists. A COMMIT in an aborted transaction is not an error there: it ends the transaction and answers with the command tag ROLLBACK. A PostgreSQL transport fills this seam from that tag -- EV::Pg has it in $pg->result_meta->{cmd_status}, DBD::Pg in the statement attribute pg_cmd_status. An engine whose COMMIT fails when it cannot commit leaves the default.

When it answers true the transaction fails with a DBIO::Exception saying so. No ROLLBACK is sent -- the server has ended the transaction -- and the connection is released as usual.

The transaction does not depend on this seam to notice a failed statement: it waits for every statement its context handed out and refuses COMMIT when one failed ("txn_do_async"). What the seam adds is the statement the context never saw -- one sent on the connection past the context, or a pipeline statement whose batch coderef dropped its failure.

_txn_inner_future

my $future = $storage->_txn_inner_future($txn_ctx, $conn, $inner_future);

Driver seam. Asked by "txn_do_async" once the coderef has returned and its Future is known -- $inner_future, the Future the coderef returned (or an already done one wrapping a plain return) -- and before the transaction chains COMMIT onto it. $txn_ctx is the transaction context, $conn the pinned connection. It answers the Future the transaction adopts in its place. It is not asked when the coderef died or the connection is already unusable.

The default refuses an inner Future that waits in the queue of the transaction's own pool, the case a transaction can never see settle: the coderef returns the Future of a call on the STORAGE ($storage->select_async(...), an unbound $rs->all_async) instead of one on $txn_ctx. That call is not part of the transaction; it is queued for a pool connection, and with pool_size 1 -- or with as many transactions open as the pool has connections -- the connection it waits for is one a transaction holds, this one among them. The pool knows the Futures it queued the storage's calls for and the aggregates made of them (needs_all and its kind, through pending_futures; "_queued_waiters_for" in DBIO::Storage::PoolBase). The default takes each such waiter out of the queue and fails it, so the statement behind it never runs, and throws the coderef returned a Future queued for this transaction's own pool; call it on the context. That is the coderef dying: ROLLBACK goes out and "txn_do_async" fails with that message, the connection goes back to the pool. A Future the caller derived with ->then from such a Future is the caller's own and is not known to the pool: call the storage's statements on the context, and chain what follows on the context's Futures.

A driver overrides the seam to refuse more, or to refuse in its own way, and calls $self->next::method(@_) to keep the default.

A failed Future (the one passed in, once refused, or another) takes the normal error path: ROLLBACK is sent, "txn_do_async" fails with exactly that failure, and the connection goes back to the pool. Throwing is the same as the coderef dying: ROLLBACK, then the transaction fails with the exception (no ROLLBACK when the connection is unusable). A return that is not a Future is ignored and $inner_future is used.

_txn_context_class

The transaction-context class "txn_do_async" hands to its coderef. Defaults to the generic DBIO::Storage::Async::TransactionContext, which knows the pinned connection as txn_conn only. A backend that overrides "_txn_conn_accessor" to another name therefore overrides this too, with a context class that goes by that name: its new takes the connection under it, and it answers the connection to a method of that name.

_txn_conn_accessor

The name the "_txn_context_class" knows the pinned connection by. Defaults to txn_conn. It is asked both ways: "txn_do_async" passes the connection to the context's new under this key, and where core needs the connection back from a context -- the transactional run of "deploy_async" sends its statements on it -- it calls the context's method of this name. Core never reads txn_conn as such: that used to be what "deploy_async" did, so a backend answering another name could not deploy.

pipeline

my $future = $storage->pipeline(sub {
    my ($batch) = @_;
    my @futures;
    push @futures, $batch->insert_async('artist', { name => $_ })
        for @names;
    return $batch->future_class->needs_all(@futures);
});

Execute multiple queries in pipeline mode for reduced round-trips. The scaffold acquires a connection, brackets the batch with "_pipeline_enter", "_pipeline_sync" and "_pipeline_exit", and releases the connection -- however the batch ends, also when one of those seams dies; a connection whose exit failed is retired from the pool instead (see "_pipeline_on_conn"), and so is one whose Future was cancelled before the sync was answered: whether the batch took effect is unknown and the sync may still be answered on that connection, so it never goes back into the pool. Both go to the pool the connection was acquired from: "pool" is asked once, as the batch begins, so a batch that ends after a "disconnect" builds no new pool on the disconnected storage. The DB-specific pipeline mechanics are supplied by the backend via those three seams; a backend that cannot pipeline simply does not override them, and "pipeline" then returns a failed Future naming the transport before it touches the pool (see "_pipeline_refusal").

With an DBIO::AccessBroker attached that is not transaction-safe -- one whose credentials rotate, such as DBIO::AccessBroker::Vault -- the batch is refused like "txn_do_async" ("TRANSACTION SAFETY" in DBIO::AccessBroker): the returned Future fails with the same refusal, before a connection is taken from the pool, and the coderef does not run. DBIO_ALLOW_UNSAFE_BROKER_TRANSACTIONS=1 lets the batch run, with a warning.

The coderef receives a DBIO::Storage::Async::PipelineContext (see "_pipeline_context_class") -- not the storage. Pipeline mode is per-connection state, so a statement is only batched if it is sent on the one connection the scaffold bracketed; the context's *_async methods route through "_query_async_pinned" onto exactly that connection. Calling $storage->insert_async inside the batch instead goes through the pooled path, takes a different connection and runs outside the bracket -- so issue in-batch queries on the object the coderef is handed. This mirrors how "txn_do_async" hands its coderef a DBIO::Storage::Async::TransactionContext for the same reason (dbio-async ADR 0002 / ADR 0003).

Two limits worth knowing, both inherent to the batching shape rather than gaps in the scaffold:

  • Only queries issued during the synchronous run of the coderef are batched. A query chained onto an in-batch Future (->then(sub { ... })) fires after the coderef has returned, i.e. after the sync -- it cannot join the batch, and the context must not be used for it.

  • The ResultSet/Row async layer ($rs->all_async and friends) resolves the pooled backend unless it is bound to this context, so a bare $rs->all_async is not batched even inside the coderef -- it takes a different connection and runs outside the bracket. Bind it first ("with_context" in DBIO::ResultSet, ADR 0045) and every *_async on that resultset -- and on clones and rows made from it -- joins the batch. The binding lasts only as long as the batch: the scaffold invalidates the context when the coderef returns, and a bound resultset used after that throws instead of silently re-issuing on a pooled connection. The context's own CRUD methods batch either way.

_pipeline_refusal

if (my $failed = $storage->_pipeline_refusal) { return $failed }

Returns undef when this transport can pipeline, otherwise a failed Future carrying a DBIO::Exception that names the transport class and says it does not support pipeline(). A transport can pipeline iff it overrides the "_pipeline_enter" seam -- the same seam probe the async layer gate uses for _query_async, not a transport_capabilities name, so a pipelining transport that does not advertise pipeline keeps working. "pipeline" and "pipeline" in DBIO::Storage::Async::TransactionContext ask it before they touch a connection, so a refusal takes nothing from the pool and sends nothing on a transaction connection. Before that, the caller got the bare seam's Subclass must override _pipeline_enter($conn), which is written for transport authors.

_pipeline_on_conn

my $future = $storage->_pipeline_on_conn($conn, $coderef, $release, $retire);

The pipeline scaffold on a connection the caller already holds: builds the "_pipeline_context_class" around $conn, brackets the coderef with "_pipeline_enter" / "_pipeline_sync" / "_pipeline_exit", and calls the optional $release coderef at each point where the bracket is finished with the connection. "pipeline" passes a release that returns its pooled connection to the pool it acquired it from (and a $retire bound to that pool likewise -- neither asks "pool" again); "pipeline" in DBIO::Storage::Async::TransactionContext passes none, because the transaction connection belongs to the transaction boundary and must stay pinned until COMMIT/ROLLBACK.

A die in the context constructor, a seam or the coderef comes back as a failed Future carrying the error that ended the batch, and the connection is handed back exactly once on every path. It goes back only when the sync, every statement Future of the batch and the Future the coderef returned are decided; a batch that fails sends no sync, fails every statement Future still open with its error, and retires the connection instead of releasing it. Once pipeline mode has been entered it is exited on every path too, best-effort: a dying "_pipeline_exit" skips neither the hand-back nor the original error, and its own error is reported only when nothing else failed. On success the hand-back waits for the "_pipeline_sync" Future, as its obligation requires, and for the statements and the returned Future after it.

A connection whose "_pipeline_exit" died is still in pipeline mode, and the next borrower could not use it (EV::Pg rejects a plain query there). On that path alone the scaffold calls the optional $retire coderef instead of $release. "pipeline" passes one that takes the connection out of the pool for good through "_retire_in_use_connection" in DBIO::Storage::PoolBase, so its slot is freed and the next acquire gets a fresh connection. Without a $retire, $release runs on that path too.

A batch Future cancelled while the bracket is open takes the same path: Future runs neither branch of the sync's completion, so the scaffold answers the cancel itself and calls $retire, once, without leaving pipeline mode -- the connection is closed, not reused. A cancel after the bracket finished does nothing; the connection was handed back then. "pipeline" in DBIO::Storage::Async::TransactionContext passes a $retire that only marks the transaction connection unusable: the transaction boundary then retires it instead of releasing it ("txn_do_async").

_pipeline_context_class

The batch-context class "pipeline" instantiates around the bracketed connection and hands to its coderef. Defaults to the generic DBIO::Storage::Async::PipelineContext; a backend needing a different pinned-CRUD surface for batches overrides this. Whatever it returns is constructed with storage => $self, pipeline_conn => $conn.

_pipeline_inner_future

my $future = $storage->_pipeline_inner_future($batch, $conn, $inner_future);

Driver seam, the pipeline's counterpart of "_txn_inner_future". Asked by "_pipeline_on_conn" when the batch coderef has returned a Future, $inner_future, and before the batch waits on it. $batch is the pipeline context, $conn the bracketed connection. It answers the Future the batch adopts in its place; a return that is no Future leaves $inner_future.

The default refuses a Future that waits in the queue of the pipeline's own pool, as "_txn_inner_future" does for a transaction: such a Future is the result of a call on the storage instead of on $batch, it waits for a connection while the pipeline holds one, and the pipeline would wait for it to settle. The waiter is taken out of the queue and failed, so its statement never runs, and the seam throws the coderef returned a Future queued for this pipeline's own pool; call it on the context. A throw is the coderef dying: the batch fails with that message, no sync goes out, its open statements fail with it and its connection is retired ("_pipeline_on_conn"). Inside a transaction's pipeline the transaction fails with the same message.

_pipeline_enter

Transport seam. Open pipeline/batch mode on the connection. Overridden by a backend that supports pipelining.

_pipeline_sync

Transport seam. Flush the batched queries and resolve when the batch has been sent/acknowledged. Overridden by a backend that supports pipelining.

Obligation: it must not resolve before the individual batched result Futures have been resolved off the connection. "pipeline" exits pipeline mode and releases the connection in this Future's completion, and only then hands back the batch's own aggregate Future -- so a _pipeline_sync that resolves early puts the connection back in the pool while results are still being read off it.

A _pipeline_sync that dies instead of returning a Future is handled like one that returned a failed Future: pipeline mode is exited and the connection released, and the batch fails with the sync's own error.

_pipeline_exit

Transport seam. Close pipeline/batch mode on the connection. Overridden by a backend that supports pipelining. The scaffold calls it best-effort: if it dies, the connection is still handed back -- retired from the pool rather than released, since it is still in pipeline mode.

listen

my $f = $storage->listen($channel, sub {
  my ($channel, $payload, $sender_pid) = @_;
});

Register $cb for $channel and subscribe the listen connection to it, opening that connection first if this is the first channel. Returns a Future that resolves once the subscription is in effect and fails if the attempt that carries it fails. Where the transport's Futures can await, ->get on it runs the event loop until then -- and so do the Futures of "unlisten" and "listen_reconnect" (see "Listen seams"). A failed Future does not unregister the channel when the connection died: the machinery keeps healing and subscribes it on the next connection. A subscription the database rejects on a healthy connection is dropped from the registry instead, and the Future fails with the reason.

A second "listen" on an already-registered channel replaces the callback and issues no second subscribe. $sender_pid is the transport's sender identity (the PostgreSQL backend pid, or whatever an emulating transport records); it may be undef.

unlisten

my $f = $storage->unlisten($channel);

Drop the callback for $channel and unsubscribe it. When the last channel goes, watching stops and the listen connection is closed. Returns a Future that resolves once the unsubscribe has been sent (at once when there is nothing to send). Unlistening a channel that is not registered is a no-op.

notify

my $f = $storage->notify($channel, $payload);

Send a notification. Returns the Future of the transport's "_notify_send". The payload travels as a bind value, never interpolated into SQL; undef means no payload. Needs the notify capability, not listen: sending does not use the listen connection.

listen_alive

unless ($storage->listen_alive) { ... }

True when the listen connection is present, fully set up (every channel subscribed, watching armed) and the transport's "_listen_probe" says it is alive. A failing probe is treated as a death report: self-healing starts, and the method returns false. False while a reconnect is pending, when nothing is being listened to, and in a forked child that has not opened its own connection.

listen_reconnect

my $f = $storage->listen_reconnect;

Force an immediate rebuild of the listen connection, for an external watchdog: close the current connection (or cut a pending backoff short), open a new one, subscribe every registered channel, re-arm, fire "on_listen_reconnect". The backoff restarts from "listen_backoff_initial". Returns a Future that resolves when the new connection is up and fails if this attempt fails -- in which case self-healing carries on as usual. With no channel registered there is nothing to rebuild and the Future is already done.

_listen_open

my $f = $storage->_listen_open;   # resolves with $conn

Open the dedicated listen connection -- a new one, not from the pool -- and resolve with it. $conn is opaque to core; it is handed back to every other listen seam. Open it with the storage's current connect info (a broker-backed storage fetches fresh credentials here, as a pool spawn does) and fork-safe (DBD::Pg: AutoInactiveDestroy => 1), so that a child dropping its copy of the handle leaves the parent's session alone.

_listen_close

$storage->_listen_close($conn);

Close the listen connection. Called on a connection that may already be dead, so it must tolerate that. Watching has been stopped first ("_listen_unwatch") whenever it was started.

_listen_subscribe

my $f = $storage->_listen_subscribe($conn, $channel);

Subscribe $conn to $channel (PostgreSQL: LISTEN). The channel name arrives exactly as the caller passed it: quoting or validating it before it reaches the wire is the transport's obligation at this seam (ADR 0017 §3). An emulating transport may implement this as purely local bookkeeping -- recording a high-water mark, or nothing at all -- and return a done Future.

_listen_unsubscribe

my $f = $storage->_listen_unsubscribe($conn, $channel);

The inverse of "_listen_subscribe" (PostgreSQL: UNLISTEN), with the same quoting obligation and the same freedom to be local-only.

_listen_watch

$storage->_listen_watch($conn);

Start delivering notifications from $conn. From now until "_listen_unwatch", the transport calls "_listen_dispatch" once per notification, in arrival order, and "_listen_lost" as soon as it finds the connection dead. Returns nothing; a throw counts as a dead connection, and a watch that threw is not unwatched.

Core counts $conn as watched from the moment it calls this seam, so both calls back into core may already happen from inside it -- a first drain of notifications the client library buffered during the subscribe, say. A channel callback run from there that unlistens the last channel or calls "listen_reconnect", or a "_listen_lost" report, then gets "_listen_unwatch" for $conn -- while this seam is still on the stack -- before "_listen_close".

How the transport watches is its own choice, and both shapes fit this one seam:

  • socket-driven -- watch the connection's socket for readability, drain every pending notification, watch again (PostgreSQL over Future::IO: poll pg_socket, drain pg_notifies).

  • timer-driven -- for an engine with no server push, emulate: wait a poll interval (typically through "_listen_delay"), query the rows newer than the connection's high-water mark in its subscribed channels, deliver them, wait again. A failed poll query is a dead connection and goes to "_listen_lost"; a failed step that leaves the connection usable -- pruning old rows, say -- goes to "_listen_error". Latency is the poll interval, and a notification becomes visible when the transaction that wrote it commits -- the same on-commit delivery PostgreSQL gives. An emulation like this may declare listen and notify, as long as its POD states those semantics.

_listen_unwatch

$storage->_listen_unwatch($conn);

Stop what "_listen_watch" started: no further "_listen_dispatch" or "_listen_lost" for $conn after this returns (cancel the pending poll or timer). A transport whose watching needs no teardown overrides this with an empty method -- and says so.

_listen_probe

my $alive = $storage->_listen_probe($conn);

Synchronous liveness probe: true when $conn is still usable. False or a throw means dead. Asked by "listen_alive" and after a failed channel operation, to tell a rejected statement from a lost connection.

_listen_delay

my $f = $storage->_listen_delay($seconds);

A Future that resolves after $seconds -- the reconnect backoff. Core has no event loop, so the transport supplies the timer (Future::IO: sleep). It must resolve asynchronously, from the loop; a synchronously resolved delay would turn a persistent outage into unbounded recursion. Core cancels it (when the Future can cancel) if the wait is cut short.

_notify_send

my $f = $storage->_notify_send($channel, $payload);

Send one notification and resolve when it has been dispatched. The payload must travel as a bind value (PostgreSQL: SELECT pg_notify($1, $2); an emulating transport: an INSERT with bound values), never interpolated into SQL. $payload may be undef. Any connection may carry it -- typically a pooled one; the listen connection is not required.

_listen_dispatch

$storage->_listen_dispatch($channel, $payload, $sender_pid);

Called by the transport while watching, once per notification. Core looks the channel up in the registry and calls its callback in contract order. A notification for a channel that is no longer registered is dropped. A callback that dies is reported through "on_listen_error"; delivery continues. Ignored in a process other than the one that opened the connection.

_listen_lost

$storage->_listen_lost($conn, $error);

Called by the transport when it finds the listen connection $conn dead. Core closes it and starts self-healing. A report about a connection that is no longer the current one (already replaced, or closed) is ignored, as is any report in a process other than the opener.

_listen_error

$storage->_listen_error($error);

Called by the transport to report an error that does not end the listen connection -- a failed housekeeping step next to the notifications, say, such as an emulating transport's retention cleanup. Core hands $error to "on_listen_error" exactly as it reports its own errors: verbatim, without a trailing newline; warned when no hook is set; a hook that dies is itself warned. Nothing else happens: the connection stays up, no reconnect is scheduled, no Future is settled, and "listen_alive" answers as before. A connection that is dead goes to "_listen_lost" instead.

Ignored when this process is not listening -- in a forked child that has not opened a listen connection of its own, and once listening has ended -- as a "_listen_dispatch" is. Returns nothing.

deploy_async

my $future = $storage->deploy_async($schema, \%opts);
$future->get;

Deploy $schema asynchronously: generate the install DDL and execute every statement over the async transport. The Future resolves on success or fails on the first DDL error -- also when the transport dies dispatching a statement, before it has a Future for it. Every connection taken from the pool goes back to it either way -- to the pool it was taken from, which "pool" is asked for once, as the deploy begins, and not again for a later run or as one ends: a statement in flight at a "disconnect" releases into the pool that was shut down, a no-op, and builds no new one. A disconnect between two runs fails the deploy with storage disconnected during deploy rather than carrying on on a new pool; there is no resume. The pool is asked between the statements of one transactional run as well: found shut down there, the deploy fails with the same message, the run is rolled back (ROLLBACK, neither the rest of its statements nor COMMIT) and nothing more is sent on its connection. A statement in flight at the "disconnect" is not touched by this rule: it ends as the transport ends it. A pool that does not answer "is_shutdown" in DBIO::Storage::PoolBase counts as not shut down. On a transactional engine a transaction-safe run is rolled back on failure; on a non-transactional engine, or on a statement the per-statement hook declares non-transactional, no rollback applies (see "Transactional-DDL discipline").

A deploy whose Future is cancelled with a statement in flight ends as "txn_do_async" does: a transactional run gets a best-effort ROLLBACK and its connection is retired from the pool; a run on a pooled connection outside a transaction, or on a non-transactional engine, has its connection retired. Never released into the pool, and once; what the statement did is unknown.

Once the Future has settled, nothing of the deploy holds the backend, its connection or the statements: a backend that has deployed is freed when its holder lets go of it, like any other. It used to stay for the life of the process, with its pool and the connections in it -- the runs and the statements were chained by a sub that called itself through its own variable, and so held itself.

%opts:

add_drop_table => 1

Prepend DROP VIEW IF EXISTS ... for every declared view and DROP TABLE IF EXISTS ... CASCADE for every table source in the schema ("_drop_statements_for"), so a re-run on a populated database is idempotent. Default 0.

_install_ddl

my $ddl = $storage->_install_ddl($schema);

The install DDL string for $schema. Default: _ddl_class->install_ddl($schema) -- the same convention the sync "_install_ddl" in DBIO::Deploy::Base uses. Override to source the DDL differently.

_ddl_class

Class name whose install_ddl($schema) returns the install DDL, parallel to "_ddl_class" in DBIO::Deploy::Base. Abstract seam: a backend that supports "deploy_async" overrides it (e.g. 'DBIO::PostgreSQL::DDL'). Defaults to croaking.

_use_transactional_ddl

if ($storage->_use_transactional_ddl) { ... }

Whether the engine behind this transport honours transactional DDL (ADR 0026). Delegates to the owning sync storage's transactional_ddl capability ("_owner_storage", DBIO::Storage::DBI::Capabilities); false when no owner is wired or the capability is unset -- the conservative default (do not assume atomic DDL). Mirrors the sync probe in _storage_uses_transactional_ddl in DBIO::DeploymentHandler.

_execute_ddl_async

my $future = $storage->_execute_ddl_async($conn, $ddl);

Split $ddl into statements and run each on the pinned connection $conn, one at a time, through the "_query_async_pinned" transport seam. The text is cut by the splitter the sync "_execute_ddl" in DBIO::Deploy::Base uses (DBIO::Deploy::Base::_split_ddl, over "_split_statements" in DBIO::SQL::Util), in the SQL dialect the owning storage declares through _ddl_sql_dialect: a semicolon inside parentheses or a BEGIN ATOMIC body does not end a statement, a comment in front of a statement is taken off it, and a piece that holds only comments is no statement and is not run. Returns a Future that resolves when the last statement completes or fails on the first error.

The statements are chained sequentially -- each waits for the previous to complete -- so the transport never has two DDL statements in flight on one connection. Routing through _query_async_pinned rather than a native client call is what lets every Model-B backend that already implements that seam for CRUD execute DDL with no extra code.

_ddl_quote_ident

my $quoted = $storage->_ddl_quote_ident($name);

The identifier $name as the DDL of this storage writes it. The default leaves a plain identifier bare and double-quotes any other (DBIO::SQL::Util); a storage whose engine quotes differently overrides this one method, and "_drop_statements_for" writes every name through it.

_drop_statements_for

my $sql = $storage->_drop_statements_for($schema);

Build the statements of the "deploy_async" add_drop_table pre-pass: first DROP VIEW IF EXISTS ...; for every declared view (DBIO::ResultSource::View) that is a real view of the database, then DROP TABLE IF EXISTS ... CASCADE; for every regular table source in $schema. The views go in the reverse of their install order -- the source names in order, each behind the views its deploy_depends_on names -- so a view is dropped before the views it reads. Virtual views and sources whose name is not a plain identifier (scalar-ref / subselect names, or names containing whitespace or parens) are left out. Every name is written through "_ddl_quote_ident". Returns the statements joined into one string, separated by a blank line. Fully schema-driven -- override for an engine whose DROP syntax differs.

select

select_single

insert

update

delete

txn_do

deploy

Blocking forms of "select_async", "select_single_async", "insert_async", "update_async", "delete_async", "txn_do_async" and "deploy_async": each calls its *_async counterpart with the same arguments and waits for the Future with ->get, returning its result or rethrowing its failure. Useful in scripts and migrations that run outside an event loop.

debug

Returns the trace level, initialised from DBIO_TRACE at construction (the leading digits of the flag, 1 for any other true value; see "DBIO_TRACE" in DBIO::Storage).

in_txn

Always false on the storage itself: an async transaction lives in the DBIO::Storage::Async::TransactionContext that "txn_do_async" hands to its coderef, which answers in_txn for its own lifetime.

connected

True when a pool has been built and it currently holds at least one idle connection (its available count).

disconnect

Invalidates every transaction and pipeline context issued so far (they can no longer dispatch), ends listening -- the listen connection is closed, the channel registry cleared, a pending reconnect abandoned and any Future still waiting on a subscription failed -- then shuts the pool down and drops it. In a forked child the parent's listen connection is left alone (see "LISTEN / NOTIFY"). Also called from DESTROY when a pool or listen state exists.

A LAYERED SCHEMA ACROSS ASYNC TRANSPORTS

Because the async mode is chosen per connection (ADR 0030) rather than baked onto the storage class, one schema -- even a heavily-layered one -- can be driven across every async execution model at once. This worked example takes a single PostgreSQL schema defined with both the Apache AGE and PostGIS extensions and runs it under future_io, ev, immediate and forked, from the same class, with no per-transport plumbing.

The AGE / PostGIS / EV code below is illustrative -- it shows the shape a real application sees. What actually runs in THIS distribution's test suite is a synthetic stand-in that proves the same mechanism with no downstream dependencies and no database: t/composed/async_modes_example.t. The concrete AGE+PostGIS+EV realization is exercised by the DBIO-PostgreSQL-Age suite -- t/40-stacking.t (AGE and PostGIS compose together), t/41-dual-mode-coexistence.t (an ev and a future_io connection to one schema at once), t/42-immediate-smoke.t and t/43-ev-integration-live.t.

Define the schema once

Both extensions register as storage layers (DBIO::Storage::Composed), so the composed sync storage isa both extensions and the driver, and their methods -- AGE's cypher, PostGIS's ensure_postgis -- live on one object:

package MyApp::Schema;
use DBIO 'Schema';
__PACKAGE__->load_components('PostgreSQL', 'PostgreSQL::Age', 'PostgreSQL::PostGIS');

# $schema->storage now isa DBIO::PostgreSQL::Storage, ...::Age, ...::PostGIS;
# $schema->storage->cypher(...) and $schema->storage->ensure_postgis both work.

future_io -- a non-blocking Future::IO loop

my $schema = MyApp::Schema->connect($dsn, $user, $pass, { async => 'future_io' });

# AGE's async layer is composed over the future_io transport; cypher_async is
# non-blocking on the Future::IO loop:
$schema->storage->async->cypher_async('MATCH (n:Person) RETURN n')->then(sub {
    my @rows = @_;
    ...
});

The future_io transport is resolved by convention off the concrete driver storage (DBIO::PostgreSQL::Storage::Async); AGE's ...::Age::Async layer composes on top of it (see "transport_capabilities").

ev -- a native EV::Pg loop, same schema class

my $schema = MyApp::Schema->connect($dsn, $user, $pass, { async => 'ev' });

# Identical API surface, a different loop; LOAD 'age' replays on every pooled
# connection (see L</"POOL CONNECTION ACTIONS">).
$schema->storage->async->cypher_async('MATCH (n:Person) RETURN n')->then(sub { ... });

The mode is chosen per connect, not on the class, so the two are independent: you can hold a future_io connection and an ev connection to the same schema class at once, each resolving its own distinct composed backend (a future_io transport with AGE's async layer, and an ev transport with AGE's async layer). That per-instance distinctness is exactly what t/composed/async_modes_example.t asserts.

immediate -- the "no event loop" case

my $schema = MyApp::Schema->connect($dsn, $user, $pass, { async => 'immediate' });

# The *_async methods run in-process and hand back an already-resolved Future
# (a DBIO::Future::Immediate). No backend is built, no loop is stood up:
my $rows = $schema->storage->select_async($source, \@cols, \%where)->get;

This is for code that wants the async API uniformly -- the same *_async call sites -- without depending on an event loop.

forked

my $schema = MyApp::Schema->connect($dsn, $user, $pass, { async => 'forked' });

Another loop transport: DBIO::Forked::Storage (dist dbio-forked) runs the ordinary sync driver inside a fork(). Same per-connection mode selection.

With no async mode, *_async croaks

my $schema = MyApp::Schema->connect($dsn, $user, $pass);  # plain sync
$schema->storage->select_async(...);  # croaks: not an async connection

Async is opt-in per connection: a sync instance has no chosen mode, so *_async fails loudly rather than degrading silently.

PostGIS is sync-only

PostGIS ships no ::Async layer and says so: its storage layer defines async_layer_class returning undef, the stated sync-only opt-out (see "Async mirror: async_layer_class" in DBIO::Storage::Composed). So under any async mode no PostGIS async layer is composed onto the backend; geometry CRUD flows through the transport unchanged. A layer that neither declares this nor ships an ::Async sibling is refused at async compose time rather than skipped. AGE, by contrast, needs its LOAD 'age' session setup replayed on every pooled connection, so its async layer declares the on_connect_replay transport capability as required. The capability gate refuses to compose an async layer over a transport that does not provide what it declares (naming the layer, the missing capability and the transport); both PostgreSQL async transports (future_io and ev) provide on_connect_replay, so AGE composes cleanly over either.

ACCESSBROKER CONSUMPTION

The async storage tier is the second consumer of the DBIO::AccessBroker credential seam (the first being DBIO::Storage::DBI; see CONTEXT.md and the ADRs). The broker-management API itself (set_access_broker, clear_access_broker, current_access_broker_connect_info) lives on the base DBIO::Storage. "set_access_broker" and "clear_access_broker" are extended here, because on an async storage a broker is more than the broker slot.

What this class adds is the async consumption wiring that was previously re-implemented by every async driver: detecting a broker passed as connect info, building the per-spawn conninfo_provider coderef that pulls fresh credentials, and feeding it to the pool so every NEW pool connection gets freshly-refreshed connect info. Drivers supply only the one storage-native seam hook, "_async_broker_conninfo".

One state, set in one place and taken away in one. What a "connect_info" leaves on the storage is spread over several slots -- the broker and its mode, the provider, the stored conninfo, the pool size, the options, the connect options given beside the broker -- and they belong together: the conninfo of a static connection must not stand beside a broker, nor the pool size of the connect info before beside a new one. So nothing sets a part of it:

  • "set_access_broker" is where a broker is attached, whoever calls it -- "_setup_access_broker" is the same call. It forgets everything the connect info before left and installs the provider.

  • "clear_access_broker" is where a broker is detached, under either name. What came with the broker goes with it. From then on the storage has nothing to connect from, and every connection it is asked for is refused, saying that no broker is attached: the provider refuses, on the storage and in a pool built with it. "_clear_access_broker" is the detach followed by the forgetting of everything else, the provider included -- the state of a storage that was never given connect info.

  • "connect_info" forgets all of it before it reads anything of the new connect info, so a call that throws leaves nothing of the call before.

ASYNC OPTIONS

The connect options an async backend takes beside the connection itself form a declared list. With an async mode set on the connection, the owning storage keeps every name of the list out of its own DBI->connect and hands it to the backend; a name that is neither on the list nor a DBI attribute is refused like every unknown connect attribute. Without an async mode the DBD decides, as before.

The core's names are pool_size (the size of the pool), acquire_timeout and max_waiters (the bounds of a wait for a pool connection, "new" in DBIO::Storage::PoolBase), txn_timeout (the time a "txn_do_async" may take) and reset_timeout (the time the reset of a released connection may take, "_reset_pool_connection"). A backend adds names of its own with "async_option_names", and reads what it was given with "async_option".

POOL CONNECTION ACTIONS

Every physical pool connection is set up with the SAME on_connect_do / on_connect_call the owning sync storage was configured with -- and torn down with the matching on_disconnect_do / on_disconnect_call -- so that a pooled async connection has identical session semantics (search_path, timezone, SET variables, extension LOADs, ...) to the sync path on the same instance. Without this a user's session setup would silently apply to $schema->resultset(...) (sync) but not to *_async (pool), violating the fail-loud rule.

The central seam is "_setup_pool_connection" (and "_teardown_pool_connection"), invoked once per physical connection from the shared core pool-spawn / shutdown path (_spawn_connection in DBIO::Storage::PoolBase, "shutdown" in DBIO::Storage::PoolBase), so every Model-B backend whose pool wires this storage as its owner inherits the behaviour. The action list and its connect_call_* method resolution are read from the owning sync storage ("_owner_storage"): the exact same _do_connection_actions dispatch the sync path uses (coderef / nested arrayref / scalar method-name / do_sql), so an extension's connect_call_load_age resolves by convention just as it does synchronously -- but each statement it emits (via $storage->_do_query) is redirected onto the fresh pool connection instead of the sync dbh.

Execution is synchronous -- a blocking do per statement on the fresh connection at spawn time (see "_run_pool_connect_statement"). Pool spawn is rare, and a blocking do is far simpler and safer than re-entering the async transport / event loop mid-acquire to route a few setup statements. This suits every backend whose pool connection is ready at spawn (the DBD-based future_io adapters open with a blocking DBI->connect); a native backend whose handle cannot run a synchronous do, or is not ready until after an async connect, overrides "_run_pool_connect_statement" (and, if needed, the timing of the whole seam).

LISTEN / NOTIFY

Database notifications are part of the core async contract (ADR 0056). The public surface -- "listen", "unlisten", "notify", "listen_alive", "listen_reconnect" and the two hooks "on_listen_error" / "on_listen_reconnect" -- is defined here once, and so is the transport-neutral machinery behind it. A transport fills a small set of listen seams, the same shape as the pipeline precedent above: core orchestrates, the transport knows its wire.

my $async = $schema->storage->async;   # e.g. { async => 'future_io' }

$async->on_listen_error(sub {
  my ($storage, $error) = @_;
  $log->warn("listen: $error");
});
$async->on_listen_reconnect(sub {
  my ($storage) = @_;
  $log->info('listen connection rebuilt, every channel re-subscribed');
});

$async->listen(jobs => sub {
  my ($channel, $payload, $sender_pid) = @_;
  ...
})->get;

$async->notify(jobs => 'wake up');      # a Future

# an external watchdog
$async->listen_reconnect unless $async->listen_alive;

What core does

  • One dedicated listen connection, outside the pool, shared by every channel of this storage. It is opened by the first "listen" and closed when the last channel is unlistened (or on "disconnect").

  • A channel registry -- one callback per channel -- and dispatch in contract order: $cb->($channel, $payload, $sender_pid).

  • Serialised channel operations. Every subscribe and unsubscribe runs through one queue per connection, one at a time, so no transport ever sees two in flight on the listen connection.

  • Self-healing. A dead connection -- the transport reports it through "_listen_lost", the probe fails, or a channel operation fails and the probe then says the connection is gone -- is closed, a reconnect is scheduled after a bounded exponential backoff ("listen_backoff_initial", "listen_backoff_max"), every registered channel is subscribed again on the new connection, watching is re-armed and "on_listen_reconnect" fires. A failed reconnect attempt schedules the next one; healing continues until it succeeds, the last channel is unlistened, or the storage disconnects.

  • Fork safety. The listen connection belongs to the process that opened it. Core records that pid; in any other process it never touches the connection -- no subscribe, no probe, no close. A forked child that calls "listen" starts with an empty registry and opens a connection of its own; "listen_alive" in a child is false until it does. The transport's half of that obligation is to open the connection fork-safe, so that dropping the inherited handle in a child does not tear the parent's session down (DBD::Pg: AutoInactiveDestroy => 1).

The capability gate

"listen", "unlisten", "listen_alive" and "listen_reconnect" require the transport to declare listen in "transport_capabilities"; "notify" requires notify. Without the declaration they throw LISTEN/NOTIFY not supported by this storage driver -- before touching any state. A transport that emulates notifications (see "_listen_watch") may declare both.

Transports that override the public methods

A transport may still override "listen" / "unlisten" / "notify" wholesale; its override wins and none of the machinery here runs. It must then also answer "listen_alive" and "listen_reconnect" itself, because core's versions only know the connection core opened. That is a migration state, not a design option: the seams are the contract (ADR 0056).

Listen seams

Implemented by a transport that declares listen / notify; each croaks until provided: "_listen_open", "_listen_close", "_listen_subscribe", "_listen_unsubscribe", "_listen_watch", "_listen_unwatch", "_listen_probe", "_listen_delay" and "_notify_send". The transport calls back into core through "_listen_dispatch", "_listen_lost" and "_listen_error". Core calls the seams only in the process that opened the connection, and never runs two channel operations on one connection at once. Every seam documented as returning a Future may instead return a plain value when its work is already complete; core takes that as an already-resolved Future.

The Futures callers get are made from yours. A Future that "listen", "unlisten" or "listen_reconnect" hands out while its work is still in flight is settled by core, so core has to construct it -- and one constructed from "future_class" knows no event loop, so its get could not wait. Core therefore makes it from the last pending Future one of your listen seams returned, as $that_future->new: the Future idiom for a new pending Future of the same kind, carrying your class and whatever it keeps with it (an IO::Async::Future keeps its loop). Its get then awaits by running the loop your seams resolve on. So a pending Future a listen seam returns must answer new called on the instance with a new pending Future that core can done and fail, as every Future subclass does. Where your Futures can await (Future::IO's, IO::Async's), so can the caller's; where they cannot, a caller drives your loop until the Future is ready, as with any other Future you return.

ASYNC DEPLOY

The async counterpart of the sync deploy pipeline (DBIO::Deploy::Base). "deploy_async" generates the install DDL from the schema classes -- in memory, no DB roundtrips -- then splits it into statements and runs each on a single pinned connection through the "_query_async_pinned" transport seam. So every Model-B backend that already implements _query_async_pinned for CRUD gets DDL execution for free. The DDL generator is resolved via the same _ddl_class->install_ddl($schema) hook the sync path uses ("_install_ddl" in DBIO::Deploy::Base), not a driver-specific schema method.

Transactional-DDL discipline

Following ADR 0026, the DDL batch is not blanket-wrapped in a transaction. "deploy_async" probes "_use_transactional_ddl" first and only brackets the DDL in "txn_do_async" on engines whose DDL is transactional (e.g. PostgreSQL) -- a failure on statement N of M then rolls back the preceding N-1. On engines whose DDL forces an implicit COMMIT per statement (MySQL and MariaDB on every version, Oracle, Sybase, Informix) an async transaction buys no atomicity, so the loop runs statement-at-a-time on one pooled connection and a one-shot warning is emitted naming the class -- exactly the sync DBIO::Deploy::Base::_execute_ddl contract, on the Future side of the wire. An engine whose driver declines the capability for reasons of its own (SQLite, DB2) takes the same path; see "TRANSACTIONAL DDL" in DBIO::Deploy::Base. A future non-transactional async driver therefore inherits the correct (non-atomic, warned) behaviour rather than a silent false-atomicity footgun.

The capability answers for the connection, but atomicity is decided per statement (ADR 0026 addendum). On a transactional engine "deploy_async" asks the same per-statement _ddl_statement_is_transactional hook the sync path asks -- on the owning sync storage ("_owner_storage"), the storage "_use_transactional_ddl" already delegates to and the one carrying the capability -- and reuses DBIO::Deploy::Base::_group_ddl_statements so the runs come out in the same order and grouping as the sync deploy. Each run of transaction-safe statements is bracketed in one "txn_do_async" on a pinned connection; a statement the hook declares non-transactional runs on its own pooled connection, outside any transaction. A batch where every statement is transaction-safe -- the usual case, and always when a driver never defines the hook -- yields exactly one run and is identical to bracketing the whole loop. Where a batch splits into more than one run the deploy is no longer atomic as a whole; core announces the split once per class (same recovery story as sync: the __VERSION gate in DBIO::DeploymentHandler). This is the async mirror of the per-statement grouping ADR 0026 §Addendum records for the sync path -- a parity fix, not a new decision.

A backend that inherits "deploy_async" provides a "_ddl_class", and its "_txn_context_class" answers the pinned connection under the name "_txn_conn_accessor" gives -- txn_conn by default, as the generic DBIO::Storage::Async::TransactionContext does. A transactional run sends its statements on that connection through "_query_async_pinned", not through the context: they are not among the statements the context tracks. What decides whether the run goes on is the pool it began on, which "deploy_async" asks between the statements.

AUTHOR

DBIO & DBIx::Class Authors

COPYRIGHT AND LICENSE

Copyright (C) 2026 DBIO Authors Portions Copyright (C) 2005-2025 DBIx::Class Authors Based on DBIx::Class, heavily modified.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.