Skip to content

Commit d1d8fee

Browse files
committed
MDL-87703 dml: Introduce mark_tables_for_primary method
1 parent 19c8567 commit d1d8fee

3 files changed

Lines changed: 77 additions & 3 deletions

File tree

public/lib/dml/moodle_database.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2919,6 +2919,18 @@ public function perf_get_reads_replica(): int {
29192919
return 0;
29202920
}
29212921

2922+
/**
2923+
* Mark one or more tables as requiring reads from the primary (writer) connection.
2924+
*
2925+
* This is useful in a read replica setup, where code wants to avoid subtle race
2926+
* conditions by ensuring reads are routed to the writer before an imminent write.
2927+
*
2928+
* @param string ...$tables Unprefixed table names (e.g., 'user', 'task_adhoc').
2929+
*/
2930+
public function mark_tables_for_primary(string ...$tables): void {
2931+
// No-op by default. Drivers supporting read replicas override this.
2932+
}
2933+
29222934
/**
29232935
* Returns the number of writes done by this database.
29242936
* @return int Number of writes.

public/lib/dml/moodle_read_replica_trait.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
// You should have received a copy of the GNU General Public License
1515
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
1616

17+
use core\exception\coding_exception;
18+
1719
/**
1820
* Trait that adds read-only replica connection capability.
1921
*
@@ -305,6 +307,35 @@ public function perf_get_reads_replica(): int {
305307
return $this->readsreplica;
306308
}
307309

310+
/**
311+
* Mark one or more tables as requiring reads from the primary (writer) connection.
312+
*
313+
* This marks the provided table(s) as having been recently written which forces
314+
* SELECT queries on those tables to be routed to the writer until the configured
315+
* replica latency has elapsed.
316+
*
317+
* @param string ...$tables Unprefixed table names (e.g., 'user', 'task_adhoc').
318+
* @throws coding_exception If an invalid table name is provided.
319+
*/
320+
public function mark_tables_for_primary(string ...$tables): void {
321+
if (!$this->wantreadreplica || empty($tables)) {
322+
return;
323+
}
324+
325+
$now = microtime(true);
326+
foreach ($tables as $tablename) {
327+
if (empty($tablename)) {
328+
throw new coding_exception('Table name must not be empty');
329+
}
330+
331+
if (!preg_match('/^[a-z][a-z0-9_]*$/', $tablename)) {
332+
throw new coding_exception('Invalid table name: '.$tablename);
333+
}
334+
335+
$this->written[$tablename] = $now;
336+
}
337+
}
338+
308339
/**
309340
* On DBs that support it, switch to transaction mode and begin a transaction.
310341
*

public/lib/dml/tests/dml_read_replica_test.php

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ final class dml_read_replica_test extends \database_driver_testcase {
5151
* @param bool $wantlatency
5252
* @param mixed $readonly
5353
* @param mixed $dbclass
54+
* @param float|null $latency Override replica latency seconds.
5455
* @return read_replica_moodle_database $db
5556
*/
5657
public function new_db(
@@ -60,16 +61,18 @@ public function new_db(
6061
['dbhost' => 'test_ro2', 'dbport' => 2, 'dbuser' => 'test2', 'dbpass' => 'test2'],
6162
['dbhost' => 'test_ro3', 'dbport' => 3, 'dbuser' => 'test3', 'dbpass' => 'test3'],
6263
],
63-
$dbclass = read_replica_moodle_database::class
64+
$dbclass = read_replica_moodle_database::class,
65+
?float $latency = null
6466
): read_replica_moodle_database {
6567
$dbhost = 'test_rw';
6668
$dbname = 'test';
6769
$dbuser = 'test';
6870
$dbpass = 'test';
6971
$prefix = 'test_';
7072
$dboptions = ['readonly' => ['instance' => $readonly, 'exclude_tables' => ['exclude']]];
71-
if ($wantlatency) {
72-
$dboptions['readonly']['latency'] = self::$dbreadonlylatency;
73+
$effectivelatency = $latency ?? ($wantlatency ? self::$dbreadonlylatency : null);
74+
if ($effectivelatency !== null) {
75+
$dboptions['readonly']['latency'] = $effectivelatency;
7376
}
7477

7578
$db = new $dbclass();
@@ -373,6 +376,34 @@ public function test_long_update(): void {
373376
}
374377
}
375378

379+
/**
380+
* Test mark_tables_for_primary() routes reads to the writer until latency expires.
381+
*/
382+
public function test_mark_tables_for_primary(): void {
383+
$latency = 2;
384+
$DB = $this->new_db(latency: $latency);
385+
386+
// Check reads are going to the reader.
387+
$handle = $DB->get_records('table');
388+
$this->assert_readonly_handle($handle);
389+
$readsreplica = $DB->perf_get_reads_replica();
390+
$this->assertGreaterThan(0, $readsreplica);
391+
392+
$DB->mark_tables_for_primary('table');
393+
394+
// Verify reads are now routed to the writer.
395+
$handle = $DB->get_records('table');
396+
$this->assertEquals('test_rw::test:test', $handle);
397+
$this->assertEquals($readsreplica, $DB->perf_get_reads_replica());
398+
399+
sleep($latency + 1);
400+
401+
// Past latency, reads should be routed back to the reader.
402+
$handle = $DB->get_records('table');
403+
$this->assert_readonly_handle($handle);
404+
$this->assertEquals($readsreplica + 1, $DB->perf_get_reads_replica());
405+
}
406+
376407
/**
377408
* Test readonly handle is not used with events
378409
* when the latency parameter is applied properly.

0 commit comments

Comments
 (0)