diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs
index f387bf1..c95e8e8 100644
--- a/src/BLite.Core/Collections/DocumentCollection.cs
+++ b/src/BLite.Core/Collections/DocumentCollection.cs
@@ -903,7 +903,8 @@ internal DocumentCollection(StorageEngine storage, ITransactionHolder transactio
///
/// Decimal properties used to be indexed as (double)value. An index whose persisted
/// predates the exact decimal encoding, a double-keyed
- /// index holding a key of -0.0 from before -0.0 and +0.0 shared a key (#249), or a full-text
+ /// index holding a key of -0.0 from before -0.0 and +0.0 shared a key (#249) or of a NaN with the
+ /// sign bit clear from before every NaN shared one key (#257), or a full-text
/// index written in another format or holding keys it did not write, is rebuilt from the
/// documents into a copy that queries don't see, the first time the collection is opened. Only
/// after the copy committed is it swapped in, with its target format, in one metadata save, and
diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs
index ed40ad2..00c4e7c 100644
--- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs
+++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs
@@ -824,21 +824,36 @@ private IndexKey ConvertToIndexKey(object value)
// IndexKey(double) gives -0.0 the key of +0.0 (#249). Earlier releases keyed -0.0 apart, just
// below +0.0; such an entry is neither found by a query nor removed when its document is updated
// or deleted, so a double- or float-keyed index that still holds one is rebuilt on open
- // (NeedsKeyFormatMigration). Looking for it costs one seek per index and open.
+ // (NeedsKeyFormatMigration). The same holds for a NaN with the sign bit clear (what Arm64
+ // computes), which was keyed above +Infinity before every NaN took the key of double.NaN.
+ // Looking for them costs two seeks per index and open.
// The former key of -0.0: every bit of 0x8000_0000_0000_0000 inverted, after KeyPrefix.
private static readonly IndexKey s_legacyNegativeZeroKey =
new IndexKey(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF });
- private bool HoldsLegacyNegativeZeroKey()
+ // The former keys of the NaNs with the sign bit clear, 0x7FF0_0000_0000_0001..0x7FFF_FFFF_FFFF_FFFF
+ // with the sign bit set, after KeyPrefix: every key above that of +Infinity.
+ private static readonly IndexKey s_legacyPositiveNaNMinKey =
+ new IndexKey(new byte[] { 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 });
+ private static readonly IndexKey s_legacyPositiveNaNMaxKey =
+ new IndexKey(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF });
+
+ private bool HoldsLegacyDoubleKey()
{
if (_btreeIndex == null) return false;
var keyType = SingleKeyType();
if (keyType != typeof(double) && keyType != typeof(float)) return false;
- foreach (var _ in _btreeIndex.Range(
- CreateCompositeKeyBoundary(s_legacyNegativeZeroKey, useMinObjectId: true),
- CreateCompositeKeyBoundary(s_legacyNegativeZeroKey, useMinObjectId: false),
+ return HoldsKeyBetween(s_legacyNegativeZeroKey, s_legacyNegativeZeroKey)
+ || HoldsKeyBetween(s_legacyPositiveNaNMinKey, s_legacyPositiveNaNMaxKey);
+ }
+
+ private bool HoldsKeyBetween(IndexKey minUserKey, IndexKey maxUserKey)
+ {
+ foreach (var _ in _btreeIndex!.Range(
+ CreateCompositeKeyBoundary(minUserKey, useMinObjectId: true),
+ CreateCompositeKeyBoundary(maxUserKey, useMinObjectId: false),
IndexDirection.Forward, null))
return true;
return false;
@@ -874,11 +889,12 @@ private static decimal ToDecimalClamped(float f)
/// format, i.e. hold (double)value keys that the exact decimal encoding cannot match; or
/// when this full-text index was written with another analyzer version or holds keys it did not
/// write (e.g. from an older BLite that read it as a plain B-tree); or when this double- or
- /// float-keyed B-tree index holds a key of -0.0 written before -0.0 and +0.0 shared a key.
+ /// float-keyed B-tree index holds a key of -0.0 written before -0.0 and +0.0 shared a key, or
+ /// a key of a NaN with the sign bit clear written before every NaN took the key of .
///
internal bool NeedsKeyFormatMigration =>
(_btreeIndex != null && KeyFormat < IndexMetadata.CurrentKeyFormat && IsDecimalKeyed)
- || HoldsLegacyNegativeZeroKey()
+ || HoldsLegacyDoubleKey()
|| (_fullTextIndex != null && (KeyFormat != FullTextIndex.FormatVersion || _fullTextIndex.HasForeignKeys()));
/// The key format a rebuilt copy of this index is written in.
diff --git a/src/BLite.Core/Indexing/IndexKey.cs b/src/BLite.Core/Indexing/IndexKey.cs
index a36926c..282dde9 100644
--- a/src/BLite.Core/Indexing/IndexKey.cs
+++ b/src/BLite.Core/Indexing/IndexKey.cs
@@ -99,7 +99,13 @@ public IndexKey(double value)
// Result: lexicographic byte order == IEEE 754 numeric order for all finite and ±Inf.
// -0.0 == +0.0, so both take the key of +0.0; -0.0 would otherwise encode just below it (#249).
if (value == 0) value = 0.0;
- var bits = BitConverter.DoubleToInt64Bits(value);
+ // A NaN's sign bit and payload are arbitrary (a computed NaN has the sign bit clear on Arm64,
+ // set on x64), which put NaNs both below -Inf and above +Inf. Every NaN takes one key, that of
+ // the bits of double.NaN (0xFFF8...), which sorts below -Inf, where double.CompareTo orders
+ // NaN (#257). The bits are spelled out so the key does not depend on how a NaN was produced.
+ var bits = double.IsNaN(value)
+ ? unchecked((long)0xFFF8_0000_0000_0000UL)
+ : BitConverter.DoubleToInt64Bits(value);
ulong u = bits < 0 ? ~(ulong)bits : (ulong)bits | 0x8000_0000_0000_0000UL;
_data = [KeyPrefix, (byte)(u >> 56), (byte)(u >> 48), (byte)(u >> 40), (byte)(u >> 32),
(byte)(u >> 24), (byte)(u >> 16), (byte)(u >> 8), (byte)u];
diff --git a/tests/BLite.Tests/IndexKeyNaNTests.cs b/tests/BLite.Tests/IndexKeyNaNTests.cs
new file mode 100644
index 0000000..25998e8
--- /dev/null
+++ b/tests/BLite.Tests/IndexKeyNaNTests.cs
@@ -0,0 +1,141 @@
+using BLite.Bson;
+using BLite.Core.Indexing;
+using BLite.Core.Query;
+using BLite.Core.Storage;
+
+namespace BLite.Tests;
+
+///
+/// Every NaN gets one index key, below -Infinity (#257). The sort-preserving double encoding keyed a
+/// NaN by its bits, so a NaN with the sign bit clear (what Arm64 computes for 0.0 / 0.0) sorted above
+/// +Infinity and one with the sign bit set (, what x64 computes) below
+/// -Infinity: the order of an index-served query depended on the CPU that wrote the value.
+///
+public sealed class IndexKeyNaNTests : IDisposable
+{
+ private static readonly double PositiveNaN = BitConverter.Int64BitsToDouble(0x7FF8_0000_0000_0000L);
+ private static readonly double PayloadNaN = BitConverter.Int64BitsToDouble(0x7FF0_0000_0000_0001L);
+ private static readonly double NegativePayloadNaN = BitConverter.Int64BitsToDouble(unchecked((long)0xFFF0_0000_0000_0001UL));
+
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"blite_nan_{Guid.NewGuid():N}.db");
+
+ public void Dispose()
+ {
+ foreach (var p in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") })
+ try { if (File.Exists(p)) File.Delete(p); } catch { }
+ }
+
+ public static IEnumerable