refactor(timeline): harden pipeline stages with shared resolver and exhaustive error handling

Follows up on the resolve_entity_ref extraction by updating all three
pipeline stages to consume the shared helper and removing their local
duplicates (~75 lines of dead code eliminated).

timeline_seed.rs:
- Switch from local resolve_entity to shared resolve_entity_ref with
  explicit Some(proj_id) scoping
- Add tracing::debug for orphaned discussion parents instead of silently
  skipping them, aiding debugging when evidence notes go missing
- Use saturating_mul for the over-fetch multiplier to prevent overflow on
  pathological max_seeds values

timeline_expand.rs:
- Switch from local resolve_entity_ref to shared version with None
  project scoping (cross-project traversal)
- Pass Option<i64> for target_iid in UnresolvedRef construction instead
  of unwrap_or(0) sentinel
- Update test assertion to compare against Some(42)

timeline_collect.rs:
- Make entity_id_column return Result instead of silently defaulting to
  issue_id for unknown entity types. The previous fallback could produce
  incorrect SQL queries that return wrong results rather than failing
- Replace if-let chains in collect_merged_event with exhaustive match
  blocks that propagate real DB errors while gracefully handling expected
  missing-data cases (QueryReturnedNoRows, NULL merged_at)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 08:38:24 -05:00
parent a324fa26e1
commit 9b23d91378
3 changed files with 72 additions and 116 deletions

View File

@@ -1,6 +1,6 @@
use rusqlite::Connection;
use crate::core::error::Result;
use crate::core::error::{LoreError, Result};
use crate::core::timeline::{EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType};
/// Collect all events for seed and expanded entities, interleave chronologically.
@@ -118,7 +118,7 @@ fn collect_state_events(
is_seed: bool,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (id_col, id_val) = entity_id_column(entity);
let (id_col, id_val) = entity_id_column(entity)?;
let sql = format!(
"SELECT state, actor_username, created_at FROM resource_state_events
@@ -169,7 +169,7 @@ fn collect_label_events(
is_seed: bool,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (id_col, id_val) = entity_id_column(entity);
let (id_col, id_val) = entity_id_column(entity)?;
let sql = format!(
"SELECT action, label_name, actor_username, created_at FROM resource_label_events
@@ -231,7 +231,7 @@ fn collect_milestone_events(
is_seed: bool,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (id_col, id_val) = entity_id_column(entity);
let (id_col, id_val) = entity_id_column(entity)?;
let sql = format!(
"SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events
@@ -311,7 +311,8 @@ fn collect_merged_event(
},
);
if let Ok((Some(merged_at), merge_user, url)) = mr_result {
match mr_result {
Ok((Some(merged_at), merge_user, url)) => {
events.push(TimelineEvent {
timestamp: merged_at,
entity_type: entity.entity_type.clone(),
@@ -326,6 +327,10 @@ fn collect_merged_event(
});
return Ok(());
}
Ok((None, _, _)) => {} // merged_at is NULL, try fallback
Err(rusqlite::Error::QueryReturnedNoRows) => {} // entity not found, try fallback
Err(e) => return Err(e.into()),
}
// Fallback: check resource_state_events for state='merged'
let fallback_result = conn.query_row(
@@ -336,7 +341,8 @@ fn collect_merged_event(
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?)),
);
if let Ok((actor, created_at)) = fallback_result {
match fallback_result {
Ok((actor, created_at)) => {
events.push(TimelineEvent {
timestamp: created_at,
entity_type: entity.entity_type.clone(),
@@ -350,16 +356,22 @@ fn collect_merged_event(
is_seed,
});
}
Err(rusqlite::Error::QueryReturnedNoRows) => {} // no merged state event, MR wasn't merged
Err(e) => return Err(e.into()),
}
Ok(())
}
/// Return the correct column name and value for querying resource event tables.
fn entity_id_column(entity: &EntityRef) -> (&'static str, i64) {
fn entity_id_column(entity: &EntityRef) -> Result<(&'static str, i64)> {
match entity.entity_type.as_str() {
"issue" => ("issue_id", entity.entity_id),
"merge_request" => ("merge_request_id", entity.entity_id),
_ => ("issue_id", entity.entity_id), // shouldn't happen
"issue" => Ok(("issue_id", entity.entity_id)),
"merge_request" => Ok(("merge_request_id", entity.entity_id)),
_ => Err(LoreError::Other(format!(
"Unknown entity type for event collection: {}",
entity.entity_type
))),
}
}

View File

@@ -3,7 +3,7 @@ use std::collections::{HashSet, VecDeque};
use rusqlite::Connection;
use crate::core::error::Result;
use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef};
use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef, resolve_entity_ref};
/// Result of the expand phase.
pub struct ExpandResult {
@@ -167,7 +167,7 @@ fn find_outgoing(
match target_id {
Some(tid) => {
if let Some(resolved) = resolve_entity_ref(conn, &target_type, tid)? {
if let Some(resolved) = resolve_entity_ref(conn, &target_type, tid, None)? {
neighbors.push(Neighbor::Resolved {
entity_ref: resolved,
reference_type: ref_type,
@@ -180,7 +180,7 @@ fn find_outgoing(
source: entity.clone(),
target_project: target_project_path,
target_type,
target_iid: target_iid.unwrap_or(0),
target_iid,
reference_type: ref_type,
}));
}
@@ -235,7 +235,7 @@ fn find_incoming(
for row_result in rows {
let (source_type, source_id, ref_type, source_method) = row_result?;
if let Some(resolved) = resolve_entity_ref(conn, &source_type, source_id)? {
if let Some(resolved) = resolve_entity_ref(conn, &source_type, source_id, None)? {
neighbors.push(Neighbor::Resolved {
entity_ref: resolved,
reference_type: ref_type,
@@ -247,41 +247,6 @@ fn find_incoming(
Ok(())
}
/// Resolve an entity ID to a full EntityRef with iid and project_path.
fn resolve_entity_ref(
conn: &Connection,
entity_type: &str,
entity_id: i64,
) -> Result<Option<EntityRef>> {
let table = match entity_type {
"issue" => "issues",
"merge_request" => "merge_requests",
_ => return Ok(None),
};
let sql = format!(
"SELECT e.iid, p.path_with_namespace
FROM {table} e
JOIN projects p ON p.id = e.project_id
WHERE e.id = ?1"
);
let result = conn.query_row(&sql, rusqlite::params![entity_id], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
});
match result {
Ok((iid, project_path)) => Ok(Some(EntityRef {
entity_type: entity_type.to_owned(),
entity_id,
entity_iid: iid,
project_path,
})),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -515,7 +480,7 @@ mod tests {
result.unresolved_references[0].target_project,
Some("other/repo".to_owned())
);
assert_eq!(result.unresolved_references[0].target_iid, 42);
assert_eq!(result.unresolved_references[0].target_iid, Some(42));
}
#[test]

View File

@@ -1,9 +1,10 @@
use std::collections::HashSet;
use rusqlite::Connection;
use tracing::debug;
use crate::core::error::Result;
use crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType};
use crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType, resolve_entity_ref};
use crate::search::{FtsQueryMode, to_fts_query};
/// Result of the seed + hydrate phases.
@@ -67,7 +68,12 @@ fn find_seed_entities(
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(
rusqlite::params![fts_query, project_id, since_ms, (max_seeds * 3) as i64],
rusqlite::params![
fts_query,
project_id,
since_ms,
max_seeds.saturating_mul(3) as i64
],
|row| {
Ok((
row.get::<_, String>(0)?,
@@ -105,7 +111,8 @@ fn find_seed_entities(
continue;
}
if let Some(entity_ref) = resolve_entity(conn, &entity_type, entity_id, proj_id)? {
if let Some(entity_ref) = resolve_entity_ref(conn, &entity_type, entity_id, Some(proj_id))?
{
entities.push(entity_ref);
}
@@ -117,42 +124,6 @@ fn find_seed_entities(
Ok(entities)
}
/// Resolve an entity ID to a full EntityRef with iid and project_path.
fn resolve_entity(
conn: &Connection,
entity_type: &str,
entity_id: i64,
project_id: i64,
) -> Result<Option<EntityRef>> {
let (table, id_col) = match entity_type {
"issue" => ("issues", "id"),
"merge_request" => ("merge_requests", "id"),
_ => return Ok(None),
};
let sql = format!(
"SELECT e.iid, p.path_with_namespace
FROM {table} e
JOIN projects p ON p.id = e.project_id
WHERE e.{id_col} = ?1 AND e.project_id = ?2"
);
let result = conn.query_row(&sql, rusqlite::params![entity_id, project_id], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
});
match result {
Ok((iid, project_path)) => Ok(Some(EntityRef {
entity_type: entity_type.to_owned(),
entity_id,
entity_iid: iid,
project_path,
})),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Find evidence notes: FTS5-matched discussion notes that provide context.
fn find_evidence_notes(
conn: &Connection,
@@ -211,10 +182,18 @@ fn find_evidence_notes(
let snippet = truncate_to_chars(body.as_deref().unwrap_or(""), 200);
let entity_ref = resolve_entity(conn, &parent_type, parent_entity_id, proj_id)?;
let entity_ref = resolve_entity_ref(conn, &parent_type, parent_entity_id, Some(proj_id))?;
let (iid, project_path) = match entity_ref {
Some(ref e) => (e.entity_iid, e.project_path.clone()),
None => continue,
None => {
debug!(
parent_type,
parent_entity_id,
proj_id,
"Skipping evidence note: parent entity not found (orphaned discussion)"
);
continue;
}
};
events.push(TimelineEvent {