Skip to content

fix(eval): cache custom-coded evaluator modules instead of leaking sys.modules#1821

Open
adrian-badea wants to merge 1 commit into
mainfrom
fix/custom-evaluator-module-leak
Open

fix(eval): cache custom-coded evaluator modules instead of leaking sys.modules#1821
adrian-badea wants to merge 1 commit into
mainfrom
fix/custom-evaluator-module-leak

Conversation

@adrian-badea

Copy link
Copy Markdown
Collaborator

Problem

EvaluatorFactory._create_custom_coded_evaluator_internal names the dynamically loaded evaluator module _custom_evaluator_<stem>_<id(data)>. Because data is a fresh dict on every call, id(data) is unique each time, so the sys.modules cache never hits: the module is re-exec()'d on every call and a new module object (plus all the classes / pydantic schemas it defines) is inserted into sys.modules and never removed.

Any consumer that builds custom-coded evaluators repeatedly leaks memory and CPU without bound. Building evaluators per datapoint turns an eval run into O(n²) with unbounded sys.modules growth — observed downstream as one core pinned for 2h+ at ~9 GB RSS on a ~47K-datapoint run, before any inference ran.

Fix

Key the module name on a sha256 of the resolved file path so sys.modules acts as a real load-once cache: the module is loaded once and reused. The load is guarded with module = sys.modules.get(name); if module is None:, and the entry is popped if exec_module fails so a broken load isn't left cached (and can be retried after the file is fixed).

Per-config validation (TypeAdapter(cls).validate_python(data)) is unchanged, so distinct configs still yield distinct, correctly-configured evaluator instances despite sharing one class object.

Tests

New test_evaluator_factory.py::TestCustomCodedEvaluatorModuleLoading:

  • repeated creation reuses one module — same class object, a single sys.modules entry (this assertion fails on the pre-fix code);
  • a module that raises on import is not left cached and can be retried after the file is fixed (covers the exec-failure cleanup);
  • two files sharing a basename load as distinct modules (covers the path-hash disambiguation).

ruff, mypy, and the full tests/evaluators suite (500 passed) pass locally.

🤖 Generated with Claude Code

…s.modules

_create_custom_coded_evaluator_internal named the dynamically loaded module
`_custom_evaluator_<stem>_<id(data)>`. Because `data` is a fresh dict on every
call, id(data) was unique each time, so the sys.modules cache never hit: the
evaluator module was re-exec()'d on every call and a new module object (plus the
classes/pydantic schemas it defines) was inserted into sys.modules and never
removed. Any consumer building custom-coded evaluators repeatedly leaks memory
and CPU without bound — building evaluators per datapoint turns an eval run into
O(n^2) with multi-GB RAM growth.

Key the module name on a hash of the resolved file path so sys.modules caches
it: the module loads once and is reused. Pop the entry if exec fails so a broken
load is not left cached (and can be retried). Add regression tests: repeated
creation reuses one module (no leak), a failed load is not cached, and two files
sharing a basename load as distinct modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 17, 2026 14:21
@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Jul 17, 2026
@adrian-badea
adrian-badea requested a review from radu-mocanu July 17, 2026 14:21

Copilot AI 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.

Pull request overview

This PR fixes unbounded sys.modules growth and repeated exec() of custom-coded evaluator modules by switching to a stable, path-hash-based module name so dynamically loaded evaluator files are cached and reused across EvaluatorFactory.create_evaluator(...) calls.

Changes:

  • Cache custom-coded evaluator modules in sys.modules using a module name derived from the evaluator file’s resolved path (sha256-based), avoiding per-call module re-execution and memory leaks.
  • Ensure failed module loads don’t leave a half-initialized module cached by popping the entry on exec_module failure.
  • Add regression tests covering caching behavior, failure cleanup, and disambiguation for same-basename files in different directories.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/uipath/src/uipath/eval/evaluators/evaluator_factory.py Changes custom evaluator module naming/loading to be path-hash keyed and cached, with cleanup on import failure.
packages/uipath/tests/evaluators/test_evaluator_factory.py Adds tests verifying module caching, retry-after-failure behavior, and no collisions for same stem in different directories.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +164 to +166
spec = importlib.util.spec_from_file_location(module_name, resolved_path)
if spec is None or spec.loader is None:
raise ValueError(f"Could not load module from {file_path}")
Comment on lines +175 to +177
raise ValueError(
f"Error executing module from {file_path}: {str(e)}"
) from e
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

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

Labels

test:uipath-integrations test:uipath-langchain Triggers tests in the uipath-langchain-python repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants