PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.6.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.6.0
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 1 year ago Actions.php 8 months ago ArchiveSelector.php 8 months ago ArchiveTableCreator.php 1 year ago ArchiveTableDao.php 1 year ago ArchiveWriter.php 8 months ago ArchivingDbAdapter.php 1 year ago LogAggregator.php 8 months ago LogQueryBuilder.php 8 months ago LogTableTemporary.php 2 years ago Model.php 8 months ago RawLogDao.php 8 months ago TableMetadata.php 1 year ago
Model.php
841 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\Config\GeneralConfig;
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, idarchive DESC) as archives\n FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n AND `value` NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ")\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, bool $forceInvalidateNonexistentRanges = \false, ?string $name = null, bool $doNotCreateInvalidations = \false)
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 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment ?: new Segment('', []));
131 if (empty($plugin)) {
132 if (null === $segment) {
133 $nameCondition = "name LIKE '{$doneFlag}%'";
134 // invalidate all segments
135 } else {
136 $nameCondition = "(name = '{$doneFlag}' OR name LIKE '{$doneFlag}.%')";
137 // invalidate specific segment only
138 }
139 } else {
140 if (null === $segment) {
141 $nameCondition = "name LIKE '{$doneFlag}%.{$plugin}'";
142 // invalidate all segments for specific plugin
143 } else {
144 $nameCondition = "name = '{$doneFlag}.{$plugin}'";
145 // invalidate specific segment for specific plugin only
146 }
147 }
148 $sql .= " AND {$nameCondition}";
149 $idArchives = [];
150 $archivesToInvalidate = [];
151 // update each archive as invalidated (but only for full archives or plugin archives, not for partial archives.
152 // DONE_INVALIDATED also implies that an archive is whole and not partial, and we want to avoid that.)
153 if (empty($name)) {
154 $archivesToInvalidate = Db::fetchAll($sql);
155 $idArchives = array_column($archivesToInvalidate, 'idarchive');
156 if (!empty($idArchives)) {
157 $idArchives = array_map('intval', $idArchives);
158 // set status to DONE_INVALIDATED for finished archives
159 $sql = "UPDATE `{$archiveTable}` SET `value` = " . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . " WHERE idarchive IN (" . implode(',', $idArchives) . ") AND value NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ") AND {$nameCondition}";
160 Db::query($sql);
161 // set status to DONE_ERROR_INVALIDATED for currently processed archives
162 $sql = "UPDATE `{$archiveTable}` SET `value` = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . " WHERE idarchive IN (" . implode(',', $idArchives) . ") AND value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . " AND {$nameCondition}";
163 Db::query($sql);
164 }
165 }
166 if ($doNotCreateInvalidations) {
167 return count($idArchives);
168 }
169 // we add every archive we need to invalidate + the archives that do not already exist to archive_invalidations.
170 // except for archives that are DONE_IN_PROGRESS.
171 $archivesToCreateInvalidationRowsFor = [];
172 foreach ($archivesToInvalidate as $row) {
173 $archivesToCreateInvalidationRowsFor[$row['idsite']][$row['period']][$row['date1']][$row['date2']][$row['name']] = $row['idarchive'];
174 }
175 $now = Date::now()->getDatetime();
176 $existingInvalidations = $this->getExistingInvalidations($idSites, $periodCondition, $nameCondition);
177 $hashesOfAllSegmentsToArchiveInCoreArchive = Rules::getSegmentsToProcess($idSites);
178 $hashesOfAllSegmentsToArchiveInCoreArchive = array_map(function ($definition) {
179 return Segment::getSegmentHash($definition);
180 }, $hashesOfAllSegmentsToArchiveInCoreArchive);
181 if (empty($plugin)) {
182 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment ?: new Segment('', []));
183 } else {
184 $doneFlag = Rules::getDoneFlagArchiveContainsOnePlugin($segment ?: new Segment('', []), $plugin);
185 }
186 $dummyArchives = [];
187 foreach ($idSites as $idSite) {
188 try {
189 $siteCreationTime = Site::getCreationDateFor($idSite);
190 } catch (\Exception $ex) {
191 continue;
192 }
193 $siteCreationTime = Date::factory($siteCreationTime);
194 foreach ($allPeriodsToInvalidate as $period) {
195 if ($period->getLabel() == 'range' && !$forceInvalidateNonexistentRanges) {
196 continue;
197 // range
198 }
199 if ($period->getDateEnd()->isEarlier($siteCreationTime)) {
200 continue;
201 // don't add entries if it is before the time the site was created
202 }
203 $date1 = $period->getDateStart()->toString();
204 $date2 = $period->getDateEnd()->toString();
205 // we insert rows for the doneFlag we want to invalidate + any others we invalidated when doing the LIKE above.
206 // if we invalidated something in the archive tables, we want to make sure it appears in the invalidation queue,
207 // so we'll eventually reprocess it.
208 $doneFlagsFound = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2] ?? [];
209 $doneFlagsFound = array_keys($doneFlagsFound);
210 $doneFlagsToCheck = array_merge([$doneFlag], $doneFlagsFound);
211 $doneFlagsToCheck = array_unique($doneFlagsToCheck);
212 foreach ($doneFlagsToCheck as $doneFlagToCheck) {
213 $key = $this->makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period->getId(), $doneFlagToCheck, $name);
214 if (!empty($existingInvalidations[$key])) {
215 continue;
216 // avoid adding duplicates where possible
217 }
218 $hash = $this->getHashFromDoneFlag($doneFlagToCheck);
219 if ($doneFlagToCheck != $doneFlag && (empty($hash) || !in_array($hash, $hashesOfAllSegmentsToArchiveInCoreArchive) || strpos($doneFlagToCheck, '.') !== \false)) {
220 continue;
221 // 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.
222 }
223 $idArchive = $archivesToCreateInvalidationRowsFor[$idSite][$period->getId()][$date1][$date2][$doneFlagToCheck] ?? null;
224 $dummyArchives[] = ['idarchive' => $idArchive, 'name' => $doneFlagToCheck, 'report' => $name, 'idsite' => $idSite, 'date1' => $period->getDateStart()->getDatetime(), 'date2' => $period->getDateEnd()->getDatetime(), 'period' => $period->getId(), 'ts_invalidated' => $now];
225 }
226 }
227 }
228 if (!empty($dummyArchives)) {
229 $fields = ['idarchive', 'name', 'report', 'idsite', 'date1', 'date2', 'period', 'ts_invalidated'];
230 Db\BatchInsert::tableInsertBatch(Common::prefixTable('archive_invalidations'), $fields, $dummyArchives);
231 }
232 return count($idArchives);
233 }
234 private function getExistingInvalidations($idSites, $periodCondition, $nameCondition)
235 {
236 $table = Common::prefixTable('archive_invalidations');
237 $idSites = array_map('intval', $idSites);
238 $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";
239 $rows = Db::fetchAll($sql);
240 $invalidations = [];
241 foreach ($rows as $row) {
242 $key = $this->makeExistingInvalidationArrayKey($row['idsite'], $row['date1'], $row['date2'], $row['period'], $row['name'], $row['report']);
243 $invalidations[$key] = $row['count'];
244 }
245 return $invalidations;
246 }
247 private function makeExistingInvalidationArrayKey($idSite, $date1, $date2, $period, $name, $report)
248 {
249 return implode('.', [$idSite, $date1, $date2, $period, $name, $report]);
250 }
251 /**
252 * @param string $archiveTable Prefixed table name
253 * @param int[] $idSites
254 * @param string[][] $datesByPeriodType
255 * @param Segment $segment
256 * @throws Exception
257 */
258 public function updateRangeArchiveAsInvalidated($archiveTable, $idSites, $allPeriodsToInvalidate, ?Segment $segment = null) : void
259 {
260 if (empty($idSites)) {
261 return;
262 }
263 $bind = array();
264 $periodConditions = array();
265 if (!empty($allPeriodsToInvalidate)) {
266 foreach ($allPeriodsToInvalidate as $period) {
267 $dateConditions = array();
268 /** @var Period $period */
269 $dateConditions[] = "(date1 <= ? AND ? <= date2)";
270 $bind[] = $period->getDateStart()->getDatetime();
271 $bind[] = $period->getDateEnd()->getDatetime();
272 $dateConditionsSql = implode(" OR ", $dateConditions);
273 $periodConditions[] = "(period = 5 AND ({$dateConditionsSql}))";
274 }
275 }
276 if (null === $segment) {
277 $nameCondition = "name LIKE 'done%'";
278 } else {
279 $doneFlag = Rules::getDoneFlagArchiveContainsAllPlugins($segment);
280 $nameCondition = "(name = '{$doneFlag}' OR name LIKE '{$doneFlag}.%')";
281 }
282 $sql = "SELECT idarchive FROM `{$archiveTable}` " . " WHERE {$nameCondition}\n AND idsite IN (" . implode(", ", $idSites) . ")\n AND (" . implode(" OR ", $periodConditions) . ")";
283 $recordsToUpdate = Db::fetchAll($sql, $bind);
284 if (empty($recordsToUpdate)) {
285 return;
286 }
287 $idArchives = array_map('intval', array_column($recordsToUpdate, 'idarchive'));
288 $updateSql = "UPDATE `{$archiveTable}` SET value = " . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . " WHERE idarchive IN (" . implode(', ', $idArchives) . ") AND {$nameCondition}" . " AND value NOT IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR . ", " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . ")";
289 Db::query($updateSql);
290 $updateSql = "UPDATE `{$archiveTable}` SET value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED . " WHERE idarchive IN (" . implode(', ', $idArchives) . ") AND {$nameCondition} AND value = " . \Piwik\DataAccess\ArchiveWriter::DONE_ERROR;
291 Db::query($updateSql);
292 }
293 public function getTemporaryArchivesOlderThan($archiveTable, $purgeArchivesOlderThan)
294 {
295 $temporaryArchiveValues = [\Piwik\DataAccess\ArchiveWriter::DONE_OK_TEMPORARY, \Piwik\DataAccess\ArchiveWriter::DONE_ERROR, \Piwik\DataAccess\ArchiveWriter::DONE_ERROR_INVALIDATED];
296 $query = "SELECT idarchive FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n AND ts_archived < ?\n AND value IN (" . implode(', ', $temporaryArchiveValues) . ")";
297 return Db::fetchAll($query, array($purgeArchivesOlderThan));
298 }
299 public function getArchivesMissingDoneFlag(string $archiveTable) : array
300 {
301 $query = "SELECT DISTINCT idarchive\n FROM `{$archiveTable}`\n WHERE idarchive NOT IN (\n SELECT DISTINCT idarchive\n FROM `{$archiveTable}`\n WHERE name LIKE 'done%'\n )";
302 return Db::fetchAll($query);
303 }
304 public function deleteArchivesWithPeriod($numericTable, $blobTable, $period, $date)
305 {
306 if (SettingsServer::isArchivePhpTriggered()) {
307 StaticContainer::get(LoggerInterface::class)->info('deleteArchivesWithPeriod: ' . $numericTable . ' with period = ' . $period . ' and date = ' . $date);
308 }
309 $query = "DELETE FROM `%s` WHERE period = ? AND ts_archived < ?";
310 $bind = array($period, $date);
311 $queryObj = Db::query(sprintf($query, $numericTable), $bind);
312 $deletedRows = $queryObj->rowCount();
313 try {
314 $queryObj = Db::query(sprintf($query, $blobTable), $bind);
315 $deletedRows += $queryObj->rowCount();
316 } catch (Exception $e) {
317 // Individual blob tables could be missing
318 $this->logger->debug("Unable to delete archives by period from {blobTable}.", array('blobTable' => $blobTable, 'exception' => $e));
319 }
320 return $deletedRows;
321 }
322 public function deleteArchiveIds($numericTable, $blobTable, $idsToDelete)
323 {
324 $idsToDelete = array_values($idsToDelete);
325 $idsToDelete = array_map('intval', $idsToDelete);
326 $query = "DELETE FROM `%s` WHERE idarchive IN (" . implode(',', $idsToDelete) . ")";
327 $queryObj = Db::query(sprintf($query, $numericTable), array());
328 $deletedRows = $queryObj->rowCount();
329 try {
330 $queryObj = Db::query(sprintf($query, $blobTable), array());
331 $deletedRows += $queryObj->rowCount();
332 } catch (Exception $e) {
333 // Individual blob tables could be missing
334 $this->logger->debug("Unable to delete archive IDs from {blobTable}.", array('blobTable' => $blobTable, 'exception' => $e));
335 }
336 return $deletedRows;
337 }
338 public function deleteOlderArchives(Parameters $params, $name, $tsArchived, $idArchive)
339 {
340 $dateStart = $params->getPeriod()->getDateStart();
341 $dateEnd = $params->getPeriod()->getDateEnd();
342 $numericTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($dateStart);
343 $blobTable = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($dateStart);
344 $sql = "SELECT idarchive FROM `{$numericTable}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND period = ? AND name = ? AND ts_archived <= ? AND idarchive < ?";
345 $idArchives = Db::fetchAll($sql, [$params->getSite()->getId(), $dateStart->getDatetime(), $dateEnd->getDatetime(), $params->getPeriod()->getId(), $name, $tsArchived, $idArchive]);
346 $idArchives = array_column($idArchives, 'idarchive');
347 if (empty($idArchives)) {
348 return;
349 }
350 if (SettingsServer::isArchivePhpTriggered()) {
351 StaticContainer::get(LoggerInterface::class)->info('deleteOlderArchives with ' . $params . ', name = ' . $name . ', ts_archived < ' . $tsArchived . ', idarchive < ' . $idArchive);
352 }
353 $this->deleteArchiveIds($numericTable, $blobTable, $idArchives);
354 }
355 public function getArchiveIdAndVisits($numericTable, $idSite, $period, $dateStartIso, $dateEndIso, $minDatetimeIsoArchiveProcessedUTC, $doneFlags, $doneFlagValues = null)
356 {
357 $bindSQL = array($idSite, $dateStartIso, $dateEndIso, $period);
358 $sqlWhereArchiveName = self::getNameCondition($doneFlags, $doneFlagValues);
359 $timeStampWhere = '';
360 if ($minDatetimeIsoArchiveProcessedUTC) {
361 $timeStampWhere = " AND arc1.ts_archived >= ? ";
362 $bindSQL[] = $minDatetimeIsoArchiveProcessedUTC;
363 }
364 // 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.
365 $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";
366 $results = Db::fetchAll($sqlQuery, $bindSQL);
367 return $results;
368 }
369 public function createArchiveTable($tableName, $tableNamePrefix)
370 {
371 $db = Db::get();
372 $sql = DbHelper::getTableCreateSql($tableNamePrefix);
373 // replace table name template by real name
374 $tableNamePrefix = Common::prefixTable($tableNamePrefix);
375 $sql = str_replace($tableNamePrefix, $tableName, $sql);
376 try {
377 $db->query($sql);
378 } catch (Exception $e) {
379 // accept mysql error 1050: table already exists, throw otherwise
380 if (!$db->isErrNo($e, '1050')) {
381 throw $e;
382 }
383 }
384 try {
385 if (\Piwik\DataAccess\ArchiveTableCreator::NUMERIC_TABLE === \Piwik\DataAccess\ArchiveTableCreator::getTypeFromTableName($tableName)) {
386 $sequence = new Sequence($tableName);
387 $sequence->create();
388 }
389 } catch (Exception $e) {
390 }
391 }
392 public function getInstalledArchiveTables()
393 {
394 $allArchiveNumeric = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_numeric%') . "'");
395 $allArchiveBlob = Db::get()->fetchCol("SHOW TABLES LIKE '" . Common::prefixTable('archive_blob%') . "'");
396 return array_merge($allArchiveBlob, $allArchiveNumeric);
397 }
398 public function allocateNewArchiveId($numericTable)
399 {
400 $sequence = new Sequence($numericTable);
401 try {
402 $idarchive = $sequence->getNextId();
403 } catch (Exception $e) {
404 // edge case: sequence was not found, create it now
405 try {
406 $sequence->create();
407 } catch (Exception $ex) {
408 // Ignore duplicate entry error, as that means another request might have already created the sequence
409 if (!Db::get()->isErrNo($ex, \Piwik\Updater\Migration\Db::ERROR_CODE_DUPLICATE_ENTRY)) {
410 throw $ex;
411 }
412 }
413 $idarchive = $sequence->getNextId();
414 }
415 return $idarchive;
416 }
417 public function updateArchiveStatus($numericTable, $archiveId, $doneFlag, $value)
418 {
419 Db::query("UPDATE {$numericTable} SET `value` = ? WHERE idarchive = ? and `name` = ?", array($value, $archiveId, $doneFlag));
420 }
421 public function getArchiveStatus($numericTable, $archiveId, $doneFlag) : int
422 {
423 return (int) Db::fetchOne("SELECT value FROM `{$numericTable}` WHERE idarchive = ? AND `name` = ?", [$archiveId, $doneFlag]);
424 }
425 public function insertRecord($tableName, $fields, $record, $name, $value)
426 {
427 // duplicate idarchives are Ignored, see https://github.com/piwik/piwik/issues/987
428 $query = "INSERT IGNORE INTO `{$tableName}` (" . implode(", ", $fields) . ")\n VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE " . end($fields) . " = ?";
429 $bindSql = $record;
430 $bindSql[] = $name;
431 $bindSql[] = $value;
432 $bindSql[] = $value;
433 Db::query($query, $bindSql);
434 return \true;
435 }
436 /**
437 * Returns the site IDs for invalidated archives in an archive table.
438 *
439 * @param string $numericTable The numeric table to search through.
440 * @return int[]
441 */
442 public function getSitesWithInvalidatedArchive($numericTable)
443 {
444 $rows = Db::fetchAll("SELECT DISTINCT idsite FROM `{$numericTable}` WHERE `name` LIKE 'done%' AND `value` IN (" . \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED . ")");
445 $result = array();
446 foreach ($rows as $row) {
447 $result[] = $row['idsite'];
448 }
449 return $result;
450 }
451 /**
452 * Get a list of IDs of archives that don't have any matching rows in the site table. Excludes temporary archives
453 * that may still be in use, as specified by the $oldestToKeep passed in.
454 * @param string $archiveTableName
455 * @param string $oldestToKeep Datetime string
456 * @return array of IDs
457 */
458 public function getArchiveIdsForDeletedSites($archiveTableName)
459 {
460 $sql = "SELECT DISTINCT idsite FROM `{$archiveTableName}`";
461 $rows = Db::getReader()->fetchAll($sql, array());
462 if (empty($rows)) {
463 return array();
464 // nothing to delete
465 }
466 $idSitesUsed = array_column($rows, 'idsite');
467 $model = new \Piwik\Plugins\SitesManager\Model();
468 $idSitesExisting = $model->getSitesId();
469 $deletedSites = array_diff($idSitesUsed, $idSitesExisting);
470 if (empty($deletedSites)) {
471 return array();
472 }
473 $deletedSites = array_values($deletedSites);
474 $deletedSites = array_map('intval', $deletedSites);
475 $sql = "SELECT DISTINCT idarchive FROM `{$archiveTableName}` WHERE idsite IN (" . implode(',', $deletedSites) . ")";
476 $rows = Db::getReader()->fetchAll($sql, array());
477 return array_column($rows, 'idarchive');
478 }
479 /**
480 * Get a list of IDs of archives with segments that no longer exist in the DB. Excludes temporary archives that
481 * may still be in use, as specified by the $oldestToKeep passed in.
482 * @param string $archiveTableName
483 * @param array $segments List of segments to match against
484 * @param string $oldestToKeep Datetime string
485 * @return array With keys idarchive, name, idsite
486 */
487 public function getArchiveIdsForSegments($archiveTableName, array $segments, $oldestToKeep)
488 {
489 $segmentClauses = [];
490 foreach ($segments as $segment) {
491 if (!empty($segment['definition'])) {
492 $segmentClauses[] = $this->getDeletedSegmentWhereClause($segment);
493 }
494 }
495 if (empty($segmentClauses)) {
496 return array();
497 }
498 $segmentClauses = implode(' OR ', $segmentClauses);
499 $sql = 'SELECT idarchive FROM `' . $archiveTableName . '`' . ' WHERE ts_archived < ?' . ' AND (' . $segmentClauses . ')';
500 $rows = Db::fetchAll($sql, array($oldestToKeep));
501 return array_column($rows, 'idarchive');
502 }
503 private function getDeletedSegmentWhereClause(array $segment)
504 {
505 $idSite = (int) $segment['enable_only_idsite'];
506 $segmentHash = $segment['hash'] ?? '';
507 // Valid segment hashes are md5 strings - just confirm that it is so it's safe for SQL injection
508 if (!ctype_xdigit($segmentHash)) {
509 throw new Exception($segmentHash . ' expected to be an md5 hash');
510 }
511 $nameClause = 'name LIKE "done' . $segmentHash . '%"';
512 $idSiteClause = '';
513 if ($idSite > 0) {
514 $idSiteClause = ' AND idsite = ' . $idSite;
515 } elseif (!empty($segment['idsites_to_preserve'])) {
516 // A segment for all sites was deleted, but there are segments for a single site with the same definition
517 $idSitesToPreserve = array_map('intval', $segment['idsites_to_preserve']);
518 $idSiteClause = ' AND idsite NOT IN (' . implode(',', $idSitesToPreserve) . ')';
519 }
520 return "({$nameClause} {$idSiteClause})";
521 }
522 /**
523 * Returns the SQL condition used to find successfully completed archives that
524 * this instance is querying for.
525 */
526 private static function getNameCondition($doneFlags, $possibleValues)
527 {
528 $allDoneFlags = "'" . implode("','", $doneFlags) . "'";
529 // create the SQL to find archives that are DONE
530 $result = "((arc1.name IN ({$allDoneFlags}))";
531 if (!empty($possibleValues)) {
532 $result .= " AND (arc1.value IN (" . implode(',', $possibleValues) . ")))";
533 }
534 $result .= ')';
535 return $result;
536 }
537 /**
538 * Marks an archive as in progress if it has not been already. This method must be thread
539 * safe.
540 */
541 public function startArchive($invalidation)
542 {
543 $table = Common::prefixTable('archive_invalidations');
544 // set archive value to in progress if not set already
545 $statement = Db::query("UPDATE `{$table}` SET `status` = ?, `processing_host` = ?, `process_id` = ?, `ts_started` = NOW() WHERE `idinvalidation` = ? AND `status` = ?", [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, gethostname() ?: null, Common::getProcessId(), $invalidation['idinvalidation'], ArchiveInvalidator::INVALIDATION_STATUS_QUEUED]);
546 // if we updated, then we've marked the archive as started
547 return $statement->rowCount() > 0;
548 }
549 public function isSimilarArchiveInProgress($invalidation)
550 {
551 $table = Common::prefixTable('archive_invalidations');
552 $bind = [$invalidation['idsite'], $invalidation['period'], $invalidation['date1'], $invalidation['date2'], $invalidation['name'], ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS];
553 if (empty($invalidation['report'])) {
554 $reportClause = "(report IS NULL OR report = '')";
555 } else {
556 $reportClause = "report = ?";
557 $bind[] = $invalidation['report'];
558 }
559 $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";
560 $result = Db::fetchOne($sql, $bind);
561 return !empty($result);
562 }
563 public function getInvalidationsInProgress(array $idSites = [], array $processingHosts = [], ?Date $startTime = null, ?Date $endTime = null) : array
564 {
565 $table = Common::prefixTable('archive_invalidations');
566 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS];
567 $whereConditions = '';
568 if (!empty($processingHosts)) {
569 $whereConditions .= sprintf(' AND `processing_host` IN (%1$s)', Common::getSqlStringFieldsArray($processingHosts));
570 $bind = array_merge($bind, $processingHosts);
571 }
572 if (!empty($idSites)) {
573 $whereConditions .= sprintf(' AND `idsite` IN (' . implode(', ', $idSites) . ')');
574 }
575 if (!empty($startTime)) {
576 $whereConditions .= ' AND `ts_started` > ?';
577 $bind[] = $startTime->toString('Y-m-d H:i:s');
578 }
579 if (!empty($endTime)) {
580 $whereConditions .= ' AND `ts_started` < ?';
581 $bind[] = $endTime->toString('Y-m-d H:i:s');
582 }
583 $sql = "SELECT idinvalidation, idsite, period, date1, date2, name, report, ts_invalidated, ts_started, processing_host, process_id FROM `{$table}` WHERE `status` = ? {$whereConditions} AND ts_started IS NOT NULL ORDER BY ts_started ASC";
584 return Db::fetchAll($sql, $bind);
585 }
586 /**
587 * Gets the next invalidated archive that should be archived in a table.
588 *
589 * @param int $idSite
590 * @param string $archivingStartTime
591 * @param int[]|null $idInvalidationsToExclude
592 * @param bool $useLimit Whether to limit the result set to one result or not. Used in tests only.
593 */
594 public function getNextInvalidatedArchive($idSite, $archivingStartTime, $idInvalidationsToExclude = null, $useLimit = \true)
595 {
596 $table = Common::prefixTable('archive_invalidations');
597 $sql = "SELECT *\n FROM `{$table}`\n WHERE idsite = ? AND status != ? AND ts_invalidated <= ?";
598 $bind = [$idSite, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $archivingStartTime];
599 if (!empty($idInvalidationsToExclude)) {
600 $idInvalidationsToExclude = array_map('intval', $idInvalidationsToExclude);
601 $sql .= " AND idinvalidation NOT IN (" . implode(',', $idInvalidationsToExclude) . ')';
602 }
603 // NOTE: order here is very important to ensure we process lower period archives first, and general 'all' archives before
604 // segment archives, and so we use the latest idinvalidation
605 $sql .= " ORDER BY date1 DESC, period ASC, CHAR_LENGTH(name) ASC, idinvalidation DESC";
606 if ($useLimit) {
607 $sql .= " LIMIT 1";
608 return Db::fetchRow($sql, $bind);
609 } else {
610 return Db::fetchAll($sql, $bind);
611 }
612 }
613 public function deleteInvalidations($archiveInvalidations)
614 {
615 $ids = array_column($archiveInvalidations, 'idinvalidation');
616 $ids = array_map('intval', $ids);
617 $table = Common::prefixTable('archive_invalidations');
618 $sql = "DELETE FROM `{$table}` WHERE idinvalidation IN (" . implode(', ', $ids) . ")";
619 Db::query($sql);
620 }
621 public function removeInvalidationsLike($idSite, $start)
622 {
623 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
624 $table = Common::prefixTable('archive_invalidations');
625 $sql = "DELETE FROM `{$table}` WHERE {$idSitesClause} `name` LIKE ?";
626 Db::query($sql, ['done%.' . str_replace('_', "\\_", $start)]);
627 }
628 public function removeInvalidations($idSite, $plugin, $report)
629 {
630 $idSitesClause = $this->getRemoveInvalidationsIdSitesClause($idSite);
631 $table = Common::prefixTable('archive_invalidations');
632 $sql = "DELETE FROM `{$table}` WHERE {$idSitesClause} `name` LIKE ? AND report = ?";
633 Db::query($sql, ['done%.' . str_replace('_', "\\_", $plugin), $report]);
634 }
635 public function isArchiveAlreadyInProgress($invalidatedArchive)
636 {
637 $table = Common::prefixTable('archive_invalidations');
638 $bind = [$invalidatedArchive['idsite'], $invalidatedArchive['date1'], $invalidatedArchive['date2'], $invalidatedArchive['period'], $invalidatedArchive['name']];
639 $reportClause = "(report = '' OR report IS NULL)";
640 if (!empty($invalidatedArchive['report'])) {
641 $reportClause = "report = ?";
642 $bind[] = $invalidatedArchive['report'];
643 }
644 $sql = "SELECT MAX(idinvalidation) FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND status = 1 AND {$reportClause}";
645 $inProgressInvalidation = Db::fetchOne($sql, $bind);
646 return $inProgressInvalidation;
647 }
648 /**
649 * Returns true if there is an archive that exists that can be used when aggregating an archive for $period.
650 *
651 * @param $idSite
652 * @param Period $period
653 * @return bool
654 * @throws Exception
655 */
656 public function hasChildArchivesInPeriod($idSite, Period $period)
657 {
658 $date = $period->getDateStart();
659 while ($date->isEarlier($period->getDateEnd()->addPeriod(1, 'month'))) {
660 $archiveTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($date);
661 // we look for any archive that can be used to compute this one. this includes invalidated archives, since it is possible
662 // under certain circumstances for them to exist, when archiving a higher period that includes them. the main example being
663 // the GoogleAnalyticsImporter which disallows the recomputation of invalidated archives for imported data, since that would
664 // essentially get rid of the imported data.
665 $usableDoneFlags = [\Piwik\DataAccess\ArchiveWriter::DONE_OK, \Piwik\DataAccess\ArchiveWriter::DONE_INVALIDATED, \Piwik\DataAccess\ArchiveWriter::DONE_PARTIAL, \Piwik\DataAccess\ArchiveWriter::DONE_OK_TEMPORARY];
666 $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";
667 $bind = [$idSite, $period->getDateStart()->getDatetime(), $period->getDateEnd()->getDatetime(), $period->getId()];
668 $result = (bool) Db::fetchOne($sql, $bind);
669 if ($result) {
670 return \true;
671 }
672 $date = $date->addPeriod(1, 'month');
673 // move to next archive table
674 }
675 return \false;
676 }
677 /**
678 * Returns true if any invalidations exists for the given
679 * $idsite and $doneFlag (name column) for the $period.
680 *
681 * @param mixed $idSite
682 * @param Period $period
683 * @param mixed $doneFlag
684 * @param mixed $report
685 * @return bool
686 * @throws Exception
687 */
688 public function hasInvalidationForPeriodAndName($idSite, Period $period, $doneFlag, $report = null)
689 {
690 $table = Common::prefixTable('archive_invalidations');
691 $report = !empty($report) && !is_array($report) ? [$report] : $report;
692 if (empty($report)) {
693 $sql = "SELECT idinvalidation FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IS NULL LIMIT 1";
694 } else {
695 $sql = "SELECT idinvalidation FROM `{$table}` WHERE idsite = ? AND date1 = ? AND date2 = ? AND `period` = ? AND `name` = ? AND `report` IN (" . Common::getSqlStringFieldsArray($report) . ") LIMIT 1";
696 }
697 $bind = [$idSite, $period->getDateStart()->toString(), $period->getDateEnd()->toString(), $period->getId(), $doneFlag];
698 if (!empty($report)) {
699 $bind = array_merge($bind, $report);
700 }
701 $idInvalidation = Db::fetchOne($sql, $bind);
702 if (empty($idInvalidation)) {
703 return \false;
704 }
705 return \true;
706 }
707 public function deleteInvalidationsForSites(array $idSites)
708 {
709 $idSites = array_map('intval', $idSites);
710 $table = Common::prefixTable('archive_invalidations');
711 $sql = "DELETE FROM `{$table}` WHERE idsite IN (" . implode(',', $idSites) . ")";
712 Db::query($sql);
713 }
714 public function deleteInvalidationsForDeletedSites()
715 {
716 $siteTable = Common::prefixTable('site');
717 $table = Common::prefixTable('archive_invalidations');
718 $sql = "DELETE a FROM `{$table}` a LEFT JOIN `{$siteTable}` s ON a.idsite = s.idsite WHERE s.idsite IS NULL";
719 Db::query($sql);
720 }
721 private function getRemoveInvalidationsIdSitesClause($idSite)
722 {
723 if ($idSite === 'all') {
724 return '';
725 }
726 $idSites = is_array($idSite) ? $idSite : [$idSite];
727 $idSites = array_map('intval', $idSites);
728 $idSitesStr = implode(',', $idSites);
729 return "idsite IN ({$idSitesStr}) AND";
730 }
731 /**
732 * Releases in progress invalidations for the given ids
733 *
734 * To avoid duplicate invalidations in the database, the method is also meant to prevent having duplicates after a reset
735 * Therefor below code will check if any of the invalidations to be reset should be removed instead
736 * An invalidation can be safely removed
737 * - if there exists another queued invalidation with the same parameters
738 * - if there is another running invalidation, that had been started after the current one was invalidated
739 * Otherwise the invalidation will be reset
740 *
741 * @param array $idinvalidations
742 * @return int
743 * @throws \Zend_Db_Statement_Exception
744 */
745 public function releaseInProgressInvalidations(array $idinvalidations) : int
746 {
747 $idinvalidations = array_map('intval', $idinvalidations);
748 $table = Common::prefixTable('archive_invalidations');
749 $changedCount = 0;
750 $sql = "SELECT * FROM `{$table}` WHERE idinvalidation IN (" . implode(',', $idinvalidations) . ")";
751 $invalidations = Db::fetchAll($sql);
752 // Check invalidations one by one, to ensure we safely remove invalidations in cases where two identical ones are requested to reset
753 foreach ($invalidations as $invalidation) {
754 // Look for other identical invalidations that are either not started or started after the current one had been invalidated
755 $query = "SELECT COUNT(*) FROM `{$table}` WHERE name = ? AND idsite = ? AND date1 = ? AND date2 = ? AND period = ? AND " . "(status = ? OR (status = ? AND ts_started > ?)) AND idinvalidation != ?";
756 $bind = [$invalidation['name'], $invalidation['idsite'], $invalidation['date1'], $invalidation['date2'], $invalidation['period'], ArchiveInvalidator::INVALIDATION_STATUS_QUEUED, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['ts_invalidated'], $invalidation['idinvalidation']];
757 if (empty($invalidation['report'])) {
758 $query .= " AND (report IS NULL OR report = '')";
759 } else {
760 $query .= " AND report = ?";
761 $bind[] = $invalidation['report'];
762 }
763 $count = Db::fetchOne($query, $bind);
764 if ($count > 0) {
765 $this->logger->info('Found duplicate invalidation for params (name = {name}, idsite = {idsite}, date1 = {date1}, date2 = {date2}, period = {period}, report = {report}). Removing invalidation {idinvalidation} instead of resetting it.', $invalidation);
766 $sql = "DELETE FROM `{$table}` WHERE status = ? AND idinvalidation = ?";
767 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['idinvalidation']];
768 $query = Db::query($sql, $bind);
769 $changedCount += $query->rowCount();
770 } else {
771 $sql = "UPDATE `{$table}` SET status = ?, processing_host = NULL, process_id = NULL, ts_started = NULL WHERE status = ? AND idinvalidation = ?";
772 $bind = [ArchiveInvalidator::INVALIDATION_STATUS_QUEUED, ArchiveInvalidator::INVALIDATION_STATUS_IN_PROGRESS, $invalidation['idinvalidation']];
773 $query = Db::query($sql, $bind);
774 $changedCount += $query->rowCount();
775 }
776 }
777 return $changedCount;
778 }
779 public function resetFailedArchivingJobs()
780 {
781 $invalidationsInProgress = $this->getInvalidationsInProgress();
782 $idsToReset = [];
783 foreach ($invalidationsInProgress as $invalidation) {
784 $archiveFailureRecoveryTimeout = GeneralConfig::getConfigValue('archive_failure_recovery_timeout', $invalidation['idsite']);
785 if (empty($invalidation['ts_started']) || Date::factory($invalidation['ts_started'])->getTimestamp() < Date::now()->getTimestamp() - $archiveFailureRecoveryTimeout) {
786 $idsToReset[] = $invalidation['idinvalidation'];
787 }
788 }
789 if (empty($idsToReset)) {
790 return 0;
791 }
792 return $this->releaseInProgressInvalidations($idsToReset);
793 }
794 public function getRecordsContainedInArchives(Date $archiveStartDate, array $idArchives, $requestedRecords) : array
795 {
796 $idArchives = array_map('intval', $idArchives);
797 $idArchives = implode(',', $idArchives);
798 $requestedRecords = is_string($requestedRecords) ? [$requestedRecords] : $requestedRecords;
799 $placeholders = Common::getSqlStringFieldsArray($requestedRecords);
800 $countSql = "SELECT DISTINCT name FROM `%s` WHERE idarchive IN ({$idArchives}) AND name IN ({$placeholders}) LIMIT " . count($requestedRecords);
801 $numericTable = \Piwik\DataAccess\ArchiveTableCreator::getNumericTable($archiveStartDate);
802 $blobTable = \Piwik\DataAccess\ArchiveTableCreator::getBlobTable($archiveStartDate);
803 // if the requested metrics look numeric, prioritize the numeric table, otherwise the blob table. this way, if all the metrics are
804 // found in this table (which will be most of the time), we don't have to query the other table
805 if ($this->doRequestedRecordsLookNumeric($requestedRecords)) {
806 $tablesToSearch = [$numericTable, $blobTable];
807 } else {
808 $tablesToSearch = [$blobTable, $numericTable];
809 }
810 $existingRecords = [];
811 foreach ($tablesToSearch as $tableName) {
812 $sql = sprintf($countSql, $tableName);
813 $rows = Db::fetchAll($sql, $requestedRecords);
814 $existingRecords = array_merge($existingRecords, array_column($rows, 'name'));
815 if (count($existingRecords) == count($requestedRecords)) {
816 break;
817 }
818 }
819 return $existingRecords;
820 }
821 private function isCutOffGroupConcatResult($pair)
822 {
823 $position = strpos($pair, '.');
824 return $position === \false || $position === strlen($pair) - 1;
825 }
826 private function getHashFromDoneFlag($doneFlag)
827 {
828 preg_match('/^done([a-zA-Z0-9]+)/', $doneFlag, $matches);
829 return $matches[1] ?? '';
830 }
831 private function doRequestedRecordsLookNumeric(array $requestedRecords) : bool
832 {
833 foreach ($requestedRecords as $record) {
834 if (preg_match('/^nb_/', $record)) {
835 return \true;
836 }
837 }
838 return \false;
839 }
840 }
841