perf: eliminate redundant GET after create/update and parallelize stats (#95) #112
No reviewers
Labels
No Label
ai-changes-requested
ai-pr-opened
ai-reviewed
ai-reviewing
ai-working
in-next-release
status/in-progress
status/pr-open
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/major-domo-v2#112
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "ai/major-domo-v2#95"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #95
Summary
Eliminates redundant API round-trips after POST/PUT operations in
custom_commands_serviceandhelp_commands_service, and parallelizes theget_statistics()dashboard calls.Changes
services/custom_commands_service.pycreate_command(): Returnresult.model_copy(update={'creator': creator})from theself.create()response instead of a follow-upGET by_name. The creator object is already in scope, so no lookup needed.update_command(): Returnresult.model_copy(update={'creator': command.creator})fromupdate_item_by_field()response instead of a follow-upGET by_name. The original command (and its creator) was already fetched at the start.get_or_create_creator()(PUT case): Usemodel_copy()to update username/display_name locally instead of re-fetching after_update_creator_info.get_or_create_creator()(POST case): ReturnCustomCommandCreator(**result)from the POST response directly instead of a follow-upGET creators.get_statistics(): Replace 9 sequentialawaitcalls withasyncio.gather()— reduces latency from ~450–900ms sequential to ~50–100ms parallel.services/help_commands_service.pycreate_help(): Returnresultfromself.create()directly instead of a follow-upGET by_name.update_help(): Returnself.model_class.from_api_data(result)from the PUT response directly instead of a follow-upGET by_name.tests/test_services_help_commands.pytest_update_help_successto mockclient.put()returning a dict (the updated object) instead ofTrue, matching the real API behavior.What was NOT changed
draft_list_service.add_to_list(): The verification GET was left in place. This method returnsOptional[List[DraftList]]which requires fullTeam+Playernested objects — these are only available from the GET response, not from the POST payload (which only contains IDs). Skipping the GET here would require either changing the return type or making an extra player/team lookup.get_popular_commands(),get_commands_needing_warning(),get_commands_eligible_for_deletion(): These are addressed in issue #89.Test results
977 passed, 2 skipped
AI Code Review
Files Reviewed
services/custom_commands_service.py(modified)services/help_commands_service.py(modified)tests/test_services_help_commands.py(modified)Findings
Correctness
create_command()—result.model_copy(update={"creator": creator}): Correct.self.create()returns a parsedCustomCommandinstance;creatoris in scope from the precedingget_or_create_creator()call.model_copyis the right Pydantic v2 idiom.update_command()—result.model_copy(update={"creator": command.creator}): Correct. Onlycontentandtagsare being updated; the creator is unchanged from thecommandfetched at function start. Re-usingcommand.creatoris safe.get_or_create_creator()PUT case —creator.model_copy(update={"username": ..., "display_name": ...}): Correct. Both updated fields are already in scope; no API round-trip needed.get_or_create_creator()POST case —CustomCommandCreator(**result): Functionally correct if the API returns the created object as a dict (standard REST). Minor style note: the existing pattern in this codebase usesCustomCommandCreator(**creators[0])on GET responses, so this is internally consistent. However,from_api_data(result)would be the more conventional pattern for service-layer construction — not a blocker.get_statistics()—asyncio.gather(): Well-implemented. All 9 coroutines are truly independent (no data dependencies between them).total_uses = sum(...)andmost_popular = ...are correctly placed after the gather, computed from the returned values. Theweek_agotimestamp is computed before the gather, which is correct.update_help()—self.model_class.from_api_data(result): This is the change most worth flagging. The old code checkedif not result:(truthy guard), then did a fresh GET. The new code assumesclient.put()returns a full dict of the updated resource — not a booleanTrue. The PR correctly updates the test mock to match this assumption. If the production API actually returnsTrueon a successful PUT,from_api_data(True)will raise aTypeErrorat runtime. The PR body states this reflects real API behavior, and the test change supports that. Just worth verifying against the API spec (sba.manticorum.com/api/openapi.json) before shipping.create_help()—return result: Correct.self.create()returnsOptional[HelpCommand]; after theif not result: raiseguard,resultis guaranteed to be aHelpCommand. Clean.Security
No issues. No new external inputs, no injection vectors, no credential exposure.
Style & Conventions
model_copy,from_api_data,asyncio.gatherall match established conventions in this codebase.get_client()called within service internals only — not from commands/tasks/views (CLAUDE.md compliance: ✓).Suggestions
custom_commands_service.pychanges (four modified paths:create_command,update_command,get_or_create_creatorPUT case,get_or_create_creatorPOST case). Existing tests still pass (977 total), but they don't isolate and verify the newmodel_copypath. A test that assertsget_command_by_nameis no longer called after create/update would provide stronger regression protection.get_statistics()error isolation: If any one of the 9 gathered calls raises, the exception propagates immediately (defaultasyncio.gatherbehavior). The old sequential code had the same property. No regression, but worth noting —return_exceptions=Truewould allow partial results if that's ever desired.Verdict: COMMENT
All five functional changes are correct and the performance improvement in
get_statistics()is solid. The only actionable item is verifying theupdate_help()assumption thatclient.put()returns the updated resource dict — if that's confirmed, this is a clean approve.Automated review by Claude PR Reviewer
AI Code Review
Files Reviewed
services/custom_commands_service.py(modified)services/help_commands_service.py(modified)tests/test_services_help_commands.py(modified)Findings
Correctness
create_command()→result.model_copy(update={"creator": creator})Verified correct.
BaseService.create()returnsOptional[T]and callsself.model_class.from_api_data(response)internally (line 254 ofbase_service.py), soresultis a fullCustomCommand. Thecreatorobject is already in scope and correctly attached viamodel_copy. The guardif not result: raise BotException(...)prevents aNonedereference. ✓update_command()→result.model_copy(update={"creator": command.creator})Verified correct.
update_item_by_field()callsBaseService.update()which callsfrom_api_data(response)on the PUT response (line 301 ofbase_service.py) and returns aCustomCommand. The PUT response won't include the full nestedcreatorobject, butcommand.creatorwas fetched at the start ofupdate_command()and is accurate. ✓get_or_create_creator()PUT case →model_copyinstead of refetchCorrect. Only
usernameanddisplay_namechange, both are in-scope. The model copy avoids a round-trip where all updated values are already known locally. ✓get_or_create_creator()POST case →CustomCommandCreator(**result)Functionally correct assuming the API's POST
/custom_commands/creatorsresponse includes all required fields (id,created_at,discord_id,username).SBABaseModeluses Pydantic v2 withvalidate_assignment=True— Pydantic v2's defaultextra="ignore"means extra fields are safely dropped, and type coercion (e.g. ISO string → datetime) happens in__init__. The 977 test run provides confidence the API does return these fields.One note:
from_api_data()would be the more idiomatic pattern here (consistent with the rest of the codebase), but**resultworks because Pydantic handles coercion at construction time.get_statistics()→asyncio.gather()9 coroutines, 9 result variables, order matches exactly.
all_commandsis aList[CustomCommand],popular_commandsis aList[CustomCommand]. Post-gather linestotal_uses = sum(...)andmost_popular = popular_commands[0] if ...are correct. ✓create_help()→return resultresultis aHelpCommandfromBaseService.create()(returnsOptional[T], guarded before this point). Returning it directly eliminates one GET. ✓update_help()→return self.model_class.from_api_data(result)resultis the raw dict fromclient.put().from_api_data(dict)is the correct conversion path. Test mock correctly updated to return a dict instead ofTrue. ✓Security
Style & Conventions
services/CLAUDE.md.get_or_create_creator()POST case usesCustomCommandCreator(**result)rather thanCustomCommandCreator.from_api_data(result). Both work;from_api_datais slightly more idiomatic and consistent withBaseService.update()/create()internals, but this is not a blocker.help_commands_service.pyand test file is ruff-enforced and consistent.Suggestions
Test coverage for
custom_commands_service.pychanges: No tests were added or updated forcreate_command(),update_command(), the twoget_or_create_creator()branches, orget_statistics(). The change is relatively low-risk (removes a follow-up GET), but explicit tests for the model_copy paths would catch any futureBaseService.create()/update()return type changes. Worth a follow-up issue.CustomCommandCreator(**result)vsfrom_api_data(result): Minor consistency point only.from_api_datais how the rest of the service layer (includingBaseService.create/update) constructs models from API dicts.Pre-existing (not in scope):
update_item_by_field()internally does a GET before the PUT (callsget_by_fieldto resolve the ID). This PR removes the POST-update GET correctly, but the internal GET insideupdate_item_by_fieldremains. A more complete optimization forupdate_command()would pass the already-knowncommand.iddirectly toBaseService.update()— but that's a separate refactor.Verdict: COMMENT
Implementation is correct and safe. All
model_copy()andfrom_api_data()uses are verified against the base class return types. Theasyncio.gather()parallelization is clean and order-preserving. Minor test coverage gap incustom_commands_service.pyworth a follow-up but not a blocker.Automated review by Claude PR Reviewer
dea8b39391to95010bfd5d