mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
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.
286 lines
8.2 KiB
Go
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
|
|
}
|