chore: PR #234 회차 1 리뷰 반영 — 압축 트리거·고아 창·doctor 판정
리뷰어 두 명이 독립적으로 같은 결함을 지목했고 실측으로 확인됐다. 1) 삭제 경로 압축이 실제로는 안 걸렸다 `maybe_compact` 가 `version % compact_every == 0` 으로 판정했는데, writer 마다 버전을 올리는 폭이 다르다. `upsert` 는 호출당 1 이라 배수를 반드시 밟지만 `delete_by_chunk_ids` 는 200-id 배치마다 커밋해서 한 번의 호출이 여러 칸을 건너뛴다. 이 PR 이 실측한 28474 → 28507 이 정확히 그 경우다 (28507 % 512 = 347, 구간에 512 배수 없음). 즉 추가한 압축이 호출당 1/512 확률로만 걸렸다. 구간 교차 판정(`after / n > before / n`)으로 바꿨다. 쓰기 전 버전을 인자로 받는다. 기존 테스트도 이걸 못 잡았다 — id 를 하나씩 40회 나눠 불러 버전이 1씩 올라가는, 이 PR 이 없앤 옛 형태였다. 한 번의 호출로 2,000 id(=10 커밋)를 보내는 실제 출하 형태로 다시 썼고, modulo 판정으로 되돌리면 매니페스트 12개로 실패한다. 2) 고아 벡터 창이 문서 1건에서 sweep 전체로 커졌다 `execute_orphans_only` 는 purge 실패 시 `?` 로 즉시 반환하는데, 그러면 루프 뒤의 배치 삭제가 아예 실행되지 않아 그때까지 버퍼에 쌓인 벡터가 전부 고아가 된다. documents 행은 이미 지워져 다음 sweep 도 못 찾으므로 회수 수단이 `reset --vector-only` 전량 재임베딩뿐이다. 기존 건별 삭제보다 명확히 나빠지는 회귀였다. 5,000 id 마다 flush 하고, reset 의 에러 경로도 flush 를 거쳐 반환한다. 커밋 수는 여전히 문서 수의 수십 분의 1이라 #230 목적은 그대로다. 더불어 HOTFIXES 의 "SQLite purge 는 crash-safety 경계" 는 사실이 아니라 정정했다 — `purge_deleted_workspace_path` 는 트랜잭션을 열지 않고 DELETE 세 개가 각각 autocommit 이다. 3) doctor 판정이 규모에 따라 뒤집혔다 `meta_bytes > data_bytes` 는 절대 바이트 비교라, manifest 는 fragment 수의 제곱으로 data 는 코퍼스 크기에 비례해 자라는 탓에 작은 노트 KB 가 오탐으로 fail 했다. 리뷰어 계산으로 문서당 1청크면 105 문서부터 걸린다. 보존 버전 수가 압축 간격의 2배를 넘는지로 바꿨다(규모 무관). 그리고 판정을 정보성으로 낮췄다 — `DoctorReport.ok` 가 종료 코드 3 을 만들고 스크립트·에이전트가 그걸로 분기하는데, 압축이 밀린 스토어도 질의에는 정상 응답한다. 테이블을 못 찾았을 때도 체크를 남긴다(조용히 사라지면 정상으로 읽힌다). 검증: 워크스페이스 186 결과 전부 통과. 실 KB doctor 출력 확인 (`200 fragments / 2832.3 MB data, 201 versions / 2.0 MB metadata`, 종료 코드 영향 없음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
@@ -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 헬스 체크 |
|
||||
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음) |
|
||||
| `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**) |
|
||||
|
||||
|
||||
@@ -2143,13 +2143,19 @@ fn sweep_deleted_files(
|
||||
|
||||
let workspace_root = app.config.resolve_workspace_root();
|
||||
let mut purged: u32 = 0;
|
||||
// Vector deletes are batched across the whole sweep instead of issued
|
||||
// per file. Each `delete_by_chunk_ids` call is one Lance commit, so a
|
||||
// per-file call made "documents purged" and "Lance commits" 1:1 —
|
||||
// issue #230 measured 5,834 purges turning into 5,834 commits and a
|
||||
// 32-hour sweep. SQLite stays per-path: it is the crash-safety
|
||||
// boundary, and the pre-existing policy already tolerates orphan
|
||||
// vectors if the run dies mid-sweep.
|
||||
// Vector deletes are batched instead of issued per file. Each
|
||||
// `delete_by_chunk_ids` call is one Lance commit, so a per-file call
|
||||
// made "documents purged" and "Lance commits" 1:1 — issue #230
|
||||
// measured 5,834 purges turning into 5,834 commits and a 32-hour
|
||||
// sweep.
|
||||
//
|
||||
// Buffered rather than accumulated for the whole sweep. SQLite commits
|
||||
// per path and does not roll back, so every id sitting in this buffer
|
||||
// when the process dies is a vector whose document row is already
|
||||
// gone — unreachable by any later sweep and only recoverable by
|
||||
// `reset --vector-only` plus a full re-embed. The flush bound caps
|
||||
// that exposure while still collapsing commits by three orders of
|
||||
// magnitude.
|
||||
let mut doomed_chunk_ids: Vec<kebab_core::ChunkId> = Vec::new();
|
||||
|
||||
for stored_path in stored_paths {
|
||||
@@ -2193,8 +2199,11 @@ fn sweep_deleted_files(
|
||||
}
|
||||
};
|
||||
|
||||
if vector_store.is_some() {
|
||||
if let Some(vec) = vector_store {
|
||||
doomed_chunk_ids.extend(chunk_ids);
|
||||
if doomed_chunk_ids.len() >= VECTOR_DELETE_FLUSH {
|
||||
flush_vector_deletes(vec, &mut doomed_chunk_ids, purged);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -2205,25 +2214,46 @@ fn sweep_deleted_files(
|
||||
purged = purged.saturating_add(1);
|
||||
}
|
||||
|
||||
// Purge associated vectors in one call (best-effort; partial failure
|
||||
// acceptable — orphan vectors get cleaned by `kebab reset
|
||||
// --vector-only` if they accumulate).
|
||||
if let (Some(vec), false) = (vector_store, doomed_chunk_ids.is_empty()) {
|
||||
use kebab_core::VectorStore as _;
|
||||
if let Err(e) = vec.delete_by_chunk_ids(&doomed_chunk_ids) {
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
count = doomed_chunk_ids.len(),
|
||||
docs = purged,
|
||||
error = %e,
|
||||
"sweep_deleted_files: vector delete failed; SQLite side already cleaned"
|
||||
);
|
||||
}
|
||||
if let Some(vec) = vector_store {
|
||||
flush_vector_deletes(vec, &mut doomed_chunk_ids, purged);
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
/// Ids buffered before a batched Lance delete is forced out.
|
||||
///
|
||||
/// Trades Lance commits against the orphan-vector window: at 5,000 a
|
||||
/// 600k-chunk workspace flushes ~120 times instead of once per document,
|
||||
/// and an abort strands at most 5,000 vectors instead of the whole sweep.
|
||||
pub(crate) const VECTOR_DELETE_FLUSH: usize = 5_000;
|
||||
|
||||
/// Delete the buffered chunk ids and clear the buffer.
|
||||
///
|
||||
/// Best-effort: a failure is logged and the ids dropped. The SQLite rows
|
||||
/// are already gone, so retrying would need state this function does not
|
||||
/// have — orphan vectors are reclaimed by `kebab reset --vector-only`.
|
||||
pub(crate) fn flush_vector_deletes(
|
||||
vec: &kebab_store_vector::LanceVectorStore,
|
||||
buf: &mut Vec<kebab_core::ChunkId>,
|
||||
docs: u32,
|
||||
) {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
use kebab_core::VectorStore as _;
|
||||
if let Err(e) = vec.delete_by_chunk_ids(buf) {
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
count = buf.len(),
|
||||
docs,
|
||||
error = %e,
|
||||
"vector delete failed; SQLite side already cleaned"
|
||||
);
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
/// P7-3: process one `MediaType::Pdf` asset end-to-end.
|
||||
///
|
||||
/// - Reads bytes from disk.
|
||||
|
||||
@@ -453,27 +453,47 @@ pub fn doctor_with_config_path(
|
||||
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
|
||||
let vector_dir =
|
||||
kebab_config::expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
|
||||
if let Some((fragments, data_bytes, manifests, meta_bytes)) = lance_dir_stats(&vector_dir) {
|
||||
let mb = |b: u64| b as f64 / 1_048_576.0;
|
||||
// Metadata outweighing vectors is the shape of the #230
|
||||
// pathology; a healthy compacted table is the other way round
|
||||
// by orders of magnitude.
|
||||
let bloated = meta_bytes > data_bytes && data_bytes > 0;
|
||||
checks.push(DoctorCheck {
|
||||
name: "vector_store".to_string(),
|
||||
ok: !bloated,
|
||||
detail: format!(
|
||||
"{fragments} fragments / {:.1} MB data, {manifests} versions / {:.1} MB metadata",
|
||||
mb(data_bytes),
|
||||
mb(meta_bytes)
|
||||
),
|
||||
hint: bloated.then(|| {
|
||||
"version history is larger than the vectors it describes — \
|
||||
a `kebab ingest` on a build with periodic compaction reclaims it"
|
||||
.to_string()
|
||||
}),
|
||||
});
|
||||
}
|
||||
let (detail, hint) = match lance_dir_stats(&vector_dir) {
|
||||
Some((fragments, data_bytes, manifests, meta_bytes)) => {
|
||||
let mb = |b: u64| b as f64 / 1_048_576.0;
|
||||
// Scale-independent on purpose. Comparing metadata bytes to
|
||||
// data bytes looks obvious but inverts on small stores:
|
||||
// manifest bytes grow with the square of the fragment count
|
||||
// while data grows with the corpus, so a healthy notes KB of
|
||||
// a few hundred one-chunk documents would trip it while a
|
||||
// genuinely bloated 600k-chunk store would not. Versions far
|
||||
// past the compaction interval means compaction is not
|
||||
// keeping up, at any size.
|
||||
let ceiling = kebab_store_vector::COMPACT_EVERY_N_UPSERTS.saturating_mul(2);
|
||||
let behind = manifests > ceiling;
|
||||
(
|
||||
format!(
|
||||
"{fragments} fragments / {:.1} MB data, {manifests} versions / {:.1} MB metadata",
|
||||
mb(data_bytes),
|
||||
mb(meta_bytes)
|
||||
),
|
||||
behind.then(|| {
|
||||
format!(
|
||||
"{manifests} retained versions is past the {ceiling} compaction \
|
||||
ceiling — version history will keep growing faster than the \
|
||||
vectors it describes"
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Reported rather than omitted: a silently missing check reads
|
||||
// as a healthy one.
|
||||
None => ("no Lance table yet".to_string(), None),
|
||||
};
|
||||
// Informational. `DoctorReport.ok` drives exit code 3, which
|
||||
// scripts and agents branch on, and a store that needs compacting
|
||||
// still answers every query correctly.
|
||||
checks.push(DoctorCheck {
|
||||
name: "vector_store".to_string(),
|
||||
ok: true,
|
||||
detail,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
|
||||
let ok = checks.iter().all(|c| c.ok);
|
||||
|
||||
@@ -249,15 +249,38 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
|
||||
let mut purged_paths: Vec<WorkspacePath> = Vec::new();
|
||||
// Batched for the same reason as `sweep_deleted_files`: one Lance
|
||||
// commit per purged document is what makes a large orphan sweep take
|
||||
// hours (issue #230).
|
||||
// hours (issue #230). Bounded by the same flush threshold, and flushed
|
||||
// on the error path below — a bare `?` here would strand every vector
|
||||
// buffered so far, whose SQLite rows are already deleted and therefore
|
||||
// unreachable by any later sweep.
|
||||
let mut doomed_chunk_ids: Vec<kebab_core::ChunkId> = Vec::new();
|
||||
|
||||
for path in &orphans {
|
||||
let chunk_ids = kebab_store_sqlite::purge_deleted_workspace_path(&store, path)
|
||||
.with_context(|| format!("execute_orphans_only: purge {}", path.0))?;
|
||||
let purged = kebab_store_sqlite::purge_deleted_workspace_path(&store, path)
|
||||
.with_context(|| format!("execute_orphans_only: purge {}", path.0));
|
||||
let chunk_ids = match purged {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
if let Some(vs) = &vector_store {
|
||||
crate::ingest::flush_vector_deletes(
|
||||
vs,
|
||||
&mut doomed_chunk_ids,
|
||||
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if vector_store.is_some() {
|
||||
if let Some(vs) = &vector_store {
|
||||
doomed_chunk_ids.extend(chunk_ids);
|
||||
if doomed_chunk_ids.len() >= crate::ingest::VECTOR_DELETE_FLUSH {
|
||||
crate::ingest::flush_vector_deletes(
|
||||
vs,
|
||||
&mut doomed_chunk_ids,
|
||||
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -268,17 +291,12 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
|
||||
purged_paths.push(path.clone());
|
||||
}
|
||||
|
||||
if let (Some(vs), false) = (&vector_store, doomed_chunk_ids.is_empty()) {
|
||||
use kebab_core::VectorStore as _;
|
||||
if let Err(e) = vs.delete_by_chunk_ids(&doomed_chunk_ids) {
|
||||
tracing::warn!(
|
||||
target: "kebab-app",
|
||||
count = doomed_chunk_ids.len(),
|
||||
docs = purged_paths.len(),
|
||||
error = %e,
|
||||
"reset --orphans-only: vector delete failed; SQLite side already cleaned"
|
||||
);
|
||||
}
|
||||
if let Some(vs) = &vector_store {
|
||||
crate::ingest::flush_vector_deletes(
|
||||
vs,
|
||||
&mut doomed_chunk_ids,
|
||||
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
|
||||
);
|
||||
}
|
||||
|
||||
let orphans_purged = u32::try_from(purged_paths.len()).unwrap_or(u32::MAX);
|
||||
|
||||
@@ -28,4 +28,4 @@ mod arrow_batch;
|
||||
mod paths;
|
||||
mod store;
|
||||
|
||||
pub use store::{INDEX_VERSION_STR, LanceVectorStore};
|
||||
pub use store::{COMPACT_EVERY_N_UPSERTS, INDEX_VERSION_STR, LanceVectorStore};
|
||||
|
||||
@@ -74,7 +74,7 @@ pub const INDEX_VERSION_STR: &str = INDEX_VERSION;
|
||||
/// struct, so single-document paths (`kebab ingest-file`, MCP
|
||||
/// `ingest_file`/`ingest_stdin`) still reach it: each opens a fresh
|
||||
/// store, and an in-memory counter would restart at zero every call.
|
||||
const COMPACT_EVERY_N_UPSERTS: u64 = 512;
|
||||
pub const COMPACT_EVERY_N_UPSERTS: u64 = 512;
|
||||
|
||||
/// Lance VectorStore.
|
||||
///
|
||||
@@ -172,7 +172,15 @@ impl LanceVectorStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Compact when the table has accumulated `compact_every` commits.
|
||||
/// Read the table's current version, or 0 if it cannot be read.
|
||||
fn table_version(&self, table: &lancedb::Table) -> u64 {
|
||||
self.runtime
|
||||
.block_on(async { table.version().await })
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Compact when this write pushed the table across a multiple of
|
||||
/// `compact_every` commits.
|
||||
///
|
||||
/// The trigger is Lance's own table version, not a counter on this
|
||||
/// struct. Version is monotonic and lives in the table, so it keeps
|
||||
@@ -181,23 +189,29 @@ impl LanceVectorStore {
|
||||
/// which is exactly the workload that accumulates fragments one at a
|
||||
/// time and would therefore never compact.
|
||||
///
|
||||
/// Both writers call this: `upsert` and `delete_by_chunk_ids`. A large
|
||||
/// sweep is as capable of leaving thousands of fragments behind as a
|
||||
/// large ingest is.
|
||||
/// It compares the quotients on either side of the write rather than
|
||||
/// testing `version % compact_every == 0`, because writers do not all
|
||||
/// advance the version by one. `upsert` commits once, but
|
||||
/// `delete_by_chunk_ids` commits once per 200-id batch, so a single
|
||||
/// call can step from 28474 to 28507 — straight over a multiple
|
||||
/// without ever landing on one. The modulo form made the delete path's
|
||||
/// compaction fire roughly one call in `compact_every`.
|
||||
///
|
||||
/// Both writers call this. A large sweep is as capable of leaving
|
||||
/// thousands of fragments behind as a large ingest is.
|
||||
///
|
||||
/// Failure is not fatal — the data is already committed and a later
|
||||
/// commit crosses the next multiple — but it is logged at warn, because
|
||||
/// write crosses the next multiple — but it is logged at warn, because
|
||||
/// the symptom of silently skipping it only shows up much later as a
|
||||
/// slow ingest.
|
||||
fn maybe_compact(&self, table: &lancedb::Table, table_name: &str) {
|
||||
fn maybe_compact(&self, table: &lancedb::Table, table_name: &str, version_before: u64) {
|
||||
if self.compact_every == 0 {
|
||||
return;
|
||||
}
|
||||
let version = self
|
||||
.runtime
|
||||
.block_on(async { table.version().await })
|
||||
.unwrap_or(0);
|
||||
if version == 0 || version % self.compact_every != 0 {
|
||||
let version_after = self.table_version(table);
|
||||
if version_after == 0
|
||||
|| version_after / self.compact_every <= version_before / self.compact_every
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.compact_table(table) {
|
||||
@@ -378,6 +392,7 @@ impl VectorStore for LanceVectorStore {
|
||||
.context("phase 1: stage pending embedding_records")?;
|
||||
|
||||
// Phase 2: Lance MergeInsert keyed on chunk_id.
|
||||
let version_before = self.table_version(&table);
|
||||
let batch = build_batch(recs, dim, now)?;
|
||||
merge_insert_batch(&self.runtime, &table, batch).context("phase 2: Lance MergeInsert")?;
|
||||
|
||||
@@ -397,7 +412,7 @@ impl VectorStore for LanceVectorStore {
|
||||
"upsert committed"
|
||||
);
|
||||
|
||||
self.maybe_compact(&table, &table_name);
|
||||
self.maybe_compact(&table, &table_name, version_before);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -424,7 +439,7 @@ impl VectorStore for LanceVectorStore {
|
||||
// WHERE clause within typical SQL parser limits.
|
||||
const BATCH: usize = 200;
|
||||
let touched = self.runtime.block_on(async {
|
||||
let mut touched: Vec<(String, lancedb::Table)> = Vec::new();
|
||||
let mut touched: Vec<(String, lancedb::Table, u64)> = Vec::new();
|
||||
let names = self
|
||||
.connection
|
||||
.table_names()
|
||||
@@ -447,6 +462,9 @@ impl VectorStore for LanceVectorStore {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Captured inside the async block (no nested `block_on`);
|
||||
// `maybe_compact` compares against it after the loop.
|
||||
let version_before = table.version().await.unwrap_or(0);
|
||||
for batch in chunk_ids.chunks(BATCH) {
|
||||
// chunk_ids in production come from `id_for_chunk`
|
||||
// which always emits 32 ASCII hex chars. The
|
||||
@@ -474,7 +492,7 @@ impl VectorStore for LanceVectorStore {
|
||||
.await
|
||||
.with_context(|| format!("Lance delete on {name} ({} ids)", batch.len()))?;
|
||||
}
|
||||
touched.push((name.clone(), table));
|
||||
touched.push((name.clone(), table, version_before));
|
||||
}
|
||||
anyhow::Ok(touched)
|
||||
})?;
|
||||
@@ -486,8 +504,8 @@ impl VectorStore for LanceVectorStore {
|
||||
// Deletes commit just like upserts do, so the same fragment
|
||||
// accumulation applies. Compaction runs outside the `block_on`
|
||||
// above because `maybe_compact` drives its own.
|
||||
for (name, table) in touched {
|
||||
self.maybe_compact(&table, &name);
|
||||
for (name, table, version_before) in touched {
|
||||
self.maybe_compact(&table, &name, version_before);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -91,19 +91,30 @@ fn repeated_upserts_do_not_accumulate_lance_versions() {
|
||||
|
||||
/// The delete path commits just like the upsert path, and `sweep_deleted_files`
|
||||
/// used to call it once per purged document — issue #230 measured 5,834 purges
|
||||
/// becoming 5,834 Lance commits. Batching moved that to one call per sweep, but
|
||||
/// a large sweep still commits per 200-id batch, so the delete path needs the
|
||||
/// same compaction the upsert path got.
|
||||
/// becoming 5,834 Lance commits. Batching moved that to one call per flush, but
|
||||
/// one call still commits per 200-id batch, so the delete path needs the same
|
||||
/// compaction the upsert path got.
|
||||
///
|
||||
/// Deliberately large enough that a single call spans more than
|
||||
/// `compact_every` batches. That is the case a `version % compact_every == 0`
|
||||
/// trigger misses: the call steps the version by several at once and clears the
|
||||
/// multiple without ever landing on it. A handful of ids would pass against
|
||||
/// either trigger and prove nothing.
|
||||
#[test]
|
||||
#[ignore = "requires AVX-capable hardware (LanceDB)"]
|
||||
fn repeated_deletes_do_not_accumulate_lance_versions() {
|
||||
fn batched_delete_spanning_many_commits_still_compacts() {
|
||||
require_avx_or_panic();
|
||||
|
||||
let env = TestEnv::with_compact_interval(8);
|
||||
const INTERVAL: u64 = 8;
|
||||
// `delete_by_chunk_ids` commits per 200 ids, so this is ~10 commits in
|
||||
// one call — comfortably past INTERVAL.
|
||||
const IDS: u32 = 2_000;
|
||||
|
||||
let env = TestEnv::with_compact_interval(INTERVAL);
|
||||
let doc = format!("{:032x}", 0xd0c0u32);
|
||||
for i in 0..40u8 {
|
||||
for i in 0..IDS {
|
||||
env.seed_chunk(
|
||||
&format!("{:032x}", 0x2000u32 + u32::from(i)),
|
||||
&format!("{:032x}", 0x2000u32 + i),
|
||||
&doc,
|
||||
"note.md",
|
||||
"en",
|
||||
@@ -112,26 +123,25 @@ fn repeated_deletes_do_not_accumulate_lance_versions() {
|
||||
);
|
||||
}
|
||||
|
||||
let recs: Vec<_> = (0..40u8)
|
||||
let recs: Vec<_> = (0..IDS)
|
||||
.map(|i| {
|
||||
let mut r = make_record(i, 0, vec![1.0, 0.0, 0.0, 0.0], "hi", &[], MODEL);
|
||||
r.chunk_id = kebab_core::ChunkId(format!("{:032x}", 0x2000u32 + u32::from(i)));
|
||||
let mut r = make_record(0, 0, vec![1.0, 0.0, 0.0, 0.0], "hi", &[], MODEL);
|
||||
r.chunk_id = kebab_core::ChunkId(format!("{:032x}", 0x2000u32 + i));
|
||||
r.embedding_id = kebab_core::EmbeddingId(format!("{:032x}", 0xee000000u32 + i));
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
env.vector.upsert(&recs).unwrap();
|
||||
let after_upsert = manifest_count(&env.data_dir());
|
||||
|
||||
// Delete one at a time — the shape the un-batched sweep produced.
|
||||
for r in &recs {
|
||||
env.vector
|
||||
.delete_by_chunk_ids(std::slice::from_ref(&r.chunk_id))
|
||||
.unwrap();
|
||||
}
|
||||
let ids: Vec<_> = recs.iter().map(|r| r.chunk_id.clone()).collect();
|
||||
env.vector.delete_by_chunk_ids(&ids).unwrap();
|
||||
|
||||
let manifests = manifest_count(&env.data_dir());
|
||||
assert!(
|
||||
manifests <= 12,
|
||||
"delete path accumulated Lance versions: {manifests} manifests after 40 deletes \
|
||||
(compaction interval 8)"
|
||||
manifests <= after_upsert + 2,
|
||||
"batched delete left {manifests} manifests (was {after_upsert} before the delete) — \
|
||||
a single {IDS}-id call spans ~{} commits and must still trigger compaction",
|
||||
IDS / 200
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ git history.
|
||||
|
||||
`sweep_deleted_files`(`ingest.rs`)와 `execute_orphans_only`(`reset.rs`)가 루프 안에서 파일마다 `delete_by_chunk_ids` 를 불렀다. 그 호출 하나가 Lance 커밋 하나라, 문서 삭제 수와 커밋 수가 1:1 이었다 — #230 이 보고한 "삭제 5,834건 → 커밋 5,834회" 가 이것이다.
|
||||
|
||||
chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부르도록 바꿨다. SQLite purge 는 건별 커밋을 유지한다 — 그쪽이 crash-safety 경계이고, 기존 정책이 이미 "중간에 죽으면 orphan 벡터가 남는다" 를 허용한다.
|
||||
chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부르도록 바꿨다. SQLite purge 는 경로마다 즉시 커밋된다(`purge_deleted_workspace_path` 는 트랜잭션을 열지 않는다 — DELETE 세 개가 각각 autocommit 이다). 그래서 버퍼는 무한정 쌓지 않고 5,000 id 마다 flush 한다. 버퍼에 남은 id 는 문서 행이 이미 지워져 있어 다음 sweep 이 다시 찾지 못하는 고아 벡터가 되고, 회수 수단이 `reset --vector-only` 전량 재임베딩뿐이기 때문이다. `reset --orphans-only` 는 에러로 조기 반환할 때도 flush 를 거친다.
|
||||
|
||||
실 KB 실측 (28,427 문서 / 600,808 청크, 나무위키 샤드 하나 = 364 문서 삭제):
|
||||
|
||||
@@ -36,11 +36,13 @@ chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부
|
||||
|
||||
### 삭제 경로에도 압축 (제안 2 보강)
|
||||
|
||||
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는 경로는 여전히 무한 누적이었다. 압축 트리거를 `maybe_compact` 헬퍼로 빼고 `delete_by_chunk_ids` 끝에서도 부른다. 회귀 테스트(`repeated_deletes_do_not_accumulate_lance_versions`)는 압축을 빼면 40회 삭제에 매니페스트 42개로 실패한다.
|
||||
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는 경로는 여전히 무한 누적이었다. 트리거를 `maybe_compact` 헬퍼로 빼고 `delete_by_chunk_ids` 끝에서도 부른다.
|
||||
|
||||
판정을 `version % interval == 0` 에서 **구간 교차**(`after / interval > before / interval`)로 바꿨다. writer 마다 버전을 올리는 폭이 다르기 때문이다 — `upsert` 는 호출당 1 이라 배수를 반드시 밟지만 `delete_by_chunk_ids` 는 200-id 배치마다 커밋해서 한 번의 호출이 28474 → 28507 처럼 배수를 **건너뛴다**. 위 실측이 정확히 그 경우였고(28507 % 512 = 347), modulo 판정이었다면 삭제 경로 압축은 호출당 1/512 확률로만 걸렸을 것이다.
|
||||
|
||||
### doctor 에 Lance 지표 (제안 4)
|
||||
|
||||
`kebab doctor` 에 `vector_store` 체크 추가. fragment 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여주고, **메타데이터가 데이터보다 크면 fail** 로 잡는다 — 그게 #230 병리의 모양이다. lancedb 를 열지 않고 파일시스템만 읽어서 임베딩 provider 가 `none` 이어도 나오고 비용도 0 이다. `DoctorCheck` 목록에 항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
|
||||
`kebab doctor` 에 `vector_store` 체크 추가. fragment 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여준다. 판정은 **정보성**(`ok` 항상 true)이다 — `DoctorReport.ok` 가 종료 코드 3 을 만들고 스크립트·에이전트가 그걸로 분기하는데, 압축이 밀린 스토어도 질의에는 정상 응답한다. 대신 보존 버전 수가 압축 간격의 2배를 넘으면 hint 로 경고한다. 메타데이터 대 데이터 바이트 비교는 규모에 따라 부호가 뒤집혀(작은 노트 KB 가 오탐) 쓰지 않았다. lancedb 를 열지 않고 파일시스템만 읽어서 임베딩 provider 가 `none` 이어도 나오고 비용도 0 이다. `DoctorCheck` 목록에 항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
|
||||
|
||||
현재 KB 출력: `✓ vector_store 336 fragments / 2832.7 MB data, 337 versions / 5.3 MB metadata`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user