Repository navigation
perf(group): make group list, sync, bulk operations and delete scale to 50k+ users - #967
Conversation
… group updates Root causes: - Group.total_users was len(Group.users), so listing groups (get_group), fetching one (get_group_by_id) and every create/modify/bulk-disable (load_group_attrs) loaded every member User row just to produce a count. total_users is now a SQL COUNT (query_expression + with_expression, or one COUNT in load_group_attrs); Group.users is never loaded for these paths. - Modifying a group re-syncs all of its users to the nodes, but get_users only selectin-loaded User.groups, not Group.inbounds. User.inbounds() then fell back to one "SELECT DISTINCT inbounds.tag ..." per user whenever a user belonged to a group whose inbounds were not loaded. get_users gains load_group_inbounds to nested-eager-load them (a handful of batched queries), and the group operation sync paths use it and skip the unused usage_logs. 20,000-user group on SQLite: adding an inbound 3.17s / 10,132 queries -> 0.53s / 92 queries; group list 0.20s / 4 queries -> 0.06s / 3 queries, without loading any User rows.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe change updates group and user loading to compute group totals in SQL and optionally preload group inbounds. Bulk ID, membership, reset, and accessible-tag queries process large inputs in batches. Group and admin operations use the updated loaders when synchronizing affected users. Tests cover loading, batching, and bulk operations. ChangesGroup and User Loading
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Bug fix Suggested reviewers: Merge Risk: ⚪ Minimal · up to The reviewed group and bulk-operation paths have no established issue requiring a fix before merge. 🚥 Pre-merge checks | ✅ 4 | ❌ 1
✨ Finishing Touches 💡 1
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit checks the counts in rows, Comment |
… to nodes asyncpg rejects statements with more than 32767 bind parameters. Modifying a group with more than that many members failed in get_users_accessible_tags (IN over every member id) after the group change was already committed, and removing such a group (or bulk-removing groups by id) failed in get_users(UserListQuery(username=...)). Both now issue one query per 10k ids/usernames. This also fails on the base branch; it is not caused by the count/preload change.
…per user - add_groups_to_users / remove_groups_from_users selected `user_id IN (<every target id>)`, which asyncpg rejects above 32767 bind params (fails at 50k users on PostgreSQL), and reloaded users without the groups' inbounds so syncing them to nodes issued one inbound query per user. Affected users are now found with correlated EXISTS/COUNT subqueries, missing (user, group) pairs are inserted with INSERT ... SELECT, ids are chunked (5k) for the insert/delete/reload statements, and users are reloaded with load_group_inbounds and without usage logs. - remove_group used db.delete(group), which loads Group.users and deletes the association rows with one DELETE per member (~4s of ~7.7s at 50k on MariaDB). It now reuses remove_groups (one DELETE per association table, then the group). GroupOperation.remove_group also collects member usernames with a lightweight query instead of hydrating every User, which additionally stops the later sync from reusing those stale User.groups (it dispatched the deleted group's inbounds).
…moving groups bulk_remove_groups_by_id hydrated every member of every group with get_users just to collect usernames, and the later sync reused those identity-map users (stale User.groups). get_group_usernames now takes a list of group ids and returns the distinct member usernames with one query per 10k group ids; both remove_group and bulk_remove_groups_by_id use it, so the sync always reloads fresh users with their groups' inbounds preloaded. Tests cover the removal sync for remove_group and bulk_remove_groups_by_id (no per-user inbound queries, no removed-group inbounds) and the helper's distinct/chunked behavior.
… ops preload group inbounds - _create_group_filter / _create_final_filter render the request body user ids into the statement at execution time (literal_execute) instead of binding them, so >32k explicit ids no longer fail on PostgreSQL. - update_users_expire / update_users_datalimit / update_users_proxy_settings reload through _reload_group_users (5k ids per statement, groups inbounds preloaded) so the node sync runs no query per user. - reset_all_users_data_usage updates and deletes with the admin filter / a subquery instead of binding every user id. - Admin disable/activate-all skip usage logs; activate-all also preloads group inbounds before syncing nodes.
…d sync with preloaded inbounds - _explicit_ids moves to crud/user.py (taking the column) and is used by get_users(ids), get_users_by_ids and remove_users, so bulk disable/enable/status/reset usage/revoke/set owner/apply template/remove accept more than 32k user ids on PostgreSQL. - get_users_by_ids(load_group_inbounds=True) is used by UserOperation._load_users_by_ids, which only feeds node syncs.
Resolve app/db/crud/group.py: keep the SQL member count options with dev's "ids is not None" check, so an empty id list matches nothing. Resolve app/operation/admin.py: take dev's get_admin_users_for_node_sync for the admin disable and activate paths; it already loads only the needed columns, the admin role and the groups' inbounds without usage logs.
The member count rewrite overlaps PasarGuard#967, which counts members in SQL in load_group_attrs without loading them and keeps a fallback when the count was not loaded. Leave Group.total_users and get_group as on dev so this PR adds only the reverse membership indexes, which those group-centric counts and bulk membership deletes use.
Resolve app/db/crud/user.py: get_users keeps both new keyword arguments, this PR's load_group_inbounds and the HWID count change's load_hwid_count; both loaders are applied as before.
* perf(groups): scale membership summaries * test: remove added PR tests * test: remove PR-specific changes to existing tests * fix(migrations): chain the group membership index migration onto the current head The migration branched off an older revision, and a merge migration joined it to dev's head (b4c7e8f1a2d3). That works alone, but once another migration PR lands the same way, dev has two Alembic heads and `alembic upgrade head` fails with "Multiple head revisions". Point the migration at dev's head and drop the merge migration. Also drop an extra blank line after the imports that ruff flags (I001). * refactor(groups): keep only the reverse membership indexes The member count rewrite overlaps #967, which counts members in SQL in load_group_attrs without loading them and keeps a fallback when the count was not loaded. Leave Group.total_users and get_group as on dev so this PR adds only the reverse membership indexes, which those group-centric counts and bulk membership deletes use. * fix(migrations): chain the group membership index migration after the node and core sort order The other migration PRs in the series (#774, #865, #875) have landed, so point this migration at the latest revision (d73f8a2c4e91) to keep a single Alembic head. --------- Co-authored-by: T3ST3ST3R0N <T3ST3ST3R0N@gmail.com>
Summary
Adding an inbound to a group with many users (
PUT /api/group/{id}) is very slow, fails outright on PostgreSQL above ~33k members, and listing groups loads far more data than it needs. Bulk requests that name many users (or touch every user) hit the same bind limit and the same per-user inbound queries. Seven root causes:Group summaries hydrate every member just to count them.
Group.total_userswaslen(self.users)(app/db/models.py).get_groupusedselectinload(Group.users)(app/db/crud/group.py), andload_group_attrsawaitedgroup.awaitable_attrs.usersforget_group_by_id(used by get/modify/delete),create_group,modify_groupand the bulk enable/disable path. Every one of those loaded allUserrows for a number.total_usersis now a SQLCOUNT:with_expression(Group._total_users_query, Group.total_users)inget_group/get_group_by_id, and a singleCOUNTinload_group_attrs. Thetotal_usershybrid uses the loaded value and falls back tolen(users), so API responses are unchanged. Samequery_expressionmechanism asUser._reseted_usage_query.Syncing a group's users to nodes issued one inbound query per user.
GroupOperation.modify_group(app/operation/group.py) re-syncs every member.get_usersonly didselectinload(User.groups)(app/db/crud/user.py), soGroup.inboundswas never loaded andUser.inbounds()fell back to oneSELECT DISTINCT inbounds.tag ...per user. The sync path also loadedusage_logsfor every user, which it does not use.get_users(load_group_inbounds=False)nestsselectinload(Group.inbounds)underUser.groups. The group operation paths that feedsync_users(modify, remove, bulk remove by id, bulk enable/disable) now passload_group_inbounds=True, load_usage_logs=False.One giant
IN (...)over all member ids / usernames breaks PostgreSQL at ~33k+ users. asyncpg rejects a statement with more than 32,767 bind parameters (InterfaceError: the number of query arguments cannot exceed 32767). Two places build such a list from a group's members, and both fail onorigin/devat 50k users:get_users_accessible_tags(app/db/crud/wireguard.py, called bysync_users_allocationsfrommodify_group):... WHERE users_groups_association.user_id IN (<every member id>). This runs after the group change has been committed, so the request errors and the nodes are never synced.get_users(UserListQuery(username=...))inremove_groupandbulk_remove_groups_by_id, same failure after the group is already deleted.get_users_accessible_tagsnow issues one query per 10k ids, and a newGroupOperation._get_users_for_syncloads users 10k usernames per query; it replaces the two username call sites. MariaDB has no such limit and was unaffected.bulk_add_groups/bulk_remove_groupshit the same giantIN (...)and the same missing inbound preload.add_groups_to_users/remove_groups_from_users(app/db/crud/bulk.py) selecteduser_id IN (<every target id>)(twice or three times per call), which asyncpg rejects at 50k users on PostgreSQL. The users they reload for the node sync came from_bulk_user_reload_stmt()without their groups' inbounds, soUser.inbounds()ran one query per affected user (MariaDB, 50k users: 20.9 s and ~50k statements for a bulk add).COUNTfor add,EXISTSfor remove) instead of fetching all ids and then the existing pairs. Missing(user, group)pairs are written with oneINSERT ... SELECT ... NOT EXISTSper chunk of users instead of building and executemany-inserting rows in Python. Insert / delete / reload statements take at most 5k ids (the group ids are bound next to them, so every statement stays at or below 10k bind parameters). Users are reloaded withload_group_inbounds=Trueand no usage logs (_reload_group_users;_build_user_select_stmtgetsload_group_inbounds).remove_groupdeleted association rows one by one and loaded every member first.db.delete(group)makes SQLAlchemy loadGroup.usersand deleteusers_groups_associationrows with an executemany of oneDELETEper member (MariaDB, 50k users: ~4 s of ~7.7 s; PostgreSQL ~0.13 s).GroupOperation.remove_groupandbulk_remove_groups_by_idalso calledget_usersper group (fullUserrows) just to collect usernames.crud.remove_groupnow callsremove_groups, which already deletes the association rows (users, templates, inbounds) with one statement each and then the group; theGroupobject stays usable for logging / notifications.get_group_usernames(db, group_ids)returns the distinct member usernames with oneSELECT DISTINCTper 10k group ids, and both remove paths use it.origin/dev,remove_groupalso dispatched wrong inbounds. It loaded the members before the delete withget_users(db, query=UserListQuery(group_ids=[db_group.id])), and thoseUserobjects, with theirUser.groups(still containing the removed group), stayed in the session's identity map. After the delete, theget_users(UserListQuery(username=...))reload returned those same identity-map objects with the staleUser.groups, so members whose only group was the removed one were still sent the removed group's inbound (measured on TimescaleDB, 20k users:origin/devdispatched 10,000 users within-band 10,000 within-a, the deleted group's inbound; this PR dispatches 10,000 within-band 10,000 with no inbounds). Usernames are now collected withget_group_usernames(noUserrows) and the users are reloaded fresh after the delete with their remaining groups' inbounds preloaded, so the sync sees the post-delete memberships.A bulk request naming more than ~32k users fails on PostgreSQL. Explicit user ids are
set[int]request fields with no size cap (BulkGroup.users,BulkUserFilter.userswhichBulkUser/BulkUsersProxyinherit, andBulkUsersSelection.idsfor bulk disable / enable / status / reset usage / revoke / set owner / apply template / remove). Each went into oneUser.id IN (<every id>):_create_group_filterand_create_final_filter(app/db/crud/bulk.py, OR-combined with the other filter conditions and reused by the count, id-select andUPDATEstatements), andget_users(ids=...),get_users_by_idsandremove_users(app/db/crud/user.py). asyncpg rejects the statement above 32,767 binds (InterfaceError, 40k ids at 50k users onorigin/dev)._explicit_ids(column, ids)(app/db/crud/user.py, imported bybulk.py) buildscolumn.in_(bindparam(None, ids, expanding=True, literal_execute=True)). SQLAlchemy writes the integers into the SQL text at execution time, so the statement binds no id parameters on PostgreSQL, MariaDB and SQLite, keeps the OR semantics and needs no dialect branch. It is one line per call site.= ANY(:array)is a single bind but PostgreSQL-only, so it needs a dialect branch plus a second path for MariaDB / SQLite. A temp table needs DDL and an insert per request, again per dialect.Other bulk operations reloaded users without their groups' inbounds, and bound every affected id.
update_users_expire/update_users_datalimit/update_users_proxy_settings(app/db/crud/bulk.py) reloaded the affected users with_bulk_user_reload_stmt().where(User.id.in_(<all ids>))and no inbound preload, so the node sync ran oneUser.inbounds()query per user (MariaDB, 50k users: 40,085 statements for one expire) and theINfailed on PostgreSQL at 50k. They now use_reload_group_users(5k ids per statement, inbounds preloaded, no usage logs).reset_all_users_data_usageselected every user id and bound them in oneUPDATEand threeDELETEs (asyncpgInterfaceErrorat 50k). It now updates with the admin filter (or none) and deletes with aUser.idsubquery, so no ids are bound.AdminOperationdisable-all / activate-all loaded all of the admin's users with usage logs and no inbounds before syncing (activate-all, 50k: 25,157 statements). Both skip usage logs; activate-all also preloads group inbounds (disable-all leaves no active user, so inbounds are unused there).UserOperation._load_users_by_ids, used only to reload users for a node sync after bulk status / disable / enable / apply template, now callsget_users_by_ids(load_group_inbounds=True).Files
app/db/crud/bulk.py:add_groups_to_users/remove_groups_from_usersrewritten as above;_chunked,_reload_group_users(also used by the expire / datalimit / proxy-settings reloads);_create_group_filter/_create_final_filteruse_explicit_ids;reset_all_users_data_usagebinds no ids.app/db/crud/group.py:remove_groupdelegates toremove_groups;get_group_usernames; SQLCOUNTfortotal_users.app/db/crud/user.py:get_users(load_group_inbounds=...),_build_user_select_stmt(load_group_inbounds=...);_explicit_idsused byget_users(ids=...),get_users_by_ids(load_group_inbounds=...)andremove_users.app/db/crud/wireguard.py: chunkedget_users_accessible_tags.app/db/models.py:Group._total_users_query/total_users.app/operation/group.py: preload group inbounds for syncs,_get_users_for_sync, username query on both remove paths.app/operation/admin.py: disable-all / activate-all skip usage logs; activate-all preloads group inbounds.app/operation/user.py:_load_users_by_idspreloads group inbounds for the node sync.tests/test_group_user_loading.py: regression tests.Type of change
Checklist
query_expressionadds no column;make check-migrationsis clean on PostgreSQL, MariaDB and TimescaleDB.)Testing
Run against PostgreSQL (
postgres:latest, 18.6) and MariaDB (mariadb:10.6, same images as.github/workflows/test-database-migrations.yml):Full suite (
make test) on this branch, fresh Alembic-migrated database each,make check-migrationsclean on both:timescale/timescaledb:latest-pg17, PG 17.11, extension 2.30.2)origin/dev: 712 passed, 34 skipped, 16 failed, same 16)The 16 PostgreSQL failures are pre-existing and unrelated: they fail identically on a clean
origin/devcheckout (test_admin_usage_returns_stats_for_admin, 4xtest_subscription_reads_see_updates_after_request_snapshot, and 11 tests intests/api/test_usage_functions_timezone.py).TimescaleDB was run like the
test-timescaledbCI job:timescale/timescaledb:latest-pg17(POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_HOST_AUTH_METHOD=trust),TEST_FROM=github TESTING=1 SQLALCHEMY_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:<port>/<db> NATS_ENABLED=1 NATS_URL=nats://localhost:<port> UVICORN_WORKERS=2,nats:2.10-alpine -js, fresh database per run,make run-migration && make check-migrations && ROLE=all-in-one make test, on this branch and onorigin/dev. Migrations andcheck-migrationspass on both. The failing set is identical on both (the same 16 pre-existing failures listed above), so the PR adds no failure on TimescaleDB.uv run pytest tests/test_group_user_loading.py tests/api/test_group.py -qon the branch against that database: 35 passed. The app and its migrations create no hypertables (timescaledb_information.hypertablesis empty aftermake run-migration), so on TimescaleDB the touched tables are ordinary PostgreSQL tables. As an extra check,node_user_usageswas converted to a hypertable by hand (PK(id, created_at), 1-day chunks, 30 chunks, 100k rows):reset_all_users_data_usage(clean_chart_data=True)at 50k users completed in 1.46 s / 7 statements and theDELETE ... WHERE user_id IN (SELECT id FROM users)ran in 23 ms (ModifyHypertableover the 30 chunks).ruff check .reports 4 errors (app/routers/passkey.pyx2,tests/api/test_user.py,tests/test_review_users_unit.py). They are identical on a cleanorigin/devcheckout and are not touched by this PR.New tests (
tests/test_group_user_loading.py, crud level, in-memory SQLite) assert thatget_group/get_group_by_id/load_group_attrsreturn correcttotal_userswithout loadingGroup.usersor touching theuserstable, thatget_users(load_group_inbounds=True)leaves nothing forUser.inbounds()to query, and thatget_users_accessible_tags/_get_users_for_syncnever send more than 10,000 bind parameters per statement for a 25,000-id / 25,000-username list. They also cover the bulk and delete paths:add_groups_to_users/remove_groups_from_userson 25,000 users (at most 10,000 bind parameters per statement, correct memberships and affected users, group inbounds preloaded so no per-user inbound query),remove_group(no executemany associationDELETE, members never loaded, template / inbound associations cleaned, group id and name still readable),get_group_usernames(distinct, chunked), andGroupOperation.remove_group/bulk_remove_groups_by_idsyncing users with fresh groups and no per-user inbound queries.For the bulk user paths:
add_groups_to_userswith 40,000 explicit ids OR-combined withhas_group_ids, andupdate_users_expire/update_users_datalimit/update_users_proxy_settingswith 40,000 explicit ids, never send more than 10,000 bind parameters per statement and return the right users with their inbounds preloaded (no per-user inbound query);get_users(ids=...)andget_users_by_idswith 40,000 ids;remove_userson 12,000 users;reset_all_users_data_usage(scoped to one admin and for everyone, 12,000 users, no id binds);UserOperation._load_users_by_idswith no per-user inbound query; andAdminOperationactivate-all / disable-all syncing users without usage logs and, for activate-all, without per-user inbound queries. Each new test was run with its fix reverted and fails.Before / after
Measured on real PostgreSQL 18.6 and MariaDB 10.6 (Docker, fresh Alembic-migrated database per run, users bulk-inserted). One big group with N users plus a second group sharing half its members, node dispatch stubbed, calling
GroupOperation.modify_group,get_all_groups,get_group_by_idandGroupOperation.remove_group. "Before" isorigin/dev; "After" is this PR (theremove_grouprows are re-measured with the final code). Cells are wall time / SQL statements. Timings are single runs and include a cold first call, so treat small values as order-of-magnitude.PostgreSQL
modify_group)modify_group)modify_group)InterfaceError: the number of query arguments cannot exceed 32767)remove_groupremove_groupremove_groupget_users(username=...))MariaDB
modify_group)modify_group)modify_group)remove_groupremove_groupremove_groupBulk operations (
GroupOperation.bulk_add_groups/bulk_remove_groups, same fresh databases, node dispatch stubbed). Groupg0has N users,g1half of them,g2none. "Before" is the parent commit of the bulk change (294f5f6);bulk.pyis untouched by the earlier commits of this PR, so it equalsorigin/devfor these paths. Cells are wall time / SQL statements.ERR=InterfaceError: the number of query arguments cannot exceed 32767(asyncpg).bulk_add_groups+g1,+g2 (all N users affected)bulk_add_groups+g1 (N/2 affected)bulk_remove_groups-g1 (N/2 affected)bulk_add_groups+g1,+g2bulk_add_groups+g1bulk_remove_groups-g1After the change no statement binds more than 10,000 parameters. For the bulk operations, where both versions completed, the users dispatched to nodes and group memberships are identical (compared per user including inbound tags). For
remove_group, the same N users are dispatched, each with the inbound tags of its remaining groups.Where both versions completed,
total_users(N and N/2), the modify response (inbound_tags, member count) and the number of users dispatched to nodes (5k / 20k / 50k) were identical before and after.TimescaleDB (
timescale/timescaledb:latest-pg17, PG 17.11, fresh Alembic-migrated database per run cloned from one seeded template, 50,000 users:g0all,g125,000,g2none; node dispatch stubbed; viaGroupOperation/UserOperation; wall time / SQL statements; "Before" isorigin/dev, "After" is this PR).ERRORisInterfaceError: the number of query arguments cannot exceed 32767(asyncpg).modify_group)get_all_groups)total_users: 50,000 / 25,000 / 0)remove_group(g0, 50k members)bulk_add_groups+g2 (all 50k affected)bulk_remove_groups-g1 (25k affected)clean_chart_data=Truebulk_add_groupswith 40,000 explicit user idsThe "After" dispatched inbound tags match the expected memberships in every row (for example
modify_group: 25,000 users within-a,in-b,in-cand 25,000 within-a,in-c). To compare results whereorigin/devalso completes, the same eight operations were run on a 20,000-user database (15,000 explicit ids): the final database state and the dispatched users with their tags are identical for every operation exceptremove_group, whereorigin/devdispatches the removed group's inbound (see root cause 5). 20k wall time / statements, before → after:modify_group0.86 s / 135 → 0.49 s / 96, group list 0.21 s / 7 → 0.03 s / 6,remove_group1.27 s / 254 → 0.49 s / 97,bulk_add_groups0.87 s / 52 → 0.78 s / 62,bulk_remove_groups0.33 s / 31 → 0.28 s / 35, expire 0.48 s / 43 → 0.47 s / 50, reset usage 0.29 s / 8 → 0.17 s / 7, explicit ids 0.68 s / 42 → 0.64 s / 50.Bulk user operations (explicit ids, filters, activate / disable all, reset usage)
Same fresh Alembic-migrated databases, node dispatch stubbed, called through
UserOperation.bulk_modify_expire/bulk_modify_datalimit/bulk_modify_proxy_settings,AdminOperationdisable-all / activate-all,UserOperation.reset_users_data_usage. "Before" is88941e81, "After" is this PR. Users are active / expired / limited / disabled, in two admins,g0for all andg1for half. Scenarios run in order on one database per size and state-changing ones leave their effect for the next. Cells are wall time / SQL statements;ERRis the asyncpgInterfaceError. "explicit ids" use every user at 5k / 20k and 40,000 ids at 50k. After the change no statement binds more than 10,000 parameters (explicit ids are rendered into the SQL, 0 id binds; the largest remaining statements are the 5k-id chunks that also bind group ids). Timings are single runs, with the PostgreSQL and MariaDB runs executing concurrently on one machine (the PostgreSQL 50k "after" column is an isolated re-run), so statement counts are exact and wall times indicative.PostgreSQL (before → after)
bulk_add_groups, explicit idsbulk_add_groups, explicit ids OR admin filterbulk_remove_groups, explicit idsMariaDB (before → after)
bulk_add_groups, explicit idsbulk_add_groups, explicit ids OR admin filterbulk_remove_groups, explicit idsWhere both versions completed (every row at 5k and 20k on both databases, and every row on MariaDB 50k), the number of affected users returned, the users dispatched to nodes with their inbound tags, and the resulting database state (status, expire, data limit, used traffic, shadowsocks method, group memberships, usage log / next plan counts) are identical. The PostgreSQL 50k "before" runs stop at the first error, so their later state diverges; the PostgreSQL 50k "after" results were instead compared to the MariaDB 50k "before" results and are identical in all 16 scenarios.
Does the literal
INslow MariaDB down? No. The 1.88 s → 3.15 s / 1.85 s → 2.22 s cells above were measured while PostgreSQL was benchmarked on the same machine. Re-run alone on MariaDB (50k users,bulk_add_groupsthenbulk_remove_groupswith 40,000 explicit ids, 3 runs each, seconds; "before" is88941e81):By-id bulk endpoints (
UserOperation.bulk_disable_users/bulk_enable_users, 40,000 ids of 50,000 users)Wall time / SQL statements / largest bind count in one statement; every dispatch below is 40,000 users.
The users dispatched to nodes with their inbound tags and the final status counts are identical (PostgreSQL "after" equals MariaDB "before"). The per-user ORM loops of these endpoints are unchanged (see Notes).
Is the
COUNTfast without agroups_idindex? Yes, at this scale; no index or migration is added here.(user_id, groups_id), so the count does aSeq Scan on users_groups_association(75k association rows at 50k users).EXPLAIN ANALYZEexecution time was 5.6 ms for the correlated list-style count and 3.3 ms for a single-group count. It grows linearly with the table, so perf(groups): add reverse group membership indexes #772's(groups_id, user_id)index would help PostgreSQL, but it isn't needed to justify this PR.fk_users_groups_association_groups_id_groups (groups_id);EXPLAINshowsref ... Using index, and the whole group list takes about 14 ms at 50k.with_expressionand the nestedselectinloadproduced correct counts and 3 / 216 statements on MariaDB.Screenshots
Not applicable.
Notes for reviewers
total_userswith a SQL count without loadingGroup.users" item.total_usershybrid on the model with aquery_expressionand adds the reverse association indexes. Both touch the same lines inmodels.py/crud/group.py, so one will need a rebase. This PR keeps thelen(users)fallback in the hybrid and adds the count inload_group_attrs, which perf(groups): add reverse group membership indexes #772'swith_expressionapproach is compatible with. Without perf(groups): add reverse group membership indexes #772's(groups_id, user_id)index theCOUNTstill scansusers_groups_associationon PostgreSQL (3-6 ms at 75k rows, see above); MariaDB uses its FK index.load_group_attrsnow takesdbfirst andload_total_usersreplacesload_users;get_group_by_idno longer takesload_users. All callers are updated (crud/group.py,operation/group.py).origin/dev'sGroupOperation.remove_grouploaded the members before deleting the group withget_users(db, query=UserListQuery(group_ids=[db_group.id])); theget_users(UserListQuery(username=...))reload after the delete returned those same session-cachedUserobjects, whoseUser.groupsstill included the removed group, so it sent the removed group's inbound to nodes for members who had no other group providing it (TimescaleDB, 20k users:origin/devdispatched 10,000 users within-band 10,000 within-a; this PR dispatches 10,000 within-band 10,000 with none). This is a bug onorigin/devthat this PR fixes: usernames come fromget_group_usernamesand users are reloaded after the delete (_get_users_for_sync,_reload_group_users,get_users_by_ids) with their remaining groups' inbounds preloaded.bulk_disable_users,bulk_enable_users,bulk_reset_user_data_usage,bulk_revoke_user_sub,bulk_set_owner,bulk_apply_template_to_users,bulk_remove_users) still run per-user ORM loops, in particularrefresh_and_load_userper user inbulk_reset_user_data_usage,bulk_revoke_user_subandbulk_set_owner, andbulk_reset_user_data_usage/bulk_revoke_user_substill sync users that were refreshed one by one, so their groups' inbounds are not preloaded. Only their explicit-idIN(bind limit) and the reload used for the sync after status / disable / enable / apply template were changed.update_users_status(scheduled status jobs) andget_users_simple(ids=...)(list API query parameter) still bind their ids in oneIN; they are not reachable from a bulk request body.admins/group_ids/has_group_idsfilters are small sets and are still bound.create_user_template/modify_user_templatestill load group members (perf(templates): avoid loading group members and batch list relations #944).Summary by CodeRabbit