This is an automated email from the ASF dual-hosted git repository.
hubgeter pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 6a4284e5ce7 [fix](mtmv): Re-prune Iceberg scans after partition
compensation (#69678)
6a4284e5ce7 is described below
commit 6a4284e5ce70c4541369a22c4b0210f9a4ca7cf9
Author: daidai <[email protected]>
AuthorDate: Fri Oct 30 14:35:52 2026 +0800
[fix](mtmv): Re-prune Iceberg scans after partition compensation (#69678)
### What problem does this PR solve?
Problem Summary:
Iceberg materialized-view partition compensation retained the stale
partition-pruning state when deep-copying a file scan. The compensation
branch therefore scanned all old partitions, causing nested UNIONs, Memo
expansion, and fallback to a full Iceberg scan. Invalidate the copied
scan's pruning state so the missing-partition predicate is applied
again
---
.../rules/rewrite/PruneFileScanPartition.java | 17 +++-
.../nereids/trees/copier/DeepCopierContext.java | 2 +-
.../trees/copier/LogicalPlanDeepCopier.java | 3 +
.../trees/plans/logical/LogicalFileScan.java | 21 ++++
.../mvcc/PluginDrivenMvccExternalTableTest.java | 108 +++++++++++++++++++++
.../trees/copier/LogicalPlanDeepCopierTest.java | 35 +++++++
regression-test/data/mtmv_p0/test_iceberg_mtmv.out | 6 +-
.../suites/mtmv_p0/test_iceberg_mtmv.groovy | 15 +++
8 files changed, 200 insertions(+), 7 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java
index 4ee20db8c1c..50b808b4509 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java
@@ -124,9 +124,14 @@ public class PruneFileScanPartition extends
OneRewriteRuleFactory {
boolean enableBinarySearch = ctx.getConnectContext() == null
||
ctx.getConnectContext().getSessionVariable().enableBinarySearchFilteringPartitions;
if (enableBinarySearch && !nameToPartitionItem.isEmpty()) {
- sortedPartitionRanges =
scan.getSelectedPartitions().sortedPartitionRanges
- .or(() -> (Optional)
externalTable.getSortedPartitionRanges(scan))
- .or(() ->
Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem)));
+ if (nameToPartitionItem.size() <
scan.getSelectedPartitions().totalPartitionNum) {
+ sortedPartitionRanges = Optional.ofNullable(
+ SortedPartitionRanges.build(nameToPartitionItem));
+ } else {
+ sortedPartitionRanges =
scan.getSelectedPartitions().sortedPartitionRanges
+ .or(() -> (Optional)
externalTable.getSortedPartitionRanges(scan))
+ .or(() ->
Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem)));
+ }
}
PartitionPruneResult<String> result = PartitionPruner.pruneWithResult(
partitionSlots, filter.getPredicate(), nameToPartitionItem,
ctx,
@@ -141,7 +146,9 @@ public class PruneFileScanPartition extends
OneRewriteRuleFactory {
"pruned partition %s is missing in the selected partitions
snapshot", name);
selectedPartitionItems.put(name, item);
}
- return new SelectedPartitions(nameToPartitionItem.size(),
selectedPartitionItems, true,
- result.hasPartitionPredicate);
+ boolean hasPartitionPredicate = result.hasPartitionPredicate
+ || scan.getSelectedPartitions().hasPartitionPredicate;
+ return new
SelectedPartitions(scan.getSelectedPartitions().totalPartitionNum,
selectedPartitionItems, true,
+ hasPartitionPredicate);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/DeepCopierContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/DeepCopierContext.java
index b898a08f7f1..5bce27558c9 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/DeepCopierContext.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/DeepCopierContext.java
@@ -39,7 +39,7 @@ public class DeepCopierContext {
* to ensure they keep same after deep copy
*/
private final Map<RelationId, LogicalRelation> relationReplaceMap =
Maps.newHashMap();
- /** Whether copied OLAP scans should be marked for partition pruning
again. */
+ /** Whether copied catalog scans should be marked for partition pruning
again. */
private boolean invalidatePartitionPruning = false;
public void setInvalidatePartitionPruning(boolean
invalidatePartitionPruning) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopier.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopier.java
index 500972b2e39..896c376d573 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopier.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopier.java
@@ -39,6 +39,7 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalCTEProducer;
import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalExcept;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
import org.apache.doris.nereids.trees.plans.logical.LogicalGenerate;
import org.apache.doris.nereids.trees.plans.logical.LogicalHaving;
@@ -110,6 +111,8 @@ public class LogicalPlanDeepCopier extends
DefaultPlanRewriter<DeepCopierContext
catalogRelation.withRelationId(StatementScopeIdGenerator.newRelationId());
if (context.shouldInvalidatePartitionPruning() && newRelation
instanceof LogicalOlapScan) {
newRelation = ((LogicalOlapScan)
newRelation).withPartitionPruned(false);
+ } else if (context.shouldInvalidatePartitionPruning() && newRelation
instanceof LogicalFileScan) {
+ newRelation = ((LogicalFileScan)
newRelation).withPartitionPruned(false);
}
updateReplaceMapWithOutput(catalogRelation, newRelation,
context.exprIdReplaceMap);
if (!catalogRelation.getVirtualColumns().isEmpty()) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
index 2dc541f5a45..5103334f8bf 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
@@ -172,6 +172,16 @@ public class LogicalFileScan extends
LogicalCatalogRelation implements SupportPr
cachedOutputs));
}
+ /**
+ * Return a new scan with the specified partition pruning state.
+ */
+ public LogicalFileScan withPartitionPruned(boolean isPruned) {
+ if (selectedPartitions.isNativePlanning() ||
selectedPartitions.isPruned == isPruned) {
+ return this;
+ }
+ return withSelectedPartitions(selectedPartitions.withPruned(isPruned));
+ }
+
@Override
public LogicalFileScan withRelationId(RelationId relationId) {
return AbstractPlan.copyWithSameId(this, () ->
@@ -378,6 +388,17 @@ public class LogicalFileScan extends
LogicalCatalogRelation implements SupportPr
return nativePlanning;
}
+ /**
+ * Return a copy with the specified partition pruning state.
+ */
+ public SelectedPartitions withPruned(boolean isPruned) {
+ if (nativePlanning || this.isPruned == isPruned) {
+ return this;
+ }
+ return new SelectedPartitions(totalPartitionNum,
selectedPartitions, isPruned,
+ hasPartitionPredicate, sortedPartitionRanges,
nativePlanning);
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
index 062af920cf9..b6421f6300d 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
@@ -65,11 +65,14 @@ import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.properties.PhysicalProperties;
import org.apache.doris.nereids.rules.rewrite.PruneFileScanPartition;
import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Or;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.expressions.literal.DateV2Literal;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
+import
org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions;
import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
import org.apache.doris.nereids.types.DateV2Type;
import org.apache.doris.qe.ConnectContext;
@@ -156,6 +159,111 @@ public class PluginDrivenMvccExternalTableTest {
});
}
+ @Test
+ public void repeatedFileScanPruningPreservesPartitionPredicateEvidence() {
+ Fixture f = Fixture.partitioned();
+ MvccSnapshot pin = f.table.loadSnapshot(Optional.empty(),
Optional.empty());
+ Map<String, PartitionItem> pinnedPartitions =
f.table.getNameToPartitionItems(Optional.of(pin));
+ withContextSnapshot(f.table, pin, () -> {
+ SlotReference slot = new SlotReference("dt", DateV2Type.INSTANCE);
+ DateV2Literal predicateDate = new DateV2Literal(2024, 1, 1);
+ LogicalFileScan scan = new LogicalFileScan(new RelationId(12),
f.table,
+ Collections.singletonList("db"), Collections.emptyList(),
Optional.empty(),
+ Optional.empty(), Optional.empty(),
Optional.of(Collections.singletonList(slot)));
+ Map<String, PartitionItem> retained = Collections.singletonMap(
+ "dt=2024-01-01", pinnedPartitions.get("dt=2024-01-01"));
+ scan = scan.withSelectedPartitions(new SelectedPartitions(
+ scan.getSelectedPartitions().totalPartitionNum, retained,
false, true));
+ LogicalFilter<?> filter = new LogicalFilter<>(
+ Collections.singleton(new EqualTo(slot, predicateDate)),
scan);
+ CascadesContext context = CascadesContext.initContext(
+ ConnectContext.get().getStatementContext(), filter,
PhysicalProperties.ANY);
+
+ Plan rewritten = new
PruneFileScanPartition().build().transform(filter, context).get(0);
+ LogicalFileScan result = (LogicalFileScan) rewritten.child(0);
+
+ Assertions.assertEquals(Collections.singleton("dt=2024-01-01"),
+
result.getSelectedPartitions().selectedPartitions.keySet());
+ Assertions.assertTrue(result.hasPartitionPredicate());
+ Assertions.assertEquals(2,
result.getSelectedPartitions().totalPartitionNum);
+ });
+ }
+
+ @Test
+ public void repeatedFileScanPruningRestrictsIndexToRetainedSelection() {
+ Fixture f = Fixture.with(Arrays.asList(
+ cpi("dt=2024-01-01", TS_2024_01_01),
+ cpi("dt=2024-01-02", TS_2024_01_01 + 1),
+ cpi("dt=2024-02-02", TS_2024_02_02)));
+ MvccSnapshot pin = f.table.loadSnapshot(Optional.empty(),
Optional.empty());
+ Map<String, PartitionItem> pinnedPartitions =
f.table.getNameToPartitionItems(Optional.of(pin));
+ withContextSnapshot(f.table, pin, () -> {
+
ConnectContext.get().getSessionVariable().enableBinarySearchFilteringPartitions
= true;
+ SlotReference slot = new SlotReference("dt", DateV2Type.INSTANCE);
+ LogicalFileScan scan = new LogicalFileScan(new RelationId(13),
f.table,
+ Collections.singletonList("db"), Collections.emptyList(),
Optional.empty(),
+ Optional.empty(), Optional.empty(),
Optional.of(Collections.singletonList(slot)));
+ Map<String, PartitionItem> retained = new HashMap<>();
+ retained.put("dt=2024-01-01",
pinnedPartitions.get("dt=2024-01-01"));
+ retained.put("dt=2024-02-02",
pinnedPartitions.get("dt=2024-02-02"));
+ scan = scan.withSelectedPartitions(new SelectedPartitions(
+ scan.getSelectedPartitions().totalPartitionNum, retained,
false, true));
+ Expression predicate = new Or(
+ new EqualTo(slot, new DateV2Literal(2024, 1, 2)),
+ new EqualTo(slot, new DateV2Literal(2024, 2, 2)));
+ LogicalFilter<?> filter = new
LogicalFilter<>(Collections.singleton(predicate), scan);
+ CascadesContext context = CascadesContext.initContext(
+ ConnectContext.get().getStatementContext(), filter,
PhysicalProperties.ANY);
+
+ Plan rewritten = new
PruneFileScanPartition().build().transform(filter, context).get(0);
+ LogicalFileScan result = (LogicalFileScan) rewritten.child(0);
+
+ Assertions.assertEquals(Collections.singleton("dt=2024-02-02"),
+
result.getSelectedPartitions().selectedPartitions.keySet());
+ Assertions.assertEquals(3,
result.getSelectedPartitions().totalPartitionNum);
+
+ LogicalFileScan secondCopy = result.withPartitionPruned(false);
+ Expression secondPredicate = new Or(
+ new EqualTo(slot, new DateV2Literal(2024, 1, 1)),
+ new EqualTo(slot, new DateV2Literal(2024, 1, 2)));
+ LogicalFilter<?> secondFilter = new LogicalFilter<>(
+ Collections.singleton(secondPredicate), secondCopy);
+ CascadesContext secondContext = CascadesContext.initContext(
+ ConnectContext.get().getStatementContext(), secondFilter,
PhysicalProperties.ANY);
+ Plan secondRewritten =
+ new
PruneFileScanPartition().build().transform(secondFilter, secondContext).get(0);
+ LogicalFileScan secondResult = (LogicalFileScan)
secondRewritten.child(0);
+
+
Assertions.assertTrue(secondResult.getSelectedPartitions().selectedPartitions.isEmpty());
+ Assertions.assertEquals(3,
secondResult.getSelectedPartitions().totalPartitionNum);
+ });
+ }
+
+ @Test
+ public void repeatedFileScanPruningPreservesUniverseForEmptySelection() {
+ Fixture f = Fixture.partitioned();
+ MvccSnapshot pin = f.table.loadSnapshot(Optional.empty(),
Optional.empty());
+ withContextSnapshot(f.table, pin, () -> {
+ SlotReference slot = new SlotReference("dt", DateV2Type.INSTANCE);
+ LogicalFileScan scan = new LogicalFileScan(new RelationId(14),
f.table,
+ Collections.singletonList("db"), Collections.emptyList(),
Optional.empty(),
+ Optional.empty(), Optional.empty(),
Optional.of(Collections.singletonList(slot)));
+ scan = scan.withSelectedPartitions(new SelectedPartitions(
+ scan.getSelectedPartitions().totalPartitionNum,
Collections.emptyMap(), false, true));
+ LogicalFilter<?> filter = new LogicalFilter<>(
+ Collections.singleton(new EqualTo(slot, new
DateV2Literal(2024, 1, 1))), scan);
+ CascadesContext context = CascadesContext.initContext(
+ ConnectContext.get().getStatementContext(), filter,
PhysicalProperties.ANY);
+
+ Plan rewritten = new
PruneFileScanPartition().build().transform(filter, context).get(0);
+ LogicalFileScan result = (LogicalFileScan) rewritten.child(0);
+
+
Assertions.assertTrue(result.getSelectedPartitions().selectedPartitions.isEmpty());
+ Assertions.assertEquals(2,
result.getSelectedPartitions().totalPartitionNum);
+ Assertions.assertTrue(result.hasPartitionPredicate());
+ });
+ }
+
@Test
public void sourceColumnPruningUsesTheRetainedPartitionView() {
Fixture f = Fixture.partitioned();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopierTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopierTest.java
index e740d35349b..ca37fded013 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopierTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/copier/LogicalPlanDeepCopierTest.java
@@ -17,7 +17,9 @@
package org.apache.doris.nereids.trees.copier;
+import org.apache.doris.catalog.PartitionItem;
import org.apache.doris.catalog.SchemaTable;
+import org.apache.doris.datasource.ExternalTable;
import org.apache.doris.nereids.properties.OrderKey;
import org.apache.doris.nereids.trees.expressions.Add;
import org.apache.doris.nereids.trees.expressions.Alias;
@@ -32,17 +34,24 @@ import
org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.algebra.Repeat.RepeatType;
import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
+import
org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions;
import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
import org.apache.doris.nereids.trees.plans.logical.LogicalRepeat;
import org.apache.doris.nereids.trees.plans.logical.LogicalSchemaScan;
import org.apache.doris.nereids.types.BigIntType;
+import org.apache.doris.nereids.types.IntegerType;
import org.apache.doris.nereids.util.PlanConstructor;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -89,6 +98,32 @@ public class LogicalPlanDeepCopierTest {
Assertions.assertTrue(copiedPlan.hasPartitionPredicate());
}
+ @Test
+ public void testDeepCopyFileScanInvalidatesPartitionPruning() {
+ ExternalTable table = Mockito.mock(ExternalTable.class);
+ Map<String, PartitionItem> partitionItems = ImmutableMap.of(
+ "p1", Mockito.mock(PartitionItem.class));
+ SelectedPartitions selectedPartitions = new SelectedPartitions(2,
partitionItems, true, true);
+
Mockito.when(table.initSelectedPartitions(Mockito.any())).thenReturn(selectedPartitions);
+ Slot output = new SlotReference("p1", IntegerType.INSTANCE, true);
+ LogicalFileScan relationPlan = new
LogicalFileScan(PlanConstructor.getNextRelationId(), table,
+ ImmutableList.of("db"), Collections.emptyList(),
Optional.empty(), Optional.empty(),
+ Optional.empty(), Optional.of(ImmutableList.<Slot>of(output)));
+ DeepCopierContext context = new DeepCopierContext();
+ context.setInvalidatePartitionPruning(true);
+
+ LogicalFileScan copiedPlan =
+ (LogicalFileScan)
relationPlan.accept(LogicalPlanDeepCopier.INSTANCE, context);
+
+ Assertions.assertTrue(relationPlan.getSelectedPartitions().isPruned);
+ Assertions.assertFalse(copiedPlan.getSelectedPartitions().isPruned);
+ Assertions.assertTrue(copiedPlan.hasPartitionPredicate());
+
Assertions.assertEquals(relationPlan.getSelectedPartitions().totalPartitionNum,
+ copiedPlan.getSelectedPartitions().totalPartitionNum);
+
Assertions.assertEquals(relationPlan.getSelectedPartitions().selectedPartitions.keySet(),
+
copiedPlan.getSelectedPartitions().selectedPartitions.keySet());
+ }
+
@Test
public void testDeepCopyOlapScanWithVirtualColumn() {
LogicalOlapScan scan = PlanConstructor.newLogicalOlapScan(0, "a", 0);
diff --git a/regression-test/data/mtmv_p0/test_iceberg_mtmv.out
b/regression-test/data/mtmv_p0/test_iceberg_mtmv.out
index 666a587c990..99373f8fd56 100644
--- a/regression-test/data/mtmv_p0/test_iceberg_mtmv.out
+++ b/regression-test/data/mtmv_p0/test_iceberg_mtmv.out
@@ -129,6 +129,11 @@
2024-01-01T00:00 4
2024-01-02T00:00 3
+-- !new_source_partition_rewrite --
+2024-01-01T00:00 4
+2024-01-02T00:00 3
+2024-01-03T00:00 2
+
-- !evolution2 --
{"ts_month":null, "ts_day":"2024-12-03"}
{"ts_month":657, "ts_day":null}
@@ -176,4 +181,3 @@
-- !test6 --
{"create_date1_month":647, "create_date2_year":null}
{"create_date1_month":647, "create_date2_year":53}
-
diff --git a/regression-test/suites/mtmv_p0/test_iceberg_mtmv.groovy
b/regression-test/suites/mtmv_p0/test_iceberg_mtmv.groovy
index b73b3c0b5eb..13767b6d255 100644
--- a/regression-test/suites/mtmv_p0/test_iceberg_mtmv.groovy
+++ b/regression-test/suites/mtmv_p0/test_iceberg_mtmv.groovy
@@ -263,6 +263,21 @@ suite("test_iceberg_mtmv",
"p0,external,iceberg,external_docker,external_docker_
order_qt_refresh_all_partition_rewrite "${mvSql}"
mv_rewrite_success("${mvSql}", "${mvName}")
+ // A new source partition must use partition compensation even when
the MV was previously complete.
+ sql """
+ insert into ${catalog_name}.${icebergDb}.${icebergTable3}
+ values (1, 3, "2024-01-03"), (2, 3, "2024-01-03")
+ """
+ sql """refresh table ${catalog_name}.${icebergDb}.${icebergTable3}"""
+ def showPartitionsAfterAdd = sql """show partitions from ${mvName}"""
+
assertFalse(showPartitionsAfterAdd.toString().contains("p_20240103000000_20240104000000"))
+ def explainNewPartition = sql """explain ${mvSql}"""
+ logger.info("explainNewPartition: " + explainNewPartition.toString())
+ assertTrue(explainNewPartition.toString().contains("VUNION"))
+ assertFalse(explainNewPartition.toString().contains("group expression
count exceeds"))
+ order_qt_new_source_partition_rewrite "${mvSql}"
+ mv_rewrite_success("${mvSql}", "${mvName}")
+
sql """drop materialized view if exists ${mvName};"""
sql """drop table if exists
${catalog_name}.${icebergDb}.${icebergTable3}"""
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]