Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor BITPOS Implementation #1016

Merged
merged 10 commits into from
Feb 14, 2025
Merged

Conversation

vazois
Copy link
Collaborator

@vazois vazois commented Feb 12, 2025

This PR fixes the BITPOS to support BIT search correctly.

@vazois vazois marked this pull request as draft February 12, 2025 23:17
@vazois vazois force-pushed the vazois/bitpos-bitsearch branch 2 times, most recently from 2fbe7cd to 5dde961 Compare February 13, 2025 00:05
@prvyk
Copy link
Contributor

prvyk commented Feb 13, 2025

This is great; I couldn't find time for this. But one thing, there was a test which I couldn't pass last try, and this one doesn't seem to pass it either (maybe I got the test wrong). Try applying this diff manually (or via git apply). It adds a few lines to BitmapBitPosOffsetsTest test.

diff --git a/test/Garnet.test/GarnetBitmapTests.cs b/test/Garnet.test/GarnetBitmapTests.cs
index 44c94f45..55ece807 100644
--- a/test/Garnet.test/GarnetBitmapTests.cs
+++ b/test/Garnet.test/GarnetBitmapTests.cs
@@ -620,6 +620,9 @@ namespace Garnet.test
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
 
                 ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+
+                pos = db.StringBitPosition(key, set, startOffset * 8, endOffset * 8, StringIndexType.Bit);
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset * 8} {endOffset * 8} bit");
             }
 
             // check negative offsets in range
@@ -635,6 +638,9 @@ namespace Garnet.test
                 expectedPos = Bitpos(buf, startOffset, endOffset, set);
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
                 ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+
+                pos = db.StringBitPosition(key, set, startOffset * 8, endOffset * 8, StringIndexType.Bit);
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset * 8} {endOffset * 8} bit");
             }
         }
 

You'd expect referring to the same positions (in different syntax) lead to the same result? Yet the test fails:

Failed BitmapBitPosOffsetsTest [288 ms]
Error Message:
1 False 4032 4040 bit
Assert.That(actual, Is.EqualTo(expected))
Expected: 4037
But was: 4040

Stack Trace:
at NUnit.Framework.Legacy.ClassicAssert.AreEqual(Object expected, Object actual, String message, Object[] args)
at Garnet.test.GarnetBitmapTests.BitmapBitPosOffsetsTest() in garnet/test/Garnet.test/GarnetBitmapTests.cs:line 625

  1. at NUnit.Framework.Legacy.ClassicAssert.AreEqual(Object expected, Object actual, String message, Object[] args)
    at Garnet.test.GarnetBitmapTests.BitmapBitPosOffsetsTest() in garnet/test/Garnet.test/GarnetBitmapTests.cs:line 625

(I also tried with a randomized seed a couple of times just to be sure it always passes and couldn't clear all seeds).

@vazois
Copy link
Collaborator Author

vazois commented Feb 13, 2025

This is great; I couldn't find time for this. But one thing, there was a test which I couldn't pass last try, and this one doesn't seem to pass it either (maybe I got the test wrong). Try applying this diff manually (or via git apply). It adds a few lines to BitmapBitPosOffsetsTest test.

diff --git a/test/Garnet.test/GarnetBitmapTests.cs b/test/Garnet.test/GarnetBitmapTests.cs
index 44c94f45..55ece807 100644
--- a/test/Garnet.test/GarnetBitmapTests.cs
+++ b/test/Garnet.test/GarnetBitmapTests.cs
@@ -620,6 +620,9 @@ namespace Garnet.test
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
 
                 ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+
+                pos = db.StringBitPosition(key, set, startOffset * 8, endOffset * 8, StringIndexType.Bit);
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset * 8} {endOffset * 8} bit");
             }
 
             // check negative offsets in range
@@ -635,6 +638,9 @@ namespace Garnet.test
                 expectedPos = Bitpos(buf, startOffset, endOffset, set);
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
                 ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+
+                pos = db.StringBitPosition(key, set, startOffset * 8, endOffset * 8, StringIndexType.Bit);
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset * 8} {endOffset * 8} bit");
             }
         }
 

You'd expect referring to the same positions (in different syntax) lead to the same result? Yet the test fails:

Failed BitmapBitPosOffsetsTest [288 ms]
Error Message:
1 False 4032 4040 bit
Assert.That(actual, Is.EqualTo(expected))
Expected: 4037
But was: 4040
Stack Trace:
at NUnit.Framework.Legacy.ClassicAssert.AreEqual(Object expected, Object actual, String message, Object[] args)
at Garnet.test.GarnetBitmapTests.BitmapBitPosOffsetsTest() in garnet/test/Garnet.test/GarnetBitmapTests.cs:line 625

  1. at NUnit.Framework.Legacy.ClassicAssert.AreEqual(Object expected, Object actual, String message, Object[] args)
    at Garnet.test.GarnetBitmapTests.BitmapBitPosOffsetsTest() in garnet/test/Garnet.test/GarnetBitmapTests.cs:line 625

(I also tried with a randomized seed a couple of times just to be sure it always passes and couldn't clear all seeds).

Yeah, there were a couple of boundary conditions missing. Thanks for sharing this test. I added the fix and your unit test and it seems to pass now.

@prvyk
Copy link
Contributor

prvyk commented Feb 13, 2025

It turns out the distribution of the default seed is somehow very specific. I tried a few, and they pass consistently except for one case: when startOffset equals endOffset. Didn't find problems when that was excluded.

diff --git a/test/Garnet.test/GarnetBitmapTests.cs b/test/Garnet.test/GarnetBitmapTests.cs
index 827f0e25..b953a005 100644
--- a/test/Garnet.test/GarnetBitmapTests.cs
+++ b/test/Garnet.test/GarnetBitmapTests.cs
@@ -607,6 +607,11 @@ namespace Garnet.test
             long expectedPos;
             long pos;
 
+	     //var r2 = new Random();
+	     //int x = r2.Next();
+	     int x = 1756844280; // 243876438 also fails with same case.
+	     r = new Random(x);
+
             for (var j = 0; j < iter; j++)
             {
                 r.NextBytes(buf);
@@ -619,12 +624,12 @@ namespace Garnet.test
                 expectedPos = Bitpos(buf, startOffset, endOffset, set);
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
 
-                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset} {x}");
 
                 var startBitOffset = startOffset << 3;
                 var endBitOffset = endOffset << 3;
                 pos = db.StringBitPosition(key, set, startBitOffset, endBitOffset, StringIndexType.Bit);
-                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startBitOffset} {endBitOffset} bit");
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startBitOffset} {endBitOffset} bit {x}");
             }
 
             // check negative offsets in range

Result is:

Failed BitmapBitPosOffsetsTest [294 ms]
Error Message:
9 False 3264 3264 bit 1756844280
Assert.That(actual, Is.EqualTo(expected))
Expected: 3265
But was: -1

(bit output actually can differ in redis, but only when end argument isn't applied and the result is extended past end of string. This isn't the case in this test, so likely a bug.)

@vazois
Copy link
Collaborator Author

vazois commented Feb 13, 2025

It turns out the distribution of the default seed is somehow very specific. I tried a few, and they pass consistently except for one case: when startOffset equals endOffset. Didn't find problems when that was excluded.

diff --git a/test/Garnet.test/GarnetBitmapTests.cs b/test/Garnet.test/GarnetBitmapTests.cs
index 827f0e25..b953a005 100644
--- a/test/Garnet.test/GarnetBitmapTests.cs
+++ b/test/Garnet.test/GarnetBitmapTests.cs
@@ -607,6 +607,11 @@ namespace Garnet.test
             long expectedPos;
             long pos;
 
+	     //var r2 = new Random();
+	     //int x = r2.Next();
+	     int x = 1756844280; // 243876438 also fails with same case.
+	     r = new Random(x);
+
             for (var j = 0; j < iter; j++)
             {
                 r.NextBytes(buf);
@@ -619,12 +624,12 @@ namespace Garnet.test
                 expectedPos = Bitpos(buf, startOffset, endOffset, set);
                 pos = db.StringBitPosition(key, set, startOffset, endOffset);
 
-                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset}");
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startOffset} {endOffset} {x}");
 
                 var startBitOffset = startOffset << 3;
                 var endBitOffset = endOffset << 3;
                 pos = db.StringBitPosition(key, set, startBitOffset, endBitOffset, StringIndexType.Bit);
-                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startBitOffset} {endBitOffset} bit");
+                ClassicAssert.AreEqual(expectedPos, pos, $"{j} {set} {startBitOffset} {endBitOffset} bit {x}");
             }
 
             // check negative offsets in range

Result is:

Failed BitmapBitPosOffsetsTest [294 ms]
Error Message:
9 False 3264 3264 bit 1756844280
Assert.That(actual, Is.EqualTo(expected))
Expected: 3265
But was: -1

(bit output actually can differ in redis, but only when end argument isn't applied and the result is extended past end of string. This isn't the case in this test, so likely a bug.)

The seed needs to be specific because the unit tests need to be deterministic.
Overall, the problem with this test is that it compares against a naive implementation of bitpos which is not ideal if that naive implementation has a bug.
In this case, the BitPos implementation that we are comparing against does not work with the BIT option only the BYTE option which explains the inconsistency for a single bit search.
You might want to try another test that sets or clears a bit at a specific position, so you know the expected pos return,
or maybe have a trace of the Redis output.
I added a simple test (a7574ea) to ensure correctness when range is a single bit.

@prvyk
Copy link
Contributor

prvyk commented Feb 13, 2025

I'm aware of the seed requirement (it would be a bad idea to change it), I'm just using this to verify.

Now, the bitops implementation is naive, yet the exception is on the bit test. So the byte test 'passed' with the same result as the naive implementation - which suggests a BYTE range and an equivalent BIT range returned a different result. I don't think this should be happening when the end offset is specified?

Edit: Ah, but the redis BYTE range includes the entire byte when the naive bit range is limited to a bit? So it's not the same range exactly. I was wrong here, sorry.

@vazois
Copy link
Collaborator Author

vazois commented Feb 13, 2025

Edit: Ah, but the redis BYTE range includes the entire byte when the naive bit range is limited to a bit? So it's not the same range exactly. I was wrong here, sorry.

Yes, I was about to explain that. Cool, if you find something else let me know. It has been tricky to test it extensively and you did a great job providing feedback.

@vazois vazois marked this pull request as ready for review February 13, 2025 19:31
@vazois vazois force-pushed the vazois/bitpos-bitsearch branch from a7574ea to 62ece05 Compare February 13, 2025 19:56
@vazois vazois merged commit d7d2bc6 into microsoft:main Feb 14, 2025
15 checks passed
@prvyk
Copy link
Contributor

prvyk commented Feb 14, 2025

Edit: Ah, but the redis BYTE range includes the entire byte when the naive bit range is limited to a bit? So it's not the same range exactly. I was wrong here, sorry.

Yes, I was about to explain that. Cool, if you find something else let me know. It has been tricky to test it extensively and you did a great job providing feedback.

I compared with a Redis implementation and couldn't find differences when start end arguments are given and identical regardless of bit/byte.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants