fix(wecom): replace dedupe map rotation with circular queue
The previous dedupe map rotation logic completely cleared the map when it reached max size, causing an 'amnesia cliff' where immediately arriving duplicates of just-forgotten messages would be processed. This change replaces that with a MessageDeduplicator struct that uses a circular queue (ring buffer) to track insertions. When the limit is reached, it only evicts the absolute oldest message from the map, completely resolving the cliff issue. This also cleans up the WeCom Bot and App webhook handlers by encapsulating the mutex and map state.
This commit is contained in:
parent
8640c8177c
commit
29e9b6b4b5
4 changed files with 76 additions and 52 deletions
|
|
@ -38,8 +38,7 @@ type WeComAppChannel struct {
|
||||||
tokenMu sync.RWMutex
|
tokenMu sync.RWMutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
processedMsgs *MessageDeduplicator
|
||||||
msgMu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeComXMLMessage represents the XML message structure from WeCom
|
// WeComXMLMessage represents the XML message structure from WeCom
|
||||||
|
|
@ -144,7 +143,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
|
||||||
client: &http.Client{Timeout: clientTimeout},
|
client: &http.Client{Timeout: clientTimeout},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
processedMsgs: make(map[string]bool),
|
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,7 +606,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
// As per WeCom documentation, use msg_id for deduplication
|
// As per WeCom documentation, use msg_id for deduplication
|
||||||
msgID := fmt.Sprintf("%d", msg.MsgId)
|
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||||
if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) {
|
if !c.processedMsgs.MarkMessageProcessed(msgID) {
|
||||||
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
|
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
|
||||||
"msg_id": msgID,
|
"msg_id": msgID,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -28,8 +27,7 @@ type WeComBotChannel struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
processedMsgs *MessageDeduplicator
|
||||||
msgMu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
||||||
|
|
@ -108,7 +106,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
|
||||||
client: &http.Client{Timeout: clientTimeout},
|
client: &http.Client{Timeout: clientTimeout},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
processedMsgs: make(map[string]bool),
|
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +328,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
|
|
||||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
msgID := msg.MsgID
|
msgID := msg.MsgID
|
||||||
if !markMessageProcessed(&c.msgMu, &c.processedMsgs, msgID, wecomMaxProcessedMessages) {
|
if !c.processedMsgs.MarkMessageProcessed(msgID) {
|
||||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||||
"msg_id": msgID,
|
"msg_id": msgID,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,51 @@ import "sync"
|
||||||
|
|
||||||
const wecomMaxProcessedMessages = 1000
|
const wecomMaxProcessedMessages = 1000
|
||||||
|
|
||||||
// markMessageProcessed marks msgID as processed and returns false for duplicates.
|
// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer)
|
||||||
// All map reads/writes (including len) are protected by msgMu to avoid races.
|
// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest
|
||||||
func markMessageProcessed(msgMu *sync.RWMutex, processedMsgs *map[string]bool, msgID string, maxEntries int) bool {
|
// messages without causing "amnesia cliffs" when the limit is reached.
|
||||||
|
type MessageDeduplicator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
msgs map[string]bool
|
||||||
|
ring []string
|
||||||
|
idx int
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessageDeduplicator creates a new deduplicator with the specified capacity.
|
||||||
|
func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator {
|
||||||
if maxEntries <= 0 {
|
if maxEntries <= 0 {
|
||||||
maxEntries = wecomMaxProcessedMessages
|
maxEntries = wecomMaxProcessedMessages
|
||||||
}
|
}
|
||||||
|
return &MessageDeduplicator{
|
||||||
msgMu.Lock()
|
msgs: make(map[string]bool, maxEntries),
|
||||||
defer msgMu.Unlock()
|
ring: make([]string, maxEntries),
|
||||||
|
max: maxEntries,
|
||||||
if *processedMsgs == nil {
|
|
||||||
*processedMsgs = make(map[string]bool)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (*processedMsgs)[msgID] {
|
// MarkMessageProcessed marks msgID as processed and returns false for duplicates.
|
||||||
|
func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
// 1. Check for duplicate
|
||||||
|
if d.msgs[msgID] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
(*processedMsgs)[msgID] = true
|
|
||||||
|
|
||||||
// When over limit, reset dedupe map but keep the current message.
|
// 2. Evict the oldest message at our current ring position (if any)
|
||||||
if len(*processedMsgs) > maxEntries {
|
oldestID := d.ring[d.idx]
|
||||||
*processedMsgs = map[string]bool{msgID: true}
|
if oldestID != "" {
|
||||||
|
delete(d.msgs, oldestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Store the new message
|
||||||
|
d.msgs[msgID] = true
|
||||||
|
d.ring[d.idx] = msgID
|
||||||
|
|
||||||
|
// 4. Advance the circle queue index
|
||||||
|
d.idx = (d.idx + 1) % d.max
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,20 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMarkMessageProcessed_DuplicateDetection(t *testing.T) {
|
func TestMessageDeduplicator_DuplicateDetection(t *testing.T) {
|
||||||
var mu sync.RWMutex
|
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
|
||||||
processed := make(map[string]bool)
|
|
||||||
|
|
||||||
if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); !ok {
|
if ok := d.MarkMessageProcessed("msg-1"); !ok {
|
||||||
t.Fatalf("first message should be accepted")
|
t.Fatalf("first message should be accepted")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := markMessageProcessed(&mu, &processed, "msg-1", wecomMaxProcessedMessages); ok {
|
if ok := d.MarkMessageProcessed("msg-1"); ok {
|
||||||
t.Fatalf("duplicate message should be rejected")
|
t.Fatalf("duplicate message should be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) {
|
func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) {
|
||||||
var mu sync.RWMutex
|
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
|
||||||
processed := make(map[string]bool)
|
|
||||||
|
|
||||||
const goroutines = 64
|
const goroutines = 64
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
@ -30,7 +28,7 @@ func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) {
|
||||||
for i := 0; i < goroutines; i++ {
|
for i := 0; i < goroutines; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
results <- markMessageProcessed(&mu, &processed, "msg-concurrent", wecomMaxProcessedMessages)
|
results <- d.MarkMessageProcessed("msg-concurrent")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,30 +47,37 @@ func TestMarkMessageProcessed_ConcurrentSameMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkMessageProcessed_RotationClearsMapAtBoundary(t *testing.T) {
|
func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) {
|
||||||
var mu sync.RWMutex
|
// Create a deduplicator with a very small capacity to test eviction easily
|
||||||
processed := make(map[string]bool)
|
capacity := 3
|
||||||
|
d := NewMessageDeduplicator(capacity)
|
||||||
|
|
||||||
if ok := markMessageProcessed(&mu, &processed, "msg-1", 1); !ok {
|
// Fill the queue
|
||||||
t.Fatalf("first message should be accepted")
|
d.MarkMessageProcessed("msg-1")
|
||||||
}
|
d.MarkMessageProcessed("msg-2")
|
||||||
if len(processed) != 1 {
|
d.MarkMessageProcessed("msg-3")
|
||||||
t.Fatalf("expected map size 1 after first insert, got %d", len(processed))
|
|
||||||
|
// At this point, the queue is full. msg-1 is the oldest.
|
||||||
|
if len(d.msgs) != 3 {
|
||||||
|
t.Fatalf("expected map size to be 3, got %d", len(d.msgs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserting second unique message exceeds maxEntries and should reset map, but keep the new message.
|
// This should evict msg-1 and add msg-4
|
||||||
if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); !ok {
|
if ok := d.MarkMessageProcessed("msg-4"); !ok {
|
||||||
t.Fatalf("second unique message should be accepted")
|
t.Fatalf("msg-4 should be accepted")
|
||||||
}
|
|
||||||
if len(processed) != 1 {
|
|
||||||
t.Fatalf("expected map to retain current message after rotation, got size %d", len(processed))
|
|
||||||
}
|
|
||||||
if !processed["msg-2"] {
|
|
||||||
t.Fatalf("expected current message marker to be retained after rotation")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Because msg-2 was retained, an immediate duplicate should be rejected.
|
if len(d.msgs) != 3 {
|
||||||
if ok := markMessageProcessed(&mu, &processed, "msg-2", 1); ok {
|
t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs))
|
||||||
t.Fatalf("duplicate message immediately after rotation should be rejected")
|
}
|
||||||
|
|
||||||
|
// msg-1 should now be forgotten (evicted)
|
||||||
|
if ok := d.MarkMessageProcessed("msg-1"); !ok {
|
||||||
|
t.Fatalf("msg-1 should be accepted again because it was evicted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// msg-2 should have been evicted when we added msg-1 back
|
||||||
|
if ok := d.MarkMessageProcessed("msg-2"); !ok {
|
||||||
|
t.Fatalf("msg-2 should be accepted again because it was evicted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue