Skip to content

Commit d21e7de

Browse files
tarjan1XiaJunjie2020
authored andcommitted
fix: avoid duplicate startup embedding jobs
1 parent 815435e commit d21e7de

2 files changed

Lines changed: 154 additions & 4 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Distributed-lock abstraction for process-wide singleton tasks.
2+
3+
The current provider uses PostgreSQL advisory locks. Business callers use
4+
``SingleWorkerGuard``, which delegates lock operations to ``DistributedLock``.
5+
"""
6+
7+
import hashlib
8+
from collections.abc import Callable
9+
from enum import Enum, auto
10+
from functools import wraps
11+
from threading import Lock
12+
from typing import ClassVar, Self
13+
14+
from sqlalchemy import func, select
15+
from sqlalchemy.engine import Connection
16+
from sqlalchemy.exc import SQLAlchemyError
17+
18+
from common.core.db import engine
19+
from common.utils.utils import SQLBotLogUtil
20+
21+
22+
class LockStatus(Enum):
23+
"""Process-local state of the single-worker lock acquisition attempt."""
24+
25+
NOT_ATTEMPTED = auto()
26+
ACQUIRED = auto()
27+
NOT_ACQUIRED = auto()
28+
29+
30+
class DistributedLock:
31+
"""A lock held by this process until ``release`` or process shutdown."""
32+
33+
def __init__(
34+
self,
35+
advisory_key: int,
36+
connection: Connection,
37+
) -> None:
38+
self._connection = connection
39+
self._advisory_key = advisory_key
40+
41+
@staticmethod
42+
def _postgres_advisory_key(key: str) -> int:
43+
"""Convert a readable lock key into PostgreSQL's signed 64-bit key."""
44+
digest = hashlib.sha256(key.encode("utf-8")).digest()
45+
return int.from_bytes(digest[:8], byteorder="big", signed=True)
46+
47+
@classmethod
48+
def try_acquire(cls, key: str) -> Self | None:
49+
"""Try to acquire a named distributed lock without waiting."""
50+
advisory_key = cls._postgres_advisory_key(key)
51+
connection: Connection | None = None
52+
try:
53+
connection = engine.connect()
54+
acquired = connection.execute(
55+
select(func.pg_try_advisory_lock(advisory_key))
56+
).scalar_one()
57+
# End the implicit transaction; the session-level lock remains held.
58+
connection.commit()
59+
except SQLAlchemyError:
60+
SQLBotLogUtil.exception("Failed to acquire distributed lock: %s", key)
61+
if connection is not None:
62+
connection.close()
63+
return None
64+
65+
if not acquired:
66+
connection.close()
67+
return None
68+
69+
return cls(advisory_key, connection)
70+
71+
def release(self) -> None:
72+
"""Release the lock and return its dedicated connection to the pool."""
73+
if self._connection.closed:
74+
return
75+
76+
try:
77+
self._connection.execute(
78+
select(func.pg_advisory_unlock(self._advisory_key))
79+
)
80+
self._connection.commit()
81+
finally:
82+
self._connection.close()
83+
84+
85+
class SingleWorkerGuard:
86+
"""Run startup tasks in only one worker process."""
87+
88+
_LOCK_KEY = "sqlbot:startup:embedding"
89+
_lock_status: ClassVar[LockStatus] = LockStatus.NOT_ATTEMPTED
90+
# Keep the acquired lock and its dedicated connection alive.
91+
_acquired_lock: ClassVar[DistributedLock | None] = None
92+
_state_mutex = Lock()
93+
94+
@classmethod
95+
def _has_acquired_lock(cls) -> bool:
96+
with cls._state_mutex:
97+
if cls._lock_status is LockStatus.NOT_ATTEMPTED:
98+
lock = DistributedLock.try_acquire(cls._LOCK_KEY)
99+
if lock is None:
100+
cls._lock_status = LockStatus.NOT_ACQUIRED
101+
SQLBotLogUtil.info(
102+
"Current process FAILED to acquire the single-worker lock"
103+
)
104+
else:
105+
cls._acquired_lock = lock
106+
cls._lock_status = LockStatus.ACQUIRED
107+
SQLBotLogUtil.info(
108+
"Current process acquired the single-worker lock"
109+
)
110+
return cls._lock_status is LockStatus.ACQUIRED
111+
112+
@classmethod
113+
def once(cls, func: Callable[[], None]) -> Callable[[], None]:
114+
"""Run the decorated startup task in only one worker."""
115+
116+
@wraps(func)
117+
def wrapper() -> None:
118+
if not cls._has_acquired_lock():
119+
return
120+
func()
121+
122+
return wrapper
123+
124+
@classmethod
125+
def release(cls) -> None:
126+
"""Release the single-worker lock and reset this process's state."""
127+
with cls._state_mutex:
128+
try:
129+
if cls._acquired_lock is not None:
130+
cls._acquired_lock.release()
131+
finally:
132+
cls._acquired_lock = None
133+
cls._lock_status = LockStatus.NOT_ATTEMPTED

backend/main.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@
2727
from common.core.config import settings
2828
from common.core.response_middleware import ResponseMiddleware, exception_handler
2929
from common.core.sqlbot_cache import init_sqlbot_cache
30-
from common.utils.embedding_threads import fill_empty_terminology_embeddings, fill_empty_data_training_embeddings, \
31-
fill_empty_table_and_ds_embeddings
30+
from common.utils.distributed_lock import SingleWorkerGuard
31+
from common.utils.embedding_threads import (
32+
fill_empty_data_training_embeddings,
33+
fill_empty_table_and_ds_embeddings,
34+
fill_empty_terminology_embeddings,
35+
)
3236
from common.utils.utils import SQLBotLogUtil
3337

3438

@@ -37,18 +41,28 @@ def run_migrations():
3741
command.upgrade(alembic_cfg, "head")
3842

3943

44+
@SingleWorkerGuard.once
4045
def init_terminology_embedding_data():
4146
fill_empty_terminology_embeddings()
4247

4348

49+
@SingleWorkerGuard.once
4450
def init_data_training_embedding_data():
4551
fill_empty_data_training_embeddings()
4652

4753

54+
@SingleWorkerGuard.once
4855
def init_table_and_ds_embedding():
4956
fill_empty_table_and_ds_embeddings()
5057

5158

59+
def shutdown_resources() -> None:
60+
try:
61+
SingleWorkerGuard.release()
62+
except Exception:
63+
SQLBotLogUtil.exception("SQLBot shutdown failed")
64+
65+
5266
@asynccontextmanager
5367
async def lifespan(app: FastAPI):
5468
run_migrations()
@@ -61,8 +75,11 @@ async def lifespan(app: FastAPI):
6175
await sqlbot_xpack.core.clean_xpack_cache()
6276
await async_model_info() # 异步加密已有模型的密钥和地址
6377
await sqlbot_xpack.core.monitor_app(app)
64-
yield
65-
SQLBotLogUtil.info("SQLBot 应用关闭")
78+
try:
79+
yield
80+
finally:
81+
shutdown_resources()
82+
SQLBotLogUtil.info("SQLBot 应用关闭")
6683

6784

6885
def custom_generate_unique_id(route: APIRoute) -> str:

0 commit comments

Comments
 (0)