fix(manager-core): F-P-001 batch app_user_roles lookups in users::list (N+1 → 1)

users::list page-fetched users then looped calling
self.fetch_roles(cx.app_id, row.id) — one query per user row, with
default limit 50 and max 500 — so the admin "users" page did 51 to 501
round-trips. The same fetch_roles is also called from
verify_session_for_realtime on every authenticated SSE subscribe.

Add AppUserRoleRepo::list_for_users(app_id, &[user_ids]) returning a
HashMap<AppUserId, Vec<String>>; rewrite list to use it. Single query
with `WHERE user_id = ANY($2)`.

Empty-input fast path returns an empty map without touching Postgres.

AUDIT.md anchor: F-P-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:09:31 +02:00
parent e7f9200c8f
commit 5303419eec
2 changed files with 52 additions and 5 deletions

View File

@@ -558,11 +558,22 @@ impl UsersService for UsersServiceImpl {
)
.await?;
let mut items = Vec::with_capacity(page.items.len());
for row in page.items {
let roles = self.fetch_roles(cx.app_id, row.id).await?;
items.push(Self::to_app_user(row, roles));
}
// F-P-001: one batched roles lookup instead of N per-user
// queries. With limit=500 this is 1 query vs 501.
let user_ids: Vec<AppUserId> = page.items.iter().map(|r| r.id).collect();
let mut roles_by_user = self
.roles
.list_for_users(cx.app_id, &user_ids)
.await
.map_err(|e| UsersError::Backend(e.to_string()))?;
let items: Vec<AppUser> = page
.items
.into_iter()
.map(|row| {
let roles = roles_by_user.remove(&row.id).unwrap_or_default();
Self::to_app_user(row, roles)
})
.collect();
Ok(UsersListPage {
items,
next_cursor: page.next_cursor,