Skip to content

Commit e83b66e

Browse files
Andy ScherzingerAndyScherzinger
authored andcommitted
perf(migration): replace N+1 updates in AclTimestampBackfill with grouped IN-clause batches
Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent b7e60b0 commit e83b66e

2 files changed

Lines changed: 58 additions & 28 deletions

File tree

lib/Migration/AclTimestampBackfill.php

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,27 @@ public function run(IOutput $output): void {
4040
return;
4141
}
4242

43-
$updateQb = $this->db->getQueryBuilder();
44-
$updateQb->update('deck_board_acl')
45-
->set('created_at', $updateQb->createParameter('ts'))
46-
->set('last_modified_at', $updateQb->createParameter('ts'))
47-
->where($updateQb->expr()->eq('id', $updateQb->createParameter('acl_id')));
48-
43+
// Group ACL IDs by target timestamp to issue one UPDATE per group
44+
// rather than one UPDATE per row (avoids N+1 queries).
4945
$now = time();
50-
$updated = 0;
46+
$groups = [];
5147
foreach ($rows as $row) {
5248
$timestamp = ((int)$row['board_last_modified'] > 0) ? (int)$row['board_last_modified'] : $now;
53-
$updateQb->setParameter('ts', $timestamp, IQueryBuilder::PARAM_INT);
54-
$updateQb->setParameter('acl_id', (int)$row['acl_id'], IQueryBuilder::PARAM_INT);
55-
$updateQb->executeStatement();
56-
$updated++;
49+
$groups[$timestamp][] = (int)$row['acl_id'];
50+
}
51+
52+
$updated = 0;
53+
foreach ($groups as $timestamp => $ids) {
54+
// Chunk at 1000 for Oracle compatibility (same limit used by chunkQuery).
55+
foreach (array_chunk($ids, 1000) as $chunk) {
56+
$updateQb = $this->db->getQueryBuilder();
57+
$updateQb->update('deck_board_acl')
58+
->set('created_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
59+
->set('last_modified_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
60+
->where($updateQb->expr()->in('id', $updateQb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
61+
$updateQb->executeStatement();
62+
$updated += count($chunk);
63+
}
5764
}
5865

5966
$output->info('AclTimestampBackfill: updated ' . $updated . ' row(s)');

tests/unit/Migration/AclTimestampBackfillTest.php

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,16 @@ public function testRunNoRowsIsNoop(): void {
4545
$this->backfill->run($this->output);
4646
}
4747

48-
public function testRunUpdatesTwoRows(): void {
48+
public function testRunGroupsRowsWithSameTimestampIntoOneUpdate(): void {
49+
// Two ACLs from the same board share the same timestamp → only 1 UPDATE
4950
$rows = [
5051
['acl_id' => 1, 'board_last_modified' => 1000000],
51-
['acl_id' => 2, 'board_last_modified' => 2000000],
52+
['acl_id' => 2, 'board_last_modified' => 1000000],
5253
];
5354
[$selectQb] = $this->buildSelectQb($rows);
54-
$updateQb = $this->buildUpdateQb(2);
55+
$updateQb = $this->buildUpdateQb(1); // single IN-clause UPDATE for both IDs
5556

56-
$this->db->expects($this->exactly(2))
57+
$this->db->expects($this->exactly(2)) // 1 SELECT + 1 UPDATE
5758
->method('getQueryBuilder')
5859
->willReturnOnConsecutiveCalls($selectQb, $updateQb);
5960

@@ -64,13 +65,34 @@ public function testRunUpdatesTwoRows(): void {
6465
$this->backfill->run($this->output);
6566
}
6667

68+
public function testRunIssuesSeparateUpdatePerDistinctTimestamp(): void {
69+
// Two ACLs from different boards → two distinct timestamps → 2 UPDATEs
70+
$rows = [
71+
['acl_id' => 1, 'board_last_modified' => 1000000],
72+
['acl_id' => 2, 'board_last_modified' => 2000000],
73+
];
74+
[$selectQb] = $this->buildSelectQb($rows);
75+
$updateQb1 = $this->buildUpdateQb(1);
76+
$updateQb2 = $this->buildUpdateQb(1);
77+
78+
$this->db->expects($this->exactly(3)) // 1 SELECT + 2 UPDATEs
79+
->method('getQueryBuilder')
80+
->willReturnOnConsecutiveCalls($selectQb, $updateQb1, $updateQb2);
81+
82+
$this->output->expects($this->once())
83+
->method('info')
84+
->with($this->stringContains('2'));
85+
86+
$this->backfill->run($this->output);
87+
}
88+
6789
public function testRunUsesBoardTimestampWhenAvailable(): void {
6890
$rows = [['acl_id' => 1, 'board_last_modified' => 1234567]];
6991
[$selectQb] = $this->buildSelectQb($rows);
7092

7193
$capturedTs = null;
72-
$updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void {
73-
if ($name === 'ts') {
94+
$updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void {
95+
if ($type === IQueryBuilder::PARAM_INT) {
7496
$capturedTs = $value;
7597
}
7698
});
@@ -91,8 +113,8 @@ public function testRunUsesCurrentTimeWhenBoardTimestampIsZero(): void {
91113

92114
$capturedTs = null;
93115
$before = time();
94-
$updateQb = $this->buildUpdateQb(1, function (string $name, mixed $value) use (&$capturedTs): void {
95-
if ($name === 'ts') {
116+
$updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void {
117+
if ($type === IQueryBuilder::PARAM_INT) {
96118
$capturedTs = $value;
97119
}
98120
});
@@ -132,25 +154,26 @@ private function buildSelectQb(array $rows): array {
132154
return [$qb];
133155
}
134156

135-
private function buildUpdateQb(int $expectedExecutions, ?\Closure $onSetParameter = null): IQueryBuilder&MockObject {
157+
private function buildUpdateQb(int $expectedExecutions, ?\Closure $onCreateNamedParameter = null): IQueryBuilder&MockObject {
136158
$expr = $this->createMock(IExpressionBuilder::class);
137-
$expr->method('eq')->willReturn('1=1');
159+
$expr->method('in')->willReturn('1=1');
138160

139161
$qb = $this->createMock(IQueryBuilder::class);
140162
$qb->method('update')->willReturnSelf();
141163
$qb->method('set')->willReturnSelf();
142164
$qb->method('where')->willReturnSelf();
143-
$qb->method('createParameter')->willReturn('?');
144165
$qb->method('expr')->willReturn($expr);
145166
$qb->expects($this->exactly($expectedExecutions))->method('executeStatement');
146167

147-
if ($onSetParameter !== null) {
148-
$qb->method('setParameter')->willReturnCallback(function (string $name, mixed $value) use ($onSetParameter, $qb) {
149-
$onSetParameter($name, $value);
150-
return $qb;
151-
});
168+
if ($onCreateNamedParameter !== null) {
169+
$qb->method('createNamedParameter')->willReturnCallback(
170+
function (mixed $value, int $type) use ($onCreateNamedParameter): string {
171+
$onCreateNamedParameter($value, $type);
172+
return '?';
173+
}
174+
);
152175
} else {
153-
$qb->method('setParameter')->willReturnSelf();
176+
$qb->method('createNamedParameter')->willReturn('?');
154177
}
155178

156179
return $qb;

0 commit comments

Comments
 (0)