langgenius / langgenius/dify

N+1 query in RecommendedAppService.can_trial for unauthenticated sessions

Open
#38,404 1 comment 1 reaction 0 assignees View on GitHub
🐞 bug project#dify
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.