Skip to content

Commit 8694816

Browse files
committed
Add async lock feature with distributed support
Signed-off-by: jamshale <jamiehalebc@gmail.com>
1 parent 8ef707a commit 8694816

6 files changed

Lines changed: 230 additions & 0 deletions

File tree

acapy_agent/config/default_context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ async def load_plugins(self, context: InjectionContext):
132132
"acapy_agent.vc.data_integrity",
133133
"acapy_agent.wallet",
134134
"acapy_agent.wallet.keys",
135+
"acapy_agent.core.async_lock",
135136
]
136137

137138
did_management_plugins = [
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Setup for async lock service."""
2+
3+
import json
4+
5+
from ...__main__ import LOGGER
6+
from ...config.injection_context import InjectionContext
7+
from ...utils.base_storage import get_postgres_connection_uri
8+
from .async_lock import AsyncLock
9+
from .async_lock_postgres import PostgresAsyncLock
10+
from .async_lock_sqlite import SqliteAsyncLock
11+
12+
13+
async def setup(context: InjectionContext):
14+
"""Set up the async lock service."""
15+
16+
storage_type = context.settings.get("wallet.storage_type")
17+
18+
match storage_type:
19+
case "postgres_storage":
20+
LOGGER.info("Setting up and binding PostgresAsyncLock...")
21+
connection_uri = get_postgres_connection_uri(
22+
json.loads(context.settings["wallet.storage_creds"]),
23+
json.loads(context.settings["wallet.storage_config"]),
24+
)
25+
await PostgresAsyncLock.create(connection_uri)
26+
context.injector.bind_instance(AsyncLock, PostgresAsyncLock())
27+
28+
case "default" | None:
29+
LOGGER.info("Setting up and binding SqliteAsyncLock...")
30+
SqliteAsyncLock.create()
31+
context.injector.bind_instance(AsyncLock, SqliteAsyncLock())
32+
33+
case _:
34+
raise ValueError(f"Unsupported storage type for async lock: {storage_type}")
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Abstract base class for an asynchronous lock service."""
2+
3+
from abc import ABC, abstractmethod
4+
from typing import Optional
5+
6+
7+
class AsyncLock(ABC):
8+
"""Abstract base class for an asynchronous lock service."""
9+
10+
@abstractmethod
11+
async def create(cls, connection_uri: Optional[str] = None):
12+
"""Create an instance of the lock service."""
13+
raise NotImplementedError("Subclasses must implement this method.")
14+
15+
@abstractmethod
16+
async def lock(self, value: str, timeout: float = 10.0) -> bool:
17+
"""Acquire a lock with the given value."""
18+
raise NotImplementedError("Subclasses must implement this method.")
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Advisory Lock Service for PostgreSQL."""
2+
3+
import asyncio
4+
import hashlib
5+
import logging
6+
import struct
7+
import time
8+
from contextlib import asynccontextmanager
9+
from typing import Optional
10+
11+
import psycopg
12+
from psycopg.rows import tuple_row
13+
from psycopg.sql import SQL, Identifier
14+
15+
from .async_lock import AsyncLock
16+
17+
LOGGER = logging.getLogger(__name__)
18+
19+
20+
class PostgresAsyncLock(AsyncLock):
21+
"""A class to manage distributed advisory locks in PostgreSQL.
22+
23+
Note: This implementation uses PostgreSQL's advisory locks
24+
to provide a distributed locking mechanism suitable for clustered environments.
25+
It is designed to work with PostgreSQL databases and requires a valid connection URI
26+
that it acquires from the provided storage configuration.
27+
"""
28+
29+
@classmethod
30+
async def create(cls, connection_uri: Optional[str] = None):
31+
"""Create a Lock instance with a PostgreSQL connection."""
32+
cls.connection_uri = connection_uri
33+
await cls._create_db_if_not_exists(cls.connection_uri)
34+
35+
@staticmethod
36+
async def _create_db_if_not_exists(base_uri: str, dbname="lock_db"):
37+
async with await psycopg.AsyncConnection.connect(
38+
base_uri, autocommit=True
39+
) as conn:
40+
async with conn.cursor(row_factory=tuple_row) as cur:
41+
await cur.execute(
42+
"SELECT 1 FROM pg_database WHERE datname = %s", (dbname,)
43+
)
44+
exists = await cur.fetchone()
45+
46+
if not exists:
47+
LOGGER.info(f"Creating database '{dbname}'...")
48+
await cur.execute(
49+
SQL("CREATE DATABASE {}").format(Identifier(dbname))
50+
)
51+
else:
52+
LOGGER.debug(f"Database '{dbname}' already exists.")
53+
54+
def _make_pg_lock_key(self, value) -> int:
55+
h = hashlib.sha256(str(value).encode()).digest()
56+
return struct.unpack("q", h[:8])[0] # signed 64-bit int
57+
58+
@asynccontextmanager
59+
async def lock(self, lock_key: str, timeout: int = 10):
60+
"""Acquires a PostgreSQL advisory lock for the given key.
61+
62+
Times out after `timeout` seconds if the lock isn't available.
63+
"""
64+
lock_key = self._make_pg_lock_key(lock_key)
65+
async with await psycopg.AsyncConnection.connect(
66+
self.connection_uri, autocommit=True
67+
) as conn:
68+
async with conn.cursor() as cur:
69+
start = time.time()
70+
acquired = False
71+
72+
while time.time() - start < timeout:
73+
await cur.execute("SELECT pg_try_advisory_lock(%s);", (lock_key,))
74+
acquired = (await cur.fetchone())[0]
75+
76+
if acquired:
77+
LOGGER.debug(f"[LOCK ACQUIRED] Key: {lock_key}")
78+
break
79+
80+
await asyncio.sleep(1)
81+
82+
if not acquired:
83+
raise TimeoutError(
84+
f"Could not acquire advisory lock {lock_key} within {timeout} seconds."
85+
)
86+
87+
try:
88+
yield
89+
finally:
90+
await cur.execute("SELECT pg_advisory_unlock(%s);", (lock_key,))
91+
LOGGER.debug(f"[LOCK RELEASED] Key: {lock_key}")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""A module for handling asynchronous locks using SQLite and portalocker."""
2+
3+
import asyncio
4+
import logging
5+
from contextlib import asynccontextmanager
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
import portalocker
10+
11+
from .async_lock import AsyncLock
12+
13+
LOGGER = logging.getLogger(__name__)
14+
15+
16+
class SqliteAsyncLock(AsyncLock):
17+
"""A class to handle asynchronous locking using SQLite.
18+
19+
Note: This implementation uses file-based locks with portalocker.
20+
It is suitable for single-node applications where SQLite is used as the storage
21+
backend.
22+
23+
It does not support distributed locking across multiple nodes. Clustered
24+
environments should use the currently supported postgres lock or
25+
implement a custom distributed lock mechanism.
26+
"""
27+
28+
@classmethod
29+
def create(cls, connection_uri: Optional[str] = None):
30+
"""Create a Lock instance with a SQLite connection."""
31+
cls.lock_dir = Path("/tmp/sqlite_locks")
32+
cls.lock_dir.mkdir(parents=True, exist_ok=True)
33+
34+
@staticmethod
35+
def _lock_file_path(lock_dir: Path, lock_name: str) -> Path:
36+
return lock_dir / f"{lock_name}.lock"
37+
38+
@asynccontextmanager
39+
async def lock(self, lock_key: str, timeout: int = 10):
40+
"""Acquire a lock with the given key using SQLite and portalocker."""
41+
loop = asyncio.get_running_loop()
42+
file_path = self._lock_file_path(self.lock_dir, lock_key)
43+
44+
lock = portalocker.Lock(
45+
file_path,
46+
mode="a+",
47+
flags=portalocker.LOCK_EX, # No fail_when_locked
48+
)
49+
50+
async def acquire():
51+
await loop.run_in_executor(None, lock.acquire)
52+
53+
try:
54+
await asyncio.wait_for(acquire(), timeout=timeout)
55+
LOGGER.debug(f"[LOCK ACQUIRED] Key: {lock_key}")
56+
yield
57+
except asyncio.TimeoutError:
58+
LOGGER.warning(f"Timeout acquiring lock: {lock_key}")
59+
raise TimeoutError(f"Timeout acquiring lock: {lock_key}")
60+
finally:
61+
try:
62+
await loop.run_in_executor(None, lock.release)
63+
LOGGER.debug(f"[LOCK RELEASED] Key: {lock_key}")
64+
except Exception:
65+
LOGGER.warning(f"Failed to release lock: {lock_key}")

acapy_agent/utils/base_storage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Base storage utility for connection URI generation."""
2+
3+
import urllib
4+
5+
6+
def get_postgres_connection_uri(storage_creds: dict, storage_config: dict) -> str:
7+
"""Get the connection URI for the PostgreSQL database."""
8+
uri = "postgresql://"
9+
config_url = storage_config.get("url")
10+
if not config_url:
11+
raise ValueError("No 'url' provided for postgres store")
12+
if "account" not in storage_creds:
13+
raise ValueError("No 'account' provided for postgres store")
14+
if "password" not in storage_creds:
15+
raise ValueError("No 'password' provided for postgres store")
16+
account = urllib.parse.quote(storage_creds["account"])
17+
password = urllib.parse.quote(storage_creds["password"])
18+
# FIXME parse the URL, check for parameters, remove postgres:// prefix, etc
19+
# config url expected to be in the form "host:port"
20+
uri += f"{account}:{password}@{config_url}/postgres"
21+
return uri

0 commit comments

Comments
 (0)