Система принадлежности (owner)
Принадлежность — основа модульной системы "Plug and Play": все ресурсы, зарегистрированные фреймворком во время загрузки модуля, автоматически привязываются к нему, а при отключении/выгрузке модуля — автоматически удаляются. Автор модуля просто объявляет ресурсы, не вручную удаляя их.
Связанные системы: область видимости (scope) определяет, "действует ли ресурс" при обработке событий, а принадлежность — "кому принадлежит ресурс и кто будет отвечать за его удаление при выгрузке модуля". Область видимости описана в Единая контрольная плоскость (scope), фоновые задачи — в [Управление жизненным циклом](lifecycle.md#Фоновые задачи и автоматическая отмена).
{!--< tips >!--}
- Принадлежность автоматически записывается в момент регистрации (
current_owner), без изменений в коде модуля - Выгрузка/отключение используют одну и ту же цепочку очистки (
_cleanup_module_registrations), при сбое на одном этапе — только предупреждение, не прерывание - Ресурсы, определённые пользовательскими настройками (постоянное переопределение / scope-правила / ACL команд) не удаляются при выгрузке модуля
- Внешние дескрипторы, управляемые утилитным модулем, можно включить в цепочку очистки с помощью
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.module.audit(name, deep=True): исследование gc экземпляров модуля — при невозможности сборки мусора экземпляра указывается тип источника ссылки (определение "кто держит старый экземпляр"); имеет глобальные накладные расходы, используется только для явного устранения неисправностей- Глубокое исследование — явная операция, не имеет ключа конфигурации и не выполняет автоматическое исправление
Откат при неудачной горячей перезагрузке
Горячая перезагрузка теперь реализована по схеме "Создание снимка перед выгрузкой → Автоматический откат при сбое": при синтаксических ошибках в новой версии, отсутствии зависимостей или сбое загрузки, старый экземпляр и зарегистрированные состояния (записи в реестре, свойства sdk, записи в sys.modules) автоматически восстанавливаются, сервис не прерывается, в логах появляется сообщение "Восстановлен старый экземпляр, продолжение работы".
Ограничения семантики "стараться изо всех сил" (документированные границы):
- Неотменяемые побочные эффекты, выполненные в
on_unload(разорванные соединения, отмененные задачи) — после восстановления старый экземпляр находится в состоянии "уже завершен", требуется повторный запуск загрузки для полной доступности - Ручные ссылки на старый экземпляр, созданные сторонними компонентами во время выполнения, не восстанавливаются
- Если пакет уже был выгружен (исчез entry-point), это считается успешной выгрузкой, откат не производится
Пределы принадлежности: какие ресурсы не удаляются при выгрузке
Принадлежность удаляет только ресурсы, зарегистрированные кодом модуля. Ресурсы, относящиеся к семантике пользовательских настроек (управление на стороне пользователя, возможно, намеренно настроенные), сохраняются после выгрузки модуля:
| Ресурс | Семантика | Описание |
|---|---|---|
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 и как их отменить при выгрузке модуля". Задачи попадают в таблицу следующими путями:
- Явный запуск:
spawn_background()/self.spawn()— при создании захватываетсяcurrent_owner(или явно заданный параметрowner=) и задача регистрируется; - Автоматическая регистрация 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) # Ожидание завершения (ограничение по времени)
Стратегия отладки
- Ресурс не был очищен → проверьте таблицу:
get_owner_tasks("MyModule")содержит ли задачу; если нет — путь регистрации не прошёл через цепочку принадлежности (import-период / поток / независимый цикл), сверьтесь с таблицей выше. - Неправильная привязка → проверьте
get_current_owner()в момент ошибки; для асинхронных задач/колбэков привязка берётся из контекста на момент создания, а не выполнения.
Руководство для авторов модулей
Рекомендуемый стиль
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()
Важные замечания
- Регистрация в момент импорта без принадлежности: Регистрация хуков/обработчиков на уровне модуля (в момент импорта) происходит до
owner_scope, и такие ресурсы считаются принадлежащими фреймворку (owner=None) и не удаляются. Все регистрации должны быть вon_load(). - Регистрация i18n с пользовательским domain:
i18n.register(domain=...)с domain, не равным имени модуля, не будет автоматически удалена, сохраняйте domain=имя модуля. - Рекомендуется использовать
self.spawn()для фоновых задач: с версии 2.8.3asyncio.create_taskтакже автоматически привязывается (Task Factory автоматически регистрирует, отменяет при выгрузке); ноself.spawn()всё ещё рекомендуется — поддерживает запуск вне главного цикла, явное указаниеowner=и предотвращение GC. В версиях до 2.8.3 негарантированная принадлежность, нужно использоватьself.spawn(). - Цепочка очистки "сбой только предупреждение": ошибка на одном этапе не прерывает очистку остальных ресурсов, логи видны на уровнях DEBUG/WARNING, для отладки можно включить TRACE.
Сравнение сценариев регистрации → результат принадлежности
| Сценарий регистрации | Результат принадлежности | Описание |
|---|---|---|
Регистрация внутри 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.
Сводка для разработчиков модулей доступна в Рекомендациях · Инструментальные модули.