PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 4.13.5
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v4.13.5
5.12.0 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 years ago Actions.php 5 years ago ArchiveSelector.php 3 years ago ArchiveTableCreator.php 5 years ago ArchiveTableDao.php 5 years ago ArchiveWriter.php 3 years ago ArchivingDbAdapter.php 4 years ago LogAggregator.php 3 years ago LogQueryBuilder.php 5 years ago LogTableTemporary.php 5 years ago Model.php 3 years ago RawLogDao.php 4 years ago TableMetadata.php 5 years ago
Model.php
998 lines
1 <?php
2 /**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
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\Container\StaticContainer;
17 use Piwik\Date;
18 use Piwik\Db;
19 use Piwik\DbHelper;
20 use Piwik\Period;
21 use Piwik\Segment;
22 use Piwik\Sequence;
23 use Piwik\SettingsServer;
24 use Piwik\Site;
25 use Psr\Log\LoggerInterface;
26
27 /**
28 * Cleans up outdated archives
29 */
30 class Model
31 {
32 /**
33 * @var LoggerInterface
34 */
35 private $logger;
36
37 public function __construct(LoggerInterface $logger = null)
38 {
39 $this->logger = $logger ?: StaticContainer::get('Psr\Log\LoggerInterface');
40 }
41
42 /**
43 * Returns the archives IDs that have already been invalidated and have been since re-processed.
44 *
45 * These archives { archive name (includes segment hash) , idsite, date, period } will be deleted.
46 *
47 * @param string $archiveTable
48 * @param array $idSites
49 * @param bool $setGroupContentMaxLen for tests only
50 * @return array
51 * @throws Exception
52 */
53 public function getInvalidatedArchiveIdsSafeToDelete($archiveTable, $setGroupContentMaxLen = true)
54 {
55 if ($setGroupContentMaxLen) {
56 try {
57 Db::get()->query('SET SESSION group_concat_max_len=' . (128 * 1024));
58 } catch (\Exception $ex) {
59 $this->logger->info("Could not set group_concat_max_len MySQL session variable.");
60 }
61 }
62
63 $sql = "SELECT idsite, date1, date2, period, name,
64 GROUP_CONCAT(idarchive, '.', value ORDER BY ts_archived DESC) as archives
65 FROM `$archiveTable`
66 WHERE name LIKE 'done%'
67 AND ts_archived IS NOT NULL
68 AND `value` NOT IN (" . ArchiveWriter::DONE_ERROR . ")
69 GROUP BY idsite, date1, date2, period, name HAVING count(*) > 1";
70
71 $archiveIds = array();
72
73 $rows = Db::fetchAll($sql);
74 foreach ($rows as $row) {
75 $duplicateArchives = explode(',', $row['archives']);
76
77 // do not consider purging partial archives, if they are the latest archive,
78 // and we don't want to delete the latest archive if it is usable
79 while (!empty($duplicateArchives)) {
80 $pair = $duplicateArchives[0];
81 if ($this->isCutOffGroupConcatResult($pair)) { // can occur if the GROUP_CONCAT value is cut off
82 break;
83 }
84
85 [$idarchive, $value] = explode('.', $pair);
86
87 array_shift($duplicateArchives);
88
89 if ($value != ArchiveWriter::DONE_PARTIAL) {
90 break;
91 }
92 }
93
94 // if there is more than one archive, the older invalidated ones can be deleted
95 if (!empty($duplicateArchives)) {
96 foreach ($duplicateArchives as $pair) {
97 if ($this->isCutOffGroupConcatResult($pair)) {
98 $this->logger->info("GROUP_CONCAT cut off the query result, you may have to purge archives again.");
99 break;
100 }
101
102 [$idarchive, $value] = explode('.', $pair);
103 $archiveIds[] = $idarchive; // does not matter what the value is, the latest is usable so older archives can be purged
104 }
105 }
106 }
107
108 return $archiveIds;
109 }
110
111 public function getPlaceholderArchiveIds($archiveTable)
112 {
113 $sql = "SELECT DISTINCT idarchive FROM `$archiveTable` WHERE ts_archived IS NULL";
114 $result = Db::fetchAll($sql);
115 $result = array_column($result, 'idarchive');
116 return $result;
117 }
118
119 public function updateArchiveAsInvalidated($archiveTable, $idSites, $allPeriodsToInvalidate, Segment $segment = null,
120 $forceInvalidateNonexistentRanges = false, $name = null)
121 {
122 if (empty($idSites)) {
123 return 0;
124 }
125
126 // select all idarchive/name pairs we want to invalidate
127 $sql = "SELECT idarchive, idsite, period, date1, date2, `name`, `value`
128 FROM `$archiveTable`
129 WHERE idsite IN (" . implode(',', $idSites) . ") AND value <> " . ArchiveWriter::DONE_PARTIAL;
130
131 $periodCondition = '';
132 if (!empty($allPeriodsToInvalidate)) {
133 $periodCondition .= " AND (";
134
135 $isFirst = true;
136 /** @var Period $period */
137 foreach ($allPeriodsToInvalidate as $period) {
138 if ($isFirst) {
139 $isFirst = false;
140 } else {
141 $periodCondition .= " OR ";
142 }
143
144 if ($period->getLabel() == 'range') { // for ranges, we delete all ranges that contain the given date(s)
145 $periodCondition .= "(period = " . (int)$period->getId()
146 . " AND date2 >= '" . $period->getDateStart()->getDatetime()
147 . "' AND date1 <= '" . $period->getDateEnd()->getDatetime() . "')";
148 } else {
149 $periodCondition .= "(period = " . (int)$period->getId()
150 . " AND date1 = '" . $period->getDateStart()->getDatetime() . "'"
151 . " AND date2 = '" . $period->getDateEnd()->getDatetime() . "')";
152 }
153 }
154 $periodCondition .= ")";
155 }
156 $sql .= $periodCondition;
157
158 if (!empty($name)) {
159 if (strpos($name, '.') !== false) {
160 [$plugin, $name] = explode('.', $name, 2);
161 } else {
162 $plugin = $name;
163 $name = null;
164 }
165 }
166
167 if (empty($plugin)) {
168 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment ?: new Segment('', []));
169 } else {
170 $doneFlag = Rules::getDoneFlagArchiveContainsOnePlugin($segment ?: new Segment('', []), $plugin);
171 }
172
173 $nameCondition = "name LIKE '$doneFlag%'";
174
175 $sql .= " AND $nameCondition";
176
177 $idArchives = [];
178 $archivesToInvalidate = [];
179
180 // update each archive as invalidated (but only for full archives or plugin archives, not for partial archives.
181 // DONE_INVALIDATED also implies that an archive is whole and not partial, and we want to avoid that.)
182 if (empty($name)) {
183 $archivesToInvalidate = Db::fetchAll($sql);
184 $idArchives = array_column($archivesToInvalidate, 'idarchive');
185
186 if (!empty($idArchives)) {
187 $idArchives = array_map('intval', $idArchives);
188
189 $sql = "UPDATE `$archiveTable` SET `value` = " . ArchiveWriter::DONE_INVALIDATED . " WHERE idarchive IN ("
190 . implode(',', $idArchives) . ") AND $nameCondition";
191
192 Db::query($sql);
193 }
194 }
195
196 // we add every archive we need to invalidate + the archives that do not already exist to archive_invalidations.
197 // except for archives that are DONE_IN_PROGRESS.
198 $archivesToCreateInvalidationRowsFor = [];
199 foreach ($archivesToInvalidate as $row) {
200 $archivesToCreateInvalidationRowsFor[$row['idsite']][$row['period']][$row['date1']][$row['date2']][$row['name']] = $row['idarchive'];
201 }
202
203 $now = Date::now()->getDatetime();
204
205 $existingInvalidations = $this->getExistingInvalidations($idSites, $periodCondition, $nameCondition);
206
207 $hashesOfAllSegmentsToArchiveInCoreArchive = Rules::getSegmentsToProcess($idSites);
208 $hashesOfAllSegmentsToArchiveInCoreArchive = array_map(function ($definition) {
209 return Segment::getSegmentHash($definition);
210 }, $hashesOfAllSegmentsToArchiveInCoreArchive);
211
212 $dummyArchives = [];
213 foreach ($idSites as $idSite) {
214 try {
215 $siteCreationTime = Site::getCreationDateFor($idSite);
216 } catch (\Exception $ex) {
217 continue;
218 }
219
220 $siteCreationTime = Date::factory($siteCreationTime);
221 foreach ($allPeriodsToInvalidate as $period) {
222 if ($period->getLabel() == 'range'
223 && !$forceInvalidateNonexistentRanges
224 ) {
225 continue; // range
226 }
227
228 if ($period->getDateEnd()->isEarlier($siteCreationTime)) {
229 continue; // don't add entries if it is before the time the site was created
230 }
231
232 $date1 = $period->getDateStart()->toString();
233 $date2 = $period->getDateEnd()->toString();
234
235 // we insert rows for the doneFlag we want to invalidate + any others we invalidated when doing the LIKE above.
236 // if we invalidated something in the archive tables, we want to make sure it appears in the invalidation queue,
237 // so we'll eventually reprocess it.
238 $doneFlagsFound = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2] ?? [];
239 $doneFlagsFound = array_keys($doneFlagsFound);
240 $doneFlagsToCheck = array_merge([$doneFlag], $doneFlagsFound);
241 $doneFlagsToCheck = array_unique($doneFlagsToCheck);
242
243 foreach ($doneFlagsToCheck as $doneFlagToCheck) {
244 $key = $this->makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period->getId(), $doneFlagToCheck, $name);
245 if (!empty($existingInvalidations[$key])) {
246 continue; // avoid adding duplicates where possible
247 }
248
249 $hash = $this->getHashFromDoneFlag($doneFlagToCheck);
250 if ($doneFlagToCheck != $doneFlag
251 && (empty($hash)
252 || !in_array($hash, $hashesOfAllSegmentsToArchiveInCoreArchive)
253 || strpos($doneFlagToCheck, '.') !== false)
254 ) {
255 continue; // 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.
256 }
257
258 $idArchive = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2][$doneFlagToCheck] ?? null;
259
260 $dummyArchives[] = [
261 'idarchive' => $idArchive,
262 'name' => $doneFlagToCheck,
263 'report' => $name,
264 'idsite' => $idSite,
265 'date1' => $period->getDateStart()->getDatetime(),
266 'date2' => $period->getDateEnd()->getDatetime(),
267 'period' => $period->getId(),
268 'ts_invalidated' => $now,
269 ];
270 }
271 }
272 }
273
274 if (!empty($dummyArchives)) {
275 $fields = ['idarchive', 'name', 'report', 'idsite', 'date1', 'date2', 'period', 'ts_invalidated'];
276 Db\BatchInsert::tableInsertBatch(Common::prefixTable('archive_invalidations'), $fields, $dummyArchives);
277 }
278
279 return count($idArchives);
280 }
281
282 private function getExistingInvalidations($idSites, $periodCondition, $nameCondition)
283 {
284 $table = Common::prefixTable('archive_invalidations');
285
286 $idSites = array_map('intval', $idSites);
287
288 $sql = "SELECT idsite, date1, date2, period, name, report, COUNT(*) as `count` FROM `$table`
289 WHERE idsite IN (" . implode(',', $idSites) . ") AND status = " . ArchiveInvalidator::INVALIDATION_STATUS_QUEUED . "
290 $periodCondition AND $nameCondition
291 GROUP BY idsite, date1, date2, period, name";
292 $rows = Db::fetchAll($sql);
293
294 $invalidations = [];
295 foreach ($rows as $row) {
296 $key = $this->makeExistingInvalidationArrayKey($row['idsite'], $row['date1'], $row['date2'], $row['period'], $row['name'], $row['report']);
297 $invalidations[$key] = $row['count'];
298 }
299 return $invalidations;
300 }
301
302 private function makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period, $name, $report)
303 {
304 return implode('.', [$idSite, $date1, $date2, $period, $name, $report]);
305 }
306
307 /**
308 * @param string $archiveTable Prefixed table name
309 * @param int[] $idSites
310 * @param string[][] $datesByPeriodType
311 * @param Segment $segment
312 * @return \Zend_Db_Statement
313 * @throws Exception
314 */
315 public function updateRangeArchiveAsInvalidated($archiveTable, $idSites, $allPeriodsToInvalidate, Segment $segment = null)
316 {
317 if (empty($idSites)) {
318 return;
319 }
320
321 $bind = array();
322
323 $periodConditions = array();
324 if (!empty($allPeriodsToInvalidate)) {
325 foreach ($allPeriodsToInvalidate as $period) {
326 $dateConditions = array();
327
328 /** @var Period $period */
329 $dateConditions[] = "(date1 <= ? AND ? <= date2)";
330 $bind[] = $period->getDateStart()->getDatetime();
331 $bind[] = $period->getDateEnd()->getDatetime();
332
333 $dateConditionsSql = implode(" OR ", $dateConditions);
334 $periodConditions[] = "(period = 5 AND ($dateConditionsSql))";
335 }
336 }
337
338 if ($segment) {
339 $nameCondition = "name LIKE '" . Rules::getDoneFlagArchiveContainsAllPlugins($segment) . "%'";
340 } else {
341 $nameCondition = "name LIKE 'done%'";
342 }
343
344 $sql = "UPDATE $archiveTable SET value = " . ArchiveWriter::DONE_INVALIDATED
345 . " WHERE $nameCondition
346 AND idsite IN (" . implode(", ", $idSites) . ")
347 AND (" . implode(" OR ", $periodConditions) . ")";
348
349 return Db::query($sql, $bind);
350 }
351
352 public function getTemporaryArchivesOlderThan($archiveTable, $purgeArchivesOlderThan)
353 {
354 $query = "SELECT idarchive FROM " . $archiveTable . "
355 WHERE name LIKE 'done%'
356 AND (( value = " . ArchiveWriter::DONE_OK_TEMPORARY . "
357 AND ts_archived < ?)
358 OR value = " . ArchiveWriter::DONE_ERROR . ")";
359
360 return Db::fetchAll($query, array($purgeArchivesOlderThan));
361 }
362
363 public function deleteArchivesWithPeriod($numericTable, $blobTable, $period, $date)
364 {
365 if (SettingsServer::isArchivePhpTriggered()) {
366 StaticContainer::get(LoggerInterface::class)->info('deleteArchivesWithPeriod: ' . $numericTable . ' with period = ' . $period . ' and date = ' . $date);
367 }
368
369 $query = "DELETE FROM %s WHERE period = ? AND ts_archived < ?";
370 $bind = array($period, $date);
371
372 $queryObj = Db::query(sprintf($query, $numericTable), $bind);
373 $deletedRows = $queryObj->rowCount();
374
375 try {
376 $queryObj = Db::query(sprintf($query, $blobTable), $bind);
377 $deletedRows += $queryObj->rowCount();
378 } catch (Exception $e) {
379 // Individual blob tables could be missing
380 $this->logger->debug("Unable to delete archives by period from {blobTable}.", array(
381 'blobTable' => $blobTable,
382 'exception' => $e,
383 ));
384 }
385
386 return $deletedRows;
387 }
388
389 public function deleteArchiveIds($numericTable, $blobTable, $idsToDelete)
390 {
391 $idsToDelete = array_values($idsToDelete);
392
393 $idsToDelete = array_map('intval', $idsToDelete);
394 $query = "DELETE FROM %s WHERE idarchive IN (" . implode(',', $idsToDelete) . ")";
395
396 $queryObj = Db::query(sprintf($query, $numericTable), array());
397 $deletedRows = $queryObj->rowCount();
398
399 try {
400 $queryObj = Db::query(sprintf($query, $blobTable), array());
401 $deletedRows += $queryObj->rowCount();
402 } catch (Exception $e) {
403 // Individual blob tables could be missing
404 $this->logger->debug("Unable to delete archive IDs from {blobTable}.", array(
405 'blobTable' => $blobTable,
406 'exception' => $e,
407 ));
408 }
409
410 return $deletedRows;
411 }
412
413 public function deleteOlderArchives(Parameters $params, $name, $tsArchived, $idArchive)
414 {
415 $dateStart = $params->getPeriod()->getDateStart();
416 $dateEnd = $params->getPeriod()->getDateEnd();
417
418 $numericTable = ArchiveTableCreator::getNumericTable($dateStart);
419 $blobTable = ArchiveTableCreator::getBlobTable($dateStart);
420
421 $sql = "SELECT idarchive FROM `$numericTable` WHERE idsite = ? AND date1 = ? AND date2 = ? AND period = ? AND name = ? AND ts_archived < ? AND idarchive < ?";
422
423 $idArchives = Db::fetchAll($sql, [$params->getSite()->getId(), $dateStart->getDatetime(), $dateEnd->getDatetime(), $params->getPeriod()->getId(), $name, $tsArchived, $idArchive]);
424 $idArchives = array_column($idArchives, 'idarchive');
425 if (empty($idArchives)) {
426 return;
427 }
428
429 if (SettingsServer::isArchivePhpTriggered()) {
430 StaticContainer::get(LoggerInterface::class)->info('deleteOlderArchives with ' . $params . ', name = ' . $name . ', ts_archived < ' . $tsArchived . ', idarchive < ' . $idArchive);
431 }
432
433 $this->deleteArchiveIds($numericTable, $blobTable, $idArchives);
434 }
435
436 public function getArchiveIdAndVisits($numericTable, $idSite, $period, $dateStartIso, $dateEndIso, $minDatetimeIsoArchiveProcessedUTC,
437 $doneFlags, $doneFlagValues = null)
438 {
439 $bindSQL = array($idSite,
440 $dateStartIso,
441 $dateEndIso,
442 $period,
443 );
444
445 $sqlWhereArchiveName = self::getNameCondition($doneFlags, $doneFlagValues);
446
447 $timeStampWhere = '';
448 if ($minDatetimeIsoArchiveProcessedUTC) {
449 $timeStampWhere = " AND arc1.ts_archived >= ? ";
450 $bindSQL[] = $minDatetimeIsoArchiveProcessedUTC;
451 }
452
453 // 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.
454 $sqlQuery = "SELECT arc1.idarchive, arc1.value, arc1.name, arc1.ts_archived, arc1.date1 as startDate, arc2.value as " . ArchiveSelector::NB_VISITS_RECORD_LOOKED_UP . ", arc3.value as " . ArchiveSelector::NB_VISITS_CONVERTED_RECORD_LOOKED_UP . "
455 FROM $numericTable arc1
456 LEFT JOIN $numericTable arc2 on arc2.idarchive = arc1.idarchive and (arc2.name = '" . ArchiveSelector::NB_VISITS_RECORD_LOOKED_UP . "')
457 LEFT JOIN $numericTable arc3 on arc3.idarchive = arc1.idarchive and (arc3.name = '" . ArchiveSelector::NB_VISITS_CONVERTED_RECORD_LOOKED_UP . "')
458 WHERE arc1.idsite = ?
459 AND arc1.date1 = ?
460 AND arc1.date2 = ?
461 AND arc1.period = ?
462 AND ($sqlWhereArchiveName)
463 $timeStampWhere
464 AND arc1.ts_archived IS NOT NULL
465 ORDER BY arc1.ts_archived DESC, arc1.idarchive DESC";
466
467 $results = Db::fetchAll($sqlQuery, $bindSQL);
468
469 return $results;
470 }
471
472 public function createArchiveTable($tableName, $tableNamePrefix)
473 {
474 $db = Db::get();
475 $sql = DbHelper::getTableCreateSql($tableNamePrefix);
476
477 // replace table name template by real name
478 $tableNamePrefix = Common::prefixTable($tableNamePrefix);
479 $sql = str_replace($tableNamePrefix, $tableName, $sql);
480
481 try {
482 $db->query($sql);
483 } catch (Exception $e) {
484 // accept mysql error 1050: table already exists, throw otherwise
485 if (!$db->isErrNo($e, '1050')) {
486 throw $e;
487 }
488 }
489
490 try {
491 if (ArchiveTableCreator::NUMERIC_TABLE === ArchiveTableCreator::getTypeFromTableName($tableName)) {
492 $sequence = new Sequence($tableName);
493 $sequence->create();
494 }
495 } catch (Exception $e) {
496 }
497 }
498
499 public function getInstalledArchiveTables()
500 {
501 $allArchiveNumeric = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_numeric%') . "'");
502 $allArchiveBlob = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_blob%') ."'");
503
504 return array_merge($allArchiveBlob, $allArchiveNumeric);
505 }
506
507 public function allocateNewArchiveId($numericTable)
508 {
509 $sequence = new Sequence($numericTable);
510
511 try {
512 $idarchive = $sequence->getNextId();
513 } catch (Exception $e) {
514 // edge case: sequence was not found, create it now
515 try {
516 $sequence->create();
517 } catch (Exception $ex) {
518 // Ignore duplicate entry error, as that means another request might have already created the sequence
519 if (!Db::get()->isErrNo($ex, \Piwik\Updater\Migration\Db::ERROR_CODE_DUPLICATE_ENTRY)) {
520 throw $ex;
521 }
522 }
523
524 $idarchive = $sequence->getNextId();
525 }
526
527 return $idarchive;
528 }
529
530 public function updateArchiveStatus($numericTable, $archiveId, $doneFlag, $value)
531 {
532 Db::query("UPDATE $numericTable SET `value` = ? WHERE idarchive = ? and `name` = ?",
533 array($value, $archiveId, $doneFlag)
534 );
535 }
536
537 public function insertRecord($tableName, $fields, $record, $name, $value)
538 {
539 // duplicate idarchives are Ignored, see https://github.com/piwik/piwik/issues/987
540 $query = "INSERT IGNORE INTO " . $tableName . " (" . implode(", ", $fields) . ")
541 VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " . end($fields) . " = ?";
542
543 $bindSql = $record;
544 $bindSql[] = $name;
545 $bindSql[] = $value;
546 $bindSql[] = $value;
547
548 Db::query($query, $bindSql);
549
550 return true;
551 }
552
553 /**
554 * Returns the site IDs for invalidated archives in an archive table.
555 *
556 * @param string $numericTable The numeric table to search through.
557 * @return int[]
558 */
559 public function getSitesWithInvalidatedArchive($numericTable)
560 {
561 $rows = Db::fetchAll("SELECT DISTINCT idsite FROM `$numericTable` WHERE `name` LIKE 'done%' AND `value` IN ("
562 . ArchiveWriter::DONE_INVALIDATED . ")");
563
564 $result = array();
565 foreach ($rows as $row) {
566 $result[] = $row['idsite'];
567 }
568 return $result;
569 }
570
571 /**
572 * Get a list of IDs of archives that don't have any matching rows in the site table. Excludes temporary archives
573 * that may still be in use, as specified by the $oldestToKeep passed in.
574 * @param string $archiveTableName
575 * @param string $oldestToKeep Datetime string
576 * @return array of IDs
577 */
578 public function getArchiveIdsForDeletedSites($archiveTableName)
579 {
580 $sql = "SELECT DISTINCT idsite FROM " . $archiveTableName;
581 $rows = Db::getReader()->fetchAll($sql, array());
582
583 if (empty($rows)) {
584 return array(); // nothing to delete
585 }
586
587 $idSitesUsed = array_column($rows, 'idsite');
588
589 $model = new \Piwik\Plugins\SitesManager\Model();
590 $idSitesExisting = $model->getSitesId();
591
592 $deletedSites = array_diff($idSitesUsed, $idSitesExisting);
593
594 if (empty($deletedSites)) {
595 return array();
596 }
597 $deletedSites = array_values($deletedSites);
598 $deletedSites = array_map('intval', $deletedSites);
599
600 $sql = "SELECT DISTINCT idarchive FROM " . $archiveTableName . " WHERE idsite IN (".implode(',',$deletedSites).")";
601
602 $rows = Db::getReader()->fetchAll($sql, array());
603
604 return array_column($rows, 'idarchive');
605 }
606
607 /**
608 * Get a list of IDs of archives with segments that no longer exist in the DB. Excludes temporary archives that
609 * may still be in use, as specified by the $oldestToKeep passed in.
610 * @param string $archiveTableName
611 * @param array $segments List of segments to match against
612 * @param string $oldestToKeep Datetime string
613 * @return array With keys idarchive, name, idsite
614 */
615 public function getArchiveIdsForSegments($archiveTableName, array $segments, $oldestToKeep)
616 {
617 $segmentClauses = [];
618 foreach ($segments as $segment) {
619 if (!empty($segment['definition'])) {
620 $segmentClauses[] = $this->getDeletedSegmentWhereClause($segment);
621 }
622 }
623
624 if (empty($segmentClauses)) {
625 return array();
626 }
627
628 $segmentClauses = implode(' OR ', $segmentClauses);
629
630 $sql = 'SELECT idarchive FROM ' . $archiveTableName
631 . ' WHERE ts_archived < ?'
632 . ' AND (' . $segmentClauses . ')';
633
634 $rows = Db::fetchAll($sql, array($oldestToKeep));
635
636 return array_column($rows, 'idarchive');
637 }
638
639 private function getDeletedSegmentWhereClause(array $segment)
640 {
641 $idSite = (int)$segment['enable_only_idsite'];
642 $segmentHash = $segment['hash'] ?? '';
643 // Valid segment hashes are md5 strings - just confirm that it is so it's safe for SQL injection
644 if (!ctype_xdigit($segmentHash)) {
645 throw new Exception($segmentHash . ' expected to be an md5 hash');
646 }
647
648 $nameClause = 'name LIKE "done' . $segmentHash . '%"';
649 $idSiteClause = '';
650 if ($idSite > 0) {
651 $idSiteClause = ' AND idsite = ' . $idSite;
652 } elseif (! empty($segment['idsites_to_preserve'])) {
653 // A segment for all sites was deleted, but there are segments for a single site with the same definition
654 $idSitesToPreserve = array_map('intval', $segment['idsites_to_preserve']);
655 $idSiteClause = ' AND idsite NOT IN (' . implode(',', $idSitesToPreserve) . ')';
656 }
657
658 return "($nameClause $idSiteClause)";
659 }
660
661 /**
662 * Returns the SQL condition used to find successfully completed archives that
663 * this instance is querying for.
664 */
665 private static function getNameCondition($doneFlags, $possibleValues)
666 {
667 $allDoneFlags = "'" . implode("','", $doneFlags) . "'";
668
669 // create the SQL to find archives that are DONE
670 $result = "((arc1.name IN ($allDoneFlags))";
671
672 if (!empty($possibleValues)) {
673 $result .= " AND (arc1.value IN (" . implode(',', $possibleValues) . ")))";
674 }
675 $result .= ')';
676
677 return $result;
678 }
679
680 /**
681 * Marks an archive as in progress if it has not been already. This method must be thread
682 * safe.
683 */
684 public function startArchive($invalidation)
685 {
686 $table = Common::prefixTable('archive_invalidations');
687
688 // set archive value to in progress if not set already
689 $statement = Db::query("UPDATE `$table` SET `status` = ?, ts_started = NOW() WHERE idinvalidation = ? AND status = ?", [
690 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
691 $invalidation['idinvalidation'],
692 ArchiveInvalidator::INVALIDATION_STATUS_QUEUED,
693 ]);
694
695 if ($statement->rowCount() > 0) { // if we updated, then we've marked the archive as started
696 return true;
697 }
698
699 // archive was not originally started or was started within 24 hours, we assume it's ongoing and another process
700 // (on this machine or another) is actively archiving it.
701 if (empty($invalidation['ts_started'])
702 || $invalidation['ts_started'] > Date::now()->subDay(1)->getTimestamp()
703 ) {
704 return false;
705 }
706
707 // archive was started over 24 hours ago, we assume it failed and take it over
708 Db::query("UPDATE `$table` SET `status` = ?, ts_started = NOW() WHERE idinvalidation = ?", [
709 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
710 $invalidation['idinvalidation'],
711 ]);
712
713 // remove similar invalidations w/ lesser idinvalidation values
714 $bind = [
715 $invalidation['idsite'],
716 $invalidation['period'],
717 $invalidation['date1'],
718 $invalidation['date2'],
719 $invalidation['name'],
720 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
721 ];
722
723 if (empty($invalidation['report'])) {
724 $reportClause = "(report IS NULL OR report = '')";
725 } else {
726 $reportClause = "report = ?";
727 $bind[] = $invalidation['report'];
728 }
729
730 $sql = "DELETE FROM " . Common::prefixTable('archive_invalidations') . " WHERE idinvalidation < ? AND idsite = ? AND "
731 . "date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND $reportClause";
732 Db::query($sql, $bind);
733
734 return true;
735 }
736
737 public function isSimilarArchiveInProgress($invalidation)
738 {
739 $table = Common::prefixTable('archive_invalidations');
740
741 $bind = [
742 $invalidation['idsite'],
743 $invalidation['period'],
744 $invalidation['date1'],
745 $invalidation['date2'],
746 $invalidation['name'],
747 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
748 ];
749
750 if (empty($invalidation['report'])) {
751 $reportClause = "(report IS NULL OR report = '')";
752 } else {
753 $reportClause = "report = ?";
754 $bind[] = $invalidation['report'];
755 }
756
757 $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";
758 $result = Db::fetchOne($sql, $bind);
759
760 return !empty($result);
761 }
762
763 /**
764 * Gets the next invalidated archive that should be archived in a table.
765 *
766 * @param int $idSite
767 * @param string $archivingStartTime
768 * @param int[]|null $idInvalidationsToExclude
769 * @param bool $useLimit Whether to limit the result set to one result or not. Used in tests only.
770 */
771 public function getNextInvalidatedArchive($idSite, $archivingStartTime, $idInvalidationsToExclude = null, $useLimit = true)
772 {
773 $table = Common::prefixTable('archive_invalidations');
774 $sql = "SELECT *
775 FROM `$table`
776 WHERE idsite = ? AND status != ? AND ts_invalidated <= ?";
777 $bind = [
778 $idSite,
779 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
780 $archivingStartTime,
781 ];
782
783 if (!empty($idInvalidationsToExclude)) {
784 $idInvalidationsToExclude = array_map('intval', $idInvalidationsToExclude);
785 $sql .= " AND idinvalidation NOT IN (" . implode(',', $idInvalidationsToExclude) . ')';
786 }
787
788 // NOTE: order here is very important to ensure we process lower period archives first, and general 'all' archives before
789 // segment archives, and so we use the latest idinvalidation
790 $sql .= " ORDER BY date1 DESC, period ASC, CHAR_LENGTH(name) ASC, idinvalidation DESC";
791
792 if ($useLimit) {
793 $sql .= " LIMIT 1";
794 return Db::fetchRow($sql, $bind);
795 } else {
796 return Db::fetchAll($sql, $bind);
797 }
798 }
799
800 public function deleteInvalidations($archiveInvalidations)
801 {
802 $ids = array_column($archiveInvalidations, 'idinvalidation');
803 $ids = array_map('intval', $ids);
804
805 $table = Common::prefixTable('archive_invalidations');
806 $sql = "DELETE FROM `$table` WHERE idinvalidation IN (" . implode(', ', $ids) . ")";
807
808 Db::query($sql);
809 }
810
811 public function removeInvalidationsLike($idSite, $start)
812 {
813 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
814
815 $table = Common::prefixTable('archive_invalidations');
816 $sql = "DELETE FROM `$table` WHERE $idSitesClause `name` LIKE ?";
817
818 Db::query($sql, ['done%.' . str_replace('_', "\\_", $start)]);
819 }
820
821 public function removeInvalidations($idSite, $plugin, $report)
822 {
823 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
824
825 $table = Common::prefixTable('archive_invalidations');
826 $sql = "DELETE FROM `$table` WHERE $idSitesClause `name` LIKE ? AND report = ?";
827
828 Db::query($sql, ['done%.' . str_replace('_', "\\_", $plugin), $report]);
829 }
830
831 public function isArchiveAlreadyInProgress($invalidatedArchive)
832 {
833 $table = Common::prefixTable('archive_invalidations');
834
835 $bind = [
836 $invalidatedArchive['idsite'],
837 $invalidatedArchive['date1'],
838 $invalidatedArchive['date2'],
839 $invalidatedArchive['period'],
840 $invalidatedArchive['name'],
841 ];
842
843 $reportClause = "(report = '' OR report IS NULL)";
844 if (!empty($invalidatedArchive['report'])) {
845 $reportClause = "report = ?";
846 $bind[] = $invalidatedArchive['report'];
847 }
848
849 $sql = "SELECT MAX(idinvalidation) FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND status = 1 AND $reportClause";
850
851 $inProgressInvalidation = Db::fetchOne($sql, $bind);
852 return $inProgressInvalidation;
853 }
854
855 /**
856 * Returns true if there is an archive that exists that can be used when aggregating an archive for $period.
857 *
858 * @param $idSite
859 * @param Period $period
860 * @return bool
861 * @throws Exception
862 */
863 public function hasChildArchivesInPeriod($idSite, Period $period)
864 {
865 $date = $period->getDateStart();
866 while ($date->isEarlier($period->getDateEnd()->addPeriod(1, 'month'))) {
867 $archiveTable = ArchiveTableCreator::getNumericTable($date);
868
869 // we look for any archive that can be used to compute this one. this includes invalidated archives, since it is possible
870 // under certain circumstances for them to exist, when archiving a higher period that includes them. the main example being
871 // the GoogleAnalyticsImporter which disallows the recomputation of invalidated archives for imported data, since that would
872 // essentially get rid of the imported data.
873 $usableDoneFlags = [ArchiveWriter::DONE_OK, ArchiveWriter::DONE_INVALIDATED, ArchiveWriter::DONE_PARTIAL, ArchiveWriter::DONE_OK_TEMPORARY];
874
875 $sql = "SELECT idarchive
876 FROM `$archiveTable`
877 WHERE idsite = ? AND date1 >= ? AND date2 <= ? AND period < ? AND `name` LIKE 'done%' AND `value` IN (" . implode(', ', $usableDoneFlags) . ")
878 LIMIT 1";
879 $bind = [$idSite, $period->getDateStart()->getDatetime(), $period->getDateEnd()->getDatetime(), $period->getId()];
880
881 $result = (bool) Db::fetchOne($sql, $bind);
882 if ($result) {
883 return true;
884 }
885
886 $date = $date->addPeriod(1, 'month'); // move to next archive table
887 }
888 return false;
889 }
890
891 /**
892 * Returns true if any invalidations exists for the given
893 * $idsite and $doneFlag (name column) for the $period.
894 *
895 * @param mixed $idSite
896 * @param Period $period
897 * @param mixed $doneFlag
898 * @param mixed $report
899 * @return bool
900 * @throws Exception
901 */
902 public function hasInvalidationForPeriodAndName($idSite, Period $period, $doneFlag, $report = null)
903 {
904 $table = Common::prefixTable('archive_invalidations');
905
906 if (empty($report)) {
907 $sql = "SELECT idinvalidation FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IS NULL LIMIT 1";
908 } else {
909 $sql = "SELECT idinvalidation FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` = ? LIMIT 1";
910 }
911
912 $bind = [
913 $idSite,
914 $period->getDateStart()->toString(),
915 $period->getDateEnd()->toString(),
916 $period->getId(),
917 $doneFlag
918 ];
919
920 if (!empty($report)) {
921 $bind[] = $report;
922 }
923
924 $idInvalidation = Db::fetchOne($sql, $bind);
925
926 if (empty($idInvalidation)) {
927 return false;
928 }
929
930 return true;
931 }
932
933 public function deleteInvalidationsForSites(array $idSites)
934 {
935 $idSites = array_map('intval', $idSites);
936
937 $table = Common::prefixTable('archive_invalidations');
938 $sql = "DELETE FROM `$table` WHERE idsite IN (" . implode(',', $idSites) . ")";
939
940 Db::query($sql);
941 }
942
943 public function deleteInvalidationsForDeletedSites()
944 {
945 $siteTable = Common::prefixTable('site');
946 $table = Common::prefixTable('archive_invalidations');
947 $sql = "DELETE a FROM `$table` a LEFT JOIN `$siteTable` s ON a.idsite = s.idsite WHERE s.idsite IS NULL";
948 Db::query($sql);
949 }
950
951 private function getRemoveInvalidationsIdSitesClause($idSite)
952 {
953 if ($idSite === 'all') {
954 return '';
955 }
956
957 $idSites = is_array($idSite) ? $idSite : [$idSite];
958 $idSites = array_map('intval', $idSites);
959 $idSitesStr = implode(',', $idSites);
960
961 return "idsite IN ($idSitesStr) AND";
962 }
963
964 public function releaseInProgressInvalidation($idinvalidation)
965 {
966 $table = Common::prefixTable('archive_invalidations');
967 $sql = "UPDATE $table SET status = " . ArchiveInvalidator::INVALIDATION_STATUS_QUEUED . ", ts_started = NULL WHERE idinvalidation = ?";
968 Db::query($sql, [$idinvalidation]);
969 }
970
971 public function resetFailedArchivingJobs()
972 {
973 $table = Common::prefixTable('archive_invalidations');
974 $sql = "UPDATE $table SET status = ? WHERE status = ? AND (ts_started IS NULL OR ts_started < ?)";
975
976 $bind = [
977 ArchiveInvalidator::INVALIDATION_STATUS_QUEUED,
978 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
979 Date::now()->subDay(1)->getDatetime(),
980 ];
981
982 $query = Db::query($sql, $bind);
983 return $query->rowCount();
984 }
985
986 private function isCutOffGroupConcatResult($pair)
987 {
988 $position = strpos($pair, '.');
989 return $position === false || $position === strlen($pair) - 1;
990 }
991
992 private function getHashFromDoneFlag($doneFlag)
993 {
994 preg_match('/^done([a-zA-Z0-9]+)/', $doneFlag, $matches);
995 return $matches[1] ?? '';
996 }
997 }
998