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