Skip to content

Commit 6d8f69f

Browse files
committed
feat: performance improvements and maintenance tasks
1 parent 85cc47d commit 6d8f69f

9 files changed

Lines changed: 884 additions & 57 deletions

File tree

backend/app/cli.py

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import typer
22
from sqlmodel import create_engine, SQLModel
33
import os
4-
from app.tasks import fetch_all_feeds, remove_old_embeddings
4+
from app.tasks import (
5+
fetch_all_feeds,
6+
remove_old_embeddings,
7+
freeze_dormant_users,
8+
cleanup_old_articles,
9+
cleanup_orphan_user_article_links,
10+
cleanup_orphan_user_feed_links,
11+
cleanup_inactive_users,
12+
vacuum_database,
13+
get_database_stats,
14+
run_full_maintenance,
15+
unfreeze_user,
16+
)
517
from app.models.article import Article
618
from app.models.feed import Feed
719
from app.models.user import User
@@ -14,19 +26,100 @@
1426
connect_args={"check_same_thread": False},
1527
)
1628

17-
cli = typer.Typer()
29+
cli = typer.Typer(help="RSS Filter maintenance CLI")
1830

1931

2032
@cli.command()
21-
def fetch_feeds():
22-
"""Enqueue task to fetch all feeds."""
33+
def fetch_feeds() -> None:
34+
"""Fetch all feeds for active (non-frozen) users."""
2335
fetch_all_feeds()
36+
typer.echo("Feed fetch tasks enqueued")
37+
38+
39+
@cli.command()
40+
def clean_embeddings() -> None:
41+
"""Remove embeddings from old articles to save space."""
42+
count = remove_old_embeddings()
43+
typer.echo(f"Removed embeddings from {count} articles")
44+
45+
46+
@cli.command()
47+
def freeze_users(days: int = 90) -> None:
48+
"""Freeze users who have been inactive for specified days."""
49+
os.environ["DORMANT_THRESHOLD_DAYS"] = str(days)
50+
count = freeze_dormant_users()
51+
typer.echo(f"Froze {count} dormant users (inactive >{days} days)")
52+
53+
54+
@cli.command()
55+
def unfreeze(user_id: str) -> None:
56+
"""Manually unfreeze a specific user."""
57+
if unfreeze_user(user_id):
58+
typer.echo(f"User {user_id} unfrozen successfully")
59+
else:
60+
typer.echo(f"User {user_id} was not frozen or does not exist")
61+
62+
63+
@cli.command()
64+
def clean_articles(days: int = 180) -> None:
65+
"""Delete old unread articles to free up space."""
66+
count = cleanup_old_articles(days)
67+
typer.echo(f"Deleted {count} old unread articles (>{days} days)")
68+
69+
70+
@cli.command()
71+
def clean_orphans() -> None:
72+
"""Remove orphan user-article and user-feed links."""
73+
article_links = cleanup_orphan_user_article_links()
74+
feed_links = cleanup_orphan_user_feed_links()
75+
typer.echo(
76+
f"Deleted {article_links} orphan article links, {feed_links} orphan feed links"
77+
)
78+
79+
80+
@cli.command()
81+
def clean_users(days: int = 365) -> None:
82+
"""Delete inactive users with no feeds or articles."""
83+
count = cleanup_inactive_users(days)
84+
typer.echo(f"Deleted {count} inactive users (>{days} days, no feeds/articles)")
85+
86+
87+
@cli.command()
88+
def vacuum() -> None:
89+
"""Run VACUUM and ANALYZE on the database."""
90+
vacuum_database()
91+
typer.echo("Database vacuumed and analyzed")
92+
93+
94+
@cli.command()
95+
def stats() -> None:
96+
"""Show database statistics."""
97+
stats = get_database_stats()
98+
typer.echo("\nDatabase Statistics:")
99+
typer.echo("-" * 40)
100+
typer.echo(f"Users: {stats['users']['total']}")
101+
typer.echo(f" - Active (30d): {stats['users']['active_30d']}")
102+
typer.echo(f" - Frozen: {stats['users']['frozen']}")
103+
typer.echo(f"Feeds: {stats['feeds']['total']}")
104+
typer.echo(f"Articles: {stats['articles']['total']}")
105+
typer.echo(f" - With embeddings: {stats['articles']['with_embeddings']}")
106+
typer.echo(f"User-Article Links: {stats['links']['user_article']}")
107+
typer.echo(f"User-Feed Links: {stats['links']['user_feed']}")
24108

25109

26110
@cli.command()
27-
def clean_embeddings():
28-
"""Enqueue task to remove old embeddings."""
29-
remove_old_embeddings()
111+
def maintenance() -> None:
112+
"""Run full maintenance cycle (freeze, clean, vacuum)."""
113+
typer.echo("Starting full maintenance cycle...")
114+
results = run_full_maintenance()
115+
typer.echo("\nMaintenance Results:")
116+
typer.echo("-" * 40)
117+
typer.echo(f"Frozen users: {results['frozen_users']}")
118+
typer.echo(f"Removed embeddings: {results['removed_embeddings']}")
119+
typer.echo(f"Deleted articles: {results['deleted_articles']}")
120+
typer.echo(f"Orphan article links: {results['orphan_article_links']}")
121+
typer.echo(f"Orphan feed links: {results['orphan_feed_links']}")
122+
typer.echo("Database vacuumed: Yes")
30123

31124

32125
if __name__ == "__main__":

backend/app/models/user.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class User(SQLModel, table=True):
1515
)
1616
clusters: str | None = Field(default=None, repr=False)
1717
clusters_updated_at: datetime | None = Field(default=None, repr=False)
18+
is_frozen: bool = Field(default=False, index=True)
19+
frozen_at: datetime | None = Field(default=None, repr=False)
1820

1921
articles: list["Article"] = Relationship( # type: ignore # noqa: F821
2022
back_populates="users", link_model=UserArticleLink

backend/app/routers/feed.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fastapi import APIRouter, Response, Depends
77
from sqlmodel import Session, select
88
from loguru import logger
9+
from app.models.article import Article
910
from app.models.feed import (
1011
Feed,
1112
generate_feed,
@@ -20,9 +21,7 @@
2021
from fastapi import HTTPException
2122
from fastapi import BackgroundTasks
2223

23-
# from fastapi_cache.coder import PickleCoder
24-
# from fastapi_cache.decorator import cache
25-
from sqlalchemy.orm.exc import NoResultFound
24+
from sqlalchemy.exc import NoResultFound
2625

2726
router = APIRouter(
2827
tags=["feed"],
@@ -40,11 +39,15 @@ async def get_feed(
4039
feed_url: HttpUrl,
4140
background_tasks: BackgroundTasks,
4241
engine=Depends(get_engine),
43-
) -> str:
42+
) -> Response:
4443
with Session(engine, autoflush=False) as session:
45-
# User handling (same as before)
4644
try:
4745
user: User = session.exec(select(User).where(User.id == user_id)).one()
46+
user.last_request = datetime.now(timezone.utc)
47+
if user.is_frozen:
48+
user.is_frozen = False
49+
user.frozen_at = None
50+
logger.info(f"Auto-unfroze user {user_id} due to feed request")
4851
except NoResultFound:
4952
logger.info(f"User {user_id} not found in database, creating new user")
5053
user = User(id=user_id)
@@ -104,7 +107,14 @@ async def get_feed(
104107
break
105108
session.refresh(feed)
106109

107-
articles = feed.articles[-30:]
110+
articles = list(
111+
session.exec(
112+
select(Article)
113+
.where(Article.feed_id == feed.id)
114+
.order_by(Article.pub_date.desc()) # type: ignore[union-attr]
115+
.limit(30)
116+
).all()
117+
)
108118

109119
if user.clusters:
110120
filtered_articles = filter_articles(

0 commit comments

Comments
 (0)