perf(api,frontend): index hot queries, paginate chats, fix realtime
This commit is contained in:
@@ -34,6 +34,7 @@ from api.routers import (
|
||||
watches,
|
||||
)
|
||||
from dependencies.container import container
|
||||
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
|
||||
from utils.env import env
|
||||
|
||||
if env.auth.token is None:
|
||||
@@ -105,8 +106,11 @@ if _spa_dir.is_dir():
|
||||
async def serve_spa(spa_path: str) -> FileResponse:
|
||||
candidate = (_spa_dir / spa_path).resolve()
|
||||
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(_spa_index)
|
||||
immutable = spa_path.startswith("_app/immutable/")
|
||||
return FileResponse(
|
||||
candidate, headers=IMMUTABLE_HEADERS if immutable else DAY_HEADERS
|
||||
)
|
||||
return FileResponse(_spa_index, headers=NO_STORE_HEADERS)
|
||||
|
||||
|
||||
app.add_middleware(BearerAuthMiddleware, token=_token)
|
||||
|
||||
@@ -65,11 +65,16 @@ class EventHub:
|
||||
return
|
||||
account_id = event.get("account_id")
|
||||
chat_id = event.get("chat_id")
|
||||
scoped = event.get("kind") == "presence"
|
||||
targets = [
|
||||
sub
|
||||
for sub in self._subscribers
|
||||
if sub.account_id == account_id
|
||||
and (sub.chat_id is None or sub.chat_id == chat_id)
|
||||
and (
|
||||
sub.chat_id == chat_id
|
||||
if scoped
|
||||
else sub.chat_id is None or sub.chat_id == chat_id
|
||||
)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from utils.cache import IMMUTABLE_HEADERS, SHORT_HEADERS
|
||||
from utils.jobs import enqueue
|
||||
from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
|
||||
from utils.read.models import AvatarHistoryView
|
||||
@@ -52,5 +53,7 @@ async def serve_avatar(
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
|
||||
return FileResponse(
|
||||
storage.url(avatar.storage_key), media_type=avatar.mime or "image/jpeg"
|
||||
storage.url(avatar.storage_key),
|
||||
media_type=avatar.mime or "image/jpeg",
|
||||
headers=IMMUTABLE_HEADERS if unique_id is not None else SHORT_HEADERS,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.jobs import enqueue
|
||||
from utils.policy import repository
|
||||
from utils.read import chats
|
||||
from utils.read.models import (
|
||||
DEFAULT_LIMIT,
|
||||
@@ -35,8 +36,24 @@ async def list_chats(
|
||||
account_id: AccountId,
|
||||
limit: Limit = DEFAULT_LIMIT,
|
||||
offset: Offset = 0,
|
||||
folder_id: Annotated[int | None, Query()] = None,
|
||||
search: Annotated[str | None, Query()] = None,
|
||||
) -> list[ChatListItem]:
|
||||
return await chats.list_chats(pool, account_id, Page(limit=limit, offset=offset))
|
||||
folder = (
|
||||
await repository.get_folder(pool, account_id, folder_id)
|
||||
if folder_id is not None
|
||||
else None
|
||||
)
|
||||
return await chats.list_chats(
|
||||
pool, account_id, Page(limit=limit, offset=offset), folder=folder, search=search
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}")
|
||||
async def get_chat(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
|
||||
) -> ChatListItem | None:
|
||||
return await chats.get_chat(pool, account_id, chat_id)
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}/messages")
|
||||
|
||||
@@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
|
||||
from utils.read.media import (
|
||||
get_media,
|
||||
get_media_version,
|
||||
@@ -47,6 +48,7 @@ async def serve_media_version(
|
||||
return FileResponse(
|
||||
storage.url(version.storage_key),
|
||||
media_type=version.mime or "application/octet-stream",
|
||||
headers=IMMUTABLE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,4 +82,5 @@ async def serve_media(
|
||||
return FileResponse(
|
||||
storage.url(media.storage_key),
|
||||
media_type=media.mime or "application/octet-stream",
|
||||
headers=DAY_HEADERS,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
DAY_HEADERS = {"Cache-Control": "public, max-age=86400"}
|
||||
SHORT_HEADERS = {"Cache-Control": "public, max-age=300"}
|
||||
NO_STORE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
@@ -85,6 +85,18 @@ async def list_folders(pool: asyncpg.Pool, account_id: int) -> list[FolderSpec]:
|
||||
return [_row_to_folder(row) for row in rows]
|
||||
|
||||
|
||||
async def get_folder(
|
||||
pool: asyncpg.Pool, account_id: int, folder_id: int
|
||||
) -> FolderSpec | None:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT folder_id, title, order_index, is_chatlist, raw "
|
||||
"FROM folders WHERE account_id = $1 AND folder_id = $2",
|
||||
account_id,
|
||||
folder_id,
|
||||
)
|
||||
return _row_to_folder(row) if row else None
|
||||
|
||||
|
||||
async def create_policy(
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int | None,
|
||||
|
||||
+121
-70
@@ -1,5 +1,6 @@
|
||||
import asyncpg
|
||||
|
||||
from utils.policy.models import FolderSpec
|
||||
from utils.read.accounts import self_user_id
|
||||
from utils.read.message_view import build_message_view, load_raw, media_ref_from
|
||||
from utils.read.models import (
|
||||
@@ -43,81 +44,131 @@ def _single_media(
|
||||
return [ref] if ref else []
|
||||
|
||||
|
||||
def _peer_title(
|
||||
first: str | None, last: str | None, username: str | None
|
||||
) -> str | None:
|
||||
name = " ".join(part for part in (first, last) if part)
|
||||
return name or username
|
||||
_ALL_IDS = """
|
||||
SELECT chat_id FROM chat_stats WHERE account_id = $1
|
||||
UNION
|
||||
SELECT chat_id FROM dialogs WHERE account_id = $1
|
||||
UNION
|
||||
SELECT scope_id AS chat_id FROM capture_policy
|
||||
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id IS NOT NULL
|
||||
"""
|
||||
|
||||
_ONE_ID = """
|
||||
SELECT chat_id FROM chat_stats WHERE account_id = $1 AND chat_id = $2
|
||||
UNION
|
||||
SELECT chat_id FROM dialogs WHERE account_id = $1 AND chat_id = $2
|
||||
UNION
|
||||
SELECT scope_id AS chat_id FROM capture_policy
|
||||
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
|
||||
"""
|
||||
|
||||
_CHAT_ROWS = """
|
||||
WITH ids AS ({ids})
|
||||
SELECT ids.chat_id,
|
||||
COALESCE(cs.message_count, 0) AS message_count,
|
||||
cs.last_date, cs.last_text, cs.last_sender_id,
|
||||
COALESCE(named.title,
|
||||
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
|
||||
p.username) AS title,
|
||||
COALESCE(typed.is_broadcast, false) AS is_broadcast,
|
||||
COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
|
||||
p.raw->>'type' = 'ChatType.BOT', false) AS is_bot,
|
||||
COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool,
|
||||
false) AS is_contact,
|
||||
EXISTS (SELECT 1 FROM avatars a
|
||||
WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar
|
||||
FROM ids
|
||||
LEFT JOIN chat_stats cs ON cs.account_id = $1 AND cs.chat_id = ids.chat_id
|
||||
LEFT JOIN peers p ON p.account_id = $1 AND p.peer_id = ids.chat_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ch.title FROM chat_history ch
|
||||
WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id AND ch.title IS NOT NULL
|
||||
ORDER BY ch.ts DESC LIMIT 1
|
||||
) named ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type')
|
||||
= 'ChatType.CHANNEL' AS is_broadcast
|
||||
FROM chat_history ch
|
||||
WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id
|
||||
AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL
|
||||
ORDER BY ch.ts DESC LIMIT 1
|
||||
) typed ON true
|
||||
"""
|
||||
|
||||
|
||||
def _folder_filter(base: int) -> str:
|
||||
return (
|
||||
f"NOT (chat.chat_id = ANY(${base + 1}::bigint[])) "
|
||||
f"AND (chat.chat_id = ANY(${base + 2}::bigint[]) "
|
||||
f"OR (NOT ${base + 3}::bool AND CASE "
|
||||
f"WHEN chat.is_broadcast THEN ${base + 4}::bool "
|
||||
f"WHEN chat.chat_id < 0 THEN ${base + 5}::bool "
|
||||
f"WHEN chat.is_bot THEN ${base + 6}::bool "
|
||||
f"WHEN chat.is_contact THEN ${base + 7}::bool "
|
||||
f"ELSE ${base + 8}::bool END))"
|
||||
)
|
||||
|
||||
|
||||
def _folder_params(folder: FolderSpec) -> list[object]:
|
||||
return [
|
||||
sorted(folder.exclude_ids),
|
||||
sorted(folder.include_ids | folder.pinned_ids),
|
||||
folder.is_chatlist,
|
||||
folder.broadcasts,
|
||||
folder.groups,
|
||||
folder.bots,
|
||||
folder.contacts,
|
||||
folder.non_contacts,
|
||||
]
|
||||
|
||||
|
||||
def _chat_item(row: asyncpg.Record) -> ChatListItem:
|
||||
return ChatListItem(
|
||||
chat_id=row["chat_id"],
|
||||
title=row["title"],
|
||||
kind="private" if row["chat_id"] > 0 else "group",
|
||||
has_avatar=row["has_avatar"],
|
||||
is_bot=bool(row["is_bot"]),
|
||||
is_contact=bool(row["is_contact"]),
|
||||
is_broadcast=bool(row["is_broadcast"]),
|
||||
message_count=row["message_count"],
|
||||
last_date=row["last_date"],
|
||||
last_text=row["last_text"],
|
||||
last_sender_id=row["last_sender_id"],
|
||||
)
|
||||
|
||||
|
||||
async def list_chats(
|
||||
pool: asyncpg.Pool, account_id: int, page: Page
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
page: Page,
|
||||
*,
|
||||
folder: FolderSpec | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[ChatListItem]:
|
||||
rows = await pool.fetch(
|
||||
"WITH ids AS ("
|
||||
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
|
||||
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1 "
|
||||
"UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 "
|
||||
"AND scope_type = 'chat' AND scope_id IS NOT NULL), "
|
||||
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
|
||||
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
|
||||
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
|
||||
"agg.last_date AS last_date, "
|
||||
"(SELECT p.first_name FROM peers p "
|
||||
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS first_name, "
|
||||
"(SELECT p.last_name FROM peers p "
|
||||
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS last_name, "
|
||||
"(SELECT p.username FROM peers p "
|
||||
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS username, "
|
||||
"(SELECT ch.title FROM chat_history ch "
|
||||
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
|
||||
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title, "
|
||||
"EXISTS (SELECT 1 FROM avatars a "
|
||||
"WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar, "
|
||||
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, "
|
||||
"p.raw->>'type' = 'ChatType.BOT', false) "
|
||||
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_bot, "
|
||||
"(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, "
|
||||
"false) FROM peers p "
|
||||
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_contact, "
|
||||
"(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') "
|
||||
"= 'ChatType.CHANNEL' FROM chat_history ch "
|
||||
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
|
||||
"AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') IS NOT NULL "
|
||||
"ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, "
|
||||
"(SELECT lm.text FROM messages lm "
|
||||
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
|
||||
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, "
|
||||
"(SELECT lm.sender_id FROM messages lm "
|
||||
"WHERE lm.account_id = $1 AND lm.chat_id = ids.chat_id "
|
||||
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
|
||||
"FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id "
|
||||
"ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3",
|
||||
account_id,
|
||||
page.capped_limit,
|
||||
page.offset,
|
||||
params: list[object] = [account_id, page.capped_limit, page.offset]
|
||||
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
|
||||
clauses: list[str] = []
|
||||
if folder is not None:
|
||||
clauses.append(_folder_filter(len(params)))
|
||||
params.extend(_folder_params(folder))
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
clauses.append(f"chat.title ILIKE ${len(params)}")
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
query = (
|
||||
f"SELECT chat.* FROM ({rows_sql}) chat{where} " # noqa: S608
|
||||
"ORDER BY last_date DESC NULLS LAST, chat_id DESC LIMIT $2 OFFSET $3"
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
title = row["group_title"] or _peer_title(
|
||||
row["first_name"], row["last_name"], row["username"]
|
||||
)
|
||||
items.append(
|
||||
ChatListItem(
|
||||
chat_id=row["chat_id"],
|
||||
title=title,
|
||||
kind="private" if row["chat_id"] > 0 else "group",
|
||||
has_avatar=row["has_avatar"],
|
||||
is_bot=bool(row["is_bot"]),
|
||||
is_contact=bool(row["is_contact"]),
|
||||
is_broadcast=bool(row["is_broadcast"]),
|
||||
message_count=row["message_count"],
|
||||
last_date=row["last_date"],
|
||||
last_text=row["last_text"],
|
||||
last_sender_id=row["last_sender_id"],
|
||||
)
|
||||
)
|
||||
return items
|
||||
rows = await pool.fetch(query, *params)
|
||||
return [_chat_item(row) for row in rows]
|
||||
|
||||
|
||||
async def get_chat(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||
) -> ChatListItem | None:
|
||||
row = await pool.fetchrow(_CHAT_ROWS.format(ids=_ONE_ID), account_id, chat_id)
|
||||
return _chat_item(row) if row is not None else None
|
||||
|
||||
|
||||
async def get_chat_history( # noqa: PLR0913
|
||||
|
||||
Reference in New Issue
Block a user