-
Notifications
You must be signed in to change notification settings - Fork 4
feat(runtime): in-memory output sinks for host-driven log/result delivery #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e1e607
f0939d9
3a67e85
ec4c706
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| from pathlib import Path | ||
| from typing import Any, Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, model_validator | ||
| from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator | ||
| from uipath.core.errors import UiPathFaultedTriggerError | ||
| from uipath.core.tracing import UiPathTraceManager | ||
|
|
||
|
|
@@ -19,6 +19,7 @@ | |
| UiPathRuntimeError, | ||
| ) | ||
| from uipath.runtime.logging._interceptor import UiPathRuntimeLogsInterceptor | ||
| from uipath.runtime.output_sinks import ResultSink, get_log_handler, get_result_sink | ||
| from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
@@ -120,9 +121,13 @@ class UiPathRuntimeContext(BaseModel): | |
| keep_state_file: bool = Field( | ||
| False, description="Prevents deletion of state file before running." | ||
| ) | ||
|
|
||
| model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") | ||
|
|
||
| # Snapshot of the result sink taken at __enter__ and reused at __exit__, so the same sink that was | ||
| # installed when the context started is the one that receives the result — even if the registry is | ||
| # changed in between or __exit__ runs on a different event loop. | ||
| _result_sink: ResultSink | None = PrivateAttr(default=None) | ||
|
|
||
| def _apply_execution_source(self) -> None: | ||
| """Derive execution_source from the command, if not already set. | ||
|
|
||
|
|
@@ -238,13 +243,17 @@ def __enter__(self): | |
| Returns: | ||
| The runtime context instance | ||
| """ | ||
| # Intercept all stdout/stderr/logs | ||
| # Write to file (runtime), stdout (debug) or log handler (if provided) | ||
| # Snapshot both caller-installed sinks now, at context start. The log handler is consumed here; | ||
| # the result sink is stashed for __exit__ so the two are read at the same moment (same context). | ||
| log_handler = get_log_handler() | ||
| self._result_sink = get_result_sink() | ||
|
|
||
| self.logs_interceptor = UiPathRuntimeLogsInterceptor( | ||
| min_level=self.logs_min_level, | ||
| dir=self.runtime_dir, | ||
| file=self.logs_file, | ||
| job_id=self.job_id, | ||
| log_handler=log_handler, | ||
| ) | ||
| self.logs_interceptor.setup() | ||
|
|
||
|
|
@@ -311,6 +320,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): | |
| with open(self.output_file, "w") as f: | ||
| json.dump(output_payload, f, default=str) | ||
|
|
||
| # Best-effort side channel: a sink failure must NOT reach the catch-all below, which would | ||
| # rewrite the already-good output.json as FAULTED. Reuse the __enter__ snapshot, not a fresh | ||
| # read, so the sink is the one that was installed when the context started. | ||
| result_sink = self._result_sink | ||
| if result_sink is not None and self.result.status in ( | ||
| UiPathRuntimeStatus.SUCCESSFUL, | ||
| UiPathRuntimeStatus.FAULTED, | ||
| ): | ||
| try: | ||
| self._deliver_result(result_sink, output_payload) | ||
| except Exception: | ||
| logger.exception("Failed to deliver result to sink") | ||
|
|
||
| # Don't suppress exceptions | ||
| return False | ||
|
|
||
|
|
@@ -355,6 +377,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): | |
| if hasattr(self, "logs_interceptor"): | ||
| self.logs_interceptor.teardown() | ||
|
|
||
| def _deliver_result(self, sink: ResultSink, output_payload: Any) -> None: | ||
| """Spill the output arguments to a file, then hand the result + that path to the sink.""" | ||
| args_path = self.resolved_output_arguments_file_path | ||
| # Avoid re-spilling if split_output_arguments already wrote this file. | ||
| if not (self.split_output_arguments and self.job_id): | ||
| os.makedirs(os.path.dirname(args_path), exist_ok=True) | ||
| with open(args_path, "w") as f: | ||
| json.dump(output_payload, f, default=str) | ||
|
Comment on lines
+386
to
+387
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: we can (and should) use the async file apis
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's true I've increased it a bit, but sync-IO in |
||
| sink(self.result, args_path) | ||
|
|
||
| @cached_property | ||
| def resolved_result_file_path(self) -> str: | ||
| """Get the full path to the result file.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| """Per-execution slots for a log handler and a result sink a caller can install. | ||
|
|
||
| Unset, the runtime writes its usual files; set, it routes a job's logs and result to them instead. | ||
| Values are contextvars, isolated across concurrent jobs in one process. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from contextvars import ContextVar | ||
| from typing import Any, Callable | ||
|
|
||
| # (result, output_arguments_file_path) -> None | ||
| ResultSink = Callable[[Any, str], None] | ||
|
|
||
| _log_handler: ContextVar[logging.Handler | None] = ContextVar( | ||
| "uipath_log_handler", default=None | ||
| ) | ||
| _result_sink: ContextVar[ResultSink | None] = ContextVar( | ||
| "uipath_result_sink", default=None | ||
| ) | ||
|
|
||
|
|
||
| def set_log_handler(handler: logging.Handler | None) -> None: | ||
| """Install the log handler (``None`` clears).""" | ||
| _log_handler.set(handler) | ||
|
|
||
|
|
||
| def get_log_handler() -> logging.Handler | None: | ||
| """The installed log handler, or None.""" | ||
| return _log_handler.get() | ||
|
|
||
|
|
||
| def set_result_sink(sink: ResultSink | None) -> None: | ||
| """Install the result sink (``None`` clears).""" | ||
| _result_sink.set(sink) | ||
|
|
||
|
|
||
| def get_result_sink() -> ResultSink | None: | ||
| """The installed result sink, or None.""" | ||
| return _result_sink.get() |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can read here the sink value and reuse it later on exit
we ensure the same values that were present when the context started are being used at the end (in case of any consumer that uses multiple even loops)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✅done