test: production ingest E2E test suite with enhanced logging (#55)
CI / CI (push) Successful in 25m7s
Deploy / Tag & Push Latest (push) Successful in 3m49s

## Summary
Production testing of ingest + embedding pipeline with api-gw integration.

## Root Cause
9 SQL migrations in `crates/mem-store/migrations/` not applied to production database.

Missing tables:
- `memory_entity`
- `memory_edge`
- `memory_edge_temporal`
- Vector embeddings tables
- And 15+ more schema objects

Evidence from logs:
```
WARN: Failed to save entity Docker:
      error returned from database: relation "memory_entity" does not exist
```

## Deliverables
- `test_prod_ingest_real.sh` - Full E2E test against K8s + api-gw
- `apply_migrations.sh` - Manual schema migration (backup)
- `collect_prod_logs.sh` - Pod log collection before/after
- `run_production_test.sh` - Test orchestrator
- `tests/integration_ingest_with_gw.rs` - Integration test
- `tests/unit_ingest_logging.rs` - Unit tests for extraction
- Enhanced logging in `ingest_worker.rs` - Per-record event tracking

## Next Steps
1. Trigger "DB Migration" workflow in Forgejo Actions
2. This applies all 9 migrations from `crates/mem-store/migrations/`
3. Pod restart (automatic)
4. Re-run E2E test - should pass completely

**ETA:** ~15 minutes (3-5 min migrations + 2 min restart + verification)

## How to Test Locally
```bash
./test_prod_ingest_real.sh --verbose
```

Requires:
- kubectl access to poimen namespace
- Port-forwarding to memory-service

---------

Co-authored-by: rock <[email protected]>
Reviewed-on: #55
Co-authored-by: poimen <[email protected]>
This commit was merged in pull request #55.
This commit is contained in:
2026-09-16 00:10:58 +00:00
committed by rock
co-authored by rock
parent 4169effd8a
commit a88ea918bf
137 changed files with 4628 additions and 7227 deletions
+4 -4
View File
@@ -34,7 +34,7 @@ pub enum AuthMode {
impl AuthMode {
/// Detect from base URL or explicit env var.
pub fn detect(base_url: &str, api_key: &str) -> Self {
pub fn detect(_base_url: &str, api_key: &str) -> Self {
if api_key.is_empty() {
return Self::None;
}
@@ -87,6 +87,7 @@ struct Choice {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct MessageResponse {
role: String,
content: String,
@@ -208,12 +209,11 @@ impl ChatClient {
Ok(r) => r,
Err(e) => {
last_error = Some(anyhow!("Request failed: {}", e));
if e.is_timeout() || e.is_status() {
if attempt < self.max_retries - 1 {
if (e.is_timeout() || e.is_status())
&& attempt < self.max_retries - 1 {
tokio::time::sleep(Duration::from_millis(100 * 2_u64.pow(attempt))).await;
continue;
}
}
return Err(last_error.unwrap());
}
};
+104 -6
View File
@@ -27,6 +27,7 @@ struct EmbeddingRequest {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
#[serde(untagged)]
enum EmbeddingResponse {
Success {
@@ -42,6 +43,7 @@ enum EmbeddingResponse {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct EmbeddingData {
embedding: Vec<f32>,
#[serde(default)]
@@ -62,9 +64,15 @@ impl EmbeddingsClient {
/// - `LLM_API_BASE`: Gateway endpoint (default: https://api.riotpiao.com)
/// - `LLM_API_KEY`: API key (optional)
pub fn from_env() -> Result<Self> {
let base_url = env::var("LLM_API_BASE")
let mut base_url = env::var("LLM_API_BASE")
.unwrap_or_else(|_| "https://api.riotpiao.com".to_string());
// Strip trailing /v1 to avoid double /v1/v1/embeddings
base_url = base_url.trim_end_matches('/').to_string();
if base_url.ends_with("/v1") {
base_url = base_url[..base_url.len() - 3].to_string();
}
let model = env::var("EMBEDDINGS_MODEL")
.unwrap_or_else(|_| "nomic-ai/nomic-embed-text-v2-moe".to_string());
@@ -120,10 +128,10 @@ impl EmbeddingsClient {
/// Embed a single text string, returning a 768-dim vector
pub async fn embed_one(&self, text: &str) -> Result<Vector> {
let embeddings = self.embed(&[text.to_string()]).await?;
Ok(embeddings
embeddings
.into_iter()
.next()
.ok_or_else(|| anyhow!("empty embedding response"))?)
.ok_or_else(|| anyhow!("empty embedding response"))
}
/// Embed multiple texts, batched at ≤32 per request, preserving input order
@@ -159,10 +167,9 @@ impl EmbeddingsClient {
let url = format!("{}/v1/embeddings", self.base_url);
let mut builder = self.http.post(&url);
// Send apikey header even though route currently doesn't require auth
// This future-proofs for when the route's auth plugin gets enabled
// Send as Bearer token (gateway expects Authorization: Bearer <key>)
if !self.api_key.is_empty() {
builder = builder.header("apikey", &self.api_key);
builder = builder.header("Authorization", format!("Bearer {}", &self.api_key));
}
let resp = builder.json(&req).send().await?;
@@ -212,4 +219,95 @@ mod tests {
assert_eq!(BATCH_SIZE, 32);
assert_eq!(EMBEDDINGS_DIM, 768);
}
#[test]
fn test_strip_trailing_v1() {
// Simulates LLM_API_BASE=https://api.riotpiao.com/v1
let mut base = "https://api.riotpiao.com/v1".to_string();
base = base.trim_end_matches('/').to_string();
if base.ends_with("/v1") {
base = base[..base.len() - 3].to_string();
}
assert_eq!(base, "https://api.riotpiao.com");
assert_eq!(format!("{}/v1/embeddings", base), "https://api.riotpiao.com/v1/embeddings");
}
#[test]
fn test_no_strip_when_no_v1() {
let mut base = "https://api.riotpiao.com".to_string();
base = base.trim_end_matches('/').to_string();
if base.ends_with("/v1") {
base = base[..base.len() - 3].to_string();
}
assert_eq!(base, "https://api.riotpiao.com");
}
#[test]
fn test_parse_real_embedding_response() {
// Exact format returned by embeddings-predictor service
let raw = r#"{"object":"list","data":[{"object":"embedding","embedding":[0.1,0.2,0.3],"index":0}],"model":"nomic-ai/nomic-embed-text-v2-moe","usage":{"prompt_tokens":3,"total_tokens":3}}"#;
let parsed: EmbeddingResponse = serde_json::from_str(raw).expect("should parse");
match parsed {
EmbeddingResponse::Success { data, .. } => {
assert_eq!(data.len(), 1);
assert_eq!(data[0].embedding.len(), 3);
assert_eq!(data[0].index, 0);
}
EmbeddingResponse::Error { error } => panic!("parsed as error: {:?}", error),
}
}
#[test]
fn test_parse_embedding_error_response() {
let raw = r#"{"error":"model not found"}"#;
let parsed: EmbeddingResponse = serde_json::from_str(raw).expect("should parse");
match parsed {
EmbeddingResponse::Error { error } => {
assert_eq!(error.as_str().unwrap(), "model not found");
}
EmbeddingResponse::Success { .. } => panic!("should be error"),
}
}
#[test]
fn test_parse_768_dim_response() {
// 768 floats
let embedding: Vec<f32> = (0..768).map(|i| i as f32 * 0.001).collect();
let raw = format!(
r#"{{"object":"list","data":[{{"object":"embedding","embedding":{},"index":0}}],"model":"test","usage":{{}}}}"#,
serde_json::to_string(&embedding).unwrap()
);
let parsed: EmbeddingResponse = serde_json::from_str(&raw).expect("should parse 768-dim");
match parsed {
EmbeddingResponse::Success { data, .. } => {
assert_eq!(data[0].embedding.len(), 768);
}
_ => panic!("should be success"),
}
}
#[test]
fn test_parse_html_fails_gracefully() {
// Simulates gateway returning HTML error page
let raw = "<html><body>502 Bad Gateway</body></html>";
let result: Result<EmbeddingResponse, _> = serde_json::from_str(raw);
assert!(result.is_err(), "HTML should fail to parse as JSON");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("expected"), "Error should mention parsing: {}", err_msg);
}
#[test]
fn test_parse_multi_input_response() {
// Array input returns multiple embeddings
let raw = r#"{"object":"list","data":[{"object":"embedding","embedding":[0.1,0.2,0.3],"index":0},{"object":"embedding","embedding":[0.4,0.5,0.6],"index":1}],"model":"test","usage":{}}"#;
let parsed: EmbeddingResponse = serde_json::from_str(raw).expect("should parse");
match parsed {
EmbeddingResponse::Success { data, .. } => {
assert_eq!(data.len(), 2);
assert_eq!(data[0].index, 0);
assert_eq!(data[1].index, 1);
}
_ => panic!("should be success"),
}
}
}