PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.8.2
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.8.2
5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / app / core / DataAccess / Model.php
matomo / app / core / DataAccess Last commit date
LogQueryBuilder 3 months ago Actions.php 6 months ago ArchiveSelector.php 3 months ago ArchiveTableCreator.php 3 months ago ArchiveTableDao.php 3 months ago ArchiveWriter.php 3 months ago ArchivingDbAdapter.php 1 year ago LogAggregator.php 3 months ago LogQueryBuilder.php 6 months ago LogTableTemporary.php 2 years ago Model.php 3 months ago RawLogDao.php 6 months ago TableMetadata.php 1 year ago
Model.php
833 lines
1 <?php
2
3 /**
4 * Matomo - free/libre analytics platform
5 *
6 * @link https://matomo.org
7 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8 */
9 namespace Piwik\DataAccess;
10
11 use Exception;
12 use Piwik\Archive\ArchiveInvalidator;
13 use Piwik\ArchiveProcessor\Parameters;
14 use Piwik\ArchiveProcessor\Rules;
15 use Piwik\Common;
16 use Piwik\Config\GeneralConfig;
17 use Piwik\Container\StaticContainer;
18 use Piwik\Date;
19 use Piwik\Db;
20 use Piwik\DbHelper;
21 use Piwik\Period;
22 use Piwik\Segment;
23 use Piwik\Sequence;
24 use Piwik\SettingsServer;
25 use Piwik\Site;
26 use Piwik\Log\LoggerInterface;
27 /**
28 * Cleans up outdated archives
29 */
30 class Model
31 {
32 /**
33 * @var LoggerInterface
34 */
35 private $logger;
36 public function __construct(?LoggerInterface $logger = null)
37 {
38 $this->logger = $logger ?: StaticContainer::get(LoggerInterface::class);
39 }
40 /**
41 * Returns the archives IDs that have already been invalidated and have been since re-processed.
42 *
43 * These archives { archive name (includes segment hash) , idsite, date, period } will be deleted.
44 *
45 * @param string $archiveTable
46 * @param bool $setGroupContentMaxLen for tests only
47 * @return array
48 * @throws Exception
49 */
50 public function getInvalidatedArchiveIdsSafeToDelete($archiveTable, $setGroupContentMaxLen = \true)
51 {
52 if ($setGroupContentMaxLen) {
53 try {
54 Db::get()->query('SET SESSION group_concat_max_len=' . 128 * 1024);
55 } catch (\Exception $ex) {
56 $this->logger->info("Could not set group_concat_max_len MySQL session variable.");
57 }
58 }
59 $sql = "SELECT idsite, date1, date2, period, name,\n GROUP_CONCAT(idarchive, '.', value ORDER BY ts_archived DESC, idarchive DESC) as archives\n FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n AND `value` NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ")\n GROUP BY idsite, date1, date2, period, name HAVING count(*) > 1";
60 $archiveIds = array();
61 $rows = Db::fetchAll($sql);
62 foreach ($rows as $row) {
63 $duplicateArchives = explode(',', $row['archives']);
64 // do not consider purging partial archives, if they are the latest archive,
65 // and we don't want to delete the latest archive if it is usable
66 while (!empty($duplicateArchives)) {
67 $pair = $duplicateArchives[0];
68 if ($this->isCutOffGroupConcatResult($pair)) {
69 // can occur if the GROUP_CONCAT value is cut off
70 break;
71 }
72 [$idarchive, $value] = explode('.', $pair);
73 array_shift($duplicateArchives);
74 if ($value != \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL) {
75 break;
76 }
77 }
78 // if there is more than one archive, the older invalidated ones can be deleted
79 if (!empty($duplicateArchives)) {
80 foreach ($duplicateArchives as $pair) {
81 if ($this->isCutOffGroupConcatResult($pair)) {
82 $this->logger->info("GROUP_CONCAT cut off the query result, you may have to purge archives again.");
83 break;
84 }
85 [$idarchive, $value] = explode('.', $pair);
86 $archiveIds[] = $idarchive;
87 // does not matter what the value is, the latest is usable so older archives can be purged
88 }
89 }
90 }
91 return $archiveIds;
92 }
93 public function updateArchiveAsInvalidated($archiveTable, $idSites, $allPeriodsToInvalidate, ?Segment $segment = null, bool $forceInvalidateNonexistentRanges = \false, ?string $name = null, bool $doNotCreateInvalidations = \false)
94 {
95 if (empty($idSites)) {
96 return 0;
97 }
98 // select all idarchive/name pairs we want to invalidate
99 $sql = "SELECT idarchive, idsite, period, date1, date2, `name`, `value`\n FROM `{$archiveTable}`\n WHERE idsite IN (" . implode(',', $idSites) . ") AND value <> " . \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL;
100 $periodCondition = '';
101 if (!empty($allPeriodsToInvalidate)) {
102 $periodCondition .= " AND (";
103 $isFirst = \true;
104 /** @var Period $period */
105 foreach ($allPeriodsToInvalidate as $period) {
106 if ($isFirst) {
107 $isFirst = \false;
108 } else {
109 $periodCondition .= " OR ";
110 }
111 if ($period->getLabel() == 'range') {
112 // for ranges, we delete all ranges that contain the given date(s)
113 $periodCondition .= "(period = " . (int) $period->getId() . " AND date2 >= '" . $period->getDateStart()->getDatetime() . "' AND date1 <= '" . $period->getDateEnd()->getDatetime() . "')";
114 } else {
115 $periodCondition .= "(period = " . (int) $period->getId() . " AND date1 = '" . $period->getDateStart()->getDatetime() . "'" . " AND date2 = '" . $period->getDateEnd()->getDatetime() . "')";
116 }
117 }
118 $periodCondition .= ")";
119 }
120 $sql .= $periodCondition;
121 if (!empty($name)) {
122 if (strpos($name, '.') !== \false) {
123 [$plugin, $name] = explode('.', $name, 2);
124 } else {
125 $plugin = $name;
126 $name = null;
127 }
128 }
129 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment ?: new Segment('', []));
130 if (empty($plugin)) {
131 if (null === $segment) {
132 $nameCondition = "name LIKE '{$doneFlag}%'";
133 // invalidate all segments
134 } else {
135 $nameCondition = "(name = '{$doneFlag}' OR name LIKE '{$doneFlag}.%')";
136 // invalidate specific segment only
137 }
138 } else {
139 if (null === $segment) {
140 $nameCondition = "name LIKE '{$doneFlag}%.{$plugin}'";
141 // invalidate all segments for specific plugin
142 } else {
143 $nameCondition = "name = '{$doneFlag}.{$plugin}'";
144 // invalidate specific segment for specific plugin only
145 }
146 }
147 $sql .= " AND {$nameCondition}";
148 $idArchives = [];
149 $archivesToInvalidate = [];
150 // update each archive as invalidated (but only for full archives or plugin archives, not for partial archives.
151 // DONE_INVALIDATED also implies that an archive is whole and not partial, and we want to avoid that.)
152 if (empty($name)) {
153 $archivesToInvalidate = Db::fetchAll($sql);
154 $idArchives = array_column($archivesToInvalidate, 'idarchive');
155 if (!empty($idArchives)) {
156 $idArchives = array_map('intval', $idArchives);
157 // set status to DONE_INVALIDATED for finished archives
158 $sql = "UPDATE `{$archiveTable}` SET `value` = " . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . " WHERE idarchive IN (" . implode(',', $idArchives) . ") AND value NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ") AND {$nameCondition}";
159 Db::query($sql);
160 // set status to DONE_ERROR_INVALIDATED for currently processed archives
161 $sql = "UPDATE `{$archiveTable}` SET `value` = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . " WHERE idarchive IN (" . implode(',', $idArchives) . ") AND value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . " AND {$nameCondition}";
162 Db::query($sql);
163 }
164 }
165 if ($doNotCreateInvalidations) {
166 return count($idArchives);
167 }
168 // we add every archive we need to invalidate + the archives that do not already exist to archive_invalidations.
169 // except for archives that are DONE_IN_PROGRESS.
170 $archivesToCreateInvalidationRowsFor = [];
171 foreach ($archivesToInvalidate as $row) {
172 $archivesToCreateInvalidationRowsFor[$row['idsite']][$row['period']][$row['date1']][$row['date2']][$row['name']] = $row['idarchive'];
173 }
174 $now = Date::now()->getDatetime();
175 $existingInvalidations = $this->getExistingInvalidations($idSites, $periodCondition, $nameCondition);
176 $hashesOfAllSegmentsToArchiveInCoreArchive = Rules::getSegmentsToProcess($idSites);
177 $hashesOfAllSegmentsToArchiveInCoreArchive = array_map(function ($definition) {
178 return Segment::getSegmentHash($definition);
179 }, $hashesOfAllSegmentsToArchiveInCoreArchive);
180 if (empty($plugin)) {
181 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment ?: new Segment('', []));
182 } else {
183 $doneFlag = Rules::getDoneFlagArchiveContainsOnePlugin($segment ?: new Segment('', []), $plugin);
184 }
185 $dummyArchives = [];
186 foreach ($idSites as $idSite) {
187 try {
188 $siteCreationTime = Site::getCreationDateFor($idSite);
189 } catch (\Exception $ex) {
190 continue;
191 }
192 $siteCreationTime = Date::factory($siteCreationTime);
193 foreach ($allPeriodsToInvalidate as $period) {
194 if ($period->getLabel() == 'range' && !$forceInvalidateNonexistentRanges) {
195 continue;
196 // range
197 }
198 if ($period->getDateEnd()->isEarlier($siteCreationTime)) {
199 continue;
200 // don't add entries if it is before the time the site was created
201 }
202 $date1 = $period->getDateStart()->toString();
203 $date2 = $period->getDateEnd()->toString();
204 // we insert rows for the doneFlag we want to invalidate + any others we invalidated when doing the LIKE above.
205 // if we invalidated something in the archive tables, we want to make sure it appears in the invalidation queue,
206 // so we'll eventually reprocess it.
207 $doneFlagsFound = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2] ?? [];
208 $doneFlagsFound = array_keys($doneFlagsFound);
209 $doneFlagsToCheck = array_merge([$doneFlag], $doneFlagsFound);
210 $doneFlagsToCheck = array_unique($doneFlagsToCheck);
211 foreach ($doneFlagsToCheck as $doneFlagToCheck) {
212 $key = $this->makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period->getId(), $doneFlagToCheck, $name);
213 if (!empty($existingInvalidations[$key])) {
214 continue;
215 // avoid adding duplicates where possible
216 }
217 $hash = $this->getHashFromDoneFlag($doneFlagToCheck);
218 if ($doneFlagToCheck != $doneFlag && (empty($hash) || !in_array($hash, $hashesOfAllSegmentsToArchiveInCoreArchive) || strpos($doneFlagToCheck, '.') !== \false)) {
219 continue;
220 // the done flag is for a segment that is not auto archive or a plugin specific archive, so we don't want to process it.
221 }
222 $idArchive = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2][$doneFlagToCheck] ?? null;
223 $dummyArchives[] = ['idarchive' => $idArchive, 'name' => $doneFlagToCheck, 'report' => $name, 'idsite' => $idSite, 'date1' => $period->getDateStart()->getDatetime(), 'date2' => $period->getDateEnd()->getDatetime(), 'period' => $period->getId(), 'ts_invalidated' => $now];
224 }
225 }
226 }
227 if (!empty($dummyArchives)) {
228 $fields = ['idarchive', 'name', 'report', 'idsite', 'date1', 'date2', 'period', 'ts_invalidated'];
229 Db\BatchInsert::tableInsertBatch(Common::prefixTable('archive_invalidations'), $fields, $dummyArchives);
230 }
231 return count($idArchives);
232 }
233 private function getExistingInvalidations($idSites, $periodCondition, $nameCondition)
234 {
235 $table = Common::prefixTable('archive_invalidations');
236 $idSites = array_map('intval', $idSites);
237 $sql = "SELECT idsite, date1, date2, period, name, report, COUNT(*) as `count` FROM `{$table}`\n WHERE idsite IN (" . implode(',', $idSites) . ") AND status = " . ArchiveInvalidator::INVALIDATION_STATUS_QUEUED . "\n {$periodCondition} AND {$nameCondition}\n GROUP BY idsite, date1, date2, period, name";
238 $rows = Db::fetchAll($sql);
239 $invalidations = [];
240 foreach ($rows as $row) {
241 $key = $this->makeExistingInvalidationArrayKey($row['idsite'], $row['date1'], $row['date2'], $row['period'], $row['name'], $row['report']);
242 $invalidations[$key] = $row['count'];
243 }
244 return $invalidations;
245 }
246 private function makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period, $name, $report)
247 {
248 return implode('.', [$idSite, $date1, $date2, $period, $name, $report]);
249 }
250 /**
251 * @param string $archiveTable Prefixed table name
252 * @param int[] $idSites
253 * @param Period[] $allPeriodsToInvalidate
254 * @throws Exception
255 */
256 public function updateRangeArchiveAsInvalidated($archiveTable, $idSites, $allPeriodsToInvalidate, ?Segment $segment = null) : void
257 {
258 if (empty($idSites)) {
259 return;
260 }
261 $bind = array();
262 $periodConditions = array();
263 if (!empty($allPeriodsToInvalidate)) {
264 foreach ($allPeriodsToInvalidate as $period) {
265 $dateConditions = array();
266 $dateConditions[] = "(date1 <= ? AND ? <= date2)";
267 $bind[] = $period->getDateStart()->getDatetime();
268 $bind[] = $period->getDateEnd()->getDatetime();
269 $dateConditionsSql = implode(" OR ", $dateConditions);
270 $periodConditions[] = "(period = 5 AND ({$dateConditionsSql}))";
271 }
272 }
273 if (null === $segment) {
274 $nameCondition = "name LIKE 'done%'";
275 } else {
276 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment);
277 $nameCondition = "(name = '{$doneFlag}' OR name LIKE '{$doneFlag}.%')";
278 }
279 $sql = "SELECT idarchive FROM `{$archiveTable}` " . " WHERE {$nameCondition}\n AND idsite IN (" . implode(", ", $idSites) . ")\n AND (" . implode(" OR ", $periodConditions) . ")";
280 $recordsToUpdate = Db::fetchAll($sql, $bind);
281 if (empty($recordsToUpdate)) {
282 return;
283 }
284 $idArchives = array_map('intval', array_column($recordsToUpdate, 'idarchive'));
285 $updateSql = "UPDATE `{$archiveTable}` SET value = " . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . " WHERE idarchive IN (" . implode(', ', $idArchives) . ") AND {$nameCondition}" . " AND value NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ")";
286 Db::query($updateSql);
287 $updateSql = "UPDATE `{$archiveTable}` SET value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . " WHERE idarchive IN (" . implode(', ', $idArchives) . ") AND {$nameCondition} AND value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR;
288 Db::query($updateSql);
289 }
290 public function getTemporaryArchivesOlderThan($archiveTable, $purgeArchivesOlderThan)
291 {
292 $temporaryArchiveValues = [\Piwik\DataAccess\ArchiveWriter::DONE_OK_TEMPORARY, \Piwik\DataAccess\ArchiveWriter::DONE_ERROR, \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED];
293 $query = "SELECT idarchive FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n AND ts_archived < ?\n AND value IN (" . implode(', ', $temporaryArchiveValues) . ")";
294 return Db::fetchAll($query, array($purgeArchivesOlderThan));
295 }
296 public function getArchivesMissingDoneFlag(string $archiveTable) : array
297 {
298 $query = "SELECT DISTINCT idarchive\n FROM `{$archiveTable}`\n WHERE idarchive NOT IN (\n SELECT DISTINCT idarchive\n FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n )";
299 return Db::fetchAll($query);
300 }
301 public function deleteArchivesWithPeriod($numericTable, $blobTable, $period, $date)
302 {
303 if (SettingsServer::isArchivePhpTriggered()) {
304 StaticContainer::get(LoggerInterface::class)->info('deleteArchivesWithPeriod: ' . $numericTable . ' with period = ' . $period . ' and date = ' . $date);
305 }
306 $query = "DELETE FROM `%s` WHERE period = ? AND ts_archived < ?";
307 $bind = array($period, $date);
308 $queryObj = Db::query(sprintf($query, $numericTable), $bind);
309 $deletedRows = $queryObj->rowCount();
310 try {
311 $queryObj = Db::query(sprintf($query, $blobTable), $bind);
312 $deletedRows += $queryObj->rowCount();
313 } catch (Exception $e) {
314 // Individual blob tables could be missing
315 $this->logger->debug("Unable to delete archives by period from {blobTable}.", array('blobTable' => $blobTable, 'exception' => $e));
316 }
317 return $deletedRows;
318 }
319 public function deleteArchiveIds($numericTable, $blobTable, $idsToDelete)
320 {
321 $idsToDelete = array_values($idsToDelete);
322 $idsToDelete = array_map('intval', $idsToDelete);
323 $query = "DELETE FROM `%s` WHERE idarchive IN (" . implode(',', $idsToDelete) . ")";
324 $queryObj = Db::query(sprintf($query, $numericTable), array());
325 $deletedRows = $queryObj->rowCount();
326 try {
327 $queryObj = Db::query(sprintf($query, $blobTable), array());
328 $deletedRows += $queryObj->rowCount();
329 } catch (Exception $e) {
330 // Individual blob tables could be missing
331 $this->logger->debug("Unable to delete archive IDs from {blobTable}.", array('blobTable' => $blobTable, 'exception' => $e));
332 }
333 return $deletedRows;
334 }
335 public function deleteOlderArchives(Parameters $params, $name, $tsArchived, $idArchive)
336 {
337 $dateStart = $params->getPeriod()->getDateStart();
338 $dateEnd = $params->getPeriod()->getDateEnd();
339 $numericTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($dateStart);
340 $blobTable = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($dateStart);
341 $sql = "SELECT idarchive FROM `{$numericTable}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND period = ? AND name = ? AND ts_archived <= ? AND idarchive < ?";
342 $idArchives = Db::fetchAll($sql, [$params->getSite()->getId(), $dateStart->getDatetime(), $dateEnd->getDatetime(), $params->getPeriod()->getId(), $name, $tsArchived, $idArchive]);
343 $idArchives = array_column($idArchives, 'idarchive');
344 if (empty($idArchives)) {
345 return;
346 }
347 if (SettingsServer::isArchivePhpTriggered()) {
348 StaticContainer::get(LoggerInterface::class)->info('deleteOlderArchives with ' . $params . ', name = ' . $name . ', ts_archived < ' . $tsArchived . ', idarchive < ' . $idArchive);
349 }
350 $this->deleteArchiveIds($numericTable, $blobTable, $idArchives);
351 }
352 public function getArchiveIdAndVisits($numericTable, $idSite, $period, $dateStartIso, $dateEndIso, $minDatetimeIsoArchiveProcessedUTC, $doneFlags, $doneFlagValues = null)
353 {
354 $bindSQL = array($idSite, $dateStartIso, $dateEndIso, $period);
355 $sqlWhereArchiveName = self::getNameCondition($doneFlags, $doneFlagValues);
356 $timeStampWhere = '';
357 if ($minDatetimeIsoArchiveProcessedUTC) {
358 $timeStampWhere = " AND arc1.ts_archived >= ? ";
359 $bindSQL[] = $minDatetimeIsoArchiveProcessedUTC;
360 }
361 // NOTE: we can't predict how many segments there will be so there could be lots of nb_visits/nb_visits_converted rows... have to select everything.
362 $sqlQuery = "SELECT arc1.idarchive, arc1.value, arc1.name, arc1.ts_archived, arc1.date1 as startDate, arc2.value as " . \Piwik\DataAccess\ArchiveSelector::NB_VISITS_RECORD_LOOKED_UP . ", arc3.value as " . \Piwik\DataAccess\ArchiveSelector::NB_VISITS_CONVERTED_RECORD_LOOKED_UP . "\n FROM `{$numericTable}` arc1\n LEFT JOIN `{$numericTable}` arc2 on arc2.idarchive = arc1.idarchive and (arc2.name = '" . \Piwik\DataAccess\ArchiveSelector::NB_VISITS_RECORD_LOOKED_UP . "')\n LEFT JOIN `{$numericTable}` arc3 on arc3.idarchive = arc1.idarchive and (arc3.name = '" . \Piwik\DataAccess\ArchiveSelector::NB_VISITS_CONVERTED_RECORD_LOOKED_UP . "')\n WHERE arc1.idsite = ?\n AND arc1.date1 = ?\n AND arc1.date2 = ?\n AND arc1.period = ?\n AND ({$sqlWhereArchiveName})\n {$timeStampWhere}\n ORDER BY arc1.ts_archived DESC, arc1.idarchive DESC";
363 $results = Db::fetchAll($sqlQuery, $bindSQL);
364 return $results;
365 }
366 public function createArchiveTable($tableName, $tableNamePrefix)
367 {
368 $db = Db::get();
369 $sql = DbHelper::getTableCreateSql($tableNamePrefix);
370 // replace table name template by real name
371 $tableNamePrefix = Common::prefixTable($tableNamePrefix);
372 $sql = str_replace($tableNamePrefix, $tableName, $sql);
373 try {
374 $db->query($sql);
375 } catch (Exception $e) {
376 // accept mysql error 1050: table already exists, throw otherwise
377 if (!$db->isErrNo($e, '1050')) {
378 throw $e;
379 }
380 }
381 try {
382 if (\Piwik\DataAccess\ArchiveTableCreator::NUMERIC_TABLE === \Piwik\DataAccess\ArchiveTableCreator::getTypeFromTableName($tableName)) {
383 $sequence = new Sequence($tableName);
384 $sequence->create();
385 }
386 } catch (Exception $e) {
387 }
388 }
389 public function getInstalledArchiveTables()
390 {
391 $allArchiveNumeric = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_numeric%') . "'");
392 $allArchiveBlob = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_blob%') . "'");
393 return array_merge($allArchiveBlob, $allArchiveNumeric);
394 }
395 public function allocateNewArchiveId($numericTable)
396 {
397 $sequence = new Sequence($numericTable);
398 try {
399 $idarchive = $sequence->getNextId();
400 } catch (Exception $e) {
401 // edge case: sequence was not found, create it now
402 try {
403 $sequence->create();
404 } catch (Exception $ex) {
405 // Ignore duplicate entry error, as that means another request might have already created the sequence
406 if (!Db::get()->isErrNo($ex, \Piwik\Updater\Migration\Db::ERROR_CODE_DUPLICATE_ENTRY)) {
407 throw $ex;
408 }
409 }
410 $idarchive = $sequence->getNextId();
411 }
412 return $idarchive;
413 }
414 public function updateArchiveStatus($numericTable, $archiveId, $doneFlag, $value)
415 {
416 Db::query("UPDATE {$numericTable} SET `value` = ? WHERE idarchive = ? and `name` = ?", array($value, $archiveId, $doneFlag));
417 }
418 public function getArchiveStatus($numericTable, $archiveId, $doneFlag) : int
419 {
420 return (int) Db::fetchOne("SELECT value FROM `{$numericTable}` WHERE idarchive = ? AND `name` = ?", [$archiveId, $doneFlag]);
421 }
422 public function insertRecord($tableName, $fields, $record, $name, $value)
423 {
424 // duplicate idarchives are Ignored, see https://github.com/piwik/piwik/issues/987
425 $query = "INSERT IGNORE INTO `{$tableName}` (" . implode(", ", $fields) . ")\n VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " . end($fields) . " = ?";
426 $bindSql = $record;
427 $bindSql[] = $name;
428 $bindSql[] = $value;
429 $bindSql[] = $value;
430 Db::query($query, $bindSql);
431 return \true;
432 }
433 /**
434 * Returns the site IDs for invalidated archives in an archive table.
435 *
436 * @param string $numericTable The numeric table to search through.
437 * @return int[]
438 */
439 public function getSitesWithInvalidatedArchive($numericTable)
440 {
441 $rows = Db::fetchAll("SELECT DISTINCT idsite FROM `{$numericTable}` WHERE `name` LIKE 'done%' AND `value` IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . ")");
442 $result = array();
443 foreach ($rows as $row) {
444 $result[] = $row['idsite'];
445 }
446 return $result;
447 }
448 /**
449 * Get a list of IDs of archives that don't have any matching rows in the site table.
450 * @param string $archiveTableName
451 * @return array of IDs
452 */
453 public function getArchiveIdsForDeletedSites($archiveTableName)
454 {
455 $sql = "SELECT DISTINCT idsite FROM `{$archiveTableName}`";
456 $rows = Db::getReader()->fetchAll($sql, array());
457 if (empty($rows)) {
458 return array();
459 // nothing to delete
460 }
461 $idSitesUsed = array_column($rows, 'idsite');
462 $model = new \Piwik\Plugins\SitesManager\Model();
463 $idSitesExisting = $model->getSitesId();
464 $deletedSites = array_diff($idSitesUsed, $idSitesExisting);
465 if (empty($deletedSites)) {
466 return array();
467 }
468 $deletedSites = array_values($deletedSites);
469 $deletedSites = array_map('intval', $deletedSites);
470 $sql = "SELECT DISTINCT idarchive FROM `{$archiveTableName}` WHERE idsite IN (" . implode(',', $deletedSites) . ")";
471 $rows = Db::getReader()->fetchAll($sql, array());
472 return array_column($rows, 'idarchive');
473 }
474 /**
475 * Get a list of IDs of archives with segments that no longer exist in the DB. Excludes temporary archives that
476 * may still be in use, as specified by the $oldestToKeep passed in.
477 * @param string $archiveTableName
478 * @param array $segments List of segments to match against
479 * @param string $oldestToKeep Datetime string
480 * @return array With keys idarchive, name, idsite
481 */
482 public function getArchiveIdsForSegments($archiveTableName, array $segments, $oldestToKeep)
483 {
484 $segmentClauses = [];
485 foreach ($segments as $segment) {
486 if (!empty($segment['definition'])) {
487 $segmentClauses[] = $this->getDeletedSegmentWhereClause($segment);
488 }
489 }
490 if (empty($segmentClauses)) {
491 return array();
492 }
493 $segmentClauses = implode(' OR ', $segmentClauses);
494 $sql = 'SELECT idarchive FROM `' . $archiveTableName . '`' . ' WHERE ts_archived < ?' . ' AND (' . $segmentClauses . ')';
495 $rows = Db::fetchAll($sql, array($oldestToKeep));
496 return array_column($rows, 'idarchive');
497 }
498 private function getDeletedSegmentWhereClause(array $segment)
499 {
500 $idSite = (int) $segment['enable_only_idsite'];
501 $segmentHash = $segment['hash'] ?? '';
502 // Valid segment hashes are md5 strings - just confirm that it is so it's safe for SQL injection
503 if (!ctype_xdigit($segmentHash)) {
504 throw new Exception($segmentHash . ' expected to be an md5 hash');
505 }
506 $nameClause = 'name LIKE "done' . $segmentHash . '%"';
507 $idSiteClause = '';
508 if ($idSite > 0) {
509 $idSiteClause = ' AND idsite = ' . $idSite;
510 } elseif (!empty($segment['idsites_to_preserve'])) {
511 // A segment for all sites was deleted, but there are segments for a single site with the same definition
512 $idSitesToPreserve = array_map('intval', $segment['idsites_to_preserve']);
513 $idSiteClause = ' AND idsite NOT IN (' . implode(',', $idSitesToPreserve) . ')';
514 }
515 return "({$nameClause} {$idSiteClause})";
516 }
517 /**
518 * Returns the SQL condition used to find successfully completed archives that
519 * this instance is querying for.
520 */
521 private static function getNameCondition($doneFlags, $possibleValues)
522 {
523 $allDoneFlags = "'" . implode("','", $doneFlags) . "'";
524 // create the SQL to find archives that are DONE
525 $result = "((arc1.name IN ({$allDoneFlags}))";
526 if (!empty($possibleValues)) {
527 $result .= " AND (arc1.value IN (" . implode(',', $possibleValues) . ")))";
528 }
529 $result .= ')';
530 return $result;
531 }
532 /**
533 * Marks an archive as in progress if it has not been already. This method must be thread
534 * safe.
535 */
536 public function startArchive($invalidation)
537 {
538 $table = Common::prefixTable('archive_invalidations');
539 // set archive value to in progress if not set already
540 $statement = Db::query("UPDATE `{$table}` SET `status` = ?, `processing_host` = ?, `process_id` = ?, `ts_started` = NOW() WHERE `idinvalidation` = ? AND `status` = ?", [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, gethostname() ?: null, Common::getProcessId(), $invalidation['idinvalidation'], ArchiveInvalidator::INVALIDATION_STATUS_QUEUED]);
541 // if we updated, then we've marked the archive as started
542 return $statement->rowCount() > 0;
543 }
544 public function isSimilarArchiveInProgress($invalidation)
545 {
546 $table = Common::prefixTable('archive_invalidations');
547 $bind = [$invalidation['idsite'], $invalidation['period'], $invalidation['date1'], $invalidation['date2'], $invalidation['name'], ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS];
548 if (empty($invalidation['report'])) {
549 $reportClause = "(report IS NULL OR report = '')";
550 } else {
551 $reportClause = "report = ?";
552 $bind[] = $invalidation['report'];
553 }
554 $sql = "SELECT idinvalidation FROM `{$table}` WHERE idsite = ? AND `period` = ? AND date1 = ? AND date2 = ? AND `name` = ? AND `status` = ? AND ts_started IS NOT NULL AND {$reportClause} LIMIT 1";
555 $result = Db::fetchOne($sql, $bind);
556 return !empty($result);
557 }
558 public function getInvalidationsInProgress(array $idSites = [], array $processingHosts = [], ?Date $startTime = null, ?Date $endTime = null) : array
559 {
560 $table = Common::prefixTable('archive_invalidations');
561 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS];
562 $whereConditions = '';
563 if (!empty($processingHosts)) {
564 $whereConditions .= sprintf(' AND `processing_host` IN (%1$s)', Common::getSqlStringFieldsArray($processingHosts));
565 $bind = array_merge($bind, $processingHosts);
566 }
567 if (!empty($idSites)) {
568 $whereConditions .= sprintf(' AND `idsite` IN (' . implode(', ', $idSites) . ')');
569 }
570 if (!empty($startTime)) {
571 $whereConditions .= ' AND `ts_started` > ?';
572 $bind[] = $startTime->toString('Y-m-d H:i:s');
573 }
574 if (!empty($endTime)) {
575 $whereConditions .= ' AND `ts_started` < ?';
576 $bind[] = $endTime->toString('Y-m-d H:i:s');
577 }
578 $sql = "SELECT idinvalidation, idsite, period, date1, date2, name, report, ts_invalidated, ts_started, processing_host, process_id FROM `{$table}` WHERE `status` = ? {$whereConditions} AND ts_started IS NOT NULL ORDER BY ts_started ASC";
579 return Db::fetchAll($sql, $bind);
580 }
581 /**
582 * Gets the next invalidated archive that should be archived in a table.
583 *
584 * @param int $idSite
585 * @param string $archivingStartTime
586 * @param int[]|null $idInvalidationsToExclude
587 * @param bool $useLimit Whether to limit the result set to one result or not. Used in tests only.
588 */
589 public function getNextInvalidatedArchive($idSite, $archivingStartTime, $idInvalidationsToExclude = null, $useLimit = \true)
590 {
591 $table = Common::prefixTable('archive_invalidations');
592 $sql = "SELECT *\n FROM `{$table}`\n WHERE idsite = ? AND status != ? AND ts_invalidated <= ?";
593 $bind = [$idSite, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $archivingStartTime];
594 if (!empty($idInvalidationsToExclude)) {
595 $idInvalidationsToExclude = array_map('intval', $idInvalidationsToExclude);
596 $sql .= " AND idinvalidation NOT IN (" . implode(',', $idInvalidationsToExclude) . ')';
597 }
598 // NOTE: order here is very important to ensure we process lower period archives first, and general 'all' archives before
599 // segment archives, and so we use the latest idinvalidation
600 $sql .= " ORDER BY date1 DESC, period ASC, CHAR_LENGTH(name) ASC, idinvalidation DESC";
601 if ($useLimit) {
602 $sql .= " LIMIT 1";
603 return Db::fetchRow($sql, $bind);
604 } else {
605 return Db::fetchAll($sql, $bind);
606 }
607 }
608 public function deleteInvalidations($archiveInvalidations)
609 {
610 $ids = array_column($archiveInvalidations, 'idinvalidation');
611 $ids = array_map('intval', $ids);
612 $table = Common::prefixTable('archive_invalidations');
613 $sql = "DELETE FROM `{$table}` WHERE idinvalidation IN (" . implode(', ', $ids) . ")";
614 Db::query($sql);
615 }
616 public function removeInvalidationsLike($idSite, $start)
617 {
618 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
619 $table = Common::prefixTable('archive_invalidations');
620 $sql = "DELETE FROM `{$table}` WHERE {$idSitesClause} `name` LIKE ?";
621 Db::query($sql, ['done%.' . str_replace('_', "\\_", $start)]);
622 }
623 public function removeInvalidations($idSite, $plugin, $report)
624 {
625 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
626 $table = Common::prefixTable('archive_invalidations');
627 $sql = "DELETE FROM `{$table}` WHERE {$idSitesClause} `name` LIKE ? AND report = ?";
628 Db::query($sql, ['done%.' . str_replace('_', "\\_", $plugin), $report]);
629 }
630 public function isArchiveAlreadyInProgress($invalidatedArchive)
631 {
632 $table = Common::prefixTable('archive_invalidations');
633 $bind = [$invalidatedArchive['idsite'], $invalidatedArchive['date1'], $invalidatedArchive['date2'], $invalidatedArchive['period'], $invalidatedArchive['name']];
634 $reportClause = "(report = '' OR report IS NULL)";
635 if (!empty($invalidatedArchive['report'])) {
636 $reportClause = "report = ?";
637 $bind[] = $invalidatedArchive['report'];
638 }
639 $sql = "SELECT MAX(idinvalidation) FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND status = 1 AND {$reportClause}";
640 $inProgressInvalidation = Db::fetchOne($sql, $bind);
641 return $inProgressInvalidation;
642 }
643 /**
644 * Returns true if there is an archive that exists that can be used when aggregating an archive for $period.
645 *
646 * @param $idSite
647 * @return bool
648 * @throws Exception
649 */
650 public function hasChildArchivesInPeriod($idSite, Period $period)
651 {
652 $date = $period->getDateStart();
653 while ($date->isEarlier($period->getDateEnd()->addPeriod(1, 'month'))) {
654 $archiveTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($date);
655 // we look for any archive that can be used to compute this one. this includes invalidated archives, since it is possible
656 // under certain circumstances for them to exist, when archiving a higher period that includes them. the main example being
657 // the GoogleAnalyticsImporter which disallows the recomputation of invalidated archives for imported data, since that would
658 // essentially get rid of the imported data.
659 $usableDoneFlags = [\Piwik\DataAccess\ArchiveWriter::DONE_OK, \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED, \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL, \Piwik\DataAccess\ArchiveWriter::DONE_OK_TEMPORARY];
660 $sql = "SELECT idarchive\n FROM `{$archiveTable}`\n WHERE idsite = ? AND date1 >= ? AND date2 <= ? AND period < ? AND `name` LIKE 'done%' AND `value` IN (" . implode(', ', $usableDoneFlags) . ")\n LIMIT 1";
661 $bind = [$idSite, $period->getDateStart()->getDatetime(), $period->getDateEnd()->getDatetime(), $period->getId()];
662 $result = (bool) Db::fetchOne($sql, $bind);
663 if ($result) {
664 return \true;
665 }
666 $date = $date->addPeriod(1, 'month');
667 // move to next archive table
668 }
669 return \false;
670 }
671 /**
672 * Returns true if any invalidations exists for the given
673 * $idsite and $doneFlag (name column) for the $period.
674 *
675 * @param mixed $idSite
676 * @param mixed $doneFlag
677 * @param mixed $report
678 * @return bool
679 * @throws Exception
680 */
681 public function hasInvalidationForPeriodAndName($idSite, Period $period, $doneFlag, $report = null)
682 {
683 $table = Common::prefixTable('archive_invalidations');
684 $report = !empty($report) && !is_array($report) ? [$report] : $report;
685 if (empty($report)) {
686 $sql = "SELECT idinvalidation FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IS NULL LIMIT 1";
687 } else {
688 $sql = "SELECT idinvalidation FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IN (" . Common::getSqlStringFieldsArray($report) . ") LIMIT 1";
689 }
690 $bind = [$idSite, $period->getDateStart()->toString(), $period->getDateEnd()->toString(), $period->getId(), $doneFlag];
691 if (!empty($report)) {
692 $bind = array_merge($bind, $report);
693 }
694 $idInvalidation = Db::fetchOne($sql, $bind);
695 if (empty($idInvalidation)) {
696 return \false;
697 }
698 return \true;
699 }
700 public function deleteInvalidationsForSites(array $idSites)
701 {
702 $idSites = array_map('intval', $idSites);
703 $table = Common::prefixTable('archive_invalidations');
704 $sql = "DELETE FROM `{$table}` WHERE idsite IN (" . implode(',', $idSites) . ")";
705 Db::query($sql);
706 }
707 public function deleteInvalidationsForDeletedSites()
708 {
709 $siteTable = Common::prefixTable('site');
710 $table = Common::prefixTable('archive_invalidations');
711 $sql = "DELETE a FROM `{$table}` a LEFT JOIN `{$siteTable}` s ON a.idsite = s.idsite WHERE s.idsite IS NULL";
712 Db::query($sql);
713 }
714 private function getRemoveInvalidationsIdSitesClause($idSite)
715 {
716 if ($idSite === 'all') {
717 return '';
718 }
719 $idSites = is_array($idSite) ? $idSite : [$idSite];
720 $idSites = array_map('intval', $idSites);
721 $idSitesStr = implode(',', $idSites);
722 return "idsite IN ({$idSitesStr}) AND";
723 }
724 /**
725 * Releases in progress invalidations for the given ids
726 *
727 * To avoid duplicate invalidations in the database, the method is also meant to prevent having duplicates after a reset
728 * Therefor below code will check if any of the invalidations to be reset should be removed instead
729 * An invalidation can be safely removed
730 * - if there exists another queued invalidation with the same parameters
731 * - if there is another running invalidation, that had been started after the current one was invalidated
732 * Otherwise the invalidation will be reset
733 *
734 * @param array $idinvalidations
735 * @throws \Zend_Db_Statement_Exception
736 */
737 public function releaseInProgressInvalidations(array $idinvalidations) : int
738 {
739 $idinvalidations = array_map('intval', $idinvalidations);
740 $table = Common::prefixTable('archive_invalidations');
741 $changedCount = 0;
742 $sql = "SELECT * FROM `{$table}` WHERE idinvalidation IN (" . implode(',', $idinvalidations) . ")";
743 $invalidations = Db::fetchAll($sql);
744 // Check invalidations one by one, to ensure we safely remove invalidations in cases where two identical ones are requested to reset
745 foreach ($invalidations as $invalidation) {
746 // Look for other identical invalidations that are either not started or started after the current one had been invalidated
747 $query = "SELECT COUNT(*) FROM `{$table}` WHERE name = ? AND idsite = ? AND date1 = ? AND date2 = ? AND period = ? AND " . "(status = ? OR (status = ? AND ts_started > ?)) AND idinvalidation != ?";
748 $bind = [$invalidation['name'], $invalidation['idsite'], $invalidation['date1'], $invalidation['date2'], $invalidation['period'], ArchiveInvalidator::INVALIDATION_STATUS_QUEUED, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['ts_invalidated'], $invalidation['idinvalidation']];
749 if (empty($invalidation['report'])) {
750 $query .= " AND (report IS NULL OR report = '')";
751 } else {
752 $query .= " AND report = ?";
753 $bind[] = $invalidation['report'];
754 }
755 $count = Db::fetchOne($query, $bind);
756 if ($count > 0) {
757 $this->logger->info('Found duplicate invalidation for params (name = {name}, idsite = {idsite}, date1 = {date1}, date2 = {date2}, period = {period}, report = {report}). Removing invalidation {idinvalidation} instead of resetting it.', $invalidation);
758 $sql = "DELETE FROM `{$table}` WHERE status = ? AND idinvalidation = ?";
759 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['idinvalidation']];
760 $query = Db::query($sql, $bind);
761 $changedCount += $query->rowCount();
762 } else {
763 $sql = "UPDATE `{$table}` SET status = ?, processing_host = NULL, process_id = NULL, ts_started = NULL WHERE status = ? AND idinvalidation = ?";
764 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_QUEUED, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['idinvalidation']];
765 $query = Db::query($sql, $bind);
766 $changedCount += $query->rowCount();
767 }
768 }
769 return $changedCount;
770 }
771 public function resetFailedArchivingJobs()
772 {
773 $invalidationsInProgress = $this->getInvalidationsInProgress();
774 $idsToReset = [];
775 foreach ($invalidationsInProgress as $invalidation) {
776 $archiveFailureRecoveryTimeout = GeneralConfig::getConfigValue('archive_failure_recovery_timeout', $invalidation['idsite']);
777 if (empty($invalidation['ts_started']) || Date::factory($invalidation['ts_started'])->getTimestamp() < Date::now()->getTimestamp() - $archiveFailureRecoveryTimeout) {
778 $idsToReset[] = $invalidation['idinvalidation'];
779 }
780 }
781 if (empty($idsToReset)) {
782 return 0;
783 }
784 return $this->releaseInProgressInvalidations($idsToReset);
785 }
786 public function getRecordsContainedInArchives(Date $archiveStartDate, array $idArchives, $requestedRecords) : array
787 {
788 $idArchives = array_map('intval', $idArchives);
789 $idArchives = implode(',', $idArchives);
790 $requestedRecords = is_string($requestedRecords) ? [$requestedRecords] : $requestedRecords;
791 $placeholders = Common::getSqlStringFieldsArray($requestedRecords);
792 $countSql = "SELECT DISTINCT name FROM `%s` WHERE idarchive IN ({$idArchives}) AND name IN ({$placeholders}) LIMIT " . count($requestedRecords);
793 $numericTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($archiveStartDate);
794 $blobTable = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($archiveStartDate);
795 // if the requested metrics look numeric, prioritize the numeric table, otherwise the blob table. this way, if all the metrics are
796 // found in this table (which will be most of the time), we don't have to query the other table
797 if ($this->doRequestedRecordsLookNumeric($requestedRecords)) {
798 $tablesToSearch = [$numericTable, $blobTable];
799 } else {
800 $tablesToSearch = [$blobTable, $numericTable];
801 }
802 $existingRecords = [];
803 foreach ($tablesToSearch as $tableName) {
804 $sql = sprintf($countSql, $tableName);
805 $rows = Db::fetchAll($sql, $requestedRecords);
806 $existingRecords = array_merge($existingRecords, array_column($rows, 'name'));
807 if (count($existingRecords) == count($requestedRecords)) {
808 break;
809 }
810 }
811 return $existingRecords;
812 }
813 private function isCutOffGroupConcatResult($pair)
814 {
815 $position = strpos($pair, '.');
816 return $position === \false || $position === strlen($pair) - 1;
817 }
818 private function getHashFromDoneFlag($doneFlag)
819 {
820 preg_match('/^done([a-zA-Z0-9]+)/', $doneFlag, $matches);
821 return $matches[1] ?? '';
822 }
823 private function doRequestedRecordsLookNumeric(array $requestedRecords) : bool
824 {
825 foreach ($requestedRecords as $record) {
826 if (preg_match('/^nb_/', $record)) {
827 return \true;
828 }
829 }
830 return \false;
831 }
832 }
833