Skip to content

perf: batch-load messages in advanced-chat workflow run list to remove N+1#38359

Merged
wylswz merged 5 commits into
langgenius:mainfrom
weijun-xia:perf/workflow-run-list-batch-messages
Jul 8, 2026
Merged

perf: batch-load messages in advanced-chat workflow run list to remove N+1#38359
wylswz merged 5 commits into
langgenius:mainfrom
weijun-xia:perf/workflow-run-list-batch-messages

Conversation

@weijun-xia

Copy link
Copy Markdown
Contributor

Summary

WorkflowRunService.get_paginate_advanced_chat_workflow_runs (the console "workflow runs" log list for advanced-chat apps) attaches message_id / conversation_id to each run by reading the deprecated WorkflowRun.message property inside the loop. That property runs select(Message).where(Message.app_id == self.app_id, Message.workflow_run_id == self.id) - i.e. one query per run, an N+1 that scales with page size.

Change

Batch-load the messages for the whole page in a single query and look them up in memory:

messages = db.session.scalars(
    select(Message).where(
        Message.app_id == app_model.id,
        Message.workflow_run_id.in_(run_ids),
    )
).all()
  • Filter semantics are identical to the deprecated property (app_id + workflow_run_id); all runs on a page belong to app_model, so Message.app_id == app_model.id matches run.app_id.
  • setdefault keeps one message per run, mirroring scalar()'s single-row behavior.
  • Query count drops from N+1 to a constant (one batched query per page).
  • Zero behavior change; the deprecated WorkflowRun.message property is left untouched for other callers.

Same class as the already-merged #38002 (batch-load MessageFile in TokenBufferMemory).

Tests

Updated test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists for the batched path, and added test_get_paginate_advanced_chat_workflow_runs_batch_loads_messages_without_n_plus_one, which asserts db.session.scalars is called exactly once regardless of run count (regression guard against N+1).

…e N+1

WorkflowRunService.get_paginate_advanced_chat_workflow_runs attached
message_id / conversation_id to each run by reading the deprecated
WorkflowRun.message property inside the loop, which issues one
select(Message) query per run - an N+1 that scales with page size on the
console "workflow runs" log list.

Batch-load the messages for the whole page in a single query
(Message.app_id == app_model.id, Message.workflow_run_id.in_(run_ids)) and
look them up in memory, preserving the exact filter semantics. Query count
drops from N+1 to a constant regardless of page size.

Mirrors the same optimization already merged for TokenBufferMemory (langgenius#38002).
@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-07-08 10:12:01.493940018 +0000
+++ /tmp/pyrefly_pr.txt	2026-07-08 10:11:46.300894587 +0000
@@ -8127,9 +8127,9 @@
 ERROR Argument `list[int | str]` is not assignable to parameter `mentioned_user_ids` with type `Sequence[str]` in function `services.workflow_comment_service.WorkflowCommentService._filter_valid_mentioned_user_ids` [bad-argument-type]
   --> tests/unit_tests/services/test_workflow_comment_service.py:49:13
 ERROR Argument `dict[str, str]` is not assignable to parameter `args` with type `WorkflowRunListArgs` in function `services.workflow_run_service.WorkflowRunService.get_paginate_workflow_runs` [bad-argument-type]
-   --> tests/unit_tests/services/test_workflow_run_service.py:102:18
+   --> tests/unit_tests/services/test_workflow_run_service.py:109:18
 ERROR `Literal['2']` is not assignable to TypedDict key `limit` with type `int` [bad-typed-dict-key]
-   --> tests/unit_tests/services/test_workflow_run_service.py:132:103
+   --> tests/unit_tests/services/test_workflow_run_service.py:139:103
 ERROR Argument `dict[str, dict[str, str | dict[str, str]] | str]` is not assignable to parameter `object` with type `dict[str, dict[str, list[Unknown] | str] | str]` in function `list.append` [bad-argument-type]
    --> tests/unit_tests/services/test_workflow_service.py:225:13
 ERROR `dict[@_, @_]` is not assignable to TypedDict key `data` with type `BaseNodeData` [bad-typed-dict-key]

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 52.51% 52.51% +0.00%
Strict coverage 52.02% 52.02% +0.00%
Typed symbols 32,420 32,423 +3
Untyped symbols 29,596 29,596 0
Modules 2978 2978 0

@wylswz wylswz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall LGTM.

One possible follow-up: can we push the “one message per run” selection into SQL as well, so we don’t load extra Message rows when multiple rows share the same workflow_run_id? If we do that, we should first make the aggregation rule explicit (for example latest or lowest-id message), since the current Python setdefault preserves whichever row comes back first.

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 8, 2026
@wylswz wylswz added this pull request to the merge queue Jul 8, 2026
Merged via the queue into langgenius:main with commit eb3362e Jul 8, 2026
31 checks passed
@weijun-xia

Copy link
Copy Markdown
Contributor Author

@wylswz thanks for the review and the follow-up suggestion! I opened #38578 to implement it.

It pushes the "one message per run" selection into SQL via DISTINCT ON (workflow_run_id) ordered by created_at DESC, id DESC, so extra Message rows are no longer loaded when a run has multiple messages, and the aggregation rule is now explicit (the most recent message per run) instead of depending on the row return order.

One note: since Message.id is a UUID, "lowest-id" isn't time-ordered, so I used created_at with id as a stable tie-break (there is a composite (created_at, id) index that supports this ordering). Review welcome when you have a moment.

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

Labels

lgtm This PR has been approved by a maintainer size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants