Face Recognition / People Integration #61
116
src/tags.rs
116
src/tags.rs
@@ -32,6 +32,7 @@ where
|
||||
)
|
||||
.service(web::resource("image/tags/all").route(web::get().to(get_all_tags::<TagD>)))
|
||||
.service(web::resource("image/tags/batch").route(web::post().to(update_tags::<TagD>)))
|
||||
.service(web::resource("image/tags/lookup").route(web::post().to(lookup_tags_batch::<TagD>)))
|
||||
}
|
||||
|
||||
async fn add_tag<D: TagDao>(
|
||||
@@ -238,6 +239,51 @@ async fn update_tags<D: TagDao>(
|
||||
.into_http_internal_err()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct LookupTagsBatchRequest {
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Bulk per-path tag lookup. Apollo's photo-match flow used to fan out
|
||||
/// one ``GET /image/tags?path=`` per record (~4k for a wide window) —
|
||||
/// each call locked the dao briefly and the round-trip cost dwarfed
|
||||
/// the actual SQL. This collapses the whole fan-out into one POST and
|
||||
/// one (chunked) JOIN. Body: ``{paths: [...]}``; response:
|
||||
/// ``{path: [{id, name, ...}]}`` with **only paths that have at least
|
||||
/// one tag** in the map (the caller treats absence as empty list).
|
||||
///
|
||||
/// Trade-off: this matches by ``rel_path`` directly and does NOT do
|
||||
/// the cross-library content-hash sibling expansion that the per-path
|
||||
/// ``GET /image/tags`` does. For Apollo's grid view the simpler match
|
||||
/// is fine — it's the common case for single-library deploys; the
|
||||
/// carousel still uses the per-path endpoint and resolves siblings on
|
||||
/// demand. If multi-library content-hash sharing becomes load-bearing
|
||||
/// for the grid, extend this to JOIN ``image_exif`` on content_hash.
|
||||
async fn lookup_tags_batch<D: TagDao>(
|
||||
_: Claims,
|
||||
http_request: HttpRequest,
|
||||
body: web::Json<LookupTagsBatchRequest>,
|
||||
tag_dao: web::Data<Mutex<D>>,
|
||||
) -> impl Responder {
|
||||
let context = extract_context_from_request(&http_request);
|
||||
let span = global_tracer().start_with_context("lookup_tags_batch", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
if body.paths.is_empty() {
|
||||
return HttpResponse::Ok().json(std::collections::HashMap::<String, Vec<Tag>>::new());
|
||||
}
|
||||
|
||||
let normalized: Vec<String> = body.paths.iter().map(|p| normalize_path(p)).collect();
|
||||
let mut dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||
match dao.get_tags_grouped_by_paths(&span_context, &normalized) {
|
||||
Ok(grouped) => {
|
||||
span_context.span().set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(grouped)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().body(format!("{}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub id: i32,
|
||||
@@ -317,6 +363,14 @@ pub trait TagDao: Send + Sync {
|
||||
context: &opentelemetry::Context,
|
||||
paths: &[String],
|
||||
) -> anyhow::Result<Vec<Tag>>;
|
||||
/// Per-path grouped lookup: ``rel_path → [tags]``. Used by the
|
||||
/// ``/image/tags/lookup`` batch endpoint. Returns only paths that
|
||||
/// have at least one tag; the caller treats absence as empty.
|
||||
fn get_tags_grouped_by_paths(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
paths: &[String],
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Vec<Tag>>>;
|
||||
fn create_tag(&mut self, context: &opentelemetry::Context, name: &str) -> anyhow::Result<Tag>;
|
||||
fn remove_tag(
|
||||
&mut self,
|
||||
@@ -470,6 +524,51 @@ impl TagDao for SqliteTagDao {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_tags_grouped_by_paths(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
paths: &[String],
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Vec<Tag>>> {
|
||||
use std::collections::HashMap;
|
||||
let mut out: HashMap<String, Vec<Tag>> = HashMap::new();
|
||||
if paths.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut conn = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock SqliteTagDao connection");
|
||||
trace_db_call(context, "query", "get_tags_grouped_by_paths", |span| {
|
||||
span.set_attribute(KeyValue::new("path_count", paths.len() as i64));
|
||||
// SQLite's default SQLITE_LIMIT_VARIABLE_NUMBER is 32766 in
|
||||
// modern builds (999 in old ones). Chunk at 500 to stay
|
||||
// safely under both — five queries for a 4k-photo grid is
|
||||
// still ~800x cheaper than 4k single-row HTTP calls.
|
||||
const CHUNK: usize = 500;
|
||||
for chunk in paths.chunks(CHUNK) {
|
||||
let rows: Vec<(String, i32, String, i64)> = tagged_photo::table
|
||||
.inner_join(tags::table)
|
||||
.filter(tagged_photo::rel_path.eq_any(chunk))
|
||||
.select((
|
||||
tagged_photo::rel_path,
|
||||
tags::id,
|
||||
tags::name,
|
||||
tags::created_time,
|
||||
))
|
||||
.get_results(conn.deref_mut())
|
||||
.with_context(|| "Unable to get tags grouped from Sqlite")?;
|
||||
for (rel_path, id, name, created_time) in rows {
|
||||
out.entry(rel_path).or_default().push(Tag {
|
||||
id,
|
||||
name,
|
||||
created_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn create_tag(&mut self, context: &opentelemetry::Context, name: &str) -> anyhow::Result<Tag> {
|
||||
let mut conn = self
|
||||
.connection
|
||||
@@ -893,6 +992,23 @@ mod tests {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn get_tags_grouped_by_paths(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
paths: &[String],
|
||||
) -> anyhow::Result<std::collections::HashMap<String, Vec<Tag>>> {
|
||||
let tagged = self.tagged_photos.borrow();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for p in paths {
|
||||
if let Some(tags) = tagged.get(p)
|
||||
&& !tags.is_empty()
|
||||
{
|
||||
out.insert(p.clone(), tags.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn create_tag(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
|
||||
Reference in New Issue
Block a user