12 KiB
MP Runtime Plugin Design
Overview
The MP Runtime Plugin framework allows users to run custom scripts (Python or Bash) alongside the LMCache multiprocess (MP / ZMQ) server. Plugins receive the full server configuration via an environment variable and run as child processes whose stdout is captured into the LMCache logger.
Unlike the non-MP (vLLM integration) mode where plugins receive a
single LMCacheEngineConfig, the MP mode has multiple independent
config dataclasses (MPServerConfig, StorageManagerConfig,
ObservabilityConfig, etc.). The MPRuntimePluginLauncher
aggregates them all into a single JSON blob before delegating to
the base RuntimePluginLauncher.
Key Components
MPRuntimePluginLauncher
The MP-specific entry point. Accepts arbitrary dataclass configs
via **kwargs, serializes them into a single JSON dict, and
delegates all process management to RuntimePluginLauncher.
| Method | Description |
|---|---|
__init__(runtime_plugin_config, **configs) |
Aggregate configs → JSON, create inner launcher |
launch_plugins() |
Delegate to RuntimePluginLauncher.launch_plugins() |
stop_plugins() |
Delegate to RuntimePluginLauncher.stop_plugins() |
Key design decisions:
role=None: MP mode has no role concept (no SCHEDULER / WORKER split). PassingNonedisables the filename prefix-based role filtering in the base launcher.worker_count=1, worker_id=0: The MP server runs a single process instance; there is no TP-style worker sharding.
RuntimePluginConfig
A dataclass that holds plugin configuration:
| Field | Type | Description |
|---|---|---|
locations |
list[str] |
Plugin file / directory paths |
extra_config |
dict |
Extra key-value config forwarded to plugins via JSON blob |
Accepted via --runtime-plugin-config CLI argument as a JSON string.
_MPPluginConfig
A thin @dataclass wrapper that satisfies the duck-type contract
expected by RuntimePluginLauncher:
| Field / Method | Type | Description |
|---|---|---|
runtime_plugin_locations |
list[str] |
Plugin file / directory paths |
extra_config |
dict |
Extra config from RuntimePluginConfig.extra_config |
configs_dict |
dict |
Aggregated config sections |
to_json() |
str |
Serialize configs_dict + extra_config to JSON string |
safe_asdict / make_json_safe
Public helpers in lmcache.v1.utils.json_utils that convert
dataclass instances to dicts while handling non-serializable
fields (e.g. pathlib.Path) by falling back to str().
safe_asdict operates on dataclass instances; make_json_safe
recursively sanitizes arbitrary values (dicts, lists, tuples,
primitives) and is also reused by the /config HTTP endpoint.
RuntimePluginLauncher (base)
The base launcher (in lmcache.v1.plugin) handles:
- Discovering plugin files (
.py/.sh) in configured locations - Role-based filtering (skipped when
role=None) - Worker-ID-based filtering
- Interpreter detection (shebang → fallback)
- Subprocess management (
Popenwith piped stdout) - Real-time log capture via background threads
- Graceful shutdown via
atexit
Architecture
classDiagram
class MPRuntimePluginLauncher {
-_inner: RuntimePluginLauncher
+launch_plugins()
+stop_plugins()
}
class RuntimePluginLauncher {
+config
+role: str | None
+worker_count: int
+worker_id: int
+launch_plugins()
+stop_plugins()
-_launch_plugin(file)
-_should_skip_plugin(file, parts)
-_capture_plugin_output(proc, name)
}
class RuntimePluginConfig {
+locations: list~str~
+extra_config: dict
}
class _MPPluginConfig {
+runtime_plugin_locations: list~str~
+extra_config: dict
+configs_dict: dict
+to_json() str
}
MPRuntimePluginLauncher --> RuntimePluginLauncher : delegates
MPRuntimePluginLauncher ..> _MPPluginConfig : creates
MPRuntimePluginLauncher ..> RuntimePluginConfig : reads
RuntimePluginLauncher --> _MPPluginConfig : uses as config
Data Flow
sequenceDiagram
participant S as MP Server
participant MPL as MPRuntimePluginLauncher
participant RPL as RuntimePluginLauncher
participant P as Plugin Process
S->>MPL: init(runtime_plugin_config, mp_config, storage_config, ...)
MPL->>MPL: safe_asdict() each config
MPL->>MPL: Build _MPPluginConfig wrapper
MPL->>RPL: init(config=wrapper, role=None)
S->>MPL: launch_plugins()
MPL->>RPL: launch_plugins()
RPL->>RPL: Discover .py / .sh files
RPL->>RPL: Skip role/worker filtering (role=None)
RPL->>P: subprocess.Popen(env={CONFIG=json})
Note over P: PYTHONUNBUFFERED=1
P->>P: Parse LMCACHE_RUNTIME_PLUGIN_CONFIG
P-->>RPL: stdout lines
RPL-->>S: logger.info("[plugin_name] ...")
S->>MPL: stop_plugins()
MPL->>RPL: stop_plugins()
RPL->>P: proc.terminate()
Environment Variables
The base launcher sets the following environment variables for each plugin subprocess:
| Variable | Value in MP mode | Description |
|---|---|---|
LMCACHE_RUNTIME_PLUGIN_CONFIG |
Aggregated JSON | Full server config |
LMCACHE_RUNTIME_PLUGIN_ROLE |
"" (empty) |
No role in MP mode |
LMCACHE_RUNTIME_PLUGIN_WORKER_COUNT |
"1" |
Single server process |
LMCACHE_RUNTIME_PLUGIN_WORKER_ID |
"0" |
Always worker 0 |
PYTHONUNBUFFERED |
"1" |
Force real-time stdout |
Legacy aliases (LMCACHE_PLUGIN_*) are also set for backwards
compatibility.
Input from LMCache to Plugin
LMCache passes configuration to plugins via environment
variables. The core variable is LMCACHE_RUNTIME_PLUGIN_CONFIG,
whose value is a JSON string containing the full LMCache server
configuration.
Input Sources
CLI arguments
--runtime-plugin-locations -> RuntimePluginConfig.locations
--runtime-plugin-config -> RuntimePluginConfig.extra_config (JSON)
--http-host / --http-port -> HTTPFrontendConfig
(optional, forwarded by http_server.py)
MPRuntimePluginLauncher.__init__(
runtime_plugin_config, # locations + extra_config
mp_config, # ZMQ server config
storage_manager_config, # storage config
obs_config, # observability config
http_config, # HTTP config (optional)
)
Config JSON Structure
{
"mp_config": {
"host": "localhost",
"port": 5555,
"chunk_size": 256,
"max_workers": 1,
"hash_algorithm": "blake3",
"engine_type": "default",
"runtime_plugin_config": {
"locations": ["examples/mp_runtime_plugins/"],
"extra_config": {}
}
},
"storage_manager_config": {
"l1_manager_config": {
"memory_config": {
"size_in_bytes": 10737418240,
"use_lazy": true
}
},
"eviction_config": {
"eviction_policy": "LRU",
"trigger_watermark": 0.8,
"eviction_ratio": 0.2
}
},
"obs_config": {
"enabled": true,
"metrics_enabled": true,
"logging_enabled": true,
"tracing_enabled": false
},
"http_config": {
"http_host": "0.0.0.0",
"http_port": 8080
},
"runtime_plugin_extra_config": {
"plugin.frontend.heartbeat_url": "http://discover-service:5000/heartbeat"
}
}
runtime_plugin_extra_config is only present when
RuntimePluginConfig.extra_config is non-empty; it is merged in by
_MPPluginConfig.to_json().
How Plugins Read the Config
import json
import os
raw = os.getenv("LMCACHE_RUNTIME_PLUGIN_CONFIG", "{}")
config = json.loads(raw)
mp_cfg = config.get("mp_config", {})
http_cfg = config.get("http_config", {})
extra = config.get("runtime_plugin_extra_config", {})
Use Cases
Use Case 1: Frontend Plugin (Service Discovery Heartbeat)
Scenario: Run an HTTP frontend process alongside the LMCache MP server; the process periodically sends heartbeats to a discovery service so that a centralized frontend can track live LMCache nodes.
Components:
| Component | Path | Description |
|---|---|---|
lmcache_mp_frontend_plugin.py |
lmcache/lmcache_frontend/lmcache_mp_plugin/lmcache_mp_frontend_plugin.py |
Plugin script; reads config and launches the frontend app |
simple_discover_service.py |
lmcache/tools/simple_discover_service.py |
Simple discovery service that receives heartbeats |
app.py |
lmcache/lmcache_frontend/app.py |
Centralized LMCache frontend; proxies requests to backend nodes |
Data Flow:
sequenceDiagram
participant CLI as CLI / server.py
participant P as lmcache_mp_frontend_plugin.py
participant FA as Frontend App (app.py)
participant DS as Discover Service
CLI->>P: LMCACHE_RUNTIME_PLUGIN_CONFIG (env)
P->>P: Parse http_config + extra_config
P->>FA: app.main(--nodes [...] --port ... --no-http)
loop Every N seconds
FA->>DS: GET /heartbeat?api_address=http://localhost:8080
end
DS->>DS: Store heartbeat data
Note over DS: GET /lmcache_infos returns active nodes
Startup Commands:
# 1. Start discover service
python -m lmcache.tools.simple_discover_service
# 2. Start LMCache MP server with frontend plugin
python -m lmcache.v1.multiprocess.http_server \
--host localhost --port 5555 \
--l1-size-gb 10 \
--http-host 0.0.0.0 --http-port 8080 \
--runtime-plugin-locations lmcache/lmcache_frontend/lmcache_mp_plugin/lmcache_mp_frontend_plugin.py \
--runtime-plugin-config '{"plugin.frontend.heartbeat_url": "http://localhost:5000/heartbeat"}'
How the Plugin Uses the Config:
- Reads
http_host/http_portfromhttp_configto auto-build the--nodesargument, avoiding duplicate CLI flags. - Reads
plugin.frontend.*keys fromruntime_plugin_extra_configand converts them intoapp.main()CLI arguments (e.g.--heartbeat-url).
Use Case 2: Chunk Hash File Upload Agent
Scenario: LMCache produces JSONL-format chunk hash files during operation (see PR #2928). A plugin acts as an agent that periodically scans and uploads those files to a remote storage service (S3, HDFS, OSS, etc.).
Plugin Responsibilities:
- Read
storage_manager_configfromLMCACHE_RUNTIME_PLUGIN_CONFIGto determine the chunk hash file output directory. - Read upload target settings from
runtime_plugin_extra_config(e.g.plugin.uploader.s3_bucket,plugin.uploader.prefix). - Periodically scan the output directory and upload newly
produced
.jsonlfiles to remote storage. - Optionally archive or delete local files after successful upload.
Plugin Contract
A runtime plugin must:
- Be a
.pyor.shfile in one of the configuredruntime_plugin_locations. - Be executable by the detected interpreter (Python or Bash).
- Read configuration from the
LMCACHE_RUNTIME_PLUGIN_CONFIGenvironment variable if needed.
A runtime plugin should:
- Print status / heartbeat messages to stdout (they will be
captured by the launcher and logged via
logger.info). - Handle
SIGTERMgracefully for clean shutdown. - Output single-line messages (each stdout line becomes a separate log entry).
Plugin Discovery & Filtering
runtime_plugin_locations: ["examples/mp_runtime_plugins/"]
│
▼
Path.rglob("*.py") + Path.rglob("*.sh")
│
▼
_should_skip_plugin(file, parts)
│
├─ role is None? → skip role check (MP mode)
│
├─ role is set? → parts[0].upper() must match
│ role or be "ALL"
│
└─ worker_id check → parts[1] if numeric
│
▼
_launch_plugin(file)
│
├─ Detect interpreter (shebang → fallback)
├─ Set environment variables
├─ subprocess.Popen(...)
└─ Start capture thread
Example Plugins
See examples/mp_runtime_plugins/ for reference implementations:
| File | Language | Description |
|---|---|---|
mp_plugin.py |
Python | Parses config, prints summary, runs periodic heartbeat |
mp_heartbeat.sh |
Bash | Extracts config via jq, runs heartbeat loop |
Quick Start
python -m lmcache.v1.multiprocess.http_server \
--host localhost --port 5555 \
--l1-size-gb 10 \
--eviction-policy LRU \
--runtime-plugin-locations examples/mp_runtime_plugins/
Expected log output:
[mp_plugin.py] Started
[mp_plugin.py] Config: {"mp_config": {...}, ...}
[mp_heartbeat.sh] Config: {"mp_config": {...}, ...}