Skip to content

perf(im): parallelize thread_replies fetches with bounded concurrency#1148

Closed
sammi-bytedance wants to merge 1 commit into
larksuite:mainfrom
sammi-bytedance:perf/im-thread-replies-parallel-fetch
Closed

perf(im): parallelize thread_replies fetches with bounded concurrency#1148
sammi-bytedance wants to merge 1 commit into
larksuite:mainfrom
sammi-bytedance:perf/im-thread-replies-parallel-fetch

Conversation

@sammi-bytedance

@sammi-bytedance sammi-bytedance commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

ExpandThreadReplies previously issued a strictly-serial GET per unique thread_id. Each call is 1s 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 to convert_lib/thread.go + its test.

Changes

  • Phase 1 — plan + concurrent fetch. Walk messages in first-seen order to enumerate unique thread_ids and allocate a per-thread limit greedily from the remaining totalLimit budget (threads past the budget skip outright, preserving the existing semantic). Dispatch the planned fetchThreadReplies calls under bounded concurrency (cap = 4 in flight, same as the reactions PR). Each goroutine writes only to its own results[i] slot — no shared mutable state.
  • Phase 2 — sequential attach. Stays single-threaded because FormatMessageItem may trigger a merge_forward sub-message GET that writes to the shared nameCache. Phase 2 is now split into three sub-steps:
    • 2a) FormatMessageItem each thread's raw replies.
    • 2b) One batched ResolveSenderNames across all prepared replies (was previously called once per thread, potentially firing a contact API request per thread that introduced a new sender).
    • 2c) AttachSenderNames + write thread_replies onto each plan's host outer message (the first message that referenced that thread_id — preserves existing duplicate handling).
  • Semantics preserved. First outer message per thread_id is still the host. totalLimit budget honored (with a documented mild pessimism: budget is allocated based on the planned per-thread limit, not the actual returned count). thread_replies_error: true on fetch failure unchanged. thread_has_more: true propagated 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 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.

  • Live lark-cli im +chat-messages-list --chat-id <oc_xxx> --page-size 50 --format json --as user --no-reactions against a real group chat with 4 thread roots:

    variant --no-reactions median wall time
    before (serial fetches) 14.3s
    after (parallel + batched sender resolve) 12.8s

    Modest 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

  • Orthogonal to perf(im): parallelize reactions, thread_replies, and merge_forward fetches #1146 (reactions parallelization). The two optimizations compose: with both merged, a busy chat's --no-reactions wall time drops further than either alone.
  • Out of scope here (separate follow-ups when needed): parallelizing merge_forward sub-message expansion, and fixing the fetchMergeForwardSubMessages error-masking bug (code != 0 with data: null currently surfaces as a generic "empty data" error, hiding the real code / msg / log_id).

Summary by CodeRabbit

  • Performance

    • Optimized thread reply fetching with enhanced concurrency handling.
  • Bug Fixes

    • Improved error handling for thread reply fetch failures.
  • Tests

    • Added test coverage for concurrent multi-thread reply expansion.

Review Change Stack

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
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e3be507-4002-4a01-a766-4659e5ea9929

📥 Commits

Reviewing files that changed from the base of the PR and between b91f6a2 and b17ba3d.

📒 Files selected for processing (2)
  • shortcuts/im/convert_lib/thread.go
  • shortcuts/im/convert_lib/thread_test.go

📝 Walkthrough

Walkthrough

ExpandThreadReplies transitions from serial per-thread processing to a two-phase concurrent pipeline: plan and allocate per-thread limits upfront, fetch replies concurrently (max 4 in-flight) with a semaphore, format and resolve names in batch, then attach results. Budgeting now deducts during planning rather than on actual fetches. A new test verifies multi-thread concurrent behavior.

Changes

Thread Reply Expansion Refactoring

Layer / File(s) Summary
Concurrent thread reply expansion implementation
shortcuts/im/convert_lib/thread.go
ExpandThreadReplies refactored into a two-phase pipeline: planning phase enumerates unique thread_ids and allocates per-thread limits from a shared budget; concurrent fetch phase uses a semaphore to cap in-flight fetches at 4 threads; sequential preparation phase formats and batches ResolveSenderNames across all replies; attachment phase writes thread_replies, thread_replies_error, and thread_has_more to host messages. Budgeting semantics changed to deduct limits during planning rather than on actual fetch counts. Error handling now marks host messages with thread_replies_error on fetch failure or nil reply slice.
Multi-thread concurrent expansion test
shortcuts/im/convert_lib/thread_test.go
New test TestExpandThreadRepliesMultiThreadConcurrent stubs thread-reply fetches for multiple distinct thread_ids, invokes ExpandThreadReplies with high concurrency cap, asserts exactly one fetch per thread, and verifies replies are attached to correct outer messages by checking returned thread_id values match originating messages.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A pipeline springs forth, where once threads crawled slow,
Four workers in tandem make replies all glow,
Plan once, fetch fast, then resolve as one song,
Budget stays taut, yet the messages belong.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions github-actions Bot added domain/im PR touches the im domain size/M Single-domain feat or fix with limited business impact labels May 28, 2026
@sammi-bytedance

Copy link
Copy Markdown
Contributor Author

Folding this into #1146 so both perf changes ship in one PR. Closing in favor of that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/im PR touches the im domain size/M Single-domain feat or fix with limited business impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant