Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c881042
docs: update contributors list
github-actions[bot] Apr 5, 2026
945f53d
docs: update contributors list (zh)
github-actions[bot] Apr 5, 2026
4d6ee68
Merge branch 'wecode-ai:main' into main
FicoHu Apr 7, 2026
d2009d5
Merge branch 'wecode-ai:main' into main
FicoHu Apr 7, 2026
9fd481a
Merge branch 'wecode-ai:main' into main
FicoHu Apr 9, 2026
901eb6d
Merge branch 'wecode-ai:main' into main
FicoHu Apr 9, 2026
80c0962
docs: update contributors list
github-actions[bot] Apr 12, 2026
84baf74
docs: update contributors list (zh)
github-actions[bot] Apr 12, 2026
528b257
Merge branch 'wecode-ai:main' into main
FicoHu Apr 14, 2026
1c93564
Merge branch 'wecode-ai:main' into main
FicoHu Apr 14, 2026
1a105e1
Merge branch 'wecode-ai:main' into main
FicoHu Apr 14, 2026
bda9beb
Merge branch 'wecode-ai:main' into main
FicoHu Apr 15, 2026
2f14578
Merge branch 'wecode-ai:main' into main
FicoHu Apr 15, 2026
03d6bb8
Merge branch 'wecode-ai:main' into main
FicoHu Apr 15, 2026
0ce6adc
docs: update contributors list
github-actions[bot] Apr 19, 2026
bbed9ce
docs: update contributors list (zh)
github-actions[bot] Apr 19, 2026
055413d
Merge branch 'wecode-ai:main' into main
FicoHu Apr 20, 2026
5f5f056
Merge branch 'wecode-ai:main' into main
FicoHu Apr 21, 2026
026d5c2
Merge branch 'wecode-ai:main' into main
FicoHu Apr 22, 2026
450cb37
feat(backend): add executor cleanup cursor service for incremental cl…
Apr 22, 2026
f4f8162
revert(backend): revert jobs.py to original version
Apr 22, 2026
d4d798f
fix(backend): remove test_jobs_cleanup.py incompatible with reverted …
Apr 22, 2026
909982d
feat(backend): add distributed lock and cursor check to cleanup worker
Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/app/api/endpoints/adapter/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session

from app.api.dependencies import get_db, with_task_telemetry
from app.core import security
from app.core.config import settings
from app.db.session import get_async_db
from app.models.user import User
from app.schemas.remote_workspace import (
RemoteWorkspaceStatusResponse,
Expand Down Expand Up @@ -844,18 +846,18 @@ def cancel_preserve_executor(


@router.post("/{task_id}/cleanup-executor", response_model=dict)
def cleanup_task_executor(
async def cleanup_task_executor(
task_id: int = Depends(with_task_telemetry),
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_async_db),
):
"""
Clean up the executor for a finished task.

This endpoint reuses the same cleanup rules as the scheduled executor cleanup
job and only affects the specified task.
"""
return job_service.cleanup_task_executor(
return await job_service.cleanup_task_executor(
db=db,
task_id=task_id,
user_id=current_user.id,
Expand Down
13 changes: 9 additions & 4 deletions backend/app/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
#
# SPDX-License-Identifier: Apache-2.0

import asyncio
import logging
from typing import Any, Dict, List, Optional

Expand Down Expand Up @@ -134,14 +133,20 @@ def get_user_repositories_sync(
return self.get_sync(cache_key)

async def set(
self, key: str, value: Any, expire: int = settings.REPO_CACHE_EXPIRED_TIME
self,
key: str,
value: Any,
expire: int | None = settings.REPO_CACHE_EXPIRED_TIME,
) -> bool:
"""Set value to cache with expiration (seconds)"""
"""Set value to cache with optional expiration (seconds)"""
try:
client = await self._get_client()
try:
payload = orjson.dumps(value)
ok = await client.set(key, payload, ex=expire)
if expire is None:
ok = await client.set(key, payload)
else:
ok = await client.set(key, payload, ex=expire)
return bool(ok)
finally:
await client.aclose()
Expand Down
4 changes: 4 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ class Settings(BaseSettings):
# and updated_at exceeds this threshold, the executor task will be deleted automatically.
CHAT_TASK_EXECUTOR_DELETE_AFTER_HOURS: int = 2
CODE_TASK_EXECUTOR_DELETE_AFTER_HOURS: int = 24
STALE_NON_TERMINAL_TASK_EXECUTOR_DELETE_AFTER_HOURS: int = 24
TASK_EXECUTOR_CLEANUP_PRIMARY_SCAN_BATCH_SIZE: int = 2000
TASK_EXECUTOR_CLEANUP_LOOKBACK_HOURS: int = 96
TASK_EXECUTOR_CLEANUP_LOOKBACK_SCAN_LIMIT: int = 500
# Cleanup scanning interval seconds
TASK_EXECUTOR_CLEANUP_INTERVAL_SECONDS: int = 600

Expand Down
126 changes: 124 additions & 2 deletions backend/app/core/distributed_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@
distributed_lock.release("my_task")
"""

import asyncio
import logging
from contextlib import contextmanager
from contextlib import asynccontextmanager, contextmanager
from threading import Event, Thread
from typing import Generator, Optional
from typing import AsyncGenerator, Generator, Optional

import redis
from redis.asyncio import Redis as AsyncRedis

from app.core.config import settings

Expand All @@ -56,6 +58,7 @@ class DistributedLock:
def __init__(self):
"""Initialize the distributed lock with lazy Redis client loading."""
self._redis_client: Optional[redis.Redis] = None
self._async_redis_client: Optional[AsyncRedis] = None

@property
def redis_client(self) -> Optional[redis.Redis]:
Expand Down Expand Up @@ -252,6 +255,125 @@ def _watchdog() -> None:
if acquired:
self.release(lock_name)

@property
def async_redis_client(self) -> Optional[AsyncRedis]:
"""Lazy-load async Redis client from settings."""
if self._async_redis_client is None:
try:
redis_url = getattr(settings, "CELERY_BROKER_URL", None) or getattr(
settings, "REDIS_URL", None
)
if redis_url:
self._async_redis_client = AsyncRedis.from_url(
redis_url,
decode_responses=True,
socket_timeout=5,
socket_connect_timeout=5,
)
except Exception as e:
logger.warning(
f"[DistributedLock] Failed to create async Redis client: {e}"
)
self._async_redis_client = None
return self._async_redis_client
Comment on lines +258 to +278

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of the new async lock APIs to assess event-loop risk.
rg -nP -C2 '\bacquire_async\b|\brelease_async\b|\bextend_async\b|\bacquire_watchdog_context_async\b' backend
# How is jobs.py executor cleanup loop driven? (celery / asyncio.run / embedded?)
fd -t f 'jobs.py' backend/app/services | xargs -I{} rg -nP -C3 'EXECUTOR_CLEANUP_LOCK_KEY|asyncio\.run|AsyncSessionLocal' {}

Repository: wecode-ai/Wegent

Length of output: 5086


🏁 Script executed:

# Understand how jobs.py module is invoked - look for celery tasks or scheduler calls
rg -nP 'def.*cleanup.*executors|cleanup_stale_executors' backend/app/services/jobs.py -A 10

# Search for where this cleanup function is called from
rg -nP 'cleanup_stale_executors|run_scheduled_jobs' backend --type py -B 2

# Check if there's a celery task definition
rg -nP '@.*task|@celery' backend/app/services/jobs.py -A 3

Repository: wecode-ai/Wegent

Length of output: 9742


🏁 Script executed:

# Check the distributed_lock singleton implementation
cat -n backend/app/core/distributed_lock.py | head -100

# Look for where distributed_lock is instantiated
rg -nP 'distributed_lock\s*=' backend/app/core/distributed_lock.py

Repository: wecode-ai/Wegent

Length of output: 4165


🏁 Script executed:

# Search for celery configuration to understand execution model
fd -t f '*.py' backend/app | xargs rg -l 'Celery|celery\.Celery' | head -5

# Check if there's a scheduler or background task runner
rg -nP 'APScheduler|BackgroundScheduler|interval|cron' backend/app --type py | head -20

Repository: wecode-ai/Wegent

Length of output: 2901


🏁 Script executed:

# Get the full function containing the cleanup_stale_executors call
rg -nP 'async def.*\(' backend/app/services/jobs.py | head -20

Repository: wecode-ai/Wegent

Length of output: 454


🏁 Script executed:

# Find where jobs.py functions are invoked from - look for imports and calls
rg -nP 'from.*jobs import|import.*jobs' backend/app --type py

# Search for task decorators or scheduled job definitions
rg -nP '@.*task|@app\.on_event|@scheduler' backend/app --type py | head -30

Repository: wecode-ai/Wegent

Length of output: 2867


🏁 Script executed:

# Look at the actual context around the cleanup call to find the parent function
sed -n '100,130p' backend/app/services/jobs.py

Repository: wecode-ai/Wegent

Length of output: 1139


🏁 Script executed:

# Find the start_background_jobs function to understand how cleanup_worker is invoked
rg -nP 'def start_background_jobs|async def start_background_jobs' backend/app/services/jobs.py -A 20

Repository: wecode-ai/Wegent

Length of output: 832


🏁 Script executed:

# Check main.py to see how background jobs are started
rg -nP 'start_background_jobs' backend/app/main.py -B 3 -A 3

Repository: wecode-ai/Wegent

Length of output: 639


🏁 Script executed:

# Look for lifespan or on_event handlers that might control the event loop
rg -nP '@app\.(on_event|lifespan)|lifespan' backend/app/main.py -A 5

Repository: wecode-ai/Wegent

Length of output: 950


🏁 Script executed:

# Check if there are other usages of acquire_async, release_async, extend_async, or acquire_watchdog_context_async
rg -nP 'acquire_async|release_async|extend_async|acquire_watchdog_context_async' backend/app --type py | grep -v 'def ' | grep -v '.pyc'

Repository: wecode-ai/Wegent

Length of output: 489


🏁 Script executed:

# Look for any test setup that might create multiple event loops
rg -nP 'asyncio\.run|asyncio\.new_event_loop|loop\.run_until_complete|pytest.*asyncio' backend/tests --type py | head -20

Repository: wecode-ai/Wegent

Length of output: 1437


🏁 Script executed:

# Check the AsyncSessionLocal to understand if it creates or uses a specific event loop
rg -nP 'AsyncSessionLocal|create_async_engine|async_sessionmaker' backend/app/db/session.py -A 3

Repository: wecode-ai/Wegent

Length of output: 1389


🏁 Script executed:

# Look at test files that use the distributed_lock or async lock methods
cat backend/tests/services/test_jobs_cleanup.py

Repository: wecode-ai/Wegent

Length of output: 1549


🏁 Script executed:

# Check if there's any pytest fixture or setup/teardown for distributed_lock
rg -nP 'fixture|setUp|tearDown|conftest' backend/tests --type py | grep -i lock

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Look at the actual test file that patches the lock
head -60 backend/tests/services/test_jobs_cleanup.py

Repository: wecode-ai/Wegent

Length of output: 1549


🏁 Script executed:

# Check if there are any other places creating async tasks with redis
rg -nP 'asyncio\.create_task|asyncio\.ensure_future|asyncio\.run' backend/app --type py | grep -v test

Repository: wecode-ai/Wegent

Length of output: 6319


🏁 Script executed:

# Verify if distributed_lock is used from request handlers or other contexts
rg -nP 'acquire_watchdog_context_async|acquire_async|release_async|extend_async' backend/app/api --type py

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Check if the Redis client is ever recreated or reset anywhere
rg -nP '_async_redis_client\s*=' backend/app/core/distributed_lock.py

Repository: wecode-ai/Wegent

Length of output: 183


🏁 Script executed:

# Final verification: Check if there are any plans to call async lock methods from other contexts
rg -nP 'acquire_async|release_async|extend_async' backend/app --type py | grep -v 'def ' | grep -v distributed_lock.py

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Verify the ping() check on sync client vs lack on async
sed -n '64,83p' backend/app/core/distributed_lock.py

Repository: wecode-ai/Wegent

Length of output: 934


Async Redis client lacks ping verification and has cross-event-loop binding vulnerability.

The _async_redis_client is cached on the module-level singleton, but unlike the sync redis_client property (which validates connectivity via ping()), the async property never verifies that Redis is reachable. If Redis is down at startup, every call to acquire_async / extend_async will silently hang for the full socket_connect_timeout (5 seconds) instead of failing open.

Additionally, redis.asyncio.Redis instances bind their internal connection pool to whatever event loop first issues a command. While current usage is safe—cleanup_worker runs continuously in the single uvicorn event loop—the pattern is fragile. If async lock methods are called from different event loop contexts in the future (e.g., through asyncio.run() or a separate scheduler thread), operations will fail with RuntimeError: Task ... attached to a different loop.

Recommended fixes:

  1. Add await client.ping() in the async property initialization (mirroring the sync version) to fail fast if Redis is unreachable.
  2. If multi-loop usage emerges, consider per-call client creation (matching app/core/cache.py pattern) or cache-per-loop using id(asyncio.get_running_loop()) as key.
🧰 Tools
🪛 Ruff (0.15.10)

[warning] 273-273: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/core/distributed_lock.py` around lines 258 - 278, The async Redis
client property async_redis_client currently caches an AsyncRedis instance via
AsyncRedis.from_url without verifying connectivity or guarding against
event-loop binding; update the async_redis_client getter to create the client,
perform await client.ping() to fail fast if Redis is unreachable (mirroring the
sync redis_client behavior), and then only cache the client if ping succeeds;
additionally, to avoid cross-event-loop RuntimeError, either stop global caching
and create a fresh AsyncRedis via AsyncRedis.from_url per call in
acquire_async/extend_async (matching the per-call pattern in app/core/cache.py)
or implement a cache keyed by the current loop id (use
id(asyncio.get_running_loop())) so the stored client is tied to the calling
event loop.


async def acquire_async(self, lock_name: str, expire_seconds: int = 60) -> bool:
"""Acquire a distributed lock asynchronously."""
if self.async_redis_client is None:
logger.debug(
f"[DistributedLock] Async Redis not available, allowing {lock_name}"
)
return True

lock_key = f"{LOCK_KEY_PREFIX}{lock_name}"
try:
result = await self.async_redis_client.set(
lock_key, "1", nx=True, ex=expire_seconds
)
if result:
logger.debug(f"[DistributedLock] Acquired async lock: {lock_name}")
else:
logger.debug(
f"[DistributedLock] Async lock {lock_name} already held by another instance"
)
return result is True
except Exception as e:
logger.error(
f"[DistributedLock] Failed to acquire async lock {lock_name}: {e}"
)
return True

async def release_async(self, lock_name: str) -> bool:
"""Release a distributed lock asynchronously."""
if self.async_redis_client is None:
return True

lock_key = f"{LOCK_KEY_PREFIX}{lock_name}"
try:
await self.async_redis_client.delete(lock_key)
logger.debug(f"[DistributedLock] Released async lock: {lock_name}")
return True
except Exception as e:
logger.error(
f"[DistributedLock] Failed to release async lock {lock_name}: {e}"
)
return False

async def extend_async(self, lock_name: str, expire_seconds: int = 60) -> bool:
"""Extend the expiration time of a held lock asynchronously."""
if self.async_redis_client is None:
return True

lock_key = f"{LOCK_KEY_PREFIX}{lock_name}"
try:
result = await self.async_redis_client.expire(lock_key, expire_seconds)
return result is True
except Exception as e:
logger.error(
f"[DistributedLock] Failed to extend async lock {lock_name}: {e}"
)
return False

@asynccontextmanager
async def acquire_watchdog_context_async(
self,
lock_name: str,
expire_seconds: int = 60,
extend_interval_seconds: int = 30,
) -> AsyncGenerator[bool, None]:
"""Acquire a lock and keep extending it with an async watchdog task."""
acquired = await self.acquire_async(lock_name, expire_seconds)
watchdog_task: Optional[asyncio.Task] = None

if (
acquired
and self.async_redis_client is not None
and extend_interval_seconds > 0
and extend_interval_seconds < expire_seconds
):

async def _watchdog() -> None:
try:
while True:
await asyncio.sleep(extend_interval_seconds)
await self.extend_async(lock_name, expire_seconds)
except asyncio.CancelledError:
pass

watchdog_task = asyncio.create_task(_watchdog())

try:
yield acquired
finally:
if watchdog_task is not None:
watchdog_task.cancel()
try:
await watchdog_task
except asyncio.CancelledError:
pass
if acquired:
await self.release_async(lock_name)


# Singleton instance
distributed_lock = DistributedLock()
77 changes: 75 additions & 2 deletions backend/app/db/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
#
# SPDX-License-Identifier: Apache-2.0

from contextlib import contextmanager
from typing import Generator
from contextlib import asynccontextmanager, contextmanager
from typing import AsyncGenerator, Generator, Optional

from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
Comment on lines 8 to +9

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n backend/app/db/session.py | head -80

Repository: wecode-ai/Wegent

Length of output: 3408


🏁 Script executed:

# Check SQLAlchemy version in requirements
fd -e txt -e in | xargs grep -l -i sqlalchemy 2>/dev/null | head -5

Repository: wecode-ai/Wegent

Length of output: 85


🏁 Script executed:

# Check requirements files for SQLAlchemy version
rg -i "sqlalchemy" --type txt -A 1 -B 1

Repository: wecode-ai/Wegent

Length of output: 182


🏁 Script executed:

# Check if there are any other requirements files with SQLAlchemy
find . -name "requirements*.txt" -o -name "pyproject.toml" -o -name "setup.py" | xargs grep -l -i sqlalchemy 2>/dev/null

Repository: wecode-ai/Wegent

Length of output: 224


🏁 Script executed:

# Check SQLAlchemy version in backend pyproject.toml
cat backend/pyproject.toml | grep -A 2 -B 2 sqlalchemy

Repository: wecode-ai/Wegent

Length of output: 450


🏁 Script executed:

# Check if asyncmy and aiosqlite are listed as dependencies
rg "asyncmy|aiosqlite" backend/pyproject.toml

Repository: wecode-ai/Wegent

Length of output: 81


🏁 Script executed:

# Check if aiosqlite is a dependency anywhere
rg "aiosqlite" . --type toml --type txt

Repository: wecode-ai/Wegent

Length of output: 112


🌐 Web query:

SQLAlchemy 2.0 make_url set drivername render_as_string

💡 Result:

In SQLAlchemy 2.0, use make_url to create a URL object from a string, URL.create for programmatic construction, URL.set to modify an existing URL object (including setting drivername), and URL.render_as_string to get the string representation. Example workflow: from sqlalchemy.engine import make_url, URL # From string url = make_url("postgresql://user:pass@localhost/db") # Create new url2 = URL.create(drivername="mysql+pymysql", host="example.com", database="mydb") # Set drivername on existing url3 = url.set(drivername="postgresql+psycopg2") # Render as string url_str = url3.render_as_string(hide_password=False) print(url_str) # postgresql+psycopg2://user:pass@localhost/db URL objects are immutable; set returns a new object with modifications. drivername is the dialect+driver part (e.g., "postgresql+psycopg2").

Citations:


🏁 Script executed:

# Check where _get_async_database_url is used
rg "_get_async_database_url" backend/

Repository: wecode-ai/Wegent

Length of output: 191


Use SQLAlchemy URL parsing for async driver conversion.

The fallback on line 74 creates invalid dialect strings. For example, a URL like mysql+mysqldb://... becomes mysql+asyncmy+mysqldb://..., which SQLAlchemy cannot parse. Use make_url() to properly extract and set the driver name.

Safer conversion
 from sqlalchemy import create_engine
+from sqlalchemy.engine import make_url
 from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
@@
 def _get_async_database_url() -> str:
     """Convert sync DATABASE_URL to async driver URL."""
-    url = SQLALCHEMY_DATABASE_URL
-    if "pymysql" in url:
-        return url.replace("pymysql", "asyncmy")
-    elif url.startswith("mysql://"):
-        return url.replace("mysql://", "mysql+asyncmy://")
-    elif "asyncmy" in url:
-        return url
-    elif url.startswith("sqlite"):
-        return url.replace("sqlite://", "sqlite+aiosqlite://")
-    return url.replace("mysql+", "mysql+asyncmy+", 1)
+    url = make_url(SQLALCHEMY_DATABASE_URL)
+    if url.drivername.startswith("mysql"):
+        url = url.set(drivername="mysql+asyncmy")
+    elif url.drivername.startswith("sqlite"):
+        url = url.set(drivername="sqlite+aiosqlite")
+    return url.render_as_string(hide_password=False)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/db/session.py` around lines 8 - 9, The URL-to-async conversion
currently concatenates strings and produces invalid dialects (e.g.,
mysql+mysqldb -> mysql+asyncmy+mysqldb); instead parse the DB URL with
sqlalchemy.engine.make_url (or make_url) and rebuild the drivername properly:
extract the dialect and existing driver from the parsed URL, replace the sync
driver with the intended async driver (keeping the dialect prefix), assign the
new drivername back to the parsed URL, and pass that URL to
create_async_engine/AsyncEngine/AsyncSession creation code (the code around
AsyncEngine/create_async_engine in session.py). Update the conversion logic to
use make_url so SQLAlchemy receives a valid URL rather than a simple string
concat.

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker

Expand Down Expand Up @@ -54,6 +55,78 @@ def _create_engine():
# Sync session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# Async database support (lazily initialized)
_async_engine: Optional[AsyncEngine] = None
_AsyncSessionLocal: Optional[sessionmaker] = None


def _get_async_database_url() -> str:
"""Convert sync DATABASE_URL to async driver URL."""
url = SQLALCHEMY_DATABASE_URL
if "pymysql" in url:
return url.replace("pymysql", "asyncmy")
elif url.startswith("mysql://"):
return url.replace("mysql://", "mysql+asyncmy://")
elif "asyncmy" in url:
return url
elif url.startswith("sqlite"):
return url.replace("sqlite://", "sqlite+aiosqlite://")
return url.replace("mysql+", "mysql+asyncmy+", 1)


def _create_async_engine() -> AsyncEngine:
"""Create async database engine."""
async_url = _get_async_database_url()
if async_url.startswith("sqlite"):
return create_async_engine(async_url, pool_pre_ping=True)
return create_async_engine(
async_url,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=3600,
)


def get_async_engine() -> AsyncEngine:
"""Get the async database engine, initializing if needed."""
global _async_engine
if _async_engine is None:
_async_engine = _create_async_engine()
return _async_engine


def get_async_session_factory() -> sessionmaker:
"""Get the async session factory, initializing if needed."""
global _AsyncSessionLocal
if _AsyncSessionLocal is None:
_AsyncSessionLocal = sessionmaker(
bind=get_async_engine(),
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
return _AsyncSessionLocal


# Convenience alias for direct use
def AsyncSessionLocal() -> AsyncSession:
"""Create a new async session."""
factory = get_async_session_factory()
return factory()


async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
"""Async database session dependency for FastAPI."""
session = AsyncSessionLocal()
try:
yield session
finally:
await session.close()


# Import Base from shared package for consistency
# All models should use the same Base for proper relationship resolution
from app.db.base import Base
Expand Down
Loading
Loading