From 7ada50fcfca730589a9bd288110c56ca868fa545 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Wed, 2 Sep 2026 17:32:09 +0800 Subject: [PATCH] =?UTF-8?q?bugfix:=20=E4=BF=AE=E6=94=B9mysql=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=B8=8B=E7=9A=84=E6=97=B6=E9=97=B4=E7=B2=BE=E5=BA=A6?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/session.md | 6 +++-- docs/mkdocs/zh/session.md | 6 +++-- tests/storage/test_sql_common.py | 24 +++++++++++++++++++ .../sessions/_sql_session_service.py | 7 +++--- trpc_agent_sdk/storage/__init__.py | 2 ++ trpc_agent_sdk/storage/_sql_common.py | 24 +++++++++++++++++++ 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/mkdocs/en/session.md b/docs/mkdocs/en/session.md index 899bf0cc1..0d39dc25f 100644 --- a/docs/mkdocs/en/session.md +++ b/docs/mkdocs/en/session.md @@ -479,8 +479,8 @@ CREATE TABLE sessions ( id VARCHAR(255) NOT NULL, state JSON, conversation_count INT DEFAULT 0, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_time DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (app_name, user_id, id), INDEX idx_update_time (update_time) -- Used for cleanup task ); @@ -523,6 +523,8 @@ async def _cleanup_expired_async(self) -> None: - `pool_recycle=3600` sets the connection recycle time to avoid long-lived connections - The cleanup task uses batch SQL DELETE for performance optimization - Foreign key cascade delete: deleting a session automatically deletes associated events +- **MySQL timestamp precision**: `sessions.create_time` and `sessions.update_time` use microsecond precision. The framework detects the SQLAlchemy dialect automatically from the database URL; MySQL uses `CURRENT_TIMESTAMP(6)` to match `DATETIME(6)`, while PostgreSQL, SQLite, and other databases retain their native current-time expressions. Application code does not need to detect the database type +- No migration is needed for tables created automatically by the current framework. When reusing an older MySQL table, verify that both columns are `DATETIME(6)` so fractional seconds are not truncated **Related Examples**: - 📁 [`examples/session_service_with_sql/run_agent.py`](../../../examples/session_service_with_sql/run_agent.py) - Complete SQL Session Service usage example diff --git a/docs/mkdocs/zh/session.md b/docs/mkdocs/zh/session.md index c13fc09a7..08aa20c17 100644 --- a/docs/mkdocs/zh/session.md +++ b/docs/mkdocs/zh/session.md @@ -479,8 +479,8 @@ CREATE TABLE sessions ( id VARCHAR(255) NOT NULL, state JSON, conversation_count INT DEFAULT 0, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_time DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (app_name, user_id, id), INDEX idx_update_time (update_time) -- 用于清理任务 ); @@ -523,6 +523,8 @@ async def _cleanup_expired_async(self) -> None: - `pool_recycle=3600` 设置连接回收时间,避免长时间连接 - 清理任务使用批量 SQL DELETE,性能优化 - 外键级联删除:删除会话时自动删除关联事件 +- **MySQL 时间精度**:`sessions.create_time` 和 `sessions.update_time` 使用微秒精度。框架会根据数据库连接 URL 自动识别 SQLAlchemy dialect;MySQL 使用 `CURRENT_TIMESTAMP(6)` 与 `DATETIME(6)` 对齐,PostgreSQL、SQLite 等数据库继续使用各自原生的当前时间表达式,无需业务代码手动判断数据库类型 +- 如果使用框架自动建表,无需额外迁移;如果复用旧表,请确认 MySQL 中上述两个字段为 `DATETIME(6)`,避免更新时间微秒被截断 **相关示例**: - 📁 [`examples/session_service_with_sql/run_agent.py`](../../../examples/session_service_with_sql/run_agent.py) - 完整的 SQL Session Service 使用示例 diff --git a/tests/storage/test_sql_common.py b/tests/storage/test_sql_common.py index bfd83d194..4b557ad4d 100644 --- a/tests/storage/test_sql_common.py +++ b/tests/storage/test_sql_common.py @@ -20,6 +20,7 @@ from sqlalchemy import Text from sqlalchemy.dialects import mysql from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects import sqlite from sqlalchemy.orm import DeclarativeBase from sqlalchemy.types import DateTime from sqlalchemy.types import PickleType @@ -29,6 +30,7 @@ DynamicJSON, DynamicJSONOptions, DynamicPickleType, + PreciseNow, PreciseTimestamp, SpannerPickleType, StorageData, @@ -277,6 +279,26 @@ def test_load_dialect_impl_postgresql(self): assert isinstance(result, String) +# --------------------------------------------------------------------------- +# PreciseNow SQL expression +# --------------------------------------------------------------------------- + + +class TestPreciseNow: + + def test_compile_mysql_uses_microsecond_precision(self): + sql = str(PreciseNow().compile(dialect=mysql.dialect())) + assert sql == "CURRENT_TIMESTAMP(6)" + + def test_compile_postgresql_preserves_default_now(self): + sql = str(PreciseNow().compile(dialect=postgresql.dialect())) + assert sql == "now()" + + def test_compile_sqlite_preserves_default_now(self): + sql = str(PreciseNow().compile(dialect=sqlite.dialect())) + assert sql == "CURRENT_TIMESTAMP" + + # --------------------------------------------------------------------------- # PreciseTimestamp TypeDecorator # --------------------------------------------------------------------------- @@ -464,6 +486,7 @@ def test_all_symbols_reexported(self): DynamicJSON as _DJ, DynamicJSONOptions as _DJO, DynamicPickleType as _DPT, + PreciseNow as _PN, PreciseTimestamp as _PT, SpannerPickleType as _SPT, StorageData as _SD, @@ -475,6 +498,7 @@ def test_all_symbols_reexported(self): assert _DJ is DynamicJSON assert _DJO is DynamicJSONOptions assert _DPT is DynamicPickleType + assert _PN is PreciseNow assert _PT is PreciseTimestamp assert _SPT is SpannerPickleType assert _SD is StorageData diff --git a/trpc_agent_sdk/sessions/_sql_session_service.py b/trpc_agent_sdk/sessions/_sql_session_service.py index 4333ffeb1..93fb48b6f 100644 --- a/trpc_agent_sdk/sessions/_sql_session_service.py +++ b/trpc_agent_sdk/sessions/_sql_session_service.py @@ -57,6 +57,7 @@ from trpc_agent_sdk.storage import DEFAULT_MAX_VARCHAR_LENGTH from trpc_agent_sdk.storage import DynamicJSON from trpc_agent_sdk.storage import DynamicPickleType +from trpc_agent_sdk.storage import PreciseNow from trpc_agent_sdk.storage import PreciseTimestamp from trpc_agent_sdk.storage import SqlCondition from trpc_agent_sdk.storage import SqlKey @@ -155,8 +156,8 @@ class StorageSession(SessionStorageBase): nullable=True) conversation_count: Mapped[int] = mapped_column(Integer, default=0) - create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now()) - update_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=func.now(), onupdate=func.now()) + create_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=PreciseNow()) + update_time: Mapped[datetime] = mapped_column(PreciseTimestamp, default=PreciseNow(), onupdate=PreciseNow()) storage_events: Mapped[list[SessionStorageEvent]] = relationship( "SessionStorageEvent", @@ -731,7 +732,7 @@ async def _get_session(self, sql_session: SqlSession, app_name: str, user_id: st logger.debug("Session %s is expired", session_id) return None - storage_session.update_time = func.now() + storage_session.update_time = PreciseNow() await self._sql_storage.commit(sql_session) return storage_session diff --git a/trpc_agent_sdk/storage/__init__.py b/trpc_agent_sdk/storage/__init__.py index f06958b9b..c4b0b930d 100644 --- a/trpc_agent_sdk/storage/__init__.py +++ b/trpc_agent_sdk/storage/__init__.py @@ -26,6 +26,7 @@ from ._sql_common import DynamicJSON from ._sql_common import DynamicJSONOptions from ._sql_common import DynamicPickleType +from ._sql_common import PreciseNow from ._sql_common import PreciseTimestamp from ._sql_common import SpannerPickleType from ._sql_common import StorageData @@ -59,6 +60,7 @@ "DynamicJSON", "DynamicJSONOptions", "DynamicPickleType", + "PreciseNow", "PreciseTimestamp", "SpannerPickleType", "StorageData", diff --git a/trpc_agent_sdk/storage/_sql_common.py b/trpc_agent_sdk/storage/_sql_common.py index 07fd662b2..a99064a18 100644 --- a/trpc_agent_sdk/storage/_sql_common.py +++ b/trpc_agent_sdk/storage/_sql_common.py @@ -32,9 +32,12 @@ from sqlalchemy import Dialect from sqlalchemy import Text +from sqlalchemy import func from sqlalchemy.dialects import mysql from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.sql.functions import FunctionElement from sqlalchemy.types import DateTime from sqlalchemy.types import PickleType from sqlalchemy.types import String @@ -303,6 +306,27 @@ def process_result_value(self, value: Any, dialect: Dialect) -> Any: return value +class PreciseNow(FunctionElement): + """Return the current database timestamp with MySQL microsecond precision.""" + + type = DateTime() + inherit_cache = True + + +@compiles(PreciseNow) +def _compile_precise_now(element: PreciseNow, compiler: Any, **kwargs: Any) -> str: + """Preserve SQLAlchemy's dialect-specific ``now()`` behavior by default.""" + del element + return compiler.process(func.now(), **kwargs) + + +@compiles(PreciseNow, "mysql") +def _compile_precise_now_mysql(element: PreciseNow, compiler: Any, **kwargs: Any) -> str: + """Use the precision declared by MySQL ``DATETIME(6)`` columns.""" + del element, compiler, kwargs + return "CURRENT_TIMESTAMP(6)" + + class DynamicPickleType(TypeDecorator): """Represents a type that can be pickled."""