Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/live_search.rs
T
cucadmuh 91e7195e0f fix(library): scoped live search FTS, race, and multi-server advanced search (#868)
* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
2026-05-25 04:20:02 +03:00

748 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Live Search dropdown (spec §5.9 / P24) — column-scoped FTS with LIMIT inside
//! the FTS subquery (bm25 on ≤N rowids), then a cheap join to `track`.
//! Avoids the SQLite pitfall where `JOIN track … ORDER BY bm25` on an OR
//! MATCH scans/ranks the whole hit set on 100k+ libraries (1020s queries).
use std::collections::{HashMap, HashSet};
use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto};
use crate::search::{
fts_album_prefix_any_token_match_query, fts_artist_prefix_any_token_match_query,
fts_query_meets_min_len, fts_track_prefix_any_token_match_query, library_scope_equals_sql,
};
use crate::store::LibraryStore;
const TRACK_FTS_BM25_RANK: &str = "bm25(track_fts, 10.0, 3.0, 5.0, 3.0, 0.0)";
/// FTS row candidates before GROUP BY dedupe — avoids one artist filling the whole cap.
const LIVE_SEARCH_FTS_CANDIDATE_CAP: i64 = 150;
struct LiveHit {
track: LibraryTrackDto,
}
/// `library_live_search` — read connection, scoped FTS rowid picks + join.
pub fn run_live_search(
store: &LibraryStore,
server_id: &str,
query: &str,
library_scope: Option<&str>,
artist_limit: u32,
album_limit: u32,
song_limit: u32,
) -> Result<LibraryLiveSearchResponse, String> {
if !fts_query_meets_min_len(query) {
return Ok(LibraryLiveSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
source: "local".to_string(),
});
}
store.with_read_conn(|conn| {
let scope = trimmed_scope(library_scope);
// Songs first — smallest FTS cap; warms the page cache for follow-up queries.
let songs = query_songs(conn, query, server_id, scope.as_deref(), song_limit)?;
let artists = query_artists(conn, query, server_id, scope.as_deref(), artist_limit)?;
let albums = query_albums(conn, query, server_id, scope.as_deref(), album_limit)?;
Ok(LibraryLiveSearchResponse {
artists,
albums,
tracks: songs,
source: "local".to_string(),
})
})
}
/// Top FTS rowids for column-scoped MATCH, scoped to `server_id` (multi-server safe).
fn collect_fts_rowids(
conn: &rusqlite::Connection,
match_queries: &[String],
server_id: &str,
library_scope: Option<&str>,
per_query_limit: i64,
total_limit: usize,
) -> rusqlite::Result<Vec<i64>> {
let scope = trimmed_scope(library_scope);
let mut scope_sql = String::new();
if scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql("c"));
}
let sql = format!(
"SELECT f.rowid FROM track_fts f \
WHERE track_fts MATCH ? \
AND EXISTS (\
SELECT 1 FROM track c \
WHERE c.rowid = f.rowid \
AND c.server_id = ? \
AND c.deleted = 0{scope_sql}\
) \
ORDER BY {TRACK_FTS_BM25_RANK} LIMIT ?",
);
let mut stmt = conn.prepare(&sql)?;
let mut seen = HashSet::new();
let mut rowids = Vec::new();
for mq in match_queries {
let mut bind: Vec<rusqlite::types::Value> = vec![
rusqlite::types::Value::Text(mq.clone()),
rusqlite::types::Value::Text(server_id.to_string()),
];
if let Some(ref s) = scope {
bind.push(rusqlite::types::Value::Text(s.clone()));
}
bind.push(rusqlite::types::Value::Integer(per_query_limit));
let rows = stmt.query_map(rusqlite::params_from_iter(bind.iter()), |r| r.get(0))?;
for rowid in rows {
let rowid = rowid?;
if seen.insert(rowid) {
rowids.push(rowid);
if rowids.len() >= total_limit {
return Ok(rowids);
}
}
}
}
Ok(rowids)
}
fn trimmed_scope(scope: Option<&str>) -> Option<String> {
scope
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn append_library_scope(
sql: &mut String,
params: &mut Vec<rusqlite::types::Value>,
library_scope: Option<&str>,
) {
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
sql.push_str(" AND ");
sql.push_str(&library_scope_equals_sql("t"));
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
}
fn scoped_exists_sql(library_scope: Option<&str>, extra: &str) -> String {
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql("c"));
}
format!(
"EXISTS (\
SELECT 1 FROM track c \
WHERE c.rowid = f.rowid \
AND c.server_id = ? \
AND c.deleted = 0{extra}{scope_sql}\
)"
)
}
fn push_scope_bind(params: &mut Vec<rusqlite::types::Value>, library_scope: Option<&str>) {
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
}
fn query_artists(
conn: &rusqlite::Connection,
query: &str,
server_id: &str,
library_scope: Option<&str>,
limit: u32,
) -> rusqlite::Result<Vec<LibraryArtistDto>> {
let Some(artist_fts) = fts_artist_prefix_any_token_match_query(query) else {
return Ok(Vec::new());
};
let exists = scoped_exists_sql(
library_scope,
" AND c.artist_id IS NOT NULL AND c.artist_id != ''",
);
let sql = format!(
"WITH fts_hits AS (\
SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \
FROM track_fts f \
WHERE track_fts MATCH ? \
AND {exists} \
ORDER BY rank \
LIMIT ?\
) \
SELECT t.server_id, t.artist_id, t.artist, t.synced_at, MIN(h.rank) AS best_rank \
FROM fts_hits h \
JOIN track t ON t.rowid = h.rowid \
WHERE t.server_id = ? \
AND t.deleted = 0 \
AND t.artist_id IS NOT NULL AND t.artist_id != ''"
);
let mut sql = sql;
let mut params: Vec<rusqlite::types::Value> = vec![
rusqlite::types::Value::Text(artist_fts),
rusqlite::types::Value::Text(server_id.to_string()),
];
push_scope_bind(&mut params, library_scope);
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
params.push(rusqlite::types::Value::Text(server_id.to_string()));
append_library_scope(&mut sql, &mut params, library_scope);
sql.push_str(" GROUP BY t.artist_id ORDER BY best_rank LIMIT ?");
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
let mut stmt = conn.prepare(&sql)?;
let mut out = Vec::new();
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
album_count: None,
synced_at: r.get(3)?,
raw_json: serde_json::Value::Null,
})
})? {
out.push(row?);
}
Ok(out)
}
fn query_songs(
conn: &rusqlite::Connection,
query: &str,
server_id: &str,
library_scope: Option<&str>,
limit: u32,
) -> rusqlite::Result<Vec<LibraryTrackDto>> {
let Some(song_fts) = fts_track_prefix_any_token_match_query(query) else {
return Ok(Vec::new());
};
let per_col = i64::from(limit.max(4));
let rowids = collect_fts_rowids(conn, &[song_fts], server_id, library_scope, per_col, limit as usize)?;
if rowids.is_empty() {
return Ok(Vec::new());
}
fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope)
}
fn query_albums(
conn: &rusqlite::Connection,
query: &str,
server_id: &str,
library_scope: Option<&str>,
limit: u32,
) -> rusqlite::Result<Vec<LibraryAlbumDto>> {
let Some(album_fts) = fts_album_prefix_any_token_match_query(query) else {
return Ok(Vec::new());
};
let exists = scoped_exists_sql(
library_scope,
" AND c.album_id IS NOT NULL AND c.album_id != ''",
);
let sql = format!(
"WITH fts_hits AS (\
SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \
FROM track_fts f \
WHERE track_fts MATCH ? \
AND {exists} \
ORDER BY rank \
LIMIT ?\
) \
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \
FROM fts_hits h \
JOIN track t ON t.rowid = h.rowid \
WHERE t.server_id = ? \
AND t.deleted = 0 \
AND t.album_id IS NOT NULL AND t.album_id != ''"
);
let mut sql = sql;
let mut params: Vec<rusqlite::types::Value> = vec![
rusqlite::types::Value::Text(album_fts),
rusqlite::types::Value::Text(server_id.to_string()),
];
push_scope_bind(&mut params, library_scope);
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
params.push(rusqlite::types::Value::Text(server_id.to_string()));
append_library_scope(&mut sql, &mut params, library_scope);
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
let mut stmt = conn.prepare(&sql)?;
let mut out = Vec::new();
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
raw_json: serde_json::Value::Null,
})
})? {
out.push(row?);
}
Ok(out)
}
fn rowid_placeholders(n: usize) -> String {
(0..n).map(|_| "?").collect::<Vec<_>>().join(", ")
}
fn fetch_tracks_by_rowids(
conn: &rusqlite::Connection,
rowids: &[i64],
server_id: &str,
library_scope: Option<&str>,
) -> rusqlite::Result<Vec<LibraryTrackDto>> {
let placeholders = rowid_placeholders(rowids.len());
let sql = format!(
"SELECT \
t.rowid, \
t.server_id, t.id, t.title, t.artist, t.artist_id, t.album, t.album_id, \
t.album_artist, t.duration_sec, t.track_number, t.disc_number, t.year, \
t.genre, t.suffix, t.bit_rate, t.size_bytes, t.cover_art_id, \
t.starred_at, t.user_rating, t.play_count, t.bpm, t.synced_at \
FROM track t \
WHERE t.rowid IN ({placeholders}) \
AND t.server_id = ? \
AND t.deleted = 0"
);
let mut params: Vec<rusqlite::types::Value> = rowids
.iter()
.copied()
.map(rusqlite::types::Value::Integer)
.collect();
params.push(rusqlite::types::Value::Text(server_id.to_string()));
let mut sql = sql;
append_library_scope(&mut sql, &mut params, library_scope);
let mut stmt = conn.prepare(&sql)?;
let mut by_rowid: HashMap<i64, LibraryTrackDto> = HashMap::new();
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let rowid: i64 = r.get(0)?;
let hit = map_live_hit_row(r, 1)?;
Ok((rowid, hit.track))
})? {
let (rowid, track) = row?;
by_rowid.insert(rowid, track);
}
Ok(rowids
.iter()
.filter_map(|rid| by_rowid.get(rid).cloned())
.collect())
}
fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<LiveHit> {
Ok(LiveHit {
track: LibraryTrackDto {
server_id: row.get(offset)?,
id: row.get(offset + 1)?,
content_hash: None,
title: row.get(offset + 2)?,
title_sort: None,
artist: row.get(offset + 3)?,
artist_id: row.get(offset + 4)?,
album: row.get(offset + 5)?,
album_id: row.get(offset + 6)?,
album_artist: row.get(offset + 7)?,
duration_sec: row.get(offset + 8)?,
track_number: row.get(offset + 9)?,
disc_number: row.get(offset + 10)?,
year: row.get(offset + 11)?,
genre: row.get(offset + 12)?,
suffix: row.get(offset + 13)?,
bit_rate: row.get(offset + 14)?,
size_bytes: row.get(offset + 15)?,
cover_art_id: row.get(offset + 16)?,
starred_at: row.get(offset + 17)?,
user_rating: row.get(offset + 18)?,
play_count: row.get(offset + 19)?,
bpm: row.get(offset + 20)?,
bpm_source: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
server_updated_at: None,
server_created_at: None,
synced_at: row.get(offset + 21)?,
enrichment: None,
raw_json: serde_json::Value::Null,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use serde_json::json;
fn track(
server: &str,
id: &str,
title: &str,
artist: &str,
album: &str,
album_id: &str,
artist_id: &str,
) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(artist_id.into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: Some(format!("cv_{album_id}")),
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn live_search_prefix_matches_partial_artist_name() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track(
"s1",
"t1",
"Enter Sandman",
"Metallica",
"Metallica",
"al1",
"ar_meta",
),
track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"),
])
.unwrap();
let resp = run_live_search(&store, "s1", "metal", None, 5, 5, 10).unwrap();
assert!(
resp.artists.iter().any(|a| a.name == "Metallica"),
"expected Metallica from prefix query metal"
);
assert!(resp.tracks.iter().any(|t| t.artist.as_deref() == Some("Metallica")));
}
#[test]
fn live_search_returns_songs_albums_artists_from_scoped_fts() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Aurora Song", "Aurora Quartet", "Aurora Nights", "al1", "ar1"),
track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"),
])
.unwrap();
let resp = run_live_search(&store, "s1", "aurora", None, 5, 5, 10).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar1");
assert!(resp.tracks[0].raw_json.is_null());
}
#[test]
fn live_search_does_not_surface_artist_from_unrelated_track_hit() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track(
"s1",
"t1",
"Battle Hymn",
"Arch Enemy",
"Manowar Covers Vol 1",
"al1",
"ar_arch",
),
track(
"s1",
"t2",
"Heart Of Steel",
"Manowar",
"Fighting the World",
"al2",
"ar_mano",
),
])
.unwrap();
let resp = run_live_search(&store, "s1", "manowar", None, 5, 5, 10).unwrap();
assert!(
resp.artists.iter().any(|a| a.name == "Manowar"),
"expected Manowar artist"
);
assert!(
!resp.artists.iter().any(|a| a.name == "Arch Enemy"),
"Arch Enemy must not appear when only the album title mentions Manowar"
);
assert!(resp.albums.iter().any(|a| a.name.contains("Manowar")));
}
#[test]
fn live_search_short_query_returns_empty_without_scanning() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track(
"s1",
"t1",
"Аура",
"Artist",
"Album",
"al1",
"ar1",
)])
.unwrap();
let resp = run_live_search(&store, "s1", "а", None, 5, 5, 10).unwrap();
assert!(resp.tracks.is_empty());
assert!(resp.artists.is_empty());
assert!(resp.albums.is_empty());
}
#[test]
fn live_search_library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
let mut in_lib = track(
"s1",
"t1",
"Scoped Song",
"Scoped Artist",
"Scoped Album",
"al1",
"ar1",
);
in_lib.library_id = Some("lib1".into());
let mut other = track(
"s1",
"t2",
"Scoped Song",
"Other Artist",
"Other Album",
"al2",
"ar2",
);
other.library_id = Some("lib2".into());
TrackRepository::new(&store)
.upsert_batch(&[in_lib, other])
.unwrap();
let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), 5, 5, 10).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].name, "Scoped Artist");
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].name, "Scoped Album");
}
#[test]
fn live_search_library_scope_matches_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track(
"s1",
"t1",
"Scoped Song",
"Scoped Artist",
"Scoped Album",
"al1",
"ar1",
);
row.raw_json = json!({"libraryId": 3}).to_string();
TrackRepository::new(&store).upsert_batch(&[row]).unwrap();
let resp = run_live_search(&store, "s1", "scoped", Some("3"), 5, 5, 10).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn live_search_fts_scoped_to_server_not_global_bm25() {
let store = LibraryStore::open_in_memory();
let mut batch = Vec::new();
for i in 0..20 {
batch.push(track(
"s_big",
&format!("t{i}"),
"Song",
"Nightblaze",
"Album",
&format!("al{i}"),
"ar_nightblaze",
));
}
batch.push(track(
"s_small",
"t_nw",
"Ghost Love Score",
"Nightwish",
"Once",
"al_nw",
"ar_nw",
));
TrackRepository::new(&store)
.upsert_batch(&batch)
.unwrap();
let resp = run_live_search(&store, "s_small", "night", None, 5, 5, 10).unwrap();
assert!(
resp.artists.iter().any(|a| a.name == "Nightwish"),
"expected Nightwish on s_small; global bm25 must not crowd out the active server"
);
}
#[test]
fn live_search_returns_distinct_artists_not_one_per_many_tracks() {
let store = LibraryStore::open_in_memory();
let mut batch = Vec::new();
for i in 0..12 {
batch.push(track(
"s1",
&format!("t_m{i}"),
"Song",
"Metallica",
"Album",
&format!("al_m{i}"),
"ar_meta",
));
}
for (id, name, artist_id) in [
("ar_metal1", "Metallica Tribute", "ar_t1"),
("ar_metal2", "Metallium", "ar_t2"),
("ar_metal3", "Metalloid", "ar_t3"),
] {
batch.push(track(
"s1",
&format!("t_{artist_id}"),
"One",
name,
"Other",
id,
artist_id,
));
}
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
let resp = run_live_search(&store, "s1", "metall", None, 5, 5, 10).unwrap();
assert!(
resp.artists.len() >= 3,
"expected distinct metall* artists, got {} ({:?})",
resp.artists.len(),
resp.artists.iter().map(|a| a.name.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn live_search_multiword_album_matches_any_token_not_only_first() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track(
"s1",
"t1",
"Intro",
"Artist",
"Supreme Ballads",
"al_supreme",
"ar1",
),
track(
"s1",
"t2",
"Other",
"Artist",
"Unrelated",
"al2",
"ar1",
),
])
.unwrap();
let resp = run_live_search(&store, "s1", "love supreme", None, 5, 5, 10).unwrap();
assert!(
resp.albums.iter().any(|a| a.name == "Supreme Ballads"),
"second token supreme must match album title; AND-all-tokens would miss this album"
);
}
/// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture`
#[test]
#[ignore]
fn bench_disk_live_search() {
use std::path::PathBuf;
use std::time::Instant;
let path: PathBuf = std::env::var("HOME")
.map(|h| {
PathBuf::from(h)
.join(".local/share/dev.psysonic.player/databases/library/library.sqlite")
})
.expect("HOME");
if !path.exists() {
eprintln!("skip: no db at {}", path.display());
return;
}
let conn = rusqlite::Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.expect("open db");
conn.pragma_update(None, "cache_size", -64000).unwrap();
let server_id = std::env::var("PSYSONIC_BENCH_SERVER_ID").unwrap_or_else(|_| {
conn.query_row(
"SELECT server_id FROM track WHERE deleted = 0 LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.expect("server_id")
});
for q in ["manowar", "metallica", "arch enemy", "metal", "meta"] {
let t0 = Instant::now();
let songs = query_songs(&conn, q, &server_id, None, 10).unwrap();
let t1 = Instant::now();
let artists = query_artists(&conn, q, &server_id, None, 5).unwrap();
let t2 = Instant::now();
let albums = query_albums(&conn, q, &server_id, None, 5).unwrap();
let t3 = Instant::now();
eprintln!(
"{q:?}: songs={} ({:.1}ms) artists={} ({:.1}ms) albums={} ({:.1}ms) total={:.1}ms",
songs.len(),
t1.duration_since(t0).as_secs_f64() * 1000.0,
artists.len(),
t2.duration_since(t1).as_secs_f64() * 1000.0,
albums.len(),
t3.duration_since(t2).as_secs_f64() * 1000.0,
t3.duration_since(t0).as_secs_f64() * 1000.0,
);
}
}
}