Skip to content

fix(background): release the payload after every callback, not after each one - #509

Merged
ChuckBuilds merged 1 commit into
mainfrom
fix/release-payload-after-all-callbacks
Sep 1, 2026
Merged

fix(background): release the payload after every callback, not after each one#509
ChuckBuilds merged 1 commit into
mainfrom
fix/release-payload-after-all-callbacks

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Callers that join an in-flight fetch share a single FetchResult. #499 released the payload inside the delivery loop, so the first callback got the data and every joiner got result.data is None.

That is not a quiet degradation. Consumers read result.data.get('events'), so they raise AttributeError — which this very loop catches and logs. The whole failure surfaces as one line and a manager that silently never receives its schedule.

Seen on hardware

While soaking an unrelated change on a live rig:

INFO  - src.background_data_service - Successfully fetched nhl 2026 data in 1.37s
INFO  - NHLRecentManager - Background fetch completed for 2026: 1000 events
ERROR - src.background_data_service - Error in callback for request nhl_2026_...:
        'NoneType' object has no attribute 'get'

NHLRecentManager's callback ran first and logged its 1000 events. The error is a second callback on the same request — and NHLUpcomingManager had already logged No events found in shared data. a moment earlier.

Deduplication is the normal case, not a corner. A sport's recent, upcoming and live managers all want the same season schedule, so the second and third are joiners on almost every cycle.

_release_payload's own docstring said "Only called once a callback has been handed the data" — singular. That is the assumption that broke; the loop above it was written for many and says so explicitly ("Call every callback: the original submitter's and any that joined this fetch").

The fix

Move the release after the loop, and guard it on callbacks being non-empty.

The guard is load-bearing. A request submitted without a callback must keep its payload, because polling get_result() is then the only way to collect it. The per-delivery release got that right by accident — an empty list never entered the loop body — and test_without_a_callback_the_payload_is_kept caught me omitting it on the first attempt.

Tests

TestJoinersAllGetTheData — two submitters on one in-flight cache_key:

  • test_every_joiner_is_handed_the_payload asserts both callbacks receive a populated payload, reading it the way the sport managers do (result.data.get('events'), since that is the access that actually raises). It asserts the coalescing happened first, or two independent fetches would each own their result and the test would prove nothing.
  • test_the_payload_is_still_released_once_they_have_all_had_it — the memory fix from fix(memory): release fetched payloads once they have been delivered #499 must survive this ordering fix.

test_background_fetch_dedupe.py already had test_the_joiner_still_gets_its_callback, which proves the callback fires. It never checked what the callback received. That is the gap that let this through, and the new test closes it.

Verified the new test bites: restoring the release inside the loop fails it with 'second' was handed a released payload: the result was emptied before every callback had been delivered.

Full suite 3716 passed, 6 skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Summary by CodeRabbit

  • Bug Fixes
    • Fixed shared background fetches so all callback recipients receive the complete payload.
    • Preserved fetched data for requests that retrieve results through polling.
    • Ensured payloads are released only after all callback deliveries are complete.
  • Tests
    • Added coverage for multiple callers joining the same in-progress fetch and receiving the data successfully.

…each one

Callers that join an in-flight fetch share one FetchResult. #499 released the
payload inside the delivery loop, so the first callback got the data and every
joiner got `result.data is None`.

That is not a quiet degradation. Consumers read `result.data.get('events')`, so
they raise AttributeError -- which the delivery loop catches and logs. The
entire failure surfaced as one line:

    ERROR - src.background_data_service - Error in callback for request
    nhl_2026_...: 'NoneType' object has no attribute 'get'

and a manager that silently never received its schedule. Seen on hardware:
NHLRecentManager logs "Background fetch completed for 2026: 1000 events" and
the very next line is the error, from NHLUpcomingManager's callback on the same
request -- which had already logged "No events found in shared data."

Deduplication is the normal case, not a corner. A sport's recent, upcoming and
live managers all want the same season schedule, so the second and third are
joiners on almost every cycle. _release_payload's own docstring said "once A
callback has been handed the data", singular, which is the assumption that
broke: the loop above it was written for many, and says so.

Moved after the loop, and guarded on `callbacks` being non-empty. The guard
matters: a request submitted without a callback must keep its payload, because
polling get_result() is then the only way to collect it. The per-delivery
release got that right by accident -- an empty list never entered the loop body
-- and the existing test for it caught the omission.

test_background_payload_release.py gains TestJoinersAllGetTheData: two
submitters on one in-flight cache_key, asserting both are handed a populated
payload, plus that the memory fix still happens once they have all had it.
test_background_fetch_dedupe.py already proved the joiner's callback FIRES; it
never checked what the callback received, which is the gap that let this
through.

Verified the new test bites: restoring the release inside the loop fails it
with "'second' was handed a released payload".

Full suite 3716 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 77961e93-c82a-4781-944d-11f6ab8467d2

📥 Commits

Reviewing files that changed from the base of the PR and between 9b522d4 and 43b807b.

📒 Files selected for processing (2)
  • src/background_data_service.py
  • test/test_background_payload_release.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The fetch worker now releases shared payloads after all callbacks receive the data. Callback-less requests retain payloads for polling. New tests cover joined requests and final payload release.

Changes

Shared fetch payload lifecycle

Layer / File(s) Summary
Defer payload release until callback delivery completes
src/background_data_service.py
_fetch_data_worker releases callback-backed payloads after all callbacks run. Callback-less requests retain the payload for get_result() polling.
Validate joined callback behavior
test/test_background_payload_release.py
Blocking-session tests verify that joined callbacks receive the shared 50-event payload and that the payload becomes None after all callbacks complete.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to 43b80

This localized change ensures all joined callbacks receive the shared payload before it is released while preserving polling behavior for callback-less requests. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title addresses payload release timing, but it states “after every callback,” which conflicts with the implemented change. The payload is released after all callbacks complete, not after each call… Change the title to “fix(background): release the payload after all callbacks, not after each one” or equivalent wording that clearly describes the implemented behavior.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title addresses payload release timing, but it states “after every callback,” which conflicts with the implemented change. The payload is released after all callbacks complete, not after each callback.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/release-payload-after-all-callbacks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 duplication

Metric Results
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@ChuckBuilds
ChuckBuilds merged commit 154525b into main Sep 1, 2026
9 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/release-payload-after-all-callbacks branch September 1, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant