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