Skip to content

[opt](nereids) rewrite PreferPushDownProject to slots before filter-join pushdown#61635

Merged
924060929 merged 2 commits intoapache:masterfrom
924060929:push-down-match-through-filter-join
Mar 25, 2026
Merged

[opt](nereids) rewrite PreferPushDownProject to slots before filter-join pushdown#61635
924060929 merged 2 commits intoapache:masterfrom
924060929:push-down-match-through-filter-join

Conversation

@924060929
Copy link
Contributor

@924060929 924060929 commented Mar 23, 2026

Push PreferPushDownProject expressions to the matching join child as aliases and replace them with slots in filter predicates. This keeps OR predicates above join while exposing nested-field access below join for storage-level pruning acceleration.

Plan tree demo:

Before:

  LogicalFilter((match_any(info['context'], 'abc') OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalOlapScan(student)
      right: LogicalOlapScan(score)

After:


  LogicalFilter((slot#x OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalProject(student.*, match_any(info['context'], 'abc') AS slot#x)
                  LogicalOlapScan(student)
      right: LogicalOlapScan(score)

What problem does this PR solve?

Issue Number: close #xxx

Related PR: #xxx

Problem Summary:

Release note

None

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)
    • No need to test or manual test. Explain why:
      • This is a refactor/code format and no logic has been changed.
      • Previous test can cover this change.
      • No code files have been changed.
      • Other reason
  • Behavior changed:

    • No.
    • Yes.
  • Does this need documentation?

    • No.
    • Yes.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

@hello-stephen
Copy link
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@924060929
Copy link
Contributor Author

run buildall

@doris-robot
Copy link

TPC-H: Total hot run time: 26542 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit e8eb0bc37d1bce68184429bac5a3a4b4b1724f4f, data reload: false

------ Round 1 ----------------------------------
orders	Doris	NULL	NULL	0	0	0	NULL	0	NULL	NULL	2023-12-26 18:27:23	2023-12-26 18:42:55	NULL	utf-8	NULL	NULL	
============================================
q1	17648	4444	4283	4283
q2	q3	10642	786	521	521
q4	4683	360	249	249
q5	7549	1186	1026	1026
q6	180	176	147	147
q7	765	846	669	669
q8	9300	1465	1302	1302
q9	4962	4721	4667	4667
q10	6316	1897	1649	1649
q11	465	255	248	248
q12	737	568	475	475
q13	18041	2931	2161	2161
q14	230	222	212	212
q15	q16	747	738	666	666
q17	737	828	432	432
q18	5828	5319	5151	5151
q19	1120	960	617	617
q20	537	488	377	377
q21	4433	1820	1403	1403
q22	345	287	333	287
Total cold run time: 95265 ms
Total hot run time: 26542 ms

----- Round 2, with runtime_filter_mode=off -----
orders	Doris	NULL	NULL	150000000	42	6422171781	NULL	22778155	NULL	NULL	2023-12-26 18:27:23	2023-12-26 18:42:55	NULL	utf-8	NULL	NULL	
============================================
q1	4762	4774	4701	4701
q2	q3	3908	4347	3859	3859
q4	887	1200	786	786
q5	4045	4433	4516	4433
q6	178	176	142	142
q7	1748	1643	1533	1533
q8	2485	2763	2566	2566
q9	7541	7439	7300	7300
q10	3740	3996	3637	3637
q11	501	473	440	440
q12	503	612	475	475
q13	2884	3100	2371	2371
q14	293	298	294	294
q15	q16	722	748	724	724
q17	1144	1386	1413	1386
q18	7302	6673	6716	6673
q19	875	877	924	877
q20	2079	2159	2171	2159
q21	3927	3534	3359	3359
q22	466	435	385	385
Total cold run time: 49990 ms
Total hot run time: 48100 ms

@doris-robot
Copy link

TPC-DS: Total hot run time: 167594 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit e8eb0bc37d1bce68184429bac5a3a4b4b1724f4f, data reload: false

query5	4325	638	503	503
query6	336	224	216	216
query7	4209	483	261	261
query8	350	250	239	239
query9	8719	2695	2710	2695
query10	481	406	327	327
query11	6994	5082	4883	4883
query12	183	131	128	128
query13	1280	462	339	339
query14	5774	3710	3428	3428
query14_1	2818	2809	2797	2797
query15	201	193	182	182
query16	991	466	453	453
query17	1139	738	627	627
query18	2467	461	362	362
query19	218	211	189	189
query20	136	132	131	131
query21	214	141	114	114
query22	13146	13222	13198	13198
query23	15732	15519	15393	15393
query23_1	16001	16038	15583	15583
query24	7472	1694	1360	1360
query24_1	1300	1301	1324	1301
query25	637	576	500	500
query26	1443	278	169	169
query27	3204	575	298	298
query28	4645	1938	2006	1938
query29	860	623	499	499
query30	314	243	203	203
query31	1033	942	873	873
query32	76	72	72	72
query33	515	338	288	288
query34	903	871	520	520
query35	628	673	607	607
query36	1117	1172	982	982
query37	138	97	81	81
query38	2966	2977	2872	2872
query39	851	838	807	807
query39_1	821	806	811	806
query40	235	156	138	138
query41	63	62	58	58
query42	264	258	266	258
query43	245	254	235	235
query44	
query45	246	189	193	189
query46	914	1007	612	612
query47	2821	2183	2081	2081
query48	307	310	223	223
query49	628	481	379	379
query50	682	275	211	211
query51	4114	4067	3984	3984
query52	257	263	251	251
query53	287	336	282	282
query54	302	278	269	269
query55	90	87	84	84
query56	306	324	310	310
query57	1940	1701	1702	1701
query58	285	271	269	269
query59	2809	2942	2743	2743
query60	345	338	331	331
query61	152	149	156	149
query62	632	603	555	555
query63	314	275	273	273
query64	5140	1295	1008	1008
query65	
query66	1458	471	357	357
query67	24121	24470	24124	24124
query68	
query69	429	306	285	285
query70	962	966	974	966
query71	329	312	313	312
query72	2955	2684	2506	2506
query73	536	540	315	315
query74	9592	9571	9377	9377
query75	2851	2740	2484	2484
query76	2288	1031	667	667
query77	358	376	306	306
query78	10933	11012	10436	10436
query79	3161	736	562	562
query80	1787	624	544	544
query81	575	258	231	231
query82	1011	146	117	117
query83	327	256	248	248
query84	253	114	103	103
query85	916	491	454	454
query86	493	314	258	258
query87	3217	3070	3049	3049
query88	3550	2679	2661	2661
query89	419	365	341	341
query90	1996	183	178	178
query91	169	160	139	139
query92	84	77	67	67
query93	1800	835	498	498
query94	671	318	287	287
query95	584	345	315	315
query96	654	507	225	225
query97	2480	2513	2409	2409
query98	239	217	228	217
query99	1000	1007	929	929
Total cold run time: 253966 ms
Total hot run time: 167594 ms

@zclllyybb
Copy link
Contributor

/review

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary: PushDownFilterThroughJoin PreferPushDownProject Rewrite

Overview

This PR adds handling for PreferPushDownProject expressions in filter predicates above joins. When a filter predicate (especially an OR predicate) contains a PreferPushDownProject expression (e.g., MATCH_ANY, element_at, MapKeys), the expression is replaced with an alias slot and pushed into a LogicalProject under the appropriate join child. This enables storage-level pruning acceleration for complex-type and inverted-index operations.

The implementation follows the same well-established pattern used by PushDownProject.java (rewriteDownShortCircuit + alias replacement + project insertion).

Critical Checkpoint Conclusions

  1. Goal and correctness: The code accomplishes the stated goal. The rewritePreferPushDownProjects() method correctly identifies PreferPushDownProject expressions, replaces them with slot references, and inserts LogicalProject nodes under the matching join child. The short-circuit rewrite correctly handles nested PreferPushDownProject by replacing the outermost one. Expressions spanning both sides gracefully fall through unchanged.

  2. Modification scope: The change is focused and minimal -- only PushDownFilterThroughJoin.java logic is modified, plus one test.

  3. Concurrency: No concurrency concerns. This is a single-threaded rewrite rule operating on immutable plan trees.

  4. Lifecycle / static init: No lifecycle or static initialization concerns. The INSTANCE singleton is already established.

  5. Configuration: No new config items added. None needed.

  6. Incompatible changes: None. This is an optimizer-internal rewrite change with no storage/protocol impact.

  7. Parallel code paths: PushDownProject.java handles the same PreferPushDownProject marker for join conjuncts and project-above-join patterns. This PR covers the filter-above-join pattern specifically. The two rules sit in the same PUSH_DOWN_FILTERS batch (positions 6 and 17 respectively) and operate on different plan shapes, so no conflict.

  8. Rewrite-loop risk: None. PreferPushDownProject expressions are consumed (replaced with plain SlotReferences), so the rule will not re-match on the same expressions.

  9. Mark join semantics: Preserved. The new LogicalJoin is constructed with join.getMarkJoinConjuncts() and join.getMarkJoinSlotReference() intact.

  10. Extra output slots: The inserted LogicalProject adds extra alias slots to the join child's output, which propagate through the join. This is safe -- the rewritten predicate references these slots, and ColumnPruning (in a subsequent rewrite topic) will prune any unreferenced extras. This matches the existing pattern in PushDownProject.

  11. Test coverage: Insufficient. See inline comments for details. Only one scenario (left-side MatchAny in OR, INNER_JOIN) is tested. The assertion is weak (see inline comment). Missing: right-side pushdown, both-sides simultaneous, non-inner joins, dedup path, cross-side expression (negative case), regression test.

  12. Observability: No new observability needed for this optimizer-internal change.

  13. Performance: The early-exit check at line 172 (noneMatch(containsType)) ensures zero overhead for the common case where no PreferPushDownProject exists. The containsType check is O(1) BitSet lookup. No concerns.

Issues Found

  • [Medium] Test coverage is thin -- only 1 of ~9 meaningful scenarios covered. See inline comments.
  • [Low] Test assertion weakness -- anyMatch(SlotReference.class::isInstance) is trivially satisfied by existing right-side slots. See inline comment.
  • [Suggestion] A regression test with actual SQL (e.g., MATCH_ANY or element_at in a join filter) would provide end-to-end validation through the full optimizer pipeline.

Expression rewrittenPredicate = ImmutableList.copyOf(filter.getConjuncts()).get(0);
return rewrittenPredicate instanceof Or
&& rewrittenPredicate.anyMatch(SlotReference.class::isInstance)
&& !rewrittenPredicate.anyMatch(PreferPushDownProject.class::isInstance);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Weak assertion: rewrittenPredicate.anyMatch(SlotReference.class::isInstance) is trivially satisfied by rScore.getOutput().get(2) (the SlotReference inside rightSidePredicate). This assertion would pass even if the PreferPushDownProject expression simply disappeared rather than being correctly replaced with a new slot.

Consider a stronger assertion, e.g., verifying that the Or's first child is a SlotReference (the replacement slot), or extracting the alias slot from the project and checking it appears in the predicate:

// e.g., check the Or's left child is a SlotReference that didn't exist before
return rewrittenPredicate instanceof Or
    && ((Or) rewrittenPredicate).left() instanceof SlotReference
    && !rewrittenPredicate.anyMatch(PreferPushDownProject.class::isInstance);


@Test
public void shouldRewritePreferPushDownProjectInOrFilterToSlot() {
Expression preferPushDownProjectExpr = new MatchAny(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Insufficient test coverage: This single test only covers one scenario: a MatchAny on the left side of an INNER_JOIN inside an OR predicate. Consider adding tests for:

  1. Right-side pushdown -- PreferPushDownProject expression referencing only right-child slots (exercises childIndexToPushedAlias.get(1) path, currently untested).
  2. Both sides simultaneously -- two PreferPushDownProject expressions, one per side, verifying two separate LogicalProject nodes are inserted.
  3. Dedup path -- the same PreferPushDownProject expression appearing in multiple predicates, verifying oldExprToNewSlot cache works correctly.
  4. Cross-side expression (negative case) -- a PreferPushDownProject whose input slots span both sides, verifying it remains unrewritten.
  5. Non-inner join types -- verify project insertion works correctly with LEFT_OUTER_JOIN etc., where the filter remains above the join.
  6. Standalone PreferPushDownProject predicate -- not wrapped in OR, verifying it gets rewritten and can then be pushed to one side.

airborne12
airborne12 previously approved these changes Mar 24, 2026
Copy link
Member

@airborne12 airborne12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The approach of rewriting PreferPushDownProject expressions to slot aliases before filter-join pushdown is sound and correct. It cleanly enables storage-level pruning for expressions embedded in OR predicates that couldn't be pushed down individually.

Highlights:

  • Deduplication via oldExprToNewSlot is a nice touch
  • Early return when no PreferPushDownProject exists avoids unnecessary work
  • rewriteDownShortCircuit usage is precise

Minor suggestions (non-blocking):

  • Consider adding tests for OUTER JOIN, cross-side expressions (should not rewrite), and dedup verification
  • The rewrite currently applies to all predicates containing PreferPushDownProject, including standalone pushable conjuncts — these could be skipped for slightly cleaner plan trees

@github-actions github-actions bot added the approved Indicates a PR has been approved by one committer. label Mar 24, 2026
@github-actions
Copy link
Contributor

PR approved by at least one committer and no changes requested.

@github-actions
Copy link
Contributor

PR approved by anyone and no changes requested.

Add filter(join) handling in PushDownProject to push PreferPushDownProject expressions to matching join children as aliases, and replace them with slots inside filter predicates.

This keeps OR filter semantics while exposing nested-field access below join for storage pruning.

Plan tree demo:

Before:

  LogicalFilter((match_any(info['context'], 'abc') OR r.k2 > 60))

    LogicalJoin(INNER)

      left:  LogicalOlapScan(student)

      right: LogicalOlapScan(score)

After:

  LogicalFilter((slot#x OR r.k2 > 60))

    LogicalJoin(INNER)

      left:  LogicalProject(student.*, match_any(info['context'], 'abc') AS slot#x)

              LogicalOlapScan(student)

      right: LogicalOlapScan(score)
@924060929 924060929 force-pushed the push-down-match-through-filter-join branch from e8eb0bc to aab1255 Compare March 24, 2026 10:25
@924060929
Copy link
Contributor Author

run buildall

@github-actions github-actions bot removed the approved Indicates a PR has been approved by one committer. label Mar 24, 2026
@924060929
Copy link
Contributor Author

run buildall

@doris-robot
Copy link

TPC-H: Total hot run time: 26535 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 8782efdd17e39115519ba342b11be478383e2aeb, data reload: false

------ Round 1 ----------------------------------
orders	Doris	NULL	NULL	0	0	0	NULL	0	NULL	NULL	2023-12-26 18:27:23	2023-12-26 18:42:55	NULL	utf-8	NULL	NULL	
============================================
q1	17614	4476	4303	4303
q2	q3	10646	783	521	521
q4	4671	349	248	248
q5	7561	1173	1028	1028
q6	174	173	145	145
q7	771	843	671	671
q8	9296	1481	1362	1362
q9	4869	4723	4714	4714
q10	6327	1891	1662	1662
q11	498	278	248	248
q12	744	578	466	466
q13	18036	2696	1912	1912
q14	226	232	210	210
q15	q16	743	730	657	657
q17	739	824	452	452
q18	5946	5366	5258	5258
q19	1119	978	617	617
q20	547	495	382	382
q21	4491	1817	1408	1408
q22	343	302	271	271
Total cold run time: 95361 ms
Total hot run time: 26535 ms

----- Round 2, with runtime_filter_mode=off -----
orders	Doris	NULL	NULL	150000000	42	6422171781	NULL	22778155	NULL	NULL	2023-12-26 18:27:23	2023-12-26 18:42:55	NULL	utf-8	NULL	NULL	
============================================
q1	4778	4720	4680	4680
q2	q3	3903	4364	3819	3819
q4	896	1213	779	779
q5	4046	4362	4335	4335
q6	198	185	144	144
q7	1749	1648	1531	1531
q8	2479	2746	2615	2615
q9	7578	7396	7397	7396
q10	3895	3964	3558	3558
q11	499	429	411	411
q12	509	612	462	462
q13	2474	3002	2117	2117
q14	285	319	286	286
q15	q16	712	942	837	837
q17	1184	1376	1365	1365
q18	7233	6769	6633	6633
q19	949	896	932	896
q20	2086	2134	1988	1988
q21	4006	3530	3402	3402
q22	451	451	374	374
Total cold run time: 49910 ms
Total hot run time: 47628 ms

@doris-robot
Copy link

TPC-DS: Total hot run time: 169309 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 8782efdd17e39115519ba342b11be478383e2aeb, data reload: false

query5	4326	639	497	497
query6	330	224	202	202
query7	4227	475	269	269
query8	368	263	235	235
query9	8756	2758	2721	2721
query10	545	373	341	341
query11	6959	5102	4918	4918
query12	196	131	134	131
query13	1274	468	378	378
query14	5829	3705	3673	3673
query14_1	2850	2817	2802	2802
query15	206	191	176	176
query16	985	449	463	449
query17	1100	759	615	615
query18	2450	456	366	366
query19	228	221	189	189
query20	138	126	131	126
query21	217	142	110	110
query22	13095	14065	14422	14065
query23	16641	16397	16198	16198
query23_1	16153	15624	15747	15624
query24	7231	1613	1202	1202
query24_1	1237	1238	1230	1230
query25	611	453	401	401
query26	1245	256	149	149
query27	2783	472	287	287
query28	4515	1829	1883	1829
query29	923	562	493	493
query30	297	218	191	191
query31	1004	930	875	875
query32	82	69	71	69
query33	514	358	313	313
query34	1106	889	533	533
query35	643	704	596	596
query36	1099	1130	1000	1000
query37	136	91	85	85
query38	3000	2946	2964	2946
query39	866	852	830	830
query39_1	792	805	831	805
query40	240	159	142	142
query41	62	59	95	59
query42	256	247	255	247
query43	250	248	224	224
query44	
query45	191	186	184	184
query46	868	989	608	608
query47	2124	2170	2091	2091
query48	297	315	230	230
query49	632	457	387	387
query50	681	275	209	209
query51	4120	4007	4014	4007
query52	261	269	256	256
query53	290	347	286	286
query54	313	275	270	270
query55	88	89	85	85
query56	322	319	343	319
query57	1934	1692	1766	1692
query58	286	259	276	259
query59	2773	2971	2730	2730
query60	335	325	321	321
query61	153	153	157	153
query62	617	582	539	539
query63	313	284	278	278
query64	5039	1309	1026	1026
query65	
query66	1455	455	348	348
query67	24185	24258	24051	24051
query68	
query69	408	316	288	288
query70	940	971	953	953
query71	337	303	304	303
query72	2793	2643	2509	2509
query73	543	538	311	311
query74	9647	9561	9392	9392
query75	2863	2781	2490	2490
query76	2304	1035	669	669
query77	377	389	322	322
query78	10917	11192	10440	10440
query79	1078	810	575	575
query80	1365	620	564	564
query81	569	265	217	217
query82	1241	151	116	116
query83	333	262	243	243
query84	257	118	98	98
query85	930	492	440	440
query86	415	302	289	289
query87	3172	3106	3028	3028
query88	3552	2660	2680	2660
query89	428	371	349	349
query90	2025	178	177	177
query91	173	160	147	147
query92	81	74	76	74
query93	902	815	492	492
query94	635	326	287	287
query95	581	349	393	349
query96	660	518	227	227
query97	2453	2517	2438	2438
query98	235	225	218	218
query99	938	970	916	916
Total cold run time: 250481 ms
Total hot run time: 169309 ms

@hello-stephen
Copy link
Contributor

FE UT Coverage Report

Increment line coverage 100.00% (11/11) 🎉
Increment coverage report
Complete coverage report

Copy link
Member

@airborne12 airborne12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@github-actions
Copy link
Contributor

PR approved by at least one committer and no changes requested.

@github-actions github-actions bot added the approved Indicates a PR has been approved by one committer. label Mar 25, 2026
@924060929 924060929 merged commit 64eddc5 into apache:master Mar 25, 2026
28 of 30 checks passed
@924060929 924060929 deleted the push-down-match-through-filter-join branch March 25, 2026 08:22
github-actions bot pushed a commit that referenced this pull request Mar 25, 2026
…oin pushdown (#61635)

Push PreferPushDownProject expressions to the matching join child as
aliases and replace them with slots in filter predicates. This keeps OR
predicates above join while exposing nested-field access below join for
storage-level pruning acceleration.

Plan tree demo:

Before:
```
  LogicalFilter((match_any(info['context'], 'abc') OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalOlapScan(student)
      right: LogicalOlapScan(score)
```

After:
```

  LogicalFilter((slot#x OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalProject(student.*, match_any(info['context'], 'abc') AS slot#x)
                  LogicalOlapScan(student)
      right: LogicalOlapScan(score)
```
github-actions bot pushed a commit that referenced this pull request Mar 25, 2026
…oin pushdown (#61635)

Push PreferPushDownProject expressions to the matching join child as
aliases and replace them with slots in filter predicates. This keeps OR
predicates above join while exposing nested-field access below join for
storage-level pruning acceleration.

Plan tree demo:

Before:
```
  LogicalFilter((match_any(info['context'], 'abc') OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalOlapScan(student)
      right: LogicalOlapScan(score)
```

After:
```

  LogicalFilter((slot#x OR r.k2 > 60))
    LogicalJoin(INNER)
      left:  LogicalProject(student.*, match_any(info['context'], 'abc') AS slot#x)
                  LogicalOlapScan(student)
      right: LogicalOlapScan(score)
```
airborne12 added a commit to airborne12/apache-doris that referenced this pull request Mar 25, 2026
…mn metadata

When MATCH expressions reference alias slots that have lost column metadata
(e.g., CAST(variant_col['subkey'] AS VARCHAR) AS fn in CTE/subquery + JOIN),
visitMatch() previously threw "SlotReference in Match failed to get Column".

Now gracefully falls back to invertedIndex = null instead of throwing. The BE
evaluates MATCH correctly via slow-path expression evaluation, or the
PushDownProject / PushDownMatchProjectionAsVirtualColumn rules provide
fast-path index evaluation.

This change works together with PR apache#61635 which pushes PreferPushDownProject
expressions (like match_any on variant sub-fields) down through joins as
aliased projections.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by one committer. dev/4.0.x dev/4.1.x reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants