Add refresh assignment worker
What does this MR do and why?
Add new worker to refresh the user assignments records for the given root_namespace and the code_suggestions
AddOn.
It goes through all the assignments and checks if the associated user is still eligible, or not.
When the user is not eligible, it removes the assignment to free up code_suggestions
seat.
This will be enqueued in follow up MR when GroupLink or ProjectLink gets destroyed, so that any seats that can be freed after the invited group removal, is freed.
Follow up MR: !131274 (merged)
Screenshots or screen recordings
n/a
How to set up and validate locally
- Check out this branch
- Create a new root group namespace
- Setup some seed records
namespace = Namespace.last
add_on = GitlabSubscriptions::AddOn.find_or_create_by!(name: "code_suggestions") {|e| e.description = "Test"}
add_on_purchase = GitlabSubscriptions::AddOnPurchase.create!(
add_on: add_on, namespace: namespace, expires_on: 1.month.from_now, quantity: 5, purchase_xid: 'A-S0001'
)
user_1 = User.find 69 # John Doe6
user_2 = User.find 70 # John Doe7
# add user as guest
namespace.add_guest(user_2)
# assign seat to the user
add_on_purchase.assigned_users.create(user: user_1)
add_on_purchase.assigned_users.create(user: user_2)
# enable the feature flag
Feature.enable(:hamilton_seat_management)
# check the current assigned_users count
add_on_purchase.assigned_users.count # 2
# removes only seat assignments to user_1 as user_2 is still eligible with `guest` role
GitlabSubscriptions::AddOnPurchases::RefreshUserAssignmentsWorker.new.perform(namespace.id)
add_on_purchase.assigned_users.count # 1
# remove the existing memberships
namespace.members.where(user: user_2).destroy_all
# removes the seat assignment also for user_2
GitlabSubscriptions::AddOnPurchases::RefreshUserAssignmentsWorker.new.perform(namespace.id)
add_on_purchase.assigned_users.count # 0
SQL
This is similar to the MR: !130622 (merged)
Here we loop through assignments in batches, and then delete the the ineligible assignment in bulk.
each_batch_and_delete
# find in batches
SELECT "subscription_user_add_on_assignments"."id" FROM "subscription_user_add_on_assignments" WHERE "subscription_user_add_on_assignments"."add_on_purchase_id" = 64 AND "subscription_user_add_on_assignments"."id" >= 111 ORDER BY "subscription_user_add_on_assignments"."id" ASC LIMIT 1 OFFSET 500
# pluck user_ids
SELECT "subscription_user_add_on_assignments"."user_id" FROM "subscription_user_add_on_assignments" WHERE "subscription_user_add_on_assignments"."add_on_purchase_id" = 64 AND "subscription_user_add_on_assignments"."id" >= 11
# delete
DELETE FROM "subscription_user_add_on_assignments" WHERE "subscription_user_add_on_assignments"."add_on_purchase_id" = 64 AND "subscription_user_add_on_assignments"."id" >= 111 AND "subscription_user_add_on_assignments"."user_id" IN (615, 616)
PostgresAI:
- each_batch query: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22004/commands/71183
- pluck user_ids for batch: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22004/commands/71182
- Delete assignments in batch: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22004/commands/71184
filter ineligible user_ids
# group member
SELECT "users"."id" FROM "users" WHERE "users"."id" IN (SELECT "members"."user_id" FROM "members" LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id" WHERE "members"."type" = 'GroupMember' AND "members"."source_type" = 'Namespace' AND "users"."state" = 'active' AND "members"."state" = 0 AND "members"."requested_at" IS NULL AND "members"."invite_token" IS NULL AND (members.access_level > 5) AND "members"."source_id" IN (SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{1418}'))) AND "members"."user_id" NOT IN (SELECT "namespace_bans"."user_id" FROM "namespace_bans" WHERE "namespace_bans"."namespace_id" = 1418) /* allow_cross_joins_across_databases */) AND "users"."user_type" IN (0, 4, 5) AND "users"."id" IN (615, 616)
# project member
SELECT "users"."id" FROM "users" WHERE "users"."id" IN (SELECT "members"."user_id" FROM "members" WHERE "members"."type" = 'ProjectMember' AND "members"."source_type" = 'Project' AND "members"."state" = 0 AND "members"."requested_at" IS NULL AND "members"."invite_token" IS NULL AND (members.access_level > 5) AND "members"."source_id" IN (SELECT "projects"."id" FROM "projects" INNER JOIN "namespaces" ON "namespaces"."type" = 'Group' AND "namespaces"."id" = "projects"."namespace_id" AND "namespaces"."type" = 'Group' WHERE "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{1418}')))) AND "members"."user_id" NOT IN (SELECT "namespace_bans"."user_id" FROM "namespace_bans" WHERE "namespace_bans"."namespace_id" = 1418)) AND "users"."user_type" IN (0, 4, 5) AND ("users"."state" IN ('active')) AND "users"."id" IN (615, 616)
# group group link member
SELECT "users"."id" FROM "users" WHERE "users"."id" IN (SELECT "members"."user_id" FROM "members" LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id" WHERE "users"."state" = 'active' AND "members"."state" = 0 AND "members"."requested_at" IS NULL AND "members"."invite_token" IS NULL AND (members.access_level > 5) AND "members"."source_id" IN (WITH "base_ancestors_cte" AS MATERIALIZED (SELECT "namespaces"."traversal_ids" FROM "namespaces" INNER JOIN "group_group_links" ON "group_group_links"."shared_with_group_id" = "namespaces"."id" WHERE "namespaces"."type" = 'Group' AND "group_group_links"."shared_group_id" IN (SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{1418}')))) SELECT "namespaces"."id" FROM "namespaces" INNER JOIN (SELECT DISTINCT unnest("base_ancestors_cte"."traversal_ids") FROM base_ancestors_cte) AS ancestors(ancestor_id) ON namespaces.id = ancestors.ancestor_id WHERE "namespaces"."type" = 'Group') AND "members"."type" = 'GroupMember' AND "members"."source_type" = 'Namespace' AND "members"."user_id" NOT IN (SELECT "namespace_bans"."user_id" FROM "namespace_bans" WHERE "namespace_bans"."namespace_id" = 1418) /* allow_cross_joins_across_databases */) AND "users"."user_type" IN (0, 4, 5) AND "users"."id" IN (615, 616)
# group project link member
SELECT "users"."id" FROM "users" WHERE "users"."id" IN (SELECT "members"."user_id" FROM "members" LEFT OUTER JOIN "users" ON "users"."id" = "members"."user_id" WHERE "users"."state" = 'active' AND "members"."state" = 0 AND "members"."requested_at" IS NULL AND "members"."invite_token" IS NULL AND (members.access_level > 5) AND "members"."source_id" IN (WITH "base_ancestors_cte" AS MATERIALIZED (SELECT "namespaces"."traversal_ids" FROM "namespaces" INNER JOIN "project_group_links" ON "project_group_links"."group_id" = "namespaces"."id" WHERE "namespaces"."type" = 'Group' AND "project_group_links"."project_id" IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."namespace_id" IN (SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{1418}'))))) SELECT "namespaces"."id" FROM "namespaces" INNER JOIN (SELECT DISTINCT unnest("base_ancestors_cte"."traversal_ids") FROM base_ancestors_cte) AS ancestors(ancestor_id) ON namespaces.id = ancestors.ancestor_id WHERE "namespaces"."type" = 'Group') AND "members"."type" = 'GroupMember' AND "members"."source_type" = 'Namespace' AND "members"."user_id" NOT IN (SELECT "namespace_bans"."user_id" FROM "namespace_bans" WHERE "namespace_bans"."namespace_id" = 1418)
PostgresAI:
- group member: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22004/commands/71185
- project member: https://console.postgres.ai/gitlab/gitlab-production-tunnel-pg12/sessions/22004/commands/71186
Database Decision
After the MR review discussion we found out that using .where(usr_id: user_ids)
filter didn't give much improvements on the main 4 queries to find [group, project, group_link, project_link]
members.
Hence we decided to use existing Namespace::BilledUserFinder to fetch all the eligible user_ids
at once, and use Ruby set to filter out ineligible user_ids
.
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 #415302