Files
ragflow/internal/dao/ingestion.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

435 lines
12 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 dao
import (
"errors"
"fmt"
"ragflow/internal/common"
"ragflow/internal/entity"
"ragflow/internal/utility"
)
type IngestionTaskDAO struct{}
func NewIngestionTaskDAO() *IngestionTaskDAO {
return &IngestionTaskDAO{}
}
// Use by api server to create task
// created → running : After the ingestor component assigns the task, it changes the status to running
// running → completed : Task executes successfully
// running → failed : Error occurs during execution
// created → canceling : User cancels before the task is picked up by the ingestor
// running → canceling : User cancels during execution
// completed → canceling : User cancels a completed task (e.g., for cleanup/rollback)
// canceling → canceled : Cancellation completes
// failed → created : Retry (back to start)
// canceled → created : Retry/re-execute (back to start)
func (dao *IngestionTaskDAO) CheckAndCreate(ingestionTask *entity.IngestionTask) (*entity.IngestionTask, error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
// Check if the task is created
var taskRecord *entity.IngestionTask
err := tx.Where("document_id = ?", ingestionTask.DocumentID).First(&taskRecord).Error
if err == nil {
// found
if taskRecord.Status == common.FAILED || taskRecord.Status == common.STOPPED {
// restart the task
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskRecord.ID).Update("status", common.CREATED).Error
if err != nil {
tx.Rollback()
return nil, err
}
} else {
return nil, fmt.Errorf("document id %s already exists, status: %s, task id: %s", ingestionTask.DocumentID, taskRecord.Status, taskRecord.ID)
}
} else {
// create ingestion task
ingestionTask.ID = utility.GenerateUUID()
if err = tx.Create(ingestionTask).Error; err != nil {
tx.Rollback()
return nil, err
}
taskRecord = ingestionTask
}
if err = tx.Commit().Error; err != nil {
return nil, err
}
return taskRecord, nil
}
// UpdateStatus Update ingestion task status
func (dao *IngestionTaskDAO) UpdateStatus(taskID, status string) error {
return DB.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("status", status).Error
}
// UpdateComponentTotal records the number of components in the task's DSL
// graph. It is the authoritative denominator for progress percentage.
func (dao *IngestionTaskDAO) UpdateComponentTotal(taskID string, total int) error {
return DB.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("component_total", total).Error
}
// CheckAnd called by ingestor
// if task status is RUNNING, COMPLETED, STOPPED, FAILED, just return without error
// if task status is CREATE, update to RUNNING
// if task status is STOPPING, update to STOPPED
func (dao *IngestionTaskDAO) SetRunningByIngestor(taskID string) (*entity.IngestionTask, error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, tx.Error
}
var committed bool
defer func() {
if committed {
tx.Commit()
} else {
tx.Rollback()
if r := recover(); r != nil {
panic(r)
}
}
}()
var tasks []*entity.IngestionTask
err := tx.Where("id = ?", taskID).Find(&tasks).Error
if err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, common.ErrTaskNotFound
}
if len(tasks) != 1 {
return nil, fmt.Errorf("task %s has multiple records", taskID)
}
taskStatus := tasks[0].Status
switch taskStatus {
case common.CREATED:
tasks[0].Status = common.RUNNING
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("status", common.RUNNING).Error
if err != nil {
return nil, err
}
committed = true
return tasks[0], nil
case common.STOPPING:
tasks[0].Status = common.STOPPED
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("status", common.STOPPED).Error
if err != nil {
return nil, err
}
committed = true
return tasks[0], nil
case common.RUNNING:
// this task was executing before, just return without error
committed = true
return tasks[0], nil
default:
return tasks[0], nil
}
}
func (dao *IngestionTaskDAO) SetStoppingByAPIServer(taskID string) (*entity.IngestionTask, error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, tx.Error
}
var committed bool
defer func() {
if committed {
tx.Commit()
} else {
tx.Rollback()
if r := recover(); r != nil {
panic(r)
}
}
}()
var tasks []*entity.IngestionTask
err := tx.Where("id = ?", taskID).Find(&tasks).Error
if err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, fmt.Errorf("task %s not found", taskID)
}
if len(tasks) != 1 {
return nil, fmt.Errorf("task %s has multiple records", taskID)
}
taskStatus := tasks[0].Status
switch taskStatus {
case common.CREATED:
tasks[0].Status = common.STOPPED
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("status", common.STOPPED).Error
if err != nil {
return nil, err
}
committed = true
return tasks[0], nil
case common.RUNNING:
tasks[0].Status = common.STOPPING
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Update("status", common.STOPPING).Error
if err != nil {
return nil, err
}
committed = true
return tasks[0], nil
default:
return tasks[0], nil
}
}
type TaskInfo struct {
TaskID string `json:"task_id"`
FilesToDelete []string `json:"files_to_delete"`
}
func (dao *IngestionTaskDAO) RemoveByAPIServerOrAdminServer(taskID string, userID *string) (*TaskInfo, error) {
tx := DB.Begin()
if tx.Error != nil {
return nil, tx.Error
}
var committed bool
defer func() {
if committed {
tx.Commit()
} else {
tx.Rollback()
if r := recover(); r != nil {
panic(r)
}
}
}()
var tasks []*entity.IngestionTask
err := tx.Where("id = ?", taskID).Find(&tasks).Error
if err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, fmt.Errorf("task %s not found", taskID)
}
if len(tasks) != 1 {
return nil, fmt.Errorf("task %s has multiple records", taskID)
}
if userID != nil {
if tasks[0].UserID != *userID {
return nil, errors.New("task does not belong to the user")
}
}
taskStatus := tasks[0].Status
switch taskStatus {
case common.CREATED, common.STOPPED, common.COMPLETED, common.FAILED:
// ingestion_task_log no longer carries file references (the old
// checkpoint JSON column was dropped in favor of typed columns), so
// there are no task-level files to delete here.
var filesToDelete []string
err = tx.Model(&entity.IngestionTask{}).Where("id = ?", taskID).Delete(&entity.IngestionTask{}).Error
if err != nil {
return nil, err
}
taskInfo := &TaskInfo{
TaskID: taskID,
FilesToDelete: filesToDelete,
}
committed = true
return taskInfo, nil
default:
return nil, fmt.Errorf("task %s is executing, cannot be removed", taskID)
}
}
func (dao *IngestionTaskDAO) GetAllTasks(page, pageSize int) ([]*entity.IngestionTask, error) {
var tasks []*entity.IngestionTask
var err error
if pageSize == 0 {
err = DB.Find(&tasks).Error
} else {
err = DB.Order("create_time DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks).Error
}
return tasks, err
}
func (dao *IngestionTaskDAO) ListByUserID(userID string, page, pageSize int) ([]*entity.IngestionTask, error) {
var tasks []*entity.IngestionTask
var err error
if pageSize == 0 {
err = DB.Where("user_id = ?", userID).Order("create_time DESC").Find(&tasks).Error
} else {
err = DB.Where("user_id = ?", userID).Order("create_time DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks).Error
}
return tasks, err
}
func (dao *IngestionTaskDAO) ListByUserIDAndDatasetID(userID, datasetID string, page, pageSize int) ([]*entity.IngestionTask, error) {
var tasks []*entity.IngestionTask
var err error
if pageSize == 0 {
err = DB.Where("user_id = ? AND dataset_id = ?", userID, datasetID).Order("create_time DESC").Find(&tasks).Error
} else {
err = DB.Where("user_id = ? AND dataset_id = ?", userID, datasetID).Order("create_time DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks).Error
}
return tasks, err
}
func (dao *IngestionTaskDAO) GetByID(id string) (*entity.IngestionTask, error) {
var task *entity.IngestionTask
err := DB.Where("id = ?", id).First(&task).Error
return task, err
}
func (dao *IngestionTaskDAO) GetByDocumentID(documentId string) (*entity.IngestionTask, error) {
var task *entity.IngestionTask
err := DB.Where("document_id = ?", documentId).First(&task).Error
return task, err
}
type IngestionTaskLogDAO struct{}
func NewIngestionTaskLogDAO() *IngestionTaskLogDAO {
return &IngestionTaskLogDAO{}
}
func (dao *IngestionTaskLogDAO) Create(ingestionLog *entity.IngestionTaskLog) error {
return DB.Create(ingestionLog).Error
}
func (dao *IngestionTaskLogDAO) Update(ingestionLog *entity.IngestionTaskLog) error {
return DB.Save(ingestionLog).Error
}
// ListLogsByTaskID returns the task's logs in chronological (write) order.
// Ordering is by auto-increment `id ASC` (NOT `create_time`) because
// create_time has only second-level resolution and would tie-break
// arbitrarily; `id` is monotonic and always reflects write order. This
// feeds the frontend log stream (GET .../logs), which renders each row by
// phase (0 started / 1 done / -1 failed).
func (dao *IngestionTaskLogDAO) ListLogsByTaskID(taskID string) ([]*entity.IngestionTaskLog, error) {
var tasks []*entity.IngestionTaskLog
err := DB.Where("task_id = ?", taskID).Order("id ASC").Find(&tasks).Error
return tasks, err
}
// TaskProgress is the server-side aggregate of a task's component progress,
// served by GET /api/v1/ingestion_task/{task_id}/progress so the frontend
// can render a progress bar without pulling the full log stream.
type TaskProgress struct {
Total int `json:"total"`
Done int `json:"done"`
Failed int `json:"failed"`
Running int `json:"running"`
Percent float64 `json:"percent"`
}
// AggregateProgress computes {total, done, failed, running, percent} for a
// task purely in SQL. It takes each component's latest row (max id per
// component) and classifies by its phase:
//
// done = latest phase is exit/success (1)
// failed = latest phase is error/failure (-1 legacy, or 2 after 1c)
// running = anything else (started, 0)
//
// `total` is the authoritative denominator from ingestion_task.component_total.
// The classification is forward-compatible with the §5.1 ProgressPhase
// renumbering (exit=1 stays; error moves -1 -> 2).
func (dao *IngestionTaskLogDAO) AggregateProgress(taskID string, total int) (*TaskProgress, error) {
// Latest row id per component for this task.
latestIDs := DB.Model(&entity.IngestionTaskLog{}).
Select("MAX(id)").
Where("task_id = ?", taskID).
Group("component")
type phaseRow struct {
Phase int
}
var rows []phaseRow
err := DB.Model(&entity.IngestionTaskLog{}).
Select("phase").
Where("id IN (?)", latestIDs).
Scan(&rows).Error
if err != nil {
return nil, err
}
progress := &TaskProgress{Total: total}
for _, r := range rows {
switch {
case r.Phase == 1:
progress.Done++
case r.Phase < 0 || r.Phase == 2:
progress.Failed++
default:
progress.Running++
}
}
if total > 0 {
progress.Percent = float64(progress.Done) / float64(total) * 100
}
return progress, nil
}
func (dao *IngestionTaskLogDAO) LatestLogByTaskID(taskID string) (*entity.IngestionTaskLog, error) {
var task *entity.IngestionTaskLog
err := DB.Where("task_id = ?", taskID).Order("create_time DESC").First(&task).Error
return task, err
}
func (dao *IngestionTaskLogDAO) GetLogByLogID(logID string) (*entity.IngestionTaskLog, error) {
var task *entity.IngestionTaskLog
err := DB.Where("id = ?", logID).First(&task).Error
return task, err
}
func (dao *IngestionTaskLogDAO) DeleteByTaskID(taskID string) (int64, error) {
result := DB.Unscoped().Where("task_id = ?", taskID).Delete(&entity.IngestionTaskLog{})
return result.RowsAffected, result.Error
}