@@ -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