ReMe/reme/steps/common/status.py
jinliyl 2e87b7a52e
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
feat(core): add shell execution and runtime memory status (#344)
* feat(core): add shell command execution and memory status reporting

- Introduce ShellStep for executing shell commands with timeout support
- Add StatusStep to report memory estimates for stateful data components
- Register shell and status commands in default configuration
- Update documentation with new reme status and shell command capabilities
- Implement comprehensive unit tests for both new step types
- Add support for asynchronous command execution with proper error handling

* feat(config): add log_config option to suppress config loading logs

- Add log_config parameter to resolve_app_config function with default True
- Conditionally log config loading messages based on log_config flag
- Update reme.py and service_utils.py to use log_config=False for client calls
- Suppress config logging in user-facing contexts to avoid output pollution

refactor(shell): rename command parameter to cmd for clarity

- Change 'command' to 'cmd' in default.yaml configuration schema
- Rename 'timeout' to 'shell_timeout' to avoid parameter name collisions
- Update ShellStep to accept both legacy and new parameter names
- Maintain backward compatibility with existing command/timeout usage

test(shell): add comprehensive tests for shell step parameter handling

- Add test cases for new cmd and shell_timeout parameter names
- Verify legacy command and timeout parameters still work
- Test blank command rejection message updated to use cmd
- Create integration test for shell parameter payload passing

* fix(shell): ensure proper environment loading and process timeout handling

- Move load_env() call to execute before parse_args() in main function
- Add proper process group killing for timeout scenarios on POSIX systems
- Implement recursive child process termination on Windows for proper cleanup
- Change parameter name from 'timeout' to 'shell_timeout' in shell execution
- Remove support for legacy 'command' and 'timeout' parameter names
- Update test cases to verify new timeout behavior and parameter requirements
- Add comments explaining component size tracking implementation details
2026-07-14 16:31:41 +08:00

143 lines
5 KiB
Python

"""Report ReMe runtime memory usage."""
import sys
from collections.abc import Mapping
from types import ModuleType
import numpy as np
import psutil
from ..base_step import BaseStep
from ...components import BaseComponent, R
from ...enumeration import ComponentEnum
_SKIPPED_COMPONENT_ATTRIBUTES = {"app_context", "logger"}
_TRACKED_COMPONENT_TYPES = {
ComponentEnum.EMBEDDING_STORE,
ComponentEnum.FILE_GRAPH,
ComponentEnum.FILE_STORE,
ComponentEnum.KEYWORD_INDEX,
}
def _format_bytes(size: int) -> str:
"""Format a byte count using binary units."""
value = float(size)
units = ("B", "KiB", "MiB", "GiB", "TiB")
for unit in units[:-1]:
if abs(value) < 1024:
return f"{int(value)} B" if unit == "B" else f"{value:.2f} {unit}"
value /= 1024
return f"{value:.2f} {units[-1]}"
def _component_size(obj: object) -> int:
"""Estimate the memory owned by one component's Python object graph.
References to the application context, logger, and other components are
excluded so shared application state is not charged repeatedly.
"""
seen: set[int] = set()
root_id = id(obj)
def walk(value: object) -> int: # pylint: disable=too-many-return-statements
value_id = id(value)
if value_id in seen:
return 0
seen.add(value_id)
if isinstance(value, BaseComponent) and value_id != root_id:
return 0
if isinstance(value, (type, ModuleType)):
return 0
if isinstance(value, np.ndarray):
# ndarray.__sizeof__ normally includes its owned data buffer. A
# view may be smaller, so retain at least the visible buffer size.
return max(sys.getsizeof(value), int(value.nbytes))
size = sys.getsizeof(value)
if isinstance(
value,
(str, bytes, bytearray, memoryview, int, float, bool, complex, type(None)),
):
return size
if isinstance(value, Mapping):
return size + sum(walk(key) + walk(item) for key, item in value.items())
if isinstance(value, (list, tuple, set, frozenset)):
return size + sum(walk(item) for item in value)
if hasattr(value, "__dict__"):
attributes = vars(value)
if id(attributes) in seen:
return size
seen.add(id(attributes))
return (
size
+ sys.getsizeof(attributes)
+ sum(
walk(key) + walk(item)
for key, item in attributes.items()
if key not in _SKIPPED_COMPONENT_ATTRIBUTES
)
)
slots = getattr(value, "__slots__", ())
if isinstance(slots, str):
slots = (slots,)
return size + sum(walk(getattr(value, slot)) for slot in slots if hasattr(value, slot))
return walk(obj)
def _collect_memory(app_context) -> dict:
"""Collect per-component estimates, their sum, and process RSS."""
components: dict[str, dict[str, dict[str, int | str]]] = {}
total = 0
if app_context is not None:
for component_type in sorted(_TRACKED_COMPONENT_TYPES, key=lambda item: item.value):
group = {}
for name, component in sorted(app_context.components.get(component_type, {}).items()):
# _component_size keeps an independent seen set so each component
# remains understandable in isolation. Consequently, a non-component
# object shared by multiple components may be included more than once
# in components_total_bytes; this is an estimate, not unique RSS.
size = _component_size(component)
group[name] = {"bytes": size, "human": _format_bytes(size)}
total += size
if group:
components[component_type.value] = group
rss = psutil.Process().memory_info().rss
return {
"components": components,
"components_total_bytes": total,
"components_total": _format_bytes(total),
"process_rss_bytes": rss,
"process_rss": _format_bytes(rss),
}
def _format_status(memory: dict) -> str:
"""Build the human-readable CLI response."""
lines = ["Memory (estimated component object size)"]
for component_type, group in memory["components"].items():
for name, usage in group.items():
lines.append(f" {component_type}:{name} {usage['human']}")
lines.extend(
[
f" Components total {memory['components_total']}",
f" Process RSS {memory['process_rss']}",
],
)
return "\n".join(lines)
@R.register("status_step")
class StatusStep(BaseStep):
"""Report per-component memory estimates and process RSS."""
async def execute(self):
assert self.context is not None
memory = _collect_memory(self.app_context)
self.context.response.answer = _format_status(memory)
self.context.response.metadata["status"] = {"memory": memory}
return self.context.response