mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 23:24:05 +08:00
## 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>
602 lines
20 KiB
Go
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
|
|
}
|