From bf043440f0583c3a0607f01ac13fa556eb70aac7 Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 16 Oct 2026 22:15:02 +0800 Subject: [PATCH 01/17] feat(storage): provision dedicated seekdb and oceanbase databases --- .env.example | 3 + .github/workflows/e2e-harness.yml | 18 + .github/workflows/master.yml | 17 + docs/en/docs/get-started/install-and-run.md | 86 +++- docs/en/docs/operate/configuration.md | 13 +- .../rfcs/1771-unified-database-migrations.md | 6 +- docs/zh/docs/get-started/install-and-run.md | 76 +++- docs/zh/docs/operate/configuration.md | 11 +- .../rfcs/1771-unified-database-migrations.md | 2 +- .../jupyter/server-http-oceanbase.env.example | 2 +- .../builtin/code/configuration.py | 2 +- src/powercontext/builtin/code/seekdb.py | 13 +- .../builtin/persistence/bootstrap.py | 168 ++++++++ .../builtin/persistence/migrations/backup.py | 2 +- .../persistence/migrations/connections.py | 18 +- .../builtin/persistence/oceanbase/profile.py | 65 ++- .../builtin/persistence/schema.py | 40 +- .../builtin/persistence/seekdb/__init__.py | 2 + .../builtin/persistence/seekdb/profile.py | 73 +++- src/powercontext/cli/config_wizard.py | 18 +- .../test_database_backup_providers.py | 37 +- .../persistence/test_database_bootstrap.py | 372 +++++++++++++++++ .../test_database_migration_connections.py | 10 +- .../persistence/test_oceanbase_profile.py | 15 +- .../persistence/test_seekdb_profile.py | 37 +- tests/e2e/test_database_bootstrap.py | 394 ++++++++++++++++++ tests/e2e/test_native_code_seekdb.py | 9 +- tests/test_config_wizard.py | 11 +- tests/test_database_migration_cli.py | 2 +- tests/test_server.py | 32 +- 30 files changed, 1465 insertions(+), 89 deletions(-) create mode 100644 src/powercontext/builtin/persistence/bootstrap.py create mode 100644 tests/builtin/persistence/test_database_bootstrap.py create mode 100644 tests/e2e/test_database_bootstrap.py diff --git a/.env.example b/.env.example index 645355b31a..54f010c5e7 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,7 @@ POWERCONTEXT_SERVER_DATABASE_KIND=sqlite # Do not set the removed POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION variable. # OceanBase alternative: replace the SQLite kind with these values. +# Omit the URL database path to create/use powercontext; an explicit name is always honored. # POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase # POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://user:password@host:2881/powercontext?charset=utf8mb4 @@ -67,6 +68,8 @@ POWERCONTEXT_SERVER_DATABASE_KIND=sqlite # Leave DATABASE_PATH unset to use the seekdb subdirectory below POWERCONTEXT_HOME. # POWERCONTEXT_SERVER_DATABASE_KIND=seekdb # POWERCONTEXT_SERVER_DATABASE_PATH=/absolute/path/to/seekdb +# The default database is powercontext; set test before upgrading an existing seekdb deployment. +# POWERCONTEXT_SERVER_DATABASE_DATABASE=test # Runtime --------------------------------------------------------------------- POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT=100 diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index ec2108e9ee..b3347949c8 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -120,6 +120,24 @@ jobs: uv lock --check uv run --locked prek run -a + - name: Run real OceanBase database bootstrap without skips + timeout-minutes: 5 + env: + POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL: ${{ env.POWERCONTEXT_TEST_OCEANBASE_URL }} + POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_DEFAULT: "1" + POWERCONTEXT_TEST_BOOTSTRAP_PRIVILEGES: "1" + run: | + uv run --locked python -m pytest tests/e2e/test_database_bootstrap.py -k oceanbase -q -rs \ + --junitxml=.powercontext/recall-effort-ci/bootstrap.xml + uv run --locked python - <<'PYCODE' + from xml.etree import ElementTree + + suites = ElementTree.parse(".powercontext/recall-effort-ci/bootstrap.xml").getroot() + cases = list(suites.iter("testcase")) + if not cases or any(case.find("skipped") is not None for case in cases): + raise SystemExit("Real OceanBase bootstrap contracts must execute; skipped tests are not acceptance.") + PYCODE + - name: Run the real OceanBase contract without skips run: | mkdir -p .powercontext/recall-effort-ci diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e844892c00..fd03702ffd 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -174,6 +174,23 @@ jobs: - name: Test code indexing with embedded seekdb run: make code-seekdb-test + - name: Test database bootstrap with embedded seekdb without skips + timeout-minutes: 5 + env: + POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB: "1" + run: | + uv run --locked --extra seekdb --extra code python -m pytest \ + tests/e2e/test_database_bootstrap.py -k seekdb -q -rs \ + --junitxml=.powercontext/database-bootstrap-ci/seekdb.xml + uv run --locked --extra seekdb --extra code python - <<'PYCODE' + from xml.etree import ElementTree + + suites = ElementTree.parse(".powercontext/database-bootstrap-ci/seekdb.xml").getroot() + cases = list(suites.iter("testcase")) + if not cases or any(case.find("skipped") is not None for case in cases): + raise SystemExit("Real seekDB bootstrap contracts must execute; skipped tests are not acceptance.") + PYCODE + - name: Test unified migrations with embedded seekdb timeout-minutes: 10 env: diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index 8eee957519..3b557c1a28 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -138,7 +138,10 @@ powercontext server run pass `--env-file ` to select another file, or pass `--no-env-file` to ignore environment files. Process managers and containers should normally provide an explicit environment instead of relying on their working directory. -PowerContext always uses seekDB's built-in `test` database. Leave `POWERCONTEXT_SERVER_DATABASE_PATH` unset to store +PowerContext defaults to a dedicated `powercontext` database and creates it before initializing application tables. +To select another database, set `POWERCONTEXT_SERVER_DATABASE_DATABASE=my_context`; a missing database is created, +and an existing one is reused. Names containing backticks are rejected before initialization. +Leave `POWERCONTEXT_SERVER_DATABASE_PATH` unset to store the instance in the `seekdb` subdirectory of the PowerContext user data directory. If `POWERCONTEXT_HOME` is set, the default is `$POWERCONTEXT_HOME/seekdb`; set `POWERCONTEXT_SERVER_DATABASE_PATH` only when a different location is required. @@ -151,6 +154,87 @@ powercontext ready powercontext capabilities ``` +### Existing seekDB installations + +Before upgrading, back up the seekDB instance and configuration, then explicitly retain the old database: + +```bash +export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb +export POWERCONTEXT_SERVER_DATABASE_DATABASE=test +powercontext server run +``` + +Keep the original `POWERCONTEXT_SERVER_DATABASE_PATH` or `POWERCONTEXT_HOME`. The instance directory and database name +are separate settings: changing either can select different data. Configure services and containers with the same values. + +If no database name was explicitly configured and `test` contains tables named with PowerContext's `pc_` prefix, +startup stops before creating application tables and asks you to choose a database. It never automatically selects, +copies, or deletes the old database. Explicitly choose `test` to retain old data, or `powercontext` to use the new database. +The configuration wizard also asks for the database name; existing users should enter `test`. + +To move data, keep using `test` until you have a backup and a validated export/import procedure for your engine version. +Stop all source writers, provision the target database, export/import the schema and data using that procedure, verify +identities and relationships, and read the migrated data through PowerContext before switching the configuration. +Automatic database creation does not perform this migration. Do not assume an arbitrary SQL dump is compatible across +engine or schema versions; existing schemas remain subject to compatibility checks and explicit schema maintenance. +Code graph references identify their instance and database: copying relational rows alone does not migrate graph storage. +Include target Code index rebuilding and queries in your validation; explicitly retaining `test` preserves legacy graph identities. + +After switching, keep the old database without writers for a retention period you choose. Record the source and target +instance paths and names, backup location, stop-write and cutover times, validation results, and a review date. Before +manually deleting the source, confirm that no deployment uses it and that a usable backup remains. New writes to the +target are not copied back to the source, so keeping the source does not provide lossless rollback. PowerContext does +not automatically migrate data, send retention reminders, compare databases, or delete old databases. + +## Use OceanBase + +Connect to an existing OceanBase **MySQL-compatible user tenant**. PowerContext creates a database inside that tenant; +it does not provision an OceanBase instance, tenant, or account. Ensure network access and install the Server extra: + +```bash +uv tool install --force "powercontext[cli,server]==1.3.0" +export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase +# Store credentials in your secret manager or a protected environment file. +export POWERCONTEXT_SERVER_DATABASE_URL='mysql+aoceanbase://user%40tenant:encoded-password@host:2881/?charset=utf8mb4' +powercontext server run +``` + +Use the tenant account and the actual direct-connection or OBProxy endpoint and port. Percent-encode reserved characters +in the URL username and password; for example, `user@tenant` becomes `user%40tenant`. The URL requires the +`mysql+aoceanbase` driver, an explicit port, and `charset=utf8mb4`. + +The example omits the database name and therefore selects `powercontext`. To select a custom database, put its name in +the URL path, for example `/team_context?charset=utf8mb4`. Existing URLs with an explicit database continue to select +that database. Select the database only in the URL path; `db` and `database` query parameters are rejected. +Database identifiers support 1–64 BMP characters, excluding control characters, slash, backslash, dot, backticks, +leading/trailing whitespace, and system database names; names are never silently trimmed or renamed by PowerContext. The server's identifier case rules still apply. +Names containing backticks are rejected during configuration validation, before connecting or creating a database. +Choose a name such as `powercontext`, `team_context`, or a Chinese name without backticks. + +Startup first connects to the selected database. If it is usable, no database-creation privileges are required. Only a +confirmed unknown-database error triggers an instance/tenant connection and `CREATE DATABASE IF NOT EXISTS`, followed +by a new connection to the selected database and application-table initialization. Authentication and network errors +do not trigger creation or fallback. If access is denied without proving whether the database exists, the error asks +an administrator to check both existence and privileges. + +If the database is missing and the application account cannot create it, ask an administrator to run the following in +the **same tenant**, using your configured name, then grant the application account the required database privileges: + +```sql +CREATE DATABASE IF NOT EXISTS `powercontext` CHARACTER SET utf8mb4; +``` + +Restart using the same URL after provisioning. Creating missing tables requires table-creation privileges; an existing +fully initialized database does not acquire a new database-creation privilege requirement. Startup never drops or +recreates an existing database, never switches to another database after a failure, and does not authorize schema upgrades. +Concurrent creation uses idempotent database/table DDL and handles duplicate-index creation races. This does not provide +coordination for existing-schema migrations or change embedded seekDB's process-ownership restrictions. + +For Python callers, `OceanBaseProfile.open()` resolves an omitted URL database and provisions it when necessary. +`OceanBaseProfile.attach()` requires a caller-owned engine that already selects an existing database; it does not +replace that engine or provision a different connection target. Read-only database-maintenance commands never create a +missing database; explicit writable seekDB maintenance can provision its configured database before applying its bundle. + ## Verify the installation ```bash diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 0bb5f8ec8f..e3129d8463 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -110,6 +110,7 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_DATABASE_KIND` | `sqlite` | Storage backend: `sqlite`, `seekdb`, or `oceanbase` | | `POWERCONTEXT_SERVER_DATABASE_URL` | user data SQLite file | SQLAlchemy async URL for SQLite or OceanBase; do not set for seekdb | | `POWERCONTEXT_SERVER_DATABASE_PATH` | user data `seekdb` directory | Embedded seekdb path; used only when `DATABASE_KIND=seekdb` | +| `POWERCONTEXT_SERVER_DATABASE_DATABASE` | `powercontext` | seekDB database name; explicitly set `test` to retain legacy data | | `POWERCONTEXT_SERVER_DATABASE_BUSY_TIMEOUT_MS` | `5000` | Milliseconds a business connection waits for SQLite's single write lock before raising; it does not govern usage accounting, which has its own bounded budget | | `POWERCONTEXT_SERVER_RUNTIME_SCOPE_CACHE_SIZE` | `128` | Inactive scope compositions retained by the Runtime; in-flight scopes are never evicted | | `POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT` | `100` | Maximum Source journal positions per activation; Memory reduces its window after generation timeouts | @@ -556,8 +557,16 @@ export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" ``` -The URL must use the `mysql+aoceanbase` driver, include an explicit port and database, and set `charset=utf8mb4`. The -tenant must use MySQL compatibility mode. +The URL must use the `mysql+aoceanbase` driver, include an explicit port, and set `charset=utf8mb4`. An omitted database +defaults to `powercontext`; an explicit database is preserved. The tenant must use MySQL compatibility mode. + +Both seekDB and OceanBase create a missing selected database before initializing tables and reuse existing databases +without issuing a database-creation statement. Missing-database creation failures include manual provisioning guidance; +other connection errors do not trigger fallback. seekDB detects PowerContext-named tables in the old `test` database +when no name was explicitly configured and requires an explicit choice before initializing a new database. +Both backends reject database names containing backticks during configuration validation, before any database writes. +Choose a name without backticks, such as `team_context`. +See [Install and run](../get-started/install-and-run.md) for custom names, permissions, and old seekDB data handling. ### Embeddings and SQLite vector search diff --git a/docs/en/rfcs/1771-unified-database-migrations.md b/docs/en/rfcs/1771-unified-database-migrations.md index 6f87931952..407083c8d0 100644 --- a/docs/en/rfcs/1771-unified-database-migrations.md +++ b/docs/en/rfcs/1771-unified-database-migrations.md @@ -771,9 +771,9 @@ relationships prevent planned DDL, provide a validated table-switch procedure th See the [OceanBase Fork overview](https://www.oceanbase.com/docs/common-oceanbase-database-ai-1000000006779059). Restoration keeps writes stopped, preserves the original Fork, and restores the actual PC target using a validated -procedure. Embedded `SeekDBConfig.database` currently fixes the name to `test`: deliver restoration to that name or -separately implement configurable targets and coordinated switching, rather than claiming that switching to a Fork database -already works. OceanBase restoration is limited to confirmed databases/tables and dependencies, never an entire shared +procedure. Embedded `SeekDBConfig.database` defaults to `powercontext` and supports explicit names, including legacy +`test`. Deliver restoration to the configured name or a validated coordinated target switch; configurable names alone +do not establish that switching to a Fork database works. OceanBase restoration is limited to confirmed databases/tables and dependencies, never an entire shared tenant by default. `MERGE TABLE` is not an automatic rollback protocol for incompatible DDL. ### Retained tables and cleanup in later releases diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index ba6a0f8800..31f83499f4 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -128,7 +128,10 @@ powercontext server run `--env-file ` 选择其他文件,或使用 `--no-env-file` 忽略环境文件。进程管理器和容器通常应提供显式环境, 不要依赖其工作目录。 -PowerContext 固定使用 seekDB 内置的 `test` 数据库。未设置 `POWERCONTEXT_SERVER_DATABASE_PATH` 时,实例保存在 +PowerContext 默认使用专用数据库 `powercontext`,先创建缺失的数据库,再初始化应用表。 +设置 `POWERCONTEXT_SERVER_DATABASE_DATABASE=my_context` 可以指定其他名称;缺失时创建,已有时复用。 +包含反引号的库名会在初始化之前被拒绝。 +未设置 `POWERCONTEXT_SERVER_DATABASE_PATH` 时,实例保存在 PowerContext 用户数据目录的 `seekdb` 子目录中;如果设置了 `POWERCONTEXT_HOME`,默认路径为 `$POWERCONTEXT_HOME/seekdb`。只有需要其他位置时才设置 `POWERCONTEXT_SERVER_DATABASE_PATH`。 @@ -140,6 +143,77 @@ powercontext ready powercontext capabilities ``` +### 已有 seekDB 部署 + +升级前先备份 seekDB 实例和配置,再显式保留旧数据库: + +```bash +export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb +export POWERCONTEXT_SERVER_DATABASE_DATABASE=test +powercontext server run +``` + +保持原来的 `POWERCONTEXT_SERVER_DATABASE_PATH` 或 `POWERCONTEXT_HOME`。实例目录和数据库名称是两个独立配置, +修改其中任何一个都可能连接到不同的数据。服务和容器也需要使用相同配置。 + +如果没有显式配置库名,而 `test` 中存在以 PowerContext 的 `pc_` 为前缀的表,启动会在创建应用表前停止, +要求选择数据库。系统不会自动选择、复制或删除旧库。显式选择 `test` 可以继续使用旧数据;显式选择 +`powercontext` 则使用新库。配置向导也会询问数据库名称,已有用户应填写 `test`。 + +需要迁移时,先继续使用 `test`,直到完成备份并准备好针对当前引擎版本验证过的导出、导入流程。 +停止所有源库写入者,准备目标库,按该流程导出、导入结构和数据,校验身份、关联关系,并通过 PowerContext +读取迁移结果后再切换配置。自动建库不执行数据迁移。不能假定任意 SQL 转储都能跨引擎或表结构版本使用; +已有表仍需通过兼容性检查,表结构升级需要显式维护。 +代码图引用绑定实例和数据库,仅复制关系表数据不等于迁移代码图存储;校验流程应包含目标库的 Code 索引重建和查询。 +显式继续使用 `test` 会保留旧代码图身份。 + +切换后由用户选择保留期,旧库保持停写。记录源和目标实例路径、库名、备份位置、停写及切换时间、校验结果和复核日期。 +手动删除旧库前,确认所有部署均已停止使用它,并且仍有可用备份。目标库的新写入不会同步回源库,保留源库不代表 +可以无损回退。PowerContext 不会自动迁移、发送到期提醒、比较新旧库或删除旧库。 + +## 使用 OceanBase + +连接已有 OceanBase **MySQL 兼容模式的用户租户**。PowerContext 在该租户内创建数据库, +不负责创建 OceanBase 实例、租户或账户。确认网络可达,并安装 Server extra: + +```bash +uv tool install --force "powercontext[cli,server]==1.3.0" +export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase +# 凭据应保存在密钥管理服务或受保护的环境文件中。 +export POWERCONTEXT_SERVER_DATABASE_URL='mysql+aoceanbase://user%40tenant:encoded-password@host:2881/?charset=utf8mb4' +powercontext server run +``` + +使用租户账户和实际的直连或 OBProxy 地址、端口。URL 用户名和密码中的保留字符需要百分号编码,例如 +`user@tenant` 编码为 `user%40tenant`。URL 必须使用 `mysql+aoceanbase` 驱动、显式端口和 `charset=utf8mb4`。 + +示例省略库名,因此使用 `powercontext`。自定义库名放在 URL 路径中,例如 `/team_context?charset=utf8mb4`。 +已有 URL 中显式指定的库名保持有效。库名仅通过 URL 路径指定,不接受 `db` 或 `database` 查询参数。 +数据库名称支持 1~64 个 BMP 字符,不允许控制字符、斜杠、反斜杠、点、反引号、 +首尾空白或系统数据库名称;PowerContext 不会静默裁剪或改名,服务器自身的标识符大小写规则仍然适用。 +包含反引号的名称会在配置校验阶段被拒绝,不会连接或创建数据库。请使用 `powercontext`、`team_context` +或不含反引号的中文名称。 + +启动先尝试连接目标库,已有可用库不要求额外建库权限。只有明确的“数据库不存在”错误才触发实例或租户级连接, +执行 `CREATE DATABASE IF NOT EXISTS`,随后重新连接目标库并初始化应用表。认证、网络错误不会触发建库或切库。 +如果服务器拒绝访问但无法确认库是否存在,错误会要求管理员同时检查数据库和权限。 + +目标库缺失而应用账户没有建库权限时,请管理员在**同一租户**中执行以下 SQL;自定义部署应替换为实际库名, +然后为应用账户授予所需数据库权限: + +```sql +CREATE DATABASE IF NOT EXISTS `powercontext` CHARACTER SET utf8mb4; +``` + +准备完成后使用原 URL 重启。创建缺失表仍然需要建表权限;已有完整可用库不会新增建库权限要求。 +启动不会删除或重建已有数据库,不会在失败后切换其他数据库,也不授权升级已有表结构。 +并发创建使用幂等建库、建表 DDL,并处理索引重名竞争;这不提供已有表结构迁移的协调能力,也不改变嵌入式 seekDB +对实例进程所有权的限制。 + +Python 调用方使用 `OceanBaseProfile.open()` 时可省略 URL 中的库名,并按需建库。 +`OceanBaseProfile.attach()` 要求调用方的 engine 已显式选择一个已有数据库,不替换 engine 或改变连接目标。 +只读数据库维护命令不会创建缺失库;显式的 seekDB 写入维护可以在应用迁移包前准备配置指定的数据库。 + ## 验证安装 ```bash diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 0f7080fc8a..9cc7b1d957 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -101,6 +101,7 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_DATABASE_KIND` | `sqlite` | 存储后端:`sqlite`、`seekdb` 或 `oceanbase` | | `POWERCONTEXT_SERVER_DATABASE_URL` | 用户数据目录下的 SQLite 文件 | SQLite 或 OceanBase 的 SQLAlchemy 异步 URL;seekdb 不设置 | | `POWERCONTEXT_SERVER_DATABASE_PATH` | 用户数据目录下的 `seekdb` 目录 | 嵌入式 seekdb 路径;仅在 `DATABASE_KIND=seekdb` 时使用 | +| `POWERCONTEXT_SERVER_DATABASE_DATABASE` | `powercontext` | seekDB 数据库名称;已有数据显式设置为 `test` | | `POWERCONTEXT_SERVER_DATABASE_BUSY_TIMEOUT_MS` | `5000` | 业务连接等待 SQLite 单一写锁的毫秒数;它不约束用量记账,后者有自己的有界预算 | | `POWERCONTEXT_SERVER_RUNTIME_SCOPE_CACHE_SIZE` | `128` | Runtime 保留的非活动 scope composition 数量;进行中的 scope 不会被驱逐 | | `POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT` | `100` | 每次处理的 Source 日志位置上限;Memory 在生成超时后会缩小窗口 | @@ -503,8 +504,14 @@ export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" ``` -URL 必须使用 `mysql+aoceanbase` driver,包含明确的端口和数据库,并设置 `charset=utf8mb4`。对应 -tenant 必须使用 MySQL 兼容模式。 +URL 必须使用 `mysql+aoceanbase` 驱动、明确的端口,并设置 `charset=utf8mb4`。省略库名时使用 `powercontext`, +显式库名保持有效。租户必须使用 MySQL 兼容模式。 + +seekDB 和 OceanBase 都会先创建缺失的目标库,再初始化表;已有可用库不会执行建库语句。 +建库权限不足时会提供手动准备说明,其他连接错误不会触发回退。seekDB 未显式配置库名时,若在旧 `test` 中检测到 +PowerContext 命名的表,会要求先明确选择数据库。两个后端都会在配置校验阶段拒绝包含反引号的库名, +不会产生数据库写入;请改用 `team_context` 等不含反引号的名称。自定义名称、权限和旧数据处理见 +[安装与运行](../get-started/install-and-run.md)。 ### Embedding 与 SQLite 向量检索 diff --git a/docs/zh/rfcs/1771-unified-database-migrations.md b/docs/zh/rfcs/1771-unified-database-migrations.md index 405cc88594..c0d481da6b 100644 --- a/docs/zh/rfcs/1771-unified-database-migrations.md +++ b/docs/zh/rfcs/1771-unified-database-migrations.md @@ -437,7 +437,7 @@ Fork 是同实例/同集群迁移恢复点,不是独立物理副本或存储 启用前须验证全部 PC 对象、触发器/外键、全文/向量能力是否保留或可安全重建,Fork 后计划中的源表重命名/结构变更是否允许,以及中断、重启后的恢复。若 Fork 关系阻止计划 DDL,应提供经过验证且保留旧数据的替代表切换方案,否则返回 `backup_unsupported`;不能先删除恢复点来使 DDL 成功。参考 [OceanBase Fork 概述](https://www.oceanbase.com/docs/common-oceanbase-database-ai-1000000006779059)。 -恢复保持停写、保留原 Fork,并按已验证方案恢复到 PC 实际使用的目标。当前嵌入式 `SeekDBConfig.database` 固定为 `test`,适配器须交付原库名恢复,或单独实现可配置目标及统一切换;不能把切换到 Fork 库写成现有能力。OceanBase 恢复限定已确认的数据库/表及依赖,不默认覆盖整个共享租户。`MERGE TABLE` 不作为自动撤销不兼容 DDL 的协议。 +恢复保持停写、保留原 Fork,并按已验证方案恢复到 PC 实际使用的目标。嵌入式 `SeekDBConfig.database` 默认为 `powercontext`,支持显式名称,包括旧库 `test`。适配器须交付配置库名的恢复,或经过验证的目标协调切换;库名可配置本身不能证明切换到 Fork 库可用。OceanBase 恢复限定已确认的数据库/表及依赖,不默认覆盖整个共享租户。`MERGE TABLE` 不作为自动撤销不兼容 DDL 的协议。 ### 旧表保留与后续版本清理 diff --git a/examples/jupyter/server-http-oceanbase.env.example b/examples/jupyter/server-http-oceanbase.env.example index 7b6344c1f0..f1ab4bbba2 100644 --- a/examples/jupyter/server-http-oceanbase.env.example +++ b/examples/jupyter/server-http-oceanbase.env.example @@ -15,7 +15,7 @@ # Copy to examples/jupyter/.powercontext/http-oceanbase.env and set a dedicated test database. # Pass that file to `powercontext server run --env-file` on loopback. POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase -POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://user:encoded-password@host:2881/powercontext_notebooks +POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://user:encoded-password@host:2881/powercontext_notebooks?charset=utf8mb4 POWERCONTEXT_SERVER_ACCESS_MODE=disabled POWERCONTEXT_SERVER_MCP_ENABLED=false POWERCONTEXT_SERVER_DASHBOARD_ENABLED=false diff --git a/src/powercontext/builtin/code/configuration.py b/src/powercontext/builtin/code/configuration.py index dd26daa7e9..4c7b47be87 100644 --- a/src/powercontext/builtin/code/configuration.py +++ b/src/powercontext/builtin/code/configuration.py @@ -35,7 +35,7 @@ async def open_code_service( from powercontext.builtin.code.seekdb import SeekDBGraphStore async with SeekDBProfile.open(database, tables=()) as profile: - store = SeekDBGraphStore(database.path, profile.connection_options) + store = SeekDBGraphStore(database.path, profile.connection_options, database=database.database) try: await asyncio.to_thread(store.initialize, time.monotonic() + config.limits.build_seconds) yield CodeService(config, store=store) diff --git a/src/powercontext/builtin/code/seekdb.py b/src/powercontext/builtin/code/seekdb.py index 298e031e29..c3d6f3ce64 100644 --- a/src/powercontext/builtin/code/seekdb.py +++ b/src/powercontext/builtin/code/seekdb.py @@ -154,9 +154,16 @@ def neighbors( class SeekDBGraphStore: - def __init__(self, path: Path, connection_options: Mapping[str, object]) -> None: - self.identity = "seekdb:" + digest_bytes(str(path.expanduser().resolve()).encode()) + def __init__(self, path: Path, connection_options: Mapping[str, object], *, database: str) -> None: + # Retain legacy graph references for explicitly selected test databases. + location = str(path.expanduser().resolve()) + self.identity = "seekdb:" + digest_bytes(location.encode()) + if database != "test": + self.identity += ":" + digest_bytes(database.encode()) + self.database_name = database self.options = dict(connection_options) + self.options.pop("database", None) + self.options.pop("db", None) self._condition = threading.Condition() self._active = 0 self._closed = False @@ -172,7 +179,7 @@ def connection(self, deadline: float) -> Iterator[Any]: remaining = max(1, deadline - time.monotonic()) with pymysql.connect( **self.options, - database="test", + database=self.database_name, charset="utf8mb4", autocommit=False, connect_timeout=min(10, int(remaining) + 1), diff --git a/src/powercontext/builtin/persistence/bootstrap.py b/src/powercontext/builtin/persistence/bootstrap.py new file mode 100644 index 0000000000..dd39a47ea2 --- /dev/null +++ b/src/powercontext/builtin/persistence/bootstrap.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Database selection and non-destructive MySQL-compatible database creation.""" + +from __future__ import annotations + +import asyncio +import unicodedata +from collections.abc import Awaitable, Callable + +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from powercontext.builtin.persistence.errors import PersistenceError + +DEFAULT_DATABASE = "powercontext" +_SYSTEM_DATABASES = {"information_schema", "mysql", "performance_schema", "sys", "oceanbase"} +_PERMISSION_ERRORS = {1044, 1142, 1227} + + +class DatabaseAccessError(PersistenceError): + """The server denies access without proving whether the database exists.""" + + def __init__(self, name: str, statement: str) -> None: + self.database_name = name + super().__init__( + f"Access to database {name!r} was denied. Ask an administrator to verify it in the same instance/tenant. " + f"If it is missing, execute {statement}; then grant the application account access and " + "table-initialization privileges and restart. No fallback database was selected." + ) + + +class DatabaseCreationError(PersistenceError): + """A missing database must be provisioned by an administrator.""" + + def __init__(self, name: str, statement: str) -> None: + self.database_name = name + super().__init__( + f"Database {name!r} does not exist and this account cannot create it. " + f"Ask an administrator to execute {statement}; in the same instance/tenant, " + "grant the application account access and table-initialization privileges, then restart. " + "No fallback database was selected." + ) + + +def validate_database_name(name: str) -> str: + """Accept database names supported throughout initialization and reflection.""" + if "`" in name: + raise ValueError( # noqa: TRY003 + "Database name must not contain backticks (`). Choose a name without backticks, " + "such as powercontext or team_context" + ) + if ( + not name + or len(name) > 64 + or name != name.strip() + or any(unicodedata.category(char) in {"Cc", "Cs"} or ord(char) > 0xFFFF or char in "/\\." for char in name) + or name.casefold() in _SYSTEM_DATABASES + ): + raise ValueError( # noqa: TRY003 + "Database name must contain 1-64 BMP characters, no leading/trailing whitespace, " + "control characters, slash, backslash or dot, and must not name a system database" + ) + return name + + +def mysql_error_code(error: DBAPIError) -> int | None: + """Read the driver's numeric code without parsing or exposing its message.""" + arguments = getattr(error.orig, "args", ()) + return arguments[0] if arguments and isinstance(arguments[0], int) else None + + +async def database_is_available(engine: AsyncEngine, *, name: str | None = None) -> bool: + """Only an explicit unknown-database error permits database creation.""" + try: + async with engine.connect() as connection: + if name is not None: + actual = (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() + if actual != name: + case_mode = (await connection.exec_driver_sql("SELECT @@lower_case_table_names")).scalar_one() + if case_mode not in {1, 2} or not isinstance(actual, str) or actual.lower() != name.lower(): + raise PersistenceError( # noqa: TRY003 + f"The connection did not select configured database {name!r}; check URL connection overrides" + ) + return True + except DBAPIError as error: + if mysql_error_code(error) != 1049: + raise + return False + + +async def ensure_database( + engine: AsyncEngine, + instance_engine: Callable[[], AsyncEngine], + *, + name: str, + require_backend: Callable[[AsyncConnection], Awaitable[None]] | None = None, +) -> None: + """Reuse usable databases without issuing CREATE or listing other databases.""" + quoted = engine.dialect.identifier_preparer.quote_identifier(validate_database_name(name)) + statement = f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4" + try: + if await database_is_available(engine, name=name): + return + except DBAPIError as error: + if mysql_error_code(error) == 1044: + raise DatabaseAccessError(name, statement) from None + raise + + admin = instance_engine() + try: + try: + async with admin.begin() as connection: + if require_backend is not None: + await require_backend(connection) + await connection.exec_driver_sql(statement) + except DBAPIError as error: + await _recover_creation_race(engine, name, statement, error) + finally: + await dispose_engine(admin) + + if not await database_is_available(engine, name=name): + raise PersistenceError(f"Database {name!r} is still unavailable after creation; retry startup") # noqa: TRY003 + + +async def _recover_creation_race(engine: AsyncEngine, name: str, statement: str, error: DBAPIError) -> None: + """A concurrent creator can make a failed CREATE harmless, without granting CREATE to this account.""" + code = mysql_error_code(error) + if code not in _PERMISSION_ERRORS and code != 1007: + raise error + try: + available = await database_is_available(engine, name=name) + except DBAPIError as retry_error: + if mysql_error_code(retry_error) != 1044: + raise + available = False + if available: + return + if code in _PERMISSION_ERRORS: + raise DatabaseCreationError(name, statement) from None + raise error + + +async def dispose_engine(engine: AsyncEngine) -> None: + """Finish connection disposal before propagating cancellation.""" + task = asyncio.create_task(engine.dispose()) + try: + await asyncio.shield(task) + except asyncio.CancelledError: + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + continue + task.result() + raise diff --git a/src/powercontext/builtin/persistence/migrations/backup.py b/src/powercontext/builtin/persistence/migrations/backup.py index c6e28fd01c..e8ce7ef165 100644 --- a/src/powercontext/builtin/persistence/migrations/backup.py +++ b/src/powercontext/builtin/persistence/migrations/backup.py @@ -276,7 +276,7 @@ class ForkWorkflowAcceptance(BaseModel): This is supplied by a backend adapter, never by CLI flags or user confirmation. The default provider has no accepted workflow: a feature version alone cannot enable PC automatic backup. Evidence must cover restoring the configured name, - especially embedded seekdb's fixed ``test`` database. + including an explicitly configured legacy seekdb ``test`` database. """ model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index a66293b2da..b994db96e5 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -29,6 +29,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.pool import NullPool +from powercontext.builtin.persistence.bootstrap import database_is_available, ensure_database from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.oceanbase import profile as oceanbase_profile from powercontext.builtin.persistence.seekdb import SeekDBConfig @@ -112,7 +113,9 @@ def run( "unsupported_target", "A nonempty seekdb directory must contain recognized native storage." ) initial_inode = _directory_inode(path) if path.exists() else None - return asyncio.run(self._seekdb(operation, self.config, path, identity, initial_inode)) + return asyncio.run( + self._seekdb(operation, self.config, path, identity, initial_inode, writable=writable) + ) return asyncio.run(self._oceanbase(operation, self.config, writable=writable)) async def _seekdb( @@ -122,6 +125,8 @@ async def _seekdb( path: Path, identity: BackendIdentity, initial_inode: tuple[int, int] | None, + *, + writable: bool, ) -> _T: try: module = seekdb_profile._load_binding() @@ -136,6 +141,17 @@ async def _seekdb( config.model_copy(update={"echo": False}), instance.connection_options() ) try: + await seekdb_profile._require_explicit_legacy_database(config, instance.connection_options()) + if writable: + await ensure_database( + engine, + lambda: seekdb_profile._create_engine( + config.model_copy(update={"echo": False}), instance.connection_options(), database="" + ), + name=config.database, + ) + elif not await database_is_available(engine): + return operation(None, identity, lambda: None) inode = _directory_inode(path) if initial_inode is not None and initial_inode != inode: raise MigrationError("migration_lock_lost", "The seekdb directory changed during engine startup.") diff --git a/src/powercontext/builtin/persistence/oceanbase/profile.py b/src/powercontext/builtin/persistence/oceanbase/profile.py index acab062e15..690333b7ee 100644 --- a/src/powercontext/builtin/persistence/oceanbase/profile.py +++ b/src/powercontext/builtin/persistence/oceanbase/profile.py @@ -28,6 +28,7 @@ from sqlalchemy.exc import ArgumentError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine +from powercontext.builtin.persistence.bootstrap import DEFAULT_DATABASE, ensure_database, validate_database_name from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.dream_schema import assert_dream_schema_ready from powercontext.builtin.persistence.errors import PersistenceError @@ -93,7 +94,13 @@ class OceanBaseConfig(BaseModel): model_config = ConfigDict(extra="forbid", hide_input_in_errors=True) kind: Literal["oceanbase"] = "oceanbase" - url: Annotated[SecretStr, Field(repr=False)] + url: Annotated[ + SecretStr, + Field( + repr=False, + description="Official async OceanBase URL; an omitted database selects powercontext; database names cannot contain backticks", + ), + ] echo: bool = False pool_pre_ping: bool = True @@ -102,8 +109,8 @@ class OceanBaseConfig(BaseModel): def require_official_async_dialect(cls, value: SecretStr) -> SecretStr: """Reject sync, generic MySQL and malformed URLs without exposing credentials.""" - _validate_oceanbase_url(value.get_secret_value()) - return value + url = _validate_oceanbase_url(value.get_secret_value()) + return SecretStr(url.render_as_string(hide_password=False)) class OceanBaseProfile: @@ -124,20 +131,20 @@ async def open( """Create, initialize and exclusively own an official OceanBase engine.""" _register_official_dialect() - engine = create_async_engine( - config.url.get_secret_value(), - # OceanBase's MySQL handshake can leave aiomysql believing the - # session is transactional while the server still autocommits. - # Explicitly disabling autocommit keeps SAVEPOINT-based conflict - # recovery and the surrounding publication transaction atomic. - connect_args={"init_command": "SET autocommit = 0"}, - echo=config.echo, - hide_parameters=True, - pool_pre_ping=config.pool_pre_ping, - ) + url = _validate_oceanbase_url(config.url.get_secret_value()) + engine = _create_engine(config, url) database = AsyncDatabase.own(engine) - async with _initialized_profile(cls(database=database, tables=tables)) as profile: - yield profile + try: + await ensure_database( + engine, + lambda: _create_engine(config, url.set(database="")), + name=url.database or DEFAULT_DATABASE, + require_backend=_require_mysql_tenant, + ) + async with _initialized_profile(cls(database=database, tables=tables)) as profile: + yield profile + finally: + await database.close() @classmethod @asynccontextmanager @@ -147,14 +154,29 @@ async def attach( *, tables: tuple[Table, ...], ) -> AsyncIterator[OceanBaseProfile]: - """Initialize a caller-owned official OceanBase engine without disposing it.""" - + """Initialize an explicitly selected caller-owned database without replacing its engine.""" _validate_oceanbase_url(engine.url) + if not engine.url.database: + raise ValueError( # noqa: TRY003 + "Attached OceanBase engines must select a database; use OceanBaseProfile.open for defaults" + ) database = AsyncDatabase.attach(engine) async with _initialized_profile(cls(database=database, tables=tables)) as profile: yield profile +def _create_engine(config: OceanBaseConfig, url: URL) -> AsyncEngine: + return create_async_engine( + url, + # Match AsyncDatabase's explicit transaction ownership even when the + # server handshake omits the autocommit status flag. + connect_args={"init_command": "SET autocommit = 0"}, + echo=config.echo, + hide_parameters=True, + pool_pre_ping=config.pool_pre_ping, + ) + + @asynccontextmanager async def _initialized_profile(profile: OceanBaseProfile) -> AsyncIterator[OceanBaseProfile]: try: @@ -229,8 +251,11 @@ def _validate_oceanbase_url(value: str | URL) -> URL: raise ValueError("OceanBase profile URL must include a host") # noqa: TRY003 if url.port is None: raise ValueError("OceanBase profile URL must include an explicit port") # noqa: TRY003 - if not url.database: - raise ValueError("OceanBase profile URL must include a database") # noqa: TRY003 + if url.database is None or url.database == "": + url = url.set(database=DEFAULT_DATABASE) + validate_database_name(url.database or DEFAULT_DATABASE) + if "db" in url.query or "database" in url.query: + raise ValueError("Select the OceanBase database in the URL path, not db/database query parameters") # noqa: TRY003 if url.query.get("charset") != "utf8mb4": raise ValueError("OceanBase profile URL must set charset=utf8mb4") # noqa: TRY003 return url diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index d1e4170bc3..6172b81c4c 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -18,13 +18,21 @@ from collections.abc import Sequence -from sqlalchemy import MetaData, Table +from sqlalchemy import Connection, MetaData, Table, inspect +from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import AsyncConnection +from sqlalchemy.schema import CreateTable + +from powercontext.builtin.persistence.bootstrap import mysql_error_code async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) -> None: """Create missing tables and indexes using SQLAlchemy's native metadata support.""" + if connection.dialect.name == "mysql": + await connection.run_sync(lambda sync: _create_mysql_tables(sync, tables)) + return + groups: dict[MetaData, list[Table]] = {} for table in tables: groups.setdefault(table.metadata, []).append(table) @@ -37,3 +45,33 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) checkfirst=True, ) ) + + +def _create_mysql_tables(connection: Connection, tables: Sequence[Table]) -> None: + """Use atomic table creation and normalize only verified index-name races. + + Existing tables are left untouched. This initializes missing tables; it does + not reconcile existing schemas or replace the explicit migration workflow. + """ + groups: dict[MetaData, set[Table]] = {} + for table in tables: + groups.setdefault(table.metadata, set()).add(table) + for metadata, selected in groups.items(): + for table in metadata.sorted_tables: + if table not in selected or inspect(connection).has_table(table.name, schema=table.schema): + continue + connection.execute(CreateTable(table, if_not_exists=True)) + for index in sorted(table.indexes, key=lambda item: str(item.name)): + try: + index.create(connection, checkfirst=True) + except DBAPIError as error: + if mysql_error_code(error) != 1061: + raise + actual = inspect(connection).get_indexes(table.name, schema=table.schema) + if not any( + item["name"] == index.name + and item["column_names"] == [column.name for column in index.columns] + and bool(item["unique"]) == bool(index.unique) + for item in actual + ): + raise diff --git a/src/powercontext/builtin/persistence/seekdb/__init__.py b/src/powercontext/builtin/persistence/seekdb/__init__.py index 1aacf0395b..ac26586f8b 100644 --- a/src/powercontext/builtin/persistence/seekdb/__init__.py +++ b/src/powercontext/builtin/persistence/seekdb/__init__.py @@ -15,12 +15,14 @@ """Embedded seekdb async relational profile.""" from powercontext.builtin.persistence.seekdb.profile import ( + LegacySeekDBDatabaseError, SeekDBConfig, SeekDBProfile, SeekDBUnavailableError, ) __all__ = ( + "LegacySeekDBDatabaseError", "SeekDBConfig", "SeekDBProfile", "SeekDBUnavailableError", diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index ac73af8cfb..7a92080f8a 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -24,15 +24,23 @@ from types import ModuleType from typing import Any, Literal, Protocol, TypeVar, cast -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from pyobvector import AsyncOceanBaseDialect from sqlalchemy import Table from sqlalchemy.dialects import registry as dialect_registry from sqlalchemy.engine import URL from sqlalchemy.engine.interfaces import DBAPIConnection +from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from typing_extensions import override +from powercontext.builtin.persistence.bootstrap import ( + DEFAULT_DATABASE, + dispose_engine, + ensure_database, + mysql_error_code, + validate_database_name, +) from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.dream_schema import assert_dream_schema_ready from powercontext.builtin.persistence.errors import PersistenceError @@ -75,10 +83,18 @@ class SeekDBConfig(BaseModel): kind: Literal["seekdb"] = "seekdb" path: Path - database: Literal["test"] = "test" + database: str = Field( + default=DEFAULT_DATABASE, + description="Database to create or reuse; backticks are unsupported; explicitly select test to retain legacy seekdb data", + ) echo: bool = False pool_pre_ping: bool = True + @field_validator("database") + @classmethod + def require_database_name(cls, value: str) -> str: + return validate_database_name(value) + @field_validator("path", mode="before") @classmethod def require_path(cls, value: object) -> object: @@ -120,6 +136,12 @@ async def open( database = AsyncDatabase.own(engine) profile = cls(database=database, tables=tables, connection_options=instance.connection_options()) try: + await _require_explicit_legacy_database(config, instance.connection_options()) + await ensure_database( + engine, + lambda: _create_engine(config, instance.connection_options(), database=""), + name=config.database, + ) await assert_dream_schema_ready(engine) async with database.transaction() as connection: await create_tables(connection, tables) @@ -166,8 +188,51 @@ async def _finish_task(task: asyncio.Task[_T]) -> _T: return task.result() -def _create_engine(config: SeekDBConfig, connection_options: Mapping[str, object]) -> AsyncEngine: +async def _require_explicit_legacy_database(config: SeekDBConfig, options: Mapping[str, object]) -> None: + """Never hide old PowerContext tables behind the changed implicit default.""" + if "database" in config.model_fields_set: + return + legacy = _create_engine(config, options, database="test") + try: + try: + async with legacy.connect() as connection: + result = await connection.exec_driver_sql( + "SELECT TABLE_NAME FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND LEFT(TABLE_NAME, 3) = 'pc_' LIMIT 1" + ) + if result.first() is not None: + raise LegacySeekDBDatabaseError(config.path) + except DBAPIError as error: + if mysql_error_code(error) != 1049: + raise + finally: + await dispose_engine(legacy) + + +class LegacySeekDBDatabaseError(PersistenceError): + """An existing seekdb installation needs an explicit database choice.""" + + def __init__(self, path: Path) -> None: + super().__init__( + f"Existing PowerContext tables were found in seekdb database 'test' at {str(path)!r}. " + "The default database is now 'powercontext'. Set database='test' " + "(POWERCONTEXT_SERVER_DATABASE_DATABASE=test) to keep using existing data, " + "or explicitly set database='powercontext' to select the new database. " + "No application tables were created and no data was moved or deleted. " + "See https://powercontext.oceanbase.io/en/docs/get-started/install-and-run/#existing-seekdb-installations" + ) + + +def _create_engine( + config: SeekDBConfig, + connection_options: Mapping[str, object], + *, + database: str | None = None, +) -> AsyncEngine: options = dict(connection_options) + # Instance connection hints must not override PowerContext's database selection. + options.pop("database", None) + options.pop("db", None) username = str(options.pop("user", "root")) password_value = options.pop("password", None) host = str(options.pop("host", "localhost")) @@ -182,7 +247,7 @@ def _create_engine(config: SeekDBConfig, connection_options: Mapping[str, object password=None if password_value is None else str(password_value), host=host, port=None if port_value is None else int(cast(int | str, port_value)), - database=config.database, + database=config.database if database is None else database, query={"charset": "utf8mb4"}, ) _register_seekdb_dialect() diff --git a/src/powercontext/cli/config_wizard.py b/src/powercontext/cli/config_wizard.py index 3222300cb5..cd11450382 100644 --- a/src/powercontext/cli/config_wizard.py +++ b/src/powercontext/cli/config_wizard.py @@ -201,6 +201,8 @@ def _storage(state: Wizard) -> None: _existing_server(state) return state.patch({f"{SERVER}DATABASE_KIND": kind}) + if kind != "seekdb": + state.patch({f"{SERVER}DATABASE_DATABASE": None}) if kind == "sqlite": previous = state.values.get(f"{SERVER}DATABASE_URL") default_path = str(default_database_path()) @@ -242,7 +244,21 @@ def _storage(state: Wizard) -> None: .expanduser() .absolute() ) - state.patch({f"{SERVER}DATABASE_PATH": str(path), f"{SERVER}DATABASE_URL": None}) + ui.say( + "The default database is powercontext. Existing seekdb users: enter test to retain old data.", + "默认数据库为 powercontext。已有 seekdb 用户:输入 test 以继续使用旧数据。", + ) + database = ui.ask( + "Database name", + "数据库名称", + default=state.values.get(f"{SERVER}DATABASE_DATABASE", "powercontext"), + required=True, + ) + state.patch({ + f"{SERVER}DATABASE_PATH": str(path), + f"{SERVER}DATABASE_DATABASE": database, + f"{SERVER}DATABASE_URL": None, + }) _prepare_seekdb_dependency(state) else: previous = state.values.get(f"{SERVER}DATABASE_URL", "") diff --git a/tests/builtin/persistence/test_database_backup_providers.py b/tests/builtin/persistence/test_database_backup_providers.py index c44ebc5eec..edcbfa868e 100644 --- a/tests/builtin/persistence/test_database_backup_providers.py +++ b/tests/builtin/persistence/test_database_backup_providers.py @@ -246,7 +246,7 @@ async def scenario() -> None: def inspect_provider(sync: Connection) -> None: provider = ForkBackupProvider( sync, - database_name="test", + database_name=str(sync.exec_driver_sql("SELECT DATABASE()").scalar_one()), expected_product="seekdb", directory=short_tmp_path / "manifests", ) @@ -265,15 +265,21 @@ def inspect_provider(sync: Connection) -> None: @pytest.mark.skipif(os.environ.get("POWERCONTEXT_TEST_MIGRATION_SEEKDB") != "1", reason="real seekdb probe not enabled") -def test_real_seekdb_fork_simple_table_survives_ddl_restart_and_original_name_restore(short_tmp_path: Path) -> None: +@pytest.mark.parametrize("database_name", [None, "test", "backup_context"]) +def test_real_seekdb_fork_simple_table_survives_ddl_restart_and_original_name_restore( + short_tmp_path: Path, database_name: str | None +) -> None: """Acceptance for one scratch-table shape, not the complete PC schema.""" from powercontext.builtin.persistence.migrations.connections import MaintenanceConnections from powercontext.builtin.persistence.seekdb import SeekDBConfig - adapter = MaintenanceConnections(SeekDBConfig(path=short_tmp_path / "seekdb")) + path = short_tmp_path / "seekdb" + config = SeekDBConfig(path=path) if database_name is None else SeekDBConfig(path=path, database=database_name) + adapter = MaintenanceConnections(config) - def create_and_change(connection, _identity, _verify): + def create_and_change(connection, identity, _verify): assert connection is not None + assert connection.exec_driver_sql("SELECT DATABASE()").scalar_one() == identity.database_name connection.exec_driver_sql("CREATE TABLE pc_artifacts (id INT PRIMARY KEY, content VARCHAR(40))") connection.exec_driver_sql("INSERT INTO pc_artifacts VALUES (1, 'original')") connection.commit() @@ -281,7 +287,7 @@ def create_and_change(connection, _identity, _verify): comment = str(connection.exec_driver_sql("SELECT @@version_comment").scalar_one()) number = identify_fork_server(version, comment)[1] assert number is not None - definition = str(connection.exec_driver_sql("SHOW CREATE TABLE test.pc_artifacts").one()[1]) + definition = str(connection.exec_driver_sql("SHOW CREATE TABLE pc_artifacts").one()[1]) # This test performs every claimed DDL/restart/restore check below. # It does not install this evidence in any production adapter. acceptance = ForkWorkflowAcceptance( @@ -294,7 +300,7 @@ def create_and_change(connection, _identity, _verify): covered_objects=("pc_artifacts",), evidence_reference="scratch-table acceptance executed by this integration test", restore_steps=( - "While stopped, Fork the retained table into a new table in the original test database.", + "While stopped, Fork the retained table into a new table in the original configured database.", "Rename the current table to a retained name, then atomically give the restored table its original name.", "Verify original data and revision; preserve the original Fork database and failed table.", ), @@ -305,7 +311,7 @@ def create_and_change(connection, _identity, _verify): ) provider = ForkBackupProvider( connection, - database_name="test", + database_name=identity.database_name, expected_product="seekdb", directory=short_tmp_path / "manifests", acceptance=acceptance, @@ -321,11 +327,12 @@ def create_and_change(connection, _identity, _verify): # second one reopens the scratch schema without initializing business tables. ref, acceptance = adapter.run(create_and_change, writable=True) - def inspect_and_restore(connection, _identity, _verify): + def inspect_and_restore(connection, identity, _verify): assert connection is not None + assert connection.exec_driver_sql("SELECT DATABASE()").scalar_one() == identity.database_name provider = ForkBackupProvider( connection, - database_name="test", + database_name=identity.database_name, expected_product="seekdb", directory=short_tmp_path / "manifests", acceptance=acceptance, @@ -337,16 +344,16 @@ def inspect_and_restore(connection, _identity, _verify): # Source DDL and process restart must not replace the original # point if the executor lost its own post-backup receipt. assert provider.create_backup(context()) == ref - # The generated name is hexadecimal; it is not user SQL input. - connection.exec_driver_sql(f"FORK TABLE `{ref.location}`.`pc_artifacts` TO `test`.`pc_restore_probe`") + quote = connection.dialect.identifier_preparer.quote_identifier connection.exec_driver_sql( - "RENAME TABLE test.pc_artifacts TO test.pc_failed_retained,test.pc_restore_probe TO test.pc_artifacts" + f"FORK TABLE {quote(ref.location)}.`pc_artifacts` TO {quote(identity.database_name)}.`pc_restore_probe`" ) + connection.exec_driver_sql("RENAME TABLE pc_artifacts TO pc_failed_retained, pc_restore_probe TO pc_artifacts") connection.commit() - assert connection.exec_driver_sql("SELECT id,content FROM test.pc_artifacts").all() == [(1, "original")] - assert connection.exec_driver_sql("SELECT content FROM test.pc_failed_retained").all() == [("changed",)] + assert connection.exec_driver_sql("SELECT id,content FROM pc_artifacts").all() == [(1, "original")] + assert connection.exec_driver_sql("SELECT content FROM pc_failed_retained").all() == [("changed",)] assert connection.exec_driver_sql( - f"SELECT id,content FROM `{ref.location}`.`pc_artifacts`" # noqa: S608 -- generated hexadecimal name + f"SELECT id,content FROM {quote(ref.location)}.`pc_artifacts`" # noqa: S608 -- dialect-quoted identifier ).all() == [(1, "original")] adapter.run(inspect_and_restore, writable=True) diff --git a/tests/builtin/persistence/test_database_bootstrap.py b/tests/builtin/persistence/test_database_bootstrap.py new file mode 100644 index 0000000000..249e3de5f0 --- /dev/null +++ b/tests/builtin/persistence/test_database_bootstrap.py @@ -0,0 +1,372 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock + +import pytest +from pydantic import SecretStr, ValidationError +from sqlalchemy.dialects.mysql import dialect as mysql_dialect +from sqlalchemy.engine import URL, make_url +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncEngine + +from powercontext.builtin.persistence.bootstrap import DatabaseAccessError, DatabaseCreationError, ensure_database +from powercontext.builtin.persistence.errors import PersistenceError +from powercontext.builtin.persistence.oceanbase import ( + OceanBaseConfig, + OceanBaseProfile, + UnsupportedOceanBaseTenantError, +) +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError, SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.seekdb import profile as seekdb_module + + +def driver_error(code: int) -> OperationalError: + return OperationalError(None, None, Exception(code, "driver diagnostic")) + + +class Instance: + def __init__(self, databases: set[str]) -> None: + self.databases = databases + self.failure: int | None = None + self.creation_failure: int | None = None + self.on_create: Callable[[], None] | None = None + self.statements: list[str] = [] + self.legacy_tables = False + self.case_mode = 0 + self.engines: list[Engine] = [] + + def engine(self, name: str | None) -> Engine: + engine = Engine(self, name) + self.engines.append(engine) + return engine + + +class Engine: + def __init__(self, instance: Instance, name: str | None) -> None: + self.instance = instance + self.name = name + self.dialect = mysql_dialect() + self.disposed = False + + @asynccontextmanager + async def connect(self): + if self.instance.failure is not None: + raise driver_error(self.instance.failure) + if self.name and self.name not in self.instance.databases: + raise driver_error(1049) + yield self + + def begin(self): + return self.connect() + + async def exec_driver_sql(self, statement: str): + self.instance.statements.append(statement) + if statement.startswith("CREATE DATABASE"): + if self.instance.on_create is not None: + self.instance.on_create() + if self.instance.creation_failure is not None: + raise driver_error(self.instance.creation_failure) + self.instance.databases.add("custom-db") + row = ("pc_sources",) if self.instance.legacy_tables else None + value = self.instance.case_mode if statement == "SELECT @@lower_case_table_names" else self.name + return SimpleNamespace(first=lambda: row, scalar_one=lambda: value) + + async def dispose(self) -> None: + self.disposed = True + + +@pytest.mark.parametrize("name", ["powercontext", "test", "custom-db", "知识库"]) +def test_seekdb_accepts_default_and_custom_names(tmp_path: Path, name: str) -> None: + config = SeekDBConfig(path=tmp_path, database=name) + assert config.database == name + assert SeekDBConfig(path=tmp_path).database == "powercontext" + + +@pytest.mark.parametrize( + "name", ["", " ", " trailing ", "a/b", "a\\b", "a.b", "a\x00b", "a\x85b", "a\ud800b", "x" * 65, "mysql"] +) +def test_seekdb_rejects_invalid_database_names(tmp_path: Path, name: str) -> None: + with pytest.raises(ValidationError, match="Database name"): + SeekDBConfig(path=tmp_path, database=name) + + +@pytest.mark.parametrize("name", ["quoted`db", "`database", "database`", "quoted``db"]) +@pytest.mark.parametrize("backend", ["seekdb", "oceanbase"]) +def test_configs_reject_backtick_database_names_before_initialization(tmp_path: Path, name: str, backend: str) -> None: + path = tmp_path / "not-created" + with pytest.raises(ValidationError, match="must not contain backticks") as error: + if backend == "seekdb": + SeekDBConfig(path=path, database=name) + else: + url = URL.create( + "mysql+aoceanbase", + username="user", + password="private-password", # noqa: S106 + host="localhost", + port=2881, + database=name, + query={"charset": "utf8mb4"}, + ) + OceanBaseConfig(url=SecretStr(url.render_as_string(hide_password=False))) + assert "Choose a name without backticks" in str(error.value) + assert "private-password" not in str(error.value) + assert not path.exists() + + +def test_bootstrap_rejects_backticks_before_any_database_operation() -> None: + async def scenario() -> None: + instance = Instance(set()) + with pytest.raises(ValueError, match="Choose a name without backticks"): + await ensure_database( + cast(AsyncEngine, instance.engine("quoted`db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="quoted`db", + ) + assert instance.statements == [] + assert instance.databases == set() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("path", ["", "/", "/custom_db"]) +def test_oceanbase_resolves_only_missing_database_names(path: str) -> None: + value = f"mysql+aoceanbase://user:encoded%40password@localhost:2881{path}?charset=utf8mb4&connect_timeout=12" + config = OceanBaseConfig(url=SecretStr(value)) + url = make_url(config.url.get_secret_value()) + assert url.database == ("custom_db" if path == "/custom_db" else "powercontext") + assert url.password == "encoded@password" # noqa: S105 + assert url.query == {"charset": "utf8mb4", "connect_timeout": "12"} + assert "encoded" not in repr(config) + + +def test_usable_database_needs_no_creation_privileges() -> None: + async def scenario() -> None: + instance = Instance({"custom-db"}) + instance.creation_failure = 1044 + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert not any(sql.startswith("CREATE") for sql in instance.statements) + + asyncio.run(scenario()) + + +def test_missing_database_is_created_with_a_quoted_identifier() -> None: + async def scenario() -> None: + instance = Instance(set()) + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert instance.databases == {"custom-db"} + assert [sql for sql in instance.statements if sql.startswith("CREATE")] == [ + "CREATE DATABASE IF NOT EXISTS `custom-db` CHARACTER SET utf8mb4" + ] + assert all(engine.disposed for engine in instance.engines if engine.name is None) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("code", [1044, 1142, 1227]) +def test_missing_database_permission_error_has_manual_instructions(code: int) -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.creation_failure = code + with pytest.raises(DatabaseCreationError, match="administrator") as error: + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert "CREATE DATABASE IF NOT EXISTS `custom-db`" in str(error.value) + assert "same instance/tenant" in str(error.value) + assert instance.databases == set() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("code", [1045, 2003]) +def test_non_missing_connection_errors_do_not_create_databases(code: int) -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.failure = code + with pytest.raises(OperationalError) as error: + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + driver_failure = error.value.orig + assert driver_failure is not None + assert driver_failure.args[0] == code + assert instance.statements == [] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("code", [1007, 1044]) +def test_concurrent_creator_makes_database_usable(code: int) -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.creation_failure = code + instance.on_create = lambda: instance.databases.add("custom-db") + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert "custom-db" in instance.databases + + asyncio.run(scenario()) + + +def test_unknown_creation_error_is_not_hidden() -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.creation_failure = 1064 + with pytest.raises(OperationalError): + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert instance.databases == set() + + asyncio.run(scenario()) + + +def test_legacy_seekdb_requires_an_explicit_choice_before_table_initialization(tmp_path: Path, monkeypatch) -> None: + async def scenario() -> None: + instance = Instance({"test"}) + instance.legacy_tables = True + native = SimpleNamespace(connection_options=lambda: {}, close=lambda: None) + monkeypatch.setattr( + seekdb_module, "_load_binding", lambda: SimpleNamespace(aopen=AsyncMock(return_value=native)) + ) + monkeypatch.setattr( + seekdb_module, + "_create_engine", + lambda config, _options, **options: instance.engine(options.get("database", config.database)), + ) + with pytest.raises(LegacySeekDBDatabaseError, match="POWERCONTEXT_SERVER_DATABASE_DATABASE=test"): + async with SeekDBProfile.open(SeekDBConfig(path=tmp_path), tables=()): + pytest.fail("Old data must require a database choice") + assert instance.databases == {"test"} + assert not any(sql.startswith("CREATE") for sql in instance.statements) + assert all(engine.disposed for engine in instance.engines) + + asyncio.run(scenario()) + + +def test_attached_oceanbase_engine_requires_an_explicit_database() -> None: + async def scenario() -> None: + engine = SimpleNamespace(url=make_url("mysql+aoceanbase://user:password@host:2881/?charset=utf8mb4")) + with pytest.raises(ValueError, match="must select a database"): + async with OceanBaseProfile.attach(cast(AsyncEngine, engine), tables=()): + pytest.fail("Caller-owned engine must retain an explicit target") + + asyncio.run(scenario()) + + +def test_access_denied_does_not_claim_database_is_missing_or_attempt_creation() -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.failure = 1044 + with pytest.raises(DatabaseAccessError, match="If it is missing"): + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert instance.statements == [] + + asyncio.run(scenario()) + + +def test_connection_override_cannot_silently_select_another_database() -> None: + async def scenario() -> None: + instance = Instance({"other"}) + with pytest.raises(PersistenceError, match="did not select configured database"): + await ensure_database( + cast(AsyncEngine, instance.engine("other")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + ) + assert not any(sql.startswith("CREATE") for sql in instance.statements) + + asyncio.run(scenario()) + + +def test_unsupported_tenant_is_rejected_before_database_creation() -> None: + async def scenario() -> None: + instance = Instance(set()) + + async def reject_tenant(_connection) -> None: + raise UnsupportedOceanBaseTenantError("ORACLE") + + with pytest.raises(UnsupportedOceanBaseTenantError): + await ensure_database( + cast(AsyncEngine, instance.engine("custom-db")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="custom-db", + require_backend=reject_tenant, + ) + assert instance.databases == set() + assert not any(sql.startswith("CREATE") for sql in instance.statements) + assert all(engine.disposed for engine in instance.engines if engine.name is None) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("case_mode", [0, 1, 2]) +def test_target_identity_respects_server_identifier_case_rules(case_mode: int) -> None: + async def scenario() -> None: + instance = Instance({"mixed"}) + instance.case_mode = case_mode + + async def connect() -> None: + await ensure_database( + cast(AsyncEngine, instance.engine("mixed")), + lambda: cast(AsyncEngine, instance.engine(None)), + name="Mixed", + ) + + if case_mode == 0: + with pytest.raises(PersistenceError, match="did not select configured database"): + await connect() + else: + await connect() + assert not any(sql.startswith("CREATE") for sql in instance.statements) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("parameter", ["db", "database"]) +def test_oceanbase_rejects_conflicting_query_database_selection(parameter: str) -> None: + with pytest.raises(ValidationError, match="URL path"): + OceanBaseConfig( + url=SecretStr(f"mysql+aoceanbase://user:password@host:2881/custom?charset=utf8mb4&{parameter}=other") + ) diff --git a/tests/builtin/persistence/test_database_migration_connections.py b/tests/builtin/persistence/test_database_migration_connections.py index 05bc749720..8d781e3c6c 100644 --- a/tests/builtin/persistence/test_database_migration_connections.py +++ b/tests/builtin/persistence/test_database_migration_connections.py @@ -46,7 +46,7 @@ def test_missing_seekdb_inspection_creates_no_directory_or_database(tmp_path: Pa def inspect(connection: Connection | None, identity: BackendIdentity, verify: Callable[[], None]): assert connection is None assert identity.product == "seekdb" - assert identity.database_name == "test" + assert identity.database_name == "powercontext" verify() return identity @@ -109,7 +109,7 @@ def assert_locked() -> None: @event.listens_for(engine.sync_engine, "connect") def configure_database(connection, _record) -> None: - connection.create_function("DATABASE", 0, lambda: "test") + connection.create_function("DATABASE", 0, lambda: "powercontext") @event.listens_for(engine.sync_engine, "before_cursor_execute", retval=True) def translate_transaction(_connection, _cursor, statement, parameters, _context, _many): @@ -137,7 +137,7 @@ async def dispose_engine() -> None: await dispose() disposed = True - maintained_engine = SimpleNamespace(connect=engine.connect, dispose=dispose_engine) + maintained_engine = SimpleNamespace(connect=engine.connect, dispose=dispose_engine, dialect=engine.dialect) monkeypatch.setattr(connections.seekdb_profile, "_load_binding", lambda: SimpleNamespace(aopen=open_instance)) monkeypatch.setattr(connections.seekdb_profile, "_create_engine", lambda _config, _options: maintained_engine) return path, assert_locked @@ -153,7 +153,7 @@ def fail(connection: Connection | None, _identity: BackendIdentity, verify: Call raise KeyboardInterrupt with pytest.raises(KeyboardInterrupt): - MaintenanceConnections(SeekDBConfig(path=path)).run(fail, writable=True) + MaintenanceConnections(SeekDBConfig(path=path, database="powercontext")).run(fail, writable=True) with local_migration_lock(path): pass @@ -168,7 +168,7 @@ def replace(connection: Connection | None, _identity: BackendIdentity, verify: C path.mkdir() with pytest.raises(MigrationError) as error: - MaintenanceConnections(SeekDBConfig(path=path)).run(replace, writable=True) + MaintenanceConnections(SeekDBConfig(path=path, database="powercontext")).run(replace, writable=True) assert error.value.code == "migration_lock_lost" with local_migration_lock(path): pass diff --git a/tests/builtin/persistence/test_oceanbase_profile.py b/tests/builtin/persistence/test_oceanbase_profile.py index 21b64370bf..42e75f028c 100644 --- a/tests/builtin/persistence/test_oceanbase_profile.py +++ b/tests/builtin/persistence/test_oceanbase_profile.py @@ -47,7 +47,7 @@ async def _skip_dream_preflight(_engine: AsyncEngine) -> None: - """These profile unit tests use engine doubles; read-only preflight has integration coverage.""" + """Engine doubles isolate profile behavior; CI bootstrap acceptance exercises real preflight.""" class _Result: @@ -60,6 +60,9 @@ def first(self) -> tuple[object, ...] | None: def all(self) -> list[tuple[object, ...]]: return list(self._rows) + def scalar_one(self) -> object: + return self._rows[0][0] + class _Connection: def __init__(self, row: tuple[str, str] | None = ("ob_compatibility_mode", "MYSQL")) -> None: @@ -69,6 +72,8 @@ def __init__(self, row: tuple[str, str] | None = ("ob_compatibility_mode", "MYSQ async def exec_driver_sql(self, statement: str, parameters: object | None = None) -> _Result: self.statements.append(statement) + if statement == "SELECT DATABASE()": + return _Result((("powercontext",),)) if "ob_compatibility_mode" in statement: return _Result(() if self.row is None else (self.row,)) return _Result(()) @@ -81,6 +86,8 @@ def __init__(self, columns: tuple[tuple[object, ...], ...]) -> None: async def exec_driver_sql(self, statement: str, parameters: object | None = None) -> _Result: self.statements.append(statement) + if statement == "SELECT DATABASE()": + return _Result((("powercontext",),)) if "ob_compatibility_mode" in statement: return _Result((("ob_compatibility_mode", "MYSQL"),)) if "LEFT(TABLE_NAME" in statement: @@ -105,6 +112,8 @@ async def __aexit__( class _Engine: + dialect = mysql_dialect() + def __init__( self, *, @@ -117,6 +126,9 @@ def __init__( def begin(self) -> _Begin: return _Begin(self.connection) + def connect(self) -> _Begin: + return _Begin(self.connection) + async def dispose(self) -> None: return None @@ -128,7 +140,6 @@ async def dispose(self) -> None: "mysql+aiomysql://root:secret@127.0.0.1:2881/powercontext?charset=utf8mb4", "postgresql+asyncpg://root:secret@127.0.0.1:2881/powercontext?charset=utf8mb4", "mysql+aoceanbase://root:secret@127.0.0.1/powercontext?charset=utf8mb4", - "mysql+aoceanbase://root:secret@127.0.0.1:2881/?charset=utf8mb4", "mysql+aoceanbase://root:secret@127.0.0.1:2881/powercontext", ], ) diff --git a/tests/builtin/persistence/test_seekdb_profile.py b/tests/builtin/persistence/test_seekdb_profile.py index 488e7d1b6e..00779da841 100644 --- a/tests/builtin/persistence/test_seekdb_profile.py +++ b/tests/builtin/persistence/test_seekdb_profile.py @@ -22,6 +22,7 @@ import pytest from pydantic import ValidationError from sqlalchemy import Table +from sqlalchemy.dialects.mysql import dialect as mysql_dialect from sqlalchemy.engine import URL, make_url from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.ext.asyncio import AsyncEngine @@ -31,11 +32,19 @@ from powercontext.builtin.persistence.seekdb import profile as seekdb_profile_module +class _EmptyResult: + def first(self): + return None + + def scalar_one(self): + return "powercontext" + + class _TrackedConnection: - dialect = seekdb_profile_module.AsyncSeekDBDialect() + dialect = mysql_dialect() - async def exec_driver_sql(self, _statement: str) -> None: - return None + async def exec_driver_sql(self, _statement: str) -> _EmptyResult: + return _EmptyResult() class _Begin(AbstractAsyncContextManager[_TrackedConnection]): @@ -52,12 +61,17 @@ async def __aexit__( class _TrackedEngine: + dialect = mysql_dialect() + def __init__(self, events: list[str]) -> None: self.events = events def begin(self) -> _Begin: return _Begin() + def connect(self) -> _Begin: + return _Begin() + async def dispose(self) -> None: self.events.append("engine.dispose") @@ -84,7 +98,7 @@ async def aopen(self, path: str) -> _SeekDBInstance: async def _skip_dream_preflight(_engine: AsyncEngine) -> None: - """These profile unit tests use engine doubles; read-only preflight has integration coverage.""" + """Engine doubles isolate profile behavior; CI bootstrap acceptance exercises real preflight.""" class _TerminatingConnection: @@ -110,7 +124,8 @@ def test_dialect_terminates_connections_on_close() -> None: assert connection.terminated -def test_engine_uses_the_local_socket(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("database", [None, "test", "custom"]) +def test_engine_uses_the_local_socket(tmp_path, monkeypatch: pytest.MonkeyPatch, database: str | None) -> None: captured: dict[str, object] = {} expected_engine = cast(AsyncEngine, object()) @@ -123,8 +138,8 @@ def create_engine(url: object, **options: object) -> AsyncEngine: monkeypatch.setattr(seekdb_profile_module, "_register_seekdb_dialect", lambda: None) engine = seekdb_profile_module._create_engine( - SeekDBConfig(path=tmp_path), - {"user": "root", "unix_socket": "seekdb.sock"}, + SeekDBConfig(path=tmp_path) if database is None else SeekDBConfig(path=tmp_path, database=database), + {"user": "root", "unix_socket": "seekdb.sock", "database": "test", "db": "test"}, ) captured_url = captured["url"] @@ -134,7 +149,7 @@ def create_engine(url: object, **options: object) -> AsyncEngine: assert url.drivername == "mysql+aseekdb" assert url.username == "root" assert url.host == "localhost" - assert url.database == "test" + assert url.database == (database or "powercontext") assert url.query["charset"] == "utf8mb4" assert captured["connect_args"] == { "init_command": "SET autocommit = 0", @@ -158,7 +173,7 @@ async def create_no_tables(_connection: object, _tables: tuple[Table, ...]) -> N monkeypatch.setattr(seekdb_profile_module, "create_tables", create_no_tables) monkeypatch.setattr(seekdb_profile_module, "assert_dream_schema_ready", _skip_dream_preflight) - async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()): + async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb", database="powercontext"), tables=()): pass assert events == [f"aopen:{(tmp_path / 'seekdb').resolve()}", "engine.dispose", "instance.close"] @@ -193,7 +208,7 @@ async def observe_close(database: AsyncDatabase) -> None: monkeypatch.setattr(seekdb_profile_module, "assert_dream_schema_ready", _skip_dream_preflight) monkeypatch.setattr(AsyncDatabase, "close", observe_close) - context = SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()) + context = SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb", database="powercontext"), tables=()) profile = await context.__aenter__() async def hold_transaction() -> None: @@ -252,7 +267,7 @@ async def aopen(self, path: str) -> _SeekDBInstance: monkeypatch.setattr(seekdb_profile_module, "_load_binding", DelayedModule) async def open_profile() -> None: - async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()): + async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb", database="powercontext"), tables=()): pass task = asyncio.create_task(open_profile()) diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py new file mode 100644 index 0000000000..d89f1a1293 --- /dev/null +++ b/tests/e2e/test_database_bootstrap.py @@ -0,0 +1,394 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Opt-in real seekDB/OceanBase database bootstrap acceptance. + +OceanBase tests create and drop only generated pc_bootstrap_* databases/users. +The default-name scenario additionally requires an explicitly disposable tenant. +Set POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL to a real reachable endpoint before +starting pytest; the example hostname "host" is rejected. Setup is checked before +resource cleanup is registered, and cleanup failures never replace test failures. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import SecretStr, ValidationError +from sqlalchemy import Column, Index, Integer, MetaData, String, Table, select +from sqlalchemy.engine import make_url +from sqlalchemy.exc import DBAPIError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from powercontext.builtin.persistence.bootstrap import DatabaseAccessError, DatabaseCreationError, mysql_error_code +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.oceanbase.profile import _register_official_dialect +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError, SeekDBConfig, SeekDBProfile + +_METADATA = MetaData() +_RECORDS = Table( + "pc_bootstrap_records", + _METADATA, + Column("id", Integer, primary_key=True), + Column("value", String(64), nullable=False), +) +Index("ix_pc_bootstrap_records_value", _RECORDS.c.value) +_SEEKDB = os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB") == "1" +_OCEANBASE = os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL") + + +async def insert_record(profile) -> None: + async with profile.database.transaction() as connection: + await connection.execute(_RECORDS.insert().values(id=1, value="preserved")) + + +async def assert_record(profile, name: str) -> None: + async with profile.database.transaction() as connection: + assert await connection.scalar(select(_RECORDS.c.value).where(_RECORDS.c.id == 1)) == "preserved" + assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == name + + +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +@pytest.mark.parametrize("name", [None, "team_context", "团队库"]) +def test_seekdb_creates_selected_database_and_preserves_data(short_tmp_path: Path, name: str | None) -> None: + pytest.importorskip("pylibseekdb") + + async def scenario() -> None: + config = ( + SeekDBConfig(path=short_tmp_path / "seekdb") + if name is None + else SeekDBConfig(path=short_tmp_path / "seekdb", database=name) + ) + async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, name or "powercontext") + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_rejects_backtick_database_without_side_effects(short_tmp_path: Path) -> None: + pytest.importorskip("pylibseekdb") + + async def scenario() -> None: + path = short_tmp_path / "seekdb" + retained = SeekDBConfig(path=path, database="retained_context") + async with SeekDBProfile.open(retained, tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with profile.database.transaction() as connection: + before = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("quoted`db",) + ) + assert before.all() == [] + with pytest.raises(ValidationError, match="Choose a name without backticks"): + async with SeekDBProfile.open(SeekDBConfig(path=path, database="quoted`db"), tables=(_RECORDS,)): + pytest.fail("Unsupported database names must be rejected before initialization") + async with profile.database.transaction() as connection: + after = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("quoted`db",) + ) + assert after.all() == [] + await assert_record(profile, retained.database) + async with SeekDBProfile.open(retained, tables=(_RECORDS,)) as profile: + await assert_record(profile, retained.database) + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_legacy_choice_preserves_old_data_and_separates_new_database(short_tmp_path: Path) -> None: + pytest.importorskip("pylibseekdb") + + async def scenario() -> None: + path = short_tmp_path / "seekdb" + old = SeekDBConfig(path=path, database="test") + async with SeekDBProfile.open(old, tables=(_RECORDS,)) as profile: + await insert_record(profile) + with pytest.raises(LegacySeekDBDatabaseError): + async with SeekDBProfile.open(SeekDBConfig(path=path), tables=(_RECORDS,)): + pytest.fail("Implicit database switch must be rejected") + async with SeekDBProfile.open(old, tables=(_RECORDS,)) as profile: + await assert_record(profile, "test") + async with ( + SeekDBProfile.open(SeekDBConfig(path=path, database="powercontext"), tables=(_RECORDS,)) as profile, + profile.database.transaction() as connection, + ): + assert (await connection.execute(select(_RECORDS))).all() == [] + async with SeekDBProfile.open(old, tables=(_RECORDS,)) as profile: + await assert_record(profile, "test") + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_concurrent_profiles_initialize_one_database(short_tmp_path: Path) -> None: + """Exercise concurrent clients in one embedded owner process, not multiple engine-owner processes.""" + pytest.importorskip("pylibseekdb") + + async def scenario() -> None: + config = SeekDBConfig(path=short_tmp_path / "seekdb", database="concurrent_context") + + async def initialize() -> None: + async with ( + SeekDBProfile.open(config, tables=(_RECORDS,)) as profile, + profile.database.transaction() as connection, + ): + assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == config.database + + async def initialize_all() -> None: + async with asyncio.timeout(90), asyncio.TaskGroup() as group: + for _ in range(3): + group.create_task(initialize()) + + await initialize_all() + async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + await initialize_all() + async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, config.database) + + asyncio.run(scenario()) + + +@dataclass +class _OceanBaseInstance: + engine: AsyncEngine + database_name: str + databases: list[str] = field(default_factory=list) + created_users: list[str] = field(default_factory=list) + + async def reserve_database(self, name: str) -> None: + # Negative cases can use invalid application identifiers; quote independently. + if name != "powercontext" and not name.startswith("pc_bootstrap_"): + pytest.fail("Only bootstrap test databases may be reserved", pytrace=False) + async with asyncio.timeout(15), self.engine.connect() as connection: + result = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (name,) + ) + if result.first() is not None: + pytest.fail( + f"Refusing to use existing database {name!r}; use an exclusive disposable tenant", pytrace=False + ) + self.databases.append(name) + + +@asynccontextmanager +async def _oceanbase_instance(value: str | None) -> AsyncIterator[_OceanBaseInstance]: + try: + config = OceanBaseConfig(url=SecretStr(value or "")) + except ValidationError: + pytest.fail( + "Invalid POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL: supply a real disposable MySQL user tenant URL " + "with an explicit port, charset=utf8mb4 and percent-encoded credentials", + pytrace=False, + ) + url = make_url(config.url.get_secret_value()) + if (url.host or "").casefold() == "host": + pytest.fail("Replace example hostname 'host' in POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL", pytrace=False) + _register_official_dialect() + engine = create_async_engine( + url.set(database=""), + hide_parameters=True, + connect_args={"connect_timeout": 10, "init_command": "SET autocommit = 0"}, + ) + instance = _OceanBaseInstance(engine, "pc_bootstrap_" + uuid4().hex[:20]) + failure: BaseException | None = None + try: + try: + # The first reservation also checks connectivity; failed setup owns no database. + await instance.reserve_database(instance.database_name) + except (SQLAlchemyError, TimeoutError) as error: + code = mysql_error_code(error) if isinstance(error, DBAPIError) else None + pytest.fail( + f"OceanBase bootstrap setup failed ({type(error).__name__}, code={code}). " + "Check host/port, DNS, network and tenant credentials in POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL", + pytrace=False, + ) + yield instance + except BaseException as error: + failure = error + raise + finally: + quote = engine.dialect.identifier_preparer.quote_identifier + cleanup: list[tuple[str, tuple[str, ...]]] = [ + ("DROP USER %s@%s", (user, "%")) for user in reversed(instance.created_users) + ] + cleanup.extend((f"DROP DATABASE IF EXISTS {quote(name)}", ()) for name in reversed(instance.databases)) + errors: list[str] = [] + try: + for statement, parameters in cleanup: + try: + async with asyncio.timeout(15), engine.begin() as connection: + await connection.exec_driver_sql(statement, parameters) + except Exception as error: + code = mysql_error_code(error) if isinstance(error, DBAPIError) else None + errors.append(f"Cleanup failed: {type(error).__name__}, code={code}") + finally: + try: + async with asyncio.timeout(15): + await engine.dispose() + except Exception as error: + errors.append(f"Engine disposal failed: {type(error).__name__}") + if errors: + message = "; ".join(errors) + ". Inspect the disposable tenant for leftover bootstrap resources." + if failure is not None: + failure.add_note(message) + else: + pytest.fail(message, pytrace=False) + + +def oceanbase_config(name: str) -> OceanBaseConfig: + assert _OCEANBASE is not None + url = make_url(_OCEANBASE).set(database=name) + return OceanBaseConfig(url=SecretStr(url.render_as_string(hide_password=False))) + + +@pytest.mark.skipif(not _OCEANBASE, reason="set POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL for a disposable tenant") +def test_oceanbase_creates_custom_database_and_preserves_data() -> None: + async def scenario() -> None: + async with _oceanbase_instance(_OCEANBASE) as instance: + name = instance.database_name + config = oceanbase_config(name) + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, name) + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not _OCEANBASE, reason="set POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL for a disposable tenant") +def test_oceanbase_rejects_backtick_database_without_side_effects() -> None: + async def scenario() -> None: + async with _oceanbase_instance(_OCEANBASE) as instance: + admin, name = instance.engine, instance.database_name + config = oceanbase_config(name) + invalid_name = name + "`db" + await instance.reserve_database(invalid_name) + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with admin.connect() as connection: + before = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (invalid_name,) + ) + assert before.all() == [] + with pytest.raises(ValidationError, match="Choose a name without backticks"): + async with OceanBaseProfile.open(oceanbase_config(invalid_name), tables=(_RECORDS,)): + pytest.fail("Unsupported database names must be rejected before initialization") + async with admin.connect() as connection: + after = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (invalid_name,) + ) + assert after.all() == [] + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, name) + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not _OCEANBASE, reason="set POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_URL for a disposable tenant") +def test_oceanbase_concurrent_initialization_preserves_data() -> None: + async def scenario() -> None: + async with _oceanbase_instance(_OCEANBASE) as instance: + name = instance.database_name + config = oceanbase_config(name) + + async def initialize() -> None: + async with ( + OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile, + profile.database.transaction() as connection, + ): + assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == name + + async def initialize_all() -> None: + async with asyncio.timeout(90), asyncio.TaskGroup() as group: + for _ in range(4): + group.create_task(initialize()) + + await initialize_all() + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + await initialize_all() + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, name) + + asyncio.run(scenario()) + + +@pytest.mark.skipif( + not _OCEANBASE or os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_DEFAULT") != "1", + reason="default-name test requires an exclusive disposable tenant and POWERCONTEXT_TEST_BOOTSTRAP_OCEANBASE_DEFAULT=1", +) +def test_oceanbase_omitted_database_initializes_default() -> None: + async def scenario() -> None: + async with _oceanbase_instance(_OCEANBASE) as instance: + await instance.reserve_database("powercontext") + config = oceanbase_config("") + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, "powercontext") + + asyncio.run(scenario()) + + +@pytest.mark.skipif( + not _OCEANBASE or os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_PRIVILEGES") != "1", + reason="privilege test requires disposable-user CREATE USER/GRANT privileges and POWERCONTEXT_TEST_BOOTSTRAP_PRIVILEGES=1", +) +def test_oceanbase_reuses_database_without_create_privilege_and_guides_missing_database() -> None: + async def scenario() -> None: + assert _OCEANBASE is not None + async with _oceanbase_instance(_OCEANBASE) as instance: + admin, name = instance.engine, instance.database_name + username = "pc_boot_" + uuid4().hex[:12] + password = uuid4().hex + missing = "pc_bootstrap_missing_" + uuid4().hex[:12] + async with OceanBaseProfile.open(oceanbase_config(name), tables=(_RECORDS,)) as profile: + await insert_record(profile) + async with admin.begin() as connection: + await connection.exec_driver_sql("CREATE USER %s@%s IDENTIFIED BY %s", (username, "%", password)) + instance.created_users.append(username) + await connection.exec_driver_sql( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON `{name}`.* TO %s@%s", (username, "%") + ) + admin_url = make_url(_OCEANBASE) + tenant = (admin_url.username or "").partition("@")[2] + login = username + ("@" + tenant if tenant else "") + user_url = admin_url.set(username=login, password=password, database=name) + config = OceanBaseConfig(url=SecretStr(user_url.render_as_string(hide_password=False))) + async with OceanBaseProfile.open(config, tables=(_RECORDS,)) as profile: + await assert_record(profile, name) + missing_url = user_url.set(database=missing).render_as_string(hide_password=False) + await instance.reserve_database(missing) + with pytest.raises((DatabaseCreationError, DatabaseAccessError), match="administrator") as error: + async with OceanBaseProfile.open(OceanBaseConfig(url=SecretStr(missing_url)), tables=(_RECORDS,)): + pytest.fail("Unprivileged account must not create a database") + assert "CREATE DATABASE IF NOT EXISTS" in str(error.value) + assert password not in str(error.value) + async with admin.connect() as connection: + result = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (missing,) + ) + assert result.first() is None + + asyncio.run(scenario()) diff --git a/tests/e2e/test_native_code_seekdb.py b/tests/e2e/test_native_code_seekdb.py index 4aa874f491..65b6686300 100644 --- a/tests/e2e/test_native_code_seekdb.py +++ b/tests/e2e/test_native_code_seekdb.py @@ -93,11 +93,11 @@ def make_repository(directory: Path) -> Path: @pytest.fixture(scope="module") def backend(tmp_path_factory): - database = SeekDBConfig(path=tmp_path_factory.mktemp("embedded-code") / "database") + database = SeekDBConfig(path=tmp_path_factory.mktemp("embedded-code") / "database", database="code_context") with asyncio.Runner() as runner: context = SeekDBProfile.open(database, tables=()) profile = runner.run(context.__aenter__()) - store = SeekDBGraphStore(database.path, profile.connection_options) + store = SeekDBGraphStore(database.path, profile.connection_options, database=database.database) try: store.initialize(time.monotonic() + 120) yield database, store @@ -275,7 +275,7 @@ def test_interrupted_build_recovers_without_orphan_rows(repository, backend, tmp from powercontext.builtin.code import CodeConfig, CodeService from powercontext.builtin.code import seekdb -config, database, options, marker, checkpoint = json.loads(sys.argv[1]) +config, database, options, database_name, marker, checkpoint = json.loads(sys.argv[1]) marker = Path(marker) def pause(): @@ -300,13 +300,14 @@ def interrupted_create(self, *args): return result seekdb.SeekDBGraphStore.create = interrupted_create -store = seekdb.SeekDBGraphStore(Path(database), options) +store = seekdb.SeekDBGraphStore(Path(database), options, database=database_name) CodeService(CodeConfig.model_validate(config), store=store).index("scope", full=True) """ arguments = json.dumps([ service.config.model_dump(mode="json"), str(database.path), store.options, + store.database_name, str(marker), checkpoint, ]) diff --git a/tests/test_config_wizard.py b/tests/test_config_wizard.py index fe8094d20d..8e42a46157 100644 --- a/tests/test_config_wizard.py +++ b/tests/test_config_wizard.py @@ -353,7 +353,10 @@ def test_eof_never_falls_back_to_a_template(tmp_path: Path) -> None: assert not output.exists() -def test_seekdb_install_runs_behind_prompts_and_finishes_after_save(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.parametrize("database_name", ["", "test", "team_context"]) +def test_seekdb_install_runs_behind_prompts_and_finishes_after_save( + tmp_path: Path, monkeypatch, database_name: str +) -> None: output = tmp_path / "server.env" events: list[str] = [] @@ -387,10 +390,12 @@ def cancel(self) -> None: result = CliRunner().invoke( app, ["init", "--language", "zh", "--output", str(output)], - input=f"seekdb\n{tmp_path / 'seekdb'}\ny\nlocal\nbase\nn\n\nnone\ny\ny\n", + input=f"seekdb\n{tmp_path / 'seekdb'}\n{database_name}\ny\nlocal\nbase\nn\n\nnone\ny\ny\n", ) assert result.exit_code == 0, result.output + saved = parse_environment(output.read_text(encoding="utf-8")) + assert saved["POWERCONTEXT_SERVER_DATABASE_DATABASE"] == (database_name or "powercontext") assert "在后台开始安装" in result.output assert "阿里云镜像" in result.output assert "seekdb 依赖安装完成,版本 1.3.0" in result.output # noqa: RUF001 @@ -427,7 +432,7 @@ def wait(self, timeout=None) -> SeekDBInstallResult: result = CliRunner().invoke( app, ["init", "--language", "en", "--output", str(output)], - input=f"seekdb\n{tmp_path / 'seekdb'}\ny\nlocal\nbase\nn\n\nnone\ny\ny\n", + input=f"seekdb\n{tmp_path / 'seekdb'}\n\ny\nlocal\nbase\nn\n\nnone\ny\ny\n", ) assert result.exit_code == 0, result.output diff --git a/tests/test_database_migration_cli.py b/tests/test_database_migration_cli.py index 1d61ab0be9..3969843973 100644 --- a/tests/test_database_migration_cli.py +++ b/tests/test_database_migration_cli.py @@ -430,7 +430,7 @@ def test_remote_cli_uses_the_configured_maintenance_identity_and_coordination_sc tmp_path, monkeypatch, kind: Literal["seekdb", "oceanbase"] ): _database, command = _configure(tmp_path, monkeypatch) - database_name = "test" if kind == "seekdb" else "pc_probe" + database_name = "powercontext" if kind == "seekdb" else "pc_probe" environment = f"POWERCONTEXT_SERVER_DATABASE_KIND={kind}\n" if kind == "seekdb": environment += f"POWERCONTEXT_SERVER_DATABASE_PATH={tmp_path / 'seekdb'}\n" diff --git a/tests/test_server.py b/tests/test_server.py index 62cdf4d747..54fd3789a5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -452,7 +452,7 @@ def test_server_settings_select_embedded_seekdb(tmp_path, monkeypatch) -> None: assert isinstance(settings.database, SeekDBConfig) assert settings.database.path == data_dir / "seekdb" - assert settings.database.database == "test" + assert settings.database.database == "powercontext" assert not data_dir.exists() @@ -478,16 +478,18 @@ def test_server_settings_override_embedded_seekdb_path(tmp_path, monkeypatch) -> assert isinstance(settings.database, SeekDBConfig) assert settings.database.path == database_path - assert settings.database.database == "test" + assert settings.database.database == "powercontext" -def test_server_settings_reject_custom_embedded_seekdb_database(tmp_path, monkeypatch) -> None: +def test_server_settings_accept_custom_embedded_seekdb_database(tmp_path, monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_DATABASE", "custom") - with pytest.raises(ValidationError, match="Input should be 'test'"): - ServerSettings() + settings = ServerSettings() + assert isinstance(settings.database, SeekDBConfig) + assert settings.database.database == "custom" + assert "database" in settings.database.model_fields_set def test_supervisor_uses_primary_database_without_scheduler_sidecar(tmp_path, monkeypatch) -> None: @@ -1300,3 +1302,23 @@ def fail_to_log(*args, **kwargs) -> None: assert response.status_code == 500 assert re.fullmatch(r"[0-9a-f]{16}", response.headers["X-PowerContext-Request-ID"]) + + +def test_server_settings_keep_explicit_legacy_seekdb_database(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_DATABASE", "test") + settings = ServerSettings() + assert isinstance(settings.database, SeekDBConfig) + assert settings.database.database == "test" + assert "database" in settings.database.model_fields_set + + +def test_server_settings_default_oceanbase_database(monkeypatch) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "oceanbase") + monkeypatch.setenv( + "POWERCONTEXT_SERVER_DATABASE_URL", "mysql+aoceanbase://user:password@localhost:2881/?charset=utf8mb4" + ) + settings = ServerSettings() + assert isinstance(settings.database, OceanBaseConfig) + assert "/powercontext?charset=utf8mb4" in settings.database.url.get_secret_value() From 5e760476bf32890c63972ee6adbdfa93c82c3085 Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 16 Oct 2026 23:39:08 +0800 Subject: [PATCH 02/17] fix(evaluation): select the OceanBase database only in the URL path The resumable-target fingerprint accepted db/database query parameters as authority overrides, which the OceanBase config validator now rejects. Take the database from the URL path, refuse the overrides at this boundary too, and cover the policy directly so the runner no longer documents a capability it cannot reach. --- evaluation/memory/locomo_plus/runner.py | 10 ++++-- .../persistence/test_database_bootstrap.py | 21 ++++++++---- tests/test_locomo_plus_runner.py | 32 +++++++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/evaluation/memory/locomo_plus/runner.py b/evaluation/memory/locomo_plus/runner.py index 93bace9431..27a2d75356 100644 --- a/evaluation/memory/locomo_plus/runner.py +++ b/evaluation/memory/locomo_plus/runner.py @@ -319,15 +319,21 @@ def _database_config(settings: ServerSettings, output_directory: Path, backend: def _oceanbase_target(url: URL) -> dict[str, Any]: + """Fingerprint the connection target; only the URL path selects the database.""" if any(not isinstance(value, str) for value in url.query.values()): raise ValueError("OceanBase URL query parameters must not be repeated") # noqa: TRY003 if {key.casefold() for key in url.query} & {"read_default_file", "read_default_group", "sql_mode"}: raise ValueError("OceanBase external defaults and SQL mode overrides are not supported for resumable runs") # noqa: TRY003 + if {key.casefold() for key in url.query} & {"db", "database"}: + raise ValueError( # noqa: TRY003 + "OceanBase selects its database in the URL path; db and database query parameters are not supported" + ) try: - # URL query values override the translated authority/path in the installed dialect. + # The dialect translates the URL; query parameters can only override the + # connection authority, never the database, which is always the URL path. _, options = AsyncOceanBaseDialect().create_connect_args(url) username = options.get("user") - database = options.get("db") + database = url.database host = options.get("host") port = int(options.get("port", 3306)) if ( diff --git a/tests/builtin/persistence/test_database_bootstrap.py b/tests/builtin/persistence/test_database_bootstrap.py index 249e3de5f0..37603328b2 100644 --- a/tests/builtin/persistence/test_database_bootstrap.py +++ b/tests/builtin/persistence/test_database_bootstrap.py @@ -364,9 +364,18 @@ async def connect() -> None: asyncio.run(scenario()) -@pytest.mark.parametrize("parameter", ["db", "database"]) -def test_oceanbase_rejects_conflicting_query_database_selection(parameter: str) -> None: - with pytest.raises(ValidationError, match="URL path"): - OceanBaseConfig( - url=SecretStr(f"mysql+aoceanbase://user:password@host:2881/custom?charset=utf8mb4&{parameter}=other") - ) +@pytest.mark.parametrize( + "options", + [ + "db=private-secret", + "database=private-secret", + "db=first&db=private-secret", + "database=first&database=private-secret", + ], +) +def test_oceanbase_rejects_conflicting_query_database_selection(options: str) -> None: + with pytest.raises(ValidationError, match="URL path") as error: + OceanBaseConfig.model_validate({ + "url": f"mysql+aoceanbase://user:password@host:2881/custom?charset=utf8mb4&{options}" + }) + assert "private-secret" not in str(error.value) diff --git a/tests/test_locomo_plus_runner.py b/tests/test_locomo_plus_runner.py index c4bf207f46..cbd00fbd2e 100644 --- a/tests/test_locomo_plus_runner.py +++ b/tests/test_locomo_plus_runner.py @@ -28,6 +28,7 @@ from pydantic import SecretStr from pydantic_ai.messages import ModelResponse, RetryPromptPart, TextPart, UserPromptPart from pydantic_ai.models.function import FunctionModel +from sqlalchemy.engine import make_url from evaluation.memory.locomo.dataset import LoCoMoConversation, LoCoMoSession, LoCoMoTurn from evaluation.memory.locomo_plus import runner @@ -982,8 +983,8 @@ def unexpected_service(*args, **kwargs): ), ( "mysql+aoceanbase://tenant:old-secret@db.example:2881/benchmark?charset=utf8mb4", - "mysql+aoceanbase://ignored:new-secret@other.example:2882/ignored" - "?charset=utf8mb4&user=tenant&host=db.example&port=2881&db=benchmark", + "mysql+aoceanbase://ignored:new-secret@other.example:2882/benchmark" + "?charset=utf8mb4&user=tenant&host=db.example&port=2881", ), ( "mysql+aoceanbase://tenant:password@db.example:2881/benchmark" @@ -1037,11 +1038,18 @@ def unexpected_service(*args, **kwargs): @pytest.mark.parametrize( - "target_override", - ["host=other.example", "port=2882", "user=other-tenant", "db=other-database", "unix_socket=/other/database.sock"], + "target_suffix", + [ + "/benchmark?charset=utf8mb4&host=other.example", + "/benchmark?charset=utf8mb4&port=2882", + "/benchmark?charset=utf8mb4&user=other-tenant", + "/other-database?charset=utf8mb4", + "/benchmark?charset=utf8mb4&unix_socket=/other/database.sock", + ], + ids=["host", "port", "user", "database", "unix-socket"], ) def test_oceanbase_resume_rejects_effective_target_changes_before_opening_services( - tmp_path: Path, offline_benchmark: dict[str, Any], monkeypatch: pytest.MonkeyPatch, target_override: str + tmp_path: Path, offline_benchmark: dict[str, Any], monkeypatch: pytest.MonkeyPatch, target_suffix: str ) -> None: url = "mysql+aoceanbase://tenant:password@db.example:2881/benchmark?charset=utf8mb4" settings = _settings() @@ -1063,7 +1071,9 @@ def unexpected_service(*args, **kwargs): monkeypatch.setattr(runner, "open_builtin_runtime", unexpected_service) monkeypatch.setattr(runner, "open_model", unexpected_service) - settings.database = OceanBaseConfig.model_validate({"url": f"{url}&{target_override}"}) + settings.database = OceanBaseConfig.model_validate({ + "url": f"mysql+aoceanbase://tenant:password@db.example:2881{target_suffix}" + }) with pytest.raises(ValueError, match="run identity changed"): asyncio.run(runner.run_benchmark(**parameters)) assert (tmp_path / "run.json").read_bytes() == original_manifest @@ -1072,7 +1082,6 @@ def unexpected_service(*args, **kwargs): @pytest.mark.parametrize( "options", [ - "db=first&db=private-secret", "password=first&password=private-secret", "read_default_file=private-secret", "read_default_group=private-secret", @@ -1108,6 +1117,15 @@ def unexpected_service(*args, **kwargs): assert not (tmp_path / "run.json").exists() +@pytest.mark.parametrize("options", ["db=other-database", "database=other-database"]) +def test_oceanbase_target_rejects_query_database_overrides(options: str) -> None: + """Only the URL path selects the database; an override is refused rather than silently ignored.""" + base = "mysql+aoceanbase://tenant:password@db.example:2881/benchmark?charset=utf8mb4" + assert runner._oceanbase_target(make_url(base))["database"] == "benchmark" + with pytest.raises(ValueError, match="URL path"): + runner._oceanbase_target(make_url(f"{base}&{options}")) + + def test_unversioned_oceanbase_manifest_is_replayable_but_cannot_resume( tmp_path: Path, offline_benchmark: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: From b719edfeb7b5b3511e3f06bb17c9d17b1b66d45b Mon Sep 17 00:00:00 2001 From: lkx Date: Sat, 17 Oct 2026 11:56:00 +0800 Subject: [PATCH 03/17] fix(storage): prevent read-only migration commands from creating databases --- docs/en/docs/get-started/install-and-run.md | 8 ++-- docs/zh/docs/get-started/install-and-run.md | 5 +- .../builtin/persistence/bootstrap.py | 32 ++++++++++++- .../builtin/persistence/oceanbase/profile.py | 48 ++++++++++++++++--- .../builtin/persistence/seekdb/profile.py | 39 +++++++++++++-- src/powercontext/server/cli.py | 42 ++++++++++++++-- .../persistence/test_database_bootstrap.py | 40 +++++++++++++++- tests/e2e/test_database_bootstrap.py | 33 ++++++++++++- tests/test_atomic_memory_migration_cli.py | 35 ++++++++++++++ tests/test_processing_migration_cli.py | 39 +++++++++++++++ 10 files changed, 295 insertions(+), 26 deletions(-) diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index 3b557c1a28..b894c32521 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -231,9 +231,11 @@ Concurrent creation uses idempotent database/table DDL and handles duplicate-ind coordination for existing-schema migrations or change embedded seekDB's process-ownership restrictions. For Python callers, `OceanBaseProfile.open()` resolves an omitted URL database and provisions it when necessary. -`OceanBaseProfile.attach()` requires a caller-owned engine that already selects an existing database; it does not -replace that engine or provision a different connection target. Read-only database-maintenance commands never create a -missing database; explicit writable seekDB maintenance can provision its configured database before applying its bundle. +`OceanBaseProfile.open_existing()` connects only to an already provisioned database, and `attach()` requires a +caller-owned engine that already selects an existing database; neither replaces that engine or provisions a different +connection target. Read-only inspection (`plan`, `verify`, and the `db-migrate` inspection commands) never creates a +missing OceanBase or embedded seekDB database; explicit writable seekDB maintenance can provision its configured database +before applying its bundle. ## Verify the installation diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index 31f83499f4..118e619986 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -211,8 +211,9 @@ CREATE DATABASE IF NOT EXISTS `powercontext` CHARACTER SET utf8mb4; 对实例进程所有权的限制。 Python 调用方使用 `OceanBaseProfile.open()` 时可省略 URL 中的库名,并按需建库。 -`OceanBaseProfile.attach()` 要求调用方的 engine 已显式选择一个已有数据库,不替换 engine 或改变连接目标。 -只读数据库维护命令不会创建缺失库;显式的 seekDB 写入维护可以在应用迁移包前准备配置指定的数据库。 +`OceanBaseProfile.open_existing()` 只连接已准备好的数据库;`attach()` 要求调用方的 engine 已显式选择一个已有数据库。 +两者都不替换 engine 或改变连接目标。只读巡检(`plan`、`verify` 与 `db-migrate` 的检查类命令)不会创建缺失的 +OceanBase 或内嵌 seekDB 库;显式的 seekDB 写入维护可以在应用迁移包前准备配置指定的数据库。 ## 验证安装 diff --git a/src/powercontext/builtin/persistence/bootstrap.py b/src/powercontext/builtin/persistence/bootstrap.py index dd39a47ea2..aab232a8a5 100644 --- a/src/powercontext/builtin/persistence/bootstrap.py +++ b/src/powercontext/builtin/persistence/bootstrap.py @@ -55,6 +55,17 @@ def __init__(self, name: str, statement: str) -> None: ) +class MissingDatabaseError(PersistenceError): + """An inspection path requires an already provisioned database and never creates one.""" + + def __init__(self, name: str) -> None: + self.database_name = name + super().__init__( + f"Database {name!r} does not exist. This command inspects the configured database without creating it; " + "provision it first, or start the Server once to create the default database, then repeat the command." + ) + + def validate_database_name(name: str) -> str: """Accept database names supported throughout initialization and reflection.""" if "`" in name: @@ -109,8 +120,7 @@ async def ensure_database( require_backend: Callable[[AsyncConnection], Awaitable[None]] | None = None, ) -> None: """Reuse usable databases without issuing CREATE or listing other databases.""" - quoted = engine.dialect.identifier_preparer.quote_identifier(validate_database_name(name)) - statement = f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4" + statement = creation_statement(engine, name) try: if await database_is_available(engine, name=name): return @@ -135,6 +145,24 @@ async def ensure_database( raise PersistenceError(f"Database {name!r} is still unavailable after creation; retry startup") # noqa: TRY003 +async def require_existing_database(engine: AsyncEngine, *, name: str) -> None: + """Connect to a provisioned database without creating it or listing other databases.""" + try: + available = await database_is_available(engine, name=name) + except DBAPIError as error: + if mysql_error_code(error) != 1044: + raise + raise DatabaseAccessError(name, creation_statement(engine, name)) from None + if not available: + raise MissingDatabaseError(name) + + +def creation_statement(engine: AsyncEngine, name: str) -> str: + """Render the exact statement an administrator runs to provision this database.""" + quoted = engine.dialect.identifier_preparer.quote_identifier(validate_database_name(name)) + return f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4" + + async def _recover_creation_race(engine: AsyncEngine, name: str, statement: str, error: DBAPIError) -> None: """A concurrent creator can make a failed CREATE harmless, without granting CREATE to this account.""" code = mysql_error_code(error) diff --git a/src/powercontext/builtin/persistence/oceanbase/profile.py b/src/powercontext/builtin/persistence/oceanbase/profile.py index 690333b7ee..bf6e9bbc84 100644 --- a/src/powercontext/builtin/persistence/oceanbase/profile.py +++ b/src/powercontext/builtin/persistence/oceanbase/profile.py @@ -28,7 +28,12 @@ from sqlalchemy.exc import ArgumentError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine -from powercontext.builtin.persistence.bootstrap import DEFAULT_DATABASE, ensure_database, validate_database_name +from powercontext.builtin.persistence.bootstrap import ( + DEFAULT_DATABASE, + ensure_database, + require_existing_database, + validate_database_name, +) from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.dream_schema import assert_dream_schema_ready from powercontext.builtin.persistence.errors import PersistenceError @@ -130,17 +135,46 @@ async def open( ) -> AsyncIterator[OceanBaseProfile]: """Create, initialize and exclusively own an official OceanBase engine.""" + async with cls._connect(config, tables=tables, provision=True) as profile: + yield profile + + @classmethod + @asynccontextmanager + async def open_existing( + cls, + config: OceanBaseConfig, + *, + tables: tuple[Table, ...], + ) -> AsyncIterator[OceanBaseProfile]: + """Connect to an already provisioned database without creating one.""" + + async with cls._connect(config, tables=tables, provision=False) as profile: + yield profile + + @classmethod + @asynccontextmanager + async def _connect( + cls, + config: OceanBaseConfig, + *, + tables: tuple[Table, ...], + provision: bool, + ) -> AsyncIterator[OceanBaseProfile]: _register_official_dialect() url = _validate_oceanbase_url(config.url.get_secret_value()) + name = url.database or DEFAULT_DATABASE engine = _create_engine(config, url) database = AsyncDatabase.own(engine) try: - await ensure_database( - engine, - lambda: _create_engine(config, url.set(database="")), - name=url.database or DEFAULT_DATABASE, - require_backend=_require_mysql_tenant, - ) + if provision: + await ensure_database( + engine, + lambda: _create_engine(config, url.set(database="")), + name=name, + require_backend=_require_mysql_tenant, + ) + else: + await require_existing_database(engine, name=name) async with _initialized_profile(cls(database=database, tables=tables)) as profile: yield profile finally: diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index 7a92080f8a..8d395333bd 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -39,6 +39,7 @@ dispose_engine, ensure_database, mysql_error_code, + require_existing_database, validate_database_name, ) from powercontext.builtin.persistence.database import AsyncDatabase @@ -127,6 +128,31 @@ async def open( ) -> AsyncIterator[SeekDBProfile]: """Start seekdb locally and connect through its async Unix socket.""" + async with cls._connect(config, tables=tables, provision=True) as profile: + yield profile + + @classmethod + @asynccontextmanager + async def open_existing( + cls, + config: SeekDBConfig, + *, + tables: tuple[Table, ...], + ) -> AsyncIterator[SeekDBProfile]: + """Start seekdb locally and connect to an already provisioned database.""" + + async with cls._connect(config, tables=tables, provision=False) as profile: + yield profile + + @classmethod + @asynccontextmanager + async def _connect( + cls, + config: SeekDBConfig, + *, + tables: tuple[Table, ...], + provision: bool, + ) -> AsyncIterator[SeekDBProfile]: path = config.path.expanduser().resolve() path.parent.mkdir(parents=True, exist_ok=True) module = _load_binding() @@ -137,11 +163,14 @@ async def open( profile = cls(database=database, tables=tables, connection_options=instance.connection_options()) try: await _require_explicit_legacy_database(config, instance.connection_options()) - await ensure_database( - engine, - lambda: _create_engine(config, instance.connection_options(), database=""), - name=config.database, - ) + if provision: + await ensure_database( + engine, + lambda: _create_engine(config, instance.connection_options(), database=""), + name=config.database, + ) + else: + await require_existing_database(engine, name=config.database) await assert_dream_schema_ready(engine) async with database.transaction() as connection: await create_tables(connection, tables) diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index d0fccdf568..06d3f9dd74 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -28,6 +28,7 @@ from pydantic import ValidationError from sqlalchemy.exc import OperationalError +from powercontext.builtin.persistence.bootstrap import MissingDatabaseError from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex from powercontext.builtin.persistence.processing_migration import ( @@ -118,11 +119,25 @@ def processing_migrate( if action == "apply" and not maintenance_confirmed: raise typer.BadParameter("apply requires --maintenance-confirmed after stopping old workers and writes") # noqa: TRY003 with server_settings_context(env_file=env_file) as settings: - ready = asyncio.run(_processing_maintenance(settings, action, migration_id=migration_id, batch_size=batch_size)) + try: + ready = asyncio.run( + _processing_maintenance(settings, action, migration_id=migration_id, batch_size=batch_size) + ) + except MissingDatabaseError as error: + if action == "apply": + raise + raise typer.BadParameter(_missing_read_only_database(error)) from error if not ready: raise typer.Exit(code=1) +def _missing_read_only_database(error: MissingDatabaseError) -> str: + return ( + f"plan and verify require an existing database: {error.database_name!r} is missing. " + "Provision it first, or start the Server once to create the configured database" + ) + + async def _processing_maintenance( settings: ServerSettings, action: Literal["plan", "apply", "verify"], @@ -140,14 +155,21 @@ async def _processing_maintenance( ) manifest = canonical_processing_manifest(config) database = settings.database + read_only = action in {"plan", "verify"} if isinstance(database, SQLiteConfig): if not database.is_persistent: raise typer.BadParameter("offline migration requires a persistent database") # noqa: TRY003 opened = SQLiteProfile.open(database, tables=()) elif isinstance(database, OceanBaseConfig): - opened = OceanBaseProfile.open(database, tables=()) + opened = ( + OceanBaseProfile.open_existing(database, tables=()) + if read_only + else OceanBaseProfile.open(database, tables=()) + ) elif isinstance(database, SeekDBConfig): - opened = SeekDBProfile.open(database, tables=()) + opened = ( + SeekDBProfile.open_existing(database, tables=()) if read_only else SeekDBProfile.open(database, tables=()) + ) else: raise typer.BadParameter("unsupported migration database") # noqa: TRY003 async with opened as profile: @@ -230,6 +252,10 @@ def atomic_memory_migrate( settings, action, maintenance_confirmed=maintenance_confirmed, decisions=loaded ) ) + except MissingDatabaseError as error: + if action == "apply": + raise + raise typer.BadParameter(_missing_read_only_database(error)) from error except OperationalError as error: sqlite_code = getattr(error.orig, "sqlite_errorcode", None) if ( @@ -300,10 +326,16 @@ async def _atomic_memory_maintenance( ) index = SQLiteAtomicMemoryIndex(embedding_profile) elif isinstance(database, OceanBaseConfig): - opened = OceanBaseProfile.open(database, tables=()) + opened = ( + OceanBaseProfile.open_existing(database, tables=()) + if read_only + else OceanBaseProfile.open(database, tables=()) + ) index = OceanBaseAtomicMemoryIndex(embedding_profile) elif isinstance(database, SeekDBConfig): - opened = SeekDBProfile.open(database, tables=()) + opened = ( + SeekDBProfile.open_existing(database, tables=()) if read_only else SeekDBProfile.open(database, tables=()) + ) index = OceanBaseAtomicMemoryIndex(embedding_profile) else: raise typer.BadParameter("unsupported migration database") # noqa: TRY003 diff --git a/tests/builtin/persistence/test_database_bootstrap.py b/tests/builtin/persistence/test_database_bootstrap.py index 37603328b2..c5bddac333 100644 --- a/tests/builtin/persistence/test_database_bootstrap.py +++ b/tests/builtin/persistence/test_database_bootstrap.py @@ -29,7 +29,13 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncEngine -from powercontext.builtin.persistence.bootstrap import DatabaseAccessError, DatabaseCreationError, ensure_database +from powercontext.builtin.persistence.bootstrap import ( + DatabaseAccessError, + DatabaseCreationError, + MissingDatabaseError, + ensure_database, + require_existing_database, +) from powercontext.builtin.persistence.errors import PersistenceError from powercontext.builtin.persistence.oceanbase import ( OceanBaseConfig, @@ -379,3 +385,35 @@ def test_oceanbase_rejects_conflicting_query_database_selection(options: str) -> "url": f"mysql+aoceanbase://user:password@host:2881/custom?charset=utf8mb4&{options}" }) assert "private-secret" not in str(error.value) + + +def test_read_only_inspection_reuses_an_existing_database() -> None: + async def scenario() -> None: + instance = Instance({"custom-db"}) + await require_existing_database(cast(AsyncEngine, instance.engine("custom-db")), name="custom-db") + assert not any(statement.startswith("CREATE") for statement in instance.statements) + + asyncio.run(scenario()) + + +def test_read_only_inspection_reports_a_missing_database_without_creating_it() -> None: + async def scenario() -> None: + instance = Instance(set()) + with pytest.raises(MissingDatabaseError, match="does not exist") as error: + await require_existing_database(cast(AsyncEngine, instance.engine("custom-db")), name="custom-db") + assert error.value.database_name == "custom-db" + assert not any(statement.startswith("CREATE") for statement in instance.statements) + + asyncio.run(scenario()) + + +def test_read_only_inspection_reports_denied_access_without_claiming_absence() -> None: + async def scenario() -> None: + instance = Instance(set()) + instance.failure = 1044 + with pytest.raises(DatabaseAccessError, match="denied") as error: + await require_existing_database(cast(AsyncEngine, instance.engine("custom-db")), name="custom-db") + assert "CREATE DATABASE IF NOT EXISTS `custom-db`" in str(error.value) + assert not any(statement.startswith("CREATE") for statement in instance.statements) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index d89f1a1293..b977f06f5c 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -38,7 +38,12 @@ from sqlalchemy.exc import DBAPIError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine -from powercontext.builtin.persistence.bootstrap import DatabaseAccessError, DatabaseCreationError, mysql_error_code +from powercontext.builtin.persistence.bootstrap import ( + DatabaseAccessError, + DatabaseCreationError, + MissingDatabaseError, + mysql_error_code, +) from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.oceanbase.profile import _register_official_dialect from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError, SeekDBConfig, SeekDBProfile @@ -114,6 +119,32 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_inspection_reuses_an_existing_database_without_provisioning(short_tmp_path: Path) -> None: + pytest.importorskip("pylibseekdb") + + async def scenario() -> None: + path = short_tmp_path / "seekdb" + retained = SeekDBConfig(path=path, database="retained_context") + async with SeekDBProfile.open(retained, tables=(_RECORDS,)) as profile: + await insert_record(profile) + # A second identical attempt proves inspection never created the database. + for _attempt in range(2): + with pytest.raises(MissingDatabaseError, match="does not exist"): + async with SeekDBProfile.open_existing(SeekDBConfig(path=path, database="absent_context"), tables=()): + pytest.fail("Inspection must not provision a missing database") + async with SeekDBProfile.open_existing(retained, tables=()) as profile: + await assert_record(profile, retained.database) + async with profile.database.transaction() as connection: + absent = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", + ("absent_context",), + ) + assert absent.all() == [] + + asyncio.run(scenario()) + + @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") def test_seekdb_legacy_choice_preserves_old_data_and_separates_new_database(short_tmp_path: Path) -> None: pytest.importorskip("pylibseekdb") diff --git a/tests/test_atomic_memory_migration_cli.py b/tests/test_atomic_memory_migration_cli.py index 8ddf8aa57f..2639ff376d 100644 --- a/tests/test_atomic_memory_migration_cli.py +++ b/tests/test_atomic_memory_migration_cli.py @@ -24,9 +24,11 @@ from click import unstyle from typer.testing import CliRunner +from powercontext.builtin.persistence.bootstrap import MissingDatabaseError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.cli.app import create_cli +from powercontext.server import cli as server_cli from powercontext.server.authz import PrincipalRef from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.cli import app @@ -261,3 +263,36 @@ async def read_and_write(): assert [hit.hit.artifact_ref.artifact_id for hit in result.hits] == [created.artifact_id] asyncio.run(read_and_write()) + + +class _ReadOnlyInspection: + """A backend profile whose read-only opener refuses to provision the database.""" + + @classmethod + def open(cls, config, *, tables): + raise AssertionError("read-only inspection must not use the provisioning opener") # noqa: TRY003 + + @classmethod + def open_existing(cls, config, *, tables): + raise MissingDatabaseError("configured_db") + + +@pytest.mark.parametrize("action", ["plan", "verify"]) +@pytest.mark.parametrize("backend", ["oceanbase", "seekdb"]) +def test_inspection_never_provisions_the_configured_database(tmp_path, monkeypatch, maintenance_cli, action, backend): + monkeypatch.setattr(server_cli, "OceanBaseProfile", _ReadOnlyInspection) + monkeypatch.setattr(server_cli, "SeekDBProfile", _ReadOnlyInspection) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", backend) + if backend == "oceanbase": + monkeypatch.setenv( + "POWERCONTEXT_SERVER_DATABASE_URL", + "mysql+aoceanbase://user:password@host:2881/configured_db?charset=utf8mb4", + ) + else: + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + + result = CliRunner().invoke(maintenance_cli, ["server", "atomic-memory-migrate", "--action", action]) + + assert result.exit_code == 2, result.output + assert "require an existing database" in unstyle(result.output) + assert "configured_db" in unstyle(result.output) diff --git a/tests/test_processing_migration_cli.py b/tests/test_processing_migration_cli.py index 7995253fab..47e75f3753 100644 --- a/tests/test_processing_migration_cli.py +++ b/tests/test_processing_migration_cli.py @@ -18,12 +18,15 @@ import json import os +import pytest from click import unstyle from typer.testing import CliRunner +from powercontext.builtin.persistence.bootstrap import MissingDatabaseError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.cli.app import create_cli +from powercontext.server import cli as server_cli from powercontext.server.cli import app @@ -58,3 +61,39 @@ async def setup(): replay = runner.invoke(cli, [*command, "--action", "apply", "--maintenance-confirmed"]) assert replay.exit_code == 0, replay.output assert config.url not in plan.output + applied.output + verified.output + replay.output + + +class _ReadOnlyInspection: + """A backend profile whose read-only opener refuses to provision the database.""" + + @classmethod + def open(cls, config, *, tables): + raise AssertionError("read-only inspection must not use the provisioning opener") # noqa: TRY003 + + @classmethod + def open_existing(cls, config, *, tables): + raise MissingDatabaseError("configured_db") + + +@pytest.mark.parametrize("action", ["plan", "verify"]) +@pytest.mark.parametrize("backend", ["oceanbase", "seekdb"]) +def test_processing_inspection_never_provisions_the_configured_database(tmp_path, monkeypatch, action, backend): + for key in tuple(os.environ): + if key.startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(key) + monkeypatch.setattr(server_cli, "OceanBaseProfile", _ReadOnlyInspection) + monkeypatch.setattr(server_cli, "SeekDBProfile", _ReadOnlyInspection) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", backend) + if backend == "oceanbase": + monkeypatch.setenv( + "POWERCONTEXT_SERVER_DATABASE_URL", + "mysql+aoceanbase://user:password@host:2881/configured_db?charset=utf8mb4", + ) + else: + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + + result = CliRunner().invoke(create_cli([app]), ["server", "processing-migrate", "--action", action]) + + assert result.exit_code == 2, result.output + assert "require an existing database" in unstyle(result.output) + assert "configured_db" in unstyle(result.output) From cfd823fbd8a922d97dff055170d1633538ae931d Mon Sep 17 00:00:00 2001 From: lkx Date: Sat, 17 Oct 2026 14:33:07 +0800 Subject: [PATCH 04/17] fix(storage): preserve seekdb paths during read-only inspection --- docs/en/docs/get-started/install-and-run.md | 4 +++ docs/zh/docs/get-started/install-and-run.md | 3 ++ .../persistence/migrations/connections.py | 7 +--- .../builtin/persistence/seekdb/__init__.py | 2 ++ .../builtin/persistence/seekdb/profile.py | 27 ++++++++++++++- src/powercontext/server/cli.py | 6 +++- tests/test_atomic_memory_migration_cli.py | 31 +++++++++++++++++ tests/test_processing_migration_cli.py | 34 +++++++++++++++++++ 8 files changed, 106 insertions(+), 8 deletions(-) diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index b894c32521..773c56f9f2 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -237,6 +237,10 @@ connection target. Read-only inspection (`plan`, `verify`, and the `db-migrate` missing OceanBase or embedded seekDB database; explicit writable seekDB maintenance can provision its configured database before applying its bundle. +Read-only seekDB inspection requires an initialized instance directory. A missing or empty path is rejected before +starting the native engine, without creating parent directories or storage files. A file or a nonempty directory without +recognized native storage is also rejected without changing its contents. Ordinary startup still initializes new instances. + ## Verify the installation ```bash diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index 118e619986..7bf91bf7b6 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -215,6 +215,9 @@ Python 调用方使用 `OceanBaseProfile.open()` 时可省略 URL 中的库名 两者都不替换 engine 或改变连接目标。只读巡检(`plan`、`verify` 与 `db-migrate` 的检查类命令)不会创建缺失的 OceanBase 或内嵌 seekDB 库;显式的 seekDB 写入维护可以在应用迁移包前准备配置指定的数据库。 +只读 seekDB 巡检要求实例目录已初始化。路径缺失或目录为空时,会在启动 native 引擎前报错,不创建父目录或存储文件。 +路径为文件,或非空目录中没有可识别的 native 存储时,也会拒绝操作并保留原有内容。普通启动仍可初始化新实例。 + ## 验证安装 ```bash diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index b994db96e5..f0b5a2da54 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -108,7 +108,7 @@ def run( nonempty = path.exists() and any(path.iterdir()) if not writable and not nonempty: return operation(None, identity, lambda: None) - if nonempty and not _seekdb_storage_exists(path): + if nonempty and not seekdb_profile.has_native_storage(path): raise MigrationError( "unsupported_target", "A nonempty seekdb directory must contain recognized native storage." ) @@ -233,11 +233,6 @@ def verify() -> None: await _dispose_engine(engine) -def _seekdb_storage_exists(path: Path) -> bool: - # These native markers are verified by the isolated seekdb reopen test. - return (path / "etc" / "seekdb.data_version.bin").is_file() and (path / "store" / "sstable" / "meta.db").is_file() - - def _directory_inode(path: Path) -> tuple[int, int]: try: stat = path.stat() diff --git a/src/powercontext/builtin/persistence/seekdb/__init__.py b/src/powercontext/builtin/persistence/seekdb/__init__.py index ac26586f8b..a3d85b7774 100644 --- a/src/powercontext/builtin/persistence/seekdb/__init__.py +++ b/src/powercontext/builtin/persistence/seekdb/__init__.py @@ -17,6 +17,7 @@ from powercontext.builtin.persistence.seekdb.profile import ( LegacySeekDBDatabaseError, SeekDBConfig, + SeekDBInspectionError, SeekDBProfile, SeekDBUnavailableError, ) @@ -24,6 +25,7 @@ __all__ = ( "LegacySeekDBDatabaseError", "SeekDBConfig", + "SeekDBInspectionError", "SeekDBProfile", "SeekDBUnavailableError", ) diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index 8d395333bd..d05201e291 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -77,6 +77,10 @@ def __init__(self) -> None: super().__init__("Embedded seekdb requires powercontext[seekdb] on a supported Linux or macOS platform") +class SeekDBInspectionError(PersistenceError): + """Read-only inspection cannot initialize or replace native seekdb storage.""" + + class SeekDBConfig(BaseModel): """Validated configuration for one embedded seekdb instance.""" @@ -154,7 +158,10 @@ async def _connect( provision: bool, ) -> AsyncIterator[SeekDBProfile]: path = config.path.expanduser().resolve() - path.parent.mkdir(parents=True, exist_ok=True) + if provision: + path.parent.mkdir(parents=True, exist_ok=True) + else: + _require_existing_storage(path) module = _load_binding() instance = await _open_instance(module, path) try: @@ -186,6 +193,24 @@ async def _connect( instance.close() +def has_native_storage(path: Path) -> bool: + """Recognize the native storage markers used for seekdb reopen and maintenance.""" + return (path / "etc" / "seekdb.data_version.bin").is_file() and (path / "store" / "sstable" / "meta.db").is_file() + + +def _require_existing_storage(path: Path) -> None: + if not path.exists() or (path.is_dir() and not any(path.iterdir())): + raise SeekDBInspectionError( # noqa: TRY003 + "Read-only inspection requires an existing seekdb instance. The configured path is missing or empty; " + "start the Server to initialize it, then repeat the inspection." + ) + if not path.is_dir() or not has_native_storage(path): + raise SeekDBInspectionError( # noqa: TRY003 + "Read-only inspection requires an existing seekdb instance. The configured path is not a directory " + "with recognized native storage; select an initialized seekdb directory. Existing contents were preserved." + ) + + def _load_binding() -> ModuleType: try: return import_module("pylibseekdb") diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 06d3f9dd74..951c799cc3 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -36,7 +36,7 @@ plan_processing_migration, verify_processing_migration, ) -from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBInspectionError, SeekDBProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.sqlite.atomic_memory_index import SQLiteAtomicMemoryIndex from powercontext.builtin.runtime.atomic_memory_rebuild import rebuild_atomic_memory_projection @@ -127,6 +127,8 @@ def processing_migrate( if action == "apply": raise raise typer.BadParameter(_missing_read_only_database(error)) from error + except SeekDBInspectionError as error: + raise typer.BadParameter(str(error)) from error if not ready: raise typer.Exit(code=1) @@ -256,6 +258,8 @@ def atomic_memory_migrate( if action == "apply": raise raise typer.BadParameter(_missing_read_only_database(error)) from error + except SeekDBInspectionError as error: + raise typer.BadParameter(str(error)) from error except OperationalError as error: sqlite_code = getattr(error.orig, "sqlite_errorcode", None) if ( diff --git a/tests/test_atomic_memory_migration_cli.py b/tests/test_atomic_memory_migration_cli.py index 2639ff376d..f9aa24587e 100644 --- a/tests/test_atomic_memory_migration_cli.py +++ b/tests/test_atomic_memory_migration_cli.py @@ -296,3 +296,34 @@ def test_inspection_never_provisions_the_configured_database(tmp_path, monkeypat assert result.exit_code == 2, result.output assert "require an existing database" in unstyle(result.output) assert "configured_db" in unstyle(result.output) + + +@pytest.mark.parametrize("action", ["plan", "verify"]) +@pytest.mark.parametrize("target_state", ["missing", "empty", "file", "unrecognized"]) +def test_inspection_preserves_uninitialized_seekdb_target(tmp_path, monkeypatch, maintenance_cli, action, target_state): + path = tmp_path / "missing-parent" / "seekdb" + if target_state == "empty": + path.mkdir(parents=True) + elif target_state == "file": + path.parent.mkdir() + path.write_bytes(b"retained evidence") + elif target_state == "unrecognized": + path.mkdir(parents=True) + (path / "user-content.txt").write_bytes(b"retained evidence") + original = { + item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") + } + + def unexpected_binding(): + pytest.fail("inspection must reject an uninitialized target before starting the native engine") + + monkeypatch.setattr("powercontext.builtin.persistence.seekdb.profile._load_binding", unexpected_binding) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(path)) + result = CliRunner().invoke(maintenance_cli, ["server", "atomic-memory-migrate", "--action", action]) + + assert result.exit_code == 2, result.output + assert "existing seekdb instance" in unstyle(result.output) + assert { + item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") + } == original diff --git a/tests/test_processing_migration_cli.py b/tests/test_processing_migration_cli.py index 47e75f3753..939f16af1b 100644 --- a/tests/test_processing_migration_cli.py +++ b/tests/test_processing_migration_cli.py @@ -97,3 +97,37 @@ def test_processing_inspection_never_provisions_the_configured_database(tmp_path assert result.exit_code == 2, result.output assert "require an existing database" in unstyle(result.output) assert "configured_db" in unstyle(result.output) + + +@pytest.mark.parametrize("action", ["plan", "verify"]) +@pytest.mark.parametrize("target_state", ["missing", "empty", "file", "unrecognized"]) +def test_processing_inspection_preserves_uninitialized_seekdb_target(tmp_path, monkeypatch, action, target_state): + for key in tuple(os.environ): + if key.startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(key) + path = tmp_path / "missing-parent" / "seekdb" + if target_state == "empty": + path.mkdir(parents=True) + elif target_state == "file": + path.parent.mkdir() + path.write_bytes(b"retained evidence") + elif target_state == "unrecognized": + path.mkdir(parents=True) + (path / "user-content.txt").write_bytes(b"retained evidence") + original = { + item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") + } + + def unexpected_binding(): + pytest.fail("inspection must reject an uninitialized target before starting the native engine") + + monkeypatch.setattr("powercontext.builtin.persistence.seekdb.profile._load_binding", unexpected_binding) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(path)) + result = CliRunner().invoke(create_cli([app]), ["server", "processing-migrate", "--action", action]) + + assert result.exit_code == 2, result.output + assert "existing seekdb instance" in unstyle(result.output) + assert { + item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") + } == original From 2fef5fabe16961bbc1f76d10da6e13ca6242e9ad Mon Sep 17 00:00:00 2001 From: lkx Date: Sun, 18 Oct 2026 20:36:35 +0800 Subject: [PATCH 05/17] fix(storage): preserve percent signs in database provisioning guidance --- .../builtin/persistence/bootstrap.py | 13 +++--- .../persistence/test_database_bootstrap.py | 41 +++++++++++-------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/powercontext/builtin/persistence/bootstrap.py b/src/powercontext/builtin/persistence/bootstrap.py index aab232a8a5..aaf7260cce 100644 --- a/src/powercontext/builtin/persistence/bootstrap.py +++ b/src/powercontext/builtin/persistence/bootstrap.py @@ -120,7 +120,7 @@ async def ensure_database( require_backend: Callable[[AsyncConnection], Awaitable[None]] | None = None, ) -> None: """Reuse usable databases without issuing CREATE or listing other databases.""" - statement = creation_statement(engine, name) + statement = creation_statement(name) try: if await database_is_available(engine, name=name): return @@ -135,7 +135,10 @@ async def ensure_database( async with admin.begin() as connection: if require_backend is not None: await require_backend(connection) - await connection.exec_driver_sql(statement) + driver_statement = statement + if admin.dialect.paramstyle in {"format", "pyformat"}: + driver_statement = statement.replace("%", "%%") + await connection.exec_driver_sql(driver_statement) except DBAPIError as error: await _recover_creation_race(engine, name, statement, error) finally: @@ -152,14 +155,14 @@ async def require_existing_database(engine: AsyncEngine, *, name: str) -> None: except DBAPIError as error: if mysql_error_code(error) != 1044: raise - raise DatabaseAccessError(name, creation_statement(engine, name)) from None + raise DatabaseAccessError(name, creation_statement(name)) from None if not available: raise MissingDatabaseError(name) -def creation_statement(engine: AsyncEngine, name: str) -> str: +def creation_statement(name: str) -> str: """Render the exact statement an administrator runs to provision this database.""" - quoted = engine.dialect.identifier_preparer.quote_identifier(validate_database_name(name)) + quoted = f"`{validate_database_name(name)}`" return f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4" diff --git a/tests/builtin/persistence/test_database_bootstrap.py b/tests/builtin/persistence/test_database_bootstrap.py index c5bddac333..0fb24b15eb 100644 --- a/tests/builtin/persistence/test_database_bootstrap.py +++ b/tests/builtin/persistence/test_database_bootstrap.py @@ -86,13 +86,15 @@ def begin(self): return self.connect() async def exec_driver_sql(self, statement: str): + if self.dialect.paramstyle in {"format", "pyformat"}: + statement = statement % () self.instance.statements.append(statement) if statement.startswith("CREATE DATABASE"): if self.instance.on_create is not None: self.instance.on_create() if self.instance.creation_failure is not None: raise driver_error(self.instance.creation_failure) - self.instance.databases.add("custom-db") + self.instance.databases.add(statement.split("`", 2)[1]) row = ("pc_sources",) if self.instance.legacy_tables else None value = self.instance.case_mode if statement == "SELECT @@lower_case_table_names" else self.name return SimpleNamespace(first=lambda: row, scalar_one=lambda: value) @@ -179,17 +181,18 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_missing_database_is_created_with_a_quoted_identifier() -> None: +@pytest.mark.parametrize("name", ["custom-db", "team%ctx", "team%%ctx"]) +def test_missing_database_is_created_with_a_quoted_identifier(name: str) -> None: async def scenario() -> None: instance = Instance(set()) await ensure_database( - cast(AsyncEngine, instance.engine("custom-db")), + cast(AsyncEngine, instance.engine(name)), lambda: cast(AsyncEngine, instance.engine(None)), - name="custom-db", + name=name, ) - assert instance.databases == {"custom-db"} + assert instance.databases == {name} assert [sql for sql in instance.statements if sql.startswith("CREATE")] == [ - "CREATE DATABASE IF NOT EXISTS `custom-db` CHARACTER SET utf8mb4" + f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4" ] assert all(engine.disposed for engine in instance.engines if engine.name is None) @@ -197,17 +200,18 @@ async def scenario() -> None: @pytest.mark.parametrize("code", [1044, 1142, 1227]) -def test_missing_database_permission_error_has_manual_instructions(code: int) -> None: +@pytest.mark.parametrize("name", ["custom-db", "team%ctx", "team%%ctx"]) +def test_missing_database_permission_error_has_manual_instructions(code: int, name: str) -> None: async def scenario() -> None: instance = Instance(set()) instance.creation_failure = code with pytest.raises(DatabaseCreationError, match="administrator") as error: await ensure_database( - cast(AsyncEngine, instance.engine("custom-db")), + cast(AsyncEngine, instance.engine(name)), lambda: cast(AsyncEngine, instance.engine(None)), - name="custom-db", + name=name, ) - assert "CREATE DATABASE IF NOT EXISTS `custom-db`" in str(error.value) + assert f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4;" in str(error.value) assert "same instance/tenant" in str(error.value) assert instance.databases == set() @@ -297,16 +301,18 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_access_denied_does_not_claim_database_is_missing_or_attempt_creation() -> None: +@pytest.mark.parametrize("name", ["custom-db", "team%ctx", "team%%ctx"]) +def test_access_denied_does_not_claim_database_is_missing_or_attempt_creation(name: str) -> None: async def scenario() -> None: instance = Instance(set()) instance.failure = 1044 - with pytest.raises(DatabaseAccessError, match="If it is missing"): + with pytest.raises(DatabaseAccessError, match="If it is missing") as error: await ensure_database( - cast(AsyncEngine, instance.engine("custom-db")), + cast(AsyncEngine, instance.engine(name)), lambda: cast(AsyncEngine, instance.engine(None)), - name="custom-db", + name=name, ) + assert f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4;" in str(error.value) assert instance.statements == [] asyncio.run(scenario()) @@ -407,13 +413,14 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_read_only_inspection_reports_denied_access_without_claiming_absence() -> None: +@pytest.mark.parametrize("name", ["custom-db", "team%ctx", "team%%ctx"]) +def test_read_only_inspection_reports_denied_access_without_claiming_absence(name: str) -> None: async def scenario() -> None: instance = Instance(set()) instance.failure = 1044 with pytest.raises(DatabaseAccessError, match="denied") as error: - await require_existing_database(cast(AsyncEngine, instance.engine("custom-db")), name="custom-db") - assert "CREATE DATABASE IF NOT EXISTS `custom-db`" in str(error.value) + await require_existing_database(cast(AsyncEngine, instance.engine(name)), name=name) + assert f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4;" in str(error.value) assert not any(statement.startswith("CREATE") for statement in instance.statements) asyncio.run(scenario()) From b0e379f6aae5495220e7dba4206e24df03439cf5 Mon Sep 17 00:00:00 2001 From: lkx Date: Mon, 19 Oct 2026 23:39:06 +0800 Subject: [PATCH 06/17] fix(storage): preserve legacy seekdb migration errors --- docs/en/docs/operate/database-migrations.md | 4 +- docs/zh/docs/operate/database-migrations.md | 2 +- .../persistence/migrations/connections.py | 16 ++++- .../builtin/persistence/seekdb/profile.py | 8 ++- src/powercontext/server/cli.py | 22 ++++--- .../test_database_migration_connections.py | 19 ++++++ .../persistence/test_seekdb_profile.py | 35 ++++++++++- tests/e2e/test_database_bootstrap.py | 59 +++++++++++++++++++ tests/test_atomic_memory_migration_cli.py | 34 +++++++++++ tests/test_database_migration_cli.py | 24 +++++++- tests/test_processing_migration_cli.py | 28 +++++++++ 11 files changed, 235 insertions(+), 16 deletions(-) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index aa89e309a7..0c25b28702 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -84,7 +84,9 @@ OceanBase maintenance must be able to read the tenant ID, cluster ID, and tenant identity return `target_identity_unavailable`; a proxy hostname is not used as a substitute database identity. seekdb inspection does not start an engine for a missing path or empty directory. A nonempty directory without a -recognized engine layout returns `unsupported_target`. Opening a valid existing engine may write native logs and other +recognized engine layout returns `unsupported_target`. An unconfigured database name whose old `test` database still +holds PowerContext tables returns `explicit_database_required` instead of selecting either database implicitly; select +the name explicitly and rerun the command. Opening a valid existing engine may write native logs and other engine housekeeping files; read-only migration commands do not perform business DDL or initialize tables. `--evidence-dir PATH` selects persistent storage outside the target database for maintenance progress and recovery diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index b14dff53da..ac32833441 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -52,7 +52,7 @@ powercontext server db-migrate apply --env-file deployment.env --backup manual \ OceanBase 维护须能通过 `effective_tenant_id()`、`oceanbase.GV$OB_PARAMETERS`、`oceanbase.DBA_OB_TENANTS` 读取租户 ID、集群 ID 和租户创建信息。权限不足或身份信息不完整时返回 `target_identity_unavailable`,不以代理主机名代替数据库身份。 -seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 +seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 `--evidence-dir PATH` 指定目标库之外的持久目录,保存维护进度及恢复引用。`status`、`plan`、`apply`、`verify` 和重试必须使用同一目录;进程、容器或迁移 Job 退出后仍须保留。每个数据库使用独立目录,并限制为迁移操作者可访问。 diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index f0b5a2da54..9fe364c42d 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -18,7 +18,7 @@ import asyncio import socket -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -141,7 +141,7 @@ async def _seekdb( config.model_copy(update={"echo": False}), instance.connection_options() ) try: - await seekdb_profile._require_explicit_legacy_database(config, instance.connection_options()) + await _require_explicit_database(config, instance.connection_options()) if writable: await ensure_database( engine, @@ -233,6 +233,18 @@ def verify() -> None: await _dispose_engine(engine) +async def _require_explicit_database(config: SeekDBConfig, options: Mapping[str, object]) -> None: + """Turn the expected legacy-database refusal into the maintenance error contract. + + An uncaught exception would replace the documented blocked JSON payload with a + traceback, so the operator-facing guidance is preserved inside a MigrationError. + """ + try: + await seekdb_profile._require_explicit_legacy_database(config, options) + except seekdb_profile.LegacySeekDBDatabaseError as error: + raise MigrationError("explicit_database_required", str(error)) from error + + def _directory_inode(path: Path) -> tuple[int, int]: try: stat = path.stat() diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index d05201e291..a1fa201859 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -243,10 +243,14 @@ async def _finish_task(task: asyncio.Task[_T]) -> _T: async def _require_explicit_legacy_database(config: SeekDBConfig, options: Mapping[str, object]) -> None: - """Never hide old PowerContext tables behind the changed implicit default.""" + """Never hide old PowerContext tables behind the changed implicit default. + + This probe is diagnostic: it never echoes SQL, because the caller may own the + process's machine-readable output and the echo handler writes to stdout. + """ if "database" in config.model_fields_set: return - legacy = _create_engine(config, options, database="test") + legacy = _create_engine(config.model_copy(update={"echo": False}), options, database="test") try: try: async with legacy.connect() as connection: diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 951c799cc3..72320a599b 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -36,7 +36,12 @@ plan_processing_migration, verify_processing_migration, ) -from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBInspectionError, SeekDBProfile +from powercontext.builtin.persistence.seekdb import ( + LegacySeekDBDatabaseError, + SeekDBConfig, + SeekDBInspectionError, + SeekDBProfile, +) from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.sqlite.atomic_memory_index import SQLiteAtomicMemoryIndex from powercontext.builtin.runtime.atomic_memory_rebuild import rebuild_atomic_memory_projection @@ -127,7 +132,7 @@ def processing_migrate( if action == "apply": raise raise typer.BadParameter(_missing_read_only_database(error)) from error - except SeekDBInspectionError as error: + except (SeekDBInspectionError, LegacySeekDBDatabaseError) as error: raise typer.BadParameter(str(error)) from error if not ready: raise typer.Exit(code=1) @@ -258,7 +263,7 @@ def atomic_memory_migrate( if action == "apply": raise raise typer.BadParameter(_missing_read_only_database(error)) from error - except SeekDBInspectionError as error: + except (SeekDBInspectionError, LegacySeekDBDatabaseError) as error: raise typer.BadParameter(str(error)) from error except OperationalError as error: sqlite_code = getattr(error.orig, "sqlite_errorcode", None) @@ -287,11 +292,14 @@ def atomic_memory_rebuild_projection( if not maintenance_confirmed: raise typer.BadParameter("projection rebuild requires --maintenance-confirmed after stopping every writer") # noqa: TRY003 with server_settings_context(env_file=env_file) as settings: - ready = asyncio.run( - _atomic_memory_maintenance( - settings, "rebuild-projection", maintenance_confirmed=maintenance_confirmed, batch_size=batch_size + try: + ready = asyncio.run( + _atomic_memory_maintenance( + settings, "rebuild-projection", maintenance_confirmed=maintenance_confirmed, batch_size=batch_size + ) ) - ) + except LegacySeekDBDatabaseError as error: + raise typer.BadParameter(str(error)) from error if not ready: raise typer.Exit(code=1) diff --git a/tests/builtin/persistence/test_database_migration_connections.py b/tests/builtin/persistence/test_database_migration_connections.py index 8d781e3c6c..a4a9b5ea68 100644 --- a/tests/builtin/persistence/test_database_migration_connections.py +++ b/tests/builtin/persistence/test_database_migration_connections.py @@ -408,3 +408,22 @@ def reacquire( return target assert adapter.run(reacquire, writable=True) == identity + + +def test_seekdb_legacy_database_refusal_is_a_maintenance_error(tmp_path: Path, monkeypatch) -> None: + path, _assert_locked = _fake_seekdb(tmp_path, monkeypatch) + + async def refuse(_config, _options) -> None: + from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError + + raise LegacySeekDBDatabaseError(path) + + monkeypatch.setattr(connections.seekdb_profile, "_require_explicit_legacy_database", refuse) + + def unexpected(connection: Connection | None, _identity: BackendIdentity, verify: Callable[[], None]): + pytest.fail("An expected legacy refusal must not run the maintenance operation") + + with pytest.raises(MigrationError) as error: + MaintenanceConnections(SeekDBConfig(path=path)).run(unexpected, writable=True) + assert error.value.code == "explicit_database_required" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE" in str(error.value) diff --git a/tests/builtin/persistence/test_seekdb_profile.py b/tests/builtin/persistence/test_seekdb_profile.py index 00779da841..8a3a0ebf84 100644 --- a/tests/builtin/persistence/test_seekdb_profile.py +++ b/tests/builtin/persistence/test_seekdb_profile.py @@ -15,8 +15,9 @@ from __future__ import annotations import asyncio -from contextlib import AbstractAsyncContextManager -from types import TracebackType +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path +from types import SimpleNamespace, TracebackType from typing import cast import pytest @@ -289,3 +290,33 @@ async def open_profile() -> None: assert events == [f"aopen:{(tmp_path / 'seekdb').resolve()}", "instance.close"] asyncio.run(scenario()) + + +def test_legacy_database_probe_never_echoes_sql(monkeypatch) -> None: + """The probe writes to stdout for nobody: maintenance owns machine-readable output.""" + seen: list[SeekDBConfig] = [] + + class _Connection: + async def exec_driver_sql(self, _statement): + return _EmptyResult() + + @asynccontextmanager + async def connect(): + yield _Connection() + + def create_engine(config, _options, *, database=None): + seen.append(config) + return SimpleNamespace(connect=connect) + + async def dispose(_engine) -> None: + return None + + monkeypatch.setattr(seekdb_profile_module, "_create_engine", create_engine) + monkeypatch.setattr(seekdb_profile_module, "dispose_engine", dispose) + + asyncio.run( + seekdb_profile_module._require_explicit_legacy_database(SeekDBConfig(path=Path("/nonexistent"), echo=True), {}) + ) + + assert len(seen) == 1 + assert seen[0].echo is False diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index b977f06f5c..1c84623851 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -24,7 +24,10 @@ from __future__ import annotations import asyncio +import json import os +import subprocess +import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field @@ -170,6 +173,62 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_legacy_migration_cli_preserves_json_with_sql_echo(short_tmp_path: Path) -> None: + pytest.importorskip("pylibseekdb") + path = short_tmp_path / "seekdb" + old = SeekDBConfig(path=path, database="test") + + async def seed() -> None: + async with SeekDBProfile.open(old, tables=(_RECORDS,)) as profile: + await insert_record(profile) + + asyncio.run(seed()) + environment = short_tmp_path / "deployment.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=seekdb\n" + f"POWERCONTEXT_SERVER_DATABASE_PATH={path}\n" + "POWERCONTEXT_SERVER_DATABASE_ECHO=true\n", + encoding="utf-8", + ) + for action in ("status", "plan", "verify", "apply"): + result = subprocess.run( + [ + sys.executable, + "-c", + "from powercontext.cli.app import main; main()", + "server", + "db-migrate", + action, + "--env-file", + str(environment), + ], + cwd=short_tmp_path, + capture_output=True, + text=True, + timeout=45, + check=False, + ) + assert result.returncode == 1, (action, result.stdout, result.stderr) + payload = json.loads(result.stdout) + assert payload["state"] == "blocked" + assert payload["error"] == "explicit_database_required" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in payload["message"] + assert "database='powercontext'" in payload["message"] + assert "Traceback" not in result.stderr + + async def verify_preserved() -> None: + async with SeekDBProfile.open_existing(old, tables=(_RECORDS,)) as profile: + await assert_record(profile, "test") + async with profile.database.transaction() as connection: + result = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("powercontext",) + ) + assert result.first() is None + + asyncio.run(verify_preserved()) + + @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") def test_seekdb_concurrent_profiles_initialize_one_database(short_tmp_path: Path) -> None: """Exercise concurrent clients in one embedded owner process, not multiple engine-owner processes.""" diff --git a/tests/test_atomic_memory_migration_cli.py b/tests/test_atomic_memory_migration_cli.py index f9aa24587e..54f6fa05bc 100644 --- a/tests/test_atomic_memory_migration_cli.py +++ b/tests/test_atomic_memory_migration_cli.py @@ -25,6 +25,7 @@ from typer.testing import CliRunner from powercontext.builtin.persistence.bootstrap import MissingDatabaseError +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.cli.app import create_cli @@ -327,3 +328,36 @@ def unexpected_binding(): assert { item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") } == original + + +@pytest.mark.parametrize( + ("command", "action"), + [ + ("atomic-memory-migrate", "plan"), + ("atomic-memory-migrate", "verify"), + ("atomic-memory-migrate", "apply"), + ("atomic-memory-rebuild-projection", None), + ], +) +def test_legacy_seekdb_requires_an_explicit_database_choice(tmp_path, monkeypatch, maintenance_cli, command, action): + class _LegacyRefusal: + @classmethod + def open(cls, config, *, tables): + raise LegacySeekDBDatabaseError(config.path) + + @classmethod + def open_existing(cls, config, *, tables): + raise LegacySeekDBDatabaseError(config.path) + + monkeypatch.setattr(server_cli, "SeekDBProfile", _LegacyRefusal) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + + arguments = ["server", command, "--maintenance-confirmed"] + if action is not None: + arguments.extend(["--action", action]) + result = CliRunner().invoke(maintenance_cli, arguments) + + assert result.exit_code == 2, result.output + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE" in unstyle(result.output) + assert "Traceback" not in result.output diff --git a/tests/test_database_migration_cli.py b/tests/test_database_migration_cli.py index 3969843973..da11aca5cf 100644 --- a/tests/test_database_migration_cli.py +++ b/tests/test_database_migration_cli.py @@ -29,7 +29,7 @@ from powercontext.builtin.persistence.migrations.connections import BackendIdentity, MaintenanceConnections from powercontext.builtin.persistence.migrations.deployment import production_bundle -from powercontext.builtin.persistence.migrations.models import MigrationResult +from powercontext.builtin.persistence.migrations.models import MigrationError, MigrationResult from powercontext.builtin.persistence.migrations.mysql import MySQLMigrationRunner from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES @@ -679,3 +679,25 @@ def test_no_backup_requires_separate_risk_consent_and_policy_is_plan_bound(tmp_p ) assert result.exit_code == 0, result.output assert json.loads(result.output)["backup_state"] == "skipped" + + +@pytest.mark.parametrize("action", ["status", "plan", "verify"]) +def test_legacy_seekdb_refusal_is_the_documented_blocked_json(tmp_path, monkeypatch, action): + _database, command = _configure(tmp_path, monkeypatch) + + class _RefusingRunner: + def plan(self, **_options): + raise MigrationError("explicit_database_required", "Existing PowerContext tables were found in 'test'") + + def verify(self): + raise MigrationError("explicit_database_required", "Existing PowerContext tables were found in 'test'") + + monkeypatch.setattr(database_migration, "deployment_runner", lambda *_args, **_options: _RefusingRunner()) + + result = _invoke(command, action, tmp_path) + + assert result.exit_code == 1, result.output + payload = json.loads(result.output) + assert payload["state"] == "blocked" + assert payload["error"] == "explicit_database_required" + assert "Traceback" not in result.output diff --git a/tests/test_processing_migration_cli.py b/tests/test_processing_migration_cli.py index 939f16af1b..aee5ab6c35 100644 --- a/tests/test_processing_migration_cli.py +++ b/tests/test_processing_migration_cli.py @@ -23,6 +23,7 @@ from typer.testing import CliRunner from powercontext.builtin.persistence.bootstrap import MissingDatabaseError +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.cli.app import create_cli @@ -131,3 +132,30 @@ def unexpected_binding(): assert { item.relative_to(tmp_path): item.read_bytes() if item.is_file() else None for item in tmp_path.rglob("*") } == original + + +@pytest.mark.parametrize("action", ["plan", "verify", "apply"]) +def test_processing_legacy_seekdb_requires_an_explicit_database_choice(tmp_path, monkeypatch, action): + class _LegacyRefusal: + @classmethod + def open(cls, config, *, tables): + raise LegacySeekDBDatabaseError(config.path) + + @classmethod + def open_existing(cls, config, *, tables): + raise LegacySeekDBDatabaseError(config.path) + + for key in tuple(os.environ): + if key.startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(key) + monkeypatch.setattr(server_cli, "SeekDBProfile", _LegacyRefusal) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + + result = CliRunner().invoke( + create_cli([app]), ["server", "processing-migrate", "--action", action, "--maintenance-confirmed"] + ) + + assert result.exit_code == 2, result.output + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE" in unstyle(result.output) + assert "Traceback" not in result.output From bf34f3e5effae4b379b6a7e3d23f771d92477c73 Mon Sep 17 00:00:00 2001 From: lkx Date: Tue, 20 Oct 2026 09:59:58 +0800 Subject: [PATCH 07/17] fix(seekdb): bound legacy migration CLI acceptance --- .github/workflows/master.yml | 4 +- docs/en/docs/get-started/install-and-run.md | 4 +- docs/zh/docs/get-started/install-and-run.md | 3 +- tests/e2e/test_database_bootstrap.py | 55 ++--------- tests/fixtures/seekdb_legacy_migration_cli.py | 95 +++++++++++++++++++ 5 files changed, 110 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/seekdb_legacy_migration_cli.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index fd03702ffd..f82460479c 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -178,9 +178,11 @@ jobs: timeout-minutes: 5 env: POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB: "1" + PYTHONUNBUFFERED: "1" run: | uv run --locked --extra seekdb --extra code python -m pytest \ - tests/e2e/test_database_bootstrap.py -k seekdb -q -rs \ + tests/e2e/test_database_bootstrap.py -k seekdb -vv -rs \ + -o faulthandler_timeout=60 --durations=20 \ --junitxml=.powercontext/database-bootstrap-ci/seekdb.xml uv run --locked --extra seekdb --extra code python - <<'PYCODE' from xml.etree import ElementTree diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index 773c56f9f2..a323ef169a 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -189,10 +189,10 @@ not automatically migrate data, send retention reminders, compare databases, or ## Use OceanBase Connect to an existing OceanBase **MySQL-compatible user tenant**. PowerContext creates a database inside that tenant; -it does not provision an OceanBase instance, tenant, or account. Ensure network access and install the Server extra: +it does not provision an OceanBase instance, tenant, or account. Ensure network access and complete the Server +installation steps above, then configure the connection: ```bash -uv tool install --force "powercontext[cli,server]==1.3.0" export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase # Store credentials in your secret manager or a protected environment file. export POWERCONTEXT_SERVER_DATABASE_URL='mysql+aoceanbase://user%40tenant:encoded-password@host:2881/?charset=utf8mb4' diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index 7bf91bf7b6..f9ca42bc96 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -174,10 +174,9 @@ powercontext server run ## 使用 OceanBase 连接已有 OceanBase **MySQL 兼容模式的用户租户**。PowerContext 在该租户内创建数据库, -不负责创建 OceanBase 实例、租户或账户。确认网络可达,并安装 Server extra: +不负责创建 OceanBase 实例、租户或账户。确认网络可达,按上文步骤安装 Server,然后配置连接: ```bash -uv tool install --force "powercontext[cli,server]==1.3.0" export POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase # 凭据应保存在密钥管理服务或受保护的环境文件中。 export POWERCONTEXT_SERVER_DATABASE_URL='mysql+aoceanbase://user%40tenant:encoded-password@host:2881/?charset=utf8mb4' diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index 1c84623851..5ee4554f01 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -24,7 +24,6 @@ from __future__ import annotations import asyncio -import json import os import subprocess import sys @@ -176,57 +175,21 @@ async def scenario() -> None: @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") def test_seekdb_legacy_migration_cli_preserves_json_with_sql_echo(short_tmp_path: Path) -> None: pytest.importorskip("pylibseekdb") - path = short_tmp_path / "seekdb" - old = SeekDBConfig(path=path, database="test") - - async def seed() -> None: - async with SeekDBProfile.open(old, tables=(_RECORDS,)) as profile: - await insert_record(profile) - - asyncio.run(seed()) - environment = short_tmp_path / "deployment.env" - environment.write_text( - "POWERCONTEXT_SERVER_DATABASE_KIND=seekdb\n" - f"POWERCONTEXT_SERVER_DATABASE_PATH={path}\n" - "POWERCONTEXT_SERVER_DATABASE_ECHO=true\n", - encoding="utf-8", - ) - for action in ("status", "plan", "verify", "apply"): + scenario = Path(__file__).resolve().parents[1] / "fixtures" / "seekdb_legacy_migration_cli.py" + # One child owns the engine throughout. The process deadline also bounds + # native startup and shutdown, which asyncio cancellation cannot interrupt. + try: result = subprocess.run( - [ - sys.executable, - "-c", - "from powercontext.cli.app import main; main()", - "server", - "db-migrate", - action, - "--env-file", - str(environment), - ], + [sys.executable, "-u", str(scenario), str(short_tmp_path)], cwd=short_tmp_path, capture_output=True, text=True, - timeout=45, + timeout=90, check=False, ) - assert result.returncode == 1, (action, result.stdout, result.stderr) - payload = json.loads(result.stdout) - assert payload["state"] == "blocked" - assert payload["error"] == "explicit_database_required" - assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in payload["message"] - assert "database='powercontext'" in payload["message"] - assert "Traceback" not in result.stderr - - async def verify_preserved() -> None: - async with SeekDBProfile.open_existing(old, tables=(_RECORDS,)) as profile: - await assert_record(profile, "test") - async with profile.database.transaction() as connection: - result = await connection.exec_driver_sql( - "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("powercontext",) - ) - assert result.first() is None - - asyncio.run(verify_preserved()) + except subprocess.TimeoutExpired as error: + pytest.fail(f"seekdb CLI scenario exceeded 90 seconds\nstdout: {error.stdout!r}\nstderr: {error.stderr!r}") + assert result.returncode == 0, (result.stdout, result.stderr) @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") diff --git a/tests/fixtures/seekdb_legacy_migration_cli.py b/tests/fixtures/seekdb_legacy_migration_cli.py new file mode 100644 index 0000000000..9ed2d5f709 --- /dev/null +++ b/tests/fixtures/seekdb_legacy_migration_cli.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise legacy migration CLI output with one embedded seekdb owner process.""" + +from __future__ import annotations + +import asyncio +import faulthandler +import json +import sys +from pathlib import Path + +from sqlalchemy import Column, Integer, MetaData, String, Table, select +from typer.testing import CliRunner + +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.cli.app import create_cli + + +def main(directory: Path) -> None: + records = Table( + "pc_bootstrap_records", + MetaData(), + Column("id", Integer, primary_key=True), + Column("value", String(64), nullable=False), + ) + path = directory / "seekdb" + old = SeekDBConfig(path=path, database="test") + + async def seed() -> None: + async with ( + asyncio.timeout(30), + SeekDBProfile.open(old, tables=(records,)) as profile, + profile.database.transaction() as connection, + ): + await connection.execute(records.insert().values(id=1, value="preserved")) + + print("seed", file=sys.stderr, flush=True) + asyncio.run(seed()) + environment = directory / "deployment.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=seekdb\n" + f"POWERCONTEXT_SERVER_DATABASE_PATH={path}\n" + "POWERCONTEXT_SERVER_DATABASE_ECHO=true\n", + encoding="utf-8", + ) + cli = create_cli() + runner = CliRunner() + for action in ("status", "plan", "verify", "apply"): + print(action, file=sys.stderr, flush=True) + result = runner.invoke(cli, ["server", "db-migrate", action, "--env-file", str(environment)]) + assert result.exit_code == 1, (action, result.stdout, result.stderr, result.exception) + payload = json.loads(result.stdout) + assert payload["state"] == "blocked" + assert payload["error"] == "explicit_database_required" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in payload["message"] + assert "database='powercontext'" in payload["message"] + assert "Traceback" not in result.stderr + + async def verify_preserved() -> None: + async with ( + asyncio.timeout(30), + SeekDBProfile.open_existing(old, tables=(records,)) as profile, + profile.database.transaction() as connection, + ): + assert await connection.scalar(select(records.c.value).where(records.c.id == 1)) == "preserved" + assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == "test" + result = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("powercontext",) + ) + assert result.first() is None + + print("verify preserved data", file=sys.stderr, flush=True) + asyncio.run(verify_preserved()) + print("complete", file=sys.stderr, flush=True) + + +if __name__ == "__main__": + faulthandler.dump_traceback_later(45, repeat=True) + try: + main(Path(sys.argv[1])) + finally: + faulthandler.cancel_dump_traceback_later() From 5310b1499a9f466948d3c00b41c6cc5496199532 Mon Sep 17 00:00:00 2001 From: lkx Date: Thu, 22 Oct 2026 01:32:44 +0800 Subject: [PATCH 08/17] fix(cli): report database refusals and provisioning failures The code commands lost their JSON error contract and the offline maintenance commands ended in tracebacks when an implicit legacy seekdb choice or a provisioning failure reached the command boundary. - Report `explicit_database_required` from the code entry point as its own stderr JSON payload instead of an uncaught exception. - Report `DatabaseAccessError` and `DatabaseCreationError` from processing-migrate, atomic-memory-migrate, atomic-memory-rebuild-projection and identity-reset verbatim, because the rich error panel truncates the administrator statement these messages carry. - Translate the same provisioning failures for writable seekdb db-migrate into `database_access_denied` and `database_creation_required` maintenance errors, and document both codes. --- docs/en/docs/operate/database-migrations.md | 7 +- docs/zh/docs/operate/database-migrations.md | 2 +- src/powercontext/builtin/code/cli.py | 10 +++ .../persistence/migrations/connections.py | 29 +++++++- src/powercontext/server/cli.py | 27 ++++++- .../test_database_migration_connections.py | 30 ++++++++ tests/fixtures/seekdb_legacy_migration_cli.py | 48 +++++++++++-- tests/test_cli.py | 72 +++++++++++++++++++ 8 files changed, 210 insertions(+), 15 deletions(-) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 0c25b28702..cda73c48c6 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -86,8 +86,11 @@ identity return `target_identity_unavailable`; a proxy hostname is not used as a seekdb inspection does not start an engine for a missing path or empty directory. A nonempty directory without a recognized engine layout returns `unsupported_target`. An unconfigured database name whose old `test` database still holds PowerContext tables returns `explicit_database_required` instead of selecting either database implicitly; select -the name explicitly and rerun the command. Opening a valid existing engine may write native logs and other -engine housekeeping files; read-only migration commands do not perform business DDL or initialize tables. +the name explicitly and rerun the command. Writable seekDB maintenance that cannot create its configured database +returns `database_creation_required`, and a permission denial that leaves existence unproven returns +`database_access_denied`; both keep the administrator statement and provisioning guidance in the payload. Opening a +valid existing engine may write native logs and other engine housekeeping files; read-only migration commands do not +perform business DDL or initialize tables. `--evidence-dir PATH` selects persistent storage outside the target database for maintenance progress and recovery references. Use the same directory for `status`, `plan`, `apply`, `verify`, and retries. Keep it available after a process, diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index ac32833441..5c6e45aa33 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -52,7 +52,7 @@ powercontext server db-migrate apply --env-file deployment.env --backup manual \ OceanBase 维护须能通过 `effective_tenant_id()`、`oceanbase.GV$OB_PARAMETERS`、`oceanbase.DBA_OB_TENANTS` 读取租户 ID、集群 ID 和租户创建信息。权限不足或身份信息不完整时返回 `target_identity_unavailable`,不以代理主机名代替数据库身份。 -seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 +seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。可写 seekDB 维护无法创建配置指定的库时返回 `database_creation_required`,权限被拒且无法确认库是否存在时返回 `database_access_denied`;两者都在载荷中保留管理员语句与准备指引。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 `--evidence-dir PATH` 指定目标库之外的持久目录,保存维护进度及恢复引用。`status`、`plan`、`apply`、`verify` 和重试必须使用同一目录;进程、容器或迁移 Job 退出后仍须保留。每个数据库使用独立目录,并限制为迁移操作者可访问。 diff --git a/src/powercontext/builtin/code/cli.py b/src/powercontext/builtin/code/cli.py index 71cb0ef25a..3a5b045e5e 100644 --- a/src/powercontext/builtin/code/cli.py +++ b/src/powercontext/builtin/code/cli.py @@ -23,6 +23,7 @@ from powercontext.builtin.code.errors import CodeError from powercontext.builtin.code.models import CodeQueryRequest from powercontext.builtin.code.service import CodeService +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError from powercontext.server.configuration import ServerConfigurationError, server_settings_context app = typer.Typer(name="code", help="Index and query a configured local Git worktree.", no_args_is_help=True) @@ -45,6 +46,15 @@ async def run(): return await asyncio.to_thread(_execute, service, operation, scope_id, request_file) result = asyncio.run(run()) + except LegacySeekDBDatabaseError as error: + typer.echo( + json.dumps( + {"error": {"code": "explicit_database_required", "message": str(error)}}, + ensure_ascii=False, + ), + err=True, + ) + raise typer.Exit(2) from None except CodeError as error: typer.echo(json.dumps({"error": {"code": error.code}}, ensure_ascii=False), err=True) raise typer.Exit(1) from None diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index 9fe364c42d..3a448044a8 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.pool import NullPool -from powercontext.builtin.persistence.bootstrap import database_is_available, ensure_database +from powercontext.builtin.persistence.bootstrap import ( + DatabaseAccessError, + DatabaseCreationError, + database_is_available, + ensure_database, +) from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.oceanbase import profile as oceanbase_profile from powercontext.builtin.persistence.seekdb import SeekDBConfig @@ -143,7 +148,7 @@ async def _seekdb( try: await _require_explicit_database(config, instance.connection_options()) if writable: - await ensure_database( + await _provision_database( engine, lambda: seekdb_profile._create_engine( config.model_copy(update={"echo": False}), instance.connection_options(), database="" @@ -233,6 +238,26 @@ def verify() -> None: await _dispose_engine(engine) +async def _provision_database( + engine: AsyncEngine, + instance_engine: Callable[[], AsyncEngine], + *, + name: str, +) -> None: + """Turn provisioning failures into the maintenance error contract. + + The bootstrap layer already produced administrator guidance; without this + translation ``db-migrate`` would report a traceback instead of its documented + blocked JSON payload. + """ + try: + await ensure_database(engine, instance_engine, name=name) + except DatabaseAccessError as error: + raise MigrationError("database_access_denied", str(error)) from error + except DatabaseCreationError as error: + raise MigrationError("database_creation_required", str(error)) from error + + async def _require_explicit_database(config: SeekDBConfig, options: Mapping[str, object]) -> None: """Turn the expected legacy-database refusal into the maintenance error contract. diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index b98970dab7..29cc4785a1 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -22,13 +22,17 @@ from contextlib import AsyncExitStack, nullcontext from pathlib import Path from sqlite3 import SQLITE_CANTOPEN -from typing import TYPE_CHECKING, Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal, Never import typer from pydantic import ValidationError from sqlalchemy.exc import OperationalError -from powercontext.builtin.persistence.bootstrap import MissingDatabaseError +from powercontext.builtin.persistence.bootstrap import ( + DatabaseAccessError, + DatabaseCreationError, + MissingDatabaseError, +) from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex from powercontext.builtin.persistence.processing_migration import ( @@ -138,10 +142,18 @@ def processing_migrate( raise typer.BadParameter(_missing_read_only_database(error)) from error except (SeekDBInspectionError, LegacySeekDBDatabaseError) as error: raise typer.BadParameter(str(error)) from error + except (DatabaseAccessError, DatabaseCreationError) as error: + _report_database_guidance(error) if not ready: raise typer.Exit(code=1) +def _report_database_guidance(error: DatabaseAccessError | DatabaseCreationError) -> Never: + """Print provisioning guidance verbatim; the rich error panel truncates long statements.""" + typer.echo(str(error), err=True) + raise typer.Exit(code=2) + + def _missing_read_only_database(error: MissingDatabaseError) -> str: return ( f"plan and verify require an existing database: {error.database_name!r} is missing. " @@ -223,7 +235,12 @@ def identity_reset( with server_settings_context(env_file=env_file) as settings: if isinstance(settings.database, SQLiteConfig) and not settings.database.is_persistent: raise typer.BadParameter("identity reset requires a persistent database") # noqa: TRY003 - server_id = asyncio.run(_reset_server_identity(settings)) + try: + server_id = asyncio.run(_reset_server_identity(settings)) + except LegacySeekDBDatabaseError as error: + raise typer.BadParameter(str(error)) from error + except (DatabaseAccessError, DatabaseCreationError) as error: + _report_database_guidance(error) typer.echo(server_id) @@ -269,6 +286,8 @@ def atomic_memory_migrate( raise typer.BadParameter(_missing_read_only_database(error)) from error except (SeekDBInspectionError, LegacySeekDBDatabaseError) as error: raise typer.BadParameter(str(error)) from error + except (DatabaseAccessError, DatabaseCreationError) as error: + _report_database_guidance(error) except OperationalError as error: sqlite_code = getattr(error.orig, "sqlite_errorcode", None) if ( @@ -304,6 +323,8 @@ def atomic_memory_rebuild_projection( ) except LegacySeekDBDatabaseError as error: raise typer.BadParameter(str(error)) from error + except (DatabaseAccessError, DatabaseCreationError) as error: + _report_database_guidance(error) if not ready: raise typer.Exit(code=1) diff --git a/tests/builtin/persistence/test_database_migration_connections.py b/tests/builtin/persistence/test_database_migration_connections.py index a4a9b5ea68..25eee78678 100644 --- a/tests/builtin/persistence/test_database_migration_connections.py +++ b/tests/builtin/persistence/test_database_migration_connections.py @@ -427,3 +427,33 @@ def unexpected(connection: Connection | None, _identity: BackendIdentity, verify MaintenanceConnections(SeekDBConfig(path=path)).run(unexpected, writable=True) assert error.value.code == "explicit_database_required" assert "POWERCONTEXT_SERVER_DATABASE_DATABASE" in str(error.value) + + +@pytest.mark.parametrize( + ("error_factory", "code"), + [ + ( + lambda: connections.DatabaseAccessError("custom-db", "CREATE DATABASE IF NOT EXISTS `custom-db`"), + "database_access_denied", + ), + ( + lambda: connections.DatabaseCreationError("custom-db", "CREATE DATABASE IF NOT EXISTS `custom-db`"), + "database_creation_required", + ), + ], +) +def test_seekdb_provisioning_failure_is_a_maintenance_error(tmp_path: Path, monkeypatch, error_factory, code) -> None: + path, _assert_locked = _fake_seekdb(tmp_path, monkeypatch) + + async def refuse(*_args, **_options): + raise error_factory() + + monkeypatch.setattr(connections, "ensure_database", refuse) + + def unexpected(connection: Connection | None, _identity: BackendIdentity, verify: Callable[[], None]): + pytest.fail("a provisioning failure must not run the maintenance operation") + + with pytest.raises(MigrationError) as error: + MaintenanceConnections(SeekDBConfig(path=path, database="custom-db")).run(unexpected, writable=True) + assert error.value.code == code + assert "administrator" in str(error.value) diff --git a/tests/fixtures/seekdb_legacy_migration_cli.py b/tests/fixtures/seekdb_legacy_migration_cli.py index 9ed2d5f709..fc128fa1ca 100644 --- a/tests/fixtures/seekdb_legacy_migration_cli.py +++ b/tests/fixtures/seekdb_legacy_migration_cli.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Exercise legacy migration CLI output with one embedded seekdb owner process.""" +"""Exercise legacy maintenance CLI output and identity preservation in embedded seekdb.""" from __future__ import annotations @@ -21,12 +21,14 @@ import json import sys from pathlib import Path +from uuid import UUID from sqlalchemy import Column, Integer, MetaData, String, Table, select from typer.testing import CliRunner from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile from powercontext.cli.app import create_cli +from powercontext.server.identity import SERVER_IDENTITY_TABLE, ServerIdentityRepository def main(directory: Path) -> None: @@ -39,16 +41,19 @@ def main(directory: Path) -> None: path = directory / "seekdb" old = SeekDBConfig(path=path, database="test") - async def seed() -> None: + async def seed() -> str: async with ( asyncio.timeout(30), SeekDBProfile.open(old, tables=(records,)) as profile, - profile.database.transaction() as connection, ): - await connection.execute(records.insert().values(id=1, value="preserved")) + async with profile.database.transaction() as connection: + await connection.execute(records.insert().values(id=1, value="preserved")) + repository = ServerIdentityRepository(profile.database) + await repository.initialize() + return await repository.load_or_create() print("seed", file=sys.stderr, flush=True) - asyncio.run(seed()) + old_id = asyncio.run(seed()) environment = directory / "deployment.env" environment.write_text( "POWERCONTEXT_SERVER_DATABASE_KIND=seekdb\n" @@ -69,7 +74,16 @@ async def seed() -> None: assert "database='powercontext'" in payload["message"] assert "Traceback" not in result.stderr - async def verify_preserved() -> None: + print("implicit identity reset", file=sys.stderr, flush=True) + reset_arguments = ["server", "identity-reset", "--env-file", str(environment), "--maintenance-confirmed"] + refused = runner.invoke(cli, reset_arguments, terminal_width=240) + assert refused.exit_code == 2, (refused.stdout, refused.stderr, refused.exception) + assert refused.stdout == "" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in refused.stderr + assert "database='powercontext'" in refused.stderr + assert "Traceback" not in refused.output + + async def verify_preserved(expected_id: str) -> None: async with ( asyncio.timeout(30), SeekDBProfile.open_existing(old, tables=(records,)) as profile, @@ -77,13 +91,33 @@ async def verify_preserved() -> None: ): assert await connection.scalar(select(records.c.value).where(records.c.id == 1)) == "preserved" assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == "test" + assert ( + await connection.scalar( + select(SERVER_IDENTITY_TABLE.c.server_id).where(SERVER_IDENTITY_TABLE.c.singleton_key == 1) + ) + ) == expected_id result = await connection.exec_driver_sql( "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", ("powercontext",) ) assert result.first() is None print("verify preserved data", file=sys.stderr, flush=True) - asyncio.run(verify_preserved()) + asyncio.run(verify_preserved(old_id)) + + print("explicit identity reset", file=sys.stderr, flush=True) + environment.write_text( + environment.read_text(encoding="utf-8").replace( + "POWERCONTEXT_SERVER_DATABASE_ECHO=true", "POWERCONTEXT_SERVER_DATABASE_ECHO=false" + ) + + "POWERCONTEXT_SERVER_DATABASE_DATABASE=test\n", + encoding="utf-8", + ) + rotated = runner.invoke(cli, reset_arguments) + assert rotated.exit_code == 0, (rotated.stdout, rotated.stderr, rotated.exception) + new_id = rotated.stdout.strip() + assert str(UUID(new_id)) == new_id + assert new_id != old_id + asyncio.run(verify_preserved(new_id)) print("complete", file=sys.stderr, flush=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 92918afd75..c2a0793ec9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,6 +29,9 @@ from typer.testing import CliRunner import powercontext.client.cli as client_cli +from powercontext.builtin.code.cli import app as code_app +from powercontext.builtin.persistence.bootstrap import DatabaseAccessError, DatabaseCreationError, creation_statement +from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.cli.app import create_cli from powercontext.client import ServerResponseError @@ -633,6 +636,75 @@ def test_cli_exposes_installed_role_commands() -> None: assert "client" not in result.output +@pytest.mark.parametrize("command", ["status", "index", "sync", "query", "clear"]) +def test_code_cli_reports_explicit_legacy_database_required(tmp_path, monkeypatch, command) -> None: + def refuse_implicit_database(_config, _database): + raise LegacySeekDBDatabaseError(tmp_path / "seekdb") + + monkeypatch.setattr("powercontext.builtin.code.cli.open_code_service", refuse_implicit_database) + arguments = ["code", command, "--scope", "configured-scope"] + if command == "query": + arguments.extend(["--request-file", str(tmp_path / "request.json")]) + + result = CliRunner().invoke(create_cli([code_app]), arguments) + + assert result.exit_code == 2 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload["error"]["code"] == "explicit_database_required" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in payload["error"]["message"] + assert "database='powercontext'" in payload["error"]["message"] + assert isinstance(result.exception, SystemExit) + + +@pytest.mark.parametrize("error_type", [DatabaseAccessError, DatabaseCreationError]) +@pytest.mark.parametrize( + ("command", "operation", "options"), + [ + ("processing-migrate", "_processing_maintenance", ["--action", "plan"]), + ("atomic-memory-migrate", "_atomic_memory_maintenance", ["--action", "verify"]), + ("identity-reset", "_reset_server_identity", ["--maintenance-confirmed"]), + ("atomic-memory-rebuild-projection", "_atomic_memory_maintenance", ["--maintenance-confirmed"]), + ], +) +def test_server_maintenance_reports_database_permission_guidance(monkeypatch, error_type, command, operation, options): + async def refuse_database(*_args, **_kwargs): + raise error_type("team_context", creation_statement("team_context")) + + monkeypatch.setattr(f"powercontext.server.cli.{operation}", refuse_database) + + result = CliRunner().invoke(create_cli([server_app]), ["server", command, *options], terminal_width=240) + + assert result.exit_code == 2 + assert result.stdout == "" + assert "team_context" in result.stderr + assert "administrator" in result.stderr + assert creation_statement("team_context") in result.stderr + assert "No fallback database was selected" in result.stderr + assert "Traceback" not in result.output + assert isinstance(result.exception, SystemExit) + + +def test_server_identity_reset_reports_explicit_legacy_database_required(tmp_path, monkeypatch) -> None: + async def refuse_implicit_database(_settings) -> str: + raise LegacySeekDBDatabaseError(tmp_path / "seekdb") + + monkeypatch.setattr("powercontext.server.cli._reset_server_identity", refuse_implicit_database) + + result = CliRunner().invoke( + create_cli([server_app]), + ["server", "identity-reset", "--maintenance-confirmed"], + terminal_width=240, + ) + + assert result.exit_code == 2 + assert result.stdout == "" + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in result.stderr + assert "database='powercontext'" in result.stderr + assert "Traceback" not in result.output + assert isinstance(result.exception, SystemExit) + + def test_server_identity_reset_requires_offline_confirmation_and_rotates(tmp_path) -> None: database = tmp_path / "runtime.db" environment = tmp_path / "server.env" From d8da2bcac2d15501105ae99c971fce23a604fde7 Mon Sep 17 00:00:00 2001 From: lkx Date: Thu, 22 Oct 2026 11:33:20 +0800 Subject: [PATCH 09/17] fix(storage): reuse identity tables and align seekdb fixtures --- src/powercontext/server/identity.py | 7 ++++++- tests/fixtures/seekdb_legacy_choice.py | 11 ++++++++++- tests/fixtures/seekdb_legacy_migration_cli.py | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/powercontext/server/identity.py b/src/powercontext/server/identity.py index f98bcc50f1..18b23f3e64 100644 --- a/src/powercontext/server/identity.py +++ b/src/powercontext/server/identity.py @@ -28,6 +28,7 @@ from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.schema import create_tables from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile, is_sqlite_lock_error from powercontext.builtin.runtime.config import DatabaseConfig @@ -60,7 +61,11 @@ async def initialize(self) -> None: async def _initialize(self) -> None: async with self._database.transaction() as connection: - await connection.execute(CreateTable(SERVER_IDENTITY_TABLE, if_not_exists=True)) + if connection.dialect.name == "mysql": + await create_tables(connection, (SERVER_IDENTITY_TABLE,)) + else: + # Keep atomic creation for concurrent SQLite initializers. + await connection.execute(CreateTable(SERVER_IDENTITY_TABLE, if_not_exists=True)) async def load_or_create(self) -> str: """Return the durable identity, creating it once across concurrent replicas.""" diff --git a/tests/fixtures/seekdb_legacy_choice.py b/tests/fixtures/seekdb_legacy_choice.py index 9550ee466e..d1efe59cbf 100644 --- a/tests/fixtures/seekdb_legacy_choice.py +++ b/tests/fixtures/seekdb_legacy_choice.py @@ -31,10 +31,16 @@ from powercontext.builtin.persistence.seekdb import LegacySeekDBDatabaseError, SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.seekdb import profile as seekdb_profile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.server.factory import create_server_app from powercontext.server.settings import ServerSettings +def _recovery_budget_table() -> Table: + """Return the registered Topic budget table, whose presence initializes recovery.""" + return next(table for table in BUILTIN_TABLES if table.name == "pc_topic_memory_work_budgets") + + def database_snapshot(options: dict[str, object]) -> None: """Inspect only this test's embedded socket, with bounded diagnostic reads.""" socket = options.get("unix_socket") @@ -131,7 +137,10 @@ async def assert_preserved(profile: SeekDBProfile) -> None: try: async with asyncio.timeout(60): stage("seed: opening test") - async with SeekDBProfile.open(old, tables=(records,)) as profile: + # A migrated deployment owns the registered Topic recovery shapes; startup + # rejects a partial database on purpose. Selecting the budget table + # initializes both registered recovery tables before the marker is added. + async with SeekDBProfile.open(old, tables=(_recovery_budget_table(), records)) as profile: stage("seed: insert") async with profile.database.transaction() as connection: await connection.execute(records.insert().values(id=1, value="preserved")) diff --git a/tests/fixtures/seekdb_legacy_migration_cli.py b/tests/fixtures/seekdb_legacy_migration_cli.py index fc128fa1ca..632ea06959 100644 --- a/tests/fixtures/seekdb_legacy_migration_cli.py +++ b/tests/fixtures/seekdb_legacy_migration_cli.py @@ -23,7 +23,9 @@ from pathlib import Path from uuid import UUID -from sqlalchemy import Column, Integer, MetaData, String, Table, select +from sqlalchemy import Column, Integer, MetaData, String, Table, event, select +from sqlalchemy.engine import Engine +from sqlalchemy.schema import CreateTable from typer.testing import CliRunner from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile @@ -112,7 +114,16 @@ async def verify_preserved(expected_id: str) -> None: + "POWERCONTEXT_SERVER_DATABASE_DATABASE=test\n", encoding="utf-8", ) - rotated = runner.invoke(cli, reset_arguments) + + def reject_identity_ddl(connection, clauseelement, multiparams, params, execution_options) -> None: + if isinstance(clauseelement, CreateTable): + assert clauseelement.element.name != SERVER_IDENTITY_TABLE.name, "Existing identity table must be reused" + + event.listen(Engine, "before_execute", reject_identity_ddl) + try: + rotated = runner.invoke(cli, reset_arguments) + finally: + event.remove(Engine, "before_execute", reject_identity_ddl) assert rotated.exit_code == 0, (rotated.stdout, rotated.stderr, rotated.exception) new_id = rotated.stdout.strip() assert str(UUID(new_id)) == new_id From e5d882ba51bab1baa886715a2b98994192d62831 Mon Sep 17 00:00:00 2001 From: lkx Date: Thu, 22 Oct 2026 23:44:41 +0800 Subject: [PATCH 10/17] test(seekdb): reduce startup DDL exposure and retry once past the engine stall The embedded seekDB can stall indefinitely on one CREATE TABLE during the startup schema burst; no client-side timeout bounds it, and two client variants (no IF NOT EXISTS, autocommit DDL) were measured without effect. Seed the legacy-choice fixture with the complete schema so the real startup path issues no table DDL, and retry a failing scenario child once in a fresh directory only when the output carries the stall signature. Genuine failures still fail immediately, and per-attempt diagnostics are retained. Also keep the KIMI guidance normalization alongside master's structure and assert that a failed seekDB transaction rolls back. --- e2e/bub/tests/test_harbor_job_config.py | 1 + tests/e2e/test_database_bootstrap.py | 106 ++++++++++++++++-------- tests/fixtures/seekdb_legacy_choice.py | 13 +-- 3 files changed, 75 insertions(+), 45 deletions(-) diff --git a/e2e/bub/tests/test_harbor_job_config.py b/e2e/bub/tests/test_harbor_job_config.py index ac7d748ca4..57605907b4 100644 --- a/e2e/bub/tests/test_harbor_job_config.py +++ b/e2e/bub/tests/test_harbor_job_config.py @@ -855,6 +855,7 @@ def _kimi_installation(home: Path) -> dict[str, object]: assert script.is_file() assert encoded_script in text assert "__POWERCONTEXT_SCOPE_SCRIPT_JSON__" not in text + text = text.replace(encoded_script, '""') if path.suffix == ".json": files[path.relative_to(home).as_posix()] = json.loads(text) else: diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index 326ba2a9b6..825ada071f 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -74,45 +74,74 @@ async def assert_record(profile, name: str) -> None: assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == name +_STALL_SIGNATURES = ("Timeout (0:", "TimeoutError:") +_MAXIMUM_REPORTED_OUTPUT = 6000 + + def run_seekdb_scenario(directory: Path, filename: str) -> None: + """Run one embedded-engine scenario, retrying once past a known engine stall. + + A stall blocks the child inside a native call, so an in-process timeout cannot + recover it; a fresh directory and a new engine instance can. Only a stall is + retried: any other failure is reported immediately. + """ scenario = Path(__file__).resolve().parents[1] / "fixtures" / filename - output = "" - failed = True + reported: list[str] = [] + for attempt in (1, 2): + target = directory / f"attempt-{attempt}" + target.mkdir() + output = "" + failed = True + stalled = False + try: + result = subprocess.run( + [sys.executable, "-u", str(scenario), str(target)], + cwd=target, + capture_output=True, + text=True, + timeout=90, + check=False, + ) + output = result.stdout + result.stderr + failed = result.returncode != 0 + except subprocess.TimeoutExpired as error: + # TimeoutExpired retains bytes even when subprocess.run uses text=True. + output = (error.stdout or b"").decode(errors="replace") + (error.stderr or b"").decode(errors="replace") + stalled = True + finally: + _retain_scenario_diagnostics(scenario, target, attempt, output, failed or stalled) + if not failed and not stalled: + return + reported.append(f"attempt {attempt}:\n{_bounded_output(output)}") + if attempt == 2 or not (stalled or any(signature in output for signature in _STALL_SIGNATURES)): + break + pytest.fail(f"{filename} failed after {len(reported)} attempt(s)\n" + "\n".join(reported)) + + +def _bounded_output(output: str) -> str: + return output if len(output) <= _MAXIMUM_REPORTED_OUTPUT else "..." + output[-_MAXIMUM_REPORTED_OUTPUT:] + + +def _retain_scenario_diagnostics(scenario: Path, directory: Path, attempt: int, output: str, failed: bool) -> None: + diagnostic_root = os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_DIAGNOSTICS") + if not diagnostic_root: + return try: - result = subprocess.run( - [sys.executable, "-u", str(scenario), str(directory)], - cwd=directory, - capture_output=True, - text=True, - timeout=90, - check=False, - ) - output = result.stdout + result.stderr - failed = result.returncode != 0 - assert not failed, output - except subprocess.TimeoutExpired as error: - # TimeoutExpired retains bytes even when subprocess.run uses text=True. - output = (error.stdout or b"").decode(errors="replace") + (error.stderr or b"").decode(errors="replace") - pytest.fail(f"{filename} exceeded 90 seconds\n{output}") - finally: - if diagnostic_root := os.environ.get("POWERCONTEXT_TEST_BOOTSTRAP_DIAGNOSTICS"): - try: - destination = Path(diagnostic_root) / scenario.stem - destination.mkdir(parents=True, exist_ok=True) - (destination / "scenario.log").write_text(output, encoding="utf-8") - if failed: - # Retain bounded native log tails before short_tmp_path removes storage. - native_logs = directory / "seekdb" / "log" - for path in native_logs.glob("*"): - if path.is_file() and path.name.endswith((".log", ".log.wf")): - with ( - path.open("rb") as source, - gzip.open(destination / f"{path.name}.tail.gz", "wb") as target, - ): - source.seek(max(0, path.stat().st_size - 1024 * 1024)) - target.write(source.read()) - except OSError as error: - print(f"seekdb diagnostics unavailable: {type(error).__name__}", file=sys.stderr) + destination = Path(diagnostic_root) / scenario.stem + destination.mkdir(parents=True, exist_ok=True) + (destination / f"attempt-{attempt}.log").write_text(output, encoding="utf-8") + if failed: + # Retain bounded native log tails before short_tmp_path removes storage. + for path in (directory / "seekdb" / "log").glob("*"): + if path.is_file() and path.name.endswith((".log", ".log.wf")): + with ( + path.open("rb") as source, + gzip.open(destination / f"attempt-{attempt}.{path.name}.tail.gz", "wb") as target, + ): + source.seek(max(0, path.stat().st_size - 1024 * 1024)) + target.write(source.read()) + except OSError as error: + print(f"seekdb diagnostics unavailable: {type(error).__name__}", file=sys.stderr) @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") @@ -128,6 +157,11 @@ async def scenario() -> None: ) async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: await insert_record(profile) + with pytest.raises(RuntimeError, match="abort business write"): + async with profile.database.transaction() as connection: + await connection.execute(_RECORDS.update().values(value="must roll back")) + raise RuntimeError("abort business write") # noqa: TRY003 + await assert_record(profile, name or "powercontext") async with SeekDBProfile.open(config, tables=(_RECORDS,)) as profile: await assert_record(profile, name or "powercontext") diff --git a/tests/fixtures/seekdb_legacy_choice.py b/tests/fixtures/seekdb_legacy_choice.py index d1efe59cbf..f00bb704cf 100644 --- a/tests/fixtures/seekdb_legacy_choice.py +++ b/tests/fixtures/seekdb_legacy_choice.py @@ -36,11 +36,6 @@ from powercontext.server.settings import ServerSettings -def _recovery_budget_table() -> Table: - """Return the registered Topic budget table, whose presence initializes recovery.""" - return next(table for table in BUILTIN_TABLES if table.name == "pc_topic_memory_work_budgets") - - def database_snapshot(options: dict[str, object]) -> None: """Inspect only this test's embedded socket, with bounded diagnostic reads.""" socket = options.get("unix_socket") @@ -137,10 +132,10 @@ async def assert_preserved(profile: SeekDBProfile) -> None: try: async with asyncio.timeout(60): stage("seed: opening test") - # A migrated deployment owns the registered Topic recovery shapes; startup - # rejects a partial database on purpose. Selecting the budget table - # initializes both registered recovery tables before the marker is added. - async with SeekDBProfile.open(old, tables=(_recovery_budget_table(), records)) as profile: + # Model a provisioned deployment: a partial database is rejected on purpose, + # and creating the whole schema here keeps the fragile startup phase free of + # table DDL because it only observes that everything already exists. + async with SeekDBProfile.open(old, tables=(*BUILTIN_TABLES, records)) as profile: stage("seed: insert") async with profile.database.transaction() as connection: await connection.execute(records.insert().values(id=1, value="preserved")) From d847f9193df4fd9f5c4f940bc486649e18ad7e46 Mon Sep 17 00:00:00 2001 From: lkx Date: Sat, 24 Oct 2026 14:21:50 +0800 Subject: [PATCH 11/17] fix(desktop): gate current contract without qualification --- desktop/README.md | 2 +- desktop/VALIDATION.md | 10 +++--- desktop/src-tauri/tests/session.rs | 54 +++++++++++++++++++++--------- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index a661c77d41..a03f5e68bb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -119,7 +119,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For the qualified current Server sources, explicitly choose `sqlite-atomic-d9a78567-v1` and check the connection before selecting a Scope; that record's contract digest matches this build's bundled contract, while `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` qualify their corresponding Server source deployments and do not match it. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. No product qualification record matches this build's bundled API contract, so ordinary builds cannot use business operations until a matching Server build is independently qualified. `sqlite-atomic-d9a78567-v1` and the other recorded profiles qualify earlier contracts; selecting one does not authorize this build. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 5ec28c8e48..2725a42b39 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -22,17 +22,19 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract is qualified by `sqlite-atomic-d9a78567-v1` below; ordinary builds require explicitly selecting it and checking the connection before business operations become available. The `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. No product record matches this build's bundled contract. Ordinary builds keep business operations unavailable until an independently verified matching profile is added. `sqlite-atomic-d9a78567-v1` qualifies the previous contract, as do the other recorded profiles for their respective Server sources. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing an available matching profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path and digest, then validate the diagnostic response schema. The observed version is optional report information; a registration does not require a version. -### Current Server protocol qualification + + +### Server protocol qualification records #### Server d9a78567 protocol qualification -The bundled contract is qualified by `sqlite-atomic-d9a78567-v1`. The record identifies Server +The previous preview contract was qualified by `sqlite-atomic-d9a78567-v1`. The record identifies Server sources at `d9a785675dedb72cd72a386b346e590f4c067ac0` and normalized OpenAPI digest `a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25`. The qualification wheel is `powercontext-1.4.1.dev69+ga200d6094.d20261022-py3-none-any.whl`, @@ -139,7 +141,7 @@ matching `sqlite-atomic-caf2a35a-v1`. The observed app is identified by the exec this record does not identify its exact build source revision. This is historical evidence, not a procedure or native UI qualification for the current bundled contract. -The current `sqlite-atomic-d9a78567-v1` record has non-GUI protocol evidence on Linux x86_64 only. +The `sqlite-atomic-d9a78567-v1` record has non-GUI protocol evidence on Linux x86_64 only. Native macOS save/search/exact-read and clipboard acceptance against that record remains unverified. The normal release app (without `ci-fixtures`) was operated on macOS 26.5.2 ARM64 against the diff --git a/desktop/src-tauri/tests/session.rs b/desktop/src-tauri/tests/session.rs index b7f5a5babc..fa5dae99a4 100644 --- a/desktop/src-tauri/tests/session.rs +++ b/desktop/src-tauri/tests/session.rs @@ -271,7 +271,7 @@ async fn editing_a_scope_query_cancels_its_read_without_disconnecting() { } #[tokio::test] -async fn bundled_qualification_allows_explicit_scope_browsing() { +async fn historical_qualification_cannot_authorize_the_current_contract() { let server = fixture().await; let dir = tempfile::tempdir().unwrap(); let manager = ConnectionManager::new( @@ -280,24 +280,48 @@ async fn bundled_qualification_allows_explicit_scope_browsing() { let contract: serde_json::Value = serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); let state = manager.state().unwrap(); - let qualification = state - .compatibility_profiles - .iter() - .find(|profile| { - Some(profile.contract_sha256.as_str()) == contract["contractSha256"].as_str() - }) - .expect("the shipped preview needs a qualified Server for its current contract"); - let state = manager.save_profile(serde_json::from_value(serde_json::json!({ - "id":null,"revision":null,"name":"Explicit qualified Server","endpoint":server.endpoint, - "authentication":"unauthenticated_loopback","caPem":null, - "compatibility":qualification.id,"keepCredential":false,"credential":null - })).unwrap()).unwrap(); assert!( - state.active.is_none(), - "saving a profile must not activate it" + state + .compatibility_profiles + .iter() + .all(|profile| Some(profile.contract_sha256.as_str()) + != contract["contractSha256"].as_str()) ); + let historical = state.compatibility_profiles.last().unwrap(); + let state = manager + .save_profile( + serde_json::from_value(serde_json::json!({ + "id":null,"revision":null,"name":"Historical Server","endpoint":server.endpoint, + "authentication":"unauthenticated_loopback","caPem":null, + "compatibility":historical.id,"keepCredential":false,"credential":null + })) + .unwrap(), + ) + .unwrap(); let state = manager.check(&state.profiles[0].id, true).await.unwrap(); let active = state.active.unwrap(); + assert!(!active.report.compatibility_verified); + assert!(active.report.supported_operations.is_empty()); + assert_eq!( + manager + .scopes(active.generation, "", None) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); +} + +#[tokio::test] +async fn explicit_fixture_qualification_allows_scope_browsing() { + let server = fixture().await; + let dir = tempfile::tempdir().unwrap(); + let manager = manager(&dir); + let id = add(&manager, "Explicitly qualified fixture", &server.endpoint); + assert!(manager.state().unwrap().active.is_none()); + let state = manager.check(&id, true).await.unwrap(); + let active = state.active.unwrap(); assert!(active.report.compatibility_verified); server.release.notify_one(); assert!(manager.scopes(active.generation, "", None).await.is_ok()); From b54ccf5d1bff6970fd39ef1d33641f0de8a4112f Mon Sep 17 00:00:00 2001 From: lkx Date: Tue, 27 Oct 2026 09:23:21 +0800 Subject: [PATCH 12/17] fix(storage): sort only selected MySQL tables --- .../builtin/persistence/schema.py | 14 ++++---- .../builtin/persistence/test_mysql_schema.py | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index 0917107a3c..26b6dc4d11 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -21,7 +21,7 @@ from sqlalchemy import Connection, MetaData, Table, inspect, text from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import AsyncConnection -from sqlalchemy.schema import CreateTable +from sqlalchemy.schema import CreateTable, sort_tables from powercontext.builtin.persistence.bootstrap import mysql_error_code @@ -62,12 +62,12 @@ def _create_mysql_tables(connection: Connection, tables: Sequence[Table]) -> Non Existing tables are left untouched. This initializes missing tables; it does not reconcile existing schemas or replace the explicit migration workflow. """ - groups: dict[MetaData, set[Table]] = {} - for table in tables: - groups.setdefault(table.metadata, set()).add(table) - for metadata, selected in groups.items(): - for table in metadata.sorted_tables: - if table not in selected or inspect(connection).has_table(table.name, schema=table.schema): + groups: dict[MetaData, list[Table]] = {} + for table in dict.fromkeys(tables): + groups.setdefault(table.metadata, []).append(table) + for selected in groups.values(): + for table in sort_tables(selected): + if inspect(connection).has_table(table.name, schema=table.schema): continue connection.execute(CreateTable(table, if_not_exists=True)) for index in sorted(table.indexes, key=lambda item: str(item.name)): diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index 5c0c5e7a79..febd48735b 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -15,10 +15,24 @@ import re from pathlib import Path -from sqlalchemy import BigInteger, Date, DateTime, Integer, LargeBinary, String, Table +from sqlalchemy import ( + BigInteger, + Column, + Date, + DateTime, + ForeignKey, + Integer, + LargeBinary, + MetaData, + String, + Table, + create_engine, + inspect, +) from sqlalchemy.dialects import mysql from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint +from powercontext.builtin.persistence.schema import _create_mysql_tables from powercontext.builtin.persistence.tables import ( AGENT_SKILL_TARGETS_TABLE, ARTIFACT_HANDLES_TABLE, @@ -35,6 +49,22 @@ UTF8MB4_MAX_BYTES_PER_CHARACTER = 4 +def test_mysql_initialization_ignores_unselected_table_with_unresolved_foreign_key() -> None: + metadata = MetaData() + selected = Table("pc_selected", metadata, Column("id", Integer, primary_key=True)) + Table( + "pc_unselected", + metadata, + Column("id", Integer, primary_key=True), + Column("missing_id", Integer, ForeignKey("pc_missing.id")), + ) + + with create_engine("sqlite://").begin() as connection: + _create_mysql_tables(connection, (selected,)) + assert inspect(connection).has_table(selected.name) + assert not inspect(connection).has_table("pc_unselected") + + class _UnbudgetedColumnTypeError(AssertionError): def __init__(self, column_type: object) -> None: super().__init__(f"unbudgeted indexed column type: {column_type!r}") From 475b723900952e996387c4078cd6f69226bc5444 Mon Sep 17 00:00:00 2001 From: lkx Date: Tue, 27 Oct 2026 09:59:31 +0800 Subject: [PATCH 13/17] chore(release): review persistence compatibility --- release/upgrade-support.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index c0afba9bf2..41b03053bf 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "9b67d639cf57b3ae7cf37dcd1845f50561af36bb19edd9a7b979408f31e9a6de", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB full-server-code and full-server-vector-code are temporarily manual-only due to native DDL waits with the available engine. Main and publication require the other ten; no manual result is needed for publication. The two deferred configurations remain unverified and are excluded from generated supported upgrade declarations. Reports retain their identities and explicitly record incomplete full-matrix acceptance. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged." + "persistence_sha256": "1cd47980b5d17f7826080ab8c3ebcf4f9027ac96eb5836688d64d77fb05a0a7e", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB full-server-code and full-server-vector-code are temporarily manual-only due to native DDL waits with the available engine. Main and publication require the other ten; no manual result is needed for publication. The two deferred configurations remain unverified and are excluded from generated supported upgrade declarations. Reports retain their identities and explicitly record incomplete full-matrix acceptance. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance requires an existing database and never provisions it. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies." }, "cases": [], "automatic_sources": { From eff0ceb2584eccc2d9bc3871172f8818a3ca53ff Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Thu, 29 Oct 2026 15:40:09 +0800 Subject: [PATCH 14/17] perf(storage): avoid copying whole migration inventories --- docs/en/docs/operate/configuration.md | 4 +- docs/zh/docs/operate/configuration.md | 3 +- release/upgrade-support.json | 4 +- .../persistence/migrations/mysql_schema.py | 70 +++++++++++-------- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index c3b5fdd226..9c2a5c491e 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -616,12 +616,14 @@ export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" The URL must use the `mysql+aoceanbase` driver, include an explicit port, and set `charset=utf8mb4`. An omitted database defaults to `powercontext`; an explicit database is preserved. The tenant must use MySQL compatibility mode. -Both seekDB and OceanBase create a missing selected database before initializing tables and reuse existing databases +seekDB startup and OceanBase Python SDK initialization create a missing selected database before initializing tables and reuse existing databases without issuing a database-creation statement. Missing-database creation failures include manual provisioning guidance; other connection errors do not trigger fallback. seekDB detects PowerContext-named tables in the old `test` database when no name was explicitly configured and requires an explicit choice before initializing a new database. Both backends reject database names containing backticks during configuration validation, before any database writes. Choose a name without backticks, such as `team_context`. +Full OceanBase Server deployments require a prepared database and explicit Server schema maintenance before startup; +database auto-provisioning does not replace that admission contract. See [Install and run](../get-started/install-and-run.md) for custom names, permissions, and old seekDB data handling. ### Embeddings and SQLite vector search diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 9aed195f0e..e4a178942e 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -555,7 +555,8 @@ export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" URL 必须使用 `mysql+aoceanbase` 驱动、明确的端口,并设置 `charset=utf8mb4`。省略库名时使用 `powercontext`, 显式库名保持有效。租户必须使用 MySQL 兼容模式。 -seekDB 和 OceanBase 都会先创建缺失的目标库,再初始化表;已有可用库不会执行建库语句。 +seekDB 启动和 OceanBase Python SDK 初始化会先创建缺失的目标库,再初始化表;已有可用库不会执行建库语句。 +完整 OceanBase Server 需要先准备数据库并通过显式维护初始化 Server 表结构,再启动服务;自动建库不替代该准入规则。 建库权限不足时会提供手动准备说明,其他连接错误不会触发回退。seekDB 未显式配置库名时,若在旧 `test` 中检测到 PowerContext 命名的表,会要求先明确选择数据库。两个后端都会在配置校验阶段拒绝包含反引号的库名, 不会产生数据库写入;请改用 `team_context` 等不含反引号的名称。自定义名称、权限和旧数据处理见 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index c52a093aa2..bcd7dd0e54 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "85c6a51d0942ac2bc4d92c0619cf903901b57af7dd731297f9810ffdf2023c27", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance requires an existing database and never provisions it. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before copying inventories to normalize backend defaults. This avoids whole-schema copies for impossible DDL prefixes without changing admitted layouts, registered transitions or recovery checks." + "persistence_sha256": "504ca85e8a6a0b98ca1c266cca473c4b8208b674b0221a8187d647d677ad40e8", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance requires an existing database and never provisions it. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/mysql_schema.py b/src/powercontext/builtin/persistence/migrations/mysql_schema.py index 333869b92e..faee56f2a3 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql_schema.py +++ b/src/powercontext/builtin/persistence/migrations/mysql_schema.py @@ -332,12 +332,12 @@ def _extend_vectors(self) -> None: def _matches(self, inventory: dict[str, Any], expected: dict[str, Any]) -> bool: # Most DDL recovery prefixes have different table sets. Reject them - # before copying the complete inventory to normalize backend defaults. + # before normalizing backend defaults for every table. if inventory.keys() != expected.keys() or inventory["tables"].keys() != expected["tables"].keys(): return False # Captured backends may expose an explicit default that another backend # omits. Only registered values may be absent; retain it in fingerprints. - actual = copy.deepcopy(inventory) + actual = {**inventory, "tables": dict(inventory["tables"])} for name, options in self.optional_table_options.items(): table = actual["tables"].get(name) target = expected["tables"].get(name) @@ -345,6 +345,8 @@ def _matches(self, inventory: dict[str, Any], expected: dict[str, Any]) -> bool: continue for option, allowed in options.items(): if option not in target and table.get(option) in allowed: + table = dict(table) + actual["tables"][name] = table del table[option] return _matches(actual, expected) @@ -867,34 +869,44 @@ def _apply_statement(inventory: dict[str, Any], statement: str) -> None: def _matches(actual: dict[str, Any], expected: dict[str, Any]) -> bool: - normalized = copy.deepcopy(actual) - if set(normalized) != set(expected) or set(normalized["tables"]) != set(expected["tables"]): + if actual.keys() != expected.keys() or actual["tables"].keys() != expected["tables"].keys(): return False - for name, table in normalized["tables"].items(): - expected_table = expected["tables"][name] - if set(table["columns"]) != set(expected_table["columns"]): - return False - for column, specification in table["columns"].items(): - # The frozen SQL inherits backend defaults for display text only. - # Identity columns explicitly require utf8mb4_bin and remain exact. - for option in ("charset", "collation"): - if expected_table["columns"][column][option] is None: - specification[option] = None - automatic_keys: set[tuple[str, ...]] = set() - for index_name, index in tuple(table["indexes"].items()): - if index_name in expected_table["indexes"] or index["unique"] or index.get("kind") is not None: - continue - columns = tuple(key[0] for key in index["columns"]) - foreign_columns = {tuple(key["columns"]) for key in table["foreign_keys"]} - if ( - columns in foreign_columns - and columns not in automatic_keys - and all(prefix is None and order == "asc" for _, prefix, order in index["columns"]) - ): - # MySQL can generate one child-key index; OceanBase need not. - # Ignore its generated name, not arbitrary prefixes/unique keys. - del table["indexes"][index_name] - automatic_keys.add(columns) + if any(value != expected[key] for key, value in actual.items() if key != "tables"): + return False + return all(_table_matches(table, expected["tables"][name]) for name, table in actual["tables"].items()) + + +def _table_matches(table: dict[str, Any], expected: dict[str, Any]) -> bool: + if table["columns"].keys() != expected["columns"].keys(): + return False + # Copy only the maps whose backend-generated defaults can be ignored. + # Constraints and index definitions remain unchanged and compare exactly. + normalized = { + **table, + "columns": {column: dict(specification) for column, specification in table["columns"].items()}, + "indexes": dict(table["indexes"]), + } + for column, specification in normalized["columns"].items(): + # The frozen SQL inherits backend defaults for display text only. + # Identity columns explicitly require utf8mb4_bin and remain exact. + for option in ("charset", "collation"): + if expected["columns"][column][option] is None: + specification[option] = None + automatic_keys: set[tuple[str, ...]] = set() + for index_name, index in table["indexes"].items(): + if index_name in expected["indexes"] or index["unique"] or index.get("kind") is not None: + continue + columns = tuple(key[0] for key in index["columns"]) + foreign_columns = {tuple(key["columns"]) for key in table["foreign_keys"]} + if ( + columns in foreign_columns + and columns not in automatic_keys + and all(prefix is None and order == "asc" for _, prefix, order in index["columns"]) + ): + # MySQL can generate one child-key index; OceanBase need not. + # Ignore its generated name, not arbitrary prefixes/unique keys. + del normalized["indexes"][index_name] + automatic_keys.add(columns) return normalized == expected From dff2aa1370d64b35a78cd8206b7988c98bb78e6e Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Thu, 29 Oct 2026 15:49:03 +0800 Subject: [PATCH 15/17] test(runtime): preserve terminal-cache progress checks under slow I/O --- tests/builtin/runtime/test_processing_scheduler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/builtin/runtime/test_processing_scheduler.py b/tests/builtin/runtime/test_processing_scheduler.py index 87f5d04f4c..dbb7c92c56 100644 --- a/tests/builtin/runtime/test_processing_scheduler.py +++ b/tests/builtin/runtime/test_processing_scheduler.py @@ -665,9 +665,12 @@ async def check(_connection, scope, _binding): ), ), lease_mode="single-process", - blocked_check_seconds=60, + blocked_check_seconds=600, ) as supervisor: - await _wait(lambda: supervisor.family_status["topic-memory"]["completed"] == 1, timeout_seconds=20) + # Scanning 1005 durable intents performs real SQLite I/O. Keep + # the observation window below terminal-cache expiry so expiry + # cannot hide a failure to advance to the healthy tail. + await _wait(lambda: supervisor.family_status["topic-memory"]["completed"] == 1, timeout_seconds=120) assert [a.scope_id for a in launcher.assignments] == ["healthy-tail"] assert supervisor.family_status["topic-memory"]["failed"] == 0 assert supervisor.max_resident <= 1000 + 100 + 1 From 67445051250537501f874e419e124500d5352bc2 Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 30 Oct 2026 00:29:38 +0800 Subject: [PATCH 16/17] fix(storage): distinguish and provision selected seekdb databases Read-only maintenance now reports a missing selected database as `database_missing` and denied access as `database_access_denied` instead of collapsing both into an empty plan, so operators are no longer told to initialize the full bundle when the configured database simply does not exist. Server startup keeps provisioning a missing selected database through its writable path before replanning, while read-only commands still never provision it. Installed-release acceptance explicitly retains the historical `test` database and separately asserts that an omitted selection is refused, and the snapshot probe selects the deployment's actual database. The bootstrap step budget now covers the bounded native retry, the CLI guidance case isolates its data home, and the Desktop qualification case selects an incompatible contract by digest instead of a pinned id. --- .github/workflows/master.yml | 6 +-- desktop/src-tauri/tests/session.rs | 9 ++-- docs/en/docs/operate/database-migrations.md | 4 +- docs/zh/docs/operate/database-migrations.md | 2 +- release/upgrade-support.json | 4 +- scripts/upgrade_database_probe.py | 17 +++++-- .../builtin/persistence/bootstrap.py | 5 +- .../persistence/migrations/connections.py | 12 +++-- .../migrations/server_readiness.py | 10 +++- .../test_database_migration_connections.py | 32 ++++++++++++ tests/e2e/test_database_bootstrap.py | 51 +++++++++++++++++++ tests/release_upgrades/test_full_server.py | 19 ++++++- tests/test_cli.py | 6 ++- 13 files changed, 156 insertions(+), 21 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 18d4d0988a..59cf12305b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -445,9 +445,9 @@ jobs: run: make code-seekdb-test - name: Test database bootstrap with embedded seekdb without skips - # Legacy selection covers two full Server initializations plus reopen - # checks. Keep room for the bounded native-process retry. - timeout-minutes: 15 + # Two 360s native-process deadlines still leave room for the remaining + # bootstrap cases and diagnostic upload before the step deadline. + timeout-minutes: 20 env: POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB: "1" POWERCONTEXT_TEST_BOOTSTRAP_DIAGNOSTICS: ${{ github.workspace }}/.powercontext/database-bootstrap-ci diff --git a/desktop/src-tauri/tests/session.rs b/desktop/src-tauri/tests/session.rs index 9e02011628..e4190a7631 100644 --- a/desktop/src-tauri/tests/session.rs +++ b/desktop/src-tauri/tests/session.rs @@ -297,15 +297,18 @@ async fn historical_qualification_cannot_authorize_the_current_contract() { ); let contract: serde_json::Value = serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); + let current_contract = contract["contractSha256"] + .as_str() + .expect("the bundled contract needs a SHA-256 digest"); let state = manager.state().unwrap(); let historical = state .compatibility_profiles .iter() - .find(|profile| profile.id == "sqlite-atomic-d9a78567-v1") - .unwrap(); + .find(|profile| profile.contract_sha256 != current_contract) + .expect("the compatibility catalog needs a historical contract for this test"); assert_ne!( Some(historical.contract_sha256.as_str()), - contract["contractSha256"].as_str() + Some(current_contract) ); let state = manager .save_profile( diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index e59039545e..bfa5a97db0 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -331,7 +331,9 @@ identity return `target_identity_unavailable`; a proxy hostname is not used as a seekdb inspection does not start an engine for a missing path or empty directory. A nonempty directory without a recognized engine layout returns `unsupported_target`. An unconfigured database name whose old `test` database still holds PowerContext tables returns `explicit_database_required` instead of selecting either database implicitly; select -the name explicitly and rerun the command. Writable seekDB maintenance that cannot create its configured database +the name explicitly and rerun the command. For an existing engine, read-only inspection of a missing selected database +returns `database_missing` with its name and provisioning guidance; denied access returns `database_access_denied`. +Writable seekDB maintenance that cannot create its configured database returns `database_creation_required`, and a permission denial that leaves existence unproven returns `database_access_denied`; both keep the administrator statement and provisioning guidance in the payload. Opening a valid existing engine may write native logs and other engine housekeeping files; read-only migration commands do not diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index f6659ef60a..4154dd3467 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -271,7 +271,7 @@ powercontext server db-migrate apply --env-file deployment.env --backup manual \ OceanBase 维护须能通过 `effective_tenant_id()`、`oceanbase.GV$OB_PARAMETERS`、`oceanbase.DBA_OB_TENANTS` 读取租户 ID、集群 ID 和租户创建信息。权限不足或身份信息不完整时返回 `target_identity_unavailable`,不以代理主机名代替数据库身份。 -seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。可写 seekDB 维护无法创建配置指定的库时返回 `database_creation_required`,权限被拒且无法确认库是否存在时返回 `database_access_denied`;两者都在载荷中保留管理员语句与准备指引。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 +seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。已有引擎中缺少所选数据库时,只读检查返回带库名和准备指引的 `database_missing`;访问被拒时返回 `database_access_denied`。可写 seekDB 维护无法创建配置指定的库时返回 `database_creation_required`,权限被拒且无法确认库是否存在时返回 `database_access_denied`;两者都在载荷中保留管理员语句与准备指引。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 `--evidence-dir PATH` 指定目标库之外的持久目录,保存维护进度及恢复引用。`status`、`plan`、`apply`、`verify` 和重试必须使用同一目录;进程、容器或迁移 Job 退出后仍须保留。每个数据库使用独立目录,并限制为迁移操作者可访问。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index bcd7dd0e54..981c7f83e4 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "504ca85e8a6a0b98ca1c266cca473c4b8208b674b0221a8187d647d677ad40e8", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance requires an existing database and never provisions it. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved." + "persistence_sha256": "b3a218642d40aad8450886afd635b803f2f70f665e904b53cf0214845a5d2702", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. Read-only seekDB maintenance now distinguishes a missing configured database from an uninitialized engine and denied access before planning. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup may provision a missing selected seekDB database through the existing writable bootstrap path before repeating read-only planning; read-only maintenance commands still never provision it." }, "cases": [], "automatic_sources": { diff --git a/scripts/upgrade_database_probe.py b/scripts/upgrade_database_probe.py index 4fa141e17e..f4e633ffd5 100644 --- a/scripts/upgrade_database_probe.py +++ b/scripts/upgrade_database_probe.py @@ -110,7 +110,9 @@ def sqlite_snapshot(database: Path, *, add_revision: bool) -> dict[str, Any]: } -async def inspect(backend: str, database: Path, *, add_revision: bool, canonical: bool = False) -> dict[str, Any]: +async def inspect( + backend: str, database: Path, *, add_revision: bool, canonical: bool = False, seekdb_database: str = "test" +) -> dict[str, Any]: if backend == "sqlite": return sqlite_snapshot(database, add_revision=add_revision) # Use the driver's public API, not a current-checkout Profile that could run @@ -121,7 +123,7 @@ async def inspect(backend: str, database: Path, *, add_revision: bool, canonical if backend == "seekdb": binding = import_module("pylibseekdb") instance = await binding.aopen(str(database)) - options = {**instance.connection_options(), "database": "test"} + options = {**instance.connection_options(), "database": seekdb_database} details["embedded_binding_version"] = version("pylibseekdb") else: url = urlsplit(os.environ["PC_UPGRADE_OCEANBASE_URL"]) @@ -198,8 +200,17 @@ def main() -> None: parser.add_argument("--output", type=Path, required=True) parser.add_argument("--add-revision", action="store_true") parser.add_argument("--canonical", action="store_true") + parser.add_argument("--seekdb-database", default="test") args = parser.parse_args() - result = asyncio.run(inspect(args.backend, args.database, add_revision=args.add_revision, canonical=args.canonical)) + result = asyncio.run( + inspect( + args.backend, + args.database, + add_revision=args.add_revision, + canonical=args.canonical, + seekdb_database=args.seekdb_database, + ) + ) args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/src/powercontext/builtin/persistence/bootstrap.py b/src/powercontext/builtin/persistence/bootstrap.py index aaf7260cce..938667ec0e 100644 --- a/src/powercontext/builtin/persistence/bootstrap.py +++ b/src/powercontext/builtin/persistence/bootstrap.py @@ -61,8 +61,9 @@ class MissingDatabaseError(PersistenceError): def __init__(self, name: str) -> None: self.database_name = name super().__init__( - f"Database {name!r} does not exist. This command inspects the configured database without creating it; " - "provision it first, or start the Server once to create the default database, then repeat the command." + f"Database {name!r} does not exist. This command inspects the configured database without creating it. " + f"Ask an administrator to run {creation_statement(name)}, or use a supported writable bootstrap path, " + "then repeat the command. No fallback database was selected." ) diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index 4169686ea9..1dec7adfd2 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -35,8 +35,9 @@ from powercontext.builtin.persistence.bootstrap import ( DatabaseAccessError, DatabaseCreationError, - database_is_available, + MissingDatabaseError, ensure_database, + require_existing_database, ) from powercontext.builtin.persistence.database import begin_mysql_read_snapshot from powercontext.builtin.persistence.oceanbase import OceanBaseConfig @@ -203,8 +204,13 @@ async def _seekdb( # noqa: C901 ), name=config.database, ) - elif not await database_is_available(engine): - return operation(None, identity, lambda: None) + else: + try: + await require_existing_database(engine, name=config.database) + except MissingDatabaseError as error: + raise MigrationError("database_missing", str(error)) from error + except DatabaseAccessError as error: + raise MigrationError("database_access_denied", str(error)) from error inode = _directory_inode(path) if initial_inode is not None and initial_inode != inode: raise MigrationError("migration_lock_lost", "The seekdb directory changed during engine startup.") diff --git a/src/powercontext/builtin/persistence/migrations/server_readiness.py b/src/powercontext/builtin/persistence/migrations/server_readiness.py index 6771f46845..0bd9f5b430 100644 --- a/src/powercontext/builtin/persistence/migrations/server_readiness.py +++ b/src/powercontext/builtin/persistence/migrations/server_readiness.py @@ -144,7 +144,15 @@ def verify(connection, identity, guard): MaintenanceConnections(database).run(verify) return runner = deployment_runner(settings, embedding_profile=profile) - plan = runner.plan() + try: + plan = runner.plan() + except MigrationError as error: + if database.kind != "seekdb" or error.code != "database_missing": + raise + from .connections import MaintenanceConnections + + MaintenanceConnections(database).run(lambda _connection, _identity, _guard: None, writable=True) + plan = runner.plan() if plan.state == "uninitialized" and database.kind in {"sqlite", "seekdb"}: runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) elif plan.state != "ready": diff --git a/tests/builtin/persistence/test_database_migration_connections.py b/tests/builtin/persistence/test_database_migration_connections.py index 79cf53107f..7b99cdf6d6 100644 --- a/tests/builtin/persistence/test_database_migration_connections.py +++ b/tests/builtin/persistence/test_database_migration_connections.py @@ -35,6 +35,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from powercontext.builtin.persistence import dream_schema +from powercontext.builtin.persistence.bootstrap import MissingDatabaseError from powercontext.builtin.persistence.migrations import connections from powercontext.builtin.persistence.migrations.connections import BackendIdentity, MaintenanceConnections from powercontext.builtin.persistence.migrations.locking import local_migration_lock @@ -462,6 +463,37 @@ def unexpected(connection: Connection | None, _identity: BackendIdentity, verify assert "administrator" in str(error.value) +@pytest.mark.parametrize( + ("error_factory", "code"), + [ + (lambda: MissingDatabaseError("custom-db"), "database_missing"), + ( + lambda: connections.DatabaseAccessError("custom-db", "CREATE DATABASE IF NOT EXISTS `custom-db`"), + "database_access_denied", + ), + ], +) +def test_seekdb_read_only_inspection_reports_selected_database_error( + tmp_path: Path, monkeypatch, error_factory, code +) -> None: + path, _assert_locked = _fake_seekdb(tmp_path, monkeypatch) + path.mkdir() + (path / "fixture").touch() + monkeypatch.setattr(connections.seekdb_profile, "has_native_storage", lambda _path: True) + + async def refuse(_engine, *, name: str) -> None: + assert name == "custom-db" + raise error_factory() + + monkeypatch.setattr(connections, "require_existing_database", refuse) + operation = Mock() + with pytest.raises(MigrationError) as error: + MaintenanceConnections(SeekDBConfig(path=path, database="custom-db")).run(operation) + assert error.value.code == code + assert "custom-db" in str(error.value) + operation.assert_not_called() + + def _statement_snapshot_adapter(target): engine = create_async_engine(f"sqlite+aiosqlite:///{target}") diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index 90d549eb85..7508daa48e 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -25,6 +25,7 @@ import asyncio import gzip +import json import os import subprocess import sys @@ -40,6 +41,7 @@ from sqlalchemy.engine import make_url from sqlalchemy.exc import DBAPIError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from typer.testing import CliRunner from powercontext.builtin.persistence.bootstrap import ( DatabaseAccessError, @@ -50,6 +52,8 @@ from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.oceanbase.profile import _register_official_dialect from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.cli.app import create_cli +from powercontext.server.cli import app as server_app _METADATA = MetaData() _RECORDS = Table( @@ -230,6 +234,53 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") +def test_seekdb_db_migrate_reports_a_missing_selected_database(short_tmp_path: Path) -> None: + pytest.importorskip("pylibseekdb") + path = short_tmp_path / "seekdb" + retained = SeekDBConfig(path=path, database="retained_context") + + async def seed() -> None: + async with SeekDBProfile.open(retained, tables=()): + pass + + asyncio.run(seed()) + environment = short_tmp_path / "server.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=seekdb\n" + f"POWERCONTEXT_SERVER_DATABASE_PATH={path}\n" + "POWERCONTEXT_SERVER_DATABASE_DATABASE=absent_context\n", + encoding="utf-8", + ) + + for command in ("status", "plan", "verify"): + result = CliRunner().invoke( + create_cli([server_app]), ["server", "db-migrate", command, "--env-file", str(environment)] + ) + assert result.exit_code == 1, result.output + payload = json.loads(result.stdout) + assert payload["state"] == "blocked" + assert payload["error"] == "database_missing" + assert "absent_context" in payload["message"] + assert "does not exist" in payload["message"] + assert "CREATE DATABASE IF NOT EXISTS `absent_context`" in payload["message"] + assert "No fallback database was selected" in payload["message"] + + async def verify() -> None: + async with ( + SeekDBProfile.open_existing(retained, tables=()) as profile, + profile.database.transaction() as connection, + ): + assert (await connection.exec_driver_sql("SELECT DATABASE()")).scalar_one() == retained.database + absent = await connection.exec_driver_sql( + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", + ("absent_context",), + ) + assert absent.all() == [] + + asyncio.run(verify()) + + @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") def test_seekdb_legacy_choice_preserves_old_data_and_separates_new_database(short_tmp_path: Path) -> None: pytest.importorskip("pylibseekdb") diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py index c5462e9b79..8f1c578d4a 100644 --- a/tests/release_upgrades/test_full_server.py +++ b/tests/release_upgrades/test_full_server.py @@ -303,6 +303,8 @@ def environment_for(case, *, fresh=False): environment["POWERCONTEXT_SERVER_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}" elif backend == "seekdb": environment["POWERCONTEXT_SERVER_DATABASE_PATH"] = str(database) + if not fresh: + environment["POWERCONTEXT_SERVER_DATABASE_DATABASE"] = "test" else: name = "PC_UPGRADE_OCEANBASE_FRESH_URL" if fresh else "PC_UPGRADE_OCEANBASE_URL" url = os.environ[name] @@ -341,6 +343,7 @@ def snapshot(case, role, name, *, fresh=False): case["backend"], "--database", str(case["work"] / ("fresh" if fresh else "database")), + *(["--seekdb-database", "powercontext"] if case["backend"] == "seekdb" and fresh else []), "--output", str(path), ], @@ -348,7 +351,10 @@ def snapshot(case, role, name, *, fresh=False): case["work"], case["report"] / f"{name}-probe.log", ) - return json.loads(path.read_text()) + result = json.loads(path.read_text()) + if case["backend"] == "seekdb": + assert result["database"] == ("powercontext" if fresh else "test") + return result def task_state(case, role, operation, scope): @@ -580,6 +586,17 @@ def historical(case): @pytest.fixture(scope="session") def rejected_legacy_startup(case, historical): before = snapshot(case, "target", "before-rejected-startup") + if case["backend"] == "seekdb": + implicit_environment = environment_for(case) + implicit_environment.pop("POWERCONTEXT_SERVER_DATABASE_DATABASE") + implicit_log = case["report"] / "implicit-legacy-target-server.log" + with ( + pytest.raises(AssertionError, match="Server exited before readiness"), + server(case["target"], implicit_environment, case["work"], implicit_log), + ): + pytest.fail("The target Server switched away from the historical seekdb database") + assert "explicit_database_required" in implicit_log.read_text() + assert "POWERCONTEXT_SERVER_DATABASE_DATABASE=test" in implicit_log.read_text() log = case["report"] / "legacy-target-server.log" with ( pytest.raises(AssertionError, match="Server exited before readiness"), diff --git a/tests/test_cli.py b/tests/test_cli.py index e1b950cf85..df65853b89 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -667,7 +667,11 @@ def refuse_implicit_database(_config, _database): ("atomic-memory-rebuild-projection", "_atomic_memory_maintenance", ["--maintenance-confirmed"]), ], ) -def test_server_maintenance_reports_database_permission_guidance(monkeypatch, error_type, command, operation, options): +def test_server_maintenance_reports_database_permission_guidance( + tmp_path, monkeypatch, error_type, command, operation, options +): + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path)) + async def refuse_database(*_args, **_kwargs): raise error_type("team_context", creation_statement("team_context")) From f203fbfee8956c97b7e32490c0d7d58d0857ab46 Mon Sep 17 00:00:00 2001 From: lkx Date: Fri, 30 Oct 2026 11:54:58 +0800 Subject: [PATCH 17/17] fix(migrations): plan missing seekdb databases before apply --- docs/en/docs/operate/database-migrations.md | 6 ++- docs/zh/docs/operate/database-migrations.md | 2 +- release/upgrade-support.json | 4 +- .../persistence/migrations/connections.py | 18 ++++++++- .../builtin/persistence/migrations/mysql.py | 15 ++++++-- .../migrations/server_readiness.py | 10 +---- src/powercontext/server/database_migration.py | 8 +++- tests/e2e/test_database_bootstrap.py | 38 ++++++++++++++++--- tests/test_database_migration_cli.py | 2 +- 9 files changed, 78 insertions(+), 25 deletions(-) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index bfa5a97db0..e9ca6724c0 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -331,8 +331,10 @@ identity return `target_identity_unavailable`; a proxy hostname is not used as a seekdb inspection does not start an engine for a missing path or empty directory. A nonempty directory without a recognized engine layout returns `unsupported_target`. An unconfigured database name whose old `test` database still holds PowerContext tables returns `explicit_database_required` instead of selecting either database implicitly; select -the name explicitly and rerun the command. For an existing engine, read-only inspection of a missing selected database -returns `database_missing` with its name and provisioning guidance; denied access returns `database_access_denied`. +the name explicitly and rerun the command. For an existing engine missing the selected database, `status` and `verify` +return `database_missing` with its name and provisioning guidance. `plan` instead returns an `uninitialized` plan bound +to the selected database without creating it; after the operator accepts that plan, `apply` may create the database and +initialize its registered schema. Denied access returns `database_access_denied`. Writable seekDB maintenance that cannot create its configured database returns `database_creation_required`, and a permission denial that leaves existence unproven returns `database_access_denied`; both keep the administrator statement and provisioning guidance in the payload. Opening a diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index 4154dd3467..f511938a7e 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -271,7 +271,7 @@ powercontext server db-migrate apply --env-file deployment.env --backup manual \ OceanBase 维护须能通过 `effective_tenant_id()`、`oceanbase.GV$OB_PARAMETERS`、`oceanbase.DBA_OB_TENANTS` 读取租户 ID、集群 ID 和租户创建信息。权限不足或身份信息不完整时返回 `target_identity_unavailable`,不以代理主机名代替数据库身份。 -seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。已有引擎中缺少所选数据库时,只读检查返回带库名和准备指引的 `database_missing`;访问被拒时返回 `database_access_denied`。可写 seekDB 维护无法创建配置指定的库时返回 `database_creation_required`,权限被拒且无法确认库是否存在时返回 `database_access_denied`;两者都在载荷中保留管理员语句与准备指引。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 +seekdb 只读检查遇到缺失路径或空目录时不启动引擎;非空目录缺少可识别的引擎结构时返回 `unsupported_target`。未显式配置库名、而旧 `test` 库中仍有 PowerContext 表时返回 `explicit_database_required`,不会隐式选择任一数据库;显式选定库名后重跑该命令。已有引擎中缺少所选数据库时,`status` 和 `verify` 返回带库名和准备指引的 `database_missing`;`plan` 不创建数据库,而是返回绑定所选库的 `uninitialized` 计划。操作员确认计划后,`apply` 才可创建数据库并初始化已登记的结构。访问被拒时返回 `database_access_denied`。可写 seekDB 维护无法创建配置指定的库时返回 `database_creation_required`,权限被拒且无法确认库是否存在时返回 `database_access_denied`;两者都在载荷中保留管理员语句与准备指引。打开有效的已有引擎可能写入原生日志和引擎维护文件;只读迁移命令不执行业务 DDL、不初始化表。 `--evidence-dir PATH` 指定目标库之外的持久目录,保存维护进度及恢复引用。`status`、`plan`、`apply`、`verify` 和重试必须使用同一目录;进程、容器或迁移 Job 退出后仍须保留。每个数据库使用独立目录,并限制为迁移操作者可访问。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 981c7f83e4..25ed122095 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "b3a218642d40aad8450886afd635b803f2f70f665e904b53cf0214845a5d2702", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. Read-only seekDB maintenance now distinguishes a missing configured database from an uninitialized engine and denied access before planning. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup may provision a missing selected seekDB database through the existing writable bootstrap path before repeating read-only planning; read-only maintenance commands still never provision it." + "persistence_sha256": "2037f0c8d95537354ab2a84e193512422683d6c01ccfd3a17c89ec889b117154", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/connections.py b/src/powercontext/builtin/persistence/migrations/connections.py index 1dec7adfd2..14a7fce9ab 100644 --- a/src/powercontext/builtin/persistence/migrations/connections.py +++ b/src/powercontext/builtin/persistence/migrations/connections.py @@ -105,6 +105,7 @@ def run( operation: Callable[[Connection | None, BackendIdentity, Callable[[], None]], _T], *, writable: bool = False, + allow_missing_database: bool = False, ) -> _T: if isinstance(self.config, SQLiteConfig): return self._sqlite(operation, self.config, writable=writable) @@ -129,7 +130,15 @@ def run( ) initial_inode = _directory_inode(path) if path.exists() else None return asyncio.run( - self._seekdb(operation, self.config, path, identity, initial_inode, writable=writable) + self._seekdb( + operation, + self.config, + path, + identity, + initial_inode, + writable=writable, + allow_missing_database=allow_missing_database, + ) ) return asyncio.run(self._oceanbase(operation, self.config, writable=writable)) @@ -181,6 +190,7 @@ async def _seekdb( # noqa: C901 initial_inode: tuple[int, int] | None, *, writable: bool, + allow_missing_database: bool, ) -> _T: try: module = seekdb_profile._load_binding() @@ -208,6 +218,12 @@ async def _seekdb( # noqa: C901 try: await require_existing_database(engine, name=config.database) except MissingDatabaseError as error: + if allow_missing_database: + if initial_inode != _directory_inode(path): + raise MigrationError( + "migration_lock_lost", "The seekdb maintenance target was lost." + ) from error + return operation(None, identity, lambda: None) raise MigrationError("database_missing", str(error)) from error except DatabaseAccessError as error: raise MigrationError("database_access_denied", str(error)) from error diff --git a/src/powercontext/builtin/persistence/migrations/mysql.py b/src/powercontext/builtin/persistence/migrations/mysql.py index 4cb5baf641..ea7706d431 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql.py +++ b/src/powercontext/builtin/persistence/migrations/mysql.py @@ -34,6 +34,8 @@ from pydantic import BaseModel, ConfigDict, model_validator from sqlalchemy import Connection +from powercontext.builtin.persistence.seekdb import SeekDBConfig + from .backup import BackupContext, BackupProvider, BackupRef, ForkBackupProvider from .bundle import ALEMBIC_CONTEXT_LOCK, MigrationBundle from .connections import BackendIdentity, MaintenanceConnections @@ -265,13 +267,20 @@ def plan( backup_policy: BackupPolicy = "auto", service: dict[str, Any] | None = None, shared_database: bool = False, + require_existing_database: bool = False, ) -> MigrationPlan: self._assert_unchanged() - return self.connections.run( - lambda connection, identity, _guard: self._inspect( + + def inspect( + connection: Connection | None, identity: BackendIdentity, _guard: Callable[[], None] + ) -> MigrationPlan: + return self._inspect( connection, identity, backup_policy=backup_policy, service=service, shared_database=shared_database ) - ) + + if isinstance(self.connections.config, SeekDBConfig) and not require_existing_database: + return self.connections.run(inspect, allow_missing_database=True) + return self.connections.run(inspect) def verify(self) -> MigrationResult: def verify( diff --git a/src/powercontext/builtin/persistence/migrations/server_readiness.py b/src/powercontext/builtin/persistence/migrations/server_readiness.py index 0bd9f5b430..6771f46845 100644 --- a/src/powercontext/builtin/persistence/migrations/server_readiness.py +++ b/src/powercontext/builtin/persistence/migrations/server_readiness.py @@ -144,15 +144,7 @@ def verify(connection, identity, guard): MaintenanceConnections(database).run(verify) return runner = deployment_runner(settings, embedding_profile=profile) - try: - plan = runner.plan() - except MigrationError as error: - if database.kind != "seekdb" or error.code != "database_missing": - raise - from .connections import MaintenanceConnections - - MaintenanceConnections(database).run(lambda _connection, _identity, _guard: None, writable=True) - plan = runner.plan() + plan = runner.plan() if plan.state == "uninitialized" and database.kind in {"sqlite", "seekdb"}: runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) elif plan.state != "ready": diff --git a/src/powercontext/server/database_migration.py b/src/powercontext/server/database_migration.py index bd33f9fd1b..cc535ac8d0 100644 --- a/src/powercontext/server/database_migration.py +++ b/src/powercontext/server/database_migration.py @@ -28,6 +28,7 @@ from powercontext.builtin.persistence.migrations import MigrationError, MigrationPlan, MigrationResult from powercontext.builtin.persistence.migrations.deployment import MigrationRunner, deployment_runner +from powercontext.builtin.persistence.migrations.mysql import MySQLMigrationRunner from powercontext.builtin.persistence.migrations.revision_names import describe_revision from powercontext.server.configuration import ( ServerConfigurationError, @@ -259,7 +260,12 @@ def _inspect( migration_id=migration_id, batch_size=batch_size, ) - result = runner.verify() if verify else runner.plan() + if verify: + result = runner.verify() + elif isinstance(runner, MySQLMigrationRunner): + result = runner.plan(require_existing_database=True) + else: + result = runner.plan() _write_payload(result) diff --git a/tests/e2e/test_database_bootstrap.py b/tests/e2e/test_database_bootstrap.py index 7508daa48e..b41f45c1b5 100644 --- a/tests/e2e/test_database_bootstrap.py +++ b/tests/e2e/test_database_bootstrap.py @@ -235,7 +235,7 @@ async def scenario() -> None: @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") -def test_seekdb_db_migrate_reports_a_missing_selected_database(short_tmp_path: Path) -> None: +def test_seekdb_db_migrate_plans_and_applies_a_missing_selected_database(short_tmp_path: Path) -> None: pytest.importorskip("pylibseekdb") path = short_tmp_path / "seekdb" retained = SeekDBConfig(path=path, database="retained_context") @@ -253,10 +253,9 @@ async def seed() -> None: encoding="utf-8", ) - for command in ("status", "plan", "verify"): - result = CliRunner().invoke( - create_cli([server_app]), ["server", "db-migrate", command, "--env-file", str(environment)] - ) + cli = create_cli([server_app]) + for command in ("status", "verify"): + result = CliRunner().invoke(cli, ["server", "db-migrate", command, "--env-file", str(environment)]) assert result.exit_code == 1, result.output payload = json.loads(result.stdout) assert payload["state"] == "blocked" @@ -266,6 +265,13 @@ async def seed() -> None: assert "CREATE DATABASE IF NOT EXISTS `absent_context`" in payload["message"] assert "No fallback database was selected" in payload["message"] + planned = CliRunner().invoke( + cli, ["server", "db-migrate", "plan", "--env-file", str(environment), "--backup", "skip"] + ) + assert planned.exit_code == 0, planned.output + plan = json.loads(planned.stdout) + assert plan["state"] == "uninitialized" + async def verify() -> None: async with ( SeekDBProfile.open_existing(retained, tables=()) as profile, @@ -280,6 +286,28 @@ async def verify() -> None: asyncio.run(verify()) + applied = CliRunner().invoke( + cli, + [ + "server", + "db-migrate", + "apply", + "--env-file", + str(environment), + "--backup", + "skip", + "--plan-id", + plan["plan_id"], + "--yes", + "--maintenance-confirmed", + ], + ) + assert applied.exit_code == 0, applied.output + assert json.loads(applied.stdout)["state"] == "ready" + verified = CliRunner().invoke(cli, ["server", "db-migrate", "verify", "--env-file", str(environment)]) + assert verified.exit_code == 0, verified.output + assert json.loads(verified.stdout)["state"] == "ready" + @pytest.mark.skipif(not _SEEKDB, reason="set POWERCONTEXT_TEST_BOOTSTRAP_SEEKDB=1") def test_seekdb_legacy_choice_preserves_old_data_and_separates_new_database(short_tmp_path: Path) -> None: diff --git a/tests/test_database_migration_cli.py b/tests/test_database_migration_cli.py index 74aaf7f34a..83e9752bca 100644 --- a/tests/test_database_migration_cli.py +++ b/tests/test_database_migration_cli.py @@ -515,7 +515,7 @@ def test_remote_cli_uses_the_configured_maintenance_identity_and_coordination_sc (tmp_path / "deployment.env").write_text(environment, encoding="utf-8") identity = BackendIdentity(kind, f"simulated-{kind}-database-identity", database_name) - def inspect_empty_database(_connections, operation, *, writable=False): + def inspect_empty_database(_connections, operation, *, writable=False, **_options): assert not writable return operation(None, identity, lambda: None)