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

Система принадлежности (owner)

Принадлежность — основа модульной системы "Plug and Play": все ресурсы, зарегистрированные фреймворком во время загрузки модуля, автоматически привязываются к нему, а при отключении/выгрузке модуля — автоматически удаляются. Автор модуля просто объявляет ресурсы, не вручную удаляя их.

Связанные системы: область видимости (scope) определяет, "действует ли ресурс" при обработке событий, а принадлежность — "кому принадлежит ресурс и кто будет отвечать за его удаление при выгрузке модуля". Область видимости описана в Единая контрольная плоскость (scope), фоновые задачи — в [Управление жизненным циклом](lifecycle.md#Фоновые задачи и автоматическая отмена).

{!--< 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() Имя платформы Весь процесс запуска адаптера
Регистрация stub'а для ленивой загрузки activate_on Имя модуля Зарезервированная регистрация команд/обработчиков
Выполнение обработчика событий Имя модуля, к которому принадлежит обработчик Перезапись контекста в 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() Двойная очистка по пространству имён и владельцу
Промежуточные обработчики маршрутов @router.middleware() / add_middleware() router.unregister_all_by_owner()
Вход на главную панель 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()
Провайдер источника главного 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 с привязкой к владельцу Точный сброс по модулю

Ресурсы со стороны адаптера (с именем платформы в качестве владельца) при 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/>= reclaim_sync() фасада ownership"] 
    D --> D1["Области перевода i18n"]
    D1 --> D2["Маршрутизация: пространство имён + owner по умолчанию<br/>(удаление по точному совпадению объекта маршрута,<br/>включая промежуточные обработчики и главный вход)"]
    D2 --> D3["Обработчики событий адаптеров / промежуточные обработчики"]
    D3 --> D4["Обработчики команд + событий"]
    D4 --> D5["Пользовательские типы сессий"]
    D5 --> D5b["Внедрение методов платформенных событий"]
    D5b --> D6["Переопределение событий во время выполнения (persist=False)"]
    D6 --> D7["Провайдер источника владельца"]
    D7 --> D8["Жизненный цикл хуки"]
    D8 --> E["Удаление свойств SDK + ленивые загрузки"]
    E --> F["Автоматическая легкая аудитория: предупреждение о "орphaned" owner"]

При выходе sdk.uninit() есть дополнительный глобальный хук: остановка всех адаптеров → загрузка всех модулей → router.stop() (очистка маршрутов / промежуточных обработчиков / главного входа) → cancel_all_background_tasks() → очистка обработчиков событий и хуков.

Поверхность унификации прав собственности (ownership)

Шестнадцать шагов очистки цепочки сходятся под поверхностью унификации прав собственности ErisPulse.Core.ownership, четыре глагола охватывают "отмену, подсчет, сканирование, аудит" — функции *_by_owner отдельных подсистем остаются без изменений, служа внутренней реализацией поверхности:

Глагол Назначение
ownership.reclaim(owner) Унифицированная отмена всех ресурсов владельца (отмена задач → чистящие хуки → ресурсы регистраций; асинхронная полная версия)
ownership.reclaim_sync(owner) Отмена ресурсов регистраций (синхронная версия, для синхронного пути выгрузки)
ownership.counts(owner=None) Только чтение: подсчет ресурсов владельца (если None, то подсчет всех владельцев)
ownership.orphans() Сканирование сирот: ресурсы в реестре, но владельцы уже отменены (список подтвержденных утечек)
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 отвечают за привязку — "кому принадлежит ресурс, зарегистрированный в данный момент". Правила передачи соответствуют семантике contextvars в Python:

Выполнение Передача контекста Результат привязки
Синхронные вызовы / await цепочки ✅ Передаётся Правильная привязка
owner_scope внутри asyncio.create_task ✅ Передаётся (task копирует контекст на момент создания) Правильная привязка внутри задачи
run_in_executor / обычные потоки ❌ Не передаётся Потеря привязки
Самостоятельно созданный цикл событий ❌ Не передаётся Потеря привязки

Привязка ≠ регистрация: передача контекста влияет только на "кому принадлежит", а возможность очистки зависит от того, попал ли ресурс в цепочку отмены.

Цепочка отмены (таблица задач)

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/роутер) Принадлежит модулю Автоматически удаляется при выгрузке
Регистрация на уровне модуля (в момент импорта) Без принадлежности (owner=None) Не удаляется, не используйте
Создание фоновой задачи с self.spawn() Принадлежит модулю Автоматически отменяется при выгрузке
Регистрация внутри owner_scope("Name") через API сторонней библиотеки Принадлежит модулю Зависит от синхронного выполнения колбэка внутри scope
Негарантированная принадлежность asyncio.create_task (включая loop.create_task / ensure_future) Автоматически привязывается (Task Factory, 2.8.3+) Мгновенно захватывает current_owner, автоматически регистрируется, отменяется при выгрузке; см. [Внутренняя реализация](#Внутренняя реализация-как-работает-принадлежность)
Задачи, созданные в асинхронных колбэках сторонних библиотек (aiohttp / APScheduler и др.) Автоматически привязывается (Task Factory, 2.8.3+) Если current_owner уже установлен (например, во время выполнения обработчика фреймворка), задача автоматически регистрируется
run_in_executor (пул потоков) Без принадлежности (не asyncio.Task) Потоки не управляются Task Factory, жизненным циклом нужно управлять самостоятельно
Регистрация в независимом цикле событий Без принадлежности Task Factory устанавливается только в главный цикл; contextvars также не передаются между циклами

Принцип: принадлежность следует за current_owner на момент регистрации; любая асинхронная задержка, пул потоков или независимый цикл событий отключает этот контекст — при необходимости принадлежности явно используйте owner_scope.

Руководство по модулю-инструменту: обработка ссылок на другие модули

Сценарий: модули-инструменты, такие как таймеры, реестры и пулы соединений, хранят ссылки на другие модули — например, при вызове sdk.Cron.on_trigger(handler) в on_load модулем, ваш контейнер сохраняет ссылку на экземпляр этого модуля. Фреймворк автоматически очищает все ресурсы, зарегистрированные модулем, но не может очистить вашу частную ссылку в контейнере: после отключения модуля ваш контейнер по-прежнему ссылается на его экземпляр, и он не может быть удалён сборщиком мусора (утечка памяти, 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):
        # Автоматически определяет имя вызывающего модуля (работает как при прямом вызове, так и через module.call()),
        # возвращает имя модуля, которое можно использовать как ключ
        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="имя модуля"). Обязательная проверка: если имя модуля не может быть определено (все три источника отсутствуют), выбрасывается ValueError — частные модули-инструменты должны регистрировать хуки в контексте загрузки собственного модуля
Подпись обратного вызова cb(owner: str), синхронный / асинхронный; асинхронный вызов защищён таймаутом (CLEANUP_CALLBACK_TIMEOUT_SECS, по умолчанию 10 секунд)
Обработка ошибок Ошибка или таймаут одного обратного вызова записываются в лог, не влияя на остальные хуки и цепочку очистки
Повторная регистрация Одна и та же пара (owner, callback) регистрируется только один раз

Когда не нужно использовать: если модуль регистрирует ресурсы фреймворка (команды, обработчики событий, маршруты, фоновые задачи и т.д.), фреймворк уже автоматически очищает их (см. выше Обзор ресурсов). Только ссылки на объекты других модулей, хранящиеся в вашем частном контейнере, требуют on_cleanup. Сводка для разработчиков модулей доступна в Рекомендациях · Инструментальные модули.