Skip to content

Commit e27c2e2

Browse files
authored
perf: cut avoidable SQL round trips from every endpoint (#63)
* test: pin SQL statement counts per endpoint and a BEGIN-free revocation check * perf: cut avoidable SQL round trips from every endpoint
1 parent 791ee7b commit e27c2e2

19 files changed

Lines changed: 345 additions & 83 deletions

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ a session, a transaction or a repository directly. Provider scopes:
109109
- `Database.database_session` — request-scoped, finalizer closes the session.
110110
- `Database.transaction` — request-scoped `db_retry.Transaction`, the object
111111
every use case wraps its work in.
112+
- `Database.autocommit_transaction` — the same, opened at `AUTOCOMMIT` so the
113+
token revocation check sends no BEGIN or COMMIT. `rolled_back_db_session`
114+
overrides `Database.autocommit_isolation_level` to `None`, because switching
115+
the shared test connection would roll back its outer transaction.
112116
- `Repositories.*` — request-scoped, `auto_commit=False` (see above).
113117
- `UseCases.*` — request-scoped, one class per operation.
114118

‎app/database/resources.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import typing
33

4+
from sqlalchemy.engine.interfaces import IsolationLevel
45
from sqlalchemy.ext import asyncio as sa
56

67
from app.settings import settings
@@ -31,6 +32,10 @@ def create_session(engine: sa.AsyncEngine | sa.AsyncConnection) -> sa.AsyncSessi
3132
)
3233

3334

35+
def autocommit_isolation_level() -> IsolationLevel | None:
36+
return "AUTOCOMMIT"
37+
38+
3439
async def close_session(session: sa.AsyncSession) -> None:
3540
task: typing.Final = asyncio.create_task(session.close())
3641
await asyncio.shield(task)

‎app/ioc.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ class Database(Group):
3939
creator=Transaction,
4040
kwargs={"session": database_session},
4141
)
42+
autocommit_isolation_level = providers.Factory(
43+
creator=database_resources.autocommit_isolation_level, bound_type=None
44+
)
45+
autocommit_transaction = providers.Factory(
46+
scope=Scope.REQUEST,
47+
creator=Transaction,
48+
bound_type=None,
49+
kwargs={"session": database_session, "isolation_level": autocommit_isolation_level},
50+
)
4251

4352

4453
class Repositories(Group, scope=Scope.REQUEST):
@@ -77,7 +86,9 @@ class UseCases(Group, scope=Scope.REQUEST):
7786
fetch_user_use_case = providers.Factory(creator=FetchUserUseCase)
7887
mark_read_use_case = providers.Factory(creator=MarkReadUseCase)
7988
revoke_token_use_case = providers.Factory(creator=RevokeTokenUseCase)
80-
check_token_revoked_use_case = providers.Factory(creator=CheckTokenRevokedUseCase)
89+
check_token_revoked_use_case = providers.Factory(
90+
creator=CheckTokenRevokedUseCase, kwargs={"transaction": Database.autocommit_transaction}
91+
)
8192

8293

8394
ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases]

‎app/repositories/chat_members_repository.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import typing
2+
from collections.abc import Sequence
23

34
import sqlalchemy as sa
4-
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
5+
from advanced_alchemy.exceptions import wrap_sqlalchemy_exception
6+
from advanced_alchemy.repository import DEFAULT_ERROR_MESSAGE_TEMPLATES, SQLAlchemyAsyncRepository
57
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
8+
from sqlalchemy import orm
69

710
from app.database import tables
811

@@ -16,8 +19,29 @@ class BaseRepository(SQLAlchemyAsyncRepository[tables.ChatMembersTable]):
1619
async def is_member(self, chat_id: int, user_id: int) -> bool:
1720
return await self.exists(chat_id=chat_id, user_id=user_id)
1821

19-
async def fetch_member(self, chat_id: int, user_id: int) -> tables.ChatMembersTable | None:
20-
return await self.get_one_or_none(chat_id=chat_id, user_id=user_id)
22+
async def add_members(self, chat: tables.ChatsTable, user_ids: Sequence[int]) -> None:
23+
with wrap_sqlalchemy_exception(error_messages=DEFAULT_ERROR_MESSAGE_TEMPLATES, dialect_name="postgresql"):
24+
members: typing.Final = await self.repository.session.scalars(
25+
sa.insert(tables.ChatMembersTable).returning(tables.ChatMembersTable),
26+
[{"chat_id": chat.id, "user_id": user_id} for user_id in user_ids],
27+
)
28+
orm.attributes.set_committed_value(chat, "members", members.all())
29+
30+
async def fetch_member_and_message_exists(
31+
self, chat_id: int, user_id: int, message_id: int
32+
) -> tuple[tables.ChatMembersTable, bool] | None:
33+
message_exists: typing.Final = (
34+
sa.exists()
35+
.where(tables.MessagesTable.id == message_id, tables.MessagesTable.chat_id == chat_id)
36+
.label("message_exists")
37+
)
38+
result: typing.Final = await self.repository.session.execute(
39+
sa.select(tables.ChatMembersTable, message_exists).where(
40+
tables.ChatMembersTable.chat_id == chat_id, tables.ChatMembersTable.user_id == user_id
41+
)
42+
)
43+
row: typing.Final = result.one_or_none()
44+
return None if row is None else (row[0], row[1])
2145

2246
async def mark_read(self, member_id: int, requested_message_id: int) -> tables.ChatMembersTable:
2347
"""Advance last_read_message_id to GREATEST(current, requested), atomically.

‎app/repositories/chats_repository.py‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime as dt
12
import typing
23
from collections.abc import Sequence
34

@@ -11,18 +12,45 @@
1112
from app.database import tables
1213

1314

15+
_WITH_MEMBERS: typing.Final[dict[str, typing.Any]] = {
16+
"load": [orm.joinedload(tables.ChatsTable.members)],
17+
"uniquify": True,
18+
# A chat already in the identity map keeps its noload members without this.
19+
"execution_options": {"populate_existing": True},
20+
}
21+
22+
1423
class ChatsRepository(SQLAlchemyAsyncRepositoryService[tables.ChatsTable]):
1524
class BaseRepository(SQLAlchemyAsyncRepository[tables.ChatsTable]):
1625
model_type = tables.ChatsTable
1726

1827
repository_type = BaseRepository
1928

2029
async def fetch_with_members(self, chat_id: int) -> tables.ChatsTable:
21-
return await self.get_one(
22-
tables.ChatsTable.id == chat_id,
23-
load=[orm.selectinload(tables.ChatsTable.members)],
24-
# A chat already in the identity map keeps its noload members without this.
25-
execution_options={"populate_existing": True},
30+
return await self.get_one(tables.ChatsTable.id == chat_id, **_WITH_MEMBERS)
31+
32+
async def find_with_members(self, chat_id: int) -> tables.ChatsTable | None:
33+
return await self.get_one_or_none(tables.ChatsTable.id == chat_id, **_WITH_MEMBERS)
34+
35+
async def set_last_message(self, chat_id: int, message_id: int) -> None:
36+
await self.repository.session.execute(
37+
sa.update(tables.ChatsTable)
38+
.where(tables.ChatsTable.id == chat_id)
39+
.values(last_message_id=message_id, updated_at=dt.datetime.now(tz=dt.UTC))
40+
)
41+
42+
async def repoint_last_message(self, chat_id: int, deleted_message_id: int) -> None:
43+
newest_active: typing.Final = (
44+
sa.select(tables.MessagesTable.id)
45+
.where(tables.MessagesTable.chat_id == chat_id, tables.MessagesTable.deleted_at.is_(None))
46+
.order_by(tables.MessagesTable.id.desc())
47+
.limit(1)
48+
.scalar_subquery()
49+
)
50+
await self.repository.session.execute(
51+
sa.update(tables.ChatsTable)
52+
.where(tables.ChatsTable.id == chat_id, tables.ChatsTable.last_message_id == deleted_message_id)
53+
.values(last_message_id=newest_active, updated_at=dt.datetime.now(tz=dt.UTC))
2654
)
2755

2856
async def get_or_create_direct(self, *, direct_key: str, created_by_id: int) -> tuple[tables.ChatsTable, bool]:
@@ -59,7 +87,7 @@ async def list_for_user(self, user_id: int) -> Sequence[tables.ChatsTable]:
5987
sa.select(tables.ChatsTable)
6088
.options(
6189
orm.with_expression(tables.ChatsTable.unread_count, unread_count),
62-
orm.selectinload(tables.ChatsTable.last_message),
90+
orm.joinedload(tables.ChatsTable.last_message),
6391
)
6492
.join(tables.ChatMembersTable, tables.ChatMembersTable.chat_id == tables.ChatsTable.id)
6593
.where(tables.ChatMembersTable.user_id == user_id)

‎app/repositories/messages_repository.py‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import datetime as dt
12
import typing
23
import uuid
34
from collections.abc import Sequence
45

56
import sqlalchemy as sa
6-
from advanced_alchemy.exceptions import wrap_sqlalchemy_exception
7+
from advanced_alchemy.exceptions import NotFoundError, wrap_sqlalchemy_exception
78
from advanced_alchemy.filters import LimitOffset
89
from advanced_alchemy.repository import DEFAULT_ERROR_MESSAGE_TEMPLATES, SQLAlchemyAsyncRepository
910
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
@@ -37,18 +38,37 @@ async def get_or_create(
3738
message, was_created = result.one()
3839
return message, was_created
3940

40-
async def fetch_latest_active(self, chat_id: int) -> tables.MessagesTable | None:
41-
"""Return the newest non-deleted message in a chat, or None if none remains.
41+
async def fetch_with_membership(self, message_id: int, user_id: int) -> tuple[tables.MessagesTable, bool]:
42+
is_member: typing.Final = (
43+
sa.exists()
44+
.where(
45+
tables.ChatMembersTable.chat_id == tables.MessagesTable.chat_id,
46+
tables.ChatMembersTable.user_id == user_id,
47+
)
48+
.label("is_member")
49+
)
50+
result: typing.Final = await self.repository.session.execute(
51+
sa.select(tables.MessagesTable, is_member).where(tables.MessagesTable.id == message_id)
52+
)
53+
row: typing.Final = result.one_or_none()
54+
if row is None:
55+
msg = "No item found when one was expected"
56+
raise NotFoundError(msg)
57+
return row[0], row[1]
4258

43-
Used to repoint ChatsTable.last_message_id after a delete removes the current pointer.
44-
"""
45-
messages = await self.get_many(
46-
tables.MessagesTable.chat_id == chat_id,
47-
tables.MessagesTable.deleted_at.is_(None),
48-
LimitOffset(limit=1, offset=0),
49-
order_by=[("id", True)],
59+
async def edit_text(self, message_id: int, text: str, edited_at: dt.datetime) -> tables.MessagesTable:
60+
result: typing.Final = await self.repository.session.execute(
61+
sa.update(tables.MessagesTable)
62+
.where(tables.MessagesTable.id == message_id)
63+
.values(text=text, edited_at=edited_at)
64+
.returning(tables.MessagesTable)
65+
)
66+
return result.scalar_one()
67+
68+
async def soft_delete(self, message_id: int, deleted_at: dt.datetime) -> None:
69+
await self.repository.session.execute(
70+
sa.update(tables.MessagesTable).where(tables.MessagesTable.id == message_id).values(deleted_at=deleted_at)
5071
)
51-
return messages[0] if messages else None
5272

5373
async def list_page(
5474
self,

‎app/repositories/revoked_tokens_repository.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime as dt
22

3+
import sqlalchemy as sa
34
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
45
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
56
from sqlalchemy.dialects import postgresql
@@ -25,4 +26,6 @@ async def revoke(self, jti: str, expires_at: dt.datetime) -> None:
2526
)
2627

2728
async def prune_expired(self, now: dt.datetime) -> None:
28-
await self.delete_where(tables.RevokedTokensTable.expires_at < now)
29+
await self.repository.session.execute(
30+
sa.delete(tables.RevokedTokensTable).where(tables.RevokedTokensTable.expires_at < now)
31+
)

‎app/use_cases/create_chat.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@ async def __call__(self, *, actor: Actor, data: CreateChatRequest) -> tuple[tabl
3838
)
3939
else:
4040
chat = await self.chats_repository.create(
41-
tables.ChatsTable(chat_type=data.chat_type, title=data.title, created_by_id=actor.id)
41+
tables.ChatsTable(chat_type=data.chat_type, title=data.title, created_by_id=actor.id),
42+
auto_refresh=False,
4243
)
4344
created = True
4445
if created:
45-
for user_id in sorted(member_ids):
46-
await self.chat_members_repository.create(tables.ChatMembersTable(chat_id=chat.id, user_id=user_id))
47-
chat_with_members: typing.Final = await self.chats_repository.fetch_with_members(chat.id)
46+
await self.chat_members_repository.add_members(chat, sorted(member_ids))
47+
else:
48+
chat = await self.chats_repository.fetch_with_members(chat.id)
4849
await self.transaction.commit()
49-
return chat_with_members, created
50+
return chat, created

‎app/use_cases/create_message.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ async def __call__(
3131
chat_id=chat_id, user_id=actor.id, idempotency_key=data.idempotency_key, text=data.text
3232
)
3333
if created:
34-
await self.chats_repository.update(
35-
tables.ChatsTable(id=chat_id, last_message_id=message.id),
36-
item_id=chat_id,
37-
attribute_names=["last_message_id"],
38-
)
34+
await self.chats_repository.set_last_message(chat_id, message.id)
3935
await self.transaction.commit()
4036
return message, created

‎app/use_cases/delete_message.py‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
from db_retry import Transaction, postgres_retry
55

66
from app.actor import Actor
7-
from app.database import tables
8-
from app.repositories.chat_members_repository import ChatMembersRepository
97
from app.repositories.chats_repository import ChatsRepository
108
from app.repositories.messages_repository import MessagesRepository
119
from app.use_cases.message_authorization import fetch_message_for_author
@@ -15,31 +13,20 @@
1513
class DeleteMessageUseCase:
1614
transaction: Transaction
1715
messages_repository: MessagesRepository
18-
chat_members_repository: ChatMembersRepository
1916
chats_repository: ChatsRepository
2017

2118
@postgres_retry
2219
async def __call__(self, *, actor: Actor, message_id: int) -> None:
2320
async with self.transaction:
2421
message = await fetch_message_for_author(
2522
messages_repository=self.messages_repository,
26-
chat_members_repository=self.chat_members_repository,
2723
actor=actor,
2824
message_id=message_id,
2925
action="delete",
3026
)
3127
if message.deleted_at is not None:
3228
# DELETE is idempotent; a second delete is not an error, unlike an edit.
3329
return
34-
message.deleted_at = dt.datetime.now(tz=dt.UTC)
35-
await self.messages_repository.update(message, item_id=message_id)
36-
37-
chat = await self.chats_repository.get_one(id=message.chat_id)
38-
if chat.last_message_id == message_id:
39-
newest = await self.messages_repository.fetch_latest_active(message.chat_id)
40-
await self.chats_repository.update(
41-
tables.ChatsTable(id=message.chat_id, last_message_id=newest.id if newest is not None else None),
42-
item_id=message.chat_id,
43-
attribute_names=["last_message_id"],
44-
)
30+
await self.messages_repository.soft_delete(message_id, dt.datetime.now(tz=dt.UTC))
31+
await self.chats_repository.repoint_last_message(message.chat_id, message_id)
4532
await self.transaction.commit()

0 commit comments

Comments
 (0)