Skip to content

Commit fc9bf24

Browse files
committed
perf(mailqueue): cover amq_latest_send in the affecteduser index
1 parent de94488 commit fc9bf24

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Activity\Migration;
10+
11+
use Closure;
12+
use OCP\DB\ISchemaWrapper;
13+
use OCP\Migration\IOutput;
14+
use OCP\Migration\SimpleMigrationStep;
15+
16+
/**
17+
* Widen the `amq_affecteduser` index on `activity_mq` to also cover
18+
* `amq_latest_send`.
19+
*
20+
* The email digest cron (`MailQueueHandler::getAffectedUsers()`) runs on every
21+
* cron tick and does
22+
* SELECT amq_affecteduser, MIN(amq_latest_send)
23+
* FROM activity_mq WHERE amq_latest_send < ? GROUP BY amq_affecteduser
24+
* The previous `amp_user` index only contained `amq_affecteduser`, so the
25+
* aggregate forced a full index scan plus a temporary table to resolve the
26+
* GROUP BY. Including `amq_latest_send` lets MariaDB/MySQL resolve the MIN()
27+
* with a loose index scan ("Using index for group-by"), reading one entry per
28+
* user instead of the whole queue.
29+
*
30+
* `amp_user` is a strict prefix of the new index, so it becomes redundant and
31+
* is dropped to avoid carrying two overlapping indexes.
32+
*/
33+
class Version8000Date20260603120000 extends SimpleMigrationStep {
34+
/**
35+
* @param IOutput $output
36+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
37+
* @param array $options
38+
* @return null|ISchemaWrapper
39+
*/
40+
#[\Override]
41+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
42+
/** @var ISchemaWrapper $schema */
43+
$schema = $schemaClosure();
44+
45+
if (!$schema->hasTable('activity_mq')) {
46+
return null;
47+
}
48+
49+
$table = $schema->getTable('activity_mq');
50+
$changed = false;
51+
52+
if (!$table->hasIndex('amp_user_send')) {
53+
$table->addIndex(['amq_affecteduser', 'amq_latest_send'], 'amp_user_send');
54+
$changed = true;
55+
}
56+
57+
if ($table->hasIndex('amp_user')) {
58+
$table->dropIndex('amp_user');
59+
$changed = true;
60+
}
61+
62+
return $changed ? $schema : null;
63+
}
64+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Activity\Tests\Migration;
10+
11+
use Doctrine\DBAL\Schema\Table;
12+
use OCA\Activity\Migration\Version8000Date20260603120000;
13+
use OCA\Activity\Tests\TestCase;
14+
use OCP\DB\ISchemaWrapper;
15+
use OCP\Migration\IOutput;
16+
use PHPUnit\Framework\MockObject\MockObject;
17+
18+
/**
19+
* @see Version8000Date20260603120000
20+
*/
21+
class Version8000Date20260603120000Test extends TestCase {
22+
protected IOutput&MockObject $output;
23+
protected Version8000Date20260603120000 $migration;
24+
25+
protected function setUp(): void {
26+
parent::setUp();
27+
28+
$this->output = $this->createMock(IOutput::class);
29+
$this->migration = new Version8000Date20260603120000();
30+
}
31+
32+
/**
33+
* On a queue that still has the legacy single-column `amp_user` index the
34+
* migration adds the covering composite index and drops the now-redundant one.
35+
*/
36+
public function testAddsCompositeIndexAndDropsRedundantOne(): void {
37+
$table = $this->createMock(Table::class);
38+
$table->method('hasIndex')
39+
->willReturnMap([
40+
['amp_user_send', false],
41+
['amp_user', true],
42+
]);
43+
$table->expects($this->once())
44+
->method('addIndex')
45+
->with(['amq_affecteduser', 'amq_latest_send'], 'amp_user_send');
46+
$table->expects($this->once())
47+
->method('dropIndex')
48+
->with('amp_user');
49+
50+
$schema = $this->getSchemaMock($table);
51+
52+
$result = $this->migration->changeSchema($this->output, fn (): ISchemaWrapper => $schema, []);
53+
$this->assertSame($schema, $result, 'The schema must be returned when it was changed');
54+
}
55+
56+
/**
57+
* Running the migration again (composite index present, legacy index gone)
58+
* must be a no-op so re-runs / fresh installs are not touched.
59+
*/
60+
public function testIsIdempotentWhenAlreadyMigrated(): void {
61+
$table = $this->createMock(Table::class);
62+
$table->method('hasIndex')
63+
->willReturnMap([
64+
['amp_user_send', true],
65+
['amp_user', false],
66+
]);
67+
$table->expects($this->never())
68+
->method('addIndex');
69+
$table->expects($this->never())
70+
->method('dropIndex');
71+
72+
$schema = $this->getSchemaMock($table);
73+
74+
$result = $this->migration->changeSchema($this->output, fn (): ISchemaWrapper => $schema, []);
75+
$this->assertNull($result, 'Nothing must change once the queue is already migrated');
76+
}
77+
78+
/**
79+
* The mail queue table does not exist on every install (e.g. before its
80+
* creation migration ran), so a missing table must be skipped gracefully.
81+
*/
82+
public function testSkipsWhenTableIsMissing(): void {
83+
$schema = $this->createMock(ISchemaWrapper::class);
84+
$schema->method('hasTable')
85+
->with('activity_mq')
86+
->willReturn(false);
87+
$schema->expects($this->never())
88+
->method('getTable');
89+
90+
$result = $this->migration->changeSchema($this->output, fn (): ISchemaWrapper => $schema, []);
91+
$this->assertNull($result);
92+
}
93+
94+
protected function getSchemaMock(Table&MockObject $table): ISchemaWrapper&MockObject {
95+
$schema = $this->createMock(ISchemaWrapper::class);
96+
$schema->method('hasTable')
97+
->with('activity_mq')
98+
->willReturn(true);
99+
$schema->method('getTable')
100+
->with('activity_mq')
101+
->willReturn($table);
102+
103+
return $schema;
104+
}
105+
}

0 commit comments

Comments
 (0)