https://bugs.openldap.org/show_bug.cgi?id=10526
Issue ID: 10526 Summary: back-mdb: mdb_idl_intersection is O(ID-gap), not O(n), when a short candidate list is ANDed with a range Product: OpenLDAP Version: 2.5.20 Hardware: All OS: Linux Status: UNCONFIRMED Keywords: needs_review Severity: normal Priority: --- Component: backends Assignee: bugs@openldap.org Reporter: choeger@open-xchange.com Target Milestone: ---
Created attachment 1157 --> https://bugs.openldap.org/attachment.cgi?id=1157&action=edit the patch above
---------------------------------------------------------------------------- Note
The below report including the patch has been generated by Claude Opus 4.8. I built packages based on the OpenLDAP-LTB project version v2.5.20 and verified in our problematic production deployment.
This refers to the following thread: https://lists.openldap.org/hyperkitty/list/openldap-technical@openldap.org/t...
End Note ----------------------------------------------------------------------------
SUMMARY ------- AND-filter searches can be orders of magnitude slower than the size of their result set warrants. mdb_idl_intersection() falls back to a generic element-at-a-time merge whose cost is proportional to the distance in ID space between candidate IDs, not to the number of candidates. When one operand is a range (any indexed term matching more than the IDL range threshold, e.g. a common objectClass) and the other is a short list of IDs that are widely separated, the merge walks the range one ID at a time across the entire gap.
This is common in practice: filters of the form (&(objectClass=X)(someAttr=val)...) hit it whenever someAttr matches a small number of entries whose IDs happen to be far apart and objectClass=X is stored as a range that does not fully cover that span.
ENVIRONMENT ----------- - OpenLDAP 2.5.19, slapd back-mdb. - ~4.6M entries. An attribute is indexed eq and a given value normally matches exactly one entry. A common objectClass value matches ~2,000,000 entries and is therefore stored as an IDL range (it exceeds MDB_idl_db_max; see servers/slapd/back-mdb/idl.c:467, "No room, convert to a range").
SYMPTOM ------- Query: (&(objectClass=<common>)(<almost-unique-attr>=<value>)(<attr>=*)) The filter resolves to a single entry in all cases.
- When <almost-unique-attr>=<value> matches ONE entry: the search is very fast. - When it matches TWO entries whose IDs are far apart (here 1759457 and 2731413, a gap of ~972,000), the same search becomes roughly 20x slower, even though the AND still yields a single entry.
perf shows nearly all CPU in mdb_idl_next() and mdb_idl_intersection(). Enabling LDAP_DEBUG_FILTER and inspecting the per-term candidate counts confirms the broad term is a range; gdb in mdb_idl_intersection shows idmin/idmax equal to the two widely separated IDs.
Neither index_hash64 nor dropping the substring index changes the timing; the cost is not in the index key lookups, it is in the candidate intersection.
ROOT CAUSE ---------- In servers/slapd/back-mdb/idl.c, mdb_idl_intersection() has fast paths for both-ranges, for idmin==idmax, and for "range fully covers list". Anything that misses those falls into a generic merge:
cursora = cursorb = idmin; ida = mdb_idl_first( a, &cursora ); idb = mdb_idl_first( b, &cursorb ); cursorc = 0; while( ida <= idmax || idb <= idmax ) { if( ida == idb ) { a[++cursorc] = ida; ida = mdb_idl_next( a, &cursora ); idb = mdb_idl_next( b, &cursorb ); } else if ( ida < idb ) { ida = mdb_idl_next( a, &cursora ); } else { idb = mdb_idl_next( b, &cursorb ); } }
For a range, mdb_idl_next() returns ++(*cursor), i.e. consecutive integers. So when b is a range that does not fully cover list a (the "range fully covers list" shortcut is missed because the non-matching list member lies outside the range bounds), the loop advances idb one integer at a time from idmin to idmax. With the two matches ~972,000 apart, that is ~972,000 iterations per search.
The single-match case is fast only because it lands on the idmin==idmax or "range fully covers list" shortcut; it has nothing to do with the index.
The same loop is also O(gap) for the list-vs-list case (a populous list ANDed with a short, widely-spread list): it walks the populous list across the gap.
mdb_idl_intersection() is reached for every non-first term of an AND filter via list_candidates() in servers/slapd/back-mdb/filterindex.c.
REPRODUCTION (without specific data) ------------------------------------ 1. Load a DB where some indexed attribute value V matches more than MDB_idl_db_max entries (so V is stored as a range), and where the range's [lo,hi] does not span the whole DB. 2. Pick another indexed attribute A and two entries that share a value W on A, such that one of them is NOT in V's range and their IDs are far apart. 3. Time (&(A=W)) vs (&(V)(A=W)) repeatedly. The second is dramatically slower despite returning fewer (or equal) entries.
A standalone harness that extracts the real 2.5.19 IDL routines, cross-checks a fixed mdb_idl_intersection against brute-force set intersection, and benchmarks the pathology is attached (idl_intersection_test.c).
PROPOSED FIX ------------ mdb_idl_intersection() never needs to iterate a range. After the existing "range fully covers list" shortcut:
- If b is a range, the result is just the members of list a that fall within [first,last]: iterate the (bounded) list, test each against the bounds. O(|a|) instead of O(range width).
- If both a and b are lists, keep the sorted merge but, when the cursors diverge, binary-search (mdb_idl_search) the lagging list forward to the other's current value rather than stepping one element at a time. Each catch-up becomes O(log n) instead of O(gap).
Result ordering (ascending) and the a[0] count convention are preserved, as is the swap/copy-back behaviour. The in-place writes are safe: the write index (cursorc, number of matches) never exceeds the read index.
Patch against OPENLDAP_REL_ENG_2_5_19 (applies cleanly to RE25 and master HEAD as well); also attached as mdb-idl-intersection.patch:
--- a/servers/slapd/back-mdb/idl.c +++ b/servers/slapd/back-mdb/idl.c @@ -759,23 +759,49 @@ goto done; }
- /* Fine, do the intersection one element at a time. - * First advance to idmin in both IDLs. + /* If b is a range (and does not fully cover a, handled above), the + * intersection is just the elements of list a that fall within the + * range bounds. Walk the (bounded) list, never the (potentially huge) + * range: O(|a|), not O(range width). */ - cursora = cursorb = idmin; - ida = mdb_idl_first( a, &cursora ); - idb = mdb_idl_first( b, &cursorb ); - cursorc = 0; + if ( MDB_IDL_IS_RANGE( b ) ) { + ID lo = MDB_IDL_RANGE_FIRST( b ); + ID hi = MDB_IDL_RANGE_LAST( b ); + cursorc = 0; + for ( ida = mdb_idl_first( a, &cursora ); + ida != NOID; + ida = mdb_idl_next( a, &cursora ) ) { + if ( ida < lo ) + continue; + if ( ida > hi ) + break; + a[++cursorc] = ida; + } + a[0] = cursorc; + goto done; + }
- while( ida <= idmax || idb <= idmax ) { - if( ida == idb ) { + /* Both a and b are lists. Sorted-merge intersection, but when the two + * cursors diverge, binary-search the lagging list forward to the other's + * current value instead of stepping one element at a time. This keeps a + * large gap in one list (e.g. two widely separated matches intersected + * against a populous list) from forcing a linear scan of the other list + * across the whole gap: each catch-up is O(log n) rather than O(gap). + */ + cursora = mdb_idl_search( a, idmin ); + cursorb = mdb_idl_search( b, idmin ); + cursorc = 0; + while ( cursora <= a[0] && cursorb <= b[0] ) { + ida = a[cursora]; + idb = b[cursorb]; + if ( ida == idb ) { a[++cursorc] = ida; - ida = mdb_idl_next( a, &cursora ); - idb = mdb_idl_next( b, &cursorb ); + cursora++; + cursorb++; } else if ( ida < idb ) { - ida = mdb_idl_next( a, &cursora ); + cursora = mdb_idl_search( a, idb ); } else { - idb = mdb_idl_next( b, &cursorb ); + cursorb = mdb_idl_search( b, ida ); } } a[0] = cursorc;
TESTING ------- Using the attached harness (the patched function is byte-for-byte the shipped code):
- Correctness: 400,000 randomized trials (two seeds) over zero/list/range operands of varied sizes and gaps, in both argument orders, cross-checked against brute-force set intersection. Zero mismatches.
- The reported case, list {1759457, 2731413} intersected with a 2,000,000-entry range [1 .. 2731412] that does not cover the upper member, 1200 iterations:
original: 2.54 s, 1,166,348,400 mdb_idl_next calls, result count = 1 fixed: ~0 s, 1,200 mdb_idl_next calls, result count = 1
- Single-value baseline (original): ~0 s, 0 mdb_idl_next calls, confirming the original is fast only for the single-match case.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #1 from Carsten Höger choeger@open-xchange.com --- Created attachment 1158 --> https://bugs.openldap.org/attachment.cgi?id=1158&action=edit standalone correctness + benchmark harness; build: cc -O2 -o idltest idl_intersection_test.c
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #2 from Carsten Höger choeger@open-xchange.com --- Created attachment 1159 --> https://bugs.openldap.org/attachment.cgi?id=1159&action=edit Grafana load graphs 1
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #3 from Carsten Höger choeger@open-xchange.com --- Created attachment 1160 --> https://bugs.openldap.org/attachment.cgi?id=1160&action=edit Grafana load graphs 2
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #4 from Howard Chu hyc@openldap.org --- (In reply to Carsten Höger from comment #0)
Created attachment 1157 [details] the patch above
Note
The below report including the patch has been generated by Claude Opus 4.8. I built packages based on the OpenLDAP-LTB project version v2.5.20 and verified in our problematic production deployment.
Thanks for the report, but please pay attention to the Contribution policy https://openldap.org/devel/contributing.html
AI-generated submissions are prohibited. Nobody will read anything you've attached here.
This refers to the following thread: https://lists.openldap.org/hyperkitty/list/openldap-technical@openldap.org/ thread/QZGNY42OBJTAGHKDWLMWRVQERQ3IYPEN/
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #5 from Carsten Höger choeger@open-xchange.com --- (In reply to Howard Chu from comment #4)
(In reply to Carsten Höger from comment #0)
Created attachment 1157 [details] the patch above
Note
The below report including the patch has been generated by Claude Opus 4.8. I built packages based on the OpenLDAP-LTB project version v2.5.20 and verified in our problematic production deployment.
Thanks for the report, but please pay attention to the Contribution policy https://openldap.org/devel/contributing.html
AI-generated submissions are prohibited. Nobody will read anything you've attached here.
Oh, nice. Even if the fix is improving the code base?
Or do I just have to add a text like
I, <YOUR NAME>, hereby place the following modifications to OpenLDAP Software (and only these modifications) into the public domain. Hence, these modifications may be freely used and/or redistributed for any purpose with or without attribution and/or other notice.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #6 from Howard Chu hyc@openldap.org --- (In reply to Carsten Höger from comment #5)
(In reply to Howard Chu from comment #4)
(In reply to Carsten Höger from comment #0)
Created attachment 1157 [details] the patch above
Note
The below report including the patch has been generated by Claude Opus 4.8. I built packages based on the OpenLDAP-LTB project version v2.5.20 and verified in our problematic production deployment.
Thanks for the report, but please pay attention to the Contribution policy https://openldap.org/devel/contributing.html
AI-generated submissions are prohibited. Nobody will read anything you've attached here.
Oh, nice. Even if the fix is improving the code base?
Or do I just have to add a text like
As you are not the original author of the code being submitted, no.
https://bugs.openldap.org/show_bug.cgi?id=10526
Howard Chu hyc@openldap.org changed:
What |Removed |Added ---------------------------------------------------------------------------- Status|UNCONFIRMED |IN_PROGRESS Ever confirmed|0 |1
--- Comment #7 from Howard Chu hyc@openldap.org --- https://git.openldap.org/openldap/openldap/-/merge_requests/896
Test by compiling just back-mdb/idl.c using
make AC_CFLAGS="-g -DMDB_JUST_TESTING_IDLS" idl.o
use a test program like so:
### typedef unsigned long ID; #define NOID ((ID)~0) extern int mdb_idl_intersection(ID *a, ID *b);
int main() { ID a[32] = {NOID, 1, 90000}; ID b[32] = {2, 1, 99999};
mdb_idl_intersection(a, b); } ###
Link with the above compiled idl.o, examine in gdb.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #8 from Howard Chu hyc@openldap.org --- It's not clear that using binary search is actually an optimization in the case of two lists. That requires a lot of setup, and in sparse lists usually a single cursor increment is sufficient. E.g. using your test harness, with span=50000, a couple test arrays:
gdb) p a $97 = {48, 561, 830, 1039, 1180, 1716, 3496, 4396, 4577, 4874, 5704, 7840, 8809, 10437, 11020, 15124, 17456, 20076, 21188, 21781, 23226, 23362, 23650, 26690, 27326, 27385, 28610, 29661, 30207, 30762, 30874, 31278, 31780, 32215, 32478, 33171, 37241, 37419, 39315, 39322, 39365, 44196, 44678, 45238, 45629, 46511, 46683, 49124, 49953, 49, 50, 48499, 49509, 49769, 0 <repeats 8138 times>, <unavailable> <repeats 191808 times>} (gdb) p b $98 = {80, 3510, 4309, 4837, 4963, 5017, 5480, 6087, 7795, 8513, 9549, 10012, 10161, 10427, 10484, 12562, 13007, 13753, 14185, 14857, 14919, 14933, 15161, 15523, 15600, 16057, 16303, 16565, 17792, 18627, 19088, 19949, 21731, 22354, 23500, 24359, 24462, 24913, 25042, 25441, 26714, 27242, 28034, 28079, 28380, 29309, 30223, 30793, 31023, 31532, 32511, 32790, 33513, 34047, 34591, 34815, 34945, 35191, 35357, 35510, 37063, 37821, 38216, 38632, 39696, 40963, 41642, 42080, 42263, 42808, 43331, 43891, 44125, 44286, 44347, 45799, 46183, 46451, 47121, 47836, 47950, 0 <repeats 8111 times>, <unavailable> <repeats 191808 times>}
The values typically leapfrog each other, and one or two cursor increments is sufficient to get to the next candidate.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #9 from Howard Chu hyc@openldap.org --- Created attachment 1161 --> https://bugs.openldap.org/attachment.cgi?id=1161&action=edit Benchmark original vs binary search
Using your test as a starting point. This program first just generates the data 200000 times, to get a baseline cost of data generation. Only the case of two lists is being timed.
Then it times both the mdb_idl_intersection_orig and the mdb_idl_intersection_fixed. The _fixed version, using binary search, is slower.
You can also specify the number of iterations and seed as arguments, if desired.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #10 from Carsten Höger choeger@open-xchange.com ---
Howard, thank you very much to take my report into account even though your policy prohibits AI generated reports! This issue is serious for us and working around it is painful and expensive.
(In reply to Howard Chu from comment #9)
Created attachment 1161 [details] Benchmark original vs binary search
Using your test as a starting point. This program first just generates the data 200000 times, to get a baseline cost of data generation. Only the case of two lists is being timed.
Then it times both the mdb_idl_intersection_orig and the mdb_idl_intersection_fixed. The _fixed version, using binary search, is slower.
You can also specify the number of iterations and seed as arguments, if desired.
The fix works at least in our environment (reduces load by factors, see graphs).
I confronted Claude with your analysis and here's the response:
------------------------------------------------------------------------------
Thanks for benchmarking it. Agreed -- for two comparably sized lists the binary-search merge is a net loss (I measure ~2.5x slower than the linear merge here as well), so that half should be dropped. It was a "while I'm here" change and doesn't pay for itself.
That benchmark only exercises list INT list, though; the reported case is list INT range, which it never constructs. In the original report objectClass=<value> matches ~2,000,000 entries, so that term is stored as a range, and the slow filter intersects the small almost_uniqe list against it. When the range does not fully cover the list (the non-matching member's ID lies outside [first,last], so the "range completely covers the list" shortcut is missed), the generic merge iterates the *range* one ID at a time via mdb_idl_next across the whole gap. With the two IDs ~972k apart that is ~10^6 mdb_idl_next calls per search:
almost_uniqe{1759457,2731413} INT objectClass range[1..2731412], 1200 searches: original: 1.52 s, 1.17e9 mdb_idl_next calls range trim: 0.00 s, 1.20e3 mdb_idl_next calls
That is the list-vs-range trim already present in your _fixed. So the minimal change is just that branch, with the linear list INT list merge left exactly as it is -- no change to the path the benchmark covers. Patch below (also attached). Cross-checked against brute-force set intersection over 400k randomized list/range trials in both argument orders: no mismatches.
--- a/servers/slapd/back-mdb/idl.c +++ b/servers/slapd/back-mdb/idl.c @@ -750,12 +750,24 @@ } }
- /* If a range completely covers the list, the result is - * just the list. + /* If b is a range, the intersection is just the elements of list a + * that fall within the range bounds. Never iterate the range itself: + * doing so walks one ID at a time across the whole [first,last] span, + * which is O(range width) rather than O(|a|). */ - if ( MDB_IDL_IS_RANGE( b ) - && MDB_IDL_RANGE_FIRST( b ) <= MDB_IDL_FIRST( a ) - && MDB_IDL_RANGE_LAST( b ) >= MDB_IDL_LLAST( a ) ) { + if ( MDB_IDL_IS_RANGE( b ) ) { + idmin = MDB_IDL_RANGE_FIRST( b ); + idmax = MDB_IDL_RANGE_LAST( b ); + if ( idmin <= MDB_IDL_FIRST( a ) && idmax >= MDB_IDL_LLAST( a ) ) + goto done; /* range completely covers the list */ + cursorc = 0; + cursora = idmin; + ida = mdb_idl_first( a, &cursora ); + while ( ida <= idmax ) { + a[++cursorc] = ida; + ida = mdb_idl_next( a, &cursora ); + } + a[0] = cursorc; goto done; }
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #11 from Carsten Höger choeger@open-xchange.com --- Created attachment 1162 --> https://bugs.openldap.org/attachment.cgi?id=1162&action=edit Range-trim-only fix against RE25 (OPENLDAP_REL_ENG_2_5_19); list-vs-list merge unchanged.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #12 from Carsten Höger choeger@open-xchange.com --- Created attachment 1163 --> https://bugs.openldap.org/attachment.cgi?id=1163&action=edit Standalone harness: 400k brute-force correctness trials over lists+ranges, plus timing for list∩range, disparate list∩list, and balanced list∩list. cc -O2 -o itest idl_intersection_test_v2.c
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #13 from Carsten Höger choeger@open-xchange.com --- I will now apply the 2nd patch to my local OpenLDAP package and verify whether it still improves our situation.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #14 from Howard Chu hyc@openldap.org --- (In reply to Carsten Höger from comment #13)
I will now apply the 2nd patch to my local OpenLDAP package and verify whether it still improves our situation.
No need. Just use the patch in the merge request I already linked above. https://bugs.openldap.org/show_bug.cgi?id=10526#c7
I only benchmarked the list-vs-list because that was suspicious. I've rewritten the range-vs-list fix because it was obviously a good thing to do. Just not written by an AI.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #15 from Howard Chu hyc@openldap.org --- (In reply to Carsten Höger from comment #10)
Howard, thank you very much to take my report into account even though your policy prohibits AI generated reports! This issue is serious for us and working around it is painful and expensive.
You can use AI to submit a report. But nothing AI-generated can be merged into our source trees. I examined your test code because it's not going to be merged anyway.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #16 from Carsten Höger choeger@open-xchange.com --- (In reply to Howard Chu from comment #14)
(In reply to Carsten Höger from comment #13)
I will now apply the 2nd patch to my local OpenLDAP package and verify whether it still improves our situation.
No need. Just use the patch in the merge request I already linked above. https://bugs.openldap.org/show_bug.cgi?id=10526#c7
I only benchmarked the list-vs-list because that was suspicious. I've rewritten the range-vs-list fix because it was obviously a good thing to do. Just not written by an AI.
Ok, great! Will start testing.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #17 from Carsten Höger choeger@open-xchange.com --- Patched our 2.5 version with the patch from your merge request an can verify that it works.
https://bugs.openldap.org/show_bug.cgi?id=10526
--- Comment #18 from Howard Chu hyc@openldap.org --- (In reply to Carsten Höger from comment #17)
Patched our 2.5 version with the patch from your merge request an can verify that it works.
Thanks for the feedback.
https://bugs.openldap.org/show_bug.cgi?id=10526
Quanah Gibson-Mount quanah@openldap.org changed:
What |Removed |Added ---------------------------------------------------------------------------- Assignee|bugs@openldap.org |hyc@openldap.org
https://bugs.openldap.org/show_bug.cgi?id=10526
Quanah Gibson-Mount quanah@openldap.org changed:
What |Removed |Added ---------------------------------------------------------------------------- Target Milestone|--- |2.6.14 Keywords|needs_review |
https://bugs.openldap.org/show_bug.cgi?id=10526
Quanah Gibson-Mount quanah@openldap.org changed:
What |Removed |Added ---------------------------------------------------------------------------- Status|IN_PROGRESS |RESOLVED Resolution|--- |FIXED
--- Comment #19 from Quanah Gibson-Mount quanah@openldap.org --- head:
• 0a68eb2d by Howard Chu at 2026-06-30T14:14:33+00:00 ITS#10526 back-mdb: optimize mdb_idl_intersection
RE27:
• 01f425b4 by Howard Chu at 2026-07-02T15:23:52+00:00 ITS#10526 back-mdb: optimize mdb_idl_intersection
RE26:
• 1363bb56 by Howard Chu at 2026-07-02T15:24:03+00:00 ITS#10526 back-mdb: optimize mdb_idl_intersection