Skip to content

Commit ead8a9d

Browse files
authored
Merge pull request #90 from NetherlandsForensicInstitute/feature/asyncify-sqlite-database-interface
Make sqlite interface async, parametrize both database and api tests to use both sqlite and postgresql in tests
2 parents 5aa192d + 7f28617 commit ead8a9d

10 files changed

Lines changed: 249 additions & 435 deletions

File tree

citatio/README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ Configuration and runtime
1313
The citatio REST API takes 2 configuration options:
1414

1515
- The model to be used for embedding (currently, only `NetherlandsForensicInstitute/ARM64BERT-embedding` is supported);
16-
- The database to store both assembly and embeddings in (currently, only SQLite is implemented).
16+
- The database to store both assembly and embeddings in, either SQLite+sqlitevec or PostgreSQL+pgvector.
1717

18-
Both of these can be configured through environment variables:
18+
These can be configured through environment variables:
1919

2020
- `CITATIO_MODEL`: a local path or huggingface model name (though again, currently on the `ARM64BERT-embedding` model is supported);
21-
- `CITATIO_SQLITE_DATABASE`: either `:memory:` or a local path to a SQLite database (will be created if it doesn't currently exist).
21+
- when using SQLite: `CITATIO_DATABASE_SQLITE`: either `:memory:` or a local path to a SQLite database (will be created if it doesn't currently exist).
22+
- when using PostgreSQL: either `CITATIO_DATABASE_HOST`, `..._PORT`, `..._USER`, `..._PASSWORD`, `..._DATABASE` to connect to the database in question,
23+
or `CITATIO_DATABASE_DSN` with the full connection url to connect to that same database.
2224

23-
The defaults for these values are `NetherlandsForensicInstitute/ARM64BERT-embedding` and `:memory:`,
24-
resulting in a functioning but non-persistent service.
25+
The database configuration is required, the default model to be loaded is `NetherlandsForensicInstitute/ARM64BERT-embedding`.
2526

2627
> [!NOTE]
27-
> After observing concurrency issues with SQLite and `sqlite-vec`, the REST API is currently served fully serialized and is consequently fairly slow.
28-
> We're expecting to be able to solve this by using PostgreSQL and `pgvector`.
28+
> After observing concurrency issues with SQLite and `sqlite-vec`, the REST API is currently served fully serialized when using SQLite and is consequently fairly slow.
2929
3030
Running the REST API service follows the default FastAPI command line setup,
3131
where the application is available from the `citatio` module:
@@ -44,7 +44,7 @@ though end users are encouraged to use the [ready-made Ghidra plugin (sententia)
4444
Prerequisites
4545
-------------
4646

47-
Python 3.12 or newer with SQLite version 3.35.0 or newer.
47+
Python 3.13 or newer with SQLite version 3.35.0 or newer.
4848

4949
Requirements
5050
------------

citatio/citatio/api.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi import FastAPI, Request
77
from fastapi.params import Body
88

9-
from citatio.db import PostgreSQLDatabase
9+
from citatio.db import PostgreSQLDatabase, SQLiteDatabase
1010
from citatio.models import ControlFlowGraph
1111

1212

@@ -16,12 +16,19 @@
1616
@asynccontextmanager
1717
async def lifespan(app: FastAPI):
1818
config = confidence.load_name('citatio')
19-
if not config.database:
20-
raise ValueError(f'missing database configuration, available: {config}')
2119

22-
app.state.model = ASMEmbedder.from_pretrained(config.model or DEFAULT_MODEL)
20+
match config:
21+
case {'database.sqlite': name}:
22+
# explicit sqlite name to connect to, use SQLiteDatabase
23+
database = await SQLiteDatabase.connect(name)
24+
case {'database': connect}:
25+
# database settings *not* mentioning sqlite, use PostgreSQLDatabase
26+
database = await PostgreSQLDatabase.connect(**connect)
27+
case _:
28+
raise ValueError(f'missing database configuration, available: {config}')
2329

24-
async with await PostgreSQLDatabase.connect(**config.database) as database:
30+
app.state.model = ASMEmbedder.from_pretrained(config.model or DEFAULT_MODEL)
31+
async with database:
2532
app.state.database = database
2633
yield
2734

citatio/citatio/db.py

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99

1010
class Database:
11-
def add_function(
11+
async def add_function(
1212
self,
1313
name: str,
1414
cfg: dict[int, list[str]],
@@ -19,13 +19,13 @@ def add_function(
1919
):
2020
pass
2121

22-
def search_function(self, embedding: np.array, top_n: int = 25):
22+
async def search_function(self, embedding: np.array, top_n: int = 25):
2323
pass
2424

2525

2626
class SQLiteDatabase(Database):
2727
@classmethod
28-
def from_name(cls, name):
28+
async def connect(cls, name):
2929
# make sure to pass check_same_thread=False, Python 3.11+ has thread-safe sqlite
3030
connection = sqlite3.connect(name, check_same_thread=False)
3131
return cls(connection)
@@ -38,18 +38,17 @@ def __init__(self, connection):
3838
sqlite_vec.load(self.connection)
3939
self.connection.enable_load_extension(False)
4040

41-
# NB: read_text() shows up as deprecated in 3.11, it has since been un-deprecated (causing confusing errors)
4241
schema = resources.read_text('citatio', 'schema-sqlite.sql')
4342
self.connection.executescript(schema)
4443

45-
def __enter__(self):
44+
async def __aenter__(self):
4645
return self
4746

48-
def __exit__(self, exc_type, exc_val, exc_tb):
47+
async def __aexit__(self, exc_type, exc_val, exc_tb):
4948
self.connection.close()
5049
self.connection = None
5150

52-
def _insert_or_get_function(self, cursor, cfg, embedding):
51+
async def _insert_or_get_function(self, cursor, cfg, embedding):
5352
# coerce cfg to str for database storage (keep the cfg type responsible for that (de)serialization)
5453
parameters = (str(cfg),)
5554
try:
@@ -66,10 +65,10 @@ def _insert_or_get_function(self, cursor, cfg, embedding):
6665

6766
return function_id
6867

69-
def add_function(self, name, cfg, embedding, binary_name, binary_sha256, model_identifier=None):
68+
async def add_function(self, name, cfg, embedding, binary_name, binary_sha256, model_identifier=None):
7069
with self.connection:
7170
cursor = self.connection.cursor()
72-
function_id = self._insert_or_get_function(cursor, cfg, embedding)
71+
function_id = await self._insert_or_get_function(cursor, cfg, embedding)
7372
cursor.execute(
7473
"""INSERT INTO labels (function_id, label, binary_name, binary_sha256) VALUES (?, ?, ?, ?)""",
7574
(function_id, name, binary_name, binary_sha256),
@@ -78,7 +77,7 @@ def add_function(self, name, cfg, embedding, binary_name, binary_sha256, model_i
7877
# return the function id for convenience
7978
return function_id
8079

81-
def search_function(self, embedding, top_n=25):
80+
async def search_function(self, embedding, top_n=25):
8281
cursor = self.connection.cursor()
8382
cursor.execute(
8483
"""
@@ -125,33 +124,30 @@ async def __aenter__(self):
125124
async def __aexit__(self, exc_type, exc_val, exc_tb):
126125
await self.connection.close()
127126

128-
async def _insert_or_get_function(self, cfg, embedding):
129-
cfg = str(cfg)
130-
try:
131-
return await self.connection.fetchval(
132-
'INSERT INTO functions (cfg, embedding) VALUES ($1, $2) RETURNING id',
133-
cfg,
127+
async def add_function(self, name, cfg, embedding, binary_name, binary_sha256, model_identifier=None):
128+
async with self.connection.transaction():
129+
function_id = await self.connection.fetchval(
130+
# use PostgreSQL's conflict resolution to issue an update-or-get
131+
# NB: the conflict resolution update is idempotent, but needed to make sure RETURNING id works
132+
"""
133+
INSERT INTO functions (cfg, embedding) VALUES ($1, $2)
134+
ON CONFLICT (cfg) DO UPDATE SET cfg = EXCLUDED.cfg RETURNING id
135+
""",
136+
str(cfg),
134137
embedding,
135138
)
136-
except asyncpg.IntegrityConstraintViolationError:
137-
return await self.connection.fetchval('SELECT id FROM functions WHERE cfg = $1', cfg)
138-
139-
async def _insert_label(self, function_id, name, binary_name, binary_sha256):
140-
await self.connection.execute(
141-
'INSERT INTO labels (function_id, label, binary_name, binary_sha256) VALUES ($1, $2, $3, $4)',
142-
function_id,
143-
name,
144-
binary_name,
145-
binary_sha256,
146-
)
139+
await self.connection.execute(
140+
'INSERT INTO labels (function_id, label, binary_name, binary_sha256) VALUES ($1, $2, $3, $4)',
141+
function_id,
142+
name,
143+
binary_name,
144+
binary_sha256,
145+
)
147146

148-
async def add_function(self, name, cfg, embedding, binary_name, binary_sha256, model_identifier=None):
149-
function_id = await self._insert_or_get_function(cfg, embedding)
150-
await self._insert_label(function_id, name, binary_name, binary_sha256)
151147
return function_id
152148

153-
async def _search_near(self, embedding, top_n):
154-
return await self.connection.fetch(
149+
async def search_function(self, embedding, top_n=25):
150+
results = await self.connection.fetch(
155151
"""
156152
SELECT label, (2 - (embedding <=> $1)) / 2 AS similarity, binary_name, binary_sha256
157153
FROM labels
@@ -163,9 +159,6 @@ async def _search_near(self, embedding, top_n):
163159
top_n,
164160
)
165161

166-
async def search_function(self, embedding, top_n=25):
167-
results = await self._search_near(embedding, top_n)
168-
169162
return [
170163
dict(
171164
zip(

citatio/pdm.lock

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

citatio/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ dependencies = [
2727
"pydantic>=2.11.5",
2828
"scipy",
2929
"sqlite-vec>=0.1.6",
30-
"testcontainers[postgres]>=4.14.2",
3130
"torch",
3231
"transformers",
3332
]
@@ -44,6 +43,7 @@ test = [
4443
"httpx2", # avoid deprecation warnings for fastapi / starlette tests
4544
"pytest>=8.3.5",
4645
"pytest-asyncio>=1.4.0",
46+
"testcontainers[postgres]>=4.14.2",
4747
]
4848

4949
[tool.pdm.version]
@@ -74,7 +74,7 @@ test = "coverage run --branch --source citatio --module pytest --strict-markers
7474
asyncio_default_test_loop_scope = "session"
7575
asyncio_default_fixture_loop_scope = "session"
7676
asyncio_mode = "auto"
77-
markers = ["postgresql"]
77+
markers = ["postgresql", "sqlite"]
7878

7979
[tool.ruff]
8080
format.quote-style = "single"

0 commit comments

Comments
 (0)