mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 14:15:40 +08:00
feat(ingestion): mirror Go pipeline progress into the document table;
harden resume guards
- pipeline: bind the owning document via WithDocumentID; after each
TrackProgress event aggregate ingestion_task_log progress and mirror
progress/run/progress_msg back into the document table, so GET
/api/v1/datasets/{dataset_id}/documents reflects live Go pipeline
progress without a bespoke endpoint.
- canvas: extend the S3 resume guard to reject legacy no-op nodes (e.g.
ExitLoop) so component_total equals the count of progress-reporting
components and the aggregate percent can reach 100%.
- runtime/canvas: route progress through TrackProgress; add interrupt
test coverage (r3_interrupt_test.go).
- dao/entity: add IngestionTask.DocumentID column and AggregateProgress
support used by the mirror; IngestionTaskLog keeps a Checkpoint column
alongside the progress fields.
feat(deepdoc): cache DocAnalyzer inference results in Redis (1h TTL)
- Redis-backed DocAnalyzerCache decorator over inference.Client; cache
key = "ddoc:cache:<method>:" + sha256 of the JPEG-encoded image bytes
(deterministic).
- TTL = 1h; hits skip the inner HTTP call and return cached JSON; inner
errors are not cached.
refactor(deepdoc): align figure cropping with Python cropout + bounded
page caches
- CropSectionByDLA mirrors Python cropout: best-overlap DLA
figure/equation region, fallback to section bbox per page, vertical
concat on gray background.
- sliding-window page-image cache bounds peak memory to the recent
window instead of the whole PDF.
- rename DLADebug -> DLARegions across parser/chunker/tests.
refactor(parser): drop lib_type selector; align NewXxxParser with
NewPDFParser
- remove config["lib_type"] lookup and the libType param/field/switch
from all nine constructors; surface the CGO-required error at
ParseWithResult time instead of construction time; drop resolveLibType,
its test, and the four lib_type constants.
feat(utility): add a reusable workerpool for bounded concurrent
execution
- internal/utility/workerpool.go (+ tests).
refactor: translate Chinese prose comments to English in non-harness Go
files.
chore: upgrade github.com/cloudwego/eino from v0.9.9 to v0.9.12.
376 lines
11 KiB
Go
376 lines
11 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 inference
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
doctype "ragflow/internal/deepdoc/parser/type"
|
|
)
|
|
|
|
// ── fake store ───────────────────────────────────────────────────────
|
|
|
|
// fakeStore is an in-memory implementation of cacheStore used
|
|
// by tests. Safe for concurrent use.
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
enabled bool
|
|
data map[string][]byte
|
|
sets int32
|
|
gets int32
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{enabled: true, data: map[string][]byte{}}
|
|
}
|
|
|
|
func (s *fakeStore) Enabled() bool { return s.enabled }
|
|
|
|
func (s *fakeStore) GetObj(key string, dest any) bool {
|
|
atomic.AddInt32(&s.gets, 1)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
raw, ok := s.data[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return json.Unmarshal(raw, dest) == nil
|
|
}
|
|
|
|
func (s *fakeStore) SetObj(key string, value any, _ time.Duration) bool {
|
|
atomic.AddInt32(&s.sets, 1)
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data[key] = raw
|
|
return true
|
|
}
|
|
|
|
// ── fake analyser ──────────────────────────────────────────────────
|
|
|
|
// fakeAnalyzer is a deterministic, count-tracking DocAnalyzer.
|
|
// Each method increments a counter and returns a value shaped
|
|
// uniquely so a cache hit can be told apart from an inner call.
|
|
type fakeAnalyzer struct {
|
|
dlaCount int32
|
|
tsrCount int32
|
|
ocrDetCnt int32
|
|
ocrRecCnt int32
|
|
|
|
healthy bool
|
|
|
|
dlaOut []doctype.DLARegion
|
|
tsrOut []doctype.TSRCell
|
|
ocrDet []doctype.OCRBox
|
|
ocrRec []doctype.OCRText
|
|
dlaErr error
|
|
tsrErr error
|
|
ocrDetEr error
|
|
ocrRecEr error
|
|
}
|
|
|
|
func (f *fakeAnalyzer) DLA(_ context.Context, _ image.Image) ([]doctype.DLARegion, error) {
|
|
atomic.AddInt32(&f.dlaCount, 1)
|
|
if f.dlaErr != nil {
|
|
return nil, f.dlaErr
|
|
}
|
|
return f.dlaOut, nil
|
|
}
|
|
|
|
func (f *fakeAnalyzer) TSR(_ context.Context, _ image.Image) ([]doctype.TSRCell, error) {
|
|
atomic.AddInt32(&f.tsrCount, 1)
|
|
if f.tsrErr != nil {
|
|
return nil, f.tsrErr
|
|
}
|
|
return f.tsrOut, nil
|
|
}
|
|
|
|
func (f *fakeAnalyzer) OCRDetect(_ context.Context, _ image.Image) ([]doctype.OCRBox, error) {
|
|
atomic.AddInt32(&f.ocrDetCnt, 1)
|
|
if f.ocrDetEr != nil {
|
|
return nil, f.ocrDetEr
|
|
}
|
|
return f.ocrDet, nil
|
|
}
|
|
|
|
func (f *fakeAnalyzer) OCRRecognize(_ context.Context, _ image.Image) ([]doctype.OCRText, error) {
|
|
atomic.AddInt32(&f.ocrRecCnt, 1)
|
|
if f.ocrRecEr != nil {
|
|
return nil, f.ocrRecEr
|
|
}
|
|
return f.ocrRec, nil
|
|
}
|
|
|
|
func (f *fakeAnalyzer) Health() bool { return f.healthy }
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────
|
|
|
|
// newImage returns a tiny 4x4 RGBA image with stable pixel
|
|
// content. JPEG encoding of identical pixel data is
|
|
// deterministic, so the cache key for the same newImage() result
|
|
// is the same across calls.
|
|
func newImage(seed byte) *image.RGBA {
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
for y := 0; y < 4; y++ {
|
|
for x := 0; x < 4; x++ {
|
|
img.SetRGBA(x, y, color.RGBA{seed, seed * 2, seed * 3, 255})
|
|
}
|
|
}
|
|
return img
|
|
}
|
|
|
|
// ── key tests ──────────────────────────────────────────────────────
|
|
|
|
func TestCacheKey_Deterministic(t *testing.T) {
|
|
a, errA := cacheKey("dla", newImage(0x10))
|
|
if errA != nil {
|
|
t.Fatalf("cacheKey(dla,#1): %v", errA)
|
|
}
|
|
b, errB := cacheKey("dla", newImage(0x10))
|
|
if errB != nil {
|
|
t.Fatalf("cacheKey(dla,#2): %v", errB)
|
|
}
|
|
if a != b {
|
|
t.Fatalf("same image → different key: %q vs %q", a, b)
|
|
}
|
|
if want := cacheKeyPrefix + "dla:"; len(a) < len(want) || a[:len(want)] != want {
|
|
t.Fatalf("key missing prefix: %q", a)
|
|
}
|
|
|
|
c, errC := cacheKey("tsr", newImage(0x10))
|
|
if errC != nil {
|
|
t.Fatalf("cacheKey(tsr): %v", errC)
|
|
}
|
|
if c == a {
|
|
t.Fatal("different methods should yield different keys")
|
|
}
|
|
|
|
d, errD := cacheKey("dla", newImage(0x11))
|
|
if errD != nil {
|
|
t.Fatalf("cacheKey(dla,#2): %v", errD)
|
|
}
|
|
if d == a {
|
|
t.Fatal("different image bytes should yield different keys")
|
|
}
|
|
}
|
|
|
|
func TestCacheKey_NilImageErrors(t *testing.T) {
|
|
if _, err := cacheKey("dla", nil); err == nil {
|
|
t.Fatal("want error for nil image, got nil")
|
|
}
|
|
}
|
|
|
|
// ── cache hit / miss / disabled ────────────────────────────────────
|
|
|
|
func TestDocAnalyzerCache_MissThenHit(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaOut: []doctype.DLARegion{{X0: 1, Y0: 2, X1: 3, Y1: 4, Label: "text"}},
|
|
}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
img := newImage(0x20)
|
|
|
|
out1, err := c.DLA(context.Background(), img)
|
|
if err != nil {
|
|
t.Fatalf("DLA #1: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
|
t.Fatalf("inner called %d times after first DLA, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&s.sets); got != 1 {
|
|
t.Fatalf("store.SetObj called %d times after first DLA, want 1", got)
|
|
}
|
|
|
|
out2, err := c.DLA(context.Background(), img)
|
|
if err != nil {
|
|
t.Fatalf("DLA #2: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
|
t.Fatalf("inner called %d times after hit, want 1 (cached)", got)
|
|
}
|
|
if got := atomic.LoadInt32(&s.gets); got < 1 {
|
|
t.Fatalf("store.GetObj not called, want ≥1")
|
|
}
|
|
|
|
if len(out1) != len(out2) || out1[0].Label != out2[0].Label {
|
|
t.Fatalf("cached result diverged from inner: %v vs %v", out1, out2)
|
|
}
|
|
}
|
|
|
|
func TestDocAnalyzerCache_StoreDisabledPassthrough(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaOut: []doctype.DLARegion{{Label: "text"}},
|
|
}
|
|
s := newFakeStore()
|
|
s.enabled = false
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
img := newImage(0x30)
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
|
t.Fatalf("DLA #%d: %v", i, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 3 {
|
|
t.Fatalf("inner called %d times, want 3 (no caching when disabled)", got)
|
|
}
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
|
t.Fatalf("store.SetObj called %d times, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDocAnalyzerCache_NilImageBypassesCache(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaOut: []doctype.DLARegion{{Label: "noop"}},
|
|
}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := c.DLA(context.Background(), nil); err != nil {
|
|
t.Fatalf("DLA nil #%d: %v", i, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
|
t.Fatalf("nil image should bypass store.SetObj, got count %d", got)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 2 {
|
|
t.Fatalf("inner called %d times, want 2 (nil → no cache)", got)
|
|
}
|
|
}
|
|
|
|
// ── all 4 methods ───────────────────────────────────────────────────
|
|
|
|
func TestDocAnalyzerCache_AllMethods(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaOut: []doctype.DLARegion{{Label: "title"}},
|
|
tsrOut: []doctype.TSRCell{{X0: 0, Y0: 0, X1: 1, Y1: 1, Text: "A"}},
|
|
ocrDet: []doctype.OCRBox{{X0: 0, Y0: 0, X1: 10, Y1: 10}},
|
|
ocrRec: []doctype.OCRText{{Text: "x", Confidence: 0.9}},
|
|
}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
img := newImage(0x40)
|
|
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
|
t.Fatalf("DLA: %v", err)
|
|
}
|
|
if _, err := c.TSR(context.Background(), img); err != nil {
|
|
t.Fatalf("TSR: %v", err)
|
|
}
|
|
if _, err := c.OCRDetect(context.Background(), img); err != nil {
|
|
t.Fatalf("OCRDetect: %v", err)
|
|
}
|
|
if _, err := c.OCRRecognize(context.Background(), img); err != nil {
|
|
t.Fatalf("OCRRecognize: %v", err)
|
|
}
|
|
|
|
if got := atomic.LoadInt32(&s.sets); got != 4 {
|
|
t.Fatalf("store.SetObj called %d times, want 4", got)
|
|
}
|
|
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
|
t.Fatalf("DLA #2: %v", err)
|
|
}
|
|
if _, err := c.TSR(context.Background(), img); err != nil {
|
|
t.Fatalf("TSR #2: %v", err)
|
|
}
|
|
if _, err := c.OCRDetect(context.Background(), img); err != nil {
|
|
t.Fatalf("OCRDetect #2: %v", err)
|
|
}
|
|
if _, err := c.OCRRecognize(context.Background(), img); err != nil {
|
|
t.Fatalf("OCRRecognize #2: %v", err)
|
|
}
|
|
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
|
t.Fatalf("DLA inner count = %d, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.tsrCount); got != 1 {
|
|
t.Fatalf("TSR inner count = %d, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.ocrDetCnt); got != 1 {
|
|
t.Fatalf("OCRDetect inner count = %d, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.ocrRecCnt); got != 1 {
|
|
t.Fatalf("OCRRecognize inner count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// ── inner error propagation ─────────────────────────────────────────
|
|
|
|
func TestDocAnalyzerCache_InnerErrorNotCached(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaErr: errors.New("inference service down"),
|
|
}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
img := newImage(0x50)
|
|
if _, err := c.DLA(context.Background(), img); err == nil {
|
|
t.Fatal("want error, got nil")
|
|
}
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
|
t.Fatalf("error result must not be cached, got sets=%d", got)
|
|
}
|
|
}
|
|
|
|
// ── Health passthrough ─────────────────────────────────────────────
|
|
|
|
func TestDocAnalyzerCache_HealthPassthrough(t *testing.T) {
|
|
for _, healthy := range []bool{true, false} {
|
|
inner := &fakeAnalyzer{healthy: healthy}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
if got := c.Health(); got != healthy {
|
|
t.Fatalf("Health() = %v, want %v", got, healthy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── different images → different keys ───────────────────────────────
|
|
|
|
func TestDocAnalyzerCache_DifferentImagesBypassCache(t *testing.T) {
|
|
inner := &fakeAnalyzer{
|
|
dlaOut: []doctype.DLARegion{{Label: "a"}},
|
|
}
|
|
s := newFakeStore()
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
|
|
|
if _, err := c.DLA(context.Background(), newImage(0x80)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.DLA(context.Background(), newImage(0x81)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 2 {
|
|
t.Fatalf("inner called %d times for 2 different images, want 2", got)
|
|
}
|
|
}
|