Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 156 additions & 7 deletions src/openhuman/people/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ type PersonRow = (
i64,
);

/// A decoded `people` row, minus handles (fetched separately, in one batched
/// query, by [`PeopleStore::list`]): `(id, display_name, primary_email,
/// primary_phone, created_at, updated_at)`.
///
/// Distinct from the raw `PersonRow` used by `get()`, whose first element is the
/// undecoded `String` id straight from SQLite; here the id is already parsed to
/// [`PersonId`] so `list()` can collect ids for the batched handle fetch without
/// re-parsing. Kept as a separate alias to avoid an `E0428` name clash.
type DecodedPersonRow = (
PersonId,
Option<String>,
Option<String>,
Option<String>,
i64,
i64,
);

/// Process-global handle to the `PeopleStore`, tagged with the workspace it is
/// bound to. Controller handlers are free functions with no `&self`, so they
/// fetch the store via `get()`. Seeded at core boot and re-bound on active-user
Expand Down Expand Up @@ -390,23 +407,46 @@ impl PeopleStore {
r.get::<_, i64>(5)?,
))
})?;
let mut out = Vec::new();
// Materialise the person rows, then fetch every person's handles in
// one batched `WHERE person_id IN (…)` query instead of a
// `load_handles` round-trip per person (N+1). Both run under the
// same lock, so batching also shortens how long `list` holds it.
// Mirrors `batch_interactions_for`.
let mut people_rows: Vec<DecodedPersonRow> = Vec::new();
for r in rows {
let (id_str, display_name, primary_email, primary_phone, created, updated) = r?;
let id = uuid::Uuid::parse_str(&id_str)
.map(PersonId)
.map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?;
let handles = load_handles(&guard, &id)?;
out.push(Person {
people_rows.push((
id,
display_name,
primary_email,
primary_phone,
handles,
created_at: ts_to_dt(created),
updated_at: ts_to_dt(updated),
});
created,
updated,
));
}
drop(stmt);
let ids: Vec<PersonId> = people_rows.iter().map(|row| row.0).collect();
let mut handles_by_id = batch_handles_conn(&guard, &ids)?;
let out = people_rows
.into_iter()
.map(
|(id, display_name, primary_email, primary_phone, created, updated)| {
let handles = handles_by_id.remove(&id).unwrap_or_default();
Person {
id,
display_name,
primary_email,
primary_phone,
handles,
created_at: ts_to_dt(created),
updated_at: ts_to_dt(updated),
}
},
)
.collect();
Ok(out)
})
.await
Expand Down Expand Up @@ -572,6 +612,59 @@ fn load_handles(conn: &Connection, id: &PersonId) -> SqlResult<Vec<Handle>> {
Ok(out)
}

/// Number of `person_id` binds per handle-batch query. Kept under SQLite's
/// default 999 bound-parameter cap so `list` over an unbounded contact list
/// never overflows a single statement.
const HANDLE_BATCH: usize = 900;

/// Batched form of [`load_handles`]: fetch handle aliases for many people in
/// `O(ceil(n / HANDLE_BATCH))` queries instead of one per person, keyed by
/// person id. Decodes each `kind` exactly as `load_handles` does, so behaviour
/// is identical — only the round-trip count changes. A person with no aliases
/// is simply absent from the map (callers use `unwrap_or_default`).
fn batch_handles_conn(
conn: &Connection,
ids: &[PersonId],
) -> SqlResult<HashMap<PersonId, Vec<Handle>>> {
let mut out: HashMap<PersonId, Vec<Handle>> = HashMap::new();
for window in ids.chunks(HANDLE_BATCH) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpick (dead branch): slice::chunks never yields an empty slice — for an empty ids it yields zero windows, and for non-empty ids every window has ≥1 element. So if window.is_empty() { continue; } is unreachable. Harmless, but it can be dropped. (The sibling batch_interactions_for guards emptiness once, up front, via an early return Ok(HashMap::new()) — the empty-ids case is already handled here implicitly by the zero-window loop, so no guard is needed at all.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped in c329383e2. Agreed — slice::chunks never yields an empty window (empty ids → zero windows), so the guard was unreachable. Removed it entirely; the empty-ids case is handled implicitly by the zero-window loop.

let placeholders = std::iter::repeat_n("?", window.len())
.collect::<Vec<_>>()
.join(",");
Comment on lines +631 to +633

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 batch_handles_conn uses std::iter::repeat_n("?", window.len()), but the existing batch_interactions_for uses std::iter::repeat("?").take(ids.len()). repeat_n was stabilized in Rust 1.82 — if the project's MSRV is below that it won't compile. Aligning with the established pattern also makes the two batching functions easier to compare.

Suggested change
let placeholders = std::iter::repeat_n("?", window.len())
.collect::<Vec<_>>()
.join(",");
let placeholders = std::iter::repeat("?")
.take(window.len())
.collect::<Vec<_>>()
.join(",");

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

let sql = format!(
"SELECT person_id, kind, value FROM handle_aliases \
WHERE person_id IN ({placeholders}) ORDER BY person_id, kind, value"
);
let id_strings: Vec<String> = window.iter().map(ToString::to_string).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(id_strings.iter()), |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?;
for r in rows {
let (pid_str, kind, value) = r?;
let person_id = uuid::Uuid::parse_str(&pid_str)
.map(PersonId)
.map_err(|e| rusqlite::Error::InvalidColumnName(e.to_string()))?;
let h = match kind.as_str() {
"imessage" => Handle::IMessage(value),
"email" => Handle::Email(value),
"display_name" => Handle::DisplayName(value),
other => {
return Err(rusqlite::Error::InvalidColumnName(format!(
"unknown handle kind: {other}"
)));
}
};
out.entry(person_id).or_default().push(h);
Comment on lines +652 to +662

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Duplicated handle-kind decode logic

The match kind.as_str() arms in batch_handles_conn are a verbatim copy of those in load_handles. If a new Handle variant and its string key are ever added to load_handles, they must also be added here — and the compiler won't warn if one is missed. Extracting a small decode_handle(kind: &str, value: String) -> SqlResult<Handle> free function shared by both would eliminate the divergence risk.

}
}
Ok(out)
}

fn ts_to_dt(ts: i64) -> DateTime<Utc> {
Utc.timestamp_opt(ts, 0)
.single()
Expand Down Expand Up @@ -616,6 +709,62 @@ mod tests {
assert_eq!(list[0].handles.len(), 2);
}

#[tokio::test]
async fn list_batches_handles_per_person() {
// Guards the batched handle fetch in `list`: each person must get
// exactly its own aliases and never another person's — the failure
// mode a single `WHERE person_id IN (…)` query risks if rows aren't
// keyed back correctly. A person with no aliases must come back empty,
// not inherit a neighbour's.
let s = PeopleStore::open_in_memory().unwrap();
let now = Utc::now();
let mk = |name: &str, email: &str| Person {
id: PersonId::new(),
display_name: Some(name.to_string()),
primary_email: Some(email.to_string()),
primary_phone: None,
handles: vec![],
created_at: now,
updated_at: now,
};
let alice = mk("Alice", "alice@example.com");
let bob = mk("Bob", "bob@example.com");
let carol = mk("Carol", "carol@example.com");

s.insert_person(
&alice,
&[
Handle::Email("alice@example.com".into()),
Handle::DisplayName("Alice".into()),
],
)
.await
.unwrap();
s.insert_person(&bob, &[]).await.unwrap();
s.insert_person(&carol, &[Handle::IMessage("+15550001".into())])
.await
.unwrap();

let list = s.list().await.unwrap();
assert_eq!(list.len(), 3);
let by_id: HashMap<PersonId, &Person> = list.iter().map(|p| (p.id, p)).collect();

assert_eq!(
by_id[&alice.id].handles.len(),
2,
"alice keeps both aliases"
);
assert!(
by_id[&bob.id].handles.is_empty(),
"bob has no aliases and must not inherit another person's"
);
assert_eq!(by_id[&carol.id].handles.len(), 1, "carol keeps her alias");
assert!(
matches!(by_id[&carol.id].handles[0], Handle::IMessage(_)),
"carol's alias kind is preserved"
);
}

#[tokio::test]
async fn interactions_round_trip() {
let s = PeopleStore::open_in_memory().unwrap();
Expand Down
Loading