fix(dogfood): auto-purge stored docs for filesystem-deleted files #148

Merged
altair823 merged 3 commits from fix/dogfood-file-deletion-auto-purge into main 2026-05-20 07:10:37 +00:00
Owner

요약

Multi-root dogfooding 결과 추가 issue — rm a.mdkebab ingest 가 stored doc/chunks/embeddings 를 그대로 두어 search 에 ghost citation 노출. 사용자 결정: "A만 fix (file deletion 자동 purge), config include scope 좁아짐은 explicit 명령으로".

동작 차이

케이스 이전 이후
rm a.md → ingest stale doc 그대로, 검색에 ghost 자동 purge, search 깨끗
include scope 좁아짐 (file 은 fs 에 살아있음) doc 그대로 여전히 그대로 (사용자 데이터 보호 — kebab reset 명령 필요)

filesystem 존재 확인이 두 케이스를 구분 — 안전 보장.

변경

  • kebab-core::DocumentStore trait: all_workspace_paths() 메서드 추가 (orphan reconciliation 용)
  • kebab-store-sqlite::SqliteStore::all_workspace_paths 구현 (SELECT workspace_path FROM documents)
  • kebab-store-sqlite::purge_deleted_workspace_path(path) -> Vec<ChunkId>: doc + cascade chunks/blocks/embedding_records 삭제, 'copied' 면 storage 파일 best-effort 삭제, twin-file 보호 (SELECT COUNT(*) FROM documents WHERE asset_id = ? 가 1 이상이면 asset row 보존)
  • kebab-app::sweep_deleted_files: walker scan 후 per-asset loop 전 호출. stored_paths - scanned_paths 의 각 path 에 대해 fs existence 확인 → 없을 때만 purge. vector store 가 wired 됐으면 chunk_ids 로 vector delete. corpus_revision bump 으로 search cache invalidation.
  • IngestReport.purged_deleted_files: u32 필드 추가 (additive, #[serde(default)] 로 back-compat).
  • CLI 사람-친화 summary: purged N 표기 (count > 0 일 때만).
  • ingest_report snapshot baseline 재생성.

회귀 테스트 (2건 추가)

  • file_deletion_auto_purge: 2 files ingest → 1 file 삭제 → re-ingest → purged_deleted_files=1, deleted file 이 kebab list docs 에서 사라짐, 그 content 검색 시 hit 없음.
  • include_scope_narrowing_does_not_purge: 2 files ingest → config include 좁힘 (file 들 fs 에 살아있음) → re-ingest → purged_deleted_files=0. 사용자 데이터 보호 보장.

검증

  • cargo test -p kebab-core --lib → 57/57
  • cargo test -p kebab-store-sqlite --lib → 20/20
  • cargo test -p kebab-app --lib → 51/51
  • cargo test -p kebab-app --test file_deletion_auto_purge → 2/2 (new)
  • cargo test -p kebab-app --test code_ingest_smoke → 6/6 (regression 없음)
  • cargo test -p kebab-app --test twin_files_idempotent → 1/1 (regression 없음 — twin-file asset 보호 정상)
  • cargo clippy --all-targets -- -D warnings clean

영향

  • Wire schema additive (IngestReport.purged_deleted_files 신규 필드, #[serde(default)] 라 기존 consumer ���영향).
  • frozen design 변경 없음.
  • 기존 색인 데이터 invalidation 없음.

🤖 Generated with Claude Code

## 요약 Multi-root dogfooding 결과 추가 issue — `rm a.md` 후 `kebab ingest` 가 stored doc/chunks/embeddings 를 그대로 두어 search 에 ghost citation 노출. 사용자 결정: "**A만 fix (file deletion 자동 purge)**, config include scope 좁아짐은 explicit 명령으로". ## 동작 차이 | 케이스 | 이전 | 이후 | |--------|------|------| | `rm a.md` → ingest | stale doc 그대로, 검색에 ghost | 자동 purge, search 깨끗 | | `include` scope 좁아짐 (file 은 fs 에 살아있음) | doc 그대로 | **여전히 그대로** (사용자 데이터 보호 — `kebab reset` 명령 필요) | filesystem 존재 확인이 두 케이스를 구분 — 안전 보장. ## 변경 - `kebab-core::DocumentStore` trait: `all_workspace_paths()` 메서드 추가 (orphan reconciliation 용) - `kebab-store-sqlite::SqliteStore::all_workspace_paths` 구현 (`SELECT workspace_path FROM documents`) - `kebab-store-sqlite::purge_deleted_workspace_path(path) -> Vec<ChunkId>`: doc + cascade chunks/blocks/embedding_records 삭제, `'copied'` 면 storage 파일 best-effort 삭제, **twin-file 보호** (`SELECT COUNT(*) FROM documents WHERE asset_id = ?` 가 1 이상이면 asset row 보존) - `kebab-app::sweep_deleted_files`: walker scan 후 per-asset loop 전 호출. `stored_paths - scanned_paths` 의 각 path 에 대해 fs existence 확인 → 없을 때만 purge. vector store 가 wired 됐으면 chunk_ids 로 vector delete. `corpus_revision` bump 으로 search cache invalidation. - `IngestReport.purged_deleted_files: u32` 필드 추가 (additive, `#[serde(default)]` 로 back-compat). - CLI 사람-친화 summary: `purged N` 표기 (count > 0 일 때만). - ingest_report snapshot baseline 재생성. ## 회귀 테스트 (2건 추가) - `file_deletion_auto_purge`: 2 files ingest → 1 file 삭제 → re-ingest → `purged_deleted_files=1`, deleted file 이 `kebab list docs` 에서 사라짐, 그 content 검색 시 hit 없음. - `include_scope_narrowing_does_not_purge`: 2 files ingest → config include 좁힘 (file 들 fs 에 살아있음) → re-ingest → `purged_deleted_files=0`. **사용자 데이터 보호 보장.** ## 검증 - `cargo test -p kebab-core --lib` → 57/57 - `cargo test -p kebab-store-sqlite --lib` → 20/20 - `cargo test -p kebab-app --lib` → 51/51 - `cargo test -p kebab-app --test file_deletion_auto_purge` → 2/2 (new) - `cargo test -p kebab-app --test code_ingest_smoke` → 6/6 (regression 없음) - `cargo test -p kebab-app --test twin_files_idempotent` → 1/1 (regression 없음 — twin-file asset 보호 정상) - `cargo clippy --all-targets -- -D warnings` clean ## 영향 - Wire schema **additive** (`IngestReport.purged_deleted_files` 신규 필드, `#[serde(default)]` 라 기존 consumer ���영향). - frozen design 변경 없음. - 기존 색인 데이터 invalidation 없음. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
altair823 added 1 commit 2026-05-20 06:52:11 +00:00
Files deleted from disk (rm a.md) were leaving stale documents + chunks +
embeddings in the store, surfacing as ghost citations in search/ask.
Existing purge_orphan_at_workspace_path only handled content-changed
stale (WHERE workspace_path=? AND asset_id != ?) — file deletion has no
new asset_id.

Fix: post-walker-scan sweep. Compute (stored_paths - scanned_paths),
for each candidate check filesystem existence — only purge when the
file is TRULY missing. Scope-narrowing case (file on disk but outside
include glob) is explicitly NOT purged to protect users from accidental
data loss via config edits.

Adds:
- DocumentStore::all_workspace_paths trait method + SqliteStore impl
- purge_deleted_workspace_path in store-sqlite (returns chunk_ids for
  vector delete; deletes doc CASCADE + asset row + copied storage file)
- sweep_deleted_files in kebab-app::ingest path; called once per ingest
  before the per-asset loop
- IngestReport.purged_deleted_files counter (additive, serde default)
- CLI ingest summary mentions purge count when > 0
- 2 integration tests: file_deletion_auto_purge + include_scope_narrowing_does_NOT_purge

dogfood discovery (PR #142 1B + multi-root: kebab-docs + httpx + zod
+ lodash). Per user decision: only filesystem deletion auto-purges;
scope narrowing requires explicit kebab reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-20 06:55:54 +00:00
claude-reviewer-01 left a comment
Member

회차 1 — 전반 설계와 테스트 커버리지는 견고하나, fs 존재 확인 로직에 데이터-안전성 버그 1건을 발견했습니다.

요약: sweep 로직·twin-file COUNT 순서·chunk_ids 수집 순서·vector store None 처리·corpus_revision 조건부 bump 모두 정확합니다. 테스트 2건(file_deletion_auto_purge + include_scope_narrowing_does_not_purge), 11개 파일 변경, snapshot 재베이킹, wire 필드 backward-compat — 모두 확인 완료.

필수 수정 1건: abs.exists()fs::metadata().is_ok()와 동일하여, 파일에 접근 권한이 없는 경우(NFS permission denied, 소유자 변경 등) false를 반환해 존재하는 파일을 purge합니다. try_exists().unwrap_or(true)로 교체해야 합니다.

선택 수정 1건: CLI 출력에서 purged 토큰이 errors 뒤에 위치해 순서가 다소 어색합니다.

회차 1 — 전반 설계와 테스트 커버리지는 견고하나, fs 존재 확인 로직에 데이터-안전성 버그 1건을 발견했습니다. **요약**: sweep 로직·twin-file COUNT 순서·chunk_ids 수집 순서·vector store None 처리·corpus_revision 조건부 bump 모두 정확합니다. 테스트 2건(file_deletion_auto_purge + include_scope_narrowing_does_not_purge), 11개 파일 변경, snapshot 재베이킹, wire 필드 backward-compat — 모두 확인 완료. **필수 수정 1건**: `abs.exists()`는 `fs::metadata().is_ok()`와 동일하여, 파일에 접근 권한이 없는 경우(NFS permission denied, 소유자 변경 등) `false`를 반환해 존재하는 파일을 purge합니다. `try_exists().unwrap_or(true)`로 교체해야 합니다. **선택 수정 1건**: CLI 출력에서 `purged` 토큰이 `errors` 뒤에 위치해 순서가 다소 어색합니다.

[안전성 — 중요] abs.exists()fs::metadata(self).is_ok()와 동일합니다. 파일에 읽기 권한이 없는 경우 (NFS permission denied, 소유자 변경 등) metadata()Err를 반환하여 exists()false를 돌려줍니다 — 즉 파일이 디스크에 존재함에도 purge가 실행됩니다.

보수적 접근이 필요한 이 코드에서는 try_exists().unwrap_or(true)를 써야 합니다:

// 현재 (unsafe)
if abs.exists() {
    continue;
}

// 권장 (conservative)
if abs.try_exists().unwrap_or(true) {
    // true면 존재 또는 판별 불가 → 보존
    continue;
}

try_exists()는 1.63 stable부터 사용 가능하며 러스트 에디션 2024과 무관합니다. unwrap_or(true) 덕분에 FS 오류 시 '파일 존재 가정' 원칙이 성립합니다.

**[안전성 — 중요]** `abs.exists()`는 `fs::metadata(self).is_ok()`와 동일합니다. 파일에 읽기 권한이 없는 경우 (NFS permission denied, 소유자 변경 등) `metadata()`가 `Err`를 반환하여 `exists()`가 `false`를 돌려줍니다 — 즉 파일이 **디스크에 존재함에도 purge가 실행됩니다**. 보수적 접근이 필요한 이 코드에서는 `try_exists().unwrap_or(true)`를 써야 합니다: ```rust // 현재 (unsafe) if abs.exists() { continue; } // 권장 (conservative) if abs.try_exists().unwrap_or(true) { // true면 존재 또는 판별 불가 → 보존 continue; } ``` `try_exists()`는 1.63 stable부터 사용 가능하며 러스트 에디션 2024과 무관합니다. `unwrap_or(true)` 덕분에 FS 오류 시 '파일 존재 가정' 원칙이 성립합니다.

[설계 칭찬] non-fatal sweep 설계 (개별 파일 purge 실패 시 warn 로깅 후 continue)가 적절합니다. 단일 파일 DB 오류가 전체 ingest를 막지 않고, vector 삭제 실패 시 'orphan vector는 kebab reset --vector-only로 정리 가능'이라는 escape hatch도 명시되어 있습니다.

**[설계 칭찬]** non-fatal sweep 설계 (개별 파일 purge 실패 시 warn 로깅 후 continue)가 적절합니다. 단일 파일 DB 오류가 전체 ingest를 막지 않고, vector 삭제 실패 시 'orphan vector는 `kebab reset --vector-only`로 정리 가능'이라는 escape hatch도 명시되어 있습니다.
@@ -0,0 +44,4 @@
std::fs::write(&b_path, "// file b\nfn bravo() {}\n").unwrap();
// First ingest — both must be New.
let first = ingest_with_config_opts(

[칭찬] include_scope_narrowing_does_not_purge 테스트가 설계 제약 조건을 정확히 검증합니다. b_narrow.rs가 여전히 디스크에 존재하는 상태로 include glob만 좁혀서 두 번째 ingest를 실행한 뒤 purged_deleted_files == 0을 단언하고, store에서도 b_narrow.rs가 유지됨을 직접 확인합니다. 이 테스트가 존재하는 것만으로도 '설계 불변식이 회귀되지 않는다'는 신뢰를 줍니다.

**[칭찬]** `include_scope_narrowing_does_not_purge` 테스트가 설계 제약 조건을 정확히 검증합니다. `b_narrow.rs`가 여전히 디스크에 존재하는 상태로 include glob만 좁혀서 두 번째 ingest를 실행한 뒤 `purged_deleted_files == 0`을 단언하고, store에서도 `b_narrow.rs`가 유지됨을 직접 확인합니다. 이 테스트가 존재하는 것만으로도 '설계 불변식이 회귀되지 않는다'는 신뢰를 줍니다.

[Nit] purged 토큰이 errors 뒤에 붙어서 출력 순서가 약간 어색합니다:

scanned 5  new 0  updated 0  skipped 0  errors 0  purged 1  (42 ms)

purged는 '처리 결과 카운트'에 가까우므로 errors 앞에 놓는 게 더 자연스럽습니다:

scanned 5  new 0  updated 0  purged 1  skipped 0  errors 0  (42 ms)

필수는 아니지만 사용자 출력 가독성 측면에서 고려해볼 만합니다.

**[Nit]** `purged` 토큰이 `errors` 뒤에 붙어서 출력 순서가 약간 어색합니다: ``` scanned 5 new 0 updated 0 skipped 0 errors 0 purged 1 (42 ms) ``` `purged`는 '처리 결과 카운트'에 가까우므로 `errors` 앞에 놓는 게 더 자연스럽습니다: ``` scanned 5 new 0 updated 0 purged 1 skipped 0 errors 0 (42 ms) ``` 필수는 아니지만 사용자 출력 가독성 측면에서 고려해볼 만합니다.

[칭찬] chunk_ids를 CASCADE DELETE 이전에 수집하는 순서 (SELECT chunk_idDELETE FROM documents → vector 삭제)가 올바릅니다. FK CASCADE가 먼저 실행되면 chunk_ids를 잃어버리므로 이 순서가 핵심입니다. drop(stmt) 호출로 statement를 명시적으로 닫고 DELETE를 진행하는 것도 좋은 방어 코드입니다.

**[칭찬]** chunk_ids를 CASCADE DELETE 이전에 수집하는 순서 (`SELECT chunk_id` → `DELETE FROM documents` → vector 삭제)가 올바릅니다. FK CASCADE가 먼저 실행되면 chunk_ids를 잃어버리므로 이 순서가 핵심입니다. `drop(stmt)` 호출로 statement를 명시적으로 닫고 DELETE를 진행하는 것도 좋은 방어 코드입니다.

[칭찬] twin-file protection의 COUNT-after-DELETE 순서가 정확합니다. DELETE FROM documents WHERE doc_id = ? 실행 후에 SELECT COUNT(*) FROM documents WHERE asset_id = ?를 조회하므로, 이 문서 자신은 이미 제거된 상태에서 remaining_refs를 세게 됩니다. 같은 asset을 공유하는 다른 문서가 없을 때만 remaining_refs == 0이 되어 asset row가 삭제됩니다. 정확한 구현입니다.

**[칭찬]** twin-file protection의 COUNT-after-DELETE 순서가 정확합니다. `DELETE FROM documents WHERE doc_id = ?` 실행 후에 `SELECT COUNT(*) FROM documents WHERE asset_id = ?`를 조회하므로, 이 문서 자신은 이미 제거된 상태에서 `remaining_refs`를 세게 됩니다. 같은 asset을 공유하는 다른 문서가 없을 때만 `remaining_refs == 0`이 되어 asset row가 삭제됩니다. 정확한 구현입니다.
altair823 added 1 commit 2026-05-20 07:04:08 +00:00
Round 1 review found a data-safety bug: fs::exists() returns false on
errors like EACCES / EPERM / NFS-hiccup / ownership-change, which would
trigger purge on a file that is in fact still on disk (just unreadable
this moment). Switched to try_exists().unwrap_or(true) so transient FS
errors are CONSERVATIVELY treated as 'file present' — never purge on
uncertain signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-20 07:09:02 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 blocking 지적(data-safety) 완전 반영. APPROVE.

round 1 에서 제기한 핵심 버그( 가 EACCES 등 FS 오류 시 false 를 반환해 파일을 wrongful purge 할 수 있음)를 로 교체해 보수적 처리로 확정했습니다. 인라인 코드 주석도 WHY 를 명확히 설명하도록 갱신되어 향후 유지보수에서 실수할 여지를 줄였습니다.

cosmetic nit (CLI summary 에서 purged 가 errors 뒤에 위치) 은 반영되지 않았으나, errors 가 먼저 오는 현재 순서도 기능적으로 문제없고 별도 커밋 비용이 수정 효익보다 크다는 판단으로 이해합니다 — 수용 가능합니다.

테스트 재확인:

  • : 2/2 PASS (deletion purges + scope-narrowing 보호 모두 정상)
  • : 1/1 PASS (asset-row 공유 보호 정상)
  • : clean

인라인에 문서 불일치 nit 1건 남깁니다 (안전 무관, 후속 PR 처리 가능).

회차 2 — 회차 1 blocking 지적(data-safety) 완전 반영. APPROVE. round 1 에서 제기한 핵심 버그( 가 EACCES 등 FS 오류 시 false 를 반환해 파일을 wrongful purge 할 수 있음)를 로 교체해 보수적 처리로 확정했습니다. 인라인 코드 주석도 WHY 를 명확히 설명하도록 갱신되어 향후 유지보수에서 실수할 여지를 줄였습니다. cosmetic nit (CLI summary 에서 purged 가 errors 뒤에 위치) 은 반영되지 않았으나, errors 가 먼저 오는 현재 순서도 기능적으로 문제없고 별도 커밋 비용이 수정 효익보다 크다는 판단으로 이해합니다 — 수용 가능합니다. 테스트 재확인: - : 2/2 PASS (deletion purges + scope-narrowing 보호 모두 정상) - : 1/1 PASS (asset-row 공유 보호 정상) - : clean 인라인에 문서 불일치 nit 1건 남깁니다 (안전 무관, 후속 PR 처리 가능).

nit: 함수 수준 doc-comment 3번 항목(line ~1486)에 fs::exists() 라는 구버전 표현이 남아 있습니다. 실제 구현은 이미 try_exists().unwrap_or(true) 로 교체됐으므로, doc-comment 도 try_exists().unwrap_or(true) (또는 "보수적 존재 확인")로 맞추면 좋겠습니다. 안전에 영향 없는 문서 불일치이므로 후속 PR 에서 처리해도 무방합니다.

nit: 함수 수준 doc-comment 3번 항목(line ~1486)에 `fs::exists()` 라는 구버전 표현이 남아 있습니다. 실제 구현은 이미 `try_exists().unwrap_or(true)` 로 교체됐으므로, doc-comment 도 `try_exists().unwrap_or(true)` (또는 "보수적 존재 확인")로 맞추면 좋겠습니다. 안전에 영향 없는 문서 불일치이므로 후속 PR 에서 처리해도 무방합니다.
altair823 added 1 commit 2026-05-20 07:10:31 +00:00
Round 2 review found the function-level doc-comment still referenced the
old fs::exists() (now replaced by try_exists().unwrap_or(true) in commit
2baa846). One-line clarification — describes the conservative-on-Err
semantics so future readers don't reintroduce the data-safety bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
altair823 merged commit d26efe167f into main 2026-05-20 07:10:36 +00:00
altair823 deleted branch fix/dogfood-file-deletion-auto-purge 2026-05-20 07:10:37 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#148