Skip to content

scan cursor: incremental/cancellable scan, exact total, per-call filter #39

Description

@mundi4

배경 — 소비자 요구 3건, 하나의 스캔 구조

  1. 취소·양보 가능 스캔: search()가 전 엔트리를 동기 단일 루프로 훑고 한 번에 반환한다 (createSearcher.ts makeRuntime의 scan 루프). 워커에서 쓰면 블로킹되고, 키스트로크 간 이전 쿼리 취소가 불가능하다. 소비자는 N건마다 event loop 양보 + AbortSignal 취소가 필요하다. 세션 재사용은 비용을 줄일 뿐 cold(첫 타) 스캔의 양보/취소성을 주지 못한다
  2. 정확한 total: limit > 0이면 heap이 top-N만 반환하지만, 전체 매치 수(matchedIndices.length)는 내부적으로 이미 알고 있다 — 노출만 안 됨. UI의 "전체 N건" 표시에 필요
  3. per-call filter: 그룹 필터링을 위해 searcher를 그룹별로 쪼개면 전역 랭킹·limit·세션이 깨진다 → 단일 searcher에 per-call 필터 필요. 단, 필터는 세션 prefix-reuse 캐시와 충돌하므로 재사용 규칙이 함께 정의되어야 한다

셋 다 "스캔 한 번의 진행 / 산출물 / 커밋 시점"이라는 같은 지점을 건드리므로 하나의 커서 구조로 함께 해결한다.

설계 결정

pull 기반 커서 — 라이브러리에 Promise/AbortSignal을 도입하지 않는다:

  • 취소 = 커서를 버리는 것. 세션 커밋은 스캔 완료 시에만 일어나므로, 중단된 스캔의 부분 matchedIndices(전체 매치 집합의 부분집합)가 세션에 커밋되어 이후 prefix 쿼리가 매치를 누락하는 오염이 구조적으로 불가능
  • 양보 주기(budget)와 async 래핑은 소비자 몫 — 라이브러리는 zero-dependency 유지
interface ScanCursor<R> {
    /** budget개 엔트리 평가 후 반환. 스캔 완료 시 true. budget 생략 = 끝까지 */
    next(budget?: number): boolean;
    readonly done: boolean;
    /** 지금까지 평가한 엔트리 수 (진행률 UI용) */
    readonly processed: number;
    /** 이 스캔이 평가할 엔트리 총수 (세션 재사용 시 = 이전 매치 수) */
    readonly scanSize: number;
    /** 지금까지 발견한 매치 수. done 이후엔 limit와 무관한 정확한 전체 매치 수 */
    readonly total: number;
    /** score desc 정렬된 결과 (limit 적용). done 전엔 현재까지의 부분 결과 snapshot */
    results(): R[];
}
  • Searcher<T>.scan(queryInput, options?: SearchResultOptions<T>): ScanCursor<SearchResult<T>>, 멀티필드 동일 (ScanCursor<MultiFieldSearchResult<T>>)
  • SearchResultOptionsSearchResultOptions<T = unknown>로 제네릭화하고 filter?: (item: T) => boolean 추가. 기본 타입 인자 덕에 기존 무인자 사용처(SearchOptions alias 포함) 호환
  • search()는 scan 위에 재구현: const c = scanImpl(q, opts); c.next(); return c.results(); — 코드 경로 단일화. 기존 시그니처·반환 타입 불변 (total이 필요한 소비자는 scan 사용)

세션 커밋 규칙

  • 커밋(prevTokens / prevLiteral / prevMatchedIndices / prevFilter)은 next()가 마지막 엔트리를 평가해 스캔을 완료하는 시점에만 수행
  • mutation guard: makeRuntime에 generation 카운터를 두고 add/remove/replaceAll에서 증가. 커서는 생성 시 캡처하고 next()에서 불일치하면 Error("fuzzly: searcher was mutated during scan") throw (entries 인덱스가 무효화되므로). results()는 이미 만들어진 값의 반환이라 guard 불필요
  • 커서 동시 사용 허용, last-completion-wins: 늦게 완료된 이전 쿼리의 커서가 세션을 "되돌려도", 커밋되는 (tokens ↔ matched set ↔ filter) 쌍이 내부적으로 일관되므로 unsound하지 않다 — 다음 재사용이 덜 최적일 뿐. 코드 주석으로 문서화

filter × 세션 재사용

  • filter는 evaluate 전에 평가 — 미통과 엔트리는 매칭 비용 자체를 스킵하고 결과·total·matchedIndices에서 제외
  • 재사용 조건 확장: 기존 토큰 atom-prefix 조건 AND literal 플래그 일치 AND filtersCompatible:
    • currentFilter === prevFilter (참조 동등) → 재사용 가능
    • prevFilter == null (이전 스캔이 무필터) → 재사용 가능 — superset을 좁히는 방향이라 sound
    • 그 외 (필터 제거·교체) → full scan
  • 계약 문서화: 키스트로크 간 세션 재사용을 유지하려면 동일한 함수 참조를 유지할 것 (그룹 선택별로 filter 함수를 memoize)

선행 이슈

변경 사항

src/types.ts

  • ScanCursor<R> 추가 (export)
  • SearchResultOptions<T = unknown> + filter?: (item: T) => boolean
  • Searcher<T> / MultiFieldSearcher<T>scan() 추가, search의 options 타입을 SearchResultOptions<T>

src/createSearcher.ts — makeRuntime 재구성

  • SEARCH_ONLY_KEYS"filter" 추가
  • 세션 상태에 prevFilter 추가 (resetSession 포함), generation 카운터 + 뮤테이션 메서드에서 증가
  • scan(queryInput, opts) 구현:
    1. 쿼리 빌드 / 토큰 산출 / 재사용 판정(filter 호환 포함)은 커서 생성 시 1회
    2. 스캔 소스는 sessionIndices 배열 또는 0..entries.length 숫자 범위 — 커서 내부 position 인덱스로 budget 단위 진행 (iota generator 제거)
    3. limit 경로 heap / no-limit 수집 배열을 커서 상태로 이동. results(): 미완료 시 복사본 정렬([...heap].sort), 완료 시 1회 정렬 후 캐시
    4. 완료 시점에 세션 커밋
  • search()를 scan 합성으로 교체

테스트 (신규 test/scan.test.ts 권장)

  • 등가성: 동일 쿼리에서 scan + next() + results()search() (search가 scan 합성으로 바뀌므로 기존 search 테스트 전체 green도 등가성 증거)
  • budget: next(2) 반복 시 processed 단조 증가, 완료 전 done === false, 최종 processed === scanSize
  • total: 매치 5건 / limit: 2results().length === 2, total === 5. prefix 확장으로 세션 재사용된 후에도 fresh searcher와 total 동일
  • abort 무해성: scan("가")을 절반만 진행하고 버림 → 이어지는 search("가나") 결과가 fresh searcher와 동일 (부분 스캔이 세션을 오염시키지 않음)
  • filter:
    • 적용 시 결과·total 정확
    • 동일 참조 유지한 prefix 시퀀스 = fresh와 동일 결과 (재사용 경로 정확성)
    • 참조 교체 시에도 정확 (full scan 강제 확인 — 이전 필터보다 넓은 필터로 교체해 재사용이 남아 있으면 결과가 누락되는 구성으로)
    • 무필터 세션 뒤 필터 추가도 정확
  • mutation guard: scan 진행 중 add() → 다음 next() throw
  • 멀티필드 smoke: scan / total / filter 각 1케이스 (공유 런타임 검증)

문서

  • CLAUDE.md: Public API에 scan/ScanCursor, SearchResultOptions.filter, total 노출, 커밋·재사용 규칙(완료 시 커밋, filter 참조 동등) 요약 추가
  • 소비자 async 래퍼 예시를 scan() JSDoc에 포함:
async function searchAsync(searcher, q, { limit, filter, signal, chunk = 256 } = {}) {
    const cursor = searcher.scan(q, { limit, filter });
    while (!cursor.next(chunk)) {
        if (signal?.aborted) return null; // 커서 버림 = 취소. 세션 오염 없음
        await new Promise((r) => setTimeout(r)); // event loop 양보
    }
    return { results: cursor.results(), total: cursor.total };
}

Non-goals

  • AbortSignal/async API 라이브러리 내장 (위 래퍼로 충분, zero-dependency 유지)
  • done 전 results()의 순서/완전성 보장 강화 (현재까지의 top-N snapshot이면 충분)
  • score 기반 초성 demotion (#36의 후속 아이디어 — 별도 이슈로)

완료 기준

  • 신규 테스트 전부 + 기존 전체 green, npm run check:fix clean
  • search()가 scan 합성으로 단일 경로화
  • CLAUDE.md 갱신

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions