perf(im): parallelize thread_replies fetches with bounded concurrency#1148
perf(im): parallelize thread_replies fetches with bounded concurrency#1148sammi-bytedance wants to merge 1 commit into
Conversation
ExpandThreadReplies previously walked every unique thread_id and issued a
serial GET /messages?container_id_type=thread for each. Each call is ~1s
RTT, so chats with N thread roots paid an N × ~1s stall before the main
output could be rendered. This is the same latency-multiplier pattern
that motivated the reactions enrichment fan-out: independent
per-resource fetches dispatched in lockstep.
The refactor is a two-phase plan:
1. Plan + concurrent fetch. Walk messages in first-seen order to
enumerate unique thread_ids, allocate a per-thread fetch limit
greedily from the remaining totalLimit budget (so threads past the
budget skip outright, matching the old totalLimit semantic), then
dispatch fetchThreadReplies for the planned set with bounded
concurrency (cap = 4 in flight, mirroring reactions). Each
goroutine writes only to its own results[i] slot, so concurrent
batches don't contend on any shared mutable state.
2. Sequential attach. Walk the plans in original order:
- 2a) Format each thread's replies via FormatMessageItem. This stays
single-threaded because FormatMessageItem may trigger a
merge_forward sub-message GET that also writes to the shared
nameCache.
- 2b) Issue ONE batched ResolveSenderNames across all prepared
replies. The pre-existing per-thread call pattern would issue a
fresh contact API request for every thread that introduced a new
sender; consolidating resolves every still-missing open_id in one
request and lets the nameCache absorb the rest.
- 2c) AttachSenderNames + write thread_replies onto each plan's
host outer message (the first message that referenced that
thread_id, preserving existing duplicate-handling).
Semantics preserved:
- First outer message per thread_id is the host that receives
thread_replies; later duplicates inherit nothing.
- totalLimit budget is honored: threads whose allocation would exceed
the remaining budget are skipped entirely (the budget is allocated
based on the planned per-thread limit, which is slightly pessimistic
relative to actual returned counts; documented in the function
godoc).
- thread_replies_error: true on fetch failure (mirrors existing
behavior; same flag pattern as reactions_error).
- thread_has_more: true propagated when the API reports more replies
beyond the limit.
Tests:
- Existing tests (TestExpandThreadReplies, TestFetchThreadRepliesError,
TestExpandThreadRepliesMarksFetchError) pass unchanged — they all
exercise the single-thread fast path.
- New TestExpandThreadRepliesMultiThreadConcurrent: 5 distinct thread
roots → 5 planned fetches under the bounded fan-out. Each thread's
reply is tagged with its own thread_id, so a goroutine cross-
contamination bug would manifest as mis-attached replies. Assertion
verifies callCount=5 and every host received the right reply.
- `go test -race -count=1 ./shortcuts/im/convert_lib/` clean.
- `gofmt -l .` / `go vet ./...` clean.
Measured impact:
Same group chat as the reactions PR (larksuite#1146) measurements, page-size
50, 4 thread roots:
before (serial fetches): --no-reactions median 14.3s
after (parallel + batched sender resolve): --no-reactions
median 12.8s
The modest delta on this specific chat (~1.5s) reflects two things:
(a) only 4 thread roots, so the serial path was 4 × ~1s of fetch
RTT, of which the parallel path saves ~3 of 4; and (b) outer
sender resolution had already populated nameCache, so the new
batched sender resolution had little additional work.
The improvement scales linearly with the number of distinct thread
roots. Chats with 10+ thread roots will save proportionally more,
and the structural simplification (one batched contact API call
instead of N) is correctness-positive even when the absolute
savings are small.
This PR is independent of larksuite#1146 (reactions parallelization) — the
two optimizations are orthogonal and compose; with both applied, a
busy chat's --no-reactions wall time drops further than either
alone.
Scope:
- Only shortcuts/im/convert_lib/thread.go and its test.
- No changes to call sites (mget, chat-messages-list both already
invoke ExpandThreadReplies with the same signature).
Change-Id: Ied311ef8a13e08b314b5f7a462d53445e3f7a316
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthrough
ChangesThread Reply Expansion Refactoring
Sequence DiagramsequenceDiagram
participant ER as ExpandThreadReplies
participant Plan as Planning Phase
participant Fetch as Concurrent Fetcher
participant Format as Format & Resolve
participant Attach as Attachment Phase
ER->>Plan: Enumerate unique thread_ids, allocate per-thread limits
Plan->>ER: Return planned threads and budget allocation
ER->>Fetch: Fetch planned thread replies (semaphore cap=4)
Fetch-->>Fetch: Fetch concurrently across threads
Fetch->>ER: Return fetched replies per thread
ER->>Format: Format replies and batch ResolveSenderNames
Format->>ER: Return formatted & resolved replies
ER->>Attach: Attach replies, errors, has_more to host messages
Attach->>ER: Return updated host messages
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
Folding this into #1146 so both perf changes ship in one PR. Closing in favor of that. |
Summary
ExpandThreadRepliespreviously issued a strictly-serial GET per uniquethread_id. Each call is1s RTT, so chats with N thread roots paid an N×1s stall before the main message output could render — the same latency-multiplier pattern that motivated #1146 (reactions). This PR parallelizes the per-thread fetches with the same bounded fan-out pattern and consolidates the follow-up sender-name resolution into a single batched contact API call. Scope is limited toconvert_lib/thread.go+ its test.Changes
thread_ids and allocate a per-thread limit greedily from the remainingtotalLimitbudget (threads past the budget skip outright, preserving the existing semantic). Dispatch the plannedfetchThreadRepliescalls under bounded concurrency (cap = 4 in flight, same as the reactions PR). Each goroutine writes only to its ownresults[i]slot — no shared mutable state.FormatMessageItemmay trigger amerge_forwardsub-message GET that writes to the sharednameCache. Phase 2 is now split into three sub-steps:FormatMessageItemeach thread's raw replies.ResolveSenderNamesacross all prepared replies (was previously called once per thread, potentially firing a contact API request per thread that introduced a new sender).AttachSenderNames+ writethread_repliesonto each plan's host outer message (the first message that referenced thatthread_id— preserves existing duplicate handling).thread_idis still the host.totalLimitbudget honored (with a documented mild pessimism: budget is allocated based on the planned per-thread limit, not the actual returned count).thread_replies_error: trueon fetch failure unchanged.thread_has_more: truepropagated unchanged.Test Plan
All three pre-existing tests pass unchanged (
TestExpandThreadReplies,TestFetchThreadRepliesError,TestExpandThreadRepliesMarksFetchError) — they exercise the single-thread fast path.New
TestExpandThreadRepliesMultiThreadConcurrent: 5 distinct thread roots → 5 planned fetches under the bounded fan-out. Each thread's reply is tagged with its ownthread_idso a goroutine cross-contamination bug would manifest as mis-attached replies. Assertion verifiescallCount=5and every host received the right reply.go test -race -count=1 ./shortcuts/im/convert_lib/clean.gofmt -l ./go vet ./...clean.Live
lark-cli im +chat-messages-list --chat-id <oc_xxx> --page-size 50 --format json --as user --no-reactionsagainst a real group chat with 4 thread roots:--no-reactionsmedian wall timeModest delta on this specific chat (~1.5s) because only 4 thread roots → serial path was 4 × ~1s of which parallel saves ~3 of 4. Improvement scales linearly with thread-root count: a chat with 10 thread roots would save proportionally more.
Related Issues
--no-reactionswall time drops further than either alone.merge_forwardsub-message expansion, and fixing thefetchMergeForwardSubMessageserror-masking bug (code != 0withdata: nullcurrently surfaces as a generic"empty data"error, hiding the realcode/msg/log_id).Summary by CodeRabbit
Performance
Bug Fixes
Tests