Files
ragflow/internal/deepdoc/parser/pdf/inference/cache_test.go
Zhichang Yu 12787996d1 feat(agent): Go ingestion pipeline progress mirroring and DeepDOC parser hardening (#16795)
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.
2026-07-10 10:36:10 +08:00

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)
}
}