Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
<?php
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadRepository as CampaignLeadRepository;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Psr\Log\LoggerInterface;
class InactiveContactFinder
{
/**
* @var array<string, \DateTimeInterface>|null
*/
private ?array $campaignMemberDatesAdded = null;
public function __construct(
private LeadRepository $leadRepository,
private CampaignLeadRepository $campaignLeadRepository,
private LoggerInterface $logger,
) {
}
/**
* @param int $campaignId
*
* @return ArrayCollection
*
* @throws NoContactsFoundException
*/
public function getContacts($campaignId, Event $decisionEvent, ContactLimiter $limiter)
{
if ($limiter->hasCampaignLimit() && 0 === $limiter->getCampaignLimitRemaining()) {
// Limit was reached but do not trigger the NoContactsFoundException
return new ArrayCollection();
}
// Get list of all campaign leads
$decisionParentEvent = $decisionEvent->getParent();
$this->campaignMemberDatesAdded = $this->campaignLeadRepository->getInactiveContacts(
$campaignId,
$decisionEvent->getId(),
($decisionParentEvent) ? $decisionParentEvent->getId() : null,
$limiter
);
if (empty($this->campaignMemberDatesAdded)) {
// No new contacts found in the campaign
throw new NoContactsFoundException();
}
$campaignContacts = array_keys($this->campaignMemberDatesAdded);
$this->logger->debug('CAMPAIGN: Processing the following contacts: '.implode(', ', $campaignContacts));
// Fetch entity objects for the found contacts
$contacts = $this->leadRepository->getContactCollection($campaignContacts);
if (!count($contacts)) {
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
$this->logger->debug('CAMPAIGN: No contact entities found.');
throw new NoContactsFoundException();
}
return $contacts;
}
/**
* @return array<string, \DateTimeInterface>|null
*/
public function getDatesAdded(): ?array
{
return $this->campaignMemberDatesAdded;
}
/**
* @param int $campaignId
*/
public function getContactCount($campaignId, array $decisionEvents, ContactLimiter $limiter): int
{
return $this->campaignLeadRepository->getInactiveContactCount($campaignId, $decisionEvents, $limiter);
}
/**
* Clear Lead entities from memory.
*
* @param Collection<int, Lead> $contacts
*/
public function clear(Collection $contacts): void
{
$this->leadRepository->detachEntities($contacts->toArray());
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Mautic\CampaignBundle\Entity\CampaignRepository;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Psr\Log\LoggerInterface;
class KickoffContactFinder
{
public function __construct(
private LeadRepository $leadRepository,
private CampaignRepository $campaignRepository,
private LoggerInterface $logger,
) {
}
/**
* @param int $campaignId
*
* @return ArrayCollection
*
* @throws NoContactsFoundException
*/
public function getContacts($campaignId, ContactLimiter $limiter)
{
// Get list of all campaign leads; start is always zero in practice because of $pendingOnly
$campaignContacts = $this->campaignRepository->getPendingContactIds($campaignId, $limiter);
if (empty($campaignContacts)) {
// No new contacts found in the campaign
throw new NoContactsFoundException();
}
$this->logger->debug('CAMPAIGN: Processing the following contacts: '.implode(', ', $campaignContacts));
// Fetch entity objects for the found contacts
$contacts = $this->leadRepository->getContactCollection($campaignContacts);
if (!count($contacts)) {
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
$this->logger->debug('CAMPAIGN: No contact entities found.');
throw new NoContactsFoundException();
}
return $contacts;
}
/**
* @param int $campaignId
*/
public function getContactCount($campaignId, array $eventIds, ContactLimiter $limiter): int
{
$countResult = $this->campaignRepository->getCountsForPendingContacts($campaignId, $eventIds, $limiter);
return $countResult->getCount();
}
/**
* Clear Lead entities from memory.
*
* @param Collection<int, Lead> $contacts
*/
public function clear(Collection $contacts): void
{
$this->leadRepository->detachEntities($contacts->toArray());
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace Mautic\CampaignBundle\Executioner\ContactFinder\Limiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
class ContactLimiter
{
private int $batchLimit;
private ?int $contactId;
private ?int $minContactId;
private ?int $batchMinContactId = null;
private ?int $maxContactId;
private ?int $threadId = null;
private ?int $maxThreads = null;
/**
* @var int|null
*/
private $campaignLimit;
private ?int $campaignLimitUsed = null;
/**
* @param int $batchLimit
* @param int|null $contactId
* @param int|null $minContactId
* @param int|null $maxContactId
* @param int|null $threadId
* @param int|null $maxThreads
* @param int|null $campaignLimit
*/
public function __construct(
$batchLimit,
$contactId = null,
$minContactId = null,
$maxContactId = null,
private array $contactIdList = [],
$threadId = null,
$maxThreads = null,
$campaignLimit = null,
) {
$this->batchLimit = ($batchLimit) ? (int) $batchLimit : 100;
$this->contactId = ($contactId) ? (int) $contactId : null;
$this->minContactId = ($minContactId) ? (int) $minContactId : null;
$this->maxContactId = ($maxContactId) ? (int) $maxContactId : null;
if ($threadId && $maxThreads) {
$this->threadId = (int) $threadId;
$this->maxThreads = (int) $maxThreads;
if ($threadId > $maxThreads) {
throw new \InvalidArgumentException('$threadId cannot be larger than $maxThreads');
}
}
if ($campaignLimit) {
$this->campaignLimit = $campaignLimit;
$this->campaignLimitUsed = 0;
}
}
public function getBatchLimit(): int
{
return $this->batchLimit;
}
/**
* @return int|null
*/
public function getContactId()
{
return $this->contactId;
}
public function getMinContactId(): ?int
{
return $this->batchMinContactId ?: $this->minContactId;
}
/**
* @return int|null
*/
public function getMaxContactId()
{
return $this->maxContactId;
}
/**
* @return array
*/
public function getContactIdList()
{
return $this->contactIdList;
}
/**
* @param int $id
*
* @return $this
*
* @throws NoContactsFoundException
*/
public function setBatchMinContactId($id)
{
// Prevent a never ending loop if the contact ID never changes due to being the last batch of contacts
if ($this->minContactId && $this->minContactId > (int) $id) {
throw new NoContactsFoundException();
}
// We've surpasssed the max so bai
if ($this->maxContactId && $this->maxContactId < (int) $id) {
throw new NoContactsFoundException();
}
// The same batch of contacts were somehow processed so let's stop to prevent the loop
if ($this->batchMinContactId && $this->batchMinContactId >= $id) {
throw new NoContactsFoundException();
}
$this->batchMinContactId = (int) $id;
return $this;
}
/**
* @return $this
*/
public function resetBatchMinContactId()
{
$this->batchMinContactId = null;
return $this;
}
/**
* @return int|null
*/
public function getMaxThreads()
{
return $this->maxThreads;
}
/**
* @return int|null
*/
public function getThreadId()
{
return $this->threadId;
}
/**
* @return int|null
*/
public function getCampaignLimit()
{
return $this->campaignLimit;
}
public function hasCampaignLimit(): bool
{
return null !== $this->campaignLimit;
}
/**
* @return int
*
* @throws \Exception
*/
public function getCampaignLimitRemaining()
{
if (!$this->hasCampaignLimit()) {
throw new \Exception('Campaign Limit was not set');
}
return $this->campaignLimit - $this->campaignLimitUsed;
}
/**
* @return $this
*
* @throws \Exception
*/
public function reduceCampaignLimitRemaining($reduction)
{
if (!$this->hasCampaignLimit()) {
throw new \Exception('Campaign Limit was not set');
} elseif ($this->campaignLimit < ($this->campaignLimitUsed + $reduction)) {
throw new \Exception('Campaign Limit exceeded');
}
$this->campaignLimitUsed += $reduction;
return $this;
}
/**
* @return $this
*/
public function resetCampaignLimitRemaining()
{
$this->campaignLimitUsed = 0;
return $this;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Psr\Log\LoggerInterface;
class ScheduledContactFinder
{
public function __construct(
private LeadRepository $leadRepository,
private LoggerInterface $logger,
) {
}
/**
* Hydrate contacts with custom field value, companies, etc.
*
* @return Collection<int, Lead>
*/
public function hydrateContacts(ArrayCollection $logs): Collection
{
$contactIds = [];
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$contactIds[] = $log->getLead()->getId();
}
if (!count($contactIds)) {
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
$this->logger->debug('CAMPAIGN: No contact entities found.');
throw new NoContactsFoundException();
}
$contacts = $this->leadRepository->getContactCollection($contactIds);
foreach ($logs as $key => $log) {
$contactId = $log->getLead()->getId();
if (!$contact = $contacts->get($contactId)) {
// the contact must have been deleted mid execution so remove this log from memory
$logs->remove($key);
continue;
}
$log->setLead($contact);
}
return $contacts;
}
/**
* Clear Lead entities from memory.
*
* @param Collection<int, Lead> $contacts
*/
public function clear(Collection $contacts): void
{
$this->leadRepository->detachEntities($contacts->toArray());
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\ExecutedBatchEvent;
use Mautic\CampaignBundle\Event\ExecutedEvent;
use Mautic\CampaignBundle\Event\FailedEvent;
use Mautic\CampaignBundle\Event\PendingEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException;
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class ActionDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
private LoggerInterface $logger,
private EventScheduler $scheduler,
private LegacyEventDispatcher $legacyDispatcher,
) {
}
/**
* @throws LogNotProcessedException
* @throws LogPassedAndFailedException
*/
public function dispatchEvent(ActionAccessor $config, Event $event, ArrayCollection $logs, ?PendingEvent $pendingEvent = null): PendingEvent
{
if (!$pendingEvent) {
$pendingEvent = new PendingEvent($config, $event, $logs);
}
// this if statement can be removed when legacy dispatcher is removed
if ($customEvent = $config->getBatchEventName()) {
$this->dispatcher->dispatch($pendingEvent, $customEvent);
$success = $pendingEvent->getSuccessful();
$failed = $pendingEvent->getFailures();
$this->validateProcessedLogs($logs, $success, $failed);
if ($success) {
$this->dispatchExecutedEvent($config, $event, $success);
}
if ($failed) {
$this->dispatchFailedEvent($config, $failed);
}
// Dispatch legacy ON_EVENT_EXECUTION event for BC
$this->legacyDispatcher->dispatchExecutionEvents($config, $success, $failed);
}
// Execute BC eventName or callback. Or support case where the listener has been converted to batchEventName but still wants to execute
// eventName for BC support for plugins that could be listening to it's own custom event.
$this->legacyDispatcher->dispatchCustomEvent($config, $logs, $customEvent, $pendingEvent);
return $pendingEvent;
}
private function dispatchExecutedEvent(AbstractEventAccessor $config, Event $event, ArrayCollection $logs): void
{
if (!$logs->count()) {
return;
}
foreach ($logs as $log) {
$this->dispatcher->dispatch(
new ExecutedEvent($config, $log),
CampaignEvents::ON_EVENT_EXECUTED
);
}
$this->dispatcher->dispatch(
new ExecutedBatchEvent($config, $event, $logs),
CampaignEvents::ON_EVENT_EXECUTED_BATCH
);
}
private function dispatchFailedEvent(AbstractEventAccessor $config, ArrayCollection $logs): void
{
if (!$logs->count()) {
return;
}
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$this->logger->debug(
'CAMPAIGN: '.ucfirst($log->getEvent()->getEventType() ?? 'unknown event').' ID# '.$log->getEvent()->getId().' for contact ID# '.$log->getLead()->getId()
);
$this->dispatcher->dispatch(
new FailedEvent($config, $log),
CampaignEvents::ON_EVENT_FAILED
);
}
$this->scheduler->rescheduleFailures($logs);
}
/**
* @throws LogNotProcessedException
* @throws LogPassedAndFailedException
*/
private function validateProcessedLogs(ArrayCollection $pending, ArrayCollection $success, ArrayCollection $failed): void
{
foreach ($pending as $log) {
if (!$success->contains($log) && !$failed->contains($log)) {
throw new LogNotProcessedException($log);
}
if ($success->contains($log) && $failed->contains($log)) {
throw new LogPassedAndFailedException($log);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\ConditionEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ConditionAccessor;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class ConditionDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {
}
public function dispatchEvent(ConditionAccessor $config, LeadEventLog $log): ConditionEvent
{
$event = new ConditionEvent($config, $log);
$this->dispatcher->dispatch($event, $config->getEventName());
$this->dispatcher->dispatch($event, CampaignEvents::ON_EVENT_CONDITION_EVALUATION);
return $event;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\DecisionEvent;
use Mautic\CampaignBundle\Event\DecisionResultsEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class DecisionDispatcher
{
public function __construct(
private EventDispatcherInterface $dispatcher,
private LegacyEventDispatcher $legacyDispatcher,
) {
}
/**
* @param mixed $passthrough
*/
public function dispatchRealTimeEvent(DecisionAccessor $config, LeadEventLog $log, $passthrough): DecisionEvent
{
$event = new DecisionEvent($config, $log, $passthrough);
$this->dispatcher->dispatch($event, $config->getEventName());
return $event;
}
public function dispatchEvaluationEvent(DecisionAccessor $config, LeadEventLog $log): DecisionEvent
{
$event = new DecisionEvent($config, $log);
$this->dispatcher->dispatch($event, CampaignEvents::ON_EVENT_DECISION_EVALUATION);
$this->legacyDispatcher->dispatchDecisionEvent($event);
return $event;
}
public function dispatchDecisionResultsEvent(DecisionAccessor $config, ArrayCollection $logs, EvaluatedContacts $evaluatedContacts): void
{
if (!$logs->count()) {
return;
}
$this->dispatcher->dispatch(
new DecisionResultsEvent($config, $logs, $evaluatedContacts),
CampaignEvents::ON_EVENT_DECISION_EVALUATION_RESULTS
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher\Exception;
use Mautic\CampaignBundle\Entity\LeadEventLog;
class LogNotProcessedException extends \Exception
{
public function __construct(LeadEventLog $log)
{
parent::__construct("LeadEventLog ID # {$log->getId()} must be passed to either pass() or fail()", 0, null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher\Exception;
use Mautic\CampaignBundle\Entity\LeadEventLog;
class LogPassedAndFailedException extends \Exception
{
public function __construct(LeadEventLog $log)
{
parent::__construct("LeadEventLog ID # {$log->getId()} was passed to both pass() or fail(). Pass or fail the log, not both.", 0, null);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\CampaignDecisionEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CampaignBundle\Event\DecisionEvent;
use Mautic\CampaignBundle\Event\EventArrayTrait;
use Mautic\CampaignBundle\Event\ExecutedBatchEvent;
use Mautic\CampaignBundle\Event\ExecutedEvent;
use Mautic\CampaignBundle\Event\FailedEvent;
use Mautic\CampaignBundle\Event\PendingEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @deprecated 2.13.0 to be removed in 3.0; BC support for old listeners
*/
class LegacyEventDispatcher
{
use EventArrayTrait;
public function __construct(
private EventDispatcherInterface $dispatcher,
private EventScheduler $scheduler,
private LoggerInterface $logger,
private ContactTracker $contactTracker,
) {
}
public function dispatchCustomEvent(
AbstractEventAccessor $config,
ArrayCollection $logs,
$wasBatchProcessed,
PendingEvent $pendingEvent,
): void {
$settings = $config->getConfig();
if (!isset($settings['eventName']) && !isset($settings['callback'])) {
// Bad plugin but only fail if the new event didn't already process the log
if (!$wasBatchProcessed) {
$pendingEvent->failAll('Invalid event configuration');
}
return;
}
$rescheduleFailures = new ArrayCollection();
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$this->contactTracker->setSystemContact($log->getLead());
if (isset($settings['eventName'])) {
$event = $this->dispatchEventName($settings['eventName'], $settings, $log);
$result = $event->getResult();
} else {
if (!is_callable($settings['callback'])) {
// No use to keep trying for the other logs as it won't ever work
break;
}
$result = $this->dispatchCallback($settings, $log);
}
// If the new batch event was handled, the $log was already processed so only process legacy logs if false
if (!$wasBatchProcessed) {
$this->dispatchExecutionEvent($config, $log, $result);
if (!is_bool($result)) {
$log->appendToMetadata($result);
}
// Dispatch new events for legacy processed logs
if ($this->isFailed($result)) {
$this->processFailedLog($log, $pendingEvent);
$rescheduleFailures->set($log->getId(), $log);
$this->dispatchFailedEvent($config, $log);
continue;
}
if (is_array($result) && !empty($result['failed']) && isset($result['reason'])) {
$pendingEvent->passWithError($log, (string) $result['reason']);
} else {
$pendingEvent->pass($log);
}
$this->dispatchExecutedEvent($config, $log);
}
}
if ($rescheduleFailures->count()) {
$this->scheduler->rescheduleFailures($rescheduleFailures);
}
$this->contactTracker->setSystemContact(null);
}
/**
* Execute the new ON_EVENT_FAILED and ON_EVENT_EXECUTED events for logs processed by BC code.
*/
public function dispatchExecutionEvents(AbstractEventAccessor $config, ArrayCollection $success, ArrayCollection $failures): void
{
foreach ($success as $log) {
$this->dispatchExecutionEvent($config, $log, true);
}
foreach ($failures as $log) {
$this->dispatchExecutionEvent($config, $log, false);
}
}
public function dispatchDecisionEvent(DecisionEvent $decisionEvent): void
{
if ($this->dispatcher->hasListeners(CampaignEvents::ON_EVENT_DECISION_TRIGGER)) {
$log = $decisionEvent->getLog();
$event = $log->getEvent();
$legacyDecisionEvent = $this->dispatcher->dispatch(
new CampaignDecisionEvent(
$log->getLead(),
$event->getType(),
$decisionEvent->getEventConfig()->getConfig(),
$this->getLegacyEventsArray($log),
$this->getLegacyEventsConfigArray($event, $decisionEvent->getEventConfig()),
0 === $event->getOrder(),
[$log]
),
CampaignEvents::ON_EVENT_DECISION_TRIGGER
);
if ($legacyDecisionEvent->wasDecisionTriggered()) {
$decisionEvent->setAsApplicable();
}
}
}
private function dispatchEventName($eventName, array $settings, LeadEventLog $log): CampaignExecutionEvent
{
@trigger_error('eventName is deprecated. Convert to using batchEventName.', E_USER_DEPRECATED);
$campaignEvent = new CampaignExecutionEvent(
[
'eventSettings' => $settings,
'eventDetails' => null,
'event' => $log->getEvent(),
'lead' => $log->getLead(),
'systemTriggered' => $log->getSystemTriggered(),
],
null,
$log
);
$this->dispatcher->dispatch($campaignEvent, $eventName);
if ($channel = $campaignEvent->getChannel()) {
$log->setChannel($channel);
$log->setChannelId($campaignEvent->getChannelId());
}
return $campaignEvent;
}
/**
* @return mixed
*/
private function dispatchCallback(array $settings, LeadEventLog $log)
{
@trigger_error('callback is deprecated. Convert to using batchEventName.', E_USER_DEPRECATED);
$eventArray = $this->getEventArray($log->getEvent());
$args = [
'eventSettings' => $settings,
'eventDetails' => null, // @todo fix when procesing decisions,
'event' => $eventArray,
'lead' => $log->getLead(),
'systemTriggered' => $log->getSystemTriggered(),
'config' => $eventArray['properties'],
];
try {
if (is_array($settings['callback'])) {
$reflection = new \ReflectionMethod($settings['callback'][0], $settings['callback'][1]);
} elseif (str_contains($settings['callback'], '::')) {
$parts = explode('::', $settings['callback']);
$reflection = new \ReflectionMethod($parts[0], $parts[1]);
} else {
$reflection = new \ReflectionMethod(null, $settings['callback']);
}
$pass = [];
foreach ($reflection->getParameters() as $param) {
if (isset($args[$param->getName()])) {
$pass[] = $args[$param->getName()];
} else {
$pass[] = null;
}
}
return $reflection->invokeArgs($this, $pass);
} catch (\ReflectionException) {
return false;
}
}
private function dispatchExecutionEvent(AbstractEventAccessor $config, LeadEventLog $log, $result): void
{
$eventArray = $this->getEventArray($log->getEvent());
$this->dispatcher->dispatch(
new CampaignExecutionEvent(
[
'eventSettings' => $config->getConfig(),
'eventDetails' => null, // @todo fix when procesing decisions,
'event' => $eventArray,
'lead' => $log->getLead(),
'systemTriggered' => $log->getSystemTriggered(),
'config' => $eventArray['properties'],
],
$result,
$log
),
CampaignEvents::ON_EVENT_EXECUTION
);
}
private function dispatchExecutedEvent(AbstractEventAccessor $config, LeadEventLog $log): void
{
$this->dispatcher->dispatch(
new ExecutedEvent($config, $log),
CampaignEvents::ON_EVENT_EXECUTED
);
$collection = new ArrayCollection();
$collection->set($log->getId(), $log);
$this->dispatcher->dispatch(
new ExecutedBatchEvent($config, $log->getEvent(), $collection),
CampaignEvents::ON_EVENT_EXECUTED_BATCH
);
}
private function dispatchFailedEvent(AbstractEventAccessor $config, LeadEventLog $log): void
{
$this->dispatcher->dispatch(
new FailedEvent($config, $log),
CampaignEvents::ON_EVENT_FAILED
);
}
private function isFailed($result): bool
{
return
false === $result
|| (is_array($result) && isset($result['result']) && false === $result['result']);
}
private function processFailedLog(LeadEventLog $log, PendingEvent $pendingEvent): void
{
$this->logger->debug(
'CAMPAIGN: '.ucfirst($log->getEvent()->getEventType() ?? 'unknown event').' ID# '.$log->getEvent()->getId().' for contact ID# '.$log->getLead()->getId()
);
$metadata = $log->getMetadata();
$reason = null;
if (isset($metadata['errors'])) {
$reason = (is_array($metadata['errors'])) ? implode('<br />', $metadata['errors']) : $metadata['errors'];
} elseif (isset($metadata['reason'])) {
$reason = $metadata['reason'];
}
$pendingEvent->fail($log, $reason);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Event;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
use Mautic\CampaignBundle\Executioner\Dispatcher\ActionDispatcher;
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
use Mautic\CoreBundle\Service\OptimisticLockServiceInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ActionExecutioner implements EventInterface
{
public const TYPE = 'action';
public function __construct(
private ActionDispatcher $dispatcher,
private EventLogger $eventLogger,
private OptimisticLockServiceInterface $optimisticLockService,
#[Autowire(service: 'monolog.logger.mautic')]
private LoggerInterface $logger,
) {
}
/**
* @throws CannotProcessEventException
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException
*/
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
{
\assert($config instanceof ActionAccessor);
/** @var LeadEventLog $firstLog */
if (!$firstLog = $logs->first()) {
return new EvaluatedContacts();
}
$event = $firstLog->getEvent();
if (Event::TYPE_ACTION !== $event->getEventType()) {
throw new CannotProcessEventException('Cannot process event ID '.$event->getId().' as an action.');
}
$this->lockLogs($logs);
// Execute to process the batch of contacts
$pendingEvent = $this->dispatcher->dispatchEvent($config, $event, $logs);
$passed = $this->eventLogger->extractContactsFromLogs($pendingEvent->getSuccessful());
$failed = $this->eventLogger->extractContactsFromLogs($pendingEvent->getFailures());
return new EvaluatedContacts($passed, $failed);
}
/**
* @param Collection<LeadEventLog> $logs
*/
private function lockLogs(Collection $logs): void
{
foreach ($logs as $key => $log) {
if (!$this->optimisticLockService->acquireLock($log)) {
$logs->remove($key);
$this->logger->error(message: sprintf(
'Campaign event log ID "%s" was skipped as it had been executed already.',
$log->getId(),
));
}
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Event;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ConditionAccessor;
use Mautic\CampaignBundle\Executioner\Dispatcher\ConditionDispatcher;
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
use Mautic\CampaignBundle\Executioner\Exception\ConditionFailedException;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
class ConditionExecutioner implements EventInterface
{
public const TYPE = 'condition';
public function __construct(
private ConditionDispatcher $dispatcher,
) {
}
/**
* @throws CannotProcessEventException
*/
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
{
\assert($config instanceof ConditionAccessor);
$evaluatedContacts = new EvaluatedContacts();
/** @var LeadEventLog $log */
foreach ($logs as $log) {
try {
/* @var ConditionAccessor $config */
$this->dispatchEvent($config, $log);
$evaluatedContacts->pass($log->getLead());
} catch (ConditionFailedException) {
$evaluatedContacts->fail($log->getLead());
$log->setNonActionPathTaken(true);
}
// Unschedule the condition and update date triggered timestamp
$log->setDateTriggered(new \DateTime());
}
return $evaluatedContacts;
}
/**
* @throws CannotProcessEventException
* @throws ConditionFailedException
*/
private function dispatchEvent(ConditionAccessor $config, LeadEventLog $log): void
{
if (Event::TYPE_CONDITION !== $log->getEvent()->getEventType()) {
throw new CannotProcessEventException('Cannot process event ID '.$log->getEvent()->getId().' as a condition.');
}
$conditionEvent = $this->dispatcher->dispatchEvent($config, $log);
if (!$conditionEvent->wasConditionSatisfied()) {
throw new ConditionFailedException('evaluation failed');
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Event;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
use Mautic\CampaignBundle\Executioner\Dispatcher\DecisionDispatcher;
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
use Mautic\LeadBundle\Entity\Lead;
class DecisionExecutioner implements EventInterface
{
public const TYPE = 'decision';
public function __construct(
private EventLogger $eventLogger,
private DecisionDispatcher $dispatcher,
) {
}
/**
* @param mixed $passthrough
* @param string|null $channel
* @param int|null $channelId
*
* @throws CannotProcessEventException
* @throws DecisionNotApplicableException
*/
public function evaluateForContact(DecisionAccessor $config, Event $event, Lead $contact, $passthrough = null, $channel = null, $channelId = null): void
{
if (Event::TYPE_DECISION !== $event->getEventType()) {
throw new CannotProcessEventException('Cannot process event ID '.$event->getId().' as a decision.');
}
$log = $this->eventLogger->buildLogEntry($event, $contact);
$log->setChannel($channel);
$log->setChannelId($channelId);
$decisionEvent = $this->dispatcher->dispatchRealTimeEvent($config, $log, $passthrough);
if (!$decisionEvent->wasDecisionApplicable()) {
throw new DecisionNotApplicableException('evaluation failed');
}
$this->eventLogger->persistLog($log);
}
/**
* @throws CannotProcessEventException
*/
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
{
\assert($config instanceof DecisionAccessor);
$evaluatedContacts = new EvaluatedContacts();
$failedLogs = [];
/** @var LeadEventLog $log */
foreach ($logs as $log) {
if (Event::TYPE_DECISION !== $log->getEvent()->getEventType()) {
throw new CannotProcessEventException('Event ID '.$log->getEvent()->getId().' is not a decision');
}
try {
/* @var DecisionAccessor $config */
$this->dispatchEvent($config, $log);
$evaluatedContacts->pass($log->getLead());
// Update the date triggered timestamp
$log->setDateTriggered(new \DateTime());
} catch (DecisionNotApplicableException) {
// Fail the contact but remove the log from being processed upstream
// active/positive/green path while letting the InactiveExecutioner handle the inactive/negative/red paths
$failedLogs[] = $log;
$evaluatedContacts->fail($log->getLead());
}
}
$this->dispatcher->dispatchDecisionResultsEvent($config, $logs, $evaluatedContacts);
// Remove the logs
foreach ($failedLogs as $log) {
$logs->removeElement($log);
}
return $evaluatedContacts;
}
/**
* @throws DecisionNotApplicableException
*/
private function dispatchEvent(DecisionAccessor $config, LeadEventLog $log): void
{
$decisionEvent = $this->dispatcher->dispatchEvaluationEvent($config, $log);
if (!$decisionEvent->wasDecisionApplicable()) {
throw new DecisionNotApplicableException('evaluation failed');
}
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Event;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
interface EventInterface
{
/**
* @return EvaluatedContacts
*/
public function execute(AbstractEventAccessor $config, ArrayCollection $logs);
}

View File

@@ -0,0 +1,426 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\FailedLeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\EventCollector\Accessor\Exception\TypeNotFoundException;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\EventListener\CampaignActionJumpToEventSubscriber;
use Mautic\CampaignBundle\Executioner\Event\ActionExecutioner;
use Mautic\CampaignBundle\Executioner\Event\ConditionExecutioner;
use Mautic\CampaignBundle\Executioner\Event\DecisionExecutioner;
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
use Mautic\CampaignBundle\Executioner\Result\Counter;
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
use Mautic\CampaignBundle\Executioner\Result\Responses;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CampaignBundle\Helper\RemovedContactTracker;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
class EventExecutioner
{
private ?Responses $responses = null;
private \DateTimeInterface $executionDate;
public function __construct(
private EventCollector $collector,
private EventLogger $eventLogger,
private ActionExecutioner $actionExecutioner,
private ConditionExecutioner $conditionExecutioner,
private DecisionExecutioner $decisionExecutioner,
private LoggerInterface $logger,
private EventScheduler $scheduler,
private RemovedContactTracker $removedContactTracker,
) {
// Be sure that all events are compared using the exact same \DateTime
$this->executionDate = new \DateTime();
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function executeForContact(Event $event, Lead $contact, ?Responses $responses = null, ?Counter $counter = null): void
{
if ($responses) {
$this->responses = $responses;
}
$contacts = new ArrayCollection([$contact->getId() => $contact]);
$this->executeForContacts($event, $contacts, $counter);
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function executeEventsForContact(ArrayCollection $events, Lead $contact, ?Responses $responses = null, ?Counter $counter = null): void
{
if ($responses) {
$this->responses = $responses;
}
$contacts = new ArrayCollection([$contact->getId() => $contact]);
$this->executeEventsForContacts($events, $contacts, $counter);
}
/**
* @param ArrayCollection<int,Lead> $contacts
* @param bool $isInactiveEvent
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function executeForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null, $isInactiveEvent = false): void
{
if (!$contacts->count()) {
$this->logger->debug('CAMPAIGN: No contacts to process for event ID '.$event->getId());
return;
}
$config = $this->collector->getEventConfig($event);
$logs = $this->eventLogger->fetchRotationAndGenerateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
$this->executeLogs($event, $logs, $counter);
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function executeLogs(Event $event, ArrayCollection $logs, ?Counter $counter = null): void
{
$this->logger->debug('CAMPAIGN: Executing '.$event->getType().' ID '.$event->getId());
if (!$logs->count()) {
$this->logger->debug('CAMPAIGN: No logs to process for event ID '.$event->getId());
return;
}
$config = $this->collector->getEventConfig($event);
if ($counter) {
// Must pass $counter around rather than setting it as a class property as this class is used
// circularly to process children of parent events thus counter must be kept track separately
$counter->advanceExecuted($logs->count());
}
switch ($event->getEventType()) {
case Event::TYPE_ACTION:
$evaluatedContacts = $this->actionExecutioner->execute($config, $logs);
$this->persistLogs($logs);
$this->executeConditionEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
$this->executeActionEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
break;
case Event::TYPE_CONDITION:
$evaluatedContacts = $this->conditionExecutioner->execute($config, $logs);
$this->persistLogs($logs);
$this->executeBranchedEventsForContacts($event, $evaluatedContacts, $counter);
break;
case Event::TYPE_DECISION:
$evaluatedContacts = $this->decisionExecutioner->execute($config, $logs);
$this->persistLogs($logs);
$this->executePositivePathEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
break;
default:
throw new TypeNotFoundException("{$event->getEventType()} is not a valid event type");
}
}
/**
* @param bool $isInactive
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function executeEventsForContacts(ArrayCollection $events, ArrayCollection $contacts, ?Counter $childrenCounter = null, $isInactive = false): void
{
if (!$contacts->count()) {
return;
}
// Schedule then return those that need to be immediately executed
$executeThese = $this->scheduleEvents($events, $contacts, $childrenCounter, $isInactive);
// Execute non jump-to events normally
$otherEvents = $executeThese->filter(fn (Event $event): bool => CampaignActionJumpToEventSubscriber::EVENT_NAME !== $event->getType());
if ($otherEvents->count()) {
foreach ($otherEvents as $event) {
$this->executeForContacts($event, $contacts, $childrenCounter, $isInactive);
}
}
// Now execute jump to events
$jumpEvents = $executeThese->filter(fn (Event $event): bool => CampaignActionJumpToEventSubscriber::EVENT_NAME === $event->getType());
if ($jumpEvents->count()) {
$jumpLogs = [];
// Create logs for the jump to events before the rotation is incremented
foreach ($jumpEvents as $key => $event) {
$config = $this->collector->getEventConfig($event);
$jumpLogs[$key] = $this->eventLogger->fetchRotationAndGenerateLogsFromContacts($event, $config, $contacts, $isInactive);
}
// Process the jump to events
foreach ($jumpLogs as $key => $logs) {
$this->executeLogs($jumpEvents->get($key), $logs, $childrenCounter);
}
}
}
/**
* @param bool $isInactiveEvent
*/
public function recordLogsAsExecutedForEvent(Event $event, ArrayCollection $contacts, $isInactiveEvent = false): void
{
$config = $this->collector->getEventConfig($event);
$logs = $this->eventLogger->generateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
// Save updated log entries and clear from memory
if (!$logs->isEmpty()) {
$this->eventLogger->persistCollection($logs)
->clearCollection($logs);
}
}
/**
* @param bool $isInactiveEvent
*/
public function recordLogsAsFailedForEvent(Event $event, ArrayCollection $contacts, $reason, $isInactiveEvent = false): void
{
$config = $this->collector->getEventConfig($event);
$logs = $this->eventLogger->generateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
if (!$logs->isEmpty()) {
foreach ($logs as $log) {
$failedLog = new FailedLeadEventLog();
$failedLog->setLog($log)
->setReason($reason);
}
// Save updated log entries and clear from memory
$this->eventLogger->persistCollection($logs)
->clearCollection($logs);
}
}
/**
* @return \DateTimeInterface
*/
public function getExecutionDate()
{
return $this->executionDate;
}
/**
* @param bool $isInactive
*
* @return ArrayCollection
*
* @throws Scheduler\Exception\NotSchedulableException
*/
private function scheduleEvents(ArrayCollection $events, ArrayCollection $contacts, ?Counter $childrenCounter = null, $isInactive = false)
{
$events = clone $events;
foreach ($events as $key => $event) {
// Ignore decisions
if (Event::TYPE_DECISION == $event->getEventType()) {
$this->logger->debug('CAMPAIGN: Ignoring child event ID '.$event->getId().' as a decision');
continue;
}
$executionDate = $this->scheduler->getExecutionDateTime($event, $this->executionDate);
$this->logger->debug(
'CAMPAIGN: Event ID# '.$event->getId().
' to be executed on '.$executionDate->format('Y-m-d H:i:s e')
);
// Check if we need to schedule this if it is not an inactivity check
if (!$isInactive && $this->scheduler->shouldScheduleEvent($event, $executionDate, $this->executionDate)) {
if ($childrenCounter) {
$childrenCounter->advanceTotalScheduled($contacts->count());
}
$this->scheduler->schedule($event, $executionDate, $contacts, $isInactive);
$events->remove($key);
continue;
}
}
return $events;
}
private function persistLogs(ArrayCollection $logs): void
{
if ($this->responses) {
// Extract responses
$this->responses->setFromLogs($logs);
}
$this->checkForRemovedContacts($logs);
// Save updated log entries and clear from memory
$this->eventLogger->persistCollection($logs)
->clearCollection($logs);
}
private function checkForRemovedContacts(ArrayCollection $logs): void
{
/**
* @var int $key
* @var LeadEventLog $log
*/
foreach ($logs as $key => $log) {
// Use the deleted ID if the contact was removed by the delete contact action
$contact = $log->getLead();
$contactId = (!empty($contact->deletedId)) ? $contact->deletedId : $contact->getId();
$campaignId = $log->getCampaign()->getId();
if ($this->removedContactTracker->wasContactRemoved($campaignId, $contactId)) {
$this->logger->debug("CAMPAIGN: Contact ID# $contactId has been removed from campaign ID $campaignId");
$logs->remove($key);
// Clear out removed contacts to prevent a memory leak
$this->removedContactTracker->clearRemovedContact($campaignId, $contactId);
}
}
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeActionEventsForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null): void
{
$childrenCounter = new Counter();
$actions = $event->getChildrenByEventType(Event::TYPE_ACTION);
$childrenCounter->advanceEvaluated($actions->count());
$this->logger->debug('CAMPAIGN: Executing '.$actions->count().' actions under action ID '.$event->getId());
$this->executeEventsForContacts($actions, $contacts, $childrenCounter);
if ($counter) {
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
}
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeConditionEventsForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null): void
{
$childrenCounter = new Counter();
$conditions = $event->getChildrenByEventType(Event::TYPE_CONDITION);
$childrenCounter->advanceEvaluated($conditions->count());
$this->logger->debug('CAMPAIGN: Evaluating '.$conditions->count().' conditions for action ID '.$event->getId());
$this->executeEventsForContacts($conditions, $contacts, $childrenCounter);
if ($counter) {
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
}
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeBranchedEventsForContacts(Event $event, EvaluatedContacts $contacts, ?Counter $counter = null): void
{
$childrenCounter = new Counter();
$this->executePositivePathEventsForContacts($event, $contacts->getPassed(), $childrenCounter);
$this->executeNegativePathEventsForContacts($event, $contacts->getFailed(), $childrenCounter);
if ($counter) {
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
}
}
/**
* @param Counter|null $counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executePositivePathEventsForContacts(Event $event, ArrayCollection $contacts, Counter $counter): void
{
if (!$contacts->count()) {
return;
}
$this->logger->debug('CAMPAIGN: Contact IDs '.implode(',', $contacts->getKeys()).' passed evaluation for event ID '.$event->getId());
$children = $event->getPositiveChildren();
$counter->advanceEvaluated($children->count());
$this->executeEventsForContacts($children, $contacts, $counter);
}
/**
* @param Counter|null $counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeNegativePathEventsForContacts(Event $event, ArrayCollection $contacts, Counter $counter): void
{
if (!$contacts->count()) {
return;
}
$this->logger->debug('CAMPAIGN: Contact IDs '.implode(',', $contacts->getKeys()).' failed evaluation for event ID '.$event->getId());
$children = $event->getNegativeChildren();
$counter->advanceEvaluated($children->count());
$this->executeEventsForContacts($children, $contacts, $counter);
}
/**
* @throws \Doctrine\DBAL\Exception
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function persistSummaries(): void
{
$this->eventLogger->getSummaryModel()->persistSummaries();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class CampaignNotExecutableException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class CannotProcessEventException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class ConditionFailedException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class DecisionNotApplicableException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Executioner\Exception;
class IntervalNotConfiguredException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class NoContactsFoundException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Exception;
class NoEventsFoundException extends \Exception
{
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Symfony\Component\Console\Output\OutputInterface;
interface ExecutionerInterface
{
/**
* @return mixed
*/
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null);
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Executioner\Helper;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
use Mautic\LeadBundle\Entity\Lead;
class DecisionHelper
{
public function __construct(
private LeadRepository $leadRepository,
) {
}
/**
* @throws DecisionNotApplicableException
*/
public function checkIsDecisionApplicableForContact(Event $event, Lead $contact, ?string $channel = null, ?int $channelId = null): void
{
if (Event::TYPE_DECISION !== $event->getEventType()) {
@trigger_error(
"{$event->getType()} is not assigned to a decision and no longer supported. ".
'Check that you are executing RealTimeExecutioner::execute for an event registered as a decision.',
E_USER_DEPRECATED
);
throw new DecisionNotApplicableException("Event {$event->getId()} is not a decision.");
}
// If channels do not match up at all (not even fuzzy logic i.e. page vs page.redirect), there's no need to go further
if ($channel && $event->getChannel() && !str_contains($channel, $event->getChannel())) {
throw new DecisionNotApplicableException("Channels, $channel and {$event->getChannel()}, do not match.");
}
if ($channel && $channelId && $event->getChannelId() && (string) $channelId !== (string) $event->getChannelId()) {
throw new DecisionNotApplicableException("Channel IDs, $channelId and {$event->getChannelId()}, do not match for $channel.");
}
// Check if parent taken path is the path of this event, otherwise exit
$parentEvent = $event->getParent();
if (null !== $parentEvent && !$parentEvent->isDeleted() && null !== $event->getDecisionPath()) {
$rotation = $this->leadRepository->getContactRotations([$contact->getId()], $event->getCampaign()->getId());
$rotationValue = isset($rotation[$contact->getId()]) ? $rotation[$contact->getId()]['rotation'] : null;
$log = $parentEvent->getLogByContactAndRotation($contact, $rotationValue);
if (null === $log) {
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} has not been fired, event {$event->getId()} should not be fired.");
}
$pathTaken = (int) $log->getNonActionPathTaken();
if (1 === $pathTaken && !$parentEvent->getNegativeChildren()->contains($event)) {
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} take negative path, event {$event->getId()} is on positive path.");
} elseif (0 === $pathTaken && !$parentEvent->getPositiveChildren()->contains($event)) {
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} take positive path, event {$event->getId()} is on negative path.");
}
}
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Helper;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\EventRepository;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Executioner\ContactFinder\InactiveContactFinder;
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Psr\Log\LoggerInterface;
class InactiveHelper
{
private ?\DateTimeInterface $earliestInactiveDate = null;
public function __construct(
private EventScheduler $scheduler,
private InactiveContactFinder $inactiveContactFinder,
private LeadEventLogRepository $eventLogRepository,
private EventRepository $eventRepository,
private LoggerInterface $logger,
private DecisionHelper $decisionHelper,
) {
}
/**
* @param ArrayCollection<int, Event> $decisions
*/
public function removeDecisionsWithoutNegativeChildren(ArrayCollection $decisions): void
{
/**
* @var int $key
* @var Event $decision
*/
foreach ($decisions as $key => $decision) {
$negativeChildren = $decision->getNegativeChildren();
if (!$negativeChildren->count()) {
$decisions->remove($key);
}
}
}
/**
* @param int $lastActiveEventId
*
* @throws \Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException
*/
public function removeContactsThatAreNotApplicable(
\DateTime $now,
ArrayCollection $contacts,
$lastActiveEventId,
ArrayCollection $negativeChildren,
Event $event,
): void {
$contactIds = $contacts->getKeys();
$lastActiveDates = $this->getLastActiveDates($lastActiveEventId, $contactIds);
$this->earliestInactiveDate = $now;
foreach ($contactIds as $contactId) {
try {
$this->decisionHelper->checkIsDecisionApplicableForContact($event, $contacts->get($contactId));
} catch (DecisionNotApplicableException $e) {
$this->logger->debug($e->getMessage());
$contacts->remove($contactId);
continue;
}
if (!isset($lastActiveDates[$contactId])) {
// This contact does not have a last active date so likely the event is scheduled
$contacts->remove($contactId);
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' does not have a last active date ('.$lastActiveEventId.')');
continue;
}
$earliestContactInactiveDate = $this->getEarliestInactiveDate($negativeChildren, $lastActiveDates[$contactId]);
$this->logger->debug(
'CAMPAIGN: Earliest date for inactivity for contact ID# '.$contactId.' is '.
$earliestContactInactiveDate->format('Y-m-d H:i:s T').' based on last active date of '.
$lastActiveDates[$contactId]->format('Y-m-d H:i:s T')
);
if ($this->earliestInactiveDate < $earliestContactInactiveDate) {
$this->earliestInactiveDate = $earliestContactInactiveDate;
}
// If any are found to be inactive, we process or schedule all the events associated with the inactive path of a decision
if ($earliestContactInactiveDate > $now) {
$contacts->remove($contactId);
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' has been active and thus not applicable');
continue;
}
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' has not been active');
}
}
/**
* @return \DateTimeInterface
*/
public function getEarliestInactiveDateTime()
{
return $this->earliestInactiveDate;
}
public function getCollectionByDecisionId($decisionId): ArrayCollection
{
$collection = new ArrayCollection();
/** @var Event|null $decision */
$decision = $this->eventRepository->find($decisionId);
if ($decision && !$decision->isDeleted()) {
$collection->set($decision->getId(), $decision);
}
return $collection;
}
/**
* @throws \Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException
*/
public function getEarliestInactiveDate(ArrayCollection $negativeChildren, \DateTimeInterface $lastActiveDate): ?\DateTimeInterface
{
$earliestDate = null;
foreach ($negativeChildren as $event) {
$executionDate = $this->scheduler->getExecutionDateTime($event, $lastActiveDate);
if (!$earliestDate || $executionDate < $earliestDate) {
$earliestDate = $executionDate;
}
}
return $earliestDate;
}
/**
* @return array<string, \DateTimeInterface>|null
*/
private function getLastActiveDates($lastActiveEventId, array $contactIds): ?array
{
// If there is a parent ID, get last active dates based on when that event was executed for the given contact
// Otherwise, use when the contact was added to the campaign for comparison
if ($lastActiveEventId) {
return $this->eventLogRepository->getDatesExecuted($lastActiveEventId, $contactIds);
}
return $this->inactiveContactFinder->getDatesAdded();
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Helper;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\Router;
use Symfony\Contracts\Translation\TranslatorInterface;
class NotificationHelper
{
public function __construct(
private UserModel $userModel,
private NotificationModel $notificationModel,
private TranslatorInterface $translator,
private Router $router,
private CoreParametersHelper $coreParametersHelper,
) {
}
public function notifyOfFailure(Lead $contact, Event $event): void
{
$user = $this->getUser($contact, $event);
if (!$user || !$user->getId()) {
return;
}
$this->notificationModel->addNotification(
$event->getCampaign()->getName().' / '.$event->getName(),
'error',
false,
$this->translator->trans(
'mautic.campaign.event.failed',
[
'%contact%' => '<a href="'.$this->router->generate(
'mautic_contact_action',
['objectAction' => 'view', 'objectId' => $contact->getId()]
).'" data-toggle="ajax">'.$contact->getPrimaryIdentifier().'</a>',
]
),
null,
null,
$user
);
}
public function notifyOfUnpublish(Event $event): void
{
/**
* Pass a fake lead so we can just get the campaign creator.
*/
$user = $this->getUser(new Lead(), $event);
if (!$user || !$user->getId()) {
return;
}
$campaign = $event->getCampaign();
$this->notificationModel->addNotification(
$campaign->getName().' / '.$event->getName(),
'error',
false,
$this->translator->trans(
'mautic.campaign.event.failed.campaign.unpublished',
[
'%campaign%' => '<a href="'.$this->router->generate(
'mautic_campaign_action',
[
'objectAction' => 'view',
'objectId' => $campaign->getId(),
],
UrlGeneratorInterface::ABSOLUTE_URL
).'" data-toggle="ajax">'.$campaign->getName().'</a>',
'%event%' => $event->getName(),
]
),
null,
null,
$user
);
$subject = $this->translator->trans(
'mautic.campaign.event.campaign_unpublished',
[
'%title%' => $campaign->getName(),
]
);
$content = $this->translator->trans(
'mautic.campaign.event.failed.campaign.unpublished',
[
'%campaign%' => '<a href="'.$this->router->generate(
'mautic_campaign_action',
[
'objectAction' => 'view',
'objectId' => $campaign->getId(),
],
UrlGeneratorInterface::ABSOLUTE_URL
).'" data-toggle="ajax">'.$campaign->getName().'</a>',
'%event%' => $event->getName(),
]
);
$sendToAuthor = $this->coreParametersHelper->get('campaign_send_notification_to_author', 1);
if ($sendToAuthor) {
$this->userModel->emailUser($user, $subject, $content);
} else {
$emailAddresses = array_map('trim', explode(',', $this->coreParametersHelper->get('campaign_notification_email_addresses')));
$this->userModel->sendMailToEmailAddresses($emailAddresses, $subject, $content);
}
}
/**
* @return User|null
*/
private function getUser(Lead $contact, Event $event)
{
// Default is to notify the contact owner
if ($owner = $contact->getOwner()) {
return $owner;
}
// If the contact doesn't have an owner, notify the one that created the campaign
if ($campaignCreator = $event->getCampaign()->getCreatedBy()) {
if ($owner = $this->userModel->getEntity($campaignCreator)) {
return $owner;
}
}
// If all else fails, notifiy a system admins
return $this->userModel->getSystemAdministrator();
}
}

View File

@@ -0,0 +1,299 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Executioner\ContactFinder\InactiveContactFinder;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
use Mautic\CampaignBundle\Executioner\Helper\InactiveHelper;
use Mautic\CampaignBundle\Executioner\Result\Counter;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class InactiveExecutioner implements ExecutionerInterface
{
/**
* @var Campaign
*/
private $campaign;
private ?ContactLimiter $limiter = null;
private ?OutputInterface $output = null;
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
private ?Counter $counter = null;
private ?ArrayCollection $decisions = null;
protected ?\DateTime $now = null;
public function __construct(
private InactiveContactFinder $inactiveContactFinder,
private LoggerInterface $logger,
private TranslatorInterface $translator,
private EventScheduler $scheduler,
private InactiveHelper $helper,
private EventExecutioner $executioner,
private ProcessSignalService $processSignalService,
) {
}
/**
* @return Counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
{
$this->campaign = $campaign;
$this->limiter = $limiter;
$this->output = $output ?: new NullOutput();
$this->counter = new Counter();
try {
$this->decisions = $this->campaign->getEventsByType(Event::TYPE_DECISION);
$this->prepareForExecution();
$this->executeEvents();
} catch (NoContactsFoundException) {
$this->logger->debug('CAMPAIGN: No more contacts to process');
} catch (NoEventsFoundException) {
$this->logger->debug('CAMPAIGN: No events to process');
} finally {
if ($this->progressBar) {
$this->progressBar->finish();
}
}
return $this->counter;
}
/**
* @param int $decisionId
*
* @return Counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function validate($decisionId, ContactLimiter $limiter, ?OutputInterface $output = null)
{
$this->limiter = $limiter;
$this->output = $output ?: new NullOutput();
$this->counter = new Counter();
try {
$this->decisions = $this->helper->getCollectionByDecisionId($decisionId);
$this->checkCampaignIsPublished();
$this->prepareForExecution();
$this->executeEvents();
} catch (NoContactsFoundException) {
$this->logger->debug('CAMPAIGN: No more contacts to process');
} catch (NoEventsFoundException) {
$this->logger->debug('CAMPAIGN: No events to process');
} finally {
if ($this->progressBar) {
$this->progressBar->finish();
}
}
return $this->counter;
}
/**
* @throws NoEventsFoundException
*/
private function checkCampaignIsPublished(): void
{
if (!$this->decisions->count()) {
throw new NoEventsFoundException();
}
$this->campaign = $this->decisions->first()->getCampaign();
if (!$this->campaign->isPublished()) {
throw new NoEventsFoundException();
}
if ($this->campaign->isDeleted()) {
throw new NoEventsFoundException();
}
}
/**
* @throws NoContactsFoundException
* @throws NoEventsFoundException
*/
private function prepareForExecution(): void
{
$this->logger->debug('CAMPAIGN: Triggering inaction events');
$this->helper->removeDecisionsWithoutNegativeChildren($this->decisions);
$totalDecisions = $this->decisions->count();
if (!$totalDecisions) {
throw new NoEventsFoundException();
}
$totalContacts = 0;
if (!($this->output instanceof NullOutput)) {
$totalContacts = $this->inactiveContactFinder->getContactCount($this->campaign->getId(), $this->decisions->getKeys(), $this->limiter);
$this->output->writeln(
$this->translator->trans(
'mautic.campaign.trigger.decision_count_analyzed',
[
'%decisions%' => $totalDecisions,
'%leads%' => $totalContacts,
'%batch%' => $this->limiter->getBatchLimit(),
]
)
);
if (!$totalContacts) {
throw new NoContactsFoundException();
}
}
// Approximate total count because the query to fetch contacts will filter out those that have not arrived to this point in the campaign yet
$this->progressBar = ProgressBarHelper::init($this->output, $totalContacts * $totalDecisions);
$this->progressBar->start();
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeEvents(): void
{
// Use the same timestamp across all contacts processed
$now = $this->now ?? new \DateTime();
/** @var Event $decisionEvent */
foreach ($this->decisions as $decisionEvent) {
try {
// We need the parent ID of the decision in order to fetch the time the contact executed this event
$parentEvent = $decisionEvent->getParent();
$parentEventId = $parentEvent && !$parentEvent->isDeleted() ? $parentEvent->getId() : null;
// Ge the first batch of contacts
$contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);
// Loop over all contacts till we've processed all those applicable for this decision
while ($contacts->count()) {
// Get the max contact ID before any are removed
$batchMinContactId = max($contacts->getKeys()) + 1;
$this->progressBar->advance($contacts->count());
$this->counter->advanceEvaluated($contacts->count());
$inactiveEvents = $decisionEvent->getNegativeChildren();
$this->helper->removeContactsThatAreNotApplicable($now, $contacts, $parentEventId, $inactiveEvents, $decisionEvent);
$earliestLastActiveDateTime = $this->helper->getEarliestInactiveDateTime();
$this->logger->debug(
'CAMPAIGN: ('.$decisionEvent->getId().') Earliest date for inactivity for this batch of contacts is '.
$earliestLastActiveDateTime->format('Y-m-d H:i:s T')
);
if ($contacts->count()) {
// Record decision for these contacts
$this->executioner->recordLogsAsExecutedForEvent($decisionEvent, $contacts, true);
// Execute or schedule the events attached to the inactive side of the decision
$this->executeLogsForInactiveEvents($inactiveEvents, $contacts, $this->counter, $earliestLastActiveDateTime);
}
// Clear contacts from memory
$this->inactiveContactFinder->clear($contacts);
if ($this->limiter->getContactId()) {
// No use making another call
break;
}
$this->processSignalService->throwExceptionIfSignalIsCaught();
$this->logger->debug('CAMPAIGN: Fetching the next batch of inactive contacts starting with contact ID '.$batchMinContactId);
$this->limiter->setBatchMinContactId($batchMinContactId);
// Get the next batch, starting with the max contact ID
$contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);
}
} catch (NoContactsFoundException) {
// On to the next decision
$this->logger->debug('CAMPAIGN: No more contacts to process for decision ID #'.$decisionEvent->getId());
}
// Ensure the batch min is reset from the last decision event
$this->limiter->resetBatchMinContactId();
}
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeLogsForInactiveEvents(ArrayCollection $events, ArrayCollection $contacts, Counter $childrenCounter, \DateTimeInterface $earliestLastActiveDateTime): void
{
$events = clone $events;
$eventExecutionDates = $this->scheduler->getSortedExecutionDates($events, $earliestLastActiveDateTime);
/** @var \DateTime $earliestExecutionDate */
$earliestExecutionDate = reset($eventExecutionDates);
$executionDate = $this->executioner->getExecutionDate();
foreach ($events as $key => $event) {
// Ignore decisions
if (Event::TYPE_DECISION == $event->getEventType()) {
$this->logger->debug('CAMPAIGN: Ignoring child event ID '.$event->getId().' as a decision');
$events->remove($key);
continue;
}
$eventExecutionDate = $this->scheduler->getExecutionDateForInactivity(
$eventExecutionDates[$event->getId()],
$earliestExecutionDate,
$executionDate
);
$this->logger->debug(
'CAMPAIGN: Event ID# '.$event->getId().
' to be executed on '.$eventExecutionDate->format('Y-m-d H:i:s e')
);
if ($this->scheduler->shouldScheduleEvent($event, $eventExecutionDate, $executionDate)) {
$childrenCounter->advanceTotalScheduled($contacts->count());
$this->scheduler->schedule($event, $eventExecutionDate, $contacts, true);
$events->remove($key);
continue;
}
}
if ($events->count()) {
$this->executioner->executeEventsForContacts($events, $contacts, $childrenCounter, true);
}
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Executioner\ContactFinder\KickoffContactFinder;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
use Mautic\CampaignBundle\Executioner\Result\Counter;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
use Mautic\CoreBundle\Event\JobExtendTimeEvent;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class KickoffExecutioner implements ExecutionerInterface
{
private ?ContactLimiter $limiter = null;
private ?Campaign $campaign = null;
private ?OutputInterface $output = null;
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
private ?\Doctrine\Common\Collections\ArrayCollection $rootEvents = null;
private ?Counter $counter = null;
public function __construct(
private LoggerInterface $logger,
private KickoffContactFinder $kickoffContactFinder,
private TranslatorInterface $translator,
private EventExecutioner $executioner,
private EventScheduler $scheduler,
private ProcessSignalService $processSignalService,
private CoreParametersHelper $coreParametersHelper,
private EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @return Counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws NotSchedulableException
*/
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
{
$this->campaign = $campaign;
$this->limiter = $limiter;
$this->output = $output ?: new NullOutput();
$this->counter = new Counter();
try {
$this->prepareForExecution();
$this->executeOrScheduleEvent();
} catch (NoContactsFoundException) {
$this->logger->debug('CAMPAIGN: No more contacts to process');
} catch (NoEventsFoundException) {
$this->logger->debug('CAMPAIGN: No events to process');
} finally {
if ($this->progressBar) {
$this->progressBar->finish();
}
if ($this->coreParametersHelper->get('campaign_use_summary')) {
$this->executioner->persistSummaries();
}
}
return $this->counter;
}
/**
* @throws NoEventsFoundException
*/
private function prepareForExecution(): void
{
$this->logger->debug('CAMPAIGN: Triggering kickoff events');
$this->rootEvents = $this->campaign->getRootEvents();
$totalRootEvents = $this->rootEvents->count();
if (!$totalRootEvents) {
throw new NoEventsFoundException();
}
$this->logger->debug('CAMPAIGN: Processing the following events: '.implode(', ', $this->rootEvents->getKeys()));
$totalKickoffEvents = 0;
if (!($this->output instanceof NullOutput)) {
$totalContacts = $this->kickoffContactFinder->getContactCount($this->campaign->getId(), $this->rootEvents->getKeys(), $this->limiter);
$totalKickoffEvents = $totalRootEvents * $totalContacts;
$this->output->writeln(
$this->translator->trans(
'mautic.campaign.trigger.event_count',
[
'%events%' => $totalKickoffEvents,
'%batch%' => $this->limiter->getBatchLimit(),
]
)
);
if (!$totalKickoffEvents) {
throw new NoEventsFoundException();
}
}
$this->progressBar = ProgressBarHelper::init($this->output, $totalKickoffEvents);
$this->progressBar->start();
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws NoContactsFoundException
* @throws NotSchedulableException
*/
private function executeOrScheduleEvent(): void
{
// Use the same timestamp across all contacts processed
$now = new \DateTime();
$this->counter->advanceEventCount($this->rootEvents->count());
// Loop over contacts until the entire campaign is executed
$contacts = $this->kickoffContactFinder->getContacts($this->campaign->getId(), $this->limiter);
while ($contacts && $contacts->count()) {
$batchMinContactId = max($contacts->getKeys()) + 1;
$rootEvents = clone $this->rootEvents;
/** @var Event $event */
foreach ($rootEvents as $key => $event) {
$this->progressBar->advance($contacts->count());
$this->counter->advanceEvaluated($contacts->count());
try {
// Get the date the event would be executed on as if it was based on days only
$executionDate = $this->scheduler->getExecutionDateTime($event, $now);
$this->logger->debug(
'CAMPAIGN: Event ID# '.$event->getId().
' to be executed on '.$executionDate->format('Y-m-d H:i:s e').
' compared to '.$now->format('Y-m-d H:i:s e')
);
// Adjust the hour based on contact timezone if applicable
$this->scheduler->validateAndScheduleEventForContacts($event, $executionDate, $contacts, $now);
$this->counter->advanceTotalScheduled($contacts->count());
$rootEvents->remove($key);
continue;
} catch (NotSchedulableException) {
// Execute the event
}
}
if ($rootEvents->count()) {
// Execute the events for the batch of contacts
$this->executioner->executeEventsForContacts($rootEvents, $contacts, $this->counter);
}
$this->kickoffContactFinder->clear($contacts);
if ($this->limiter->getContactId()) {
// No use making another call
break;
}
$this->processSignalService->throwExceptionIfSignalIsCaught();
$this->logger->debug('CAMPAIGN: Fetching the next batch of kickoff contacts starting with contact ID '.$batchMinContactId);
$this->limiter->setBatchMinContactId($batchMinContactId);
// Get the next batch
$contacts = $this->kickoffContactFinder->getContacts($this->campaign->getId(), $this->limiter);
$this->eventDispatcher->dispatch(new JobExtendTimeEvent());
}
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Logger;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\Helper\ChannelExtractor;
use Mautic\CampaignBundle\Model\SummaryModel;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
class EventLogger
{
private ArrayCollection $persistQueue;
private ArrayCollection $logs;
private array $contactRotations = [];
private int $lastUsedCampaignIdToFetchRotation;
public function __construct(
private IpLookupHelper $ipLookupHelper,
private ContactTracker $contactTracker,
private LeadEventLogRepository $leadEventLogRepository,
private LeadRepository $leadRepository,
private SummaryModel $summaryModel,
private CoreParametersHelper $coreParametersHelper,
) {
$this->persistQueue = new ArrayCollection();
$this->logs = new ArrayCollection();
}
public function queueToPersist(LeadEventLog $log): void
{
$this->persistQueue->add($log);
if ($this->persistQueue->count() >= 20) {
$this->persistPendingAndInsertIntoLogStack();
}
}
public function persistLog(LeadEventLog $log): void
{
$this->leadEventLogRepository->saveEntity($log);
if ($this->coreParametersHelper->get('campaign_use_summary')) {
$this->summaryModel->updateSummary([$log]);
}
}
/**
* @param bool $isInactiveEvent
*/
public function buildLogEntry(Event $event, ?Lead $contact = null, $isInactiveEvent = false): LeadEventLog
{
$log = new LeadEventLog();
if (!defined('MAUTIC_CAMPAIGN_SYSTEM_TRIGGERED')) {
$log->setIpAddress($this->ipLookupHelper->getIpAddress());
}
$log->setEvent($event);
$log->setCampaign($campaign = $event->getCampaign());
if (null === $contact) {
$contact = $this->contactTracker->getContact();
}
$log->setLead($contact);
if ($isInactiveEvent) {
$log->setNonActionPathTaken(true);
}
$log->setDateTriggered(new \DateTime());
$log->setSystemTriggered(defined('MAUTIC_CAMPAIGN_SYSTEM_TRIGGERED'));
if (isset($this->contactRotations[$campaign->getId()][$contact->getId()]) && ($this->lastUsedCampaignIdToFetchRotation === $event->getCampaign()->getId())) {
$log->setRotation($this->contactRotations[$campaign->getId()][$contact->getId()]['rotation']);
} else {
// Likely a single contact handle such as decision processing
$rotations = $this->leadRepository->getContactRotations([$contact->getId()], $event->getCampaign()->getId());
$rotationVal = isset($rotations[$contact->getId()]) ? $rotations[$contact->getId()]['rotation'] : 1;
$log->setRotation($rotationVal);
}
return $log;
}
/**
* Persist the queue, clear the entities from memory, and reset the queue.
*
* @return ArrayCollection
*/
public function persistQueuedLogs()
{
$this->persistPendingAndInsertIntoLogStack();
$logs = clone $this->logs;
$this->logs->clear();
return $logs;
}
public function persistCollection(ArrayCollection $collection): self
{
if (!$collection->count()) {
return $this;
}
$this->leadEventLogRepository->saveEntities($collection->getValues());
if ($this->coreParametersHelper->get('campaign_use_summary')) {
$this->summaryModel->updateSummary($collection->getValues());
}
return $this;
}
public function clearCollection(ArrayCollection $collection): self
{
$this->leadEventLogRepository->detachEntities($collection->getValues());
return $this;
}
public function extractContactsFromLogs(ArrayCollection $logs): ArrayCollection
{
$contacts = new ArrayCollection();
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$contact = $log->getLead();
$contacts->set($contact->getId(), $contact);
}
return $contacts;
}
/**
* @param bool $isInactiveEntry
*
* @return ArrayCollection
*/
public function fetchRotationAndGenerateLogsFromContacts(Event $event, AbstractEventAccessor $config, ArrayCollection $contacts, $isInactiveEntry = false)
{
$this->hydrateContactRotationsForNewLogs($contacts->getKeys(), $event->getCampaign()->getId());
return $this->generateLogsFromContacts($event, $config, $contacts, $isInactiveEntry);
}
/**
* @param bool $isInactiveEntry
*
* @return ArrayCollection
*/
public function generateLogsFromContacts(Event $event, AbstractEventAccessor $config, ArrayCollection $contacts, $isInactiveEntry)
{
$isDecision = Event::TYPE_DECISION === $event->getEventType();
$campaign = $event->getCampaign();
// Ensure each contact has a log entry to prevent them from being picked up again prematurely
foreach ($contacts as $contact) {
if (isset($this->contactRotations[$campaign->getId()][$contact->getId()]) && $this->contactRotations[$campaign->getId()][$contact->getId()]['manually_removed']) {
continue;
}
$log = $this->buildLogEntry($event, $contact, $isInactiveEntry);
$log->setIsScheduled(false);
$log->setDateTriggered(new \DateTime());
ChannelExtractor::setChannel($log, $event, $config);
if ($isDecision) {
// Do not pre-persist decision logs as they must be evaluated first
$this->logs->add($log);
} else {
$this->queueToPersist($log);
}
}
return $this->persistQueuedLogs();
}
/**
* @param int $campaignId
*/
public function hydrateContactRotationsForNewLogs(array $contactIds, $campaignId): void
{
$this->contactRotations[$campaignId] = $this->leadRepository->getContactRotations($contactIds, $campaignId);
$this->lastUsedCampaignIdToFetchRotation = $campaignId;
}
private function persistPendingAndInsertIntoLogStack(): void
{
if (!$this->persistQueue->count()) {
return;
}
$this->leadEventLogRepository->saveEntities($this->persistQueue->getValues());
// Push them into the logs ArrayCollection to be used later.
/** @var LeadEventLog $log */
foreach ($this->persistQueue as $log) {
$this->logs->set($log->getId(), $log);
}
$this->persistQueue->clear();
}
public function getSummaryModel(): SummaryModel
{
return $this->summaryModel;
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\EventRepository;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\Executioner\Event\DecisionExecutioner as Executioner;
use Mautic\CampaignBundle\Executioner\Exception\CampaignNotExecutableException;
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
use Mautic\CampaignBundle\Executioner\Helper\DecisionHelper;
use Mautic\CampaignBundle\Executioner\Result\Responses;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CampaignBundle\Helper\ChannelExtractor;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Psr\Log\LoggerInterface;
class RealTimeExecutioner
{
/**
* @var Lead
*/
private $contact;
/**
* @var array
*/
private $events;
private ?Responses $responses = null;
public function __construct(
private LoggerInterface $logger,
private LeadModel $leadModel,
private EventRepository $eventRepository,
private EventExecutioner $executioner,
private Executioner $decisionExecutioner,
private EventCollector $collector,
private EventScheduler $scheduler,
private ContactTracker $contactTracker,
private DecisionHelper $decisionHelper,
) {
}
/**
* @param string $type
* @param mixed $passthrough
* @param string|null $channel
* @param int|null $channelId
*
* @return Responses
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
public function execute($type, $passthrough = null, $channel = null, $channelId = null)
{
$this->responses = new Responses();
$now = new \DateTime();
$this->logger->debug('CAMPAIGN: Campaign triggered for event type '.$type.'('.$channel.' / '.$channelId.')');
// Kept for BC support although not sure we need this
defined('MAUTIC_CAMPAIGN_NOT_SYSTEM_TRIGGERED') or define('MAUTIC_CAMPAIGN_NOT_SYSTEM_TRIGGERED', 1);
try {
$this->fetchCurrentContact();
} catch (CampaignNotExecutableException $exception) {
$this->logger->debug('CAMPAIGN: '.$exception->getMessage());
return $this->responses;
}
try {
$this->fetchCampaignData($type);
} catch (CampaignNotExecutableException $exception) {
$this->logger->debug('CAMPAIGN: '.$exception->getMessage());
return $this->responses;
}
/** @var Event $event */
foreach ($this->events as $event) {
try {
$this->evaluateDecisionForContact($event, $passthrough, $channel, $channelId);
} catch (DecisionNotApplicableException $exception) {
$this->logger->debug('CAMPAIGN: Event ID '.$event->getId().' is not applicable ('.$exception->getMessage().')');
continue;
}
$children = $event->getPositiveChildren();
if (!$children->count()) {
$this->logger->debug('CAMPAIGN: Event ID '.$event->getId().' has no positive children');
continue;
}
$this->executeAssociatedEvents($children, $now);
}
// Save any changes to the contact done by the listeners
if ($this->contact->getChanges()) {
$this->leadModel->saveEntity($this->contact, false);
}
return $this->responses;
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
*/
private function executeAssociatedEvents(ArrayCollection $children, \DateTime $now): void
{
$children = clone $children;
/** @var Event $child */
foreach ($children as $key => $child) {
$executionDate = $this->scheduler->getExecutionDateTime($child, $now);
$this->logger->debug(
'CAMPAIGN: Event ID# '.$child->getId().
' to be executed on '.$executionDate->format('Y-m-d H:i:s e')
);
if ($this->scheduler->shouldSchedule($executionDate, $now)) {
$this->scheduler->scheduleForContact($child, $executionDate, $this->contact);
$children->remove($key);
}
}
if ($children->count()) {
$this->executioner->executeEventsForContact($children, $this->contact, $this->responses);
}
}
/**
* @param mixed $passthrough
* @param string|null $channel
* @param int|null $channelId
*
* @throws DecisionNotApplicableException
* @throws Exception\CannotProcessEventException
*/
private function evaluateDecisionForContact(Event $event, $passthrough = null, $channel = null, $channelId = null): void
{
$this->logger->debug('CAMPAIGN: Executing '.$event->getType().' ID '.$event->getId().' for contact ID '.$this->contact->getId());
$this->decisionHelper->checkIsDecisionApplicableForContact($event, $this->contact, $channel, $channelId);
/** @var DecisionAccessor $config */
$config = $this->collector->getEventConfig($event);
$this->decisionExecutioner->evaluateForContact($config, $event, $this->contact, $passthrough, $channel, $channelId);
}
/**
* @throws CampaignNotExecutableException
*/
private function fetchCurrentContact(): void
{
$this->contact = $this->contactTracker->getContact();
if (!$this->contact instanceof Lead || !$this->contact->getId()) {
throw new CampaignNotExecutableException('Unidentifiable contact');
}
$this->logger->debug('CAMPAIGN: Current contact ID# '.$this->contact->getId());
}
/**
* @throws CampaignNotExecutableException
*/
private function fetchCampaignData($type): void
{
if (!$this->events = $this->eventRepository->getContactPendingEvents($this->contact->getId(), $type)) {
throw new CampaignNotExecutableException('Contact does not have any applicable '.$type.' associations.');
}
// 2.14 BC break workaround - pre 2.14 had a bug that recorded channelId for decisions as 1 regardless of actually ID
// if channelIdField was an array and only one item was selected. That caused the channel ID check in evaluateDecisionForContact
// to fail resulting in the decision never being evaluated. Therefore we are going to self heal these decisions.
/** @var Event $event */
foreach ($this->events as $event) {
if ('1' === $event->getChannelId()) {
ChannelExtractor::setChannel($event, $event, $this->collector->getEventConfig($event));
$this->eventRepository->saveEntity($event);
}
}
$this->logger->debug('CAMPAIGN: Found '.count($this->events).' events to analyze for contact ID '.$this->contact->getId());
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Result;
class Counter
{
/**
* @param int $eventCount
* @param int $evaluated
* @param int $executed
* @param int $totalEvaluated
* @param int $totalExecuted
* @param int $totalScheduled
*/
public function __construct(
private $eventCount = 0,
private $evaluated = 0,
private $executed = 0,
private $totalEvaluated = 0,
private $totalExecuted = 0,
private $totalScheduled = 0,
) {
}
/**
* @return int
*/
public function getEventCount()
{
return $this->eventCount;
}
/**
* @param int $step
*/
public function advanceEventCount($step = 1): void
{
$this->eventCount += $step;
}
/**
* @return int
*/
public function getEvaluated()
{
return $this->evaluated;
}
/**
* @param int $step
*/
public function advanceEvaluated($step = 1): void
{
$this->evaluated += $step;
$this->totalEvaluated += $step;
}
/**
* @return int
*/
public function getExecuted()
{
return $this->executed;
}
/**
* @param int $step
*/
public function advanceExecuted($step = 1): void
{
$this->executed += $step;
$this->totalExecuted += $step;
}
/**
* Includes all child events (conditions, etc) evaluated.
*
* @return int
*/
public function getTotalEvaluated()
{
return $this->totalEvaluated;
}
/**
* @param int $step
*/
public function advanceTotalEvaluated($step = 1): void
{
$this->totalEvaluated += $step;
}
/**
* Includes all child events (conditions, etc) executed.
*
* @return int
*/
public function getTotalExecuted()
{
return $this->totalExecuted;
}
/**
* @param int $step
*/
public function advanceTotalExecuted($step = 1): void
{
$this->totalExecuted += $step;
}
/**
* @return int
*/
public function getTotalScheduled()
{
return $this->totalScheduled;
}
/**
* @param int $step
*/
public function advanceTotalScheduled($step = 1): void
{
$this->totalScheduled += $step;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Result;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\LeadBundle\Entity\Lead;
class EvaluatedContacts
{
private ArrayCollection $passed;
private ArrayCollection $failed;
public function __construct(?ArrayCollection $passed = null, ?ArrayCollection $failed = null)
{
$this->passed = $passed ?? new ArrayCollection();
$this->failed = $failed ?? new ArrayCollection();
}
public function pass(Lead $contact): void
{
$this->passed->set($contact->getId(), $contact);
}
public function fail(Lead $contact): void
{
$this->failed->set($contact->getId(), $contact);
}
/**
* @return ArrayCollection|Lead[]
*/
public function getPassed()
{
return $this->passed;
}
/**
* @return ArrayCollection|Lead[]
*/
public function getFailed()
{
return $this->failed;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Result;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
class Responses
{
private array $actionResponses = [];
private array $conditionResponses = [];
public function setFromLogs(ArrayCollection $logs): void
{
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$metadata = $log->getMetadata();
$response = $metadata;
if (isset($metadata['timeline']) && 1 === count($metadata)) {
// Legacy listeners set a string in CampaignExecutionEvent::setResult that Lead::appendToMetadata put into
// under a timeline key for BC support. To keep BC for decisions, we have to extract that back out for the bubble
// up responses
$response = $metadata['timeline'];
}
$this->setResponse($log->getEvent(), $response);
}
}
/**
* @param mixed $response
*/
public function setResponse(Event $event, $response): void
{
switch ($event->getEventType()) {
case Event::TYPE_ACTION:
if (!isset($this->actionResponses[$event->getType()])) {
$this->actionResponses[$event->getType()] = [];
}
$this->actionResponses[$event->getType()][$event->getId()] = $response;
break;
case Event::TYPE_CONDITION:
if (!isset($this->conditionResponses[$event->getType()])) {
$this->conditionResponses[$event->getType()] = [];
}
$this->conditionResponses[$event->getType()][$event->getId()] = $response;
break;
}
}
/**
* @param string|null $type
*
* @return array
*/
public function getActionResponses($type = null)
{
if ($type) {
return $this->actionResponses[$type] ?? [];
}
return $this->actionResponses;
}
/**
* @param string|null $type
*
* @return array
*/
public function getConditionResponses($type = null)
{
if ($type) {
return $this->conditionResponses[$type] ?? [];
}
return $this->conditionResponses;
}
public function containsResponses(): int
{
return count($this->actionResponses) + count($this->conditionResponses);
}
}

View File

@@ -0,0 +1,322 @@
<?php
namespace Mautic\CampaignBundle\Executioner;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\EventListener\CampaignActionJumpToEventSubscriber;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\ContactFinder\ScheduledContactFinder;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
use Mautic\CampaignBundle\Executioner\Result\Counter;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ScheduledExecutioner implements ExecutionerInterface, ResetInterface
{
private ?Campaign $campaign = null;
private ?ContactLimiter $limiter = null;
private ?OutputInterface $output = null;
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
private ?array $scheduledEvents = null;
private ?Counter $counter = null;
protected ?\DateTime $now = null;
public function __construct(
private LeadEventLogRepository $repo,
private LoggerInterface $logger,
private TranslatorInterface $translator,
private EventExecutioner $executioner,
private EventScheduler $scheduler,
private ScheduledContactFinder $scheduledContactFinder,
private ProcessSignalService $processSignalService,
) {
}
/**
* @return Counter|mixed
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
* @throws \Doctrine\ORM\Query\QueryException
*/
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
{
$this->campaign = $campaign;
$this->limiter = $limiter;
$this->output = $output ?: new NullOutput();
$this->counter = new Counter();
$this->logger->debug('CAMPAIGN: Triggering scheduled events');
try {
$this->prepareForExecution();
$this->executeOrRescheduleEvent();
} catch (NoEventsFoundException) {
$this->logger->debug('CAMPAIGN: No events to process');
} finally {
if ($this->progressBar) {
$this->progressBar->finish();
}
}
return $this->counter;
}
/**
* @return Counter
*
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
* @throws \Doctrine\ORM\Query\QueryException
*/
public function executeByIds(array $logIds, ?OutputInterface $output = null, ?\DateTime $now = null)
{
$now = $now ?? $this->now ?? new \DateTime();
$this->output = $output ?: new NullOutput();
$this->counter = new Counter();
if (!$logIds) {
return $this->counter;
}
$logs = $this->repo->getScheduledByIds($logIds);
$totalLogsFound = $logs->count();
$this->counter->advanceEvaluated($totalLogsFound);
$this->logger->debug('CAMPAIGN: '.$logs->count().' events scheduled to execute.');
$this->output->writeln(
$this->translator->trans(
'mautic.campaign.trigger.event_count',
[
'%events%' => $totalLogsFound,
'%batch%' => 'n/a',
]
)
);
if (!$logs->count()) {
return $this->counter;
}
$this->progressBar = ProgressBarHelper::init($this->output, $totalLogsFound);
$this->progressBar->start();
$scheduledLogCount = $totalLogsFound - $logs->count();
$this->progressBar->advance($scheduledLogCount);
// Organize the logs by event ID
$organized = $this->organizeByEvent($logs);
foreach ($organized as $organizedLogs) {
/** @var Event $event */
$event = $organizedLogs->first()->getEvent();
// Validate that the schedule is still appropriate
$this->validateSchedule($organizedLogs, $now, true);
// Check that the campaign is published with up/down dates
if ($event->getCampaign()->isPublished()) {
try {
// Hydrate contacts with custom field data
$this->scheduledContactFinder->hydrateContacts($organizedLogs);
$this->executioner->executeLogs($event, $organizedLogs, $this->counter);
} catch (NoContactsFoundException) {
// All of the events were rescheduled
}
}
$this->progressBar->advance($organizedLogs->count());
}
$this->progressBar->finish();
return $this->counter;
}
public function reset(): void
{
$this->now = null;
}
/**
* @throws NoEventsFoundException
*/
private function prepareForExecution(): void
{
$this->now ??= new \DateTime();
// Get counts by event
$scheduledEvents = $this->repo->getScheduledCounts($this->campaign->getId(), $this->now, $this->limiter);
$totalScheduledCount = $scheduledEvents ? array_sum($scheduledEvents) : 0;
$this->scheduledEvents = array_keys($scheduledEvents);
$this->logger->debug('CAMPAIGN: '.$totalScheduledCount.' events scheduled to execute.');
$this->output->writeln(
$this->translator->trans(
'mautic.campaign.trigger.event_count',
[
'%events%' => $totalScheduledCount,
'%batch%' => $this->limiter->getBatchLimit(),
]
)
);
if (!$totalScheduledCount) {
throw new NoEventsFoundException();
}
$this->progressBar = ProgressBarHelper::init($this->output, $totalScheduledCount);
$this->progressBar->start();
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
* @throws \Doctrine\ORM\Query\QueryException
*/
private function executeOrRescheduleEvent(): void
{
// Use the same timestamp across all contacts processed
$now = $this->now ?? new \DateTime();
foreach ($this->scheduledEvents as $eventId) {
$this->counter->advanceEventCount();
// Loop over contacts until the entire campaign is executed
$this->executeScheduled($eventId, $now);
}
}
/**
* @throws Dispatcher\Exception\LogNotProcessedException
* @throws Dispatcher\Exception\LogPassedAndFailedException
* @throws Exception\CannotProcessEventException
* @throws Scheduler\Exception\NotSchedulableException
* @throws \Doctrine\ORM\Query\QueryException
*/
private function executeScheduled($eventId, \DateTime $now): void
{
$logs = $this->repo->getScheduled($eventId, $this->now, $this->limiter);
while ($logs->count()) {
try {
$fetchedContacts = $this->scheduledContactFinder->hydrateContacts($logs);
} catch (NoContactsFoundException) {
break;
}
$event = $logs->first()->getEvent();
$this->progressBar->advance($logs->count());
$this->counter->advanceEvaluated($logs->count());
// Validate that the schedule is still appropriate
$this->validateSchedule($logs, $now);
// Execute if there are any that did not get rescheduled
$this->executioner->executeLogs($event, $logs, $this->counter);
$this->processSignalService->throwExceptionIfSignalIsCaught();
// Get next batch
$this->scheduledContactFinder->clear($fetchedContacts);
$logs = $this->repo->getScheduled($eventId, $this->now, $this->limiter);
}
}
/**
* @param bool $scheduleTogether
*
* @throws Scheduler\Exception\NotSchedulableException
*/
private function validateSchedule(ArrayCollection $logs, \DateTime $now, $scheduleTogether = false): void
{
$toBeRescheduled = new ArrayCollection();
$latestExecutionDate = $now;
// Check if the event should be scheduled (let the schedulers do the debug logging)
/** @var LeadEventLog $log */
foreach ($logs as $key => $log) {
$executionDate = $this->scheduler->validateExecutionDateTime($log, $now);
$this->logger->debug(
'CAMPAIGN: Log ID #'.$log->getID().
' to be executed on '.$executionDate->format('Y-m-d H:i:s e').
' compared to '.$now->format('Y-m-d H:i:s e')
);
if ($this->scheduler->shouldSchedule($executionDate, $now)) {
// The schedule has changed for this event since first scheduled
$this->counter->advanceTotalScheduled();
if ($scheduleTogether) {
$toBeRescheduled->set($key, $log);
if ($executionDate > $latestExecutionDate) {
$latestExecutionDate = $executionDate;
}
} else {
$this->scheduler->reschedule($log, $executionDate);
}
$logs->remove($key);
continue;
}
}
if ($toBeRescheduled->count()) {
$this->scheduler->rescheduleLogs($toBeRescheduled, $latestExecutionDate);
}
}
/**
* @return ArrayCollection[]
*/
private function organizeByEvent(ArrayCollection $logs): array
{
$jumpTo = [];
$other = [];
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$event = $log->getEvent();
$eventType = $event->getType();
if (CampaignActionJumpToEventSubscriber::EVENT_NAME === $eventType) {
if (!isset($jumpTo[$event->getId()])) {
$jumpTo[$event->getId()] = new ArrayCollection();
}
$jumpTo[$event->getId()]->set($log->getId(), $log);
} else {
if (!isset($other[$event->getId()])) {
$other[$event->getId()] = new ArrayCollection();
}
$other[$event->getId()]->set($log->getId(), $log);
}
}
return array_merge($other, $jumpTo);
}
}

View File

@@ -0,0 +1,377 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\ScheduledBatchEvent;
use Mautic\CampaignBundle\Event\ScheduledEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\Executioner\Exception\IntervalNotConfiguredException;
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\DateTime as DateTimeScheduler;
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\Interval as IntervalScheduler;
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\Optimized as OptimizedScheduler;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Service\OptimisticLockServiceInterface;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class EventScheduler
{
public function __construct(
#[Autowire(service: 'monolog.logger.mautic')]
private LoggerInterface $logger,
private EventLogger $eventLogger,
private IntervalScheduler $intervalScheduler,
private DateTimeScheduler $dateTimeScheduler,
private OptimizedScheduler $optimizedScheduler,
private EventCollector $collector,
private EventDispatcherInterface $dispatcher,
private CoreParametersHelper $coreParametersHelper,
private OptimisticLockServiceInterface $optimisticLockService,
) {
}
public function scheduleForContact(Event $event, \DateTimeInterface $executionDate, Lead $contact): void
{
$contacts = new ArrayCollection([$contact]);
$this->schedule($event, $executionDate, $contacts);
}
/**
* @param bool $isInactiveEvent
*/
public function schedule(Event $event, \DateTimeInterface $executionDate, ArrayCollection $contacts, $isInactiveEvent = false): void
{
$config = $this->collector->getEventConfig($event);
// Load the rotations for creating new log entries
$this->eventLogger->hydrateContactRotationsForNewLogs($contacts->getKeys(), $event->getCampaign()->getId());
// If this is relative to a specific hour, process the contacts in batches by contacts' timezone
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
$groupedExecutionDates = $this->intervalScheduler->groupContactsByDate($event, $contacts, $executionDate);
foreach ($groupedExecutionDates as $groupExecutionDateDAO) {
$this->scheduleEventForContacts(
$event,
$config,
$groupExecutionDateDAO->getExecutionDate(),
$groupExecutionDateDAO->getContacts(),
$isInactiveEvent
);
}
return;
}
// Otherwise just schedule as the default
$this->scheduleEventForContacts($event, $config, $executionDate, $contacts, $isInactiveEvent);
}
public function reschedule(LeadEventLog $log, \DateTimeInterface $toBeExecutedOn): void
{
$log->setTriggerDate($toBeExecutedOn);
$this->eventLogger->persistLog($log);
$event = $log->getEvent();
$config = $this->collector->getEventConfig($event);
$this->dispatchScheduledEvent($config, $log, true);
}
/**
* @param ArrayCollection|LeadEventLog[] $logs
*/
public function rescheduleLogs(ArrayCollection $logs, \DateTimeInterface $toBeExecutedOn): void
{
foreach ($logs as $log) {
$log->setTriggerDate($toBeExecutedOn);
}
$this->eventLogger->persistCollection($logs);
$event = $logs->first()->getEvent();
$config = $this->collector->getEventConfig($event);
$this->dispatchBatchScheduledEvent($config, $event, $logs, true);
}
public function rescheduleFailures(ArrayCollection $logs): void
{
if (!$logs->count()) {
return;
}
foreach ($logs as $log) {
try {
$this->reschedule($log, $this->getRescheduleDate($log));
$this->optimisticLockService->resetVersion($log);
} catch (IntervalNotConfiguredException) {
// Do not reschedule if an interval was not configured.
}
}
// Send out a batch event
$event = $logs->first()->getEvent();
$config = $this->collector->getEventConfig($event);
$this->dispatchBatchScheduledEvent($config, $event, $logs, true);
}
/**
* @throws NotSchedulableException
*/
public function getExecutionDateTime(Event $event, ?\DateTimeInterface $compareFromDateTime = null, ?\DateTime $comparedToDateTime = null): \DateTimeInterface
{
if (null === $compareFromDateTime) {
$compareFromDateTime = new \DateTime();
} else {
// Prevent comparisons from modifying original object
$compareFromDateTime = clone $compareFromDateTime;
}
if (null === $comparedToDateTime) {
$comparedToDateTime = clone $compareFromDateTime;
} else {
// Prevent comparisons from modifying original object
$comparedToDateTime = clone $comparedToDateTime;
}
switch ($event->getTriggerMode()) {
case Event::TRIGGER_MODE_IMMEDIATE:
case Event::TRIGGER_MODE_OPTIMIZED:
case null: // decision
$this->logger->debug('CAMPAIGN: ('.$event->getId().') Executing immediately');
return $compareFromDateTime;
case Event::TRIGGER_MODE_INTERVAL:
return $this->intervalScheduler->getExecutionDateTime($event, $compareFromDateTime, $comparedToDateTime);
case Event::TRIGGER_MODE_DATE:
return $this->dateTimeScheduler->getExecutionDateTime($event, $compareFromDateTime, $comparedToDateTime);
}
throw new NotSchedulableException();
}
/**
* @return \DateTimeInterface
*
* @throws NotSchedulableException
*/
public function validateExecutionDateTime(LeadEventLog $log, \DateTime $currentDateTime)
{
if (!$scheduledDateTime = $log->getTriggerDate()) {
throw new NotSchedulableException();
}
$event = $log->getEvent();
switch ($event->getTriggerMode()) {
case Event::TRIGGER_MODE_IMMEDIATE:
case Event::TRIGGER_MODE_OPTIMIZED:
case null: // decision
$this->logger->debug('CAMPAIGN: ('.$event->getId().') Executing immediately');
return $currentDateTime;
case Event::TRIGGER_MODE_INTERVAL:
return $this->intervalScheduler->validateExecutionDateTime($log, $currentDateTime);
case Event::TRIGGER_MODE_DATE:
return $this->dateTimeScheduler->getExecutionDateTime($event, $currentDateTime, $scheduledDateTime);
}
throw new NotSchedulableException();
}
/**
* @param ArrayCollection|Event[] $events
*
* @throws NotSchedulableException
*/
public function getSortedExecutionDates(ArrayCollection $events, \DateTimeInterface $lastActiveDate): array
{
$eventExecutionDates = [];
/** @var Event $child */
foreach ($events as $child) {
$eventExecutionDates[$child->getId()] = $this->getExecutionDateTime($child, $lastActiveDate);
}
uasort(
$eventExecutionDates,
fn (\DateTimeInterface $a, \DateTimeInterface $b): int => $a <=> $b
);
return $eventExecutionDates;
}
public function getExecutionDateForInactivity(\DateTimeInterface $eventExecutionDate, \DateTimeInterface $earliestExecutionDate, \DateTimeInterface $now): \DateTimeInterface
{
if ($eventExecutionDate->getTimestamp() === $earliestExecutionDate->getTimestamp()) {
// Inactivity is based on the "wait" period so execute now
return clone $now;
}
return $eventExecutionDate;
}
public function shouldSchedule(\DateTimeInterface $executionDate, \DateTimeInterface $now): bool
{
// Mainly for functional tests so we don't have to wait minutes but technically can be used in an environment as well if this behavior
// is desired by system admin
if (false === (bool) getenv('CAMPAIGN_EXECUTIONER_SCHEDULER_ACKNOWLEDGE_SECONDS')) {
// Purposively ignore seconds to prevent rescheduling based on a variance of a few seconds
$executionDate = new \DateTime($executionDate->format('Y-m-d H:i'), $executionDate->getTimezone());
$now = new \DateTime($now->format('Y-m-d H:i'), $now->getTimezone());
}
return $executionDate > $now;
}
public function shouldScheduleEvent(Event $event, \DateTimeInterface $executionDate, \DateTimeInterface $now): bool
{
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
// Event has days in week specified. Needs to be recalculated to the next day configured
return true;
}
return $this->shouldSchedule($executionDate, $now);
}
/**
* @throws NotSchedulableException
*/
public function validateAndScheduleEventForContacts(Event $event, \DateTimeInterface $executionDateTime, ArrayCollection $contacts, \DateTimeInterface $comparedFromDateTime): void
{
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
$this->logger->debug(
'CAMPAIGN: Event ID# '.$event->getId().
' has to be scheduled based on contact specific parameters '.
' compared to '.$executionDateTime->format('Y-m-d H:i:s')
);
$groupedExecutionDates = $this->intervalScheduler->groupContactsByDate($event, $contacts, $executionDateTime);
$config = $this->collector->getEventConfig($event);
foreach ($groupedExecutionDates as $groupExecutionDateDAO) {
$this->scheduleEventForContacts(
$event,
$config,
$groupExecutionDateDAO->getExecutionDate(),
$groupExecutionDateDAO->getContacts()
);
}
return;
}
if ($this->shouldSchedule($executionDateTime, $comparedFromDateTime)) {
$this->schedule($event, $executionDateTime, $contacts);
return;
}
throw new NotSchedulableException();
}
/**
* @param bool $isReschedule
*/
private function dispatchScheduledEvent(AbstractEventAccessor $config, LeadEventLog $log, $isReschedule = false): void
{
$this->dispatcher->dispatch(
new ScheduledEvent($config, $log, $isReschedule),
CampaignEvents::ON_EVENT_SCHEDULED
);
}
/**
* @param bool $isReschedule
*/
private function dispatchBatchScheduledEvent(AbstractEventAccessor $config, Event $event, ArrayCollection $logs, $isReschedule = false): void
{
if (!$logs->count()) {
return;
}
$this->dispatcher->dispatch(
new ScheduledBatchEvent($config, $event, $logs, $isReschedule),
CampaignEvents::ON_EVENT_SCHEDULED_BATCH
);
}
/**
* @param bool $isInactiveEvent
*/
private function scheduleEventForContacts(Event $event, AbstractEventAccessor $config, \DateTimeInterface $executionDate, ArrayCollection $contacts, $isInactiveEvent = false): void
{
foreach ($contacts as $contact) {
// Create the entry
$log = $this->eventLogger->buildLogEntry($event, $contact, $isInactiveEvent);
// Determine the execution date based on the trigger mode
if (Event::TRIGGER_MODE_OPTIMIZED === $event->getTriggerMode()) {
$optimizedExecutionDate = $this->optimizedScheduler->getExecutionDateTimeForContact($event, $contact);
$log->setTriggerDate($optimizedExecutionDate);
} else {
// For other trigger modes, use the provided execution date
$log->setTriggerDate($executionDate);
}
// Add it to the queue to persist to the DB
$this->eventLogger->queueToPersist($log);
// lead actively triggered this event, a decision wasn't involved, or it was system triggered and a "no" path so schedule the event to be fired at the defined time
$this->logger->debug(
'CAMPAIGN: '.ucfirst($event->getEventType()).' ID# '.$event->getId().' for contact ID# '.$contact->getId()
.' has timing that is not appropriate and thus scheduled for '.$executionDate->format('Y-m-d H:i:s T')
);
$this->dispatchScheduledEvent($config, $log);
}
// Persist any pending in the queue
$logs = $this->eventLogger->persistQueuedLogs();
// Send out a batch event
$this->dispatchBatchScheduledEvent($config, $event, $logs);
// Update log entries and clear from memory
$this->eventLogger->persistCollection($logs)
->clearCollection($logs);
}
/**
* @throws IntervalNotConfiguredException
*/
private function getRescheduleDate(LeadEventLog $leadEventLog): \DateTimeInterface
{
$rescheduleDate = new \DateTime();
$logInterval = $leadEventLog->getRescheduleInterval();
if ($logInterval) {
return $rescheduleDate->add($logInterval);
}
$defaultIntervalString = $this->coreParametersHelper->get('campaign_time_wait_on_event_false');
if (!$defaultIntervalString) {
throw new IntervalNotConfiguredException('No Interval has been set on the lead event log nor as campaign_time_wait_on_event_false config value.');
}
try {
return $rescheduleDate->add(new \DateInterval($defaultIntervalString));
} catch (\Exception) {
// Bad interval
throw new IntervalNotConfiguredException("'{$defaultIntervalString}' is not valid interval string for campaign_time_wait_on_event_false config key.");
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler\Exception;
class NotSchedulableException extends \Exception
{
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode\DAO;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\LeadBundle\Entity\Lead;
class GroupExecutionDateDAO
{
private ArrayCollection $contacts;
public function __construct(
private \DateTimeInterface $executionDate,
) {
$this->contacts = new ArrayCollection();
}
public function addContact(Lead $contact): void
{
$this->contacts->set($contact->getId(), $contact);
}
/**
* @return \DateTimeInterface
*/
public function getExecutionDate()
{
return $this->executionDate;
}
/**
* @return ArrayCollection
*/
public function getContacts()
{
return $this->contacts;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
use Mautic\CampaignBundle\Entity\Event;
use Psr\Log\LoggerInterface;
class DateTime implements ScheduleModeInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function getExecutionDateTime(Event $event, \DateTimeInterface $compareFromDateTime, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
{
$triggerDate = $event->getTriggerDate();
if (null === $triggerDate) {
$this->logger->debug('CAMPAIGN: Trigger date is null');
return $compareFromDateTime;
}
if ($compareFromDateTime >= $triggerDate) {
$this->logger->debug(
'CAMPAIGN: ('.$event->getId().') Date to execute ('.$triggerDate->format('Y-m-d H:i:s T').') compared to now ('
.$compareFromDateTime->format('Y-m-d H:i:s T').') and is thus overdue'
);
return $compareFromDateTime;
}
return $triggerDate;
}
}

View File

@@ -0,0 +1,360 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\DAO\GroupExecutionDateDAO;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\LoggerInterface;
class Interval implements ScheduleModeInterface
{
public const LOG_DATE_FORMAT = 'Y-m-d H:i:s T';
private ?\DateTimeZone $defaultTimezone = null;
public function __construct(
private LoggerInterface $logger,
private CoreParametersHelper $coreParametersHelper,
) {
}
/**
* @throws NotSchedulableException
*/
public function getExecutionDateTime(Event $event, \DateTimeInterface $compareFromDateTime, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
{
$interval = $event->getTriggerInterval();
$unit = $event->getTriggerIntervalUnit();
try {
$this->logger->debug(
'CAMPAIGN: ('.$event->getId().') Adding interval of '.$interval.$unit.' to '.$comparedToDateTime->format(self::LOG_DATE_FORMAT)
);
/** @var \DateTime $comparedToDateTime */
$comparedToDateTime->add((new DateTimeHelper())->buildInterval($interval, $unit));
} catch (\Exception $exception) {
$this->logger->error('CAMPAIGN: Determining interval scheduled failed with "'.$exception->getMessage().'"');
throw new NotSchedulableException($exception->getMessage());
}
if ($comparedToDateTime > $compareFromDateTime) {
$this->logger->debug(
'CAMPAIGN: ('.$event->getId().') '.$comparedToDateTime->format(self::LOG_DATE_FORMAT).' is later than '
.$compareFromDateTime->format(self::LOG_DATE_FORMAT).' and thus returning '.$comparedToDateTime->format(self::LOG_DATE_FORMAT)
);
// the event is to be scheduled based on the time interval
return $comparedToDateTime;
}
$this->logger->debug(
'CAMPAIGN: ('.$event->getId().') '.$comparedToDateTime->format(self::LOG_DATE_FORMAT).' is earlier than '
.$compareFromDateTime->format(self::LOG_DATE_FORMAT).' and thus returning '.$compareFromDateTime->format(self::LOG_DATE_FORMAT)
);
return $compareFromDateTime;
}
/**
* @return \DateTimeInterface
*
* @throws NotSchedulableException
*/
public function validateExecutionDateTime(LeadEventLog $log, \DateTimeInterface $compareFromDateTime)
{
$event = $log->getEvent();
$dateTriggered = clone $log->getDateTriggered();
if (!$this->isContactSpecificExecutionDateRequired($event)) {
return $this->getExecutionDateTime($event, $compareFromDateTime, $dateTriggered);
}
$interval = $event->getTriggerInterval();
$unit = $event->getTriggerIntervalUnit();
if ($interval && $unit) {
/** @var \DateTime $dateTriggered */
$dateTriggered->add((new DateTimeHelper())->buildInterval($interval, $unit));
}
if ($dateTriggered < $compareFromDateTime) {
$this->logger->debug(
sprintf('CAMPAIGN: (%s) %s is earlier than %s and thus setting %s', $event->getId(), $dateTriggered->format(self::LOG_DATE_FORMAT), $compareFromDateTime->format(self::LOG_DATE_FORMAT), $compareFromDateTime->format(self::LOG_DATE_FORMAT))
);
$dateTriggered = clone $compareFromDateTime;
}
$hour = $event->getTriggerHour();
$startTime = $event->getTriggerRestrictedStartHour();
$endTime = $event->getTriggerRestrictedStopHour();
$dow = $event->getTriggerRestrictedDaysOfWeek();
return $this->getGroupExecutionDateTime($event->getId(), $log->getLead(), $dateTriggered, $hour, $startTime, $endTime, $dow);
}
/**
* @return GroupExecutionDateDAO[]
*/
public function groupContactsByDate(Event $event, ArrayCollection $contacts, \DateTimeInterface $executionDate, ?\DateTimeInterface $compareFromDateTime = null): array
{
$groupedExecutionDates = [];
$hour = $event->getTriggerHour();
$startTime = $event->getTriggerRestrictedStartHour();
$endTime = $event->getTriggerRestrictedStopHour();
$daysOfWeek = $event->getTriggerRestrictedDaysOfWeek();
/** @var Lead $contact */
foreach ($contacts as $contact) {
$groupExecutionDate = $this->getGroupExecutionDateTime(
$event->getId(),
$contact,
$executionDate,
$hour,
$startTime,
$endTime,
$daysOfWeek
);
$key = $groupExecutionDate->format(DateTimeHelper::FORMAT_DB);
if (!isset($groupedExecutionDates[$key])) {
$groupedExecutionDates[$key] = new GroupExecutionDateDAO($groupExecutionDate);
}
$groupedExecutionDates[$key]->addContact($contact);
}
return $groupedExecutionDates;
}
/**
* Checks if an event has a relative time configured.
*/
public function isContactSpecificExecutionDateRequired(Event $event): bool
{
if ($this->isTriggerModeOptimized($event)) {
return true;
}
if (!$this->isTriggerModeInterval($event) || $this->isRestrictedToDailyScheduling($event) || $this->hasTimeRelatedRestrictions($event) || $this->isNegativePath($event)) {
return false;
}
return true;
}
private function isTriggerModeInterval(Event $event): bool
{
return Event::TRIGGER_MODE_INTERVAL === $event->getTriggerMode();
}
private function isTriggerModeOptimized(Event $event): bool
{
return Event::TRIGGER_MODE_OPTIMIZED === $event->getTriggerMode();
}
private function isRestrictedToDailyScheduling(Event $event): bool
{
return !in_array($event->getTriggerIntervalUnit(), ['i', 'h', 'd', 'm', 'y'])
&& empty($event->getTriggerRestrictedDaysOfWeek());
}
private function hasTimeRelatedRestrictions(Event $event): bool
{
return null === $event->getTriggerHour()
&& (null === $event->getTriggerRestrictedStartHour() || null === $event->getTriggerRestrictedStopHour())
&& empty($event->getTriggerRestrictedDaysOfWeek());
}
private function isNegativePath(Event $event): bool
{
if ($event->getParent()) {
return Event::TYPE_DECISION === $event->getParent()->getEventType() && Event::TYPE_ACTION === $event->getEventType() && Event::PATH_INACTION === $event->getDecisionPath();
}
return false;
}
/**
* @return \DateTimeInterface
*/
private function getGroupExecutionDateTime(
$eventId,
Lead $contact,
\DateTimeInterface $compareFromDateTime,
?\DateTimeInterface $hour = null,
?\DateTimeInterface $startTime = null,
?\DateTimeInterface $endTime = null,
array $daysOfWeek = [],
) {
$this->logger->debug(
sprintf('CAMPAIGN: Comparing calculated executed time for event ID %s and contact ID %s with %s', $eventId, $contact->getId(), $compareFromDateTime->format('Y-m-d H:i:s e'))
);
if ($hour) {
$this->logger->debug(
sprintf('CAMPAIGN: Scheduling event ID %s for contact ID %s based on hour of %s', $eventId, $contact->getId(), $hour->format('H:i e'))
);
$groupDateTime = $this->getExecutionDateTimeFromHour($contact, $hour, $eventId, $compareFromDateTime);
} elseif ($startTime && $endTime) {
$this->logger->debug(
sprintf(
'CAMPAIGN: Scheduling event ID %s for contact ID %s based on hour range of %s to %s',
$eventId,
$contact->getId(),
$startTime->format('H:i e'),
$endTime->format('H:i e')
)
);
$groupDateTime = $this->getExecutionDateTimeBetweenHours($contact, $startTime, $endTime, $eventId, $compareFromDateTime);
} else {
$this->logger->debug(
sprintf('CAMPAIGN: Scheduling event ID %s for contact ID %s without hour restrictions.', $eventId, $contact->getId())
);
$groupDateTime = clone $compareFromDateTime;
}
if ([] !== $daysOfWeek) {
$this->logger->debug(
sprintf(
'CAMPAIGN: Scheduling event ID %s for contact ID %s based on DOW restrictions of %s',
$eventId,
$contact->getId(),
implode(',', $daysOfWeek)
)
);
if (in_array(7, $daysOfWeek, true) || in_array('7', $daysOfWeek, true)) {
throw new \LogicException('The Mautic accepts only 0-6 as day of week (0 is Sunday).');
}
// Schedule for the next day of the week if applicable
while (!in_array((int) $groupDateTime->format('w'), $daysOfWeek)) {
/** @var \DateTime $groupDateTime */
$groupDateTime->modify('+1 day');
}
}
return $groupDateTime;
}
/**
* @return \DateTimeInterface
*/
private function getExecutionDateTimeFromHour(Lead $contact, \DateTimeInterface $hour, $eventId, \DateTimeInterface $compareFromDateTime)
{
/** @var \DateTime $groupHour */
$groupHour = clone $hour;
/** @var \DateTime $groupExecutionDate */
$groupExecutionDate = $this->getGroupExecutionDateWithTimeZone($contact, $eventId, $compareFromDateTime);
$groupExecutionDate->setTime((int) $groupExecutionDate->format('H'), (int) $groupExecutionDate->format('i'));
$testGroupHour = clone $groupExecutionDate;
$testGroupHour->setTime($groupHour->format('H'), $groupHour->format('i'));
if ($groupExecutionDate <= $testGroupHour) {
// Schedule for the configured hour today if it's not passed yet.
return $testGroupHour;
}
// Execute rigt away if the hour has passed.
return $groupExecutionDate;
}
/**
* @return \DateTimeInterface
*/
private function getExecutionDateTimeBetweenHours(
Lead $contact,
\DateTimeInterface $startTime,
\DateTimeInterface $endTime,
$eventId,
\DateTimeInterface $compareFromDateTime,
) {
/* @var \DateTime $startTime */
$startTime = clone $startTime;
/* @var \DateTime $endTime */
$endTime = clone $endTime;
if ($endTime < $startTime) {
// End time is after start time so switch them
$tempStartTime = clone $startTime;
$startTime = clone $endTime;
$endTime = clone $tempStartTime;
unset($tempStartTime);
}
/** @var \DateTime $groupExecutionDate */
$groupExecutionDate = $this->getGroupExecutionDateWithTimeZone($contact, $eventId, $compareFromDateTime);
// Is the time between the start and end hours?
$testStartDateTime = clone $groupExecutionDate;
$testStartDateTime->setTime($startTime->format('H'), $startTime->format('i'));
$testStopDateTime = clone $groupExecutionDate;
$testStopDateTime->setTime($endTime->format('H'), $endTime->format('i'));
if ($groupExecutionDate < $testStartDateTime) {
// Too early so set it to the start date
return $testStartDateTime;
}
if ($groupExecutionDate >= $testStopDateTime) {
// Too late so try again tomorrow
$groupExecutionDate->modify('+1 day')->setTime((int) $startTime->format('H'), (int) $startTime->format('i'));
}
return $groupExecutionDate;
}
/**
* @return \DateTimeZone
*/
private function getDefaultTimezone()
{
if ($this->defaultTimezone) {
return $this->defaultTimezone;
}
$this->defaultTimezone = new \DateTimeZone(
$this->coreParametersHelper->getDefaultTimezone()
);
return $this->defaultTimezone;
}
private function getGroupExecutionDateWithTimeZone(Lead $contact, int $eventId, \DateTimeInterface $compareFromDateTime): \DateTimeInterface
{
/** @var \DateTime $groupExecutionDate */
$groupExecutionDate = clone $compareFromDateTime;
$contactTimezone = $this->getDefaultTimezone();
// Set execution to UTC
if ($timezone = $contact->getTimezone()) {
try {
// Set the group's timezone to the contact's
$contactTimezone = new \DateTimeZone($timezone);
$this->logger->debug(
'CAMPAIGN: ('.$eventId.') Setting '.$timezone.' for contact '.$contact->getId()
);
} catch (\Exception) {
// Timezone is not recognized so use the default
$this->logger->debug(
'CAMPAIGN: ('.$eventId.') '.$timezone.' for contact '.$contact->getId().' is not recognized'
);
}
}
$groupExecutionDate->setTimezone($contactTimezone);
return $groupExecutionDate;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Services\PeakInteractionTimer;
class Optimized implements ScheduleModeInterface
{
public const OPTIMIZED_TIME = 0;
public const OPTIMIZED_DAY_AND_TIME = 1;
/** @var string[] */
public const AVAILABLE_FOR_EVENTS = ['email.send', 'message.send', 'plugin.leadpush', 'campaign.sendwebhook'];
public function __construct(
private PeakInteractionTimer $peakInteractionTimer,
) {
}
public function getExecutionDateTime(Event $event, \DateTimeInterface $now, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
{
return $now;
}
public function getExecutionDateTimeForContact(Event $event, Lead $contact): \DateTimeInterface
{
if (self::OPTIMIZED_DAY_AND_TIME === $event->getTriggerWindow()) {
return $this->peakInteractionTimer->getOptimalTimeAndDay($contact);
} else {
return $this->peakInteractionTimer->getOptimalTime($contact);
}
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
use Mautic\CampaignBundle\Entity\Event;
interface ScheduleModeInterface
{
public function getExecutionDateTime(Event $event, \DateTimeInterface $now, \DateTimeInterface $comparedToDateTime): \DateTimeInterface;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Executioner;
use Symfony\Contracts\Service\ResetInterface;
/**
* @internal Used in tests
*/
class TestInactiveExecutioner extends InactiveExecutioner implements ResetInterface
{
/**
* @internal Used in tests
*/
public function setNowTime(\DateTime $dateTime): void
{
$this->now = $dateTime;
}
public function reset(): void
{
$this->now = null;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Executioner;
/**
* @internal Used in tests
*/
class TestScheduledExecutioner extends ScheduledExecutioner
{
/**
* @internal Used in tests
*/
public function setNowTime(\DateTime $dateTime): void
{
$this->now = $dateTime;
}
}