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:
1 parent
1c508cdabd
commit
b6deca2a52
55 files changed
+1942
-3852
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
+174
-345
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+70
-167
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+137
-297
@@ -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())),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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...
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+3
-18
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user