Files
2026-07-13 12:24:33 +08:00

849 lines
30 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
LMCache Configuration Base Module
This module provides common configuration utilities and base classes
for all LMCache configuration systems to avoid code duplication.
"""
# Standard
from dataclasses import make_dataclass
from typing import Any, Callable, Dict, Optional, Protocol, Union
import ast
import json
import os
import threading
import uuid
# Third Party
import requests
import yaml
# First Party
from lmcache.logging import init_logger
logger = init_logger(__name__)
def _apply_env_converter_safely(config_definitions, name, value):
"""Apply env_converter to a value safely."""
if name not in config_definitions:
return value
config = config_definitions[name]
env_converter = config.get("env_converter")
if env_converter:
try:
# Don't apply converter if value is None
if value is None:
return None
return env_converter(value)
except (ValueError, json.JSONDecodeError) as e:
log_message = f"Failed to convert value for {name}={value!r}: {e}"
logger.warning(log_message)
# Return None if conversion fails
return None
return value
# Common configuration parsing utilities
def _parse_local_disk(local_disk) -> Optional[str]:
"""Parse local disk path configuration.
Accepts a single path or comma-separated paths, each optionally
prefixed with ``file://``. Returns a comma-joined string of bare
directory paths suitable for ``LocalDiskBackend``.
Examples::
"file:///mnt/nvme0/" -> "/mnt/nvme0/"
"/mnt/nvme0/,/mnt/nvme1/" -> "/mnt/nvme0/,/mnt/nvme1/"
"file:///mnt/nvme0/,file:///mnt/nvme1/"
-> "/mnt/nvme0/,/mnt/nvme1/"
Args:
local_disk: Raw config value — ``None``, a single path, or
comma-separated paths.
Returns:
Comma-joined bare directory paths, or ``None`` if disabled.
"""
if local_disk is None:
return None
raw_parts = [p.strip() for p in str(local_disk).split(",") if p.strip()]
if not raw_parts:
return None
parsed: list[str] = []
for part in raw_parts:
if part.startswith("file://"):
parsed.append(part[7:])
else:
parsed.append(part)
return ",".join(parsed)
def _to_int_list(
value: Optional[Union[str, int, list[Any]]],
) -> Optional[list[int]]:
"""Convert value to list of integers"""
if value is None:
return None
if isinstance(value, list):
return [int(x) for x in value]
if isinstance(value, int):
return [value]
parts = [p.strip() for p in str(value).split(",") if p.strip()]
return [int(p) for p in parts]
def _to_float_list(
value: Optional[Union[str, float, list[Any]]],
) -> Optional[list[float]]:
"""Convert value to list of floats"""
if value is None:
return None
if isinstance(value, list):
return [float(x) for x in value]
if isinstance(value, float):
return [value]
parts = [p.strip() for p in str(value).split(",") if p.strip()]
return [float(p) for p in parts]
def _to_str_list(
value: Optional[Union[str, list[str]]],
) -> Optional[list[str]]:
"""Convert value to list of strings"""
if value is None:
return None
if isinstance(value, list):
return value
parts = [p.strip() for p in value.split(",") if p.strip()]
return [p for p in parts]
def _to_bool(
value: Optional[Union[bool, int, str]],
) -> bool:
"""Convert value to boolean"""
if isinstance(value, bool):
return value
return str(value).strip().lower() in ["true", "1"]
def _parse_quoted_string(value: str) -> str:
"""Parse a string that may be surrounded by quotes and handle escape characters.
Args:
value: The input string that may be quoted
Returns:
The unquoted string with escape characters properly handled
"""
if not value:
return value
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
try:
evaluated = ast.literal_eval(value)
if isinstance(evaluated, str):
return evaluated
except (ValueError, SyntaxError):
# If ast.literal_eval fails, it's not a valid Python literal.
# Fall back to simply stripping the outer quotes.
return value[1:-1]
return value
def _to_json(obj: Any) -> str:
"""Convert object to JSON string"""
# If object has to_dict method, use it to convert to dict first
if hasattr(obj, "to_dict"):
return json.dumps(obj.to_dict(), indent=2)
# Otherwise try to serialize directly
return json.dumps(obj, indent=2)
def _from_json(cls, json_str: str):
"""Deserialize a JSON string into a configuration object."""
try:
config_dict = json.loads(json_str)
return cls.from_dict(config_dict)
except json.JSONDecodeError as e:
logger.error("Invalid JSON input: %s", e)
raise
# Configuration aliases and deprecated mappings utility
def _resolve_config_aliases(
config_dict: dict,
source: str,
config_definitions: dict,
config_aliases: dict,
deprecated_configs: dict,
) -> dict:
"""Resolve configuration aliases and handle deprecated configurations."""
resolved = {}
# Process each key in the input
for key, value in config_dict.items():
if key in deprecated_configs:
# Log deprecation warning
logger.warning("%s (source: %s)", deprecated_configs[key], source)
# Map to new key if alias exists
if key in config_aliases:
new_key = config_aliases[key]
resolved[new_key] = value
else:
# Keep deprecated key for backward compatibility
resolved[key] = value
elif key in config_definitions:
# Valid configuration key
resolved[key] = value
else:
# Unknown configuration key
logger.warning("Unknown configuration key: %s (source: %s)", key, source)
return resolved
# Base configuration class creator
def create_config_class(
config_name: str,
config_definitions: dict[str, dict[str, Any]],
config_aliases: Optional[dict[str, str]] = None,
deprecated_configs: Optional[dict[str, str]] = None,
namespace_extras: Optional[dict[str, Any]] = None,
env_prefix: str = "LMCACHE_",
):
"""Create a configuration class dynamically with common functionality.
Args:
config_name: Name of the configuration class
config_definitions: Dictionary of configuration definitions
config_aliases: Optional mapping of deprecated names to current names
deprecated_configs: Optional mapping of deprecated names to warning messages
namespace_extras: Optional additional namespace items for the class
env_prefix: Environment variable prefix (default: "LMCACHE_")
Returns:
A dynamically created dataclass with configuration functionality
"""
# Default values
config_aliases = config_aliases or {}
deprecated_configs = deprecated_configs or {}
namespace_extras = namespace_extras or {}
# Extract fields from configuration definitions
fields_dict = {}
for name, config in config_definitions.items():
fields_dict[name] = (config["type"], config["default"])
def _post_init(self):
"""Post-initialization setup"""
# Initialize user-set keys tracking set
# This tracks which config keys were explicitly set by user
# (via file, env vars, or overrides) vs. using default values
if not hasattr(self, "_user_set_keys"):
object.__setattr__(self, "_user_set_keys", set())
# Generate instance ID if not set
if not getattr(self, "lmcache_instance_id", None):
self.lmcache_instance_id = f"{config_name.lower()}_{uuid.uuid4().hex}"
def _from_env(cls):
"""Load configuration from environment variables"""
def get_env_name(attr_name: str) -> str:
return f"{env_prefix}{attr_name.upper()}"
# Collect all defined and deprecated env vars
all_keys = list(config_definitions.keys()) + list(config_aliases.keys())
env_config = {}
for name in all_keys:
env_name = get_env_name(name)
env_value = os.getenv(env_name)
if env_value is not None:
env_config[name] = env_value
# Resolve aliases and handle deprecated configurations
resolved_config = _resolve_config_aliases(
env_config,
"environment variables",
config_definitions,
config_aliases,
deprecated_configs,
)
config_values = {}
user_set_keys = set() # Track keys explicitly set by user
for name, config in config_definitions.items():
if name in resolved_config:
try:
raw_value = resolved_config[name]
value = _parse_quoted_string(raw_value)
# Apply env_converter safely
config_values[name] = _apply_env_converter_safely(
config_definitions, name, value
)
user_set_keys.add(name) # Mark as user-set
except (ValueError, json.JSONDecodeError) as e:
raw_value_for_log = resolved_config.get(name, "unknown value")
log_message = (
f"Failed to parse {get_env_name(name)}"
f"={raw_value_for_log!r}: {e}"
)
logger.warning(log_message)
# Use default value with conversion
config_values[name] = _apply_env_converter_safely(
config_definitions, name, config["default"]
)
else:
# Use default value with conversion
config_values[name] = _apply_env_converter_safely(
config_definitions, name, config["default"]
)
instance = cls(**config_values)
# Store user-set keys in the instance
object.__setattr__(instance, "_user_set_keys", user_set_keys)
return instance
def _from_file(cls, file_path: str):
"""Load configuration from file"""
with open(file_path, "r") as fin:
file_config = yaml.safe_load(fin) or {}
# Resolve aliases and handle deprecated configurations
resolved_config = _resolve_config_aliases(
file_config,
f"file: {file_path}",
config_definitions,
config_aliases,
deprecated_configs,
)
config_values = {}
user_set_keys = set() # Track keys explicitly set by user
for name, config in config_definitions.items():
if name in resolved_config:
value = resolved_config[name]
user_set_keys.add(name) # Mark as user-set
else:
value = config["default"]
# Apply env_converter safely regardless of whether value is None or not
config_values[name] = _apply_env_converter_safely(
config_definitions, name, value
)
instance = cls(**config_values)
# Store user-set keys in the instance
object.__setattr__(instance, "_user_set_keys", user_set_keys)
return instance
def _from_defaults(cls, **kwargs):
"""Create configuration from defaults"""
config_values = {}
user_set_keys = set() # Track keys explicitly set by user
for name, config in config_definitions.items():
if name in kwargs:
value = kwargs[name]
user_set_keys.add(name) # Mark as user-set
else:
value = config["default"]
# Apply env_converter safely regardless of whether value is None or not
config_values[name] = _apply_env_converter_safely(
config_definitions, name, value
)
instance = cls(**config_values)
# Store user-set keys in the instance
object.__setattr__(instance, "_user_set_keys", user_set_keys)
return instance
def _update_config_from_env(self):
"""Update an existing config object with environment variable configurations."""
def get_env_name(attr_name: str) -> str:
return f"{env_prefix}{attr_name.upper()}"
env_config = {}
# Collect all defined and deprecated env vars
all_keys = list(config_definitions.keys()) + list(config_aliases.keys())
for name in all_keys:
env_name = get_env_name(name)
env_value = os.getenv(env_name)
if env_value is not None:
env_config[name] = env_value
# Resolve aliases
resolved_config = _resolve_config_aliases(
env_config,
"environment variables",
config_definitions,
config_aliases,
deprecated_configs,
)
# Ensure _user_set_keys exists
if not hasattr(self, "_user_set_keys"):
object.__setattr__(self, "_user_set_keys", set())
# Update config object
for name, config in config_definitions.items():
if name in resolved_config:
try:
raw_value = resolved_config[name]
value = _parse_quoted_string(raw_value)
converted_value = config["env_converter"](value)
setattr(self, name, converted_value)
# Mark as user-set
self._user_set_keys.add(name)
except (ValueError, json.JSONDecodeError) as e:
raw_value_for_log = resolved_config.get(name, "unknown value")
log_message = (
f"Failed to parse {get_env_name(name)}"
f"={raw_value_for_log!r}: {e}"
)
logger.warning(log_message)
return self
def _from_dict(cls, config_dict: dict):
"""Create configuration from a dictionary."""
resolved_config = _resolve_config_aliases(
config_dict,
"dictionary input",
config_definitions,
config_aliases,
deprecated_configs,
)
config_values = {}
user_set_keys = set() # Track keys explicitly set by user
for name, config in config_definitions.items():
if name in resolved_config:
value = resolved_config[name]
user_set_keys.add(name) # Mark as user-set
else:
value = config["default"]
if value is not None:
value = config["env_converter"](value)
config_values[name] = value
instance = cls(**config_values)
# Store user-set keys in the instance
object.__setattr__(instance, "_user_set_keys", user_set_keys)
return instance
def _to_dict(self):
"""Convert the configuration object into a dictionary."""
return {name: getattr(self, name) for name in config_definitions}
# Build namespace
namespace = {
"__post_init__": _post_init,
"from_defaults": classmethod(_from_defaults),
"from_file": classmethod(_from_file),
"from_env": classmethod(_from_env),
"update_config_from_env": _update_config_from_env,
"from_dict": classmethod(_from_dict),
"to_dict": _to_dict,
"to_json": _to_json,
"from_json": classmethod(_from_json),
"__str__": lambda self: str(
{name: getattr(self, name) for name in config_definitions}
),
}
# Add extra namespace items
namespace.update(namespace_extras)
# Create class
cls = make_dataclass(
config_name,
[(name, type_, default) for name, (type_, default) in fields_dict.items()],
namespace=namespace,
)
# Add config_definitions as a class attribute for accessing converters
cls._config_definitions = config_definitions # type: ignore[attr-defined]
return cls
# Thread-safe singleton utility
class SingletonGetter(Protocol):
"""Protocol for singleton getter functions"""
def __call__(self) -> Any: ...
reset: Callable[[], None]
def create_singleton_config(
getter_func_name: str,
config_class,
config_env_var: str = "LMCACHE_CONFIG_FILE",
) -> SingletonGetter:
"""Create thread-safe singleton configuration access pattern.
Args:
getter_func_name: Name for the singleton getter function
config_class: The configuration class to create singleton for
config_env_var: Environment variable name for configuration file path
"""
_config_instance = None
_config_lock = threading.Lock()
def get_or_create_config() -> config_class:
"""Get the configuration singleton"""
nonlocal _config_instance
# Double-checked locking for thread-safe singleton
if _config_instance is None:
with _config_lock:
if _config_instance is None: # Check again within lock
if config_env_var not in os.environ:
logger.warning(
"No configuration file is set. Trying to read "
"configurations from the environment variables."
)
logger.warning(
f"You can set the configuration file through "
f"the environment variable: {config_env_var}"
)
_config_instance = config_class.from_env()
else:
config_file = os.environ[config_env_var]
logger.info("Loading config file %s", config_file)
_config_instance = config_class.from_file(config_file)
# Update config from environment variables
_config_instance.update_config_from_env()
return _config_instance
def reset_config_instance() -> None:
"""Reset the configuration singleton for testing"""
nonlocal _config_instance
with _config_lock:
_config_instance = None
# Set the function name for better debugging
get_or_create_config.__name__ = getter_func_name
get_or_create_config.reset = reset_config_instance # type: ignore[attr-defined]
return get_or_create_config # type: ignore[return-value]
def load_config_with_overrides(
config_class,
config_file_env_var: str = "LMCACHE_CONFIG_FILE",
config_file_path: Optional[str] = None,
overrides: Optional[Dict[str, Any]] = None,
):
"""
Load configuration with support for file, environment variables, and overrides.
This is a generic utility function that can be reused across different
configuration classes (LMCacheEngineConfig, ControllerConfig, etc.)
Args:
config_class: The configuration class to instantiate
config_file_env_var: Environment variable name for config file path
config_file_path: Optional direct config file path (overrides env var)
overrides: Optional dictionary of configuration overrides
Returns:
Loaded and validated configuration instance
"""
# Load configuration from file or environment
actual_config_path = config_file_path or os.getenv(config_file_env_var)
if actual_config_path:
logger.info("Loading config file: %s", actual_config_path)
config = config_class.from_file(actual_config_path)
# Allow environment variables to override file settings
config.update_config_from_env()
else:
logger.info("No config file specified, loading from environment variables.")
config = config_class.from_env()
# Apply any overrides
if overrides:
for key, value in overrides.items():
if hasattr(config, key):
old_value = getattr(config, key)
# Check if this configuration class has definitions with converters
if (
hasattr(config, "_config_definitions")
and key in config._config_definitions
):
# Use the global helper function to safely apply env_converter
new_value = _apply_env_converter_safely(
config._config_definitions, key, value
)
setattr(config, key, new_value)
else:
setattr(config, key, value)
new_value = getattr(config, key)
if old_value != new_value:
logger.info(
"Override config: %s = %s (was %s)", key, new_value, old_value
)
else:
logger.warning("Unknown config key: %s, ignoring", key)
# Validate configuration
if hasattr(config, "validate"):
config.validate()
# Log configuration
if hasattr(config, "log_config"):
config.log_config()
return config
def parse_command_line_extra_params(extra_args: list[str]) -> dict[str, Any]:
"""
Parse extra command-line parameters in key=value format.
Args:
extra_args: List of strings in format "key=value"
Returns:
Dictionary of parsed parameters
"""
params = {}
for arg in extra_args:
if "=" in arg:
key, value = arg.split("=", 1)
key = key.lstrip("-")
try:
if value.lower() in ("true", "false"):
params[key] = value.lower() == "true"
elif value.isdigit():
params[key] = int(value) # type: ignore[assignment]
elif value.replace(".", "", 1).isdigit():
params[key] = float(value) # type: ignore[assignment]
else:
params[key] = value # type: ignore[assignment]
except ValueError:
params[key] = value # type: ignore[assignment]
logger.info("Extra parameter: %s = %s", key, params[key])
return params
def validate_and_set_config_value(config, config_key, value, override: bool = True):
"""Validate and set configuration value.
Args:
config: Configuration object to update.
config_key: The configuration key to set.
value: The value to set.
override: If True, completely replace the value. If False:
- For 'extra_config': merge with existing dict (new values take
precedence for conflicting keys).
- For other keys: skip if key was user-set.
Default is True.
Returns:
True if the value was set successfully, False otherwise.
"""
if not hasattr(config, config_key):
logger.warning("Config key '%s' does not exist in configuration", config_key)
return False
try:
# Apply type conversion using env_converter if available
# Skip for extra_config which has special handling below
if hasattr(config, "_config_definitions") and config_key != "extra_config":
original_value = value
value = _apply_env_converter_safely(
config._config_definitions, config_key, value
)
# If original value was not None but conversion returned None,
# it means conversion failed
if original_value is not None and value is None:
# _apply_env_converter_safely already logged warning
return False
# Convert string to dict for extra_config
if config_key == "extra_config" and isinstance(value, str):
value = json.loads(value) if value else None
# Handle extra_config special logic
if config_key == "extra_config":
current_value = getattr(config, config_key, None)
# Handle None or empty value when override=False
if not override and (value is None or value == ""):
# Keep current value
logger.info(
"Keeping current extra_config (override=False, value is None/empty)"
)
return True
if override:
# Full override: replace current value with new value
setattr(config, config_key, value)
logger.info("Overridden extra_config")
return True
else:
# Merge mode (override=False)
if value is not None and isinstance(value, dict):
if current_value is not None and isinstance(current_value, dict):
# Merge: current values preserved, new values override
merged_value = {**current_value, **value}
setattr(config, config_key, merged_value)
logger.info("Merged extra_config")
else:
# Current value is None or not a dict, set new value
setattr(config, config_key, value)
logger.info("Set extra_config")
return True
# For non-extra_config keys: skip if override=False and key user-set
if not override:
user_set_keys: set[str] = getattr(config, "_user_set_keys", set())
if config_key in user_set_keys:
current_value = getattr(config, config_key, None)
logger.info(
"Skipping config %s (override=False, user-set value=%s)",
config_key,
current_value,
)
# Return True to indicate operation completed (kept existing)
return True
setattr(config, config_key, value)
logger.info("Set config item '%s'", config_key)
return True
except Exception as e:
logger.error(
"Failed to set config item '%s' with value %s: %s",
config_key,
value,
e,
)
return False
def fetch_remote_config(
remote_config_url: str,
app_id: Optional[str],
config: Any,
timeout: int = 10,
) -> Optional[dict]:
"""Fetch configuration from remote config service.
The config server protocol:
- Request: POST with JSON body containing 'current_config' and 'env_variables'
- Query parameter: 'appId' if app_id is provided
- Response: JSON with 'configs' array, each item has 'key', 'value', 'override'
See examples/remote_config_server/ for a reference implementation.
Args:
remote_config_url: URL of the remote config service.
app_id: Optional app ID to send to the config service.
config: Current LMCacheEngineConfig to send to the config service.
timeout: Request timeout in seconds.
Returns:
Parsed JSON response from the config service, or None if failed.
"""
try:
# Build request payload with current config and env variables
payload: dict[str, Any] = {
"current_config": config.to_dict(),
"env_variables": {k: v for k, v in os.environ.items()},
}
# Build URL with app_id query parameter if provided
params = {"appId": app_id} if app_id else None
# Send POST request with JSON payload
response = requests.get(
remote_config_url,
json=payload,
params=params,
headers={"Content-Type": "application/json"},
timeout=timeout,
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
logger.warning(
"Failed to fetch remote config from %s: %s", remote_config_url, e
)
return None
except json.JSONDecodeError as e:
logger.warning("Failed to parse remote config response: %s", e)
return None
except Exception as e:
logger.warning("Unexpected error fetching remote config: %s", e)
return None
def apply_remote_configs(config: Any, remote_response: dict) -> Any:
"""Apply remote configuration to LMCacheEngineConfig.
This function extracts the 'configs' field from the remote response
and applies each config item to the LMCacheEngineConfig instance.
The expected format of remote_response['configs'] is:
[
{"override": true, "key": "config_key", "value": "config_value"},
...
]
Args:
config: LMCacheEngineConfig instance to update.
remote_response: Response from the remote config service.
Returns:
Updated LMCacheEngineConfig instance.
"""
configs = remote_response.get("configs", [])
if not configs:
logger.info("No configs found in remote response")
return config
applied_count = 0
for config_item in configs:
if not isinstance(config_item, dict):
logger.warning("Invalid config item format: %s", config_item)
continue
key = config_item.get("key")
value = config_item.get("value")
override = config_item.get("override", True)
if not key:
logger.warning("Config item missing 'key': %s", config_item)
continue
# Try to convert value to appropriate type
if validate_and_set_config_value(config, key, value, override=override):
logger.info("Applied remote config: %s=%s", key, value)
applied_count += 1
else:
logger.warning(
"Failed to apply remote config %s=%s. Using default value.",
key,
value,
)
logger.info("Applied %d remote configuration items", applied_count)
return config