Skip to content

Commit 6ebfbba

Browse files
DPMMA-3118 Segment build time optimization (Segment membership filter) (mautic#15031)
* fix: [DPMMA-3118] segment static filters fix: [DPMMA-3118] breaking change info and cleanup * fix: [DPMMA-3118] phpstan and csfixer * fix: [DPMMA-3118] segment functional tests --------- Co-authored-by: Ruth Cheesley <ruth@ruthcheesley.co.uk>
1 parent 5121f37 commit 6ebfbba

File tree

8 files changed

+297
-307
lines changed

8 files changed

+297
-307
lines changed

UPGRADE-7.0.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
### PHP
5959
- Removed `Mautic\DashboardBundle\Dashboard\Widget::FORMAT_MYSQL` constant. Use `DateTimeHelper::FORMAT_DB_DATE_ONLY` instead.
6060
- Removed `Mautic\ApiBundle\Security\OAuth2\Firewall::OAuthListener` class as it was empty. Use `FOS\OAuthServerBundle\Security\Firewall\OAuthListener` instead.
61+
- Removed `Mautic\LeadBundle\Segment\Query\Filter\SegmentReferenceFilterQueryBuilder` as unused.
6162

6263
### Javascript
6364
- Removed `Mautic.insertTextInEditor` function. Use `Mautic.insertHtmlInEditor` instead.

app/bundles/LeadBundle/Command/UpdateLeadListsCommand.php

Lines changed: 128 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7878
{
7979
$id = $input->getOption('list-id');
8080
$batch = $input->getOption('batch-limit');
81-
$max = $input->getOption('max-contacts');
81+
$max = $input->getOption('max-contacts') ? (int) $input->getOption('max-contacts') : null;
8282
$enableTimeMeasurement = (bool) $input->getOption('timing');
8383
$output = ($input->getOption('quiet')) ? new NullOutput() : $output;
8484
$excludeSegments = $input->getOption('exclude');
8585

86-
if (is_numeric($max)) {
87-
$max = (int) $max;
88-
}
89-
9086
if (!$this->checkRunStatus($input, $output, $id)) {
9187
return \Symfony\Component\Console\Command\Command::SUCCESS;
9288
}
@@ -104,7 +100,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int
104100
return \Symfony\Component\Console\Command\Command::FAILURE;
105101
}
106102

107-
$this->rebuildSegment($list, $batch, $max, $output);
103+
// Track already rebuilt lists to avoid rebuilding them multiple times
104+
$rebuiltLists = [];
105+
106+
// First check if this segment has dependencies and rebuild them
107+
if ($list->hasFilterTypeOf('leadlist')) {
108+
$this->rebuildDependentSegments($list, $rebuiltLists, $batch, $max, $output, $enableTimeMeasurement, [], $excludeSegments);
109+
}
110+
111+
// Add the current list ID to the rebuilt lists to avoid rebuilding it again
112+
$rebuiltLists[] = (int) $list->getId();
113+
114+
$this->rebuildSegment($list, $batch, $max, $output, $enableTimeMeasurement);
108115
} else {
109116
$filter = [
110117
'iterable_mode' => true,
@@ -121,18 +128,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int
121128
],
122129
];
123130
}
124-
$leadLists = $this->listModel->getEntities($filter);
125131

132+
$rebuiltLists = [];
133+
$leadLists = $this->listModel->getEntities($filter);
134+
135+
/** @var LeadList $leadList */
126136
foreach ($leadLists as $leadList) {
127-
$startTimeForSingleSegment = time();
128-
$this->rebuildSegment($leadList, $batch, $max, $output);
129-
if ($enableTimeMeasurement) {
130-
$totalTime = round(microtime(true) - $startTimeForSingleSegment, 2);
131-
$output->writeln('<fg=cyan>'.$this->translator->trans('mautic.lead.list.rebuild.contacts.time', ['%time%' => $totalTime]).'</>'."\n");
137+
$listId = $leadList->getId();
138+
139+
// Skip if already rebuilt
140+
if (in_array($listId, $rebuiltLists)) {
141+
continue;
132142
}
133-
unset($leadList);
143+
144+
// Process any dependent segments first (segments that are used as filters in this segment)
145+
if ($leadList->hasFilterTypeOf('leadlist')) {
146+
$this->rebuildDependentSegments($leadList, $rebuiltLists, $batch, $max, $output, $enableTimeMeasurement, [], $excludeSegments);
147+
}
148+
149+
// Add the current list ID to the rebuilt lists to avoid rebuilding it again
150+
$rebuiltLists[] = $listId;
151+
152+
// Rebuild the current segment
153+
$this->rebuildSegment($leadList, $batch, $max, $output, $enableTimeMeasurement);
134154
}
135-
unset($leadLists);
136155
}
137156

138157
$this->completeRun();
@@ -145,23 +164,103 @@ protected function execute(InputInterface $input, OutputInterface $output): int
145164
return \Symfony\Component\Console\Command\Command::SUCCESS;
146165
}
147166

148-
private function rebuildSegment(LeadList $segment, int $batch, int $max, OutputInterface $output): void
149-
{
150-
if ($segment->isPublished()) {
151-
$output->writeln('<info>'.$this->translator->trans('mautic.lead.list.rebuild.rebuilding', ['%id%' => $segment->getId()]).'</info>');
152-
$startTime = microtime(true);
153-
$processed = $this->listModel->rebuildListLeads($segment, $batch, $max, $output);
154-
$rebuildTime = round(microtime(true) - $startTime, 2);
155-
if (0 >= (int) $max) {
156-
// Only full segment rebuilds count
157-
$segment->setLastBuiltDateToCurrentDatetime();
158-
$segment->setLastBuiltTime($rebuildTime);
159-
$this->listModel->saveEntity($segment);
167+
/**
168+
* @param array<int> $rebuiltLists List of segment IDs that have already been rebuilt
169+
* @param array<int> $dependencyChain Chain of segment IDs to detect circular dependencies
170+
* @param array<int|string> $excludeSegments List of segment IDs to exclude from rebuilding
171+
*
172+
* @param-out array<int> $rebuiltLists Updated list of segment IDs that have been rebuilt
173+
*/
174+
private function rebuildDependentSegments(
175+
LeadList $leadList,
176+
array &$rebuiltLists,
177+
int $batch,
178+
?int $max,
179+
OutputInterface $output,
180+
bool $enableTimeMeasurement,
181+
array $dependencyChain = [],
182+
array $excludeSegments = [],
183+
): void {
184+
// Track the current segment in our dependency chain
185+
$currentId = $leadList->getId();
186+
$dependencyChain[] = $currentId;
187+
188+
foreach ($leadList->getFilters() as $filter) {
189+
if ('leadlist' === $filter['type']) {
190+
foreach ($filter['filter'] ?? [] as $dependentListId) {
191+
$dependentListId = (int) $dependentListId;
192+
193+
// Skip if already rebuilt or in exclude list
194+
if (in_array($dependentListId, $rebuiltLists) || in_array($dependentListId, $excludeSegments)) {
195+
continue;
196+
}
197+
198+
// Check for circular dependency
199+
if (in_array($dependentListId, $dependencyChain)) {
200+
$output->writeln(
201+
'<error>'.$this->translator->trans(
202+
'Circular dependency detected in segment chain: %chain%',
203+
['%chain%' => implode('', array_merge($dependencyChain, [$dependentListId]))]
204+
).'</error>'
205+
);
206+
continue; // Skip this dependency to prevent infinite recursion
207+
}
208+
209+
$dependentLeadList = $this->listModel->getEntity($dependentListId);
210+
if (!$dependentLeadList) {
211+
continue; // Skip if the dependent segment doesn't exist - it may have been deleted
212+
}
213+
214+
// Check if this dependent segment has its own dependencies
215+
if ($dependentLeadList->hasFilterTypeOf('leadlist')) {
216+
// Recursively process this segment's dependencies first, passing the current chain
217+
$this->rebuildDependentSegments(
218+
$dependentLeadList,
219+
$rebuiltLists,
220+
$batch,
221+
$max,
222+
$output,
223+
$enableTimeMeasurement,
224+
$dependencyChain,
225+
$excludeSegments
226+
);
227+
}
228+
229+
// Now rebuild this dependent segment
230+
$this->rebuildSegment($dependentLeadList, $batch, $max, $output, $enableTimeMeasurement);
231+
$rebuiltLists[] = $dependentListId;
232+
}
160233
}
234+
}
235+
}
161236

162-
$output->writeln(
163-
'<comment>'.$this->translator->trans('mautic.lead.list.rebuild.leads_affected', ['%leads%' => $processed]).'</comment>'
164-
);
237+
private function rebuildSegment(LeadList $segment, int $batch, ?int $max, OutputInterface $output, bool $enableTimeMeasurement = false): void
238+
{
239+
if (!$segment->isPublished()) {
240+
return;
241+
}
242+
243+
$output->writeln('<info>'.$this->translator->trans('mautic.lead.list.rebuild.rebuilding', ['%id%' => $segment->getId()]).'</info>');
244+
$startTime = microtime(true);
245+
$processed = $this->listModel->rebuildListLeads($segment, $batch, $max, $output);
246+
$rebuildTime = round(microtime(true) - $startTime, 2);
247+
248+
if (0 >= (int) $max) {
249+
// Only full segment rebuilds count
250+
$segment->setLastBuiltDateToCurrentDatetime();
251+
$segment->setLastBuiltTime($rebuildTime);
252+
$this->listModel->saveEntity($segment);
253+
}
254+
255+
$output->writeln(
256+
'<comment>'.$this->translator->trans('mautic.lead.list.rebuild.leads_affected', ['%leads%' => $processed]).'</comment>'
257+
);
258+
259+
if ($enableTimeMeasurement) {
260+
$output->writeln('<fg=cyan>'.$this->translator->trans(
261+
'mautic.lead.list.rebuild.contacts.time',
262+
['%time%' => $rebuildTime]
263+
).'</>'."\n");
165264
}
166265
}
167266
}

app/bundles/LeadBundle/Config/config.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -797,16 +797,6 @@
797797
'class' => Mautic\LeadBundle\Segment\Query\Filter\ComplexRelationValueFilterQueryBuilder::class,
798798
'arguments' => ['mautic.lead.model.random_parameter_name', 'event_dispatcher'],
799799
],
800-
'mautic.lead.query.builder.special.leadlist' => [
801-
'class' => Mautic\LeadBundle\Segment\Query\Filter\SegmentReferenceFilterQueryBuilder::class,
802-
'arguments' => [
803-
'mautic.lead.model.random_parameter_name',
804-
'mautic.lead.repository.lead_segment_query_builder',
805-
'doctrine.orm.entity_manager',
806-
'mautic.lead.model.lead_segment_filter_factory',
807-
'event_dispatcher',
808-
],
809-
],
810800
'mautic.lead.query.builder.channel_click.value' => [
811801
'class' => Mautic\LeadBundle\Segment\Query\Filter\ChannelClickQueryBuilder::class,
812802
'arguments' => [

app/bundles/LeadBundle/Segment/Query/Filter/SegmentReferenceFilterQueryBuilder.php

Lines changed: 0 additions & 104 deletions
This file was deleted.

app/bundles/LeadBundle/Services/ContactSegmentFilterDictionary.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
use Mautic\LeadBundle\Segment\Query\Filter\ForeignFuncFilterQueryBuilder;
1212
use Mautic\LeadBundle\Segment\Query\Filter\ForeignValueFilterQueryBuilder;
1313
use Mautic\LeadBundle\Segment\Query\Filter\IntegrationCampaignFilterQueryBuilder;
14-
use Mautic\LeadBundle\Segment\Query\Filter\SegmentReferenceFilterQueryBuilder;
1514
use Mautic\LeadBundle\Segment\Query\Filter\SessionsFilterQueryBuilder;
1615
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1716

@@ -137,7 +136,10 @@ private function setDefaultFilters(): void
137136
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
138137
];
139138
$this->filters['leadlist'] = [
140-
'type' => SegmentReferenceFilterQueryBuilder::getServiceId(),
139+
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
140+
'foreign_table' => 'lead_lists_leads',
141+
'field' => 'leadlist_id',
142+
'where' => 'lead_lists_leads.manually_removed = 0',
141143
];
142144
$this->filters['globalcategory'] = [
143145
'type' => ForeignValueFilterQueryBuilder::getServiceId(),

0 commit comments

Comments
 (0)