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/persistencereceives the connection configuration, SQL telemetry, the default link binding and its close on application disposal, and the lazy request-scopedTransactionGuard.kinetis/migrationsrequires the bridge and registers its ownmigrate*commands, which connect throughConnectionFactory::singleSession()(Migrations).kinetis/query-builderneeds no binding. AQueryis mutable and holds one statement, so code constructsnew Query($link)per statement over the bound link; aQueryis never registered as a shared or request-scoped service.kinetis/ormreceives its entity metadata through the bridge’s AOT discovery section, oneOrmFactoryfor the worker, and a lazy request-scopedEntityManagerclosed with its scope (ORM).
The default link’s lifetime¶
With DB_CONNECTION set, the bridge’s package bootstrap builds the
default client, registers close() on AppScope::onDispose() and only
then binds it under its dialect contract. Registering the close first is
what makes a later bootstrap failure, or a failing boot(), still close
a connection that is already open (Ending the application’s lifetime).
The callback holds that exact object, so ownership follows whoever built the link:
The bridge built it. It is closed when the application scope is disposed.
bootstrap.phpbound its own. The bridge’s callback still closes the link the bridge built, if it built one; the replacement is application-owned and nothing here closes it. Register its ownonDispose()if it needs one.No
DB_CONNECTION. No link is built, so there is nothing to register and nothing to close. “No database” is a configuration, not an error.A named connection. Explicit application wiring throughout, including its lifetime.
Request-scoped cleanup is unchanged and separate: TransactionGuard’s
rollbackDangling() and EntityManager’s close() are registered on
the scope that resolved them and run at the end of that unit of work,
not at application disposal.
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’sScopedMessageHandler, for every MCP message over stdio.kinetis/queue’sQueueWorker, for every popped job, andSyncQueue, for everypush(). 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:
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 |
|---|---|---|---|---|
|
|
DSN |
|
|
|
|
|
— |
— |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
— |
— |
|
|
|
|
|
— |
— |
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_SSLMODEisdisable,require(encrypt without verifying the server),verify-caorverify-full.allowandpreferare PostgreSQL only, since MySQL clients have no opportunistic TLS.On MySQL, a verify mode needs
DB_SSL_CA,DB_SSL_CAneeds a verify mode, andverify-caverifies the host name too, asverify-fulldoes.DB_SSL_CERTandDB_SSL_KEYare set together, with aDB_SSLMODEother thandisable. Every driver presents them where the server requires a client certificate, as MySQL’sREQUIRE X509and PostgreSQL’sclientcert=verify-cado.MySQL connections use
utf8mb4unlessDB_CHARSETsays 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
COMMITwith a rollback and no error, so Kinetis ends it as the rollback it is and throwsException\TransactionExceptionrather than reporting a commit that did not happen.PDO MySQL —
PDO::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,ROLLBACKandSAVEPOINTstatements 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¶
Database — connecting, transactions and driver selection in an application.
Appendix: Query Builder — the query builder’s complete contract.
Appendix: Container Lifecycle — request-scope initializers and disposal.
Logging — the logger
rollbackDangling()reports through.