Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/en/docs/how-to/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,74 @@ powercontext server run
Use the same environment variable whenever you start or diagnose that instance. PowerContext creates missing parent
directories for a file-backed SQLite database.

## OceanBase startup rejects an incompatible schema

Current PowerContext releases compare opaque identity columns byte-for-byte with `utf8mb4_bin`. A database created by
an older release may still use a case-insensitive collation such as `utf8mb4_general_ci`. The Server checks existing
identity columns before creating any missing tables and refuses to start when it finds a mismatch. The startup error
lists each affected `table.column`, its actual collation, and the required collation; it never includes the database
URL or credentials.

Do not alter these columns in place. They participate in primary keys, foreign keys, and indexes, and an earlier
case-insensitive deployment may already have treated distinct identities as the same value. Use a new empty database
so the previous database remains available for recovery:

1. Stop the Server and every process that writes to the database.
2. Take and verify a full recoverable backup using your normal OceanBase backup procedure.
3. Export the PowerContext table data with OceanBase `obdumper` in CSV or SQL data mode **without `--ddl`**. Keep the
export and the original database unchanged until the migration is verified. Supply credentials through your
approved secret-handling process rather than placing them in logs or documentation.
4. Create a new empty OceanBase MySQL-mode database and point `POWERCONTEXT_SERVER_DATABASE_URL` at it. Start the
current PowerContext version once to create tables with `utf8mb4_bin`, then stop it before restoring data.
5. Import only the exported row data into the existing new tables with OceanBase `obloader`, again **without
Comment thread
Vanillaxi marked this conversation as resolved.
`--ddl`**. Keep foreign-key enforcement enabled and run these three layers separately. The examples use CSV; if
you exported SQL data, replace `--csv` with `--sql` in all three commands. Fill in `<connection-options>` through
your approved secret-handling process and make `<new-database>` select the database created in step 4.

Before running the commands, compare the exported table files with `SHOW TABLES` in the target database. Every
exported table named below must exist in the target; if one is missing, stop and create it with the current
PowerContext configuration before importing. Remove a name only when the source export does not contain that table.
If the source predates the seven `pc_handoff_report_*` tables, remove them from Layer 1. If the export contains them
but Handoff Report is disabled, temporarily enable it in step 4 to create the current tables, restore their data,
and return the setting to its intended value only after verification.

Layer 1 contains parents and tables without foreign keys:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_external_skill_registrations,pc_model_usage_daily,pc_recall_token_daily,pc_handoff_report_projects,pc_handoff_report_project_revisions,pc_handoff_report_workstreams,pc_handoff_report_workstream_revisions,pc_handoff_report_workspace_bindings,pc_handoff_report_activity_heads,pc_handoff_report_activities' \
-f <export-directory>
```

After Layer 1 completes successfully, import its children in Layer 2:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_candidate_versions,pc_memory_entry_versions' \
-f <export-directory>
```

After Layer 2 completes successfully, import the remaining children in Layer 3:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_artifact_candidate_heads,pc_memory_entry_heads' \
-f <export-directory>
```

Wait for each invocation to complete successfully before starting the next. Treat any OBLoader error, bad record,
or conflict record as a failed restore. Order within a layer is irrelevant because no table in a layer references
another table in the same layer.
6. If the installation has additional PowerContext-managed tables not listed above, these tested layers do not
classify them. Inspect their foreign-key constraints and place each table after all of its parents; do not add
them to an all-table invocation.
7. Compare source and target row counts for every restored table, inspect the identity-column collations, and test
identities that differ only by case or accent. Start normal traffic only after every check passes. Retain the
source database, verified backup, and export through the rollback window.

If records were previously merged because the old collation considered their identities equal, changing the schema
cannot reconstruct them. Resolve those records from an authoritative source before accepting writes.

## An inference readiness check fails

When generation or embedding is configured, Server readiness makes one minimal real provider request. This catches
Expand Down
61 changes: 61 additions & 0 deletions docs/zh/docs/how-to/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,67 @@ powercontext server run
每次启动或诊断该实例时都应使用同一个环境变量。对于文件型 SQLite 数据库,PowerContext 会创建缺失的父
目录。

## OceanBase 因 schema 不兼容而拒绝启动

当前 PowerContext 使用 `utf8mb4_bin` 对不透明 identity column 进行逐字节比较。旧版本创建的数据库可能仍然
使用 `utf8mb4_general_ci` 等不区分大小写的 collation。Server 会在创建任何缺失表之前检查已有 identity
column;发现不兼容时拒绝启动。启动错误会列出每个受影响的 `table.column`、实际 collation 和要求的
collation,但不会包含数据库 URL 或凭据。

不要直接修改这些 column。它们参与主键、外键和索引,而且旧部署可能已经把本应不同的 identity 当作同一个值。
请使用新的空数据库,使旧数据库可以继续用于恢复:

1. 停止 Server 以及所有会写入该数据库的进程。
2. 按现有 OceanBase 备份流程创建并验证一份可恢复的完整备份。
3. 使用 OceanBase `obdumper` 的 CSV 或 SQL 数据模式导出 PowerContext 表数据,且**不要使用 `--ddl`**。
在迁移验证完成之前,保持导出文件和原数据库不变。请通过获批的 secret 管理流程提供凭据,不要把凭据写入日志
或文档。
4. 新建一个空的 OceanBase MySQL-mode 数据库,将 `POWERCONTEXT_SERVER_DATABASE_URL` 指向它。启动一次当前
PowerContext,使其创建使用 `utf8mb4_bin` 的表;恢复数据前再次停止 Server。
5. 使用 OceanBase `obloader` 只把导出的数据导入已经存在的新表,同样**不要使用 `--ddl`**。保持外键检查开启,
并分别运行下面三层命令。示例使用 CSV;如果导出的是 SQL 数据,请把三个命令中的 `--csv` 全部替换为
`--sql`。通过获批的 secret 管理流程填写 `<connection-options>`,并让 `<new-database>` 指向第 4 步创建的
数据库。

运行命令前,对照导出的表文件和目标数据库中的 `SHOW TABLES`。下面列出的每张已导出表都必须存在于目标数据库;
如果目标表缺失,请停止恢复,并先使用当前 PowerContext 配置创建该表。只有源导出不包含某张表时,才能从命令中
删除它。源数据库早于七张 `pc_handoff_report_*` 表时,应从第 1 层删除这些表;如果导出包含这些表但 Handoff
Report 已关闭,请在第 4 步临时启用该功能以创建当前表并恢复其数据,验证完成后再恢复预期配置。

第 1 层包含父表和无外键的表:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_external_skill_registrations,pc_model_usage_daily,pc_recall_token_daily,pc_handoff_report_projects,pc_handoff_report_project_revisions,pc_handoff_report_workstreams,pc_handoff_report_workstream_revisions,pc_handoff_report_workspace_bindings,pc_handoff_report_activity_heads,pc_handoff_report_activities' \
-f <export-directory>
```

第 1 层成功完成后,导入第 2 层中的子表:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_candidate_versions,pc_memory_entry_versions' \
-f <export-directory>
```

第 2 层成功完成后,导入第 3 层中剩余的子表:

```bash
obloader <connection-options> -D <new-database> --csv \
--table 'pc_artifact_candidate_heads,pc_memory_entry_heads' \
-f <export-directory>
```

每个命令成功完成后才能开始下一层。OBLoader 出现任何错误、bad record 或 conflict record 时,都应判定恢复
失败。同一层内的表互不引用,因此层内顺序无关。
6. 如果安装中还存在上面未列出的 PowerContext 管理表,则这些已测试的层并未对它们分类。检查其外键约束,将每张
表放在其所有父表之后;不要把它们加入全表导入命令。
7. 逐表比较源数据库和目标数据库的记录数,检查 identity column collation,并测试仅大小写或重音不同的
identity。所有检查通过后才能恢复正常流量。在整个回滚窗口内,保留源数据库、已验证的备份和导出文件。

如果旧 collation 曾因 identity 相等而合并记录,重建 schema 无法恢复这些记录。接受新的写入之前,请从权威数据源
修复它们。

## 推理服务 readiness 检查失败

配置 generation 或 embedding 后,Server readiness 会向 provider 发起一次最小化真实请求。这样可以发现只有
Expand Down
2 changes: 2 additions & 0 deletions src/powercontext/builtin/persistence/oceanbase/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
"""OceanBase async relational profile."""

from powercontext.builtin.persistence.oceanbase.profile import (
IncompatibleOceanBaseSchemaError,
OceanBaseConfig,
OceanBaseProfile,
UnsupportedOceanBaseTenantError,
)

__all__ = (
"IncompatibleOceanBaseSchemaError",
"OceanBaseConfig",
"OceanBaseProfile",
"UnsupportedOceanBaseTenantError",
Expand Down
72 changes: 72 additions & 0 deletions src/powercontext/builtin/persistence/oceanbase/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
Expand All @@ -30,11 +31,35 @@
from powercontext.builtin.persistence.database import AsyncDatabase
from powercontext.builtin.persistence.errors import PersistenceError
from powercontext.builtin.persistence.schema import create_tables
from powercontext.builtin.persistence.tables import MYSQL_IDENTITY_COLLATION

_DIALECT_DRIVER = "mysql+aoceanbase"
_DIALECT_REGISTRY_NAME = "mysql.aoceanbase"
_DIALECT_MODULE = "pyobvector"
_DIALECT_CLASS = "AsyncOceanBaseDialect"
_SCHEMA_COLUMNS_QUERY = """
SELECT TABLE_NAME, COLUMN_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND DATA_TYPE = 'varchar'
"""
_SCHEMA_RECREATION_GUIDE = (
"https://oceanbase.github.io/powercontext/en/docs/how-to/troubleshoot/"
"#oceanbase-startup-rejects-an-incompatible-schema"
)


@dataclass(frozen=True)
class _IdentityCollationMismatch:
table_name: str
column_name: str
actual: str | None

@property
def qualified_name(self) -> str:
"""Return the operator-facing table and column name."""

return f"{self.table_name}.{self.column_name}"


class UnsupportedOceanBaseTenantError(PersistenceError):
Expand All @@ -46,6 +71,22 @@ def __init__(self, compatibility_mode: str | None) -> None:
super().__init__(f"OceanBase profile requires a MySQL-compatible tenant; found {description}")


class IncompatibleOceanBaseSchemaError(PersistenceError):
"""Raised when an existing identity column has unsafe comparison semantics."""

def __init__(self, mismatches: tuple[_IdentityCollationMismatch, ...]) -> None:
self.columns = tuple(mismatch.qualified_name for mismatch in mismatches)
details = "; ".join(
f"{mismatch.qualified_name} uses {mismatch.actual or 'NULL'} (expected {MYSQL_IDENTITY_COLLATION})"
for mismatch in mismatches
)
super().__init__(
"OceanBase schema has incompatible identity column collations: "
f"{details}. Back up the database, recreate the PowerContext schema, and restore the data before "
f"restarting. See {_SCHEMA_RECREATION_GUIDE}"
)


class OceanBaseConfig(BaseModel):
"""Validated component configuration for an OceanBase async engine."""

Expand Down Expand Up @@ -114,6 +155,7 @@ async def _initialized_profile(profile: OceanBaseProfile) -> AsyncIterator[Ocean
try:
async with profile.database.transaction() as connection:
await _require_mysql_tenant(connection)
await _require_compatible_identity_collations(connection, profile.tables)
await create_tables(connection, profile.tables)
yield profile
finally:
Expand All @@ -130,6 +172,36 @@ async def _require_mysql_tenant(connection: AsyncConnection) -> None:
raise UnsupportedOceanBaseTenantError(mode)


async def _require_compatible_identity_collations(
connection: AsyncConnection,
tables: tuple[Table, ...],
) -> None:
"""Reject legacy PowerContext VARCHAR columns with non-binary identity semantics."""

identity_columns = {
(table.name, column.name)
for table in tables
for column in table.columns
if getattr(column.type.dialect_impl(connection.dialect), "collation", None) == MYSQL_IDENTITY_COLLATION
}
if not identity_columns:
return

result = await connection.exec_driver_sql(_SCHEMA_COLUMNS_QUERY)
incompatible: list[_IdentityCollationMismatch] = []
for table_name_value, column_name_value, actual_value in result.all():
table_name = str(table_name_value)
column_name = str(column_name_value)
if (table_name, column_name) not in identity_columns:
continue
actual_collation = None if actual_value is None else str(actual_value)
if actual_collation is None or actual_collation.casefold() != MYSQL_IDENTITY_COLLATION.casefold():
incompatible.append(_IdentityCollationMismatch(table_name, column_name, actual_collation))

if incompatible:
raise IncompatibleOceanBaseSchemaError(tuple(sorted(incompatible, key=lambda item: item.qualified_name)))


def _register_official_dialect() -> None:
# pyobvector publishes the official AsyncOceanBaseDialect but currently
# documents explicit SQLAlchemy registry setup instead of an entry point.
Expand Down
7 changes: 4 additions & 3 deletions src/powercontext/builtin/persistence/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

SHARED_METADATA = MetaData()

_MYSQL_IDENTITY_COLLATION = "utf8mb4_bin"
MYSQL_IDENTITY_COLLATION = "utf8mb4_bin"


def identity_string(length: int):
Expand All @@ -58,11 +58,12 @@ def identity_string(length: int):

``create_all(checkfirst=True)`` does not rewrite existing column collations,
and OceanBase rejects ``ALTER COLUMN ... COLLATE`` when foreign keys exist.
Existing MySQL/OceanBase schemas must be recreated to pick up this type.
The OceanBase profile rejects incompatible existing schemas so operators
can recreate them before the Server accepts work.
"""

return String(length).with_variant(
VARCHAR(length, charset="utf8mb4", collation=_MYSQL_IDENTITY_COLLATION),
VARCHAR(length, charset="utf8mb4", collation=MYSQL_IDENTITY_COLLATION),
"mysql",
)

Expand Down
59 changes: 58 additions & 1 deletion tests/builtin/persistence/test_mysql_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from sqlalchemy import BigInteger, Date, Integer, String
import re
from pathlib import Path

from sqlalchemy import BigInteger, Date, Integer, String, Table
from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint

from powercontext.builtin.handoff_report.sqlite import HANDOFF_REPORT_TABLES
from powercontext.builtin.persistence.tables import (
ARTIFACTS_TABLE,
BUILTIN_TABLES,
SHARED_METADATA,
SOURCE_CURSORS_TABLE,
SOURCES_TABLE,
Expand Down Expand Up @@ -66,6 +71,58 @@ def test_mysql_ddl_uses_mediumblob_for_every_canonical_payload() -> None:
assert f"{column_name} MEDIUMBLOB NOT NULL" in ddl


def _assert_restore_layers_are_parent_first(
restore_layers: tuple[tuple[str, ...], ...],
tables: tuple[Table, ...],
) -> None:
restored_tables = tuple(table_name for layer in restore_layers for table_name in layer)
table_names = {table.name for table in tables}

assert len(restored_tables) == len(set(restored_tables)) == len(tables)
assert set(restored_tables) == table_names

foreign_keys = tuple(
(table, constraint)
for table in tables
for constraint in table.constraints
if isinstance(constraint, ForeignKeyConstraint)
)

layer_by_table = {
table_name: layer_index for layer_index, layer in enumerate(restore_layers) for table_name in layer
}
for table, foreign_key in foreign_keys:
parent_table_name = foreign_key.referred_table.name
assert layer_by_table[parent_table_name] < layer_by_table[table.name]


def test_documented_obloader_restore_layers_are_parent_first() -> None:
restore_guides = (
Path("docs/en/docs/how-to/troubleshoot.md"),
Path("docs/zh/docs/how-to/troubleshoot.md"),
)
restore_plans = tuple(
tuple(
tuple(table_names.split(","))
for table_names in re.findall(r"--table '([^']+)'", guide.read_text(encoding="utf-8"))
)
for guide in restore_guides
)
assert all(len(restore_layers) == 3 for restore_layers in restore_plans)
assert len(set(restore_plans)) == 1

restore_layers = restore_plans[0]
_assert_restore_layers_are_parent_first(restore_layers, BUILTIN_TABLES + HANDOFF_REPORT_TABLES)

handoff_report_table_names = {table.name for table in HANDOFF_REPORT_TABLES}
assert handoff_report_table_names <= set(restore_layers[0])
core_only_layers = tuple(
tuple(table_name for table_name in layer if table_name not in handoff_report_table_names)
for layer in restore_layers
)
_assert_restore_layers_are_parent_first(core_only_layers, BUILTIN_TABLES)


def test_every_mysql_utf8mb4_key_stays_below_the_innodb_limit() -> None:
budgets: dict[str, int] = {}
for table in SHARED_METADATA.tables.values():
Expand Down
Loading
Loading