| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529 |
- """ClickZetta Volume Storage Implementation
- This module provides storage backend using ClickZetta Volume functionality.
- Supports Table Volume, User Volume, and External Volume types.
- """
- import logging
- import os
- import tempfile
- from collections.abc import Generator
- from io import BytesIO
- from pathlib import Path
- from typing import Optional
- import clickzetta # type: ignore[import]
- from pydantic import BaseModel, model_validator
- from extensions.storage.base_storage import BaseStorage
- from .volume_permissions import VolumePermissionManager, check_volume_permission
- logger = logging.getLogger(__name__)
- class ClickZettaVolumeConfig(BaseModel):
- """Configuration for ClickZetta Volume storage."""
- username: str = ""
- password: str = ""
- instance: str = ""
- service: str = "api.clickzetta.com"
- workspace: str = "quick_start"
- vcluster: str = "default_ap"
- schema_name: str = "dify"
- volume_type: str = "table" # table|user|external
- volume_name: Optional[str] = None # For external volumes
- table_prefix: str = "dataset_" # Prefix for table volume names
- dify_prefix: str = "dify_km" # Directory prefix for User Volume
- permission_check: bool = True # Enable/disable permission checking
- @model_validator(mode="before")
- @classmethod
- def validate_config(cls, values: dict) -> dict:
- """Validate the configuration values.
- This method will first try to use CLICKZETTA_VOLUME_* environment variables,
- then fall back to CLICKZETTA_* environment variables (for vector DB config).
- """
- import os
- # Helper function to get environment variable with fallback
- def get_env_with_fallback(volume_key: str, fallback_key: str, default: str | None = None) -> str:
- # First try CLICKZETTA_VOLUME_* specific config
- volume_value = values.get(volume_key.lower().replace("clickzetta_volume_", ""))
- if volume_value:
- return str(volume_value)
- # Then try environment variables
- volume_env = os.getenv(volume_key)
- if volume_env:
- return volume_env
- # Fall back to existing CLICKZETTA_* config
- fallback_env = os.getenv(fallback_key)
- if fallback_env:
- return fallback_env
- return default or ""
- # Apply environment variables with fallback to existing CLICKZETTA_* config
- values.setdefault("username", get_env_with_fallback("CLICKZETTA_VOLUME_USERNAME", "CLICKZETTA_USERNAME"))
- values.setdefault("password", get_env_with_fallback("CLICKZETTA_VOLUME_PASSWORD", "CLICKZETTA_PASSWORD"))
- values.setdefault("instance", get_env_with_fallback("CLICKZETTA_VOLUME_INSTANCE", "CLICKZETTA_INSTANCE"))
- values.setdefault(
- "service", get_env_with_fallback("CLICKZETTA_VOLUME_SERVICE", "CLICKZETTA_SERVICE", "api.clickzetta.com")
- )
- values.setdefault(
- "workspace", get_env_with_fallback("CLICKZETTA_VOLUME_WORKSPACE", "CLICKZETTA_WORKSPACE", "quick_start")
- )
- values.setdefault(
- "vcluster", get_env_with_fallback("CLICKZETTA_VOLUME_VCLUSTER", "CLICKZETTA_VCLUSTER", "default_ap")
- )
- values.setdefault("schema_name", get_env_with_fallback("CLICKZETTA_VOLUME_SCHEMA", "CLICKZETTA_SCHEMA", "dify"))
- # Volume-specific configurations (no fallback to vector DB config)
- values.setdefault("volume_type", os.getenv("CLICKZETTA_VOLUME_TYPE", "table"))
- values.setdefault("volume_name", os.getenv("CLICKZETTA_VOLUME_NAME"))
- values.setdefault("table_prefix", os.getenv("CLICKZETTA_VOLUME_TABLE_PREFIX", "dataset_"))
- values.setdefault("dify_prefix", os.getenv("CLICKZETTA_VOLUME_DIFY_PREFIX", "dify_km"))
- # 暂时禁用权限检查功能,直接设置为false
- values.setdefault("permission_check", False)
- # Validate required fields
- if not values.get("username"):
- raise ValueError("CLICKZETTA_VOLUME_USERNAME or CLICKZETTA_USERNAME is required")
- if not values.get("password"):
- raise ValueError("CLICKZETTA_VOLUME_PASSWORD or CLICKZETTA_PASSWORD is required")
- if not values.get("instance"):
- raise ValueError("CLICKZETTA_VOLUME_INSTANCE or CLICKZETTA_INSTANCE is required")
- # Validate volume type
- volume_type = values["volume_type"]
- if volume_type not in ["table", "user", "external"]:
- raise ValueError("CLICKZETTA_VOLUME_TYPE must be one of: table, user, external")
- if volume_type == "external" and not values.get("volume_name"):
- raise ValueError("CLICKZETTA_VOLUME_NAME is required for external volume type")
- return values
- class ClickZettaVolumeStorage(BaseStorage):
- """ClickZetta Volume storage implementation."""
- def __init__(self, config: ClickZettaVolumeConfig):
- """Initialize ClickZetta Volume storage.
- Args:
- config: ClickZetta Volume configuration
- """
- self._config = config
- self._connection = None
- self._permission_manager: VolumePermissionManager | None = None
- self._init_connection()
- self._init_permission_manager()
- logger.info("ClickZetta Volume storage initialized with type: %s", config.volume_type)
- def _init_connection(self):
- """Initialize ClickZetta connection."""
- try:
- self._connection = clickzetta.connect(
- username=self._config.username,
- password=self._config.password,
- instance=self._config.instance,
- service=self._config.service,
- workspace=self._config.workspace,
- vcluster=self._config.vcluster,
- schema=self._config.schema_name,
- )
- logger.debug("ClickZetta connection established")
- except Exception as e:
- logger.exception("Failed to connect to ClickZetta")
- raise
- def _init_permission_manager(self):
- """Initialize permission manager."""
- try:
- self._permission_manager = VolumePermissionManager(
- self._connection, self._config.volume_type, self._config.volume_name
- )
- logger.debug("Permission manager initialized")
- except Exception as e:
- logger.exception("Failed to initialize permission manager")
- raise
- def _get_volume_path(self, filename: str, dataset_id: Optional[str] = None) -> str:
- """Get the appropriate volume path based on volume type."""
- if self._config.volume_type == "user":
- # Add dify prefix for User Volume to organize files
- return f"{self._config.dify_prefix}/{filename}"
- elif self._config.volume_type == "table":
- # Check if this should use User Volume (special directories)
- if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
- # Use User Volume with dify prefix for special directories
- return f"{self._config.dify_prefix}/{filename}"
- if dataset_id:
- return f"{self._config.table_prefix}{dataset_id}/{filename}"
- else:
- # Extract dataset_id from filename if not provided
- # Format: dataset_id/filename
- if "/" in filename:
- return filename
- else:
- raise ValueError("dataset_id is required for table volume or filename must include dataset_id/")
- elif self._config.volume_type == "external":
- return filename
- else:
- raise ValueError(f"Unsupported volume type: {self._config.volume_type}")
- def _get_volume_sql_prefix(self, dataset_id: Optional[str] = None) -> str:
- """Get SQL prefix for volume operations."""
- if self._config.volume_type == "user":
- return "USER VOLUME"
- elif self._config.volume_type == "table":
- # For Dify's current file storage pattern, most files are stored in
- # paths like "upload_files/tenant_id/uuid.ext", "tools/tenant_id/uuid.ext"
- # These should use USER VOLUME for better compatibility
- if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
- return "USER VOLUME"
- # Only use TABLE VOLUME for actual dataset-specific paths
- # like "dataset_12345/file.pdf" or paths with dataset_ prefix
- if dataset_id:
- table_name = f"{self._config.table_prefix}{dataset_id}"
- else:
- # Default table name for generic operations
- table_name = "default_dataset"
- return f"TABLE VOLUME {table_name}"
- elif self._config.volume_type == "external":
- return f"VOLUME {self._config.volume_name}"
- else:
- raise ValueError(f"Unsupported volume type: {self._config.volume_type}")
- def _execute_sql(self, sql: str, fetch: bool = False):
- """Execute SQL command."""
- try:
- if self._connection is None:
- raise RuntimeError("Connection not initialized")
- with self._connection.cursor() as cursor:
- cursor.execute(sql)
- if fetch:
- return cursor.fetchall()
- return None
- except Exception as e:
- logger.exception("SQL execution failed: %s", sql)
- raise
- def _ensure_table_volume_exists(self, dataset_id: str) -> None:
- """Ensure table volume exists for the given dataset_id."""
- if self._config.volume_type != "table" or not dataset_id:
- return
- # Skip for upload_files and other special directories that use USER VOLUME
- if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
- return
- table_name = f"{self._config.table_prefix}{dataset_id}"
- try:
- # Check if table exists
- check_sql = f"SHOW TABLES LIKE '{table_name}'"
- result = self._execute_sql(check_sql, fetch=True)
- if not result:
- # Create table with volume
- create_sql = f"""
- CREATE TABLE {table_name} (
- id INT PRIMARY KEY AUTO_INCREMENT,
- filename VARCHAR(255) NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- INDEX idx_filename (filename)
- ) WITH VOLUME
- """
- self._execute_sql(create_sql)
- logger.info("Created table volume: %s", table_name)
- except Exception as e:
- logger.warning("Failed to create table volume %s: %s", table_name, e)
- # Don't raise exception, let the operation continue
- # The table might exist but not be visible due to permissions
- def save(self, filename: str, data: bytes) -> None:
- """Save data to ClickZetta Volume.
- Args:
- filename: File path in volume
- data: File content as bytes
- """
- # Extract dataset_id from filename if present
- dataset_id = None
- if "/" in filename and self._config.volume_type == "table":
- parts = filename.split("/", 1)
- if parts[0].startswith(self._config.table_prefix):
- dataset_id = parts[0][len(self._config.table_prefix) :]
- filename = parts[1]
- else:
- dataset_id = parts[0]
- filename = parts[1]
- # Ensure table volume exists (for table volumes)
- if dataset_id:
- self._ensure_table_volume_exists(dataset_id)
- # Check permissions (if enabled)
- if self._config.permission_check:
- # Skip permission check for special directories that use USER VOLUME
- if dataset_id not in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
- if self._permission_manager is not None:
- check_volume_permission(self._permission_manager, "save", dataset_id)
- # Write data to temporary file
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_file.write(data)
- temp_file_path = temp_file.name
- try:
- # Upload to volume
- volume_prefix = self._get_volume_sql_prefix(dataset_id)
- # Get the actual volume path (may include dify_km prefix)
- volume_path = self._get_volume_path(filename, dataset_id)
- # For User Volume, use the full path with dify_km prefix
- if volume_prefix == "USER VOLUME":
- sql = f"PUT '{temp_file_path}' TO {volume_prefix} FILE '{volume_path}'"
- else:
- sql = f"PUT '{temp_file_path}' TO {volume_prefix} FILE '{filename}'"
- self._execute_sql(sql)
- logger.debug("File %s saved to ClickZetta Volume at path %s", filename, volume_path)
- finally:
- # Clean up temporary file
- Path(temp_file_path).unlink(missing_ok=True)
- def load_once(self, filename: str) -> bytes:
- """Load file content from ClickZetta Volume.
- Args:
- filename: File path in volume
- Returns:
- File content as bytes
- """
- # Extract dataset_id from filename if present
- dataset_id = None
- if "/" in filename and self._config.volume_type == "table":
- parts = filename.split("/", 1)
- if parts[0].startswith(self._config.table_prefix):
- dataset_id = parts[0][len(self._config.table_prefix) :]
- filename = parts[1]
- else:
- dataset_id = parts[0]
- filename = parts[1]
- # Check permissions (if enabled)
- if self._config.permission_check:
- # Skip permission check for special directories that use USER VOLUME
- if dataset_id not in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
- if self._permission_manager is not None:
- check_volume_permission(self._permission_manager, "load_once", dataset_id)
- # Download to temporary directory
- with tempfile.TemporaryDirectory() as temp_dir:
- volume_prefix = self._get_volume_sql_prefix(dataset_id)
- # Get the actual volume path (may include dify_km prefix)
- volume_path = self._get_volume_path(filename, dataset_id)
- # For User Volume, use the full path with dify_km prefix
- if volume_prefix == "USER VOLUME":
- sql = f"GET {volume_prefix} FILE '{volume_path}' TO '{temp_dir}'"
- else:
- sql = f"GET {volume_prefix} FILE '{filename}' TO '{temp_dir}'"
- self._execute_sql(sql)
- # Find the downloaded file (may be in subdirectories)
- downloaded_file = None
- for root, dirs, files in os.walk(temp_dir):
- for file in files:
- if file == filename or file == os.path.basename(filename):
- downloaded_file = Path(root) / file
- break
- if downloaded_file:
- break
- if not downloaded_file or not downloaded_file.exists():
- raise FileNotFoundError(f"Downloaded file not found: {filename}")
- content = downloaded_file.read_bytes()
- logger.debug("File %s loaded from ClickZetta Volume", filename)
- return content
- def load_stream(self, filename: str) -> Generator:
- """Load file as stream from ClickZetta Volume.
- Args:
- filename: File path in volume
- Yields:
- File content chunks
- """
- content = self.load_once(filename)
- batch_size = 4096
- stream = BytesIO(content)
- while chunk := stream.read(batch_size):
- yield chunk
- logger.debug("File %s loaded as stream from ClickZetta Volume", filename)
- def download(self, filename: str, target_filepath: str):
- """Download file from ClickZetta Volume to local path.
- Args:
- filename: File path in volume
- target_filepath: Local target file path
- """
- content = self.load_once(filename)
- with Path(target_filepath).open("wb") as f:
- f.write(content)
- logger.debug("File %s downloaded from ClickZetta Volume to %s", filename, target_filepath)
- def exists(self, filename: str) -> bool:
- """Check if file exists in ClickZetta Volume.
- Args:
- filename: File path in volume
- Returns:
- True if file exists, False otherwise
- """
- try:
- # Extract dataset_id from filename if present
- dataset_id = None
- if "/" in filename and self._config.volume_type == "table":
- parts = filename.split("/", 1)
- if parts[0].startswith(self._config.table_prefix):
- dataset_id = parts[0][len(self._config.table_prefix) :]
- filename = parts[1]
- else:
- dataset_id = parts[0]
- filename = parts[1]
- volume_prefix = self._get_volume_sql_prefix(dataset_id)
- # Get the actual volume path (may include dify_km prefix)
- volume_path = self._get_volume_path(filename, dataset_id)
- # For User Volume, use the full path with dify_km prefix
- if volume_prefix == "USER VOLUME":
- sql = f"LIST {volume_prefix} REGEXP = '^{volume_path}$'"
- else:
- sql = f"LIST {volume_prefix} REGEXP = '^{filename}$'"
- rows = self._execute_sql(sql, fetch=True)
- exists = len(rows) > 0
- logger.debug("File %s exists check: %s", filename, exists)
- return exists
- except Exception as e:
- logger.warning("Error checking file existence for %s: %s", filename, e)
- return False
- def delete(self, filename: str):
- """Delete file from ClickZetta Volume.
- Args:
- filename: File path in volume
- """
- if not self.exists(filename):
- logger.debug("File %s not found, skip delete", filename)
- return
- # Extract dataset_id from filename if present
- dataset_id = None
- if "/" in filename and self._config.volume_type == "table":
- parts = filename.split("/", 1)
- if parts[0].startswith(self._config.table_prefix):
- dataset_id = parts[0][len(self._config.table_prefix) :]
- filename = parts[1]
- else:
- dataset_id = parts[0]
- filename = parts[1]
- volume_prefix = self._get_volume_sql_prefix(dataset_id)
- # Get the actual volume path (may include dify_km prefix)
- volume_path = self._get_volume_path(filename, dataset_id)
- # For User Volume, use the full path with dify_km prefix
- if volume_prefix == "USER VOLUME":
- sql = f"REMOVE {volume_prefix} FILE '{volume_path}'"
- else:
- sql = f"REMOVE {volume_prefix} FILE '{filename}'"
- self._execute_sql(sql)
- logger.debug("File %s deleted from ClickZetta Volume", filename)
- def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
- """Scan files and directories in ClickZetta Volume.
- Args:
- path: Path to scan (dataset_id for table volumes)
- files: Include files in results
- directories: Include directories in results
- Returns:
- List of file/directory paths
- """
- try:
- # For table volumes, path is treated as dataset_id
- dataset_id = None
- if self._config.volume_type == "table":
- dataset_id = path
- path = "" # Root of the table volume
- volume_prefix = self._get_volume_sql_prefix(dataset_id)
- # For User Volume, add dify prefix to path
- if volume_prefix == "USER VOLUME":
- if path:
- scan_path = f"{self._config.dify_prefix}/{path}"
- sql = f"LIST {volume_prefix} SUBDIRECTORY '{scan_path}'"
- else:
- sql = f"LIST {volume_prefix} SUBDIRECTORY '{self._config.dify_prefix}'"
- else:
- if path:
- sql = f"LIST {volume_prefix} SUBDIRECTORY '{path}'"
- else:
- sql = f"LIST {volume_prefix}"
- rows = self._execute_sql(sql, fetch=True)
- result = []
- for row in rows:
- file_path = row[0] # relative_path column
- # For User Volume, remove dify prefix from results
- dify_prefix_with_slash = f"{self._config.dify_prefix}/"
- if volume_prefix == "USER VOLUME" and file_path.startswith(dify_prefix_with_slash):
- file_path = file_path[len(dify_prefix_with_slash) :] # Remove prefix
- if files and not file_path.endswith("/") or directories and file_path.endswith("/"):
- result.append(file_path)
- logger.debug("Scanned %d items in path %s", len(result), path)
- return result
- except Exception as e:
- logger.exception("Error scanning path %s", path)
- return []
|