Repository navigation
Expose trace progress and per-record truncation for bounded highlight recovery #68
Description
Outcome
Expose bounded trace progress and per-record truncation state so consumers can recover highlight batches without discarding completed records, and can distinguish truncated highlights from incomplete match analysis.
The current response-wide complete: false behavior is documented; this requests a recovery capability, not removal of resource limits. A consumer currently has to reject the batch or retry smaller batches. Splitting cannot recover a single record that exceeds the span ceiling.
Independent reproduction — observed
Reproduced without Harper, application data, or the earlier Harper patch:
Input to traceMatches({ text: 'needle', mode: 'any' }, records) |
Observed result |
|---|---|
Three records a, b, c, each containing "needle " repeated 600 times |
complete: false; a: 600 spans, b: 424 spans, c: omitted |
| Each of those records traced alone | Each is complete with 600 spans |
| One record containing 1,100 repetitions | complete: false, 1,024 spans; retrying as a singleton cannot remove this limit |
| Same oversized record, snippets enabled with one 32-character fragment | Same incompleteness and 1,024 spans |
Executed with Node.js 24.16.0 on macOS arm64 and installed @harperfast/fulltext 0.5.0, native ABI 8 / query API 3. The portable script below was executed against that installed package; a fresh registry installation was not tested. A separate Rust reproduction confirmed the batch and singleton results against current-main source, whose manifest is 0.6.0; this is source-build evidence, not a claim about a released 0.6.0 artifact.
Standalone public-API reproduction with assertions
In a separate consumer directory with Node.js 24:
npm init -y
npm install --ignore-scripts @harperfast/fulltext@0.5.0
# Save the following as repro.mjs, then:
node repro.mjsimport assert from 'node:assert';
import { mkdtemp, rm } from 'node:fs/promises';
import path from 'node:path';
import { openNativeFullTextIndex, runtimeInfo } from '@harperfast/fulltext/native';
const parent = process.cwd();
const root = await mkdtemp(path.join(parent, 'highlight-independent-'));
const record = (id, n) => ({ id, fields: { title: 'needle '.repeat(n) } });
const describe = (result) => ({ complete: result.complete, records: result.records.map(r => ({ id: r.id, spans: r.values.reduce((n, v) => n + v.spans.length, 0) })) });
let index;
try {
index = await openNativeFullTextIndex({path: path.join(root, 'index'), indexId: 'highlight-repro', generation: '1', fields: [{name: 'title'}], analyzer: 'english@2', positions: true, surfaceTerms: true, limits: { indexingThreads: 1, searchThreads: 2, writerMemoryBytes: 15_000_000, maxQueuedCommands: 16, maxQueuedBytes: 1_048_576, maxBatchBytes: 1_048_576 }});
const query = { text: 'needle', mode: 'any' };
const input = [record('a', 600), record('b', 600), record('c', 600)];
const batch = describe(await index.traceMatches(query, input));
assert.strictEqual(batch.complete, false);
assert.deepStrictEqual(batch.records, [{id:'a',spans:600},{id:'b',spans:424}]);
const singles=[];
for (const row of input) singles.push(describe(await index.traceMatches(query,[row])));
assert(singles.every(r=>r.complete && r.records[0].spans===600));
const singleton = describe(await index.traceMatches(query,[record('large',1100)]));
assert.strictEqual(singleton.complete,false); assert.strictEqual(singleton.records[0].spans,1024);
const fewerFragments = describe(await index.traceMatches(query,[record('large',1100)],{snippets:true,maxFragmentsPerValue:1,fragmentLength:32}));
assert.deepStrictEqual(fewerFragments,singleton);
const output = { runtime: await runtimeInfo(), batch, singles, singleton, fewerFragments, note:'No Harper runtime or application data used; trace input supplied directly through public native API.' };
console.log(JSON.stringify(output,null,2));
} finally { await index?.close({mode:'rollback'}); await rm(root,{recursive:true,force:true}); }Cause and ownership — source verified
- The native protocol limits one trace to 100 records, 1 MiB of source and 1,024 returned spans.
- Native tracing shares the span budget across records and exposes one batch-wide completeness flag. Returned records can include a truncated record; missing records do not identify a safe retry boundary.
- The wrapper generates snippet fragments after native tracing. Lowering
maxFragmentsPerValuedoes not reduce native trace spans. - Documented limits also distinguish the token-analysis ceiling. An incomplete empty trace must not be interpreted as proof of no match.
Kris raised native recovery in Recover incomplete full-text highlight batches. That closed, unmerged Harper mitigation discards incomplete batches and recursively splits them; it still rejects an incomplete singleton. Fulltext should own trace progress/truncation; the consumer should own source projection, request deadlines/cancellation and its response policy. This issue does not reopen or implement the Harper change.
Proposed direction and verification
Proposed contract, with final API shape still to design:
- Report an unambiguous input-progress boundary, including processed nonmatching records, or equivalent per-input status. Consumers must not infer progress from the IDs of returned matches.
- Identify span/output truncation separately from incomplete source analysis, at the appropriate record/value granularity. Do not relabel a partial trace as fully complete.
- Retain completed-record output and retry only unprocessed input. When a single record cannot fit, expose bounded partial-highlight status or an explicitly bounded within-record continuation rather than forcing the same failing retry forever.
- Preserve existing byte/span/token limits and the consumer's original deadline/cancellation budget.
Observed prototype results: a disposable, test-only native prototype retains the existing production trace_matches implementation unchanged and adds a separate diagnostic method. Its record-atomic progress variant defers a boundary record to the next batch; an oversized first record returns bounded spans with explicit truncation status. Five focused Rust tests pass:
- Baseline batch and oversized-singleton reproduction.
- Recovery of all 1,800 spans from the three 600-match records in three calls; each completed record is emitted once. Boundary-record analysis can be repeated; the prototype is not zero-rework and does not guarantee one additional call.
- Oversized singleton consumes one input record, returns 1,024 spans, flags span truncation and does not claim full trace completeness.
- Processed no-match input advances progress; analysis-limited input remains explicitly unknown/incomplete, including an empty result when
needlefollows the token ceiling. - An already expired deadline still returns
E_TIMEOUT.
cargo test --locked --offline with the prototype: 56 passed, 1 ignored, plus zero doc tests. Focused tests were rerun after formatting: 5 passed. This verifies the bounded scenarios above, not a production-ready fix or an end-to-end Harper integration.
Exact test-only prototype patch and reproduction instructions
Apply this diagnostic patch to the pinned main commit in a disposable checkout. It changes no production method and is not a proposed merge-ready patch. With Rust 1.90.0 and the locked dependencies available:
cargo test --locked highlight_issue_ -- --nocapture
cargo test --lockeddiff --git a/src/engine.rs b/src/engine.rs
index d805b83..bf444c6 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -4685,4 +4685,304 @@ mod tests {
};
assert_eq!(error.code, "E_INCOMPLETE_CREATE");
}
+ fn highlight_issue_fixture() -> (Engine, SearchRequest) {
+ let mut cfg = config();
+ cfg.identity.surface_terms = true;
+ let engine = Engine::open(RamDirectory::create(), &cfg).unwrap();
+ let request = SearchRequest {
+ expression: expression("needle", SearchMode::Any, vec!["description".to_owned()]),
+ candidate_ids: None,
+ filter: None,
+ offset: 0,
+ limit: 10,
+ exact_total: true,
+ budget_milliseconds: 30_000,
+ traversal: false,
+ after: None,
+ };
+ (engine, request)
+ }
+ fn highlight_issue_record(id: &str, n: usize) -> TraceRecord {
+ TraceRecord {
+ id: id.to_owned(),
+ fields: vec![("description".to_owned(), vec!["needle ".repeat(n)])],
+ }
+ }
+ fn highlight_issue_counts(result: &TraceResult) -> Vec<(String, usize)> {
+ result
+ .records
+ .iter()
+ .map(|r| (r.id.clone(), r.values.iter().map(|v| v.spans.len()).sum()))
+ .collect()
+ }
+ #[test]
+ fn highlight_issue_baseline_batch_and_singleton() {
+ let (engine, request) = highlight_issue_fixture();
+ let records = [
+ highlight_issue_record("a", 600),
+ highlight_issue_record("b", 600),
+ highlight_issue_record("c", 600),
+ ];
+ let batch = engine.trace_matches(&request, &records, None).unwrap();
+ assert!(!batch.complete);
+ assert_eq!(
+ highlight_issue_counts(&batch),
+ vec![("a".to_owned(), 600), ("b".to_owned(), 424)]
+ );
+ for row in &records {
+ let result = engine.trace_matches(&request, std::slice::from_ref(row), None).unwrap();
+ assert!(result.complete);
+ assert_eq!(highlight_issue_counts(&result)[0].1, 600);
+ }
+ let singleton = engine
+ .trace_matches(&request, &[highlight_issue_record("large", 1100)], None)
+ .unwrap();
+ assert!(!singleton.complete);
+ assert_eq!(highlight_issue_counts(&singleton)[0].1, 1024);
+ println!("BASELINE batch=a:600,b:424,c:omitted; individual=600 complete; oversized=1024 incomplete");
+ }
+ #[test]
+ fn highlight_issue_prototype_progress_preserves_completed_records() {
+ let (engine, request) = highlight_issue_fixture();
+ let records = [
+ highlight_issue_record("a", 600),
+ highlight_issue_record("b", 600),
+ highlight_issue_record("c", 600),
+ ];
+ let mut offset = 0;
+ let mut seen = Vec::new();
+ let mut calls = 0;
+ while offset < records.len() {
+ let result = engine.probe_trace_progress(&request, &records[offset..], None).unwrap();
+ assert!(result.next_record > 0);
+ assert!(result.states.iter().all(|(_, spans, analysis)| !spans && !analysis));
+ seen.extend(highlight_issue_counts(&result.trace));
+ offset += result.next_record;
+ calls += 1;
+ }
+ assert_eq!(
+ seen,
+ vec![("a".to_owned(), 600), ("b".to_owned(), 600), ("c".to_owned(), 600)]
+ );
+ assert_eq!(calls, 3);
+ println!(
+ "PROTOTYPE progress: 3 calls, 1800 spans, completed records emitted once; not a one-extra-call guarantee"
+ );
+ }
+ #[test]
+ fn highlight_issue_prototype_singleton_truncation_is_explicit() {
+ let (engine, request) = highlight_issue_fixture();
+ let result = engine
+ .probe_trace_progress(&request, &[highlight_issue_record("large", 1100)], None)
+ .unwrap();
+ assert_eq!(result.next_record, 1);
+ assert!(!result.trace.complete);
+ assert_eq!(result.states, vec![("large".to_owned(), true, false)]);
+ assert_eq!(highlight_issue_counts(&result.trace)[0].1, 1024);
+ println!("PROTOTYPE singleton: bounded1024 spans; explicit span truncation; source analysis not truncated");
+ }
+ #[test]
+ fn highlight_issue_prototype_no_match_and_analysis_unknown_are_distinct() {
+ let (engine, request) = highlight_issue_fixture();
+ let unmatched = TraceRecord {
+ id: "none".to_owned(),
+ fields: vec![("description".to_owned(), vec!["stone trail".to_owned()])],
+ };
+ let result = engine
+ .probe_trace_progress(&request, &[unmatched, highlight_issue_record("a", 1)], None)
+ .unwrap();
+ assert_eq!(result.next_record, 2);
+ assert_eq!(result.states.len(), 2);
+ assert!(result.trace.complete);
+ assert_eq!(highlight_issue_counts(&result.trace), vec![("a".to_owned(), 1)]);
+ let unknown = TraceRecord {
+ id: "unknown".to_owned(),
+ fields: vec![(
+ "description".to_owned(),
+ vec![format!("{}needle", "x ".repeat(MAX_TRACE_TOKENS_PER_VALUE))],
+ )],
+ };
+ let result = engine.probe_trace_progress(&request, &[unknown], None).unwrap();
+ assert!(!result.trace.complete);
+ assert!(result.trace.records.is_empty());
+ assert_eq!(result.states, vec![("unknown".to_owned(), false, true)]);
+ }
+ #[test]
+ fn highlight_issue_prototype_preserves_deadline() {
+ let (engine, request) = highlight_issue_fixture();
+ let deadline = Some(Instant::now() - std::time::Duration::from_millis(1));
+ let error = match engine.probe_trace_progress(&request, &[highlight_issue_record("a", 1)], deadline) {
+ Ok(_) => panic!("expired deadline accepted"),
+ Err(error) => error,
+ };
+ assert_eq!(error.code, "E_TIMEOUT");
+ }
+}
+
+#[cfg(test)]
+struct ProbeTrace {
+ trace: TraceResult,
+ next_record: usize,
+ states: Vec<(String, bool, bool)>,
+}
+#[cfg(test)]
+impl Engine {
+ fn probe_trace_progress(
+ &self,
+ request: &SearchRequest,
+ records: &[TraceRecord],
+ deadline: Option<Instant>,
+ ) -> Result<ProbeTrace> {
+ check_deadline(deadline)?;
+ if let Some(candidate_ids) = &request.candidate_ids {
+ for id in candidate_ids {
+ validate_record_id(id)?;
+ }
+ }
+ for record in records {
+ validate_record_id(&record.id)?;
+ }
+ let clause = match &request.expression {
+ SearchExpression::Clause(clause) => clause,
+ _ => {
+ return Err(FulltextError::invalid(
+ "traceMatches does not support boolean expressions",
+ ))
+ }
+ };
+ let selected = self.selected_fields(&clause.fields)?;
+ self.require_surface_fields(&selected)?;
+ let selected_names = selected.iter().map(|field| field.name.as_str()).collect::<HashSet<_>>();
+ let candidates = request
+ .candidate_ids
+ .as_ref()
+ .map(|ids| ids.iter().map(String::as_str).collect::<HashSet<_>>());
+ if candidates.as_ref().is_some_and(HashSet::is_empty) {
+ return Ok(ProbeTrace {
+ trace: TraceResult {
+ complete: true,
+ records: Vec::new(),
+ },
+ next_record: records.len(),
+ states: Vec::new(),
+ });
+ }
+ let plan = self.trace_plan(clause)?;
+ let mut complete = true;
+ let mut remaining_spans = MAX_TRACE_SPANS;
+ let mut response_bytes = 3usize;
+ let mut matched_records = Vec::new();
+ let mut next_record = 0usize;
+ let mut states = Vec::new();
+ let mut analyzed_source_analyzer = self.index_analyzer.clone();
+ let mut surface_source_analyzer = self.surface_index_analyzer.clone();
+ for (record_index, record) in records.iter().enumerate() {
+ check_deadline(deadline)?;
+ if remaining_spans == 0 {
+ complete = false;
+ break;
+ }
+ if candidates
+ .as_ref()
+ .is_some_and(|candidates| !candidates.contains(record.id.as_str()))
+ {
+ next_record = record_index + 1;
+ continue;
+ }
+ let mut seen_fields = HashSet::new();
+ let mut pending_values = Vec::new();
+ let mut found = HashSet::new();
+ let mut record_span_budget = remaining_spans;
+ let mut record_truncated = false;
+ let mut record_analysis_truncated = false;
+ for (field, source_values) in &record.fields {
+ if !seen_fields.insert(field.as_str()) {
+ return Err(FulltextError::invalid(format!("duplicate trace field {field}")));
+ }
+ if !self.field_lookup.contains_key(field) {
+ return Err(FulltextError::invalid(format!("unknown trace field {field}")));
+ }
+ if !selected_names.contains(field.as_str()) {
+ continue;
+ }
+ for (value_index, value) in source_values.iter().enumerate() {
+ check_deadline(deadline)?;
+ let (spans, terms, spans_truncated, analysis_truncated) = self.trace_value(
+ &plan,
+ value,
+ record_span_budget,
+ deadline,
+ &mut analyzed_source_analyzer,
+ &mut surface_source_analyzer,
+ )?;
+ record_truncated |= spans_truncated || analysis_truncated;
+ record_analysis_truncated |= analysis_truncated;
+ found.extend(terms);
+ if spans.is_empty() {
+ continue;
+ }
+ record_span_budget = record_span_budget.saturating_sub(spans.len());
+ pending_values.push((field.as_str(), value_index as u32, spans));
+ }
+ }
+ if record_truncated && !record_analysis_truncated && next_record > 0 {
+ complete = false;
+ break;
+ }
+ next_record = record_index + 1;
+ states.push((
+ record.id.clone(),
+ record_truncated && !record_analysis_truncated,
+ record_analysis_truncated,
+ ));
+ complete &= !record_analysis_truncated;
+ if plan.record_matches(&found) {
+ complete &= !record_truncated;
+ if pending_values.is_empty() {
+ continue;
+ }
+ let record_header_bytes = 6usize.saturating_add(record.id.len());
+ let mut record_bytes = 0usize;
+ let mut values = Vec::new();
+ for (field, value_index, spans) in pending_values {
+ let value_header_bytes = 10usize.saturating_add(field.len());
+ let available = MAX_SEARCH_RESPONSE_BYTES
+ .saturating_sub(response_bytes)
+ .saturating_sub(record_header_bytes)
+ .saturating_sub(record_bytes);
+ let byte_limited_spans = available.saturating_sub(value_header_bytes) / 8;
+ let keep = spans.len().min(remaining_spans).min(byte_limited_spans);
+ if keep < spans.len() {
+ complete = false;
+ }
+ if keep == 0 {
+ continue;
+ }
+ values.push(TraceValueMatch {
+ field: field.to_owned(),
+ value_index,
+ spans: spans.into_iter().take(keep).collect(),
+ });
+ remaining_spans -= keep;
+ record_bytes = record_bytes.saturating_add(value_header_bytes + keep * 8);
+ }
+ if values.is_empty() {
+ continue;
+ }
+ response_bytes = response_bytes.saturating_add(record_header_bytes + record_bytes);
+ matched_records.push(TraceRecordMatch {
+ id: record.id.clone(),
+ values,
+ });
+ }
+ }
+ Ok(ProbeTrace {
+ trace: TraceResult {
+ complete,
+ records: matched_records,
+ },
+ next_record,
+ states,
+ })
+ }
}Acceptance criteria and remaining qualification
- Add native and public Node-API regressions for the reproduction, completed/nonmatching input progress, exact-limit boundaries, oversized records and multi-value/multi-field text.
- Qualify response-byte exhaustion and mixed token/span truncation; the diagnostic prototype has not established correct status for those paths.
- Specify whether each reason permits retry, bounded partial highlights, or an explicit failure; incomplete match analysis must never become a false negative or
complete: true. - Preserve source offsets, ordering and duplicate-free completed output; ensure progress or a terminal status on every resumable response.
- Update the protocol/ABI and TypeScript contract compatibly, retain resource bounds, and test deadline/cancellation across recovery calls and shutdown.
- Measure trace calls, repeated analysis, bytes and latency against splitting on representative batches. No performance improvement is claimed from the diagnostic tests.
- Qualify a consumer integration that retains completed records and handles a single oversized record without a generic 500 solely for explicitly permitted highlight truncation. Any fail-closed behavior for incomplete match analysis must remain explicit.
- Keep Boolean-expression tracing in Support Boolean search expressions in match tracing and snippets; it is a separate capability.
Evidence review
A tools-denied public Gemini 2.5 Pro CLI second reader checked this draft against the supplied public source, observed reproduction output, diagnostic patch and Rust test logs; it reported no unverifiable claims or contradictions. This was a static document/evidence review, not an independent execution or a production-fix review.
Tracking
Parent: Epic: Extend fulltext search capabilities beyond the initial wrapper API. Proposed type: Feature; priority P3 because this extends a documented bounded API and has no committed roadmap/release allocation. Effort: High, covering the native protocol, wrapper contract and subsequent consumer qualification. No milestone or assignee requested; no customer data or security incident involved.
Comment generated by kAIle (GPT-6); posted via @kylebernhardy.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity