mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-10 21:55:42 +08:00
## Summary This PR updates Go agent publish logic to persist the parent canvas update and canvas-version save in the same transaction. ## Changes: - Reuse SaveOrReplaceLatest semantics for published versions - Add SaveOrReplaceLatestTx for transactional publish flow - Keep canvas release update and version persistence atomic - Add a focused publish test covering canvas and released version state ## Tested: ``` bash build.sh --test -run 'Test(PublishAgentUpdatesCanvasAndReleasedVersion|UpdateAgentDSLCreatesAndReplacesDraftVersion| UpdateAgentDSLDoesNotOverwriteLatestReleasedVersion)$' ./internal/service ./internal/dao ``` <img width="1476" height="850" alt="image" src="https://github.com/user-attachments/assets/2c576581-1143-420b-8750-a77aa3c4292d" />
254 lines
8.4 KiB
Go
254 lines
8.4 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"
|
|
"reflect"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"ragflow/internal/entity"
|
|
)
|
|
|
|
// ErrUserCanvasVersionNotFound is returned when a version lookup by id or
|
|
// canvas id yields no rows. Service and handler layers map this to a 404.
|
|
var ErrUserCanvasVersionNotFound = errors.New("user_canvas_version: not found")
|
|
|
|
// UserCanvasVersionDAO persists and queries UserCanvasVersion rows.
|
|
type UserCanvasVersionDAO struct{}
|
|
|
|
// SaveOrReplaceLatestVersionOptions controls a version-history save.
|
|
type SaveOrReplaceLatestVersionOptions struct {
|
|
NewID string
|
|
UserCanvasID string
|
|
Title *string
|
|
Description *string
|
|
DSL entity.JSONMap
|
|
Release bool
|
|
KeepUnpublished int
|
|
SameDSL func(entity.JSONMap) bool
|
|
}
|
|
|
|
// NewUserCanvasVersionDAO returns a zero-value DAO. The struct is stateless
|
|
// so callers can share a single instance or create their own.
|
|
func NewUserCanvasVersionDAO() *UserCanvasVersionDAO {
|
|
return &UserCanvasVersionDAO{}
|
|
}
|
|
|
|
// Create inserts a new version row. The caller assigns ID, UserCanvasID,
|
|
// Title, Description, DSL. CreateTime/UpdateTime are stamped by the
|
|
// BaseModel BeforeCreate hook.
|
|
func (dao *UserCanvasVersionDAO) Create(v *entity.UserCanvasVersion) error {
|
|
return DB.Create(v).Error
|
|
}
|
|
|
|
// GetByID fetches a single version by primary key. Returns
|
|
// ErrUserCanvasVersionNotFound when the row is absent so callers can map
|
|
// to a 404 instead of inspecting gorm.ErrRecordNotFound directly.
|
|
func (dao *UserCanvasVersionDAO) GetByID(id string) (*entity.UserCanvasVersion, error) {
|
|
var v entity.UserCanvasVersion
|
|
err := DB.Where("id = ?", id).First(&v).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserCanvasVersionNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
// ListByCanvasID returns every version of the given canvas, ordered by
|
|
// create_time DESC so the most recent publish appears first.
|
|
func (dao *UserCanvasVersionDAO) ListByCanvasID(canvasID string) ([]*entity.UserCanvasVersion, error) {
|
|
var vs []*entity.UserCanvasVersion
|
|
err := DB.Where("user_canvas_id = ?", canvasID).
|
|
Order("create_time DESC").
|
|
Find(&vs).Error
|
|
return vs, err
|
|
}
|
|
|
|
// GetLatest returns the most recently created version of canvasID, or
|
|
// ErrUserCanvasVersionNotFound when the canvas has never been published.
|
|
func (dao *UserCanvasVersionDAO) GetLatest(canvasID string) (*entity.UserCanvasVersion, error) {
|
|
var v entity.UserCanvasVersion
|
|
err := DB.Where("user_canvas_id = ?", canvasID).
|
|
Order("create_time DESC").
|
|
First(&v).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserCanvasVersionNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
// Delete removes a single version by id. No-op when the row is absent.
|
|
func (dao *UserCanvasVersionDAO) Delete(id string) error {
|
|
return DB.Where("id = ?", id).Delete(&entity.UserCanvasVersion{}).Error
|
|
}
|
|
|
|
// DeleteTx is the transactional variant of Delete. Used by
|
|
// service.AgentService.DeleteVersion so the version-row removal and the
|
|
// (future) parent-canvas stat update land in one atomic write.
|
|
func (dao *UserCanvasVersionDAO) DeleteTx(tx *gorm.DB, id string) error {
|
|
return tx.Where("id = ?", id).Delete(&entity.UserCanvasVersion{}).Error
|
|
}
|
|
|
|
// DeleteByCanvasID removes every version of the given canvas. Called from
|
|
// the service layer when the parent canvas is deleted to enforce the
|
|
// §2.9 cascade rule. Returns the number of rows actually deleted.
|
|
func (dao *UserCanvasVersionDAO) DeleteByCanvasID(canvasID string) (int64, error) {
|
|
res := DB.Where("user_canvas_id = ?", canvasID).Delete(&entity.UserCanvasVersion{})
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
// DeleteByCanvasIDTx is the transactional variant of DeleteByCanvasID.
|
|
// Used by service.AgentService.DeleteAgent so the cascade runs atomically
|
|
// with the parent canvas row removal.
|
|
func (dao *UserCanvasVersionDAO) DeleteByCanvasIDTx(tx *gorm.DB, canvasID string) (int64, error) {
|
|
res := tx.Where("user_canvas_id = ?", canvasID).Delete(&entity.UserCanvasVersion{})
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
// CreateTx is the transactional variant of Create.
|
|
func (dao *UserCanvasVersionDAO) CreateTx(tx *gorm.DB, v *entity.UserCanvasVersion) error {
|
|
return tx.Create(v).Error
|
|
}
|
|
|
|
// SaveOrReplaceLatest inserts a new version or refreshes the latest matching
|
|
// draft in place. If the latest matching version is released and the current
|
|
// save is a draft, it creates a new draft to preserve the released snapshot.
|
|
func (dao *UserCanvasVersionDAO) SaveOrReplaceLatest(opts SaveOrReplaceLatestVersionOptions) (*entity.UserCanvasVersion, error) {
|
|
if opts.KeepUnpublished <= 0 {
|
|
opts.KeepUnpublished = 20
|
|
}
|
|
var saved *entity.UserCanvasVersion
|
|
if err := DB.Transaction(func(tx *gorm.DB) error {
|
|
row, err := dao.SaveOrReplaceLatestTx(tx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
saved = row
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return saved, nil
|
|
}
|
|
|
|
// SaveOrReplaceLatestTx inserts or refreshes a version using the caller's
|
|
// transaction. Its write semantics match SaveOrReplaceLatest exactly.
|
|
func (dao *UserCanvasVersionDAO) SaveOrReplaceLatestTx(tx *gorm.DB, opts SaveOrReplaceLatestVersionOptions) (*entity.UserCanvasVersion, error) {
|
|
if opts.KeepUnpublished <= 0 {
|
|
opts.KeepUnpublished = 20
|
|
}
|
|
var parent struct {
|
|
ID string
|
|
}
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Table((&entity.UserCanvas{}).TableName()).
|
|
Select("id").
|
|
Where("id = ?", opts.UserCanvasID).
|
|
Take(&parent).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var latest entity.UserCanvasVersion
|
|
err := tx.Where("user_canvas_id = ?", opts.UserCanvasID).
|
|
Order("create_time DESC, id DESC").
|
|
First(&latest).Error
|
|
if err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
} else if opts.sameDSL(latest.DSL) {
|
|
if !latest.Release || opts.Release {
|
|
updates := map[string]interface{}{
|
|
"dsl": opts.DSL,
|
|
"release": opts.Release,
|
|
}
|
|
if opts.Title != nil {
|
|
updates["title"] = opts.Title
|
|
}
|
|
if opts.Description != nil {
|
|
updates["description"] = opts.Description
|
|
}
|
|
if err := tx.Model(&entity.UserCanvasVersion{}).
|
|
Where("id = ?", latest.ID).
|
|
Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
latest.DSL = opts.DSL
|
|
latest.Release = opts.Release
|
|
if opts.Title != nil {
|
|
latest.Title = opts.Title
|
|
}
|
|
if opts.Description != nil {
|
|
latest.Description = opts.Description
|
|
}
|
|
return &latest, dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
|
|
}
|
|
}
|
|
row := &entity.UserCanvasVersion{
|
|
ID: opts.NewID,
|
|
UserCanvasID: opts.UserCanvasID,
|
|
Title: opts.Title,
|
|
Description: opts.Description,
|
|
Release: opts.Release,
|
|
DSL: opts.DSL,
|
|
}
|
|
if err := tx.Create(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return row, dao.deleteAllUnpublishedExcessTx(tx, opts.UserCanvasID, opts.KeepUnpublished)
|
|
}
|
|
|
|
func (opts SaveOrReplaceLatestVersionOptions) sameDSL(dsl entity.JSONMap) bool {
|
|
if opts.SameDSL != nil {
|
|
return opts.SameDSL(dsl)
|
|
}
|
|
return reflect.DeepEqual(dsl, opts.DSL)
|
|
}
|
|
|
|
// DeleteAllUnpublishedExcess keeps the newest keep unpublished versions for a
|
|
// canvas and deletes older unpublished rows. Released versions are never
|
|
// removed by this cleanup.
|
|
func (dao *UserCanvasVersionDAO) DeleteAllUnpublishedExcess(canvasID string, keep int) error {
|
|
return dao.deleteAllUnpublishedExcessTx(DB, canvasID, keep)
|
|
}
|
|
|
|
func (dao *UserCanvasVersionDAO) deleteAllUnpublishedExcessTx(tx *gorm.DB, canvasID string, keep int) error {
|
|
if keep < 0 {
|
|
keep = 0
|
|
}
|
|
var ids []string
|
|
if err := tx.Model(&entity.UserCanvasVersion{}).
|
|
Where(map[string]interface{}{"user_canvas_id": canvasID, "release": false}).
|
|
Order("create_time DESC").
|
|
Pluck("id", &ids).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(ids) <= keep {
|
|
return nil
|
|
}
|
|
ids = ids[keep:]
|
|
return tx.Where("id IN ?", ids).Delete(&entity.UserCanvasVersion{}).Error
|
|
}
|