Skip to content

perf(room-worker): marshal room-key once per fan-out + projected survivor list#242

Merged
vjauhari-work merged 2 commits into
mainfrom
claude/room-worker-performance-yMxIi
Jun 1, 2026
Merged

perf(room-worker): marshal room-key once per fan-out + projected survivor list#242
vjauhari-work merged 2 commits into
mainfrom
claude/room-worker-performance-yMxIi

Conversation

@hmchangw
Copy link
Copy Markdown
Owner

@hmchangw hmchangw commented May 29, 2026

Summary

Two behavior-preserving efficiency fixes on room-worker's highest-frequency path (room-key fan-out, which runs on every create / add / remove). No functional change — the same accounts receive the same key event.

Fix #1 — marshal the room-key event once per fan-out, not once per recipient

fanOutKey spawned a worker per account and each called keySender.Send, which did its own json.Marshal. The payload is identical for every recipient (the only per-recipient difference was a Timestamp that Send stamped itself and which is meaningless per-recipient). On a 10k-member room that was 10k marshals of the same struct.

  • Split Sender.Send into Marshal(evt) ([]byte, error) (stamp + serialize once) and SendData(account, data) (publish pre-marshaled bytes). Send is kept as a thin wrapper, so its contract/tests are unchanged.
  • fanOutKey now marshals once and publishes the same bytes to every account.

Fix #2 — fetch only accounts for the survivor fan-out, not full subscription docs

The remove-flow rotate path called ListByRoom, which Finds with no projection and decodes every field of every subscription in the room — then discarded everything except User.Account. On every single-member removal from a large room, the whole roster was pulled into memory and decoded.

  • The rotate path now uses the existing projected GetSubscriptionAccounts ({u.account:1}).
  • rotateAndFanOut / fanOutRoomKeyToSurvivors take []string accounts directly.
  • Removed ListByRoom from the SubscriptionStore interface (the handler no longer needs it); kept the concrete method for integration-test verification and regenerated the mock.

Tests

  • New TestSender_Marshal, TestSender_SendData, and TestFanOutKey_MarshalsOnce (asserts every recipient gets byte-identical payload), all written test-first (red → green).
  • Converted the remove-flow handler-test expectations from ListByRoomGetSubscriptionAccounts.
  • make test (whole repo, -race): 0 failures. make lint: 0 issues. gosec: 0 issues (the #nosec G117 on the key payload was preserved).

🤖 Finding #1/#2 from the room-worker performance review. Finding #3 is in a separate PR (#241).


Generated by Claude Code

Summary by CodeRabbit

  • Tests

    • Added coverage ensuring room-key events are stamped, serialized once, and published identically to all recipients.
  • Refactor

    • Room key distribution now marshals events once and reuses the payload when sending to multiple accounts, improving efficiency and reliability during member removal and key rotation.

…ivor accounts

Two hot-path efficiency fixes in room-worker, no behavioral change:

1. Marshal-once fan-out. fanOutKey previously called Sender.Send per
   account, which re-marshaled the identical RoomKeyEvent once per
   recipient (10k marshals for a 10k-member room create/rotation). Split
   Send into Marshal (stamp + serialize once) and SendData (publish
   pre-marshaled bytes); fanOutKey now marshals once and publishes the
   same bytes to every account. Send is retained as a thin wrapper.

2. Projected survivor list. The remove-flow rotate path called
   ListByRoom, decoding every field of every subscription in the room
   just to extract accounts. It now uses the projected
   GetSubscriptionAccounts ({u.account:1}); rotateAndFanOut and
   fanOutRoomKeyToSurvivors take []string accounts directly. ListByRoom
   is dropped from the SubscriptionStore interface (handler no longer
   needs it) but kept on the concrete store for integration tests.

https://claude.ai/code/session_01KyEPakZnVkZKrPL5j9cjpw
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d4e7505-ddc4-4072-b481-a436eb780b8b

📥 Commits

Reviewing files that changed from the base of the PR and between cce97e4 and 4da4774.

📒 Files selected for processing (1)
  • room-worker/keyfanout_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • room-worker/keyfanout_test.go

📝 Walkthrough

Walkthrough

This PR refactors room key delivery: Sender now exposes Marshal and SendData, member-removal flows use GetSubscriptionAccounts for survivor account lists instead of ListByRoom, and fanout marshals the event once and reuses the payload across recipients.

Changes

Room Key Sender Refactoring and Account Lookup Optimization

Layer / File(s) Summary
Sender.Marshal and Sender.SendData helper methods
pkg/roomkeysender/roomkeysender.go, pkg/roomkeysender/roomkeysender_test.go
Sender.Send now delegates timestamp stamping and JSON marshaling to Marshal, and per-account publish to SendData. Tests validate Marshal does not mutate the caller's event, stamps Timestamp, serializes to bytes, and that SendData publishes bytes verbatim and wraps publish errors.
Store interface simplification
room-worker/store.go, room-worker/store_mongo.go
SubscriptionStore no longer declares ListByRoom. MongoStore.ListByRoom is retained with a comment explaining its integration-test role and non-hot-path status.
Handler member removal and survivor account lookup
room-worker/handler.go
Member removal flows switch from listing subscription documents to calling GetSubscriptionAccounts for post-deletion survivor account IDs. rotateAndFanOut, fanOutRoomKeyToSurvivors, and fanOutKey accept []string accounts; fanOutKey marshals the RoomKeyEvent once and calls keySender.SendData for each account.
Test updates for account lookup and fanout refactoring
room-worker/handler_test.go, room-worker/keyfanout_test.go, room-worker/mock_store_test.go
Handler tests and mocks updated to use GetSubscriptionAccounts. Added dataRecordingPublisher and TestFanOutKey_MarshalsOnce to assert single marshaling and reuse of the same payload buffer across recipients. MockSubscriptionStore removal of ListByRoom was applied.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • hmchangw/chat#171: Overlaps room-key delivery and per-account subject publishing that this refactor reorganizes.
  • hmchangw/chat#40: Introduced the original roomkeysender Send behavior that this PR splits into Marshal/SendData.
  • hmchangw/chat#206: Related changes to room-worker fanout and bounded-concurrency fanout logic.

Suggested Reviewers

  • vjauhari-work

Poem

🐰 A rabbit's ode to cleaner keys
I stamped the time and wrapped the bytes,
Marshaled once beneath the lights.
Fanout hops to every door,
Same payload shared, no copy more.
Hooray — clean code and fewer plights!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR objectives describe performance optimizations to room-worker, but the linked issues (#1, #2) are about initializing Go modules and adding SessionStart hooks—unrelated to the actual code changes. The PR does not implement the linked issues. Either link relevant issues about room-worker performance optimization or verify these are the correct issues.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. 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.
Title check ✅ Passed The title clearly summarizes the two main performance optimizations: marshaling room keys once per fan-out and using projected survivor accounts instead of full subscription documents.
Out of Scope Changes check ✅ Passed All code changes are focused on room-worker performance optimizations (marshaling once, using survivor projections) and directly align with the stated PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/room-worker-performance-yMxIi

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
room-worker/handler_test.go (1)

398-398: ⚡ Quick win

Exercise a non-empty survivor projection in one remove-flow test.

These updated expectations all return nil, so the suite now only proves GetSubscriptionAccounts is called. It does not prove the returned []string survivors are actually passed through to the rotate/fan-out path. Giving one happy-path remove test a non-empty survivor list and asserting the corresponding chat.user.<acct>.event.room.key publish would catch wiring regressions in this refactor.

As per coding guidelines, "Tests must cover: happy path, error paths, edge cases (empty collections, boundary conditions), and invalid input".

Also applies to: 585-585, 1171-1171, 1245-1245, 1537-1537, 1576-1576, 4116-4116, 4146-4146

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@room-worker/keyfanout_test.go`:
- Around line 159-183: The test TestFanOutKey_MarshalsOnce relies on
byte-equality but can false-pass when per-recipient re-marshals happen within
the same millisecond; change the test to deterministically detect multiple
marshals by either (A) making the dataRecordingPublisher used by
newFanoutTestHandler introduce an artificial per-publish delay so that different
publishes land in different milliseconds (e.g. sleep briefly in
dataRecordingPublisher.Publish) and then assert payload bytes differ when
broken, or (B) add a marshal spy around the room key serialization (wrap/replace
the marshal function used by roomkeysender.NewSender or inject a spy into
h/fanOutKey) that increments a counter each time the event is marshaled and
assert the counter == 1 after calling h.fanOutKey; reference
dataRecordingPublisher, newFanoutTestHandler, roomkeysender.NewSender,
h.fanOutKey and dp.snapshot when locating where to add the delay or spy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: de3e4358-0d27-402e-b879-5af788ab7ddb

📥 Commits

Reviewing files that changed from the base of the PR and between ad10203 and cce97e4.

📒 Files selected for processing (8)
  • pkg/roomkeysender/roomkeysender.go
  • pkg/roomkeysender/roomkeysender_test.go
  • room-worker/handler.go
  • room-worker/handler_test.go
  • room-worker/keyfanout_test.go
  • room-worker/mock_store_test.go
  • room-worker/store.go
  • room-worker/store_mongo.go
💤 Files with no reviewable changes (2)
  • room-worker/store.go
  • room-worker/mock_store_test.go

Comment thread room-worker/keyfanout_test.go Outdated
// of SubscriptionStore — the handler's hot paths only need accounts (see
// GetSubscriptionAccounts); this full-document read is retained for integration
// test verification.
func (s *MongoStore) ListByRoom(ctx context.Context, roomID string) ([]model.Subscription, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since we are not using this method anywhere, we can remove it and related tests too. Can be done in a repo-wide refactor later

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed it's a good cleanup candidate — deferring to a later refactor as you suggest rather than widening this PR.

One note so it doesn't get deleted as "dead" by accident: it's no longer in the SubscriptionStore interface (the handler's hot paths now use the projected GetSubscriptionAccounts), but the concrete method is still exercised by the room-worker integration tests as a verification probe (TestMongoStore_..._Integration assert on full subscription docs / User.ID). So removing it is a small test rework, not a one-line delete — which is why I kept it here with the explanatory comment.


Generated by Claude Code

TestFanOutKey_MarshalsOnce compared payload bytes, which a per-recipient
re-marshal could still pass when all marshals land in the same
millisecond and produce identical JSON. Compare backing-array identity
instead: two json.Marshal calls always allocate distinct buffers, so a
re-marshal regression now fails deterministically regardless of timing.

Addresses CodeRabbit review feedback on #242.

https://claude.ai/code/session_01KyEPakZnVkZKrPL5j9cjpw
@vjauhari-work vjauhari-work merged commit 3674c1d into main Jun 1, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants