Skip to content

Commit f55dbb3

Browse files
committed
fix(stream): harden XACKDEL parsing and replication
1 parent b733248 commit f55dbb3

10 files changed

Lines changed: 819 additions & 130 deletions

File tree

src/cluster/batch_sender.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Status BatchSender::Put(rocksdb::ColumnFamilyHandle *cf, const rocksdb::Slice &k
3838
return {Status::NotOK, fmt::format("failed to put key value to migration batch, {}", s.ToString())};
3939
}
4040

41+
pending_logdata_only_ = false;
4142
pending_entries_++;
4243
entries_num_++;
4344
return Status::OK();
@@ -48,6 +49,7 @@ Status BatchSender::Delete(rocksdb::ColumnFamilyHandle *cf, const rocksdb::Slice
4849
if (!s.ok()) {
4950
return {Status::NotOK, fmt::format("failed to delete key from migration batch, {}", s.ToString())};
5051
}
52+
pending_logdata_only_ = false;
5153
pending_entries_++;
5254
entries_num_++;
5355
return Status::OK();
@@ -58,6 +60,7 @@ Status BatchSender::PutLogData(const rocksdb::Slice &blob) {
5860
if (!s.ok()) {
5961
return {Status::NotOK, fmt::format("failed to put log data to migration batch, {}", s.ToString())};
6062
}
63+
pending_logdata_only_ = true;
6164
pending_entries_++;
6265
entries_num_++;
6366
return Status::OK();
@@ -89,6 +92,7 @@ Status BatchSender::Send() {
8992
sent_bytes_ += write_batch_.GetDataSize();
9093
sent_batches_num_++;
9194
pending_entries_ = 0;
95+
pending_logdata_only_ = false;
9296
write_batch_.Clear();
9397
return Status::OK();
9498
}

src/cluster/batch_sender.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ class BatchSender {
4646
void SetMaxBytes(size_t max_bytes) {
4747
if (max_bytes_ != max_bytes) max_bytes_ = max_bytes;
4848
}
49-
bool IsFull() const { return write_batch_.GetDataSize() >= max_bytes_; }
49+
// A log-data-only batch must wait for the first data record;
50+
// otherwise the receiver would see metadata without matching writes.
51+
bool IsFull() const { return !pending_logdata_only_ && write_batch_.GetDataSize() >= max_bytes_; }
5052
uint64_t GetSentBytes() const { return sent_bytes_; }
5153
uint32_t GetSentBatchesNum() const { return sent_batches_num_; }
5254
uint32_t GetEntriesNum() const { return entries_num_; }
@@ -62,6 +64,7 @@ class BatchSender {
6264
uint32_t sent_batches_num_ = 0;
6365
uint32_t entries_num_ = 0;
6466
uint32_t pending_entries_ = 0;
67+
bool pending_logdata_only_ = false;
6568

6669
int dst_fd_;
6770
size_t max_bytes_;

src/commands/cmd_stream.cc

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <limits>
2323
#include <memory>
2424
#include <stdexcept>
25+
#include <string_view>
2526

2627
#include "command_parser.h"
2728
#include "commander.h"
@@ -49,6 +50,48 @@ CommandKeyRange ParseStreamReadRange(const std::vector<std::string> &args, uint3
4950
range.last_key = range.first_key + stream_size - 1;
5051
return range;
5152
}
53+
54+
// Redis accepts only canonical positive decimal numids here;
55+
// reject forms like +1 or 01 before integer parsing.
56+
bool IsXAckDelNumIDs(std::string_view input) {
57+
if (input.empty() || input[0] < '1' || input[0] > '9') return false;
58+
59+
return std::all_of(input.begin() + 1, input.end(), [](char c) { return c >= '0' && c <= '9'; });
60+
}
61+
62+
StatusOr<uint64_t> ParseXAckDelStreamEntryIDComponent(std::string_view input, bool allow_negative_zero) {
63+
if (input.empty()) return {Status::RedisParseErr, redis::kErrInvalidEntryIdSpecified};
64+
65+
if (input[0] == '+') {
66+
input.remove_prefix(1);
67+
} else if (input[0] == '-') {
68+
if (!allow_negative_zero) return {Status::RedisParseErr, redis::kErrInvalidEntryIdSpecified};
69+
input.remove_prefix(1);
70+
if (input.empty() || !std::all_of(input.begin(), input.end(), [](char c) { return c == '0'; })) {
71+
return {Status::RedisParseErr, redis::kErrInvalidEntryIdSpecified};
72+
}
73+
return 0;
74+
}
75+
76+
auto parsed = ParseInt<uint64_t>(input, 10);
77+
if (!parsed) return {Status::RedisParseErr, redis::kErrInvalidEntryIdSpecified};
78+
return *parsed;
79+
}
80+
81+
Status ParseXAckDelStreamEntryID(const std::string &input, redis::StreamEntryID *id) {
82+
auto pos = input.find('-');
83+
if (pos != std::string::npos) {
84+
auto ms = GET_OR_RET(ParseXAckDelStreamEntryIDComponent(std::string_view(input).substr(0, pos), false));
85+
auto seq = GET_OR_RET(ParseXAckDelStreamEntryIDComponent(std::string_view(input).substr(pos + 1), true));
86+
id->ms = ms;
87+
id->seq = seq;
88+
} else {
89+
auto ms = GET_OR_RET(ParseXAckDelStreamEntryIDComponent(input, false));
90+
id->ms = ms;
91+
id->seq = 0;
92+
}
93+
return Status::OK();
94+
}
5295
} // namespace
5396

5497
void AddStreamEntriesToResponse(std::string *output, const std::vector<StreamEntry> &entries) {
@@ -276,44 +319,57 @@ class CommandXAckDel : public Commander {
276319
group_name_ = GET_OR_RET(parser.TakeStr());
277320

278321
option_ = redis::StreamDeleteOption::KeepRef;
322+
bool has_option = false;
323+
bool has_ids = false;
279324

280-
while (parser.Good() && !util::EqualICase(parser.RawPeek(), "IDS")) {
325+
while (parser.Good()) {
281326
if (parser.EatEqICase("KEEPREF")) {
327+
if (has_option) return parser.InvalidSyntax();
282328
option_ = redis::StreamDeleteOption::KeepRef;
329+
has_option = true;
283330
} else if (parser.EatEqICase("DELREF")) {
331+
if (has_option) return parser.InvalidSyntax();
284332
option_ = redis::StreamDeleteOption::DelRef;
333+
has_option = true;
285334
} else if (parser.EatEqICase("ACKED")) {
335+
if (has_option) return parser.InvalidSyntax();
286336
option_ = redis::StreamDeleteOption::Acked;
337+
has_option = true;
338+
} else if (parser.EatEqICase("IDS")) {
339+
has_ids = true;
340+
341+
if (!parser.Good() || !IsXAckDelNumIDs(parser.RawPeek())) {
342+
return {Status::RedisParseErr, "Number of IDs must be a positive integer"};
343+
}
344+
auto numids_result = parser.TakeInt<int64_t>();
345+
if (!numids_result.IsOK()) {
346+
return {Status::RedisParseErr, "Number of IDs must be a positive integer"};
347+
}
348+
int64_t numids = numids_result.GetValue();
349+
350+
if (parser.Remains() < static_cast<size_t>(numids)) {
351+
return {Status::RedisParseErr, "The `numids` parameter must match the number of arguments"};
352+
}
353+
354+
std::vector<redis::StreamEntryID> entry_ids;
355+
entry_ids.reserve(static_cast<size_t>(numids));
356+
for (int64_t i = 0; i < numids; i++) {
357+
auto id_str = GET_OR_RET(parser.TakeStr());
358+
redis::StreamEntryID id;
359+
auto s = ParseXAckDelStreamEntryID(id_str, &id);
360+
if (!s.IsOK()) return s;
361+
entry_ids.emplace_back(id);
362+
}
363+
entry_ids_ = std::move(entry_ids);
287364
} else {
288365
return parser.InvalidSyntax();
289366
}
290367
}
291368

292-
if (!parser.EatEqICase("IDS")) {
369+
if (!has_ids) {
293370
return {Status::RedisParseErr, "syntax error, expected IDS keyword"};
294371
}
295372

296-
auto numids_result = parser.TakeInt<int64_t>();
297-
if (!numids_result.IsOK()) {
298-
return {Status::RedisParseErr, errValueNotInteger};
299-
}
300-
int64_t numids = numids_result.GetValue();
301-
if (numids <= 0) {
302-
return {Status::RedisParseErr, "numids must be positive"};
303-
}
304-
305-
for (int64_t i = 0; i < numids; i++) {
306-
auto id_str = GET_OR_RET(parser.TakeStr());
307-
redis::StreamEntryID id;
308-
auto s = ParseStreamEntryID(id_str, &id);
309-
if (!s.IsOK()) return s;
310-
entry_ids_.emplace_back(id);
311-
}
312-
313-
if (parser.Good()) {
314-
return {Status::RedisParseErr, "syntax error, unexpected trailing arguments after IDs"};
315-
}
316-
317373
return Status::OK();
318374
}
319375

src/storage/batch_extractor.cc

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ void WriteBatchExtractor::LogData(const rocksdb::Slice &blob) {
3737
}
3838
} else {
3939
// Redis type log data
40+
// Reset state first so malformed log data cannot reuse XACKDEL dedup keys.
41+
log_data_ = redis::WriteBatchLogData();
42+
first_seen_ = true;
43+
seen_xackdel_xack_keys_.clear();
44+
seen_xackdel_xdel_keys_.clear();
4045
if (auto s = log_data_.Decode(blob); !s.IsOK()) {
4146
WARN("Failed to decode Redis type log: {}", s.Msg());
42-
} else {
43-
seen_xackdel_entry_keys_.clear();
4447
}
4548
}
4649
}
@@ -417,10 +420,11 @@ rocksdb::Status WriteBatchExtractor::DeleteCF(uint32_t column_family_id, const S
417420
GetFixed64(&encoded_id, &entry_id.ms);
418421

419422
if (entry_id.ms == UINT64_MAX) {
420-
// PEL / group / consumer metadata sub-key. Only PEL deletions
421-
// produce XACKDEL commands for replication.
423+
// XACKDEL physical replay emits XACK for each PEL deletion,
424+
// using the actual group encoded in the PEL key.
422425
auto args = log_data_.GetArguments();
423426
if (!args->empty() && (*args)[0] == "XACKDEL" && args->size() >= 3) {
427+
// Skip malformed internal stream keys instead of emitting partial replay commands.
424428
uint8_t type_delimiter = 0;
425429
if (!GetFixed8(&encoded_id, &type_delimiter)) {
426430
return rocksdb::Status::OK();
@@ -433,6 +437,7 @@ rocksdb::Status WriteBatchExtractor::DeleteCF(uint32_t column_family_id, const S
433437
if (group_name_len > encoded_id.size() || encoded_id.size() - group_name_len < 16) {
434438
return rocksdb::Status::OK();
435439
}
440+
std::string group_name = encoded_id.ToString().substr(0, group_name_len);
436441
encoded_id.remove_prefix(group_name_len);
437442

438443
if (!GetFixed64(&encoded_id, &entry_id.ms) || !GetFixed64(&encoded_id, &entry_id.seq)) {
@@ -446,9 +451,9 @@ rocksdb::Status WriteBatchExtractor::DeleteCF(uint32_t column_family_id, const S
446451
return rocksdb::Status::OK();
447452
}
448453
ns = ikey.GetNamespace().ToString();
449-
std::string dedup_key = ns + '\0' + user_key + '\0' + (*args)[1] + '\0' + entry_id_str;
450-
if (seen_xackdel_entry_keys_.insert(std::move(dedup_key)).second) {
451-
command_args = {(*args)[0], user_key, (*args)[1], (*args)[2], "IDS", "1", entry_id_str};
454+
std::string dedup_key = ns + '\0' + user_key + '\0' + group_name + '\0' + entry_id_str;
455+
if (seen_xackdel_xack_keys_.insert(std::move(dedup_key)).second) {
456+
command_args = {"XACK", user_key, group_name, entry_id_str};
452457
resp_commands_[ns].emplace_back(redis::ArrayOfBulkStrings(command_args));
453458
}
454459
}
@@ -469,9 +474,9 @@ rocksdb::Status WriteBatchExtractor::DeleteCF(uint32_t column_family_id, const S
469474
auto args = log_data_.GetArguments();
470475
if (!args->empty()) {
471476
if ((*args)[0] == "XACKDEL" && args->size() >= 3) {
472-
std::string dedup_key = ns + '\0' + user_key + '\0' + (*args)[1] + '\0' + entry_id_str;
473-
if (seen_xackdel_entry_keys_.insert(std::move(dedup_key)).second) {
474-
command_args = {(*args)[0], user_key, (*args)[1], (*args)[2], "IDS", "1", entry_id_str};
477+
std::string dedup_key = ns + '\0' + user_key + '\0' + entry_id_str;
478+
if (seen_xackdel_xdel_keys_.insert(std::move(dedup_key)).second) {
479+
command_args = {"XDEL", user_key, entry_id_str};
475480
}
476481
} else {
477482
command_args = {"XDEL", user_key, entry_id_str};

src/storage/batch_extractor.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,6 @@ class WriteBatchExtractor : public rocksdb::WriteBatch::Handler {
5555
bool is_slot_id_encoded_ = false;
5656
SlotRange slot_range_;
5757
bool to_redis_;
58-
std::unordered_set<std::string> seen_xackdel_entry_keys_;
58+
std::unordered_set<std::string> seen_xackdel_xack_keys_;
59+
std::unordered_set<std::string> seen_xackdel_xdel_keys_;
5960
};

src/types/redis_stream.cc

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include <vector>
2929

3030
#include "db_util.h"
31+
#include "scope_exit.h"
3132
#include "string_util.h"
3233
#include "time_util.h"
3334

@@ -466,6 +467,7 @@ rocksdb::Status Stream::flushPendingNumberUpdates(
466467
engine::Context &ctx, const std::string &ns_key, const StreamMetadata &metadata, rocksdb::WriteBatchBase *batch,
467468
const std::map<std::string, uint64_t> &group_pending_decrements,
468469
const std::map<std::string, std::map<std::string, uint64_t>> &consumer_pending_decrements) {
470+
// Saturate pending counters to tolerate stale metadata without uint64_t underflow.
469471
for (const auto &[group_name, decrement] : group_pending_decrements) {
470472
auto group_key = internalKeyFromGroupName(ns_key, metadata, group_name);
471473
std::string group_value;
@@ -475,7 +477,7 @@ rocksdb::Status Stream::flushPendingNumberUpdates(
475477
}
476478
if (s.ok()) {
477479
auto group_meta = decodeStreamConsumerGroupMetadataValue(group_value);
478-
group_meta.pending_number -= decrement;
480+
group_meta.pending_number = group_meta.pending_number > decrement ? group_meta.pending_number - decrement : 0;
479481
s = batch->Put(stream_cf_handle_, group_key, encodeStreamConsumerGroupMetadataValue(group_meta));
480482
if (!s.ok()) return s;
481483
}
@@ -491,7 +493,8 @@ rocksdb::Status Stream::flushPendingNumberUpdates(
491493
}
492494
if (s.ok()) {
493495
auto consumer_meta = decodeStreamConsumerMetadataValue(consumer_value);
494-
consumer_meta.pending_number -= decrement;
496+
consumer_meta.pending_number =
497+
consumer_meta.pending_number > decrement ? consumer_meta.pending_number - decrement : 0;
495498
s = batch->Put(stream_cf_handle_, consumer_key, encodeStreamConsumerMetadataValue(consumer_meta));
496499
if (!s.ok()) return s;
497500
}
@@ -560,6 +563,11 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
560563
}
561564

562565
auto batch = storage_->GetWriteBatchBase();
566+
// PutLogData is added before knowing whether any ID mutates data;
567+
// roll it back on no-op or error to avoid phantom replay.
568+
batch->SetSavePoint();
569+
auto rollback_batch = MakeScopeExit([&batch] { batch->RollbackToSavePoint().PermitUncheckedError(); });
570+
563571
std::string option_str;
564572
switch (option) {
565573
case StreamDeleteOption::DelRef:
@@ -626,11 +634,21 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
626634
StreamEntryID original_first_entry_id = metadata.first_entry_id;
627635
StreamEntryID original_last_entry_id = metadata.last_entry_id;
628636

637+
std::vector<std::string> other_groups;
638+
if (need_groups) {
639+
other_groups.reserve(all_groups.empty() ? 0 : all_groups.size() - 1);
640+
for (const auto &candidate_group_name : all_groups) {
641+
if (candidate_group_name != group_name) other_groups.push_back(candidate_group_name);
642+
}
643+
}
644+
629645
for (size_t i = 0; i < ids.size(); i++) {
630646
const auto &id = ids[i];
631647

632648
std::string entry_key = internalKeyFromEntryID(ns_key, metadata, id);
633649

650+
// Each ID can mutate the stream at most once per command;
651+
// duplicates report not-found after the first attempt.
634652
if (!seen_entry_keys.insert(entry_key).second) {
635653
(*results)[i] = static_cast<int>(StreamEntryDeleteResult::kEntryNotFound);
636654
continue;
@@ -663,13 +681,6 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
663681
}
664682
bool stream_entry_exists = s.ok();
665683

666-
std::vector<std::string> other_groups;
667-
if (need_groups) {
668-
for (const auto &candidate_group_name : all_groups) {
669-
if (candidate_group_name != group_name) other_groups.push_back(candidate_group_name);
670-
}
671-
}
672-
673684
if (!stream_entry_exists) {
674685
if (option == StreamDeleteOption::DelRef && !other_groups.empty()) {
675686
s = cleanPelFromAllGroups(ctx, ns_key, metadata, id, batch.Get(), &batch_modified, other_groups,
@@ -777,9 +788,11 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
777788
if (!s.ok()) return s;
778789
}
779790

791+
// Saturate pending counters to tolerate stale metadata without uint64_t underflow.
780792
if (acknowledged_cnt > 0) {
781793
StreamConsumerGroupMetadata group_metadata = decodeStreamConsumerGroupMetadataValue(get_group_value);
782-
group_metadata.pending_number -= acknowledged_cnt;
794+
group_metadata.pending_number =
795+
group_metadata.pending_number > acknowledged_cnt ? group_metadata.pending_number - acknowledged_cnt : 0;
783796
std::string group_value = encodeStreamConsumerGroupMetadataValue(group_metadata);
784797
s = batch->Put(stream_cf_handle_, group_key, group_value);
785798
if (!s.ok()) return s;
@@ -793,7 +806,8 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
793806
}
794807
if (s.ok()) {
795808
auto consumer_metadata = decodeStreamConsumerMetadataValue(consumer_meta_original);
796-
consumer_metadata.pending_number -= ack_count;
809+
consumer_metadata.pending_number =
810+
consumer_metadata.pending_number > ack_count ? consumer_metadata.pending_number - ack_count : 0;
797811
s = batch->Put(stream_cf_handle_, consumer_meta_key, encodeStreamConsumerMetadataValue(consumer_metadata));
798812
if (!s.ok()) return s;
799813
}
@@ -811,6 +825,10 @@ rocksdb::Status Stream::DeleteEntriesAndAck(engine::Context &ctx, const Slice &s
811825
return rocksdb::Status::OK();
812826
}
813827

828+
s = batch->PopSavePoint();
829+
if (!s.ok()) return s;
830+
rollback_batch.Disable();
831+
814832
return storage_->Write(ctx, storage_->DefaultWriteOptions(), batch->GetWriteBatch());
815833
}
816834

src/types/redis_stream_base.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
namespace redis {
3434

35+
extern const char *kErrInvalidEntryIdSpecified;
36+
3537
struct StreamEntryID {
3638
uint64_t ms = 0;
3739
uint64_t seq = 0;

0 commit comments

Comments
 (0)