Skip to content

Commit ae02efd

Browse files
committed
Fix __main__ module resolution for RQ workers and improve logging
- Add automatic resolution of __main__ module paths to fully qualified names - Functions defined in scripts run as __main__ are now properly resolved - Update examples/with_rq.py to use logging with timestamps - Improve error messages for unresolvable module paths This fixes the issue where functions were referenced as __main__.add instead of the full path (e.g., examples.with_rq.add), making them importable by RQ workers.
1 parent 5789a50 commit ae02efd

4 files changed

Lines changed: 141 additions & 33 deletions

File tree

durable_monty/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
execute tasks in parallel, and resume when results are ready.
66
"""
77

8-
__version__ = "0.1.3"
8+
__version__ = "0.1.4"
99

1010
from durable_monty.models import init_db, Execution, Call, ExecutionStatus, CallStatus
1111
from durable_monty.service import OrchestratorService
12-
from durable_monty.functions import register_function, FUNCTION_REGISTRY
1312
from durable_monty.worker import Worker
1413
from durable_monty.executor import Executor, LocalExecutor
1514

@@ -24,6 +23,4 @@
2423
"Worker",
2524
"Executor",
2625
"LocalExecutor",
27-
"register_function",
28-
"FUNCTION_REGISTRY",
2926
]

durable_monty/service.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,81 @@
11
"""Orchestrator service that manages executions."""
22

33
import uuid
4-
from typing import Any
4+
import inspect
5+
import os
6+
import sys
7+
from typing import Any, Callable
8+
from pathlib import Path
59
import pydantic_monty
610
from sqlalchemy.orm import Session
711
from sqlalchemy import Engine
812

913
from durable_monty.models import Execution, Call, ExecutionStatus, CallStatus, to_json, from_json
1014

1115

16+
def _resolve_function_path(func: Callable) -> str:
17+
"""
18+
Resolve the full import path for a function, handling __main__ modules.
19+
20+
When a script is run directly (python script.py), functions defined in it
21+
have __module__ = "__main__". This converts it to the actual module path
22+
that can be imported by workers.
23+
24+
Args:
25+
func: Function object
26+
27+
Returns:
28+
Full import path like "examples.with_rq.add"
29+
30+
Raises:
31+
ValueError: If module path cannot be resolved
32+
"""
33+
module_name = func.__module__
34+
func_name = func.__name__
35+
36+
# If not __main__, use as-is
37+
if module_name != "__main__":
38+
return f"{module_name}.{func_name}"
39+
40+
# Try to resolve __main__ to actual module path
41+
try:
42+
# Get the file where the function is defined
43+
source_file = inspect.getfile(func)
44+
source_path = Path(source_file).resolve()
45+
46+
# Find which sys.path entry contains this file
47+
for path_entry in sys.path:
48+
try:
49+
path_entry = Path(path_entry).resolve()
50+
if source_path.is_relative_to(path_entry):
51+
# Convert file path to module path
52+
rel_path = source_path.relative_to(path_entry)
53+
54+
# Remove .py extension and convert to module notation
55+
module_parts = list(rel_path.parts[:-1]) + [rel_path.stem]
56+
resolved_module = ".".join(module_parts)
57+
58+
return f"{resolved_module}.{func_name}"
59+
except (ValueError, OSError):
60+
continue
61+
62+
# Could not resolve - provide helpful error
63+
raise ValueError(
64+
f"Cannot resolve module path for function '{func_name}' defined in __main__.\n"
65+
f"The function is in '{source_file}' which is not in any Python path.\n"
66+
f"Either:\n"
67+
f" 1. Import the function from its module instead of running the script directly\n"
68+
f" 2. Ensure the script's directory is in PYTHONPATH\n"
69+
f" 3. Pass the full import path as a string instead of the function object"
70+
)
71+
72+
except (TypeError, OSError) as e:
73+
raise ValueError(
74+
f"Cannot determine source file for function '{func_name}': {e}\n"
75+
f"Pass the full import path as a string instead of the function object."
76+
)
77+
78+
1279
class OrchestratorService:
1380
"""Service for managing durable executions."""
1481

@@ -18,18 +85,46 @@ def __init__(self, engine: Engine):
1885
def start_execution(
1986
self,
2087
code: str,
21-
external_functions: list[str],
88+
external_functions: list[str | Callable], # Accept strings or callable objects
2289
inputs: dict | None = None,
2390
) -> str:
24-
"""Schedule a new workflow execution. Returns execution_id."""
91+
"""
92+
Schedule a new workflow execution. Returns execution_id.
93+
94+
Args:
95+
code: Python code to execute
96+
external_functions: List of function names (as full paths or callable objects)
97+
inputs: Optional input variables for the code
98+
99+
Example:
100+
# Pass actual function objects (recommended)
101+
exec_id = service.start_execution(code, [add, multiply])
102+
103+
# Or pass full import paths as strings
104+
exec_id = service.start_execution(code, ["myapp.tasks.add"])
105+
"""
25106
execution_id = str(uuid.uuid4())
26107

108+
# Convert callable objects to {short_name: full_path} mapping
109+
function_mapping = {}
110+
for func in external_functions:
111+
if callable(func):
112+
# Extract full path and short name from function object
113+
# This handles __main__ module resolution
114+
full_path = _resolve_function_path(func)
115+
short_name = func.__name__
116+
function_mapping[short_name] = full_path
117+
else:
118+
# String path - extract short name
119+
short_name = func.rsplit(".", 1)[-1] if "." in func else func
120+
function_mapping[short_name] = func
121+
27122
with Session(self.engine) as session:
28-
# Just save to DB - worker will pick it up
123+
# Save mapping to DB - worker will use it
29124
execution = Execution(
30125
id=execution_id,
31126
code=code,
32-
external_functions=to_json(external_functions),
127+
external_functions=to_json(function_mapping),
33128
status=ExecutionStatus.SCHEDULED,
34129
inputs=to_json(inputs),
35130
)
@@ -53,14 +148,16 @@ def process_execution(
53148
if not execution:
54149
return
55150

151+
# Load function mapping {short_name: full_path} for converting names
152+
function_mapping = from_json(execution.external_functions)
153+
56154
# Get Monty progress based on execution status
57155
if execution.status == ExecutionStatus.SCHEDULED:
58156
# First time - start fresh
59-
external_functions = from_json(execution.external_functions)
60157
inputs = from_json(execution.inputs)
61158
m = pydantic_monty.Monty(
62159
execution.code,
63-
external_functions=external_functions,
160+
external_functions=list(function_mapping.keys()), # Pass short names to Monty
64161
inputs=list(inputs.keys()) if inputs else None,
65162
)
66163
progress = m.start(inputs=inputs) if inputs else m.start()
@@ -117,12 +214,16 @@ def process_execution(
117214
# Save all calls in this group
118215
for call_id in progress.pending_call_ids:
119216
call_info = pending_calls[call_id]
217+
# Convert short name to full path for execution
218+
short_name = call_info["function"]
219+
full_path = function_mapping.get(short_name, short_name)
120220
call = Call(
121221
execution_id=execution_id,
122222
resume_group_id=new_resume_group_id,
123223
call_id=call_id,
124-
function_name=call_info["function"],
224+
function_name=full_path, # Store full path for RQ workers
125225
args=to_json(call_info["args"]),
226+
kwargs=to_json(call_info["kwargs"]),
126227
status=CallStatus.PENDING,
127228
)
128229
session.add(call)
@@ -183,6 +284,7 @@ def poll(self, execution_id: str | None = None) -> dict[str, Any] | list[dict[st
183284
"call_id": c.call_id,
184285
"function_name": c.function_name,
185286
"args": from_json(c.args),
287+
"kwargs": from_json(c.kwargs),
186288
"status": c.status,
187289
}
188290
for c in calls
@@ -245,6 +347,7 @@ def get_pending_calls(self, execution_id: str) -> list[dict]:
245347
"call_id": c.call_id,
246348
"function_name": c.function_name,
247349
"args": from_json(c.args),
350+
"kwargs": from_json(c.kwargs),
248351
}
249352
for c in calls
250353
]

examples/with_rq.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,41 @@
44
1. Start Redis: redis-server
55
2. Start RQ worker: rq worker durable-monty
66
3. Run: uv sync --extra rq && uv run python examples/with_rq.py
7-
"""
87
8+
Note: Functions must be importable by RQ workers. We import them from the module
9+
(not __main__) so they have the correct __module__ attribute for RQ to find them.
10+
"""
11+
import logging
912
import time
1013
import threading
11-
from durable_monty import init_db, OrchestratorService, Worker, register_function
14+
from durable_monty import init_db, OrchestratorService, Worker
1215
from durable_monty.executors.rq import RQExecutor
1316

17+
# Configure logging with timestamps
18+
logging.basicConfig(
19+
level=logging.INFO,
20+
format='%(asctime)s - %(levelname)s - %(message)s',
21+
datefmt='%Y-%m-%d %H:%M:%S'
22+
)
23+
logger = logging.getLogger(__name__)
1424

15-
@register_function("add")
25+
26+
# Define functions that RQ workers can import
1627
def add(a, b):
28+
time.sleep(2)
1729
return a + b
1830

1931

20-
@register_function("multiply")
2132
def multiply(a, b):
33+
time.sleep(2)
2234
return a * b
2335

2436

2537
code = """
2638
from asyncio import gather
2739
results = await gather(add(1, 2), add(3, 4), multiply(5, 6))
40+
results += [await add(5, 7)]
41+
results += await gather(add(1, 2), add(3, 4), multiply(5, 6))
2842
sum(results)
2943
"""
3044

@@ -34,28 +48,22 @@ def multiply(a, b):
3448
try:
3549
executor = RQExecutor()
3650
except Exception as e:
37-
print(f"Error: {e}")
38-
print("Make sure Redis is running: redis-server")
51+
logger.error(f"Failed to connect to Redis: {e}")
52+
logger.error("Make sure Redis is running: redis-server")
3953
exit(1)
4054

41-
# Schedule execution
42-
exec_id = service.start_execution(code, ["add", "multiply"])
43-
print(f"Scheduled: {exec_id[:8]}...")
55+
# Schedule execution - pass function objects
56+
exec_id = service.start_execution(code, [add, multiply])
57+
logger.info(f"Scheduled execution: {exec_id[:8]}...")
4458

4559
# Run worker
46-
worker = Worker(service, executor)
47-
48-
def run_worker():
49-
for _ in range(20):
50-
worker.run(once=True)
51-
time.sleep(0.5)
60+
worker = Worker(service, executor, poll_interval=0.1)
61+
logger.info("Starting worker (will run until execution completes)...")
5262

53-
thread = threading.Thread(target=run_worker)
54-
thread.start()
55-
thread.join()
63+
worker.run(until_complete=True)
5664

5765
# Check result
5866
result = service.poll(exec_id)
59-
print(f"Status: {result['status']}, Output: {result['output']}")
67+
logger.info(f"Execution {result['status']}: output = {result['output']}")
6068
if result["status"] != "completed":
61-
print("Note: Start RQ workers with: rq worker durable-monty")
69+
logger.warning("Note: Start RQ workers with: rq worker durable-monty")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "durable-monty"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = "Durable functions using monty-python"
55
requires-python = ">=3.10"
66
dependencies = [

0 commit comments

Comments
 (0)