Appendix: Databases

The complete contract behind Database: which package owns what, using the packages without Kinetis, and how the drivers, transactions and TransactionGuard behave. The guides — Database, Query Builder and ORM — cover everyday use, and Appendix: Satellite Packages lists each package’s classes.

Packages and wiring

kinetis/persistence holds the drivers, SqlConnectionFactory, TransactionGuard and the Contract\SqlLink/MysqlLink/PostgresLink contracts, and depends on no Kinetis package. kinetis/database-bridge wires it into a Kinetis application.

Kinetis\DatabaseBridge\ConnectionFactory::fromConfig() reads a connection’s DB_* keys into a ConnectionDefinition, validating every key before a driver is constructed, and builds the client through SqlConnectionFactory::create() with Kinetis telemetry as its instrumentation. Its $driver argument overrides DB_DRIVER for one call. ConnectionFactory::singleSession() builds the single-session client from the same keys.

The bridge composes with each database package on that package’s terms:

  • kinetis/persistence receives the connection configuration, SQL telemetry, the default link binding and its close on application disposal, and the lazy request-scoped TransactionGuard.

  • kinetis/migrations requires the bridge and registers its own migrate* commands, which connect through ConnectionFactory::singleSession() (Migrations).

  • kinetis/query-builder needs no binding. A Query is mutable and holds one statement, so code constructs new Query($link) per statement over the bound link; a Query is never registered as a shared or request-scoped service.

  • kinetis/orm receives its entity metadata through the bridge’s AOT discovery section, one OrmFactory for the worker, and a lazy request-scoped EntityManager closed with its scope (ORM).

The request-scoped TransactionGuard

The bridge’s package bootstrap registers an AppScope::onRequestScopeCreated() initializer (see Appendix: Container Lifecycle) that binds TransactionGuard on every RequestScope. The first resolution in a scope builds the guard and registers its rollbackDangling() on that scope’s disposal; a scope that never resolves it builds none. Every entry point takes its scopes from AppScope::createRequestScope(), so each unit of work is covered:

  • Kernel, for every HTTP request, an MCP message over HTTP included.

  • bin/kinetis, for every command that has not declared #[Command(bootstrap: false)]. A bootstrap-free command runs no package bootstrap, so its scope carries no initializer and there is no bound connection to guard.

  • kinetis/mcp’s ScopedMessageHandler, for every MCP message over stdio.

  • kinetis/queue’s QueueWorker, for every popped job, and SyncQueue, for every push(). A job that begins a transaction and returns or throws without closing it does not leave that transaction open into whatever job the same connection serves next.

Request-scoped wiring a database package needs belongs in the bridge’s request-scope initializer, as the guard’s does. Replacing the guard means registering a later onRequestScopeCreated() initializer that binds the replacement on the scope and registers its own disposal callback there. A TransactionGuard bound on AppScope would be one worker-lifetime guard shared by every unit of work, never a safe override.

Registering connections in bootstrap.php

A registration in bootstrap.php wins over the bridge’s default binding, and the link it registers is the application’s to close — see The default link’s lifetime. Register the default connection to set pool options in code, which win over DB_MAX_CONNECTIONS and DB_WARM_CONNECTIONS, and a named connection under an id of its own:

bootstrap.php
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
use Kinetis\DatabaseBridge\ConnectionFactory;
use Kinetis\Persistence\Contract\MysqlLink;

return static function (AppScope $app, Config $config): void {
    $app->instance(MysqlLink::class, ConnectionFactory::fromConfig($config, poolOptions: [
        'maxConnections' => 12,
        'warmConnections' => 12,
    ]));

    $app->instance('db.reporting', ConnectionFactory::fromConfig($config, 'reporting'));
};

A named connection reads every key with its name inserted after DB_, following Configuration’s named-connection convention:

DB_REPORTING_CONNECTION=pgsql
DB_REPORTING_HOST=reporting.internal
DB_REPORTING_PASSWORD=secret

Only the default connection is injected by its contract type. A named connection is retrieved by its id ($app->get('db.reporting')) and passed explicitly to whatever runs on it, such as new Query($link) or an OrmFactory (ORM).

Without Kinetis

kinetis/persistence, kinetis/query-builder and kinetis/orm run in any PHP process. The host then does what the bridge does in an application: it builds each client once, gives every unit of work its own TransactionGuard and EntityManager, and closes the clients when the process stops using them.

composer require kinetis/persistence
use Kinetis\Persistence\ConnectionDefinition;
use Kinetis\Persistence\ConnectionOptions;
use Kinetis\Persistence\SqlConnectionFactory;

// Once per process.
$db = SqlConnectionFactory::create(new ConnectionDefinition(
    dialect: 'pgsql',   // or 'mysql'
    host: 'db.internal',
    database: 'shop',
    user: 'shop',
    password: $password,
    port: 5432,         // the dialect's own when omitted
    driver: 'auto',     // 'auto' (the default), 'native' or 'pdo'
    options: new ConnectionOptions(sslMode: 'verify-full', sslCa: '/etc/ssl/certs/db-ca.pem', maxConnections: 12),
    warmConnections: 0, // connections opened at construction
));

The definition rejects an unknown dialect or driver, a port outside 1–65535 and a negative warm count, and ConnectionOptions validates its own fields, each with an InvalidArgumentException at construction. Build one client per connection and keep it for the process’s lifetime: the native clients are connection pools. close() takes a client out of service when the process is done with it, and every later call throws Exception\ConnectionException.

A guard per unit of work

use Kinetis\Persistence\TransactionGuard;

$guard = new TransactionGuard($logger);

try {
    $handler->handle($job, $guard);
} finally {
    $guard->rollbackDangling();
}

Calling rollbackDangling() from finally closes what the unit of work left open whether it returned or threw. A guard is never shared between units of work.

Queries and entities

new Query($db) works on a client built this way exactly as in an application (Query Builder). For the ORM, build the metadata and the factory once, and open and close a manager per unit of work:

use Kinetis\Orm\Metadata\MetadataRegistry;
use Kinetis\Orm\OrmFactory;

// Once per process.
$orm = OrmFactory::create($db, MetadataRegistry::fromClasses([Article::class]));

// Once per unit of work.
$entities = $orm->open();

try {
    $article = $entities->repository(Article::class)->findOrFail($id);
    $article->publish();
    $entities->flush();
} finally {
    $entities->close();
}

Article is the entity from ORM. OrmFactory::create() takes the client, never a transaction, and the factory holds no unit-of-work state, so one serves the whole process. close() never flushes and leaves the client open. MetadataRegistry::toArray() and fromArray() let a build step export the metadata so a worker loads it without scanning a directory, as the package README’s “Metadata” shows. When a flush() or an OrmFactory::transaction() has committed is covered in Flushing and transactions, and holds without Kinetis unchanged.

Instrumentation

Either SqlConnectionFactory method takes a Contract\SqlInstrumentation as its second argument. Every client it builds, and every transaction that client begins, reports five moments through it: a statement dispatched (queryDispatched(), with mysql or postgresql and the SQL text), sent to the server (queryServerStarted(), again when a pooled driver retries on a fresh connection), and reaped (queryReaped(), with the failure when there is one); a transaction started, and ended (transactionEnded(), with commit, rollback or unknown — see Transactions). A started moment returns an opaque token that its ended moment receives.

A transaction reports the three query moments for each statement run through it, against the SQL the caller wrote rather than the text a driver put on the wire, and reports them around the driver call alone — so a statement its pre-flight refuses reports none of them, and one the server answered is reaped before the transaction settles or hands its connection back. queryServerStarted() follows queryDispatched() immediately there, since a transaction’s connection is already pinned and nothing waits between the two. The BEGIN, COMMIT and ROLLBACK a transaction sends are not query moments at all: the started/ended pair is what reports the transaction’s boundary.

Every moment runs inline, on the Fiber issuing the statement, inside the driver’s own call. An implementation must be synchronous — it never suspends the Fiber — bounded in time, and free of blocking I/O; anything it exports goes to separately owned, bounded infrastructure it hands the data to. A client keeps its instrumentation for the client’s whole lifetime — the process’s, under a persistent worker — so an implementation holds no mutable request or unit-of-work state. queryDispatched() receives the complete SQL text. Bound parameter values are never passed, but the text can carry literals and is sensitive: an implementation must not log or export it verbatim.

The client contains whatever the instrumentation throws. A started moment that fails hands back null, the failure is reported once through error_log() naming the moment and both classes but never the exception’s message, and the query result, transaction outcome and connection release are exactly what they are without instrumentation. A client built with none reports nothing. kinetis/database-bridge’s clients report through Kinetis telemetry (Telemetry).

Statements and arguments

Every driver returns fully-buffered results — part of the SqlResult contract, so a caller stops iterating whenever it likes and nothing is left to drain. Parameterized calls go through execute(): real server-side binding on PostgreSQL (pg_send_query_params) and PDO, and escaped client-side interpolation on native MySQL, whose async mode has no bind step. That client pins the connection charset explicitly, so escaping always runs against a known charset.

One call carries one statement. SQL producing more than one result set throws Exception\QueryException rather than returning the first: draining the rest would block the event loop on the async drivers, and an unread result set left on a pooled connection fails whatever borrows it next. The connection survives either way — mysqli discards its own for the pool to replace, and the others drain what is left. PDO PostgreSQL is the one case the rule cannot reach: libpq runs a semicolon-separated string as a single command and reports only its last result. Issue one query()/execute() per statement.

A later result set can carry the server’s own error rather than rows — what a stored procedure raising SIGNAL after a SELECT produces. It reaches the caller as Exception\QueryException like any other server error, and the span records the failure it is: nothing is built, and nothing reported, until every result set has been read.

COPY is not supported on the native PostgreSQL driver: it puts the connection into a streaming mode the driver has no protocol for, and the server holds it there waiting for data that is never coming. The connection is taken out of service and replaced by the pool, and the caller gets an Exception\ConnectionException — the exchange was lost, which is a different thing from a statement the server refused. Use a server-side COPY — one that reads or writes a file the server itself can reach — or ordinary statements.

The pre-flight

Every parameterized call passes one pre-flight first — before the driver opens an instrumentation span, asks its pool for a connection, opens one, sets that connection’s charset and collation, or prepares a statement. Keying, count and value kind are all settled there, so an argument list outside the contract costs the caller one Exception\QueryException and nothing else: nothing is sent to a server, and nothing is opened to send it to. A cold or unreachable client refuses the call exactly as a warm one does.

That ordering is itself the contract, not an optimization. A driver checking on its way through execution would answer a caller’s own mistake with a ConnectionException from a server it was never going to send to, and would answer the identical call differently depending on whether its pool happened to be warm already. Transactions run the same pre-flight, ahead of anything reaching their pinned connection.

Arguments

execute() takes exactly one argument per ? placeholder, on every driver: a call carrying more or fewer throws naming both counts. That is a contract rather than an incidental check on the PDO drivers, whose prepared statements are memoized (Prepared statements on PDO): a reused statement still holds what was last bound to it, so a short argument list without the check would silently execute against the previous call’s leftover value in the position it omitted.

Those arguments are a list — keys 0..n-1, in the order the placeholders appear. An associative or sparse array is rejected rather than reindexed: pdo binds by iteration order while native indexes by position, so the two would read ['b' => 2, 'a' => 1] as two different queries. Reindexing it here would settle that disagreement on an argument list the call site never wrote, which is the mistake worth seeing.

Each argument is one of five kinds: null, bool, int, a finite float, or string. Anything else — an array, a resource, an object, INF, NAN — throws Exception\QueryException naming the position and the type, with the whole list read before a single value is encoded or bound, so a refused call leaves no position bound. PostgreSQL adds one rule of its own: a string holding a NUL byte is refused, since libpq carries text parameters as C strings and the value would reach the server truncated at that byte. MySQL takes one intact, which is what a VARBINARY/BLOB column needs. The narrower set is the one all four drivers agree on, so the same call is refused the same way whichever driver DB_DRIVER picked. Format a DateTimeInterface, an enum or a JSON payload at the call site, where the shape is your decision rather than the driver’s.

Placeholders

Which ? is a placeholder is decided by one dialect-aware scan of the SQL text, shared by every driver — the native drivers substitute each one they find, the PDO drivers count them for the argument check above. That scan is execute()’s: query() takes complete SQL and Kinetis reads none of it. The scan recognizes '...'/"..."/`...` quoting, --/#//* */ comments, and PostgreSQL’s $$...$$/$tag$...$tag$ dollar-quoted strings, so a ? inside any of those is data, never a slot. PostgreSQL’s own jsonb containment/existence operators (?, ?|, ?&) are lexically identical to a placeholder at the position they appear — write ??, ??|, ??& in an execute() string to mean the literal operator rather than a bind slot. The doubling is that scanner’s escape rather than SQL, and the scan is what removes it again. query() runs no scan, so nothing removes a doubling there — and PDO runs a placeholder parser of its own over whatever string it is handed. A literal ? operator belongs in an execute() call, where the rule holds on every driver. A $ that continues the identifier to its left opens nothing: col$tag$ is one PostgreSQL identifier, and a dollar-quoted literal following an identifier or a keyword has to be separated from it (col $tag$), the same way the server lexes it. A dollar-quote tag is spelled with PostgreSQL’s own unquoted-identifier bytes, non-ASCII included, so $é$ ... $é$ quotes its contents exactly as $body$ ... $body$ does.

Two comment rules match real MySQL rather than a generic reading of the syntax. -- only opens a comment against MySQL when the second dash is followed by whitespace, a control character, or the end of the string — 5--? is 5 - - ?, not a comment (PostgreSQL has no such condition; a bare -- always opens one there). MySQL/MariaDB’s executable comments (/*! ... */, /*M! ... */) are scanned as ordinary comments: their text is copied through verbatim for the connected server to interpret on its own, and a ? inside one is data rather than a bind slot. A query meaning one to be bound fails loudly — on the argument count, or on the server that executes the comment — rather than binding something silently. Write the bound value outside the comment.

Pools and sessions

The native clients are connection pools: connections open lazily up to maxConnections, are reused across requests under a persistent worker, and are discarded and replaced when they die. A connection being opened counts against the cap for the whole attempt, not only once it is finished, so a burst of callers arriving at an empty PostgreSQL pool opens maxConnections connections between them rather than one each.

A definition’s warmConnections opens that many connections at construction instead of on first use, clamped to maxConnections. Through the bridge it comes from DB_WARM_CONNECTIONS or $poolOptions['warmConnections'], and maxConnections from DB_MAX_CONNECTIONS or $poolOptions['maxConnections'], an explicit pool option winning over its key. Every driver also exposes the underlying call, warmUp(?int $connections = null), where null warms the whole pool. Under a persistent worker, warming is load-bearing for the native MySQL driver: see Performance tuning’s “mysqli’s poll limit”.

The auto split is measured, not aesthetic: under boot-and-die PHP-FPM, per-request connection handshakes and per-query client CPU dominate, and an async client’s I/O overlap cannot pay for them (sub-millisecond queries leave nothing to overlap); under a persistent worker, connections amortize across requests and native async fan-out keeps its benefits at native protocol cost.

Sizing pools under persistent workers

DB_MAX_CONNECTIONS defaults to 8, and callers beyond the cap wait inside the pool for a free connection. It does not apply to PDO, which holds one connection per process.

Each FrankenPHP worker thread and each RoadRunner worker process runs the bootstrap chain and builds its own pool, so the ceiling the database sees is:

worker count × DB_MAX_CONNECTIONS

FrankenPHP’s worker.num or RoadRunner’s pool.num_workers is the worker count: 128 worker threads with DB_MAX_CONNECTIONS=256 can open 32,768 connections. Keep that product comfortably below the database’s own max_connections. A request whose concurrently() fan-out exceeds its worker’s pool queues inside the pool, which adds latency to that one request, a far softer failure than connections the database rejects. Runtime Adapters covers sizing the workers.

A connection the server closed

A pooled connection the server closes (an idle socket past wait_timeout, an administrative KILL, a network drop) costs exactly one query. Writing to a socket whose peer is already gone is buffered locally rather than failing, so the first query on a newly-dead connection dispatches successfully and only discovers the death while reading the result — surfacing as an Exception\ConnectionException where the client reports the session gone, and an Exception\QueryException where the server answered. Either way the caller has to handle it. Retrying it automatically is not an option: at that point the statement may already have executed, and replaying a non-idempotent one silently is worse than an error. A transaction pinned to that connection ends with it, discarding the connection rather than rolling back on a session that is gone. The next query’s dispatch does fail immediately, and that is retried transparently on a fresh connection.

PDO sessions

A PDO client holds one connection at a time and opens it lazily. A session it can carry no more work on — abandoned by a transaction, ended by a terminal MySQL lock failure (1205/1213), or left in a result state that could not be cleared — goes back to the server, and the next call opens a fresh one. close() is the separate, final ending: it takes the client itself out of service, and every later call throws Exception\ConnectionException. The two differ wherever a process outlives one session, which under auto is every process that is not a persistent worker — a queue:work CLI worker included.

Single-session clients

SqlConnectionFactory::singleSession() — and ConnectionFactory::singleSession() over a connection’s DB_* keys — builds the other policy: a PDO client, whatever the definition’s driver says, pinned to the session it opens, closing rather than reconnecting if that session is discarded. It is for work that lives in the session itself — a session-scoped advisory lock, a temporary table — where a replacement is a different session holding none of it, and running on one quietly would be worse than stopping. kinetis/migrations’ commands run every migration on one (see Migrations).

Prepared statements on PDO

The PDO drivers run with native (non-emulated) prepares, where every prepare() is its own server round trip — so execute() memoizes prepared statements per SQL string for the connection’s lifetime. A loop issuing the same parameterized statement N times costs N+1 round trips instead of 2N. The cache holds at most 256 statements (workloads that interpolate values into their SQL text instead of binding reset it on overflow rather than growing it forever) and goes with the connection it was built on, so a replacement connection starts an empty one. A transaction runs on the client’s own connection, so it shares that one cache rather than re-preparing what it already holds. Server-side prepared statements are scoped to a database connection, which is why Database warns against multiplexing proxies.

What blocks on the native drivers

MySQL. mysqli cannot expose its socket to the event loop, so while its queries are in flight the client polls with a short (1 ms) blocking window per loop turn — indistinguishable from a blocking wait when the request’s only outstanding work is the database, and at worst a 1 ms delay per turn for anything else scheduled concurrently. Opening a connection blocks outright: mysqli has no async connect primitive, so a connection opened under load stalls the worker thread for a TCP connect, a TLS handshake and an auth exchange.

PostgreSQL. ext-pgsql exposes its socket (pg_socket()), and the driver keeps every phase off the loop. Connecting runs PGSQL_CONNECT_ASYNC and drives the handshake from readiness waits, bounded by DB_CONNECT_TIMEOUT. Dispatch puts libpq into nonblocking mode first, so a parameter larger than its output buffer leaves bytes queued and the loop pushes them out, instead of one pg_send_query_params() call flushing megabytes synchronously. Queued output is waited on in both directions: the server sends NOTICE and NOTIFY traffic while a statement is still going out, so a readable socket is consumed before the flush continues, and a client that only watched for writability would fill both socket buffers and stop. Disposal ends the connection’s transport rather than draining it, which is why this driver needs ext-sockets: closing a libpq connection the ordinary way reads every outstanding result first, and a statement still running — or a COPY the server is waiting on input for — would be a wait the whole loop pays.

One thing stays synchronous: libpq resolves the host name itself, inside the connect call, so a slow DNS resolver stalls the worker thread for as long as it takes.

Connection options

Each option is a connection-scoped DB_* key (Configuration lists them) that the selected driver translates. A driver refuses an option it cannot honor when the client is built, naming the option and the driver, so a configuration never means something different on another runtime:

canonical key

native mysqli

PDO mysql

native pgsql

PDO pgsql

DB_CHARSET

set_charset()

DSN charset=

client_encoding

client_encoding

DB_COLLATION

SET NAMES ... COLLATE

SET NAMES ... COLLATE

DB_SSLMODE

MYSQLI_CLIENT_SSL + verify flag

Pdo\Mysql::ATTR_SSL_*

sslmode

sslmode

DB_SSL_CA

ssl_set()

Pdo\Mysql::ATTR_SSL_CA

sslrootcert

sslrootcert

DB_SSL_CERT

ssl_set()

Pdo\Mysql::ATTR_SSL_CERT

sslcert

sslcert

DB_SSL_KEY

ssl_set()

Pdo\Mysql::ATTR_SSL_KEY

sslkey

sslkey

DB_CONNECT_TIMEOUT

MYSQLI_OPT_CONNECT_TIMEOUT

PDO::ATTR_TIMEOUT

connect_timeout

connect_timeout

DB_APP_NAME

application_name

application_name

DB_COMPRESSION

MYSQLI_CLIENT_COMPRESS

Pdo\Mysql::ATTR_COMPRESS

A TLS connection that verifies the server, with a client certificate for servers that authenticate the client too:

DB_SSLMODE=verify-full
DB_SSL_CA=/etc/ssl/certs/db-ca.pem
DB_SSL_CERT=/etc/ssl/certs/db-client.pem
DB_SSL_KEY=/etc/ssl/private/db-client.key
  • DB_SSLMODE is disable, require (encrypt without verifying the server), verify-ca or verify-full. allow and prefer are PostgreSQL only, since MySQL clients have no opportunistic TLS.

  • On MySQL, a verify mode needs DB_SSL_CA, DB_SSL_CA needs a verify mode, and verify-ca verifies the host name too, as verify-full does.

  • DB_SSL_CERT and DB_SSL_KEY are set together, with a DB_SSLMODE other than disable. Every driver presents them where the server requires a client certificate, as MySQL’s REQUIRE X509 and PostgreSQL’s clientcert=verify-ca do.

  • MySQL connections use utf8mb4 unless DB_CHARSET says otherwise, never the server’s default.

Each of these rules fails when the client is built, not on the first query. ConnectionOptions also refuses a charset or collation outside [A-Za-z0-9_], since both reach a driver as SQL or an API call; an empty CA, certificate or key path; a connect timeout below one second; and a maxConnections below 1.

Warning

PostgreSQL refuses a client key readable beyond its owner: make it 0600, or 0640 when owned by root. The refusal comes from libpq at connect time, so it surfaces as a connection failure rather than a configuration error, and a deployment that works against MySQL can fail against PostgreSQL for this reason alone.

The MySQL PDO driver sets Pdo\Mysql::ATTR_*, not the equivalent PDO::MYSQL_ATTR_* constants deprecated as of PHP 8.5: identical underlying values, without the deprecation notice.

The PDO drivers refuse a ; or a NUL byte in any value they put in a DSN — host, database, and the PostgreSQL options. PDO’s DSN grammar has no quoting for either: pdo_mysql splits its DSN on ;, pdo_pgsql turns every ; into a space for libpq, and a NUL ends the C string, so such a value would be read as further connection parameters.

Transactions

beginTransaction() pins one connection and returns a transaction with the same query()/execute() surface as the client. Its static type keeps the link’s dialect: a MysqlLink begins a MysqlTransaction, a PostgresLink a PostgresTransaction, and code typed against the generic SqlLink gets a SqlTransaction. TransactionGuard keeps that type through its own beginTransaction() and the transaction() callback, so Query Builder’s new Query($tx) takes the transaction as it is; Query detects the dialect from the concrete object rather than the callback’s declared parameter type. Every statement belonging to the transaction goes through that object: a client-level call while it is open is refused rather than served, on every driver.

A PDO client holds one connection, so the transaction owns the client for as long as it runs — query(), execute() and a second beginTransaction() on the client throw Exception\TransactionException until it ends. An async client pools connections, so the refusal is scoped to the Fiber holding the transaction: another Fiber keeps its own connection and can open a transaction of its own, while the holder’s client-level call would land on a different connection, in autocommit, outside the transaction it believes it is in.

A transaction also belongs to the Fiber that began it. query(), execute(), commit() and rollback() from any other Fiber throw: the pinned connection carries one statement at a time, so a second Fiber dispatching on it would corrupt both. Give that Fiber its own transaction instead.

rollback() on a transaction that has already ended is a no-op, so catch (Throwable) { $tx->rollback(); throw $e; } needs no isActive() check first. commit() throws there. A COMMIT or ROLLBACK the server refuses throws and discards the connection rather than handing it back: what is left on it is a transaction of unknown outcome.

The span’s outcome attribute says only what the server confirmed: commit for a COMMIT it acknowledged, rollback for a ROLLBACK it acknowledged, and unknown for everything else — a lost connection, a discarded connection, a finish nothing answered, a transaction the server ended on its own. A transaction that never sent either statement is unknown too: the work is discarded with the session, which is not the same as a rollback the server reported.

isActive() stays true until the connection has been handed back and the span closed, which is a moment later than the last statement being accepted: while a COMMIT is on the wire the transaction refuses further statements but still owns its connection. That window is what close() — and so TransactionGuard at the end of a unit of work — has to be able to reach. Closing there takes the connection out from under the finish, the owning Fiber comes back with an Exception\ConnectionException, and the outcome is recorded once, as unknown.

What the server does underneath the object

A transaction can end where the server is while the object still believes it is open, and the statements that follow would then run in autocommit. Each driver settles it from what it can see locally, without a probe round trip, after every statement — succeeded or failed — and before the next one:

  • PostgreSQL, both drivers — libpq tracks the transaction status the server sent with its last message, so an implicitly ended transaction is visible directly. A failed statement aborts the whole transaction there rather than ending it: the server answers a later COMMIT with a rollback and no error, so Kinetis ends it as the rollback it is and throws Exception\TransactionException rather than reporting a commit that did not happen.

  • PDO MySQLPDO::inTransaction() reads the same status flag, which is what makes MySQL’s implicit commit on DDL (see Migrations) visible.

  • Native mysqli — mysqli exposes no transaction-status accessor, so an implicit commit cannot be seen at all: keep DDL and raw COMMIT, ROLLBACK and SAVEPOINT statements out of a transaction on this driver.

Where a driver can see it, isActive() both reports the transaction gone and settles it — connection handed back, nothing left to close.

MySQL lock failures

Both MySQL drivers add one rule the status flag is not allowed to decide. A deadlock (error 1213) is always resolved by rolling the losing transaction back whole. A lock-wait timeout (1205) rolls back only the statement — unless innodb_rollback_on_timeout is on, where it rolls back the whole transaction too, and nothing in the error says which setting is live. Both therefore end the transaction, discard its connection rather than handing it to the next caller, and record the outcome as unknown. The server’s own error number is the Exception\QueryException’s code, so a caller that wants to retry can still tell the two apart:

try {
    $guard->transaction($db, $work);
} catch (QueryException $e) {
    if ($e->getCode() === 1213) {
        // Deadlock: the server rolled it back whole; retrying is the
        // documented response.
    }
}

Closing from another Fiber

close() is the lifecycle escape hatch — what TransactionGuard runs at the end of a unit of work — and is callable from any Fiber. On the owning Fiber it is an ordinary rollback. From another it ends the transaction and takes the connection out of service rather than sending a ROLLBACK down one the owner may be using: the pool replaces it, and the server rolls the work back with the session. A statement still in flight when that happens is settled where it stands, with an Exception\ConnectionException saying the connection went before the server acknowledged anything — the statement may well have run. Closing a PDO client does the same to the transaction holding it, since both run on the client’s one connection.

A transaction nothing ends

Calling beginTransaction() on a link makes ending the transaction the caller’s own job, and an exception path that drops the object without reaching commit(), rollback() or close() leaves nobody holding it: a driver keeps a transaction’s owner Fiber, never the transaction. The last reference going away is where such a transaction ends. Its connection is discarded, the Fiber’s client-level ownership goes with it, and the span closes with the outcome unknown.

Nothing goes on the wire there. That cleanup runs in a destructor, which cannot suspend and so cannot wait for an answer; a ROLLBACK dispatched with nobody to read the reply would sit on a connection about to serve someone else. The server rolls the work back as the session goes, which is not a ROLLBACK it acknowledged — and the span says so rather than claiming one.

What that costs is the connection: an async client’s pool opens a replacement, and a PDO client, holding one at a time, opens a fresh one on its next call. TransactionGuard::transaction() costs neither — it ends the transaction on every path out of the work, so the connection goes back to the pool with the outcome the server confirmed. Use the guard; the discard is a safety net for a connection, not a way to end a transaction.

TransactionGuard

Connection pooling is the drivers’ own job. What no driver can know about is where a unit of work — a request, a job, a command — ends: if application code begins a transaction and something throws before it is explicitly committed or rolled back, nothing commits or rolls it back, and it holds its connection — and the locks on it — for as long as anything still references it.

Kinetis\Persistence\TransactionGuard is the safety net for exactly this. One guard belongs to one unit of work and tracks every transaction it starts. transaction() commits on success, rolls back on any throw, and always closes before returning, so it leaves nothing for the safety net to find. rollbackDangling(), called when the unit ends, closes a transaction begun through the guard’s own beginTransaction(), held open across calls, that never reached commit() or rollback().

When it does find something to close, it logs a warning through the guard’s logger (see Logging) — a genuine anomaly signal, since it means a transaction was left open somewhere it should not have been.

What it finds is what it started: $guard->beginTransaction($link) and transaction(), the two calls that put a transaction on its tracked list. One begun straight off the link is not tracked here or anywhere else, and ends by being dropped — connection discarded, outcome unknown. Route a transaction held open across several calls through the guard, and disposal rolls it back on the wire and hands the connection back instead.

Both methods work identically for MySQL and PostgreSQL: all drivers implement the same Contract\SqlLink/SqlTransaction abstraction, so TransactionGuard never needs to know which one it is talking to.

What happens when cleanup itself fails

Inspecting a transaction (isActive()) or closing one (close()) is itself a network call to a driver — it can fail, and this class is designed around that possibility rather than assuming it away.

rollbackDangling() is best-effort across the complete tracked set, not fail-fast. One transaction’s isActive() or close() throwing never prevents the rest from being attempted — a cleanup fault on one connection must not leak transactions and locks on every other tracked one. Tracking is cleared up front, before any transaction is touched, so a transaction this call already attempted — successfully or not — is never retried by a later call. Each failure is logged individually (error, not warning), and the first of them is rethrown once every tracked transaction has been attempted. Under the bridge it propagates from RequestScope::dispose(), which runs every dispose callback to completion regardless of one throwing (see Appendix: Container Lifecycle) and rethrows only once all of them have finished.

It closes rather than rolls back because the end-of-unit cleanup runs in the host’s own context while the Fiber that leaked the transaction may be parked: close() from a foreign Fiber ends the transaction and discards its connection instead of putting a concurrent ROLLBACK on it.

transaction() never lets a rollback failure erase the failure that triggered cleanup. If your callback (or commit()) throws, and the resulting rollback attempt also throws, the rollback failure is logged and the original exception — the one your code actually threw — is what propagates, unchanged. The transaction is untracked either way, whether the rollback attempt succeeded or failed: transaction() only ever makes one cleanup attempt of its own, and leaving a failed one tracked would defer a second attempt to rollbackDangling() at the end of the unit of work — from a finally block, where a second failure there would silently replace the exception already propagating from transaction(), undoing the same guarantee one level up.

None of this depends on the logger being healthy. Psr\Log\LoggerInterface gives no no-throw guarantee, and a failing log handler — a broken remote sink, a full disk — is a real production scenario. Every log call this class makes is wrapped so an exception from the logger itself is discarded: it can never be misclassified as a rollback failure, never prevent a later tracked transaction from being attempted, and never replace an already-propagating exception the way an unprotected logger call could.

See also