// // 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] }