Skip to content

Commit 2c65ddd

Browse files
authored
feat(api): cache /user upstream responses in postgres (24h TTL) (#78)
1 parent 3058f46 commit 2c65ddd

2 files changed

Lines changed: 66 additions & 9 deletions

File tree

src/app.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# Import tasks before db so dotenv loads before SQLAlchemy reads POSTGRES_URL.
99
import tasks
1010
from enqueue import enqueue_package
11-
from db import PackageProcessStatus, SavedPackageData, Session, fetch_package_status, fetch_package_rank
11+
from db import PackageProcessStatus, SavedPackageData, Session, fetch_package_status, fetch_package_rank, get_cached_discord_user, cache_discord_user
1212

1313
from sqlite import generate_demo_database
1414

@@ -291,13 +291,23 @@ def get_avatar(package_id, user_id):
291291
return make_response('', 401)
292292

293293
session = Session()
294-
data = fetch_package_status(package_id, session)
295-
session.close()
296-
297-
if not data:
298-
return make_response('', 401)
299-
300-
user = fetch_diswho_user(user_id)
294+
try:
295+
data = fetch_package_status(package_id, session)
296+
297+
if not data:
298+
return make_response('', 401)
299+
300+
# Hit the upstream only on a cache miss / stale entry. Then
301+
# only persist responses that look real — username present
302+
# is the cheapest signal that we got past auth/404/5xx and
303+
# have something worth handing back to a future caller.
304+
user = get_cached_discord_user(user_id, session)
305+
if not user:
306+
user = fetch_diswho_user(user_id)
307+
if user and user.get('username'):
308+
cache_discord_user(user_id, user, session)
309+
finally:
310+
session.close()
301311

302312
if not user:
303313
return make_response('', 500)

src/db.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
22

3-
from datetime import datetime
3+
from datetime import datetime, timedelta
44
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean
55
from sqlalchemy.orm import declarative_base, sessionmaker
66
from sqlalchemy.sql import func, text
77

8+
import orjson
9+
810
engine = create_engine(os.getenv('POSTGRES_URL'), pool_recycle=3600, pool_pre_ping=True)
911

1012
Base = declarative_base()
@@ -18,6 +20,24 @@ class SavedPackageData(Base):
1820
created_at = Column(DateTime, nullable=False, default=func.now())
1921
updated_at = Column(DateTime, nullable=False, onupdate=func.now(), default=func.now())
2022

23+
class CachedDiscordUser(Base):
24+
"""Cache of upstream /user payloads (diswho or Discord bot API).
25+
26+
Discord usernames + avatars don't move often; the upstream is rate-
27+
limited (429s on bursts) so a small Postgres-backed cache absorbs
28+
the per-package fan-out and stops us hammering Discord every time
29+
a user opens a heavy package.
30+
"""
31+
__tablename__ = 'cached_discord_user'
32+
33+
user_id = Column(String(255), primary_key=True)
34+
payload = Column(String, nullable=False) # JSON-serialized upstream response
35+
cached_at = Column(DateTime, nullable=False, default=func.now())
36+
37+
38+
CACHED_DISCORD_USER_TTL_HOURS = 24
39+
40+
2141
class PackageProcessStatus(Base):
2242
__tablename__ = 'package_process_status'
2343

@@ -93,6 +113,33 @@ def fetch_package_status(package_id, session):
93113
status = session.query(PackageProcessStatus).filter_by(package_id=package_id).order_by(PackageProcessStatus.created_at.desc()).first()
94114
return status
95115

116+
def get_cached_discord_user(user_id, session):
117+
"""Return the cached upstream user dict, or None if absent or stale."""
118+
row = session.query(CachedDiscordUser).filter_by(user_id=user_id).first()
119+
if not row:
120+
return None
121+
if datetime.now() - row.cached_at > timedelta(hours=CACHED_DISCORD_USER_TTL_HOURS):
122+
return None
123+
return orjson.loads(row.payload)
124+
125+
126+
def cache_discord_user(user_id, payload, session):
127+
"""Upsert a user payload into the cache.
128+
129+
Caller must only pass valid upstream responses — never None, never
130+
error envelopes — so the cache doesn't pin failures and starve
131+
legitimate retries.
132+
"""
133+
serialized = orjson.dumps(payload).decode()
134+
existing = session.query(CachedDiscordUser).filter_by(user_id=user_id).first()
135+
if existing:
136+
existing.payload = serialized
137+
existing.cached_at = datetime.now()
138+
else:
139+
session.add(CachedDiscordUser(user_id=user_id, payload=serialized))
140+
session.commit()
141+
142+
96143
def fetch_pending_packages():
97144
session = Session()
98145
# select * from package_process_status pps where step <> 'PROCESSED' and error_message_code is null

0 commit comments

Comments
 (0)