chore: PR #235 회차 2 리뷰 반영 — doctor 점검의 오탐·부작용 제거

2회차 리뷰가 1회차 지적 6건은 반영됐다고 확인했고, 대신 **1회차 대응으로
새로 넣은 `fts_shadow` 점검 자체에** HIGH 1건 + MEDIUM 2건을 찾았다.

1) 진단 명령이 없던 스토어를 만들었다 (HIGH)

   `SqliteStore::open` 은 마이그레이션은 안 돌리지만 `Connection::open` 이
   파일을 만든다. KB 없는 머신에서 `kebab doctor` 한 번이
   `<data_dir>/kebab.sqlite` 를 남겼고, 그러면 이후 `open_existing` 이
   성공해 버려 `not_indexed` 로 갈렸어야 할 경로가 일반 오류로 바뀐다.
   `--readonly` 규약도 진단 명령이 깬다.

   `SQLITE_FILE` 을 공개하고 파일 존재를 먼저 확인한 뒤에만 연다.
   `doctor_does_not_create_a_store_where_none_exists` 로 고정.

2) V016 미적용 스토어를 고장 났다고 하고, 파괴적 조치를 권했다 (MEDIUM)

   doctor 는 마이그레이션을 돌리지 않으므로 V015 스토어를 그대로 읽는다.
   그런데 V009 백필이 rowid 를 명시하지 않아서, 그 시점 `chunks.rowid` 에
   구멍이 있던 스토어는 V015 에서 **이미 어긋나 있다**. 거기서는 삭제가
   `chunk_id` 기준이라 무해한데, 새 점검이 `ok: false` → exit 3 + "`kebab
   reset` 후 재색인" 을 냈다. 바이너리를 올리고 doctor 부터 돌리는 자연스러운
   순서에서 멀쩡한 KB 를 날리라고 안내한 셈이다.

   `migration_version()` 을 보고 V016 미만이면 `ok: true` + "마이그레이션
   후 점검된다"로 간다. 실제 해법이 그것이다 — V016 의 명시 rowid
   repopulate 가 이 어긋남을 고쳐 준다.
   `doctor_does_not_call_a_pre_v016_store_broken` 으로 고정.

3) env override 를 무시해 엉뚱한 DB 를 검사했다 (MEDIUM)

   바로 위 `data_dir_writable` 은 "Config::load 와 같은 precedence 유지"를
   이유로 env 를 다시 얹는데 이 블록은 안 했다. `KEBAB_STORAGE_DATA_DIR` 을
   쓰면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 같은 방식으로
   맞췄다.

4) 잔가지 (LOW)

   - `None` 분기가 "스토어 없음 / 열기 실패 / 읽기 실패"를 전부 "KB 없음"
     으로 뭉갰다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안
     되므로 "점검하지 못했다" 를 따로 뒀다 (hint 가 있으므로 CLI 가 `!`
     로 찍는다).
   - "표본 400행" 이 chunk 400개 미만인 스토어에서 거짓이었다. ASC/DESC
     두 창이 겹치면 분자와 분모를 둘 다 두 번 셌다. UNION 으로 바꾸고
     실제 표본 수를 함께 돌려준다 — 반환형이 `(checked, misaligned)` 다.
   - `pub const SQLITE_FILE` 위에 "Kept private" 이라는 옛 독 주석이
     남아 있었다.
   - README 의 doctor 행에 `fts_shadow` 가 exit 3 을 낼 수 있다고 적었다.
     새 플래그도 config 키도 없지만 **doctor 가 실패하는 새 사유**는
     스크립트와 에이전트가 분기하는 사용자 표면이다.
   - `tasks/phase-2-lexical-search.md` 가 `kebab index --rebuild-fts` 를
     산출물로 나열하고 있었다. 배선된 적 없는 명령이고 이 PR 이 "CLI
     경로 없음"이라고 못박은 것과 어긋나서 정정했다.

실측 확인: env override 를 준 doctor 가 지정한 KB 를 검사하고("양끝 400행
표본"), V015 실제 KB(28,427 문서)는 "V016 적용 전 (현재 V015)" 로 나오며,
KB 없는 경로에서는 파일을 남기지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
2026-08-16 20:00:22 +09:00
parent 17167e6943
commit 8dbfa89b57
8 changed files with 222 additions and 52 deletions

View File

@@ -91,7 +91,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단. `compare --max-drop <낙폭>` 은 어떤 지표든 그 이상 **떨어지면** exit 1 — 절대 하한이 아니라 델타 예산이다 (없으면 delta 만 출력하고 항상 exit 0) |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음) |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음). `fts_shadow` 체크는 어휘 인덱스가 원본과 어긋났는지 보며, **어긋나면 exit 3** — 이 상태에서는 문서 삭제가 엉뚱한 인덱스 행을 지운다 |
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |

View File

@@ -460,35 +460,75 @@ pub fn doctor_with_config_path(
//
// Sampled, not exhaustive: the full anti-join is a 33s scan at 600k
// chunks, which is too slow to put in front of every `kebab doctor`.
// The head and tail of the rowid range cost 10ms and catch wholesale
// Both ends of the rowid range cost 10ms and catch wholesale
// renumbering, which is the shape any realistic drift takes. The
// detail says "표본" so this is not read as a proof of alignment.
{
let cfg = loaded_cfg
.clone()
.unwrap_or_else(kebab_config::Config::defaults);
let probe = kebab_store_sqlite::SqliteStore::open(&cfg.storage)
.ok()
.and_then(|s| s.fts_shadow_misaligned_sample(200).ok());
// Same precedence as `Config::load` — `data_dir_writable` above
// re-applies env for the same reason. Without this, doctor would
// report one data_dir and probe a different one whenever
// KEBAB_STORAGE_DATA_DIR is set.
let cfg = match loaded_cfg.as_ref() {
Some(c) => {
let env: std::collections::HashMap<String, String> = std::env::vars().collect();
c.clone().apply_env(&env)
}
None => kebab_config::Config::defaults(),
};
// `SqliteStore::open` creates the file, and doctor must not
// leave a store behind on a machine that has none — check for it
// first and report "no KB" rather than manufacturing one.
let db = kebab_config::expand_path(&cfg.storage.data_dir, "")
.join(kebab_store_sqlite::SQLITE_FILE);
let probe = db.exists().then(|| {
kebab_store_sqlite::SqliteStore::open(&cfg.storage)
.ok()
.map(|s| (s.migration_version(), s.fts_shadow_misaligned_sample(200)))
});
const V016: u32 = 16;
let (fok, detail, hint) = match probe {
Some(0) => (
None => (
true,
"chunks_fts rowid 정렬 정상 (앞뒤 200행 표본)".to_string(),
"KB 없음 — 점검할 인덱스가 아직 없다".to_string(),
None,
),
Some(n) => (
// Opened, but the probe failed. Do not report this as
// health: a check whose whole point is to surface a silent
// failure must not swallow its own.
Some(None) | Some(Some((_, Err(_)))) => (
true,
"점검하지 못했다".to_string(),
Some(
"SQLite 를 열거나 읽지 못했다 — 다른 kebab 프로세스가 쓰는 중이거나 \
DB 가 손상됐을 수 있다"
.to_string(),
),
),
// Pre-V016 stores can be misaligned already: V009's backfill
// inserted without an explicit rowid, so a store with gaps in
// `chunks.rowid` at that moment got a densely-numbered shadow.
// That is harmless there — deletes still address rows by
// chunk_id. Applying V016 repopulates with explicit rowids and
// fixes it, so the advice is "migrate", never "wipe".
Some(Some((Some(v), _))) if v < V016 => (
true,
format!("V016 적용 전 (현재 V{v:03}) — 마이그레이션 후 점검된다"),
None,
),
Some(Some((_, Ok((checked, 0))))) => (
true,
format!("chunks_fts rowid 정렬 정상 (양끝 {checked}행 표본)"),
None,
),
Some(Some((_, Ok((checked, bad))))) => (
false,
format!("chunks_fts rowid 정렬 어긋남 — 표본 400행 중 {n}행 불일치"),
format!("chunks_fts rowid 정렬 어긋남 — 표본 {checked}행 중 {bad}행 불일치"),
Some(
"삭제가 엉뚱한 FTS 행을 지우고 있다. `kebab reset` 후 재색인으로 \
인덱스를 다시 만들어라"
.to_string(),
),
),
// No store yet, or the probe itself failed — either way this
// is not evidence of drift, and claiming health would be
// worse than saying we could not look.
None => (true, "확인 불가 (KB 없음)".to_string(), None),
};
checks.push(DoctorCheck {
name: "fts_shadow".to_string(),

View File

@@ -83,3 +83,96 @@ fn doctor_flags_outdated_config() {
.unwrap();
assert!(check.ok, "after migrate should pass");
}
/// `doctor` is a diagnostic and must not manufacture the store it is
/// asked about. The `fts_shadow` check (issue #229 / V016) reads SQLite,
/// and `SqliteStore::open` creates the file — so on a machine with no KB
/// yet, running doctor once used to leave an empty `kebab.sqlite` behind.
#[test]
fn doctor_does_not_create_a_store_where_none_exists() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
format!(
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
[storage]\ndata_dir = \"{}\"\n",
data.display()
),
)
.unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "fts_shadow")
.expect("doctor must report fts_shadow even with no store");
assert!(check.ok, "a missing store is not a drifted one");
assert!(
!data.join(kebab_store_sqlite::SQLITE_FILE).exists(),
"doctor must not create {} — it only reports",
kebab_store_sqlite::SQLITE_FILE
);
}
/// A store that predates V016 can be misaligned already — V009's
/// backfill inserted without an explicit rowid — but there the delete
/// trigger still addresses rows by chunk_id, so the drift is harmless.
/// Telling that user to `kebab reset` would destroy a healthy KB over a
/// condition that applying the migration fixes by itself.
#[test]
fn doctor_does_not_call_a_pre_v016_store_broken() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
std::fs::create_dir_all(&data).unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
format!(
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
[storage]\ndata_dir = \"{}\"\n",
data.display()
),
)
.unwrap();
// A store stamped at V015 with a shadow deliberately misaligned:
// exactly what a pre-V016 upgrade can look like.
let db = data.join(kebab_store_sqlite::SQLITE_FILE);
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute_batch(
"CREATE TABLE refinery_schema_history (version INTEGER);
INSERT INTO refinery_schema_history VALUES (15);
CREATE TABLE chunks (chunk_id TEXT PRIMARY KEY, text TEXT);
CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, text);
INSERT INTO chunks VALUES ('a', 'x'), ('b', 'y');
INSERT INTO chunks_fts(rowid, chunk_id, text) VALUES (77, 'a', 'x');",
)
.unwrap();
drop(conn);
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "fts_shadow")
.unwrap();
assert!(
check.ok,
"a pre-V016 store must not fail the check: {}",
check.detail
);
assert!(
check.detail.contains("V016"),
"the detail should say the migration has not been applied, got {:?}",
check.detail
);
assert!(
check.hint.as_deref().unwrap_or_default().is_empty(),
"and must not tell the user to reset: {:?}",
check.hint
);
}

View File

@@ -35,4 +35,4 @@ pub use error::StoreError;
pub use eval::{EvalQueryResultRecord, EvalRunRecord, EvalRunRow};
pub use fts::rebuild_chunks_fts;
pub use jobs::IngestRunRow;
pub use store::{CountSummary, NotIndexed, SqliteStore, purge_deleted_workspace_path};
pub use store::{CountSummary, NotIndexed, SQLITE_FILE, SqliteStore, purge_deleted_workspace_path};

View File

@@ -41,10 +41,10 @@ static TEMP_SUFFIX_COUNTER: AtomicU64 = AtomicU64::new(0);
/// truncated, mirrored from `kb-core`'s newtype invariant.
const ASSET_ID_HEX_LEN: usize = 32;
/// Default file name under `config.storage.data_dir`. Kept private — the
/// path layout is a §6.3 design decision, not part of the store's public
/// surface.
const SQLITE_FILE: &str = "kebab.sqlite";
/// Filename of the main SQLite database under `storage.data_dir`.
/// Public so read-only probes (e.g. `kebab doctor`) can test for the
/// file before calling [`SqliteStore::open`], which creates it.
pub const SQLITE_FILE: &str = "kebab.sqlite";
/// Subdirectory under `data_dir` holding shard-prefixed asset bytes
/// (`<aa>/<asset_id>`). Mirrors design §6.3.
@@ -351,9 +351,10 @@ fn temp_path_for(dest: &Path) -> PathBuf {
}
impl SqliteStore {
/// Count rows in a bounded sample where `chunks_fts` and `chunks`
/// disagree about which chunk lives at a given rowid (issue #229 /
/// V016). `0` means the sample is aligned.
/// Sample rows from both ends of the rowid range and report
/// `(checked, misaligned)` — how many were compared and how many
/// disagree with `chunks` about which chunk lives at that rowid
/// (issue #229 / V016). A `misaligned` of 0 means the sample agrees.
///
/// V016 pointed the FTS delete trigger at `rowid`, so this alignment
/// is what keeps a delete from removing some other document's shadow
@@ -362,27 +363,51 @@ impl SqliteStore {
/// 10ms, and enough to catch the wholesale renumbering that any
/// realistic drift would produce. It is a health probe, not a proof:
/// a drift confined to the middle of the range passes.
pub fn fts_shadow_misaligned_sample(&self, sample: usize) -> Result<usize> {
pub fn fts_shadow_misaligned_sample(&self, sample: usize) -> Result<(usize, usize)> {
let conn = self.read_conn();
let mut total = 0usize;
for order in ["ASC", "DESC"] {
let n: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM (
SELECT rowid AS r, chunk_id AS cid FROM chunks
ORDER BY rowid {order} LIMIT ?1
) c
LEFT JOIN chunks_fts f ON f.rowid = c.r
WHERE f.rowid IS NULL OR f.chunk_id != c.cid"
),
params![sample as i64],
|r| r.get(0),
)
.map_err(StoreError::from)?;
total += usize::try_from(n).unwrap_or(0);
}
Ok(total)
// UNION of the two ends rather than two separate counts: on a
// store with fewer rows than 2×sample the windows overlap, and
// counting them separately would double both the numerator and
// the denominator the caller reports.
let (checked, bad): (i64, i64) = conn
.query_row(
"WITH ends AS (
SELECT rowid AS r, chunk_id AS cid FROM
(SELECT rowid, chunk_id FROM chunks ORDER BY rowid ASC LIMIT ?1)
UNION
SELECT rowid AS r, chunk_id AS cid FROM
(SELECT rowid, chunk_id FROM chunks ORDER BY rowid DESC LIMIT ?1)
)
SELECT COUNT(*),
COALESCE(SUM(f.rowid IS NULL OR f.chunk_id != c.cid), 0)
FROM ends c LEFT JOIN chunks_fts f ON f.rowid = c.r",
params![sample as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(StoreError::from)?;
Ok((
usize::try_from(checked).unwrap_or(0),
usize::try_from(bad).unwrap_or(0),
))
}
/// Highest applied refinery migration version, or `None` if the
/// history table is missing (a file that is not a kebab store, or
/// one opened before `run_migrations`).
///
/// Read-only callers use this to tell "this store predates the
/// migration I care about" from "this store is broken" — the two
/// need opposite advice.
pub fn migration_version(&self) -> Option<u32> {
self.read_conn()
.query_row(
"SELECT MAX(version) FROM refinery_schema_history",
[],
|r| r.get::<_, Option<i64>>(0),
)
.ok()
.flatten()
.and_then(|v| u32::try_from(v).ok())
}
/// p9-fb-19: read the persisted `corpus_revision` from the `kv`

View File

@@ -852,8 +852,9 @@ fn fts_v016_shadow_probe_detects_forced_drift() {
}
assert_eq!(
store.fts_shadow_misaligned_sample(200).unwrap(),
0,
"a freshly written shadow must be aligned"
(3, 0),
"a freshly written shadow must be aligned, and the two rowid \
windows must not double-count a store smaller than the sample"
);
// Shift one shadow row off its source. `chunks_fts` rows are not
@@ -868,8 +869,15 @@ fn fts_v016_shadow_probe_detects_forced_drift() {
)
.expect("reinsert at a drifted rowid");
assert!(
store.fts_shadow_misaligned_sample(200).unwrap() > 0,
"the probe must report the drifted row"
assert_eq!(
store.fts_shadow_misaligned_sample(200).unwrap(),
(3, 1),
"the probe must report the one drifted row out of three checked"
);
assert_eq!(
store.migration_version(),
Some(16),
"a migrated store must report V016 so doctor can tell it apart \
from a pre-V016 one, where the same drift is harmless"
);
}

View File

@@ -59,7 +59,11 @@ git history.
남는 실제 경로는 **앞으로 `chunks` 를 테이블 재작성 방식(새 테이블 → 복사 → DROP → RENAME)으로 바꾸는 마이그레이션**이다. 그러면 rowid 가 조용히 다시 매겨진다. V016 주석에 "그런 마이그레이션은 repopulate 를 같이 돌려야 한다"는 울타리를 박아 뒀다. 지금까지의 `chunks` 변경은 전부 in-place 다 (V009 `ADD COLUMN`, V013 `DROP COLUMN`).
알려진 유발 경로가 없더라도, 정확성을 떠받치게 된 불변식이 눈에 안 보이는 상태로 남는 게 문제다. 그래서 `kebab doctor``fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk 에서 33초라 doctor 앞에 둘 수 없어서 rowid 범위의 앞뒤 200행씩만 본다 — 10 ms 이고, 현실적인 드리프트가 취하는 형태(전면 재번호)는 잡는다. 표본이라는 사실을 detail 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다.
알려진 유발 경로가 없더라도, 정확성을 떠받치게 된 불변식이 눈에 안 보이는 상태로 남는 게 문제다. 그래서 `kebab doctor``fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk 에서 33초라 doctor 앞에 둘 수 없어서 rowid 범위의 양끝 200행씩만 본다 — 10 ms 이고, 현실적인 드리프트가 취하는 형태(전면 재번호)는 잡는다. 표본이라는 사실을 detail 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다. **어긋나면 exit 3** 이므로 README 의 doctor 행에도 적었다.
이 점검은 세 가지를 구분한다. V016 미적용 스토어는 위에 적은 V009 백필 사정 때문에 이미 어긋나 있을 수 있는데 거기서는 무해하므로 `ok: true` 로 두고 "마이그레이션 후 점검된다"고만 말한다 — `kebab reset` 을 권했다가는 멀쩡한 KB 를 날리게 된다. SQLite 를 열거나 읽지 못한 경우도 정상으로 보고하지 않고 "점검하지 못했다"로 따로 말한다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안 된다. 나머지 경우에만 실제 정렬을 판정한다.
doctor 는 `data_dir_writable` 과 같은 precedence 로 env 를 다시 얹는다(`KEBAB_STORAGE_DATA_DIR`). 안 그러면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 그리고 `SqliteStore::open` 이 파일을 만들기 때문에, 파일 존재를 먼저 확인한 뒤에만 연다 — 진단 명령이 없던 스토어를 만들어 놓고 가면 안 된다.
참고로 V016 이전 스토어라고 해서 정렬이 어긋나 있는 건 아니다. 트리거가 매 연산을 그대로 미러링하므로 FTS5 가 알아서 매기는 번호도 `chunks` 와 나란히 간다. 다만 **V009 의 백필**은 rowid 를 명시하지 않고 `SELECT … FROM chunks` 로 채웠으므로, 그 시점에 `chunks` 의 rowid 에 구멍이 있었다면 shadow 는 1..N 으로 촘촘히 매겨져 어긋난다. V016 의 명시 rowid repopulate 가 그런 스토어를 바로잡는다. 이 머신의 실제 KB(V015, 문서 28,427건)는 V009 이후 새로 색인한 것이라 점검이 정상으로 나온다.

View File

@@ -74,7 +74,7 @@ pub struct SearchHit {
## 인덱스 라이프사이클
- ingest 시 trigger 로 자동 동기화.
- `kebab index --rebuild-fts` command 로 FTS table 재구축 (chunker version bump 후 사용).
- FTS table 재구축 경로 (chunker version bump 후 사용). ※ 2026-08-16 현재 `kebab index --rebuild-fts` 는 배선되지 않았다 — 재구축은 `kebab_store_sqlite::rebuild_chunks_fts` 라이브러리 API 뿐이고, CLI 경로는 `kebab reset` 후 재색인이다.
- `index_version``(schema_version, fts_config_hash)` 조합.
## kebab-app facade 확장
@@ -87,7 +87,7 @@ pub fn search(query: SearchQuery) -> anyhow::Result<Vec<SearchHit>>;
```text
kebab search "Rust workspace 설계" [--k 10] [--tag rust] [--mode lexical]
kebab index --rebuild-fts
# (미배선 — 위 주석 참조)
```
출력 예: