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 갱신

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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