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