diff --git a/README.md b/README.md index 4d1ba2e8..c710b7db 100644 --- a/README.md +++ b/README.md @@ -1050,9 +1050,9 @@ AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agen 30 分钟换一次;断点库另有一个小池子。前面有 PgBouncer(事务模式)时:`AGENTLAB_DB_POOL_SIZE` 设成 0(不用连接池)、 地址后加 `?prepared_statement_cache_size=0`;断点库用预备语句,要 PgBouncer 1.21 以上开 `max_prepared_statements`, 或者用 `AGENTLAB_CHECKPOINT_URL` 让它直连 PostgreSQL。 -- 和 SQLite 上的行为一致:字符串列不限长(SQLite 本来就不查长度,模型里写的长度只是示意)、整数列是 BIGINT、JSON 列存原文 - (PostgreSQL 的 json,不是 JSONB:JSONB 会重排对象里的键)、时间存不带时区的 UTC、排序时可空列的 NULL 照 SQLite 的位置 - (升序在前、降序在后)。两处 SQLite 照存而 PostgreSQL 不收的值会被换掉:JSON 里的 NaN / Infinity 写成 null,字符串里的 +- 和 SQLite 上的行为一致:字符串列不限长(SQLite 本来就不查长度,模型里写的长度只是示意)、文本按字节比大小和排序 + (列上带 COLLATE "C",同 SQLite,不看建库时选的区域设置)、整数列是 BIGINT、JSON 列存原文(PostgreSQL 的 json,不是 JSONB: + JSONB 会重排对象里的键)、时间存不带时区的 UTC、排序时可空列的 NULL 照 SQLite 的位置(升序在前、降序在后)。两处 SQLite 照存而 PostgreSQL 不收的值会被换掉:JSON 里的 NaN / Infinity 写成 null,字符串里的 NUL 字符去掉。 - `AGENTLAB_DATA_DIR` 照旧要设:工件、上传表格的版本库、原件、附件、记录表、沙箱工作区、`.secret_key` 都还是文件。 diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 94cc3966..a64cf211 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -35,8 +35,9 @@ _BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError", _UNREACHABLE = ("connection refused", "nodename nor servname", "name or service not known", "all connection attempts failed", "network is unreachable") -#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」 -_WRAPPED = re.compile(r"^\([\w.]+\)\s*") +#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」;asyncpg(PostgreSQL)的后面还跟着 +#: 「: 」,一起去掉 +_WRAPPED = re.compile(r"^\([\w.]+\)\s*(?::\s*)?") # 内部符号:`a.b.._run()`、``、模块路径。对使用者没有意义 _LOCALS = re.compile(r"[\w.]*\.?[\w.]*(\(\))?") _REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>") diff --git a/backend/app/db/postgres.py b/backend/app/db/postgres.py index 74318aa3..6a15ab24 100644 --- a/backend/app/db/postgres.py +++ b/backend/app/db/postgres.py @@ -9,6 +9,11 @@ VARCHAR(n) 超长直接报错、INTEGER 只有 32 位。模型里的 String(n) 检查)。CAST(x AS VARCHAR(n)) 这种查询里的写法不动——那是截断的意思。Integer 列在 PostgreSQL 上是 BIGINT: 那是在模型这一层换的(db/base._portable_integer),绑定参数的类型转换跟着列走,大数不在半路溢出。 +**文本怎么比大小、怎么排序。** SQLite 缺省按字节比(BINARY);PostgreSQL 按建库时的区域设置(常见的 en_US.UTF-8 不分 +大小写地先比字母、忽略标点),同一列排出来的先后、`key < '…'` 这种比较的结果都会变。应用库上的文本列建表时一律带 +COLLATE "C"(按字节比),和 SQLite 一致,不看建库时选了什么区域;LIKE 'xx%' 这种前缀匹配也因此用得上普通索引。 +ILIKE、lower() 照旧按库的字符集分大小写。 + **ORDER BY 里 NULL 的位置。** SQLite 升序 NULL 在前、降序 NULL 在后,PostgreSQL 正好相反。应用里两百多处排序, 可空的列(开始时间、到期时间、发布时间……)排出来的先后要和 SQLite 一致:可空列上没写 nulls_first / nulls_last 的 ASC 补 NULLS FIRST、DESC 补 NULLS LAST,ORDER BY 里直接写列名(隐含 ASC)的同样补上。不可空的列不动—— @@ -34,12 +39,20 @@ def _nullable(element: Any) -> bool: return isinstance(element, Column) and bool(element.nullable) and not element.primary_key +#: 建表时文本列的排序规则:按字节比,同 SQLite 的 BINARY(见模块说明) +_COLLATE = ' COLLATE "C"' + + class AppTypeCompiler(PGTypeCompiler): def visit_string(self, type_: Any, **kw: Any) -> str: - if type_.length and isinstance(kw.get("type_expression"), Column): - return "VARCHAR" + if isinstance(kw.get("type_expression"), Column): + return ("VARCHAR" if type_.length else super().visit_string(type_, **kw)) + _COLLATE return super().visit_string(type_, **kw) + def visit_text(self, type_: Any, **kw: Any) -> str: + text = super().visit_text(type_, **kw) + return text + _COLLATE if isinstance(kw.get("type_expression"), Column) else text + def _ordered(clauses: Any) -> list[Any]: """ORDER BY 里直接写的可空列补成 ASC(visit_unary 再给它补 NULLS FIRST)。""" diff --git a/backend/tests/test_db_portable.py b/backend/tests/test_db_portable.py index 88e9e879..d56c206d 100644 --- a/backend/tests/test_db_portable.py +++ b/backend/tests/test_db_portable.py @@ -59,13 +59,14 @@ def test_checkpoint_tables_do_not_clash_with_app_tables(): def test_postgres_columns_are_unbounded_varchar_bigint_and_json(): - """应用库上 String(n) 建成不限长的 VARCHAR、Integer 是 BIGINT(自增主键 BIGSERIAL)、JSON 是 json(不是 JSONB: - JSONB 会重排对象里的键)、时间是不带时区的 TIMESTAMP。SQLite 上的建表语句不变。""" + """应用库上 String(n) 建成不限长的 VARCHAR、文本列按字节比(COLLATE "C",同 SQLite)、Integer 是 BIGINT(自增主键 + BIGSERIAL)、JSON 是 json(不是 JSONB:JSONB 会重排对象里的键)、时间是不带时区的 TIMESTAMP。SQLite 上的建表语句不变。""" from sqlalchemy.dialects.sqlite.aiosqlite import dialect as sqlite_dialect runs = Base.metadata.tables["runs"] pg = CreateTable(runs).compile(dialect=_pg_dialect()).string - assert "id VARCHAR NOT NULL" in pg and "VARCHAR(" not in pg + assert 'id VARCHAR COLLATE "C" NOT NULL' in pg and "VARCHAR(" not in pg + assert 'error TEXT COLLATE "C"' in pg assert "last_seq BIGINT" in pg and "INTEGER" not in pg assert "input JSON NOT NULL" in pg and "JSONB" not in pg assert "created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL" in pg @@ -73,6 +74,7 @@ def test_postgres_columns_are_unbounded_varchar_bigint_and_json(): assert "seq BIGSERIAL NOT NULL" in changes lite = CreateTable(runs).compile(dialect=sqlite_dialect()).string assert "id VARCHAR(32) NOT NULL" in lite and "last_seq INTEGER" in lite and "input JSON NOT NULL" in lite + assert "COLLATE" not in lite def test_postgres_casts_keep_their_length(): @@ -276,6 +278,23 @@ async def test_order_by_puts_nulls_where_sqlite_does(): assert [n[-1] for n in down] == ["0", "2", "1"] # 降序:NULL 在后 +async def test_text_sorts_and_compares_byte_by_byte_like_sqlite(): + """大写在小写前、标点按码位:两种库排出来一样(PostgreSQL 上不看建库时的区域设置)。""" + tag = uuid.uuid4().hex[:12] + names = ["b", "B", "a-c", "ab", "_z", "Z", "中"] + async with SessionLocal() as session: + for name in names: + session.add(Run(workflow_name=f"字节-{tag}-{name}")) + await session.commit() + async with SessionLocal() as session: + got = (await session.execute(select(Run.workflow_name).where(Run.workflow_name.like(f"字节-{tag}-%")) + .order_by(Run.workflow_name))).scalars().all() + lower = (await session.execute(select(func.count()).select_from(Run).where( + Run.workflow_name.like(f"字节-{tag}-%"), Run.workflow_name < f"字节-{tag}-a"))).scalar_one() + assert [g.removeprefix(f"字节-{tag}-") for g in got] == sorted(names) + assert lower == sum(1 for n in names if n < "a") + + async def test_timestamps_read_back_as_aware_utc(): from datetime import datetime, timedelta, timezone