fix(mq-starter-core): 打烊二次检查拒收时复位 processing 标记
review 发现 #6(终审残余观察①的 follow-up):批量分支锁内的 closed 二次 检查发生在 tryAcquire 之后,拒收抛 InProgress 时去重标记已置 processing 却无人复位。重投每次都撞 stale 标记再抛 InProgress,白烧重试计数直到 TTL 过期;redis-stream 端若 deliveryCount 先超 maxRetry 且未配 errorHandler, 业务一次没跑就被判耗尽、静默丢弃。 修复:锁内只判定拒收,锁外 markFail 复位标记后再抛(markFail 是 Redis 往返, 不进 buffer 锁)。dispatch 入口的打烊快速检查在 tryAcquire 之前,不受影响。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,18 +83,29 @@ public class MqConsumeDispatcher {
|
|||||||
}
|
}
|
||||||
// 攒批:满 batchSize 才 flush。锁内只攒/取快照,消费放锁外,避免持锁做 IO。
|
// 攒批:满 batchSize 才 flush。锁内只攒/取快照,消费放锁外,避免持锁做 IO。
|
||||||
List<MqMessage> flush = null;
|
List<MqMessage> flush = null;
|
||||||
|
boolean rejected = false;
|
||||||
synchronized (buffer) {
|
synchronized (buffer) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
throw new MqMessageInProgressException(msg.getKey());
|
rejected = true;
|
||||||
|
} else {
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
batchStartAtMs = clock.getAsLong();
|
||||||
|
}
|
||||||
|
buffer.add(msg);
|
||||||
|
if (buffer.size() >= batchSize) {
|
||||||
|
flush = new ArrayList<>(buffer);
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (buffer.isEmpty()) {
|
}
|
||||||
batchStartAtMs = clock.getAsLong();
|
if (rejected) {
|
||||||
}
|
// 打烊二次检查发生在 tryAcquire 之后:拒收前必须把 processing 标记复位(markFail 是
|
||||||
buffer.add(msg);
|
// Redis 往返,放 buffer 锁外做),否则重投每次都撞 stale 标记再抛 InProgress,白烧重试
|
||||||
if (buffer.size() >= batchSize) {
|
// 计数——redis-stream 端可能业务一次没跑就被判耗尽。
|
||||||
flush = new ArrayList<>(buffer);
|
if (idempotent && store != null) {
|
||||||
buffer.clear();
|
store.markFail(msg.getKey());
|
||||||
}
|
}
|
||||||
|
throw new MqMessageInProgressException(msg.getKey());
|
||||||
}
|
}
|
||||||
if (flush != null) {
|
if (flush != null) {
|
||||||
doFlush(flush);
|
doFlush(flush);
|
||||||
|
|||||||
@@ -416,6 +416,37 @@ class MqConsumeDispatcherTest {
|
|||||||
assertEquals(1, consumed.size(), "拒收的消息不得进入缓冲(再次强刷无动作)");
|
assertEquals(1, consumed.size(), "拒收的消息不得进入缓冲(再次强刷无动作)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closedDuringBatchDispatch_afterAcquire_resetsProcessingMark() {
|
||||||
|
// 竞态窗口:dispatch 已过打烊快速检查、tryAcquire 已把标记置 processing,
|
||||||
|
// 此刻停机 forceFlush 打烊 → 批量分支锁内二次检查拒收抛 InProgress。
|
||||||
|
// 拒收前必须 markFail 复位标记:否则重投每次都撞 stale processing 再抛 InProgress,
|
||||||
|
// 白烧重试计数(redis-stream 端 deliveryCount 超 maxRetry 且未配 errorHandler 时,
|
||||||
|
// 业务一次没跑就被判耗尽、静默丢弃)。
|
||||||
|
List<String> failed = new ArrayList<>();
|
||||||
|
MqConsumeDispatcher[] ref = new MqConsumeDispatcher[1];
|
||||||
|
MqIdempotentStore store = new MqIdempotentStore() {
|
||||||
|
@Override public boolean tryAcquire(String key) {
|
||||||
|
ref[0].forceFlush(); // 模拟 tryAcquire 成功瞬间停机打烊
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
@Override public void markSuccess(String key) { }
|
||||||
|
@Override public void markFail(String key) { failed.add(key); }
|
||||||
|
};
|
||||||
|
MqConsumeDispatcher d = MqConsumeDispatcher.builder()
|
||||||
|
.batchSize(3).batchTimeoutMs(60_000)
|
||||||
|
.idempotent(true).store(store)
|
||||||
|
.consumer(batch -> { })
|
||||||
|
.build();
|
||||||
|
ref[0] = d;
|
||||||
|
|
||||||
|
assertThrows(MqMessageInProgressException.class,
|
||||||
|
() -> d.dispatch(MqMessage.of("k1", "", "a")),
|
||||||
|
"打烊后批量路径锁内二次检查应拒收");
|
||||||
|
assertEquals(Arrays.asList("k1"), failed,
|
||||||
|
"已 tryAcquire 的拒收必须 markFail 复位,否则重投一直撞 processing 标记");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void closed_rejectsSingleModeToo_beforeTouchingDedupStore() {
|
void closed_rejectsSingleModeToo_beforeTouchingDedupStore() {
|
||||||
List<String> acquired = new ArrayList<>();
|
List<String> acquired = new ArrayList<>();
|
||||||
|
|||||||
Reference in New Issue
Block a user