Skip to content

Commit 1b4e77d

Browse files
sgiehlclaude
andcommitted
Address matomo-review medium findings for blob-row cap
- Bump version to 5.10.0-b1 - Centralise the MEDIUMBLOB-list + flag-clear logic in ArchiveBlobColumnType::recheckAndUpdateFlag() and have the CLI command delegate to it, removing the duplicated write path. - Make ArchiveBlobColumnType::isMediumBlob() return true on empty/missing INFORMATION_SCHEMA results, matching the documented fail-safe contract. - Add ArchiveBlobColumnType::CONFIG_KEY constant and use it everywhere the flag is read or written, preventing silent key drift. - Narrow the archive_blob table discovery LIKE pattern to the configured table prefix. - Use DatabaseConfig instead of Config class Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5e0ebb6 commit 1b4e77d

8 files changed

Lines changed: 102 additions & 44 deletions

File tree

core/ArchiveProcessor/ArchiveBlobRowCap.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Piwik\ArchiveProcessor;
1111

12-
use Piwik\Config;
12+
use Piwik\Config\DatabaseConfig;
1313
use Piwik\DataAccess\ArchiveBlobColumnType;
1414

1515
/**
@@ -54,7 +54,7 @@ final class ArchiveBlobRowCap
5454
*/
5555
public static function isCapPossiblyNeeded(): bool
5656
{
57-
return (int) (Config::getInstance()->database['archive_blob_tables_may_contain_mediumblob'] ?? 0) !== 0;
57+
return (int)(DatabaseConfig::getConfigValue(ArchiveBlobColumnType::CONFIG_KEY) ?? 0) !== 0;
5858
}
5959

6060
/**
@@ -91,7 +91,7 @@ public static function capMaxSubtableRows(?int $configuredMax, string $tableName
9191
private static function applyCap(?int $configuredMax, string $tableName): ?int
9292
{
9393
// Fast-path: skip all I/O when the flag is not set (fresh installs).
94-
$flag = (int) (Config::getInstance()->database['archive_blob_tables_may_contain_mediumblob'] ?? 0);
94+
$flag = (int)(DatabaseConfig::getConfigValue(ArchiveBlobColumnType::CONFIG_KEY) ?? 0);
9595
if ($flag === 0) {
9696
return $configuredMax;
9797
}

core/DataAccess/ArchiveBlobColumnType.php

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace Piwik\DataAccess;
1111

12+
use Piwik\Common;
1213
use Piwik\Config;
1314
use Piwik\Container\StaticContainer;
1415
use Piwik\Db;
@@ -27,6 +28,12 @@
2728
*/
2829
final class ArchiveBlobColumnType
2930
{
31+
/**
32+
* The [database] config key used to signal that archive_blob tables may still contain
33+
* MEDIUMBLOB `value` columns (set by the 5.10.0-b1 migration on existing installs).
34+
*/
35+
public const CONFIG_KEY = 'archive_blob_tables_may_contain_mediumblob';
36+
3037
/**
3138
* Per-request cache: table name → isMediumBlob result.
3239
*
@@ -54,9 +61,21 @@ public static function isMediumBlob(string $tableName): bool
5461
[$tableName, 'value']
5562
);
5663

57-
// MySQL returns the type in lowercase ('mediumblob', 'longblob'); use
58-
// case-insensitive comparison so we are not surprised by unusual drivers.
59-
$result = (strtolower((string) $columnType) === 'mediumblob');
64+
$normalized = strtolower((string) $columnType);
65+
if ($normalized === '') {
66+
// INFORMATION_SCHEMA returned no row: the table or column is missing.
67+
// This is unexpected (callers create the table before calling us) and
68+
// indicates a race condition or schema corruption. Apply the cap conservatively.
69+
StaticContainer::get(LoggerInterface::class)->warning(
70+
'ArchiveBlobColumnType: INFORMATION_SCHEMA returned no row for table {table}; applying cap conservatively.',
71+
['table' => $tableName]
72+
);
73+
$result = true;
74+
} else {
75+
// MySQL returns the type in lowercase ('mediumblob', 'longblob'); use
76+
// case-insensitive comparison so we are not surprised by unusual drivers.
77+
$result = ($normalized === 'mediumblob');
78+
}
6079
} catch (\Exception $e) {
6180
StaticContainer::get(LoggerInterface::class)->warning(
6281
'ArchiveBlobColumnType: could not determine column type for table {table}: {exception}',
@@ -81,7 +100,8 @@ public static function clearCache(): void
81100
/**
82101
* Checks whether any archive_blob_* tables in the current schema still use MEDIUMBLOB for
83102
* their `value` column. If none remain, the
84-
* `[database] archive_blob_tables_may_contain_mediumblob` config flag is removed.
103+
* `[database] archive_blob_tables_may_contain_mediumblob` ({@see CONFIG_KEY}) config flag is
104+
* removed.
85105
*
86106
* If the flag is not set (0 / unset) this method returns immediately without any I/O so that
87107
* fresh installs pay zero runtime cost during updates.
@@ -90,47 +110,57 @@ public static function clearCache(): void
90110
* - `core/Updater.php` after each component update finishes and at the end of a full batch.
91111
* - `plugins/CoreUpdater/Commands/RecheckArchiveBlobTypes.php` as an on-demand CLI tool.
92112
*
93-
* @return bool `true` when MEDIUMBLOB tables were found (flag left as-is or not set),
94-
* `false` when flag was unset because no MEDIUMBLOB tables remain.
113+
* @return string[] Names of archive_blob tables that still use MEDIUMBLOB.
114+
* An empty array means no MEDIUMBLOB tables remain and the flag was cleared.
115+
* A non-empty array means the flag was left as-is.
116+
* Returns an empty array (without I/O) when the flag was not set.
95117
*/
96-
public static function recheckAndUpdateFlag(): bool
118+
public static function recheckAndUpdateFlag(): array
97119
{
98-
$flag = (int) (Config::getInstance()->database['archive_blob_tables_may_contain_mediumblob'] ?? 0);
120+
$flag = (int) (Config::getInstance()->database[self::CONFIG_KEY] ?? 0);
99121
if ($flag === 0) {
100122
// Nothing to do on fresh installs or after the flag was already cleared.
101-
return false;
123+
return [];
102124
}
103125

104126
$mediumBlobTables = self::getMediumBlobArchiveTables();
105127
if (empty($mediumBlobTables)) {
106128
// All tables have been migrated (or none existed). Remove the flag.
107129
$config = Config::getInstance();
108130
$database = $config->database;
109-
unset($database['archive_blob_tables_may_contain_mediumblob']);
131+
unset($database[self::CONFIG_KEY]);
110132
$config->database = $database;
111133
$config->forceSave();
112-
return false;
113134
}
114135

115-
return true;
136+
return $mediumBlobTables;
116137
}
117138

118139
/**
119140
* Returns the names of all archive_blob_* tables in the current schema whose `value` column
120-
* is MEDIUMBLOB.
141+
* is MEDIUMBLOB. Only tables whose name begins with the configured Matomo table prefix are
142+
* inspected, so tables belonging to other Matomo instances (or unrelated tables that happen to
143+
* contain "archive_blob_" in their name) are never returned.
121144
*
122145
* @return string[]
123146
*/
124147
public static function getMediumBlobArchiveTables(): array
125148
{
149+
// Build a prefix-anchored LIKE pattern, e.g. "matomo_archive\_blob\_%".
150+
// LIKE-escape any '%' or '_' in the prefix itself so a weird table-prefix cannot
151+
// accidentally broaden the match.
152+
$rawPrefix = Common::prefixTable('archive_blob_');
153+
$likePrefix = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $rawPrefix);
154+
$likePattern = $likePrefix . '%';
155+
126156
try {
127157
$rows = Db::fetchAll(
128158
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.COLUMNS
129159
WHERE TABLE_SCHEMA = DATABASE()
130160
AND TABLE_NAME LIKE ?
131161
AND COLUMN_NAME = ?
132162
AND LOWER(COLUMN_TYPE) = ?",
133-
['%archive\_blob\_%', 'value', 'mediumblob']
163+
[$likePattern, 'value', 'mediumblob']
134164
);
135165
} catch (\Exception $e) {
136166
StaticContainer::get(LoggerInterface::class)->warning(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* Fresh installs (all tables are LONGBLOB) receive no migration so the fast-path short-circuit
2222
* in {@see \Piwik\ArchiveProcessor\ArchiveBlobRowCap} costs nothing at runtime.
2323
*/
24-
class Updates_5_10_0_b1 extends Updates
24+
class Updates_5_11_0_b2 extends Updates
2525
{
2626
/**
2727
* @var MigrationFactory

core/Version.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ final class Version
2222
* The current Matomo version.
2323
* @var string
2424
*/
25-
public const VERSION = '5.11.0-b1';
25+
public const VERSION = '5.11.0-b2';
2626

2727
public const MAJOR_VERSION = 5;
2828

plugins/CoreUpdater/Commands/RecheckArchiveBlobTypes.php

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,27 +38,22 @@ protected function doExecute(): int
3838
{
3939
$output = $this->getOutput();
4040

41-
$flag = (int) (Config::getInstance()->database['archive_blob_tables_may_contain_mediumblob'] ?? 0);
41+
// Short-circuit when the flag is not set (fresh installs, or already cleared).
42+
$flag = (int) (Config::getInstance()->database[ArchiveBlobColumnType::CONFIG_KEY] ?? 0);
4243
if ($flag === 0) {
4344
$output->writeln(
4445
'No MEDIUMBLOB archive_blob tables possible (flag not set); nothing to do.'
4546
);
4647
return self::SUCCESS;
4748
}
4849

49-
$mediumBlobTables = ArchiveBlobColumnType::getMediumBlobArchiveTables();
50+
// Delegate all detection and flag-clearing logic to the single canonical implementation.
51+
$mediumBlobTables = ArchiveBlobColumnType::recheckAndUpdateFlag();
5052

5153
if (empty($mediumBlobTables)) {
52-
// All tables have been migrated. Remove the flag.
53-
$config = Config::getInstance();
54-
$database = $config->database;
55-
unset($database['archive_blob_tables_may_contain_mediumblob']);
56-
$config->database = $database;
57-
$config->forceSave();
58-
5954
$output->writeln(
6055
'No MEDIUMBLOB archive_blob tables found. ' .
61-
'The archive_blob_tables_may_contain_mediumblob flag has been removed from config.ini.php. ' .
56+
'The ' . ArchiveBlobColumnType::CONFIG_KEY . ' flag has been removed from config.ini.php. ' .
6257
'Archive row-limit cap will no longer be applied.'
6358
);
6459
} else {
@@ -70,7 +65,7 @@ protected function doExecute(): int
7065
$output->writeln(' - ' . $table);
7166
}
7267
$output->writeln(
73-
'The archive_blob_tables_may_contain_mediumblob flag remains set. ' .
68+
'The ' . ArchiveBlobColumnType::CONFIG_KEY . ' flag remains set. ' .
7469
'To migrate, run ALTER TABLE on the listed tables to change the `value` column to LONGBLOB, ' .
7570
'then re-run this command.'
7671
);

tests/PHPUnit/Integration/DataAccess/ArchiveBlobColumnTypeTest.php

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
*/
2222
class ArchiveBlobColumnTypeTest extends IntegrationTestCase
2323
{
24-
private const TEST_TABLE_MEDIUM = 'archive_blob_test_medium';
25-
private const TEST_TABLE_LONG = 'archive_blob_test_long';
24+
private const TEST_TABLE_MEDIUM = 'archive_blob_test_medium';
25+
private const TEST_TABLE_LONG = 'archive_blob_test_long';
26+
/** Raw (un-prefixed) name for a table that belongs to a different Matomo instance. */
27+
private const TEST_TABLE_FOREIGN_MEDIUM = 'other_archive_blob_foreign_medium';
2628

2729
public function setUp(): void
2830
{
@@ -64,11 +66,10 @@ public function testIsMediumBlobReturnsTrueForNonExistentTableFailSafe(): void
6466
{
6567
// A table that does not exist → INFORMATION_SCHEMA returns nothing → fail-safe returns true.
6668
$tableName = Common::prefixTable('archive_blob_9999_99');
67-
// Result is false because INFORMATION_SCHEMA returns null/empty string, not 'mediumblob'.
68-
// The fail-safe only triggers on exceptions; an empty result means the table is not
69-
// MEDIUMBLOB (it doesn't exist at all). Confirm no exception is thrown.
69+
// An empty/null result from INFORMATION_SCHEMA is treated as a fail-safe condition:
70+
// the cap is applied conservatively rather than risking a silent 16 MB truncation.
7071
$result = ArchiveBlobColumnType::isMediumBlob($tableName);
71-
self::assertFalse($result);
72+
self::assertTrue($result);
7273
}
7374

7475
// -----------------------------------------------------------------------
@@ -133,13 +134,37 @@ public function testGetMediumBlobArchiveTablesReturnsEmptyWhenNoneMediumBlob():
133134
self::assertNotContains($prefixedLong, $tables);
134135
}
135136

137+
public function testGetMediumBlobArchiveTablesIgnoresTablesWithDifferentPrefix(): void
138+
{
139+
// Create a MEDIUMBLOB table whose raw name contains "archive_blob_" but does NOT carry the
140+
// Matomo table prefix (simulates a table from a different Matomo instance sharing the same
141+
// DB schema).
142+
$this->createRawTable(self::TEST_TABLE_FOREIGN_MEDIUM, 'MEDIUMBLOB');
143+
144+
$tables = ArchiveBlobColumnType::getMediumBlobArchiveTables();
145+
146+
self::assertNotContains(
147+
self::TEST_TABLE_FOREIGN_MEDIUM,
148+
$tables,
149+
'getMediumBlobArchiveTables() must not return tables from a different table prefix'
150+
);
151+
}
152+
136153
// -----------------------------------------------------------------------
137154
// Helpers
138155
// -----------------------------------------------------------------------
139156

140157
private function createTestTable(string $tableBaseName, string $blobType): void
141158
{
142159
$tableName = Common::prefixTable($tableBaseName);
160+
$this->createRawTable($tableName, $blobType);
161+
}
162+
163+
/**
164+
* Creates a table using the exact (already-resolved) name passed — no prefix is added.
165+
*/
166+
private function createRawTable(string $tableName, string $blobType): void
167+
{
143168
Db::exec(sprintf(
144169
'CREATE TABLE IF NOT EXISTS `%s` (
145170
`idarchive` INT UNSIGNED NOT NULL,
@@ -166,5 +191,12 @@ private function dropTestTables(): void
166191
// Ignore errors during cleanup.
167192
}
168193
}
194+
195+
// Drop the foreign table (stored under its raw name, not prefixed).
196+
try {
197+
Db::exec('DROP TABLE IF EXISTS `' . self::TEST_TABLE_FOREIGN_MEDIUM . '`');
198+
} catch (\Exception $e) {
199+
// Ignore errors during cleanup.
200+
}
169201
}
170202
}

tests/PHPUnit/Integration/Updates/Updates5100b1Test.php renamed to tests/PHPUnit/Integration/Updates/Updates5110b2Test.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@
1818
use Piwik\Updater\Migration\Db\Factory as DbFactory;
1919
use Piwik\Updater\Migration\Factory as MigrationFactory;
2020
use Piwik\Updater\Migration\Plugin\Factory as PluginFactory;
21-
use Piwik\Updates\Updates_5_10_0_b1;
21+
use Piwik\Updates\Updates_5_11_0_b2;
2222

23-
require_once PIWIK_INCLUDE_PATH . '/core/Updates/5.10.0-b1.php';
23+
require_once PIWIK_INCLUDE_PATH . '/core/Updates/5.11.0-b2.php';
2424

2525
/**
26-
* @group Updates5100b1Test
26+
* @group Updates5110b2Test
2727
* @group Core
2828
*/
29-
class Updates5100b1Test extends IntegrationTestCase
29+
class Updates5110B2Test extends IntegrationTestCase
3030
{
31-
private const TEST_TABLE_MEDIUM = 'archive_blob_test_5100_medium';
32-
private const TEST_TABLE_LONG = 'archive_blob_test_5100_long';
31+
private const TEST_TABLE_MEDIUM = 'archive_blob_test_5110_medium';
32+
private const TEST_TABLE_LONG = 'archive_blob_test_5110_long';
3333

3434
public function setUp(): void
3535
{
@@ -78,14 +78,14 @@ public function testGetMigrationsReturnsEmptyWhenNoArchiveBlobTablesExist(): voi
7878
// Helpers
7979
// -----------------------------------------------------------------------
8080

81-
private function buildUpdate(): Updates_5_10_0_b1
81+
private function buildUpdate(): Updates_5_11_0_b2
8282
{
8383
$migrationFactory = new MigrationFactory(
8484
$this->getMockBuilder(DbFactory::class)->disableOriginalConstructor()->getMock(),
8585
$this->getMockBuilder(PluginFactory::class)->disableOriginalConstructor()->getMock(),
8686
new ConfigFactory()
8787
);
88-
return new Updates_5_10_0_b1($migrationFactory);
88+
return new Updates_5_11_0_b2($migrationFactory);
8989
}
9090

9191
private function createTestTable(string $tableBaseName, string $blobType): void

tests/PHPUnit/Unit/ArchiveProcessor/ArchiveBlobRowCapTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ public function testCapMaxRowsDBErrorReturnsCap(): void
177177
private function setFlag(int $value): void
178178
{
179179
$config = Config::getInstance();
180+
/** @var array<string, scalar> $database */
180181
$database = $config->database;
181182
if ($value === 0) {
182183
unset($database['archive_blob_tables_may_contain_mediumblob']);

0 commit comments

Comments
 (0)