Skip to content

Commit ceaaf7f

Browse files
nkrokerclaude
andcommitted
fix(bitmap): fix BITOP DIFF/DIFF1/ANDOR when first key missing and add key count validation
- DIFF/DIFF1/ANDOR now correctly treat missing X as zero (Redis semantics) - DIFF/DIFF1/ANDOR now require at least two source keys - Fix clang-format violations - Add edge case tests documenting Redis semantics for missing keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7171f79 commit ceaaf7f

3 files changed

Lines changed: 89 additions & 12 deletions

File tree

src/commands/cmd_bit.cc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ class CommandBitOp : public Commander {
232232
if (op_flag_ == kBitOpNot && args.size() != 4) {
233233
return {Status::RedisInvalidCmd, "BITOP NOT must be called with a single source key."};
234234
}
235+
if ((op_flag_ == kBitOpDiff || op_flag_ == kBitOpDiff1 || op_flag_ == kBitOpAndOr) && args.size() < 5) {
236+
return {Status::RedisInvalidCmd, "BITOP DIFF/DIFF1/ANDOR must be called with at least two source keys."};
237+
}
235238

236239
return Commander::Parse(args);
237240
}

src/types/redis_bitmap.cc

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,10 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
483483
}
484484
size_t num_keys = meta_pairs.size();
485485

486+
// Determine if the first source key (X) exists in meta_pairs.
487+
// meta_pairs preserves op_keys order for existing keys, so meta_pairs[0] is X iff X exists.
488+
const bool first_key_exists = !meta_pairs.empty() && meta_pairs[0].first == AppendNamespacePrefix(op_keys[0]);
489+
486490
auto batch = storage_->GetWriteBatchBase();
487491
if (max_bitmap_size == 0) {
488492
/* Compute the bit operation, if all bitmap is empty. cleanup the dest bitmap. */
@@ -499,9 +503,10 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
499503
if (!s.ok()) return s;
500504

501505
BitmapMetadata res_metadata;
502-
// If the operation is AND and the number of keys is less than the number of op_keys,
503-
// we can skip setting the subkeys of the result bitmap and just set the metadata.
504-
const bool can_skip_op = op_flag == kBitOpAnd && num_keys != op_keys.size();
506+
// AND: any missing key means result is all zeros.
507+
// DIFF/ANDOR: missing X means result is all zeros (X=0 → X & anything = 0).
508+
const bool can_skip_op = (op_flag == kBitOpAnd && num_keys != op_keys.size()) ||
509+
((op_flag == kBitOpDiff || op_flag == kBitOpAndOr) && !first_key_exists);
505510
if (!can_skip_op) {
506511
uint64_t stop_index = (max_bitmap_size - 1) / kBitmapSegmentBytes;
507512
std::unique_ptr<unsigned char[]> frag_res(new unsigned char[kBitmapSegmentBytes]);
@@ -510,6 +515,9 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
510515
for (uint64_t frag_index = 0; frag_index <= stop_index; frag_index++) {
511516
std::vector<rocksdb::PinnableSlice> fragments;
512517
uint16_t frag_maxlen = 0, frag_minlen = 0;
518+
// Tracks whether fragments[0] is X's fragment (only relevant for DIFF/DIFF1/ANDOR).
519+
bool x_frag_is_first = false;
520+
bool is_first_meta_pair = true;
513521
for (const auto &meta_pair : meta_pairs) {
514522
std::string sub_key = InternalKey(meta_pair.first, std::to_string(frag_index * kBitmapSegmentBytes),
515523
meta_pair.second.version, storage_->IsSlotIdEncoded())
@@ -521,16 +529,24 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
521529
}
522530
if (s.IsNotFound()) {
523531
if (op_flag == kBitOpAnd) {
524-
// If any of the input bitmaps is empty, the result of AND
525-
// is empty.
532+
// If any of the input bitmaps is empty, the result of AND is empty.
533+
frag_maxlen = 0;
534+
break;
535+
}
536+
// For DIFF/ANDOR: X's segment missing means result is 0 for this segment.
537+
if ((op_flag == kBitOpDiff || op_flag == kBitOpAndOr) && first_key_exists && is_first_meta_pair) {
526538
frag_maxlen = 0;
527539
break;
528540
}
529541
} else {
530542
if (frag_maxlen < fragment.size()) frag_maxlen = fragment.size();
531543
if (fragment.size() < frag_minlen || frag_minlen == 0) frag_minlen = fragment.size();
544+
if (is_first_meta_pair && first_key_exists && fragments.empty()) {
545+
x_frag_is_first = true;
546+
}
532547
fragments.emplace_back(std::move(fragment));
533548
}
549+
is_first_meta_pair = false;
534550
}
535551

536552
size_t frag_numkeys = fragments.size();
@@ -548,8 +564,8 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
548564
* result in GCC compiling the code using multiple-words load/store
549565
* operations that are not supported even in ARM >= v6. */
550566
#ifndef USE_ALIGNED_ACCESS
551-
if (frag_minlen >= sizeof(uint64_t) * 4 && frag_numkeys <= 16 &&
552-
op_flag != kBitOpDiff && op_flag != kBitOpDiff1 && op_flag != kBitOpAndOr && op_flag != kBitOpOne) {
567+
if (frag_minlen >= sizeof(uint64_t) * 4 && frag_numkeys <= 16 && op_flag != kBitOpDiff &&
568+
op_flag != kBitOpDiff1 && op_flag != kBitOpAndOr && op_flag != kBitOpOne) {
553569
auto *lres = reinterpret_cast<uint64_t *>(frag_res.get());
554570
const uint64_t *lp[16];
555571
for (uint64_t i = 0; i < frag_numkeys; i++) {
@@ -594,23 +610,30 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
594610
}
595611
#endif
596612

613+
// For DIFF/DIFF1/ANDOR: y_start is where Y fragments begin in fragments[].
614+
// When x_frag_is_first=true, fragments[0]=X and Ys start at 1.
615+
// When x_frag_is_first=false, X=0 (missing) and all fragments are Ys.
616+
const uint64_t y_start = x_frag_is_first ? 1 : 0;
617+
597618
uint8_t output = 0, byte = 0;
598619
for (; j < frag_maxlen; j++) {
599620
output = (fragments[0].size() <= j) ? 0 : static_cast<uint8_t>(fragments[0][j]);
600621
if (op_flag == kBitOpNot) {
601622
output = ~output;
602623
} else if (op_flag == kBitOpDiff1) {
603-
// DIFF1: bits set in any Y but not in X (X = fragments[0])
624+
// DIFF1: bits set in any Y but not in X
625+
uint8_t x_byte = x_frag_is_first ? output : 0;
604626
uint8_t or_rest = 0;
605-
for (uint64_t i = 1; i < frag_numkeys; i++) {
627+
for (uint64_t i = y_start; i < frag_numkeys; i++) {
606628
byte = (fragments[i].size() <= j) ? 0 : static_cast<uint8_t>(fragments[i][j]);
607629
or_rest |= byte;
608630
}
609-
output = or_rest & ~output;
631+
output = or_rest & ~x_byte;
610632
} else if (op_flag == kBitOpAndOr) {
611633
// ANDOR: bits set in X AND in at least one Y
634+
// (ANDOR with missing X is already handled by can_skip_op)
612635
uint8_t or_rest = 0;
613-
for (uint64_t i = 1; i < frag_numkeys; i++) {
636+
for (uint64_t i = y_start; i < frag_numkeys; i++) {
614637
byte = (fragments[i].size() <= j) ? 0 : static_cast<uint8_t>(fragments[i][j]);
615638
or_rest |= byte;
616639
}
@@ -626,7 +649,12 @@ rocksdb::Status Bitmap::BitOp(engine::Context &ctx, BitOpFlags op_flag, const st
626649
}
627650
output = xor_acc & ~and_acc;
628651
} else {
629-
for (uint64_t i = 1; i < frag_numkeys; i++) {
652+
// For DIFF: X = fragments[0] if x_frag_is_first, else X = 0.
653+
// DIFF with missing X is handled by can_skip_op, so x_frag_is_first=true here.
654+
if (op_flag == kBitOpDiff && !x_frag_is_first) {
655+
output = 0;
656+
}
657+
for (uint64_t i = (op_flag == kBitOpDiff ? y_start : 1); i < frag_numkeys; i++) {
630658
byte = (fragments[i].size() <= j) ? 0 : static_cast<uint8_t>(fragments[i][j]);
631659
switch (op_flag) {
632660
case kBitOpAnd:

tests/gocase/unit/type/bitmap/bitmap_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,52 @@ func TestBitmap(t *testing.T) {
469469
require.EqualValues(t, SimulateBitOp(ANDOR, []byte("\xff"), []byte("\x0f"), []byte("\xf0")), rdb.Get(ctx, "dest").Val())
470470
})
471471

472+
// Redis semantics: when X (first source key) does not exist, it is treated as
473+
// a stream of zero bytes. So DIFF(nosuch, y) = 0 & ~y = 0, not y.
474+
t.Run("BITOP DIFF missing first key X treated as zero (Redis semantics)", func(t *testing.T) {
475+
require.NoError(t, rdb.FlushDB(ctx).Err())
476+
Set2SetBit(t, rdb, ctx, "y", []byte("\x0f"))
477+
require.NoError(t, rdb.Do(ctx, "BITOP", "DIFF", "dest", "nosuch", "y").Err())
478+
// X=0x00, Y=0x0f -> DIFF = 0x00 & ~0x0f = 0x00
479+
require.EqualValues(t, SimulateBitOp(DIFF, []byte("\x00"), []byte("\x0f")), rdb.Get(ctx, "dest").Val())
480+
})
481+
482+
t.Run("BITOP DIFF1 missing first key X treated as zero (Redis semantics)", func(t *testing.T) {
483+
require.NoError(t, rdb.FlushDB(ctx).Err())
484+
Set2SetBit(t, rdb, ctx, "y", []byte("\x0f"))
485+
require.NoError(t, rdb.Do(ctx, "BITOP", "DIFF1", "dest", "nosuch", "y").Err())
486+
// X=0x00, Y=0x0f -> DIFF1 = 0x0f & ~0x00 = 0x0f
487+
require.EqualValues(t, SimulateBitOp(DIFF1, []byte("\x00"), []byte("\x0f")), rdb.Get(ctx, "dest").Val())
488+
})
489+
490+
t.Run("BITOP ANDOR missing first key X treated as zero (Redis semantics)", func(t *testing.T) {
491+
require.NoError(t, rdb.FlushDB(ctx).Err())
492+
Set2SetBit(t, rdb, ctx, "y", []byte("\x0f"))
493+
require.NoError(t, rdb.Do(ctx, "BITOP", "ANDOR", "dest", "nosuch", "y").Err())
494+
// X=0x00, Y=0x0f -> ANDOR = 0x00 & 0x0f = 0x00
495+
require.EqualValues(t, SimulateBitOp(ANDOR, []byte("\x00"), []byte("\x0f")), rdb.Get(ctx, "dest").Val())
496+
})
497+
498+
// Redis requires at least 2 source keys for DIFF, DIFF1, ANDOR (X + at least one Y).
499+
// Calling with only X and no Y keys should return an error.
500+
t.Run("BITOP DIFF requires at least one Y key (Redis semantics)", func(t *testing.T) {
501+
require.NoError(t, rdb.FlushDB(ctx).Err())
502+
Set2SetBit(t, rdb, ctx, "x", []byte("\xaa"))
503+
util.ErrorRegexp(t, rdb.Do(ctx, "BITOP", "DIFF", "dest", "x").Err(), ".*")
504+
})
505+
506+
t.Run("BITOP DIFF1 requires at least one Y key (Redis semantics)", func(t *testing.T) {
507+
require.NoError(t, rdb.FlushDB(ctx).Err())
508+
Set2SetBit(t, rdb, ctx, "x", []byte("\xaa"))
509+
util.ErrorRegexp(t, rdb.Do(ctx, "BITOP", "DIFF1", "dest", "x").Err(), ".*")
510+
})
511+
512+
t.Run("BITOP ANDOR requires at least one Y key (Redis semantics)", func(t *testing.T) {
513+
require.NoError(t, rdb.FlushDB(ctx).Err())
514+
Set2SetBit(t, rdb, ctx, "x", []byte("\xaa"))
515+
util.ErrorRegexp(t, rdb.Do(ctx, "BITOP", "ANDOR", "dest", "x").Err(), ".*")
516+
})
517+
472518
t.Run("BITOP ONE basic", func(t *testing.T) {
473519
require.NoError(t, rdb.FlushDB(ctx).Err())
474520
// A=0xff, B=0x0f -> ONE = 0xf0 (bits set in exactly one key)

0 commit comments

Comments
 (0)