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