feat: batch kafka message persistence

This commit is contained in:
richjohnrambo
2026-09-24 22:45:07 +08:00
parent 7f2df1dd4f
commit f898a7171c
18 changed files with 682 additions and 98 deletions
+5
View File
@@ -260,6 +260,11 @@ type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
type TenantMessageBatchAdapter interface {
TenantMessageSaveBatch(t.TenantID, []*t.Message) error
TenantGroupMessageSaveBatch(t.TenantID, []*t.Message) error
}
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
// by Kafka dead-letter handling.
type TenantHTTPInfrastructureAdapter interface {
+98
View File
@@ -2249,6 +2249,41 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
return err
}
func (a *adapter) TenantMessageSaveBatch(id t.TenantID, messages []*t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if len(messages) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageSaveBatch(id, messages)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
values := make([]string, 0, len(messages))
args := make([]any, 0, len(messages)*13)
for _, msg := range messages {
if msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 || msg.Uid().IsZero() {
return t.ErrMalformed
}
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?)")
args = append(args, id, store.DecodeUid(msg.Uid()), msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
decodeUID(t.ParseUid(msg.From)), sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""},
messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
}
query := fmt.Sprintf("INSERT INTO %s(tenant_id,id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES %s",
tenantMessagesTable(id), strings.Join(values, ","))
_, err = a.db.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 {
@@ -2315,6 +2350,69 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
return tx.Commit()
}
func (a *adapter) TenantGroupMessageSaveBatch(id t.TenantID, messages []*t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if len(messages) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSaveBatch(id, messages)
}
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil {
return err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
month := messages[0].ShardMonth
table, ok := groupMessageShardTable(id, month)
if !ok {
return t.ErrMalformed
}
groupIDs := make(map[string]int64)
for _, msg := range messages {
if msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 || msg.Uid().IsZero() || msg.ShardMonth != month {
return t.ErrMalformed
}
if _, found := groupIDs[msg.Topic]; !found {
groupIDs[msg.Topic], err = a.groupIDByTopic(ctx, id, msg.Topic)
if err != nil {
return err
}
}
}
var shardStatus string
if err = a.db.QueryRowContext(ctx, "SELECT status FROM im_group_message_shards WHERE tenant_id=? AND shard_month=? AND table_name=?", id, month, table).Scan(&shardStatus); err != nil {
return err
}
if shardStatus != "writable" && shardStatus != "sealing" {
return fmt.Errorf("mysql: group message shard %s is %s", month, shardStatus)
}
values := make([]string, 0, len(messages))
args := make([]any, 0, len(messages)*20)
for _, msg := range messages {
contentJSON := common.ToJSON(msg.Content)
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
args = append(args, id, store.DecodeUid(msg.Uid()), msg.Topic, groupIDs[msg.Topic], msg.SeqId,
decodeUID(t.ParseUid(msg.From)), sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""},
messageHeadType(msg.Head), "normal", contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"),
messageHeadBool(msg.Head, "mention_all"), 0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt,
msg.UpdatedAt, msg.ContentNonce, msg.KeyVersion)
}
query := fmt.Sprintf(`INSERT INTO %s
(tenant_id,message_id,topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at,content_nonce,key_version) VALUES %s`, table, strings.Join(values, ","))
_, err = a.db.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
+7 -18
View File
@@ -141,7 +141,6 @@ func handleKafkaAdminCommand(ctx context.Context, envelope kafkaEnvelope, _ kafk
return nil
}
if kafkaCommandReasonRetryable(reason) {
sleepKafkaRetryBackoff(failedRecord.RetryCount)
return execErr
}
if err := publishKafkaAdminCommandResult(ctx, envelope, payload.CommandID, "failed", reason, execErr.Error(), result); err != nil {
@@ -608,7 +607,7 @@ func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope
if err != nil {
return err
}
return publishKafkaDeadLetter(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
if err = publishKafkaDeadLetter(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
EventID: deadID,
TraceID: envelope.TraceID,
TenantCode: envelope.TenantCode,
@@ -621,7 +620,12 @@ func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope
OccurredAt: now,
ParentEventID: envelope.EventID,
Payload: deadPayload,
})
}); err != nil {
return err
}
// Persist the terminal state before the source consumer commits its offset.
// The dead-letter consumer repeats this idempotent upsert for recovery and auditing.
return store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, dead, now)
}
func shouldDeadLetterKafkaCommand(reason string, retryCount int) bool {
@@ -643,21 +647,6 @@ func kafkaCommandReasonRetryable(reason string) bool {
}
}
func sleepKafkaRetryBackoff(retryCount int) {
if kafkaBus == nil || retryCount <= 0 {
return
}
backoffs := kafkaBus.config.RetryBackoffMS
index := retryCount - 1
if index >= len(backoffs) {
index = len(backoffs) - 1
}
delay := time.Duration(backoffs[index]) * time.Millisecond
if delay > 0 {
time.Sleep(delay)
}
}
func parseKafkaCommandUser(raw string) types.Uid {
raw = strings.TrimSpace(raw)
if raw == "" {
-1
View File
@@ -70,7 +70,6 @@ func handleKafkaCacheInvalidateEvent(ctx context.Context, envelope kafkaEnvelope
if failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, reason, err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, consumerGroup, time.Now().UTC().Round(time.Millisecond))
+11 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
@@ -12,6 +13,8 @@ import (
"github.com/tinode/chat/server/store/types"
)
var errKafkaConsumeInProgress = errors.New("kafka event is already being processed")
const (
configEventTenantUpdated = "tenant.updated"
configEventTenantStatusChanged = "tenant.status.changed"
@@ -66,7 +69,6 @@ func handleKafkaConfigEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka
if failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, reason, err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, consumerGroup, time.Now().UTC().Round(time.Millisecond))
@@ -155,7 +157,7 @@ func beginKafkaConsumeForTopicWithGroup(tenantID types.TenantID, envelope kafkaE
consumerGroup = kafkaBus.config.ConsumerGroup
}
deadline := time.Now().UTC().Add(5 * time.Minute).Round(time.Millisecond)
return store.HTTPInfrastructure.BeginKafkaConsume(tenantID, &types.KafkaConsumeRecord{
record, claimed, err := store.HTTPInfrastructure.BeginKafkaConsume(tenantID, &types.KafkaConsumeRecord{
EventID: envelope.EventID,
ConsumerGroup: consumerGroup,
SourceTopic: kafkaTopic(topicName),
@@ -163,4 +165,11 @@ func beginKafkaConsumeForTopicWithGroup(tenantID types.TenantID, envelope kafkaE
BizType: envelope.BizType,
BizID: envelope.BizID,
}, deadline)
if err != nil || claimed || record == nil {
return record, claimed, err
}
if record.Status != types.KafkaConsumeSucceeded && record.Status != types.KafkaConsumeDead {
return record, false, errKafkaConsumeInProgress
}
return record, false, nil
}
+134 -44
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/segmentio/kafka-go"
@@ -24,8 +25,12 @@ type kafkaMessagePersistPayload struct {
var publishKafkaMessagePersist = publishKafkaEnvelope
func startKafkaMessagePersistConsumer() {
startKafkaConsumer("message_persist", handleKafkaMessagePersist)
startKafkaConsumer("group_message_persist", handleKafkaGroupMessagePersist)
startKafkaBatchConsumer("message_persist", func(ctx context.Context, messages []kafka.Message) error {
return handleKafkaMessagePersistBatch(ctx, messages, "message_persist")
})
startKafkaBatchConsumer("group_message_persist", func(ctx context.Context, messages []kafka.Message) error {
return handleKafkaMessagePersistBatch(ctx, messages, "group_message_persist")
})
}
func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
@@ -132,70 +137,155 @@ func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, ms
return publishKafkaMessagePersist(ctx, kafkaMessagePersistTopicName(msg.Topic), msg.Topic, envelope)
}
func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
return handleKafkaMessagePersistFromTopic(ctx, envelope, "message_persist")
type kafkaMessagePersistItem struct {
envelope kafkaEnvelope
tenantID types.TenantID
payload kafkaMessagePersistPayload
}
func handleKafkaGroupMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
return handleKafkaMessagePersistFromTopic(ctx, envelope, "group_message_persist")
func handleKafkaMessagePersistBatch(ctx context.Context, messages []kafka.Message, sourceTopicName string) error {
groups := make(map[string][]*kafkaMessagePersistItem)
for _, message := range messages {
item, terminal, err := prepareKafkaMessagePersistItem(ctx, message, sourceTopicName)
if err != nil {
return err
}
if terminal {
continue
}
key := fmt.Sprintf("%d", item.tenantID)
if item.payload.MessageKind == "group" {
key += ":" + item.payload.Message.ShardMonth
}
groups[key] = append(groups[key], item)
}
for _, items := range groups {
batch := make([]store.PreparedMessageBatchItem, 0, len(items))
for _, item := range items {
batch = append(batch, store.PreparedMessageBatchItem{Message: &item.payload.Message, ReadBySender: item.payload.ReadBySender})
}
if err := store.PersistPreparedMessageBatch(items[0].tenantID, batch); err == nil {
for _, item := range items {
if err = finishKafkaMessagePersist(ctx, item); err != nil {
if err = persistKafkaMessageIndividually(ctx, item); err != nil {
return err
}
}
}
continue
} else {
logs.Warn.Printf("kafka message batch persist failed, degrading to singles: tenant=%d kind=%s shard=%s size=%d err=%v",
items[0].tenantID, items[0].payload.MessageKind, items[0].payload.Message.ShardMonth, len(items), err)
}
for _, item := range items {
if err := persistKafkaMessageIndividually(ctx, item); err != nil {
return err
}
}
}
return nil
}
func handleKafkaMessagePersistFromTopic(ctx context.Context, envelope kafkaEnvelope, sourceTopicName string) error {
func prepareKafkaMessagePersistItem(ctx context.Context, message kafka.Message, sourceTopicName string) (*kafkaMessagePersistItem, bool, error) {
var envelope kafkaEnvelope
if err := json.Unmarshal(message.Value, &envelope); err != nil {
logs.Err.Printf("kafka message malformed envelope skipped: partition=%d offset=%d err=%v", message.Partition, message.Offset, err)
return nil, true, nil
}
tenantID := envelope.TenantID
var err error
if tenantID.IsZero() {
tenantID, err = kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
return nil, false, err
}
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, sourceTopicName)
if err != nil || !claimed {
return err
if err != nil {
return nil, false, err
}
var payload kafkaMessagePersistPayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
if !claimed {
return nil, true, nil
}
if payload.Message.TenantID == types.ZeroTenantID {
payload.Message.TenantID = tenantID
item := &kafkaMessagePersistItem{envelope: envelope, tenantID: tenantID}
if err = json.Unmarshal(envelope.Payload, &item.payload); err != nil {
return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if payload.Message.ShardMonth == "" {
payload.Message.ShardMonth = payload.ShardMonth
if item.payload.Message.TenantID == types.ZeroTenantID {
item.payload.Message.TenantID = tenantID
}
if payload.Message.TenantID != tenantID {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "TENANT_MISMATCH", "message tenant does not match envelope tenant")
if item.payload.Message.ShardMonth == "" {
item.payload.Message.ShardMonth = item.payload.ShardMonth
}
if err = validateKafkaMessageKind(payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
if item.payload.Message.TenantID != tenantID {
return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "TENANT_MISMATCH", "message tenant does not match envelope tenant")
}
if _, err = store.PersistPreparedMessage(tenantID, &payload.Message, nil, payload.ReadBySender); err != nil && !errors.Is(err, types.ErrDuplicate) {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
if err = validateKafkaMessageKind(item.payload); err != nil {
return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
return item, false, nil
}
func persistKafkaMessageIndividually(ctx context.Context, item *kafkaMessagePersistItem) error {
operation := func() error {
err := store.PersistPreparedMessageBatch(item.tenantID, []store.PreparedMessageBatchItem{{
Message: &item.payload.Message, ReadBySender: item.payload.ReadBySender,
}})
if errors.Is(err, types.ErrUnsupported) {
_, err = store.PersistPreparedMessage(item.tenantID, &item.payload.Message, nil, item.payload.ReadBySender)
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
if len(payload.AttachmentURLs) > 0 {
if err = enqueueKafkaFileAttachmentsBind(ctx, tenantID, &payload.Message, payload.AttachmentURLs); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
"KAFKA_FILE_LIFECYCLE_FAILED", err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, "KAFKA_FILE_LIFECYCLE_FAILED", err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
if err != nil && !errors.Is(err, types.ErrDuplicate) {
return err
}
return finishKafkaMessagePersist(ctx, item)
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
markFailed := func(err error) (*types.KafkaConsumeRecord, error) {
return store.HTTPInfrastructure.MarkKafkaConsumeFailed(item.tenantID, item.envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaMessagePersistFailureReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
}
markDead := func(failed *types.KafkaConsumeRecord, err error) error {
return markKafkaDeadLetter(ctx, item.tenantID, item.envelope, failed, kafkaMessagePersistFailureReason(err), err.Error())
}
return runKafkaMessageSingleRetries(ctx, kafkaBus.config.MessageRetries, operation, markFailed, markDead, waitKafkaRetry)
}
func runKafkaMessageSingleRetries(ctx context.Context, retryCount int, operation func() error,
markFailed func(error) (*types.KafkaConsumeRecord, error), markDead func(*types.KafkaConsumeRecord, error) error,
wait kafkaRetryWaiter) error {
var lastErr error
var failed *types.KafkaConsumeRecord
for attempt := 1; attempt <= retryCount+1; attempt++ {
if lastErr = operation(); lastErr == nil {
return nil
}
var err error
if failed, err = markFailed(lastErr); err != nil {
return err
}
if attempt <= retryCount {
if err = wait(ctx, kafkaRetryBackoff(attempt)); err != nil {
return err
}
}
}
return markDead(failed, lastErr)
}
func finishKafkaMessagePersist(ctx context.Context, item *kafkaMessagePersistItem) error {
if len(item.payload.AttachmentURLs) > 0 {
if err := enqueueKafkaFileAttachmentsBind(ctx, item.tenantID, &item.payload.Message, item.payload.AttachmentURLs); err != nil {
return fmt.Errorf("file lifecycle enqueue failed: %w", err)
}
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(item.tenantID, item.envelope.EventID,
kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func kafkaMessagePersistFailureReason(err error) string {
if err != nil && strings.Contains(err.Error(), "file lifecycle") {
return "KAFKA_FILE_LIFECYCLE_FAILED"
}
return kafkaCommandErrorReason(err)
}
func kafkaMessagePersistTopicName(topic string) string {
+163 -11
View File
@@ -51,6 +51,9 @@ type kafkaConfig struct {
SASLPassword string `json:"sasl_password"`
MaxRetryCount int `json:"max_retry_count"`
RetryBackoffMS []int `json:"retry_backoff_ms"`
MessageBatchSize int `json:"message_batch_size"`
MessageBatchWait int `json:"message_batch_wait_ms"`
MessageRetries int `json:"message_single_retry_count"`
DeadRetentionSec int `json:"dead_letter_retention_seconds"`
}
@@ -180,6 +183,17 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
if len(cfg.RetryBackoffMS) == 0 {
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
}
if cfg.MessageBatchSize <= 0 {
cfg.MessageBatchSize = 100
} else if cfg.MessageBatchSize > 500 {
cfg.MessageBatchSize = 500
}
if cfg.MessageBatchWait <= 0 {
cfg.MessageBatchWait = 10
}
if cfg.MessageRetries <= 0 {
cfg.MessageRetries = 3
}
if cfg.DeadRetentionSec <= 0 {
cfg.DeadRetentionSec = 90 * 24 * 3600
}
@@ -276,6 +290,11 @@ func publishKafkaEnvelope(ctx context.Context, topicName string, key string, env
type kafkaMessageHandler func(context.Context, kafkaEnvelope, kafka.Message) error
type kafkaMessageCommitter func(context.Context, kafka.Message) error
type kafkaRetryWaiter func(context.Context, time.Duration) error
type kafkaBatchHandler func(context.Context, []kafka.Message) error
type kafkaBatchCommitter func(context.Context, ...kafka.Message) error
func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
startKafkaConsumerWithGroup(topicName, "", handler)
}
@@ -327,24 +346,157 @@ func startKafkaConsumerWithGroup(topicName, groupID string, handler kafkaMessage
logs.Err.Println("Kafka fetch failed:", err)
continue
}
var envelope kafkaEnvelope
if err = json.Unmarshal(msg.Value, &envelope); err != nil {
logs.Err.Println("Kafka envelope decode failed:", err)
_ = reader.CommitMessages(context.Background(), msg)
continue
commit := func(ctx context.Context, message kafka.Message) error {
return reader.CommitMessages(ctx, message)
}
if err = handler(kafkaBus.ctx, envelope, msg); err != nil {
logs.Err.Println("Kafka handler failed:", envelope.EventID, envelope.EventType, err)
continue
}
if err = reader.CommitMessages(context.Background(), msg); err != nil {
logs.Err.Println("Kafka commit failed:", err)
if err = consumeKafkaMessage(kafkaBus.ctx, msg, handler, commit, waitKafkaRetry); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
logs.Err.Printf("Kafka message stopped without commit: topic=%s partition=%d offset=%d err=%v", msg.Topic, msg.Partition, msg.Offset, err)
}
}
}()
logs.Info.Printf("Kafka consumer started: topic=%s group=%s", topic, groupID)
}
func startKafkaBatchConsumer(topicName string, handler kafkaBatchHandler) {
if kafkaBus == nil || handler == nil {
return
}
topic := kafkaBus.topics[topicName]
if topic == "" {
topic = topicName
}
groupID := kafkaBus.config.ConsumerGroup + "." + topicName
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: kafkaBus.config.Brokers, GroupID: groupID, Topic: topic, StartOffset: kafka.FirstOffset,
MinBytes: 1, MaxBytes: 10e6, MaxWait: time.Second, Dialer: kafkaBus.dialer(),
})
kafkaBus.wg.Add(1)
go func() {
defer kafkaBus.wg.Done()
defer reader.Close()
for {
messages, err := fetchKafkaMessageBatch(kafkaBus.ctx, reader, kafkaBus.config.MessageBatchSize,
time.Duration(kafkaBus.config.MessageBatchWait)*time.Millisecond)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
logs.Err.Println("Kafka batch fetch failed:", err)
continue
}
if err = consumeKafkaMessageBatch(kafkaBus.ctx, messages, handler, reader.CommitMessages, waitKafkaRetry); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
logs.Err.Printf("Kafka batch stopped without commit: topic=%s size=%d err=%v", topic, len(messages), err)
}
}
}()
logs.Info.Printf("Kafka batch consumer started: topic=%s group=%s size=%d wait_ms=%d", topic, groupID, kafkaBus.config.MessageBatchSize, kafkaBus.config.MessageBatchWait)
}
func fetchKafkaMessageBatch(ctx context.Context, reader *kafka.Reader, maxSize int, maxWait time.Duration) ([]kafka.Message, error) {
first, err := reader.FetchMessage(ctx)
if err != nil {
return nil, err
}
messages := []kafka.Message{first}
deadline := time.Now().Add(maxWait)
for len(messages) < maxSize {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
message, fetchErr := reader.FetchMessage(fetchCtx)
cancel()
if errors.Is(fetchErr, context.DeadlineExceeded) && ctx.Err() == nil {
break
}
if fetchErr != nil {
return nil, fetchErr
}
messages = append(messages, message)
}
return messages, nil
}
func consumeKafkaMessageBatch(ctx context.Context, messages []kafka.Message, handler kafkaBatchHandler, commit kafkaBatchCommitter, wait kafkaRetryWaiter) error {
for attempt := 1; ; attempt++ {
if err := handler(ctx, messages); err == nil {
return commitKafkaMessageBatch(ctx, messages, commit, wait)
}
if err := wait(ctx, kafkaRetryBackoff(attempt)); err != nil {
return err
}
}
}
func consumeKafkaMessage(ctx context.Context, msg kafka.Message, handler kafkaMessageHandler, commit kafkaMessageCommitter, wait kafkaRetryWaiter) error {
var envelope kafkaEnvelope
if err := json.Unmarshal(msg.Value, &envelope); err != nil {
return commitKafkaMessage(ctx, msg, commit, wait)
}
for attempt := 1; ; attempt++ {
if err := handler(ctx, envelope, msg); err == nil {
return commitKafkaMessage(ctx, msg, commit, wait)
}
if err := wait(ctx, kafkaRetryBackoff(attempt)); err != nil {
return err
}
}
}
func commitKafkaMessage(ctx context.Context, msg kafka.Message, commit kafkaMessageCommitter, wait kafkaRetryWaiter) error {
for attempt := 1; ; attempt++ {
if err := commit(ctx, msg); err == nil {
return nil
}
if err := wait(ctx, kafkaRetryBackoff(attempt)); err != nil {
return err
}
}
}
func commitKafkaMessageBatch(ctx context.Context, messages []kafka.Message, commit kafkaBatchCommitter, wait kafkaRetryWaiter) error {
for attempt := 1; ; attempt++ {
if err := commit(ctx, messages...); err == nil {
return nil
}
if err := wait(ctx, kafkaRetryBackoff(attempt)); err != nil {
return err
}
}
}
func kafkaRetryBackoff(attempt int) time.Duration {
if kafkaBus == nil || len(kafkaBus.config.RetryBackoffMS) == 0 {
return time.Second
}
if attempt < 1 {
attempt = 1
}
index := attempt - 1
if index >= len(kafkaBus.config.RetryBackoffMS) {
index = len(kafkaBus.config.RetryBackoffMS) - 1
}
return time.Duration(kafkaBus.config.RetryBackoffMS[index]) * time.Millisecond
}
func waitKafkaRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func kafkaBroadcastConsumerGroup(topicName string) string {
if kafkaBus == nil {
return ""
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"errors"
"testing"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/store/types"
)
func TestConsumeKafkaMessageBatchRetriesHandlerAndCommit(t *testing.T) {
handlerCalls := 0
commitCalls := 0
waitCalls := 0
messages := []kafka.Message{{Offset: 1}, {Offset: 2}}
err := consumeKafkaMessageBatch(context.Background(), messages,
func(context.Context, []kafka.Message) error {
handlerCalls++
if handlerCalls == 1 {
return errors.New("batch failed")
}
return nil
},
func(_ context.Context, committed ...kafka.Message) error {
commitCalls++
if len(committed) != len(messages) {
t.Fatalf("committed %d messages, want %d", len(committed), len(messages))
}
if commitCalls == 1 {
return errors.New("commit failed")
}
return nil
},
func(context.Context, time.Duration) error {
waitCalls++
return nil
})
if err != nil {
t.Fatalf("consumeKafkaMessageBatch() error = %v", err)
}
if handlerCalls != 2 || commitCalls != 2 || waitCalls != 2 {
t.Fatalf("calls handler=%d commit=%d wait=%d, want 2/2/2", handlerCalls, commitCalls, waitCalls)
}
}
func TestConsumeKafkaMalformedMessageRetriesCommit(t *testing.T) {
commitCalls := 0
waitCalls := 0
handlerCalls := 0
err := consumeKafkaMessage(context.Background(), kafka.Message{Value: []byte("{")},
func(context.Context, kafkaEnvelope, kafka.Message) error {
handlerCalls++
return nil
},
func(context.Context, kafka.Message) error {
commitCalls++
if commitCalls == 1 {
return errors.New("commit failed")
}
return nil
},
func(context.Context, time.Duration) error {
waitCalls++
return nil
})
if err != nil {
t.Fatalf("consumeKafkaMessage() error = %v", err)
}
if handlerCalls != 0 || commitCalls != 2 || waitCalls != 1 {
t.Fatalf("calls handler=%d commit=%d wait=%d, want 0/2/1", handlerCalls, commitCalls, waitCalls)
}
}
func TestRunKafkaMessageSingleRetriesThenDeadLetters(t *testing.T) {
operationCalls := 0
markFailedCalls := 0
markDeadCalls := 0
waitCalls := 0
err := runKafkaMessageSingleRetries(context.Background(), 3,
func() error {
operationCalls++
return errors.New("write failed")
},
func(error) (*types.KafkaConsumeRecord, error) {
markFailedCalls++
return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil
},
func(record *types.KafkaConsumeRecord, err error) error {
markDeadCalls++
if record == nil || record.RetryCount != 4 || err == nil {
t.Fatalf("unexpected dead-letter input: record=%+v err=%v", record, err)
}
return nil
},
func(context.Context, time.Duration) error {
waitCalls++
return nil
})
if err != nil {
t.Fatalf("runKafkaMessageSingleRetries() error = %v", err)
}
if operationCalls != 4 || markFailedCalls != 4 || waitCalls != 3 || markDeadCalls != 1 {
t.Fatalf("calls operation=%d failed=%d wait=%d dead=%d, want 4/4/3/1",
operationCalls, markFailedCalls, waitCalls, markDeadCalls)
}
}
func TestRunKafkaMessageSingleRetriesStopsOnSuccess(t *testing.T) {
operationCalls := 0
markFailedCalls := 0
markDeadCalls := 0
err := runKafkaMessageSingleRetries(context.Background(), 3,
func() error {
operationCalls++
if operationCalls < 3 {
return errors.New("temporary failure")
}
return nil
},
func(error) (*types.KafkaConsumeRecord, error) {
markFailedCalls++
return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil
},
func(*types.KafkaConsumeRecord, error) error {
markDeadCalls++
return nil
},
func(context.Context, time.Duration) error { return nil })
if err != nil {
t.Fatalf("runKafkaMessageSingleRetries() error = %v", err)
}
if operationCalls != 3 || markFailedCalls != 2 || markDeadCalls != 0 {
t.Fatalf("calls operation=%d failed=%d dead=%d, want 3/2/0", operationCalls, markFailedCalls, markDeadCalls)
}
}
-3
View File
@@ -56,9 +56,6 @@ func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, msg
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, event.TenantID, envelope, failed, reason, err.Error())
}
if failed != nil {
sleepKafkaRetryBackoff(failed.RetryCount)
}
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(event.TenantID, event.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
+3
View File
@@ -128,6 +128,9 @@
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"message_batch_size": 100,
"message_batch_wait_ms": 10,
"message_single_retry_count": 3,
"dead_letter_retention_seconds": 7776000,
"topics": {
"admin_command": "im.admin.command",
+76
View File
@@ -3677,6 +3677,82 @@ func PersistPreparedMessage(tenantID types.TenantID, msg *types.Message, attachm
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
}
type PreparedMessageBatchItem struct {
Message *types.Message
ReadBySender bool
}
func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessageBatchItem) error {
if len(items) == 0 {
return nil
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
batchAdp, ok := tenantAdp.(adapter.TenantMessageBatchAdapter)
if !ok {
return types.ErrUnsupported
}
if items[0].Message == nil {
return types.ErrMalformed
}
isGroup := types.GetTopicCat(items[0].Message.Topic) == types.TopicCatGrp
month := items[0].Message.ShardMonth
stored := make([]*types.Message, 0, len(items))
for _, item := range items {
msg := item.Message
if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 || msg.Uid().IsZero() ||
(types.GetTopicCat(msg.Topic) == types.TopicCatGrp) != isGroup || (isGroup && msg.ShardMonth != month) {
return types.ErrMalformed
}
clientMsgID, normalizeErr := normalizeClientMsgID(msg.Head)
if normalizeErr != nil {
return normalizeErr
}
msg.ClientMsgID = clientMsgID
storedMsg, encryptErr := encryptMessageContent(msg)
if encryptErr != nil {
return encryptErr
}
stored = append(stored, storedMsg)
}
if isGroup {
err = batchAdp.TenantGroupMessageSaveBatch(tenantID, stored)
} else {
err = batchAdp.TenantMessageSaveBatch(tenantID, stored)
}
if err != nil {
return err
}
for _, item := range items {
msg := item.Message
from := types.ParseUid(msg.From)
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return err
}
invalidateTopicCache(tenantID, msg.Topic)
if isGroup && groupMsgRuntime != nil {
if msg.ClientMsgID != "" {
_ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, from, msg.ClientMsgID, msg.SeqId, msg.Uid().String())
}
_ = groupMsgRuntime.PutShardRoute(tenantID, msg.Topic, GroupMessageShardRoute{
Month: msg.ShardMonth, MinSeq: msg.SeqId, MaxSeq: msg.SeqId, Count: 1,
})
_ = groupMsgRuntime.CacheHotMessage(tenantID, msg)
}
if item.ReadBySender && !from.IsZero() {
if updateErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, from, map[string]any{
"RecvSeqId": msg.SeqId, "ReadSeqId": msg.SeqId,
}); updateErr == nil {
invalidateSubscriptionCache(tenantID, msg.Topic, from)
DeleteCachedUserUnreadCount(tenantID, from)
}
}
}
return nil
}
func NormalizeClientMsgID(head types.KVMap) (string, error) {
return normalizeClientMsgID(head)
}
+3
View File
@@ -128,6 +128,9 @@
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"message_batch_size": 100,
"message_batch_wait_ms": 10,
"message_single_retry_count": 3,
"dead_letter_retention_seconds": 7776000,
"topics": {
"admin_command": "im.admin.command",
+3 -3
View File
@@ -1284,7 +1284,7 @@ func TestFilterHotMessagesForUserSkipsDeleted(t *testing.T) {
}
}
func TestMarkKafkaDeadLetterPublishesBeforeDBWrite(t *testing.T) {
func TestMarkKafkaDeadLetterPublishesAndWritesTerminalState(t *testing.T) {
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaDeadLetter
oldInfrastructure := store.HTTPInfrastructure
@@ -1322,8 +1322,8 @@ func TestMarkKafkaDeadLetterPublishesBeforeDBWrite(t *testing.T) {
if err := markKafkaDeadLetter(context.Background(), 1, original, consume, "DB_FAILED", "boom"); err != nil {
t.Fatalf("markKafkaDeadLetter returned error: %v", err)
}
if len(infrastructure.deadLetters) != 0 {
t.Fatalf("dead letter was written before dead_letter topic consumption: %#v", infrastructure.deadLetters)
if len(infrastructure.deadLetters) != 1 || infrastructure.deadLetters[0].DeadID == "" {
t.Fatalf("dead letter terminal state was not written: %#v", infrastructure.deadLetters)
}
if publishedEnvelope.EventID == "" || publishedEnvelope.TenantID != 1 {
t.Fatalf("dead letter event was not published correctly: %#v", publishedEnvelope)