Tracing: DAO layer + handler spans on tracing, drop all Context params (#141)

trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.

The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.

Background-job raw spans (thumbnails, video actors) are left for #142.

tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 17:02:40 -04:00
1 parent 1c508cdabd
commit b6deca2a52
55 files changed
+1942 -3852

No files matched your search

+19 -3
View File
@@ -108,7 +108,7 @@ cargo run --bin create_user -- <username> <password>
- `image_exif`: Rich metadata (file_path + 16 EXIF fields: camera, GPS, dates, exposure settings)
**DAO Pattern:**
All database access goes through trait-based DAOs (e.g., `ExifDao`, `SqliteExifDao`). Connection pooling uses `Arc<Mutex<SqliteConnection>>`. All DB operations are traced with OpenTelemetry in release builds.
All database access goes through trait-based DAOs (e.g., `ExifDao`, `SqliteExifDao`). Connection pooling uses `Arc<Mutex<SqliteConnection>>`. Every DAO method runs inside `otel::trace_db_call` (a `db.<type>.<op>` span); DAO methods take no tracing context.
**Key DAO Methods:**
- `store_exif()`, `get_exif()`, `get_exif_batch()`: EXIF CRUD operations
@@ -563,8 +563,24 @@ Always use `is_valid_full_path(&base_path, &requested_path, check_exists)` to pr
**File Type Detection:**
Centralized in `file_types.rs` with constants `IMAGE_EXTENSIONS` and `VIDEO_EXTENSIONS`. Provides both `Path` and `DirEntry` variants for performance.
**OpenTelemetry Tracing:**
All database operations and HTTP handlers wrapped in spans. In release builds, exports to OTLP endpoint via `OTLP_OTLS_ENDPOINT`. Debug builds use basic logger.
**Tracing (`tracing` + `tracing-opentelemetry`):**
Spans are `tracing` spans in every build; `otel::init_tracing` installs the
subscriber and exports over OTLP when `OTLP_OTLS_ENDPOINT` is set. The
`tracing-actix-web` middleware creates the per-request root span (and honors
an incoming `traceparent`). Conventions:
- **No context threading.** Never pass an `opentelemetry::Context` down a
call chain — spans parent on the ambient span. Handlers and feature fns use
`#[tracing::instrument(name = "...", skip_all)]`; attributes/status go
through `tracing_opentelemetry::OpenTelemetrySpanExt` (`set_attribute`,
`set_status`) on `tracing::Span::current()`.
- **Carry the span across `tokio::spawn`.** Tracing context is thread-local;
wrap the spawned future with `.instrument(span)` or its spans orphan onto a
fresh trace (pinned by `tests/otel_propagation.rs`).
- **Mid-function sub-spans** (e.g. an agentic loop): create an `info_span!`
and `.instrument(span.clone())` each await that should nest under it —
never hold `span.enter()` across an `.await`.
- DB: `otel::trace_db_call`; LLM: `otel::start_llm_request_span` (injects
`traceparent` downstream).
**Memory Exclusion:**
`PathExcluder` in `memories.rs` filters out directories from memories API via `EXCLUDED_DIRS` environment variable (comma-separated paths or substring patterns). The same excluder is applied to face-detection candidates (`face_watch::filter_excluded`) so junk directories like `@eaDir` / `.thumbnails` don't burn detect calls on Apollo.
+2 -7
View File
@@ -232,12 +232,8 @@ pub async fn generate_daily_summaries(
// Check if summary already exists
{
let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao");
let otel_context = opentelemetry::Context::new();
if dao
.summary_exists(&otel_context, &date_str, contact)
.unwrap_or(false)
{
if dao.summary_exists(&date_str, contact).unwrap_or(false) {
skipped += 1;
if idx % 10 == 0 {
log::info!(
@@ -366,11 +362,10 @@ async fn generate_and_store_daily_summary(
};
// DAO calls still take an explicit OTel context (#141).
let child_cx = span.context();
let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao");
let result = dao
.store_summary(&child_cx, insert)
.store_summary(insert)
.map_err(|e| anyhow::anyhow!("Failed to store summary: {:?}", e));
match &result {
+34 -69
View File
@@ -93,14 +93,12 @@ pub async fn generation_status_handler(
query: web::Query<GenerationStatusQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let ctx = opentelemetry::Context::new();
if let Some(jid) = query.job_id {
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.get_job_by_id(&ctx, jid) {
match dao.get_job_by_id(jid) {
Ok(Some(job)) => {
return HttpResponse::Ok().json(GenerationStatusResponse {
job_id: job.id,
@@ -136,7 +134,7 @@ pub async fn generation_status_handler(
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.get_active_job(&ctx, library.id, &normalized) {
match dao.get_active_job(library.id, &normalized) {
Ok(Some(job)) => {
return HttpResponse::Ok().json(GenerationStatusResponse {
job_id: job.id,
@@ -187,14 +185,12 @@ pub async fn cancel_generation_handler(
request: web::Json<CancelGenerationRequest>,
app_state: web::Data<AppState>,
) -> impl Responder {
let ctx = opentelemetry::Context::new();
if let Some(jid) = request.job_id {
let mut dao = app_state
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.cancel_job(&ctx, jid) {
match dao.cancel_job(jid) {
Ok(true) => {
let mut handles = app_state
.insight_job_handles
@@ -238,12 +234,12 @@ pub async fn cancel_generation_handler(
.lock()
.expect("Unable to lock InsightJobDao");
let ids = dao
.get_active_job(&ctx, library.id, &normalized)
.get_active_job(library.id, &normalized)
.ok()
.flatten()
.map(|j| vec![j.id])
.unwrap_or_default();
let _ = dao.cancel_active_jobs(&ctx, library.id, &normalized);
let _ = dao.cancel_active_jobs(library.id, &normalized);
ids
};
@@ -400,7 +396,7 @@ fn apply_owner_defaults(
let Some(mut dao) = app_state.user_ai_prefs_dao.lock().ok() else {
return;
};
let Ok(Some(prefs)) = dao.get_prefs(&opentelemetry::Context::new()) else {
let Ok(Some(prefs)) = dao.get_prefs() else {
return;
};
if model.is_none() {
@@ -455,14 +451,13 @@ pub async fn generate_insight_handler(
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
let ctx = opentelemetry::Context::new();
let ids = dao
.get_active_job(&ctx, library.id, &normalized_path)
.get_active_job(library.id, &normalized_path)
.ok()
.flatten()
.map(|j| vec![j.id])
.unwrap_or_default();
let _ = dao.cancel_active_jobs(&ctx, library.id, &normalized_path);
let _ = dao.cancel_active_jobs(library.id, &normalized_path);
ids
};
for jid in &old_job_ids {
@@ -481,12 +476,7 @@ pub async fn generate_insight_handler(
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.create_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path,
gen_type,
) {
match dao.create_job(library.id, &normalized_path, gen_type) {
Ok(id) => id,
Err(e) => {
log::error!("Failed to create generation job: {:?}", e);
@@ -538,7 +528,6 @@ pub async fn generate_insight_handler(
})
.await;
let ctx = opentelemetry::Context::new();
let mut dao = job_dao.lock().expect("Unable to lock InsightJobDao");
match result {
@@ -547,18 +536,12 @@ pub async fn generate_insight_handler(
.insight_dao()
.lock()
.expect("Unable to lock InsightDao");
let insight_id = insight_dao
.get_insight(&ctx, &path)
.ok()
.flatten()
.map(|i| i.id);
let insight_id = insight_dao.get_insight(&path).ok().flatten().map(|i| i.id);
if let Some(id) = insight_id {
if let Err(e) = dao.complete_job(&ctx, job_id, id) {
if let Err(e) = dao.complete_job(job_id, id) {
log::error!("Failed to mark job {} as completed: {:?}", job_id, e);
}
} else if let Err(e) =
dao.fail_job(&ctx, job_id, "generation returned no insight")
{
} else if let Err(e) = dao.fail_job(job_id, "generation returned no insight") {
log::error!("Failed to mark job {} as failed: {:?}", job_id, e);
}
}
@@ -566,7 +549,7 @@ pub async fn generate_insight_handler(
log::error!("Insight generation failed for {}: {:?}", path, e);
// `{:#}` = one-line context chain; the job's error_message is
// returned to the client verbatim, so no Debug/backtrace here.
if let Err(err) = dao.fail_job(&ctx, job_id, &format!("{:#}", e)) {
if let Err(err) = dao.fail_job(job_id, &format!("{:#}", e)) {
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
@@ -577,14 +560,14 @@ pub async fn generate_insight_handler(
timeout_secs
);
if let Err(err) =
dao.fail_job(&ctx, job_id, &format!("timeout after {}s", timeout_secs))
dao.fail_job(job_id, &format!("timeout after {}s", timeout_secs))
{
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
Err(_) => {
log::error!("Insight generation task panicked for {}", path);
if let Err(err) = dao.fail_job(&ctx, job_id, "generation task panicked") {
if let Err(err) = dao.fail_job(job_id, "generation task panicked") {
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
@@ -627,8 +610,6 @@ pub async fn get_insight_handler(
let normalized_path = normalize_path(&query.path);
log::debug!("Fetching insight for {}", normalized_path);
let otel_context = opentelemetry::Context::new();
// Expand to rel_paths sharing content so an insight generated under
// library 1 still shows when the same photo is viewed from library 2.
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
@@ -637,13 +618,13 @@ pub async fn get_insight_handler(
.unwrap_or_else(|| app_state.primary_library());
let sibling_paths = {
let mut exif = exif_dao.lock().expect("Unable to lock ExifDao");
exif.get_rel_paths_sharing_content(&otel_context, library.id, &normalized_path)
exif.get_rel_paths_sharing_content(library.id, &normalized_path)
.unwrap_or_else(|_| vec![normalized_path.clone()])
};
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.get_insight_for_paths(&otel_context, &sibling_paths) {
match dao.get_insight_for_paths(&sibling_paths) {
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Insight not found"
@@ -667,10 +648,9 @@ pub async fn delete_insight_handler(
let normalized_path = normalize_path(&query.path);
log::info!("Deleting insight for {}", normalized_path);
let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.delete_insight(&otel_context, &normalized_path) {
match dao.delete_insight(&normalized_path) {
Ok(()) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"message": "Insight deleted successfully"
@@ -692,10 +672,9 @@ pub async fn get_all_insights_handler(
) -> impl Responder {
log::debug!("Fetching all insights");
let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.get_all_insights(&otel_context) {
match dao.get_all_insights() {
Ok(insights) => {
let responses: Vec<PhotoInsightResponse> = insights
.into_iter()
@@ -725,10 +704,9 @@ pub async fn get_insight_history_handler(
let normalized_path = normalize_path(&query.path);
log::debug!("Fetching insight history for {}", normalized_path);
let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
match dao.get_insight_history(&otel_context, &normalized_path) {
match dao.get_insight_history(&normalized_path) {
Ok(insights) => {
let responses: Vec<PhotoInsightResponse> = insights
.into_iter()
@@ -790,14 +768,13 @@ pub async fn generate_agentic_insight_handler(
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
let ctx = opentelemetry::Context::new();
let ids = dao
.get_active_job(&ctx, library.id, &normalized_path)
.get_active_job(library.id, &normalized_path)
.ok()
.flatten()
.map(|j| vec![j.id])
.unwrap_or_default();
let _ = dao.cancel_active_jobs(&ctx, library.id, &normalized_path);
let _ = dao.cancel_active_jobs(library.id, &normalized_path);
ids
};
for jid in &old_job_ids {
@@ -816,12 +793,7 @@ pub async fn generate_agentic_insight_handler(
.insight_job_dao
.lock()
.expect("Unable to lock InsightJobDao");
match dao.create_job(
&opentelemetry::Context::new(),
library.id,
&normalized_path,
gen_type,
) {
match dao.create_job(library.id, &normalized_path, gen_type) {
Ok(id) => id,
Err(e) => {
log::error!("Failed to create agentic generation job: {:?}", e);
@@ -844,7 +816,6 @@ pub async fn generate_agentic_insight_handler(
};
let fewshot_examples: Vec<Vec<ChatMessage>> = {
let otel_context = opentelemetry::Context::new();
let mut dao = app_state
.insight_chat
.insight_dao()
@@ -853,7 +824,7 @@ pub async fn generate_agentic_insight_handler(
fewshot_ids
.iter()
.filter_map(|id| {
let insight = dao.get_insight_by_id(&otel_context, *id).ok().flatten()?;
let insight = dao.get_insight_by_id(*id).ok().flatten()?;
let json = insight.training_messages?;
match serde_json::from_str::<Vec<ChatMessage>>(&json) {
Ok(msgs) => Some(msgs),
@@ -940,19 +911,16 @@ pub async fn generate_agentic_insight_handler(
})
.await;
let ctx = opentelemetry::Context::new();
let mut dao = job_dao.lock().expect("Unable to lock InsightJobDao");
match result {
Ok(Ok(Ok((Some(insight_id), _, _)))) => {
if let Err(e) = dao.complete_job(&ctx, job_id, insight_id) {
if let Err(e) = dao.complete_job(job_id, insight_id) {
log::error!("Failed to mark job {} as completed: {:?}", job_id, e);
}
}
Ok(Ok(Ok((None, _, _)))) => {
if let Err(e) =
dao.fail_job(&ctx, job_id, "agentic generation returned no insight")
{
if let Err(e) = dao.fail_job(job_id, "agentic generation returned no insight") {
log::error!("Failed to mark job {} as failed: {:?}", job_id, e);
}
}
@@ -960,7 +928,7 @@ pub async fn generate_agentic_insight_handler(
log::error!("Agentic insight generation failed for {}: {:?}", path, e);
// `{:#}` = one-line context chain; the job's error_message is
// returned to the client verbatim, so no Debug/backtrace here.
if let Err(err) = dao.fail_job(&ctx, job_id, &format!("{:#}", e)) {
if let Err(err) = dao.fail_job(job_id, &format!("{:#}", e)) {
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
@@ -971,14 +939,14 @@ pub async fn generate_agentic_insight_handler(
timeout_secs
);
if let Err(err) =
dao.fail_job(&ctx, job_id, &format!("timeout after {}s", timeout_secs))
dao.fail_job(job_id, &format!("timeout after {}s", timeout_secs))
{
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
Err(_) => {
log::error!("Agentic insight generation task panicked for {}", path);
if let Err(err) = dao.fail_job(&ctx, job_id, "generation task panicked") {
if let Err(err) = dao.fail_job(job_id, "generation task panicked") {
log::error!("Failed to mark job {} as failed: {:?}", job_id, err);
}
}
@@ -1135,14 +1103,13 @@ pub async fn rate_insight_handler(
request.approved
);
let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
// Rate a specific version by id when provided (history view), otherwise
// rate the current insight for the path.
let result = match request.insight_id {
Some(id) => dao.rate_insight_by_id(&otel_context, id, request.approved),
None => dao.rate_insight(&otel_context, &normalized_path, request.approved),
Some(id) => dao.rate_insight_by_id(id, request.approved),
None => dao.rate_insight(&normalized_path, request.approved),
};
match result {
@@ -1169,13 +1136,12 @@ pub async fn export_training_data_handler(
let approved_only = query.approved_only.unwrap_or(true);
log::info!("Exporting training data (approved_only={})", approved_only);
let otel_context = opentelemetry::Context::new();
let mut dao = insight_dao.lock().expect("Unable to lock InsightDao");
let insights = if approved_only {
dao.get_approved_insights(&otel_context)
dao.get_approved_insights()
} else {
dao.get_all_insights(&otel_context)
dao.get_all_insights()
};
match insights {
@@ -2369,8 +2335,7 @@ mod owner_defaults_tests {
)));
if let Some(p) = prefs {
let mut dao = SqliteUserAiPrefsDao::from_connection(conn);
dao.upsert_prefs(&opentelemetry::Context::new(), &p)
.expect("seed prefs");
dao.upsert_prefs(&p).expect("seed prefs");
}
state
}
+18 -41
View File
@@ -154,15 +154,14 @@ impl InsightChatService {
branch_id: Option<u64>,
) -> Result<HistoryView> {
let normalized = normalize_path(file_path);
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
let insight = match dao
.get_current_insight_for_library(&cx, library_id, &normalized)
.get_current_insight_for_library(library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
{
Some(i) => i,
None => dao
.get_insight(&cx, &normalized)
.get_insight(&normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
.ok_or_else(|| anyhow!("no insight found for path"))?,
};
@@ -238,9 +237,8 @@ impl InsightChatService {
// 2. Load the current insight + history.
let insight = {
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&cx, req.library_id, &normalized)
dao.get_current_insight_for_library(req.library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
.ok_or_else(|| anyhow!("no insight found for path"))?
};
@@ -363,7 +361,6 @@ impl InsightChatService {
// Each LLM / tool await runs inside `loop_span` so their spans
// nest under it.
let loop_span = tracing::info_span!("ai.chat.loop");
let loop_cx = loop_span.context();
let mut tool_calls_made = 0usize;
let mut iterations_used = 0usize;
let mut last_prompt_eval_count: Option<i32> = None;
@@ -414,7 +411,6 @@ impl InsightChatService {
&normalized,
req.user_id,
&active_persona,
&loop_cx,
)
.instrument(loop_span.clone())
.await;
@@ -533,18 +529,16 @@ impl InsightChatService {
};
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, new_row.clone())
d.store_insight(new_row.clone())
})
.map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?;
amended_insight_id = Some(stored.id);
} else {
let dao = self.insight_dao.clone();
let rows = retry_with_backoff("update_training_messages", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.update_training_messages(&cx, req.library_id, &normalized, &json)
d.update_training_messages(req.library_id, &normalized, &json)
})
.map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?;
if rows == 0 {
@@ -602,9 +596,8 @@ impl InsightChatService {
let _guard = entry_lock.lock().await;
let insight = {
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&cx, library_id, &normalized)
dao.get_current_insight_for_library(library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
.ok_or_else(|| anyhow!("no insight found for path"))?
};
@@ -639,9 +632,8 @@ impl InsightChatService {
let dao = self.insight_dao.clone();
let rows = retry_with_backoff("update_training_messages", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.update_training_messages(&cx, library_id, &normalized, &json)
d.update_training_messages(library_id, &normalized, &json)
})
.map_err(|e| anyhow!("failed to persist rewound history: {:?}", e))?;
if rows == 0 {
@@ -677,9 +669,8 @@ impl InsightChatService {
let _guard = entry_lock.lock().await;
let insight = {
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&cx, library_id, &normalized)
dao.get_current_insight_for_library(library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
.ok_or_else(|| anyhow!("no insight found for path"))?
};
@@ -710,9 +701,8 @@ impl InsightChatService {
let dao = self.insight_dao.clone();
let rows = retry_with_backoff("update_training_messages", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.update_training_messages(&cx, library_id, &normalized, &json)
d.update_training_messages(library_id, &normalized, &json)
})
.map_err(|e| anyhow!("failed to persist branch switch: {:?}", e))?;
if rows == 0 {
@@ -742,10 +732,9 @@ impl InsightChatService {
viewing_leaf: Option<u64>,
) -> Result<(Vec<BranchLeafInfo>, u64)> {
let normalized = normalize_path(file_path);
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
let insight = dao
.get_current_insight_for_library(&cx, library_id, &normalized)
.get_current_insight_for_library(library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
.ok_or_else(|| anyhow!("no insight found for path"))?;
@@ -930,9 +919,8 @@ impl InsightChatService {
// Look up existing insight scoped to this turn's library_id.
let existing_insight = {
let cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&cx, req.library_id, &normalized)
dao.get_current_insight_for_library(req.library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
};
@@ -1129,18 +1117,16 @@ impl InsightChatService {
};
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, new_row.clone())
d.store_insight(new_row.clone())
})
.map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?;
amended_insight_id = Some(stored.id);
} else {
let dao = self.insight_dao.clone();
let rows = retry_with_backoff("update_training_messages", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.update_training_messages(&cx, req.library_id, &normalized, &json)
d.update_training_messages(req.library_id, &normalized, &json)
})
.map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?;
if rows == 0 {
@@ -1327,9 +1313,8 @@ impl InsightChatService {
};
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, new_row.clone())
d.store_insight(new_row.clone())
})
.map_err(|e| anyhow!("failed to store bootstrap insight: {:?}", e))?;
@@ -1417,9 +1402,8 @@ impl InsightChatService {
// library bootstraps cleanly, even when another library has
// an insight for the same rel_path.
let existing_insight = {
let db_cx = opentelemetry::Context::new();
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
dao.get_current_insight_for_library(&db_cx, req.library_id, &normalized)
dao.get_current_insight_for_library(req.library_id, &normalized)
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
};
@@ -1595,18 +1579,16 @@ impl InsightChatService {
};
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, new_row.clone())
d.store_insight(new_row.clone())
})
.map_err(|e| anyhow!("failed to store amended insight: {:?}", e))?;
amended_insight_id = Some(stored.id);
} else {
let dao = self.insight_dao.clone();
let rows = retry_with_backoff("update_training_messages", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.update_training_messages(&cx, req.library_id, &normalized, &json)
d.update_training_messages(req.library_id, &normalized, &json)
})
.map_err(|e| anyhow!("failed to persist chat history: {:?}", e))?;
if rows == 0 {
@@ -1810,9 +1792,8 @@ impl InsightChatService {
};
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, new_row.clone())
d.store_insight(new_row.clone())
})
.map_err(|e| anyhow!("failed to store bootstrap insight: {:?}", e))?;
@@ -1920,7 +1901,6 @@ impl InsightChatService {
arguments: tool_call.function.arguments.clone(),
})
.await;
let cx = opentelemetry::Context::new();
let result = self
.generator
.execute_tool(
@@ -1931,7 +1911,6 @@ impl InsightChatService {
normalized,
user_id,
active_persona,
&cx,
)
.await;
let (result_preview, result_truncated) = truncate_tool_result(&result);
@@ -2421,7 +2400,6 @@ pub async fn run_streaming_agentic_loop_with_entry(
arguments: tool_call.function.arguments.clone(),
})
.await;
let cx = opentelemetry::Context::new();
let result = generator
.execute_tool(
&tool_call.function.name,
@@ -2431,7 +2409,6 @@ pub async fn run_streaming_agentic_loop_with_entry(
normalized,
user_id,
active_persona,
&cx,
)
.await;
let (result_preview, result_truncated) = truncate_tool_result(&result);
+65 -131
View File
@@ -239,7 +239,7 @@ impl InsightGenerator {
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|mut dao| dao.get_prefs().ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(stored.as_deref())
};
@@ -345,36 +345,35 @@ impl InsightGenerator {
has_vision: bool,
persona: Option<(i32, &str)>,
) -> ToolGateOpts {
let cx = opentelemetry::Context::new();
let calendar_present = {
let mut dao = self
.calendar_dao
.lock()
.expect("Unable to lock CalendarEventDao");
dao.get_event_count(&cx).map(|n| n > 0).unwrap_or(false)
dao.get_event_count().map(|n| n > 0).unwrap_or(false)
};
let location_history_present = {
let mut dao = self
.location_dao
.lock()
.expect("Unable to lock LocationHistoryDao");
dao.get_location_count(&cx).map(|n| n > 0).unwrap_or(false)
dao.get_location_count().map(|n| n > 0).unwrap_or(false)
};
let daily_summaries_present = {
let mut dao = self
.daily_summary_dao
.lock()
.expect("Unable to lock DailySummaryDao");
dao.has_any_summaries(&cx).unwrap_or(false)
dao.has_any_summaries().unwrap_or(false)
};
let faces_present = {
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
dao.has_any_faces(&cx).unwrap_or(false)
dao.has_any_faces().unwrap_or(false)
};
let allow_agent_corrections = persona
.and_then(|(uid, pid)| {
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(&cx, uid, pid).ok().flatten()
pdao.get_persona(uid, pid).ok().flatten()
})
.map(|p| p.allow_agent_corrections)
.unwrap_or(false);
@@ -396,9 +395,8 @@ impl InsightGenerator {
/// carries `persona_id` but no explicit `system_prompt` should speak
/// in the persona's stored voice, not the neutral default.
pub(crate) fn persona_system_prompt(&self, user_id: i32, persona_id: &str) -> Option<String> {
let cx = opentelemetry::Context::new();
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(&cx, user_id, persona_id)
pdao.get_persona(user_id, persona_id)
.ok()
.flatten()
.map(|p| p.system_prompt)
@@ -446,9 +444,8 @@ impl InsightGenerator {
/// that need a few specific fields (date_taken, GPS) without
/// duplicating DAO plumbing.
pub(crate) fn fetch_exif(&self, file_path: &str) -> Option<crate::database::models::ImageExif> {
let cx = opentelemetry::Context::current();
let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif(&cx, file_path).ok().flatten()
dao.get_exif(file_path).ok().flatten()
}
/// Load image file, resize it, and encode as base64 for vision models
@@ -619,7 +616,6 @@ impl InsightGenerator {
span.set_attribute("query", query.clone());
// Create context with this span for child operations
let search_cx = span.context();
log::info!("========================================");
log::info!("RAG QUERY: {}", query);
@@ -640,7 +636,7 @@ impl InsightGenerator {
let date_str = date.format("%Y-%m-%d").to_string();
let similar_summaries = summary_dao
.find_similar_summaries_with_time_weight(&search_cx, &query_embedding, &date_str, limit)
.find_similar_summaries_with_time_weight(&query_embedding, &date_str, limit)
.map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?;
log::info!(
@@ -684,7 +680,6 @@ impl InsightGenerator {
if let Some(d) = date {
span.set_attribute("date", d.to_string());
}
let search_cx = span.context();
log::info!("RAG QUERY: {} (anchor date: {:?})", query, date);
@@ -700,12 +695,11 @@ impl InsightGenerator {
let similar_summaries = match date {
Some(d) => summary_dao.find_similar_summaries_with_time_weight(
&search_cx,
&query_embedding,
&d.format("%Y-%m-%d").to_string(),
limit,
),
None => summary_dao.find_similar_summaries(&search_cx, &query_embedding, limit),
None => summary_dao.find_similar_summaries(&query_embedding, limit),
}
.map_err(|e| anyhow::anyhow!("Failed to find similar summaries: {:?}", e))?;
@@ -770,7 +764,6 @@ impl InsightGenerator {
location: Option<&str>,
) -> Result<Option<String>> {
let span = tracing::Span::current();
let calendar_cx = span.context();
let query_embedding = if let Some(loc) = location {
match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), loc).await {
@@ -790,7 +783,6 @@ impl InsightGenerator {
.lock()
.expect("Unable to lock CalendarEventDao");
dao.find_relevant_events_hybrid(
&calendar_cx,
timestamp,
7, // ±7 days window
query_embedding.as_deref(),
@@ -844,7 +836,6 @@ impl InsightGenerator {
exif_gps: Option<(f64, f64)>,
) -> Result<Option<String>> {
let span = tracing::Span::current();
let location_cx = span.context();
let nearest = {
let mut dao = self
@@ -852,9 +843,7 @@ impl InsightGenerator {
.lock()
.expect("Unable to lock LocationHistoryDao");
dao.find_nearest_location(
&location_cx,
timestamp,
10800, // ±3 hours (more realistic for photo timing)
timestamp, 10800, // ±3 hours (more realistic for photo timing)
)
.ok()
.flatten()
@@ -922,7 +911,6 @@ impl InsightGenerator {
enrichment: Option<&str>,
) -> Result<Option<String>> {
let span = tracing::Span::current();
let search_cx = span.context();
// Use enrichment (topics + photo description + tags) if available;
// fall back to generic temporal query.
@@ -961,7 +949,6 @@ impl InsightGenerator {
.lock()
.expect("Unable to lock SearchHistoryDao");
dao.find_relevant_searches_hybrid(
&search_cx,
timestamp,
30, // ±30 days (wider window than calendar)
Some(&query_embedding),
@@ -1099,13 +1086,12 @@ impl InsightGenerator {
}
// Create context with this span for child operations
let insight_cx = span.context();
// 1. Get EXIF data for the photo
let exif = {
let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
exif_dao
.get_exif(&insight_cx, &file_path)
.get_exif(&file_path)
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
@@ -1124,7 +1110,7 @@ impl InsightGenerator {
// Fetch file tags (used to enrich RAG and final context)
let tag_names: Vec<String> = {
let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao");
dao.get_tags_for_path(&insight_cx, &file_path)
dao.get_tags_for_path(&file_path)
.unwrap_or_else(|e| {
log::warn!("Failed to fetch tags for insight {}: {}", file_path, e);
Vec::new()
@@ -1562,9 +1548,8 @@ impl InsightGenerator {
let dao = self.insight_dao.clone();
let result = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, insight.clone())
d.store_insight(insight.clone())
})
.map_err(|e| anyhow::anyhow!("Failed to store insight: {:?}", e));
@@ -1770,34 +1755,33 @@ Return ONLY the summary, nothing else."#,
file_path: &str,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
let model = backend.model();
let backend_label = backend.kind.as_str();
let result = match tool_name {
"search_rag" => self.tool_search_rag(arguments, backend.local()).await,
"search_messages" => self.tool_search_messages(arguments, cx).await,
"get_sms_messages" => self.tool_get_sms_messages(arguments, cx).await,
"get_calendar_events" => self.tool_get_calendar_events(arguments, cx).await,
"get_location_history" => self.tool_get_location_history(arguments, cx).await,
"get_file_tags" => self.tool_get_file_tags(arguments, cx).await,
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments, cx).await,
"search_messages" => self.tool_search_messages(arguments).await,
"get_sms_messages" => self.tool_get_sms_messages(arguments).await,
"get_calendar_events" => self.tool_get_calendar_events(arguments).await,
"get_location_history" => self.tool_get_location_history(arguments).await,
"get_file_tags" => self.tool_get_file_tags(arguments).await,
"get_faces_in_photo" => self.tool_get_faces_in_photo(arguments).await,
"describe_photo" => {
self.tool_describe_photo(backend.local(), image_base64)
.await
}
"reverse_geocode" => self.tool_reverse_geocode(arguments).await,
"get_personal_place_at" => self.tool_get_personal_place_at(arguments).await,
"recall_entities" => self.tool_recall_entities(arguments, cx).await,
"recall_entities" => self.tool_recall_entities(arguments).await,
"recall_facts_for_photo" => {
self.tool_recall_facts_for_photo(arguments, user_id, persona_id, cx)
self.tool_recall_facts_for_photo(arguments, user_id, persona_id)
.await
}
"recall_facts_for_entity" => {
self.tool_recall_facts_for_entity(arguments, user_id, persona_id, cx)
self.tool_recall_facts_for_entity(arguments, user_id, persona_id)
.await
}
"store_entity" => self.tool_store_entity(arguments, cx).await,
"store_entity" => self.tool_store_entity(arguments).await,
"store_fact" => {
self.tool_store_fact(
arguments,
@@ -1806,16 +1790,15 @@ Return ONLY the summary, nothing else."#,
persona_id,
model,
backend_label,
cx,
)
.await
}
"update_fact" => {
self.tool_update_fact(arguments, user_id, persona_id, model, backend_label, cx)
self.tool_update_fact(arguments, user_id, persona_id, model, backend_label)
.await
}
"supersede_fact" => {
self.tool_supersede_fact(arguments, user_id, persona_id, model, backend_label, cx)
self.tool_supersede_fact(arguments, user_id, persona_id, model, backend_label)
.await
}
"get_current_datetime" => Self::tool_get_current_datetime(),
@@ -2039,11 +2022,7 @@ Return ONLY the summary, nothing else."#,
/// Tool: search_messages — keyword / semantic / hybrid search over all
/// SMS message bodies via the Django FTS5 + embeddings pipeline. Now
/// supports optional `contact_id`, `start_ts`, `end_ts` filters.
async fn tool_search_messages(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_search_messages(&self, args: &serde_json::Value) -> String {
let query = match args.get("query").and_then(|v| v.as_str()) {
Some(q) if !q.trim().is_empty() => q.trim(),
_ => {
@@ -2068,7 +2047,7 @@ Return ONLY the summary, nothing else."#,
log::info!(
"search_messages with `date` and no `query` — routing to get_sms_messages"
);
let routed = self.tool_get_sms_messages(args, cx).await;
let routed = self.tool_get_sms_messages(args).await;
return format!(
"(Note: routed to get_sms_messages — search_messages requires a \
`query`; date-only browsing belongs on get_sms_messages. Prefer \
@@ -2207,11 +2186,7 @@ Return ONLY the summary, nothing else."#,
}
/// Tool: get_sms_messages — fetch SMS messages near a date for a contact
async fn tool_get_sms_messages(
&self,
args: &serde_json::Value,
_cx: &opentelemetry::Context,
) -> String {
async fn tool_get_sms_messages(&self, args: &serde_json::Value) -> String {
let date_str = match args.get("date").and_then(|v| v.as_str()) {
Some(d) => d,
None => return "Error: missing required parameter 'date'".to_string(),
@@ -2289,11 +2264,7 @@ Return ONLY the summary, nothing else."#,
}
/// Tool: get_calendar_events — fetch calendar events near a date
async fn tool_get_calendar_events(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_get_calendar_events(&self, args: &serde_json::Value) -> String {
let date_str = match args.get("date").and_then(|v| v.as_str()) {
Some(d) => d,
None => return "Error: missing required parameter 'date'".to_string(),
@@ -2327,7 +2298,7 @@ Return ONLY the summary, nothing else."#,
.calendar_dao
.lock()
.expect("Unable to lock CalendarEventDao");
dao.find_relevant_events_hybrid(cx, timestamp, days_radius, None, limit)
dao.find_relevant_events_hybrid(timestamp, days_radius, None, limit)
.ok()
};
@@ -2369,11 +2340,7 @@ Return ONLY the summary, nothing else."#,
}
/// Tool: get_location_history — fetch location records near a date
async fn tool_get_location_history(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_get_location_history(&self, args: &serde_json::Value) -> String {
let date_str = match args.get("date").and_then(|v| v.as_str()) {
Some(d) => d,
None => return "Error: missing required parameter 'date'".to_string(),
@@ -2404,7 +2371,7 @@ Return ONLY the summary, nothing else."#,
.location_dao
.lock()
.expect("Unable to lock LocationHistoryDao");
dao.find_locations_in_range(cx, start_ts, end_ts).ok()
dao.find_locations_in_range(start_ts, end_ts).ok()
};
match locations {
@@ -2462,11 +2429,7 @@ Return ONLY the summary, nothing else."#,
}
/// Tool: get_file_tags — fetch tags for a file path
async fn tool_get_file_tags(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_get_file_tags(&self, args: &serde_json::Value) -> String {
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p.to_string(),
None => return "Error: missing required parameter 'file_path'".to_string(),
@@ -2476,7 +2439,7 @@ Return ONLY the summary, nothing else."#,
let tags = {
let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao");
dao.get_tags_for_path(cx, &file_path).ok()
dao.get_tags_for_path(&file_path).ok()
};
match tags {
@@ -2493,11 +2456,7 @@ Return ONLY the summary, nothing else."#,
/// the given file path. Resolves rel_path → content_hash via FaceDao,
/// then queries face_detections joined with persons (status='detected'
/// only). Returns a compact bullet list keyed for human-LLM readability.
async fn tool_get_faces_in_photo(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_get_faces_in_photo(&self, args: &serde_json::Value) -> String {
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
Some(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => return "Error: missing required parameter 'file_path'".to_string(),
@@ -2509,11 +2468,9 @@ Return ONLY the summary, nothing else."#,
// is synchronous and there's no await in the loop body.
let content_hash: Option<String> = {
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
self.libraries.iter().find_map(|lib| {
dao.resolve_content_hash(cx, lib.id, &file_path)
.ok()
.flatten()
})
self.libraries
.iter()
.find_map(|lib| dao.resolve_content_hash(lib.id, &file_path).ok().flatten())
};
let Some(content_hash) = content_hash else {
return "No content_hash found for that file path (the photo may not be indexed yet, \
@@ -2523,7 +2480,7 @@ Return ONLY the summary, nothing else."#,
let faces = {
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
match dao.list_for_content_hash(cx, &content_hash) {
match dao.list_for_content_hash(&content_hash) {
Ok(rows) => rows,
Err(e) => return format!("Error querying faces: {}", e),
}
@@ -2642,11 +2599,7 @@ Return ONLY the summary, nothing else."#,
}
/// Tool: recall_entities — search the knowledge memory for known entities
async fn tool_recall_entities(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_recall_entities(&self, args: &serde_json::Value) -> String {
use crate::database::EntityFilter;
let name_search = args
@@ -2688,7 +2641,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
match kdao.list_entities(cx, filter) {
match kdao.list_entities(filter) {
Ok((entities, _total)) if entities.iter().all(|e| e.status == "rejected") => {
"No known entities found matching the query.".to_string()
}
@@ -2719,7 +2672,6 @@ Return ONLY the summary, nothing else."#,
args: &serde_json::Value,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
use crate::database::PersonaFilter;
let persona_filter = PersonaFilter::Single {
@@ -2739,7 +2691,7 @@ Return ONLY the summary, nothing else."#,
// default of active+reviewed.
let reviewed_only = {
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(cx, user_id, persona_id)
pdao.get_persona(user_id, persona_id)
.ok()
.flatten()
.map(|p| p.reviewed_only_facts)
@@ -2752,7 +2704,7 @@ Return ONLY the summary, nothing else."#,
.expect("Unable to lock KnowledgeDao");
// Fetch photo links to find which entities appear in this photo
let links = match kdao.get_links_for_photo(cx, &file_path) {
let links = match kdao.get_links_for_photo(&file_path) {
Ok(l) => l,
Err(e) => return format!("Error fetching photo links: {:?}", e),
};
@@ -2766,7 +2718,7 @@ Return ONLY the summary, nothing else."#,
// For each linked entity, fetch its facts
for entity_id in entity_ids {
if let Ok(entity) = kdao.get_entity_by_id(cx, entity_id)
if let Ok(entity) = kdao.get_entity_by_id(entity_id)
&& let Some(e) = entity
{
let role = links
@@ -2778,7 +2730,7 @@ Return ONLY the summary, nothing else."#,
"Entity: {} ({}, role: {})",
e.name, e.entity_type, role
));
if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id, &persona_filter) {
if let Ok(facts) = kdao.get_facts_for_entity(entity_id, &persona_filter) {
// Default scope: active + reviewed (everything not
// rejected / superseded). Strict mode trims to
// reviewed only — the persona has opted into seeing
@@ -2794,7 +2746,7 @@ Return ONLY the summary, nothing else."#,
let obj = if let Some(ref v) = f.object_value {
v.clone()
} else if let Some(oid) = f.object_entity_id {
kdao.get_entity_by_id(cx, oid)
kdao.get_entity_by_id(oid)
.ok()
.flatten()
.map(|e| format!("{} (entity ID: {})", e.name, e.id))
@@ -2825,7 +2777,6 @@ Return ONLY the summary, nothing else."#,
args: &serde_json::Value,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
use crate::database::PersonaFilter;
let persona_filter = PersonaFilter::Single {
@@ -2853,7 +2804,7 @@ Return ONLY the summary, nothing else."#,
// row → permissive active+reviewed default).
let reviewed_only = {
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(cx, user_id, persona_id)
pdao.get_persona(user_id, persona_id)
.ok()
.flatten()
.map(|p| p.reviewed_only_facts)
@@ -2865,14 +2816,14 @@ Return ONLY the summary, nothing else."#,
.lock()
.expect("Unable to lock KnowledgeDao");
let entity = match kdao.get_entity_by_id(cx, entity_id) {
let entity = match kdao.get_entity_by_id(entity_id) {
Ok(Some(e)) => e,
Ok(None) => return format!("No entity found with ID {}.", entity_id),
Err(e) => return format!("Error fetching entity: {:?}", e),
};
let mut output_lines = vec![format!("Entity: {} ({})", entity.name, entity.entity_type)];
match kdao.get_facts_for_entity(cx, entity_id, &persona_filter) {
match kdao.get_facts_for_entity(entity_id, &persona_filter) {
Ok(facts) => {
// Default scope: active + reviewed. Strict mode trims to
// reviewed only — same allow rule as recall_facts_for_photo.
@@ -2887,7 +2838,7 @@ Return ONLY the summary, nothing else."#,
let obj = if let Some(ref v) = f.object_value {
v.clone()
} else if let Some(oid) = f.object_entity_id {
kdao.get_entity_by_id(cx, oid)
kdao.get_entity_by_id(oid)
.ok()
.flatten()
.map(|e| format!("{} (entity ID: {})", e.name, e.id))
@@ -2914,11 +2865,7 @@ Return ONLY the summary, nothing else."#,
/// Tool: store_entity — upsert an entity into the knowledge memory.
/// Embeddings go through the configured local backend (`LLM_BACKEND`),
/// independent of the per-request chat backend in the caller.
async fn tool_store_entity(
&self,
args: &serde_json::Value,
cx: &opentelemetry::Context,
) -> String {
async fn tool_store_entity(&self, args: &serde_json::Value) -> String {
use crate::database::models::InsertEntity;
let name = match args.get("name").and_then(|v| v.as_str()) {
@@ -2964,7 +2911,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.list_entities(cx, filter)
kdao.list_entities(filter)
.unwrap_or_default()
.0
.into_iter()
@@ -3025,7 +2972,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.upsert_entity(cx, insert.clone())
kdao.upsert_entity(insert.clone())
}) {
Ok(entity) => {
let mut response = format!(
@@ -3056,7 +3003,6 @@ Return ONLY the summary, nothing else."#,
persona_id: &str,
model: &str,
backend: &str,
cx: &opentelemetry::Context,
) -> String {
use crate::database::models::{InsertEntityFact, InsertEntityPhotoLink};
@@ -3140,7 +3086,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.upsert_fact(cx, fact.clone())
kdao.upsert_fact(fact.clone())
}) {
Ok((f, is_new)) => (f, is_new),
Err(e) => return format!("Error storing fact: {:?}", e),
@@ -3164,7 +3110,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.upsert_photo_link(cx, link.clone())
kdao.upsert_photo_link(link.clone())
},
) {
log::warn!(
@@ -3197,14 +3143,13 @@ Return ONLY the summary, nothing else."#,
persona_id: &str,
model: &str,
backend: &str,
cx: &opentelemetry::Context,
) -> String {
use crate::database::FactPatch;
// Defense-in-depth gate check.
let allowed = {
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(cx, user_id, persona_id)
pdao.get_persona(user_id, persona_id)
.ok()
.flatten()
.map(|p| p.allow_agent_corrections)
@@ -3257,7 +3202,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.update_fact(cx, fact_id, patch.clone(), Some((model, backend)))
kdao.update_fact(fact_id, patch.clone(), Some((model, backend)))
}) {
Ok(Some(f)) => format!(
"Updated fact ID:{} (status={}, confidence={:.2})",
@@ -3277,11 +3222,10 @@ Return ONLY the summary, nothing else."#,
persona_id: &str,
model: &str,
backend: &str,
cx: &opentelemetry::Context,
) -> String {
let allowed = {
let mut pdao = self.persona_dao.lock().expect("Unable to lock PersonaDao");
pdao.get_persona(cx, user_id, persona_id)
pdao.get_persona(user_id, persona_id)
.ok()
.flatten()
.map(|p| p.allow_agent_corrections)
@@ -3318,7 +3262,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
kdao.supersede_fact(cx, old_fact_id, new_fact_id, Some((model, backend)))
kdao.supersede_fact(old_fact_id, new_fact_id, Some((model, backend)))
},
) {
Ok(Some(f)) => format!(
@@ -4140,13 +4084,11 @@ Return ONLY the summary, nothing else."#,
span.set_attribute("model", backend.model().to_string());
span.set_attribute("images_inline", backend.images_inline);
let insight_cx = span.context();
// 3. Fetch EXIF
let exif = {
let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
exif_dao
.get_exif(&insight_cx, &file_path)
.get_exif(&file_path)
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
@@ -4160,7 +4102,7 @@ Return ONLY the summary, nothing else."#,
// 5. Fetch tags
let tag_names: Vec<String> = {
let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao");
dao.get_tags_for_path(&insight_cx, &file_path)
dao.get_tags_for_path(&file_path)
.unwrap_or_else(|e| {
log::warn!("Failed to fetch tags for agentic {}: {}", file_path, e);
Vec::new()
@@ -4196,7 +4138,7 @@ Return ONLY the summary, nothing else."#,
created_at: Utc::now().timestamp(),
updated_at: Utc::now().timestamp(),
};
match kdao.upsert_entity(&insight_cx, owner) {
match kdao.upsert_entity(owner) {
Ok(e) => {
log::info!("Owner entity '{}' ID: {}", owner_name, e.id);
Some(e.id)
@@ -4334,7 +4276,6 @@ Return ONLY the summary, nothing else."#,
// LLM / tool awaits below run inside `loop_span` so their spans
// nest under it.
let loop_span = tracing::info_span!("ai.agentic.loop");
let loop_cx = loop_span.context();
let mut final_content = String::new();
let mut iterations_used = 0usize;
@@ -4392,7 +4333,6 @@ Return ONLY the summary, nothing else."#,
&file_path,
user_id,
&persona_id,
&loop_cx,
)
.instrument(loop_span.clone())
.await;
@@ -4445,7 +4385,6 @@ Return ONLY the summary, nothing else."#,
&file_path,
user_id,
&persona_id,
&loop_cx,
)
.instrument(loop_span.clone())
.await;
@@ -4526,9 +4465,8 @@ Return ONLY the summary, nothing else."#,
let dao = self.insight_dao.clone();
let stored = retry_with_backoff("store_insight", 3, || {
let cx = opentelemetry::Context::new();
let mut d = dao.lock().expect("Unable to lock InsightDao");
d.store_insight(&cx, insight.clone())
d.store_insight(insight.clone())
})
.map_err(|e| anyhow::anyhow!("Failed to store agentic insight: {:?}", e));
@@ -4551,8 +4489,7 @@ Return ONLY the summary, nothing else."#,
.knowledge_dao
.lock()
.expect("Unable to lock KnowledgeDao");
if let Err(e) = kdao.update_facts_insight_id(&insight_cx, &file_path, stored_insight.id)
{
if let Err(e) = kdao.update_facts_insight_id(&file_path, stored_insight.id) {
log::warn!(
"Failed to backfill source_insight_id for {}: {:?}",
file_path,
@@ -4591,7 +4528,6 @@ Return ONLY the summary, nothing else."#,
tools: Vec<Tool>,
max_iter: usize,
) -> Result<String> {
let loop_cx = opentelemetry::Context::current();
let mut final_content = String::new();
for iteration in 0..max_iter {
@@ -4641,7 +4577,6 @@ Return ONLY the summary, nothing else."#,
"",
0,
"",
&loop_cx,
)
.await;
messages.push(ChatMessage::tool_result(result));
@@ -4683,7 +4618,6 @@ Return ONLY the summary, nothing else."#,
"",
0,
"",
&loop_cx,
)
.await;
messages.push(ChatMessage::tool_result(result));
+12 -22
View File
@@ -529,26 +529,24 @@ impl PersonaChatSession {
if persona_id.is_empty() {
return Err(PersonaChatError::UnknownPersona(String::new()));
}
let cx = opentelemetry::Context::current();
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
// Validate up front rather than letting the first turn 404: a
// conversation row pointing at a persona that doesn't exist would be
// a permanently dead entry in the list.
let persona = dao
.get_persona(&cx, user_id, persona_id)
.get_persona(user_id, persona_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?;
if persona.is_none() {
return Err(PersonaChatError::UnknownPersona(persona_id.to_string()));
}
dao.create_persona_chat(&cx, user_id, persona_id, Utc::now().timestamp_millis())
dao.create_persona_chat(user_id, persona_id, Utc::now().timestamp_millis())
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))
}
/// Delete a conversation and its transcript outright.
pub fn delete_conversation(&self, user_id: i32, conversation_id: &str) -> Result<()> {
let cx = opentelemetry::Context::current();
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
dao.delete_persona_chat(&cx, user_id, conversation_id)
dao.delete_persona_chat(user_id, conversation_id)
.map_err(|e| anyhow!("failed to delete persona chat: {e:?}"))?;
Ok(())
}
@@ -565,10 +563,9 @@ impl PersonaChatSession {
conversation_id: &str,
branch_id: Option<u64>,
) -> Result<PersonaChatHistoryView> {
let cx = opentelemetry::Context::current();
let row = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
dao.get_persona_chat(&cx, user_id, conversation_id)
dao.get_persona_chat(user_id, conversation_id)
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
};
let Some(row) = row else {
@@ -628,18 +625,17 @@ impl PersonaChatSession {
return Err(PersonaChatError::UnknownConversation(conversation_id));
}
let cx = opentelemetry::Context::current();
// The persona comes from the stored conversation, not the request:
// a transcript's voice is fixed when it is created, so a stale
// client can't quietly swap it half-way through a thread.
let (persona, persona_id) = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let row = dao
.get_persona_chat(&cx, user_id, &conversation_id)
.get_persona_chat(user_id, &conversation_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
.ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?;
let persona = dao
.get_persona(&cx, user_id, &row.persona_id)
.get_persona(user_id, &row.persona_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?;
(persona, row.persona_id)
@@ -742,10 +738,9 @@ impl PersonaChatSession {
let _guard = lock.lock().await;
// Replay the active branch of the conversation tree.
let cx = opentelemetry::Context::current();
let row = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
dao.get_persona_chat(&cx, user_id, &conversation_id)
dao.get_persona_chat(user_id, &conversation_id)
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
};
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
@@ -935,7 +930,6 @@ impl PersonaChatSession {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let rows = dao
.update_persona_chat(
&cx,
user_id,
&conversation_id,
&json,
@@ -958,7 +952,7 @@ impl PersonaChatSession {
.unwrap_or_else(|| fallback_title(&messages));
if !title.is_empty() {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
if let Err(e) = dao.set_persona_chat_title(&cx, user_id, &conversation_id, &title) {
if let Err(e) = dao.set_persona_chat_title(user_id, &conversation_id, &title) {
log::warn!("failed to store persona chat title: {e:?}");
}
}
@@ -989,14 +983,13 @@ impl PersonaChatSession {
/// Conversations that hold nothing but the seed are omitted too — the
/// user has never actually said anything in them.
pub fn list_conversations(&self, user_id: i32) -> Result<Vec<PersonaConversationSummary>> {
let cx = opentelemetry::Context::current();
let (rows, personas) = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let rows = dao
.list_persona_chats(&cx, user_id)
.list_persona_chats(user_id)
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
let personas = dao
.list_personas(&cx, user_id)
.list_personas(user_id)
.map_err(|e| anyhow!("failed to list personas: {e:?}"))?;
(rows, personas)
};
@@ -1099,10 +1092,9 @@ impl PersonaChatSession {
};
let _guard = lock.lock().await;
let cx = opentelemetry::Context::current();
let row = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
dao.get_persona_chat(&cx, user_id, conversation_id)
dao.get_persona_chat(user_id, conversation_id)
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
};
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
@@ -1121,7 +1113,6 @@ impl PersonaChatSession {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let rows = dao
.update_persona_chat(
&cx,
user_id,
conversation_id,
&json,
@@ -1195,10 +1186,9 @@ impl PersonaChatSession {
node_id: Option<u64>,
viewing_leaf: Option<u64>,
) -> Result<(Vec<crate::ai::llm_client::BranchLeafInfo>, u64)> {
let cx = opentelemetry::Context::current();
let row = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
dao.get_persona_chat(&cx, user_id, conversation_id)
dao.get_persona_chat(user_id, conversation_id)
.map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?
};
let row = row.ok_or_else(|| anyhow!("conversation not found"))?;
+108 -169
View File
@@ -43,7 +43,6 @@ use crate::tags;
///
/// Returns the number of rows successfully backfilled this pass.
pub fn backfill_unhashed_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
@@ -61,8 +60,7 @@ pub fn backfill_unhashed_backlog(
// library's tick. Negligible cost given the cap.
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_missing_hash(context, cap + 1)
.unwrap_or_default()
dao.get_rows_missing_hash(cap + 1).unwrap_or_default()
};
if rows.is_empty() {
return 0;
@@ -88,13 +86,9 @@ pub fn backfill_unhashed_backlog(
match content_hash::compute(&abs) {
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.backfill_content_hash(
context,
library.id,
rel_path,
&id.content_hash,
id.size_bytes,
) {
if let Err(e) =
dao.backfill_content_hash(library.id, rel_path, &id.content_hash, id.size_bytes)
{
warn!(
"face_watch: backfill_content_hash failed for {}: {:?}",
rel_path, e
@@ -148,7 +142,6 @@ fn date_backfill_cap() -> i64 {
/// exiftool is the meaningful new attempt — it handles videos and
/// MakerNote-hosted dates kamadak can't reach.
pub fn backfill_missing_date_taken(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
) -> usize {
@@ -156,7 +149,7 @@ pub fn backfill_missing_date_taken(
let rows: Vec<(i32, String)> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_needing_date_backfill(context, library.id, cap + 1)
dao.get_rows_needing_date_backfill(library.id, cap + 1)
.unwrap_or_default()
};
if rows.is_empty() {
@@ -198,7 +191,6 @@ pub fn backfill_missing_date_taken(
continue;
};
match dao.backfill_date_taken(
context,
library.id,
rel_path,
rd.timestamp,
@@ -300,7 +292,6 @@ pub struct UtcBackfillOutcome {
/// regenerates them. Daily conversation summaries are SMS-derived and
/// don't depend on photo dates, so they're not touched.
pub fn backfill_utc_dates(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
@@ -308,13 +299,10 @@ pub fn backfill_utc_dates(
cursor: &mut i32,
) -> UtcBackfillOutcome {
let cap = date_backfill_cap();
backfill_utc_dates_capped(
context, library, exif_dao, reel_dao, display_tz, cursor, cap,
)
backfill_utc_dates_capped(library, exif_dao, reel_dao, display_tz, cursor, cap)
}
fn backfill_utc_dates_capped(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
@@ -350,7 +338,7 @@ fn backfill_utc_dates_capped(
'paging: loop {
let page = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rows_needing_utc_backfill(context, library.id, *cursor, cap)
dao.get_rows_needing_utc_backfill(library.id, *cursor, cap)
.unwrap_or_default()
};
let page_len = page.len() as i64;
@@ -444,7 +432,7 @@ fn backfill_utc_dates_capped(
tz_source,
} in &conversions
{
match dao.backfill_date_utc(context, row.id, *utc, *offset, tz_source.as_str()) {
match dao.backfill_date_utc(row.id, *utc, *offset, tz_source.as_str()) {
Ok(true) => {
outcome.converted += 1;
let before = display_day(zone, row.date_taken);
@@ -468,8 +456,7 @@ fn backfill_utc_dates_capped(
}
if !affected_days.is_empty() {
outcome.reels_invalidated =
invalidate_day_reels(context, library, reel_dao, zone, &affected_days);
outcome.reels_invalidated = invalidate_day_reels(library, reel_dao, zone, &affected_days);
}
if outcome.converted > 0 || outcome.unresolved > 0 || more_remain {
@@ -510,7 +497,6 @@ fn reel_covered_day(reel: &PrecomputedReel, zone: chrono_tz::Tz) -> Option<Naive
/// With the ledger row gone the nightly pre-gen's freshness gate fails
/// and it renders a new one on its normal schedule. Returns rows deleted.
fn invalidate_day_reels(
context: &opentelemetry::Context,
library: &libraries::Library,
reel_dao: &Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
zone: chrono_tz::Tz,
@@ -518,7 +504,7 @@ fn invalidate_day_reels(
) -> usize {
let keys = vec!["all".to_string(), library.id.to_string()];
let mut dao = reel_dao.lock().expect("Unable to lock PrecomputedReelDao");
let reels = match dao.list_for_span(context, "day", &keys) {
let reels = match dao.list_for_span("day", &keys) {
Ok(r) => r,
Err(e) => {
warn!("utc_backfill: listing day reels failed: {:?}", e);
@@ -535,7 +521,7 @@ fn invalidate_day_reels(
return 0;
}
let ids: Vec<i32> = stale.iter().map(|r| r.id).collect();
match dao.delete_ids(context, &ids) {
match dao.delete_ids(&ids) {
Ok(n) => {
// Unlink too, so the on-demand cache can't serve the stale
// render under the same key.
@@ -563,14 +549,13 @@ fn invalidate_day_reels(
/// doesn't call this for them), so a flapping share doesn't make the
/// gauge dip.
pub fn refresh_utc_backfill_progress(
context: &opentelemetry::Context,
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
remaining: &mut HashMap<i32, i64>,
) {
let count = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.count_rows_needing_utc_backfill(context, library.id)
dao.count_rows_needing_utc_backfill(library.id)
};
if let Ok(n) = count {
remaining.insert(library.id, n);
@@ -594,7 +579,6 @@ pub fn refresh_utc_backfill_progress(
/// retry every tick at this point; future Branch may add a status
/// column like face_detections has.
pub fn process_clip_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
clip_client: &crate::ai::clip_client::ClipClient,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
@@ -611,7 +595,7 @@ pub fn process_clip_backlog(
let rows: Vec<(String, String)> = {
let mut dao = exif_dao.lock().expect("exif dao");
match dao.list_clip_unencoded_candidates(context, library.id, cap) {
match dao.list_clip_unencoded_candidates(library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
@@ -653,7 +637,6 @@ pub fn process_clip_backlog(
}
pub fn process_face_backlog(
context: &opentelemetry::Context,
library: &libraries::Library,
face_client: &crate::ai::face_client::FaceClient,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
@@ -668,7 +651,7 @@ pub fn process_face_backlog(
let rows: Vec<(String, String)> = {
let mut dao = face_dao.lock().expect("face dao");
match dao.list_unscanned_candidates(context, library.id, cap) {
match dao.list_unscanned_candidates(library.id, cap) {
Ok(r) => r,
Err(e) => {
warn!(
@@ -719,7 +702,6 @@ pub fn process_face_backlog(
/// table (vanished mid-scan, permission denied, etc.) burned the budget
/// every tick and the rest of the backlog never advanced.
pub fn backfill_missing_content_hashes(
context: &opentelemetry::Context,
files: &[(PathBuf, String)],
library: &libraries::Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
@@ -735,7 +717,7 @@ pub fn backfill_missing_content_hashes(
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(context, Some(library.id), &image_paths)
dao.get_exif_batch(Some(library.id), &image_paths)
.unwrap_or_default()
};
// Cheap lookup back from rel_path → absolute file_path so
@@ -775,7 +757,6 @@ pub fn backfill_missing_content_hashes(
Ok(id) => {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.backfill_content_hash(
context,
library.id,
&record.file_path,
&id.content_hash,
@@ -820,7 +801,6 @@ pub fn backfill_missing_content_hashes(
/// just inserted alongside any pre-existing rows the watcher walked
/// over — covers both new uploads and the initial backlog scan.
pub fn build_face_candidates(
context: &opentelemetry::Context,
library: &libraries::Library,
files: &[(PathBuf, String)],
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
@@ -839,7 +819,7 @@ pub fn build_face_candidates(
let exif_records = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_exif_batch(context, Some(library.id), &image_paths)
dao.get_exif_batch(Some(library.id), &image_paths)
.unwrap_or_default()
};
// rel_path → content_hash (only rows with a hash; without one we have
@@ -857,7 +837,7 @@ pub fn build_face_candidates(
let Some(hash) = hash_by_path.get(&rel_path) else {
continue;
};
match dao.already_scanned(context, hash) {
match dao.already_scanned(hash) {
Ok(true) => continue,
Ok(false) => candidates.push(face_watch::FaceCandidate {
rel_path,
@@ -887,10 +867,6 @@ mod tests {
use crate::faces::{FaceDao, SqliteFaceDao};
use crate::libraries::Library;
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
/// Everything `setup` hands back to a test: tempdir, library, shared
/// connection, and the two DAOs. Aliased to keep clippy's
/// type-complexity lint satisfied.
@@ -952,37 +928,34 @@ mod tests {
content_hash: Option<&str>,
) {
let mut dao = exif_dao.lock().unwrap();
dao.store_exif(
&ctx(),
InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: None,
created_time: 0,
last_modified: 0,
content_hash: content_hash.map(|s| s.to_string()),
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
dao.store_exif(InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: None,
created_time: 0,
last_modified: 0,
content_hash: content_hash.map(|s| s.to_string()),
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
})
.expect("insert");
}
@@ -1002,12 +975,12 @@ mod tests {
insert_exif(&exif_dao, 1, "a.jpg", None);
insert_exif(&exif_dao, 1, "b.jpg", None);
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 2);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(&ctx(), Some(1), &["a.jpg".to_string(), "b.jpg".to_string()])
.get_exif_batch(Some(1), &["a.jpg".to_string(), "b.jpg".to_string()])
.unwrap();
assert_eq!(rows.len(), 2);
for r in rows {
@@ -1029,12 +1002,12 @@ mod tests {
// Row in the other library — must be skipped (different lib_id).
insert_exif(&exif_dao, 2, "other.jpg", None);
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 1, "only the existing in-library file hashes");
let mut dao = exif_dao.lock().unwrap();
let other = dao
.get_exif_batch(&ctx(), Some(2), &["other.jpg".to_string()])
.get_exif_batch(Some(2), &["other.jpg".to_string()])
.unwrap();
assert_eq!(other.len(), 1);
assert!(
@@ -1042,7 +1015,7 @@ mod tests {
"other-library row must remain unhashed"
);
let ghost = dao
.get_exif_batch(&ctx(), Some(1), &["ghost.jpg".to_string()])
.get_exif_batch(Some(1), &["ghost.jpg".to_string()])
.unwrap();
assert_eq!(ghost.len(), 1);
assert!(
@@ -1068,7 +1041,7 @@ mod tests {
insert_exif(&exif_dao, 1, &rel, None);
}
let backfilled = backfill_unhashed_backlog(&ctx(), &library, &exif_dao);
let backfilled = backfill_unhashed_backlog(&library, &exif_dao);
assert_eq!(backfilled, 2, "cap=2 must bound the per-tick successes");
unsafe {
std::env::remove_var("FACE_HASH_BACKFILL_MAX_PER_TICK");
@@ -1091,12 +1064,11 @@ mod tests {
(tmp.path().join("already.jpg"), "already.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
backfill_missing_content_hashes(&ctx(), &files, &library, &exif_dao);
backfill_missing_content_hashes(&files, &library, &exif_dao);
let mut dao = exif_dao.lock().unwrap();
let rows = dao
.get_exif_batch(
&ctx(),
Some(1),
&[
"fresh.jpg".to_string(),
@@ -1138,7 +1110,7 @@ mod tests {
// Mark `scanned.jpg`'s hash as already detected.
{
let mut dao = face_dao.lock().unwrap();
dao.mark_status(&ctx(), 1, "hash-scanned", "scanned.jpg", "no_faces", "test")
dao.mark_status(1, "hash-scanned", "scanned.jpg", "no_faces", "test")
.expect("mark scanned");
}
@@ -1148,7 +1120,7 @@ mod tests {
(tmp.path().join("unhashed.jpg"), "unhashed.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
];
let candidates = build_face_candidates(&ctx(), &library, &files, &exif_dao, &face_dao);
let candidates = build_face_candidates(&library, &files, &exif_dao, &face_dao);
assert_eq!(
candidates.len(),
@@ -1194,37 +1166,34 @@ mod tests {
source: Option<&str>,
) -> i32 {
let mut dao = exif_dao.lock().unwrap();
dao.store_exif(
&ctx(),
InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: source.map(str::to_string),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
dao.store_exif(InsertImageExif {
library_id: lib_id,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: source.map(str::to_string),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
})
.expect("insert")
.id
}
@@ -1241,7 +1210,7 @@ mod tests {
exif_dao
.lock()
.unwrap()
.count_rows_needing_utc_backfill(&ctx(), lib_id)
.count_rows_needing_utc_backfill(lib_id)
.unwrap()
}
@@ -1385,8 +1354,7 @@ mod tests {
);
assert_eq!(remaining(&exif_dao, 1), 5);
let out =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 5, "{out:?}");
assert_eq!(out.unresolved, 0, "{out:?}");
@@ -1446,12 +1414,11 @@ mod tests {
.collect::<Vec<_>>()
};
let before = snapshot(&conn);
let again =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let again = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(again, UtcBackfillOutcome::default());
assert_eq!(snapshot(&conn), before);
let mut progress = HashMap::new();
refresh_utc_backfill_progress(&ctx(), &library, &exif_dao, &mut progress);
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
assert_eq!(progress.get(&1), Some(&0));
}
@@ -1467,8 +1434,7 @@ mod tests {
let undated = insert_legacy(&exif_dao, 1, "undated.jpg", None, None);
let other = insert_legacy(&exif_dao, 2, "1500000000.jpg", Some(ts), Some("filename"));
let out =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 1);
assert_eq!(row(&conn, converted).date_utc, Some(ts));
assert_eq!(row(&conn, undated).date_utc, None);
@@ -1508,16 +1474,8 @@ mod tests {
// Cursor carried across ticks, as the watcher does.
let mut cursor = 0;
for _ in 0..4 {
let out = backfill_utc_dates_capped(
&ctx(),
&library,
&exif_dao,
&reel_dao,
NY,
&mut cursor,
2,
);
refresh_utc_backfill_progress(&ctx(), &library, &exif_dao, &mut progress);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut cursor, 2);
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
seen.push((out.converted, progress[&1]));
}
// Cap holds (2 per tick) and the 3 missing rows never spend it.
@@ -1532,7 +1490,7 @@ mod tests {
.execute(&mut *c)
.unwrap();
}
refresh_utc_backfill_progress(&ctx(), &library, &exif_dao, &mut progress);
refresh_utc_backfill_progress(&library, &exif_dao, &mut progress);
assert_eq!(progress[&1], 0);
}
@@ -1566,15 +1524,7 @@ mod tests {
let mut cursor = 0;
let mut converted = 0;
for _ in 0..4 {
let out = backfill_utc_dates_capped(
&ctx(),
&library,
&exif_dao,
&reel_dao,
NY,
&mut cursor,
2,
);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut cursor, 2);
converted += out.converted;
}
assert_eq!(converted, 2, "the rows behind the stuck ones still drain");
@@ -1614,16 +1564,14 @@ mod tests {
let health_map = libraries::new_health_map(std::slice::from_ref(&unmounted));
assert!(!libraries::refresh_health(&health_map, &unmounted, true).is_online());
// …and the drain itself converts nothing even if called.
let out =
backfill_utc_dates_capped(&ctx(), &unmounted, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&unmounted, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out, UtcBackfillOutcome::default());
assert_eq!(row(&conn, filed).date_utc, None);
assert_eq!(row(&conn, manual).date_utc, None);
// Share returns → picked up next tick.
assert!(libraries::refresh_health(&health_map, &library, true).is_online());
let out =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 2);
assert_eq!(remaining(&exif_dao, 1), 0);
}
@@ -1643,21 +1591,18 @@ mod tests {
let id = reel_dao
.lock()
.unwrap()
.record_reel(
&ctx(),
&InsertablePrecomputedReel {
span: span.to_string(),
library_key: library_key.to_string(),
cache_key: format!("{span}-{library_key}-{month}-{day}"),
output_path: mp4.to_string_lossy().to_string(),
title: "t".to_string(),
media_count: 1,
render_version: 1,
tz_offset_minutes: -240,
voice: None,
generated_at: utc(2025, month, day, 7, 0, 0),
},
)
.record_reel(&InsertablePrecomputedReel {
span: span.to_string(),
library_key: library_key.to_string(),
cache_key: format!("{span}-{library_key}-{month}-{day}"),
output_path: mp4.to_string_lossy().to_string(),
title: "t".to_string(),
media_count: 1,
render_version: 1,
tz_offset_minutes: -240,
voice: None,
generated_at: utc(2025, month, day, 7, 0, 0),
})
.unwrap();
(id, mp4)
}
@@ -1704,8 +1649,7 @@ mod tests {
let (other_day, mp4_0301) = seed_reel(&reel_dao, reels_dir.path(), "day", "1", 3, 1);
let (week, _) = seed_reel(&reel_dao, reels_dir.path(), "week", "1", 6, 12);
let out =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 2);
assert_eq!(out.day_changed, 1, "{out:?}");
assert_eq!(out.reels_invalidated, 2, "{out:?}");
@@ -1722,11 +1666,7 @@ mod tests {
// With the ledger row gone the nightly pre-gen's freshness gate
// (`latest_for` must match the new cache key) can't skip → it
// regenerates on its normal schedule; no new scheduler involved.
let latest = reel_dao
.lock()
.unwrap()
.latest_for(&ctx(), "day", "all")
.unwrap();
let latest = reel_dao.lock().unwrap().latest_for("day", "all").unwrap();
assert!(latest.is_none());
}
@@ -1749,8 +1689,7 @@ mod tests {
seed_reel(&reel_dao, reels_dir.path(), "day", "1", 6, 11);
seed_reel(&reel_dao, reels_dir.path(), "day", "all", 6, 12);
let out =
backfill_utc_dates_capped(&ctx(), &library, &exif_dao, &reel_dao, NY, &mut 0, 500);
let out = backfill_utc_dates_capped(&library, &exif_dao, &reel_dao, NY, &mut 0, 500);
assert_eq!(out.converted, 1);
assert_eq!(out.day_changed, 0);
assert_eq!(out.reels_invalidated, 0);
+1 -3
View File
@@ -71,7 +71,6 @@ fn main() -> anyhow::Result<()> {
);
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let ctx = opentelemetry::Context::new();
let mut total_hashed = 0u64;
let mut total_missing = 0u64;
@@ -84,7 +83,7 @@ fn main() -> anyhow::Result<()> {
let rows = {
let mut guard = dao.lock().expect("Unable to lock ExifDao");
guard
.get_rows_missing_hash(&ctx, args.batch_size)
.get_rows_missing_hash(args.batch_size)
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
};
if rows.is_empty() {
@@ -128,7 +127,6 @@ fn main() -> anyhow::Result<()> {
match ident {
Some(id) => {
match guard.backfill_content_hash(
&ctx,
*library_id,
rel_path,
&id.content_hash,
+1 -4
View File
@@ -78,7 +78,6 @@ fn main() -> anyhow::Result<()> {
);
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let ctx = opentelemetry::Context::new();
let mut total_hashed = 0u64;
let mut total_missing = 0u64;
@@ -92,7 +91,7 @@ fn main() -> anyhow::Result<()> {
let rows = {
let mut guard = dao.lock().expect("Unable to lock ExifDao");
guard
.get_rows_missing_perceptual_hash(&ctx, args.batch_size)
.get_rows_missing_perceptual_hash(args.batch_size)
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
};
if rows.is_empty() {
@@ -137,7 +136,6 @@ fn main() -> anyhow::Result<()> {
match result {
FilePerceptualResult::Ok(id) => {
match guard.backfill_perceptual_hash(
&ctx,
*library_id,
rel_path,
Some(id.phash_64),
@@ -170,7 +168,6 @@ fn main() -> anyhow::Result<()> {
library_id
);
match guard.backfill_perceptual_hash(
&ctx,
*library_id,
rel_path,
Some(0),
+3 -8
View File
@@ -42,8 +42,6 @@ async fn main() -> Result<()> {
info!("Found {} calendar events", events.len());
let context = opentelemetry::Context::current();
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
// in the same vector space the query side searches.
let llm = if args.generate_embeddings {
@@ -65,11 +63,8 @@ async fn main() -> Result<()> {
// Check if event exists
if args.skip_existing
&& let Ok(exists) = dao_instance.event_exists(
&context,
event.event_uid.as_deref().unwrap_or(""),
event.start_time,
)
&& let Ok(exists) = dao_instance
.event_exists(event.event_uid.as_deref().unwrap_or(""), event.start_time)
&& exists
{
skipped_count += 1;
@@ -120,7 +115,7 @@ async fn main() -> Result<()> {
source_file: Some(args.path.clone()),
};
match dao_instance.store_event(&context, insert_event) {
match dao_instance.store_event(insert_event) {
Ok(_) => inserted_count += 1,
Err(e) => {
pb.println(format!("store failed for '{}': {:?}", event.summary, e));
+1 -4
View File
@@ -37,8 +37,6 @@ async fn main() -> Result<()> {
info!("Found {} location records", locations.len());
let context = opentelemetry::Context::current();
let mut inserted_count = 0usize;
let mut skipped_count = 0usize;
let mut error_count = 0usize;
@@ -58,7 +56,6 @@ async fn main() -> Result<()> {
// Skip existing check if requested (makes import much slower)
if args.skip_existing
&& let Ok(exists) = dao_instance.location_exists(
&context,
location.timestamp,
location.latitude,
location.longitude,
@@ -87,7 +84,7 @@ async fn main() -> Result<()> {
// Batch insert entire chunk in single transaction
if !batch_inserts.is_empty() {
match dao_instance.store_locations_batch(&context, batch_inserts) {
match dao_instance.store_locations_batch(batch_inserts) {
Ok(count) => inserted_count += count,
Err(e) => {
pb.println(format!("batch insert failed: {:?}", e));
+2 -4
View File
@@ -41,7 +41,6 @@ async fn main() -> Result<()> {
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
// in the same vector space the query side searches.
let llm = LocalLlm::from_env();
let context = opentelemetry::Context::current();
let mut inserted_count = 0usize;
let mut skipped_count = 0usize;
@@ -84,8 +83,7 @@ async fn main() -> Result<()> {
for (search, embedding_opt) in chunk.iter().zip(embeddings_result.iter()) {
// Check if search exists (optional for speed)
if args.skip_existing
&& let Ok(exists) =
dao_instance.search_exists(&context, search.timestamp, &search.query)
&& let Ok(exists) = dao_instance.search_exists(search.timestamp, &search.query)
&& exists
{
skipped_count += 1;
@@ -110,7 +108,7 @@ async fn main() -> Result<()> {
// Batch insert entire chunk in single transaction
if !batch_inserts.is_empty() {
match dao_instance.store_searches_batch(&context, batch_inserts) {
match dao_instance.store_searches_batch(batch_inserts) {
Ok(count) => inserted_count += count,
Err(e) => {
pb.println(format!("batch insert failed: {:?}", e));
+1 -2
View File
@@ -305,7 +305,6 @@ async fn main() -> anyhow::Result<()> {
return Ok(());
}
let cx = opentelemetry::Context::new();
let mut processed = 0usize;
let mut skipped = 0usize;
let mut errors = 0usize;
@@ -319,7 +318,7 @@ async fn main() -> anyhow::Result<()> {
let has_insight = insight_dao
.lock()
.unwrap()
.get_insight(&cx, relative)
.get_insight(relative)
.unwrap_or(None)
.is_some();
+1 -2
View File
@@ -128,7 +128,6 @@ async fn main() -> anyhow::Result<()> {
);
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let ctx = opentelemetry::Context::new();
// Encode the query up-front so the long image-encode loop doesn't
// race a slow query encode. Fails fast on a misspelled query.
@@ -168,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
let rows = {
let mut guard = dao.lock().expect("dao lock");
guard
.list_rel_paths_for_library_page(&ctx, lib.id, PAGE, offset)
.list_rel_paths_for_library_page(lib.id, PAGE, offset)
.map_err(|e| anyhow::anyhow!("list rel_paths: {:?}", e))?
};
if rows.is_empty() {
+1 -2
View File
@@ -251,9 +251,8 @@ async fn main() -> Result<()> {
};
let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao");
let context = opentelemetry::Context::new();
match dao.store_summary(&context, insert) {
match dao.store_summary(insert) {
Ok(_) => println!("✓ Saved to database"),
Err(e) => println!("✗ Database error: {:?}", e),
}
+4 -7
View File
@@ -2,7 +2,6 @@ use crate::database::{ExifDao, FavoriteDao};
use crate::tags::TagDao;
use anyhow::Result;
use log::{error, info};
use opentelemetry;
use std::sync::{Arc, Mutex};
pub struct DatabaseUpdater {
@@ -27,13 +26,12 @@ impl DatabaseUpdater {
/// Update file path across all three database tables
/// Returns Ok(()) if successful, continues on partial failures but logs errors
pub fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<()> {
let context = opentelemetry::Context::current();
let mut success_count = 0;
let mut error_count = 0;
// Update tagged_photo table
if let Ok(mut dao) = self.tag_dao.lock() {
match dao.update_photo_name(old_path, new_path, &context) {
match dao.update_photo_name(old_path, new_path) {
Ok(_) => {
info!("Updated tagged_photo: {} -> {}", old_path, new_path);
success_count += 1;
@@ -50,7 +48,7 @@ impl DatabaseUpdater {
// Update image_exif table
if let Ok(mut dao) = self.exif_dao.lock() {
match dao.update_file_path(&context, old_path, new_path) {
match dao.update_file_path(old_path, new_path) {
Ok(_) => {
info!("Updated image_exif: {} -> {}", old_path, new_path);
success_count += 1;
@@ -102,12 +100,11 @@ impl DatabaseUpdater {
/// Get all file paths from all three database tables
pub fn get_all_file_paths(&mut self) -> Result<Vec<String>> {
let context = opentelemetry::Context::current();
let mut all_paths = Vec::new();
// Get from tagged_photo
if let Ok(mut dao) = self.tag_dao.lock() {
match dao.get_all_photo_names(&context) {
match dao.get_all_photo_names() {
Ok(paths) => {
info!("Found {} paths in tagged_photo", paths.len());
all_paths.extend(paths);
@@ -120,7 +117,7 @@ impl DatabaseUpdater {
// Get from image_exif
if let Ok(mut dao) = self.exif_dao.lock() {
match dao.get_all_file_paths(&context) {
match dao.get_all_file_paths() {
Ok(paths) => {
info!("Found {} paths in image_exif", paths.len());
all_paths.extend(paths);
+5 -10
View File
@@ -186,11 +186,9 @@ pub async fn score_photos(
// before scoring. The caller-supplied `model_version` (or the live
// engine's) forces a strict join so a mid-flight model swap can't mix
// geometries.
let ctx = opentelemetry::Context::current();
let rows: Vec<(String, Vec<u8>)> = {
let mut dao = exif_dao.lock().expect("exif dao");
dao.list_clip_index(
&ctx,
library_ids,
model_version.or(Some(&query_resp.model_version)),
)
@@ -235,19 +233,16 @@ pub fn resolve_hits(
if scored.is_empty() {
return Vec::new();
}
let ctx = opentelemetry::Context::current();
let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect();
let mut dao = exif_dao.lock().expect("exif dao");
let path_map = dao
.get_rel_paths_for_hashes(&ctx, &hashes)
.unwrap_or_else(|e| {
log::warn!("clip_search: get_rel_paths_for_hashes failed: {:?}", e);
std::collections::HashMap::new()
});
let path_map = dao.get_rel_paths_for_hashes(&hashes).unwrap_or_else(|e| {
log::warn!("clip_search: get_rel_paths_for_hashes failed: {:?}", e);
std::collections::HashMap::new()
});
let mut results = Vec::with_capacity(scored.len());
for (score, hash) in scored {
let row = match dao.find_by_content_hash(&ctx, hash) {
let row = match dao.find_by_content_hash(hash) {
Ok(Some(r)) => r,
Ok(None) => continue,
Err(e) => {
-2
View File
@@ -135,7 +135,6 @@ async fn process_one(
library_id,
rel_path: cand.rel_path.clone(),
};
let ctx = opentelemetry::Context::current();
match clip_client.encode_image(bytes, meta).await {
Ok(resp) => {
@@ -148,7 +147,6 @@ async fn process_one(
};
let mut dao = exif_dao.lock().expect("exif dao");
if let Err(e) = dao.backfill_clip_embedding(
&ctx,
library_id,
&cand.rel_path,
&emb_bytes,
+15 -47
View File
@@ -46,23 +46,14 @@ pub struct InsertCalendarEvent {
pub trait CalendarEventDao: Sync + Send {
/// Store calendar event with optional embedding
fn store_event(
&mut self,
context: &opentelemetry::Context,
event: InsertCalendarEvent,
) -> Result<CalendarEvent, DbError>;
fn store_event(&mut self, event: InsertCalendarEvent) -> Result<CalendarEvent, DbError>;
/// Batch insert events (for import efficiency)
fn store_events_batch(
&mut self,
context: &opentelemetry::Context,
events: Vec<InsertCalendarEvent>,
) -> Result<usize, DbError>;
fn store_events_batch(&mut self, events: Vec<InsertCalendarEvent>) -> Result<usize, DbError>;
/// Find events in time range (PRIMARY query method)
fn find_events_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<CalendarEvent>, DbError>;
@@ -70,7 +61,6 @@ pub trait CalendarEventDao: Sync + Send {
/// Find semantically similar events (SECONDARY - requires embeddings)
fn find_similar_events(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<CalendarEvent>, DbError>;
@@ -79,7 +69,6 @@ pub trait CalendarEventDao: Sync + Send {
/// "Events during photo timestamp ±N days, ranked by similarity to context"
fn find_relevant_events_hybrid(
&mut self,
context: &opentelemetry::Context,
center_timestamp: i64,
time_window_days: i64,
query_embedding: Option<&[f32]>,
@@ -87,15 +76,10 @@ pub trait CalendarEventDao: Sync + Send {
) -> Result<Vec<CalendarEvent>, DbError>;
/// Check if event exists (idempotency)
fn event_exists(
&mut self,
context: &opentelemetry::Context,
event_uid: &str,
start_time: i64,
) -> Result<bool, DbError>;
fn event_exists(&mut self, event_uid: &str, start_time: i64) -> Result<bool, DbError>;
/// Get count of events
fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError>;
fn get_event_count(&mut self) -> Result<i64, DbError>;
}
pub struct SqliteCalendarEventDao {
@@ -209,12 +193,8 @@ struct LastInsertRowId {
}
impl CalendarEventDao for SqliteCalendarEventDao {
fn store_event(
&mut self,
context: &opentelemetry::Context,
event: InsertCalendarEvent,
) -> Result<CalendarEvent, DbError> {
trace_db_call(context, "insert", "store_event", |_span| {
fn store_event(&mut self, event: InsertCalendarEvent) -> Result<CalendarEvent, DbError> {
trace_db_call("insert", "store_event", |_span| {
let mut conn = self
.connection
.lock()
@@ -278,12 +258,8 @@ impl CalendarEventDao for SqliteCalendarEventDao {
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn store_events_batch(
&mut self,
context: &opentelemetry::Context,
events: Vec<InsertCalendarEvent>,
) -> Result<usize, DbError> {
trace_db_call(context, "insert", "store_events_batch", |_span| {
fn store_events_batch(&mut self, events: Vec<InsertCalendarEvent>) -> Result<usize, DbError> {
trace_db_call("insert", "store_events_batch", |_span| {
let mut conn = self
.connection
.lock()
@@ -354,11 +330,10 @@ impl CalendarEventDao for SqliteCalendarEventDao {
fn find_events_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<CalendarEvent>, DbError> {
trace_db_call(context, "query", "find_events_in_range", |_span| {
trace_db_call("query", "find_events_in_range", |_span| {
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
diesel::sql_query(
@@ -379,11 +354,10 @@ impl CalendarEventDao for SqliteCalendarEventDao {
fn find_similar_events(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<CalendarEvent>, DbError> {
trace_db_call(context, "query", "find_similar_events", |_span| {
trace_db_call("query", "find_similar_events", |_span| {
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
if query_embedding.len() != crate::ai::embedding_dim() {
@@ -436,13 +410,12 @@ impl CalendarEventDao for SqliteCalendarEventDao {
fn find_relevant_events_hybrid(
&mut self,
context: &opentelemetry::Context,
center_timestamp: i64,
time_window_days: i64,
query_embedding: Option<&[f32]>,
limit: usize,
) -> Result<Vec<CalendarEvent>, DbError> {
trace_db_call(context, "query", "find_relevant_events_hybrid", |_span| {
trace_db_call("query", "find_relevant_events_hybrid", |_span| {
let window_seconds = time_window_days * 86400;
let start_ts = center_timestamp - window_seconds;
let end_ts = center_timestamp + window_seconds;
@@ -506,13 +479,8 @@ impl CalendarEventDao for SqliteCalendarEventDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn event_exists(
&mut self,
context: &opentelemetry::Context,
event_uid: &str,
start_time: i64,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "event_exists", |_span| {
fn event_exists(&mut self, event_uid: &str, start_time: i64) -> Result<bool, DbError> {
trace_db_call("query", "event_exists", |_span| {
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
#[derive(QueryableByName)]
@@ -534,8 +502,8 @@ impl CalendarEventDao for SqliteCalendarEventDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
trace_db_call(context, "query", "get_event_count", |_span| {
fn get_event_count(&mut self) -> Result<i64, DbError> {
trace_db_call("query", "get_event_count", |_span| {
let mut conn = self
.connection
.lock()
+14 -44
View File
@@ -36,16 +36,11 @@ pub struct InsertDailySummary {
pub trait DailySummaryDao: Sync + Send {
/// Store a daily summary with its embedding
fn store_summary(
&mut self,
context: &opentelemetry::Context,
summary: InsertDailySummary,
) -> Result<DailySummary, DbError>;
fn store_summary(&mut self, summary: InsertDailySummary) -> Result<DailySummary, DbError>;
/// Find semantically similar daily summaries using vector similarity
fn find_similar_summaries(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<DailySummary>, DbError>;
@@ -55,31 +50,21 @@ pub trait DailySummaryDao: Sync + Send {
/// Final score = similarity * time_weight, where time_weight decays with distance from target_date
fn find_similar_summaries_with_time_weight(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
target_date: &str,
limit: usize,
) -> Result<Vec<DailySummary>, DbError>;
/// Check if a summary exists for a given date and contact
fn summary_exists(
&mut self,
context: &opentelemetry::Context,
date: &str,
contact: &str,
) -> Result<bool, DbError>;
fn summary_exists(&mut self, date: &str, contact: &str) -> Result<bool, DbError>;
/// Get count of summaries for a contact
fn get_summary_count(
&mut self,
context: &opentelemetry::Context,
contact: &str,
) -> Result<i64, DbError>;
fn get_summary_count(&mut self, contact: &str) -> Result<i64, DbError>;
/// Cheap presence check — returns true iff at least one daily summary row
/// exists. Used by gating logic that only needs "is the table empty?",
/// avoiding a `COUNT(*)` full scan on large corpora.
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError>;
fn has_any_summaries(&mut self) -> Result<bool, DbError>;
}
pub struct SqliteDailySummaryDao {
@@ -138,12 +123,8 @@ impl SqliteDailySummaryDao {
}
impl DailySummaryDao for SqliteDailySummaryDao {
fn store_summary(
&mut self,
context: &opentelemetry::Context,
summary: InsertDailySummary,
) -> Result<DailySummary, DbError> {
trace_db_call(context, "insert", "store_summary", |_span| {
fn store_summary(&mut self, summary: InsertDailySummary) -> Result<DailySummary, DbError> {
trace_db_call("insert", "store_summary", |_span| {
let mut conn = self
.connection
.lock()
@@ -196,11 +177,10 @@ impl DailySummaryDao for SqliteDailySummaryDao {
fn find_similar_summaries(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<DailySummary>, DbError> {
trace_db_call(context, "query", "find_similar_summaries", |_span| {
trace_db_call("query", "find_similar_summaries", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != crate::ai::embedding_dim() {
@@ -293,12 +273,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
fn find_similar_summaries_with_time_weight(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
target_date: &str,
limit: usize,
) -> Result<Vec<DailySummary>, DbError> {
trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| {
trace_db_call("query", "find_similar_summaries_with_time_weight", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != crate::ai::embedding_dim() {
@@ -414,13 +393,8 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn summary_exists(
&mut self,
context: &opentelemetry::Context,
date: &str,
contact: &str,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "summary_exists", |_span| {
fn summary_exists(&mut self, date: &str, contact: &str) -> Result<bool, DbError> {
trace_db_call("query", "summary_exists", |_span| {
let mut conn = self
.connection
.lock()
@@ -441,12 +415,8 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_summary_count(
&mut self,
context: &opentelemetry::Context,
contact: &str,
) -> Result<i64, DbError> {
trace_db_call(context, "query", "get_summary_count", |_span| {
fn get_summary_count(&mut self, contact: &str) -> Result<i64, DbError> {
trace_db_call("query", "get_summary_count", |_span| {
let mut conn = self
.connection
.lock()
@@ -463,8 +433,8 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError> {
trace_db_call(context, "query", "has_any_summaries", |_span| {
fn has_any_summaries(&mut self) -> Result<bool, DbError> {
trace_db_call("query", "has_any_summaries", |_span| {
let mut conn = self
.connection
.lock()
+65 -134
View File
@@ -18,7 +18,6 @@ pub trait InsightGenerationJobDao: Sync + Send {
/// Cleans up terminal-state rows for the same key first.
fn create_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
@@ -27,59 +26,34 @@ pub trait InsightGenerationJobDao: Sync + Send {
/// Mark a job as completed with the resulting insight id. Only updates
/// if the job is still in "running" status (prevents overwriting a
/// cancelled job with a late-completing task).
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError>;
fn complete_job(&mut self, job_id: i32, insight_id: i32) -> Result<(), DbError>;
/// Mark a job as failed with an error message. Only updates if the job
/// is still in "running" status.
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError>;
fn fail_job(&mut self, job_id: i32, error_message: &str) -> Result<(), DbError>;
/// Cancel a specific job by id. Only updates if the job is still
/// in "running" status. Returns true if a row was updated.
fn cancel_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<bool, DbError>;
fn cancel_job(&mut self, job_id: i32) -> Result<bool, DbError>;
/// Cancel all running jobs for a given file. Returns the number of
/// jobs cancelled.
fn cancel_active_jobs(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<usize, DbError>;
fn cancel_active_jobs(&mut self, library_id: i32, file_path: &str) -> Result<usize, DbError>;
/// Find the latest running job for a given file. Returns None if no
/// running job exists.
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError>;
/// Find any job by id regardless of status.
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError>;
fn get_job_by_id(&mut self, job_id: i32) -> Result<Option<InsightGenerationJob>, DbError>;
/// Mark all jobs still in "running" status as "failed" with a recovery
/// error message. Returns the number of jobs recovered.
fn recover_orphaned_jobs(&mut self, context: &opentelemetry::Context)
-> Result<usize, DbError>;
fn recover_orphaned_jobs(&mut self) -> Result<usize, DbError>;
}
pub struct SqliteInsightGenerationJobDao {
@@ -108,12 +82,11 @@ impl SqliteInsightGenerationJobDao {
impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
fn create_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
generation_type: InsightGenerationType,
) -> Result<i32, DbError> {
trace_db_call(context, "insert", "create_job", |_span| {
trace_db_call("insert", "create_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -155,13 +128,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn complete_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
insight_id: i32,
) -> Result<(), DbError> {
trace_db_call(context, "update", "complete_job", |_span| {
fn complete_job(&mut self, job_id: i32, insight_id: i32) -> Result<(), DbError> {
trace_db_call("update", "complete_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -195,13 +163,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn fail_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
error_message: &str,
) -> Result<(), DbError> {
trace_db_call(context, "update", "fail_job", |_span| {
fn fail_job(&mut self, job_id: i32, error_message: &str) -> Result<(), DbError> {
trace_db_call("update", "fail_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -234,12 +197,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn cancel_job(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<bool, DbError> {
trace_db_call(context, "update", "cancel_job", |_span| {
fn cancel_job(&mut self, job_id: i32) -> Result<bool, DbError> {
trace_db_call("update", "cancel_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -272,13 +231,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn cancel_active_jobs(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<usize, DbError> {
trace_db_call(context, "update", "cancel_active_jobs", |_span| {
fn cancel_active_jobs(&mut self, library_id: i32, file_path: &str) -> Result<usize, DbError> {
trace_db_call("update", "cancel_active_jobs", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -314,11 +268,10 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
fn get_active_job(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_active_job", |_span| {
trace_db_call("query", "get_active_job", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -341,12 +294,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_job_by_id(
&mut self,
context: &opentelemetry::Context,
job_id: i32,
) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call(context, "query", "get_job_by_id", |_span| {
fn get_job_by_id(&mut self, job_id: i32) -> Result<Option<InsightGenerationJob>, DbError> {
trace_db_call("query", "get_job_by_id", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -363,11 +312,8 @@ impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn recover_orphaned_jobs(
&mut self,
context: &opentelemetry::Context,
) -> Result<usize, DbError> {
trace_db_call(context, "update", "recover_orphaned_jobs", |_span| {
fn recover_orphaned_jobs(&mut self) -> Result<usize, DbError> {
trace_db_call("update", "recover_orphaned_jobs", |_span| {
use schema::insight_generation_jobs::dsl;
let mut connection = self
@@ -414,21 +360,16 @@ mod tests {
SqliteInsightGenerationJobDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn create_job_inserts_new_row() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_ne!(job_id_1, job_id_2, "each create_job call inserts a new row");
@@ -437,15 +378,14 @@ mod tests {
#[test]
fn complete_job_sets_result() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
dao.complete_job(&ctx, job_id, 42).unwrap();
dao.complete_job(job_id, 42).unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(42));
assert!(job.completed_at.is_some());
@@ -454,15 +394,14 @@ mod tests {
#[test]
fn fail_job_sets_error() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
.create_job(1, "photos/test.jpg", InsightGenerationType::Agentic)
.unwrap();
dao.fail_job(&ctx, job_id, "model timeout").unwrap();
dao.fail_job(job_id, "model timeout").unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Failed.as_str());
assert_eq!(job.error_message.as_deref(), Some("model timeout"));
assert!(job.completed_at.is_some());
@@ -471,61 +410,58 @@ mod tests {
#[test]
fn get_active_job_returns_none_when_completed() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Job is running
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
let active = dao.get_active_job(1, "photos/test.jpg").unwrap();
assert!(active.is_some());
assert_eq!(active.unwrap().id, job_id);
// Complete it
dao.complete_job(&ctx, job_id, 1).unwrap();
dao.complete_job(job_id, 1).unwrap();
// No longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
let active = dao.get_active_job(1, "photos/test.jpg").unwrap();
assert!(active.is_none());
}
#[test]
fn cancel_active_jobs() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let cancelled = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
let cancelled = dao.cancel_active_jobs(1, "photos/test.jpg").unwrap();
assert_eq!(cancelled, 1, "should cancel 1 running job");
// Job is no longer active
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
let active = dao.get_active_job(1, "photos/test.jpg").unwrap();
assert!(active.is_none());
// Job exists with cancelled status
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
// Cancelling again returns 0 (nothing to cancel)
let cancelled2 = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
let cancelled2 = dao.cancel_active_jobs(1, "photos/test.jpg").unwrap();
assert_eq!(cancelled2, 0, "should return 0 when no running job");
}
#[test]
fn get_active_job_scoped_by_library() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id_1 = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 2, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(2, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
assert_ne!(
@@ -534,14 +470,14 @@ mod tests {
);
// Complete lib1's job
dao.complete_job(&ctx, job_id_1, 1).unwrap();
dao.complete_job(job_id_1, 1).unwrap();
// lib1 has no active job
let active1 = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
let active1 = dao.get_active_job(1, "photos/test.jpg").unwrap();
assert!(active1.is_none());
// lib2 still has active job
let active2 = dao.get_active_job(&ctx, 2, "photos/test.jpg").unwrap();
let active2 = dao.get_active_job(2, "photos/test.jpg").unwrap();
assert!(active2.is_some());
assert_eq!(active2.unwrap().id, job_id_2);
}
@@ -549,21 +485,20 @@ mod tests {
#[test]
fn get_job_by_id_finds_any_status() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
// Find while running
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Running.as_str());
// Complete it
dao.complete_job(&ctx, job_id, 99).unwrap();
dao.complete_job(job_id, 99).unwrap();
// Still findable
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
assert_eq!(job.result_insight_id, Some(99));
}
@@ -571,29 +506,28 @@ mod tests {
#[test]
fn recover_orphaned_jobs() {
let mut dao = setup_dao();
let ctx = ctx();
// Create two running jobs
let job_id_1 = dao
.create_job(&ctx, 1, "photos/a.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/a.jpg", InsightGenerationType::Standard)
.unwrap();
let job_id_2 = dao
.create_job(&ctx, 1, "photos/b.jpg", InsightGenerationType::Agentic)
.create_job(1, "photos/b.jpg", InsightGenerationType::Agentic)
.unwrap();
// Complete one
dao.complete_job(&ctx, job_id_1, 1).unwrap();
dao.complete_job(job_id_1, 1).unwrap();
// Recover should only affect the running job
let recovered = dao.recover_orphaned_jobs(&ctx).unwrap();
let recovered = dao.recover_orphaned_jobs().unwrap();
assert_eq!(recovered, 1, "should recover exactly 1 running job");
// job_id_1 is still completed
let job1 = dao.get_job_by_id(&ctx, job_id_1).unwrap().unwrap();
let job1 = dao.get_job_by_id(job_id_1).unwrap().unwrap();
assert_eq!(job1.status, InsightJobStatus::Completed.as_str());
// job_id_2 is now failed with recovery message
let job2 = dao.get_job_by_id(&ctx, job_id_2).unwrap().unwrap();
let job2 = dao.get_job_by_id(job_id_2).unwrap().unwrap();
assert_eq!(job2.status, InsightJobStatus::Failed.as_str());
assert_eq!(
job2.error_message.as_deref(),
@@ -601,25 +535,24 @@ mod tests {
);
// Second recovery is a no-op
let recovered2 = dao.recover_orphaned_jobs(&ctx).unwrap();
let recovered2 = dao.recover_orphaned_jobs().unwrap();
assert_eq!(recovered2, 0, "no running jobs remain");
}
#[test]
fn complete_job_noop_when_cancelled() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
dao.cancel_job(&ctx, job_id).unwrap();
dao.cancel_job(job_id).unwrap();
// Late-completing task tries to mark as completed — should be a no-op
dao.complete_job(&ctx, job_id, 42).unwrap();
dao.complete_job(job_id, 42).unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(
job.status,
InsightJobStatus::Cancelled.as_str(),
@@ -634,18 +567,17 @@ mod tests {
#[test]
fn fail_job_noop_when_cancelled() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
.create_job(1, "photos/test.jpg", InsightGenerationType::Agentic)
.unwrap();
dao.cancel_job(&ctx, job_id).unwrap();
dao.cancel_job(job_id).unwrap();
// Late-failing task tries to mark as failed — should be a no-op
dao.fail_job(&ctx, job_id, "timeout after 120s").unwrap();
dao.fail_job(job_id, "timeout after 120s").unwrap();
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(
job.status,
InsightJobStatus::Cancelled.as_str(),
@@ -661,21 +593,20 @@ mod tests {
#[test]
fn cancel_job_by_id() {
let mut dao = setup_dao();
let ctx = ctx();
let job_id = dao
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
.create_job(1, "photos/test.jpg", InsightGenerationType::Standard)
.unwrap();
let cancelled = dao.cancel_job(&ctx, job_id).unwrap();
let cancelled = dao.cancel_job(job_id).unwrap();
assert!(cancelled, "should cancel running job");
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
let job = dao.get_job_by_id(job_id).unwrap().unwrap();
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
assert!(job.completed_at.is_some());
// Cancelling again is a no-op
let cancelled2 = dao.cancel_job(&ctx, job_id).unwrap();
let cancelled2 = dao.cancel_job(job_id).unwrap();
assert!(!cancelled2, "already cancelled job should return false");
}
}
+52 -144
View File
@@ -9,17 +9,9 @@ use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
pub trait InsightDao: Sync + Send {
fn store_insight(
&mut self,
context: &opentelemetry::Context,
insight: InsertPhotoInsight,
) -> Result<PhotoInsight, DbError>;
fn store_insight(&mut self, insight: InsertPhotoInsight) -> Result<PhotoInsight, DbError>;
fn get_insight(
&mut self,
context: &opentelemetry::Context,
file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>;
fn get_insight(&mut self, file_path: &str) -> Result<Option<PhotoInsight>, DbError>;
/// Library-scoped variant of `get_insight`. The default `get_insight`
/// finds any `is_current=true` row matching `file_path` across
@@ -32,7 +24,6 @@ pub trait InsightDao: Sync + Send {
/// one (chat / insight write paths always do).
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>;
@@ -41,61 +32,29 @@ pub trait InsightDao: Sync + Send {
/// `paths`. Used for content-hash sharing: the caller expands a
/// single file into all rel_paths with the same content_hash, then
/// asks here for any existing insight attached to any of them.
fn get_insight_for_paths(
&mut self,
context: &opentelemetry::Context,
paths: &[String],
) -> Result<Option<PhotoInsight>, DbError>;
fn get_insight_for_paths(&mut self, paths: &[String]) -> Result<Option<PhotoInsight>, DbError>;
fn get_insight_history(
&mut self,
context: &opentelemetry::Context,
file_path: &str,
) -> Result<Vec<PhotoInsight>, DbError>;
fn get_insight_history(&mut self, file_path: &str) -> Result<Vec<PhotoInsight>, DbError>;
/// Fetch a single insight by primary key, regardless of `is_current`.
/// Used by the few-shot injection flow where the caller picks specific
/// historical insights (which may have been superseded) as training
/// exemplars for a fresh generation.
fn get_insight_by_id(
&mut self,
context: &opentelemetry::Context,
insight_id: i32,
) -> Result<Option<PhotoInsight>, DbError>;
fn get_insight_by_id(&mut self, insight_id: i32) -> Result<Option<PhotoInsight>, DbError>;
fn delete_insight(
&mut self,
context: &opentelemetry::Context,
file_path: &str,
) -> Result<(), DbError>;
fn delete_insight(&mut self, file_path: &str) -> Result<(), DbError>;
fn get_all_insights(
&mut self,
context: &opentelemetry::Context,
) -> Result<Vec<PhotoInsight>, DbError>;
fn get_all_insights(&mut self) -> Result<Vec<PhotoInsight>, DbError>;
fn rate_insight(
&mut self,
context: &opentelemetry::Context,
file_path: &str,
approved: bool,
) -> Result<(), DbError>;
fn rate_insight(&mut self, file_path: &str, approved: bool) -> Result<(), DbError>;
/// Rate a specific insight version by primary key, regardless of
/// `is_current`. Used by the per-file history view to approve/reject
/// previously generated (superseded) versions, which the path-based
/// `rate_insight` (current row only) cannot reach.
fn rate_insight_by_id(
&mut self,
context: &opentelemetry::Context,
insight_id: i32,
approved: bool,
) -> Result<(), DbError>;
fn rate_insight_by_id(&mut self, insight_id: i32, approved: bool) -> Result<(), DbError>;
fn get_approved_insights(
&mut self,
context: &opentelemetry::Context,
) -> Result<Vec<PhotoInsight>, DbError>;
fn get_approved_insights(&mut self) -> Result<Vec<PhotoInsight>, DbError>;
/// Replace the `training_messages` JSON blob on the current row for
/// `(library_id, rel_path)`. Used by chat-turn append mode to persist
@@ -104,7 +63,6 @@ pub trait InsightDao: Sync + Send {
/// indicating a concurrent regenerate/reconcile flipped `is_current`).
fn update_training_messages(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
training_messages_json: &str,
@@ -136,12 +94,8 @@ impl SqliteInsightDao {
}
impl InsightDao for SqliteInsightDao {
fn store_insight(
&mut self,
context: &opentelemetry::Context,
mut insight: InsertPhotoInsight,
) -> Result<PhotoInsight, DbError> {
trace_db_call(context, "insert", "store_insight", |_span| {
fn store_insight(&mut self, mut insight: InsertPhotoInsight) -> Result<PhotoInsight, DbError> {
trace_db_call("insert", "store_insight", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -193,12 +147,8 @@ impl InsightDao for SqliteInsightDao {
})
}
fn get_insight(
&mut self,
context: &opentelemetry::Context,
path: &str,
) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_insight", |_span| {
fn get_insight(&mut self, path: &str) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call("query", "get_insight", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -215,40 +165,30 @@ impl InsightDao for SqliteInsightDao {
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
lib_id: i32,
path: &str,
) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call(
context,
"query",
"get_current_insight_for_library",
|_span| {
use schema::photo_insights::dsl::*;
trace_db_call("query", "get_current_insight_for_library", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
photo_insights
.filter(library_id.eq(lib_id))
.filter(rel_path.eq(path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
},
)
photo_insights
.filter(library_id.eq(lib_id))
.filter(rel_path.eq(path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_insight_for_paths(
&mut self,
context: &opentelemetry::Context,
paths: &[String],
) -> Result<Option<PhotoInsight>, DbError> {
fn get_insight_for_paths(&mut self, paths: &[String]) -> Result<Option<PhotoInsight>, DbError> {
if paths.is_empty() {
return Ok(None);
}
trace_db_call(context, "query", "get_insight_for_paths", |_span| {
trace_db_call("query", "get_insight_for_paths", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -264,12 +204,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_insight_history(
&mut self,
context: &opentelemetry::Context,
path: &str,
) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_insight_history", |_span| {
fn get_insight_history(&mut self, path: &str) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call("query", "get_insight_history", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -283,12 +219,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_insight_by_id(
&mut self,
context: &opentelemetry::Context,
insight_id: i32,
) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_insight_by_id", |_span| {
fn get_insight_by_id(&mut self, insight_id: i32) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call("query", "get_insight_by_id", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -302,12 +234,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_insight(
&mut self,
context: &opentelemetry::Context,
path: &str,
) -> Result<(), DbError> {
trace_db_call(context, "delete", "delete_insight", |_span| {
fn delete_insight(&mut self, path: &str) -> Result<(), DbError> {
trace_db_call("delete", "delete_insight", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -320,11 +248,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_all_insights(
&mut self,
context: &opentelemetry::Context,
) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_all_insights", |_span| {
fn get_all_insights(&mut self) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call("query", "get_all_insights", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -338,13 +263,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn rate_insight(
&mut self,
context: &opentelemetry::Context,
path: &str,
is_approved: bool,
) -> Result<(), DbError> {
trace_db_call(context, "update", "rate_insight", |_span| {
fn rate_insight(&mut self, path: &str, is_approved: bool) -> Result<(), DbError> {
trace_db_call("update", "rate_insight", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -362,13 +282,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn rate_insight_by_id(
&mut self,
context: &opentelemetry::Context,
target_id: i32,
is_approved: bool,
) -> Result<(), DbError> {
trace_db_call(context, "update", "rate_insight_by_id", |_span| {
fn rate_insight_by_id(&mut self, target_id: i32, is_approved: bool) -> Result<(), DbError> {
trace_db_call("update", "rate_insight_by_id", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -382,11 +297,8 @@ impl InsightDao for SqliteInsightDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn get_approved_insights(
&mut self,
context: &opentelemetry::Context,
) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_approved_insights", |_span| {
fn get_approved_insights(&mut self) -> Result<Vec<PhotoInsight>, DbError> {
trace_db_call("query", "get_approved_insights", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -403,12 +315,11 @@ impl InsightDao for SqliteInsightDao {
fn update_training_messages(
&mut self,
context: &opentelemetry::Context,
lib_id: i32,
path: &str,
training_messages_json: &str,
) -> Result<usize, DbError> {
trace_db_call(context, "update", "update_training_messages", |_span| {
trace_db_call("update", "update_training_messages", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
@@ -466,19 +377,17 @@ mod tests {
#[test]
fn get_insight_history_returns_all_versions_newest_first() {
let cx = opentelemetry::Context::new();
let mut dao = dao();
// store_insight flips prior rows to is_current=false, so three
// generations for the same path leave a 3-row history.
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 300, "m3")).unwrap();
dao.store_insight(insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(insert("a.jpg", 200, "m2")).unwrap();
dao.store_insight(insert("a.jpg", 300, "m3")).unwrap();
// A different path must not leak into the history.
dao.store_insight(&cx, insert("b.jpg", 250, "other"))
.unwrap();
dao.store_insight(insert("b.jpg", 250, "other")).unwrap();
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
let history = dao.get_insight_history("a.jpg").unwrap();
assert_eq!(history.len(), 3);
assert_eq!(
history.iter().map(|i| i.generated_at).collect::<Vec<_>>(),
@@ -493,20 +402,19 @@ mod tests {
#[test]
fn rate_insight_by_id_rates_only_the_targeted_version() {
let cx = opentelemetry::Context::new();
let mut dao = dao();
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
dao.store_insight(insert("a.jpg", 100, "m1")).unwrap();
dao.store_insight(insert("a.jpg", 200, "m2")).unwrap();
// History is newest-first: [200 (current), 100 (superseded)].
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
let history = dao.get_insight_history("a.jpg").unwrap();
let old_version = history.iter().find(|i| i.generated_at == 100).unwrap();
assert!(!old_version.is_current);
dao.rate_insight_by_id(&cx, old_version.id, true).unwrap();
dao.rate_insight_by_id(old_version.id, true).unwrap();
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
let history = dao.get_insight_history("a.jpg").unwrap();
let old = history.iter().find(|i| i.generated_at == 100).unwrap();
let current = history.iter().find(|i| i.generated_at == 200).unwrap();
assert_eq!(old.approved, Some(true), "targeted version is rated");
File diff suppressed because it is too large. Load diff
+11 -25
View File
@@ -43,16 +43,12 @@ pub struct InsertLocationRecord {
pub trait LocationHistoryDao: Sync + Send {
/// Store single location record
fn store_location(
&mut self,
context: &opentelemetry::Context,
location: InsertLocationRecord,
) -> Result<LocationRecord, DbError>;
fn store_location(&mut self, location: InsertLocationRecord)
-> Result<LocationRecord, DbError>;
/// Batch insert locations (Google Takeout has millions of points)
fn store_locations_batch(
&mut self,
context: &opentelemetry::Context,
locations: Vec<InsertLocationRecord>,
) -> Result<usize, DbError>;
@@ -60,7 +56,6 @@ pub trait LocationHistoryDao: Sync + Send {
/// "Where was I at photo timestamp ±N minutes?"
fn find_nearest_location(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
max_time_diff_seconds: i64,
) -> Result<Option<LocationRecord>, DbError>;
@@ -68,7 +63,6 @@ pub trait LocationHistoryDao: Sync + Send {
/// Find locations in time range
fn find_locations_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<LocationRecord>, DbError>;
@@ -77,7 +71,6 @@ pub trait LocationHistoryDao: Sync + Send {
/// Uses approximate bounding box for performance
fn find_locations_near_point(
&mut self,
context: &opentelemetry::Context,
latitude: f64,
longitude: f64,
radius_km: f64,
@@ -86,14 +79,13 @@ pub trait LocationHistoryDao: Sync + Send {
/// Deduplicate: check if location exists
fn location_exists(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
latitude: f64,
longitude: f64,
) -> Result<bool, DbError>;
/// Get count of location records
fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError>;
fn get_location_count(&mut self) -> Result<i64, DbError>;
}
pub struct SqliteLocationHistoryDao {
@@ -205,10 +197,9 @@ struct LastInsertRowId {
impl LocationHistoryDao for SqliteLocationHistoryDao {
fn store_location(
&mut self,
context: &opentelemetry::Context,
location: InsertLocationRecord,
) -> Result<LocationRecord, DbError> {
trace_db_call(context, "insert", "store_location", |_span| {
trace_db_call("insert", "store_location", |_span| {
let mut conn = self
.connection
.lock()
@@ -279,10 +270,9 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
fn store_locations_batch(
&mut self,
context: &opentelemetry::Context,
locations: Vec<InsertLocationRecord>,
) -> Result<usize, DbError> {
trace_db_call(context, "insert", "store_locations_batch", |_span| {
trace_db_call("insert", "store_locations_batch", |_span| {
let mut conn = self
.connection
.lock()
@@ -356,11 +346,10 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
fn find_nearest_location(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
max_time_diff_seconds: i64,
) -> Result<Option<LocationRecord>, DbError> {
trace_db_call(context, "query", "find_nearest_location", |_span| {
trace_db_call("query", "find_nearest_location", |_span| {
let mut conn = self
.connection
.lock()
@@ -391,11 +380,10 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
fn find_locations_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<LocationRecord>, DbError> {
trace_db_call(context, "query", "find_locations_in_range", |_span| {
trace_db_call("query", "find_locations_in_range", |_span| {
let mut conn = self
.connection
.lock()
@@ -419,12 +407,11 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
fn find_locations_near_point(
&mut self,
context: &opentelemetry::Context,
latitude: f64,
longitude: f64,
radius_km: f64,
) -> Result<Vec<LocationRecord>, DbError> {
trace_db_call(context, "query", "find_locations_near_point", |_span| {
trace_db_call("query", "find_locations_near_point", |_span| {
let mut conn = self
.connection
.lock()
@@ -474,12 +461,11 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
fn location_exists(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
latitude: f64,
longitude: f64,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "location_exists", |_span| {
trace_db_call("query", "location_exists", |_span| {
let mut conn = self
.connection
.lock()
@@ -506,8 +492,8 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
trace_db_call(context, "query", "get_location_count", |_span| {
fn get_location_count(&mut self) -> Result<i64, DbError> {
trace_db_call("query", "get_location_count", |_span| {
let mut conn = self
.connection
.lock()
+244 -475
View File
File diff suppressed because it is too large. Load diff
+70 -167
View File
@@ -77,22 +77,12 @@ pub struct ImportPersona {
}
pub trait PersonaDao: Sync + Send {
fn list_personas(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
) -> Result<Vec<Persona>, DbError>;
fn list_personas(&mut self, user_id: i32) -> Result<Vec<Persona>, DbError>;
fn get_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<Option<Persona>, DbError>;
fn get_persona(&mut self, user_id: i32, persona_id: &str) -> Result<Option<Persona>, DbError>;
fn create_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
name: &str,
@@ -103,28 +93,17 @@ pub trait PersonaDao: Sync + Send {
fn update_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError>;
fn delete_persona(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
) -> Result<bool, DbError>;
fn delete_persona(&mut self, user_id: i32, persona_id: &str) -> Result<bool, DbError>;
/// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id)
/// — re-uploading the same set is a no-op. Returns the number of rows
/// actually inserted (skipped duplicates don't count).
fn bulk_import(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
personas: &[ImportPersona],
) -> Result<usize, DbError>;
fn bulk_import(&mut self, user_id: i32, personas: &[ImportPersona]) -> Result<usize, DbError>;
// ── Persona-chat (open chat with persona) persistence ───────────
//
@@ -137,7 +116,6 @@ pub trait PersonaDao: Sync + Send {
/// another user — the two are deliberately indistinguishable to callers.
fn get_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
) -> Result<Option<PersonaChatRow>, DbError>;
@@ -145,18 +123,13 @@ pub trait PersonaDao: Sync + Send {
/// Every conversation this user has going, newest first. Backs the chat
/// list screen, so it returns the transcript blob too — the snippet is
/// derived from the tree rather than denormalized into its own column.
fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
) -> Result<Vec<PersonaChatRow>, DbError>;
fn list_persona_chats(&mut self, user_id: i32) -> Result<Vec<PersonaChatRow>, DbError>;
/// Start a new conversation with a persona, returning its id. Several
/// conversations with the same persona are expected, so this never
/// reuses an existing row.
fn create_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
persona_id: &str,
created_at: i64,
@@ -169,7 +142,6 @@ pub trait PersonaDao: Sync + Send {
/// this user's.
fn update_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
messages_json: &str,
@@ -181,7 +153,6 @@ pub trait PersonaDao: Sync + Send {
/// enough of an exchange to summarize.
fn set_persona_chat_title(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
title: &str,
@@ -189,12 +160,7 @@ pub trait PersonaDao: Sync + Send {
/// Delete a conversation outright. Backs both the list screen's delete
/// affordance and the reset endpoint.
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
user_id: i32,
conversation_id: &str,
) -> Result<(), DbError>;
fn delete_persona_chat(&mut self, user_id: i32, conversation_id: &str) -> Result<(), DbError>;
}
pub struct SqlitePersonaDao {
@@ -220,12 +186,8 @@ impl SqlitePersonaDao {
}
impl PersonaDao for SqlitePersonaDao {
fn list_personas(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
) -> Result<Vec<Persona>, DbError> {
trace_db_call(cx, "query", "list_personas", |_span| {
fn list_personas(&mut self, uid: i32) -> Result<Vec<Persona>, DbError> {
trace_db_call("query", "list_personas", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
@@ -237,13 +199,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
) -> Result<Option<Persona>, DbError> {
trace_db_call(cx, "query", "get_persona", |_span| {
fn get_persona(&mut self, uid: i32, pid: &str) -> Result<Option<Persona>, DbError> {
trace_db_call("query", "get_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
@@ -258,7 +215,6 @@ impl PersonaDao for SqlitePersonaDao {
fn create_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
nm: &str,
@@ -266,7 +222,7 @@ impl PersonaDao for SqlitePersonaDao {
builtin: bool,
include_all: bool,
) -> Result<Persona, DbError> {
trace_db_call(cx, "insert", "create_persona", |_span| {
trace_db_call("insert", "create_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
@@ -298,12 +254,11 @@ impl PersonaDao for SqlitePersonaDao {
fn update_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError> {
trace_db_call(cx, "update", "update_persona", |_span| {
trace_db_call("update", "update_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
@@ -359,13 +314,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn delete_persona(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
) -> Result<bool, DbError> {
trace_db_call(cx, "delete", "delete_persona", |_span| {
fn delete_persona(&mut self, uid: i32, pid: &str) -> Result<bool, DbError> {
trace_db_call("delete", "delete_persona", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
// One transaction so a persona and its conversations can't get
// out of step. There is no FK between the two tables, and the
@@ -392,13 +342,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn bulk_import(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
rows: &[ImportPersona],
) -> Result<usize, DbError> {
trace_db_call(cx, "insert", "bulk_import_personas", |_span| {
fn bulk_import(&mut self, uid: i32, rows: &[ImportPersona]) -> Result<usize, DbError> {
trace_db_call("insert", "bulk_import_personas", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
let mut inserted = 0usize;
@@ -428,13 +373,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn get_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
) -> Result<Option<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "get_persona_chat", |_span| {
fn get_persona_chat(&mut self, uid: i32, cid: &str) -> Result<Option<PersonaChatRow>, DbError> {
trace_db_call("query", "get_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
@@ -459,12 +399,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn list_persona_chats(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
) -> Result<Vec<PersonaChatRow>, DbError> {
trace_db_call(cx, "query", "list_persona_chats", |_span| {
fn list_persona_chats(&mut self, uid: i32) -> Result<Vec<PersonaChatRow>, DbError> {
trace_db_call("query", "list_persona_chats", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
@@ -488,12 +424,11 @@ impl PersonaDao for SqlitePersonaDao {
fn create_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
pid: &str,
created: i64,
) -> Result<String, DbError> {
trace_db_call(cx, "insert", "create_persona_chat", |_span| {
trace_db_call("insert", "create_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let new_id = uuid::Uuid::new_v4().to_string();
@@ -519,14 +454,13 @@ impl PersonaDao for SqlitePersonaDao {
fn update_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
json: &str,
count: i32,
updated: i64,
) -> Result<usize, DbError> {
trace_db_call(cx, "update", "update_persona_chat", |_span| {
trace_db_call("update", "update_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
@@ -547,12 +481,11 @@ impl PersonaDao for SqlitePersonaDao {
fn set_persona_chat_title(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
new_title: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
trace_db_call("update", "set_persona_chat_title", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
@@ -568,13 +501,8 @@ impl PersonaDao for SqlitePersonaDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_persona_chat(
&mut self,
cx: &opentelemetry::Context,
uid: i32,
cid: &str,
) -> Result<(), DbError> {
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
fn delete_persona_chat(&mut self, uid: i32, cid: &str) -> Result<(), DbError> {
trace_db_call("delete", "delete_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::delete(
@@ -611,31 +539,29 @@ mod tests {
#[test]
fn create_and_list_round_trip() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("alice");
// The migration seeds 3 built-ins for any existing user; alice
// was created post-migration so she starts empty.
let p = dao
.create_persona(&cx, uid, "custom-1", "Custom A", "prompt A", false, false)
.create_persona(uid, "custom-1", "Custom A", "prompt A", false, false)
.unwrap();
assert_eq!(p.persona_id, "custom-1");
assert_eq!(p.user_id, uid);
assert!(!p.is_built_in);
let list = dao.list_personas(&cx, uid).unwrap();
let list = dao.list_personas(uid).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].persona_id, "custom-1");
}
#[test]
fn unique_constraint_blocks_duplicate_persona_id() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("bob");
dao.create_persona(&cx, uid, "x", "X", "p", false, false)
dao.create_persona(uid, "x", "X", "p", false, false)
.unwrap();
let err = dao.create_persona(&cx, uid, "x", "X2", "p2", false, false);
let err = dao.create_persona(uid, "x", "X2", "p2", false, false);
assert!(
err.is_err(),
"second insert with same persona_id should fail"
@@ -644,7 +570,6 @@ mod tests {
#[test]
fn bulk_import_is_idempotent() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("carol");
let rows = vec![
@@ -664,12 +589,12 @@ mod tests {
},
];
let first = dao.bulk_import(&cx, uid, &rows).unwrap();
let first = dao.bulk_import(uid, &rows).unwrap();
assert_eq!(first, 2);
let second = dao.bulk_import(&cx, uid, &rows).unwrap();
let second = dao.bulk_import(uid, &rows).unwrap();
assert_eq!(second, 0, "re-import should insert nothing");
assert_eq!(dao.list_personas(&cx, uid).unwrap().len(), 2);
assert_eq!(dao.list_personas(uid).unwrap().len(), 2);
}
#[test]
@@ -683,14 +608,12 @@ mod tests {
// depth — but keep this test passing so anyone who removes
// the handler guard gets a failing call site, not silent data
// corruption.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("eve");
dao.create_persona(&cx, uid, "default", "Default", "old", true, false)
dao.create_persona(uid, "default", "Default", "old", true, false)
.unwrap();
let updated = dao
.update_persona(
&cx,
uid,
"default",
PersonaPatch {
@@ -713,14 +636,12 @@ mod tests {
#[test]
fn update_toggles_include_all_memories() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("dan");
dao.create_persona(&cx, uid, "j", "Journal", "p", true, false)
dao.create_persona(uid, "j", "Journal", "p", true, false)
.unwrap();
let updated = dao
.update_persona(
&cx,
uid,
"j",
PersonaPatch {
@@ -755,22 +676,16 @@ mod tests {
#[test]
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p1");
assert!(
dao.get_persona_chat(&cx, uid, "no-such-id")
.unwrap()
.is_none()
);
assert!(dao.get_persona_chat(uid, "no-such-id").unwrap().is_none());
}
#[test]
fn persona_chat_create_then_get_round_trip() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p2");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.conversation_id, cid);
assert_eq!(row.persona_id, "journal");
assert_eq!(row.title, "", "unnamed until the first turn completes");
@@ -783,31 +698,27 @@ mod tests {
fn persona_chat_create_makes_a_separate_row_each_time() {
// The whole point of the conversation_id key: one persona, several
// independent threads.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p3");
let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
let first = dao.create_persona_chat(uid, "journal", 100).unwrap();
let second = dao.create_persona_chat(uid, "journal", 200).unwrap();
assert_ne!(first, second);
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
assert_eq!(dao.list_persona_chats(uid).unwrap().len(), 2);
}
#[test]
fn persona_chat_update_replaces_the_transcript() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p4");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
let rows = dao
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
.unwrap();
let rows = dao.update_persona_chat(uid, &cid, "first", 1, 150).unwrap();
assert_eq!(rows, 1);
let rows = dao
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
.update_persona_chat(uid, &cid, "second", 2, 200)
.unwrap();
assert_eq!(rows, 1);
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "second");
assert_eq!(row.turn_count, 2);
assert_eq!(row.updated_at, 200);
@@ -818,36 +729,32 @@ mod tests {
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
// The turn loop treats 0 as "the conversation went away mid-turn"
// rather than silently succeeding.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p5");
let rows = dao
.update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
.update_persona_chat(uid, "no-such-id", "x", 1, 1)
.unwrap();
assert_eq!(rows, 0);
}
#[test]
fn persona_chat_set_title_names_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p6");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
.unwrap();
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
dao.set_persona_chat_title(uid, &cid, "June recap").unwrap();
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.title, "June recap");
}
#[test]
fn persona_chat_list_orders_newest_first() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p7");
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
let older = dao.create_persona_chat(uid, "journal", 100).unwrap();
let newer = dao.create_persona_chat(uid, "coach", 300).unwrap();
let middle = dao.create_persona_chat(uid, "default", 200).unwrap();
let ids: Vec<String> = dao
.list_persona_chats(&cx, uid)
.list_persona_chats(uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
@@ -861,20 +768,18 @@ mod tests {
#[test]
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p8");
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
assert!(dao.list_persona_chats(uid).unwrap().is_empty());
}
#[test]
fn persona_chat_delete_removes_the_conversation() {
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p9");
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
dao.delete_persona_chat(uid, &cid).unwrap();
assert!(dao.get_persona_chat(uid, &cid).unwrap().is_none());
assert!(dao.list_persona_chats(uid).unwrap().is_empty());
}
#[test]
@@ -882,18 +787,17 @@ mod tests {
// No FK between the tables, and the chat list hides conversations
// whose persona is gone — so an orphan row would be an invisible,
// unreachable transcript sitting in the database forever.
let cx = opentelemetry::Context::new();
let (mut dao, uid) = dao_with_user("p-cascade");
dao.create_persona(&cx, uid, "custom-1", "Custom", "prompt", false, false)
dao.create_persona(uid, "custom-1", "Custom", "prompt", false, false)
.unwrap();
dao.create_persona_chat(&cx, uid, "custom-1", 100).unwrap();
dao.create_persona_chat(&cx, uid, "custom-1", 200).unwrap();
let survivor = dao.create_persona_chat(&cx, uid, "other", 300).unwrap();
dao.create_persona_chat(uid, "custom-1", 100).unwrap();
dao.create_persona_chat(uid, "custom-1", 200).unwrap();
let survivor = dao.create_persona_chat(uid, "other", 300).unwrap();
assert!(dao.delete_persona(&cx, uid, "custom-1").unwrap());
assert!(dao.delete_persona(uid, "custom-1").unwrap());
let remaining: Vec<String> = dao
.list_persona_chats(&cx, uid)
.list_persona_chats(uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
@@ -909,27 +813,26 @@ mod tests {
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
// A conversation_id is a bearer token for someone's transcript, so
// holding one must not grant another user access to it.
let cx = opentelemetry::Context::new();
let (mut dao, uid1) = dao_with_user("owner");
let uid2 = second_user(&dao, "intruder");
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
let cid = dao.create_persona_chat(uid1, "journal", 100).unwrap();
dao.update_persona_chat(uid1, &cid, "private", 1, 100)
.unwrap();
assert!(
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
dao.get_persona_chat(uid2, &cid).unwrap().is_none(),
"another user cannot read it"
);
assert_eq!(
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
dao.update_persona_chat(uid2, &cid, "tampered", 9, 999)
.unwrap(),
0,
"another user cannot write it"
);
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
dao.delete_persona_chat(uid2, &cid).unwrap();
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
let row = dao.get_persona_chat(uid1, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "private", "and cannot delete it");
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
assert!(dao.list_persona_chats(uid2).unwrap().is_empty());
}
}
+45 -87
View File
@@ -15,16 +15,11 @@ pub trait PrecomputedReelDao: Sync + Send {
/// Insert a precomputed reel row. Returns the new row's id.
/// Written by the nightly agentic job (Section D).
#[allow(dead_code)]
fn record_reel(
&mut self,
context: &opentelemetry::Context,
row: &InsertablePrecomputedReel,
) -> Result<i32, DbError>;
fn record_reel(&mut self, row: &InsertablePrecomputedReel) -> Result<i32, DbError>;
/// Find the latest precomputed reel for the given (span, library_key).
fn latest_for(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
) -> Result<Option<PrecomputedReel>, DbError>;
@@ -35,7 +30,6 @@ pub trait PrecomputedReelDao: Sync + Send {
/// back to `latest_for` (avoids a second query path).
fn exists_fresh(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
render_version: i32,
@@ -48,7 +42,6 @@ pub trait PrecomputedReelDao: Sync + Send {
#[allow(dead_code)]
fn prune_superseded(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
keep: usize,
@@ -57,25 +50,20 @@ pub trait PrecomputedReelDao: Sync + Send {
/// Every cache_key currently in the ledger. Used by the on-disk cache sweep
/// to protect files a ledger row still points at.
#[allow(dead_code)]
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError>;
fn all_cache_keys(&mut self) -> Result<Vec<String>, DbError>;
/// Every reel row for `span` whose `library_key` is one of
/// `library_keys`. Used by the true-instant date drain (ticket #118)
/// to find the day reels a re-dated photo invalidates.
fn list_for_span(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_keys: &[String],
) -> Result<Vec<PrecomputedReel>, DbError>;
/// Delete the given reel rows (stale-marking: the nightly pre-gen
/// finds no current row and regenerates). Returns rows deleted.
fn delete_ids(
&mut self,
context: &opentelemetry::Context,
ids: &[i32],
) -> Result<usize, DbError>;
fn delete_ids(&mut self, ids: &[i32]) -> Result<usize, DbError>;
}
pub struct SqlitePrecomputedReelDao {
@@ -102,12 +90,8 @@ impl SqlitePrecomputedReelDao {
}
impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn record_reel(
&mut self,
context: &opentelemetry::Context,
row: &InsertablePrecomputedReel,
) -> Result<i32, DbError> {
trace_db_call(context, "insert", "record_reel", |_span| {
fn record_reel(&mut self, row: &InsertablePrecomputedReel) -> Result<i32, DbError> {
trace_db_call("insert", "record_reel", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -131,11 +115,10 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn latest_for(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
) -> Result<Option<PrecomputedReel>, DbError> {
trace_db_call(context, "query", "latest_for", |_span| {
trace_db_call("query", "latest_for", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -156,13 +139,12 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn exists_fresh(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
render_version: i32,
min_generated_at: i64,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "exists_fresh", |_span| {
trace_db_call("query", "exists_fresh", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -186,12 +168,11 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn prune_superseded(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_key: &str,
keep: usize,
) -> Result<Vec<PrecomputedReel>, DbError> {
trace_db_call(context, "delete", "prune_superseded", |_span| {
trace_db_call("delete", "prune_superseded", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -221,8 +202,8 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError> {
trace_db_call(context, "query", "all_cache_keys", |_span| {
fn all_cache_keys(&mut self) -> Result<Vec<String>, DbError> {
trace_db_call("query", "all_cache_keys", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -240,11 +221,10 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
fn list_for_span(
&mut self,
context: &opentelemetry::Context,
span: &str,
library_keys: &[String],
) -> Result<Vec<PrecomputedReel>, DbError> {
trace_db_call(context, "query", "list_for_span", |_span| {
trace_db_call("query", "list_for_span", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -261,12 +241,8 @@ impl PrecomputedReelDao for SqlitePrecomputedReelDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_ids(
&mut self,
context: &opentelemetry::Context,
ids: &[i32],
) -> Result<usize, DbError> {
trace_db_call(context, "delete", "delete_ids", |_span| {
fn delete_ids(&mut self, ids: &[i32]) -> Result<usize, DbError> {
trace_db_call("delete", "delete_ids", |_span| {
use schema::precomputed_reels::dsl;
let mut connection = self
@@ -298,10 +274,6 @@ mod tests {
SqlitePrecomputedReelDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
fn sample_row() -> InsertablePrecomputedReel {
InsertablePrecomputedReel {
span: "day".to_string(),
@@ -320,28 +292,25 @@ mod tests {
#[test]
fn record_reel_inserts_and_returns_id() {
let mut dao = setup_dao();
let ctx = ctx();
let row = sample_row();
let id = dao.record_reel(&ctx, &row).unwrap();
let id = dao.record_reel(&row).unwrap();
assert!(id > 0, "should return a positive id");
}
#[test]
fn record_reel_returns_increasing_ids() {
let mut dao = setup_dao();
let ctx = ctx();
let row = sample_row();
let id1 = dao.record_reel(&ctx, &row).unwrap();
let id2 = dao.record_reel(&ctx, &row).unwrap();
let id1 = dao.record_reel(&row).unwrap();
let id2 = dao.record_reel(&row).unwrap();
assert!(id2 > id1, "each insert should get a higher id");
}
#[test]
fn latest_for_returns_latest() {
let mut dao = setup_dao();
let ctx = ctx();
let row1 = InsertablePrecomputedReel {
generated_at: 1_000_000,
@@ -352,17 +321,16 @@ mod tests {
..sample_row()
};
dao.record_reel(&ctx, &row1).unwrap();
dao.record_reel(&ctx, &row2).unwrap();
dao.record_reel(&row1).unwrap();
dao.record_reel(&row2).unwrap();
let latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
let latest = dao.latest_for("day", "1").unwrap().unwrap();
assert_eq!(latest.generated_at, 2_000_000);
}
#[test]
fn latest_for_scoped_by_span_and_library() {
let mut dao = setup_dao();
let ctx = ctx();
let day_row = InsertablePrecomputedReel {
span: "day".to_string(),
@@ -377,94 +345,85 @@ mod tests {
..sample_row()
};
dao.record_reel(&ctx, &day_row).unwrap();
dao.record_reel(&ctx, &week_row).unwrap();
dao.record_reel(&day_row).unwrap();
dao.record_reel(&week_row).unwrap();
let day_latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
let day_latest = dao.latest_for("day", "1").unwrap().unwrap();
assert_eq!(day_latest.span, "day");
let week_latest = dao.latest_for(&ctx, "week", "1").unwrap().unwrap();
let week_latest = dao.latest_for("week", "1").unwrap().unwrap();
assert_eq!(week_latest.span, "week");
// Different library returns None
let missing = dao.latest_for(&ctx, "day", "99").unwrap();
let missing = dao.latest_for("day", "99").unwrap();
assert!(missing.is_none());
}
#[test]
fn latest_for_returns_none_when_no_rows() {
let mut dao = setup_dao();
let ctx = ctx();
let result = dao.latest_for(&ctx, "day", "1").unwrap();
let result = dao.latest_for("day", "1").unwrap();
assert!(result.is_none());
}
#[test]
fn exists_fresh_returns_true_when_present() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
dao.record_reel(&sample_row()).unwrap();
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
let exists = dao.exists_fresh("day", "1", 1, 900_000).unwrap();
assert!(exists, "should find the row we just inserted");
}
#[test]
fn exists_fresh_returns_false_when_missing() {
let mut dao = setup_dao();
let ctx = ctx();
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
let exists = dao.exists_fresh("day", "1", 1, 900_000).unwrap();
assert!(!exists, "should not find anything in empty table");
}
#[test]
fn exists_fresh_respects_min_generated_at() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
dao.record_reel(&sample_row()).unwrap();
// Below the threshold — should exist
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 500_000).unwrap();
let exists = dao.exists_fresh("day", "1", 1, 500_000).unwrap();
assert!(exists);
// Above the threshold — should not exist
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 2_000_000).unwrap();
let exists = dao.exists_fresh("day", "1", 1, 2_000_000).unwrap();
assert!(!exists);
}
#[test]
fn exists_fresh_respects_render_version() {
let mut dao = setup_dao();
let ctx = ctx();
let row_v1 = InsertablePrecomputedReel {
render_version: 1,
..sample_row()
};
dao.record_reel(&ctx, &row_v1).unwrap();
dao.record_reel(&row_v1).unwrap();
assert!(dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap());
assert!(!dao.exists_fresh(&ctx, "day", "1", 2, 900_000).unwrap());
assert!(dao.exists_fresh("day", "1", 1, 900_000).unwrap());
assert!(!dao.exists_fresh("day", "1", 2, 900_000).unwrap());
}
#[test]
fn prune_superseded_keeps_newest_and_returns_deleted() {
let mut dao = setup_dao();
let ctx = ctx();
// Three day/lib1 reels at increasing timestamps, plus an unrelated one.
for (i, key) in ["k1", "k2", "k3"].iter().enumerate() {
dao.record_reel(
&ctx,
&InsertablePrecomputedReel {
cache_key: key.to_string(),
generated_at: 1_000_000 + i as i64 * 1000,
..sample_row()
},
)
dao.record_reel(&InsertablePrecomputedReel {
cache_key: key.to_string(),
generated_at: 1_000_000 + i as i64 * 1000,
..sample_row()
})
.unwrap();
}
let other = InsertablePrecomputedReel {
@@ -472,15 +431,15 @@ mod tests {
cache_key: "other".to_string(),
..sample_row()
};
dao.record_reel(&ctx, &other).unwrap();
dao.record_reel(&other).unwrap();
// Keep the newest 2 of (day, "1"); k1 (oldest) is superseded.
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
let deleted = dao.prune_superseded("day", "1", 2).unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0].cache_key, "k1");
// The newest 2 survive; the other-library row is untouched.
let keys = dao.all_cache_keys(&ctx).unwrap();
let keys = dao.all_cache_keys().unwrap();
assert_eq!(keys.len(), 3);
assert!(keys.contains(&"k2".to_string()));
assert!(keys.contains(&"k3".to_string()));
@@ -491,10 +450,9 @@ mod tests {
#[test]
fn prune_superseded_noop_when_within_keep() {
let mut dao = setup_dao();
let ctx = ctx();
dao.record_reel(&ctx, &sample_row()).unwrap();
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
dao.record_reel(&sample_row()).unwrap();
let deleted = dao.prune_superseded("day", "1", 2).unwrap();
assert!(deleted.is_empty());
assert_eq!(dao.all_cache_keys(&ctx).unwrap().len(), 1);
assert_eq!(dao.all_cache_keys().unwrap().len(), 1);
}
}
+33 -81
View File
@@ -10,16 +10,10 @@ use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
pub trait PreviewDao: Sync + Send {
fn insert_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError>;
fn insert_preview(&mut self, file_path_val: &str, status_val: &str) -> Result<(), DbError>;
fn update_status(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
@@ -27,23 +21,14 @@ pub trait PreviewDao: Sync + Send {
error: Option<&str>,
) -> Result<(), DbError>;
fn get_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError>;
fn get_preview(&mut self, file_path_val: &str) -> Result<Option<VideoPreviewClip>, DbError>;
fn get_previews_batch(
&mut self,
context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError>;
fn get_by_status(
&mut self,
context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError>;
fn get_by_status(&mut self, status_val: &str) -> Result<Vec<VideoPreviewClip>, DbError>;
}
pub struct SqlitePreviewDao {
@@ -72,13 +57,8 @@ impl SqlitePreviewDao {
}
impl PreviewDao for SqlitePreviewDao {
fn insert_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError> {
trace_db_call(context, "insert", "insert_preview", |_span| {
fn insert_preview(&mut self, file_path_val: &str, status_val: &str) -> Result<(), DbError> {
trace_db_call("insert", "insert_preview", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
@@ -101,14 +81,13 @@ impl PreviewDao for SqlitePreviewDao {
fn update_status(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
size: Option<i32>,
error: Option<&str>,
) -> Result<(), DbError> {
trace_db_call(context, "update", "update_preview_status", |_span| {
trace_db_call("update", "update_preview_status", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
@@ -129,12 +108,8 @@ impl PreviewDao for SqlitePreviewDao {
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn get_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_preview", |_span| {
fn get_preview(&mut self, file_path_val: &str) -> Result<Option<VideoPreviewClip>, DbError> {
trace_db_call("query", "get_preview", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
@@ -153,10 +128,9 @@ impl PreviewDao for SqlitePreviewDao {
fn get_previews_batch(
&mut self,
context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_previews_batch", |_span| {
trace_db_call("query", "get_previews_batch", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
if file_paths.is_empty() {
@@ -173,12 +147,8 @@ impl PreviewDao for SqlitePreviewDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_by_status(
&mut self,
context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_previews_by_status", |_span| {
fn get_by_status(&mut self, status_val: &str) -> Result<Vec<VideoPreviewClip>, DbError> {
trace_db_call("query", "get_previews_by_status", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
@@ -201,19 +171,13 @@ mod tests {
SqlitePreviewDao::from_connection(in_memory_db_connection())
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn test_insert_and_get_preview() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.insert_preview("photos/video.mp4", "pending").unwrap();
let result = dao.get_preview(&ctx, "photos/video.mp4").unwrap();
let result = dao.get_preview("photos/video.mp4").unwrap();
assert!(result.is_some());
let clip = result.unwrap();
assert_eq!(clip.file_path, "photos/video.mp4");
@@ -226,28 +190,23 @@ mod tests {
#[test]
fn test_insert_duplicate_ignored() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.insert_preview("photos/video.mp4", "pending").unwrap();
// Second insert with same path should not error (INSERT OR IGNORE)
dao.insert_preview(&ctx, "photos/video.mp4", "processing")
dao.insert_preview("photos/video.mp4", "processing")
.unwrap();
// Status should remain "pending" from the first insert
let clip = dao.get_preview(&ctx, "photos/video.mp4").unwrap().unwrap();
let clip = dao.get_preview("photos/video.mp4").unwrap().unwrap();
assert_eq!(clip.status, "pending");
}
#[test]
fn test_update_status_to_complete() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.insert_preview("photos/video.mp4", "pending").unwrap();
dao.update_status(
&ctx,
"photos/video.mp4",
"complete",
Some(9.5),
@@ -256,7 +215,7 @@ mod tests {
)
.unwrap();
let clip = dao.get_preview(&ctx, "photos/video.mp4").unwrap().unwrap();
let clip = dao.get_preview("photos/video.mp4").unwrap().unwrap();
assert_eq!(clip.status, "complete");
assert_eq!(clip.duration_seconds, Some(9.5));
assert_eq!(clip.file_size_bytes, Some(1024000));
@@ -266,12 +225,9 @@ mod tests {
#[test]
fn test_update_status_to_failed() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.insert_preview("photos/video.mp4", "pending").unwrap();
dao.update_status(
&ctx,
"photos/video.mp4",
"failed",
None,
@@ -280,7 +236,7 @@ mod tests {
)
.unwrap();
let clip = dao.get_preview(&ctx, "photos/video.mp4").unwrap().unwrap();
let clip = dao.get_preview("photos/video.mp4").unwrap().unwrap();
assert_eq!(clip.status, "failed");
assert_eq!(
clip.error_message.as_deref(),
@@ -291,24 +247,22 @@ mod tests {
#[test]
fn test_get_preview_not_found() {
let mut dao = setup_dao();
let ctx = ctx();
let result = dao.get_preview(&ctx, "nonexistent/path.mp4").unwrap();
let result = dao.get_preview("nonexistent/path.mp4").unwrap();
assert!(result.is_none());
}
#[test]
fn test_get_previews_batch() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "a/one.mp4", "complete").unwrap();
dao.insert_preview(&ctx, "b/two.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "c/three.mp4", "failed").unwrap();
dao.insert_preview("a/one.mp4", "complete").unwrap();
dao.insert_preview("b/two.mp4", "pending").unwrap();
dao.insert_preview("c/three.mp4", "failed").unwrap();
// Query only two of the three
let paths = vec!["a/one.mp4".to_string(), "c/three.mp4".to_string()];
let results = dao.get_previews_batch(&ctx, &paths).unwrap();
let results = dao.get_previews_batch(&paths).unwrap();
assert_eq!(results.len(), 2);
let statuses: Vec<&str> = results.iter().map(|c| c.status.as_str()).collect();
@@ -319,30 +273,28 @@ mod tests {
#[test]
fn test_get_previews_batch_empty_input() {
let mut dao = setup_dao();
let ctx = ctx();
let results = dao.get_previews_batch(&ctx, &[]).unwrap();
let results = dao.get_previews_batch(&[]).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_get_by_status() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "a.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "b.mp4", "complete").unwrap();
dao.insert_preview(&ctx, "c.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "d.mp4", "failed").unwrap();
dao.insert_preview("a.mp4", "pending").unwrap();
dao.insert_preview("b.mp4", "complete").unwrap();
dao.insert_preview("c.mp4", "pending").unwrap();
dao.insert_preview("d.mp4", "failed").unwrap();
let pending = dao.get_by_status(&ctx, "pending").unwrap();
let pending = dao.get_by_status("pending").unwrap();
assert_eq!(pending.len(), 2);
let complete = dao.get_by_status(&ctx, "complete").unwrap();
let complete = dao.get_by_status("complete").unwrap();
assert_eq!(complete.len(), 1);
assert_eq!(complete[0].file_path, "b.mp4");
let processing = dao.get_by_status(&ctx, "processing").unwrap();
let processing = dao.get_by_status("processing").unwrap();
assert!(processing.is_empty());
}
}
+15 -43
View File
@@ -33,23 +33,15 @@ pub struct InsertSearchRecord {
pub trait SearchHistoryDao: Sync + Send {
/// Store search with embedding
fn store_search(
&mut self,
context: &opentelemetry::Context,
search: InsertSearchRecord,
) -> Result<SearchRecord, DbError>;
fn store_search(&mut self, search: InsertSearchRecord) -> Result<SearchRecord, DbError>;
/// Batch insert searches
fn store_searches_batch(
&mut self,
context: &opentelemetry::Context,
searches: Vec<InsertSearchRecord>,
) -> Result<usize, DbError>;
fn store_searches_batch(&mut self, searches: Vec<InsertSearchRecord>)
-> Result<usize, DbError>;
/// Find searches in time range (for temporal context)
fn find_searches_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<SearchRecord>, DbError>;
@@ -57,7 +49,6 @@ pub trait SearchHistoryDao: Sync + Send {
/// Find semantically similar searches (PRIMARY - embeddings shine here)
fn find_similar_searches(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<SearchRecord>, DbError>;
@@ -65,7 +56,6 @@ pub trait SearchHistoryDao: Sync + Send {
/// Hybrid: Time window + semantic ranking
fn find_relevant_searches_hybrid(
&mut self,
context: &opentelemetry::Context,
center_timestamp: i64,
time_window_days: i64,
query_embedding: Option<&[f32]>,
@@ -73,15 +63,10 @@ pub trait SearchHistoryDao: Sync + Send {
) -> Result<Vec<SearchRecord>, DbError>;
/// Deduplication check
fn search_exists(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
query: &str,
) -> Result<bool, DbError>;
fn search_exists(&mut self, timestamp: i64, query: &str) -> Result<bool, DbError>;
/// Get count of search records
fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError>;
fn get_search_count(&mut self) -> Result<i64, DbError>;
}
pub struct SqliteSearchHistoryDao {
@@ -177,12 +162,8 @@ struct LastInsertRowId {
}
impl SearchHistoryDao for SqliteSearchHistoryDao {
fn store_search(
&mut self,
context: &opentelemetry::Context,
search: InsertSearchRecord,
) -> Result<SearchRecord, DbError> {
trace_db_call(context, "insert", "store_search", |_span| {
fn store_search(&mut self, search: InsertSearchRecord) -> Result<SearchRecord, DbError> {
trace_db_call("insert", "store_search", |_span| {
let mut conn = self
.connection
.lock()
@@ -233,10 +214,9 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
fn store_searches_batch(
&mut self,
context: &opentelemetry::Context,
searches: Vec<InsertSearchRecord>,
) -> Result<usize, DbError> {
trace_db_call(context, "insert", "store_searches_batch", |_span| {
trace_db_call("insert", "store_searches_batch", |_span| {
let mut conn = self
.connection
.lock()
@@ -289,11 +269,10 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
fn find_searches_in_range(
&mut self,
context: &opentelemetry::Context,
start_ts: i64,
end_ts: i64,
) -> Result<Vec<SearchRecord>, DbError> {
trace_db_call(context, "query", "find_searches_in_range", |_span| {
trace_db_call("query", "find_searches_in_range", |_span| {
let mut conn = self
.connection
.lock()
@@ -316,11 +295,10 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
fn find_similar_searches(
&mut self,
context: &opentelemetry::Context,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<SearchRecord>, DbError> {
trace_db_call(context, "query", "find_similar_searches", |_span| {
trace_db_call("query", "find_similar_searches", |_span| {
let mut conn = self
.connection
.lock()
@@ -379,13 +357,12 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
fn find_relevant_searches_hybrid(
&mut self,
context: &opentelemetry::Context,
center_timestamp: i64,
time_window_days: i64,
query_embedding: Option<&[f32]>,
limit: usize,
) -> Result<Vec<SearchRecord>, DbError> {
trace_db_call(context, "query", "find_relevant_searches_hybrid", |_span| {
trace_db_call("query", "find_relevant_searches_hybrid", |_span| {
let window_seconds = time_window_days * 86400;
let start_ts = center_timestamp - window_seconds;
let end_ts = center_timestamp + window_seconds;
@@ -465,13 +442,8 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn search_exists(
&mut self,
context: &opentelemetry::Context,
timestamp: i64,
query: &str,
) -> Result<bool, DbError> {
trace_db_call(context, "query", "search_exists", |_span| {
fn search_exists(&mut self, timestamp: i64, query: &str) -> Result<bool, DbError> {
trace_db_call("query", "search_exists", |_span| {
let mut conn = self
.connection
.lock()
@@ -496,8 +468,8 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
trace_db_call(context, "query", "get_search_count", |_span| {
fn get_search_count(&mut self) -> Result<i64, DbError> {
trace_db_call("query", "get_search_count", |_span| {
let mut conn = self
.connection
.lock()
+14 -32
View File
@@ -13,18 +13,11 @@ use crate::otel::trace_db_call;
/// pre-generation scheduler (Section D) to pick up user preferences.
pub trait UserAiPrefsDao: Sync + Send {
/// Read the single row; `None` when it hasn't been populated yet.
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError>;
fn get_prefs(&mut self) -> Result<Option<UserAiPrefs>, DbError>;
/// Upsert the single row (id is always 1).
#[allow(dead_code)]
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError>;
fn upsert_prefs(&mut self, prefs: &UpsertUserAiPrefs) -> Result<(), DbError>;
}
pub struct SqliteUserAiPrefsDao {
@@ -51,11 +44,8 @@ impl SqliteUserAiPrefsDao {
}
impl UserAiPrefsDao for SqliteUserAiPrefsDao {
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError> {
trace_db_call(context, "query", "get_prefs", |_span| {
fn get_prefs(&mut self) -> Result<Option<UserAiPrefs>, DbError> {
trace_db_call("query", "get_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
@@ -71,12 +61,8 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError> {
trace_db_call(context, "upsert", "upsert_prefs", |_span| {
fn upsert_prefs(&mut self, prefs: &UpsertUserAiPrefs) -> Result<(), DbError> {
trace_db_call("upsert", "upsert_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
@@ -131,14 +117,10 @@ mod tests {
SqliteUserAiPrefsDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn get_prefs_returns_none_when_empty() {
let mut dao = setup_dao();
let result = dao.get_prefs(&ctx()).unwrap();
let result = dao.get_prefs().unwrap();
assert!(result.is_none());
}
@@ -158,9 +140,9 @@ mod tests {
reel_span: Some("week".to_string()),
reel_max_segments: Some(24),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
dao.upsert_prefs(&prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.id, 1);
assert_eq!(row.voice, Some("grandma".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-480));
@@ -192,7 +174,7 @@ mod tests {
reel_span: Some("week".to_string()),
reel_max_segments: Some(24),
};
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
dao.upsert_prefs(&prefs1).unwrap();
let prefs2 = UpsertUserAiPrefs {
voice: Some("dad".to_string()),
@@ -206,9 +188,9 @@ mod tests {
reel_span: None,
reel_max_segments: None,
};
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
dao.upsert_prefs(&prefs2).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.voice, Some("dad".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none());
@@ -238,9 +220,9 @@ mod tests {
reel_span: Some("month".to_string()),
reel_max_segments: None,
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
dao.upsert_prefs(&prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.tz_offset_minutes, Some(-480));
assert!(row.voice.is_none());
assert!(row.library.is_none());
+14 -46
View File
@@ -20,16 +20,14 @@ use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use actix_web::{App, HttpRequest, HttpResponse, Responder, dev::ServiceFactory, web};
use actix_web::{App, HttpResponse, Responder, dev::ServiceFactory, web};
use bk_tree::{BKTree, Metric};
use lazy_static::lazy_static;
use opentelemetry::trace::{TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use crate::data::Claims;
use crate::database::{DuplicateRow, ExifDao};
use crate::libraries;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
// ── Cache ────────────────────────────────────────────────────────────────
@@ -223,17 +221,13 @@ pub struct FolderPair {
// ── Handlers ─────────────────────────────────────────────────────────────
#[tracing::instrument(name = "duplicates.list_exact", skip_all)]
async fn list_exact_handler(
_: Claims,
request: HttpRequest,
app_state: web::Data<AppState>,
query: web::Query<ListDuplicatesQuery>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let context = extract_context_from_request(&request);
let span = global_tracer().start_with_context("duplicates.list_exact", &context);
let span_context = opentelemetry::Context::current_with_span(span);
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
.ok()
.flatten()
@@ -242,7 +236,7 @@ async fn list_exact_handler(
let rows = {
let mut dao = exif_dao.lock().expect("exif dao lock");
match dao.list_duplicates_exact(&span_context, library_id, include_resolved) {
match dao.list_duplicates_exact(library_id, include_resolved) {
Ok(rows) => rows,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
@@ -254,17 +248,13 @@ async fn list_exact_handler(
HttpResponse::Ok().json(GroupsResponse { groups })
}
#[tracing::instrument(name = "duplicates.list_perceptual", skip_all)]
async fn list_perceptual_handler(
_: Claims,
request: HttpRequest,
app_state: web::Data<AppState>,
query: web::Query<ListDuplicatesQuery>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let context = extract_context_from_request(&request);
let span = global_tracer().start_with_context("duplicates.list_perceptual", &context);
let span_context = opentelemetry::Context::current_with_span(span);
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
.ok()
.flatten()
@@ -287,7 +277,7 @@ async fn list_perceptual_handler(
let rows = {
let mut dao = exif_dao.lock().expect("exif dao lock");
match dao.list_perceptual_candidates(&span_context, library_id, include_resolved) {
match dao.list_perceptual_candidates(library_id, include_resolved) {
Ok(rows) => rows,
Err(e) => {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
@@ -310,16 +300,12 @@ async fn list_perceptual_handler(
HttpResponse::Ok().json(GroupsResponse { groups })
}
#[tracing::instrument(name = "duplicates.resolve", skip_all)]
async fn resolve_handler(
_: Claims,
request: HttpRequest,
body: web::Json<ResolveDuplicatesReq>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let context = extract_context_from_request(&request);
let span = global_tracer().start_with_context("duplicates.resolve", &context);
let span_context = opentelemetry::Context::current_with_span(span);
if body.demoted.is_empty() {
return HttpResponse::BadRequest().body("demoted list is empty");
}
@@ -328,11 +314,8 @@ async fn resolve_handler(
// Resolve survivor → its content_hash, plus the canonical rel_path
// we'll use as the destination for any tag-union INSERTs.
let survivor = match dao.lookup_duplicate_row(
&span_context,
body.survivor.library_id,
&body.survivor.rel_path,
) {
let survivor = match dao.lookup_duplicate_row(body.survivor.library_id, &body.survivor.rel_path)
{
Ok(Some(row)) => row,
Ok(None) => return HttpResponse::NotFound().body("survivor not found"),
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
@@ -348,11 +331,7 @@ async fn resolve_handler(
let mut resolved_count = 0usize;
for member_ref in &body.demoted {
let demoted = match dao.lookup_duplicate_row(
&span_context,
member_ref.library_id,
&member_ref.rel_path,
) {
let demoted = match dao.lookup_duplicate_row(member_ref.library_id, &member_ref.rel_path) {
Ok(Some(row)) => row,
Ok(None) => {
log::warn!(
@@ -379,7 +358,6 @@ async fn resolve_handler(
// already shared at the bytes layer — the union is a no-op.
if demoted.content_hash != survivor.content_hash
&& let Err(e) = dao.union_perceptual_tags(
&span_context,
&survivor.content_hash,
&demoted.content_hash,
&survivor.rel_path,
@@ -397,7 +375,6 @@ async fn resolve_handler(
}
if let Err(e) = dao.set_duplicate_of(
&span_context,
demoted.library_id,
&demoted.rel_path,
&survivor.content_hash,
@@ -416,18 +393,14 @@ async fn resolve_handler(
HttpResponse::Ok().json(ResolveResponse { resolved_count })
}
#[tracing::instrument(name = "duplicates.unresolve", skip_all)]
async fn unresolve_handler(
_: Claims,
request: HttpRequest,
body: web::Json<UnresolveDuplicateReq>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let context = extract_context_from_request(&request);
let span = global_tracer().start_with_context("duplicates.unresolve", &context);
let span_context = opentelemetry::Context::current_with_span(span);
let mut dao = exif_dao.lock().expect("exif dao lock");
if let Err(e) = dao.clear_duplicate_of(&span_context, body.library_id, &body.rel_path) {
if let Err(e) = dao.clear_duplicate_of(body.library_id, &body.rel_path) {
return HttpResponse::InternalServerError().body(format!("{:?}", e));
}
@@ -438,17 +411,13 @@ async fn unresolve_handler(
HttpResponse::Ok().finish()
}
#[tracing::instrument(name = "duplicates.list_folder_pairs", skip_all)]
async fn list_folder_pairs_handler(
_: Claims,
request: HttpRequest,
app_state: web::Data<AppState>,
query: web::Query<ListFolderPairsQuery>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let context = extract_context_from_request(&request);
let span = global_tracer().start_with_context("duplicates.list_folder_pairs", &context);
let span_context = opentelemetry::Context::current_with_span(span);
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
.ok()
.flatten()
@@ -472,12 +441,11 @@ async fn list_folder_pairs_handler(
let (dup_rows, all_paths) = {
let mut dao = exif_dao.lock().expect("exif dao lock");
let dup_rows = match dao.list_duplicates_exact(&span_context, library_id, include_resolved)
{
let dup_rows = match dao.list_duplicates_exact(library_id, include_resolved) {
Ok(rows) => rows,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
let all_paths = match dao.list_image_paths(&span_context, library_id, include_resolved) {
let all_paths = match dao.list_image_paths(library_id, include_resolved) {
Ok(rows) => rows,
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
};
+19 -31
View File
@@ -156,7 +156,6 @@ async fn process_one(
orientation: None,
model_version: None,
};
let ctx = opentelemetry::Context::current();
match face_client.detect(bytes, meta).await {
Ok(resp) => {
@@ -167,7 +166,6 @@ async fn process_one(
let mut dao = face_dao.lock().expect("face dao");
if resp.faces.is_empty() {
if let Err(e) = dao.mark_status(
&ctx,
library_id,
&cand.content_hash,
&cand.rel_path,
@@ -195,21 +193,18 @@ async fn process_one(
};
// Decode the f32 vector once for auto-bind comparison.
let emb_floats = faces::decode_embedding_bytes(&emb);
match dao.store_detection(
&ctx,
InsertFaceDetectionInput {
library_id,
content_hash: cand.content_hash.clone(),
rel_path: cand.rel_path.clone(),
bbox: Some((face.bbox.x, face.bbox.y, face.bbox.w, face.bbox.h)),
embedding: Some(emb),
confidence: Some(face.confidence),
source: "auto".to_string(),
person_id: None,
status: "detected".to_string(),
model_version: resp.model_version.clone(),
},
) {
match dao.store_detection(InsertFaceDetectionInput {
library_id,
content_hash: cand.content_hash.clone(),
rel_path: cand.rel_path.clone(),
bbox: Some((face.bbox.x, face.bbox.y, face.bbox.w, face.bbox.h)),
embedding: Some(emb),
confidence: Some(face.confidence),
source: "auto".to_string(),
person_id: None,
status: "detected".to_string(),
model_version: resp.model_version.clone(),
}) {
Ok(row) => {
if let Some(floats) = emb_floats {
stored_for_autobind.push((row.id, floats));
@@ -233,7 +228,6 @@ async fn process_one(
// serialize with concurrent detect tasks.
if !stored_for_autobind.is_empty() {
try_auto_bind(
&ctx,
&cand.rel_path,
&resp.model_version,
stored_for_autobind,
@@ -251,14 +245,9 @@ async fn process_one(
// model_version is best-effort here — the engine that rejected
// the bytes may not have echoed one. Empty string is fine; this
// row is purely a "don't retry" sentinel.
if let Err(e) = dao.mark_status(
&ctx,
library_id,
&cand.content_hash,
&cand.rel_path,
"failed",
"",
) {
if let Err(e) =
dao.mark_status(library_id, &cand.content_hash, &cand.rel_path, "failed", "")
{
warn!(
"face_watch: mark failed errored for {}: {:?}",
cand.rel_path, e
@@ -293,7 +282,6 @@ async fn process_one(
/// additional matching. Those faces stay unassigned for the cluster
/// suggester (Phase 6) to handle.
fn try_auto_bind(
ctx: &opentelemetry::Context,
rel_path: &str,
model_version: &str,
new_faces: Vec<(i32, Vec<f32>)>, // (face_id, decoded embedding)
@@ -303,7 +291,7 @@ fn try_auto_bind(
// 1. Pull the photo's tags.
let tag_names: Vec<String> = {
let mut td = tag_dao.lock().expect("tag dao");
match td.get_tags_for_path(ctx, rel_path) {
match td.get_tags_for_path(rel_path) {
Ok(tags) => tags.into_iter().map(|t| t.name).collect(),
Err(e) => {
warn!(
@@ -321,7 +309,7 @@ fn try_auto_bind(
// 2. Find tags that map to existing persons (case-insensitive).
let person_for_tag: std::collections::HashMap<String, i32> = {
let mut fd = face_dao.lock().expect("face dao");
match fd.find_persons_by_names_ci(ctx, &tag_names) {
match fd.find_persons_by_names_ci(&tag_names) {
Ok(m) => m,
Err(e) => {
warn!(
@@ -357,7 +345,7 @@ fn try_auto_bind(
// 4. Reference embedding (if any) under the same model_version.
let reference: Option<Vec<f32>> = {
let mut fd = face_dao.lock().expect("face dao");
match fd.person_reference_embedding(ctx, person_id, model_version) {
match fd.person_reference_embedding(person_id, model_version) {
Ok(r) => r,
Err(e) => {
warn!(
@@ -402,7 +390,7 @@ fn try_auto_bind(
}
}
};
if bind && let Err(e) = fd.assign_face_to_person(ctx, face_id, person_id) {
if bind && let Err(e) = fd.assign_face_to_person(face_id, person_id) {
warn!(
"face_watch: assign_face_to_person failed (face={}, person={}): {:?}",
face_id, person_id, e
+232 -462
View File
File diff suppressed because it is too large. Load diff
+137 -297
View File
@@ -9,6 +9,7 @@ use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::AppState;
use crate::data::{
@@ -23,17 +24,17 @@ use crate::thumbnails::create_thumbnails;
use crate::utils::earliest_fs_time;
use actix_web::web::Data;
use actix_web::{
HttpRequest, HttpResponse,
HttpResponse,
web::{self, Query},
};
use chrono::{DateTime, Utc};
use log::{debug, error, info, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::trace::{Status, Tracer};
use crate::data::SortType::NameAsc;
use crate::error::IntoHttpError;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::otel::global_tracer;
use crate::tags::{FileWithTagCount, TagDao};
use crate::video::actors::StreamActor;
use path_absolutize::*;
@@ -58,7 +59,6 @@ fn apply_sorting_with_exif(
file_libraries: Vec<i32>,
sort_type: SortType,
exif_dao: &mut Box<dyn ExifDao>,
span_context: &opentelemetry::Context,
libraries: &[crate::libraries::Library],
limit: Option<i64>,
offset: i64,
@@ -73,7 +73,6 @@ fn apply_sorting_with_exif(
file_libraries,
sort_type,
exif_dao,
span_context,
libraries,
limit,
offset,
@@ -103,7 +102,6 @@ fn in_memory_date_sort(
file_libraries: Vec<i32>,
sort_type: SortType,
exif_dao: &mut Box<dyn ExifDao>,
span_context: &opentelemetry::Context,
libraries: &[crate::libraries::Library],
limit: Option<i64>,
offset: i64,
@@ -122,7 +120,7 @@ fn in_memory_date_sort(
_ => None,
};
let exif_rows = exif_dao
.get_exif_batch(span_context, scope_library, &file_paths)
.get_exif_batch(scope_library, &file_paths)
.unwrap_or_default();
let exif_map: std::collections::HashMap<(String, i32), i64> = exif_rows
.into_iter()
@@ -193,9 +191,9 @@ fn in_memory_date_sort(
(result, result_libs, total_count)
}
#[tracing::instrument(name = "list_photos", skip_all)]
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
_: Claims,
request: HttpRequest,
req: Query<FilesRequest>,
app_state: Data<AppState>,
file_system: Data<FS>,
@@ -204,75 +202,76 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
) -> HttpResponse {
let search_path = &req.path;
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("list_photos", &context);
span.set_attributes(vec![
KeyValue::new("path", search_path.to_string()),
KeyValue::new("recursive", req.recursive.unwrap_or(false).to_string()),
KeyValue::new(
"tag_ids",
req.tag_ids.clone().unwrap_or_default().to_string(),
),
KeyValue::new(
"tag_filter_mode",
format!("{:?}", req.tag_filter_mode.unwrap_or(FilterMode::Any)),
),
KeyValue::new(
"exclude_tag_ids",
req.exclude_tag_ids.clone().unwrap_or_default().to_string(),
),
KeyValue::new("sort", format!("{:?}", &req.sort.unwrap_or(NameAsc))),
// EXIF search parameters
KeyValue::new("camera_make", req.camera_make.clone().unwrap_or_default()),
KeyValue::new("camera_model", req.camera_model.clone().unwrap_or_default()),
KeyValue::new("lens_model", req.lens_model.clone().unwrap_or_default()),
KeyValue::new(
"gps_lat",
req.gps_lat.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"gps_lon",
req.gps_lon.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"gps_radius_km",
req.gps_radius_km.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"date_from",
req.date_from.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"date_to",
req.date_to.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"media_type",
req.media_type
.as_ref()
.map(|mt| format!("{:?}", mt))
.unwrap_or_default(),
),
// Pagination parameters
KeyValue::new("pagination.enabled", req.limit.is_some().to_string()),
KeyValue::new(
"pagination.limit",
req.limit.map(|l| l.to_string()).unwrap_or_default(),
),
KeyValue::new("pagination.offset", req.offset.unwrap_or(0).to_string()),
// Optimization flags
KeyValue::new("optimization.batch_tags", "true"),
KeyValue::new(
"optimization.db_sort",
matches!(
req.sort,
Some(SortType::DateTakenAsc | SortType::DateTakenDesc)
)
.to_string(),
),
KeyValue::new("library", req.library.clone().unwrap_or_default()),
]);
let span = tracing::Span::current();
crate::otel::set_span_attributes(
&span,
vec![
KeyValue::new("path", search_path.to_string()),
KeyValue::new("recursive", req.recursive.unwrap_or(false).to_string()),
KeyValue::new(
"tag_ids",
req.tag_ids.clone().unwrap_or_default().to_string(),
),
KeyValue::new(
"tag_filter_mode",
format!("{:?}", req.tag_filter_mode.unwrap_or(FilterMode::Any)),
),
KeyValue::new(
"exclude_tag_ids",
req.exclude_tag_ids.clone().unwrap_or_default().to_string(),
),
KeyValue::new("sort", format!("{:?}", &req.sort.unwrap_or(NameAsc))),
// EXIF search parameters
KeyValue::new("camera_make", req.camera_make.clone().unwrap_or_default()),
KeyValue::new("camera_model", req.camera_model.clone().unwrap_or_default()),
KeyValue::new("lens_model", req.lens_model.clone().unwrap_or_default()),
KeyValue::new(
"gps_lat",
req.gps_lat.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"gps_lon",
req.gps_lon.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"gps_radius_km",
req.gps_radius_km.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"date_from",
req.date_from.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"date_to",
req.date_to.map(|v| v.to_string()).unwrap_or_default(),
),
KeyValue::new(
"media_type",
req.media_type
.as_ref()
.map(|mt| format!("{:?}", mt))
.unwrap_or_default(),
),
// Pagination parameters
KeyValue::new("pagination.enabled", req.limit.is_some().to_string()),
KeyValue::new(
"pagination.limit",
req.limit.map(|l| l.to_string()).unwrap_or_default(),
),
KeyValue::new("pagination.offset", req.offset.unwrap_or(0).to_string()),
// Optimization flags
KeyValue::new("optimization.batch_tags", "true"),
KeyValue::new(
"optimization.db_sort",
matches!(
req.sort,
Some(SortType::DateTakenAsc | SortType::DateTakenDesc)
)
.to_string(),
),
KeyValue::new("library", req.library.clone().unwrap_or_default()),
],
);
// Resolve the optional library filter. Unknown values return 400. A
// `None` result means "union across all libraries" and downstream
@@ -286,8 +285,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
}
};
let span_context = opentelemetry::Context::current_with_span(span);
// Check if EXIF filtering is requested
let has_exif_filters = req.camera_make.is_some()
|| req.camera_model.is_some()
@@ -303,9 +300,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
&& !(req.gps_lat.is_some() && req.gps_lon.is_some() && req.gps_radius_km.is_some())
{
warn!("GPS search requires lat, lon, and radius_km to all be specified");
span_context
.span()
.set_status(Status::error("Invalid GPS parameters"));
span.set_status(Status::error("Invalid GPS parameters"));
return HttpResponse::BadRequest().body("GPS search requires lat, lon, and radius_km");
}
@@ -326,7 +321,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
let exif_results = exif_dao_guard
.query_by_exif(
&span_context,
library.map(|l| l.id),
req.camera_make.as_deref(),
req.camera_model.as_deref(),
@@ -407,12 +401,8 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
.collect::<Vec<i32>>();
return match filter_mode {
FilterMode::Any => {
dao.get_files_with_any_tag_ids(tag_ids.clone(), exclude_tag_ids, &span_context)
}
FilterMode::All => {
dao.get_files_with_all_tag_ids(tag_ids.clone(), exclude_tag_ids, &span_context)
}
FilterMode::Any => dao.get_files_with_any_tag_ids(tag_ids.clone(), exclude_tag_ids),
FilterMode::All => dao.get_files_with_all_tag_ids(tag_ids.clone(), exclude_tag_ids),
}
.context(format!(
"Failed to get files with tag_ids: {:?} with filter_mode: {:?}",
@@ -467,13 +457,8 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
}
let siblings = {
let mut dao = exif_dao.lock().expect("Unable to get ExifDao");
match dao
.find_content_hash_anywhere(&span_context, &f.file_name)
.unwrap_or(None)
{
Some(hash) => dao
.get_rel_paths_by_hash(&span_context, &hash)
.unwrap_or_default(),
match dao.find_content_hash_anywhere(&f.file_name).unwrap_or(None) {
Some(hash) => dao.get_rel_paths_by_hash(&hash).unwrap_or_default(),
None => Vec::new(),
}
};
@@ -511,7 +496,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
file_libs,
sort_type,
&mut exif_dao_guard,
&span_context,
&app_state.libraries,
limit,
offset,
@@ -543,13 +527,9 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
(None, None, None)
};
span_context
.span()
.set_attribute(KeyValue::new("file_count", tagged_files.len().to_string()));
span_context
.span()
.set_attribute(KeyValue::new("total_count", total_count.to_string()));
span_context.span().set_status(Status::Ok);
span.set_attribute("file_count", tagged_files.len().to_string());
span.set_attribute("total_count", total_count.to_string());
span.set_status(Status::Ok);
HttpResponse::Ok().json(PhotosResponse {
photos: tagged_files,
@@ -589,7 +569,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
let include_duplicates = req.include_duplicates.unwrap_or(false);
let rows = {
let mut dao = exif_dao.lock().expect("Unable to get ExifDao");
dao.list_rel_paths_for_libraries(&span_context, &lib_ids, prefix, include_duplicates)
dao.list_rel_paths_for_libraries(&lib_ids, prefix, include_duplicates)
.unwrap_or_else(|e| {
warn!("list_rel_paths_for_libraries failed: {:?}", e);
Vec::new()
@@ -689,9 +669,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
if !any_library_resolved {
error!("Bad photos request: {}", req.path);
span_context
.span()
.set_status(Status::error("Invalid path"));
span.set_status(Status::error("Invalid path"));
return HttpResponse::BadRequest().finish();
}
@@ -714,7 +692,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
let tag_counts = {
let mut tag_dao_guard = tag_dao.lock().expect("Unable to get TagDao");
tag_dao_guard
.get_tag_counts_batch(&span_context, &file_names)
.get_tag_counts_batch(&file_names)
.unwrap_or_default()
};
info!("Batch tag count query took {:?}", start_tags.elapsed());
@@ -731,7 +709,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
.iter()
.filter_map(|file_name| {
tag_dao_guard
.get_tags_for_path(&span_context, file_name)
.get_tags_for_path(file_name)
.ok()
.map(|tags| (file_name.clone(), tags))
})
@@ -826,7 +804,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
file_libs_sorted_input,
sort_type,
&mut exif_dao_guard,
&span_context,
&app_state.libraries,
limit,
offset,
@@ -875,17 +852,10 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
(None, None, None)
};
span_context.span().set_attribute(KeyValue::new(
"file_count",
response_files.len().to_string(),
));
span_context
.span()
.set_attribute(KeyValue::new("returned_count", returned_count.to_string()));
span_context
.span()
.set_attribute(KeyValue::new("total_count", total_count.to_string()));
span_context.span().set_status(Status::Ok);
span.set_attribute("file_count", response_files.len().to_string());
span.set_attribute("returned_count", returned_count.to_string());
span.set_attribute("total_count", total_count.to_string());
span.set_status(Status::Ok);
HttpResponse::Ok().json(PhotosResponse {
photos: response_files,
@@ -965,11 +935,11 @@ fn sort_with_metadata(
.unzip()
}
#[tracing::instrument(name = "list_files", skip_all)]
pub fn list_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let tracer = global_tracer();
let mut span = tracer.start("list_files");
let span = tracing::Span::current();
let dir_name_string = dir.to_str().unwrap_or_default().to_string();
span.set_attribute(KeyValue::new("dir", dir_name_string));
span.set_attribute("dir", dir_name_string);
info!("Listing files in: {:?}", dir);
let files = read_dir(dir)?
@@ -978,7 +948,7 @@ pub fn list_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
.map(|entry| entry.path())
.collect::<Vec<PathBuf>>();
span.set_attribute(KeyValue::new("file_count", files.len().to_string()));
span.set_attribute("file_count", files.len().to_string());
span.set_status(Status::Ok);
info!("Found {:?} files in directory: {:?}", files.len(), dir);
Ok(files)
@@ -1084,28 +1054,19 @@ fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
/// Handler for GPS summary endpoint
/// Returns lightweight GPS data for all photos with coordinates
#[tracing::instrument(name = "get_gps_summary", skip_all)]
pub async fn get_gps_summary(
_: Claims,
request: HttpRequest,
req: Query<FilesRequest>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
app_state: Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
use crate::data::{GpsPhotoSummary, GpsPhotosResponse};
let parent_cx = extract_context_from_request(&request);
let tracer = global_tracer();
let mut span = tracer
.span_builder("get_gps_summary")
.start_with_context(&tracer, &parent_cx);
let span = tracing::Span::current();
span.set_attribute(KeyValue::new("path", req.path.clone()));
span.set_attribute(KeyValue::new(
"recursive",
req.recursive.unwrap_or(false).to_string(),
));
let cx = opentelemetry::Context::current_with_span(span);
span.set_attribute("path", req.path.clone());
span.set_attribute("recursive", req.recursive.unwrap_or(false).to_string());
// The database stores relative paths, so we use the path as-is.
// Normalize empty path or "/" to return all GPS photos. Validation
@@ -1122,7 +1083,7 @@ pub async fn get_gps_summary(
});
if !validated {
warn!("Invalid path for GPS summary: {}", req.path);
cx.span().set_status(Status::error("Invalid path"));
span.set_status(Status::error("Invalid path"));
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid path"
})));
@@ -1138,7 +1099,7 @@ pub async fn get_gps_summary(
// Query database for all photos with GPS
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) {
match exif_dao_guard.get_all_with_gps(&requested_path, recursive) {
Ok(gps_data) => {
let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter()
@@ -1194,16 +1155,14 @@ pub async fn get_gps_summary(
}
let total = photos.len();
cx.span()
.set_attribute(KeyValue::new("result_count", total as i64));
cx.span().set_status(Status::Ok);
span.set_attribute("result_count", total as i64);
span.set_status(Status::Ok);
Ok(HttpResponse::Ok().json(GpsPhotosResponse { photos, total }))
}
Err(e) => {
error!("Error querying GPS data: {:?}", e);
cx.span()
.set_status(Status::error(format!("Database error: {:?}", e)));
span.set_status(Status::error(format!("Database error: {:?}", e)));
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to query GPS data"
})))
@@ -1224,31 +1183,24 @@ pub async fn get_gps_summary(
/// supplied). Filename-extracted dates are not synthesized here; if a
/// caller needs that fallback, fetch the row separately via
/// `/image/metadata` (rare path).
#[tracing::instrument(name = "list_exif_summary", skip_all)]
pub async fn list_exif_summary(
_: Claims,
request: HttpRequest,
req: Query<ExifBatchRequest>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
app_state: Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
let parent_cx = extract_context_from_request(&request);
let tracer = global_tracer();
let mut span = tracer
.span_builder("list_exif_summary")
.start_with_context(&tracer, &parent_cx);
let span = tracing::Span::current();
span.set_attribute(KeyValue::new(
span.set_attribute(
"date_from",
req.date_from.map(|v| v.to_string()).unwrap_or_default(),
));
span.set_attribute(KeyValue::new(
);
span.set_attribute(
"date_to",
req.date_to.map(|v| v.to_string()).unwrap_or_default(),
));
span.set_attribute(KeyValue::new(
"library",
req.library.clone().unwrap_or_default(),
));
);
span.set_attribute("library", req.library.clone().unwrap_or_default());
// Resolve the library filter up front so a bad id/name 400s before we
// ever take the DAO mutex. None == union across all libraries.
@@ -1260,7 +1212,6 @@ pub async fn list_exif_summary(
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })));
}
};
let cx = opentelemetry::Context::current_with_span(span);
// Pre-build an id → name map so we don't linear-scan libraries per row.
let library_names: std::collections::HashMap<i32, String> = app_state
@@ -1271,7 +1222,6 @@ pub async fn list_exif_summary(
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
match exif_dao_guard.query_by_exif(
&cx,
library_filter,
None,
None,
@@ -1303,16 +1253,14 @@ pub async fn list_exif_summary(
.collect();
let total = photos.len();
cx.span()
.set_attribute(KeyValue::new("result_count", total as i64));
cx.span().set_status(Status::Ok);
span.set_attribute("result_count", total as i64);
span.set_status(Status::Ok);
Ok(HttpResponse::Ok().json(ExifBatchResponse { photos, total }))
}
Err(e) => {
error!("Error querying EXIF batch: {:?}", e);
cx.span()
.set_status(Status::error(format!("Database error: {:?}", e)));
span.set_status(Status::error(format!("Database error: {:?}", e)));
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to query EXIF data"
})))
@@ -1543,7 +1491,6 @@ mod tests {
impl ExifDao for MockExifDao {
fn store_exif(
&mut self,
_context: &opentelemetry::Context,
data: crate::database::models::InsertImageExif,
) -> Result<crate::database::models::ImageExif, DbError> {
// Return a dummy ImageExif for tests
@@ -1589,7 +1536,6 @@ mod tests {
fn get_exif(
&mut self,
_context: &opentelemetry::Context,
_: &str,
) -> Result<Option<crate::database::models::ImageExif>, DbError> {
Ok(None)
@@ -1597,7 +1543,6 @@ mod tests {
fn update_exif(
&mut self,
_context: &opentelemetry::Context,
data: crate::database::models::InsertImageExif,
) -> Result<crate::database::models::ImageExif, DbError> {
// Return a dummy ImageExif for tests
@@ -1641,17 +1586,12 @@ mod tests {
})
}
fn delete_exif(
&mut self,
_context: &opentelemetry::Context,
_: &str,
) -> Result<(), DbError> {
fn delete_exif(&mut self, _: &str) -> Result<(), DbError> {
Ok(())
}
fn get_all_with_date_taken(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
) -> Result<Vec<(String, i64)>, DbError> {
Ok(Vec::new())
@@ -1659,7 +1599,6 @@ mod tests {
fn get_exif_batch(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_: &[String],
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
@@ -1668,7 +1607,6 @@ mod tests {
fn query_by_exif(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_: Option<&str>,
_: Option<&str>,
@@ -1680,32 +1618,20 @@ mod tests {
Ok(Vec::new())
}
fn get_camera_makes(
&mut self,
_context: &opentelemetry::Context,
) -> Result<Vec<(String, i64)>, DbError> {
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, DbError> {
Ok(Vec::new())
}
fn update_file_path(
&mut self,
_context: &opentelemetry::Context,
_old_path: &str,
_new_path: &str,
) -> Result<(), DbError> {
fn update_file_path(&mut self, _old_path: &str, _new_path: &str) -> Result<(), DbError> {
Ok(())
}
fn get_all_file_paths(
&mut self,
_context: &opentelemetry::Context,
) -> Result<Vec<String>, DbError> {
fn get_all_file_paths(&mut self) -> Result<Vec<String>, DbError> {
Ok(Vec::new())
}
fn get_all_with_gps(
&mut self,
_context: &opentelemetry::Context,
_base_path: &str,
_recursive: bool,
) -> Result<
@@ -1723,17 +1649,12 @@ mod tests {
todo!()
}
fn get_rows_missing_hash(
&mut self,
_context: &opentelemetry::Context,
_limit: i64,
) -> Result<Vec<(i32, String)>, DbError> {
fn get_rows_missing_hash(&mut self, _limit: i64) -> Result<Vec<(i32, String)>, DbError> {
Ok(Vec::new())
}
fn backfill_content_hash(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
_hash: &str,
@@ -1742,16 +1663,12 @@ mod tests {
Ok(())
}
fn list_distinct_content_hashes(
&mut self,
_context: &opentelemetry::Context,
) -> Result<Vec<String>, DbError> {
fn list_distinct_content_hashes(&mut self) -> Result<Vec<String>, DbError> {
Ok(Vec::new())
}
fn list_paths_and_hashes_for_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<Vec<(String, Option<String>)>, DbError> {
Ok(Vec::new())
@@ -1759,7 +1676,6 @@ mod tests {
fn get_rows_needing_date_backfill(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_limit: i64,
) -> Result<Vec<(i32, String)>, DbError> {
@@ -1768,7 +1684,6 @@ mod tests {
fn backfill_date_taken(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
_date_taken: i64,
@@ -1782,7 +1697,6 @@ mod tests {
fn get_rows_needing_utc_backfill(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_after_id: i32,
_limit: i64,
@@ -1792,7 +1706,6 @@ mod tests {
fn backfill_date_utc(
&mut self,
_context: &opentelemetry::Context,
_id: i32,
_date_utc: i64,
_camera_offset_minutes: Option<i32>,
@@ -1801,17 +1714,12 @@ mod tests {
Ok(false)
}
fn count_rows_needing_utc_backfill(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<i64, DbError> {
fn count_rows_needing_utc_backfill(&mut self, _library_id: i32) -> Result<i64, DbError> {
Ok(0)
}
fn set_manual_date_taken(
&mut self,
_context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
date_taken: i64,
@@ -1830,7 +1738,6 @@ mod tests {
fn clear_manual_date_taken(
&mut self,
_context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
) -> Result<crate::database::models::ImageExif, DbError> {
@@ -1839,7 +1746,6 @@ mod tests {
fn get_memories_in_window(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_conv_lo: i64,
_conv_hi: i64,
@@ -1851,7 +1757,6 @@ mod tests {
fn find_by_content_hash(
&mut self,
_context: &opentelemetry::Context,
_hash: &str,
) -> Result<Option<crate::database::models::ImageExif>, DbError> {
Ok(None)
@@ -1859,40 +1764,29 @@ mod tests {
fn get_rel_paths_sharing_content(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
rel_path: &str,
) -> Result<Vec<String>, DbError> {
Ok(vec![rel_path.to_string()])
}
fn get_rel_paths_for_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<Vec<String>, DbError> {
fn get_rel_paths_for_library(&mut self, _library_id: i32) -> Result<Vec<String>, DbError> {
Ok(vec![])
}
fn find_content_hash_anywhere(
&mut self,
_context: &opentelemetry::Context,
_rel_path: &str,
) -> Result<Option<String>, DbError> {
Ok(None)
}
fn get_rel_paths_by_hash(
&mut self,
_context: &opentelemetry::Context,
_hash: &str,
) -> Result<Vec<String>, DbError> {
fn get_rel_paths_by_hash(&mut self, _hash: &str) -> Result<Vec<String>, DbError> {
Ok(vec![])
}
fn get_rel_paths_for_hashes(
&mut self,
_context: &opentelemetry::Context,
_hashes: &[String],
) -> Result<std::collections::HashMap<String, Vec<String>>, DbError> {
Ok(std::collections::HashMap::new())
@@ -1900,7 +1794,6 @@ mod tests {
fn list_rel_paths_for_libraries(
&mut self,
_context: &opentelemetry::Context,
_library_ids: &[i32],
_path_prefix: Option<&str>,
_include_duplicates: bool,
@@ -1910,24 +1803,18 @@ mod tests {
fn delete_exif_by_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
) -> Result<(), DbError> {
Ok(())
}
fn count_for_library(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
) -> Result<i64, DbError> {
fn count_for_library(&mut self, _library_id: i32) -> Result<i64, DbError> {
Ok(0)
}
fn list_rel_paths_for_library_page(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_limit: i64,
_offset: i64,
@@ -1937,7 +1824,6 @@ mod tests {
fn get_rows_missing_perceptual_hash(
&mut self,
_context: &opentelemetry::Context,
_limit: i64,
) -> Result<Vec<(i32, String)>, DbError> {
Ok(Vec::new())
@@ -1945,7 +1831,6 @@ mod tests {
fn backfill_perceptual_hash(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
_phash_64: Option<i64>,
@@ -1956,7 +1841,6 @@ mod tests {
fn list_duplicates_exact(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_include_resolved: bool,
) -> Result<Vec<crate::database::DuplicateRow>, DbError> {
@@ -1965,7 +1849,6 @@ mod tests {
fn list_perceptual_candidates(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_include_resolved: bool,
) -> Result<Vec<crate::database::DuplicateRow>, DbError> {
@@ -1974,7 +1857,6 @@ mod tests {
fn list_image_paths(
&mut self,
_context: &opentelemetry::Context,
_library_id: Option<i32>,
_include_resolved: bool,
) -> Result<Vec<(i32, String)>, DbError> {
@@ -1983,7 +1865,6 @@ mod tests {
fn lookup_duplicate_row(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
) -> Result<Option<crate::database::DuplicateRow>, DbError> {
@@ -1992,7 +1873,6 @@ mod tests {
fn set_duplicate_of(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
_survivor_hash: &str,
@@ -2001,18 +1881,12 @@ mod tests {
Ok(())
}
fn clear_duplicate_of(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
) -> Result<(), DbError> {
fn clear_duplicate_of(&mut self, _library_id: i32, _rel_path: &str) -> Result<(), DbError> {
Ok(())
}
fn union_perceptual_tags(
&mut self,
_context: &opentelemetry::Context,
_survivor_hash: &str,
_demoted_hash: &str,
_survivor_rel_path: &str,
@@ -2022,7 +1896,6 @@ mod tests {
fn list_clip_unencoded_candidates(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_limit: i64,
) -> Result<Vec<(String, String)>, DbError> {
@@ -2031,7 +1904,6 @@ mod tests {
fn backfill_clip_embedding(
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_rel_path: &str,
_embedding: &[u8],
@@ -2042,7 +1914,6 @@ mod tests {
fn list_clip_index(
&mut self,
_context: &opentelemetry::Context,
_library_ids: &[i32],
_model_version: Option<&str>,
) -> Result<Vec<(String, Vec<u8>)>, DbError> {
@@ -2062,7 +1933,6 @@ mod tests {
use crate::database::test::in_memory_db_connection;
use crate::tags::SqliteTagDao;
use actix_web::test::TestRequest;
use actix_web::web::Data;
use std::fs;
@@ -2097,7 +1967,6 @@ mod tests {
let response: HttpResponse = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(test_state),
Data::new(RealFileSystem::new(test_base.to_str().unwrap().to_string())),
@@ -2141,7 +2010,6 @@ mod tests {
let temp_dir = env::temp_dir();
let response = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(AppState::test_state()),
Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())),
@@ -2168,26 +2036,15 @@ mod tests {
let mut tag_dao =
SqliteTagDao::new(std::sync::Arc::new(Mutex::new(in_memory_db_connection())));
let tag1 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag1")
.unwrap();
let _tag2 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag2")
.unwrap();
let tag3 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag3")
.unwrap();
let tag1 = tag_dao.create_tag("tag1").unwrap();
let _tag2 = tag_dao.create_tag("tag2").unwrap();
let tag3 = tag_dao.create_tag("tag3").unwrap();
let _ = &tag_dao
.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id)
.unwrap();
let _ = &tag_dao
.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id)
.unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
let response: HttpResponse = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(HashMap::new())),
@@ -2215,31 +2072,15 @@ mod tests {
let mut tag_dao =
SqliteTagDao::new(std::sync::Arc::new(Mutex::new(in_memory_db_connection())));
let tag1 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag1")
.unwrap();
let _tag2 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag2")
.unwrap();
let tag3 = tag_dao
.create_tag(&opentelemetry::Context::current(), "tag3")
.unwrap();
let tag1 = tag_dao.create_tag("tag1").unwrap();
let _tag2 = tag_dao.create_tag("tag2").unwrap();
let tag3 = tag_dao.create_tag("tag3").unwrap();
let _ = &tag_dao
.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id)
.unwrap();
let _ = &tag_dao
.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id)
.unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
// Should get filtered since it doesn't have tag3
tag_dao
.tag_file(
&opentelemetry::Context::current(),
"some-other.jpg",
tag1.id,
)
.unwrap();
tag_dao.tag_file("some-other.jpg", tag1.id).unwrap();
let request: Query<FilesRequest> = Query::from_query(&format!(
"path=&tag_ids={},{}&tag_filter_mode=All&recursive=true",
@@ -2249,7 +2090,6 @@ mod tests {
let response: HttpResponse = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(HashMap::new())),
+5 -7
View File
@@ -3,27 +3,25 @@
//! under lib2 if the same rel_path resolves there too.
use std::sync::Mutex;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use actix_web::{
HttpRequest, HttpResponse, Responder, delete, get, put,
HttpResponse, Responder, delete, get, put,
web::{self, Data},
};
use log::{error, info, warn};
use opentelemetry::trace::{Span, Status, Tracer};
use opentelemetry::trace::Status;
use crate::data::{AddFavoriteRequest, Claims, PhotosResponse};
use crate::database::{DbError, DbErrorKind, FavoriteDao};
use crate::otel::{extract_context_from_request, global_tracer};
#[get("image/favorites")]
#[tracing::instrument(name = "get favorites", skip_all)]
pub async fn favorites(
claims: Claims,
request: HttpRequest,
favorites_dao: Data<Mutex<Box<dyn FavoriteDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get favorites", &context);
let span = tracing::Span::current();
match web::block(move || {
favorites_dao
+29 -65
View File
@@ -8,6 +8,7 @@ use std::io::ErrorKind;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use actix_files::NamedFile;
use actix_multipart as mp;
@@ -19,7 +20,7 @@ use chrono::Utc;
use futures::stream::StreamExt;
use log::{debug, error, info, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::trace::Status;
use urlencoding::decode;
use crate::content_hash;
@@ -34,11 +35,11 @@ use crate::file_types;
use crate::files::{RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path};
use crate::libraries;
use crate::memories;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::perceptual_hash;
use crate::state::AppState;
#[get("/image")]
#[tracing::instrument(name = "get_image", skip_all)]
pub async fn get_image(
_claims: Claims,
request: HttpRequest,
@@ -46,10 +47,7 @@ pub async fn get_image(
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_image", &context);
let span = tracing::Span::current();
// Resolve library from query param; default to primary so clients that
// don't yet send `library=` continue to work.
@@ -110,7 +108,7 @@ pub async fn get_image(
// rows that aren't hashed yet.
let hash_large_path: Option<PathBuf> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.get_exif(&context, &relative_path_str) {
match dao.get_exif(&relative_path_str) {
Ok(Some(row)) => row
.content_hash
.as_deref()
@@ -208,7 +206,7 @@ pub async fn get_image(
let hash_xlarge_path: Option<PathBuf> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.get_exif(&context, &relative_path_str) {
match dao.get_exif(&relative_path_str) {
Ok(Some(row)) => row
.content_hash
.as_deref()
@@ -327,7 +325,7 @@ pub async fn get_image(
// prevents needless 404s for already-warmed deployments.
let hash_thumb_path: Option<PathBuf> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.get_exif(&context, &relative_path_str) {
match dao.get_exif(&relative_path_str) {
Ok(Some(row)) => row
.content_hash
.as_deref()
@@ -479,18 +477,14 @@ async fn create_circular_thumbnail(
}
#[get("/image/metadata")]
#[tracing::instrument(name = "get_file_metadata", skip_all)]
pub async fn get_file_metadata(
_: Claims,
request: HttpRequest,
path: web::Query<ThumbnailRequest>,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_file_metadata", &context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
.ok()
@@ -531,7 +525,7 @@ pub async fn get_file_metadata(
// Query EXIF data if available
if let Ok(mut dao) = exif_dao.lock()
&& let Ok(Some(exif)) = dao.get_exif(&span_context, &path.path)
&& let Ok(Some(exif)) = dao.get_exif(&path.path)
{
response.exif = Some(exif.into());
}
@@ -567,18 +561,14 @@ struct SetGpsRequest {
}
#[post("/image/exif/gps")]
#[tracing::instrument(name = "set_image_gps", skip_all)]
pub async fn set_image_gps(
_: Claims,
request: HttpRequest,
body: web::Json<SetGpsRequest>,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("set_image_gps", &context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
let library = libraries::resolve_library_param_state(&app_state, body.library.as_deref())
.ok()
@@ -693,10 +683,10 @@ pub async fn set_image_gps(
// If the row doesn't exist yet (file isn't indexed for some reason),
// insert instead so the GPS write is at least visible the moment
// the watcher catches up.
match dao.get_exif(&span_context, &normalized_path) {
Ok(Some(_)) => dao.update_exif(&span_context, insert_exif),
Ok(None) => dao.store_exif(&span_context, insert_exif),
Err(_) => dao.update_exif(&span_context, insert_exif),
match dao.get_exif(&normalized_path) {
Ok(Some(_)) => dao.update_exif(insert_exif),
Ok(None) => dao.store_exif(insert_exif),
Err(_) => dao.update_exif(insert_exif),
}
};
@@ -744,15 +734,13 @@ pub async fn set_image_gps(
/// to 503 when exiftool isn't installed (deployer guidance is the same
/// as for the RAW preview pipeline: install exiftool for full coverage).
#[get("/image/exif/full")]
#[tracing::instrument(name = "get_full_exif", skip_all)]
pub async fn get_full_exif(
_: Claims,
request: HttpRequest,
path: web::Query<ThumbnailRequest>,
app_state: Data<AppState>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_full_exif", &context);
let span = tracing::Span::current();
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
.ok()
@@ -887,18 +875,14 @@ fn build_metadata_response_for_date_mutation(
}
#[post("/image/exif/date")]
#[tracing::instrument(name = "set_image_date", skip_all)]
pub async fn set_image_date(
_: Claims,
request: HttpRequest,
body: web::Json<SetDateRequest>,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("set_image_date", &context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
{
@@ -922,7 +906,7 @@ pub async fn set_image_date(
.user_ai_prefs_dao
.lock()
.expect("Unable to lock UserAiPrefsDao");
match prefs_dao.get_prefs(&span_context) {
match prefs_dao.get_prefs() {
Ok(Some(prefs)) => crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()),
_ => crate::camera_tz::display_zone_for(None),
}
@@ -930,13 +914,7 @@ pub async fn set_image_date(
let updated = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.set_manual_date_taken(
&span_context,
library.id,
&normalized_path,
body.date_taken,
&display_tz,
)
dao.set_manual_date_taken(library.id, &normalized_path, body.date_taken, &display_tz)
};
match updated {
@@ -961,18 +939,14 @@ pub async fn set_image_date(
}
#[post("/image/exif/date/clear")]
#[tracing::instrument(name = "clear_image_date", skip_all)]
pub async fn clear_image_date(
_: Claims,
request: HttpRequest,
body: web::Json<ClearDateRequest>,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("clear_image_date", &context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
{
@@ -988,7 +962,7 @@ pub async fn clear_image_date(
let updated = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.clear_manual_date_taken(&span_context, library.id, &normalized_path)
dao.clear_manual_date_taken(library.id, &normalized_path)
};
match updated {
@@ -1018,19 +992,15 @@ struct UploadQuery {
}
#[post("/image")]
#[tracing::instrument(name = "upload_image", skip_all)]
pub async fn upload_image(
_: Claims,
request: HttpRequest,
query: web::Query<UploadQuery>,
mut payload: mp::Multipart,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("upload_image", &context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
// Resolve the optional library selector. Absent → primary library
// (backwards-compatible with clients that don't yet send `library=`).
@@ -1097,7 +1067,7 @@ pub async fn upload_image(
.to_string();
{
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Ok(Some(existing)) = dao.find_by_content_hash(&span_context, &upload_hash)
if let Ok(Some(existing)) = dao.find_by_content_hash(&upload_hash)
&& existing.duplicate_of_hash.is_none()
{
let library_name = libraries::load_all(&mut crate::database::connect())
@@ -1116,12 +1086,6 @@ pub async fn upload_image(
}
}
let context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
tracer
.span_builder("file write")
.start_with_context(&tracer, &context);
let uploaded_path = if !full_path.is_file() && is_image_or_video(&full_path) {
let mut file = File::create(&full_path).unwrap();
file.write_all(&file_content).unwrap();
@@ -1212,7 +1176,7 @@ pub async fn upload_image(
};
if let Ok(mut dao) = exif_dao.lock() {
if let Err(e) = dao.store_exif(&span_context, insert_exif) {
if let Err(e) = dao.store_exif(insert_exif) {
error!("Failed to store EXIF data for {}: {:?}", relative_path, e);
} else {
debug!("EXIF data stored for {}", relative_path);
+24 -56
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use actix_files::NamedFile;
use actix_web::{
@@ -12,7 +13,7 @@ use actix_web::{
};
use log::{debug, error, info, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, Tracer};
use opentelemetry::trace::Status;
use serde::Serialize;
use crate::content_hash;
@@ -23,7 +24,6 @@ use crate::data::{
use crate::database::{ExifDao, PreviewDao};
use crate::files::is_valid_full_path;
use crate::libraries;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
use crate::video::actors::{
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
@@ -56,16 +56,14 @@ struct GenerateVideoResponse {
}
#[post("/video/generate")]
#[tracing::instrument(name = "generate_video", skip_all)]
pub async fn generate_video(
_claims: Claims,
request: HttpRequest,
app_state: Data<AppState>,
exif_dao: Data<std::sync::Mutex<Box<dyn ExifDao>>>,
body: web::Json<ThumbnailRequest>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("generate_video", &context);
let span = tracing::Span::current();
let preferred_library =
libraries::resolve_library_param_state(&app_state, body.library.as_deref())
@@ -106,11 +104,7 @@ pub async fn generate_video(
// already-ingested videos.
let hash_from_db: Option<String> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.get_exif_batch(
&context,
Some(resolved_library_id),
std::slice::from_ref(&rel_path),
) {
match dao.get_exif_batch(Some(resolved_library_id), std::slice::from_ref(&rel_path)) {
Ok(rows) => rows.into_iter().next().and_then(|r| r.content_hash),
Err(e) => {
warn!(
@@ -209,15 +203,14 @@ pub async fn generate_video(
/// shape is 400'd to defend against operators stashing other content in
/// the hash dir.
#[get("/video/hls/{hash}/{file}")]
#[tracing::instrument(name = "stream_hls_file", skip_all)]
pub async fn stream_hls_file(
request: HttpRequest,
_: Claims,
path: web::Path<(String, String)>,
app_state: Data<AppState>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("stream_hls_file", &context);
let span = tracing::Span::current();
let (hash, file) = path.into_inner();
if !is_valid_hash(&hash) {
@@ -319,6 +312,7 @@ fn is_allowed_hls_filename(name: &str) -> bool {
}
#[get("/video/preview")]
#[tracing::instrument(name = "get_video_preview", skip_all)]
pub async fn get_video_preview(
_claims: Claims,
request: HttpRequest,
@@ -326,9 +320,7 @@ pub async fn get_video_preview(
app_state: Data<AppState>,
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_video_preview", &context);
let span = tracing::Span::current();
// Validate path
let full_path = match is_valid_full_path(&app_state.base_path, &req.path, true) {
@@ -351,7 +343,7 @@ pub async fn get_video_preview(
// Check preview status in DB
let preview = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
dao.get_preview(&context, &relative_path)
dao.get_preview(&relative_path)
};
match preview {
@@ -369,14 +361,7 @@ pub async fn get_video_preview(
Err(_) => {
// File missing on disk but DB says complete - reset and regenerate
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&context,
&relative_path,
"pending",
None,
None,
None,
);
let _ = dao.update_status(&relative_path, "pending", None, None, None);
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
@@ -424,7 +409,7 @@ pub async fn get_video_preview(
// No record exists - insert as pending and trigger generation
{
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, &relative_path, "pending");
let _ = dao.insert_preview(&relative_path, "pending");
}
app_state
.preview_clip_generator
@@ -445,16 +430,14 @@ pub async fn get_video_preview(
}
#[post("/video/preview/status")]
#[tracing::instrument(name = "get_preview_status", skip_all)]
pub async fn get_preview_status(
_claims: Claims,
request: HttpRequest,
body: web::Json<PreviewStatusRequest>,
app_state: Data<AppState>,
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_preview_status", &context);
let span = tracing::Span::current();
// Limit to 200 paths per request
if body.paths.len() > 200 {
@@ -465,7 +448,7 @@ pub async fn get_preview_status(
let previews = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
dao.get_previews_batch(&context, &body.paths)
dao.get_previews_batch(&body.paths)
};
match previews {
@@ -507,7 +490,7 @@ pub async fn get_preview_status(
// No record exists — insert as pending and trigger generation
{
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, path, "pending");
let _ = dao.insert_preview(path, "pending");
}
// Build full path for ffmpeg (actor needs the absolute path for input)
@@ -687,8 +670,7 @@ mod tests {
// Verify the DAO now has a pending record
let mut dao_lock = preview_dao.lock().unwrap();
let ctx = opentelemetry::Context::new();
let clip = dao_lock.get_preview(&ctx, "photos/new_video.mp4").unwrap();
let clip = dao_lock.get_preview("photos/new_video.mp4").unwrap();
assert!(clip.is_some());
assert_eq!(clip.unwrap().status, "pending");
}
@@ -696,18 +678,9 @@ mod tests {
#[actix_rt::test]
async fn test_get_preview_status_returns_complete_with_url() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
dao.insert_preview(&ctx, "photos/done.mp4", "pending")
dao.insert_preview("photos/done.mp4", "pending").unwrap();
dao.update_status("photos/done.mp4", "complete", Some(9.5), Some(500000), None)
.unwrap();
dao.update_status(
&ctx,
"photos/done.mp4",
"complete",
Some(9.5),
Some(500000),
None,
)
.unwrap();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
@@ -771,10 +744,9 @@ mod tests {
#[actix_rt::test]
async fn test_get_preview_status_mixed_statuses() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
dao.insert_preview(&ctx, "a.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "b.mp4", "pending").unwrap();
dao.update_status(&ctx, "b.mp4", "complete", Some(10.0), Some(100000), None)
dao.insert_preview("a.mp4", "pending").unwrap();
dao.insert_preview("b.mp4", "pending").unwrap();
dao.update_status("b.mp4", "complete", Some(10.0), Some(100000), None)
.unwrap();
let preview_dao = make_preview_dao(dao);
@@ -824,15 +796,11 @@ mod tests {
#[actix_rt::test]
async fn test_get_preview_status_requeues_pending_and_failed() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
// Simulate stale records left from a previous server run
dao.insert_preview(&ctx, "stale/pending.mp4", "pending")
.unwrap();
dao.insert_preview(&ctx, "stale/failed.mp4", "pending")
.unwrap();
dao.insert_preview("stale/pending.mp4", "pending").unwrap();
dao.insert_preview("stale/failed.mp4", "pending").unwrap();
dao.update_status(
&ctx,
"stale/failed.mp4",
"failed",
None,
+2 -4
View File
@@ -117,10 +117,9 @@ pub fn compute_and_publish(
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
video_dir: &Path,
) -> Vec<HlsLibraryStats> {
let ctx = opentelemetry::Context::new();
let mut out = Vec::with_capacity(libraries.len());
for lib in libraries {
let stats = compute_for_library(&ctx, lib, exif_dao, video_dir);
let stats = compute_for_library(lib, exif_dao, video_dir);
publish_gauges(&stats);
out.push(stats);
}
@@ -143,14 +142,13 @@ fn publish_gauges(s: &HlsLibraryStats) {
}
fn compute_for_library(
ctx: &opentelemetry::Context,
lib: &Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
video_dir: &Path,
) -> HlsLibraryStats {
let rows = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.list_paths_and_hashes_for_library(ctx, lib.id) {
match dao.list_paths_and_hashes_for_library(lib.id) {
Ok(r) => r,
Err(e) => {
warn!(
+31 -56
View File
@@ -34,9 +34,8 @@ fn resolve_persona_filter(
let uid = claims.sub.parse::<i32>().unwrap_or(1);
let cx = opentelemetry::Context::current();
let mut dao = persona_dao.lock().expect("Unable to lock PersonaDao");
match dao.get_persona(&cx, uid, &pid) {
match dao.get_persona(uid, &pid) {
Ok(Some(p)) if p.include_all_memories => PersonaFilter::All { user_id: uid },
_ => PersonaFilter::Single {
user_id: uid,
@@ -490,16 +489,15 @@ async fn list_entities<D: KnowledgeDao + 'static>(
offset,
};
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.list_entities_with_fact_counts(&cx, filter, sort, &persona) {
match dao.list_entities_with_fact_counts(filter, sort, &persona) {
Ok((pairs, total)) => {
// Batch fetch persona breakdowns so the list-row tooltip
// and detail panel can show "0 here · 12 in journal".
// One extra query for the visible page.
let entity_ids: Vec<i32> = pairs.iter().map(|(e, _)| e.id).collect();
let breakdowns = dao
.get_persona_breakdowns_for_entities(&cx, &entity_ids, persona.user_id())
.get_persona_breakdowns_for_entities(&entity_ids, persona.user_id())
.unwrap_or_default();
let summaries: Vec<EntitySummary> = pairs
.into_iter()
@@ -534,11 +532,10 @@ async fn get_entity<D: KnowledgeDao + 'static>(
persona_dao: PersonaDaoData,
) -> impl Responder {
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let entity_id = id.into_inner();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
let entity = match dao.get_entity_by_id(&cx, entity_id) {
let entity = match dao.get_entity_by_id(entity_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
@@ -551,7 +548,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
};
// Fetch all facts (all statuses for audit), scoped to the active persona.
let raw_facts: Vec<EntityFact> = match dao.get_facts_for_entity(&cx, entity_id, &persona) {
let raw_facts: Vec<EntityFact> = match dao.get_facts_for_entity(entity_id, &persona) {
Ok(f) => f,
Err(e) => {
log::error!("get_facts_for_entity error: {:?}", e);
@@ -564,10 +561,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
let mut facts = Vec::with_capacity(raw_facts.len());
for f in raw_facts {
let object_entity_name = if let Some(oid) = f.object_entity_id {
dao.get_entity_by_id(&cx, oid)
.ok()
.flatten()
.map(|e| e.name)
dao.get_entity_by_id(oid).ok().flatten().map(|e| e.name)
} else {
None
};
@@ -646,7 +640,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
}
// Fetch photo links
let photo_links: Vec<PhotoLinkDetail> = match dao.get_links_for_entity(&cx, entity_id) {
let photo_links: Vec<PhotoLinkDetail> = match dao.get_links_for_entity(entity_id) {
Ok(links) => links.into_iter().map(PhotoLinkDetail::from).collect(),
Err(e) => {
log::error!("get_links_for_entity error: {:?}", e);
@@ -659,7 +653,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
// {persona}" block — same data the list-row tooltip reads. One
// query, single entity in scope.
let persona_breakdown: Vec<PersonaCount> = dao
.get_persona_breakdowns_for_entities(&cx, &[entity_id], persona.user_id())
.get_persona_breakdowns_for_entities(&[entity_id], persona.user_id())
.ok()
.and_then(|mut map| map.remove(&entity_id))
.unwrap_or_default()
@@ -688,7 +682,6 @@ async fn patch_entity<D: KnowledgeDao + 'static>(
body: web::Json<EntityPatchRequest>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let entity_id = id.into_inner();
let patch = EntityPatch {
name: body.name.clone(),
@@ -698,7 +691,7 @@ async fn patch_entity<D: KnowledgeDao + 'static>(
};
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.update_entity(&cx, entity_id, patch) {
match dao.update_entity(entity_id, patch) {
Ok(Some(entity)) => HttpResponse::Ok().json(EntitySummary::from(entity)),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"})),
Err(e) => {
@@ -713,12 +706,11 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
id: web::Path<i32>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let entity_id = id.into_inner();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
// Verify entity exists before deleting
match dao.get_entity_by_id(&cx, entity_id) {
match dao.get_entity_by_id(entity_id) {
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
}
@@ -730,7 +722,7 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
Ok(Some(_)) => {}
}
match dao.delete_entity(&cx, entity_id) {
match dao.delete_entity(entity_id) {
Ok(()) => HttpResponse::NoContent().finish(),
Err(e) => {
log::error!("delete_entity error: {:?}", e);
@@ -749,12 +741,11 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
.json(serde_json::json!({"error": "source_id and target_id must be different"}));
}
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
// Verify both entities exist
for id in [body.source_id, body.target_id] {
match dao.get_entity_by_id(&cx, id) {
match dao.get_entity_by_id(id) {
Ok(None) => {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": format!("Entity {} not found", id)}));
@@ -768,7 +759,7 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
}
}
match dao.merge_entities(&cx, body.source_id, body.target_id) {
match dao.merge_entities(body.source_id, body.target_id) {
Ok((facts_transferred, links_transferred)) => HttpResponse::Ok().json(MergeResponse {
merged_entity_id: body.target_id,
deleted_entity_id: body.source_id,
@@ -804,10 +795,9 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
}
let (source, target) = {
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
let source = match dao.get_entity_by_id(&cx, body.source_id) {
let source = match dao.get_entity_by_id(body.source_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::BadRequest()
@@ -819,7 +809,7 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
.json(serde_json::json!({"error": "Database error"}));
}
};
let target = match dao.get_entity_by_id(&cx, body.target_id) {
let target = match dao.get_entity_by_id(body.target_id) {
Ok(Some(e)) => e,
Ok(None) => {
return HttpResponse::BadRequest()
@@ -938,22 +928,18 @@ async fn list_facts<D: KnowledgeDao + 'static>(
offset,
};
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.list_facts(&cx, filter) {
match dao.list_facts(filter) {
Ok((facts, total)) => {
let mut summaries = Vec::with_capacity(facts.len());
for f in facts {
let subject_entity_name = dao
.get_entity_by_id(&cx, f.subject_entity_id)
.get_entity_by_id(f.subject_entity_id)
.ok()
.flatten()
.map(|e| e.name);
let object_entity_name = if let Some(oid) = f.object_entity_id {
dao.get_entity_by_id(&cx, oid)
.ok()
.flatten()
.map(|e| e.name)
dao.get_entity_by_id(oid).ok().flatten().map(|e| e.name)
} else {
None
};
@@ -1015,11 +1001,10 @@ async fn create_fact<D: KnowledgeDao + 'static>(
PersonaFilter::All { user_id } => (*user_id, "default".to_string()),
};
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
// Verify subject entity exists.
match dao.get_entity_by_id(&cx, body.subject_entity_id) {
match dao.get_entity_by_id(body.subject_entity_id) {
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Subject entity {} not found", body.subject_entity_id)
@@ -1035,7 +1020,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
// Optional object entity validation when supplied.
if let Some(oid) = body.object_entity_id {
match dao.get_entity_by_id(&cx, oid) {
match dao.get_entity_by_id(oid) {
Ok(None) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("Object entity {} not found", oid)
@@ -1078,7 +1063,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
last_modified_at: None,
};
match dao.upsert_fact(&cx, insert) {
match dao.upsert_fact(insert) {
Ok((fact, is_new)) => {
let status = if is_new {
actix_web::http::StatusCode::CREATED
@@ -1100,7 +1085,6 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
body: web::Json<FactPatchRequest>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let fact_id = id.into_inner();
let patch = FactPatch {
predicate: body.predicate.clone(),
@@ -1115,7 +1099,7 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
// Manual PATCH from the curation UI — provenance stamped as
// "manual" so the audit feed can distinguish human edits from
// agent corrections.
match dao.update_fact(&cx, fact_id, patch, Some(("manual", "manual"))) {
match dao.update_fact(fact_id, patch, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
Err(e) => {
@@ -1130,10 +1114,9 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
id: web::Path<i32>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let fact_id = id.into_inner();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.delete_fact(&cx, fact_id) {
match dao.delete_fact(fact_id) {
Ok(()) => HttpResponse::NoContent().finish(),
Err(e) => {
log::warn!("delete_fact({}) error: {:?}", fact_id, e);
@@ -1148,7 +1131,6 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
body: web::Json<SupersedeRequest>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let old_id = id.into_inner();
if old_id == body.by_fact_id {
return HttpResponse::BadRequest()
@@ -1157,7 +1139,7 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
// Manual supersede from the curation UI — same stamping rule as
// the PATCH path.
match dao.supersede_fact(&cx, old_id, body.by_fact_id, Some(("manual", "manual"))) {
match dao.supersede_fact(old_id, body.by_fact_id, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => {
HttpResponse::NotFound().json(serde_json::json!({"error": "Old or new fact not found"}))
@@ -1174,10 +1156,9 @@ async fn restore_fact<D: KnowledgeDao + 'static>(
id: web::Path<i32>,
dao: web::Data<Mutex<D>>,
) -> impl Responder {
let cx = opentelemetry::Context::current();
let fact_id = id.into_inner();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.revert_supersession(&cx, fact_id, Some(("manual", "manual"))) {
match dao.revert_supersession(fact_id, Some(("manual", "manual"))) {
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
Err(e) => {
@@ -1200,9 +1181,8 @@ async fn get_recent<D: KnowledgeDao + 'static>(
let limit = query.limit.unwrap_or(20).min(100);
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.get_recent_activity(&cx, since, limit, &persona) {
match dao.get_recent_activity(since, limit, &persona) {
Ok(RecentActivity { entities, facts }) => {
let entity_summaries: Vec<EntitySummary> =
entities.into_iter().map(EntitySummary::from).collect();
@@ -1227,9 +1207,8 @@ async fn get_predicate_stats<D: KnowledgeDao + 'static>(
) -> impl Responder {
let limit = query.limit.unwrap_or(100).clamp(1, 500) as usize;
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.get_predicate_stats(&cx, &persona, limit) {
match dao.get_predicate_stats(&persona, limit) {
Ok(rows) => HttpResponse::Ok().json(PredicateStatsResponse {
predicates: rows
.into_iter()
@@ -1256,10 +1235,8 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
.json(serde_json::json!({"error": "predicate must not be empty"}));
}
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.bulk_reject_facts_by_predicate(&cx, &persona, &predicate, Some(("manual", "manual")))
{
match dao.bulk_reject_facts_by_predicate(&persona, &predicate, Some(("manual", "manual"))) {
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
Err(e) => {
log::error!("bulk_reject_predicate error: {:?}", e);
@@ -1277,9 +1254,8 @@ async fn get_graph<D: KnowledgeDao + 'static>(
) -> impl Responder {
let limit = query.limit.unwrap_or(200).clamp(1, 1000) as usize;
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.build_entity_graph(&cx, query.entity_type.as_deref(), limit, &persona) {
match dao.build_entity_graph(query.entity_type.as_deref(), limit, &persona) {
Ok(EntityGraph { nodes, edges }) => HttpResponse::Ok().json(GraphResponse {
nodes: nodes
.into_iter()
@@ -1320,10 +1296,9 @@ async fn get_consolidation_proposals<D: KnowledgeDao + 'static>(
let max_groups = query.limit.unwrap_or(50).clamp(1, 200) as usize;
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
let groups: Vec<ConsolidationGroup> =
match dao.find_consolidation_proposals(&cx, threshold, max_groups) {
match dao.find_consolidation_proposals(threshold, max_groups) {
Ok(g) => g,
Err(e) => {
log::error!("find_consolidation_proposals: {:?}", e);
@@ -1340,7 +1315,7 @@ async fn get_consolidation_proposals<D: KnowledgeDao + 'static>(
.flat_map(|g| g.entities.iter().map(|e| e.id))
.collect();
let breakdowns = dao
.get_persona_breakdowns_for_entities(&cx, &entity_ids, persona.user_id())
.get_persona_breakdowns_for_entities(&entity_ids, persona.user_id())
.unwrap_or_default();
let groups_view: Vec<ConsolidationGroupView> = groups
+1 -4
View File
@@ -492,7 +492,6 @@ pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl
.library_health
.read()
.unwrap_or_else(|e| e.into_inner());
let context = opentelemetry::Context::new();
let mut exif_dao = app_state
.insight_generator
.exif_dao()
@@ -508,9 +507,7 @@ pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl
.unwrap_or(LibraryHealth::Online),
// Live count off the partial index — cheap, and exact even for
// a stale library the watcher isn't refreshing.
date_backfill_remaining: exif_dao
.count_rows_needing_utc_backfill(&context, lib.id)
.ok(),
date_backfill_remaining: exif_dao.count_rows_needing_utc_backfill(lib.id).ok(),
})
.collect();
HttpResponse::Ok().json(LibrariesResponse {
+2 -3
View File
@@ -68,7 +68,6 @@ pub const DEFAULT_SCAN_PAGE_SIZE: i64 = 500;
/// Caller must already have confirmed the library is online — running
/// against a Stale library would interpret every row as missing.
pub fn detect_missing_files_for_library(
context: &opentelemetry::Context,
library: &Library,
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
offset: i64,
@@ -77,7 +76,7 @@ pub fn detect_missing_files_for_library(
) -> (usize, i64) {
let rows = {
let mut dao = exif_dao.lock().expect("exif_dao poisoned");
match dao.list_rel_paths_for_library_page(context, library.id, page_size, offset) {
match dao.list_rel_paths_for_library_page(library.id, page_size, offset) {
Ok(r) => r,
Err(e) => {
warn!(
@@ -136,7 +135,7 @@ pub fn detect_missing_files_for_library(
{
let mut dao = exif_dao.lock().expect("exif_dao poisoned");
for rel_path in &to_delete {
match dao.delete_exif_by_library(context, library.id, rel_path) {
match dao.delete_exif_by_library(library.id, rel_path) {
Ok(()) => deleted += 1,
Err(e) => warn!(
"missing-file scan: delete failed for ({}, {}): {:?}",
+1 -2
View File
@@ -87,8 +87,7 @@ fn main() -> std::io::Result<()> {
{
use crate::database::{InsightGenerationJobDao, SqliteInsightGenerationJobDao};
let mut dao = SqliteInsightGenerationJobDao::new();
let ctx = opentelemetry::Context::new();
match dao.recover_orphaned_jobs(&ctx) {
match dao.recover_orphaned_jobs() {
Ok(n) if n > 0 => {
info!("Recovered {} orphaned insight generation jobs", n);
}
+47 -69
View File
@@ -1,18 +1,18 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use actix_web::{HttpResponse, Responder, get, web};
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::trace::Status;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Mutex;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::data::Claims;
use crate::database::ExifDao;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
// Helper that encapsulates path-exclusion semantics
@@ -487,18 +487,14 @@ fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
pub const DEFAULT_YEARS_BACK: i32 = 20;
#[get("/memories")]
#[tracing::instrument(name = "list_memories", skip_all)]
pub async fn list_memories(
_claims: Claims,
request: HttpRequest,
q: web::Query<MemoriesRequest>,
app_state: Data<AppState>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let parent_context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("list_memories", &parent_context);
let span_context =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
@@ -513,7 +509,7 @@ pub async fn list_memories(
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten());
.and_then(|mut dao| dao.get_prefs().ok().flatten());
crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref()))
};
let display_zone =
@@ -522,7 +518,6 @@ pub async fn list_memories(
let items = match gather_memory_items(
&app_state,
&exif_dao,
&span_context,
span_mode,
display_zone,
q.library.as_deref(),
@@ -565,7 +560,6 @@ pub async fn list_memories(
pub fn gather_memory_items(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
span_mode: MemoriesSpan,
display_zone: chrono_tz::Tz,
library_param: Option<&str>,
@@ -605,23 +599,18 @@ pub fn gather_memory_items(
let path_excluder = PathExcluder::new(base, &effective);
let rows = match exif_dao.lock() {
Ok(mut dao) => match dao.get_memories_in_window(
span_context,
lib.id,
conv_lo,
conv_hi,
unconv_lo,
unconv_hi,
) {
Ok(rows) => rows,
Err(e) => {
warn!(
"Failed to query memories for library '{}': {:?}",
lib.name, e
);
continue;
Ok(mut dao) => {
match dao.get_memories_in_window(lib.id, conv_lo, conv_hi, unconv_lo, unconv_hi) {
Ok(rows) => rows,
Err(e) => {
warn!(
"Failed to query memories for library '{}': {:?}",
lib.name, e
);
continue;
}
}
},
}
Err(e) => {
warn!("Failed to lock EXIF DAO: {:?}", e);
continue;
@@ -1339,37 +1328,34 @@ mod tests {
use crate::database::test::in_memory_db_connection;
fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) {
dao.store_exif(
&opentelemetry::Context::new(),
InsertImageExif {
library_id: 1,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: Some(date_taken),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
dao.store_exif(InsertImageExif {
library_id: 1,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: Some(date_taken),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
})
.expect("insert row");
}
@@ -1400,7 +1386,6 @@ mod tests {
let conv_instant = at_12(year - 3).and_utc().timestamp();
seed_row(&mut dao, "conv.jpg", conv_instant, "exif");
dao.backfill_date_taken(
&opentelemetry::Context::new(),
1,
"conv.jpg",
conv_instant,
@@ -1427,15 +1412,8 @@ mod tests {
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
let items = gather_memory_items(
&state,
&exif_mutex,
&opentelemetry::Context::new(),
MemoriesSpan::Month,
zone,
None,
)
.expect("gather");
let items = gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None)
.expect("gather");
let by_path = |p: &str| items.iter().find(|it| it.path == p);
// All three rows group into the same owner month (AC-2/AC-3).
+27 -55
View File
@@ -1,8 +1,6 @@
use actix_web::HttpRequest;
use actix_web::http::header::HeaderMap;
use opentelemetry::global::{BoxedSpan, BoxedTracer};
use opentelemetry::global::BoxedTracer;
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer, TracerProvider};
use opentelemetry::trace::{Status, TraceContextExt, TracerProvider};
use opentelemetry::{Context, KeyValue, global};
use opentelemetry_appender_log::OpenTelemetryLogBridge;
use opentelemetry_otlp::WithExportConfig;
@@ -102,17 +100,6 @@ pub fn init_logs() {
log::set_max_level(log::LevelFilter::Info);
}
struct HeaderExtractor<'a>(&'a HeaderMap);
impl<'a> opentelemetry::propagation::Extractor for HeaderExtractor<'a> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).and_then(|v| v.to_str().ok())
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(|k| k.as_str()).collect()
}
}
/// Start a client span for a single LLM call. Every provider funnels its
/// LlmClient methods through this so the span shape (name, `SpanKind::Client`,
/// provider / operation / model attributes) is identical across
@@ -133,24 +120,6 @@ pub fn start_llm_request_span(provider: &str, operation: &str, model: &str) -> t
)
}
/// The OpenTelemetry [`Context`] to parent this request's operation spans
/// under. Prefers the request's root span — created by the
/// `tracing-actix-web` middleware, which has already extracted any incoming
/// W3C `traceparent` — so the per-request operation span nests *under* the
/// root span (one trace tree) instead of duplicating it as a sibling. Falls
/// back to parsing the request's `traceparent` header when no root span is
/// active (e.g. a handler invoked directly in a test without the middleware).
pub fn extract_context_from_request(req: &HttpRequest) -> Context {
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
let current = tracing::Span::current();
let cx = current.context();
if cx.span().span_context().is_valid() {
return cx;
}
let propagator = TraceContextPropagator::new();
propagator.extract(&HeaderExtractor(req.headers()))
}
/// Build the W3C trace-context headers to attach to an outgoing HTTP
/// request so a downstream service (llama-swap, Ollama) can parent its own
/// spans onto this trace. Returns an empty vec when the context carries no
@@ -200,32 +169,35 @@ pub fn record_http_failure(span: Option<&tracing::Span>, status: u16, message: &
}
}
pub fn trace_db_call<F, O>(
context: &Context,
query_type: &str,
operation: &str,
func: F,
) -> anyhow::Result<O>
where
F: FnOnce(&mut BoxedSpan) -> anyhow::Result<O>,
{
let tracer = global::tracer("db");
let mut span = tracer
.span_builder(format!("db.{}.{}", query_type, operation))
.with_attributes(vec![
KeyValue::new("db.query_type", query_type.to_string().clone()),
KeyValue::new("db.operation", operation.to_string().clone()),
])
.start_with_context(&tracer, context);
/// Set several attributes on a `tracing` span's OTel data at once — the
/// `tracing` counterpart of `BoxedSpan::set_attributes`.
pub fn set_span_attributes(span: &tracing::Span, attrs: impl IntoIterator<Item = KeyValue>) {
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
for kv in attrs {
span.set_attribute(kv.key, kv.value);
}
}
let result = func(&mut span);
/// Run a synchronous DAO body inside a `db.<query_type>.<operation>` span.
/// The span parents on the caller's ambient `tracing` span (DAO methods no
/// longer take an OTel `Context`) and is entered for the duration of `func`,
/// which receives it for attribute updates.
pub fn trace_db_call<F, O>(query_type: &str, operation: &str, func: F) -> anyhow::Result<O>
where
F: FnOnce(&tracing::Span) -> anyhow::Result<O>,
{
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
let span = tracing::info_span!(
"db",
otel.name = format!("db.{query_type}.{operation}").as_str(),
db.query_type = query_type,
db.operation = operation,
);
let result = span.in_scope(|| func(&span));
match &result {
Ok(_) => {
span.set_status(Status::Ok);
}
Ok(_) => span.set_status(Status::Ok),
Err(e) => span.set_status(Status::error(e.to_string())),
}
result
}
+8 -21
View File
@@ -150,9 +150,8 @@ async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
let Some(uid) = user_id_from_claims(&claims) else {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
};
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
match dao.list_personas(&cx, uid) {
match dao.list_personas(uid) {
Ok(rows) => {
let views: Vec<PersonaView> = rows.into_iter().map(PersonaView::from).collect();
HttpResponse::Ok().json(views)
@@ -181,7 +180,6 @@ async fn create_persona(
.json(serde_json::json!({"error": "systemPrompt is required"}));
}
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
let pid = match body.persona_id.as_deref() {
@@ -197,20 +195,12 @@ async fn create_persona(
// Pre-check existence so we can return 409 cleanly. The DB UNIQUE
// would also catch it, but parsing Diesel's "constraint violation"
// out of a generic DbError is uglier than a quick lookup.
if let Ok(Some(_)) = dao.get_persona(&cx, uid, &pid) {
if let Ok(Some(_)) = dao.get_persona(uid, &pid) {
return HttpResponse::Conflict()
.json(serde_json::json!({"error": "persona already exists"}));
}
match dao.create_persona(
&cx,
uid,
&pid,
&body.name,
&body.system_prompt,
false,
false,
) {
match dao.create_persona(uid, &pid, &body.name, &body.system_prompt, false, false) {
Ok(p) => HttpResponse::Created().json(PersonaView::from(p)),
Err(e) => {
log::error!("create_persona error: {:?}", e);
@@ -229,7 +219,6 @@ async fn update_persona(
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
};
let pid = path.into_inner();
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
// Built-in personas are owned by the migration; the canonical voice
@@ -239,7 +228,7 @@ async fn update_persona(
// from the picker. `include_all_memories` stays editable on
// built-ins — that's a per-user preference, not the persona's
// identity. Mirrors the same guard delete_persona enforces below.
match dao.get_persona(&cx, uid, &pid) {
match dao.get_persona(uid, &pid) {
Ok(Some(p)) if p.is_built_in => {
let editing_identity = body.name.is_some() || body.system_prompt.is_some();
if editing_identity {
@@ -267,7 +256,7 @@ async fn update_persona(
reviewed_only_facts: body.reviewed_only_facts,
allow_agent_corrections: body.allow_agent_corrections,
};
match dao.update_persona(&cx, uid, &pid, patch) {
match dao.update_persona(uid, &pid, patch) {
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
Ok(None) => {
HttpResponse::NotFound().json(serde_json::json!({"error": "Persona not found"}))
@@ -288,10 +277,9 @@ async fn delete_persona(
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
};
let pid = path.into_inner();
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
match dao.get_persona(&cx, uid, &pid) {
match dao.get_persona(uid, &pid) {
Ok(Some(p)) if p.is_built_in => {
return HttpResponse::Conflict()
.json(serde_json::json!({"error": "Cannot delete built-in persona"}));
@@ -308,7 +296,7 @@ async fn delete_persona(
Ok(Some(_)) => {}
}
match dao.delete_persona(&cx, uid, &pid) {
match dao.delete_persona(uid, &pid) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(e) => {
log::error!("delete_persona error: {:?}", e);
@@ -325,7 +313,6 @@ async fn migrate_personas(
let Some(uid) = user_id_from_claims(&claims) else {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
};
let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
// Filter out built-in ids — those are already seeded by the
@@ -345,7 +332,7 @@ async fn migrate_personas(
})
.collect();
match dao.bulk_import(&cx, uid, &rows) {
match dao.bulk_import(uid, &rows) {
Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }),
Err(e) => {
log::error!("migrate_personas error: {:?}", e);
+14 -47
View File
@@ -269,7 +269,7 @@ pub async fn get_prefs(_claims: Claims, app_state: web::Data<AppState>) -> HttpR
}));
}
};
match dao.get_prefs(&opentelemetry::Context::new()) {
match dao.get_prefs() {
Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())),
Err(e) => HttpResponse::InternalServerError().json(json!({
"error": format!("failed to read prefs: {e}")
@@ -316,8 +316,7 @@ pub async fn put_prefs(
}));
}
};
let ctx = opentelemetry::Context::new();
let existing = match dao.get_prefs(&ctx) {
let existing = match dao.get_prefs() {
Ok(row) => row,
Err(e) => {
return HttpResponse::InternalServerError().json(json!({
@@ -362,7 +361,7 @@ pub async fn put_prefs(
reel_max_segments: merged.reel_max_segments,
updated_at: now,
};
match dao.upsert_prefs(&ctx, &upsert) {
match dao.upsert_prefs(&upsert) {
Ok(()) => HttpResponse::Ok().json(resp),
Err(e) => HttpResponse::InternalServerError().json(json!({
"error": format!("failed to write prefs: {e}")
@@ -413,8 +412,7 @@ mod tests {
}
fn seed(dao: &mut SqliteUserAiPrefsDao, row: UpsertUserAiPrefs) {
dao.upsert_prefs(&opentelemetry::Context::new(), &row)
.expect("seed prefs");
dao.upsert_prefs(&row).expect("seed prefs");
}
fn seeded_row(
@@ -594,10 +592,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.voice.as_deref(), Some("dad"));
assert_eq!(row.library.as_deref(), Some("1"));
assert_eq!(row.display_tz.as_deref(), Some("America/New_York"));
@@ -634,10 +629,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert!(row.voice.is_none());
assert_eq!(row.library.as_deref(), Some("1"));
assert_eq!(row.display_tz.as_deref(), Some("America/New_York"));
@@ -667,10 +659,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.library.as_deref(), Some("1"));
assert!(row.voice.is_none());
assert!(row.display_tz.is_none());
@@ -705,10 +694,7 @@ mod tests {
// Nothing written — not even the valid field in the same body.
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.voice.as_deref(), Some("grandma"));
assert_eq!(row.display_tz.as_deref(), Some("America/New_York"));
assert_eq!(row.updated_at, 1_700_000_000);
@@ -738,10 +724,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.display_tz.as_deref(), Some("Europe/Berlin"));
}
@@ -771,11 +754,7 @@ mod tests {
assert_eq!(body["updated_at"], serde_json::Value::Null);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
assert!(
dao.get_prefs(&opentelemetry::Context::new())
.unwrap()
.is_none()
);
assert!(dao.get_prefs().unwrap().is_none());
}
// --- GET /timezones -----------------------------------------------------
@@ -918,10 +897,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
@@ -963,10 +939,7 @@ mod tests {
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert!(row.default_model.is_none());
assert!(row.default_backend.is_none());
assert!(row.default_persona_id.is_none());
@@ -1004,10 +977,7 @@ mod tests {
assert_eq!(resp.status(), 400);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
// Nothing written — not even the valid field in the same body.
assert_eq!(row.voice.as_deref(), Some("grandma"));
assert!(row.default_backend.is_none());
@@ -1056,10 +1026,7 @@ mod tests {
assert_eq!(resp.status(), 400);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
let row = dao.get_prefs().unwrap().unwrap();
assert!(row.reel_span.is_none());
assert_eq!(row.updated_at, 1_700_000_000);
}
+58 -83
View File
@@ -34,7 +34,6 @@ use crate::data::Claims;
use crate::database::{ExifDao, InsightDao};
use crate::libraries::{Library, resolve_library_param};
use crate::memories::MemoriesSpan;
use crate::otel::extract_context_from_request;
use crate::state::AppState;
use selector::ReelSelector;
@@ -118,7 +117,6 @@ fn capture_prefs(
_ => "all".to_string(),
};
let mut dao = app_state.user_ai_prefs_dao.lock().expect("lock");
let ctx = opentelemetry::Context::new();
// The row passively mirrors the latest request's EFFECTIVE reel params
// (after the request → pref → default resolution done by the caller), so
// the stored reel_span / reel_max_segments track what was last used —
@@ -130,28 +128,25 @@ fn capture_prefs(
// silently wipe the owner's stored defaults on every reel creation. If the
// read fails we bail without writing: this is best-effort, and clobbering
// owner defaults on a transient error is worse than skipping a mirror.
let existing = match dao.get_prefs(&ctx) {
let existing = match dao.get_prefs() {
Ok(row) => row,
Err(e) => return Err(anyhow::anyhow!("failed to read prefs before mirror: {e}")),
};
dao.upsert_prefs(
&ctx,
&UpsertUserAiPrefs {
voice: req.voice.clone().filter(|s| !s.is_empty()),
tz_offset_minutes: Some(
req.timezone_offset_minutes
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc()),
),
library: Some(library),
updated_at: now as i64,
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
default_model: existing.as_ref().and_then(|e| e.default_model.clone()),
default_backend: existing.as_ref().and_then(|e| e.default_backend.clone()),
default_persona_id: existing.as_ref().and_then(|e| e.default_persona_id.clone()),
reel_span: Some(effective_span.to_string()),
reel_max_segments: Some(effective_max_segments as i32),
},
)
dao.upsert_prefs(&UpsertUserAiPrefs {
voice: req.voice.clone().filter(|s| !s.is_empty()),
tz_offset_minutes: Some(
req.timezone_offset_minutes
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc()),
),
library: Some(library),
updated_at: now as i64,
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
default_model: existing.as_ref().and_then(|e| e.default_model.clone()),
default_backend: existing.as_ref().and_then(|e| e.default_backend.clone()),
default_persona_id: existing.as_ref().and_then(|e| e.default_persona_id.clone()),
reel_span: Some(effective_span.to_string()),
reel_max_segments: Some(effective_max_segments as i32),
})
.map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}"))
}
@@ -446,15 +441,12 @@ pub struct PrecomputedReelResponse {
/// requested span. Returns 202 + a job id; the client polls GET /reels/{id}.
#[post("/reels")]
pub async fn create_reel_handler(
http_request: HttpRequest,
_claims: Claims,
req: web::Json<CreateReelRequest>,
app_state: web::Data<AppState>,
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
insight_dao: web::Data<Mutex<Box<dyn InsightDao>>>,
) -> impl Responder {
let span_context = extract_context_from_request(&http_request);
if app_state.llamacpp.is_none() {
return HttpResponse::ServiceUnavailable().json(json!({
"error": "Reel narration needs the LLM/TTS backend (set LLAMA_SWAP_URL)"
@@ -470,7 +462,7 @@ pub async fn create_reel_handler(
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten());
.and_then(|mut dao| dao.get_prefs().ok().flatten());
let (span, max_segments) =
resolve_reel_params(req.span, req.max_segments, stored_prefs.as_ref());
let display_zone_name = {
@@ -487,7 +479,7 @@ pub async fn create_reel_handler(
// Cheap pass: resolve the media set for the cache key and the emptiness
// check. Insight enrichment + scripting happen in the background job.
let (planned, meta) = match selector::resolve(&app_state, &exif_dao, &span_context, &selector) {
let (planned, meta) = match selector::resolve(&app_state, &exif_dao, &selector) {
Ok(r) => r,
Err(msg) => return HttpResponse::BadRequest().body(msg),
};
@@ -684,7 +676,6 @@ pub async fn precomputed_reel_handler(
};
let min_generated_at = now - (max_age_hours * 3600);
let ctx = opentelemetry::Context::new();
let mut dao = app_state
.precomputed_reel_dao
.lock()
@@ -692,20 +683,14 @@ pub async fn precomputed_reel_handler(
// Fast existence gate: is there a fresh row at all?
if !dao
.exists_fresh(
&ctx,
span,
&library_key,
RENDER_VERSION as i32,
min_generated_at,
)
.exists_fresh(span, &library_key, RENDER_VERSION as i32, min_generated_at)
.unwrap_or(false)
{
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
}
// Fetch the latest row for full validity checks.
let reel = match dao.latest_for(&ctx, span, &library_key) {
let reel = match dao.latest_for(span, &library_key) {
Ok(Some(r)) => r,
_ => {
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
@@ -794,8 +779,7 @@ pub(crate) async fn produce_reel(
// (one LLM call → one narration line per beat).
emit_progress(progress, "scripting");
log::info!("reel produce_reel: scripting narration via LLM…");
let span_context = opentelemetry::Context::new();
selector::enrich(insight_dao, exif_dao, &span_context, &mut planned);
selector::enrich(insight_dao, exif_dao, &mut planned);
let script = match scripter {
ScripterMode::Fast => script::generate_script(&client, &meta, &planned).await?,
ScripterMode::Agentic => {
@@ -1032,14 +1016,13 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime<chrono::Local>, run
/// DST-proof, unlike the old fixed-offset env vars.
fn load_pregen_params(app_state: &AppState) -> (String, Option<String>, String) {
// Try DB row first
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
let ctx = opentelemetry::Context::new();
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) {
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
let voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library);
}
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock()
&& let Ok(Some(prefs)) = dao.get_prefs()
{
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
let voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library);
}
// Fall back to env (explicit zone overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
@@ -1137,8 +1120,7 @@ async fn pregen_one(
let exif_dao = app_state.insight_generator.exif_dao();
let insight_dao = app_state.insight_generator.insight_dao();
let ctx = opentelemetry::Context::new();
let (planned, reel_meta) = match selector::resolve(app_state, exif_dao, &ctx, &selector) {
let (planned, reel_meta) = match selector::resolve(app_state, exif_dao, &selector) {
Ok((p, m)) => (p, m),
Err(e) => {
log::warn!("Pre-gen resolve failed for span={}: {}", span, e);
@@ -1179,7 +1161,7 @@ async fn pregen_one(
let already_current = {
let mut dao = app_state.precomputed_reel_dao.lock().expect("lock");
matches!(
dao.latest_for(&ctx, span, library),
dao.latest_for(span, library),
Ok(Some(row))
if row.cache_key == key
&& row.render_version == RENDER_VERSION as i32
@@ -1222,24 +1204,21 @@ async fn pregen_one(
// grace window. Done under one lock so the prune sees the row we just wrote.
let superseded = {
let mut reel_dao = app_state.precomputed_reel_dao.lock().expect("lock");
reel_dao.record_reel(
&ctx,
&crate::database::models::InsertablePrecomputedReel {
span: span.to_string(),
library_key: library.to_string(),
cache_key: key.clone(),
output_path: mp4.to_string_lossy().to_string(),
title,
media_count,
render_version: RENDER_VERSION as i32,
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
.unwrap_or(0) as i32,
voice: voice.clone(),
generated_at: now,
},
)?;
reel_dao.record_reel(&crate::database::models::InsertablePrecomputedReel {
span: span.to_string(),
library_key: library.to_string(),
cache_key: key.clone(),
output_path: mp4.to_string_lossy().to_string(),
title,
media_count,
render_version: RENDER_VERSION as i32,
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
.unwrap_or(0) as i32,
voice: voice.clone(),
generated_at: now,
})?;
reel_dao
.prune_superseded(&ctx, span, library, PREGEN_KEEP_PER_SCOPE)
.prune_superseded(span, library, PREGEN_KEEP_PER_SCOPE)
.unwrap_or_default()
};
for row in &superseded {
@@ -1311,9 +1290,8 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
// Files a ledger row still points at (current pre-gen reels).
let protected: std::collections::HashSet<String> = {
let ctx = opentelemetry::Context::new();
let mut dao = app_state.precomputed_reel_dao.lock().expect("lock");
dao.all_cache_keys(&ctx)
dao.all_cache_keys()
.unwrap_or_default()
.into_iter()
.collect()
@@ -1451,21 +1429,18 @@ mod tests {
)));
{
let mut dao = SqliteUserAiPrefsDao::from_connection(conn.clone());
dao.upsert_prefs(
&opentelemetry::Context::new(),
&UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
default_model: Some("llama3.2:3b".into()),
default_backend: Some("hybrid".into()),
default_persona_id: Some("cabin".into()),
reel_span: None,
reel_max_segments: None,
},
)
dao.upsert_prefs(&UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
default_model: Some("llama3.2:3b".into()),
default_backend: Some("hybrid".into()),
default_persona_id: Some("cabin".into()),
reel_span: None,
reel_max_segments: None,
})
.expect("seed owner defaults");
}
@@ -1483,7 +1458,7 @@ mod tests {
.user_ai_prefs_dao
.lock()
.expect("lock")
.get_prefs(&opentelemetry::Context::new())
.get_prefs()
.expect("read")
.expect("row");
// The mirrored reel params landed...
+2 -5
View File
@@ -279,7 +279,6 @@ pub fn form_beats(
pub fn resolve(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
selector: &ReelSelector,
) -> Result<(Vec<PlannedBeat>, ReelMeta), String> {
match selector {
@@ -294,7 +293,6 @@ pub fn resolve(
let items = memories::gather_memory_items(
app_state,
exif_dao,
span_context,
*span,
zone,
library.as_deref(),
@@ -344,7 +342,6 @@ fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec<i3
pub fn enrich(
insight_dao: &Mutex<Box<dyn InsightDao>>,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
beats: &mut [PlannedBeat],
) {
let Ok(mut insight_dao) = insight_dao.lock() else {
@@ -360,13 +357,13 @@ pub fn enrich(
}
None => continue,
};
if let Ok(Some(insight)) = insight_dao.get_insight(span_context, &rel_path) {
if let Ok(Some(insight)) = insight_dao.get_insight(&rel_path) {
beat.insight_title = Some(insight.title);
beat.insight_summary = Some(insight.summary);
}
// Enrich GPS from EXIF when the lead media is a photo.
if let Some(SegmentMedia::Photo { .. }) = beat.media.first()
&& let Ok(Some(exif)) = exif_dao.get_exif(span_context, &rel_path)
&& let Ok(Some(exif)) = exif_dao.get_exif(&rel_path)
&& let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude)
{
beat.gps = Some((lat as f64, lon as f64));
+129 -347
View File
File diff suppressed because it is too large. Load diff
+3 -18
View File
@@ -93,12 +93,7 @@ impl TestPreviewDao {
}
impl PreviewDao for TestPreviewDao {
fn insert_preview(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError> {
fn insert_preview(&mut self, file_path_val: &str, status_val: &str) -> Result<(), DbError> {
let mut clips = self.clips.lock().unwrap();
// insert_or_ignore semantics: skip if key already exists
if clips.contains_key(file_path_val) {
@@ -126,7 +121,6 @@ impl PreviewDao for TestPreviewDao {
fn update_status(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
@@ -149,17 +143,12 @@ impl PreviewDao for TestPreviewDao {
}
}
fn get_preview(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError> {
fn get_preview(&mut self, file_path_val: &str) -> Result<Option<VideoPreviewClip>, DbError> {
Ok(self.clips.lock().unwrap().get(file_path_val).cloned())
}
fn get_previews_batch(
&mut self,
_context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError> {
let clips = self.clips.lock().unwrap();
@@ -169,11 +158,7 @@ impl PreviewDao for TestPreviewDao {
.collect())
}
fn get_by_status(
&mut self,
_context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError> {
fn get_by_status(&mut self, status_val: &str) -> Result<Vec<VideoPreviewClip>, DbError> {
let clips = self.clips.lock().unwrap();
Ok(clips
.values()
+2 -9
View File
@@ -157,12 +157,10 @@ pub async fn unified_search<TagD: TagDao>(
Err(msg) => return bad_request(msg),
};
let ctx = opentelemetry::Context::current();
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
let tag_vocab: Vec<(i32, String)> = {
let mut dao = tag_dao.lock().expect("tag dao");
match dao.get_all_tags(&ctx, None) {
match dao.get_all_tags(None) {
Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(),
Err(e) => {
log::warn!("unified_search: get_all_tags failed: {e:?}");
@@ -273,11 +271,7 @@ pub async fn unified_search<TagD: TagDao>(
None
} else {
let mut dao = tag_dao.lock().expect("tag dao");
match dao.get_files_with_any_tag_ids(
sq.tag_ids.clone(),
sq.exclude_tag_ids.clone(),
&ctx,
) {
match dao.get_files_with_any_tag_ids(sq.tag_ids.clone(), sq.exclude_tag_ids.clone()) {
Ok(files) => Some(files.into_iter().map(|f| f.file_name).collect()),
Err(e) => {
log::warn!("unified_search: tag filter failed: {e:?}");
@@ -298,7 +292,6 @@ pub async fn unified_search<TagD: TagDao>(
let rows = {
let mut dao = exif_dao.lock().expect("exif dao");
dao.query_by_exif(
&ctx,
None, // scope filtered in-Rust to support multi-library
sq.camera_make.as_deref(),
sq.camera_model.as_deref(),
+1 -7
View File
@@ -806,10 +806,8 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
// Update status to processing
{
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ =
dao.update_status(&otel_ctx, &relative_path, "processing", None, None, None);
let _ = dao.update_status(&relative_path, "processing", None, None, None);
}
// Compute output path: join preview_clips_dir with relative path, change ext to .mp4
@@ -827,10 +825,8 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
"Preview clip complete for '{}' ({:.1}s, {} bytes)",
relative_path_owned, duration, size
);
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&otel_ctx,
&relative_path_owned,
"complete",
Some(duration as f32),
@@ -843,10 +839,8 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
"Failed to generate preview clip for '{}': {}",
relative_path_owned, e
);
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&otel_ctx,
&relative_path_owned,
"failed",
None,
+14 -28
View File
@@ -132,9 +132,8 @@ pub fn cleanup_orphaned_playlists(
// lives in any library is alive, even if the library a given
// download attributed it to has since been disabled.
let alive_hashes: HashSet<String> = {
let context = opentelemetry::Context::new();
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
match dao.list_distinct_content_hashes(&context) {
match dao.list_distinct_content_hashes() {
Ok(hashes) => hashes.into_iter().collect(),
Err(e) => {
error!(
@@ -399,11 +398,10 @@ pub fn watch_files(
if !lib.enabled {
continue;
}
let context = opentelemetry::Context::new();
let had_data = exif_dao
.lock()
.expect("exif_dao poisoned")
.count_for_library(&context, lib.id)
.count_for_library(lib.id)
.map(|n| n > 0)
.unwrap_or(false);
libraries::refresh_health(&library_health, lib, had_data);
@@ -448,10 +446,9 @@ pub fn watch_files(
// probe gate to cover handoff and orphan GC. See
// CLAUDE.md "Library availability and safety".
let had_data = {
let context = opentelemetry::Context::new();
let mut guard = exif_dao.lock().expect("exif_dao poisoned");
guard
.count_for_library(&context, lib.id)
.count_for_library(lib.id)
.map(|n| n > 0)
.unwrap_or(false)
};
@@ -477,10 +474,8 @@ pub fn watch_files(
let effective_excludes = lib.effective_excluded_dirs(&excluded_dirs);
if face_client.is_enabled() {
let context = opentelemetry::Context::new();
backfill::backfill_unhashed_backlog(&context, lib, &exif_dao);
backfill::backfill_unhashed_backlog(lib, &exif_dao);
backfill::process_face_backlog(
&context,
lib,
&face_client,
&face_dao,
@@ -494,9 +489,7 @@ pub fn watch_files(
// that don't have the face engine wired up. Mirrors the
// face drain shape (capped per tick, no-op when disabled).
if clip_client.is_enabled() {
let context = opentelemetry::Context::new();
backfill::process_clip_backlog(
&context,
lib,
&clip_client,
&exif_dao,
@@ -509,8 +502,7 @@ pub fn watch_files(
// of face detection — runs even on deploys that don't
// configure Apollo, since `/memories` depends on it.
{
let context = opentelemetry::Context::new();
backfill::backfill_missing_date_taken(&context, lib, &exif_dao);
backfill::backfill_missing_date_taken(lib, &exif_dao);
// True-instant drain (ADR-0001, #118): convert
// legacy mixed-convention rows, invalidating day
@@ -520,7 +512,7 @@ pub fn watch_files(
// prefs change applies without restart.
let display_tz = {
let mut prefs = user_ai_prefs_dao.lock().expect("prefs dao poisoned");
match prefs.get_prefs(&context) {
match prefs.get_prefs() {
Ok(Some(p)) => {
crate::camera_tz::display_zone_for(p.display_tz.as_deref())
}
@@ -528,7 +520,6 @@ pub fn watch_files(
}
};
backfill::backfill_utc_dates(
&context,
lib,
&exif_dao,
&precomputed_reel_dao,
@@ -536,7 +527,6 @@ pub fn watch_files(
utc_backfill_cursors.entry(lib.id).or_insert(0),
);
backfill::refresh_utc_backfill_progress(
&context,
lib,
&exif_dao,
&mut utc_backfill_remaining,
@@ -595,11 +585,9 @@ pub fn watch_files(
// visit this tick get visited next tick. See
// library_maintenance::detect_missing_files_for_library.
{
let context = opentelemetry::Context::new();
let offset = missing_file_offsets.get(&lib.id).copied().unwrap_or(0);
let (deleted, next_offset) =
library_maintenance::detect_missing_files_for_library(
&context,
lib,
&exif_dao,
offset,
@@ -708,7 +696,6 @@ pub fn process_new_files(
playlist_manager: Addr<VideoPlaylistManager>,
preview_generator: Addr<video::actors::PreviewClipGenerator>,
) {
let context = opentelemetry::Context::new();
let thumbs = dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
let thumbnail_directory = Path::new(&thumbs);
let base_path = Path::new(&library.root_path);
@@ -737,7 +724,7 @@ pub fn process_new_files(
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
// Walk is per-library, so scope the lookup so a same-named file
// in another library doesn't make this one look already-indexed.
match dao.get_exif_batch(&context, Some(library.id), &file_paths) {
match dao.get_exif_batch(Some(library.id), &file_paths) {
Ok(exif_records) => exif_records
.into_iter()
.map(|record| (record.file_path, record.content_hash))
@@ -888,7 +875,7 @@ pub fn process_new_files(
};
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
if let Err(e) = dao.store_exif(&context, insert_exif) {
if let Err(e) = dao.store_exif(insert_exif) {
error!(
"Failed to register {} in image_exif: {:?}",
relative_path, e
@@ -915,9 +902,8 @@ pub fn process_new_files(
// right tool for very large legacy libraries; this branch
// ensures small/medium deploys self-heal without operator
// action.
backfill::backfill_missing_content_hashes(&context, &files, library, &exif_dao);
let candidates =
backfill::build_face_candidates(&context, library, &files, &exif_dao, &face_dao);
backfill::backfill_missing_content_hashes(&files, library, &exif_dao);
let candidates = backfill::build_face_candidates(library, &files, &exif_dao, &face_dao);
debug!(
"face_watch: scan tick — {} image file(s) walked, {} candidate(s) (library '{}', modified_since={})",
files
@@ -996,7 +982,7 @@ pub fn process_new_files(
let existing_previews: HashMap<String, String> = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
match dao.get_previews_batch(&context, &video_rel_paths) {
match dao.get_previews_batch(&video_rel_paths) {
Ok(clips) => clips
.into_iter()
.map(|clip| (clip.file_path, clip.status))
@@ -1021,7 +1007,7 @@ pub fn process_new_files(
// Insert pending record using relative path
if status.is_none() {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, relative_path, "pending");
let _ = dao.insert_preview(relative_path, "pending");
}
// Send full path in the message — the actor will derive relative path from it
@@ -1047,7 +1033,7 @@ pub fn process_new_files(
let disk_paths: HashSet<String> = files.iter().map(|(_, rel)| rel.clone()).collect();
let db_paths: Vec<String> = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.get_rel_paths_for_library(&context, library.id)
dao.get_rel_paths_for_library(library.id)
.unwrap_or_else(|e| {
error!(
"Reconciliation: failed to load image_exif rel_paths for lib {}: {:?}",
@@ -1070,7 +1056,7 @@ pub fn process_new_files(
);
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
for rel in &stale {
if let Err(e) = dao.delete_exif_by_library(&context, library.id, rel) {
if let Err(e) = dao.delete_exif_by_library(library.id, rel) {
warn!(
"Reconciliation: failed to delete {} (lib {}): {:?}",
rel, library.id, e
+100
View File
@@ -0,0 +1,100 @@
//! Verifies the DAO-layer tracing migration (#141 / map #135).
//!
//! Every DAO method funnels through `otel::trace_db_call`, which no longer
//! takes an OpenTelemetry `Context`: the `db.<query_type>.<operation>` span
//! parents on the caller's ambient `tracing` span. Pinned here:
//!
//! 1. **Name + attributes.** The exported span keeps the pre-migration name
//! (`db.query.get_tags`) and `db.query_type` / `db.operation` attributes,
//! plus anything the closure records on the span it's handed.
//! 2. **Nesting.** It is a child of the ambient span — including inside a
//! `tokio::spawn` that carries the parent via `.instrument`.
//! 3. **Status.** `Ok` on success, `Error` with the message on failure.
//!
//! One process-global subscriber per test binary, so this file holds a
//! single test.
use opentelemetry::trace::{Status, TracerProvider};
use opentelemetry::Value;
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData};
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
fn attr(span: &SpanData, key: &str) -> Option<Value> {
span.attributes
.iter()
.find(|kv| kv.key.as_str() == key)
.map(|kv| kv.value.clone())
}
fn find<'a>(spans: &'a [SpanData], name: &str) -> &'a SpanData {
spans
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("span {name} not exported"))
}
#[test]
fn db_spans_nest_under_ambient_span() {
let exporter = InMemorySpanExporter::default();
let provider = SdkTracerProvider::builder()
.with_simple_exporter(exporter.clone())
.build();
Registry::default()
.with(tracing_opentelemetry::OpenTelemetryLayer::new(
provider.tracer("db-span-test"),
))
.try_init()
.expect("global subscriber not yet set");
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let handler = tracing::info_span!("test.handler");
tokio::spawn(
async {
let ok = image_api::otel::trace_db_call("query", "get_tags", |span| {
span.set_attribute("path", "a/b.jpg");
Ok(3)
});
assert_eq!(ok.unwrap(), 3);
let err: anyhow::Result<()> =
image_api::otel::trace_db_call("delete", "remove_tag", |_span| {
anyhow::bail!("no such tag")
});
assert!(err.is_err());
}
.instrument(handler),
)
.await
.expect("spawned handler");
});
let _ = provider.force_flush();
let spans = exporter.get_finished_spans().unwrap();
let handler = find(&spans, "test.handler");
let get = find(&spans, "db.query.get_tags");
let del = find(&spans, "db.delete.remove_tag");
for (span, qt, op) in [(get, "query", "get_tags"), (del, "delete", "remove_tag")] {
assert_eq!(span.span_context.trace_id(), handler.span_context.trace_id());
assert_eq!(
span.parent_span_id,
handler.span_context.span_id(),
"{op}: db span must be a child of the ambient span"
);
assert_eq!(attr(span, "db.query_type"), Some(qt.into()));
assert_eq!(attr(span, "db.operation"), Some(op.into()));
}
assert_eq!(attr(get, "path"), Some("a/b.jpg".into()));
assert_eq!(get.status, Status::Ok);
assert_eq!(del.status, Status::error("no such tag"));
}
+10 -16
View File
@@ -8,16 +8,15 @@
//! is parented onto the caller's span — the whole request shares the
//! caller's `trace_id`.
//!
//! 2. **No duplicate request span.** A handler that parents its operation span
//! through `otel::extract_context_from_request` nests it *under* the
//! middleware's root span (one tree) rather than re-parsing the header into
//! a second, sibling request span.
//! 2. **No duplicate request span.** A handler's `#[instrument]`ed operation
//! span nests *under* the middleware's root span (one tree) rather than
//! re-parsing the header into a second, sibling request span.
//!
//! One process-global subscriber + propagator per test binary (one-shot), so
//! this file holds a single test.
use actix_web::{web, App, HttpRequest, HttpResponse};
use opentelemetry::trace::{Tracer, TracerProvider};
use actix_web::{App, HttpResponse, web};
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData};
use tracing_subscriber::layer::SubscriberExt;
@@ -27,12 +26,10 @@ use tracing_subscriber::util::SubscriberInitExt;
const TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
const REMOTE_PARENT: &str = "b7ad6b7169203331";
/// Mirrors the app's handler pattern: parent the operation span via
/// `extract_context_from_request` (which sources the middleware's root span).
async fn operation_handler(req: HttpRequest) -> HttpResponse {
let cx = image_api::otel::extract_context_from_request(&req);
let tracer = image_api::otel::global_tracer();
let _span = tracer.start_with_context("test.operation", &cx);
/// Mirrors the app's handler pattern: an `#[instrument]`ed operation span,
/// parented ambiently on the middleware's root span.
#[tracing::instrument(name = "test.operation", skip_all)]
async fn operation_handler() -> HttpResponse {
HttpResponse::Ok().finish()
}
@@ -65,10 +62,7 @@ async fn traceparent_header_nests_request_spans() {
let req = actix_web::test::TestRequest::get()
.uri("/op")
.insert_header((
"traceparent",
format!("00-{TRACE_ID}-{REMOTE_PARENT}-01"),
))
.insert_header(("traceparent", format!("00-{TRACE_ID}-{REMOTE_PARENT}-01")))
.to_request();
let _res = actix_web::test::call_and_read_body(&srv, req).await;