dataset_version_redis_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """
  2. Document Segment Version Control Service based on Redis
  3. Provides lightweight version management without database schema changes
  4. """
  5. import hashlib
  6. import json
  7. from datetime import datetime
  8. from typing import Any, Optional
  9. from flask import request
  10. from flask_login import current_user
  11. from extensions.ext_redis import redis_client
  12. from models.dataset import Dataset, Document, DocumentSegment
  13. class RedisVersionService:
  14. """Redis-based version control service for document segments"""
  15. # Redis Key prefixes
  16. VERSION_PREFIX = "dataset:version"
  17. VERSION_LIST_PREFIX = "dataset:version:list"
  18. VERSION_COUNTER_PREFIX = "dataset:version:counter"
  19. VERSION_CURRENT_PREFIX = "dataset:version:current"
  20. # Default retention settings
  21. DEFAULT_TTL_DAYS = 30
  22. MAX_VERSIONS = 50
  23. @classmethod
  24. def _get_version_key(cls, segment_id: str, version_number: int) -> str:
  25. """Get Redis key for version data"""
  26. return f"{cls.VERSION_PREFIX}:{segment_id}:{version_number}"
  27. @classmethod
  28. def _get_version_list_key(cls, segment_id: str) -> str:
  29. """Get Redis key for version list (Sorted Set)"""
  30. return f"{cls.VERSION_LIST_PREFIX}:{segment_id}"
  31. @classmethod
  32. def _get_version_counter_key(cls, segment_id: str) -> str:
  33. """Get Redis key for version counter"""
  34. return f"{cls.VERSION_COUNTER_PREFIX}:{segment_id}"
  35. @classmethod
  36. def _get_current_hash_key(cls, segment_id: str) -> str:
  37. """Get Redis key for current content hash"""
  38. return f"{cls.VERSION_CURRENT_PREFIX}:{segment_id}"
  39. @classmethod
  40. def _generate_content_hash(cls, segment: DocumentSegment) -> str:
  41. """Generate content hash to detect real changes"""
  42. content_str = json.dumps(
  43. {
  44. "content": segment.content,
  45. "answer": segment.answer,
  46. "keywords": segment.keywords,
  47. "enabled": segment.enabled,
  48. },
  49. sort_keys=True,
  50. )
  51. return hashlib.md5(content_str.encode()).hexdigest()
  52. @classmethod
  53. def create_version_snapshot(
  54. cls,
  55. segment: DocumentSegment,
  56. document: Document,
  57. dataset: Dataset,
  58. change_type: str = "update",
  59. change_summary: Optional[str] = None,
  60. changed_fields: Optional[list[str]] = None,
  61. ) -> Optional[dict[str, Any]]:
  62. """
  63. Create version snapshot for a segment
  64. Args:
  65. segment: Document segment object
  66. document: Document object
  67. dataset: Dataset object
  68. change_type: Type of change (create/update/delete/rollback)
  69. change_summary: Summary of changes
  70. changed_fields: List of changed fields
  71. Returns:
  72. Version info dict, or None if content unchanged
  73. """
  74. segment_id = segment.id
  75. # Check if content actually changed
  76. current_hash = cls._generate_content_hash(segment)
  77. stored_hash = redis_client.get(cls._get_current_hash_key(segment_id))
  78. if stored_hash and stored_hash.decode() == current_hash and change_type == "update":
  79. # Content unchanged, don't create new version
  80. return None
  81. # Update current content hash
  82. redis_client.set(
  83. cls._get_current_hash_key(segment_id), current_hash, ex=cls.DEFAULT_TTL_DAYS * 86400
  84. )
  85. # Get next version number (atomic increment)
  86. counter_key = cls._get_version_counter_key(segment_id)
  87. version_number = redis_client.incr(counter_key)
  88. redis_client.expire(counter_key, cls.DEFAULT_TTL_DAYS * 86400)
  89. # Build version data
  90. version_data = {
  91. "version_number": version_number,
  92. "segment_id": segment_id,
  93. "document_id": document.id,
  94. "document_name": document.name,
  95. "dataset_id": dataset.id,
  96. "dataset_name": dataset.name,
  97. # Snapshot content
  98. "content": segment.content,
  99. "answer": segment.answer,
  100. "keywords": segment.keywords,
  101. "enabled": segment.enabled,
  102. "position": segment.position,
  103. "word_count": segment.word_count,
  104. # Change info
  105. "change_type": change_type,
  106. "change_summary": change_summary or f"{change_type.capitalize()} segment",
  107. "changed_fields": changed_fields or [],
  108. # Audit info
  109. "created_by": current_user.id if current_user and hasattr(current_user, "id") else None,
  110. "created_by_name": current_user.name if current_user and hasattr(current_user, "name") else "System",
  111. "created_at": datetime.utcnow().isoformat(),
  112. "created_ip": request.remote_addr if request else None,
  113. # Content hash
  114. "content_hash": current_hash,
  115. }
  116. # Store version data (Hash)
  117. version_key = cls._get_version_key(segment_id, version_number)
  118. redis_client.hset(
  119. version_key,
  120. mapping={
  121. k: json.dumps(v) if isinstance(v, (dict, list)) else (str(v) if v is not None else "")
  122. for k, v in version_data.items()
  123. },
  124. )
  125. redis_client.expire(version_key, cls.DEFAULT_TTL_DAYS * 86400)
  126. # Add to version list (Sorted Set, sorted by version number)
  127. list_key = cls._get_version_list_key(segment_id)
  128. redis_client.zadd(list_key, {str(version_number): version_number})
  129. redis_client.expire(list_key, cls.DEFAULT_TTL_DAYS * 86400)
  130. # Clean up old versions
  131. cls._cleanup_old_versions(segment_id)
  132. return version_data
  133. @classmethod
  134. def _cleanup_old_versions(cls, segment_id: str):
  135. """Clean up old versions, keep only recent ones"""
  136. list_key = cls._get_version_list_key(segment_id)
  137. # Get total count
  138. total_count = redis_client.zcard(list_key)
  139. if total_count > cls.MAX_VERSIONS:
  140. # Get versions to delete (oldest)
  141. to_delete_count = total_count - cls.MAX_VERSIONS
  142. old_versions = redis_client.zrange(list_key, 0, to_delete_count - 1)
  143. # Delete old version data
  144. for version_number in old_versions:
  145. version_key = cls._get_version_key(segment_id, int(version_number))
  146. redis_client.delete(version_key)
  147. # Remove from list
  148. redis_client.zremrangebyrank(list_key, 0, to_delete_count - 1)
  149. @classmethod
  150. def get_segment_versions(
  151. cls, segment_id: str, page: int = 1, per_page: int = 20, include_content: bool = False
  152. ) -> dict[str, Any]:
  153. """
  154. Get version history list for a segment
  155. Args:
  156. segment_id: Segment ID
  157. page: Page number
  158. per_page: Items per page
  159. include_content: Include full content (default False to save bandwidth)
  160. Returns:
  161. Dict with versions list and pagination info
  162. """
  163. list_key = cls._get_version_list_key(segment_id)
  164. # Get total count
  165. total = redis_client.zcard(list_key)
  166. if total == 0:
  167. return {"versions": [], "total": 0, "page": page, "per_page": per_page, "pages": 0}
  168. # Calculate pagination (newest first)
  169. start = (page - 1) * per_page
  170. end = start + per_page - 1
  171. # Get version numbers (reverse order)
  172. version_numbers = redis_client.zrevrange(list_key, start, end)
  173. # Get version details
  174. versions = []
  175. for version_number in version_numbers:
  176. version_key = cls._get_version_key(segment_id, int(version_number))
  177. version_data = redis_client.hgetall(version_key)
  178. if version_data:
  179. version_dict = {}
  180. for k, v in version_data.items():
  181. key = k.decode() if isinstance(k, bytes) else k
  182. value = v.decode() if isinstance(v, bytes) else v
  183. # Try to parse JSON
  184. try:
  185. if value and value.startswith(("[", "{")):
  186. version_dict[key] = json.loads(value)
  187. else:
  188. version_dict[key] = value
  189. except json.JSONDecodeError:
  190. version_dict[key] = value
  191. # Remove large fields if not needed
  192. if not include_content:
  193. version_dict.pop("content", None)
  194. version_dict.pop("answer", None)
  195. versions.append(version_dict)
  196. return {
  197. "versions": versions,
  198. "total": total,
  199. "page": page,
  200. "per_page": per_page,
  201. "pages": (total + per_page - 1) // per_page,
  202. }
  203. @classmethod
  204. def get_version_detail(cls, segment_id: str, version_number: int) -> Optional[dict[str, Any]]:
  205. """Get detailed info for a specific version"""
  206. version_key = cls._get_version_key(segment_id, version_number)
  207. version_data = redis_client.hgetall(version_key)
  208. if not version_data:
  209. return None
  210. version_dict = {}
  211. for k, v in version_data.items():
  212. key = k.decode() if isinstance(k, bytes) else k
  213. value = v.decode() if isinstance(v, bytes) else v
  214. try:
  215. if value and value.startswith(("[", "{")):
  216. version_dict[key] = json.loads(value)
  217. else:
  218. version_dict[key] = value
  219. except json.JSONDecodeError:
  220. version_dict[key] = value
  221. return version_dict
  222. @classmethod
  223. def compare_versions(
  224. cls, segment_id: str, version_number_1: int, version_number_2: int
  225. ) -> Optional[dict[str, Any]]:
  226. """Compare two versions"""
  227. v1 = cls.get_version_detail(segment_id, version_number_1)
  228. v2 = cls.get_version_detail(segment_id, version_number_2)
  229. if not v1 or not v2:
  230. return None
  231. # Calculate differences
  232. differences = {}
  233. compare_fields = ["content", "answer", "keywords", "enabled", "word_count"]
  234. for field in compare_fields:
  235. val1 = v1.get(field)
  236. val2 = v2.get(field)
  237. if val1 != val2:
  238. differences[field] = {"changed": True, "old_value": val1, "new_value": val2}
  239. return {
  240. "version_1": {
  241. "version_number": v1.get("version_number"),
  242. "created_at": v1.get("created_at"),
  243. "created_by_name": v1.get("created_by_name"),
  244. "change_type": v1.get("change_type"),
  245. },
  246. "version_2": {
  247. "version_number": v2.get("version_number"),
  248. "created_at": v2.get("created_at"),
  249. "created_by_name": v2.get("created_by_name"),
  250. "change_type": v2.get("change_type"),
  251. },
  252. "differences": differences,
  253. "has_changes": len(differences) > 0,
  254. }
  255. @classmethod
  256. def get_latest_version(cls, segment_id: str) -> Optional[dict[str, Any]]:
  257. """Get the latest version"""
  258. list_key = cls._get_version_list_key(segment_id)
  259. latest_versions = redis_client.zrevrange(list_key, 0, 0)
  260. if not latest_versions:
  261. return None
  262. return cls.get_version_detail(segment_id, int(latest_versions[0]))
  263. @classmethod
  264. def delete_all_versions(cls, segment_id: str):
  265. """Delete all versions for a segment (called when segment is deleted)"""
  266. list_key = cls._get_version_list_key(segment_id)
  267. # Get all version numbers
  268. version_numbers = redis_client.zrange(list_key, 0, -1)
  269. # Delete all version data
  270. for version_number in version_numbers:
  271. version_key = cls._get_version_key(segment_id, int(version_number))
  272. redis_client.delete(version_key)
  273. # Delete list, counter, and hash
  274. redis_client.delete(list_key)
  275. redis_client.delete(cls._get_version_counter_key(segment_id))
  276. redis_client.delete(cls._get_current_hash_key(segment_id))
  277. @classmethod
  278. def set_ttl_days(cls, days: int):
  279. """Set version retention days (global config)"""
  280. cls.DEFAULT_TTL_DAYS = days
  281. @classmethod
  282. def set_max_versions(cls, max_versions: int):
  283. """Set maximum version count (global config)"""
  284. cls.MAX_VERSIONS = max_versions
  285. @classmethod
  286. def get_segment_statistics(cls, segment_id: str) -> dict[str, Any]:
  287. """Get version statistics for a segment"""
  288. list_key = cls._get_version_list_key(segment_id)
  289. total_versions = redis_client.zcard(list_key)
  290. if total_versions == 0:
  291. return {
  292. "total_versions": 0,
  293. "first_version_at": None,
  294. "latest_version_at": None,
  295. }
  296. # Get first and last version
  297. first_version_number = redis_client.zrange(list_key, 0, 0)[0]
  298. latest_version_number = redis_client.zrevrange(list_key, 0, 0)[0]
  299. first_version = cls.get_version_detail(segment_id, int(first_version_number))
  300. latest_version = cls.get_version_detail(segment_id, int(latest_version_number))
  301. return {
  302. "total_versions": total_versions,
  303. "first_version_at": first_version.get("created_at") if first_version else None,
  304. "latest_version_at": latest_version.get("created_at") if latest_version else None,
  305. "latest_version_number": int(latest_version_number),
  306. "ttl_days": cls.DEFAULT_TTL_DAYS,
  307. "max_versions": cls.MAX_VERSIONS,
  308. }