perf: parallelize roll_for_cards and bump pack limit to 20 #102
No reviewers
Labels
No Label
ai-changes-requested
ai-failed
ai-pr-opened
ai-reviewed
ai-reviewing
ai-working
ai-working
bug
enhancement
feature
in-queue
performance
security
tech-debt
todo
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-discord#102
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "performance/97-parallelize-roll-for-cards"
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?
Summary
Restructures
roll_for_cardsinto three parallelized phases to eliminate sequential API round-trips during pack openings. Also bumps the open-packs limit from 5 to 20 since the batched approach scales efficiently.Resolves #97
Changes
db_get("players/random")per non-zero tier viaasyncio.gather(), then slices players back into per-pack groupsdb_post("cards")anddb_patch("packs")calls gathered concurrently; notifications also gathered at the endxvariable in cardset-23 dupe branch (was duplicating wrong player)all_playersaccumulation (initialized, appended to, never read)SelectOpenPacklimit from 5 → 20Benchmarks (dev API)
Testing
python -m pytest tests/test_roll_for_cards.py -vAI Code Review
Files Reviewed
helpers/main.py(modified —roll_for_cardsrewrite + cosmetic cleanup)discord_ui/selectors.py(modified — limit bump + ruff reformatting)tests/test_roll_for_cards.py(added — 13 unit tests + 5 integration tests)Findings
Correctness
The three-phase restructure is logically sound. Key areas verified:
Phase B — player slicing across packs: When the API returns fewer players than requested for a tier,
tier_offsets[key]correctly advances past the end of the available list (Python slicing on out-of-bounds returns an empty slice without raising). The deficit is tracked aspack_shortfalland filled frombackfill_players. This is correct.Phase C — write_results interleaving:
write_corosis built as[cards_0, patch_0, cards_1, patch_1, ...], sorange(0, len(write_results), 2)correctly hits only thedb_post("cards")results. The interleaving pattern is verified.Bug fix — cardset-23 dupe branch: The old code appended
x(the loop variable from the priorfor x in pl["players"]loop) instead ofpl["players"][0]. The new code correctly pullsfetched_players[key][0]for the first available tier. Fix is correct.Return value: Changed from
pack_idsaccumulation to[pack["id"] for pack in all_packs]. Sinceroll_for_cardsonly returns after all writes succeed (or raises on failure), this is equivalent and simpler.Dead variable:
tier_shortfallsis computed and populated but never read. It was presumably scaffolded for potential per-tier backfill logic but the implementation uses onlytotal_mvp_shortfall. No functional impact — just a minor cleanup opportunity.Pre-existing issue (not introduced here): Premium Card 4 has a double-
ifond_1000 <= 100/if d_1000 <= 530(secondifshould beelif), allowing both Rep and Res to be incremented for rolls 1–100. This was in the original code and is out of scope for this PR.Atomicity note (worsened slightly, not introduced here): With gathered writes, if
db_post("cards")for pack N fails after earlier packs have already written, theConnectionErroris raised but the earlier packs' cards are committed. This partial-commit scenario existed sequentially before. The new code raises after all results are checked, so it does not silently succeed — but the caller cannot distinguish which packs succeeded. Low severity given the existing architecture.limit=20inSelectOpenPack: This is the DB query limit on how many packs to fetch/open, not the Discord dropdown option count. Theoptions: listpassed to theSelectconstructor is separately controlled by the caller. No Discord API limit violation.Security
No issues found. No user input flows into DB params without going through the fixed pack-type dispatch logic. The
franchisefilter comes frompack_team["sname"](a DB-sourced value, not user-controlled).Style & Conventions
os,traceback,pygsheets,dataclass,Literal,difflib, severalutilsimports removed) — all confirmed unused in the modified function scope.f"string"→"string"changes throughouthelpers/main.pyare consistent ruff/black style normalization and do not affect behavior.selectors.pyreformatting is purely cosmetic (quote style, trailing commas, line wrapping).logging.infovslogger.infoinconsistency inroll_for_cards: the shortfall log useslogging.info(...)(root logger) while the rest of the function useslogger.info(...). This is pre-existing in the file and minor.Suggestions
tier_shortfallsdict (computed at the shortfall-detection step) is never read. It can be removed to reduce noise.pack_idxloop variable in the per-pack slicing loop is unused —_would be slightly cleaner, though trivial.Verdict: APPROVED
The parallelization is correctly implemented. The dice-rolling logic is preserved identically from the original, the batched fetch/slice pattern handles shortfalls correctly across packs, the write interleaving check is accurate, and the cardset-23 dupe bug fix is correct. Tests cover the critical paths including MVP backfill, cardset exclusion, filter params, and error propagation. The limit bump from 5 to 20 is appropriate given the performance improvement shown in the benchmarks.
Note: posted as COMMENT because Gitea does not allow self-approval.
Automated review by Claude PR Reviewer
AI Code Review
Files Reviewed
helpers/main.py(modified —roll_for_cardsrefactor + cosmetic ruff cleanup)discord_ui/selectors.py(modified — pack limit 5→20 + quote-style reformat)tests/test_roll_for_cards.py(added — 13 unit + 5 integration tests)Findings
Correctness
Phase A → B → C structure is sound.
_as loop variable (fixes the leakedx).pack_countslist accumulates per-pack rarity counts without any I/O.summed[key]sums counts across all packs for each rarity tier.fetch_keyscorrectly skips zero-count tiers.asyncio.gather(*fetch_coros)fires them concurrently.zip(fetch_keys, fetch_results)maps results back to keys without index drift.write_corosinterleavesdb_post("cards")thendb_patch("packs")per pack, so even indices (0, 2, 4…) are card posts and odd are pack patches. Therange(0, len(write_results), 2)check correctly identifies card-post results. ✓total_mvp_shortfallsums the per-tier deficit across all packs and uses it as thelimitin the single MVP backfill fetch. Per-pack slicing viatier_offsetscorrectly distributes players sequentially — the sum of allpack_shortfallvalues equalstotal_mvp_shortfall, so the backfill pool is exactly the right size (assuming the API returns the full count).xvariable bug: The old dupe branch usedx(last value from the MVP tierfor x in pl["players"]:loop) instead of the player being duplicated. The new code correctly usesfetched_players[key][0]. ✓base_paramsfromall_packs[0]only: This is consistent with the pre-existing behavior (old code also usedall_packs[0]for filter params). Since the UX only ever opens same-type packs together, this is correct.open_timefor all packs: All packs get the same timestamp. Semantically clean for a batch open. ✓Pre-existing issue (not introduced here):
helpers/main.pylines ~201 and ~212 log"could not pull evolution: {e}"as a plain string, not an f-string —{e}will literally appear in the log. The PR correctly removed the now-unusedas ebinding, andexc_info=Truestill captures the full traceback, so this is benign. Worth fixing separately.Security
No issues. All inputs come from the internal DB API, not user-controlled data. No injection surface.
Style & Conventions
"double quotes", trailing commas, no unused variables).helpers/main.py(removedos,traceback,pygsheets,log_exception,Literal, severalutilsimports) — all appear genuinely unused in this file; ruff would have flagged any live references.asynciowas already imported at the top ofhelpers/main.py. No new imports needed.Test Coverage
13 unit tests + 5 integration tests in
tests/test_roll_for_cards.py:TestSinglePack: happy path,extra_val, unknown pack type raisesTypeErrorTestMultiplePacks: return all IDs, batch fetch count ≤ 7 (not 20+), per-packpack_idin card payloadsTestMVPBackfill: shortfall detection + backfill triggerTestCardsetExclusion: cardset-23 never calls MVP backfillTestNotifications: rare pulls generate notifs; commons do notTestErrorHandling:db_postreturningFalseraisesConnectionErrorTestPackTeamFiltering: franchise filter andin_packsfilter applied correctlyCoverage is solid for the refactored paths. The
TestMVPBackfillside_effect is complex but correctly distinguishes tier calls (hasmax_rarity) from the backfill call (nomax_rarity).Suggestions
is_dupe_cardsetbranch) always picksfetched_players[key][0]— the same player for all shortfall slots. This matches the original intent but produces duplicate cards for the same player. If variety is ever desired here, a round-robin or random pick could be added later. Not blocking.pack_idxis assigned infor pack_idx, counts in enumerate(pack_counts):but never used — minor, harmless.Verdict: APPROVED
Clean parallelization across three well-separated phases. The math for batching and slicing is correct, the
xvariable bug is fixed, and dead code is removed. Benchmarks show 3.7–4.3× speedup at 5–20 packs with the new ~1s ceiling well within Discord's interaction timeout. Tests cover all critical paths including the cardset-23 exclusion and MVP backfill.Automated review by Claude PR Reviewer