Version: 0.4.9.dev.260309

feat: 🗄️ 新增自动建表功能

* 新增项目启动时自动建表能力,减少手动初始化数据库步骤
* 解决 `agent_chat` 与 `chat_history` 结构体互相持有对方结构体用于 `preload` 导致的循环依赖问题
* 修复因结构体互相依赖引发的建表失败问题,保证数据库初始化流程稳定

feat: 🐳 Docker Compose 引入 Kafka 分区自动初始化

* 更新 `docker-compose` 配置,引入 Kafka partition 自动初始化脚本
* 保证服务启动后 Topic 即具备可用 partition,实现开箱即用
* 修复转移环境后 MySQL 等容器数据无法持久化的问题,统一改为使用命名卷进行数据持久化

docs: 📚 补充 Outbox + Kafka 持久化链路注释

* 为 Outbox + Kafka 消息持久化链路补充详细代码注释
* 提升异步消息链路的可读性与维护性
* 当前代码 Review 进度约 50%

undo: ⚠️ Kafka 初始化阶段出现消息短暂堆积

* 初次初始化项目时观察到消息在 Kafka 中短暂堆积现象
* 后续被消费者一次性消费且未再次复现
* 已在生产者启动、消费者启动以及消息消费流程中增加控制台日志输出,降低系统黑箱程度
* 后续若条件允许将进一步排查该现象的触发原因
This commit is contained in:
Losita
2026-03-09 23:25:25 +08:00
parent 1ed558b488
commit 959049db42
17 changed files with 363 additions and 133 deletions

View File

@@ -48,6 +48,9 @@ func (s *AgentService) pickChatModel(requestModel string) (*ark.ChatModel, strin
return s.AIHub.Worker, "worker"
}
// saveChatHistoryReliable 是聊天记录持久化的统一入口:
// 1) 启用 outbox + Kafka 时,走异步可靠链路;
// 2) 未启用时,退化为同步写数据库。
func (s *AgentService) saveChatHistoryReliable(ctx context.Context, payload model.ChatHistoryPersistPayload) error {
if s.asyncPipeline == nil {
return s.repo.SaveChatHistory(ctx, payload.UserID, payload.ConversationID, payload.Role, payload.Message)
@@ -64,15 +67,15 @@ func pushErrNonBlocking(errChan chan error, err error) {
}
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) {
// 1) 准备输出通道
// 1) 准备输出通道
outChan := make(chan string, 5)
errChan := make(chan error, 1)
// 2) 规范会话并选择模型
// 2) 规范会话 ID 并选择模型
chatID = normalizeConversationID(chatID)
selectedModel, resolvedModelName := s.pickChatModel(modelName)
// 3) 确保会话存在
// 3) 确保会话存在:先查缓存,再回源数据库,必要时创建新会话。
result, err := s.agentCache.GetConversationStatus(ctx, chatID)
if err != nil {
errChan <- err
@@ -101,7 +104,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
}
// 4) 组装历史上下文先读缓存,缓存未命中再读数据库
// 4) 组装历史上下文先读缓存,缓存未命中再读数据库
chatHistory, err := s.agentCache.GetHistory(ctx, chatID)
if err != nil {
errChan <- err
@@ -123,12 +126,12 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
chatHistory = conv.ToEinoMessages(histories)
}
// 5) token 预算裁剪历史:从最旧消息开始持续弹出,直到满足预算
// 5) 基于 token 预算裁剪历史,避免请求超长。
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agent.SystemPrompt, userMessage)
trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget)
chatHistory = trimmedHistory
// 6) 根据最新裁剪结果动态调整 Redis 会话窗口
// 6) 根据裁剪结果调整 Redis 会话窗口,控制缓存体积。
targetWindow := pkg.CalcSessionWindowSize(len(chatHistory))
if err = s.agentCache.SetSessionWindowSize(ctx, chatID, targetWindow); err != nil {
log.Printf("failed to set history window for %s: %v", chatID, err)
@@ -142,7 +145,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
chatID, totalHistoryTokens, keptHistoryTokens, droppedCount, historyBudget, targetWindow)
}
// 缓存未命中时,把“裁剪后的历史”回填进缓存
// 缓存未命中时,把“裁剪后的历史”回填 Redis。
if cacheMiss {
if err = s.agentCache.BackfillHistory(ctx, chatID, chatHistory); err != nil {
errChan <- err
@@ -152,7 +155,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
}
// 7) 先同步写 Redis再把持久化请求交给 outbox + Kafka
// 7) 先同步写 Redis再把数据库持久化交给 outbox 可靠链路。
if err = s.agentCache.PushMessage(ctx, chatID, &schema.Message{Role: schema.User, Content: userMessage}); err != nil {
log.Printf("failed to push user message into redis history: %v", err)
}
@@ -168,7 +171,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
return outChan, errChan
}
// 8) 启动流式聊天
// 8) 启动流式对话。
go func() {
defer close(outChan)
@@ -178,7 +181,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
return
}
// 9) 回答完成后,同步写 Redis并把数据库落库交给 outbox + Kafka
// 9) 助手回答完成后,重复同样流程:先写 Redis再异步持久化。
if cacheErr := s.agentCache.PushMessage(context.Background(), chatID, &schema.Message{Role: schema.Assistant, Content: fullText}); cacheErr != nil {
log.Printf("failed to push assistant message into redis history: %v", cacheErr)
}

View File

@@ -15,11 +15,16 @@ import (
"gorm.io/gorm"
)
// AgentAsyncPipeline 负责 outbox 扫描、Kafka 投递与消费落库。
// AgentAsyncPipeline 负责 outbox 的“异步可靠链路”:
// 1) 业务侧写 outboxpending
// 2) dispatch loop 扫描并投递 Kafkapublished
// 3) consume loop 消费并落库consumed
// 4) 任一步失败按重试策略回到 pending 或 dead。
type AgentAsyncPipeline struct {
outboxRepo *dao.OutboxDAO
producer *kafkabus.Producer
consumer *kafkabus.Consumer
brokers []string
topic string
maxRetry int
scanEvery time.Duration
@@ -43,6 +48,7 @@ func NewAgentAsyncPipeline(outboxRepo *dao.OutboxDAO, cfg kafkabus.Config) (*Age
outboxRepo: outboxRepo,
producer: producer,
consumer: consumer,
brokers: cfg.Brokers,
topic: cfg.Topic,
maxRetry: cfg.MaxRetry,
scanEvery: cfg.RetryScanInterval,
@@ -50,10 +56,23 @@ func NewAgentAsyncPipeline(outboxRepo *dao.OutboxDAO, cfg kafkabus.Config) (*Age
}, nil
}
// Start 启动两个后台协程:
// - startDispatchLoop扫描 pending 并投递 Kafka
// - startConsumeLoop消费 Kafka 并执行业务落库
func (p *AgentAsyncPipeline) Start(ctx context.Context) {
if p == nil {
return
}
log.Printf("Kafka async pipeline starting: topic=%s brokers=%v retry_scan=%s batch=%d", p.topic, p.brokers, p.scanEvery, p.scanBatch)
if err := kafkabus.WaitTopicReady(ctx, p.brokers, p.topic, 30*time.Second); err != nil {
// 首次部署常见情况broker 已起来但 topic/partition 尚未可用。
// 这里明确打印,避免“消息堆积但控制台无提示”的观感。
log.Printf("Kafka topic not ready before consume loop start: %v", err)
} else {
log.Printf("Kafka topic is ready: %s", p.topic)
}
go p.startDispatchLoop(ctx)
go p.startConsumeLoop(ctx)
}
@@ -70,6 +89,10 @@ func (p *AgentAsyncPipeline) Close() {
}
}
// EnqueueChatHistoryPersist 是业务侧入口:
// 1) 先写 outbox
// 2) 立刻尝试“首发投递”一次(非阻塞主流程);
// 3) 失败后由扫描器按 next_retry_at 继续重试。
func (p *AgentAsyncPipeline) EnqueueChatHistoryPersist(ctx context.Context, payload model.ChatHistoryPersistPayload) error {
if p == nil {
return errors.New("Kafka 异步链路未初始化")
@@ -84,6 +107,7 @@ func (p *AgentAsyncPipeline) EnqueueChatHistoryPersist(ctx context.Context, payl
return nil
}
// startDispatchLoop 定时扫描 pending 且到期的消息,逐条尝试投递。
func (p *AgentAsyncPipeline) startDispatchLoop(ctx context.Context) {
ticker := time.NewTicker(p.scanEvery)
defer ticker.Stop()
@@ -98,6 +122,9 @@ func (p *AgentAsyncPipeline) startDispatchLoop(ctx context.Context) {
log.Printf("扫描 outbox 失败: %v", err)
continue
}
if len(pendingMessages) > 0 {
log.Printf("outbox due messages=%d, start dispatch", len(pendingMessages))
}
for _, msg := range pendingMessages {
if err = p.dispatchOne(ctx, msg.ID); err != nil {
log.Printf("重试投递 outbox 消息失败(id=%d): %v", msg.ID, err)
@@ -107,6 +134,11 @@ func (p *AgentAsyncPipeline) startDispatchLoop(ctx context.Context) {
}
}
// dispatchOne 执行单条 outbox 投递:
// 1) 读取 outbox 行;
// 2) 组装 Envelope
// 3) 写 Kafka
// 4) 回写 published。
func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) error {
outboxMsg, err := p.outboxRepo.GetByID(ctx, outboxID)
if err != nil {
@@ -115,6 +147,7 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
}
return err
}
// 终态不再重复投递。
if outboxMsg.Status == model.OutboxStatusConsumed || outboxMsg.Status == model.OutboxStatusDead {
return nil
}
@@ -126,7 +159,8 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
}
raw, err := json.Marshal(envelope)
if err != nil {
markErr := p.outboxRepo.MarkDead(ctx, outboxMsg.ID, "序列化 outbox 包裹失败: "+err.Error())
// 序列化都失败通常是坏数据,直接死信。
markErr := p.outboxRepo.MarkDead(ctx, outboxMsg.ID, "序列化 outbox 包装失败: "+err.Error())
if markErr != nil {
log.Printf("标记 outbox 死信失败(id=%d): %v", outboxMsg.ID, markErr)
}
@@ -144,6 +178,7 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
return nil
}
// startConsumeLoop 持续从 Kafka 拉取消息并处理。
func (p *AgentAsyncPipeline) startConsumeLoop(ctx context.Context) {
for {
select {
@@ -157,7 +192,7 @@ func (p *AgentAsyncPipeline) startConsumeLoop(ctx context.Context) {
if errors.Is(err, context.Canceled) {
return
}
log.Printf("Kafka 消费拉取失败: %v", err)
log.Printf("Kafka 消费拉取失败(topic=%s): %v", p.topic, err)
time.Sleep(300 * time.Millisecond)
continue
}
@@ -167,21 +202,24 @@ func (p *AgentAsyncPipeline) startConsumeLoop(ctx context.Context) {
}
}
// handleMessage 先解析 Envelope再按 biz_type 分发到具体处理器。
func (p *AgentAsyncPipeline) handleMessage(ctx context.Context, msg segmentkafka.Message) error {
var envelope kafkabus.Envelope
if err := json.Unmarshal(msg.Value, &envelope); err != nil {
// 包装体坏数据,提交 offset 跳过,避免阻塞分区。
_ = p.consumer.Commit(ctx, msg)
return fmt.Errorf("解析 Kafka 包失败: %w", err)
return fmt.Errorf("解析 Kafka 包失败: %w", err)
}
if envelope.OutboxID <= 0 {
_ = p.consumer.Commit(ctx, msg)
return errors.New("Kafka 包缺少 outbox_id")
return errors.New("Kafka 包缺少 outbox_id")
}
switch envelope.BizType {
case model.OutboxBizTypeChatHistoryPersist:
return p.consumeChatHistory(ctx, msg, envelope)
default:
// 未知业务类型直接死信并提交,避免反复重试无意义数据。
_ = p.outboxRepo.MarkDead(ctx, envelope.OutboxID, "未知业务类型: "+envelope.BizType)
if err := p.consumer.Commit(ctx, msg); err != nil {
return err
@@ -190,6 +228,10 @@ func (p *AgentAsyncPipeline) handleMessage(ctx context.Context, msg segmentkafka
}
}
// consumeChatHistory 执行“聊天记录持久化”消费逻辑。
// 提交策略说明:
// 1) 成功落库后提交;
// 2) 失败时先回写 outbox便于重试/排障),再提交,避免分区阻塞。
func (p *AgentAsyncPipeline) consumeChatHistory(ctx context.Context, msg segmentkafka.Message, envelope kafkabus.Envelope) error {
var payload model.ChatHistoryPersistPayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {