Skip to content

Commit ef53860

Browse files
jjbaumertclaude
andcommitted
perf(users): bound search_users scan with a 5000-row budget (#35)
search_users is a Scan with a post-read contains() filter (DynamoDB can't index a substring). It stopped early once 10 matches were collected, but a sparse- or no-match query paginated the whole table, so cost scaled with the entire user set, not the result. Add a scanned-items budget (MAX_SCANNED_ITEMS = 5000, PAGE_LIMIT = 500): once that many rows have been examined, return a best-effort result instead of traversing the rest. The budget comfortably exceeds expected per-deployment user counts, so real searches stay exhaustive; only a pathologically large table is truncated. The lasting fix is a real search index (qdrant), tracked in #35. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4157821 commit ef53860

1 file changed

Lines changed: 33 additions & 6 deletions

File tree

‎crates/storage/src/repo/user_repo.rs‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,39 @@ impl UserRepo {
123123
}
124124

125125
/// Search users by email or name substring (case-insensitive).
126-
/// Returns up to 10 matches. Walks scan pagination until either 10 matches
127-
/// are collected or the scan is exhausted — otherwise a match past the
128-
/// first page is invisible (same defect as `get_by_email`).
126+
/// Returns up to `MAX_MATCHES` matches.
127+
///
128+
/// This is a `Scan` with a post-read `contains()` filter — DynamoDB
129+
/// can't index a substring. It stops early once enough matches are
130+
/// collected, but a query that matches *few or no* users would
131+
/// otherwise paginate the whole table (the filter discards most
132+
/// items server-side), so cost scaled with the entire user set, not
133+
/// the result (issue #35). A **scanned-items budget** bounds that:
134+
/// once `MAX_SCANNED_ITEMS` rows have been examined we stop and
135+
/// return what we have. The budget comfortably exceeds expected
136+
/// per-deployment user counts (typical workspaces are <500 members),
137+
/// so real searches stay exhaustive; only a pathologically large
138+
/// table is truncated to a best-effort result. The lasting fix is a
139+
/// real search index (qdrant) rather than a scan — tracked in #35.
129140
pub async fn search_users(&self, query: &str) -> Result<Vec<User>, RepoError> {
141+
const MAX_MATCHES: usize = 10;
142+
// Cost guardrail: cap total rows examined across pages.
143+
const MAX_SCANNED_ITEMS: i32 = 5000;
144+
// Per-page evaluation cap so the budget is enforced at page
145+
// granularity and no single page reads an unbounded slice.
146+
const PAGE_LIMIT: i32 = 500;
147+
130148
let query_lower = query.trim().to_lowercase();
131149
let mut out: Vec<User> = Vec::new();
150+
let mut scanned: i32 = 0;
132151
let mut last_key: Option<HashMap<String, AttributeValue>> = None;
133152
loop {
134153
let mut builder = self
135154
.db
136155
.inner()
137156
.scan()
138157
.table_name(self.db.table_name())
158+
.limit(PAGE_LIMIT)
139159
.filter_expression("SK = :sk AND (contains(email, :q) OR contains(#n, :q))")
140160
.expression_attribute_values(":sk", AttributeValue::S("PROFILE".to_string()))
141161
.expression_attribute_values(":q", AttributeValue::S(query_lower.clone()))
@@ -148,18 +168,25 @@ impl UserRepo {
148168
.await
149169
.map_err(|e| RepoError::Dynamo(e.into_service_error().to_string()))?;
150170

171+
// `scanned_count` is rows *examined* (the RCU-bearing figure),
172+
// not rows matched — that's exactly what the budget bounds.
173+
scanned += result.scanned_count();
174+
151175
if let Some(items) = result.items {
152176
for item in items {
153177
out.push(user_from_item(&item)?);
154-
if out.len() >= 10 {
178+
if out.len() >= MAX_MATCHES {
155179
return Ok(out);
156180
}
157181
}
158182
}
159183

160184
match result.last_evaluated_key {
161-
Some(key) => last_key = Some(key),
162-
None => return Ok(out),
185+
// Keep paging only while under the scan budget; otherwise
186+
// return a best-effort result rather than traverse the
187+
// whole table for a sparse-match query.
188+
Some(key) if scanned < MAX_SCANNED_ITEMS => last_key = Some(key),
189+
_ => return Ok(out),
163190
}
164191
}
165192
}

0 commit comments

Comments
 (0)