N+1 query in RecommendedAppService.can_trial for unauthenticated sessions
- Dominant language
- TypeScript
- Stars
- 156k
- Forks
- 24.6k
- Avg merge
- 22h 9m
- Merged PRs (30d)
- 610
Description
### Self Checks
- [x] I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
- [x] This is only for bug report, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- [x] I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
- [x] I confirm that I am using English to submit this report, otherwise it will be closed.
- [x] 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
- [x] Please do not modify this template :) and fill in all the required fields.
### Dify version
main branch (uncommitted)
### Cloud or Self Hosted
Self Hosted (Source)
### Steps to reproduce
Discovered by peaks-loop code-sweep on 2026-07-02 while reviewing `api/services/recommended_app_service.py`.
Reproduction (profiling):
1. Spin up Dify self-hosted from `main` and install the recommended-apps seed data.
2. Mount the API process with SQLAlchemy echo on, hit `GET /recommended-apps?mode=` for an unauthenticated session.
3. Observe the SQL log: each page emits 20–100 single-row `SELECT ... FROM trial_apps WHERE app_id = ? LIMIT 1` queries — one per app, on the hot path.
The third hot spot `get_recommend_app_detail()` (line 66–68) already fetches exactly one app and fires the same single-row query; its N+1 is degenerate but identical in shape.
### ✔️ Expected Behavior
`can_trial_app` lookups for the recommended-apps list endpoints should be batched — one `SELECT app_id FROM trial_apps WHERE app_id IN (...)` per page, regardless of page size.
Per-row boolean semantics stay unchanged: `app["can_trial"]` is `True` iff a `TrialApp` row exists for that `app_id`. The empty-page early return must stay explicit.
### ❌ Actual Behavior
Two functions in `api/services/recommended_app_service.py` loop over the recommended-apps list and, for each entry, call `cls._can_trial_app(session, app_id)` which fires its own `SELECT ... FROM trial_apps WHERE app_id = ? LIMIT 1` query.
The list result is sized 20–100 apps per page (depending on mode), so the current code emits 20–100 single-row queries per response. In production this is the largest single contributor to recommended-apps latency.
The third hot spot `get_recommend_app_detail()` (line 66–68) already fetches exactly one app and fires the same single-row query; its N+1 is degenerate but identical in shape.
**Companion hot spot:** `get_recommend_app_detail()` (line 66–68) already calls `can_trial_app` once, so its N+1 is degenerate but identical in shape — the new helper makes both paths consistent.
Contributor guide
Research direction
Start in api/services/recommended_app_service.py, focusing on the recommended-apps list functions and get_recommend_app_detail(), then reproduce GET /recommended-apps?mode= with SQLAlchemy echo enabled. Done means each list page uses one batched trial_apps lookup, preserves the per-row boolean semantics and empty-page behavior, and avoids changing the detail path's result.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, sqlalchemy
- Domain
- api, backend, databases, performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100