feat(BA-3953): Implement bulk_update_users mutation (Strawberry GQL)#8771
feat(BA-3953): Implement bulk_update_users mutation (Strawberry GQL)#8771
Conversation
Add admin_bulk_update_users mutation for updating multiple users in bulk with partial failure support using savepoint-based transactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: octodog <mu001@lablup.com>
|
|
||
| async def _update_single_user_validated( | ||
| self, | ||
| conn: AsyncConnection, | ||
| user_id: UUID, | ||
| updater_spec: UserUpdaterSpec, | ||
| ) -> UserData: | ||
| """Update a single user with full validation. | ||
|
|
||
| Extracted from update_user_validated to be reused by both | ||
| single and bulk update operations. | ||
| """ | ||
| to_update = updater_spec.build_values() | ||
|
|
||
| # Get current user data for validation (by UUID) | ||
| current_user = await self._get_user_by_uuid_with_conn(conn, user_id) | ||
|
|
There was a problem hiding this comment.
_update_single_user_validated largely duplicates the validation/update logic of the existing update_user_validated — the only difference is that it uses _get_user_by_uuid_with_conn instead of _get_user_by_email_with_conn for user lookup.
To reduce this duplication, we could extract the logic after user lookup into a common helper like _apply_user_update(conn, current_user, updater_spec). What do you think?
There was a problem hiding this comment.
Pull request overview
Implements a new bulk user update path (repository → service/processors → Strawberry GraphQL) to support partially successful bulk updates using savepoints, following the existing bulk-create layering pattern.
Changes:
- Add bulk-update result/error types (
BulkUpdaterError,BulkUserUpdateResultData) and repository/db-source bulk update implementation with savepoint-based partial failure. - Introduce service action/processor wiring for
bulk_modify_users. - Add Strawberry GraphQL inputs/payloads and
admin_bulk_update_usersmutation, plus repository-level tests for bulk update success/partial failure.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/manager/repositories/user/test_user_repository.py | Adds unit tests for repository bulk update success and partial failure behavior. |
| src/ai/backend/manager/services/user/service.py | Adds bulk_modify_users() service method calling repository bulk update. |
| src/ai/backend/manager/services/user/processors.py | Registers bulk_modify_users action processor and supported action list entry. |
| src/ai/backend/manager/services/user/actions/modify_user.py | Adds UserUpdateSpec, BulkModifyUserAction, and result wrapper. |
| src/ai/backend/manager/repositories/user/repository.py | Adds repository bulk_update_users_validated() method with resilience decorator. |
| src/ai/backend/manager/repositories/user/db_source/db_source.py | Implements bulk_update_users_validated() with savepoints and UUID-based lookup helper. |
| src/ai/backend/manager/repositories/base/updater.py | Introduces BulkUpdaterError dataclass for bulk update failures. |
| src/ai/backend/manager/repositories/base/init.py | Exports BulkUpdaterError from the repositories base package. |
| src/ai/backend/manager/data/user/types.py | Adds BulkUserUpdateResultData type to return successes/failures from bulk updates. |
| src/ai/backend/manager/api/gql/user/types/payloads.py | Adds bulk update payload and error GraphQL types. |
| src/ai/backend/manager/api/gql/user/types/inputs.py | Adds bulk update GraphQL input types (item + wrapper input). |
| src/ai/backend/manager/api/gql/user/types/init.py | Re-exports new bulk update input/payload GraphQL types. |
| src/ai/backend/manager/api/gql/user/resolver/mutation.py | Adds admin_bulk_update_users mutation and maps GraphQL input to updater specs. |
| src/ai/backend/manager/api/gql/user/resolver/init.py | Exposes admin_bulk_update_users from user resolver package. |
| src/ai/backend/manager/api/gql/user/init.py | Re-exports new mutation at the module level for schema wiring. |
| src/ai/backend/manager/api/gql/schema.py | Registers admin_bulk_update_users on the root Mutation type. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| class BulkUpdateUserErrorGQL: | ||
| """Error information for a single user that failed during bulk update.""" | ||
|
|
||
| user_id: UUID = strawberry.field(description="UUID of the user that failed to update.") | ||
| message: str = strawberry.field(description="Error message describing the failure.") |
There was a problem hiding this comment.
Unlike BulkCreateUserErrorGQL which uses index/username/email to identify failed items (since newly created users don't have UUIDs yet), BulkUpdateUserErrorGQL uses user_id directly since update targets are already identified by UUID in the input.
Omitted fields in UpdateUserInputGQL previously defaulted to None, which caused OptionalState.from_graphql(None) to raise ValueError. Changed defaults to strawberry.UNSET so omitted fields correctly produce nop() (no-op), enabling proper partial updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: octodog <mu001@lablup.com>
| # Convert status enum if provided | ||
| status_state: OptionalState[UserStatus] = OptionalState.nop() | ||
| if user_input.status is not None and user_input.status is not UNSET: | ||
| status_state = OptionalState.update(UserStatus(user_input.status.value)) | ||
|
|
||
| # Convert role enum if provided | ||
| role_state: OptionalState[UserRole] = OptionalState.nop() | ||
| if user_input.role is not None and user_input.role is not UNSET: | ||
| role_state = OptionalState.update(UserRole(user_input.role.value)) |
There was a problem hiding this comment.
status and role use the extract-to-variable pattern (like password and group_ids) because they require enum conversion before passing to OptionalState. However, this means explicit null for these fields is silently treated as nop(), while other OptionalState fields (e.g. username, full_name) raise ValueError on explicit null via OptionalState.from_graphql(None). This inconsistency is minor since clients shouldn't send explicit null for NOT NULL fields, but could be unified later by adding early validation that rejects explicit null for non-nullable fields before reaching the conversion logic.
| Returns: | ||
| BulkUpdateUsersPayloadGQL with updated users and failures. | ||
| """ | ||
| ctx = info.context |
There was a problem hiding this comment.
I think we should call check_admin_only at first
| "Pairs a user ID with the fields to update." | ||
| ), | ||
| ) | ||
| class BulkUpdateUserItemInputGQL: |
There was a problem hiding this comment.
Shouldn’t we also add V2 to the Python types?
There was a problem hiding this comment.
And personally, I’m not sure whether we really need to add V2 to the mutation, and mutation types as well.
| # Convert password if provided | ||
| password_state: OptionalState[PasswordInfo] = OptionalState.nop() | ||
| if user_input.password is not None and user_input.password is not UNSET: | ||
| password_state = OptionalState.update( | ||
| PasswordInfo( | ||
| password=user_input.password, | ||
| algorithm=auth_config.password_hash_algorithm, | ||
| rounds=auth_config.password_hash_rounds, | ||
| salt_size=auth_config.password_hash_salt_size, | ||
| ) | ||
| ) | ||
|
|
||
| # Convert group_ids if provided | ||
| group_ids_state: OptionalState[list[str]] = OptionalState.nop() | ||
| if user_input.group_ids is not None and user_input.group_ids is not UNSET: | ||
| group_ids_state = OptionalState.update([str(gid) for gid in user_input.group_ids]) | ||
|
|
||
| # Convert status enum if provided | ||
| status_state: OptionalState[UserStatus] = OptionalState.nop() | ||
| if user_input.status is not None and user_input.status is not UNSET: | ||
| status_state = OptionalState.update(UserStatus(user_input.status.value)) | ||
|
|
||
| # Convert role enum if provided | ||
| role_state: OptionalState[UserRole] = OptionalState.nop() | ||
| if user_input.role is not None and user_input.role is not UNSET: | ||
| role_state = OptionalState.update(UserRole(user_input.role.value)) |
There was a problem hiding this comment.
When value conversion is required, the logic tends to become overly messy.
I think we'd better to add map like method like this to simplify the code.
group_ids_state = OptionalState.from_graphql(user_input.group_ids).map(
lambda gids: [str(gid) for gid in gids]
)
status_state = OptionalState.from_graphql(user_input.status).map(
lambda s: UserStatus(s.value)
)
role_state = OptionalState.from_graphql(user_input.role).map(
lambda r: UserRole(r.value)
)What do you think? @HyeockJinKim
| successes: list[UserData] = [] | ||
| failures: list[BulkUpdaterError[UserRow]] = [] | ||
|
|
||
| async with self._db.begin() as conn: |
There was a problem hiding this comment.
If possible, new code should be written using the ORM.
Is there a particular reason you implemented this using the Connection and Core API style?
Summary
admin_bulk_update_usersStrawberry GraphQL mutation for updating multiple users in bulkRelated Issue
Changes by Layer
BulkUpdaterErrordataclass (mirrorsBulkCreatorError)BulkUserUpdateResultDatawith success/failure trackingBulkModifyUserAction,BulkModifyUserActionResult,UserUpdateSpecbulk_update_users_validated()with savepoint-based partial failure,_update_single_user_validated()helper,_get_user_by_uuid_with_conn()lookupbulk_update_users_validated()with resilience decoratorbulk_modify_users()methodbulk_modify_usersprocessorBulkUpdateUserItemInputGQL,BulkUpdateUserInputGQL,BulkUpdateUserErrorGQL,BulkUpdateUsersPayloadGQLadmin_bulk_update_usersmutation with full field mapping fromUpdateUserInputGQLtoUserUpdaterSpecMutationclassbulk_update_users_validatedTest plan
pants lint --changed-since=HEAD~1passespants check --changed-since=HEAD~1passes (mypy)pants test --changed-since=HEAD~1 --changed-dependents=transitivepasses🤖 Generated with Claude Code
📚 Documentation preview 📚: https://sorna--8771.org.readthedocs.build/en/8771/
📚 Documentation preview 📚: https://sorna-ko--8771.org.readthedocs.build/ko/8771/