Files
ragflow/internal/service/skill_space.go
Yingfeng 5a7d7771a3 Decouple skill space from Python API (#15971)
### What problem does this PR solve?

Make skill space independent of Python filesystem API

### Type of change

- [x] Refactoring
2026-06-12 18:18:55 +08:00

553 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 service
import (
"context"
"fmt"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/entity"
"strings"
"sync"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SkillSpaceService handles business logic for skills space operations
type SkillSpaceService struct {
spaceDAO *dao.SkillSpaceDAO
fileDAO *dao.FileDAO
fileService *FileService
configDAO *dao.SkillSearchConfigDAO
tenantDAO *dao.TenantDAO
skillsFolderCache map[string]string // tenant-keyed cache for skills folder ID
skillsFolderMu sync.RWMutex // protects skillsFolderCache
skillsFolderCreateMu sync.Map // tenant-scoped locks for folder creation
spaceCreateMu sync.Map // tenant-scoped locks for space creation (prevents TOCTOU races)
}
// NewSkillSpaceService creates a new SkillSpaceService instance
func NewSkillSpaceService() *SkillSpaceService {
return &SkillSpaceService{
spaceDAO: dao.NewSkillSpaceDAO(),
fileDAO: dao.NewFileDAO(),
fileService: NewFileService(),
configDAO: dao.NewSkillSearchConfigDAO(),
tenantDAO: dao.NewTenantDAO(),
skillsFolderCache: make(map[string]string),
}
}
// CreateSpaceRequest represents the request to create a skills space
type CreateSpaceRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
EmbdID string `json:"embd_id"`
RerankID string `json:"rerank_id"`
}
// UpdateSpaceRequest represents the request to update a skills space
type UpdateSpaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
EmbdID string `json:"embd_id"`
RerankID string `json:"rerank_id"`
TopK int `json:"top_k"`
}
// getSkillsFolderID gets or creates the skills folder for a tenant
// Uses tenant-scoped locking to prevent duplicate folder creation
func (s *SkillSpaceService) getSkillsFolderID(tenantID string) (string, error) {
// Return cached value if available (read lock)
s.skillsFolderMu.RLock()
if cachedID, ok := s.skillsFolderCache[tenantID]; ok && cachedID != "" {
s.skillsFolderMu.RUnlock()
return cachedID, nil
}
s.skillsFolderMu.RUnlock()
// Acquire tenant-scoped creation lock
lock, _ := s.skillsFolderCreateMu.LoadOrStore(tenantID, &sync.Mutex{})
lock.(*sync.Mutex).Lock()
defer lock.(*sync.Mutex).Unlock()
// Double-check cache after acquiring lock
s.skillsFolderMu.RLock()
if cachedID, ok := s.skillsFolderCache[tenantID]; ok && cachedID != "" {
s.skillsFolderMu.RUnlock()
return cachedID, nil
}
s.skillsFolderMu.RUnlock()
// Get root folder
rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
if err != nil {
return "", fmt.Errorf("failed to get root folder: %w", err)
}
// Look for skills folder under root
files, _, err := s.fileDAO.GetByPfID(tenantID, rootFolder.ID, 0, 0, "name", false, "")
if err != nil {
return "", fmt.Errorf("failed to list root folder contents: %w", err)
}
for _, file := range files {
if file.Type == "folder" && file.Name == "skills" {
// Cache the result (write lock)
s.skillsFolderMu.Lock()
s.skillsFolderCache[tenantID] = file.ID
s.skillsFolderMu.Unlock()
return file.ID, nil
}
}
// Skills folder not found, create it
common.Info("Creating skills folder", zap.String("tenant_id", tenantID))
folderID := generateSpaceID()
folder := &entity.File{
ID: folderID,
ParentID: rootFolder.ID,
TenantID: tenantID,
CreatedBy: tenantID,
Name: "skills",
Type: "folder",
Size: 0,
SourceType: "system",
}
if err := s.fileDAO.Create(folder); err != nil {
return "", fmt.Errorf("failed to create skills folder: %w", err)
}
// Cache the result (write lock)
s.skillsFolderMu.Lock()
s.skillsFolderCache[tenantID] = folderID
s.skillsFolderMu.Unlock()
return folderID, nil
}
// CreateSpace creates a new skills space with associated folder
func (s *SkillSpaceService) CreateSpace(req *CreateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) {
// Validate name
if req.Name == "" {
return nil, common.CodeDataError, fmt.Errorf("space name is required")
}
// Tenant-scoped serialization to prevent concurrent create/delete races
tenantKey := req.TenantID + ":" + req.Name
mu, _ := s.spaceCreateMu.LoadOrStore(tenantKey, &sync.Mutex{})
tenantMu := mu.(*sync.Mutex)
tenantMu.Lock()
defer func() {
tenantMu.Unlock()
s.spaceCreateMu.Delete(tenantKey)
}()
// Double-check after acquiring lock: Check if space with same name already exists (active status)
existingSpace, err := s.spaceDAO.GetByTenantAndName(req.TenantID, req.Name)
if err != nil {
// Space doesn't exist, continue
} else if existingSpace != nil {
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' already exists", req.Name)
}
// Check if there's a space with the same name that is currently being deleted
existingSpaceAny, err := s.spaceDAO.GetByTenantAndNameAnyStatus(req.TenantID, req.Name)
if err == nil && existingSpaceAny != nil && existingSpaceAny.Status == entity.SpaceStatusDeleting {
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' is being deleted, please try again later", req.Name)
}
// Check if there's a deleted/non-active space with the same name and permanently delete it
// This handles the case where a previous creation failed partially
// Only delete non-active spaces (status != '1') to prevent TOCTOU race
if err := s.spaceDAO.DeletePermanentByName(req.TenantID, req.Name); err != nil {
common.Warn("Failed to delete permanent space by name", zap.Error(err))
}
// Get skills folder ID
skillsFolderID, err := s.getSkillsFolderID(req.TenantID)
if err != nil {
common.Error("Failed to get skills folder ID", err)
return nil, common.CodeOperatingError, err
}
// Check if there's an existing folder with the same name under skills folder
// If exists, delete it to prevent duplicate folder names
existingFolders := s.fileDAO.Query(req.Name, skillsFolderID)
for _, f := range existingFolders {
if f.Type == "folder" && f.Name == req.Name {
common.Info("Deleting existing space folder with same name", zap.String("folderID", f.ID), zap.String("name", req.Name))
if err := s.deleteFolderRecursive(f.ID); err != nil {
common.Warn("Failed to delete existing folder", zap.String("folderID", f.ID), zap.Error(err))
}
break
}
}
// Generate space ID and folder ID
spaceID := generateSpaceID()
folderID := generateSpaceID()
// Create folder for the space under skills folder
folder := &entity.File{
ID: folderID,
ParentID: skillsFolderID,
TenantID: req.TenantID,
CreatedBy: req.TenantID,
Name: req.Name,
Type: "folder",
Size: 0,
SourceType: "skill_space",
}
if err := s.fileDAO.Create(folder); err != nil {
common.Error("Failed to create space folder", err)
return nil, common.CodeOperatingError, fmt.Errorf("failed to create space folder: %w", err)
}
// Create the space
space := &entity.SkillSpace{
ID: spaceID,
TenantID: req.TenantID,
Name: req.Name,
FolderID: folderID,
Description: req.Description,
EmbdID: req.EmbdID,
RerankID: req.RerankID,
TopK: 10,
Status: "1",
}
if err := s.spaceDAO.Create(space); err != nil {
// Rollback: delete the created folder
common.Error("Failed to create space in database", err)
s.fileDAO.DeleteByIDs([]string{folderID})
return nil, common.CodeOperatingError, fmt.Errorf("failed to create space: %w", err)
}
// Create default search config for this space
defaultEmbdID := req.EmbdID
if defaultEmbdID == "" {
tenant, err := s.tenantDAO.GetByID(req.TenantID)
if err == nil && tenant != nil && tenant.EmbdID != "" {
defaultEmbdID = tenant.EmbdID
common.Info("Using tenant default embedding model", zap.String("tenantID", req.TenantID), zap.String("embdID", defaultEmbdID))
} else {
common.Warn("Tenant has no default embedding model, skill search will not work until configured", zap.String("tenantID", req.TenantID))
}
}
if defaultEmbdID != "" {
if _, err := s.configDAO.GetOrCreate(req.TenantID, spaceID, defaultEmbdID); err != nil {
common.Warn("Failed to create skill search config for new space",
zap.String("tenantID", req.TenantID),
zap.String("spaceID", spaceID),
zap.String("embdID", defaultEmbdID),
zap.Error(err))
}
}
return space.ToMap(), common.CodeSuccess, nil
}
// ListSpaces lists all skills spaces for a tenant
func (s *SkillSpaceService) ListSpaces(tenantID string) (map[string]interface{}, common.ErrorCode, error) {
spaces, err := s.spaceDAO.GetByTenantID(tenantID)
if err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to list spaces: %w", err)
}
// Convert to maps
spaceList := make([]map[string]interface{}, len(spaces))
for i, space := range spaces {
spaceList[i] = space.ToMap()
}
return map[string]interface{}{
"spaces": spaceList,
"total": len(spaceList),
}, common.CodeSuccess, nil
}
// GetSpace retrieves a skills space by ID (includes deleting status for visibility)
func (s *SkillSpaceService) GetSpace(spaceID, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
if err != nil {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
// Verify tenant ownership
if space.TenantID != tenantID {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
// Return deleted spaces as not found
if space.Status == entity.SpaceStatusDeleted {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
return space.ToMap(), common.CodeSuccess, nil
}
// UpdateSpace updates a skills space
func (s *SkillSpaceService) UpdateSpace(spaceID string, tenantID string, req *UpdateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) {
space, err := s.spaceDAO.GetByID(spaceID)
if err != nil {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
// Verify tenant ownership
if space.TenantID != tenantID {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
// Build updates
updates := make(map[string]interface{})
if req.Name != "" && req.Name != space.Name {
// Check if name already exists
existingSpace, _ := s.spaceDAO.GetByTenantAndName(tenantID, req.Name)
if existingSpace != nil && existingSpace.ID != spaceID {
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' already exists", req.Name)
}
originalName := space.Name
updates["name"] = req.Name
// Update space first, then folder (atomic-like behavior with rollback on failure)
if err := s.spaceDAO.UpdateByID(spaceID, updates); err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to update space name: %w", err)
}
// Update folder name as well - if this fails, rollback space name
if err := s.fileDAO.UpdateByID(space.FolderID, map[string]interface{}{"name": req.Name}); err != nil {
common.Error("Failed to update folder name, rolling back space name", err)
// Rollback space name
if rollbackErr := s.spaceDAO.UpdateByID(spaceID, map[string]interface{}{"name": originalName}); rollbackErr != nil {
common.Error("Failed to rollback space name after folder rename failure", rollbackErr)
}
return nil, common.CodeOperatingError, fmt.Errorf("failed to update folder name: %w", err)
}
// Clear updates map since we've already applied name change
delete(updates, "name")
}
if req.Description != space.Description {
updates["description"] = req.Description
}
if req.EmbdID != "" && req.EmbdID != space.EmbdID {
updates["embd_id"] = req.EmbdID
}
if req.RerankID != space.RerankID {
updates["rerank_id"] = req.RerankID
}
if req.TopK > 0 && req.TopK != space.TopK {
updates["top_k"] = req.TopK
}
if len(updates) > 0 {
if err := s.spaceDAO.UpdateByID(spaceID, updates); err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to update space: %w", err)
}
}
// Refresh space data
space, _ = s.spaceDAO.GetByID(spaceID)
return space.ToMap(), common.CodeSuccess, nil
}
// DeleteSpace starts asynchronous deletion of a skills space and returns immediately.
// The space status is set to "deleting" and the actual cleanup runs in a background goroutine.
func (s *SkillSpaceService) DeleteSpace(spaceID, tenantID string, docEngine engine.DocEngine, ctx context.Context) (common.ErrorCode, error) {
// Get space regardless of status (could be retrying a failed delete)
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
if err != nil {
return common.CodeDataError, fmt.Errorf("space not found")
}
// Verify tenant ownership
if space.TenantID != tenantID {
return common.CodeDataError, fmt.Errorf("space not found")
}
// If already deleting, return success (idempotent)
if space.Status == entity.SpaceStatusDeleting {
common.Info("Space is already being deleted", zap.String("spaceID", spaceID))
return common.CodeSuccess, nil
}
// If already deleted, return success (idempotent)
if space.Status == entity.SpaceStatusDeleted {
common.Info("Space is already deleted", zap.String("spaceID", spaceID))
return common.CodeSuccess, nil
}
// CAS: status must be "1" (active) → "2" (deleting) to prevent concurrent deletes
swapped, err := s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusActive, entity.SpaceStatusDeleting)
if err != nil {
return common.CodeOperatingError, fmt.Errorf("failed to update space status: %w", err)
}
if !swapped {
// Another request already changed the status
return common.CodeOperatingError, fmt.Errorf("space is being modified by another request")
}
common.Info("Space marked as deleting, starting async cleanup", zap.String("spaceID", spaceID), zap.String("tenantID", tenantID))
// Launch async deletion in background goroutine
go s.asyncDeleteSpace(spaceID, space.FolderID, tenantID, docEngine, ctx)
return common.CodeSuccess, nil
}
// asyncDeleteSpace performs the actual deletion work in the background.
// It deletes the search index, removes files via Go FileService, and soft-deletes the space record.
func (s *SkillSpaceService) asyncDeleteSpace(spaceID, folderID, tenantID string, docEngine engine.DocEngine, ctx context.Context) {
defer func() {
if r := recover(); r != nil {
common.Warn("Panic in asyncDeleteSpace, marking space as deleted", zap.Any("recover", r), zap.String("spaceID", spaceID))
_, _ = s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusDeleting, entity.SpaceStatusDeleted)
}
}()
// Step 1: Delete the search index
if docEngine != nil {
indexName := getSkillIndexName(tenantID, spaceID)
common.Info("Async deleting space index", zap.String("index", indexName), zap.String("spaceID", spaceID))
deleteCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if err := docEngine.DropChunkStore(deleteCtx, indexName, "skill"); err != nil {
common.Warn("Failed to delete space index during async delete", zap.String("index", indexName), zap.Error(err))
// Continue with other cleanup steps
} else {
common.Info("Successfully deleted space index", zap.String("index", indexName))
}
cancel()
}
// Step 2: Delete folder and storage via Go FileService
// Use a fresh background context with timeout, NOT the incoming ctx (which
// is the HTTP request context canceled when the handler returns and the
// goroutine starts executing).
common.Info("Async deleting space folder via Go FileService", zap.String("folderID", folderID), zap.String("spaceID", spaceID))
ctxFS, cancelFS := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFS()
success, msg := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID})
if !success {
common.Error(fmt.Sprintf("Failed to delete space folder via Go FileService during async delete, spaceID=%s, msg=%s", spaceID, msg), nil)
// Retry once with a delay (same ctxFS, still valid)
time.Sleep(5 * time.Second)
if retrySuccess, retryMsg := s.fileService.DeleteFiles(ctxFS, tenantID, []string{folderID}); !retrySuccess {
common.Error(fmt.Sprintf("Retry failed to delete space folder, marking space as deleted anyway, spaceID=%s, msg=%s", spaceID, retryMsg), nil)
// Mark as deleted even if folder deletion fails - orphaned folders can be cleaned up later
} else {
common.Info("Successfully deleted space folder on retry via Go FileService", zap.String("folderID", folderID))
}
} else {
common.Info("Successfully deleted space folder via Go FileService", zap.String("folderID", folderID))
}
// Step 3: Soft delete the space record (status "2" → "0")
// First, permanently remove any previously deleted spaces with the same tenant+name
// to avoid UNIQUE INDEX constraint violation when changing status from "2" to "0"
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
if err == nil && space != nil {
_ = s.spaceDAO.DeletePermanentByName(space.TenantID, space.Name)
}
swapped, err := s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusDeleting, entity.SpaceStatusDeleted)
if err != nil {
common.Error(fmt.Sprintf("Failed to update space status to deleted, spaceID=%s", spaceID), err)
return
}
if !swapped {
common.Warn("Space status was not 'deleting' when trying to mark as deleted", zap.String("spaceID", spaceID))
return
}
common.Info("Successfully completed async space deletion", zap.String("spaceID", spaceID))
}
// deleteFolderRecursive recursively deletes a folder and all its contents
func (s *SkillSpaceService) deleteFolderRecursive(folderID string) error {
// Get all children
children, err := s.fileDAO.ListByParentID(folderID)
if err != nil {
common.Error(fmt.Sprintf("Failed to list children for folder %s", folderID), err)
return err
}
common.Info("Deleting folder contents", zap.String("folder_id", folderID), zap.Int("child_count", len(children)))
// Collect file IDs (non-folder) and recurse into subfolders
var fileIDs []string
for _, child := range children {
if child.Type == "folder" {
common.Debug("Recursively deleting child folder", zap.String("folder_id", child.ID), zap.String("folder_name", child.Name))
if err := s.deleteFolderRecursive(child.ID); err != nil {
common.Warn("Failed to delete child folder", zap.String("folder_id", child.ID), zap.Error(err))
}
} else {
// Collect non-folder files for batch deletion
common.Debug("Collecting file for deletion", zap.String("file_id", child.ID), zap.String("file_name", child.Name))
fileIDs = append(fileIDs, child.ID)
}
}
// Delete all non-folder files in batch
if len(fileIDs) > 0 {
common.Info("Deleting files in folder", zap.String("folder_id", folderID), zap.Int("file_count", len(fileIDs)))
if _, err := s.fileDAO.DeleteByIDs(fileIDs); err != nil {
common.Warn("Failed to delete files in folder", zap.String("folder_id", folderID), zap.Strings("file_ids", fileIDs), zap.Error(err))
// Continue to delete folder even if file deletion fails
}
}
// Delete the folder itself
common.Info("Deleting folder", zap.String("folder_id", folderID))
_, err = s.fileDAO.DeleteByIDs([]string{folderID})
if err != nil {
common.Error(fmt.Sprintf("Failed to delete folder %s", folderID), err)
}
return err
}
// GetSpaceByFolderID retrieves a skills space by its folder ID
func (s *SkillSpaceService) GetSpaceByFolderID(folderID, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
space, err := s.spaceDAO.GetByFolderID(folderID)
if err != nil {
return nil, common.CodeDataError, fmt.Errorf("space not found for folder")
}
// Verify tenant ownership
if space.TenantID != tenantID {
return nil, common.CodeDataError, fmt.Errorf("space not found")
}
return space.ToMap(), common.CodeSuccess, nil
}
// generateSpaceID generates a unique ID for space
func generateSpaceID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
}