PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 4.4.2
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v4.4.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 5 years ago Actions.php 5 years ago ArchiveSelector.php 5 years ago ArchiveTableCreator.php 5 years ago ArchiveTableDao.php 5 years ago ArchiveWriter.php 5 years ago ArchivingDbAdapter.php 4 years ago LogAggregator.php 4 years ago LogQueryBuilder.php 5 years ago LogTableTemporary.php 5 years ago Model.php 4 years ago RawLogDao.php 5 years ago TableMetadata.php 5 years ago
Model.php
991 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 list($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 list($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 $forceInvalidateNonexistantRanges = 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 list($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 && !$forceInvalidateNonexistantRanges
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 $sequence->create();
516
517 $idarchive = $sequence->getNextId();
518 }
519
520 return $idarchive;
521 }
522
523 public function updateArchiveStatus($numericTable, $archiveId, $doneFlag, $value)
524 {
525 Db::query("UPDATE $numericTable SET `value` = ? WHERE idarchive = ? and `name` = ?",
526 array($value, $archiveId, $doneFlag)
527 );
528 }
529
530 public function insertRecord($tableName, $fields, $record, $name, $value)
531 {
532 // duplicate idarchives are Ignored, see https://github.com/piwik/piwik/issues/987
533 $query = "INSERT IGNORE INTO " . $tableName . " (" . implode(", ", $fields) . ")
534 VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " . end($fields) . " = ?";
535
536 $bindSql = $record;
537 $bindSql[] = $name;
538 $bindSql[] = $value;
539 $bindSql[] = $value;
540
541 Db::query($query, $bindSql);
542
543 return true;
544 }
545
546 /**
547 * Returns the site IDs for invalidated archives in an archive table.
548 *
549 * @param string $numericTable The numeric table to search through.
550 * @return int[]
551 */
552 public function getSitesWithInvalidatedArchive($numericTable)
553 {
554 $rows = Db::fetchAll("SELECT DISTINCT idsite FROM `$numericTable` WHERE `name` LIKE 'done%' AND `value` IN ("
555 . ArchiveWriter::DONE_INVALIDATED . ")");
556
557 $result = array();
558 foreach ($rows as $row) {
559 $result[] = $row['idsite'];
560 }
561 return $result;
562 }
563
564 /**
565 * Get a list of IDs of archives that don't have any matching rows in the site table. Excludes temporary archives
566 * that may still be in use, as specified by the $oldestToKeep passed in.
567 * @param string $archiveTableName
568 * @param string $oldestToKeep Datetime string
569 * @return array of IDs
570 */
571 public function getArchiveIdsForDeletedSites($archiveTableName)
572 {
573 $sql = "SELECT DISTINCT idsite FROM " . $archiveTableName;
574 $rows = Db::getReader()->fetchAll($sql, array());
575
576 if (empty($rows)) {
577 return array(); // nothing to delete
578 }
579
580 $idSitesUsed = array_column($rows, 'idsite');
581
582 $model = new \Piwik\Plugins\SitesManager\Model();
583 $idSitesExisting = $model->getSitesId();
584
585 $deletedSites = array_diff($idSitesUsed, $idSitesExisting);
586
587 if (empty($deletedSites)) {
588 return array();
589 }
590 $deletedSites = array_values($deletedSites);
591 $deletedSites = array_map('intval', $deletedSites);
592
593 $sql = "SELECT DISTINCT idarchive FROM " . $archiveTableName . " WHERE idsite IN (".implode(',',$deletedSites).")";
594
595 $rows = Db::getReader()->fetchAll($sql, array());
596
597 return array_column($rows, 'idarchive');
598 }
599
600 /**
601 * Get a list of IDs of archives with segments that no longer exist in the DB. Excludes temporary archives that
602 * may still be in use, as specified by the $oldestToKeep passed in.
603 * @param string $archiveTableName
604 * @param array $segments List of segments to match against
605 * @param string $oldestToKeep Datetime string
606 * @return array With keys idarchive, name, idsite
607 */
608 public function getArchiveIdsForSegments($archiveTableName, array $segments, $oldestToKeep)
609 {
610 $segmentClauses = [];
611 foreach ($segments as $segment) {
612 if (!empty($segment['definition'])) {
613 $segmentClauses[] = $this->getDeletedSegmentWhereClause($segment);
614 }
615 }
616
617 if (empty($segmentClauses)) {
618 return array();
619 }
620
621 $segmentClauses = implode(' OR ', $segmentClauses);
622
623 $sql = 'SELECT idarchive FROM ' . $archiveTableName
624 . ' WHERE ts_archived < ?'
625 . ' AND (' . $segmentClauses . ')';
626
627 $rows = Db::fetchAll($sql, array($oldestToKeep));
628
629 return array_column($rows, 'idarchive');
630 }
631
632 private function getDeletedSegmentWhereClause(array $segment)
633 {
634 $idSite = (int)$segment['enable_only_idsite'];
635 $segmentHash = $segment['hash'];
636 // Valid segment hashes are md5 strings - just confirm that it is so it's safe for SQL injection
637 if (!ctype_xdigit($segmentHash)) {
638 throw new Exception($segment . ' expected to be an md5 hash');
639 }
640
641 $nameClause = 'name LIKE "done' . $segmentHash . '%"';
642 $idSiteClause = '';
643 if ($idSite > 0) {
644 $idSiteClause = ' AND idsite = ' . $idSite;
645 } elseif (! empty($segment['idsites_to_preserve'])) {
646 // A segment for all sites was deleted, but there are segments for a single site with the same definition
647 $idSitesToPreserve = array_map('intval', $segment['idsites_to_preserve']);
648 $idSiteClause = ' AND idsite NOT IN (' . implode(',', $idSitesToPreserve) . ')';
649 }
650
651 return "($nameClause $idSiteClause)";
652 }
653
654 /**
655 * Returns the SQL condition used to find successfully completed archives that
656 * this instance is querying for.
657 */
658 private static function getNameCondition($doneFlags, $possibleValues)
659 {
660 $allDoneFlags = "'" . implode("','", $doneFlags) . "'";
661
662 // create the SQL to find archives that are DONE
663 $result = "((arc1.name IN ($allDoneFlags))";
664
665 if (!empty($possibleValues)) {
666 $result .= " AND (arc1.value IN (" . implode(',', $possibleValues) . ")))";
667 }
668 $result .= ')';
669
670 return $result;
671 }
672
673 /**
674 * Marks an archive as in progress if it has not been already. This method must be thread
675 * safe.
676 */
677 public function startArchive($invalidation)
678 {
679 $table = Common::prefixTable('archive_invalidations');
680
681 // set archive value to in progress if not set already
682 $statement = Db::query("UPDATE `$table` SET `status` = ?, ts_started = NOW() WHERE idinvalidation = ? AND status = ?", [
683 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
684 $invalidation['idinvalidation'],
685 ArchiveInvalidator::INVALIDATION_STATUS_QUEUED,
686 ]);
687
688 if ($statement->rowCount() > 0) { // if we updated, then we've marked the archive as started
689 return true;
690 }
691
692 // archive was not originally started or was started within 24 hours, we assume it's ongoing and another process
693 // (on this machine or another) is actively archiving it.
694 if (empty($invalidation['ts_started'])
695 || $invalidation['ts_started'] > Date::now()->subDay(1)->getTimestamp()
696 ) {
697 return false;
698 }
699
700 // archive was started over 24 hours ago, we assume it failed and take it over
701 Db::query("UPDATE `$table` SET `status` = ?, ts_started = NOW() WHERE idinvalidation = ?", [
702 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
703 $invalidation['idinvalidation'],
704 ]);
705
706 // remove similar invalidations w/ lesser idinvalidation values
707 $bind = [
708 $invalidation['idsite'],
709 $invalidation['period'],
710 $invalidation['date1'],
711 $invalidation['date2'],
712 $invalidation['name'],
713 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
714 ];
715
716 if (empty($invalidation['report'])) {
717 $reportClause = "(report IS NULL OR report = '')";
718 } else {
719 $reportClause = "report = ?";
720 $bind[] = $invalidation['report'];
721 }
722
723 $sql = "DELETE FROM " . Common::prefixTable('archive_invalidations') . " WHERE idinvalidation < ? AND idsite = ? AND "
724 . "date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND $reportClause";
725 Db::query($sql, $bind);
726
727 return true;
728 }
729
730 public function isSimilarArchiveInProgress($invalidation)
731 {
732 $table = Common::prefixTable('archive_invalidations');
733
734 $bind = [
735 $invalidation['idsite'],
736 $invalidation['period'],
737 $invalidation['date1'],
738 $invalidation['date2'],
739 $invalidation['name'],
740 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
741 ];
742
743 if (empty($invalidation['report'])) {
744 $reportClause = "(report IS NULL OR report = '')";
745 } else {
746 $reportClause = "report = ?";
747 $bind[] = $invalidation['report'];
748 }
749
750 $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";
751 $result = Db::fetchOne($sql, $bind);
752
753 return !empty($result);
754 }
755
756 /**
757 * Gets the next invalidated archive that should be archived in a table.
758 *
759 * @param int $idSite
760 * @param string $archivingStartTime
761 * @param int[]|null $idInvalidationsToExclude
762 * @param bool $useLimit Whether to limit the result set to one result or not. Used in tests only.
763 */
764 public function getNextInvalidatedArchive($idSite, $archivingStartTime, $idInvalidationsToExclude = null, $useLimit = true)
765 {
766 $table = Common::prefixTable('archive_invalidations');
767 $sql = "SELECT *
768 FROM `$table`
769 WHERE idsite = ? AND status != ? AND ts_invalidated <= ?";
770 $bind = [
771 $idSite,
772 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
773 $archivingStartTime,
774 ];
775
776 if (!empty($idInvalidationsToExclude)) {
777 $idInvalidationsToExclude = array_map('intval', $idInvalidationsToExclude);
778 $sql .= " AND idinvalidation NOT IN (" . implode(',', $idInvalidationsToExclude) . ')';
779 }
780
781 // NOTE: order here is very important to ensure we process lower period archives first, and general 'all' archives before
782 // segment archives, and so we use the latest idinvalidation
783 $sql .= " ORDER BY date1 DESC, period ASC, CHAR_LENGTH(name) ASC, idinvalidation DESC";
784
785 if ($useLimit) {
786 $sql .= " LIMIT 1";
787 return Db::fetchRow($sql, $bind);
788 } else {
789 return Db::fetchAll($sql, $bind);
790 }
791 }
792
793 public function deleteInvalidations($archiveInvalidations)
794 {
795 $ids = array_column($archiveInvalidations, 'idinvalidation');
796 $ids = array_map('intval', $ids);
797
798 $table = Common::prefixTable('archive_invalidations');
799 $sql = "DELETE FROM `$table` WHERE idinvalidation IN (" . implode(', ', $ids) . ")";
800
801 Db::query($sql);
802 }
803
804 public function removeInvalidationsLike($idSite, $start)
805 {
806 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
807
808 $table = Common::prefixTable('archive_invalidations');
809 $sql = "DELETE FROM `$table` WHERE $idSitesClause `name` LIKE ?";
810
811 Db::query($sql, ['done%.' . str_replace('_', "\\_", $start)]);
812 }
813
814 public function removeInvalidations($idSite, $plugin, $report)
815 {
816 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
817
818 $table = Common::prefixTable('archive_invalidations');
819 $sql = "DELETE FROM `$table` WHERE $idSitesClause `name` LIKE ? AND report = ?";
820
821 Db::query($sql, ['done%.' . str_replace('_', "\\_", $plugin), $report]);
822 }
823
824 public function isArchiveAlreadyInProgress($invalidatedArchive)
825 {
826 $table = Common::prefixTable('archive_invalidations');
827
828 $bind = [
829 $invalidatedArchive['idsite'],
830 $invalidatedArchive['date1'],
831 $invalidatedArchive['date2'],
832 $invalidatedArchive['period'],
833 $invalidatedArchive['name'],
834 ];
835
836 $reportClause = "(report = '' OR report IS NULL)";
837 if (!empty($invalidatedArchive['report'])) {
838 $reportClause = "report = ?";
839 $bind[] = $invalidatedArchive['report'];
840 }
841
842 $sql = "SELECT MAX(idinvalidation) FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND status = 1 AND $reportClause";
843
844 $inProgressInvalidation = Db::fetchOne($sql, $bind);
845 return $inProgressInvalidation;
846 }
847
848 /**
849 * Returns true if there is an archive that exists that can be used when aggregating an archive for $period.
850 *
851 * @param $idSite
852 * @param Period $period
853 * @return bool
854 * @throws Exception
855 */
856 public function hasChildArchivesInPeriod($idSite, Period $period)
857 {
858 $date = $period->getDateStart();
859 while ($date->isEarlier($period->getDateEnd()->addPeriod(1, 'month'))) {
860 $archiveTable = ArchiveTableCreator::getNumericTable($date);
861
862 // we look for any archive that can be used to compute this one. this includes invalidated archives, since it is possible
863 // under certain circumstances for them to exist, when archiving a higher period that includes them. the main example being
864 // the GoogleAnalyticsImporter which disallows the recomputation of invalidated archives for imported data, since that would
865 // essentially get rid of the imported data.
866 $usableDoneFlags = [ArchiveWriter::DONE_OK, ArchiveWriter::DONE_INVALIDATED, ArchiveWriter::DONE_PARTIAL, ArchiveWriter::DONE_OK_TEMPORARY];
867
868 $sql = "SELECT idarchive
869 FROM `$archiveTable`
870 WHERE idsite = ? AND date1 >= ? AND date2 <= ? AND period < ? AND `name` LIKE 'done%' AND `value` IN (" . implode(', ', $usableDoneFlags) . ")
871 LIMIT 1";
872 $bind = [$idSite, $period->getDateStart()->getDatetime(), $period->getDateEnd()->getDatetime(), $period->getId()];
873
874 $result = (bool) Db::fetchOne($sql, $bind);
875 if ($result) {
876 return true;
877 }
878
879 $date = $date->addPeriod(1, 'month'); // move to next archive table
880 }
881 return false;
882 }
883
884 /**
885 * Returns true if any invalidations exists for the given
886 * $idsite and $doneFlag (name column) for the $period.
887 *
888 * @param mixed $idSite
889 * @param Period $period
890 * @param mixed $doneFlag
891 * @param mixed $report
892 * @return bool
893 * @throws Exception
894 */
895 public function hasInvalidationForPeriodAndName($idSite, Period $period, $doneFlag, $report = null)
896 {
897 $table = Common::prefixTable('archive_invalidations');
898
899 if (empty($report)) {
900 $sql = "SELECT idinvalidation FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IS NULL LIMIT 1";
901 } else {
902 $sql = "SELECT idinvalidation FROM `$table` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` = ? LIMIT 1";
903 }
904
905 $bind = [
906 $idSite,
907 $period->getDateStart()->toString(),
908 $period->getDateEnd()->toString(),
909 $period->getId(),
910 $doneFlag
911 ];
912
913 if (!empty($report)) {
914 $bind[] = $report;
915 }
916
917 $idInvalidation = Db::fetchOne($sql, $bind);
918
919 if (empty($idInvalidation)) {
920 return false;
921 }
922
923 return true;
924 }
925
926 public function deleteInvalidationsForSites(array $idSites)
927 {
928 $idSites = array_map('intval', $idSites);
929
930 $table = Common::prefixTable('archive_invalidations');
931 $sql = "DELETE FROM `$table` WHERE idsite IN (" . implode(',', $idSites) . ")";
932
933 Db::query($sql);
934 }
935
936 public function deleteInvalidationsForDeletedSites()
937 {
938 $siteTable = Common::prefixTable('site');
939 $table = Common::prefixTable('archive_invalidations');
940 $sql = "DELETE a FROM `$table` a LEFT JOIN `$siteTable` s ON a.idsite = s.idsite WHERE s.idsite IS NULL";
941 Db::query($sql);
942 }
943
944 private function getRemoveInvalidationsIdSitesClause($idSite)
945 {
946 if ($idSite === 'all') {
947 return '';
948 }
949
950 $idSites = is_array($idSite) ? $idSite : [$idSite];
951 $idSites = array_map('intval', $idSites);
952 $idSitesStr = implode(',', $idSites);
953
954 return "idsite IN ($idSitesStr) AND";
955 }
956
957 public function releaseInProgressInvalidation($idinvalidation)
958 {
959 $table = Common::prefixTable('archive_invalidations');
960 $sql = "UPDATE $table SET status = " . ArchiveInvalidator::INVALIDATION_STATUS_QUEUED . ", ts_started = NULL WHERE idinvalidation = ?";
961 Db::query($sql, [$idinvalidation]);
962 }
963
964 public function resetFailedArchivingJobs()
965 {
966 $table = Common::prefixTable('archive_invalidations');
967 $sql = "UPDATE $table SET status = ? WHERE status = ? AND (ts_started IS NULL OR ts_started < ?)";
968
969 $bind = [
970 ArchiveInvalidator::INVALIDATION_STATUS_QUEUED,
971 ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS,
972 Date::now()->subDay(1)->getDatetime(),
973 ];
974
975 $query = Db::query($sql, $bind);
976 return $query->rowCount();
977 }
978
979 private function isCutOffGroupConcatResult($pair)
980 {
981 $position = strpos($pair, '.');
982 return $position === false || $position === strlen($pair) - 1;
983 }
984
985 private function getHashFromDoneFlag($doneFlag)
986 {
987 preg_match('/^done([a-zA-Z0-9]+)/', $doneFlag, $matches);
988 return $matches[1] ?? '';
989 }
990 }
991