Skip to content

Commit 48628b9

Browse files
authored
Merge pull request #42345 from nextcloud/enh/read-replica-followup
feat: Track dirty table writes and long transactions
2 parents 32a377e + c17c42a commit 48628b9

1 file changed

Lines changed: 61 additions & 0 deletions

File tree

lib/private/DB/Connection.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class Connection extends PrimaryReadReplicaConnection {
7979
/** @var DbDataCollector|null */
8080
protected $dbDataCollector = null;
8181

82+
protected ?float $transactionActiveSince = null;
83+
84+
protected $tableDirtyWrites = [];
85+
8286
/**
8387
* Initializes a new instance of the Connection class.
8488
*
@@ -255,13 +259,35 @@ public function prepare($sql, $limit = null, $offset = null): Statement {
255259
* @throws \Doctrine\DBAL\Exception
256260
*/
257261
public function executeQuery(string $sql, array $params = [], $types = [], QueryCacheProfile $qcp = null): Result {
262+
$tables = $this->getQueriedTables($sql);
263+
if (count(array_intersect($this->tableDirtyWrites, $tables)) === 0 && !$this->isTransactionActive()) {
264+
// No tables read that could have been written already in the same request and no transaction active
265+
// so we can switch back to the replica for reading as long as no writes happen that switch back to the primary
266+
// We cannot log here as this would log too early in the server boot process
267+
$this->ensureConnectedToReplica();
268+
} else {
269+
// Read to a table that was previously written to
270+
// While this might not necessarily mean that we did a read after write it is an indication for a code path to check
271+
$this->logger->debug('dirty table reads: ' . $sql, ['tables' => $this->tableDirtyWrites, 'reads' => $tables, 'exception' => new \Exception()]);
272+
}
273+
258274
$sql = $this->replaceTablePrefix($sql);
259275
$sql = $this->adapter->fixupStatement($sql);
260276
$this->queriesExecuted++;
261277
$this->logQueryToFile($sql);
262278
return parent::executeQuery($sql, $params, $types, $qcp);
263279
}
264280

281+
/**
282+
* Helper function to get the list of tables affected by a given query
283+
* used to track dirty tables that received a write with the current request
284+
*/
285+
private function getQueriedTables(string $sql): array {
286+
$re = '/(\*PREFIX\*\w+)/mi';
287+
preg_match_all($re, $sql, $matches);
288+
return array_map([$this, 'replaceTablePrefix'], $matches[0] ?? []);
289+
}
290+
265291
/**
266292
* @throws Exception
267293
*/
@@ -288,6 +314,9 @@ public function executeUpdate(string $sql, array $params = [], array $types = []
288314
* @throws \Doctrine\DBAL\Exception
289315
*/
290316
public function executeStatement($sql, array $params = [], array $types = []): int {
317+
$tables = $this->getQueriedTables($sql);
318+
$this->tableDirtyWrites = array_unique(array_merge($this->tableDirtyWrites, $tables));
319+
$this->logger->debug('dirty table writes: ' . $sql, ['tables' => $this->tableDirtyWrites]);
291320
$sql = $this->replaceTablePrefix($sql);
292321
$sql = $this->adapter->fixupStatement($sql);
293322
$this->queriesExecuted++;
@@ -306,6 +335,7 @@ protected function logQueryToFile(string $sql): void {
306335
// FIXME: Improve to log the actual target db host
307336
$isPrimary = $this->connections['primary'] === $this->_conn;
308337
$prefix .= ' ' . ($isPrimary === true ? 'primary' : 'replica') . ' ';
338+
$prefix .= ' ' . $this->getTransactionNestingLevel() . ' ';
309339

310340
file_put_contents(
311341
$this->systemConfig->getValue('query_log_file', ''),
@@ -618,4 +648,35 @@ protected function performConnect(?string $connectionName = null): bool {
618648
}
619649
return $result;
620650
}
651+
652+
public function beginTransaction() {
653+
if (!$this->inTransaction()) {
654+
$this->transactionActiveSince = microtime(true);
655+
}
656+
return parent::beginTransaction();
657+
}
658+
659+
public function commit() {
660+
$result = parent::commit();
661+
if ($this->getTransactionNestingLevel() === 0) {
662+
$timeTook = microtime(true) - $this->transactionActiveSince;
663+
$this->transactionActiveSince = null;
664+
if ($timeTook > 1) {
665+
$this->logger->warning('Transaction took longer than 1s: ' . $timeTook, ['exception' => new \Exception('Long running transaction')]);
666+
}
667+
}
668+
return $result;
669+
}
670+
671+
public function rollBack() {
672+
$result = parent::rollBack();
673+
if ($this->getTransactionNestingLevel() === 0) {
674+
$timeTook = microtime(true) - $this->transactionActiveSince;
675+
$this->transactionActiveSince = null;
676+
if ($timeTook > 1) {
677+
$this->logger->warning('Transaction rollback took longer than 1s: ' . $timeTook, ['exception' => new \Exception('Long running transaction rollback')]);
678+
}
679+
}
680+
return $result;
681+
}
621682
}

0 commit comments

Comments
 (0)