Draft: [SPIKE] Enhanced filtering based on project/group
What does this MR do and why?
This is a SPIKE MR to investigate and gather feedback to add group/project level filtering to existing AddOn eligible user list.
- Resolver:
ee/app/graphql/resolvers/gitlab_subscriptions/add_on_eligible_users_resolver.rb
- Finder:
ee/app/finders/gitlab_subscriptions/add_on_eligible_users_finder.rb
Related previous MR: !129203 (merged)
Screenshots or screen recordings
How to set up and validate locally
GraphQL queries
- Filter by
group(sub-group)
:
by_group
{
namespace(fullPath: "<GROUP-FULL-PATH>") {
addOnEligibleUsers(addOnType: CODE_SUGGESTIONS, filterByGroup: "gid://gitlab/Group/<SUB-GROUP-ID>", search: "test") {
nodes {
id
username
name
publicEmail
avatarUrl
webUrl
lastActivityOn
lastLoginAt
addOnAssignments(
addOnPurchaseIds: ["gid://gitlab/GitlabSubscriptions::AddOnPurchase/<ADD-ON-PURCHASE-ID>"]
) {
nodes {
addOnPurchase {
name
}
}
}
}
}
}
}
- Filter by
project
:
by_project
{
namespace(fullPath: "<GROUP-FULL-PATH>") {
addOnEligibleUsers(addOnType: CODE_SUGGESTIONS, filterByProject: "gid://gitlab/Project/<PROJECT-ID>") {
nodes {
id
username
name
publicEmail
avatarUrl
webUrl
lastActivityOn
lastLoginAt
addOnAssignments(
addOnPurchaseIds: ["gid://gitlab/GitlabSubscriptions::AddOnPurchase/<ADD-ON-PURCHASE-ID>"]
) {
nodes {
addOnPurchase {
name
}
}
}
}
}
Create seed records and test query
- Create a new
group
. Add yourself (logged in user) asowner
. - Also add few members as
guest
anddeveloper
to thegroup
- Create a
sub-group
and add few members asdeveloper/guest
- Create a project for the
sub-group
andgroup
, and add few members asdeveloper/guest
- Enable the
hamilton_seat_management
FFFeature.enable(:hamilton_seat_management)
- Create an add on purchase for that group
GitlabSubscriptions::AddOnPurchase.create!(add_on: GitlabSubscriptions::AddOn.first, expires_on: 1.year.from_now, namespace_id: <GROUP-ID>, quantity: 10, purchase_xid: 'A-12345')
- Visit
http://gdk.test:3000/-/graphql-explorer
, and add the query specified above
SQL Performance Analysis
Recap on existing implementation
To recap the existing SQL query for the AddOnEligibleUsersFinder, please visit the previous MR: !129203 (merged) Relavant comment discussion: !129203 (comment 1559493910) And query plan:
- First run: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22786/commands/73487
- Second run: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22786/commands/73488
SPIKE
This SPIKE MR refactors the existing query to add filtering based by group or project, that will be passed in as query argument.
If the list if filtered by a group/project, we will only show eligible members that is:
- direct members of the group/project
- inherited members of the ancestors of group/project
- invited members to the self and ancestors
If we have a tree structure something like below, with Germany and France as root-group:
Germany {direct: ['user-1', 'user-2']}
|
- Munich {direct: 'user-3' , inherited: ['user-1', 'user-2']}
|
Project-X {direct: 'user-4' , inherited: ['user-1', 'user-2', 'user-3']}
- Berlin {direct: 'user-5', inherited: ['user-1', 'user-2'], invited: {Paris: {'user-a', 'user-b'}}
France {direct: ['user-a']}
|
- Paris {direct: 'user-b' , inherited: ['user-a']}
Considering we are querying for eligible users, inside the scope of root-group Germany
:
- If we
filterByGroup: 'Munich'
, then the eligible users list would be:[user-1, user-2, user-3]
- If we
filterByProject: 'Project-X'
, then the eligible users list would be:[user-1, user-2, user-3, user-4]
- If we
filterByGroup: 'Berlin'
, then the eligible users list would be:[user-1, user-2, user-5, user-a, user-b]
Final SQL and query plan is shown in this comment: !135586 (comment 1639421306)
For historical log on how we started:
Filter by Group
SQL
SELECT
"users"."id",
"users"."email",
"users"."encrypted_password",
"users"."reset_password_token",
"users"."reset_password_sent_at",
"users"."remember_created_at",
"users"."sign_in_count",
"users"."current_sign_in_at",
"users"."last_sign_in_at",
"users"."current_sign_in_ip",
"users"."last_sign_in_ip",
"users"."created_at",
"users"."updated_at",
"users"."name",
"users"."admin",
"users"."projects_limit",
"users"."failed_attempts",
"users"."locked_at",
"users"."username",
"users"."can_create_group",
"users"."can_create_team",
"users"."state",
"users"."color_scheme_id",
"users"."password_expires_at",
"users"."created_by_id",
"users"."last_credential_check_at",
"users"."avatar",
"users"."confirmation_token",
"users"."confirmed_at",
"users"."confirmation_sent_at",
"users"."unconfirmed_email",
"users"."hide_no_ssh_key",
"users"."admin_email_unsubscribed_at",
"users"."notification_email",
"users"."hide_no_password",
"users"."password_automatically_set",
"users"."encrypted_otp_secret",
"users"."encrypted_otp_secret_iv",
"users"."encrypted_otp_secret_salt",
"users"."otp_required_for_login",
"users"."otp_backup_codes",
"users"."public_email",
"users"."dashboard",
"users"."project_view",
"users"."consumed_timestep",
"users"."layout",
"users"."hide_project_limit",
"users"."note",
"users"."unlock_token",
"users"."otp_grace_period_started_at",
"users"."external",
"users"."incoming_email_token",
"users"."auditor",
"users"."require_two_factor_authentication_from_group",
"users"."two_factor_grace_period",
"users"."last_activity_on",
"users"."notified_of_own_activity",
"users"."preferred_language",
"users"."theme_id",
"users"."accepted_term_id",
"users"."feed_token",
"users"."private_profile",
"users"."roadmap_layout",
"users"."include_private_contributions",
"users"."commit_email",
"users"."group_view",
"users"."managing_group_id",
"users"."first_name",
"users"."last_name",
"users"."static_object_token",
"users"."role",
"users"."user_type",
"users"."static_object_token_encrypted",
"users"."otp_secret_expires_at",
"users"."onboarding_in_progress"
FROM
"users"
WHERE
"users"."id" IN (WITH "our_namespace_bans" AS (
SELECT
"namespace_bans"."user_id"
FROM
"namespace_bans"
WHERE
"namespace_bans"."namespace_id" = 9970), "group_ancestors" AS (
SELECT
"namespaces"."id", "namespaces"."name", "namespaces"."path", "namespaces"."owner_id", "namespaces"."created_at", "namespaces"."updated_at", "namespaces"."type", "namespaces"."description", "namespaces"."avatar", "namespaces"."membership_lock", "namespaces"."share_with_group_lock", "namespaces"."visibility_level", "namespaces"."request_access_enabled", "namespaces"."ldap_sync_status", "namespaces"."ldap_sync_error", "namespaces"."ldap_sync_last_update_at", "namespaces"."ldap_sync_last_successful_update_at", "namespaces"."ldap_sync_last_sync_at", "namespaces"."description_html", "namespaces"."lfs_enabled", "namespaces"."parent_id", "namespaces"."shared_runners_minutes_limit", "namespaces"."repository_size_limit", "namespaces"."require_two_factor_authentication", "namespaces"."two_factor_grace_period", "namespaces"."cached_markdown_version", "namespaces"."project_creation_level", "namespaces"."runners_token", "namespaces"."file_template_project_id", "namespaces"."saml_discovery_token", "namespaces"."runners_token_encrypted", "namespaces"."custom_project_templates_group_id", "namespaces"."auto_devops_enabled", "namespaces"."extra_shared_runners_minutes_limit", "namespaces"."last_ci_minutes_notification_at", "namespaces"."last_ci_minutes_usage_notification_level", "namespaces"."subgroup_creation_level", "namespaces"."emails_disabled", "namespaces"."max_pages_size", "namespaces"."max_artifacts_size", "namespaces"."mentions_disabled", "namespaces"."default_branch_protection", "namespaces"."max_personal_access_token_lifetime", "namespaces"."push_rule_id", "namespaces"."shared_runners_enabled", "namespaces"."allow_descendants_override_disabled_shared_runners", "namespaces"."traversal_ids", "namespaces"."organization_id"
FROM
"namespaces"
WHERE
"namespaces"."type" = 'Group'
AND "namespaces"."id" IN (9970, 2255492, 2259137, 2259139, 2259140)), "groups_invited_to_group_ancestors_cte" AS (
SELECT
"namespaces"."traversal_ids"
FROM
"namespaces"
WHERE
"namespaces"."type" = 'Group'
AND ("namespaces"."id" IN (
SELECT
"group_group_links"."shared_with_group_id"
FROM
group_group_links
WHERE
"group_group_links"."shared_group_id" IN (
SELECT
id
FROM
group_ancestors))))
SELECT
"user_id"
FROM (
SELECT
"members".*
FROM
"members"
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE
"members"."type" = 'GroupMember'
AND "members"."source_type" = 'Namespace'
AND ("users"."state" IN ('active'))
AND "users"."user_type" IN (0, 4, 5)
AND "members"."state" = 0
AND "members"."requested_at" IS NULL
AND "members"."invite_token" IS NULL
AND (members.access_level > 5)
AND "members"."user_id" NOT IN (
SELECT
"user_id"
FROM
"our_namespace_bans")
AND ("members"."source_id" IN (
SELECT
unnest("groups_invited_to_group_ancestors_cte"."traversal_ids")
FROM
groups_invited_to_group_ancestors_cte
UNION
SELECT
id
FROM
group_ancestors))) subquery
)
ORDER BY
"users"."id" DESC
LIMIT 101;
Filter by project query
SQL
SELECT
"users"."id",
"users"."email",
"users"."encrypted_password",
"users"."reset_password_token",
"users"."reset_password_sent_at",
"users"."remember_created_at",
"users"."sign_in_count",
"users"."current_sign_in_at",
"users"."last_sign_in_at",
"users"."current_sign_in_ip",
"users"."last_sign_in_ip",
"users"."created_at",
"users"."updated_at",
"users"."name",
"users"."admin",
"users"."projects_limit",
"users"."failed_attempts",
"users"."locked_at",
"users"."username",
"users"."can_create_group",
"users"."can_create_team",
"users"."state",
"users"."color_scheme_id",
"users"."password_expires_at",
"users"."created_by_id",
"users"."last_credential_check_at",
"users"."avatar",
"users"."confirmation_token",
"users"."confirmed_at",
"users"."confirmation_sent_at",
"users"."unconfirmed_email",
"users"."hide_no_ssh_key",
"users"."admin_email_unsubscribed_at",
"users"."notification_email",
"users"."hide_no_password",
"users"."password_automatically_set",
"users"."encrypted_otp_secret",
"users"."encrypted_otp_secret_iv",
"users"."encrypted_otp_secret_salt",
"users"."otp_required_for_login",
"users"."otp_backup_codes",
"users"."public_email",
"users"."dashboard",
"users"."project_view",
"users"."consumed_timestep",
"users"."layout",
"users"."hide_project_limit",
"users"."note",
"users"."unlock_token",
"users"."otp_grace_period_started_at",
"users"."external",
"users"."incoming_email_token",
"users"."auditor",
"users"."require_two_factor_authentication_from_group",
"users"."two_factor_grace_period",
"users"."last_activity_on",
"users"."notified_of_own_activity",
"users"."preferred_language",
"users"."theme_id",
"users"."accepted_term_id",
"users"."feed_token",
"users"."private_profile",
"users"."roadmap_layout",
"users"."include_private_contributions",
"users"."commit_email",
"users"."group_view",
"users"."managing_group_id",
"users"."first_name",
"users"."last_name",
"users"."static_object_token",
"users"."role",
"users"."user_type",
"users"."static_object_token_encrypted",
"users"."otp_secret_expires_at",
"users"."onboarding_in_progress"
FROM
"users"
WHERE
"users"."id" IN (WITH "our_namespace_bans" AS (
SELECT
"namespace_bans"."user_id"
FROM
"namespace_bans"
WHERE
"namespace_bans"."namespace_id" = 9970), "group_ancestors" AS (
SELECT
"namespaces"."id", "namespaces"."name", "namespaces"."path", "namespaces"."owner_id", "namespaces"."created_at", "namespaces"."updated_at", "namespaces"."type", "namespaces"."description", "namespaces"."avatar", "namespaces"."membership_lock", "namespaces"."share_with_group_lock", "namespaces"."visibility_level", "namespaces"."request_access_enabled", "namespaces"."ldap_sync_status", "namespaces"."ldap_sync_error", "namespaces"."ldap_sync_last_update_at", "namespaces"."ldap_sync_last_successful_update_at", "namespaces"."ldap_sync_last_sync_at", "namespaces"."description_html", "namespaces"."lfs_enabled", "namespaces"."parent_id", "namespaces"."shared_runners_minutes_limit", "namespaces"."repository_size_limit", "namespaces"."require_two_factor_authentication", "namespaces"."two_factor_grace_period", "namespaces"."cached_markdown_version", "namespaces"."project_creation_level", "namespaces"."runners_token", "namespaces"."file_template_project_id", "namespaces"."saml_discovery_token", "namespaces"."runners_token_encrypted", "namespaces"."custom_project_templates_group_id", "namespaces"."auto_devops_enabled", "namespaces"."extra_shared_runners_minutes_limit", "namespaces"."last_ci_minutes_notification_at", "namespaces"."last_ci_minutes_usage_notification_level", "namespaces"."subgroup_creation_level", "namespaces"."emails_disabled", "namespaces"."max_pages_size", "namespaces"."max_artifacts_size", "namespaces"."mentions_disabled", "namespaces"."default_branch_protection", "namespaces"."max_personal_access_token_lifetime", "namespaces"."push_rule_id", "namespaces"."shared_runners_enabled", "namespaces"."allow_descendants_override_disabled_shared_runners", "namespaces"."traversal_ids", "namespaces"."organization_id"
FROM
"namespaces"
WHERE
"namespaces"."type" = 'Group'
AND "namespaces"."id" IN (9970)), "groups_invited_to_group_ancestors_cte" AS (
SELECT
"namespaces"."traversal_ids"
FROM
"namespaces"
WHERE
"namespaces"."type" = 'Group'
AND ("namespaces"."id" IN (
SELECT
"group_group_links"."shared_with_group_id"
FROM
group_group_links
WHERE
"group_group_links"."shared_group_id" IN (
SELECT
id
FROM
group_ancestors)
UNION
SELECT
"project_group_links"."group_id"
FROM
project_group_links
WHERE
"project_group_links"."project_id" = 278964)))
SELECT
"members"."user_id"
FROM ((
SELECT
"members".*
FROM
"members"
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE
"members"."type" = 'ProjectMember'
AND "members"."source_type" = 'Project'
AND ("users"."state" IN ('active'))
AND "users"."user_type" IN (0, 4, 5)
AND "members"."state" = 0
AND "members"."requested_at" IS NULL
AND "members"."invite_token" IS NULL
AND (members.access_level > 5)
AND "members"."user_id" NOT IN (
SELECT
"user_id"
FROM
"our_namespace_bans")
AND "members"."source_id" = 278964)
UNION (
SELECT
"members".*
FROM
"members"
LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id"
WHERE
"members"."type" = 'GroupMember'
AND "members"."source_type" = 'Namespace'
AND ("users"."state" IN ('active'))
AND "users"."user_type" IN (0, 4, 5)
AND "members"."state" = 0
AND "members"."requested_at" IS NULL
AND "members"."invite_token" IS NULL
AND (members.access_level > 5)
AND "members"."user_id" NOT IN (
SELECT
"user_id"
FROM
"our_namespace_bans")
AND ("members"."source_id" IN (
SELECT
unnest("groups_invited_to_group_ancestors_cte"."traversal_ids")
FROM
groups_invited_to_group_ancestors_cte
UNION
SELECT
id
FROM
group_ancestors)))) members
)
ORDER BY
"users"."id" DESC
LIMIT 101;
Implementation
Existing query to fetch all eligible users has been decomposed to smaller methods and reused to generate query fro each case:
- full list of eligible users
- filtered list by group
- filtered list by project
The major difference in the SQL query between group
and project
filter is the addition of few extra queries for project:
- We need to make extra query to also consider
ProjectGroupLink
forfilter_project
- We need to make extra query to get direct
ProjectMember
forfilter_project
Rest is same to fetch direct and invited, members of all the ancestor groups.
MR acceptance checklist
This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.
-
I have evaluated the MR acceptance checklist for this MR.
Related to #428880