Skip to content

Commit 09b6c84

Browse files
authored
feat(runtime-core-py): 增加异步持久化与可取消启动 (#38)
* feat(runtime-core-py): 增加异步持久化和可取消启动 * docs(task): 关联 SDK 与 Core 迁移 PR
1 parent 006759a commit 09b6c84

9 files changed

Lines changed: 421 additions & 12 deletions

File tree

‎.changes/runtime-core-py/0.1.4.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## 0.1.4 - 2026-09-17
2+
3+
### Added
4+
5+
- 新增配置、状态变更及启动的异步 Host 能力;复用 publication 作用域,在启动失败或取消时撤销本次资源,并保留旧同步 Host 入口。

‎docs/30-unit-tdd/core-python-runtime.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,28 @@ published fresh.
3232
`DistributionModules` uses standard entry-point loading and verifies that every loaded module in the canonical
3333
`extensions.<name>` package resolves to a file owned by the acquired Distribution. It does not delete, restore or shadow
3434
`sys.modules`, and it does not create a second import cache.
35+
36+
## 异步 Host 持久化能力
37+
38+
Runtime 0.1.4 增加 `update_config_async`、`get_state_async`、`mutate_state_async`、
39+
`mutate_config_and_state_async` 和 `on_start_async`。它们只调用 Host model 对应的 async capability;
40+
不会调用旧同步方法、在线程中运行数据库代码,或创建数据库 session。旧同步入口继续供旧 Host 使用,
41+
Host 必须明确选择与其持久化模型一致的入口。现有 `ExtensionManager` 仍使用旧同步 Host 合同;
42+
这些新增入口供 Core 自己的 async Host adapter 采用,不代表旧 Manager 已整体异步化。
43+
44+
Host model 需要提供 `update_config_async`、`read_state_async`、`mutate_state_async`、
45+
`mutate_config_and_state_async` 与 `update_config_schema_async`。配置和 schema 更新返回刷新后的
46+
Host model,状态读取返回 persisted JSON。变更方法必须在 Host 的事务内调用同步 transform,
47+
提交成功后才能返回;事务、行锁及并发控制仍由 Host 拥有。Runtime 恢复 typed 输入后保留 transform
48+
产出的 typed 结果,避免内部传递时再次恢复。`get_config()` 仍只恢复已绑定 model 的 config 投影。
49+
50+
`on_start_async` 复用同步启动的 publication scope,先发布资源,再 await
51+
`SourceManager.sync_source_types_async()` 和 Host schema 持久化。普通 Source/Resolver class 注册
52+
仍是同步的进程内操作;Source catalog 写入必须 await。启动完成后才报告 `runtime_active()`;同一个
53+
Extension 启动期间再次启动会失败。任何失败或取消都会撤销本次 routes、Peer inbounds 和 public claims,
54+
随后允许重新启动。进程内已 import 的 decoder/type 注册继续保留,不模拟 module unload。
55+
56+
这一变化不改变 Registry、installed、enabled 或 public-route 合同,也不让旧 wheel 自动兼容新的
57+
Core async-only API。采用方必须分别声明 Runtime 包版本与 Core Host SDK 版本窗口,并验证真实
58+
Host 的事务实现。Runtime 层的 scripted 验证可证明 await、typed round trip 和 publication 清理,
59+
不能替代 Core 对 PostgreSQL 提交、回滚及行锁的验证。

‎pdm.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎runtimes/core-py/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
This changelog records notable changes to this independently released Python
44
package. It is generated by [Changie](https://changie.dev/).
55

6+
## 0.1.4 - 2026-09-17
7+
8+
### Added
9+
10+
- 新增配置、状态变更及启动的异步 Host 能力;复用 publication 作用域,在启动失败或取消时撤销本次资源,并保留旧同步 Host 入口。
11+
612
## 0.1.3 - 2026-09-14
713

814
### Fixed

‎runtimes/core-py/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "inkcre-extension-runtime-core-py"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = "InKCre Core Python Extension Host Runtime"
55
requires-python = ">=3.12,<3.14"
66
dependencies = [

‎runtimes/core-py/src/inkcre_extension_runtime_core_py/base.py‎

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
from __future__ import annotations
44

55
import typing
6+
from collections.abc import Iterator
7+
from contextlib import contextmanager
68

79
import pydantic
810

911
from .errors import ExtensionLifecycleError, translate_host_model_error
1012

13+
if typing.TYPE_CHECKING:
14+
from .publication import ExtensionPublication
15+
1116

1217
class EmptyConfig(pydantic.BaseModel): ...
1318

@@ -123,9 +128,102 @@ def mutate(
123128
translate_host_model_error(error)
124129
return typing.cast(tuple[ConfigT, StateT], result)
125130

131+
@classmethod
132+
async def update_config_async(cls, value: dict[str, typing.Any] | ConfigT) -> ConfigT:
133+
"""Persist validated configuration through the Host async capability."""
134+
config = (
135+
value
136+
if isinstance(value, cls.__configcls__)
137+
else cls.__configcls__.model_validate(value)
138+
)
139+
try:
140+
cls.__model__ = await cls._model().update_config_async(config.model_dump(mode="json"))
141+
except Exception as error:
142+
translate_host_model_error(error)
143+
return config
144+
145+
@classmethod
146+
async def get_state_async(cls) -> StateT:
147+
"""Read fresh state through the Host async capability."""
148+
try:
149+
state = await cls._model().read_state_async()
150+
except Exception as error:
151+
translate_host_model_error(error)
152+
return cls.__statecls__.model_validate(state)
153+
154+
@classmethod
155+
async def mutate_state_async(cls, transform: typing.Callable[[StateT], StateT]) -> StateT:
156+
"""Await one committed state mutation; transform runs synchronously under the Host lock."""
157+
result: StateT | None = None
158+
159+
def mutate(raw: dict[str, typing.Any]) -> dict[str, typing.Any]:
160+
nonlocal result
161+
updated = transform(cls.__statecls__.model_validate(raw))
162+
if not isinstance(updated, cls.__statecls__):
163+
raise TypeError("Extension state transform returned the wrong model")
164+
result = updated
165+
return updated.model_dump(mode="json")
166+
167+
try:
168+
await cls._model().mutate_state_async(mutate)
169+
except Exception as error:
170+
translate_host_model_error(error)
171+
# The Host invokes the transform under its transaction and commits its
172+
# returned JSON unchanged. Keep the typed result rather than revalidating it.
173+
return typing.cast(StateT, result)
174+
175+
@classmethod
176+
async def mutate_config_and_state_async(
177+
cls, transform: typing.Callable[[ConfigT, StateT], tuple[ConfigT, StateT]]
178+
) -> tuple[ConfigT, StateT]:
179+
"""Await one atomic config/state mutation with a synchronous typed transform."""
180+
result: tuple[ConfigT, StateT] | None = None
181+
182+
def mutate(
183+
config: dict[str, typing.Any], state: dict[str, typing.Any]
184+
) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]:
185+
nonlocal result
186+
new_config, new_state = transform(
187+
cls.__configcls__.model_validate(config), cls.__statecls__.model_validate(state)
188+
)
189+
if not isinstance(new_config, cls.__configcls__) or not isinstance(
190+
new_state, cls.__statecls__
191+
):
192+
raise TypeError("Extension config/state transform returned the wrong models")
193+
result = new_config, new_state
194+
return new_config.model_dump(mode="json"), new_state.model_dump(mode="json")
195+
196+
try:
197+
await cls._model().mutate_config_and_state_async(mutate)
198+
except Exception as error:
199+
translate_host_model_error(error)
200+
return typing.cast(tuple[ConfigT, StateT], result)
201+
126202
@classmethod
127203
def on_start(cls, app: typing.Any) -> None:
128-
"""Publish concrete Core contributions; config reads restore types on use."""
204+
"""Publish contributions using the synchronous Host persistence contract."""
205+
with cls._publication_scope(app) as publication:
206+
publication.activate_source_types()
207+
try:
208+
cls.__model__ = cls._model().update_config_schema(dict(cls.__configschema__))
209+
except Exception as error:
210+
translate_host_model_error(error)
211+
212+
@classmethod
213+
async def on_start_async(cls, app: typing.Any) -> None:
214+
"""Publish contributions and await catalog/schema persistence before becoming active."""
215+
with cls._publication_scope(app) as publication:
216+
await publication.activate_source_types_async()
217+
try:
218+
cls.__model__ = await cls._model().update_config_schema_async(
219+
dict(cls.__configschema__)
220+
)
221+
except Exception as error:
222+
translate_host_model_error(error)
223+
224+
@classmethod
225+
@contextmanager
226+
def _publication_scope(cls, app: typing.Any) -> Iterator[ExtensionPublication]:
129227
import fastapi
130228
from app.business.peer import PeerManager
131229

@@ -134,9 +232,12 @@ def on_start(cls, app: typing.Any) -> None:
134232
PublicHTTPRouteClaim,
135233
)
136234

137-
if cls.runtime_active():
138-
raise ExtensionLifecycleError(f"Extension {cls.__extid__} is already active")
235+
if cls.runtime_active() or cls.__dict__.get("__runtime_starting__", False):
236+
raise ExtensionLifecycleError(
237+
f"Extension {cls.__extid__} is already active or starting"
238+
)
139239
publication = ExtensionPublication(app)
240+
cls.__runtime_starting__ = True
140241
try:
141242
router = fastapi.APIRouter(
142243
prefix=f"/{cls.__extid__}", dependencies=cls.api_dependencies()
@@ -154,15 +255,15 @@ def on_start(cls, app: typing.Any) -> None:
154255
publication.public_http_claim = PublicHTTPRouteClaim.acquire(
155256
cls.__extid__, cls.public_http_routes(), publication.routes
156257
)
157-
publication.activate_source_types()
158-
try:
159-
cls.__model__ = cls._model().update_config_schema(dict(cls.__configschema__))
160-
except Exception as error:
161-
translate_host_model_error(error)
162-
except Exception:
258+
yield publication
259+
except BaseException:
260+
# Cancellation during Host persistence must revoke already-published effects.
163261
publication.withdraw()
164262
raise
165-
cls.__runtime_publication__ = publication
263+
else:
264+
cls.__runtime_publication__ = publication
265+
finally:
266+
del cls.__runtime_starting__
166267

167268
@classmethod
168269
def api_dependencies(cls) -> list[typing.Any]:

‎runtimes/core-py/src/inkcre_extension_runtime_core_py/publication.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ def activate_source_types(self) -> None:
172172
"""Synchronize the process-monotonic Source catalog."""
173173
SourceManager.sync_source_types()
174174

175+
async def activate_source_types_async(self) -> None:
176+
"""Synchronize the catalog through the async Host capability."""
177+
await SourceManager.sync_source_types_async()
178+
175179
def withdraw(self) -> None:
176180
"""Withdraw this instance's exact active effects."""
177181
if not self.active:
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Core 数据库迁移所需的 Runtime SDK 异步能力
2+
3+
## 父任务与授权
4+
5+
本 packet 仅拥有 ext-reg 的上游 SDK 实现 slice。唯一线性计划由 core-py 的
6+
`tasks/async-database-boundaries/plan.md` 拥有;不得在此建立另一条全量迁移计划。
7+
Sir 已授权修改 ext-reg、提交、推送与创建 PR,明确禁止 Agent 合并。
8+
9+
上游交付 PR:https://github.com/InKCre/ext-reg/pull/38(实现提交 `5fc4ad7`)。
10+
父任务 draft PR:https://github.com/InKCre/core-py/pull/105。当前等待本 PR 的检查、外部合并和正式 artifact,
11+
不把本地 wheel 构建成功当作依赖已经交付。
12+
13+
从 origin/main `006759a` 建立独立分支 `feat/runtime-async-persistence`,worktree 为
14+
`/Volumes/WorkSSD/Development/InKCre/.worktrees/ext-reg-async-persistence`。既有 ext-reg checkout 未改动。
15+
16+
## 实现与交付边界
17+
18+
Runtime 0.1.4 为 ExtensionBase 增加配置、状态及启动的 async API,绑定 Host 的 awaitable
19+
持久化能力;旧 API 及现有 SDK ExtensionManager 保留同步合同。一个 publication scope
20+
同时服务两种启动入口,在失败或取消时撤销本次活动资源,阻止未完成时重复启动。
21+
22+
调查确认 Source catalog activation 也调用数据库。异步启动调用新
23+
SourceManager.sync_source_types_async;Core 采用 SDK 时先提供这个 capability,再切换 Host。
24+
SDK 不创建数据库连接、不持有 session、不引入 fallback 或 worker 包装。Host 负责事务和锁。
25+
26+
版本及 changelog 通过 Changie 生成,PDM lock 只将本地 workspace SDK 版本由原有陈旧的
27+
0.1.2 更新为 0.1.4。Registry 服务、shared contracts、web runtime 和 Toolkit 源码未变。
28+
29+
`.github/workflows/packages-release.yml` 仅在 main 发布正式 artifact。Agent 不得合并,故此
30+
slice 完成到可审阅 PR 后,Core 等待外部合并和 0.1.4 artifact;不会用本地 wheel URL 代替正式依赖。
31+
父任务及本 packet 此时保持打开。
32+
33+
## 验证与限度
34+
35+
- Runtime 及仓库 Python lint、format、pyright 通过;pnpm contracts:check、format:check、type-check 通过。
36+
- `pdm build --project runtimes/core-py --dest dist/runtime-core-py` 成功构建 wheel/sdist。
37+
- `pdm run python tasks/async-runtime-persistence/probe.py` 通过;另将构建的 wheel 正常安装到独立
38+
Python 3.12 环境,使用 Core 当前 FastAPI 0.139.2 执行同一 probe,也通过。源码环境为 Python 3.13。
39+
- probe 是本任务的 public SDK boundary 实验,不是新常驻 CI suite。它使用真实 FastAPI/HTTP transport,
40+
用内存 Host adapter 隔离持久化 owner,验证 typed mutation、两个启动 await 点的取消、失败撤销、
41+
重新启动和旧同步启动兼容。它不证明 PostgreSQL 事务;该证据仍由 Core 的真实数据库验收拥有。
42+
- 本机 Node 为 26,pnpm 对项目要求的 Node 22 给出警告;上述 JS 检查通过。正式 CI 使用 Node 22。
43+
- 本机没有执行 Registry 的 PostgreSQL 全量 gate,SDK 变更未触及该数据库;仓库 CI 使用一次性 PostgreSQL
44+
执行完整 pnpm check。不能把本地 package checks 写成全仓集成通过。
45+
46+
## 已复现的既有问题
47+
48+
当前 FastAPI include_router 会在 app.routes 中保存 _IncludedRouter,而旧 SDK 的 PublicHTTPRouteClaim
49+
只检查顶层 route.path。单独使用已安装 SDK 0.1.3 + FastAPI 0.139.2 的旧 on_start 已复现
50+
`Public Extension routes were not published: [('GET', '/probe/callback')]`。因此 probe 的成功启动不声明
51+
public callback;本次覆盖 routes/inbounds 的取消撤销,没有伪造 public-claim 成功证据。该问题不由
52+
数据库异步化引入,未扩展本次源码范围;采用方的 callback 验收仍受其影响。

0 commit comments

Comments
 (0)