Skip to content

feat(BA-3953): Implement bulk_update_users mutation (Strawberry GQL)#8771

Open
MinJuTur wants to merge 5 commits intomainfrom
feat/BA-3953-implement-bulk_update_users-mutation-Strawberry-GQL
Open

feat(BA-3953): Implement bulk_update_users mutation (Strawberry GQL)#8771
MinJuTur wants to merge 5 commits intomainfrom
feat/BA-3953-implement-bulk_update_users-mutation-Strawberry-GQL

Conversation

@MinJuTur
Copy link
Contributor

@MinJuTur MinJuTur commented Feb 11, 2026

Summary

  • Implement admin_bulk_update_users Strawberry GraphQL mutation for updating multiple users in bulk
  • Each user update is executed within a savepoint, enabling partial failure support (successful updates persist even when others fail)

Related Issue

Changes by Layer

  • Base Repository: Add BulkUpdaterError dataclass (mirrors BulkCreatorError)
  • Data Types: Add BulkUserUpdateResultData with success/failure tracking
  • Actions: Add BulkModifyUserAction, BulkModifyUserActionResult, UserUpdateSpec
  • DB Source: Add bulk_update_users_validated() with savepoint-based partial failure, _update_single_user_validated() helper, _get_user_by_uuid_with_conn() lookup
  • Repository: Add bulk_update_users_validated() with resilience decorator
  • Service: Add bulk_modify_users() method
  • Processors: Register bulk_modify_users processor
  • GQL Types: Add BulkUpdateUserItemInputGQL, BulkUpdateUserInputGQL, BulkUpdateUserErrorGQL, BulkUpdateUsersPayloadGQL
  • Resolver: Add admin_bulk_update_users mutation with full field mapping from UpdateUserInputGQL to UserUpdaterSpec
  • Schema: Register mutation in Mutation class
  • Tests: Add success and partial failure tests for bulk_update_users_validated

Test plan

  • pants lint --changed-since=HEAD~1 passes
  • pants check --changed-since=HEAD~1 passes (mypy)
  • pants test --changed-since=HEAD~1 --changed-dependents=transitive passes
  • Unit test: bulk update 3 users successfully
  • Unit test: partial failure (1 success + 1 non-existent UUID failure)

🤖 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/

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>
Copilot AI review requested due to automatic review settings February 11, 2026 08:09
@github-actions github-actions bot added size:XL 500~ LoC comp:manager Related to Manager component labels Feb 11, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: octodog <mu001@lablup.com>
@github-actions github-actions bot added the area:docs Documentations label Feb 11, 2026
Comment on lines +439 to +455

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_users mutation, 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.

Comment on lines +73 to +77
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.")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

MinJuTur and others added 2 commits February 11, 2026 09:26
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>
Comment on lines +227 to +235
# 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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should call check_admin_only at first

"Pairs a user ID with the fields to update."
),
)
class BulkUpdateUserItemInputGQL:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t we also add V2 to the Python types?

Copy link
Member

@jopemachine jopemachine Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And personally, I’m not sure whether we really need to add V2 to the mutation, and mutation types as well.

Comment on lines +210 to +235
# 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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentations comp:manager Related to Manager component Intern-OKR size:XL 500~ LoC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants