Spaces:
No application file
No application file
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); | |
} | |
} | |
/** | |
* @param ArrayCollection|LeadEventLog[] $logs | |
* @param string $error | |
*/ | |
public function recordLogsWithError(ArrayCollection $logs, $error): void | |
{ | |
foreach ($logs as $log) { | |
$log->appendToMetadata( | |
[ | |
'failed' => 1, | |
'reason' => $error, | |
] | |
); | |
$log->setIsScheduled(false); | |
} | |
// 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(); | |
} | |
} | |