NAME

DBIx::Loop - non-blocking DBI on your event loop

VERSION

Version 0.02

SYNOPSIS

use DBIx::Loop;
use DBIx::Loop::Loop::IOAsync;

my $adapter = DBIx::Loop::Loop::IOAsync->new;
my $db = DBIx::Loop->connect(
    'dbi:SQLite:dbname=app.db', '', '',
    { RaiseError => 1 },
    loop => $adapter, workers => 4,
);

# non-blocking: the query runs on a pool worker (or, for Pg, on the
# connection's own socket) while the loop keeps serving
$db->query("SELECT * FROM pets WHERE id = ?", $id)->on_ready(sub {
    my $res = shift->get;   # { rows => [[...],...], columns => [...] }
    ...
});

# transactions, pinned to one connection
$db->txn(sub {
    my ($tx) = @_;
    $tx->do("INSERT INTO pets (name) VALUES (?)", 'rex')
       ->then(sub { $tx->do("UPDATE counts SET pets = pets + 1") });
})->on_ready(sub { ... });

DESCRIPTION

DBIx::Loop runs DBI queries without blocking an event loop, behind one future-returning API. It is about concurrency and latency isolation, not per-query speed. DBD::SQLite and DBD::Pg already sit at parity with their C libraries (a direct-libsqlite3 comparison showed a dead heat), but a blocking query inside an event-driven server stalls every connection on that worker. DBIx::Loop keeps the loop live.

DBIx::Loop is not an event loop and ships none - you always supply a loop adapter (IO::Async, Mojo::IOLoop, AnyEvent, Hyperman, ...). The engine - object, capability probe, both backends, transactions, and DBIx::Loop::Future - is C.

THE TWO BACKENDS

DBI has no standard async API, so DBIx::Loop carries two backends behind the one interface and picks per driver at connect time (see "capability"):

  • The worker pool (universal). For drivers with no async surface - SQLite is the extreme: in-process, synchronous, un-yieldable - the blocking call runs on a forked worker holding its own connection, framed over a socketpair the loop watches. Works with every DBD. Workers use prepare_cached, crashed workers respawn (a storm cap applies), and max_queue bounds backpressure.

  • Native fd async (Pg; opt-in fast path). DBD::Pg exposes a non-blocking execute and the connection's socket, so queries fire with pg_async, the loop watches pg_socket, and results collect on readiness - no workers, no serialization. One query per connection is in flight (a libpq limit); the rest queue.

Either way the result is the same shape: query resolves to { rows => [ [...], ... ], columns => [ ... ] } (arrayref rows - they benchmarked ~4x faster to build than hashrefs) and do to { rows_affected => $n, insert_id => $id } (insert_id best-effort via last_insert_id).

CONSTRUCTORS

connect

my $db = DBIx::Loop->connect($dsn, $user, $pass, \%attr,
    loop      => $adapter,   # required; or 'auto'
    workers   => 4,          # pool backend: worker count
    max_queue => 0,          # pool backend: pending cap (0 = unbounded)
);

Connects via DBI->connect and keeps the connect arguments so pool workers can open their own handles (a live handle cannot cross a fork). loop => 'auto' adapts an already-loaded loop (Mojo::IOLoop, then IO::Async, then AnyEvent) and croaks when none is loaded - there is no built-in loop, by design.

new

my $db = DBIx::Loop->new(dbh => $dbh, loop => $adapter);

Wrap an existing handle. Without connect arguments the pool backend cannot fork workers, so queries on a bare wrapped handle run synchronously; use connect for the non-blocking pool.

METHODS

query / do

my $future = $db->query($sql, @bind);   # SELECT-style: rows + columns
my $future = $db->do($sql, @bind);      # writes: rows_affected, insert_id

Both return a future immediately. ->on_ready, ->then, ->else chain; ->get returns the result once ready (awaiting a pending future is the adapter's job: $adapter->await($future)). Failures carry the DBI error string.

txn

my $future = $db->txn(sub {
    my ($tx) = @_;
    # $tx->query / $tx->do are pinned to ONE connection
    return $tx->do(...)->then(sub { $tx->do(...) });
});

Acquires a pool slot (waiting when all are busy), runs BEGIN, calls the block with a DBIx::Loop::Txn handle pinned to that connection, then COMMITs - or ROLLBACKs when the block dies or its returned future fails, failing the outer future with the original error. The block may return a plain value or a future; the outer future resolves to it after commit.

Plain $db statements during a transaction run on other slots - they never join the transaction and never steal its connection. A txn inside a block is an independent transaction on another slot (beware awaiting one while its parents hold every slot). If a worker dies mid-transaction the transaction fails - it is never silently resumed on the respawned connection. Pool backend only for now; native (Pg) transactions arrive with the native connection pool.

The DBI select family

The familiar DBI conveniences exist as async counterparts - same names, same result shapes, wrapped in a future:

$db->selectall_arrayref($sql, @bind)   # -> [ [...], [...] ]
$db->selectrow_arrayref($sql, @bind)   # -> [...] or undef
$db->selectrow_array($sql, @bind)      # -> (list of first-row values)
$db->selectrow_hashref($sql, @bind)    # -> { col => val } or undef
$db->selectcol_arrayref($sql, @bind)   # -> [ first column... ]
$db->selectall_hashref($sql, $key, @bind)  # -> { key_val => {row} }
$db->selectall_rowhash($sql, @bind)        # -> [ { col => val }, ... ]

selectall_rowhash is DBI's selectall_arrayref($sql, { Slice => {} }): every row as a hashref, in the order the server returned them. selectall_hashref cannot stand in for it - keying rows by a column destroys the ordering, which is exactly what keyset pagination depends on.

Unlike DBI these take @bind directly (no \%attr slot). There is deliberately no prepare/execute/fetchrow_* statement-handle surface: query/do subsume prepare+execute+fetch in one round trip, pool workers already reuse statements via prepare_cached, and row-at-a-time fetching across an async boundary would cost a round trip per row.

capability

'native' when the driver has a usable async surface (DBD::Pg with its async constants loaded), else 'pool'. Probed per handle at construction.

loop / dbh / disconnect

Accessors, and teardown: disconnect reaps pool workers (failing any in-flight or queued futures with a clear message) and closes the handle.

LOOP ADAPTERS

An adapter is five methods - add_reader($fd,$cb), add_writer($fd,$cb), remove($fd), timer($after,$cb), new_future() - plus await and, on loops with a native future type, to_native to bridge a query future into that ecosystem (IO::Async Future, Mojo::Promise, AnyEvent condvar). One conformance suite (t/lib/AdapterConformance.pm) proves every adapter behaves identically. Adapters with a C-side loop (Hyperman) can install a C vtable so readiness dispatches with no Perl call frame.

C ABI

An XS module can run statements and consume the resulting futures without a Perl frame in between. include/dbil_abi.h declares a versioned function-pointer table:

  • connect - DBI->connect plus the constructor, as one call. Returns NULL and sets an error SV rather than croaking, so a consumer can fall back instead of dying at boot.

  • hyperman_adapter - the Hyperman loop adapter, built on a loop the caller names. Always name the loop: an adapter left to choose for itself when no loop is running constructs one that nothing will ever run, and its futures never settle.

  • exec - query/do with the binds already in an AV.

  • exec_shaped - exec plus one select* reshape as a single call, so the intermediate future of query-then-reshape is never built.

  • is_future / future_state / future_values / future_error - settle-path reads. is_future answers for any SV at all, returning false for anything that is not one rather than dereferencing whatever it was handed, so it is safe to probe with. future_values writes borrowed SVs into an array the caller supplies; a stack array is the expected use.

  • future_on_ready - a C continuation: no closure is compiled and no Perl frame runs when the result lands. This is the entry that matters most.

  • future_new / future_done1 / future_fail - for bridging into another future type.

  • reshape - the select* transforms over a result you already hold. Returns an error SV instead of croaking, because on the chaining path it runs inside a settle and must produce a failed future, not a die.

The table is resolved at runtime through DBIx::Loop::_abi_ptr and gated on its abi_version, so there is no link-time coupling and the two distributions upgrade independently; entries are only ever appended. Reach the header with ExtUtils::Depends:

my $pkg = ExtUtils::Depends->new('My::Module', 'DBIx::Loop');

Two things about the table are deliberate. Nothing in it allocates a block the caller must free - pairing a malloc in one shared object with a free in another is a good way to discover that each can carry its own heap. And there is no destructor for a connection or a future: both are Perl objects that already own their lifetime correctly, so hold a reference and let refcounting do it. What the table hands back +1, you SvREFCNT_dec.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)