Files
ragflow/internal/ingestion/knowledge_compile/consumer.go
Zhichang Yu 90f46b0b4d Go port: doc-level metadata extraction and knowledge compiler (#17536)
Ports doc-level auto-metadata extraction to Go and adds the
knowledge_compiler component with scheduler/routing. Fixes Extractor
metadata injection type assertion and enable_metadata default-on.
2026-07-29 21:06:48 +08:00

286 lines
8.2 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package knowledge_compile
import (
"context"
"sync"
"time"
)
// Consumer is the dataset-level post-processing worker (§11.5). Multiple
// instances compete on the MySQL scheduling rows; each KB is processed by at
// most one instance at a time via the per-KB claim (so the same KB is handled
// by a single worker that serializes the batch). The MySQL row — not the
// broker — is the scheduling system of record and the source of same-KB
// serialization.
type Consumer struct {
scheduler Scheduler
reader Reader
writer Writer
factory DeduperFactory
batchSize int
ttl time.Duration
heartbeat time.Duration
pollInterval time.Duration
sweepInterval time.Duration
mu sync.Mutex
seqs map[string]map[string]uint64 // dataset -> docID -> last applied seq (per-doc out-of-order guard)
tombs map[string]map[string]uint64 // dataset -> docID -> delete event seq (tombstone)
}
// NewConsumer constructs a Consumer driven by the given Scheduler. Tests pass a
// FakeScheduler and override the Reader/Writer/Deduper via options.
func NewConsumer(scheduler Scheduler, opts ...Option) *Consumer {
c := &Consumer{
scheduler: scheduler,
reader: infinityReader{},
writer: infinityWriter{},
factory: defaultDeduperFactory,
batchSize: 32,
ttl: 2 * time.Minute,
heartbeat: 20 * time.Second,
pollInterval: 2 * time.Second,
sweepInterval: 30 * time.Second,
seqs: map[string]map[string]uint64{},
tombs: map[string]map[string]uint64{},
}
for _, o := range opts {
o(c)
}
return c
}
// Run is one owned worker loop (Option E §11.5/§11.7): it wakes on NATS notify
// and otherwise polls the scheduling table for a claimable KB, then claims the
// closed batch, processes it, and acks. It returns when ctx is cancelled.
func (c *Consumer) Run(ctx context.Context) {
if c.scheduler == nil {
return
}
notifyCh, _ := c.scheduler.SubscribeNotify(ctx)
poll := time.NewTicker(c.pollInterval)
sweep := time.NewTicker(c.sweepInterval)
defer poll.Stop()
defer sweep.Stop()
for {
select {
case <-ctx.Done():
return
case ds := <-notifyCh:
if ds != "" {
c.processDataset(ctx, ds)
}
case <-poll.C:
c.claimOne(ctx)
case <-sweep.C:
// Recover inflight left by crashed workers (crash recovery, §11.5).
if _, err := c.scheduler.ReclaimExpired(ctx, time.Now()); err != nil {
// best-effort; next tick retries
_ = err
}
}
}
}
// claimOne finds a claimable KB and processes it. FindClaimable returns at most
// one dataset so the worker handles it before looking for more.
func (c *Consumer) claimOne(ctx context.Context) {
ids, err := c.scheduler.FindClaimable(ctx, 1)
if err != nil || len(ids) == 0 {
return
}
for _, ds := range ids {
c.processDataset(ctx, ds)
}
}
// processDataset claims the closed batch for datasetID, processes it, and acks.
// It is the Option E replacement for the old processOnce (lease + drain + merge):
// the claim transaction returns a frozen batch boundary, so there is no moving
// target and no Nak-churn routing.
func (c *Consumer) processDataset(ctx context.Context, datasetID string) {
cr, ok, err := c.scheduler.Claim(ctx, datasetID, c.batchSize)
if err != nil {
return
}
if !ok || len(cr.Entries) == 0 {
return // race lost or nothing to claim
}
// Heartbeat refreshes the claim TTL while we process; a failed touch means
// the lease was taken over (or reclaimed) and we must abort without acking.
stopHb := make(chan struct{})
hbFailed := make(chan struct{}, 1)
go func() {
t := time.NewTicker(c.heartbeat)
defer t.Stop()
for {
select {
case <-stopHb:
return
case <-ctx.Done():
return
case <-t.C:
alive, e := c.scheduler.TouchClaim(ctx, datasetID, cr.Token, c.ttl)
if e != nil || !alive {
select {
case hbFailed <- struct{}{}:
default:
}
return
}
}
}
}()
done := make(chan struct{})
var batchErr error
go func() {
defer close(done)
batchErr = c.processBatch(ctx, cr.TenantID, datasetID, cr.Entries)
}()
select {
case <-ctx.Done():
close(stopHb)
<-done
return // graceful shutdown: leave inflight for reclamation, do not ack
case <-hbFailed:
close(stopHb)
<-done
return // lease lost: do not ack; sweeper/redelivery reprocesses (idempotent)
case <-done:
close(stopHb)
}
// Ack only on success. A batch error (reader/dedup/writer failure) means we
// must leave the claimed batch in the backlog for reclamation/retry rather
// than silently dropping it (C5: never ack what we failed to merge).
if batchErr != nil {
return
}
if _, err := c.scheduler.Ack(ctx, datasetID, cr.Token, cr.Entries); err != nil {
_ = err
}
}
// processBatch applies out-of-order / tombstone handling, then recomputes and
// writes the dataset-level merged products for the claimed closed batch. It
// returns an error if any reader/dedup/writer step fails so the caller can
// leave the batch for reclamation instead of acking dropped work.
func (c *Consumer) processBatch(ctx context.Context, tenant, kb string, entries []BacklogEntry) error {
c.mu.Lock()
if c.tombs == nil {
c.tombs = map[string]map[string]uint64{}
}
if c.seqs == nil {
c.seqs = map[string]map[string]uint64{}
}
tomb := c.tombs[kb]
docSeqs := c.seqs[kb]
if docSeqs == nil {
docSeqs = map[string]uint64{}
c.seqs[kb] = docSeqs
}
var completed []BacklogEntry
var deleted []string
for _, e := range entries {
switch EventType(e.EventType) {
case EventTypeDeleted:
if tomb == nil {
tomb = map[string]uint64{}
c.tombs[kb] = tomb
}
tomb[e.DocID] = e.Seq
deleted = append(deleted, e.DocID)
case EventTypeCompleted:
// A tombstone means the doc was deleted; a completion with a seq
// <= the delete seq is the stale original completion (skip it).
// A completion with a higher seq is a genuine re-ingest after
// deletion: clear the tombstone so the doc is processed again
// (otherwise the tombstone would skip it forever and grow
// unbounded across the consumer's lifetime).
if delSeq, ok := tomb[e.DocID]; ok {
if e.Seq <= delSeq {
continue // deleted (or a stale completion) before it completed
}
delete(tomb, e.DocID)
}
// Seq is per-document, so the stale/duplicate check must be scoped
// to the document, not the whole dataset (C4).
if prev, ok := docSeqs[e.DocID]; ok && e.Seq <= prev {
continue // stale / duplicate completion for this doc
}
docSeqs[e.DocID] = e.Seq
completed = append(completed, e)
}
}
c.mu.Unlock()
if len(deleted) == 0 && len(completed) == 0 {
return nil
}
deduper, err := c.factory(tenant)
if err != nil || deduper == nil {
deduper = NewNoopDeduper()
}
products, err := c.reader.LoadCompiledProducts(ctx, tenant, kb)
if err != nil {
return err
}
if len(products) == 0 {
// Nothing to merge. Still drop fully-orphaned merged products for
// deleted docs, then treat as success (nothing to do).
for _, d := range deleted {
_ = c.writer.DeleteMergedForDoc(ctx, tenant, kb, d)
}
return nil
}
// With deletions present, recompute the whole KB; otherwise scope to the
// contributing documents of this batch (efficiency).
if len(deleted) == 0 {
docSet := make(map[string]bool, len(completed))
for _, e := range completed {
docSet[e.DocID] = true
}
scoped := products[:0]
for _, p := range products {
if docSet[p.DocID] {
scoped = append(scoped, p)
}
}
products = scoped
}
merged, err := deduper.Dedup(ctx, products)
if err != nil {
return err
}
if err := c.writer.WriteMerged(ctx, tenant, kb, merged); err != nil {
return err
}
for _, d := range deleted {
_ = c.writer.DeleteMergedForDoc(ctx, tenant, kb, d)
}
return nil
}