Skip to content

Commit de326fe

Browse files
authored
perf(workflow): batch the workflow-list first page into one journal RPC (kill N+1) (#325)
The DB-page workflow-list path did query_ids + a per-id `load` loop — one journal RPC per run (N+1). Bounded to 200 (via the graphql default limit) that's still 201 serialized RPCs on the journal plugin's stdio host, which queued behind other traffic and made /workflows intermittently multi-second. Add journal_client::list_page + WorkflowStateManager::list_page: fetch the `limit` newest runs in ONE bounded journal/list RPC (the plugin already supports the limit). The query() first-page path (offset 0) now does one list RPC + a cheap ids-only count for `total`, instead of the load loop. Offset>0 keeps the id-walk (the list RPC has no offset). Additive; list_all/query_ids unchanged. cargo check/fmt/clippy clean; orchestrator-core tests 160 passed.
1 parent 3a4dced commit de326fe

3 files changed

Lines changed: 51 additions & 0 deletions

File tree

crates/orchestrator-core/src/services/workflow_impl.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,19 @@ impl WorkflowServiceApi for FileServiceHub {
369369
async fn query(&self, query: WorkflowQuery) -> Result<ListPage<OrchestratorWorkflow>> {
370370
if workflow_query_can_use_db_page(&query) {
371371
let manager = self.workflow_manager();
372+
// Fast path for the first page (the workflow-list UI + graphql poll):
373+
// fetch the bounded runs in ONE journal RPC, plus a cheap ids-only
374+
// query for `total`, instead of query_ids + a per-id load loop (N+1 —
375+
// one RPC per run, which serialized behind other journal traffic and
376+
// made the list intermittently multi-second).
377+
if query.page.offset == 0 {
378+
if let Some(limit) = query.page.limit {
379+
let (_ids, total) = manager.query_ids(query.page, query.filter.status)?;
380+
let items = manager.list_page(query.filter.status, limit)?;
381+
return Ok(ListPage::new(items, total, query.page));
382+
}
383+
}
384+
// Offset > 0: the journal list RPC has no offset, so keep the id-walk.
372385
let (ids, total) = manager.query_ids(query.page, query.filter.status)?;
373386
let mut items = Vec::with_capacity(ids.len());
374387
for id in ids {

crates/orchestrator-core/src/workflow/journal_client.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,27 @@ pub(crate) fn list(
469469
Ok(resp.runs.into_iter().filter_map(|run| from_journal_run(run).ok()).collect())
470470
}
471471

472+
/// Like [`list`] but bounded to the `limit` newest rows in a SINGLE RPC — the
473+
/// fast path for the workflow-list UI. Replaces `query_ids` + a per-id `load`
474+
/// loop (N+1 — one RPC per run), which serialized behind other traffic on the
475+
/// journal plugin's stdio host and made the list intermittently slow.
476+
pub(crate) fn list_page(
477+
plugin: &DiscoveredPlugin,
478+
project_root: &Path,
479+
status: Option<WorkflowStatus>,
480+
limit: usize,
481+
) -> Result<Vec<OrchestratorWorkflow>> {
482+
let query = JournalQuery {
483+
status: status.map(|s| vec![status_wire(s).to_string()]).unwrap_or_default(),
484+
workflow_ref: None,
485+
updated_since: None,
486+
limit: Some(limit as u32),
487+
};
488+
let value = run_blocking(call(plugin, project_root, METHOD_JOURNAL_LIST, query))??;
489+
let resp: ListResult = serde_json::from_value(value).context("decoding journal ListResult")?;
490+
Ok(resp.runs.into_iter().filter_map(|run| from_journal_run(run).ok()).collect())
491+
}
492+
472493
/// All run ids matching `status` (None = all). The caller paginates client-side.
473494
pub(crate) fn query_ids(
474495
plugin: &DiscoveredPlugin,

crates/orchestrator-core/src/workflow/state_manager.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,23 @@ impl WorkflowStateManager {
362362
Ok(workflows)
363363
}
364364

365+
/// The `limit` newest runs (optionally status-filtered) in a SINGLE journal
366+
/// RPC — the fast path for the workflow-list first page (avoids the per-id
367+
/// load loop / N+1). Backend order (newest-first) mirrors `query_ids`.
368+
pub fn list_page(
369+
&self,
370+
status: Option<crate::types::WorkflowStatus>,
371+
limit: usize,
372+
) -> Result<Vec<OrchestratorWorkflow>> {
373+
if let Some(plugin) = self.journal_plugin() {
374+
return super::journal_client::list_page(plugin, &self.project_root, status, limit);
375+
}
376+
// SQLite dev fallback: fetch all and truncate (no plugin round-trips).
377+
let mut all = self.list_all()?;
378+
all.truncate(limit);
379+
Ok(all)
380+
}
381+
365382
pub fn list_all(&self) -> Result<Vec<OrchestratorWorkflow>> {
366383
if let Some(plugin) = self.journal_plugin() {
367384
return super::journal_client::list(plugin, &self.project_root, &[]);

0 commit comments

Comments
 (0)