\Drupal\strata\Capture\EventSubscriber StatementCaptureSubscriber

Sees every write the database performs, including the ones nothing else instruments.

Entity, config and state capture cover what goes through an API. This covers what does not: a raw Connection::insert() into a custom table, an update hook's DDL, a contributed module writing directly. Without it a site can be modified in ways the backup never hears about, and the backup would not know it had a gap.

What it records is that a table changed, not which rows. A statement says which rows it touched only in its WHERE clause, and evaluating that would mean running the query again. So one operation per table per window, which keeps the tree bounded by the schema rather than by traffic, and the reconciler turns a dirty table into the per-row detail a restore can actually use.

Cost. The verb guard runs on every statement and reads one keyword. Enabling the events at all costs more than the guard does: StatementExecutionEndEvent only fires when the Start event is also enabled, and Start's constructor calls Connection::findCallerFromDebugBacktrace(), measured at 0.78 us at stack depth 20 and 2.99 us at depth 100. Total capture overhead stays under 10 us per mutation, about 0.6 ms on a 200-query request. strata:calibrate measures it on the real host and the settings form shows that number next to the switch.

The connection's table prefix comes off before anything else looks at the name. The SQL a statement event carries is already prefixed, and a prefix is a settings.php detail that can differ between the site a backup came from and the site it goes back to. It also hides this module's own tables from CaptureScope::coversTable(), which would make journaling a write a write that gets journaled.

The gap this has, stated plainly. Statement events are per-connection and off by default, so they are enabled on the first request event this subscriber sees. Queries that run before that - bootstrap, routing, session - are not captured. That is what the reconciler's watermark is for: a table that changed without a captured operation is drift, and drift is reported.

The journal arrives as a factory, and that is load bearing. Building a journal reads strata.settings, and reading configuration queries the database, and a query dispatches the very event this class listens for. Taking strata.journal as a constructor argument therefore made the container resolve this subscriber while it was already resolving it, which is a circular reference and takes the site down on any request that logs. So the factory is injected and journal() resolves once on first use, by which point the container has finished with this object. The re-entrancy flag guards the resolution itself against being entered twice; it is defence for a future caller, because nothing on the statement path reaches journal() today.

Summary

Methods
Properties
Constants
__construct
getSubscribedEvents
onRequest
onTerminate
enable
disable
isTapping
onStatement
commit
pending
writesTo
No public properties found
SAMPLE_LENGTH
MAX_TABLES
No protected methods found
No protected properties found
No protected constants found
journal
recordDisabled
note
actor
now
tapping
dirty
journal
resolving
database
journalFactory
scope
currentUser
logger
ledger
requestId
No private constants found

Constant

SAMPLE_LENGTH

SAMPLE_LENGTH = 200

Longest statement fragment kept as a sample.

Enough to recognise the query in a timeline. The full text is not stored: an unbounded column fed by every write on a busy site is how an audit table becomes the largest thing in the database, and the statement is not what a restore replays anyway.

MAX_TABLES

MAX_TABLES = 200

Most distinct tables to accumulate in one request before new ones are dropped.

A bound rather than a policy. A request touching more tables than this is a migration or an update hook, and the reconciler covers what falls off the end.

Properties

$tapping

$tapping : bool

Whether events have been enabled on the connection.

Type

bool

$dirty

$dirty : array

What each table saw this request, keyed by table name.

Type

array<string, TableWrites> —

$journal

$journal : ?\Drupal\strata\Journal\JournalInterface

The journal, once something has needed it.

Type

JournalInterface|null

$resolving

$resolving : bool

Whether a journal is being resolved right now.

Resolving one reads configuration, and reading configuration queries. Nothing on the statement path asks for the journal today, so this is a guard against a future one rather than a live mechanism.

Type

bool

$database

$database : \Drupal\Core\Database\Connection

Type

Connection

$journalFactory

$journalFactory : \Drupal\strata\Journal\JournalFactory

Type

JournalFactory

$scope

$scope : \Drupal\strata\Capture\CaptureScope

Type

CaptureScope

$currentUser

$currentUser : \Drupal\Core\Session\AccountProxyInterface

Type

AccountProxyInterface

$logger

$logger : \Psr\Log\LoggerInterface

Type

LoggerInterface

$requestId

$requestId : string

Type

string

Methods

__construct()

__construct(\Drupal\Core\Database\Connection  $database, \Drupal\strata\Journal\JournalFactory  $journalFactory, \Drupal\strata\Capture\CaptureScope  $scope, \Drupal\Core\Session\AccountProxyInterface  $currentUser, \Psr\Log\LoggerInterface  $logger, \Drupal\strata\Health\HealthLedgerInterface|null  $ledger = null, string  $requestId = '') : mixed

Constructs the subscriber.

Parameters

\Drupal\Core\Database\Connection $database

The connection events are enabled on.

\Drupal\strata\Journal\JournalFactory $journalFactory

Builds the journal on first use. Deliberately not the journal itself; see the class docblock.

\Drupal\strata\Capture\CaptureScope $scope

Decides whether the tap runs and which tables it covers.

\Drupal\Core\Session\AccountProxyInterface $currentUser

Attributes an operation to whoever caused it.

\Psr\Log\LoggerInterface $logger

Records a capture that failed.

\Drupal\strata\Health\HealthLedgerInterface|null $ledger

Records the tap switching itself off, so the failure reaches the health dashboard and not only dblog. NULL leaves the log line as the only trace.

string $requestId

Groups every operation captured in one request.

Returns

mixed —

getSubscribedEvents()

getSubscribedEvents() : array<string,mixed>

{@inheritdoc}

Returns

array

Events this subscriber listens to.

onRequest()

onRequest(\Symfony\Component\HttpKernel\Event\RequestEvent  $event) : void

Turns the tap on for this request.

Parameters

\Symfony\Component\HttpKernel\Event\RequestEvent $event

The request event, unused beyond triggering this.

Returns

void —

onTerminate()

onTerminate(\Symfony\Component\HttpKernel\Event\TerminateEvent  $event) : void

Journals what the request touched, once the response has been sent.

Parameters

\Symfony\Component\HttpKernel\Event\TerminateEvent $event

The terminate event, unused beyond triggering this.

Returns

void —

enable()

enable() : void

Enables statement events on the connection.

Public because a request is not the only thing that writes: cron, a drush command and a queue worker all need to call this, and none of them dispatches a kernel request event.

Returns

void —

disable()

disable() : void

Turns the tap off.

Used by a restore, which writes a great deal and whose writes are already described by the commit it is restoring from, and by a calibration run measuring the cost of the tap itself.

Returns

void —

isTapping()

isTapping() : bool

Whether the tap is currently on.

Returns

bool —

TRUE when statement events are enabled.

onStatement()

onStatement(\Drupal\Core\Database\Event\StatementExecutionEndEvent  $event) : void

Notes a statement that changed something.

This is the widest blast radius in the module, so it is the one path that must never throw. It runs inside Connection::execute() on every write the site performs, and an exception here does not fail a capture - it fails the query, and with it the request, on every write. Every other capture source already holds to "a backup never takes a save down with it"; this one reads configuration on its first call and folds a value object on every later one, and neither is something to bet a site's availability on.

A failed classification is dropped rather than retried. The table it belonged to is then a table that changed with no captured operation, which is exactly what the reconciler's watermark reports as drift, so the gap is visible rather than silent.

Parameters

\Drupal\Core\Database\Event\StatementExecutionEndEvent $event

The statement that just ran.

Returns

void —

commit()

commit() : int

Journals one operation per table touched, and clears the accumulator.

Deferred to the end of the request so a request writing four hundred rows to one table produces one operation rather than four hundred, and so the journal write happens off the response's critical path. Public so cron and a drush command can flush what they touched without a terminate event.

Returns

int —

How many operations were journaled.

pending()

pending() : list<string>

Tables noted this request but not yet journaled.

Returns

list

Table names.

writesTo()

writesTo(string  $table) : \Drupal\strata\Capture\TableWrites|null

What a table has seen so far this request.

Parameters

string $table

The table name.

Returns

\Drupal\strata\Capture\TableWrites|null —

The record, or NULL when the table has not been written to.

journal()

journal() : \Drupal\strata\Journal\JournalInterface|null

The journal, resolved once.

Returns

\Drupal\strata\Journal\JournalInterface|null —

The journal, or NULL while one is already being resolved, which means the caller is the configuration read that resolution itself triggered.

recordDisabled()

recordDisabled(\Throwable  $error) : void

Puts the tap switching itself off in front of an operator.

Table capture stops for the rest of the request when this happens, and until 1.0.3 the only trace was a log line - so a site could stop capturing writes and its own health dashboard would still read clean.

Recorded at WARN rather than ERROR on purpose. Severity picks the repair rung, and everything ERROR and above is automatic; there is no unattended pass that fixes a statement tap, so an ERROR here would spend a bucket-wide reindex on a problem a reindex cannot touch.

Parameters

\Throwable $error

What the tap failed with.

Returns

void —

note()

note(\Drupal\strata\Capture\SqlStatement  $statement, string  $sql) : void

Folds one statement into the accumulator.

Parameters

\Drupal\strata\Capture\SqlStatement $statement

The classified statement.

string $sql

The statement text, kept as a shape so the timeline can name the query without carrying any value that might have been interpolated into it.

Returns

void —

actor()

actor() : int|null

The user an operation is attributed to.

Returns

int|null —

A Drupal user id, or NULL for unattended work.

now()

now() : int

The current time in unix microseconds.

Returns

int —

Microseconds since the epoch.