From 8af7ebabaeb1368e2d8288443566ce6df1ea2f32 Mon Sep 17 00:00:00 2001 From: AntTBD Date: Mon, 17 Aug 2026 11:59:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?perf(calendar=5Fevent):=20d=C3=A9marrer=20l?= =?UTF-8?q?e=20calcul=20des=20occurrences=20=C3=A0=20la=20plage=20demand?= =?UTF-8?q?=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculOccurrence() itérait depuis la 1ère occurrence de l'évènement : un agenda quotidien créé en 2017 effectuait ~3000 tours de boucle à chaque rendu de widget, et un agenda horaire plusieurs dizaines de milliers. Les dashboards comportant plusieurs widgets agenda devenaient sensiblement lents. On avance d'un multiple entier de la fréquence de répétition jusqu'au dernier cycle se terminant avant $_startDate : la grille des occurrences est strictement identique à celle produite par itération. Limité aux unités de durée fixe (minutes, heures, jours, semaines), l'arithmétique PHP sur les mois n'étant pas associative en fin de mois (31 janvier +1 mois +1 mois != 31 janvier +2 mois) ; les mois et les années continuent d'itérer, le nombre de tours y étant déjà faible. Le saut est divisé par deux jusqu'à ne plus ignorer que des occurrences se terminant avant la plage, et abandonné s'il n'y parvient pas. Sortie également des invariants de la boucle : $excludeDate est indexé par clé pour un test isset() en O(1) au lieu d'un in_array(), et la vérification de until est résolue une seule fois avant la boucle au lieu d'appeler strtotime() à chaque itération. --- core/class/calendar_event.class.php | 113 ++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 15 deletions(-) diff --git a/core/class/calendar_event.class.php b/core/class/calendar_event.class.php index 476a83b..22979f2 100644 --- a/core/class/calendar_event.class.php +++ b/core/class/calendar_event.class.php @@ -159,6 +159,64 @@ public static function getNationalDay($year = null) { return $holidays; } + /** + * Avance le couple (startDate, endDate) de N cycles complets pour se placer + * juste avant le début de la plage demandée, sans itérer cycle par cycle. + * + * Le saut est toujours un multiple exact de la fréquence de répétition : la + * grille des occurrences est donc strictement identique à celle obtenue par + * itération. On ne saute que des occurrences dont la fin est antérieure au + * début de la plage, c'est-à-dire celles que la boucle aurait de toute façon + * filtrées. Si le saut calculé dépasse, il est divisé par deux jusqu'à + * devenir sûr, sinon il est abandonné. + * + * Volontairement limité aux unités de durée fixe (minutes/heures/jours/ + * semaines) : pour mois/années le nombre d'itérations reste faible et + * l'arithmétique PHP sur les fins de mois n'est pas associative + * (31 janvier +1 mois +1 mois != 31 janvier +2 mois). + * + * @return array array($startDate, $endDate) + */ + private static function fastForwardToRange($_startDate, $_endDate, $_repeat, $_rangeStartTime, $_initStartTime, $_initEndTime) { + $unitSeconds = array( + 'minute' => 60, 'minutes' => 60, + 'hour' => 3600, 'hours' => 3600, + 'day' => 86400, 'days' => 86400, + 'week' => 604800, 'weeks' => 604800, + ); + if (!isset($_repeat['unite']) || !isset($unitSeconds[$_repeat['unite']])) { + return array($_startDate, $_endDate); + } + $freq = intval(isset($_repeat['freq']) ? $_repeat['freq'] : 0); + if ($freq <= 0) { + return array($_startDate, $_endDate); + } + $step = $freq * $unitSeconds[$_repeat['unite']]; + $delta = $_rangeStartTime - strtotime($_endDate); + if ($delta <= ($step * 2)) { + return array($_startDate, $_endDate); + } + $isTimeUnit = ($unitSeconds[$_repeat['unite']] < 86400); + $steps = intval(floor($delta / $step)) - 1; + while ($steps > 0) { + $jump = '+' . ($steps * $freq) . ' ' . $_repeat['unite'] . ' '; + if ($isTimeUnit) { + $newStartTime = strtotime($jump . $_startDate); + $newEndTime = strtotime($jump . $_endDate); + } else { + $newStartTime = strtotime($jump . substr($_startDate, 0, 10) . ' ' . $_initStartTime); + $newEndTime = strtotime($jump . substr($_endDate, 0, 10) . ' ' . $_initEndTime); + } + if ($newStartTime !== false && $newEndTime !== false + && $newEndTime <= $_rangeStartTime + && $newStartTime >= strtotime($_startDate)) { + return array(date('Y-m-d H:i:s', $newStartTime), date('Y-m-d H:i:s', $newEndTime)); + } + $steps = intval(floor($steps / 2)); + } + return array($_startDate, $_endDate); + } + public function getLinkData(&$_data = array('node' => array(), 'link' => array()), $_level = 0, $_drill = 3) { if (isset($_data['node']['calendar' . $this->getId()])) { return; @@ -255,6 +313,8 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $return = array(); $repeat = $this->getRepeat(); if (isset($repeat['enable']) && $repeat['enable'] == 1) { + // Dates exclues stockées en clés du tableau : test en O(1) via isset() + // au lieu d'un in_array() sur toute la liste à chaque itération. $excludeDate = array(); if (isset($repeat['excludeDate']) && $repeat['excludeDate'] != '') { $excludeDate_tmp = explode(',', $repeat['excludeDate']); @@ -265,12 +325,12 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $startDate = date('Y-m-d', strtotime($expDate[0])); $endDate = date('Y-m-d', strtotime($expDate[1])); while (strtotime($startDate) <= strtotime($endDate)) { - $excludeDate[] = $startDate; + $excludeDate[$startDate] = true; $startDate = date('Y-m-d', strtotime('+1 day ' . $startDate)); } } } else { - $excludeDate[] = date('Y-m-d', strtotime($date)); + $excludeDate[date('Y-m-d', strtotime($date))] = true; } } } @@ -290,10 +350,10 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $startDate = date('Y-m-d', strtotime($occurrence['start'])); $endDate = date('Y-m-d', strtotime($occurrence['end'])); if ($startDate == $endDate) { - $excludeDate[] = $startDate; + $excludeDate[$startDate] = true; } else { while (strtotime($startDate) <= strtotime($endDate)) { - $excludeDate[] = $startDate; + $excludeDate[$startDate] = true; $startDate = date('Y-m-d', strtotime('+1 day ' . $startDate)); } } @@ -315,9 +375,30 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r // } $initStartTime = date('H:i:s', strtotime($startDate)); $initEndTime = date('H:i:s', strtotime($endDate)); - while ($this->getUntil() == null || strtotime($this->getUntil()) > strtotime($startDate) || $this->getUntil() == '0000-00-00 00:00:00') { - if (!in_array(date('Y-m-d', strtotime($startDate)), $excludeDate) && ($startTime < strtotime($startDate) || strtotime($endDate) > $startTime)) { - if ($repeat['excludeDay'][date('N', strtotime($startDate))] == 1 || (isset($repeat['mode']) && $repeat['mode'] == 'advance')) { + + $isAdvanceMode = (isset($repeat['mode']) && $repeat['mode'] == 'advance'); + + // PERF : on se place directement au début de la plage demandée au lieu + // de dérouler toutes les occurrences depuis la 1ère (un agenda quotidien + // créé en 2017 faisait ~3000 tours de boucle inutiles à chaque affichage + // de widget). + if (!$isAdvanceMode) { + list($startDate, $endDate) = self::fastForwardToRange($startDate, $endDate, $repeat, $startTime, $initStartTime, $initEndTime); + } + + // Sorties de la boucle : ces valeurs ne changent jamais d'un tour à + // l'autre et coûtaient plusieurs strtotime() par itération. Au passage, + // strtotime() n'est plus jamais appelé sur un until null. + $until = $this->getUntil(); + $hasUntil = ($until !== null && $until != '' && $until != '0000-00-00 00:00:00'); + $untilTime = $hasUntil ? strtotime($until) : null; + + $curStartTime = strtotime($startDate); + $curEndTime = strtotime($endDate); + + while (!$hasUntil || $untilTime > $curStartTime) { + if (!isset($excludeDate[date('Y-m-d', $curStartTime)]) && ($startTime < $curStartTime || $curEndTime > $startTime)) { + if ($repeat['excludeDay'][date('N', $curStartTime)] == 1 || $isAdvanceMode) { if (!isset($repeat['nationalDay']) || $repeat['nationalDay'] == 'all') { $return[] = array( 'start' => $startDate, @@ -325,7 +406,7 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r ); } else if ($repeat['nationalDay'] == 'exeptNationalDay') { $nationalDay = self::getNationalDay(date('Y'), strtotime($startDate)); - if (!in_array(date('Y-m-d', strtotime($startDate)), $nationalDay)) { + if (!in_array(date('Y-m-d', $curStartTime), $nationalDay)) { $return[] = array( 'start' => $startDate, 'end' => $endDate, @@ -333,21 +414,21 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r } } else if ($repeat['nationalDay'] == 'onlyNationalDay') { $nationalDay = self::getNationalDay(date('Y'), strtotime($startDate)); - if (in_array(date('Y-m-d', strtotime($startDate)), $nationalDay)) { + if (in_array(date('Y-m-d', $curStartTime), $nationalDay)) { $return[] = array( 'start' => $startDate, 'end' => $endDate, ); } } else if ($repeat['nationalDay'] == 'onlyEven') { - if ((date('W', strtotime($startDate)) % 2) == 0) { + if ((date('W', $curStartTime) % 2) == 0) { $return[] = array( 'start' => $startDate, 'end' => $endDate, ); } } else if ($repeat['nationalDay'] == 'onlyOdd') { - if ((date('W', strtotime($startDate)) % 2) == 1) { + if ((date('W', $curStartTime) % 2) == 1) { $return[] = array( 'start' => $startDate, 'end' => $endDate, @@ -359,8 +440,8 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r } } } - $prevStartDate = $startDate; - if (isset($repeat['mode']) && $repeat['mode'] == 'advance') { + $prevStartTime = $curStartTime; + if ($isAdvanceMode) { $nextMonth = date('F', strtotime('+1 month ' . $startDate)); $year = date('Y', strtotime('+1 month ' . $startDate)); $tmp_startDate = date('Y-m-d', strtotime($repeat['positionAt'] . ' ' . $repeat['day'] . ' of ' . $nextMonth . ' ' . $year)); @@ -381,10 +462,12 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $endDate = date('Y-m-d H:i:s', strtotime('+' . $repeat['freq'] . ' ' . $repeat['unite'] . ' ' . substr($endDate, 0, 10) . ' ' . $initEndTime)); } } - if (strtotime($startDate) <= strtotime($prevStartDate)) { + $curStartTime = strtotime($startDate); + $curEndTime = strtotime($endDate); + if ($curStartTime <= $prevStartTime) { break; } - if (strtotime($startDate) > $endTime) { + if ($curStartTime > $endTime) { break; } } From b6ee10abca90e4f8255ecdffa51ee019a2d93401 Mon Sep 17 00:00:00 2001 From: AntTBD Date: Mon, 17 Aug 2026 12:02:13 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(calendar=5Fevent):=20calculer=20les=20j?= =?UTF-8?q?ours=20f=C3=A9ri=C3=A9s=20de=20l'ann=C3=A9e=20de=20l'occurrence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getNationalDay() était appelée sous la forme getNationalDay(date('Y'), strtotime($startDate)), avec un second argument que la méthode n'accepte pas et l'année courante au lieu de celle de l'occurrence. Les options « sauf jours fériés » et « seulement les jours fériés » étaient donc fausses pour toute occurrence au-delà de l'année suivante. On passe l'année de l'occurrence, et le résultat est mis en cache par année : easter_date() et 20 appels à mktime() étaient recalculés à chaque itération. --- core/class/calendar_event.class.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/core/class/calendar_event.class.php b/core/class/calendar_event.class.php index 22979f2..471f64e 100644 --- a/core/class/calendar_event.class.php +++ b/core/class/calendar_event.class.php @@ -393,6 +393,7 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $hasUntil = ($until !== null && $until != '' && $until != '0000-00-00 00:00:00'); $untilTime = $hasUntil ? strtotime($until) : null; + $nationalDayCache = array(); $curStartTime = strtotime($startDate); $curEndTime = strtotime($endDate); @@ -405,7 +406,7 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r 'end' => $endDate, ); } else if ($repeat['nationalDay'] == 'exeptNationalDay') { - $nationalDay = self::getNationalDay(date('Y'), strtotime($startDate)); + $nationalDay = $this->getNationalDayCached($nationalDayCache, $curStartTime); if (!in_array(date('Y-m-d', $curStartTime), $nationalDay)) { $return[] = array( 'start' => $startDate, @@ -413,7 +414,7 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r ); } } else if ($repeat['nationalDay'] == 'onlyNationalDay') { - $nationalDay = self::getNationalDay(date('Y'), strtotime($startDate)); + $nationalDay = $this->getNationalDayCached($nationalDayCache, $curStartTime); if (in_array(date('Y-m-d', $curStartTime), $nationalDay)) { $return[] = array( 'start' => $startDate, @@ -565,6 +566,19 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r return $return; } + /** + * Jours fériés de l'année de l'occurrence, mis en cache. + * easter_date() + 20 mktime() étaient recalculés à chaque itération, et pour + * l'année courante seulement (donc faux pour une occurrence en N+2). + */ + private function getNationalDayCached(&$_cache, $_timestamp) { + $year = intval(date('Y', $_timestamp)); + if (!isset($_cache[$year])) { + $_cache[$year] = self::getNationalDay($year); + } + return $_cache[$year]; + } + public function preSave() { if ($this->getEqLogic_id() == '') { throw new Exception(__("L'id de l'équipement ne peut être vide", __FILE__)); From b8bc4fa145b087ad1cfb46be47708b138228b806 Mon Sep 17 00:00:00 2001 From: AntTBD Date: Mon, 17 Aug 2026 12:04:42 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(calendar=5Fevent):=20prot=C3=A9ger=20la?= =?UTF-8?q?=20boucle=20d'occurrences=20et=20supprimer=20les=20warnings=20P?= =?UTF-8?q?HP=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limitation de la boucle de calcul des occurrences à MAX_OCCURRENCE_LOOP itérations, avec un log de debug, afin qu'un évènement mal configuré ne puisse plus figer le rendu du widget. Ajout des tests isset() manquants sur $repeat['excludeDay'][...] et $repeat['freq']. --- core/class/calendar_event.class.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/core/class/calendar_event.class.php b/core/class/calendar_event.class.php index 471f64e..06cceb8 100644 --- a/core/class/calendar_event.class.php +++ b/core/class/calendar_event.class.php @@ -28,6 +28,10 @@ class calendar_event { private $until = null; private $_changed = false; + /* Garde-fou : nombre maximum d'itérations dans la boucle de calcul des + occurrences, pour qu'un agenda mal configuré ne bloque pas le rendu. */ + const MAX_OCCURRENCE_LOOP = 100000; + public static function sortEventDate($a, $b) { if (strtotime($a['start']) == strtotime($b['start'])) { return 0; @@ -394,12 +398,17 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $untilTime = $hasUntil ? strtotime($until) : null; $nationalDayCache = array(); + $loop = 0; $curStartTime = strtotime($startDate); $curEndTime = strtotime($endDate); while (!$hasUntil || $untilTime > $curStartTime) { + if (++$loop > self::MAX_OCCURRENCE_LOOP) { + log::add('calendar', 'debug', __('Calcul des occurrences interrompu (trop d\'itérations) pour l\'évènement', __FILE__) . ' : ' . $this->getId()); + break; + } if (!isset($excludeDate[date('Y-m-d', $curStartTime)]) && ($startTime < $curStartTime || $curEndTime > $startTime)) { - if ($repeat['excludeDay'][date('N', $curStartTime)] == 1 || $isAdvanceMode) { + if ((isset($repeat['excludeDay'][date('N', $curStartTime)]) && $repeat['excludeDay'][date('N', $curStartTime)] == 1) || $isAdvanceMode) { if (!isset($repeat['nationalDay']) || $repeat['nationalDay'] == 'all') { $return[] = array( 'start' => $startDate, @@ -452,7 +461,7 @@ public function calculOccurrence($_startDate, $_endDate, $_max = 9999999999, $_r $endDate = date('Y-m-d H:i:s', strtotime($tmp_startDate . ' ' . $initEndTime)); $startDate = date('Y-m-d H:i:s', strtotime($tmp_startDate . ' ' . $initStartTime)); } else { - if ($repeat['freq'] == 0) { + if (!isset($repeat['freq']) || $repeat['freq'] == 0) { break; } if ($repeat['unite'] == 'hours') {