DynamicCollection: range filters and sorts on an indexed double field return wrong results (keys are little-endian BitConverter bytes)
Maintainers usually reply within 2 days
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
Research direction
Read src/BLite.Core/DynamicCollection.cs at BsonValueToIndexKey, CreateIndexKeyFromObject, and PersistIndexMetadata, then compare with the typed IndexKey(double) path and the migrations mentioned in the issue. Reproduce the indexed range and sort failures with the supplied snippet. Done means numeric ranges and ordering match the unindexed results, and existing dynamic B-tree indexes are rebuilt safely after the key encoding changes.
Written by the indexing model from the issue text.
Description
Found while fixing #249 (see #252), and checked on its own. On a DynamicCollection with a B-tree index on a double field, BLQL range filters and sorts on that field return wrong results: documents are missing, or come back in the wrong order. The unindexed collection returns the right answer.
Repro
using var engine = new BLiteEngine(path);
var col = engine.GetOrCreateCollection("zz");
await col.CreateIndexAsync("ratio");
foreach (var (n, v) in new[] { ("a", 0.1), ("b", 2.2), ("c", 1.3), ("d", 5.7), ("e", -3.3), ("f", 1.0000001), ("nz", -0.0), ("pz", 0.0) })
await col.InsertAsync(col.CreateDocument(["name", "ratio"], b => b.AddString("name", n).AddDouble("ratio", v)));
col.Query("{\"ratio\": {\"$lt\": 2.0}}").ToList();
col.Query("{\"ratio\": {\"$gte\": 1.0, \"$lt\": 3.0}}").ToList();
col.Query().OrderBy("ratio").ToList();
| Query | Without the index (correct) | With the index |
|---|---|---|
{ratio: {$lt: 2.0}} |
a, c, e, f, nz, pz | pz |
{ratio: {$gte: 1.0, $lt: 3.0}} |
b, c, f | (empty) |
{ratio: 0.0} |
nz, pz | pz |
OrderBy("ratio") |
e, pz, nz, a, f, c, b, d | pz, nz, e, b, a, f, d, c |
OrderByDescending("ratio").Take(3) |
d, b, c | c, d, f |
QueryIndexAsync(idx, 1.0, 3.0) |
— | (empty) |
{ratio: {$gte: 1.0}} happens to come out right with these values: its upper bound is MaxKey, and every document it should return has a key above the lower bound.
Mechanism (verified on 0450b97)
DynamicCollectionkeys a double asnew IndexKey(BitConverter.GetBytes(d)). This happens for stored values inBsonValueToIndexKey(src/BLite.Core/DynamicCollection.cs:2725) and for query bounds inCreateIndexKeyFromObject(:2812).BitConverter.GetBytesis little-endian IEEE 754: the first byte compared is the lowest mantissa byte and the sign bit is compared last, so key order has no relation to numeric order, and -0.0 and +0.0 get different keys. The typed path uses the order-preservingIndexKey(double)(IndexKey.cs:94-105).- Equality on a non-zero value still works, because both sides use the same bytes. Ranges and ordering do not.
- BLQL takes the index range as its only candidate source.
BlqlQuery.GetScanSource(src/BLite.Core/Query/Blql/BlqlQuery.cs:302) returnsQueryIndexAsync(index, min, max)whenever the filter field is indexed, and the residual filter can only drop documents, never add the ones the scan missed. - The single-key sort fast path (
BlqlQuery.cs:233-260) iterates the same index in key order, so the result comes out in byte order. - Dynamic B-tree indexes do not persist a
KeyFormat:PersistIndexMetadatasets it for full-text indexes only (DynamicCollection.cs:2209). Every existing dynamic B-tree index therefore reads back asIndexMetadata.LegacyKeyFormat.
Affected versions: the encoding dates from 39e6c89 (2026-02-21, BLiteEngine, first in v1.4.0). BLQL has used the index for range scans and sorts since 6364585 (2026-03-25, first in v3.8.1-preview.2), which is when this became visible through BLQL. Before that, only QueryIndexAsync with bounds was affected. Reproduced on v5.2.3 / 0450b97.
Fix considerations
- Key doubles with
new IndexKey(d)in both places. That is order-preserving, and with #252 it gives -0.0 and +0.0 one key. - Stored keys change, so existing dynamic indexes holding doubles must be rebuilt. An old key cannot be told apart by looking at it: it is a 9-byte
0x02…key, like anInt64. Two clean triggers:- Since dynamic B-tree indexes read back as
LegacyKeyFormat, writeCurrentKeyFormatfor them from now on and rebuild every dynamic B-tree index that reads back as legacy on open. This is the same pattern as the typed decimal migration (MigrateLegacyDecimalIndexes) and the dynamic full-text rebuild. It is a one-time rebuild of every dynamic B-tree index, including those without doubles. - Alternatively, fold it into the v6
KeyFormatbump planned for #212.
- Since dynamic B-tree indexes read back as
DateTimeis unaffected: it is keyed throughIndexKey(long).
Happy to send the fix if you want it, with the first option unless you prefer to wait for the v6 bump.
- Dominant language
- C#
- Stars
- 66
- Forks
- 6
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 58
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from EntglDb/BLite
-
GdprMode.Strict warns that deleted documents are not zeroed, but every delete zero-fills themPossibly taken @CaffeinatedCoder claimed this 3 days ago. Openbug gdpr package/core severity/low
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
EntglDb/BLite#299 · 1 comment ·
Maintainers usually reply within 2 days
-
bug package/core severity/medium triage
Difficulty 4/5 3-5 days Newbie friendliness 54/100
Maintainers usually reply within 2 days
-
bug package/core severity/medium
Difficulty 4/5 3-5 days Newbie friendliness 48/100
EntglDb/BLite#322 · 1 comment ·
Maintainers usually reply within 2 days
-
enhancement package/core
Difficulty 4/5 3-5 days Newbie friendliness 48/100
EntglDb/BLite#320 · 1 comment ·
Maintainers usually reply within 2 days
-
BLiteSession.Rollback() does not await the rollback: it returns while the transaction is still active, and failures go unobservedPossibly taken @CaffeinatedCoder claimed this 3 days ago. Openbug package/core severity/medium
Difficulty 3/5 1-2 days Newbie friendliness 78/100
EntglDb/BLite#307 · 1 comment ·
Maintainers usually reply within 2 days
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 73/100
Maintainers usually reply within 1 day
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
KnifeLemon/Filee#363 ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
-
area-Native untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
dotnet/machinelearning#7837 ·
Maintainers usually reply within 2 days