Files
ragflow/internal/ingestion/knowledge_compile/scheduler.go
Zhichang Yu 01d667296d refactor(knowledge_compile): global compile pool, token-budget batching, and DocEngine-only deletion (#17679)
## Summary

This PR refactors the Go knowledge-compilation ingestion pipeline
(`internal/ingestion/knowledge_compile` +
`internal/ingestion/component/knowledge_compiler`) with three related
changes:

- **Token-budget batching for LLM merge decisions.**
`LLMMergeDecider.DecideBatch` previously stuffed every `(existing,
candidate)` pair into a single LLM call, risking `max_token` overflow.
It now splits pairs into token-bounded sub-batches (budget =
`llmMaxTokens * 0.85`) via `tokenizer.NumTokensFromString`, runs them
concurrently while preserving the global pair index, and never
reindexes.
- **Process-level global compile pool.** Introduces a single vCPU-sized
goroutine pool (`pool.go`, env `KC_COMPILE_CONCURRENCY`) dedicated to
*all* knowledge-compilation stages. KNN search loop, `DecideBatch`
sub-batches, `WriteMerged`/`DeleteMerged` internals, and the
component-level (structure/mindmap) per-call pools are all unified into
it via an injected submitter. No more per-job short-lived goroutines in
`runCompilerJobs` (futures are collected then awaited on the caller).
Fan-out stays bounded by the pool worker count; these stages are
docengine-bounded / LLM-bounded, not CPU-bounded.
- **DocEngine-only deletion.** `Consumer.processBatch` deletion no
longer loads the deleted docs' products into memory. Two sequential
DocEngine calls replace the old in-memory surgery:
- `DeleteDocLevelForDocs` — one `DeleteChunks` over `doc_id IN
deletedDocIDs` (merged rows carry `doc_id == kb`, so only per-doc
products match).
- `StripMergedSources` — one `Search` of `kc_merged=1` rows filtered by
`source_doc_ids IN deletedDocIDs` (intersection pushed down to the
engine), `UpdateChunks` the source array of survivors, and
`DeleteChunks` the rows whose array became empty.

## Changes

- `internal/ingestion/knowledge_compile/pool.go` (new): global
`compilerPool` +
`runCompilerJobs`/`SubmitCompilerJob`/`SubmitCompilerJobs`.
- `internal/ingestion/knowledge_compile/consumer.go`: deletion rewritten
to the two DocEngine calls;
`mergedBase`/`toDelete`/`stripDeletedSources` removed.
- `internal/ingestion/knowledge_compile/writer.go`:
`DeleteDocLevelForDocs` + `StripMergedSources` replace
`DeleteMergedForDoc`/`DeleteMerged`.
- `internal/ingestion/knowledge_compile/reader.go`: drop
`LoadMergedBySourceDoc` + `containsString` (keep `LoadDocProducts` for
the completion branch).
- `internal/ingestion/knowledge_compile/dedup.go`: `NewLLMDeduper` takes
`llmMaxTokens`; wires `SetMaxBatchTokens`/`SetSubmitter`.
- `internal/ingestion/knowledge_compiler/{structure,merge}.go`,
`mindmap/mindmap.go`, `pool_wiring.go`: token-budget split + submitter
injection.
- Tests: `structure_test.go` (token-budget split), `dedup_test.go`,
`consumer_test.go` (tombstone + DocEngine deletion assertions) updated.

## Validation

`bash build.sh --test -race ./internal/ingestion/knowledge_compile/...
./internal/ingestion/component/knowledge_compiler/...` passes (unit
tier, no external services).

🤖 Generated with [CodeBuddy](https://www.codebuddy.ai)

---------

Co-authored-by: yuzhichang <yuzhichang@infiniflow.ai>
2026-08-02 17:06:29 +08:00

602 lines
20 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"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"ragflow/internal/engine"
"ragflow/internal/entity"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ErrClaimTokenMismatch is returned by Ack/TouchClaim when the supplied token
// no longer identifies the live claim (another worker took over, or the
// sweeper reclaimed it). The caller must abort without writing.
var ErrClaimTokenMismatch = errors.New("knowledge_compile: claim token mismatch")
// notifySubject is the Option E wake-up channel (§11.4): publishers push a
// {dataset_id} payload here after appending to a KB's backlog; workers
// subscribe to wake promptly. It carries no routing payload — MySQL is the
// scheduling truth.
const notifySubject = "notify.kc.workers"
// BacklogEntry is one scheduling unit appended to a KB's backlog (Option E
// §11.4). It carries the doc id plus the original event kind/seq so the
// consumer can re-apply the same out-of-order / tombstone guards as the
// broker-based design without re-reading the queue.
type BacklogEntry struct {
DocID string `json:"doc_id"`
EventType string `json:"event_type"`
Seq uint64 `json:"seq"`
}
// ClaimResult is returned by Scheduler.Claim: the KB's tenant plus the closed
// batch of entries moved into inflight, and the token identifying this claim.
type ClaimResult struct {
DatasetID string
TenantID string
Entries []BacklogEntry
Token string
}
// Publisher is the producer-side role (Option E §11.4). It appends a document
// event to the dataset's durable backlog and wakes idle workers. MySQL is the
// system of record; NATS notify is only a best-effort wake-up. Publish is the
// single producer entry point so a publisher never forgets to Notify after
// enqueue (the two are now one atomic call site).
type Publisher interface {
// Provision ensures the backing store (table + notify subject) exists.
Provision(ctx context.Context) error
// Publish records one doc event in the dataset's durable backlog and wakes
// idle workers. It is transactional so concurrent publishers do not clobber
// each other's backlog, and it always pairs the append with a notify.
Publish(ctx context.Context, tenantID, datasetID, docID, eventType string, seq uint64) error
}
// Claimer is the consumer-side role (Option E §11.5). A cluster of competing
// workers claims closed batches (backlog -> inflight, a move not a copy) per
// dataset, keeps the lease alive while processing, and acks when done. The claim
// is per-KB, so the same dataset is serialized by its single live lease.
type Claimer interface {
// TryClaim finds a dataset with ready backlog and no live lease, or reclaims
// an expired lease, claims it atomically, and returns the closed batch.
// ok is false when there is nothing to claim or reclaim.
TryClaim(ctx context.Context) (ClaimResult, bool, error)
// Claim claims datasetID directly (no batch-size argument; the implementation
// decides the batch boundary). acquired=false when a live lease already holds
// the row (the race was lost) or the backlog is empty.
Claim(ctx context.Context, datasetID string) (ClaimResult, bool, error)
// TouchClaim extends the lease while processing (heartbeat). Returns false
// when the lease is gone (taken over / reclaimed) so the worker must abort.
TouchClaim(ctx context.Context, datasetID, token string, ttl time.Duration) (bool, error)
// Ack removes the claimed batch from inflight; clears the claim metadata
// only when backlog is also empty.
Ack(ctx context.Context, datasetID, token string, batch []BacklogEntry) (backlogRemaining int, err error)
// SubscribeNotify returns a channel of dataset ids pushed by Publish, or nil
// when the implementation has no push wake-up (callers fall back to polling).
SubscribeNotify(ctx context.Context) (<-chan string, error)
}
// newScheduler is the production constructor: a MySQL-backed scheduler with an
// optional NATS wake-up. holder identifies this ingestor instance; ttl is the
// claim lease duration. The returned *mysqlScheduler satisfies both Publisher
// and Claimer.
func newScheduler(db *gorm.DB, mq engine.MessageQueue, holder string, ttl time.Duration) *mysqlScheduler {
if ttl <= 0 {
ttl = 2 * time.Minute
}
return &mysqlScheduler{db: db, mq: mq, holder: holder, leaseTTL: ttl, claimBatchSize: defaultClaimBatch}
}
// ---- JSON helpers (the *_doc_ids columns are TEXT holding []BacklogEntry) ----
func marshalEntries(es []BacklogEntry) string {
if len(es) == 0 {
return "[]"
}
b, _ := json.Marshal(es)
return string(b)
}
func parseEntries(s string) []BacklogEntry {
if s == "" || s == "[]" {
return nil
}
var es []BacklogEntry
if err := json.Unmarshal([]byte(s), &es); err != nil || len(es) == 0 {
return nil
}
return es
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// ---- MySQL-backed scheduler ----
type mysqlScheduler struct {
db *gorm.DB
mq engine.MessageQueue
holder string
leaseTTL time.Duration
claimBatchSize int
}
// defaultClaimBatch is the closed-batch boundary when Claim is invoked without
// an explicit size (the previous batchSize argument).
const defaultClaimBatch = 32
func (s *mysqlScheduler) Provision(ctx context.Context) error {
if s.db == nil {
return nil
}
return s.db.WithContext(ctx).AutoMigrate(&entity.KnowledgeCompileDataset{})
}
// Publish appends one doc event to the dataset's durable backlog and wakes idle
// workers. The append is transactional (concurrent publishers do not clobber
// each other's backlog), and the notify is always paired with the append so a
// producer never needs a separate Notify call.
func (s *mysqlScheduler) Publish(ctx context.Context, tenantID, datasetID, docID, eventType string, seq uint64) error {
if s.db == nil {
return nil
}
entry := BacklogEntry{DocID: docID, EventType: eventType, Seq: seq}
// KnowledgeCompileDataset.dataset_id is the PRIMARY KEY, so the SELECT ... FOR
// UPDATE below also takes an InnoDB gap lock for a not-yet-existing dataset;
// concurrent publishers for the same dataset serialize on that lock and the
// loser observes the row already present. FirstOrCreate then reliably finds
// or inserts exactly one row, and the append below runs under the same row
// lock so concurrent appends from different publishers cannot interleave.
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Set the key/tenant fields on the struct itself (not only via a string
// Where) so the inserted row is fully populated and later queries by
// dataset_id find it (M19: a raw-string Where alone leaves DatasetID
// blank on the created row).
row := entity.KnowledgeCompileDataset{
DatasetID: datasetID,
TenantID: tenantID,
BacklogDocIDs: "[]",
InflightDocIDs: "[]",
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dataset_id = ?", datasetID).
FirstOrCreate(&row).Error; err != nil {
return err
}
if row.TenantID == "" {
row.TenantID = tenantID
}
backlog := parseEntries(row.BacklogDocIDs)
backlog = append(backlog, entry)
row.BacklogDocIDs = marshalEntries(backlog)
return tx.Save(&row).Error
})
if err != nil {
return fmt.Errorf("knowledge_compile: publish backlog %s: %w", datasetID, err)
}
return s.notify(ctx, datasetID)
}
// claimRow atomically claims the closed batch from the row identified by
// datasetID: it takes a FOR UPDATE row lock, refuses a live lease, moves up to
// claimBatchSize entries from backlog to inflight, and stamps the lease. The
// callers (Claim and TryClaim) differ only in how they pick the datasetID; the
// claim itself is identical so both share this helper and stay race-free.
func (s *mysqlScheduler) claimRow(ctx context.Context, tx *gorm.DB, datasetID string) (ClaimResult, bool, error) {
var row entity.KnowledgeCompileDataset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dataset_id = ?", datasetID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ClaimResult{}, false, nil // nothing to claim
}
return ClaimResult{}, false, err
}
now := time.Now()
liveLease := row.ClaimOwner != "" && row.ClaimExpiresAt != nil && row.ClaimExpiresAt.After(now)
if liveLease {
return ClaimResult{}, false, nil // a live lease means some worker is already processing this KB
}
backlog := parseEntries(row.BacklogDocIDs)
if len(backlog) == 0 {
return ClaimResult{}, false, nil
}
n := minInt(s.claimBatchSize, len(backlog))
batch := backlog[:n]
inflight := parseEntries(row.InflightDocIDs)
inflight = append(inflight, batch...)
row.BacklogDocIDs = marshalEntries(backlog[n:])
row.InflightDocIDs = marshalEntries(inflight)
row.ClaimOwner = s.holder
row.ClaimToken = generateHolder()
exp := now.Add(s.leaseTTL)
row.ClaimExpiresAt = &exp
if err := tx.Save(&row).Error; err != nil {
return ClaimResult{}, false, err
}
return ClaimResult{DatasetID: row.DatasetID, TenantID: row.TenantID, Entries: batch, Token: row.ClaimToken}, true, nil
}
func (s *mysqlScheduler) Claim(ctx context.Context, datasetID string) (ClaimResult, bool, error) {
if s.db == nil {
return ClaimResult{}, false, nil
}
var res ClaimResult
var acquired bool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
cr, ok, e := s.claimRow(ctx, tx, datasetID)
if e != nil {
return e
}
res, acquired = cr, ok
return nil
})
if err != nil {
return ClaimResult{}, false, err
}
return res, acquired, nil
}
func (s *mysqlScheduler) Ack(ctx context.Context, datasetID, token string, batch []BacklogEntry) (int, error) {
if s.db == nil {
return 0, nil
}
var remaining int
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row entity.KnowledgeCompileDataset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dataset_id = ?", datasetID).First(&row).Error; err != nil {
return err
}
if row.ClaimToken != token {
return ErrClaimTokenMismatch
}
inflight := parseEntries(row.InflightDocIDs)
inflight = removeEntries(inflight, batch)
row.InflightDocIDs = marshalEntries(inflight)
if len(inflight) == 0 {
row.ClaimOwner = ""
row.ClaimToken = ""
row.ClaimExpiresAt = nil
}
if err := tx.Save(&row).Error; err != nil {
return err
}
remaining = len(parseEntries(row.BacklogDocIDs))
return nil
})
if err != nil {
return 0, err
}
return remaining, nil
}
func (s *mysqlScheduler) TouchClaim(ctx context.Context, datasetID, token string, ttl time.Duration) (bool, error) {
if s.db == nil {
return false, nil
}
res := s.db.WithContext(ctx).Model(&entity.KnowledgeCompileDataset{}).
Where("dataset_id = ? AND claim_token = ?", datasetID, token).
Update("claim_expires_at", time.Now().Add(ttl))
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// TryClaim finds a dataset with ready backlog and no live lease, or reclaims an
// expired lease, claims it atomically within a single transaction, and returns
// the closed batch. ok is false when there is nothing to claim or reclaim.
//
// The find and the claim run inside one locked transaction so there is no race
// window between picking a dataset and taking its lease: a concurrent worker
// cannot claim the same row out from under us. Reclaim is inlined here so the
// sweeper and the poller share one entry point (no separate ReclaimExpired).
func (s *mysqlScheduler) TryClaim(ctx context.Context) (ClaimResult, bool, error) {
if s.db == nil {
return ClaimResult{}, false, nil
}
var res ClaimResult
var acquired bool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
// 1) a ready dataset (backlog, no live lease) — claim it directly.
if id, ok := s.findClaimableID(ctx, tx, now); ok {
cr, ok, e := s.claimRow(ctx, tx, id)
if e != nil {
return e
}
res, acquired = cr, ok
return nil
}
// 2) reclaim an expired lease back into backlog, then claim it.
if id, ok, e := s.reclaimOne(ctx, tx, now); e != nil {
return e
} else if ok {
cr, ok, e := s.claimRow(ctx, tx, id)
if e != nil {
return e
}
res, acquired = cr, ok
return nil
}
return nil
})
if err != nil {
return ClaimResult{}, false, err
}
return res, acquired, nil
}
// findClaimableID returns one dataset id with non-empty backlog and no live
// lease, or ok=false when none exists. It runs inside caller's transaction so
// the returned id is still locked when the caller claims it.
func (s *mysqlScheduler) findClaimableID(ctx context.Context, tx *gorm.DB, now time.Time) (string, bool) {
var id string
err := tx.Model(&entity.KnowledgeCompileDataset{}).
Where("(claim_expires_at IS NULL OR claim_expires_at <= ?) AND backlog_doc_ids <> '[]' AND backlog_doc_ids <> '' AND backlog_doc_ids IS NOT NULL", now).
Order("priority DESC, updated_at ASC").
Limit(1).
Pluck("dataset_id", &id).Error
if err != nil || id == "" {
return "", false
}
return id, true
}
// reclaimOne moves one expired inflight batch back to backlog (crash recovery)
// and returns that dataset id, or ok=false when nothing is expired. It runs
// inside caller's transaction so the reclaimed row remains locked when the
// caller claims it.
func (s *mysqlScheduler) reclaimOne(ctx context.Context, tx *gorm.DB, now time.Time) (string, bool, error) {
var rows []entity.KnowledgeCompileDataset
if err := tx.Model(&entity.KnowledgeCompileDataset{}).
Where("claim_expires_at IS NOT NULL AND claim_expires_at <= ? AND inflight_doc_ids <> '[]' AND inflight_doc_ids <> ''", now).
Find(&rows).Error; err != nil {
return "", false, err
}
for _, row := range rows {
var cur entity.KnowledgeCompileDataset
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dataset_id = ?", row.DatasetID).First(&cur).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return "", false, err
}
if cur.ClaimExpiresAt != nil && cur.ClaimExpiresAt.After(now) {
continue
}
inflight := parseEntries(cur.InflightDocIDs)
if len(inflight) == 0 {
continue
}
backlog := parseEntries(cur.BacklogDocIDs)
backlog = append(backlog, inflight...)
cur.BacklogDocIDs = marshalEntries(backlog)
cur.InflightDocIDs = "[]"
cur.ClaimOwner = ""
cur.ClaimToken = ""
cur.ClaimExpiresAt = nil
if err := tx.Save(&cur).Error; err != nil {
return "", false, err
}
return cur.DatasetID, true, nil
}
return "", false, nil
}
// notify is the best-effort wake-up published after a successful Publish. It is
// a no-op without a configured NATS subject; callers fall back to polling.
func (s *mysqlScheduler) notify(ctx context.Context, datasetID string) error {
if s.mq == nil {
return nil
}
payload, _ := json.Marshal(map[string]string{"dataset_id": datasetID})
return s.mq.PublishKnowledgeCompile(notifySubject, payload)
}
func (s *mysqlScheduler) SubscribeNotify(ctx context.Context) (<-chan string, error) {
if s.mq == nil {
return nil, nil
}
return s.mq.SubscribeNotify(ctx)
}
// removeEntries drops every entry in batch from the inflight slice (match by
// doc_id + event_type + seq). The batch is the exact set the worker claimed, so
// this is a precise removal, not a "clear all" — any inflight added by a
// concurrent claim is preserved.
func removeEntries(inflight, batch []BacklogEntry) []BacklogEntry {
if len(batch) == 0 {
return inflight
}
drop := make(map[BacklogEntry]bool, len(batch))
for _, e := range batch {
drop[e] = true
}
out := make([]BacklogEntry, 0, len(inflight))
for _, e := range inflight {
if drop[e] {
continue
}
out = append(out, e)
}
return out
}
// ---- in-memory scheduler for tests ----
type fakeRow struct {
tenant string
backlog []BacklogEntry
inflight []BacklogEntry
owner string
token string
expires *time.Time
}
// FakeScheduler is an in-memory Publisher + Claimer used by tests. It mirrors
// the MySQL semantics (move-not-copy claim, token-checked ack, lease takeover).
type FakeScheduler struct {
mu sync.Mutex
rows map[string]*fakeRow
notifyCh chan string
holder string
leaseTTL time.Duration
}
// NewFakeScheduler constructs an in-memory scheduler for tests.
func NewFakeScheduler() *FakeScheduler {
return &FakeScheduler{
rows: map[string]*fakeRow{},
notifyCh: make(chan string, 64),
holder: generateHolder(),
leaseTTL: 2 * time.Minute,
}
}
func (f *FakeScheduler) Provision(_ context.Context) error { return nil }
// Publish appends one doc event and pushes a notify (same as the MySQL path).
func (f *FakeScheduler) Publish(_ context.Context, tenantID, datasetID, docID, eventType string, seq uint64) error {
f.mu.Lock()
defer f.mu.Unlock()
r, ok := f.rows[datasetID]
if !ok {
r = &fakeRow{tenant: tenantID}
f.rows[datasetID] = r
}
if r.tenant == "" {
r.tenant = tenantID
}
r.backlog = append(r.backlog, BacklogEntry{DocID: docID, EventType: eventType, Seq: seq})
select {
case f.notifyCh <- datasetID:
default:
}
return nil
}
func (f *FakeScheduler) Claim(_ context.Context, datasetID string) (ClaimResult, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
r, ok := f.rows[datasetID]
if !ok {
return ClaimResult{}, false, nil
}
now := time.Now()
live := r.owner != "" && r.expires != nil && r.expires.After(now)
if live {
return ClaimResult{}, false, nil
}
if len(r.backlog) == 0 {
return ClaimResult{}, false, nil
}
n := minInt(defaultClaimBatch, len(r.backlog))
batch := append([]BacklogEntry{}, r.backlog[:n]...)
r.inflight = append(r.inflight, batch...)
r.backlog = append([]BacklogEntry{}, r.backlog[n:]...)
r.owner = f.holder
r.token = generateHolder()
exp := now.Add(f.leaseTTL)
r.expires = &exp
return ClaimResult{DatasetID: datasetID, TenantID: r.tenant, Entries: batch, Token: r.token}, true, nil
}
func (f *FakeScheduler) Ack(_ context.Context, datasetID, token string, batch []BacklogEntry) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
r, ok := f.rows[datasetID]
if !ok {
return 0, ErrClaimTokenMismatch
}
if r.token != token {
return 0, ErrClaimTokenMismatch
}
r.inflight = removeEntries(r.inflight, batch)
if len(r.inflight) == 0 {
r.owner, r.token, r.expires = "", "", nil
}
return len(r.backlog), nil
}
func (f *FakeScheduler) TouchClaim(_ context.Context, datasetID, token string, _ time.Duration) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
r, ok := f.rows[datasetID]
if !ok || r.token != token {
return false, nil
}
exp := time.Now().Add(f.leaseTTL)
r.expires = &exp
return true, nil
}
// TryClaim mirrors the production flow: claim a ready dataset, otherwise reclaim
// an expired lease and claim it.
func (f *FakeScheduler) TryClaim(ctx context.Context) (ClaimResult, bool, error) {
f.mu.Lock()
now := time.Now()
var readyID, expiredID string
for id, r := range f.rows {
if len(r.backlog) > 0 && (r.owner == "" || r.expires == nil || !r.expires.After(now)) {
readyID = id
break
}
}
if readyID == "" {
for id, r := range f.rows {
if r.owner != "" && r.expires != nil && r.expires.After(now) {
continue
}
if len(r.inflight) > 0 {
r.backlog = append(r.backlog, r.inflight...)
r.inflight = nil
r.owner, r.token, r.expires = "", "", nil
expiredID = id
break
}
}
}
f.mu.Unlock()
if readyID != "" {
return f.Claim(ctx, readyID)
}
if expiredID != "" {
return f.Claim(ctx, expiredID)
}
return ClaimResult{}, false, nil
}
func (f *FakeScheduler) SubscribeNotify(_ context.Context) (<-chan string, error) {
return f.notifyCh, nil
}