360 lines
12 KiB
Markdown
360 lines
12 KiB
Markdown
# Plugin L2 Adapter Design
|
|
|
|
## Overview
|
|
|
|
The **Plugin L2 Adapter** framework allows third-party developers to extend
|
|
LMCache with custom L2 storage backends **without modifying any LMCache source
|
|
code**. A plugin is simply a Python module that implements `L2AdapterInterface`
|
|
and is loaded at runtime via the `PluginL2AdapterConfig` mechanism.
|
|
|
|
This is the recommended way to integrate external storage systems (e.g.
|
|
NitroFS, custom distributed caches) into LMCache's MP-mode L2 pipeline.
|
|
|
|
---
|
|
|
|
## Key Components
|
|
|
|
### `PluginL2AdapterConfig`
|
|
|
|
Config class registered under the type name `"plugin"`. Fields:
|
|
|
|
| Field | Type | Required | Description |
|
|
|---|---|---|---|
|
|
| `module_path` | `str` | yes | Dotted Python import path of the module containing the adapter class. |
|
|
| `class_name` | `str` | yes | Name of the class inside `module_path` that implements `L2AdapterInterface`. |
|
|
| `adapter_params` | `dict` | no | Arbitrary keyword arguments forwarded to the adapter class constructor. |
|
|
|
|
Defined in `plugin_l2_adapter.py` and self-registered at import time via:
|
|
```python
|
|
register_l2_adapter_type("plugin", PluginL2AdapterConfig)
|
|
register_l2_adapter_factory("plugin", _create_plugin_adapter)
|
|
```
|
|
|
|
### `_create_plugin_adapter`
|
|
|
|
Factory function that:
|
|
1. Calls `importlib.import_module(config.module_path)` to load the user module.
|
|
2. Retrieves `config.class_name` from the module via `getattr`.
|
|
3. Validates it is a subclass of `L2AdapterInterface`.
|
|
4. Resolves a config class via `_resolve_config_class` (see below).
|
|
5. If a config class is found, builds a config instance via `from_dict()`
|
|
and passes it to the adapter constructor (built-in convention).
|
|
Otherwise falls back to passing the raw `adapter_params` dict.
|
|
|
|
### Config Class Auto-Discovery (`_resolve_config_class`)
|
|
|
|
The factory automatically resolves the adapter's config class using
|
|
the following priority chain (first match wins):
|
|
|
|
| Priority | Strategy | Example |
|
|
|---|---|---|
|
|
| 1 | Explicit `config_class_name` field in JSON config | `"config_class_name": "MyConfig"` |
|
|
| 2 | Convention: adapter class name + `"Config"` suffix | `MyL2Adapter` → looks for `MyL2AdapterConfig` |
|
|
| 3 | `config_class_name` attribute on the adapter class | `class MyAdapter: config_class_name = "MyConfig"` |
|
|
| 4 | No config class found — pass raw `adapter_params` dict | legacy / simple plugins |
|
|
|
|
Each candidate name is looked up in the loaded module and validated as
|
|
an `L2AdapterConfigBase` subclass. Non-existent or invalid names are
|
|
silently skipped, moving to the next candidate.
|
|
|
|
This means most plugins that follow the naming convention (e.g.
|
|
`InMemoryL2Adapter` + `InMemoryL2AdapterConfig` in the same module)
|
|
will **automatically** receive a typed config instance without any
|
|
extra configuration.
|
|
|
|
---
|
|
|
|
## Loading Flow
|
|
|
|
```
|
|
CLI / config JSON
|
|
│
|
|
▼
|
|
parse_args_to_l2_adapters_config()
|
|
│ JSON: {"type": "plugin",
|
|
│ "module_path": "my_plugin",
|
|
│ "class_name": "MyL2Adapter",
|
|
│ "adapter_params": {...}}
|
|
│
|
|
▼
|
|
PluginL2AdapterConfig.from_dict(d)
|
|
│ validates module_path, class_name, adapter_params
|
|
│
|
|
▼
|
|
create_l2_adapter_from_registry(config, **kwargs)
|
|
│ looks up factory for "plugin"
|
|
│
|
|
▼
|
|
_create_plugin_adapter(config, ...)
|
|
│
|
|
├─ importlib.import_module(config.module_path)
|
|
├─ getattr(module, config.class_name)
|
|
├─ issubclass check against L2AdapterInterface
|
|
│
|
|
├─ _resolve_config_class(module, config, adapter_cls)
|
|
│ ├─ 1. config.config_class_name (explicit)
|
|
│ ├─ 2. class_name + "Config" (convention)
|
|
│ ├─ 3. adapter_cls.config_class_name (attribute)
|
|
│ └─ 4. None (fall back to raw dict)
|
|
│
|
|
├─ [if config class found]
|
|
│ └─ adapter_cls(cfg_cls.from_dict(adapter_params))
|
|
│
|
|
└─ [otherwise]
|
|
└─ adapter_cls(adapter_params)
|
|
│
|
|
▼
|
|
L2AdapterInterface instance (ready for use)
|
|
```
|
|
|
|
---
|
|
|
|
|
|
## Plugin Contract
|
|
|
|
A plugin adapter class **must**:
|
|
|
|
1. **Subclass `L2AdapterInterface`** from `lmcache.v1.distributed.l2_adapters.base`.
|
|
2. **Implement all abstract methods**: store, lookup & lock, load, close,
|
|
and all three event-fd getters.
|
|
3. **Provide three distinct event fds** (store / lookup / load). The
|
|
controllers build `fd → adapter` maps; duplicates will misroute events.
|
|
4. **Be thread-safe**: the `StoreController` and `PrefetchController`
|
|
call adapter methods from different threads concurrently.
|
|
5. **Accept `**kwargs` in `__init__`** to stay forward-compatible with new
|
|
framework-level arguments.
|
|
|
|
A plugin adapter class **should**:
|
|
|
|
1. Create its own asyncio event loop and background thread if it needs
|
|
async I/O (the framework does **not** provide a loop to L2 adapters,
|
|
unlike the old Connector-based architecture).
|
|
2. Use `create_event_notifier()` from `lmcache.v1.platform` for the
|
|
three event fds (cross-platform: `os.eventfd` on Linux,
|
|
`os.pipe` fallback elsewhere).
|
|
3. Clean up all resources (event fds, threads, connections) in `close()`.
|
|
|
|
---
|
|
|
|
## Threading Model (Plugin Side)
|
|
|
|
Since the framework does **not** provide an event loop to L2 adapters
|
|
(unlike the old non-MP `ConnectorContext.loop`), plugins that need async
|
|
I/O must manage their own:
|
|
|
|
```
|
|
Plugin.__init__()
|
|
├─ self._loop = asyncio.new_event_loop()
|
|
└─ self._thread = Thread(target=run_loop, daemon=True)
|
|
|
|
Caller threads (StoreController / PrefetchController)
|
|
│
|
|
├─ submit_store_task() → run_coroutine_threadsafe(...)
|
|
├─ submit_lookup_task() → call_soon_threadsafe(...)
|
|
└─ submit_load_task() → run_coroutine_threadsafe(...)
|
|
│
|
|
▼
|
|
Plugin background thread (event loop)
|
|
│
|
|
├─ Executes store/load coroutines
|
|
├─ Writes to eventfd on completion
|
|
└─ Accesses shared state under lock
|
|
```
|
|
|
|
This pattern is identical to the one used by `MockL2Adapter` and
|
|
`NixlStoreL2Adapter`.
|
|
|
|
---
|
|
|
|
|
|
## Example: Minimal Plugin
|
|
|
|
### 1. Implement the Adapter
|
|
|
|
```python
|
|
# my_plugin/adapter.py
|
|
import asyncio, threading
|
|
from lmcache.native_storage_ops import Bitmap
|
|
from lmcache.v1.distributed.l2_adapters.base import (
|
|
L2AdapterInterface, L2TaskId,
|
|
)
|
|
from lmcache.v1.platform import create_event_notifier
|
|
|
|
class MyL2Adapter(L2AdapterInterface):
|
|
def __init__(self, host="localhost", **_kw):
|
|
self._store_efd = create_event_notifier()
|
|
self._lookup_efd = create_event_notifier()
|
|
self._load_efd = create_event_notifier()
|
|
# ... set up connection to `host`, background thread, etc.
|
|
|
|
# implement all abstract methods ...
|
|
```
|
|
|
|
### 2. Configure via JSON
|
|
|
|
```json
|
|
{
|
|
"type": "plugin",
|
|
"module_path": "my_plugin.adapter",
|
|
"class_name": "MyL2Adapter",
|
|
"adapter_params": {
|
|
"host": "10.0.0.1"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Launch
|
|
|
|
```bash
|
|
# via CLI
|
|
--l2-adapter '{"type":"plugin","module_path":"my_plugin.adapter","class_name":"MyL2Adapter","adapter_params":{"host":"10.0.0.1"}}'
|
|
|
|
# or via pytest (for testing)
|
|
cfg = PluginL2AdapterConfig.from_dict({...})
|
|
adapter = create_l2_adapter_from_registry(cfg)
|
|
```
|
|
|
|
---
|
|
|
|
## Reference Implementation
|
|
|
|
See `examples/lmc_external_l2_adapter/` for a complete, pip-installable
|
|
example plugin (`InMemoryL2Adapter`) that demonstrates:
|
|
|
|
- FIFO eviction with configurable capacity.
|
|
- Simulated bandwidth delay for realistic testing.
|
|
- Background asyncio event loop with proper shutdown.
|
|
- Full test suite covering store, lookup, load, batch operations,
|
|
and eviction behavior.
|
|
|
|
---
|
|
|
|
## Native Plugin L2 Adapter (`native_plugin`)
|
|
|
|
The **Native Plugin** adapter type (`"native_plugin"`) enables loading
|
|
third-party **native connectors** (pybind-wrapped C++ or pure-Python
|
|
implementations of the `IStorageConnector` interface) without requiring
|
|
them to re-implement the Python-side demux/lock bridging logic.
|
|
|
|
### How It Differs from `plugin`
|
|
|
|
| Aspect | `plugin` | `native_plugin` |
|
|
|---|---|---|
|
|
| What is loaded | A full `L2AdapterInterface` subclass | A **connector** object (lower level) |
|
|
| Bridging logic | Provided by the plugin itself | Reused from `NativeConnectorL2Adapter` |
|
|
| Third-party effort | Must implement all abstract methods + 3 eventfds | Only 6 connector methods |
|
|
|
|
### `NativePluginL2AdapterConfig`
|
|
|
|
Config class registered under the type name `"native_plugin"`. Fields:
|
|
|
|
| Field | Type | Required | Description |
|
|
|---|---|---|---|
|
|
| `module_path` | `str` | yes | Dotted Python import path of the module containing the connector class. |
|
|
| `class_name` | `str` | yes | Name of the connector class inside `module_path`. |
|
|
| `adapter_params` | `dict` | no | Forwarded as `**kwargs` to the connector class constructor. |
|
|
|
|
### Required Connector Interface
|
|
|
|
The dynamically loaded connector instance must expose the following
|
|
methods (identical to the pybind `LMCACHE_BIND_CONNECTOR_METHODS` contract):
|
|
|
|
```python
|
|
class NativeConnectorProtocol:
|
|
def event_fd(self) -> int: ...
|
|
def submit_batch_get(self, keys: list[str], memoryviews: list[memoryview]) -> int: ...
|
|
def submit_batch_set(self, keys: list[str], memoryviews: list[memoryview]) -> int: ...
|
|
def submit_batch_exists(self, keys: list[str]) -> int: ...
|
|
def drain_completions(self) -> list[tuple[int, bool, str, list[bool] | None]]: ...
|
|
def close(self) -> None: ...
|
|
```
|
|
|
|
The factory validates these methods at creation time and raises
|
|
`TypeError` if any are missing.
|
|
|
|
### Loading Flow
|
|
|
|
```
|
|
CLI / config JSON
|
|
│
|
|
▼
|
|
parse_args_to_l2_adapters_config()
|
|
│ JSON: {"type": "native_plugin",
|
|
│ "module_path": "my_ext.connector",
|
|
│ "class_name": "MyConnectorClient",
|
|
│ "adapter_params": {"host": "localhost"}}
|
|
│
|
|
▼
|
|
NativePluginL2AdapterConfig.from_dict(d)
|
|
│
|
|
▼
|
|
_create_native_plugin_l2_adapter(config, ...)
|
|
│
|
|
├─ importlib.import_module(config.module_path)
|
|
├─ getattr(module, config.class_name)
|
|
├─ connector_cls(**config.adapter_params)
|
|
├─ validate 6 required methods
|
|
└─ NativeConnectorL2Adapter(native_client)
|
|
│
|
|
▼
|
|
L2AdapterInterface instance (ready for use)
|
|
```
|
|
|
|
### Example
|
|
|
|
```json
|
|
{
|
|
"type": "native_plugin",
|
|
"module_path": "lmc_external_native_connector",
|
|
"class_name": "ExampleNativeConnector",
|
|
"adapter_params": {
|
|
"backend": "fs",
|
|
"base_path": "/tmp/lmcache_ext",
|
|
"num_workers": 2
|
|
}
|
|
}
|
|
```
|
|
|
|
### Reference Implementation
|
|
|
|
See `examples/lmc_external_native_connector/` for a complete,
|
|
pip-installable example connector plugin that demonstrates:
|
|
|
|
- C++ pybind11-wrapped connectors inheriting from
|
|
`ConnectorBase<T>` (same as built-in Redis/FS).
|
|
- Two backends: filesystem (ExampleFSConnector) and
|
|
in-memory (ExampleMemoryConnector), both in C++.
|
|
- A thin Python factory class (`ExampleNativeConnector`)
|
|
that selects the backend via a `"backend"` parameter.
|
|
- Worker thread pool with eventfd notification
|
|
(inherited from `ConnectorBase`).
|
|
- Build via `pip install -e .` using pybind11 + setuptools.
|
|
|
|
---
|
|
|
|
## Self-Registration Mechanism
|
|
|
|
The `plugin_l2_adapter.py` and `native_connector_l2_adapter.py`
|
|
modules follow the same self-registration pattern as all other
|
|
adapters in the package:
|
|
|
|
```
|
|
__init__.py
|
|
└─ pkgutil.iter_modules → importlib.import_module
|
|
├─ plugin_l2_adapter.py (auto-discovered)
|
|
│ ├─ register_l2_adapter_type("plugin", PluginL2AdapterConfig)
|
|
│ └─ register_l2_adapter_factory("plugin", _create_plugin_adapter)
|
|
│
|
|
├─ raw_block_l2_adapter.py (auto-discovered)
|
|
│ ├─ register_l2_adapter_type("raw_block", RawBlockL2AdapterConfig)
|
|
│ └─ register_l2_adapter_factory("raw_block", _create_raw_block_adapter)
|
|
│
|
|
└─ native_connector_l2_adapter.py (auto-discovered)
|
|
├─ register_l2_adapter_type("resp", ...)
|
|
├─ register_l2_adapter_type("fs_native", ...)
|
|
└─ register_l2_adapter_type("native_plugin", NativePluginL2AdapterConfig)
|
|
```
|
|
|
|
No changes to existing codes are needed when these modules
|
|
are present in the `l2_adapters/` package directory.
|