NAME

DBIO::PostgreSQL::EV::TestHarness - Reusable live-PostgreSQL harness for EV async on_connect-replay tests

VERSION

version 0.900001

SYNOPSIS

use Test::More;
use DBIO::PostgreSQL::EV::TestHarness;

# Skip the whole file cleanly unless a live PG DSN + EV::Pg are available.
DBIO::PostgreSQL::EV::TestHarness->skip_all_unless_live;

my $h = DBIO::PostgreSQL::EV::TestHarness->new(
    # each fresh pool connection replays these against the owning sync storage
    connect_attrs => {
        on_connect_do => [ q{SET myapp.replay_tag = 'live'} ],
        pool_size     => 4,
    },
);

# Force 3 DISTINCT pool connections to spawn (each replays the connect
# actions at spawn) and run an async op on each -- the replay-verification seam.
my @tags = $h->run_on_each_pooled_connection(3, sub {
    my ($conn, $harness) = @_;
    return $harness->query_on($conn, q{SELECT current_setting('myapp.replay_tag')});
});
is $_->[0][0], 'live', 'connect action replayed on this pooled connection'
    for @tags;

$h->disconnect;
done_testing;

DESCRIPTION

An installable test harness for the native ev async transport (DBIO::PostgreSQL::EV::Storage). It removes the boilerplate every live EV test repeats -- parse DBIO_TEST_PG_DSN, connect an { async => 'ev' } schema, reach the embedded async backend + its pool, drive the EV loop -- and, crucially, provides the one thing an on_connect-replay test needs and cannot get from a directly-constructed storage: an EV async backend wired to an owning sync storage whose on_connect_do / on_connect_call actually replay on every freshly-spawned pool connection (karr #68).

It ships under lib/ (not t/) on purpose: it is consumed both by this distribution's own live tests and by downstream extension distributions (e.g. dbio-postgresql-age), which cannot see another dist's t/lib. An extension that carries a develop/recommends dependency on DBIO::PostgreSQL::EV reaches this class from its own t/.

METHODS

live_available

DBIO::PostgreSQL::EV::TestHarness->live_available or ...;

Class method. True when DBIO_TEST_PG_DSN is set and EV::Pg (and EV) can be loaded -- i.e. the live harness can run.

skip_all_unless_live

DBIO::PostgreSQL::EV::TestHarness->skip_all_unless_live;

Class method. When the live harness is unavailable (see "live_available"), calls Test::More->plan(skip_all => ...), which skips and exits the test file cleanly. A no-op when live testing is available.

new

my $h = DBIO::PostgreSQL::EV::TestHarness->new(%opts);

Connect an { async => 'ev' } schema and build its embedded async backend. Options:

  • dsn / user / pass -- connect info; default to DBIO_TEST_PG_DSN / DBIO_TEST_PG_USER / DBIO_TEST_PG_PASS.

  • schema_class -- the schema class to connect (default a generic sync PostgreSQL schema). Supply your own to register storage layers / components (this is how an extension injects its behaviour).

  • connect_attrs -- extra connect attributes merged into the { async => 'ev', ... } attrs hash, e.g. on_connect_do, on_connect_call, pool_size. These are exactly the actions replayed on each pooled connection.

Croaks unless "live_available" (call "skip_all_unless_live" first).

schema

The connected schema instance.

sync_storage

The sync DBIO::PostgreSQL::Storage the schema connected to (the owner of the async backend). Forces driver determination on first call.

async

The embedded async backend (a DBIO::PostgreSQL::EV::Storage, possibly with composed extension async layers). Built lazily on first access via the sync storage's async resolver, which also wires the owner back-reference.

pool

The DBIO::PostgreSQL::EV::Pool of the async backend.

await

my @result = $h->await($future);

Drive the EV loop until $future is ready, then return its result (dies on a failed Future). Guarded by a wall-clock alarm so a hang fails loudly instead of spinning forever.

query_on

my @rows = $h->await( $h->query_on($conn, $sql, \@bind) );

Run $sql on the specific pooled connection $conn (pinned, not released), returning a Future of the raw result rows -- the natural way to assert that a connect action replayed on that connection. $sql may use ? placeholders (shaped to $N by the transport). Returns a Future; wrap with "await".

run_on_each_pooled_connection

my @results = $h->run_on_each_pooled_connection($n, sub {
    my ($conn, $harness) = @_;
    return $harness->query_on($conn, $sql);   # a Future
});

The replay-verification seam. Forces $n distinct pool connections to spawn by acquiring (and holding) them -- each spawn replays the owner's on_connect actions via "_run_pool_connect_statement" in DBIO::PostgreSQL::EV::Storage. Then runs $code->($conn, $self) on each held connection, awaits the returned Future, releases every connection, and returns one entry per connection (in spawn order). Each entry is an arrayref of whatever that connection's Future resolved to -- e.g. for a "query_on" op it is the arrayref of raw result rows, so $results[$i][0] is connection $i's first row. Capturing per connection as an arrayref keeps multi-row results from flattening across connections.

$code must return a Future. Croaks if the pool's max_size is smaller than $n (raise pool_size in connect_attrs), or if the pool ever hands back a non-distinct connection (which would mean a spawn did not happen and the replay was not exercised on a fresh connection).

disconnect

Tear down the async backend (shutting the pool, which runs the on_disconnect actions on each live connection) and the schema storage.

REUSE BY AN EXTENSION (e.g. AGE)

An async storage extension composes onto this transport via core's storage-layer composition (core karr #70); it does not ship its own transport. To live-test that its on_connect_call (e.g. connect_call_load_age) replays on pooled EV connections, an extension reuses this harness by supplying its own schema class (carrying the extension's storage layer + component) and its own per-connection assertion:

my $h = DBIO::PostgreSQL::EV::TestHarness->new(
    schema_class  => 'MyApp::Age::Schema',        # registers the Age layer
    connect_attrs => { on_connect_call => 'load_age', pool_size => 3 },
);

$h->run_on_each_pooled_connection(3, sub {
    my ($conn, $harness) = @_;
    # LOAD 'age' + search_path must have replayed on THIS pooled connection
    return $harness->query_on($conn, q{SELECT current_setting('search_path')});
});

The harness stays generic: it never mentions AGE, exposes the schema / sync storage / async backend / pool, and lets the caller add the layer (via schema_class) and assert whatever it needs on each pooled connection.

CAVEAT

This is a live harness: it opens real connections against DBIO_TEST_PG_DSN. It is for driver/extension test suites, not production code. It is excluded from the mock-only offline discipline by design -- gate every consumer with "skip_all_unless_live".

AUTHOR

DBIO Authors

COPYRIGHT AND LICENSE

Copyright (C) 2026 DBIO Authors

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