后端: 1.阶段 6 agent / memory 服务化收口 - 新增 cmd/agent 独立进程入口,承载 agent zrpc server、agent outbox relay / consumer 和运行时依赖初始化 - 补齐 services/agent/rpc 的 Chat stream 与 conversation meta/list/timeline、schedule-preview、context-stats、schedule-state unary RPC - 新增 gateway/client/agent 与 shared/contracts/agent,将 /api/v1/agent chat 和非 chat 门面切到 agent zrpc - 收缩 gateway 本地 AgentService 装配,双 RPC 开关开启时不再初始化本地 agent 编排、LLM、RAG 和 memory reader fallback - 将 backend/memory 物理迁入 services/memory,私有实现收入 internal,保留 module/model/observe 作为 memory 服务门面 - 调整 memory outbox、memory reader 和 agent 记忆渲染链路的 import 与服务边界,cmd/memory 独占 memory worker / consumer - 关闭 gateway 侧 agent outbox worker 所有权,agent relay / consumer 由 cmd/agent 独占,gateway 仅保留 HTTP/SSE 门面与迁移期开关回退 - 更新阶段 6 文档,记录 agent / memory 当前切流点、smoke 结果,以及 backend/client 与 gateway/shared 的目录收口口径
223 lines
5.5 KiB
Go
223 lines
5.5 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// JobRepo 封装 memory_jobs 的数据访问。
|
||
type JobRepo struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewJobRepo(db *gorm.DB) *JobRepo {
|
||
return &JobRepo{db: db}
|
||
}
|
||
|
||
func (r *JobRepo) WithTx(tx *gorm.DB) *JobRepo {
|
||
return &JobRepo{db: tx}
|
||
}
|
||
|
||
// CreatePendingExtractJob 创建“待抽取”任务(幂等写入)。
|
||
//
|
||
// 失败语义:
|
||
// 1. 参数非法直接返回 error,由上游决定 dead 或重试;
|
||
// 2. 同幂等键重复写入采用 DoNothing,保证无副作用。
|
||
func (r *JobRepo) CreatePendingExtractJob(
|
||
ctx context.Context,
|
||
payload memorymodel.ExtractJobPayload,
|
||
sourceEventID string,
|
||
) error {
|
||
if r == nil || r.db == nil {
|
||
return errors.New("memory job repo is nil")
|
||
}
|
||
if payload.UserID <= 0 {
|
||
return errors.New("invalid user_id")
|
||
}
|
||
if payload.IdempotencyKey == "" {
|
||
return errors.New("idempotency_key is empty")
|
||
}
|
||
|
||
rawPayload, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
now := time.Now()
|
||
job := model.MemoryJob{
|
||
UserID: payload.UserID,
|
||
ConversationID: strPtrOrNil(payload.ConversationID),
|
||
SourceMessageID: int64PtrOrNil(payload.SourceMessageID),
|
||
SourceEventID: strPtrOrNil(sourceEventID),
|
||
JobType: model.MemoryJobTypeExtract,
|
||
IdempotencyKey: payload.IdempotencyKey,
|
||
PayloadJSON: string(rawPayload),
|
||
Status: model.MemoryJobStatusPending,
|
||
RetryCount: 0,
|
||
MaxRetry: 6,
|
||
NextRetryAt: &now,
|
||
}
|
||
|
||
return r.db.WithContext(ctx).
|
||
Clauses(clause.OnConflict{
|
||
Columns: []clause.Column{{Name: "idempotency_key"}},
|
||
DoNothing: true,
|
||
}).
|
||
Create(&job).Error
|
||
}
|
||
|
||
// ClaimNextRunnableExtractJob 抢占一个可执行的 extract 任务。
|
||
//
|
||
// 抢占规则:
|
||
// 1. 只从 pending/failed 中挑 next_retry_at 已到期任务;
|
||
// 2. 用行锁避免多个 worker 抢到同一条任务;
|
||
// 3. 抢占成功后立即置为 processing,防止重复执行。
|
||
func (r *JobRepo) ClaimNextRunnableExtractJob(ctx context.Context, now time.Time) (*model.MemoryJob, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, errors.New("memory job repo is nil")
|
||
}
|
||
|
||
var claimed *model.MemoryJob
|
||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var job model.MemoryJob
|
||
query := tx.
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("job_type = ?", model.MemoryJobTypeExtract).
|
||
Where("status IN ?", []string{model.MemoryJobStatusPending, model.MemoryJobStatusFailed}).
|
||
Where("(next_retry_at IS NULL OR next_retry_at <= ?)", now).
|
||
Order("id ASC").
|
||
Limit(1).
|
||
Find(&job)
|
||
if query.Error != nil {
|
||
return query.Error
|
||
}
|
||
if query.RowsAffected == 0 {
|
||
return nil
|
||
}
|
||
|
||
updates := map[string]any{
|
||
"status": model.MemoryJobStatusProcessing,
|
||
"updated_at": now,
|
||
"last_error": nil,
|
||
}
|
||
if updateErr := tx.Model(&model.MemoryJob{}).Where("id = ?", job.ID).Updates(updates).Error; updateErr != nil {
|
||
return updateErr
|
||
}
|
||
|
||
job.Status = model.MemoryJobStatusProcessing
|
||
job.UpdatedAt = &now
|
||
claimed = &job
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return claimed, nil
|
||
}
|
||
|
||
// MarkSuccess 把任务推进为 success 最终态。
|
||
func (r *JobRepo) MarkSuccess(ctx context.Context, jobID int64) error {
|
||
if r == nil || r.db == nil {
|
||
return errors.New("memory job repo is nil")
|
||
}
|
||
now := time.Now()
|
||
updates := map[string]any{
|
||
"status": model.MemoryJobStatusSuccess,
|
||
"last_error": nil,
|
||
"next_retry_at": nil,
|
||
"updated_at": now,
|
||
}
|
||
return r.db.WithContext(ctx).Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
|
||
}
|
||
|
||
// MarkFailed 按重试策略推进任务到 failed/dead。
|
||
//
|
||
// 规则:
|
||
// 1. retry_count +1 后若超上限,直接 dead;
|
||
// 2. 未超上限则写 failed 并设置 next_retry_at。
|
||
func (r *JobRepo) MarkFailed(ctx context.Context, jobID int64, reason string) error {
|
||
if r == nil || r.db == nil {
|
||
return errors.New("memory job repo is nil")
|
||
}
|
||
|
||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var job model.MemoryJob
|
||
queryErr := tx.
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("id = ?", jobID).
|
||
First(&job).Error
|
||
if queryErr != nil {
|
||
return queryErr
|
||
}
|
||
if job.Status == model.MemoryJobStatusSuccess || job.Status == model.MemoryJobStatusDead {
|
||
return nil
|
||
}
|
||
|
||
maxRetry := job.MaxRetry
|
||
if maxRetry <= 0 {
|
||
maxRetry = 6
|
||
}
|
||
nextRetryCount := job.RetryCount + 1
|
||
now := time.Now()
|
||
status := model.MemoryJobStatusFailed
|
||
var nextRetryAt *time.Time
|
||
if nextRetryCount >= maxRetry {
|
||
status = model.MemoryJobStatusDead
|
||
nextRetryAt = nil
|
||
} else {
|
||
t := now.Add(calcRetryBackoff(nextRetryCount))
|
||
nextRetryAt = &t
|
||
}
|
||
|
||
lastErr := truncateError(reason)
|
||
updates := map[string]any{
|
||
"status": status,
|
||
"retry_count": nextRetryCount,
|
||
"last_error": &lastErr,
|
||
"next_retry_at": nextRetryAt,
|
||
"updated_at": now,
|
||
}
|
||
return tx.Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
|
||
})
|
||
}
|
||
|
||
func calcRetryBackoff(retryCount int) time.Duration {
|
||
if retryCount <= 0 {
|
||
return time.Second
|
||
}
|
||
if retryCount > 6 {
|
||
retryCount = 6
|
||
}
|
||
return time.Second * time.Duration(1<<(retryCount-1))
|
||
}
|
||
|
||
func truncateError(reason string) string {
|
||
if len(reason) <= 2000 {
|
||
return reason
|
||
}
|
||
return reason[:2000]
|
||
}
|
||
|
||
func strPtrOrNil(v string) *string {
|
||
if v == "" {
|
||
return nil
|
||
}
|
||
value := v
|
||
return &value
|
||
}
|
||
|
||
func int64PtrOrNil(v int64) *int64 {
|
||
if v <= 0 {
|
||
return nil
|
||
}
|
||
value := v
|
||
return &value
|
||
}
|