Skip to content

Commit 90548d5

Browse files
committed
feat: implement LazyOrmProxy for deferred ORM model loading and enhance Tortoise ORM integration
1 parent 1820528 commit 90548d5

File tree

9 files changed

+750
-20
lines changed

9 files changed

+750
-20
lines changed

src/asynctasq/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ async def async_cleanup():
160160
def init(
161161
config_overrides: ConfigOverrides | None = None,
162162
event_emitters: list[EventEmitter] | None = None,
163+
tortoise_config: dict | None = None,
163164
) -> None:
164165
"""Initialize AsyncTasQ with configuration and event emitters.
165166
@@ -177,6 +178,16 @@ def init(
177178
AsyncTasQ behavior (driver settings, timeouts, etc.)
178179
event_emitters: Optional list of additional event emitters to register
179180
for monitoring and logging task/worker events
181+
tortoise_config: Optional Tortoise ORM configuration dictionary.
182+
When provided, Tortoise will be automatically initialized when
183+
lazy ORM proxies are resolved in the worker. This allows tasks
184+
to use ORM models without manual initialization.
185+
186+
Example:
187+
tortoise_config={
188+
"db_url": "postgres://user:pass@localhost/db",
189+
"modules": {"models": ["myapp.models"]}
190+
}
180191
181192
Note:
182193
AsyncTasQ now works seamlessly with any event loop:
@@ -216,6 +227,12 @@ def init(
216227
# Ensure config is initialized even without overrides
217228
Config.get()
218229

230+
# Store Tortoise config if provided (separate from config_overrides)
231+
if tortoise_config is not None:
232+
config = Config.get()
233+
config.tortoise_orm = tortoise_config
234+
logger.debug("Registered Tortoise ORM configuration for automatic initialization")
235+
219236
# Initialize default event emitters based on config
220237
EventRegistry.init()
221238

src/asynctasq/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class ConfigOverrides(TypedDict, total=False):
1919
process_pool: ProcessPoolConfig
2020
repository: RepositoryConfig
2121
sqlalchemy_engine: Any
22+
tortoise_orm: dict[str, Any]
2223

2324

2425
@dataclass
@@ -216,6 +217,9 @@ class Config:
216217
# SQLAlchemy engine for ORM cleanup
217218
sqlalchemy_engine: Any = None
218219

220+
# Tortoise ORM configuration for automatic initialization
221+
tortoise_orm: dict[str, Any] | None = None
222+
219223
def __post_init__(self):
220224
"""Initialize nested config objects with defaults if not provided."""
221225
if self.redis is None:

src/asynctasq/serializers/hooks/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,10 @@ def create_default_registry() -> HookRegistry:
279279
"""Create a registry with all built-in type hooks.
280280
281281
Returns:
282-
HookRegistry with datetime, date, Decimal, UUID, and set hooks
282+
HookRegistry with datetime, date, Decimal, UUID, set, and LazyOrmProxy hooks
283283
"""
284284
from .builtin import DateHook, DatetimeHook, DecimalHook, SetHook, UUIDHook
285+
from .orm.lazy_proxy_hook import LazyOrmProxyHook
285286

286287
registry = HookRegistry()
287288

@@ -291,6 +292,7 @@ def create_default_registry() -> HookRegistry:
291292
registry.register(DecimalHook())
292293
registry.register(UUIDHook())
293294
registry.register(SetHook())
295+
registry.register(LazyOrmProxyHook())
294296

295297
return registry
296298

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Lazy loading proxy for ORM models.
2+
3+
Enables deferred loading of ORM models when the ORM is not initialized
4+
during task deserialization. The actual model is fetched when accessed.
5+
6+
This allows users to pass ORM model instances as task parameters without
7+
requiring ORM initialization before worker startup. Models are automatically
8+
fetched when tasks execute.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
from typing import Any
15+
16+
17+
class LazyOrmProxy:
18+
"""Proxy that defers ORM model loading until first access.
19+
20+
This allows task parameters to reference ORM models even when the ORM
21+
is not initialized during deserialization. The actual model is fetched
22+
when the task accesses the parameter.
23+
24+
Attributes:
25+
_model_class: The ORM model class to fetch
26+
_pk: The primary key value
27+
_fetch_callback: Async callback to fetch the model
28+
_resolved: Cached resolved model instance
29+
"""
30+
31+
__slots__ = ("_model_class", "_pk", "_fetch_callback", "_resolved")
32+
33+
def __init__(
34+
self,
35+
model_class: type,
36+
pk: Any,
37+
fetch_callback: Any, # Callable awaitable
38+
) -> None:
39+
"""Initialize lazy proxy.
40+
41+
Args:
42+
model_class: The ORM model class
43+
pk: The primary key value
44+
fetch_callback: Async callable that fetches the model
45+
"""
46+
object.__setattr__(self, "_model_class", model_class)
47+
object.__setattr__(self, "_pk", pk)
48+
object.__setattr__(self, "_fetch_callback", fetch_callback)
49+
object.__setattr__(self, "_resolved", None)
50+
51+
async def _resolve(self) -> Any:
52+
"""Resolve the proxy by fetching the actual model.
53+
54+
Returns:
55+
The resolved ORM model instance
56+
57+
Raises:
58+
RuntimeError: If ORM is still not initialized
59+
"""
60+
resolved = object.__getattribute__(self, "_resolved")
61+
if resolved is not None:
62+
return resolved
63+
64+
# Check if we need to initialize Tortoise ORM first
65+
try:
66+
from asynctasq.config import Config
67+
68+
config = Config.get()
69+
if config.tortoise_orm is not None:
70+
# Auto-initialize Tortoise if config is available
71+
try:
72+
from tortoise import Tortoise
73+
74+
if not Tortoise._inited:
75+
await Tortoise.init(**config.tortoise_orm)
76+
# Generate schemas to ensure tables exist
77+
await Tortoise.generate_schemas(safe=True)
78+
except ImportError:
79+
pass # Tortoise not installed, fetch_callback will handle error
80+
except Exception as e:
81+
# Log the error but don't crash - let fetch_callback handle it
82+
import logging
83+
84+
logger = logging.getLogger(__name__)
85+
logger.warning(
86+
f"Failed to auto-initialize Tortoise ORM: {e}\n"
87+
f"Make sure the modules specified in tortoise_config are importable.\n"
88+
f"If running a script directly, you may need to set PYTHONPATH or use proper module paths."
89+
)
90+
except Exception as e:
91+
# Log config access errors
92+
import logging
93+
94+
logger = logging.getLogger(__name__)
95+
logger.warning(f"Failed to access Tortoise config: {e}")
96+
97+
fetch_callback = object.__getattribute__(self, "_fetch_callback")
98+
model_class = object.__getattribute__(self, "_model_class")
99+
pk = object.__getattribute__(self, "_pk")
100+
101+
resolved = await fetch_callback(model_class, pk)
102+
object.__setattr__(self, "_resolved", resolved)
103+
return resolved
104+
105+
def __getattribute__(self, name: str) -> Any:
106+
"""Intercept attribute access to trigger lazy loading.
107+
108+
This is synchronous and will raise an error if the model hasn't
109+
been resolved yet. The proxy will auto-resolve when awaited.
110+
"""
111+
# Allow access to special methods and private attributes
112+
if name.startswith("_") or name in (
113+
"__class__",
114+
"__dict__",
115+
"__slots__",
116+
"await_resolve",
117+
):
118+
return object.__getattribute__(self, name)
119+
120+
# Check if already resolved
121+
resolved = object.__getattribute__(self, "_resolved")
122+
if resolved is not None:
123+
return getattr(resolved, name)
124+
125+
# Not resolved - provide helpful error message
126+
model_class = object.__getattribute__(self, "_model_class")
127+
raise RuntimeError(
128+
f"LazyOrmProxy for {model_class.__name__} has not been resolved yet.\n\n"
129+
f"The ORM model is lazy-loaded because the ORM was not initialized during deserialization.\n"
130+
f"To fix this, pass your Tortoise configuration to asynctasq.init():\n\n"
131+
f" from asynctasq import init\n\n"
132+
f" init(\n"
133+
f" {{'driver': 'redis', 'redis': RedisConfig(url='redis://localhost')}},\n"
134+
f" tortoise_config={{\n"
135+
f" 'db_url': 'postgres://user:pass@localhost/db',\n"
136+
f" 'modules': {{'models': ['myapp.models']}}\n"
137+
f" }}\n"
138+
f" )\n\n"
139+
f"This will auto-initialize Tortoise when the worker deserializes tasks.\n"
140+
)
141+
142+
def __setattr__(self, name: str, value: Any) -> None:
143+
"""Set attribute on resolved model."""
144+
resolved = object.__getattribute__(self, "_resolved")
145+
if resolved is not None:
146+
setattr(resolved, name, value)
147+
else:
148+
model_class = object.__getattribute__(self, "_model_class")
149+
raise RuntimeError(
150+
f"Cannot set attribute on unresolved LazyOrmProxy for {model_class.__name__}"
151+
)
152+
153+
async def await_resolve(self) -> Any:
154+
"""Public method to explicitly resolve the proxy.
155+
156+
Returns:
157+
The resolved ORM model instance
158+
"""
159+
return await self._resolve()
160+
161+
def __await__(self):
162+
"""Make the proxy awaitable.
163+
164+
This allows users to resolve proxies by awaiting them:
165+
product = await product # Resolves the proxy
166+
167+
Returns:
168+
Generator that resolves to the ORM model instance
169+
"""
170+
return self._resolve().__await__()
171+
172+
def __repr__(self) -> str:
173+
"""Return string representation."""
174+
model_class = object.__getattribute__(self, "_model_class")
175+
pk = object.__getattribute__(self, "_pk")
176+
resolved = object.__getattribute__(self, "_resolved")
177+
status = "resolved" if resolved is not None else "unresolved"
178+
return f"<LazyOrmProxy({model_class.__name__}, pk={pk}, {status})>"
179+
180+
181+
def is_lazy_proxy(obj: Any) -> bool:
182+
"""Check if an object is a LazyOrmProxy.
183+
184+
Args:
185+
obj: Object to check
186+
187+
Returns:
188+
True if obj is a LazyOrmProxy
189+
"""
190+
return isinstance(obj, LazyOrmProxy)
191+
192+
193+
async def resolve_lazy_proxies(obj: Any) -> Any:
194+
"""Recursively resolve all LazyOrmProxy instances in a data structure.
195+
196+
Resolves proxies in parallel when possible for better performance.
197+
198+
Args:
199+
obj: Object that may contain lazy proxies (dict, list, tuple, or single value)
200+
201+
Returns:
202+
The same structure with all lazy proxies resolved
203+
"""
204+
if is_lazy_proxy(obj):
205+
return await obj.await_resolve()
206+
207+
if isinstance(obj, dict):
208+
# Resolve all values in parallel
209+
keys = list(obj.keys())
210+
values = await asyncio.gather(*[resolve_lazy_proxies(v) for v in obj.values()])
211+
return dict(zip(keys, values, strict=False))
212+
213+
if isinstance(obj, (list, tuple)):
214+
# Resolve all items in parallel
215+
resolved_items = await asyncio.gather(*[resolve_lazy_proxies(item) for item in obj])
216+
return type(obj)(resolved_items)
217+
218+
return obj

0 commit comments

Comments
 (0)