feat(bus): 入库 JetStream 持久工作队列 —— 崩溃重投/背压/幂等

把入库从网关裸 goroutine 升级为和任务流同级的 JetStream 持久工作队列:
- contract/ingest.go: IngestJob(claim-check 引用) + 流/消费者常量
- bus.go: EnsureIngestStream / PublishIngestJob / ConsumeIngestJobs
  (durable consumer + MaxAckPending 背压 + AckWait 可配(env) + MaxDeliver 4
   毒消息兜底 + lastAttempt 终态收尾 + 优雅 drain)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-30 13:37:24 +08:00
parent ce70388e52
commit 6f16062dfc
2 changed files with 178 additions and 0 deletions
+138
View File
@@ -690,6 +690,144 @@ func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (drain func(conte
}, nil
}
// ---- 入库工作队列(JetStream 持久,与任务流同级)----
// IngestHandler 处理一条入库作业;lastAttempt=true 表示这是最后一次投递(重投已耗尽),
// handler 应据此做终态收尾(不要再要求重试)。返回非 nil 错误 → 延迟重投(瞬时失败可恢复)。
type IngestHandler func(ctx context.Context, job *contract.IngestJob, lastAttempt bool) error
// ingestMaxDeliver 是单条入库作业的最大投递次数(含首发)。瞬时失败重投兜底,
// 到第 N 次时 handler 收到 lastAttempt=true 做终态收尾,避免毒消息无限重投。
const ingestMaxDeliver = 4
// ingestConcurrency 返回单实例入库并发上限(env INGEST_CONCURRENCY,默认 4)。
// 每篇入库内部还会并发 embedding/图谱,故此值不宜过大,避免打爆 provider 速率。
func ingestConcurrency() int {
if v := os.Getenv("INGEST_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 4
}
// ingestAckWait 是入库作业未 ack 的最长时长(env INGEST_ACKWAIT_SEC,默认 1800s=30min)。
// 须覆盖单篇最长入库时长;崩溃在途作业在此时长后由 JetStream 重投兜底(值越小恢复越快、但越易把慢作业误判重投)。
func ingestAckWait() time.Duration {
if v := os.Getenv("INGEST_ACKWAIT_SEC"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Minute
}
// EnsureIngestStream 幂等地创建/更新入库作业流,持久捕获 sundynix.ingest.>。
func (b *Bus) EnsureIngestStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamIngest,
Subjects: []string{contract.SubjectIngestAll},
Storage: jetstream.FileStorage,
MaxAge: ingestStreamMaxAge,
})
return err
}
// ingestStreamMaxAge 限制入库流里消息的保留时长(已 ack 的会被清;未消费的超时丢弃),
// 防止异常堆积无限增长。30 天足以覆盖任何正常重投窗口。
const ingestStreamMaxAge = 30 * 24 * time.Hour
// PublishIngestJob 把入库作业发布到 sundynix.ingest.<job>,持久入队(崩溃不丢)。
func (b *Bus) PublishIngestJob(ctx context.Context, job *contract.IngestJob) error {
data, err := job.Marshal()
if err != nil {
return err
}
ctx, span := tracer().Start(ctx, "nats.publish ingest",
trace.WithSpanKind(trace.SpanKindProducer),
trace.WithAttributes(
attribute.String("messaging.system", "nats"),
attribute.String("messaging.destination.name", contract.IngestSubject(job.JobID)),
))
defer span.End()
msg := nats.NewMsg(contract.IngestSubject(job.JobID))
msg.Data = data
injectTrace(ctx, msg.Header)
if _, err := b.js.PublishMsg(ctx, msg); err != nil {
span.RecordError(err)
return fmt.Errorf("publish ingest job: %w", err)
}
return nil
}
// ConsumeIngestJobs 在持久消费者上消费入库作业,队列组内多副本负载均衡。
// 每条作业派到独立 worker goroutine;并发由信号量 + MaxAckPending 双重约束(背压,削峰不 OOM)。
// 失败延迟重投、网关崩溃由 AckWait 兜底重投——入库幂等(按 doc 先删后写),重跑安全。
func (b *Bus) ConsumeIngestJobs(ctx context.Context, h IngestHandler) (drain func(context.Context), err error) {
concurrency := ingestConcurrency()
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamIngest, jetstream.ConsumerConfig{
Durable: contract.ConsumerIngest,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectIngestAll,
// 一篇几十万字含切块+上千次 embedding+至多 60 次图谱抽取,可达数分钟;
// AckWait 须覆盖单篇最长入库时长,否则在途未 ack 会被重投成重复入库。
AckWait: ingestAckWait(),
MaxAckPending: concurrency, // 背压:服务端不下发超过本节点同时能处理的量
MaxDeliver: ingestMaxDeliver, // 毒消息兜底:重投上限,避免坏作业无限循环
})
if err != nil {
return nil, fmt.Errorf("create ingest consumer: %w", err)
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
cc, err := cons.Consume(func(msg jetstream.Msg) {
job, err := contract.UnmarshalIngestJob(msg.Data())
if err != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
wg.Add(1)
go func() {
defer func() {
<-sem
wg.Done()
if r := recover(); r != nil {
log.Printf("[bus] ingest %s handler panic: %v", job.JobID, r)
_ = msg.Term() // panic 丢弃不重投,避免崩溃循环
}
}()
lastAttempt := true // 取不到投递元数据时按"最后一次"处理,宁可不重试也不死循环
if meta, merr := msg.Metadata(); merr == nil {
lastAttempt = meta.NumDelivered >= ingestMaxDeliver
}
// handler ctx 派生自 Background:停止消费不掐断在途入库,由 drain 等完 / AckWait 兜底。
mctx := extractTrace(context.Background(), nats.Header(msg.Headers()))
mctx, span := tracer().Start(mctx, "nats.consume ingest",
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithAttributes(attribute.String("sundynix.ingest_job", job.JobID)))
defer span.End()
if herr := h(mctx, job, lastAttempt); herr != nil {
span.RecordError(herr)
_ = msg.NakWithDelay(2 * time.Second) // 瞬时失败延迟重投(至多 ingestMaxDeliver 次)
return
}
_ = msg.Ack()
}()
})
if err != nil {
return nil, fmt.Errorf("consume ingest: %w", err)
}
return func(dctx context.Context) {
cc.Stop()
drainWait(&wg, dctx)
}, nil
}
// ---- JetStream KV:临时持久态(dispatcher compose checkpoint 等)----
// KVHandle 是一个 JetStream KV 桶的薄封装,把 NATS 细节(ErrKeyNotFound 等)挡在 bus 内,
+40
View File
@@ -0,0 +1,40 @@
package contract
import "encoding/json"
// 入库工作队列约定(准生产级)。把"解析→切块→向量化→图谱"从网关的裸 goroutine 升级为
// JetStream 持久工作队列:崩溃可重投、有界并发背压、削峰不 OOM。与任务流(SUNDYNIX_TASKS)同级。
const (
StreamIngest = "SUNDYNIX_INGEST" // 入库作业 JetStream 流(持久,作业不因网关重启而丢)
SubjectIngest = "sundynix.ingest" // 入库作业发布前缀;实际 sundynix.ingest.<job_id>
SubjectIngestAll = "sundynix.ingest.>" // 流捕获的通配
ConsumerIngest = "ingest-workers" // 入库 worker 持久消费者(队列组:多副本负载均衡 + 背压)
)
// IngestSubject 返回某入库作业的发布主题。
func IngestSubject(jobID string) string { return SubjectIngest + "." + jobID }
// IngestJob 是一条入库作业(claim-check 模式):大文件原始字节/正文先暂存到对象存储,
// 作业消息只带「暂存键 + 元数据」这条小消息进队列,worker 消费时再按 StageKey 取回原料。
// 这样作业消息恒小(不受 JetStream max_payload 限制),且崩溃重投只搬一个引用。
type IngestJob struct {
JobID string `json:"job_id"`
Owner string `json:"owner"` // 雪花 user.id
KBName string `json:"kb_name"` // 知识库展示名(原文留存分区)
Scoped string `json:"scoped"` // owner/kb 作向量/全文/图谱分区键
ForceDoc string `json:"force_doc,omitempty"` // 非空=强制文档名(笔记编辑保持身份稳定)
Filename string `json:"filename,omitempty"` // 非空=文件入库(StageKey 指向原始字节,需先解析)
StageKey string `json:"stage_key"` // 暂存对象键:原始文件字节 或 纯文本
IsText bool `json:"is_text"` // true=StageKey 指向纯文本(跳过解析)
}
// Marshal/Unmarshal 入库作业(JetStream 消息体)。
func (j *IngestJob) Marshal() ([]byte, error) { return json.Marshal(j) }
func UnmarshalIngestJob(data []byte) (*IngestJob, error) {
var j IngestJob
if err := json.Unmarshal(data, &j); err != nil {
return nil, err
}
return &j, nil
}