11 Commits

Author SHA1 Message Date
Usman Akinyemi e6bd9f7b9c Update README.md
CI / Test (macos-latest, stable) (push) Waiting to run
CI / Test (ubuntu-latest, stable) (push) Waiting to run
CI / Test (windows-latest, stable) (push) Waiting to run
CI / Lint (push) Waiting to run
2026-07-14 20:09:06 +05:30
Usman Akinyemi b33aa7a816 Update README.md
Added the correct discord link
2026-07-11 02:54:52 +05:30
Olow304 fa9efe5127 test: make commit_per_put_survives_wal_growth Windows-safe and deterministic
The regression test read the WAL header from a second file handle while the
writer was still open, which fails on Windows ("another process has locked a
portion of the file"). Read the header size only after the writer is dropped,
and ramp payload sizes past the initial 64 KiB WAL region so growth is forced
deterministically instead of relying on wrap/checkpoint timing. Account for
chunked frames when asserting durability.
2026-05-27 15:31:22 -04:00
Olow304 e18fe55494 v2.0.140: fix WAL corruption after region growth (#230) and Tantivy temp-dir leak (#215)
Docker Release / build-and-push (push) Waiting to run
- #230: refresh cached_payload_end after WAL growth so rebuild_indexes no
  longer seeks into the grown WAL region and overwrites record payloads
  (was surfacing as "wal record checksum mismatch").
- #215: make TantivyEngine drop its TempDir last and release the index
  writer first, so per-put working directories are cleaned up instead of
  leaking into the system temp dir (notably on Windows).
- add commit_per_put_survives_wal_growth regression test.
2026-05-27 15:12:48 -04:00
Saleban Olow 178e2772eb clean up - read me 2026-05-06 13:37:48 -05:00
Mr Sharafdin 7460a79766 Merge pull request #206 from nestorfernando3/codex/update-readme-es
docs(i18n): refresh Spanish README sections
2026-03-16 16:23:50 +03:00
Olow304 92b0ec8a9b fix: clippy pedantic — use let-else, allow trivially_copy_pass_by_ref 2026-03-14 11:57:46 -04:00
Nestor Fernando De Leon Llanos 0352770baf docs(i18n): refresh Spanish README sections 2026-03-14 10:51:52 -05:00
Olow304 11a18fdd6b fmt: fix formatting 2026-03-14 11:34:56 -04:00
Olow304 a79dfddc8b v2.0.139: Fix MV005 — graceful handling of stale frame_ids in all search paths
Docker Release / build-and-push (push) Waiting to run
2026-03-13 18:33:13 -04:00
Olow304 e35546ff7a fix: expose lex_enabled/vec_enabled in stats and auto-detect vec on reopen, gracefully skip out-of-range frame ids in timeline (#194, #196)
Docker Release / build-and-push (push) Waiting to run
2026-03-03 16:58:47 -05:00
19 changed files with 551 additions and 64 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "memvid-core"
version = "2.0.137"
version = "2.0.140"
edition = "2024"
rust-version = "1.85.0"
license = "Apache-2.0"
+1 -3
View File
@@ -38,12 +38,10 @@
<a href="https://github.com/memvid/memvid/stargazers"><img src="https://img.shields.io/github/stars/memvid/memvid?style=flat-square&logo=github" alt="Stars" /></a>
<a href="https://github.com/memvid/memvid/network/members"><img src="https://img.shields.io/github/forks/memvid/memvid?style=flat-square&logo=github" alt="Forks" /></a>
<a href="https://github.com/memvid/memvid/issues"><img src="https://img.shields.io/github/issues/memvid/memvid?style=flat-square&logo=github" alt="Issues" /></a>
<a href="https://discord.gg/2mynS7fcK7"><img src="https://img.shields.io/discord/1442910055233224745?style=flat-square&logo=discord&label=discord" alt="Discord" /></a>
<a href="https://discord.gg/7RUve6Zrrv"><img src="https://img.shields.io/discord/1442910055233224745?style=flat-square&logo=discord&label=discord" alt="Discord" /></a>
</p>
## Benchmark Highlights
**🚀 Higher accuracy than any other memory system :** +35% SOTA on LoCoMo, best-in-class long-horizon conversational recall & reasoning
+163 -11
View File
@@ -58,6 +58,18 @@
<h2 align="center">⭐️ Deja una STAR para apoyar el proyecto ⭐️</h2>
</p>
## Lo más destacado de los benchmarks
**🚀 Mayor precisión que cualquier otro sistema de memoria:** +35% SOTA en LoCoMo, con recall y razonamiento conversacional de largo horizonte de primer nivel.
**🧠 Mejor razonamiento multi-hop y temporal:** +76% en multi-hop y +56% en temporal frente al promedio de la industria.
**⚡ Latencia ultra baja a escala:** 0.025 ms P50 y 0.075 ms P99, con 1,372× más throughput que los enfoques estándar.
**🔬 Benchmarks totalmente reproducibles:** LoCoMo (10 conversaciones de ~26K tokens), evaluación open source y LLM-as-Judge.
---
## ¿Qué es Memvid?
Memvid es un sistema de memoria portable para IA que empaqueta tus datos, embeddings, estructura de búsqueda y metadatos en un solo archivo.
@@ -152,16 +164,18 @@ memvid-core = "2.0"
### Feature Flags
| Feature | Description |
| ------------------- | ---------------------------------------------- |
| `lex` | Full-text search with BM25 ranking (Tantivy) |
| `pdf_extract` | Pure Rust PDF text extraction |
| `vec` | Vector similarity search (HNSW + ONNX) |
| `clip` | CLIP visual embeddings for image search |
| `whisper` | Audio transcription with Whisper |
| `temporal_track` | Natural language date parsing ("last Tuesday") |
| `parallel_segments` | Multi-threaded ingestion |
| `encryption` | Password-based encryption capsules (.mv2e) |
| Feature | Descripción |
| ------------------- | ------------------------------------------------------------------ |
| `lex` | Búsqueda full-text con ranking BM25 (Tantivy) |
| `pdf_extract` | Extracción de texto PDF 100% en Rust |
| `vec` | Búsqueda por similitud vectorial (HNSW + embeddings locales vía ONNX) |
| `clip` | Embeddings visuales CLIP para búsqueda de imágenes |
| `whisper` | Transcripción de audio con Whisper |
| `api_embed` | Embeddings en la nube mediante API (OpenAI) |
| `temporal_track` | Interpretación de fechas en lenguaje natural ("el martes pasado") |
| `parallel_segments` | Ingesta multi-hilo |
| `encryption` | Cápsulas cifradas con contraseña (.mv2e) |
| `symspell_cleanup` | Reparación robusta de texto PDF (corrige "emp lo yee" -> "employee") |
Activa las features según lo necesites:
@@ -305,6 +319,137 @@ cargo run --example test_whisper --features whisper
---
## Modelos de embeddings de texto
La feature `vec` incluye soporte para embeddings de texto locales usando modelos ONNX. Antes de usar embeddings locales, necesitas descargar manualmente los archivos del modelo.
### Inicio rápido: BGE-small (recomendado)
Descarga el modelo BGE-small por defecto (384 dimensiones, rápido y eficiente):
```bash
mkdir -p ~/.cache/memvid/text-models
# Descargar modelo ONNX
curl -L 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx' \
-o ~/.cache/memvid/text-models/bge-small-en-v1.5.onnx
# Descargar tokenizer
curl -L 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/tokenizer.json' \
-o ~/.cache/memvid/text-models/bge-small-en-v1.5_tokenizer.json
```
### Modelos disponibles
| Modelo | Dimensiones | Tamaño | Mejor para |
| ----------------------- | ----------- | ------ | --------------------- |
| `bge-small-en-v1.5` | 384 | ~120MB | Opción por defecto, rápido |
| `bge-base-en-v1.5` | 768 | ~420MB | Mejor calidad |
| `nomic-embed-text-v1.5` | 768 | ~530MB | Tareas versátiles |
| `gte-large` | 1024 | ~1.3GB | Máxima calidad |
### Otros modelos
**BGE-base** (768 dimensiones):
```bash
curl -L 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/onnx/model.onnx' \
-o ~/.cache/memvid/text-models/bge-base-en-v1.5.onnx
curl -L 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/tokenizer.json' \
-o ~/.cache/memvid/text-models/bge-base-en-v1.5_tokenizer.json
```
**Nomic** (768 dimensiones):
```bash
curl -L 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/onnx/model.onnx' \
-o ~/.cache/memvid/text-models/nomic-embed-text-v1.5.onnx
curl -L 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/tokenizer.json' \
-o ~/.cache/memvid/text-models/nomic-embed-text-v1.5_tokenizer.json
```
**GTE-large** (1024 dimensiones):
```bash
curl -L 'https://huggingface.co/thenlper/gte-large/resolve/main/onnx/model.onnx' \
-o ~/.cache/memvid/text-models/gte-large.onnx
curl -L 'https://huggingface.co/thenlper/gte-large/resolve/main/tokenizer.json' \
-o ~/.cache/memvid/text-models/gte-large_tokenizer.json
```
### Uso en código
```rust
use memvid_core::text_embed::{LocalTextEmbedder, TextEmbedConfig};
use memvid_core::types::embedding::EmbeddingProvider;
// Usar el modelo por defecto (BGE-small)
let config = TextEmbedConfig::default();
let embedder = LocalTextEmbedder::new(config)?;
let embedding = embedder.embed_text("hello world")?;
assert_eq!(embedding.len(), 384);
// Usar un modelo distinto
let config = TextEmbedConfig::bge_base();
let embedder = LocalTextEmbedder::new(config)?;
```
Consulta `examples/text_embedding.rs` para ver un ejemplo completo con cálculo de similitud y ranking de búsqueda.
### Consistencia del modelo
Para evitar mezclar modelos por accidente, por ejemplo consultar un índice BGE-small con embeddings de OpenAI, puedes asociar explícitamente tu instancia de Memvid a un nombre de modelo:
```rust
// Vincula el índice a un modelo concreto.
// Si el índice ya fue creado con otro modelo, devolverá un error.
mem.set_vec_model("bge-small-en-v1.5")?;
```
Esta vinculación es persistente. Una vez definida, cualquier intento futuro de usar otro nombre de modelo fallará de inmediato con un error `ModelMismatch`.
---
## Embeddings por API (OpenAI)
La feature `api_embed` habilita la generación de embeddings en la nube usando la API de OpenAI.
### Configuración
Define tu clave de API de OpenAI:
```bash
export OPENAI_API_KEY="sk-..."
```
### Uso
```rust
use memvid_core::api_embed::{OpenAIConfig, OpenAIEmbedder};
use memvid_core::types::embedding::EmbeddingProvider;
// Usar el modelo por defecto (text-embedding-3-small)
let config = OpenAIConfig::default();
let embedder = OpenAIEmbedder::new(config)?;
let embedding = embedder.embed_text("hello world")?;
assert_eq!(embedding.len(), 1536);
// Usar un modelo de mayor calidad
let config = OpenAIConfig::large(); // text-embedding-3-large (3072 dims)
let embedder = OpenAIEmbedder::new(config)?;
```
### Modelos disponibles
| Modelo | Dimensiones | Mejor para |
| ------------------------ | ----------- | -------------------------------- |
| `text-embedding-3-small` | 1536 | Por defecto, más rápido y económico |
| `text-embedding-3-large` | 3072 | Máxima calidad |
| `text-embedding-ada-002` | 1536 | Modelo heredado |
Consulta `examples/openai_embedding.rs` para ver un ejemplo completo.
---
## Formato de archivo
Todo vive en un único archivo `.mv2`:
@@ -342,7 +487,14 @@ Email: contact@memvid.com
---
> **Memvid v1 (memoria basada en QR) está obsoleto**
>
> Si estás viendo referencias a códigos QR, estás usando información desactualizada.
>
> Consulta: https://docs.memvid.com/memvid-v1-deprecation
---
## Licencia
Apache License 2.0 — consulta el archivo [LICENSE](LICENSE) para más detalles.
+2
View File
@@ -576,6 +576,7 @@ impl Memvid {
snippet_chars: request.snippet_chars,
cursor: search_request.cursor.clone(),
},
stale_index_skips: 0,
});
}
@@ -659,6 +660,7 @@ impl Memvid {
snippet_chars: request.snippet_chars,
cursor: search_request.cursor.clone(),
},
stale_index_skips: 0,
})
}
}
+23 -6
View File
@@ -429,6 +429,7 @@ impl Memvid {
let original_data_end = self.data_end;
let original_generation = self.generation;
let original_dirty = self.dirty;
let original_lex_enabled = self.lex_enabled;
#[cfg(feature = "lex")]
let original_tantivy_dirty = self.tantivy_dirty;
@@ -462,6 +463,7 @@ impl Memvid {
self.data_end = original_data_end;
self.generation = original_generation;
self.dirty = original_dirty;
self.lex_enabled = original_lex_enabled;
#[cfg(feature = "lex")]
{
self.tantivy_dirty = original_tantivy_dirty;
@@ -483,6 +485,7 @@ impl Memvid {
self.data_end = original_data_end;
self.generation = original_generation;
self.dirty = original_dirty;
self.lex_enabled = original_lex_enabled;
#[cfg(feature = "lex")]
{
self.tantivy_dirty = original_tantivy_dirty;
@@ -604,9 +607,7 @@ impl Memvid {
self.shift_data_for_wal_growth(delta)?;
self.header.wal_size = new_size;
self.header.footer_offset = self.header.footer_offset.saturating_add(delta);
self.data_end = self.data_end.saturating_add(delta);
self.adjust_offsets_after_wal_growth(delta);
self.apply_wal_growth_offsets(delta);
let catalog_end = self.catalog_data_end();
self.header.footer_offset = catalog_end
@@ -656,6 +657,24 @@ impl Memvid {
Ok(())
}
fn apply_wal_growth_offsets(&mut self, delta: u64) {
if delta == 0 {
return;
}
self.header.footer_offset = self.header.footer_offset.saturating_add(delta);
self.data_end = self.data_end.saturating_add(delta);
// `cached_payload_end` mirrors `data_end` / footer positioning. After
// `shift_data_for_wal_growth` every byte past the old WAL boundary
// moved right by `delta`, so the cached payload boundary must also
// advance. Forgetting this caused `rebuild_indexes` (which seeks to
// `payload_region_end()`) to write embedded indexes back into the
// grown portion of the WAL region, corrupting WAL record payloads.
// See https://github.com/memvid/memvid/issues/230.
self.cached_payload_end = self.cached_payload_end.saturating_add(delta);
self.adjust_offsets_after_wal_growth(delta);
}
fn adjust_offsets_after_wal_growth(&mut self, delta: u64) {
if delta == 0 {
return;
@@ -798,9 +817,7 @@ impl Memvid {
self.shift_data_for_wal_growth(delta)?;
self.header.wal_size = target;
self.header.footer_offset = self.header.footer_offset.saturating_add(delta);
self.data_end = self.data_end.saturating_add(delta);
self.adjust_offsets_after_wal_growth(delta);
self.apply_wal_growth_offsets(delta);
let catalog_end = self.catalog_data_end();
self.header.footer_offset = catalog_end
+12 -3
View File
@@ -19,10 +19,17 @@ impl Memvid {
pub fn enable_lex(&mut self) -> Result<()> {
self.ensure_writable()?;
if self.lex_enabled {
// If index exists on disk but not in memory, load it
#[cfg(feature = "lex")]
if self.lex_index.is_none() && crate::memvid::lifecycle::has_lex_index(&self.toc) {
self.load_lex_index_from_manifest()?;
{
// If index exists on disk but not in memory, load it
if self.lex_index.is_none() && crate::memvid::lifecycle::has_lex_index(&self.toc) {
self.load_lex_index_from_manifest()?;
}
// Ensure Tantivy engine is running even if lex was already enabled
// (e.g. create() set lex_enabled=true but tantivy may have been lost)
if self.tantivy.is_none() {
self.init_tantivy()?;
}
}
return Ok(());
}
@@ -353,6 +360,7 @@ impl Memvid {
context: build_context(&[]),
next_cursor: None,
engine: SearchEngineKind::Hybrid,
stale_index_skips: 0,
});
}
@@ -457,6 +465,7 @@ impl Memvid {
context,
next_cursor: None,
engine: SearchEngineKind::Hybrid,
stale_index_skips: 0,
})
}
+21 -8
View File
@@ -39,18 +39,24 @@ pub(super) fn search_with_lex_fallback(
let max_snippets_per_doc = request.top_k.max(1);
let mut evaluated = Vec::new();
let mut stale_skips = 0u32;
for matched in &matches {
if let Some(filter) = candidate_filter {
if !filter.contains(&matched.frame_id) {
continue;
}
}
let frame_meta = usize::try_from(matched.frame_id)
let Some(frame_meta) = usize::try_from(matched.frame_id)
.ok()
.and_then(|idx| memvid.toc.frames.get(idx))
.ok_or(MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
})?;
else {
tracing::warn!(
frame_id = matched.frame_id,
"skipping search hit with stale frame_id"
);
stale_skips = stale_skips.saturating_add(1);
continue;
};
let content_lower = matched.content.to_ascii_lowercase();
let ctx = EvaluationContext {
frame: frame_meta,
@@ -86,14 +92,18 @@ pub(super) fn search_with_lex_fallback(
let mut hits = Vec::new();
let mut produced = 0usize;
for (matched, slices) in evaluated {
let frame_meta = memvid
let Some(frame_meta) = memvid
.toc
.frames
.get(usize::try_from(matched.frame_id).unwrap_or(usize::MAX))
.cloned()
.ok_or(MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
})?;
else {
tracing::warn!(
frame_id = matched.frame_id,
"skipping stale frame_id in snippet assembly"
);
continue;
};
let canonical = memvid.frame_content(&frame_meta)?;
let canonical_limit = frame_meta.canonical_length.map_or_else(
|| canonical.len(),
@@ -200,6 +210,7 @@ pub(super) fn search_with_lex_fallback(
context,
next_cursor,
engine: SearchEngineKind::LexFallback,
stale_index_skips: stale_skips,
})
}
@@ -250,6 +261,7 @@ pub(super) fn search_with_filters_only(
context: build_context(&[]),
next_cursor: None,
engine: SearchEngineKind::LexFallback,
stale_index_skips: 0,
});
}
@@ -322,5 +334,6 @@ pub(super) fn search_with_filters_only(
context,
next_cursor,
engine: SearchEngineKind::LexFallback,
stale_index_skips: 0,
})
}
+13 -7
View File
@@ -33,6 +33,7 @@ pub(super) fn empty_search_response(
context: String::new(),
next_cursor: None,
engine,
stale_index_skips: 0,
}
}
@@ -344,13 +345,18 @@ pub(crate) fn attach_temporal_metadata(memvid: &mut Memvid, hits: &mut [SearchHi
let text = if mention_end > mention_start {
if !canonical_cache.contains_key(&frame_id) {
let frame = memvid.toc.frames.get(frame_id as usize).cloned().ok_or(
MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
},
)?;
let content = memvid.frame_content(&frame)?;
canonical_cache.insert(frame_id, content);
match memvid.toc.frames.get(frame_id as usize).cloned() {
Some(frame) => {
let content = memvid.frame_content(&frame)?;
canonical_cache.insert(frame_id, content);
}
None => {
tracing::warn!(
frame_id,
"skipping temporal text for stale frame_id"
);
}
}
}
canonical_cache.get(&frame_id).and_then(|content| {
if mention_end <= content.len() {
+7
View File
@@ -48,6 +48,13 @@ impl Memvid {
return Err(MemvidError::LexNotEnabled);
}
// Lazy-init Tantivy if lex is enabled but engine is missing.
// This can happen when a wrapper re-enables lex on an already-enabled
// instance, or after a staging-lock rollback lost the engine reference.
if self.tantivy.is_none() {
self.init_tantivy()?;
}
let start_time = Instant::now();
// parse_query can return structured tokens; we only keep non-empty, lower-cased terms.
let parsed = crate::search::parse_query(&request.query)?;
+20 -9
View File
@@ -6,6 +6,7 @@ use super::helpers::attach_temporal_metadata;
use super::helpers::{
build_context, collect_token_occurrences, parse_cursor, timestamp_to_rfc3339,
};
use crate::Result;
use crate::lex::compute_snippet_slices;
use crate::memvid::frame::ChunkInfo;
use crate::memvid::lifecycle::Memvid;
@@ -14,7 +15,6 @@ use crate::types::{
FrameId, SearchEngineKind, SearchHit, SearchHitMetadata, SearchParams, SearchRequest,
SearchResponse,
};
use crate::{MemvidError, Result};
use log::warn;
use std::collections::HashSet;
use std::time::Instant;
@@ -119,15 +119,21 @@ pub(super) fn try_tantivy_search(
let snippet_window = request.snippet_chars.max(80);
let max_snippets_per_doc = request.top_k.max(1);
let mut evaluated = Vec::new();
let mut stale_skips = 0u32;
for hit in search_hits {
let frame_meta = memvid
let Some(frame_meta) = memvid
.toc
.frames
.get(usize::try_from(hit.frame_id).unwrap_or(usize::MAX))
.cloned()
.ok_or(MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
})?;
else {
tracing::warn!(
frame_id = hit.frame_id,
"skipping search hit with stale frame_id"
);
stale_skips = stale_skips.saturating_add(1);
continue;
};
if let Some(uri_expected) = uri_filter {
if !uri_matches(frame_meta.uri.as_deref(), uri_expected) {
continue;
@@ -274,14 +280,18 @@ pub(super) fn try_tantivy_search(
if hits.len() == effective_top_k && produced >= offset {
break;
}
let frame_meta = memvid
let Some(frame_meta) = memvid
.toc
.frames
.get(usize::try_from(hit.frame_id).unwrap_or(usize::MAX))
.cloned()
.ok_or(MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
})?;
else {
tracing::warn!(
frame_id = hit.frame_id,
"skipping stale frame_id in snippet assembly"
);
continue;
};
let uri = frame_meta
.uri
.clone()
@@ -373,6 +383,7 @@ pub(super) fn try_tantivy_search(
context,
next_cursor,
engine: SearchEngineKind::Tantivy,
stale_index_skips: stale_skips,
}))
}
+2
View File
@@ -117,6 +117,8 @@ impl Memvid {
time_index_bytes,
vector_count,
clip_image_count,
lex_enabled: self.lex_enabled,
vec_enabled: self.vec_enabled,
})
}
+10 -6
View File
@@ -1,5 +1,6 @@
//! Timeline assembly helpers for `Memvid`.
use crate::Result;
use crate::io::time_index::{TimeIndexEntry, read_track as time_index_read};
use crate::memvid::lifecycle::Memvid;
#[cfg(feature = "temporal_track")]
@@ -10,7 +11,6 @@ use crate::types::{
SearchHitTemporal, SearchHitTemporalAnchor, SearchHitTemporalMention, TemporalFilter,
TemporalTrack,
};
use crate::{MemvidError, Result};
#[cfg(feature = "temporal_track")]
use std::collections::HashSet;
use std::num::NonZeroU64;
@@ -95,14 +95,18 @@ pub(crate) fn build_timeline(
#[cfg(feature = "temporal_track")]
let temporal_track_snapshot = memvid.temporal_track_ref()?.cloned();
for entry in entries.into_iter().take(limit) {
let frame = memvid
let Some(frame) = memvid
.toc
.frames
.get(usize::try_from(entry.frame_id).unwrap_or(usize::MAX))
.ok_or(MemvidError::InvalidTimeIndex {
reason: "frame id out of range".into(),
})?
.clone();
.cloned()
else {
tracing::warn!(
frame_id = entry.frame_id,
"skipping time index entry with out-of-range frame id"
);
continue;
};
if frame.status != FrameStatus::Active {
continue;
}
+3 -3
View File
@@ -6,9 +6,9 @@ mod pdf;
mod pptx;
mod xls;
mod xlsx;
pub(crate) mod xlsx_chunker;
pub(crate) mod xlsx_ooxml;
pub(crate) mod xlsx_table_detect;
pub mod xlsx_chunker;
pub mod xlsx_ooxml;
pub mod xlsx_table_detect;
use serde_json::Value;
+5 -5
View File
@@ -324,7 +324,7 @@ fn parse_workbook_sheet_names(xml: &str) -> Vec<String> {
{
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"name" {
if let Ok(val) = attr.unescape_value() {
if let Ok(val) = attr.decode_and_unescape_value(&reader) {
names.push(val.to_string());
}
}
@@ -364,12 +364,12 @@ fn parse_styles_xml(xml: &str, metadata: &mut OoxmlMetadata) {
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"numFmtId" => {
if let Ok(v) = attr.unescape_value() {
if let Ok(v) = attr.decode_and_unescape_value(&reader) {
fmt_id = v.parse::<u32>().ok();
}
}
b"formatCode" => {
if let Ok(v) = attr.unescape_value() {
if let Ok(v) = attr.decode_and_unescape_value(&reader) {
fmt_code = Some(v.to_string());
}
}
@@ -384,7 +384,7 @@ fn parse_styles_xml(xml: &str, metadata: &mut OoxmlMetadata) {
let mut num_fmt_id = 0u32;
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"numFmtId" {
if let Ok(v) = attr.unescape_value() {
if let Ok(v) = attr.decode_and_unescape_value(&reader) {
num_fmt_id = v.parse::<u32>().unwrap_or(0);
}
}
@@ -425,7 +425,7 @@ fn parse_merge_cells_xml(xml: &str) -> Vec<MergedRegion> {
{
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"ref" {
if let Ok(val) = attr.unescape_value() {
if let Ok(val) = attr.decode_and_unescape_value(&reader) {
if let Some(((tr, lc), (br, rc))) = parse_range_ref(&val) {
regions.push(MergedRegion {
top_row: tr,
+23 -1
View File
@@ -12,8 +12,16 @@ use tantivy::{Index, IndexReader, Term, doc};
use tempfile::TempDir;
/// Tantivy-backed search index used when the `lex` feature is enabled.
///
/// Field order is load-bearing for `Drop`: Rust drops struct fields in
/// declaration order, so `work_dir` (the temporary directory backing the
/// index) **must remain the last field**. The `index`, `reader`, and
/// `index_writer` all keep file handles and lock files open inside that
/// directory; if the `TempDir` were dropped first, its directory removal
/// would fail on platforms that refuse to delete files with open handles
/// (notably Windows), silently leaking one working directory per discarded
/// engine. See https://github.com/memvid/memvid/issues/215.
pub struct TantivyEngine {
pub(super) work_dir: TempDir,
pub(super) index: Index,
pub(super) _schema: Schema,
pub(super) content: Field,
@@ -26,6 +34,20 @@ pub struct TantivyEngine {
pub(super) index_writer: Option<IndexWriter>,
pub(super) reader: IndexReader,
pub(super) tokenizer: Option<String>,
// MUST be the last field — see the type-level comment above.
pub(super) work_dir: TempDir,
}
impl Drop for TantivyEngine {
fn drop(&mut self) {
// Release the exclusive writer (and its `.tantivy-writer.lock`) before
// the `work_dir` TempDir is removed during normal field drop. Without
// this the writer lock can still be held when the directory removal
// runs, leaking the working directory on Windows. See issue #215.
if let Some(writer) = self.index_writer.take() {
drop(writer);
}
}
}
/// Search hit returned from Tantivy queries.
+126
View File
@@ -1,6 +1,7 @@
#[cfg(test)]
mod tests {
use crate::{Memvid, PutOptions, SearchRequest, run_serial_test};
use std::sync::Mutex;
use tempfile::NamedTempFile;
#[test]
@@ -87,4 +88,129 @@ mod tests {
}
});
}
/// Regression test for GitHub issue #201:
/// Lexical index not enabled when Memvid is wrapped in a Mutex.
/// The wrapper pattern acquires the lock, performs an operation, releases
/// the lock — mimicking the typical tokio::sync::Mutex usage in async code.
#[test]
#[cfg(not(target_os = "windows"))]
fn test_lex_works_through_mutex_wrapper() {
run_serial_test(|| {
let temp = NamedTempFile::new().unwrap();
let path = temp.path();
// Wrap Memvid in a Mutex, exactly like an async wrapper would
let wrapper = Mutex::new(Memvid::create(path).unwrap());
// Step 1: enable_lex while holding the lock, then release
{
let mut mem = wrapper.lock().unwrap();
mem.enable_lex().unwrap();
}
// Step 2: commit while holding the lock (separate acquisition)
{
let mut mem = wrapper.lock().unwrap();
mem.commit().unwrap();
}
// Step 3: put data while holding the lock
{
let mut mem = wrapper.lock().unwrap();
let opts = PutOptions::builder()
.uri("mv2://test/login".to_string())
.search_text("user clicked login button on the authentication page".to_string())
.build();
mem.put_bytes_with_options(b"login event data", opts)
.unwrap();
}
// Step 4: commit while holding the lock
{
let mut mem = wrapper.lock().unwrap();
mem.commit().unwrap();
}
// Step 5: search while holding the lock — this was failing in #201
{
let mut mem = wrapper.lock().unwrap();
let resp = mem
.search(SearchRequest {
query: "login".into(),
top_k: 10,
snippet_chars: 200,
uri: None,
scope: None,
cursor: None,
#[cfg(feature = "temporal_track")]
temporal: None,
as_of_frame: None,
as_of_ts: None,
no_sketch: false,
acl_context: None,
acl_enforcement_mode: crate::types::AclEnforcementMode::Audit,
})
.expect("search must succeed through mutex wrapper");
assert!(
!resp.hits.is_empty(),
"Should find the frame with 'login' in the message"
);
}
// Step 6: search_lex uses the legacy LexIndex, which may not be
// populated when only Tantivy is active. Verify it doesn't panic.
{
let mut mem = wrapper.lock().unwrap();
let _ = mem.search_lex("login", 10);
// Result may be Ok (if legacy index was built) or Err (if only Tantivy).
// The important thing is it doesn't panic.
}
});
}
/// Regression test for #201: enable_lex, put, commit, search — all in one lock scope.
#[test]
#[cfg(not(target_os = "windows"))]
fn test_lex_works_single_scope() {
run_serial_test(|| {
let temp = NamedTempFile::new().unwrap();
let path = temp.path();
let mut mem = Memvid::create(path).unwrap();
mem.enable_lex().unwrap();
let opts = PutOptions::builder()
.uri("mv2://test/login".to_string())
.search_text("user clicked login button on the authentication page".to_string())
.build();
mem.put_bytes_with_options(b"login event data", opts)
.unwrap();
mem.commit().unwrap();
let resp = mem
.search(SearchRequest {
query: "login".into(),
top_k: 10,
snippet_chars: 200,
uri: None,
scope: None,
cursor: None,
#[cfg(feature = "temporal_track")]
temporal: None,
as_of_frame: None,
as_of_ts: None,
no_sketch: false,
acl_context: None,
acl_enforcement_mode: crate::types::AclEnforcementMode::Audit,
})
.expect("search must succeed");
assert!(
!resp.hits.is_empty(),
"Should find the frame with 'login' in the message"
);
});
}
}
+6
View File
@@ -136,6 +136,12 @@ pub struct Stats {
/// Number of CLIP visual embeddings (images/PDF pages)
#[serde(default)]
pub clip_image_count: u64,
/// Whether the lex (full-text) search engine is enabled at runtime.
#[serde(default)]
pub lex_enabled: bool,
/// Whether the vec (vector/semantic) search engine is enabled at runtime.
#[serde(default)]
pub vec_enabled: bool,
}
/// Entry returned by `timeline` queries, carrying a lightweight preview.
+8
View File
@@ -190,4 +190,12 @@ pub struct SearchResponse {
#[serde(default)]
/// Engine responsible for the results.
pub engine: SearchEngineKind,
/// Number of search hits skipped due to stale frame_ids in the index.
#[serde(default, skip_serializing_if = "is_zero")]
pub stale_index_skips: u32,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_zero(v: &u32) -> bool {
*v == 0
}
+105 -1
View File
@@ -3,11 +3,23 @@
use memvid_core::{
EmbeddingIdentitySummary, MEMVID_EMBEDDING_MODEL_KEY, MEMVID_EMBEDDING_PROVIDER_KEY, Memvid,
MemvidError, PutOptions, TimelineQuery,
MemvidError, PutOptions, TimelineQuery, constants::HEADER_SIZE, io::header::HeaderCodec,
};
use std::fs::File;
use std::io::Read;
use std::num::NonZeroU64;
use std::path::Path;
use tempfile::TempDir;
fn read_wal_size(path: &Path) -> u64 {
let mut header_bytes = [0u8; HEADER_SIZE];
File::open(path)
.unwrap()
.read_exact(&mut header_bytes)
.unwrap();
HeaderCodec::decode(&header_bytes).unwrap().wal_size
}
/// Test basic put operation with bytes.
#[test]
fn put_bytes_basic() {
@@ -434,3 +446,95 @@ fn timeline_iteration() {
assert_eq!(entries.len(), 3, "Should have 3 timeline entries");
}
/// Regression test for memvid/memvid#230 — sustained commit-per-put workloads
/// that span multiple WAL growth cycles must keep the embedded WAL intact.
///
/// Before the fix, `grow_wal_region` / `ensure_wal_capacity` updated
/// `header.footer_offset` and `self.data_end` after shifting the data region
/// but left the cached `payload_region_end()` value stale. The next call to
/// `rebuild_indexes` then sought to that pre-growth offset (which now lies
/// inside the grown WAL region) and overwrote WAL record payloads, producing
/// `Embedded WAL is corrupted at offset N: wal record checksum mismatch` on
/// the following commit.
#[test]
fn commit_per_put_survives_wal_growth() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("wal_growth.mv2");
// Capture the initial WAL size from a freshly created, then closed, store.
// The header is read with no writer open so it never races the store's
// file lock — on Windows a held byte-range lock makes a second handle's
// read fail with "another process has locked a portion of the file".
let initial_wal_size = {
let mem = Memvid::create(&path).unwrap();
drop(mem);
read_wal_size(&path)
};
// Use text-indexable payloads of varying length so each commit drives the
// full Tantivy rebuild path (`rebuild_indexes` → `flush_tantivy`) that
// seeks to `payload_region_end()`. The mix of sizes ensures multiple
// WAL growth cycles occur across the run.
let words: &[&str] = &[
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
"juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo", "sierra",
"tango", "uniform", "victor", "whiskey", "x-ray", "yankee", "zulu",
];
let doc_count = 24u32;
let frames_written;
{
let mut mem = Memvid::open(&path).unwrap();
for i in 0..doc_count {
// Ramp the body size from ~4 KiB up past the initial 64 KiB WAL
// region so the run deterministically crosses several
// `grow_wal_region` cycles (rather than relying on incidental
// wrap/checkpoint timing). Each commit then runs `rebuild_indexes`,
// which seeks to `payload_region_end()` — the path #230 corrupted.
let body_len = 4096usize + (i as usize) * 6144;
let mut body = String::with_capacity(body_len + 16);
let mut idx = i as usize;
while body.len() < body_len {
body.push_str(words[idx % words.len()]);
body.push(' ');
idx = idx.wrapping_add(1);
}
let opts = PutOptions {
uri: Some(format!("mv2://wal-growth/doc-{i}")),
title: Some(format!("doc-{i}")),
search_text: Some(body.clone()),
..Default::default()
};
mem.put_bytes_with_options(body.as_bytes(), opts)
.unwrap_or_else(|e| panic!("put #{i} failed: {e}"));
mem.commit()
.unwrap_or_else(|e| panic!("commit #{i} failed: {e}"));
}
// Large `search_text` bodies are chunked, so each doc yields one or
// more frames; capture the true total before closing.
frames_written = mem.stats().unwrap().frame_count;
// mem dropped here, releasing the file lock before we read the header.
}
// The WAL region only ever grows, so reading the size once the writer is
// closed reflects the maximum it reached during the run.
let final_wal_size = read_wal_size(&path);
assert!(
final_wal_size > initial_wal_size,
"test must exercise WAL growth (initial={initial_wal_size}, final={final_wal_size})"
);
// Reopening forces a full WAL scan; checksum verification will fire here
// if any record payload was clobbered by a stale-offset index write.
assert!(
frames_written >= u64::from(doc_count),
"expected at least one frame per doc (got {frames_written} for {doc_count} docs)"
);
let reopened = Memvid::open_read_only(&path).unwrap();
assert_eq!(
reopened.stats().unwrap().frame_count,
frames_written,
"all frames should be durable after WAL growth"
);
}