MemoryOS 2.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. memoryos-2.0.3.dist-info/METADATA +418 -0
  2. memoryos-2.0.3.dist-info/RECORD +315 -0
  3. memoryos-2.0.3.dist-info/WHEEL +4 -0
  4. memoryos-2.0.3.dist-info/entry_points.txt +3 -0
  5. memoryos-2.0.3.dist-info/licenses/LICENSE +201 -0
  6. memos/__init__.py +20 -0
  7. memos/api/client.py +571 -0
  8. memos/api/config.py +1018 -0
  9. memos/api/context/dependencies.py +50 -0
  10. memos/api/exceptions.py +53 -0
  11. memos/api/handlers/__init__.py +62 -0
  12. memos/api/handlers/add_handler.py +158 -0
  13. memos/api/handlers/base_handler.py +194 -0
  14. memos/api/handlers/chat_handler.py +1401 -0
  15. memos/api/handlers/component_init.py +388 -0
  16. memos/api/handlers/config_builders.py +190 -0
  17. memos/api/handlers/feedback_handler.py +93 -0
  18. memos/api/handlers/formatters_handler.py +237 -0
  19. memos/api/handlers/memory_handler.py +316 -0
  20. memos/api/handlers/scheduler_handler.py +497 -0
  21. memos/api/handlers/search_handler.py +222 -0
  22. memos/api/handlers/suggestion_handler.py +117 -0
  23. memos/api/mcp_serve.py +614 -0
  24. memos/api/middleware/request_context.py +101 -0
  25. memos/api/product_api.py +38 -0
  26. memos/api/product_models.py +1206 -0
  27. memos/api/routers/__init__.py +1 -0
  28. memos/api/routers/product_router.py +477 -0
  29. memos/api/routers/server_router.py +394 -0
  30. memos/api/server_api.py +44 -0
  31. memos/api/start_api.py +433 -0
  32. memos/chunkers/__init__.py +4 -0
  33. memos/chunkers/base.py +24 -0
  34. memos/chunkers/charactertext_chunker.py +41 -0
  35. memos/chunkers/factory.py +24 -0
  36. memos/chunkers/markdown_chunker.py +62 -0
  37. memos/chunkers/sentence_chunker.py +54 -0
  38. memos/chunkers/simple_chunker.py +50 -0
  39. memos/cli.py +113 -0
  40. memos/configs/__init__.py +0 -0
  41. memos/configs/base.py +82 -0
  42. memos/configs/chunker.py +59 -0
  43. memos/configs/embedder.py +88 -0
  44. memos/configs/graph_db.py +236 -0
  45. memos/configs/internet_retriever.py +100 -0
  46. memos/configs/llm.py +151 -0
  47. memos/configs/mem_agent.py +54 -0
  48. memos/configs/mem_chat.py +81 -0
  49. memos/configs/mem_cube.py +105 -0
  50. memos/configs/mem_os.py +83 -0
  51. memos/configs/mem_reader.py +91 -0
  52. memos/configs/mem_scheduler.py +385 -0
  53. memos/configs/mem_user.py +70 -0
  54. memos/configs/memory.py +324 -0
  55. memos/configs/parser.py +38 -0
  56. memos/configs/reranker.py +18 -0
  57. memos/configs/utils.py +8 -0
  58. memos/configs/vec_db.py +80 -0
  59. memos/context/context.py +355 -0
  60. memos/dependency.py +52 -0
  61. memos/deprecation.py +262 -0
  62. memos/embedders/__init__.py +0 -0
  63. memos/embedders/ark.py +95 -0
  64. memos/embedders/base.py +106 -0
  65. memos/embedders/factory.py +29 -0
  66. memos/embedders/ollama.py +77 -0
  67. memos/embedders/sentence_transformer.py +49 -0
  68. memos/embedders/universal_api.py +51 -0
  69. memos/exceptions.py +30 -0
  70. memos/graph_dbs/__init__.py +0 -0
  71. memos/graph_dbs/base.py +274 -0
  72. memos/graph_dbs/factory.py +27 -0
  73. memos/graph_dbs/item.py +46 -0
  74. memos/graph_dbs/nebular.py +1794 -0
  75. memos/graph_dbs/neo4j.py +1942 -0
  76. memos/graph_dbs/neo4j_community.py +1058 -0
  77. memos/graph_dbs/polardb.py +5446 -0
  78. memos/hello_world.py +97 -0
  79. memos/llms/__init__.py +0 -0
  80. memos/llms/base.py +25 -0
  81. memos/llms/deepseek.py +13 -0
  82. memos/llms/factory.py +38 -0
  83. memos/llms/hf.py +443 -0
  84. memos/llms/hf_singleton.py +114 -0
  85. memos/llms/ollama.py +135 -0
  86. memos/llms/openai.py +222 -0
  87. memos/llms/openai_new.py +198 -0
  88. memos/llms/qwen.py +13 -0
  89. memos/llms/utils.py +14 -0
  90. memos/llms/vllm.py +218 -0
  91. memos/log.py +237 -0
  92. memos/mem_agent/base.py +19 -0
  93. memos/mem_agent/deepsearch_agent.py +391 -0
  94. memos/mem_agent/factory.py +36 -0
  95. memos/mem_chat/__init__.py +0 -0
  96. memos/mem_chat/base.py +30 -0
  97. memos/mem_chat/factory.py +21 -0
  98. memos/mem_chat/simple.py +200 -0
  99. memos/mem_cube/__init__.py +0 -0
  100. memos/mem_cube/base.py +30 -0
  101. memos/mem_cube/general.py +240 -0
  102. memos/mem_cube/navie.py +172 -0
  103. memos/mem_cube/utils.py +169 -0
  104. memos/mem_feedback/base.py +15 -0
  105. memos/mem_feedback/feedback.py +1192 -0
  106. memos/mem_feedback/simple_feedback.py +40 -0
  107. memos/mem_feedback/utils.py +230 -0
  108. memos/mem_os/client.py +5 -0
  109. memos/mem_os/core.py +1203 -0
  110. memos/mem_os/main.py +582 -0
  111. memos/mem_os/product.py +1608 -0
  112. memos/mem_os/product_server.py +455 -0
  113. memos/mem_os/utils/default_config.py +359 -0
  114. memos/mem_os/utils/format_utils.py +1403 -0
  115. memos/mem_os/utils/reference_utils.py +162 -0
  116. memos/mem_reader/__init__.py +0 -0
  117. memos/mem_reader/base.py +47 -0
  118. memos/mem_reader/factory.py +53 -0
  119. memos/mem_reader/memory.py +298 -0
  120. memos/mem_reader/multi_modal_struct.py +965 -0
  121. memos/mem_reader/read_multi_modal/__init__.py +43 -0
  122. memos/mem_reader/read_multi_modal/assistant_parser.py +311 -0
  123. memos/mem_reader/read_multi_modal/base.py +273 -0
  124. memos/mem_reader/read_multi_modal/file_content_parser.py +826 -0
  125. memos/mem_reader/read_multi_modal/image_parser.py +359 -0
  126. memos/mem_reader/read_multi_modal/multi_modal_parser.py +252 -0
  127. memos/mem_reader/read_multi_modal/string_parser.py +139 -0
  128. memos/mem_reader/read_multi_modal/system_parser.py +327 -0
  129. memos/mem_reader/read_multi_modal/text_content_parser.py +131 -0
  130. memos/mem_reader/read_multi_modal/tool_parser.py +210 -0
  131. memos/mem_reader/read_multi_modal/user_parser.py +218 -0
  132. memos/mem_reader/read_multi_modal/utils.py +358 -0
  133. memos/mem_reader/simple_struct.py +912 -0
  134. memos/mem_reader/strategy_struct.py +163 -0
  135. memos/mem_reader/utils.py +157 -0
  136. memos/mem_scheduler/__init__.py +0 -0
  137. memos/mem_scheduler/analyzer/__init__.py +0 -0
  138. memos/mem_scheduler/analyzer/api_analyzer.py +714 -0
  139. memos/mem_scheduler/analyzer/eval_analyzer.py +219 -0
  140. memos/mem_scheduler/analyzer/mos_for_test_scheduler.py +571 -0
  141. memos/mem_scheduler/analyzer/scheduler_for_eval.py +280 -0
  142. memos/mem_scheduler/base_scheduler.py +1319 -0
  143. memos/mem_scheduler/general_modules/__init__.py +0 -0
  144. memos/mem_scheduler/general_modules/api_misc.py +137 -0
  145. memos/mem_scheduler/general_modules/base.py +80 -0
  146. memos/mem_scheduler/general_modules/init_components_for_scheduler.py +425 -0
  147. memos/mem_scheduler/general_modules/misc.py +313 -0
  148. memos/mem_scheduler/general_modules/scheduler_logger.py +389 -0
  149. memos/mem_scheduler/general_modules/task_threads.py +315 -0
  150. memos/mem_scheduler/general_scheduler.py +1495 -0
  151. memos/mem_scheduler/memory_manage_modules/__init__.py +5 -0
  152. memos/mem_scheduler/memory_manage_modules/memory_filter.py +306 -0
  153. memos/mem_scheduler/memory_manage_modules/retriever.py +547 -0
  154. memos/mem_scheduler/monitors/__init__.py +0 -0
  155. memos/mem_scheduler/monitors/dispatcher_monitor.py +366 -0
  156. memos/mem_scheduler/monitors/general_monitor.py +394 -0
  157. memos/mem_scheduler/monitors/task_schedule_monitor.py +254 -0
  158. memos/mem_scheduler/optimized_scheduler.py +410 -0
  159. memos/mem_scheduler/orm_modules/__init__.py +0 -0
  160. memos/mem_scheduler/orm_modules/api_redis_model.py +518 -0
  161. memos/mem_scheduler/orm_modules/base_model.py +729 -0
  162. memos/mem_scheduler/orm_modules/monitor_models.py +261 -0
  163. memos/mem_scheduler/orm_modules/redis_model.py +699 -0
  164. memos/mem_scheduler/scheduler_factory.py +23 -0
  165. memos/mem_scheduler/schemas/__init__.py +0 -0
  166. memos/mem_scheduler/schemas/analyzer_schemas.py +52 -0
  167. memos/mem_scheduler/schemas/api_schemas.py +233 -0
  168. memos/mem_scheduler/schemas/general_schemas.py +55 -0
  169. memos/mem_scheduler/schemas/message_schemas.py +173 -0
  170. memos/mem_scheduler/schemas/monitor_schemas.py +406 -0
  171. memos/mem_scheduler/schemas/task_schemas.py +132 -0
  172. memos/mem_scheduler/task_schedule_modules/__init__.py +0 -0
  173. memos/mem_scheduler/task_schedule_modules/dispatcher.py +740 -0
  174. memos/mem_scheduler/task_schedule_modules/local_queue.py +247 -0
  175. memos/mem_scheduler/task_schedule_modules/orchestrator.py +74 -0
  176. memos/mem_scheduler/task_schedule_modules/redis_queue.py +1385 -0
  177. memos/mem_scheduler/task_schedule_modules/task_queue.py +162 -0
  178. memos/mem_scheduler/utils/__init__.py +0 -0
  179. memos/mem_scheduler/utils/api_utils.py +77 -0
  180. memos/mem_scheduler/utils/config_utils.py +100 -0
  181. memos/mem_scheduler/utils/db_utils.py +50 -0
  182. memos/mem_scheduler/utils/filter_utils.py +176 -0
  183. memos/mem_scheduler/utils/metrics.py +125 -0
  184. memos/mem_scheduler/utils/misc_utils.py +290 -0
  185. memos/mem_scheduler/utils/monitor_event_utils.py +67 -0
  186. memos/mem_scheduler/utils/status_tracker.py +229 -0
  187. memos/mem_scheduler/webservice_modules/__init__.py +0 -0
  188. memos/mem_scheduler/webservice_modules/rabbitmq_service.py +485 -0
  189. memos/mem_scheduler/webservice_modules/redis_service.py +380 -0
  190. memos/mem_user/factory.py +94 -0
  191. memos/mem_user/mysql_persistent_user_manager.py +271 -0
  192. memos/mem_user/mysql_user_manager.py +502 -0
  193. memos/mem_user/persistent_factory.py +98 -0
  194. memos/mem_user/persistent_user_manager.py +260 -0
  195. memos/mem_user/redis_persistent_user_manager.py +225 -0
  196. memos/mem_user/user_manager.py +488 -0
  197. memos/memories/__init__.py +0 -0
  198. memos/memories/activation/__init__.py +0 -0
  199. memos/memories/activation/base.py +42 -0
  200. memos/memories/activation/item.py +56 -0
  201. memos/memories/activation/kv.py +292 -0
  202. memos/memories/activation/vllmkv.py +219 -0
  203. memos/memories/base.py +19 -0
  204. memos/memories/factory.py +42 -0
  205. memos/memories/parametric/__init__.py +0 -0
  206. memos/memories/parametric/base.py +19 -0
  207. memos/memories/parametric/item.py +11 -0
  208. memos/memories/parametric/lora.py +41 -0
  209. memos/memories/textual/__init__.py +0 -0
  210. memos/memories/textual/base.py +92 -0
  211. memos/memories/textual/general.py +236 -0
  212. memos/memories/textual/item.py +304 -0
  213. memos/memories/textual/naive.py +187 -0
  214. memos/memories/textual/prefer_text_memory/__init__.py +0 -0
  215. memos/memories/textual/prefer_text_memory/adder.py +504 -0
  216. memos/memories/textual/prefer_text_memory/config.py +106 -0
  217. memos/memories/textual/prefer_text_memory/extractor.py +221 -0
  218. memos/memories/textual/prefer_text_memory/factory.py +85 -0
  219. memos/memories/textual/prefer_text_memory/retrievers.py +177 -0
  220. memos/memories/textual/prefer_text_memory/spliter.py +132 -0
  221. memos/memories/textual/prefer_text_memory/utils.py +93 -0
  222. memos/memories/textual/preference.py +344 -0
  223. memos/memories/textual/simple_preference.py +161 -0
  224. memos/memories/textual/simple_tree.py +69 -0
  225. memos/memories/textual/tree.py +459 -0
  226. memos/memories/textual/tree_text_memory/__init__.py +0 -0
  227. memos/memories/textual/tree_text_memory/organize/__init__.py +0 -0
  228. memos/memories/textual/tree_text_memory/organize/handler.py +184 -0
  229. memos/memories/textual/tree_text_memory/organize/manager.py +518 -0
  230. memos/memories/textual/tree_text_memory/organize/relation_reason_detector.py +238 -0
  231. memos/memories/textual/tree_text_memory/organize/reorganizer.py +622 -0
  232. memos/memories/textual/tree_text_memory/retrieve/__init__.py +0 -0
  233. memos/memories/textual/tree_text_memory/retrieve/advanced_searcher.py +364 -0
  234. memos/memories/textual/tree_text_memory/retrieve/bm25_util.py +186 -0
  235. memos/memories/textual/tree_text_memory/retrieve/bochasearch.py +419 -0
  236. memos/memories/textual/tree_text_memory/retrieve/internet_retriever.py +270 -0
  237. memos/memories/textual/tree_text_memory/retrieve/internet_retriever_factory.py +102 -0
  238. memos/memories/textual/tree_text_memory/retrieve/reasoner.py +61 -0
  239. memos/memories/textual/tree_text_memory/retrieve/recall.py +497 -0
  240. memos/memories/textual/tree_text_memory/retrieve/reranker.py +111 -0
  241. memos/memories/textual/tree_text_memory/retrieve/retrieval_mid_structs.py +16 -0
  242. memos/memories/textual/tree_text_memory/retrieve/retrieve_utils.py +472 -0
  243. memos/memories/textual/tree_text_memory/retrieve/searcher.py +848 -0
  244. memos/memories/textual/tree_text_memory/retrieve/task_goal_parser.py +135 -0
  245. memos/memories/textual/tree_text_memory/retrieve/utils.py +54 -0
  246. memos/memories/textual/tree_text_memory/retrieve/xinyusearch.py +387 -0
  247. memos/memos_tools/dinding_report_bot.py +453 -0
  248. memos/memos_tools/lockfree_dict.py +120 -0
  249. memos/memos_tools/notification_service.py +44 -0
  250. memos/memos_tools/notification_utils.py +142 -0
  251. memos/memos_tools/singleton.py +174 -0
  252. memos/memos_tools/thread_safe_dict.py +310 -0
  253. memos/memos_tools/thread_safe_dict_segment.py +382 -0
  254. memos/multi_mem_cube/__init__.py +0 -0
  255. memos/multi_mem_cube/composite_cube.py +86 -0
  256. memos/multi_mem_cube/single_cube.py +874 -0
  257. memos/multi_mem_cube/views.py +54 -0
  258. memos/parsers/__init__.py +0 -0
  259. memos/parsers/base.py +15 -0
  260. memos/parsers/factory.py +21 -0
  261. memos/parsers/markitdown.py +28 -0
  262. memos/reranker/__init__.py +4 -0
  263. memos/reranker/base.py +25 -0
  264. memos/reranker/concat.py +103 -0
  265. memos/reranker/cosine_local.py +102 -0
  266. memos/reranker/factory.py +72 -0
  267. memos/reranker/http_bge.py +324 -0
  268. memos/reranker/http_bge_strategy.py +327 -0
  269. memos/reranker/noop.py +19 -0
  270. memos/reranker/strategies/__init__.py +4 -0
  271. memos/reranker/strategies/base.py +61 -0
  272. memos/reranker/strategies/concat_background.py +94 -0
  273. memos/reranker/strategies/concat_docsource.py +110 -0
  274. memos/reranker/strategies/dialogue_common.py +109 -0
  275. memos/reranker/strategies/factory.py +31 -0
  276. memos/reranker/strategies/single_turn.py +107 -0
  277. memos/reranker/strategies/singleturn_outmem.py +98 -0
  278. memos/settings.py +10 -0
  279. memos/templates/__init__.py +0 -0
  280. memos/templates/advanced_search_prompts.py +211 -0
  281. memos/templates/cloud_service_prompt.py +107 -0
  282. memos/templates/instruction_completion.py +66 -0
  283. memos/templates/mem_agent_prompts.py +85 -0
  284. memos/templates/mem_feedback_prompts.py +822 -0
  285. memos/templates/mem_reader_prompts.py +1096 -0
  286. memos/templates/mem_reader_strategy_prompts.py +238 -0
  287. memos/templates/mem_scheduler_prompts.py +626 -0
  288. memos/templates/mem_search_prompts.py +93 -0
  289. memos/templates/mos_prompts.py +403 -0
  290. memos/templates/prefer_complete_prompt.py +735 -0
  291. memos/templates/tool_mem_prompts.py +139 -0
  292. memos/templates/tree_reorganize_prompts.py +230 -0
  293. memos/types/__init__.py +34 -0
  294. memos/types/general_types.py +151 -0
  295. memos/types/openai_chat_completion_types/__init__.py +15 -0
  296. memos/types/openai_chat_completion_types/chat_completion_assistant_message_param.py +56 -0
  297. memos/types/openai_chat_completion_types/chat_completion_content_part_image_param.py +27 -0
  298. memos/types/openai_chat_completion_types/chat_completion_content_part_input_audio_param.py +23 -0
  299. memos/types/openai_chat_completion_types/chat_completion_content_part_param.py +43 -0
  300. memos/types/openai_chat_completion_types/chat_completion_content_part_refusal_param.py +16 -0
  301. memos/types/openai_chat_completion_types/chat_completion_content_part_text_param.py +16 -0
  302. memos/types/openai_chat_completion_types/chat_completion_message_custom_tool_call_param.py +27 -0
  303. memos/types/openai_chat_completion_types/chat_completion_message_function_tool_call_param.py +32 -0
  304. memos/types/openai_chat_completion_types/chat_completion_message_param.py +18 -0
  305. memos/types/openai_chat_completion_types/chat_completion_message_tool_call_union_param.py +15 -0
  306. memos/types/openai_chat_completion_types/chat_completion_system_message_param.py +36 -0
  307. memos/types/openai_chat_completion_types/chat_completion_tool_message_param.py +30 -0
  308. memos/types/openai_chat_completion_types/chat_completion_user_message_param.py +34 -0
  309. memos/utils.py +123 -0
  310. memos/vec_dbs/__init__.py +0 -0
  311. memos/vec_dbs/base.py +117 -0
  312. memos/vec_dbs/factory.py +23 -0
  313. memos/vec_dbs/item.py +50 -0
  314. memos/vec_dbs/milvus.py +654 -0
  315. memos/vec_dbs/qdrant.py +355 -0
@@ -0,0 +1,729 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import time
5
+
6
+ from abc import abstractmethod
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any, TypeVar
10
+
11
+ from sqlalchemy import Boolean, Column, DateTime, String, Text, and_, create_engine
12
+ from sqlalchemy.engine import Engine
13
+ from sqlalchemy.orm import Session, declarative_base, sessionmaker
14
+
15
+ from memos.log import get_logger
16
+ from memos.mem_user.user_manager import UserManager
17
+
18
+
19
+ class DatabaseError(Exception):
20
+ """Exception raised for database-related errors"""
21
+
22
+
23
+ T = TypeVar("T") # The model type (MemoryMonitorManager, QueryMonitorManager, etc.)
24
+ ORM = TypeVar("ORM") # The ORM model type
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ Base = declarative_base()
29
+
30
+
31
+ class LockableORM(Base):
32
+ """Abstract base class for lockable ORM models"""
33
+
34
+ __abstract__ = True
35
+
36
+ # Primary composite key
37
+ user_id = Column(String(255), primary_key=True)
38
+ mem_cube_id = Column(String(255), primary_key=True)
39
+
40
+ # Serialized data
41
+ serialized_data = Column(Text, nullable=False)
42
+
43
+ lock_acquired = Column(Boolean, default=False)
44
+ lock_expiry = Column(DateTime, nullable=True)
45
+
46
+ # Version control tag (0-255, cycles back to 0)
47
+ version_control = Column(String(3), default="0")
48
+
49
+
50
+ class BaseDBManager(UserManager):
51
+ """Abstract base class for database managers with proper locking mechanism
52
+
53
+ This class provides a foundation for managing database operations with
54
+ distributed locking capabilities to ensure data consistency across
55
+ multiple processes or threads.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ engine: Engine,
61
+ user_id: str | None = None,
62
+ mem_cube_id: str | None = None,
63
+ lock_timeout: int = 10,
64
+ ):
65
+ """Initialize the database manager
66
+
67
+ Args:
68
+ engine: SQLAlchemy engine instance
69
+ user_id: Unique identifier for the user
70
+ mem_cube_id: Unique identifier for the memory cube
71
+ lock_timeout: Timeout in seconds for lock acquisition
72
+ """
73
+ # Do not use super init func to avoid UserManager initialization
74
+ self.engine = engine
75
+ self.SessionLocal = None
76
+ self.obj = None
77
+ self.user_id = user_id
78
+ self.mem_cube_id = mem_cube_id
79
+ self.lock_timeout = lock_timeout
80
+ self.last_version_control = None # Track the last version control tag
81
+
82
+ self.init_manager(
83
+ engine=self.engine,
84
+ user_id=self.user_id,
85
+ mem_cube_id=self.mem_cube_id,
86
+ )
87
+
88
+ @property
89
+ @abstractmethod
90
+ def orm_class(self) -> type[LockableORM]:
91
+ """Return the ORM model class for this manager
92
+
93
+ Returns:
94
+ The SQLAlchemy ORM model class
95
+ """
96
+ raise NotImplementedError()
97
+
98
+ @property
99
+ @abstractmethod
100
+ def obj_class(self) -> Any:
101
+ """Return the business object class for this manager
102
+
103
+ Returns:
104
+ The business logic object class
105
+ """
106
+ raise NotImplementedError()
107
+
108
+ def init_manager(self, engine: Engine, user_id: str, mem_cube_id: str):
109
+ """Initialize the database manager with engine and identifiers
110
+
111
+ Args:
112
+ engine: SQLAlchemy engine instance
113
+ user_id: User identifier
114
+ mem_cube_id: Memory cube identifier
115
+
116
+ Raises:
117
+ RuntimeError: If database initialization fails
118
+ """
119
+ try:
120
+ self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
121
+
122
+ logger.info(f"{self.orm_class} initialized with engine {engine}")
123
+ logger.info(f"Set user_id to {user_id}; mem_cube_id to {mem_cube_id}")
124
+
125
+ # Create tables if they don't exist
126
+ self._create_table_with_error_handling(engine)
127
+ logger.debug(f"Successfully created/verified table for {self.orm_class.__tablename__}")
128
+
129
+ except Exception as e:
130
+ error_msg = f"Failed to initialize database manager for {self.orm_class.__name__}: {e}"
131
+ logger.error(error_msg, exc_info=True)
132
+ raise RuntimeError(error_msg) from e
133
+
134
+ def _create_table_with_error_handling(self, engine: Engine):
135
+ """Create table with proper error handling for common database conflicts
136
+
137
+ Args:
138
+ engine: SQLAlchemy engine instance
139
+
140
+ Raises:
141
+ RuntimeError: If table creation fails after handling known issues
142
+ """
143
+ try:
144
+ self.orm_class.__table__.create(bind=engine, checkfirst=True)
145
+ except Exception as e:
146
+ error_str = str(e).lower()
147
+
148
+ # Handle common SQLite index already exists error
149
+ if "index" in error_str and "already exists" in error_str:
150
+ logger.warning(f"Index already exists for {self.orm_class.__tablename__}: {e}")
151
+ # Try to create just the table without indexes
152
+ try:
153
+ # Create a temporary table definition without indexes
154
+ table_without_indexes = self.orm_class.__table__.copy()
155
+ table_without_indexes._indexes.clear() # Remove all indexes
156
+ table_without_indexes.create(bind=engine, checkfirst=True)
157
+ logger.info(
158
+ f"Created table {self.orm_class.__tablename__} without problematic indexes"
159
+ )
160
+ except Exception as table_error:
161
+ logger.error(f"Failed to create table even without indexes: {table_error}")
162
+ raise
163
+ else:
164
+ # Re-raise other types of errors
165
+ raise
166
+
167
+ def _get_session(self) -> Session:
168
+ """Get a database session"""
169
+ return self.SessionLocal()
170
+
171
+ def _serialize(self, obj: T) -> str:
172
+ """Serialize the object to JSON"""
173
+ if hasattr(obj, "to_json"):
174
+ return obj.to_json()
175
+ return json.dumps(obj)
176
+
177
+ def _deserialize(self, data: str, model_class: type[T]) -> T:
178
+ """Deserialize JSON to object"""
179
+ if hasattr(model_class, "from_json"):
180
+ return model_class.from_json(data)
181
+ return json.loads(data)
182
+
183
+ def acquire_lock(self, block: bool = True, **kwargs) -> bool:
184
+ """Acquire a distributed lock for the current user and memory cube
185
+
186
+ Args:
187
+ block: Whether to block until lock is acquired
188
+ **kwargs: Additional filter criteria
189
+
190
+ Returns:
191
+ True if lock was acquired, False otherwise
192
+ """
193
+ session = self._get_session()
194
+
195
+ try:
196
+ now = datetime.now()
197
+ expiry = now + timedelta(seconds=self.lock_timeout)
198
+
199
+ # Query for existing record with lock information
200
+ query = (
201
+ session.query(self.orm_class)
202
+ .filter_by(**kwargs)
203
+ .filter(
204
+ and_(
205
+ self.orm_class.user_id == self.user_id,
206
+ self.orm_class.mem_cube_id == self.mem_cube_id,
207
+ )
208
+ )
209
+ )
210
+
211
+ record = query.first()
212
+
213
+ # If no record exists, lock can be acquired immediately
214
+ if record is None:
215
+ logger.info(
216
+ f"No existing record found for {self.user_id}/{self.mem_cube_id}, lock can be acquired"
217
+ )
218
+ return True
219
+
220
+ # Check if lock is currently held and not expired
221
+ if record.lock_acquired and record.lock_expiry and now < record.lock_expiry:
222
+ if block:
223
+ # Wait for lock to be released or expire
224
+ logger.info(
225
+ f"Waiting for lock to be released for {self.user_id}/{self.mem_cube_id}"
226
+ )
227
+ while record.lock_acquired and record.lock_expiry and now < record.lock_expiry:
228
+ time.sleep(0.1) # Small delay before retry
229
+ session.refresh(record) # Refresh record state
230
+ now = datetime.now()
231
+ else:
232
+ logger.warning(
233
+ f"Lock is held for {self.user_id}/{self.mem_cube_id}, cannot acquire"
234
+ )
235
+ return False
236
+
237
+ # Acquire the lock by updating the record
238
+ query.update(
239
+ {
240
+ "lock_acquired": True,
241
+ "lock_expiry": expiry,
242
+ },
243
+ synchronize_session=False,
244
+ )
245
+
246
+ session.commit()
247
+ logger.info(f"Lock acquired for {self.user_id}/{self.mem_cube_id}")
248
+ return True
249
+
250
+ except Exception as e:
251
+ session.rollback()
252
+ logger.error(f"Failed to acquire lock for {self.user_id}/{self.mem_cube_id}: {e}")
253
+ return False
254
+ finally:
255
+ session.close()
256
+
257
+ def release_locks(self, user_id: str, mem_cube_id: str, **kwargs):
258
+ """Release locks for the specified user and memory cube
259
+
260
+ Args:
261
+ user_id: User identifier
262
+ mem_cube_id: Memory cube identifier
263
+ **kwargs: Additional filter criteria
264
+ """
265
+ session = self._get_session()
266
+
267
+ try:
268
+ # Update all matching records to release locks
269
+ result = (
270
+ session.query(self.orm_class)
271
+ .filter_by(**kwargs)
272
+ .filter(
273
+ and_(
274
+ self.orm_class.user_id == user_id, self.orm_class.mem_cube_id == mem_cube_id
275
+ )
276
+ )
277
+ .update(
278
+ {
279
+ "lock_acquired": False,
280
+ "lock_expiry": None, # Clear expiry time as well
281
+ },
282
+ synchronize_session=False,
283
+ )
284
+ )
285
+ session.commit()
286
+ logger.info(f"Lock released for {user_id}/{mem_cube_id} (affected {result} records)")
287
+
288
+ except Exception as e:
289
+ session.rollback()
290
+ logger.error(f"Failed to release lock for {user_id}/{mem_cube_id}: {e}")
291
+ finally:
292
+ session.close()
293
+
294
+ def _get_primary_key(self) -> dict[str, Any]:
295
+ """Get the primary key dictionary for the current instance
296
+
297
+ Returns:
298
+ Dictionary containing user_id and mem_cube_id
299
+ """
300
+ return {"user_id": self.user_id, "mem_cube_id": self.mem_cube_id}
301
+
302
+ def _increment_version_control(self, current_tag: str) -> str:
303
+ """Increment the version control tag, cycling from 255 back to 0
304
+
305
+ Args:
306
+ current_tag: Current version control tag as string
307
+
308
+ Returns:
309
+ Next version control tag as string
310
+ """
311
+ try:
312
+ current_value = int(current_tag)
313
+ next_value = (current_value + 1) % 256 # Cycle from 255 back to 0
314
+ return str(next_value)
315
+ except (ValueError, TypeError):
316
+ # If current_tag is invalid, start from 0
317
+ logger.warning(f"Invalid version_control '{current_tag}', resetting to '0'")
318
+ return "0"
319
+
320
+ @abstractmethod
321
+ def merge_items(self, orm_instance, obj_instance, size_limit):
322
+ """Merge items from database with current object instance
323
+
324
+ Args:
325
+ orm_instance: ORM instance from database
326
+ obj_instance: Current business object instance
327
+ size_limit: Maximum number of items to keep after merge
328
+ """
329
+
330
+ def sync_with_orm(self, size_limit: int | None = None) -> None:
331
+ """
332
+ Synchronize data between the database and the business object.
333
+
334
+ This method performs a three-step synchronization process:
335
+ 1. Acquire lock and get existing data from database
336
+ 2. Merge database items with current object items
337
+ 3. Write merged data back to database and release lock
338
+
339
+ Args:
340
+ size_limit: Optional maximum number of items to keep after synchronization.
341
+ If specified, only the most recent items will be retained.
342
+ """
343
+ logger.info(
344
+ f"Starting sync_with_orm for {self.user_id}/{self.mem_cube_id} with size_limit={size_limit}"
345
+ )
346
+ user_id = self.user_id
347
+ mem_cube_id = self.mem_cube_id
348
+
349
+ session = self._get_session()
350
+
351
+ try:
352
+ # Acquire lock before any database operations
353
+ lock_status = self.acquire_lock(block=True)
354
+ if not lock_status:
355
+ logger.error("Failed to acquire lock for synchronization")
356
+ return
357
+
358
+ # 1. Get existing data from database
359
+ orm_instance = (
360
+ session.query(self.orm_class)
361
+ .filter_by(user_id=user_id, mem_cube_id=mem_cube_id)
362
+ .first()
363
+ )
364
+
365
+ # If no existing record, create a new one
366
+ if orm_instance is None:
367
+ if self.obj is None:
368
+ logger.warning("No object to synchronize and no existing database record")
369
+ return
370
+
371
+ orm_instance = self.orm_class(
372
+ user_id=user_id,
373
+ mem_cube_id=mem_cube_id,
374
+ serialized_data=self.obj.to_json(),
375
+ version_control="0", # Start with tag 0 for new records
376
+ )
377
+ logger.info(
378
+ "No existing ORM instance found. Created a new one. "
379
+ "Note: size_limit was not applied because there is no existing data to merge."
380
+ )
381
+ session.add(orm_instance)
382
+ session.commit()
383
+ # Update last_version_control for new record
384
+ self.last_version_control = "0"
385
+ return
386
+
387
+ # 2. Check version control and merge data from database with current object
388
+ if self.obj is not None:
389
+ current_db_tag = orm_instance.version_control
390
+ new_tag = self._increment_version_control(current_db_tag)
391
+ # Check if this is the first sync (last_version_control is None)
392
+ if self.last_version_control is None:
393
+ # First sync, increment version and perform merge
394
+ logger.info(
395
+ f"First sync, incrementing version from {current_db_tag} to {new_tag} for {self.user_id}/{self.mem_cube_id}"
396
+ )
397
+ elif current_db_tag == self.last_version_control:
398
+ logger.info(
399
+ f"Version control unchanged ({current_db_tag}), directly update {self.user_id}/{self.mem_cube_id}"
400
+ )
401
+ else:
402
+ # Version control has changed, increment it and perform merge
403
+ logger.info(
404
+ f"Version control changed from {self.last_version_control} to {current_db_tag}, incrementing to {new_tag} for {self.user_id}/{self.mem_cube_id}"
405
+ )
406
+ try:
407
+ self.merge_items(
408
+ orm_instance=orm_instance, obj_instance=self.obj, size_limit=size_limit
409
+ )
410
+ except Exception as merge_error:
411
+ logger.error(f"Error during merge_items: {merge_error}", exc_info=True)
412
+ logger.warning("Continuing with current object data without merge")
413
+
414
+ # 3. Write merged data back to database
415
+ orm_instance.serialized_data = self.obj.to_json()
416
+ orm_instance.version_control = new_tag
417
+ logger.info(f"Updated serialized_data for {self.user_id}/{self.mem_cube_id}")
418
+
419
+ # Update last_version_control to current value
420
+ self.last_version_control = orm_instance.version_control
421
+ else:
422
+ logger.warning("No current object to merge with database data")
423
+
424
+ session.commit()
425
+ logger.info(f"Synchronization completed for {self.user_id}/{self.mem_cube_id}")
426
+
427
+ except Exception as e:
428
+ session.rollback()
429
+ logger.error(
430
+ f"Error during synchronization for {user_id}/{mem_cube_id}: {e}", exc_info=True
431
+ )
432
+ finally:
433
+ # Always release locks and close session
434
+ self.release_locks(user_id=user_id, mem_cube_id=mem_cube_id)
435
+ session.close()
436
+
437
+ def save_to_db(self, obj_instance) -> None:
438
+ """Save the current state of the business object to the database
439
+
440
+ Args:
441
+ obj_instance: The business object instance to save
442
+ """
443
+ user_id = self.user_id
444
+ mem_cube_id = self.mem_cube_id
445
+
446
+ session = self._get_session()
447
+
448
+ try:
449
+ # Acquire lock before database operations
450
+ lock_status = self.acquire_lock(block=True)
451
+ if not lock_status:
452
+ logger.error("Failed to acquire lock for saving to database")
453
+ return
454
+
455
+ # Check if record already exists
456
+ orm_instance = (
457
+ session.query(self.orm_class)
458
+ .filter_by(user_id=user_id, mem_cube_id=mem_cube_id)
459
+ .first()
460
+ )
461
+
462
+ if orm_instance is None:
463
+ # Create new record
464
+ orm_instance = self.orm_class(
465
+ user_id=user_id,
466
+ mem_cube_id=mem_cube_id,
467
+ serialized_data=obj_instance.to_json(),
468
+ version_control="0", # Start with version 0 for new records
469
+ )
470
+ session.add(orm_instance)
471
+ logger.info(f"Created new database record for {user_id}/{mem_cube_id}")
472
+ # Update last_version_control for new record
473
+ self.last_version_control = "0"
474
+ else:
475
+ # Update existing record with version control
476
+ current_version = orm_instance.version_control
477
+ new_version = self._increment_version_control(current_version)
478
+ orm_instance.serialized_data = obj_instance.to_json()
479
+ orm_instance.version_control = new_version
480
+ logger.info(
481
+ f"Updated existing database record for {user_id}/{mem_cube_id} with version {new_version}"
482
+ )
483
+ # Update last_version_control
484
+ self.last_version_control = new_version
485
+
486
+ session.commit()
487
+
488
+ except Exception as e:
489
+ session.rollback()
490
+ logger.error(f"Error saving to database for {user_id}/{mem_cube_id}: {e}")
491
+ finally:
492
+ # Always release locks and close session
493
+ self.release_locks(user_id=user_id, mem_cube_id=mem_cube_id)
494
+ session.close()
495
+
496
+ def load_from_db(self, acquire_lock: bool = False):
497
+ """Load the business object from the database
498
+
499
+ Args:
500
+ acquire_lock: Whether to acquire a lock during the load operation
501
+
502
+ Returns:
503
+ The deserialized business object instance, or None if not found
504
+ """
505
+ user_id = self.user_id
506
+ mem_cube_id = self.mem_cube_id
507
+
508
+ session = self._get_session()
509
+
510
+ try:
511
+ if acquire_lock:
512
+ lock_status = self.acquire_lock(block=True)
513
+ if not lock_status:
514
+ logger.error("Failed to acquire lock for loading from database")
515
+ return None
516
+
517
+ # Query for the database record
518
+ orm_instance = (
519
+ session.query(self.orm_class)
520
+ .filter_by(user_id=user_id, mem_cube_id=mem_cube_id)
521
+ .first()
522
+ )
523
+
524
+ if orm_instance is None:
525
+ logger.info(f"No database record found for {user_id}/{mem_cube_id}")
526
+ return None
527
+
528
+ # Deserialize the business object from JSON
529
+ db_instance = self.obj_class.from_json(orm_instance.serialized_data)
530
+ # Update last_version_control to track the loaded version
531
+ self.last_version_control = orm_instance.version_control
532
+ logger.info(
533
+ f"Successfully loaded object from database for {user_id}/{mem_cube_id} with version {orm_instance.version_control}"
534
+ )
535
+
536
+ return db_instance
537
+
538
+ except Exception as e:
539
+ logger.error(f"Error loading from database for {user_id}/{mem_cube_id}: {e}")
540
+ return None
541
+ finally:
542
+ if acquire_lock:
543
+ self.release_locks(user_id=user_id, mem_cube_id=mem_cube_id)
544
+ session.close()
545
+
546
+ def close(self):
547
+ """Close the database manager and clean up resources
548
+
549
+ This method releases any held locks and disposes of the database engine.
550
+ Should be called when the manager is no longer needed.
551
+ """
552
+ try:
553
+ # Release any locks held by this manager instance
554
+ if self.user_id and self.mem_cube_id:
555
+ self.release_locks(user_id=self.user_id, mem_cube_id=self.mem_cube_id)
556
+ logger.info(f"Released locks for {self.user_id}/{self.mem_cube_id}")
557
+
558
+ # Dispose of the engine to close all connections
559
+ if self.engine:
560
+ self.engine.dispose()
561
+ logger.info("Database engine disposed")
562
+
563
+ except Exception as e:
564
+ logger.error(f"Error during close operation: {e}")
565
+
566
+ @staticmethod
567
+ def create_default_sqlite_engine() -> Engine:
568
+ """Create SQLAlchemy engine with default database path
569
+
570
+ Returns:
571
+ SQLAlchemy Engine instance using default scheduler_orm.db
572
+ """
573
+ temp_dir = tempfile.mkdtemp()
574
+ db_path = os.path.join(temp_dir, "test_scheduler_orm.db")
575
+
576
+ # Clean up any existing file (though unlikely)
577
+ if os.path.exists(db_path):
578
+ os.remove(db_path)
579
+ # Remove the temp directory if still exists (should be empty)
580
+ if os.path.exists(temp_dir) and not os.listdir(temp_dir):
581
+ os.rmdir(temp_dir)
582
+
583
+ # Ensure parent directory exists (re-create in case rmdir removed it)
584
+ parent_dir = Path(db_path).parent
585
+ parent_dir.mkdir(parents=True, exist_ok=True)
586
+
587
+ # Log the creation of the default engine with database path
588
+ logger.info(
589
+ "Creating default SQLAlchemy engine with temporary SQLite database at: %s", db_path
590
+ )
591
+
592
+ return create_engine(f"sqlite:///{db_path}", echo=False)
593
+
594
+ @staticmethod
595
+ def create_engine_from_db_path(db_path: str) -> Engine:
596
+ """Create SQLAlchemy engine from database path
597
+
598
+ Args:
599
+ db_path: Path to database file
600
+
601
+ Returns:
602
+ SQLAlchemy Engine instance
603
+ """
604
+ # Ensure the directory exists
605
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
606
+
607
+ return create_engine(f"sqlite:///{db_path}", echo=False)
608
+
609
+ @staticmethod
610
+ def create_mysql_db_path(
611
+ host: str = "localhost",
612
+ port: int = 3306,
613
+ username: str = "root",
614
+ password: str = "",
615
+ database: str = "scheduler_orm",
616
+ charset: str = "utf8mb4",
617
+ ) -> str:
618
+ """Create MySQL database connection URL
619
+
620
+ Args:
621
+ host: MySQL server hostname
622
+ port: MySQL server port
623
+ username: Database username
624
+ password: Database password (optional)
625
+ database: Database name
626
+ charset: Character set encoding
627
+
628
+ Returns:
629
+ MySQL connection URL string
630
+ """
631
+ # Build MySQL connection URL with proper formatting
632
+ if password:
633
+ db_path = (
634
+ f"mysql+pymysql://{username}:{password}@{host}:{port}/{database}?charset={charset}"
635
+ )
636
+ else:
637
+ db_path = f"mysql+pymysql://{username}@{host}:{port}/{database}?charset={charset}"
638
+ return db_path
639
+
640
+ @staticmethod
641
+ def load_mysql_engine_from_env(env_file_path: str | None = None) -> Engine | None:
642
+ """Load MySQL engine from environment variables
643
+
644
+ Args:
645
+ env_file_path: Path to .env file (optional, defaults to loading from current environment)
646
+
647
+ Returns:
648
+ SQLAlchemy Engine instance configured for MySQL
649
+
650
+ Raises:
651
+ DatabaseError: If required environment variables are missing or connection fails
652
+ """
653
+ # Load environment variables from file if provided
654
+ if env_file_path:
655
+ if os.path.exists(env_file_path):
656
+ from dotenv import load_dotenv
657
+
658
+ load_dotenv(env_file_path)
659
+ logger.info(f"Loaded environment variables from {env_file_path}")
660
+ else:
661
+ logger.warning(
662
+ f"Environment file not found: {env_file_path}, using current environment variables"
663
+ )
664
+ else:
665
+ logger.info("Using current environment variables (no env_file_path provided)")
666
+
667
+ # Get MySQL configuration from environment variables
668
+ mysql_host = os.getenv("MYSQL_HOST")
669
+ mysql_port_str = os.getenv("MYSQL_PORT")
670
+ mysql_username = os.getenv("MYSQL_USERNAME")
671
+ mysql_password = os.getenv("MYSQL_PASSWORD")
672
+ mysql_database = os.getenv("MYSQL_DATABASE")
673
+ mysql_charset = os.getenv("MYSQL_CHARSET")
674
+
675
+ # Check required environment variables
676
+ required_vars = {
677
+ "MYSQL_HOST": mysql_host,
678
+ "MYSQL_USERNAME": mysql_username,
679
+ "MYSQL_PASSWORD": mysql_password,
680
+ "MYSQL_DATABASE": mysql_database,
681
+ }
682
+
683
+ missing_vars = [var for var, value in required_vars.items() if not value]
684
+ if missing_vars:
685
+ error_msg = f"Missing required MySQL environment variables: {', '.join(missing_vars)}"
686
+ logger.error(error_msg)
687
+ return None
688
+
689
+ # Parse port with validation
690
+ try:
691
+ mysql_port = int(mysql_port_str) if mysql_port_str else 3306
692
+ except ValueError:
693
+ error_msg = f"Invalid MYSQL_PORT value: {mysql_port_str}. Must be a valid integer."
694
+ logger.error(error_msg)
695
+ return None
696
+
697
+ # Set default charset if not provided
698
+ if not mysql_charset:
699
+ mysql_charset = "utf8mb4"
700
+
701
+ # Create MySQL connection URL
702
+ db_url = BaseDBManager.create_mysql_db_path(
703
+ host=mysql_host,
704
+ port=mysql_port,
705
+ username=mysql_username,
706
+ password=mysql_password,
707
+ database=mysql_database,
708
+ charset=mysql_charset,
709
+ )
710
+
711
+ try:
712
+ # Create and test the engine
713
+ engine = create_engine(db_url, echo=False)
714
+
715
+ # Test connection
716
+ with engine.connect() as conn:
717
+ from sqlalchemy import text
718
+
719
+ conn.execute(text("SELECT 1"))
720
+
721
+ logger.info(
722
+ f"Successfully created MySQL engine: {mysql_host}:{mysql_port}/{mysql_database}"
723
+ )
724
+ return engine
725
+
726
+ except Exception as e:
727
+ error_msg = f"Failed to create MySQL engine from environment variables: {e}"
728
+ logger.error(error_msg)
729
+ raise DatabaseError(error_msg) from e