document_schedule_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """
  2. Document Schedule Service
  3. Manages scheduled document status changes using Redis
  4. """
  5. import json
  6. import logging
  7. from datetime import datetime
  8. from enum import Enum
  9. from typing import Any, Optional
  10. from extensions.ext_redis import redis_client
  11. logger = logging.getLogger(__name__)
  12. class ScheduleAction(str, Enum):
  13. """Scheduled action types"""
  14. ENABLE = "enable"
  15. DISABLE = "disable"
  16. ARCHIVE = "archive"
  17. class DocumentScheduleService:
  18. """Service for managing scheduled document status changes"""
  19. # Redis key prefixes
  20. SCHEDULE_KEY_PREFIX = "document_schedule:" # Sorted Set for scheduled tasks
  21. SCHEDULE_DETAIL_PREFIX = "document_schedule_detail:" # Hash for task details
  22. @classmethod
  23. def add_schedule(
  24. cls,
  25. document_id: str,
  26. dataset_id: str,
  27. tenant_id: str,
  28. action: ScheduleAction,
  29. scheduled_at: datetime,
  30. created_by: str,
  31. metadata: Optional[dict] = None,
  32. ) -> str:
  33. """
  34. Add a scheduled document status change task
  35. Args:
  36. document_id: Document ID
  37. dataset_id: Dataset ID
  38. tenant_id: Tenant ID
  39. action: Action to perform (enable/disable/archive)
  40. scheduled_at: When to execute the action
  41. created_by: User ID who created the schedule
  42. metadata: Additional metadata (e.g., reason, notes)
  43. Returns:
  44. schedule_id: Unique identifier for this scheduled task
  45. """
  46. # Generate unique schedule ID
  47. schedule_id = f"{document_id}:{action.value}:{int(scheduled_at.timestamp())}"
  48. # Prepare schedule details
  49. schedule_detail = {
  50. "schedule_id": schedule_id,
  51. "document_id": document_id,
  52. "dataset_id": dataset_id,
  53. "tenant_id": tenant_id,
  54. "action": action.value,
  55. "scheduled_at": scheduled_at.isoformat(),
  56. "created_by": created_by,
  57. "created_at": datetime.utcnow().isoformat(),
  58. "status": "pending", # pending, executing, completed, cancelled, failed
  59. "metadata": metadata or {},
  60. }
  61. # Store in Redis
  62. # 1. Add to Sorted Set (for efficient time-based queries)
  63. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  64. redis_client.zadd(schedule_key, {schedule_id: scheduled_at.timestamp()})
  65. # 2. Store detailed information in Hash
  66. detail_key = f"{cls.SCHEDULE_DETAIL_PREFIX}{schedule_id}"
  67. redis_client.hset(detail_key, mapping={k: json.dumps(v) for k, v in schedule_detail.items()})
  68. # 3. Set expiration (keep for 30 days after execution)
  69. expire_seconds = int((scheduled_at.timestamp() - datetime.utcnow().timestamp()) + 30 * 24 * 3600)
  70. if expire_seconds > 0:
  71. redis_client.expire(detail_key, expire_seconds)
  72. # 4. Add to document-specific index for quick lookup
  73. doc_schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}doc:{document_id}"
  74. redis_client.zadd(doc_schedule_key, {schedule_id: scheduled_at.timestamp()})
  75. redis_client.expire(doc_schedule_key, expire_seconds if expire_seconds > 0 else 86400)
  76. logger.info(
  77. f"Added schedule: {schedule_id} for document {document_id}, "
  78. f"action={action.value}, scheduled_at={scheduled_at}"
  79. )
  80. return schedule_id
  81. @classmethod
  82. def cancel_schedule(cls, schedule_id: str, cancelled_by: str) -> bool:
  83. """
  84. Cancel a scheduled task
  85. Args:
  86. schedule_id: Schedule ID to cancel
  87. cancelled_by: User ID who cancelled the schedule
  88. Returns:
  89. bool: True if cancelled successfully, False if not found
  90. """
  91. detail_key = f"{cls.SCHEDULE_DETAIL_PREFIX}{schedule_id}"
  92. # Check if schedule exists
  93. if not redis_client.exists(detail_key):
  94. return False
  95. # Update status to cancelled
  96. redis_client.hset(
  97. detail_key,
  98. mapping={
  99. "status": json.dumps("cancelled"),
  100. "cancelled_by": json.dumps(cancelled_by),
  101. "cancelled_at": json.dumps(datetime.utcnow().isoformat()),
  102. },
  103. )
  104. # Remove from sorted set
  105. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  106. redis_client.zrem(schedule_key, schedule_id)
  107. # Extract document_id from schedule_id
  108. document_id = schedule_id.split(":")[0]
  109. doc_schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}doc:{document_id}"
  110. redis_client.zrem(doc_schedule_key, schedule_id)
  111. logger.info(f"Cancelled schedule: {schedule_id} by user {cancelled_by}")
  112. return True
  113. @classmethod
  114. def get_schedule(cls, schedule_id: str) -> Optional[dict]:
  115. """
  116. Get schedule details
  117. Args:
  118. schedule_id: Schedule ID
  119. Returns:
  120. dict: Schedule details or None if not found
  121. """
  122. detail_key = f"{cls.SCHEDULE_DETAIL_PREFIX}{schedule_id}"
  123. if not redis_client.exists(detail_key):
  124. return None
  125. # Get all fields from hash
  126. raw_data = redis_client.hgetall(detail_key)
  127. if not raw_data:
  128. return None
  129. # Decode and parse JSON
  130. schedule_detail = {}
  131. for key, value in raw_data.items():
  132. key_str = key.decode("utf-8") if isinstance(key, bytes) else key
  133. value_str = value.decode("utf-8") if isinstance(value, bytes) else value
  134. try:
  135. schedule_detail[key_str] = json.loads(value_str)
  136. except json.JSONDecodeError:
  137. schedule_detail[key_str] = value_str
  138. return schedule_detail
  139. @classmethod
  140. def get_document_schedules(cls, document_id: str, include_completed: bool = False) -> list[dict]:
  141. """
  142. Get all schedules for a specific document
  143. Args:
  144. document_id: Document ID
  145. include_completed: Whether to include completed/cancelled schedules
  146. Returns:
  147. list: List of schedule details
  148. """
  149. doc_schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}doc:{document_id}"
  150. # Get all schedule IDs for this document
  151. schedule_ids = redis_client.zrange(doc_schedule_key, 0, -1)
  152. if not schedule_ids:
  153. return []
  154. schedules = []
  155. for schedule_id_bytes in schedule_ids:
  156. schedule_id = schedule_id_bytes.decode("utf-8") if isinstance(schedule_id_bytes, bytes) else schedule_id_bytes
  157. schedule = cls.get_schedule(schedule_id)
  158. if schedule:
  159. # Filter based on status
  160. if include_completed or schedule.get("status") == "pending":
  161. schedules.append(schedule)
  162. # Sort by scheduled_at
  163. schedules.sort(key=lambda x: x.get("scheduled_at", ""))
  164. return schedules
  165. @classmethod
  166. def get_pending_schedules(cls, before_timestamp: Optional[float] = None) -> list[dict]:
  167. """
  168. Get all pending schedules that should be executed
  169. Args:
  170. before_timestamp: Only get schedules before this timestamp (default: now)
  171. Returns:
  172. list: List of schedule details ready for execution
  173. """
  174. if before_timestamp is None:
  175. before_timestamp = datetime.utcnow().timestamp()
  176. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  177. # Get schedule IDs with score (timestamp) <= before_timestamp
  178. schedule_ids = redis_client.zrangebyscore(schedule_key, 0, before_timestamp)
  179. if not schedule_ids:
  180. return []
  181. pending_schedules = []
  182. for schedule_id_bytes in schedule_ids:
  183. schedule_id = schedule_id_bytes.decode("utf-8") if isinstance(schedule_id_bytes, bytes) else schedule_id_bytes
  184. schedule = cls.get_schedule(schedule_id)
  185. if schedule and schedule.get("status") == "pending":
  186. pending_schedules.append(schedule)
  187. return pending_schedules
  188. @classmethod
  189. def update_schedule_status(
  190. cls, schedule_id: str, status: str, error_message: Optional[str] = None
  191. ) -> bool:
  192. """
  193. Update schedule execution status
  194. Args:
  195. schedule_id: Schedule ID
  196. status: New status (executing, completed, failed)
  197. error_message: Error message if failed
  198. Returns:
  199. bool: True if updated successfully
  200. """
  201. detail_key = f"{cls.SCHEDULE_DETAIL_PREFIX}{schedule_id}"
  202. if not redis_client.exists(detail_key):
  203. return False
  204. update_data = {
  205. "status": json.dumps(status),
  206. "updated_at": json.dumps(datetime.utcnow().isoformat()),
  207. }
  208. if error_message:
  209. update_data["error_message"] = json.dumps(error_message)
  210. if status == "completed":
  211. update_data["completed_at"] = json.dumps(datetime.utcnow().isoformat())
  212. redis_client.hset(detail_key, mapping=update_data)
  213. # Remove from sorted set if completed/failed
  214. if status in ["completed", "failed"]:
  215. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  216. redis_client.zrem(schedule_key, schedule_id)
  217. # Extract document_id from schedule_id
  218. document_id = schedule_id.split(":")[0]
  219. doc_schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}doc:{document_id}"
  220. redis_client.zrem(doc_schedule_key, schedule_id)
  221. return True
  222. @classmethod
  223. def clear_expired_schedules(cls, days: int = 30) -> int:
  224. """
  225. Clear old completed/cancelled schedules
  226. Args:
  227. days: Clear schedules older than this many days
  228. Returns:
  229. int: Number of schedules cleared
  230. """
  231. # This is handled by Redis TTL, but can be called manually
  232. # to clean up if needed
  233. cutoff_timestamp = datetime.utcnow().timestamp() - (days * 24 * 3600)
  234. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  235. removed_count = redis_client.zremrangebyscore(schedule_key, 0, cutoff_timestamp)
  236. logger.info(f"Cleared {removed_count} expired schedules older than {days} days")
  237. return removed_count
  238. @classmethod
  239. def get_statistics(cls, tenant_id: Optional[str] = None) -> dict[str, Any]:
  240. """
  241. Get schedule statistics
  242. Args:
  243. tenant_id: Optional tenant ID to filter by
  244. Returns:
  245. dict: Statistics including counts by status
  246. """
  247. schedule_key = f"{cls.SCHEDULE_KEY_PREFIX}all"
  248. all_schedule_ids = redis_client.zrange(schedule_key, 0, -1)
  249. stats = {
  250. "total": 0,
  251. "pending": 0,
  252. "executing": 0,
  253. "completed": 0,
  254. "cancelled": 0,
  255. "failed": 0,
  256. }
  257. for schedule_id_bytes in all_schedule_ids:
  258. schedule_id = schedule_id_bytes.decode("utf-8") if isinstance(schedule_id_bytes, bytes) else schedule_id_bytes
  259. schedule = cls.get_schedule(schedule_id)
  260. if schedule:
  261. # Filter by tenant if specified
  262. if tenant_id and schedule.get("tenant_id") != tenant_id:
  263. continue
  264. stats["total"] += 1
  265. status = schedule.get("status", "unknown")
  266. if status in stats:
  267. stats[status] += 1
  268. return stats