Skip to content

feat: out-of-tree handler plugin system (maru.handler_plugins)#58

Open
hyunyul-XCENA wants to merge 9 commits into
mainfrom
feat/oot-plugin-hooks
Open

feat: out-of-tree handler plugin system (maru.handler_plugins)#58
hyunyul-XCENA wants to merge 9 commits into
mainfrom
feat/oot-plugin-hooks

Conversation

@hyunyul-XCENA

@hyunyul-XCENA hyunyul-XCENA commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an out-of-tree (OOT) handler plugin system so vendor-/hardware-specific behaviour can live in separate packages instead of Maru core. Maru stays vendor-neutral: a plugin package registers a maru.handler_plugins entry point and MaruHandler discovers it at construction and calls it at defined seams — core never imports it. Loading is soft-fail (vLLM-style), so a missing or broken plugin never breaks KV-cache operation.

Key Changes

  • maru_handler/plugin.py (new): MaruHandlerPlugin protocol (4 optional hooks) + load_handler_plugins() soft-fail loader — filters by MARU_PLUGINS, dedups duplicate names (first wins), and warns on a None factory return / typo'd allowlist name.
  • maru_handler/handler.py: dispatch at four seams (on_init / on_batch_retrieve / on_close / contribute_stats) via _dispatch_plugins; the empty-plugin case is guarded so the retrieval hot path stays free. on_batch_retrieve fires after the region-mapping loop (a plugin acts on regions mapped in the current batch); plugin stats are namespaced under stats["plugins"][<PluginClassName>].
  • Stable accessor surface (the whole coupling contract): MaruHandler.is_region_mapped() and get_region_dax_path() (backed by new mapper/client get_dax_path). A contract test fails CI if the accessor signatures, hook names, or entry-point group drift.
  • Docs: docs/source/api_reference/plugins.md.
  • Deferred follow-ups (not regressions): defense-in-depth bounds check on server-supplied kv_offset + kv_length before device ops; access-dumper flush/logging tuning.

How it works

Core discovers plugins purely through the entry point, calls them at four lifecycle seams, and a plugin reaches back into core only through the hook arguments plus two documented accessors — nothing else couples the two packages.

sequenceDiagram
    autonumber
    participant EP as entry points<br/>(maru.handler_plugins)
    participant H as MaruHandler (core)
    participant PL as Plugin<br/>(separate package)
    participant HW as Device / hardware

    Note over H,PL: construction — MaruHandler.__init__
    H->>EP: load_handler_plugins() — soft-fail, MARU_PLUGINS filter
    EP-->>H: plugin instance(s)
    H->>PL: on_init(handler)

    Note over H,PL: retrieval hot path — batch_retrieve(keys)
    H->>H: batch_lookup_kv → map shared regions
    H->>PL: on_batch_retrieve(handler, keys, batch_resp)
    loop found + mapped entries
        PL->>H: is_region_mapped(rid) / get_region_dax_path(rid)
        PL->>HW: device hint (prefetch / pin)
    end
    H-->>H: return MemoryInfo[]

    Note over H,PL: teardown — close()
    H->>PL: on_close(handler)
    PL->>HW: release device resources
Loading
  • Core never imports the plugin. Discovery is entirely via the maru.handler_plugins entry point; a missing or broken plugin is logged and skipped (soft-fail).
  • Narrow coupling. A plugin uses only the on_batch_retrieve args plus is_region_mapped / get_region_dax_path — the stable contract a CI test pins.
  • Hot-path placement. on_batch_retrieve runs after regions are mapped, so a plugin can act on live shared-memory addresses; with no plugin installed the seam is a single guarded no-op.

Test Plan

  • Unit tests added/updated — tests/unit/test_plugin_loader.py (loader soft-fail / allowlist / dedup / None-return / dispatch isolation / API contract): 16 passed, ruff clean
  • Existing tests pass (pytest -v) — note: the full unit suite hits a pre-existing, environment-specific native crash in the mocked connect() coverage tests on CUDA-enabled hosts (reproduces on main without this branch; unrelated to this change)
  • E2E tests — validated on real CXL/DAX hardware with a real plugin (cross-instance producer/consumer): entry-point autoload + real device prefetch / pin / release; and in a full vLLM + LMCache benchmark, a plugin issues the same device hints as the equivalent in-tree path (identical prefetch counts, matching latency)

Related Issues

Let vendor-/hardware-specific behaviour live outside maru core instead of in
a separate package. MaruHandler discovers plugins registered under the
`maru.handler_plugins` entry-point group at construction and calls them at
four seams: on_init, on_batch_retrieve, on_close, contribute_stats.

Design follows vLLM's soft-fail plugin model (not PyTorch's hard-fail
autoload): a Maru plugin is an optional optimization, so a load/hook failure
is logged and skipped, never raised. MARU_PLUGINS filters by name.

Plugins couple to core only through a small stable accessor surface:
- MaruHandler.is_region_mapped(region_id)
- MaruHandler.get_region_dax_path(region_id)  (via mapper/client get_dax_path)

Everything a plugin needs about a batch arrives via the on_batch_retrieve
hook args. Empty-plugin hot path stays free (guarded dispatch).

Adds maru_handler/plugin.py (MaruHandlerPlugin protocol + soft-fail loader),
tests/unit/test_plugin_loader.py, and a README Plugins section.
Mark MaruHandler.is_region_mapped / get_region_dax_path and the four
MaruHandlerPlugin hook signatures as the public, stable plugin contract that
out-of-tree plugins depend on across independent release cycles.

- docs/source/api_reference/plugins.md: entry-point group, hook table with
  timing, stable accessor surface, soft-fail + version-skew warning, MARU_PLUGINS.
- handler.py: strengthen accessor docstrings/comment to flag them as stable API.
- test_plugin_loader.py: contract test pinning the accessor signatures, hook
  names, and entry-point group name so CI fails if the surface drifts.
…allowlist

Review-driven robustness for configuration mistakes (soft-fail already covers
runtime exceptions):
- a factory returning None is skipped (was retained as a dead "plugin")
- duplicate entry-point names: first wins + warn (running both would double
  every hook — matches vLLM's loader)
- an allowlist (MARU_PLUGINS) name matching no installed plugin now warns
  instead of silently loading nothing

Adds unit tests for all three (16 passed).
Main's get_stats now returns stats.stats_manager; update the plugin
get_stats test's RPC stub so it doesn't AttributeError after the rebase.
@hyunyul-XCENA hyunyul-XCENA changed the title feat: out-of-tree handler plugin system (retire maru-private fork) feat: out-of-tree handler plugin system (maru.handler_plugins) Jul 3, 2026
@hyunyul-XCENA hyunyul-XCENA force-pushed the feat/oot-plugin-hooks branch from ea88784 to c68c29c Compare July 3, 2026 08:31
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissCoverMissing
__init__.py60100% 
__main__.py330%5, 7–8
allocation_manager.py113992%30–31, 44–45, 52, 237–240
client.py186995%88, 130, 147–148, 311–313, 319, 363
config.py46198%72
constants.py90100% 
device_scanner.py943167%25, 100, 102–104, 106, 114–123, 125, 127, 129, 134–143, 145, 147
handler.py5578685%153, 168, 179–186, 194–196, 211, 217–219, 227, 238–243, 248, 273, 300–301, 305, 345, 349–350, 355, 377, 384–385, 389–395, 398, 402–404, 420, 422–423, 432, 486, 507, 517–518, 624–626, 633, 755–758, 761, 772–775, 781, 867, 971, 1134–1138, 1144, 1155–1159, 1165, 1244, 1249, 1291
ipc.py275299%365, 441
kv_manager.py1080100% 
logging_setup.py190100% 
plugin.py59395%65–67
protocol.py2320100% 
resource_manager_installer.py1031387%80–86, 167, 169–172, 187
rpc_async_client.py1900100% 
rpc_async_server.py1110100% 
rpc_client.py660100% 
rpc_client_base.py1061091%185, 221–222, 233–234, 306–307, 311–312, 374
rpc_handler_mixin.py1061982%154–156, 159–161, 219–222, 227, 231–235, 256–257, 263
rpc_server.py640100% 
serializer.py810100% 
server.py1662088%44, 54–59, 64–65, 73, 168, 172, 243, 247, 265–266, 339–341, 426
stats_manager.py950100% 
types.py60198%145
uds_helpers.py130100% 
memory
   __init__.py50100% 
   allocator.py550100% 
   mapper.py130398%229, 251, 300
   owned_region_manager.py101199%212
   types.py620100% 
TOTAL324221193% 

Tests Skipped Failures Errors Time
702 4 💤 0 ❌ 0 🔥 7.270s ⏱️

Public maru docs shouldn't name a specific vendor plugin. Replace the named
example with a generic 'writing a plugin' description and genericize the
region-mapping note.

@youngrok-XCENA youngrok-XCENA left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOT plugin 시스템 설계·구현 품질이 매우 높습니다 (soft-fail 로더, 좁은 커플링 표면, 계약 테스트, vendor-neutral 문서). 블로킹 이슈는 없습니다.

요청하신 대로 MEDIUM 2건 · LOW 2건을 인라인으로 남깁니다:

  • MEDIUM-1 (plugins.md): "owned region은 hint 안 쏨" 서술이 실제 코드와 배치 (owned도 mapper에 등록됨)
  • MEDIUM-2 (test_plugin_loader.py): 계약 테스트가 on_batch_retrieve 필드 표면을 pin하지 않음
  • LOW-1 (handler.py): 매 __init__마다 entry-point 스캔
  • LOW-2 (handler.py): 서버 제공 offset/length가 plugin으로 무검증 전달 (deferred 확인)

MEDIUM 2건은 머지 전 처리를 권합니다.

Comment thread docs/source/api_reference/plugins.md Outdated
Comment thread tests/unit/test_plugin_loader.py
Comment thread maru_handler/handler.py
Comment thread maru_handler/handler.py
…cache EP scan

- docs(MEDIUM-1): correct the plugins.md note — owned regions ARE registered
  in the mapper (owned_region_manager.add_region → mapper.map_region), so
  on_batch_retrieve fires on owned regions too; a plugin wanting shared-only
  must filter (get_owned_region_ids). The prior note asserted a false
  owned-is-skipped safety property.
- test(MEDIUM-2): contract test now also pins the on_batch_retrieve payload
  fields (BatchLookupKVResponse.entries, LookupResult.found/handle/kv_offset/
  kv_length, MaruHandle.region_id/offset) — the real cross-package contract a
  maru_common rename would silently break.
- perf(LOW-1): cache the maru.handler_plugins entry-point scan at module level
  (installed set is immutable per process) instead of scanning every
  MaruHandler.__init__.

LOW-2 (bounds-check server-supplied offset/length before device ops) tracked
as #59.
@jooho-XCENA jooho-XCENA requested a review from a team July 7, 2026 02:27
…l_pool)

#60 (feat: expose CXL pool free_size in get_stats) added stats.cxl_pool to
get_stats(); the plugin get_stats test's RPC stub was missing it → CI
AttributeError. Mirror the stats_manager fix.
Tighten the MaruHandlerPlugin hook docstrings, the public API this PR
establishes, per review findings:

- on_batch_retrieve: KV bytes are at entry.kv_offset/kv_length; handle.offset
  is only the region mmap base, so a HW hint must target handle.offset +
  kv_offset. Also note batch_retrieve is not serialized against close(), so a
  hint can land after unmap — plugins must be thread-safe and idempotent.
- on_close: fires only if the handler was connected (not paired with on_init);
  runs under the write lock, so it must not block.
- on_init: not paired with on_close — do not acquire there a resource whose
  release depends on on_close.
- contribute_stats: keyed by class name, so same-named plugins collide.
- Class docstring: soft-fail isolates raised exceptions, not hangs.

Docstring-only; no behavior change. ruff clean, plugin loader tests pass.
@hyunyul-XCENA

Copy link
Copy Markdown
Collaborator Author

Follow-up in 6775e2a: folded the remaining review findings into the stable hook contract as docstring-only hardening (no behavior change).

  • on_batch_retrieve: documented that KV bytes are at entry.kv_offset/kv_length (handle.offset is only the region mmap base) and that this hook is not serialized against close() — plugins issuing HW hints must be thread-safe and treat pin/unpin as idempotent.
  • on_close: fires only when connected (not paired with on_init) and runs under the write lock, so must not block.
  • on_init: don't acquire there a resource whose release depends on on_close.
  • contribute_stats: keyed by class name → same-named plugins collide.
  • Class docstring: soft-fail isolates raised exceptions, not hangs.

ruff clean, 17 plugin loader tests pass. LOW-2 (server offset/length bounds-check) remains tracked in #59.

@jooho-XCENA jooho-XCENA left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants