简体中文 English 繁體中文 日本語 Русский
本文为静态镜像,内容以交互版为准 在交互式文档中心打开 →

归属权(owner)系统

归属权是模块"即插即用"的基石:模块在加载期间注册的一切框架资源自动记名, 卸载/禁用时按记名一键回收——模块作者只需声明资源,无需手写清理逻辑。

相关系统:作用域(scope)在事件分发时决定"资源是否生效", 归属权在生命周期中决定"资源归谁、谁卸载时被回收"。 作用域详见统一控制面(scope),后台任务详见 生命周期管理。

{!--< tips >!--}

  1. 归属在注册瞬间按 current_owner 自动记录,模块代码零改动
  2. 卸载/禁用共用同一条清理链(_cleanup_module_registrations),每步失败仅告警不中断
  3. 用户配置语义的资源(持久化覆写 / scope 规则 / 命令 ACL)不随模块卸载清理
  4. 工具模块托管的外部句柄可用 on_cleanup(cb) 挂入清理链,对方模块卸载时自动回调(见工具模块指南) {!--< /tips >!--}

owner 上下文机制

owner 通过上下文变量 current_owner 传递(ErisPulse.runtime.context):

from ErisPulse.runtime import owner_scope, get_current_owner

with owner_scope("MyModule"):
    # 此区间内注册的一切资源自动归属 MyModule
    assert get_current_owner() == "MyModule"

框架在以下时机自动注入 owner(模块/适配器代码无需手动包裹):

时机 owner 值 位置
模块 load() 模块名 实例化 + on_load 全程
适配器 start() / restart() 平台名 适配器启动全程
activate_on 懒加载 stub 注册 模块名 占位命令/处理器注册
事件处理器执行期 处理器归属模块名 handler / 命令入口重注入

执行期重注入意味着:模块在 on_load 里声明的命令处理器运行中调用 注册型 API(如 sdk.adapter.on()、overrides.*.set(persist=False)), 同样会自动归属本模块。

归属资源全景

模块在加载上下文内注册的以下资源均记录归属,卸载/禁用时自动回收:

资源 注册方式 清理调用
命令 @command() / 命令 dict 声明 command.unregister_by_owner()
事件处理器 @message / @notice / @request / @meta handler.unregister_by_owner()
适配器事件监听 sdk.adapter.on() / raw=True adapter.unregister_handlers_by_owner()
适配器中间件 @sdk.adapter.middleware 同上
路由(HTTP/WS/SSE) router.http() / websocket() / sse() 按命名空间 + 按 owner 双重兜底
路由中间件 @router.middleware() / add_middleware() router.unregister_all_by_owner()
Dashboard 首页入口 router.register_home_entry() unregister_home_entries_by_owner()
自定义会话类型 register_custom_type() unregister_custom_types_by_owner()
平台事件方法注入 register_event_method() / register_event_mixin() unregister_event_methods_by_owner()(模块卸载自动回收,旧闭包不再泄漏)
后台任务 self.spawn() cancel_owner_tasks()
外部归属清理钩子(工具模块托管) runtime.on_cleanup(cb) run_owner_cleanups()(卸载/禁用/适配器关闭链内触发)
生命周期钩子 lifecycle.register() lifecycle.unregister_by_owner()
主人身源 provider master.provider master.unregister_by_owner()
i18n 翻译键 I18nClass 声明(domain=模块名) i18n.unregister_domain()
事件覆写(运行时) overrides.*.set(persist=False) overrides.unregister_by_owner()
交互会话(wait_reply 等待 / 租约) event.wait_reply() / sdk.interaction.acquire() interaction.cancel_by_owner()(等待方立即收到取消)
上下文数据 runtime/context 按 owner 记录 按模块精确清理

适配器侧的对应资源(以平台名为 owner)在适配器 shutdown() / restart() 时由 _cleanup_adapter_resources 回收,另含:

资源 清理调用
适配器自有的 on() 处理器与中间件 adapter.unregister_handlers_by_owner(platform)
平台事件方法扩展(EventMixin) unregister_platform_event_methods(platform)
自定义会话类型 unregister_custom_types_by_owner(platform)
交互会话(该平台挂起的 wait_reply / 租约) interaction.cancel_by_platform(platform)
i18n 翻译域(domain=配置键) i18n.unregister_domain(配置键)
细颗粒命名空间路由 router.unregister_all_by_owner(platform)

卸载/禁用清理序列

unload() 与 disable() 共用同一条清理链(每步独立 try/except, 失败仅记日志,不中断后续清理):

flowchart TD
    A["unload / disable"] --> B["on_unload()(超时保护)"]
    B --> C["兜底取消后台任务(cancel_owner_tasks)"]
    C --> C1["外部归属清理钩子<br/>(工具模块 on_cleanup 登记,run_owner_cleanups 触发)"]
    C1 --> D["_cleanup_module_registrations<br/>= 归属权门面 ownership.reclaim_sync()"]
    D --> D1["i18n 翻译域"]
    D1 --> D2["路由:命名空间 + owner 兜底<br/>(按路由对象同一性精确删除,<br/>含中间件 / 首页入口)"]
    D2 --> D3["适配器事件处理器 / 中间件"]
    D3 --> D4["命令 + 事件处理器"]
    D4 --> D5["自定义会话类型"]
    D5 --> D5b["平台事件方法注入"]
    D5b --> D6["运行时事件覆写(persist=False)"]
    D6 --> D7["主人身源 provider"]
    D7 --> D8["生命周期钩子"]
    D8 --> E["移除 SDK 属性 + 懒加载代理"]
    E --> F["自动轻审计:孤儿 owner 告警"]

sdk.uninit() 退出时另有全局兜底:全部适配器 shutdown → 全部模块 unload → router.stop()(清空路由/中间件/首页入口)→ cancel_all_background_tasks() → 清空事件处理器与钩子。

归属权统一门面(ownership)

清理链的十六个步骤收敛在归属权统一门面 ErisPulse.Core.ownership 下, 四个动词覆盖"注销、计数、扫描、审计"——子系统各自的 *_by_owner 注销 函数保持不变,作为门面的内部实现:

动词 用途
ownership.reclaim(owner) 统一注销 owner 名下全部资源(任务取消 → 清理钩子 → 注册类资源;异步完整版)
ownership.reclaim_sync(owner) 注册类资源注销(同步版,供同步卸载路径)
ownership.counts(owner=None) 只读统计 owner 在册资源(None 为全部 owner)
ownership.orphans() 孤儿扫描:资源在册而 owner 已注销(泄漏实锤清单)
ownership.audit(owner, deep=) 泄漏审计报告(计数 + 孤儿 + 可选 gc 实例普查)
from ErisPulse.Core import ownership

ownership.reclaim_sync("MyModule")       # {'commands': 1, 'routes_http': 2, ...}
ownership.counts("MyModule")             # 在册资源计数
ownership.orphans()                      # [{"owner": "ghost", "total": 2, ...}]

审计入口:

热重载失败回滚

热重载改为"卸载前快照 → 失败自动恢复":新版本语法错误、依赖缺失、 加载失败时,旧实例与注册状态(注册表条目、sdk 属性、sys.modules 条目) 自动还原,服务不中断,日志提示"已回滚到旧实例继续服务"。

尽力而为语义(文档化的边界):

设计边界:哪些资源不随卸载清理

归属权只回收模块代码注册的运行时资源。以下资源属用户配置语义 (控制权在用户,可能刻意配置),模块卸载后随配置持久保留:

资源 语义 说明
overrides.*.set(persist=True) 持久化覆写 写入配置文件,跨重启生效;模块卸载不删(用户显式配置)
scope.set_action() 等作用域规则 权限控制面 由用户/Dashboard 管理,卸载模块不回收规则
overrides.acl.set(persist=True) 命令 ACL 同上
Conversation save() 持久化 多轮对话存档 数据资产不清理

运行时临时写入(persist=False)则随 owner 回收——持久化与否即 "用户资产"与"模块运行时状态"的分界线。

内部实现:归属如何工作

归属系统由两条独立链路构成,理解它们的分工是排查归属问题的前提:

归因链(contextvar 传播)

runtime/context.py 的 current_owner 等 ContextVar 负责归因—— "此刻这段代码注册的资源/发起的调用记在谁头上"。传播规则遵循 Python contextvars 语义:

执行路径 context 是否传播 归因结果
同步调用链 / await 链 ✅ 传播 正确归因
owner_scope 内 asyncio.create_task ✅ 传播(task 拷贝创建时刻的 context) 任务内部的框架调用正确归因
run_in_executor / 裸线程 ❌ 不传播 归因丢失
自建事件循环 ❌ 不传播 归因丢失

归因 ≠ 登记:context 传播只影响"记在谁头上",资源能否被清理 取决于是否进入了下述取消链。

取消链(任务登记表)

runtime/tasks.py 的 _owner_tasks 登记表负责生命周期—— "owner 名下有哪些未完成任务,卸载时统一取消"。任务进入登记表的途径:

  1. 显式调度:spawn_background() / self.spawn() → 创建时捕获 current_owner(或显式 owner= 参数)→ 登记入表;
  2. Task Factory 自动登记(2.8.3):install_owner_task_factory() 在框架启动时安装到主事件循环——任何任务创建(含第三方库内部的 create_task)经过工厂时读取 current_owner,非 None 即登记。

登记表自清理:每个任务带 done_callback,完成即从表中移除,无泄漏。

取消时序(模块卸载)

module.unload()
  → on_unload(event)                    # 模块自行清理(兜底超时保护)
  → 框架注销该 owner 的命令/事件/钩子/路由
  → cancel_owner_tasks(owner)           # 任务登记表兜底取消
      → 逐个 task.cancel()              # 排除当前执行取消逻辑的任务自身
      → await gather(pending, timeout)  # 等待回收(超时不再阻塞)

排查思路

模块作者指南

推荐写法

from ErisPulse import sdk
from ErisPulse.Core.Event import command
from ErisPulse.runtime import owner_scope, spawn_background

class MyModule(BaseModule):
    async def on_load(self, event):
        # 框架资源:自动归属,无需手动清理
        self.task = self.spawn(self.polling())      # 后台任务
        sdk.router.register_home_entry("我的模块", "/my")  # 首页入口

        # 模块自有资源:包进 owner_scope 即纳入归属体系
        with owner_scope("MyModule"):
            self.client.on_event(self._handle)      # 假想的自定义注册

    async def on_unload(self, event):
        # 框架资源已被自动回收,只需清理 owner_scope 覆盖不到的自有资源
        await self.client.close()

注意事项

注册时机 → 归属结果对照表

注册场景 归属结果 说明
on_load() 内经框架 API(命令/事件/lifecycle/路由装饰器)注册 归属模块 卸载时自动注销
模块顶层(import 期)注册 无归属(owner=None) 不被清理,请勿使用
self.spawn() 创建的后台任务 归属模块 卸载时自动取消
owner_scope("Name") 内经第三方 API 注册 归属模块 依赖第三方回调在 scope 内同步执行
裸 asyncio.create_task(含 loop.create_task / ensure_future) 自动归属(Task Factory,2.8.3+) 创建瞬间读取 current_owner,owner 上下文内自动登记、卸载兜底取消;见下文内部实现
第三方库异步回调(aiohttp / APScheduler 等)内部创建的任务 自动归属(Task Factory,2.8.3+) 回调执行时若 current_owner 已注入(如框架处理器执行期间),任务自动登记
run_in_executor(线程池) 无归属(非 asyncio.Task) 线程不受任务工厂管辖,须自行管理生命周期
独立事件循环(自建 loop)中的注册 无归属 Task Factory 仅安装于主循环;contextvars 也不跨事件循环传播

原则:归属跟随注册瞬间的 current_owner 上下文;任何异步延迟、 线程池、独立循环都会脱离该上下文——需要归属时请显式进入 owner_scope。

工具模块指南:托管其它模块的句柄

场景:定时任务、注册表、连接池这类"工具模块"会替其它模块保管东西—— 对方在 on_load 里调用 sdk.Cron.on_trigger(handler),你的容器里就存下了 一个指向对方实例的回调。框架会自动清理对方注册的一切框架资源,但清理不了 你私有容器里的引用:对方卸载后你的容器还拉着它的实例,它就无法被 GC 回收(内存泄漏,purge 泄漏诊断报"不可回收")。

解法:在登记对方东西的同一个函数里调用 on_cleanup(), 框架会在对方卸载 / 禁用时自动回调你的清理函数:

from ErisPulse.Core.Bases import BaseModule
from ErisPulse.runtime import off_cleanup, on_cleanup

class CronModule(BaseModule):
    def __init__(self):
        self._entries = {}  # {模块名: 该模块托管的回调列表}

    def on_trigger(self, handler):
        # 自动识别调用方模块名(on_load 直接调用 / module.call 均正确),
        # 返回值是解析出的 owner,可直接用作记名键
        owner = on_cleanup(self._drop)
        self._entries.setdefault(owner, []).append(handler)

    def _drop(self, owner: str):
        """对方模块被卸载/禁用时由框架自动调用:抛弃它的句柄即可"""
        self._entries.pop(owner, None)

    async def on_unload(self, event):
        off_cleanup(self._drop)  # ③ 自己卸载前注销钩子,避免钩子表持有 self

框架保证的行为:

关注点 行为
触发时机 对方模块 unload / disable,或适配器关闭——均在框架清理链内触发,早于 purge 泄漏诊断
调用方识别 直接调用取 current_owner;经 module.call() 被调用取调用方(current_caller);也可 on_cleanup(cb, owner="模块名") 显式指定。强制校验:owner 无法解析(三种来源均缺失)时抛 ValueError——私有工具模块应在自身加载上下文内登记钩子
回调签名 cb(owner: str),同步 / 异步均可;异步带超时保护(CLEANUP_CALLBACK_TIMEOUT_SECS,默认 10 秒)
容错 单个回调异常 / 超时只记日志,不影响其余钩子与清理链
重复登记 同一 (owner, callback) 幂等去重

什么时候不需要:如果对方注册的是框架资源(命令、事件处理器、路由、 后台任务……),框架已全自动清理(见上文归属资源全景)。 只有你私有容器里持有的对方句柄才需要 on_cleanup。 模块开发视角的速查版见 最佳实践 · 工具模块。