From ec90e652c8cfa4e86bbde2eca3615bf3ff900333 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 22 Jul 2026 11:50:13 -0500 Subject: [PATCH] =?UTF-8?q?th-2e1ad2:=20batch-load=20pearl=20labels=20?= =?UTF-8?q?=E2=80=94=20kill=20the=20N+1=20that=20made=20session=20start=20?= =?UTF-8?q?5.7s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `th prime` (SessionStart) runs `th pearls ready`, which returned ~40 open pearls then fired one more Dolt query PER pearl to load labels. Each Dolt call cold-boots (~140ms), so list-style reads cost 1 + N round-trips — `th agent list` (1 query) 0.5s vs `th pearls list` (1+N) 4.7s against the same DB. Add `attach_labels(Vec)`: one `SELECT ... WHERE pearl_id IN (...)` groups labels in Rust, order preserved, label-sorted. Wire it into ready/list/blocked/ search/due_scheduled (the single-pearl get path keeps load_pearl_with_labels). Measured on the real 1200-pearl store: `th pearls ready` 5.7s -> 0.7s warm. Regression test asserts per-pearl labels with no cross-contamination. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015ZctFb4oiWoJraHN2db1nX --- .changeset/batch-pearl-labels.md | 9 +++ crates/smooth-pearls/src/store.rs | 100 +++++++++++++++++++----------- 2 files changed, 73 insertions(+), 36 deletions(-) create mode 100644 .changeset/batch-pearl-labels.md diff --git a/.changeset/batch-pearl-labels.md b/.changeset/batch-pearl-labels.md new file mode 100644 index 00000000..af4b3fe7 --- /dev/null +++ b/.changeset/batch-pearl-labels.md @@ -0,0 +1,9 @@ +--- +'@smooai/smooth': patch +--- + +smooth-pearls: kill the N+1 label query in list-style reads. `ready`/`list`/ +`blocked`/`search`/`due_scheduled` each fetched labels with one Dolt query +**per pearl** — every `th prime` / `th pearls ready` at session start cold-booted +Dolt ~40 times (~5.7s). A single `WHERE pearl_id IN (…)` batch collapses that to +2 queries: `th pearls ready` against a 1200-pearl store drops from 5.7s to ~0.7s. diff --git a/crates/smooth-pearls/src/store.rs b/crates/smooth-pearls/src/store.rs index 05ec4acf..83851a27 100644 --- a/crates/smooth-pearls/src/store.rs +++ b/crates/smooth-pearls/src/store.rs @@ -476,6 +476,36 @@ impl PearlStore { Ok(pearl) } + /// Populate `.labels` for a whole batch of pearls in a SINGLE Dolt query. + /// + /// The per-pearl [`load_pearl_with_labels`](Self::load_pearl_with_labels) + /// path is an N+1: every list-style query (`ready`/`list`/`blocked`/…) then + /// cold-boots Dolt once per row just to fetch labels, which dominated + /// session-start latency (`th prime` → `th pearls ready` ≈ 5.7s for ~40 + /// open pearls). One `WHERE pearl_id IN (…)` collapses that to 2 queries + /// total. Order of `pearls` is preserved; labels come back label-sorted. + fn attach_labels(&self, mut pearls: Vec) -> Result> { + if pearls.is_empty() { + return Ok(pearls); + } + let in_list = pearls.iter().map(|p| format!("'{}'", sql_escape(&p.id))).collect::>().join(","); + let rows = self.dolt.sql(&format!( + "SELECT pearl_id, label FROM pearl_labels WHERE pearl_id IN ({in_list}) ORDER BY label" + ))?; + let mut by_id: std::collections::HashMap> = std::collections::HashMap::new(); + for r in &rows { + if let (Some(pid), Some(label)) = (r["pearl_id"].as_str(), r["label"].as_str()) { + by_id.entry(pid.to_string()).or_default().push(label.to_string()); + } + } + for p in &mut pearls { + if let Some(labels) = by_id.remove(&p.id) { + p.labels = labels; + } + } + Ok(pearls) + } + // ── Dolt version control ──────────────────────────────────────────── /// View the Dolt commit log. @@ -576,12 +606,8 @@ impl PearlStore { } let rows = self.dolt.sql(&sql)?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Update a pearl with partial changes. Records history for each changed field. @@ -800,12 +826,8 @@ impl PearlStore { WHERE d.pearl_id = '{}' AND d.dep_type = 'blocks' AND p.status != 'closed'", sql_escape(id), ))?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Get all dependencies for a pearl. @@ -907,12 +929,8 @@ impl PearlStore { ) \ ORDER BY p.priority ASC, p.created_at DESC", )?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Scheduled pearls whose time has arrived: `scheduled_at <= now` and not @@ -929,12 +947,8 @@ impl PearlStore { WHERE p.scheduled_at IS NOT NULL AND p.scheduled_at <= '{now}' AND p.status != 'closed' \ ORDER BY p.scheduled_at ASC", ))?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Pearls that have unresolved blocking dependencies. @@ -946,12 +960,8 @@ impl PearlStore { WHERE d.dep_type = 'blocks' AND blocker.status != 'closed' AND p.status != 'closed' \ ORDER BY p.priority ASC", )?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Full-text search on title and description (LIKE-based). @@ -960,12 +970,8 @@ impl PearlStore { let rows = self.dolt.sql(&format!( "SELECT * FROM pearls WHERE title LIKE '%{pattern}%' OR description LIKE '%{pattern}%' ORDER BY priority ASC, created_at DESC", ))?; - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let pearl = Self::parse_pearl(row)?; - result.push(self.load_pearl_with_labels(pearl)?); - } - Ok(result) + let pearls = rows.iter().map(Self::parse_pearl).collect::>>()?; + self.attach_labels(pearls) } /// Aggregate stats across all pearls. @@ -1498,4 +1504,26 @@ mod tests { PearlStore::migrate_schema(&store.dolt).expect("migrate idempotent on healed store"); assert!(PearlStore::column_exists(&store.dolt, "pearls", "scheduled_at").expect("still present")); } + + #[test] + fn list_batch_loads_labels_per_pearl() { + // Regression for th-2e1ad2: list-style queries batch-load labels in one + // query instead of N+1. Verify each pearl gets exactly its own labels + // (no cross-contamination) and a label-less pearl stays empty. + let Some(store) = test_store() else { return }; + let a = store.create(&new_task("alpha")).unwrap(); + let b = store.create(&new_task("bravo")).unwrap(); + let _c = store.create(&new_task("charlie")).unwrap(); // no labels + store.add_label(&a.id, "backend").unwrap(); + store.add_label(&a.id, "auth").unwrap(); + store.add_label(&b.id, "frontend").unwrap(); + + let all = store.list(&PearlQuery::new()).unwrap(); + let get = |id: &str| all.iter().find(|p| p.id == id).unwrap().labels.clone(); + + // add_label uses ORDER BY label → alphabetical. + assert_eq!(get(&a.id), vec!["auth".to_string(), "backend".to_string()]); + assert_eq!(get(&b.id), vec!["frontend".to_string()]); + assert!(get(&_c.id).is_empty(), "unlabelled pearl must have no labels"); + } }