1use super::{AtlasStore, DbError, DbResult, SqliteReadProgressGuard, like_query};
4use projectatlas_core::graph::GraphContractError;
5use projectatlas_core::symbols::{ParserKind, RelationKind, SymbolRelation};
6use projectatlas_core::{IndexWorkControl, IndexWorkStage};
7use rusqlite::limits::Limit;
8use rusqlite::{Connection, ErrorCode, Row, params};
9use serde::Serialize;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{Duration, Instant};
13
14#[derive(Clone, Copy, Debug)]
16pub struct LegacyRelationReadBudget {
17 rows: usize,
19 deadline: Duration,
21 intermediate_bytes: u64,
23}
24
25impl LegacyRelationReadBudget {
26 pub const MAX_QUERY_BYTES: usize = 1_024;
28 pub const MAX_ROWS: usize = 200;
30 pub const MAX_INTERMEDIATE_BYTES: u64 = 1_024 * 1_024;
32 pub const MAX_INSTRUCTIONS: u64 = 100_000;
34 pub const MAX_DEADLINE: Duration = Duration::from_millis(1_000);
36
37 pub fn new(rows: usize, deadline: Duration, intermediate_bytes: u64) -> DbResult<Self> {
52 if rows == 0
53 || rows > Self::MAX_ROWS
54 || deadline.is_zero()
55 || deadline > Self::MAX_DEADLINE
56 || intermediate_bytes == 0
57 || intermediate_bytes > Self::MAX_INTERMEDIATE_BYTES
58 {
59 return Err(GraphContractError::InvalidLimits {
60 reason: "legacy relations require 1..200 rows, at most 1000 ms and 1 MiB intermediate bytes; use anchored detailed navigation for larger results",
61 }.into());
62 }
63 Ok(Self {
64 rows,
65 deadline,
66 intermediate_bytes,
67 })
68 }
69}
70
71#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
73pub struct LegacyRelationReadWork {
74 pub instructions: u64,
76 pub candidate_rows: u32,
78 pub decoded_bytes: u64,
80}
81
82#[derive(Debug)]
84pub struct LegacyRelationRead {
85 pub relations: Vec<SymbolRelation>,
87 pub work: LegacyRelationReadWork,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum LegacyRelationReadLimit {
95 Rows,
97 Instructions,
99 Deadline,
101 IntermediateBytes,
103 ValueBytes,
105 QueryBytes,
107}
108
109struct LegacyReadGuard<'connection> {
111 progress: SqliteReadProgressGuard<'connection>,
113 previous_length: i32,
115 previous_pattern_length: i32,
117 instructions: Arc<AtomicU64>,
119 deadline: Instant,
121}
122
123impl<'connection> LegacyReadGuard<'connection> {
124 fn new(
126 connection: &'connection Connection,
127 budget: LegacyRelationReadBudget,
128 control: &IndexWorkControl,
129 ) -> DbResult<Self> {
130 control.check(IndexWorkStage::RepositoryTraversal)?;
131 let deadline = Instant::now().checked_add(budget.deadline).ok_or(
132 GraphContractError::InvalidLimits {
133 reason: "legacy deadline is unrepresentable",
134 },
135 )?;
136 let previous_length = connection.limit(Limit::SQLITE_LIMIT_LENGTH)?;
137 let previous_pattern_length = connection.limit(Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH)?;
138 let length =
139 i32::try_from(budget.intermediate_bytes).map_err(|source| DbError::InvalidCount {
140 field: "legacy intermediate bytes",
141 value: i64::MAX,
142 source,
143 })?;
144 connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, previous_length.min(length))?;
145 let mut guard = Self {
146 progress: SqliteReadProgressGuard {
147 connection,
148 armed: false,
149 },
150 previous_length,
151 previous_pattern_length,
152 instructions: Arc::new(AtomicU64::new(0)),
153 deadline,
154 };
155 connection.set_limit(
156 Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH,
157 previous_pattern_length.min((LegacyRelationReadBudget::MAX_QUERY_BYTES + 2) as i32),
158 )?;
159 let instructions = Arc::clone(&guard.instructions);
160 let control = control.clone();
161 connection.progress_handler(
162 1,
163 Some(move || {
164 instructions.fetch_add(1, Ordering::Relaxed)
165 >= LegacyRelationReadBudget::MAX_INSTRUCTIONS - 1
166 || Instant::now() >= deadline
167 || control.check(IndexWorkStage::RepositoryTraversal).is_err()
168 }),
169 )?;
170 guard.progress.armed = true;
171 Ok(guard)
172 }
173
174 fn admit_value(&self, remaining: u64) -> DbResult<()> {
176 let remaining = i32::try_from(remaining).map_err(|source| DbError::InvalidCount {
177 field: "legacy remaining bytes",
178 value: i64::MAX,
179 source,
180 })?;
181 self.progress.connection.set_limit(
182 Limit::SQLITE_LIMIT_LENGTH,
183 self.previous_length.min(remaining),
184 )?;
185 Ok(())
186 }
187
188 fn check(&self, control: &IndexWorkControl, work: &mut LegacyRelationReadWork) -> DbResult<()> {
190 work.instructions = self.instructions.load(Ordering::Relaxed);
191 control.check(IndexWorkStage::RepositoryTraversal)?;
192 if work.instructions >= LegacyRelationReadBudget::MAX_INSTRUCTIONS {
193 return Err(read_limit(LegacyRelationReadLimit::Instructions, *work));
194 }
195 if Instant::now() >= self.deadline {
196 return Err(read_limit(LegacyRelationReadLimit::Deadline, *work));
197 }
198 Ok(())
199 }
200}
201
202impl Drop for LegacyReadGuard<'_> {
203 fn drop(&mut self) {
204 drop(self.progress.connection.set_limit(
205 Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH,
206 self.previous_pattern_length,
207 ));
208 drop(
210 self.progress
211 .connection
212 .set_limit(Limit::SQLITE_LIMIT_LENGTH, self.previous_length),
213 );
214 }
215}
216
217fn read_limit(limit: LegacyRelationReadLimit, work: LegacyRelationReadWork) -> DbError {
219 DbError::LegacyRelationReadLimit { limit, work }
220}
221
222fn admit_bytes(
224 work: &mut LegacyRelationReadWork,
225 bytes: u64,
226 budget: LegacyRelationReadBudget,
227) -> DbResult<()> {
228 let observed = work.decoded_bytes.saturating_add(bytes);
229 if observed > budget.intermediate_bytes {
230 return Err(read_limit(
231 LegacyRelationReadLimit::IntermediateBytes,
232 *work,
233 ));
234 }
235 work.decoded_bytes = observed;
236 Ok(())
237}
238
239impl AtlasStore {
240 pub fn load_symbol_relations_bounded(
258 &self,
259 file: Option<&str>,
260 query: Option<&str>,
261 budget: LegacyRelationReadBudget,
262 control: &IndexWorkControl,
263 ) -> DbResult<LegacyRelationRead> {
264 let mut work = LegacyRelationReadWork::default();
265 if query.is_some_and(|query| query.len() > LegacyRelationReadBudget::MAX_QUERY_BYTES) {
266 return Err(read_limit(LegacyRelationReadLimit::QueryBytes, work));
267 }
268 let snapshot = if self.connection.is_autocommit() {
269 Some(self.connection.unchecked_transaction()?)
270 } else {
271 None
272 };
273 if file.is_some_and(|value| value.len() as u64 > budget.intermediate_bytes)
274 || query.is_some_and(|value| {
275 value.len() as u64 > budget.intermediate_bytes.saturating_sub(2)
276 })
277 {
278 return Err(read_limit(LegacyRelationReadLimit::IntermediateBytes, work));
279 }
280 let pattern = query.map(like_query);
281 admit_bytes(
282 &mut work,
283 pattern.as_ref().map_or(0, |value| value.len() as u64),
284 budget,
285 )?;
286 let guard = LegacyReadGuard::new(&self.connection, budget, control)?;
287 let operation = (|| {
288 let candidates = match (file, query) {
290 (Some(file), Some(_query)) => {
291 let mut statement = self.connection.prepare("SELECT id FROM symbol_relations WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2) LIMIT ?3")?;
292 collect_candidates(
293 &mut statement,
294 params![file, pattern.as_deref(), (budget.rows + 1) as i64],
295 budget,
296 &mut work,
297 )?
298 }
299 (Some(file), None) => {
300 let mut statement = self
301 .connection
302 .prepare("SELECT id FROM symbol_relations WHERE path = ?1 LIMIT ?2")?;
303 collect_candidates(
304 &mut statement,
305 params![file, (budget.rows + 1) as i64],
306 budget,
307 &mut work,
308 )?
309 }
310 (None, Some(_query)) => {
311 let mut statement = self.connection.prepare("SELECT id FROM symbol_relations WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1 LIMIT ?2")?;
312 collect_candidates(
313 &mut statement,
314 params![pattern.as_deref(), (budget.rows + 1) as i64],
315 budget,
316 &mut work,
317 )?
318 }
319 (None, None) => {
320 let mut statement = self
321 .connection
322 .prepare("SELECT id FROM symbol_relations LIMIT ?1")?;
323 collect_candidates(
324 &mut statement,
325 params![(budget.rows + 1) as i64],
326 budget,
327 &mut work,
328 )?
329 }
330 };
331 let mut statement = self.connection.prepare("SELECT path, source_name, target_name, kind, line, context, parser FROM symbol_relations WHERE id = ?1")?;
332 admit_bytes(
333 &mut work,
334 (size_of::<(i64, SymbolRelation)>() + size_of::<SymbolRelation>()) as u64
335 * candidates.len() as u64,
336 budget,
337 )?;
338 let mut relations = Vec::with_capacity(candidates.len());
339 for id in candidates {
340 guard.check(control, &mut work)?;
341 guard.admit_value(budget.intermediate_bytes.saturating_sub(work.decoded_bytes))?;
342 let relation =
343 statement.query_row([id], |row| bounded_relation(row, budget, &mut work))??;
344 relations.push((id, relation));
345 }
346 relations.sort_unstable_by(|(left_id, left), (right_id, right)| {
347 (
348 &left.path,
349 left.line,
350 &left.source_name,
351 &left.target_name,
352 left_id,
353 )
354 .cmp(&(
355 &right.path,
356 right.line,
357 &right.source_name,
358 &right.target_name,
359 right_id,
360 ))
361 });
362 Ok(relations
363 .into_iter()
364 .map(|(_, relation)| relation)
365 .collect())
366 })();
367 guard.check(control, &mut work)?;
368 drop(guard);
369 match operation {
370 Ok(relations) => {
371 if let Some(snapshot) = snapshot {
372 snapshot.commit()?;
373 }
374 Ok(LegacyRelationRead { relations, work })
375 }
376 Err(DbError::Sqlite(error)) if error.sqlite_error_code() == Some(ErrorCode::TooBig) => {
377 Err(read_limit(LegacyRelationReadLimit::ValueBytes, work))
378 }
379 Err(DbError::LegacyRelationReadLimit { limit, .. }) => Err(read_limit(limit, work)),
380 Err(error) => Err(error),
381 }
382 }
383}
384
385fn collect_candidates<P: rusqlite::Params>(
387 statement: &mut rusqlite::Statement<'_>,
388 params: P,
389 budget: LegacyRelationReadBudget,
390 work: &mut LegacyRelationReadWork,
391) -> DbResult<Vec<i64>> {
392 admit_bytes(work, (size_of::<i64>() * budget.rows) as u64, budget)?;
393 let mut rows = statement.query(params)?;
394 let mut candidates = Vec::with_capacity(budget.rows);
395 while let Some(row) = rows.next()? {
396 work.candidate_rows += 1;
397 if candidates.len() == budget.rows {
398 return Err(read_limit(LegacyRelationReadLimit::Rows, *work));
399 }
400 candidates.push(row.get(0)?);
401 }
402 Ok(candidates)
403}
404
405fn bounded_relation(
407 row: &Row<'_>,
408 budget: LegacyRelationReadBudget,
409 work: &mut LegacyRelationReadWork,
410) -> rusqlite::Result<DbResult<SymbolRelation>> {
411 let mut bytes = 0_u64;
412 for index in [0, 1, 2, 3, 5, 6] {
413 bytes = bytes.saturating_add(row.get_ref(index)?.as_bytes()?.len() as u64);
414 }
415 if let Err(error) = admit_bytes(work, bytes, budget) {
416 return Ok(Err(error));
417 }
418 let kind_value: String = row.get(3)?;
419 let Some(kind) = RelationKind::from_db(&kind_value) else {
420 return Ok(Err(DbError::InvalidEnum {
421 field: "symbol relation kind",
422 value: kind_value,
423 }));
424 };
425 let raw_line = row.get::<_, i64>(4)?;
426 let line = match usize::try_from(raw_line) {
427 Ok(line) => line,
428 Err(source) => {
429 return Ok(Err(DbError::InvalidCount {
430 field: "symbol relation line",
431 value: raw_line,
432 source,
433 }));
434 }
435 };
436 Ok(Ok(SymbolRelation {
437 path: row.get(0)?,
438 source_name: row.get(1)?,
439 target_name: row.get(2)?,
440 kind,
441 line,
442 context: row.get(5)?,
443 parser: ParserKind::from_db(row.get_ref(6)?.as_str()?),
444 }))
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use projectatlas_core::symbols::SymbolGraph;
451 use projectatlas_core::{IndexCancellation, IndexWorkFailure};
452 use std::error::Error;
453 use std::io;
454
455 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
456 if condition {
457 Ok(())
458 } else {
459 Err(io::Error::other(message).into())
460 }
461 }
462
463 fn control() -> IndexWorkControl {
464 IndexWorkControl::new(IndexCancellation::new(), None)
465 }
466
467 fn budget(rows: usize) -> DbResult<LegacyRelationReadBudget> {
468 LegacyRelationReadBudget::new(
469 rows,
470 LegacyRelationReadBudget::MAX_DEADLINE,
471 LegacyRelationReadBudget::MAX_INTERMEDIATE_BYTES,
472 )
473 }
474
475 fn graph(count: usize) -> SymbolGraph {
476 SymbolGraph {
477 path: "lib.rs".to_string(),
478 language: Some("rust".to_string()),
479 parser: ParserKind::TreeSitter,
480 symbols: Vec::new(),
481 relations: (0..count)
482 .rev()
483 .map(|index| SymbolRelation {
484 path: "lib.rs".to_string(),
485 source_name: format!("source_{index:05}"),
486 target_name: "target".to_string(),
487 kind: RelationKind::Calls,
488 line: index / 2 + 1,
489 context: format!("target({index});"),
490 parser: ParserKind::TreeSitter,
491 })
492 .collect(),
493 }
494 }
495
496 fn limits(connection: &Connection) -> rusqlite::Result<(i32, i32)> {
497 Ok((
498 connection.limit(Limit::SQLITE_LIMIT_LENGTH)?,
499 connection.limit(Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH)?,
500 ))
501 }
502
503 #[test]
504 fn complete_arrays_keep_filters_order_and_native_query_plans() -> Result<(), Box<dyn Error>> {
505 let mut store = AtlasStore::in_memory()?;
506 store.replace_symbol_graph(&graph(50))?;
507 let original_limits = limits(&store.connection)?;
508 for (file, query) in [
509 (None, None),
510 (Some("lib.rs"), None),
511 (None, Some("source_0001_")),
512 (Some("lib.rs"), Some("source_0001_")),
513 ] {
514 let old = store.load_symbol_relations(file, query, 200)?;
515 let read = store.load_symbol_relations_bounded(file, query, budget(50)?, &control())?;
516 require(
517 read.relations == old,
518 "complete legacy fields or order changed",
519 )?;
520 require(
521 read.work.instructions > 0 && read.work.instructions < 100_000,
522 "candidate and hydration instructions not counted",
523 )?;
524 require(
525 read.work.decoded_bytes > 0 && read.work.decoded_bytes <= 1_024 * 1_024,
526 "retained bytes not bounded",
527 )?;
528 require(
529 limits(&store.connection)? == original_limits,
530 "native limits leaked",
531 )?;
532 }
533 for (sql, indexed) in [
534 (
535 "EXPLAIN QUERY PLAN SELECT id FROM symbol_relations WHERE path = 'lib.rs' LIMIT 51",
536 true,
537 ),
538 (
539 "EXPLAIN QUERY PLAN SELECT id FROM symbol_relations WHERE source_name LIKE '%absent%' LIMIT 51",
540 false,
541 ),
542 ] {
543 let plans = store
544 .connection
545 .prepare(sql)?
546 .query_map([], |row| row.get::<_, String>(3))?
547 .collect::<rusqlite::Result<Vec<_>>>()?;
548 require(
549 !plans.iter().any(|plan| plan.contains("TEMP B-TREE")),
550 "candidate query sorts wide keys",
551 )?;
552 if indexed {
553 require(
554 plans.iter().any(|plan| plan.contains("INDEX")),
555 "file lookup lost its index",
556 )?;
557 }
558 }
559 let mut tied = graph(2);
560 for (index, relation) in tied.relations.iter_mut().enumerate() {
561 relation.source_name = "same".to_string();
562 relation.line = 1;
563 relation.context = format!("ordered_{index}");
564 }
565 store.replace_symbol_graph(&tied)?;
566 store.begin_index_read_snapshot()?;
567 let read = store.load_symbol_relations_bounded(None, None, budget(2)?, &control())?;
568 require(
569 read.relations
570 .iter()
571 .map(|row| row.context.as_str())
572 .collect::<Vec<_>>()
573 == ["ordered_0", "ordered_1"],
574 "equal sort keys lost the stable row-identity order",
575 )?;
576 require(
577 !store.connection.is_autocommit(),
578 "reader committed the caller's snapshot",
579 )?;
580 store.finish_index_read_snapshot()?;
581 Ok(())
582 }
583
584 #[test]
585 fn high_degree_refuses_without_hydration_and_sparse_queries_stop_by_vm_work()
586 -> Result<(), Box<dyn Error>> {
587 let mut store = AtlasStore::in_memory()?;
588 store.replace_symbol_graph(&graph(10_001))?;
589 let original_limits = limits(&store.connection)?;
590 let broad = store.load_symbol_relations_bounded(None, None, budget(10)?, &control());
591 require(
592 matches!(
593 broad,
594 Err(DbError::LegacyRelationReadLimit {
595 limit: LegacyRelationReadLimit::Rows,
596 work: LegacyRelationReadWork {
597 candidate_rows: 11,
598 decoded_bytes: 80,
599 ..
600 },
601 })
602 ),
603 "large result did not stop at ID-only sentinel",
604 )?;
605 let sparse =
606 store.load_symbol_relations_bounded(None, Some("absent"), budget(10)?, &control());
607 require(
608 matches!(
609 sparse,
610 Err(DbError::LegacyRelationReadLimit {
611 limit: LegacyRelationReadLimit::Instructions,
612 work: LegacyRelationReadWork {
613 instructions: 100_000,
614 candidate_rows: 0,
615 decoded_bytes: 88
616 },
617 })
618 ),
619 "sparse scan escaped shared VM bound",
620 )?;
621 require(
622 limits(&store.connection)? == original_limits,
623 "failed-read native limits leaked",
624 )?;
625 store.replace_symbol_graph(&graph(1))?;
626 let retry = store.load_symbol_relations_bounded(
627 Some("lib.rs"),
628 Some("source_00000"),
629 budget(1)?,
630 &control(),
631 )?;
632 require(
633 retry.relations.len() == 1,
634 "read did not recover after VM interruption",
635 )?;
636 Ok(())
637 }
638
639 #[test]
640 fn wide_values_pattern_caps_and_cancellation_restore_connection_state()
641 -> Result<(), Box<dyn Error>> {
642 let mut store = AtlasStore::in_memory()?;
643 let mut source = graph(1);
644 source.relations[0].context = "x".repeat(1_024 * 1_024 + 1);
645 store.replace_symbol_graph(&source)?;
646 let original_limits = limits(&store.connection)?;
647 require(
648 matches!(
649 store.load_symbol_relations_bounded(None, None, budget(1)?, &control()),
650 Err(DbError::LegacyRelationReadLimit {
651 limit: LegacyRelationReadLimit::ValueBytes,
652 ..
653 })
654 ),
655 "wide stored value was not refused by native admission",
656 )?;
657 source.relations[0].context = "short".to_string();
658 store.replace_symbol_graph(&source)?;
659 for query in ["x".repeat(1_025), "é".repeat(513)] {
660 require(
661 matches!(
662 store.load_symbol_relations_bounded(None, Some(&query), budget(1)?, &control()),
663 Err(DbError::LegacyRelationReadLimit {
664 limit: LegacyRelationReadLimit::QueryBytes,
665 work: LegacyRelationReadWork {
666 instructions: 0,
667 ..
668 }
669 })
670 ),
671 "oversized pattern reached SQLite",
672 )?;
673 }
674 for query in ["%".repeat(1_024), "é".repeat(512)] {
675 let result =
676 store.load_symbol_relations_bounded(None, Some(&query), budget(1)?, &control())?;
677 require(
678 result.work.instructions > 0,
679 "maximum-byte pattern was not admitted",
680 )?;
681 }
682 let cancellation = control();
683 {
684 let guard = LegacyReadGuard::new(&store.connection, budget(1)?, &cancellation)?;
685 cancellation.cancel();
686 require(
687 store
688 .connection
689 .query_row("SELECT count(*) FROM symbol_relations", [], |row| {
690 row.get::<_, i64>(0)
691 })
692 .is_err(),
693 "installed progress hook did not observe cancellation",
694 )?;
695 require(
696 matches!(
697 guard.check(&cancellation, &mut LegacyRelationReadWork::default()),
698 Err(DbError::IndexWork(IndexWorkFailure::Cancelled { .. }))
699 ),
700 "cancellation lost its typed owner",
701 )?;
702 }
703 require(
704 limits(&store.connection)? == original_limits,
705 "native limits leaked after failure",
706 )?;
707 require(
708 store
709 .load_symbol_relations_bounded(None, None, budget(1)?, &control())?
710 .relations
711 .len()
712 == 1,
713 "next read inherited cancellation or limits",
714 )?;
715 let tiny = LegacyRelationReadBudget::new(1, Duration::from_nanos(1), 1_024 * 1_024)?;
716 require(
717 matches!(
718 store.load_symbol_relations_bounded(None, None, tiny, &control()),
719 Err(DbError::LegacyRelationReadLimit {
720 limit: LegacyRelationReadLimit::Deadline,
721 ..
722 })
723 ),
724 "local deadline was not observed",
725 )?;
726 require(
727 limits(&store.connection)? == original_limits,
728 "deadline leaked native limits",
729 )?;
730 Ok(())
731 }
732}