fix(dogfood): document-centric try_skip_unchanged for twin-file idempotency #146

Merged
altair823 merged 2 commits from fix/dogfood-bug4-idempotent-twin-files into main 2026-05-20 06:16:31 +00:00
Owner

요약

Multi-root dogfooding (kebab-docs + httpx + zod + lodash) 의 3번째 발견 production bug fix. 1A-1 origin 의 잠재 데이터 일관성 이슈.

버그

Twin files (동일 content / 다른 path) 가 매 idempotent re-ingest 마다 Updated 로 분류됨. Dogfood: 726 docs 중 27 이 twin-file victim (예: 빈 __init__.py ×3, AGENTS.mdCLAUDE.md 동일 content, 로고 PDF/JPG 중복).

진단

  • assets.asset_id = blake3 content hash (PRIMARY KEY).
  • 동일 content 두 file → 같은 asset_id → 한 asset row 공유.
  • UPSERT ON CONFLICT(asset_id) DO UPDATE SET workspace_path = excluded.workspace_path → file2 ingest 시 asset row 의 workspace_path 가 file1 → file2 로 덮어씀.
  • Re-ingest 시 try_skip_unchangedget_asset_by_workspace_path(file1)None 반환 (asset row 의 path 는 file2) → unchanged 판정 실패 → re-process → Updated.

Fix

try_skip_unchangeddocument-centric lookup 으로 전환:

  • documents.workspace_path 는 V001 부터 이미 UNIQUE.
  • id_for_doc(path, asset_id, parser_version) 이 path 포함 → twin files 각자 별도 doc row.
  • DocumentStore::get_document_by_workspace_path trait method 추가 → asset-side flip-flop 무관.

비교 순서:

  1. get_document_by_workspace_path(path) (없으면 None → re-process)
  2. doc.source_asset_id == asset.asset_id (content 변경 시 None)
  3. doc.parser_version == current_parser_version (이전 implicit 으로 id_for_doc 통해 확인, 이제 explicit)
  4. chunker_version match (기존)
  5. embedder version match (기존)

Schema migration 불필요documents.workspace_path UNIQUE 인덱스 이미 존재.

변경

  • crates/kebab-core/src/traits.rs: DocumentStore trait 에 get_document_by_workspace_path 메서드 추가
  • crates/kebab-store-sqlite/src/documents.rs: 구현 (기존 get_document 의 SELECT + document_row_from_sql 재사용, WHERE 절만 변경 — drift 없음)
  • crates/kebab-app/src/lib.rs try_skip_unchanged: 새 lookup 사용, 명시적 parser_version 체크 추가
  • crates/kebab-app/tests/twin_files_idempotent.rs: 새 통합 테스트 (pkg_a/__init__.py + pkg_b/__init__.py 동일 content; 첫 ingest 2 New, 두번째 ingest 0/0/2 Unchanged)

검증

  • cargo test -p kebab-core --lib → 57/57
  • cargo test -p kebab-store-sqlite --lib → 20/20
  • cargo test -p kebab-app --test twin_files_idempotent → 1/1 new
  • cargo test -p kebab-app --test code_ingest_smoke → 6/6 (regression 없음)
  • cargo test -p kebab-app --lib → 51/51 (regression 없음)
  • cargo clippy -p kebab-core -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings clean

영향

  • wire schema 변경 ���음 (advertised idempotency 의 정상 동작 복원).
  • frozen design 변경 없음.
  • schema migration 불필요.
  • purge_orphan_at_workspace_path 와 path-move 시나리오 영향 없음 (put_asset 가 upstream 에서 처리).

남은 minor issue

  • Asset table 의 workspace_path 컬럼이 twin files 사이에서 flip-flop 함 — try_skip_unchanged 는 이제 무관하지만 asset.workspace_path 의 의미가 모호. 별도 의미적 cleanup 가치 있음 (필요시 후속 PR).

🤖 Generated with Claude Code

## 요약 Multi-root dogfooding (kebab-docs + httpx + zod + lodash) 의 3번째 발견 production bug fix. 1A-1 origin 의 잠재 데이터 일관성 이슈. ## 버그 **Twin files (동일 content / 다른 path)** 가 매 idempotent re-ingest 마다 `Updated` 로 분류됨. Dogfood: 726 docs 중 27 이 twin-file victim (예: 빈 `__init__.py` ×3, `AGENTS.md` ↔ `CLAUDE.md` 동일 content, 로고 PDF/JPG 중복). ## 진단 - `assets.asset_id` = blake3 content hash (PRIMARY KEY). - 동일 content 두 file → 같은 `asset_id` → 한 asset row 공유. - UPSERT `ON CONFLICT(asset_id) DO UPDATE SET workspace_path = excluded.workspace_path` → file2 ingest 시 asset row 의 `workspace_path` 가 file1 → file2 로 덮어씀. - Re-ingest 시 `try_skip_unchanged` 가 `get_asset_by_workspace_path(file1)` → `None` 반환 (asset row 의 path 는 file2) → unchanged 판정 실패 → re-process → `Updated`. ## Fix `try_skip_unchanged` 를 **document-centric lookup** 으로 전환: - `documents.workspace_path` 는 V001 부터 이미 `UNIQUE`. - `id_for_doc(path, asset_id, parser_version)` 이 path 포함 → twin files 각자 별도 doc row. - 새 `DocumentStore::get_document_by_workspace_path` trait method 추가 → asset-side flip-flop 무관. 비교 순서: 1. `get_document_by_workspace_path(path)` (없으면 None → re-process) 2. `doc.source_asset_id == asset.asset_id` (content 변경 시 None) 3. `doc.parser_version == current_parser_version` (이전 implicit 으로 id_for_doc 통해 확인, 이제 explicit) 4. chunker_version match (기존) 5. embedder version match (기존) **Schema migration 불필요** — `documents.workspace_path` UNIQUE 인덱스 이미 존재. ## 변경 - `crates/kebab-core/src/traits.rs`: `DocumentStore` trait 에 `get_document_by_workspace_path` 메서드 추가 - `crates/kebab-store-sqlite/src/documents.rs`: 구현 (기존 `get_document` 의 SELECT + `document_row_from_sql` 재사용, `WHERE` 절만 변경 — drift 없음) - `crates/kebab-app/src/lib.rs` `try_skip_unchanged`: 새 lookup 사용, 명시적 parser_version 체크 추가 - `crates/kebab-app/tests/twin_files_idempotent.rs`: 새 통합 테스트 (`pkg_a/__init__.py` + `pkg_b/__init__.py` 동일 content; 첫 ingest 2 New, 두번째 ingest 0/0/2 Unchanged) ## 검증 - `cargo test -p kebab-core --lib` → 57/57 - `cargo test -p kebab-store-sqlite --lib` → 20/20 - `cargo test -p kebab-app --test twin_files_idempotent` → 1/1 new - `cargo test -p kebab-app --test code_ingest_smoke` → 6/6 (regression 없음) - `cargo test -p kebab-app --lib` → 51/51 (regression 없음) - `cargo clippy -p kebab-core -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings` clean ## 영향 - wire schema 변경 ���음 (advertised idempotency 의 정상 동작 복원). - frozen design 변경 없음. - schema migration 불필요. - `purge_orphan_at_workspace_path` 와 path-move 시나리오 영향 없음 (`put_asset` 가 upstream 에서 처리). ## 남은 minor issue - Asset table 의 `workspace_path` 컬럼이 twin files 사이에서 flip-flop 함 — `try_skip_unchanged` 는 이제 무관하지만 asset.workspace_path 의 의미가 모호. 별도 의미적 cleanup 가치 있음 (필요시 후속 PR). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
altair823 added 1 commit 2026-05-20 05:28:22 +00:00
Identical-content files at different workspace paths share one assets row
(assets.asset_id = blake3 content hash, PRIMARY KEY). The UPSERT
`ON CONFLICT(asset_id) DO UPDATE SET workspace_path = excluded` made
twin files overwrite each other's workspace_path on every ingest, so
`get_asset_by_workspace_path(path1)` returned the OTHER twin's row (or
None) — break idempotent unchanged-detection for both files.

Fix: switch try_skip_unchanged to document-centric lookup. `documents.
workspace_path` is already UNIQUE (V001) and `id_for_doc(path, ...)`
includes path, so each twin has its own stable document row. Compare
`doc.source_asset_id` with the new asset's checksum instead of going
through the assets table.

Dogfood (multi-root: kebab-docs + httpx + zod + lodash) showed 27 of
726 docs marked Updated on every idempotent re-ingest — all 27 are
twin-file victims (empty `__init__.py` ×3, AGENTS.md ↔ CLAUDE.md
same content, duplicate logo PDFs/JPGs).

After: re-ingest reports 0 new / 0 updated / 726 unchanged.

No schema migration needed.

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

회차 1 — APPROVE (수정 권고 1건: 함수 doc-comment 업데이트 누락)

검증 결과

항목 결과
cargo test -p kebab-app --test twin_files_idempotent 1/1 PASS
cargo test -p kebab-app --test code_ingest_smoke 6/6 PASS (regression 없음)
cargo test -p kebab-app --lib 51/51 PASS
cargo test -p kebab-store-sqlite --lib 20/20 PASS
cargo test -p kebab-core --lib 57/57 PASS
cargo clippy -p kebab-core -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings clean
변경 파일 수 정확히 4개 (kebab-core traits, kebab-store-sqlite documents, kebab-app lib, test)
신규 의존성 없음

정확성 확인

Twin-file 시나리오: get_document_by_workspace_path(path1) → doc1 반환. doc1.source_asset_id == new_asset_id (두 twin이 같은 asset_id를 갖더라도 각자 stable doc row 보유). get_document_by_workspace_path(path2) → doc2 반환. 각각 Unchanged. 정확합니다.

Content 변경 시나리오: 동일 경로, 다른 content → asset.asset_id 변경 → existing_doc.source_asset_id != asset.asset_idOk(None) → re-process. 정확합니다.

Parser/chunker/embedder version bump 시나리오: 각각 explicit check (item 2/3/4) 로 커버됩니다. 이전 코드의 id_for_doc 기반 implicit 판정보다 더 self-documenting합니다.

신규 파일 (no doc row): get_document_by_workspace_pathOk(None) → re-process. 정확합니다.

force_reingest: fn 진입 직후 첫 번째 가드로 유지. 변경 없음.

get_document_by_workspace_path vs get_document drift 점검: SELECT 컬럼 목록, document_row_from_sql 호출, blocks query, serde 역직렬화, CanonicalDocument 재구성 — 모두 동일. WHERE 절만 doc_id = ?workspace_path = ?로 바뀌었고, row.doc_id.clone()이 blocks query용으로 추가된 것이 유일한 차이입니다. drift 없음.

중요 이슈 1건 (수정 권고)

try_skip_unchanged 함수 doc-comment가 구 알고리즘을 서술하고 있습니다.

crates/kebab-app/src/lib.rs 의 함수 바로 위 doc-comment (이 PR에서 직접 변경되지 않은 부분):

/// 2. The freshly-scanned asset's blake3 checksum equals what the
///    existing `assets` row stores at the same `workspace_path`.
/// 3. The doc keyed on `(workspace_path, asset_id, current_parser_version)`
///    exists. If the parser_version changed, `id_for_doc` produces a
///    different `doc_id` so the lookup misses → no skip → re-process.

이 구현은 이제 assets row를 조회하지 않고, id_for_doc도 사용하지 않습니다. doc-comment가 코드 동작과 다르면 다음 독자(또는 본인)가 기존 알���리즘 기준으로 디버깅하다 혼선을 겪을 수 있습니다.

권장 수정: item 2/3을 새 알고리즘(doc-centric lookup, source_asset_id 비교, explicit parser_version check)으로 업데이트.

총평

근본 원인 진단, fix 범위, 테스트 커버리지 모두 우수합니다. doc-comment 업데이트만 추가 커밋으로 반영해 주시면 머지 가능합니다.

회차 1 — APPROVE (수정 권고 1건: 함수 doc-comment 업데이트 누락) ## 검증 결과 | 항목 | 결과 | |------|------| | `cargo test -p kebab-app --test twin_files_idempotent` | ✅ 1/1 PASS | | `cargo test -p kebab-app --test code_ingest_smoke` | ✅ 6/6 PASS (regression 없음) | | `cargo test -p kebab-app --lib` | ✅ 51/51 PASS | | `cargo test -p kebab-store-sqlite --lib` | ✅ 20/20 PASS | | `cargo test -p kebab-core --lib` | ✅ 57/57 PASS | | `cargo clippy -p kebab-core -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings` | ✅ clean | | 변경 파일 수 | ✅ 정확히 4개 (kebab-core traits, kebab-store-sqlite documents, kebab-app lib, test) | | 신규 의존성 | ✅ 없음 | ## 정확성 확인 **Twin-file 시나리오**: `get_document_by_workspace_path(path1)` → doc1 반환. `doc1.source_asset_id == new_asset_id` ✅ (두 twin이 같은 asset_id를 갖더라도 각자 stable doc row 보유). `get_document_by_workspace_path(path2)` → doc2 반환. 각각 Unchanged. 정확합니다. **Content 변경 시나리오**: 동일 경로, 다른 content → `asset.asset_id` 변경 → `existing_doc.source_asset_id != asset.asset_id` → `Ok(None)` → re-process. 정확합니다. **Parser/chunker/embedder version bump 시나리오**: 각각 explicit check (item 2/3/4) 로 커버됩니다. 이전 코드의 `id_for_doc` 기반 implicit 판정보다 더 self-documenting합니다. **신규 파일 (no doc row)**: `get_document_by_workspace_path` → `Ok(None)` → re-process. 정확합니다. **`force_reingest`**: fn 진입 직후 첫 번째 가드로 유지. 변경 없음. ✅ **`get_document_by_workspace_path` vs `get_document` drift 점검**: SELECT 컬럼 목록, `document_row_from_sql` 호출, blocks query, serde 역직렬화, `CanonicalDocument` 재구성 — 모두 동일. `WHERE` 절만 `doc_id = ?` → `workspace_path = ?`로 바뀌었고, `row.doc_id.clone()`이 blocks query용으로 추가된 것이 유일한 차이입니다. drift 없음. ✅ ## 중요 이슈 1건 (수정 권고) **`try_skip_unchanged` 함수 doc-comment가 구 알고리즘을 서술하고 있습니다.** `crates/kebab-app/src/lib.rs` 의 함수 바로 위 doc-comment (이 PR에서 직접 변경되지 않은 부분): ``` /// 2. The freshly-scanned asset's blake3 checksum equals what the /// existing `assets` row stores at the same `workspace_path`. /// 3. The doc keyed on `(workspace_path, asset_id, current_parser_version)` /// exists. If the parser_version changed, `id_for_doc` produces a /// different `doc_id` so the lookup misses → no skip → re-process. ``` 이 구현은 이제 `assets` row를 조회하지 않고, `id_for_doc`도 사용하지 않습니다. doc-comment가 코드 동작과 다르면 다음 독자(또는 본인)가 기존 알���리즘 기준으로 디버깅하다 혼선을 겪을 수 있습니다. 권장 수정: item 2/3을 새 알고리즘(doc-centric lookup, `source_asset_id` 비교, explicit `parser_version` check)으로 업데이트. ## 총평 근본 원인 진단, fix 범위, 테스트 커버리지 모두 우수합니다. doc-comment 업데이트만 추가 커밋으로 반영해 주시면 머지 가능합니다.
@@ -289,0 +308,4 @@
.map_err(StoreError::from)?;
let Some(row) = row else { return Ok(None) };
let doc_id = kebab_core::DocumentId(row.doc_id.clone());

[Minor — 선호도] get_document(기존)은 블록 루프에서 외부 row를 shadow하는 반면 이 구현은 block_row로 명명해 shadow를 회피했습니다 — 더 명확합니다.

row.doc_id.clone()이 필요한 이유가 코드 맥락만으로 즉시 드러나지 않을 수 있습니다. 짧은 주석 한 줄(// clone: row.doc_id is moved into params![] below)을 추가하면 다음 독자가 borrow-move 이유를 즉시 이해할 수 있습니다. 필수 사항은 아닙니다.

**[Minor — 선호도]** `get_document`(기존)은 블록 루프에서 외부 `row`를 shadow하는 반면 이 구현은 `block_row`로 명명해 shadow를 회피했습니다 — 더 명확합니다. `row.doc_id.clone()`이 필요한 이유가 코드 맥락만으로 즉시 드러나지 않을 수 있습니다. 짧은 주석 한 줄(`// clone: row.doc_id is moved into params![] below`)을 추가하면 다음 독자가 borrow-move 이유를 즉시 이해할 수 있습니다. 필수 사항은 아닙니다.
altair823 added 1 commit 2026-05-20 05:35:22 +00:00
Round 1 review found the function-level doc-comment still described the
old asset-side algorithm (item 2 asset-row checksum, item 3 id_for_doc
miss). Updated to the document-centric flow.

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

회차 2 — doc-comment 수정 정확, 컴파일 클린, APPROVE.

360f825 단일 커밋: try_skip_unchanged 함수 수준 doc-comment를 round 1 지적에 따라 재작성.

수정 내용 검증

  • item 2 (get_document_by_workspace_path): "A document already exists at this workspace_path (get_document_by_workspace_path)" — 실제 코드 lib.rs:788-802의 호출과 정확히 일치. asset-side가 아닌 document-side lookup임과 twin-file flip-flop 원인(assets UPSERT 경쟁) 및 documents.workspace_path UNIQUE 보장까지 명확히 서술.
  • item 3 (source_asset_id content 비교): "existing doc's source_asset_id equals the freshly-scanned asset's blake3 checksum" — lib.rs:806 existing_doc.source_asset_id != asset.asset_id 와 정확히 대응. AssetId가 blake3 content hash임을 kebab-core/src/ids.rs line 69에서 재확인.
  • item 4 (parser_version + cascade): parser_version 명시적 check(lib.rs:814) + "Combined with chunker_version and last_embedding_version checks immediately below" — lib.rs:817-823의 chunker/embedder check로 full cascade 완성. design §9 cross-reference 유지.
  • twin-file rationale: tasks/HOTFIXES.md 교차 참조 + documents.workspace_path UNIQUE vs assets flip-flop 비대칭 구조 명확히 기술.

기타

cargo check -p kebab-app 클린 (24초, dev profile). 이 커밋은 doc-comment만 수정이며 round 1에서 이미 검증된 구현 코드는 무변경 — 추가 지적 없음.

회차 2 — doc-comment 수정 정확, 컴파일 클린, APPROVE. `360f825` 단일 커밋: `try_skip_unchanged` 함수 수준 doc-comment를 round 1 지적에 따라 재작성. **수정 내용 검증** - **item 2** (`get_document_by_workspace_path`): "A document already exists at this `workspace_path` (`get_document_by_workspace_path`)" — 실제 코드 lib.rs:788-802의 호출과 정확히 일치. asset-side가 아닌 document-side lookup임과 twin-file flip-flop 원인(assets UPSERT 경쟁) 및 `documents.workspace_path` UNIQUE 보장까지 명확히 서술. - **item 3** (`source_asset_id` content 비교): "existing doc's `source_asset_id` equals the freshly-scanned asset's blake3 checksum" — lib.rs:806 `existing_doc.source_asset_id != asset.asset_id` 와 정확히 대응. `AssetId`가 blake3 content hash임을 `kebab-core/src/ids.rs` line 69에서 재확인. - **item 4** (parser_version + cascade): parser_version 명시적 check(lib.rs:814) + "Combined with `chunker_version` and `last_embedding_version` checks immediately below" — lib.rs:817-823의 chunker/embedder check로 full cascade 완성. design §9 cross-reference 유지. - **twin-file rationale**: `tasks/HOTFIXES.md` 교차 참조 + `documents.workspace_path` UNIQUE vs `assets` flip-flop 비대칭 구조 명확히 기술. **기타** `cargo check -p kebab-app` 클린 (24초, dev profile). 이 커밋은 doc-comment만 수정이며 round 1에서 이미 검증된 구현 코드는 무변경 — 추가 지적 없음.
altair823 merged commit 4389b887f0 into main 2026-05-20 06:16:31 +00:00
altair823 deleted branch fix/dogfood-bug4-idempotent-twin-files 2026-05-20 06:16:32 +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#146