clickzetta_volume_storage.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. """ClickZetta Volume Storage Implementation
  2. This module provides storage backend using ClickZetta Volume functionality.
  3. Supports Table Volume, User Volume, and External Volume types.
  4. """
  5. import logging
  6. import os
  7. import tempfile
  8. from collections.abc import Generator
  9. from io import BytesIO
  10. from pathlib import Path
  11. from typing import Optional
  12. import clickzetta # type: ignore[import]
  13. from pydantic import BaseModel, model_validator
  14. from extensions.storage.base_storage import BaseStorage
  15. from .volume_permissions import VolumePermissionManager, check_volume_permission
  16. logger = logging.getLogger(__name__)
  17. class ClickZettaVolumeConfig(BaseModel):
  18. """Configuration for ClickZetta Volume storage."""
  19. username: str = ""
  20. password: str = ""
  21. instance: str = ""
  22. service: str = "api.clickzetta.com"
  23. workspace: str = "quick_start"
  24. vcluster: str = "default_ap"
  25. schema_name: str = "dify"
  26. volume_type: str = "table" # table|user|external
  27. volume_name: Optional[str] = None # For external volumes
  28. table_prefix: str = "dataset_" # Prefix for table volume names
  29. dify_prefix: str = "dify_km" # Directory prefix for User Volume
  30. permission_check: bool = True # Enable/disable permission checking
  31. @model_validator(mode="before")
  32. @classmethod
  33. def validate_config(cls, values: dict) -> dict:
  34. """Validate the configuration values.
  35. This method will first try to use CLICKZETTA_VOLUME_* environment variables,
  36. then fall back to CLICKZETTA_* environment variables (for vector DB config).
  37. """
  38. import os
  39. # Helper function to get environment variable with fallback
  40. def get_env_with_fallback(volume_key: str, fallback_key: str, default: str | None = None) -> str:
  41. # First try CLICKZETTA_VOLUME_* specific config
  42. volume_value = values.get(volume_key.lower().replace("clickzetta_volume_", ""))
  43. if volume_value:
  44. return str(volume_value)
  45. # Then try environment variables
  46. volume_env = os.getenv(volume_key)
  47. if volume_env:
  48. return volume_env
  49. # Fall back to existing CLICKZETTA_* config
  50. fallback_env = os.getenv(fallback_key)
  51. if fallback_env:
  52. return fallback_env
  53. return default or ""
  54. # Apply environment variables with fallback to existing CLICKZETTA_* config
  55. values.setdefault("username", get_env_with_fallback("CLICKZETTA_VOLUME_USERNAME", "CLICKZETTA_USERNAME"))
  56. values.setdefault("password", get_env_with_fallback("CLICKZETTA_VOLUME_PASSWORD", "CLICKZETTA_PASSWORD"))
  57. values.setdefault("instance", get_env_with_fallback("CLICKZETTA_VOLUME_INSTANCE", "CLICKZETTA_INSTANCE"))
  58. values.setdefault(
  59. "service", get_env_with_fallback("CLICKZETTA_VOLUME_SERVICE", "CLICKZETTA_SERVICE", "api.clickzetta.com")
  60. )
  61. values.setdefault(
  62. "workspace", get_env_with_fallback("CLICKZETTA_VOLUME_WORKSPACE", "CLICKZETTA_WORKSPACE", "quick_start")
  63. )
  64. values.setdefault(
  65. "vcluster", get_env_with_fallback("CLICKZETTA_VOLUME_VCLUSTER", "CLICKZETTA_VCLUSTER", "default_ap")
  66. )
  67. values.setdefault("schema_name", get_env_with_fallback("CLICKZETTA_VOLUME_SCHEMA", "CLICKZETTA_SCHEMA", "dify"))
  68. # Volume-specific configurations (no fallback to vector DB config)
  69. values.setdefault("volume_type", os.getenv("CLICKZETTA_VOLUME_TYPE", "table"))
  70. values.setdefault("volume_name", os.getenv("CLICKZETTA_VOLUME_NAME"))
  71. values.setdefault("table_prefix", os.getenv("CLICKZETTA_VOLUME_TABLE_PREFIX", "dataset_"))
  72. values.setdefault("dify_prefix", os.getenv("CLICKZETTA_VOLUME_DIFY_PREFIX", "dify_km"))
  73. # Temporarily disable permission check feature, set directly to false
  74. values.setdefault("permission_check", False)
  75. # Validate required fields
  76. if not values.get("username"):
  77. raise ValueError("CLICKZETTA_VOLUME_USERNAME or CLICKZETTA_USERNAME is required")
  78. if not values.get("password"):
  79. raise ValueError("CLICKZETTA_VOLUME_PASSWORD or CLICKZETTA_PASSWORD is required")
  80. if not values.get("instance"):
  81. raise ValueError("CLICKZETTA_VOLUME_INSTANCE or CLICKZETTA_INSTANCE is required")
  82. # Validate volume type
  83. volume_type = values["volume_type"]
  84. if volume_type not in ["table", "user", "external"]:
  85. raise ValueError("CLICKZETTA_VOLUME_TYPE must be one of: table, user, external")
  86. if volume_type == "external" and not values.get("volume_name"):
  87. raise ValueError("CLICKZETTA_VOLUME_NAME is required for external volume type")
  88. return values
  89. class ClickZettaVolumeStorage(BaseStorage):
  90. """ClickZetta Volume storage implementation."""
  91. def __init__(self, config: ClickZettaVolumeConfig):
  92. """Initialize ClickZetta Volume storage.
  93. Args:
  94. config: ClickZetta Volume configuration
  95. """
  96. self._config = config
  97. self._connection = None
  98. self._permission_manager: VolumePermissionManager | None = None
  99. self._init_connection()
  100. self._init_permission_manager()
  101. logger.info("ClickZetta Volume storage initialized with type: %s", config.volume_type)
  102. def _init_connection(self):
  103. """Initialize ClickZetta connection."""
  104. try:
  105. self._connection = clickzetta.connect(
  106. username=self._config.username,
  107. password=self._config.password,
  108. instance=self._config.instance,
  109. service=self._config.service,
  110. workspace=self._config.workspace,
  111. vcluster=self._config.vcluster,
  112. schema=self._config.schema_name,
  113. )
  114. logger.debug("ClickZetta connection established")
  115. except Exception:
  116. logger.exception("Failed to connect to ClickZetta")
  117. raise
  118. def _init_permission_manager(self):
  119. """Initialize permission manager."""
  120. try:
  121. self._permission_manager = VolumePermissionManager(
  122. self._connection, self._config.volume_type, self._config.volume_name
  123. )
  124. logger.debug("Permission manager initialized")
  125. except Exception:
  126. logger.exception("Failed to initialize permission manager")
  127. raise
  128. def _get_volume_path(self, filename: str, dataset_id: Optional[str] = None) -> str:
  129. """Get the appropriate volume path based on volume type."""
  130. if self._config.volume_type == "user":
  131. # Add dify prefix for User Volume to organize files
  132. return f"{self._config.dify_prefix}/{filename}"
  133. elif self._config.volume_type == "table":
  134. # Check if this should use User Volume (special directories)
  135. if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
  136. # Use User Volume with dify prefix for special directories
  137. return f"{self._config.dify_prefix}/{filename}"
  138. if dataset_id:
  139. return f"{self._config.table_prefix}{dataset_id}/{filename}"
  140. else:
  141. # Extract dataset_id from filename if not provided
  142. # Format: dataset_id/filename
  143. if "/" in filename:
  144. return filename
  145. else:
  146. raise ValueError("dataset_id is required for table volume or filename must include dataset_id/")
  147. elif self._config.volume_type == "external":
  148. return filename
  149. else:
  150. raise ValueError(f"Unsupported volume type: {self._config.volume_type}")
  151. def _get_volume_sql_prefix(self, dataset_id: Optional[str] = None) -> str:
  152. """Get SQL prefix for volume operations."""
  153. if self._config.volume_type == "user":
  154. return "USER VOLUME"
  155. elif self._config.volume_type == "table":
  156. # For Dify's current file storage pattern, most files are stored in
  157. # paths like "upload_files/tenant_id/uuid.ext", "tools/tenant_id/uuid.ext"
  158. # These should use USER VOLUME for better compatibility
  159. if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
  160. return "USER VOLUME"
  161. # Only use TABLE VOLUME for actual dataset-specific paths
  162. # like "dataset_12345/file.pdf" or paths with dataset_ prefix
  163. if dataset_id:
  164. table_name = f"{self._config.table_prefix}{dataset_id}"
  165. else:
  166. # Default table name for generic operations
  167. table_name = "default_dataset"
  168. return f"TABLE VOLUME {table_name}"
  169. elif self._config.volume_type == "external":
  170. return f"VOLUME {self._config.volume_name}"
  171. else:
  172. raise ValueError(f"Unsupported volume type: {self._config.volume_type}")
  173. def _execute_sql(self, sql: str, fetch: bool = False):
  174. """Execute SQL command."""
  175. try:
  176. if self._connection is None:
  177. raise RuntimeError("Connection not initialized")
  178. with self._connection.cursor() as cursor:
  179. cursor.execute(sql)
  180. if fetch:
  181. return cursor.fetchall()
  182. return None
  183. except Exception:
  184. logger.exception("SQL execution failed: %s", sql)
  185. raise
  186. def _ensure_table_volume_exists(self, dataset_id: str) -> None:
  187. """Ensure table volume exists for the given dataset_id."""
  188. if self._config.volume_type != "table" or not dataset_id:
  189. return
  190. # Skip for upload_files and other special directories that use USER VOLUME
  191. if dataset_id in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
  192. return
  193. table_name = f"{self._config.table_prefix}{dataset_id}"
  194. try:
  195. # Check if table exists
  196. check_sql = f"SHOW TABLES LIKE '{table_name}'"
  197. result = self._execute_sql(check_sql, fetch=True)
  198. if not result:
  199. # Create table with volume
  200. create_sql = f"""
  201. CREATE TABLE {table_name} (
  202. id INT PRIMARY KEY AUTO_INCREMENT,
  203. filename VARCHAR(255) NOT NULL,
  204. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  205. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  206. INDEX idx_filename (filename)
  207. ) WITH VOLUME
  208. """
  209. self._execute_sql(create_sql)
  210. logger.info("Created table volume: %s", table_name)
  211. except Exception as e:
  212. logger.warning("Failed to create table volume %s: %s", table_name, e)
  213. # Don't raise exception, let the operation continue
  214. # The table might exist but not be visible due to permissions
  215. def save(self, filename: str, data: bytes) -> None:
  216. """Save data to ClickZetta Volume.
  217. Args:
  218. filename: File path in volume
  219. data: File content as bytes
  220. """
  221. # Extract dataset_id from filename if present
  222. dataset_id = None
  223. if "/" in filename and self._config.volume_type == "table":
  224. parts = filename.split("/", 1)
  225. if parts[0].startswith(self._config.table_prefix):
  226. dataset_id = parts[0][len(self._config.table_prefix) :]
  227. filename = parts[1]
  228. else:
  229. dataset_id = parts[0]
  230. filename = parts[1]
  231. # Ensure table volume exists (for table volumes)
  232. if dataset_id:
  233. self._ensure_table_volume_exists(dataset_id)
  234. # Check permissions (if enabled)
  235. if self._config.permission_check:
  236. # Skip permission check for special directories that use USER VOLUME
  237. if dataset_id not in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
  238. if self._permission_manager is not None:
  239. check_volume_permission(self._permission_manager, "save", dataset_id)
  240. # Write data to temporary file
  241. with tempfile.NamedTemporaryFile(delete=False) as temp_file:
  242. temp_file.write(data)
  243. temp_file_path = temp_file.name
  244. try:
  245. # Upload to volume
  246. volume_prefix = self._get_volume_sql_prefix(dataset_id)
  247. # Get the actual volume path (may include dify_km prefix)
  248. volume_path = self._get_volume_path(filename, dataset_id)
  249. # For User Volume, use the full path with dify_km prefix
  250. if volume_prefix == "USER VOLUME":
  251. sql = f"PUT '{temp_file_path}' TO {volume_prefix} FILE '{volume_path}'"
  252. else:
  253. sql = f"PUT '{temp_file_path}' TO {volume_prefix} FILE '{filename}'"
  254. self._execute_sql(sql)
  255. logger.debug("File %s saved to ClickZetta Volume at path %s", filename, volume_path)
  256. finally:
  257. # Clean up temporary file
  258. Path(temp_file_path).unlink(missing_ok=True)
  259. def load_once(self, filename: str) -> bytes:
  260. """Load file content from ClickZetta Volume.
  261. Args:
  262. filename: File path in volume
  263. Returns:
  264. File content as bytes
  265. """
  266. # Extract dataset_id from filename if present
  267. dataset_id = None
  268. if "/" in filename and self._config.volume_type == "table":
  269. parts = filename.split("/", 1)
  270. if parts[0].startswith(self._config.table_prefix):
  271. dataset_id = parts[0][len(self._config.table_prefix) :]
  272. filename = parts[1]
  273. else:
  274. dataset_id = parts[0]
  275. filename = parts[1]
  276. # Check permissions (if enabled)
  277. if self._config.permission_check:
  278. # Skip permission check for special directories that use USER VOLUME
  279. if dataset_id not in ["upload_files", "temp", "cache", "tools", "website_files", "privkeys"]:
  280. if self._permission_manager is not None:
  281. check_volume_permission(self._permission_manager, "load_once", dataset_id)
  282. # Download to temporary directory
  283. with tempfile.TemporaryDirectory() as temp_dir:
  284. volume_prefix = self._get_volume_sql_prefix(dataset_id)
  285. # Get the actual volume path (may include dify_km prefix)
  286. volume_path = self._get_volume_path(filename, dataset_id)
  287. # For User Volume, use the full path with dify_km prefix
  288. if volume_prefix == "USER VOLUME":
  289. sql = f"GET {volume_prefix} FILE '{volume_path}' TO '{temp_dir}'"
  290. else:
  291. sql = f"GET {volume_prefix} FILE '{filename}' TO '{temp_dir}'"
  292. self._execute_sql(sql)
  293. # Find the downloaded file (may be in subdirectories)
  294. downloaded_file = None
  295. for root, _, files in os.walk(temp_dir):
  296. for file in files:
  297. if file == filename or file == os.path.basename(filename):
  298. downloaded_file = Path(root) / file
  299. break
  300. if downloaded_file:
  301. break
  302. if not downloaded_file or not downloaded_file.exists():
  303. raise FileNotFoundError(f"Downloaded file not found: {filename}")
  304. content = downloaded_file.read_bytes()
  305. logger.debug("File %s loaded from ClickZetta Volume", filename)
  306. return content
  307. def load_stream(self, filename: str) -> Generator:
  308. """Load file as stream from ClickZetta Volume.
  309. Args:
  310. filename: File path in volume
  311. Yields:
  312. File content chunks
  313. """
  314. content = self.load_once(filename)
  315. batch_size = 4096
  316. stream = BytesIO(content)
  317. while chunk := stream.read(batch_size):
  318. yield chunk
  319. logger.debug("File %s loaded as stream from ClickZetta Volume", filename)
  320. def download(self, filename: str, target_filepath: str):
  321. """Download file from ClickZetta Volume to local path.
  322. Args:
  323. filename: File path in volume
  324. target_filepath: Local target file path
  325. """
  326. content = self.load_once(filename)
  327. with Path(target_filepath).open("wb") as f:
  328. f.write(content)
  329. logger.debug("File %s downloaded from ClickZetta Volume to %s", filename, target_filepath)
  330. def exists(self, filename: str) -> bool:
  331. """Check if file exists in ClickZetta Volume.
  332. Args:
  333. filename: File path in volume
  334. Returns:
  335. True if file exists, False otherwise
  336. """
  337. try:
  338. # Extract dataset_id from filename if present
  339. dataset_id = None
  340. if "/" in filename and self._config.volume_type == "table":
  341. parts = filename.split("/", 1)
  342. if parts[0].startswith(self._config.table_prefix):
  343. dataset_id = parts[0][len(self._config.table_prefix) :]
  344. filename = parts[1]
  345. else:
  346. dataset_id = parts[0]
  347. filename = parts[1]
  348. volume_prefix = self._get_volume_sql_prefix(dataset_id)
  349. # Get the actual volume path (may include dify_km prefix)
  350. volume_path = self._get_volume_path(filename, dataset_id)
  351. # For User Volume, use the full path with dify_km prefix
  352. if volume_prefix == "USER VOLUME":
  353. sql = f"LIST {volume_prefix} REGEXP = '^{volume_path}$'"
  354. else:
  355. sql = f"LIST {volume_prefix} REGEXP = '^{filename}$'"
  356. rows = self._execute_sql(sql, fetch=True)
  357. exists = len(rows) > 0
  358. logger.debug("File %s exists check: %s", filename, exists)
  359. return exists
  360. except Exception as e:
  361. logger.warning("Error checking file existence for %s: %s", filename, e)
  362. return False
  363. def delete(self, filename: str):
  364. """Delete file from ClickZetta Volume.
  365. Args:
  366. filename: File path in volume
  367. """
  368. if not self.exists(filename):
  369. logger.debug("File %s not found, skip delete", filename)
  370. return
  371. # Extract dataset_id from filename if present
  372. dataset_id = None
  373. if "/" in filename and self._config.volume_type == "table":
  374. parts = filename.split("/", 1)
  375. if parts[0].startswith(self._config.table_prefix):
  376. dataset_id = parts[0][len(self._config.table_prefix) :]
  377. filename = parts[1]
  378. else:
  379. dataset_id = parts[0]
  380. filename = parts[1]
  381. volume_prefix = self._get_volume_sql_prefix(dataset_id)
  382. # Get the actual volume path (may include dify_km prefix)
  383. volume_path = self._get_volume_path(filename, dataset_id)
  384. # For User Volume, use the full path with dify_km prefix
  385. if volume_prefix == "USER VOLUME":
  386. sql = f"REMOVE {volume_prefix} FILE '{volume_path}'"
  387. else:
  388. sql = f"REMOVE {volume_prefix} FILE '{filename}'"
  389. self._execute_sql(sql)
  390. logger.debug("File %s deleted from ClickZetta Volume", filename)
  391. def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
  392. """Scan files and directories in ClickZetta Volume.
  393. Args:
  394. path: Path to scan (dataset_id for table volumes)
  395. files: Include files in results
  396. directories: Include directories in results
  397. Returns:
  398. List of file/directory paths
  399. """
  400. try:
  401. # For table volumes, path is treated as dataset_id
  402. dataset_id = None
  403. if self._config.volume_type == "table":
  404. dataset_id = path
  405. path = "" # Root of the table volume
  406. volume_prefix = self._get_volume_sql_prefix(dataset_id)
  407. # For User Volume, add dify prefix to path
  408. if volume_prefix == "USER VOLUME":
  409. if path:
  410. scan_path = f"{self._config.dify_prefix}/{path}"
  411. sql = f"LIST {volume_prefix} SUBDIRECTORY '{scan_path}'"
  412. else:
  413. sql = f"LIST {volume_prefix} SUBDIRECTORY '{self._config.dify_prefix}'"
  414. else:
  415. if path:
  416. sql = f"LIST {volume_prefix} SUBDIRECTORY '{path}'"
  417. else:
  418. sql = f"LIST {volume_prefix}"
  419. rows = self._execute_sql(sql, fetch=True)
  420. result = []
  421. for row in rows:
  422. file_path = row[0] # relative_path column
  423. # For User Volume, remove dify prefix from results
  424. dify_prefix_with_slash = f"{self._config.dify_prefix}/"
  425. if volume_prefix == "USER VOLUME" and file_path.startswith(dify_prefix_with_slash):
  426. file_path = file_path[len(dify_prefix_with_slash) :] # Remove prefix
  427. if files and not file_path.endswith("/") or directories and file_path.endswith("/"):
  428. result.append(file_path)
  429. logger.debug("Scanned %d items in path %s", len(result), path)
  430. return result
  431. except Exception:
  432. logger.exception("Error scanning path %s", path)
  433. return []