Cache-first object fetching (#76)

* fix: submillisecond cache hits

* fix: async object loading to hand control back to renderer

* fix: clippy
This commit is contained in:
DecDuck
2025-07-27 12:04:50 +10:00
committed by GitHub
parent 682c6e9c0b
commit 739e6166c5
12 changed files with 110 additions and 101 deletions

View File

@ -1,4 +1,5 @@
<template> <template>
<LoadingIndicator />
<NuxtLayout class="select-none w-screen h-screen"> <NuxtLayout class="select-none w-screen h-screen">
<NuxtPage /> <NuxtPage />
<ModalStack /> <ModalStack />

View File

@ -23,7 +23,7 @@
<MenuItems <MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md" class="absolute bg-zinc-900 right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
> >
<PanelWidget class="flex-col gap-y-2"> <div class="flex-col gap-y-2">
<NuxtLink <NuxtLink
to="/id/me" to="/id/me"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800" class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
@ -65,7 +65,7 @@
</button> </button>
</MenuItem> </MenuItem>
</div> </div>
</PanelWidget> </div>
</MenuItems> </MenuItems>
</transition> </transition>
</Menu> </Menu>

View File

@ -0,0 +1,7 @@
<template></template>
<script setup lang="ts">
const loading = useLoadingIndicator();
watch(loading.isLoading, console.log);
</script>

View File

@ -76,7 +76,7 @@
<TransitionGroup name="slide" tag="div" class="h-full"> <TransitionGroup name="slide" tag="div" class="h-full">
<img <img
v-for="(url, index) in mediaUrls" v-for="(url, index) in mediaUrls"
:key="url" :key="index"
:src="url" :src="url"
class="absolute inset-0 w-full h-full object-cover" class="absolute inset-0 w-full h-full object-cover"
v-show="index === currentImageIndex" v-show="index === currentImageIndex"
@ -157,8 +157,8 @@
<template #default> <template #default>
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left"> <div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100" <h3 class="text-base font-semibold text-zinc-100">
>Install {{ game.mName }}? Install {{ game.mName }}?
</h3> </h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-zinc-400"> <p class="text-sm text-zinc-400">
@ -350,9 +350,7 @@
<template #buttons> <template #buttons>
<LoadingButton <LoadingButton
@click="() => install()" @click="() => install()"
:disabled=" :disabled="!(versionOptions && versionOptions.length > 0)"
!(versionOptions && versionOptions.length > 0)
"
:loading="installLoading" :loading="installLoading"
type="submit" type="submit"
class="ml-2 w-full sm:w-fit" class="ml-2 w-full sm:w-fit"
@ -370,7 +368,11 @@
</template> </template>
</ModalTemplate> </ModalTemplate>
<GameOptionsModal v-if="status.type === GameStatusEnum.Installed" v-model="configureModalOpen" :game-id="game.id" /> <GameOptionsModal
v-if="status.type === GameStatusEnum.Installed"
v-model="configureModalOpen"
:game-id="game.id"
/>
<Transition <Transition
enter="transition ease-out duration-300" enter="transition ease-out duration-300"
@ -420,7 +422,7 @@
<img <img
v-for="(url, index) in mediaUrls" v-for="(url, index) in mediaUrls"
v-show="currentImageIndex === index" v-show="currentImageIndex === index"
:key="url" :key="index"
:src="url" :src="url"
class="max-h-[90vh] max-w-[90vw] object-contain" class="max-h-[90vh] max-w-[90vw] object-contain"
:alt="`${game.mName} screenshot ${index + 1}`" :alt="`${game.mName} screenshot ${index + 1}`"
@ -441,10 +443,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
Dialog,
DialogTitle,
TransitionChild,
TransitionRoot,
Listbox, Listbox,
ListboxButton, ListboxButton,
ListboxLabel, ListboxLabel,
@ -482,7 +480,10 @@ const bannerUrl = await useObject(game.value.mBannerObjectId);
// Get all available images // Get all available images
const mediaUrls = await Promise.all( const mediaUrls = await Promise.all(
game.value.mImageCarouselObjectIds.map((id) => useObject(id)) game.value.mImageCarouselObjectIds.map(async (v) => {
const src = await useObject(v);
return src;
})
); );
const htmlDescription = micromark(game.value.mDescription); const htmlDescription = micromark(game.value.mDescription);
@ -496,7 +497,6 @@ const currentImageIndex = ref(0);
const configureModalOpen = ref(false); const configureModalOpen = ref(false);
async function installFlow() { async function installFlow() {
installFlowOpen.value = true; installFlowOpen.value = true;
versionOptions.value = undefined; versionOptions.value = undefined;
@ -535,12 +535,11 @@ async function install() {
} }
async function resumeDownload() { async function resumeDownload() {
try { try {
await invoke("resume_download", { gameId: game.value.id }) await invoke("resume_download", { gameId: game.value.id });
} } catch (e) {
catch(e) { console.error(e);
console.error(e) }
}
} }
async function launch() { async function launch() {

View File

@ -21,7 +21,7 @@ pub enum RemoteAccessError {
UnparseableResponse(String), UnparseableResponse(String),
ManifestDownloadFailed(StatusCode, String), ManifestDownloadFailed(StatusCode, String),
OutOfSync, OutOfSync,
Cache(cacache::Error), Cache(std::io::Error),
} }
impl Display for RemoteAccessError { impl Display for RemoteAccessError {

View File

@ -113,7 +113,7 @@ pub fn fetch_library_logic(
} }
// We should always have a cache of the object // We should always have a cache of the object
// Pass db_handle because otherwise we get a gridlock // Pass db_handle because otherwise we get a gridlock
let game = get_cached_object_db::<String, Game>(meta.id.clone(), &db_handle)?; let game = get_cached_object_db::<Game>(&meta.id.clone(), &db_handle)?;
games.push(game); games.push(game);
} }
@ -173,7 +173,7 @@ pub fn fetch_game_logic(
version, version,
}; };
cache_object(id, game)?; cache_object(&id, game)?;
return Ok(data); return Ok(data);
} }
@ -212,7 +212,7 @@ pub fn fetch_game_logic(
version, version,
}; };
cache_object(id, &game)?; cache_object(&id, &game)?;
Ok(data) Ok(data)
} }
@ -239,7 +239,7 @@ pub fn fetch_game_logic_offline(
drop(handle); drop(handle);
let status = GameStatusManager::fetch_state(&id); let status = GameStatusManager::fetch_state(&id);
let game = get_cached_object::<String, Game>(id)?; let game = get_cached_object::<Game>(&id)?;
Ok(FetchGameStruct { Ok(FetchGameStruct {
game, game,

View File

@ -13,6 +13,7 @@ mod remote;
use crate::process::commands::open_process_logs; use crate::process::commands::open_process_logs;
use crate::{database::db::DatabaseImpls, games::downloads::commands::resume_download}; use crate::{database::db::DatabaseImpls, games::downloads::commands::resume_download};
use bitcode::{Decode, Encode};
use client::commands::fetch_state; use client::commands::fetch_state;
use client::{ use client::{
autostart::{get_autostart_enabled, sync_autostart_on_startup, toggle_autostart}, autostart::{get_autostart_enabled, sync_autostart_on_startup, toggle_autostart},
@ -22,7 +23,7 @@ use database::commands::{
add_download_dir, delete_download_dir, fetch_download_dir_stats, fetch_settings, add_download_dir, delete_download_dir, fetch_download_dir_stats, fetch_settings,
fetch_system_data, update_settings, fetch_system_data, update_settings,
}; };
use database::db::{borrow_db_checked, borrow_db_mut_checked, DatabaseInterface, DATA_ROOT_DIR}; use database::db::{DATA_ROOT_DIR, DatabaseInterface, borrow_db_checked, borrow_db_mut_checked};
use database::models::data::GameDownloadStatus; use database::models::data::GameDownloadStatus;
use download_manager::commands::{ use download_manager::commands::{
cancel_game, move_download_in_queue, pause_downloads, resume_downloads, cancel_game, move_download_in_queue, pause_downloads, resume_downloads,
@ -37,13 +38,13 @@ use games::commands::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game,
}; };
use games::downloads::commands::download_game; use games::downloads::commands::download_game;
use games::library::{update_game_configuration, Game}; use games::library::{Game, update_game_configuration};
use log::{debug, info, warn, LevelFilter}; use log::{LevelFilter, debug, info, warn};
use log4rs::Config;
use log4rs::append::console::ConsoleAppender; use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Root}; use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use log4rs::Config;
use process::commands::{kill_game, launch_game}; use process::commands::{kill_game, launch_game};
use process::process_manager::ProcessManager; use process::process_manager::ProcessManager;
use remote::auth::{self, recieve_handshake}; use remote::auth::{self, recieve_handshake};
@ -51,7 +52,7 @@ use remote::commands::{
auth_initiate, fetch_drop_object, gen_drop_url, manual_recieve_handshake, retry_connect, auth_initiate, fetch_drop_object, gen_drop_url, manual_recieve_handshake, retry_connect,
sign_out, use_remote, sign_out, use_remote,
}; };
use remote::fetch_object::{fetch_object, fetch_object_offline}; use remote::fetch_object::fetch_object;
use remote::server_proto::{handle_server_proto, handle_server_proto_offline}; use remote::server_proto::{handle_server_proto, handle_server_proto_offline};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::File; use std::fs::File;
@ -71,7 +72,6 @@ use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, RunEvent, WindowEvent}; use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use bitcode::{Encode, Decode};
#[derive(Clone, Copy, Serialize, Eq, PartialEq)] #[derive(Clone, Copy, Serialize, Eq, PartialEq)]
pub enum AppStatus { pub enum AppStatus {
@ -401,15 +401,10 @@ pub fn run() {
Ok(()) Ok(())
}) })
.register_asynchronous_uri_scheme_protocol("object", move |ctx, request, responder| { .register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| {
let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state(); tauri::async_runtime::spawn(async move {
offline!( fetch_object(request, responder).await;
state, });
fetch_object,
fetch_object_offline,
request,
responder
);
}) })
.register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| { .register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| {
let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state(); let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state();

View File

@ -212,7 +212,7 @@ pub fn setup() -> (AppStatus, Option<User>) {
let user_result = match fetch_user() { let user_result = match fetch_user() {
Ok(data) => data, Ok(data) => data,
Err(RemoteAccessError::FetchError(_)) => { Err(RemoteAccessError::FetchError(_)) => {
let user = get_cached_object::<_, User>("user").unwrap(); let user = get_cached_object::<User>("user").unwrap();
return (AppStatus::Offline, Some(user)); return (AppStatus::Offline, Some(user));
} }
Err(_) => return (AppStatus::SignedInNeedsReauth, None), Err(_) => return (AppStatus::SignedInNeedsReauth, None),

View File

@ -1,6 +1,8 @@
use std::{ use std::{
fmt::Display, fs::File,
time::{Duration, SystemTime}, io::{self, Write},
path::{Path, PathBuf},
time::SystemTime,
}; };
use crate::{ use crate::{
@ -8,8 +10,8 @@ use crate::{
error::remote_access_error::RemoteAccessError, error::remote_access_error::RemoteAccessError,
}; };
use bitcode::{Decode, DecodeOwned, Encode}; use bitcode::{Decode, DecodeOwned, Encode};
use cacache::Integrity;
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder}; use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use log::debug;
#[macro_export] #[macro_export]
macro_rules! offline { macro_rules! offline {
@ -23,47 +25,67 @@ macro_rules! offline {
} }
} }
pub fn cache_object<K: AsRef<str>, D: Encode>( fn get_sys_time_in_secs() -> u64 {
key: K, match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
data: &D, Ok(n) => n.as_secs(),
) -> Result<Integrity, RemoteAccessError> { Err(_) => panic!("SystemTime before UNIX EPOCH!"),
}
}
fn get_cache_path(base: &Path, key: &str) -> PathBuf {
let key_hash = hex::encode(md5::compute(key.as_bytes()).0);
base.join(key_hash)
}
fn write_sync(base: &Path, key: &str, data: Vec<u8>) -> io::Result<()> {
let cache_path = get_cache_path(base, key);
let mut file = File::create(cache_path)?;
file.write_all(&data)?;
Ok(())
}
fn read_sync(base: &Path, key: &str) -> io::Result<Vec<u8>> {
let cache_path = get_cache_path(base, key);
let file = std::fs::read(cache_path)?;
Ok(file)
}
pub fn cache_object<D: Encode>(key: &str, data: &D) -> Result<(), RemoteAccessError> {
let bytes = bitcode::encode(data); let bytes = bitcode::encode(data);
cacache::write_sync(&borrow_db_checked().cache_dir, key, bytes) write_sync(&borrow_db_checked().cache_dir, key, bytes).map_err(RemoteAccessError::Cache)
.map_err(RemoteAccessError::Cache)
} }
pub fn get_cached_object<K: AsRef<str> + Display, D: Encode + DecodeOwned>( pub fn get_cached_object<D: Encode + DecodeOwned>(key: &str) -> Result<D, RemoteAccessError> {
key: K, get_cached_object_db::<D>(key, &borrow_db_checked())
) -> Result<D, RemoteAccessError> {
get_cached_object_db::<K, D>(key, &borrow_db_checked())
} }
pub fn get_cached_object_db<K: AsRef<str> + Display, D: DecodeOwned>( pub fn get_cached_object_db<D: DecodeOwned>(
key: K, key: &str,
db: &Database, db: &Database,
) -> Result<D, RemoteAccessError> { ) -> Result<D, RemoteAccessError> {
let bytes = cacache::read_sync(&db.cache_dir, &key).map_err(RemoteAccessError::Cache)?; let start = SystemTime::now();
let data = bitcode::decode::<D>(&bytes).map_err(|_| { let bytes = read_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
RemoteAccessError::Cache(cacache::Error::EntryNotFound( let read = start.elapsed().unwrap();
db.cache_dir.clone(), let data =
key.to_string(), bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?;
)) let decode = start.elapsed().unwrap();
})?; debug!(
"cache object took: r:{}, d:{}, b:{}",
read.as_millis(),
read.abs_diff(decode).as_millis(),
bytes.len()
);
Ok(data) Ok(data)
} }
#[derive(Encode, Decode)] #[derive(Encode, Decode)]
pub struct ObjectCache { pub struct ObjectCache {
content_type: String, content_type: String,
body: Vec<u8>, body: Vec<u8>,
expiry: u128, expiry: u64,
} }
impl ObjectCache { impl ObjectCache {
pub fn has_expired(&self) -> bool { pub fn has_expired(&self) -> bool {
let duration = Duration::from_millis(self.expiry.try_into().unwrap()); let current = get_sys_time_in_secs();
SystemTime::UNIX_EPOCH self.expiry < current
.checked_add(duration)
.unwrap()
.elapsed()
.is_err()
} }
} }
@ -78,12 +100,7 @@ impl From<Response<Vec<u8>>> for ObjectCache {
.unwrap() .unwrap()
.to_owned(), .to_owned(),
body: value.body().clone(), body: value.body().clone(),
expiry: SystemTime::now() expiry: get_sys_time_in_secs() + 60 * 60 * 24,
.checked_add(Duration::from_days(1))
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
} }
} }
} }

View File

@ -55,7 +55,7 @@ pub fn fetch_drop_object(path: String) -> Result<Vec<u8>, RemoteAccessError> {
} }
Err(e) => { Err(e) => {
debug!("{e}"); debug!("{e}");
get_cached_object::<&str, Vec<u8>>(&path) get_cached_object::<Vec<u8>>(&path)
} }
} }
} }

View File

@ -2,17 +2,18 @@ use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use log::warn; use log::warn;
use tauri::UriSchemeResponder; use tauri::UriSchemeResponder;
use crate::{DB, database::db::DatabaseImpls};
use super::{ use super::{
auth::generate_authorization_header, auth::generate_authorization_header,
cache::{cache_object, get_cached_object, ObjectCache}, cache::{ObjectCache, cache_object, get_cached_object},
requests::make_request,
}; };
pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) { pub async fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) {
// Drop leading / // Drop leading /
let object_id = &request.uri().path()[1..]; let object_id = &request.uri().path()[1..];
let cache_result = get_cached_object::<&str, ObjectCache>(object_id); let cache_result = get_cached_object::<ObjectCache>(object_id);
if let Ok(cache_result) = &cache_result if let Ok(cache_result) = &cache_result
&& !cache_result.has_expired() && !cache_result.has_expired()
{ {
@ -21,12 +22,10 @@ pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeRespond
} }
let header = generate_authorization_header(); let header = generate_authorization_header();
let client: reqwest::blocking::Client = reqwest::blocking::Client::new(); let client = reqwest::Client::new();
let response = make_request(&client, &["/api/v1/client/object/", object_id], &[], |f| { let url = format!("{}api/v1/client/object/{object_id}", DB.fetch_base_url());
f.header("Authorization", header) let response = client.get(url).header("Authorization", header).send().await;
})
.unwrap()
.send();
if response.is_err() { if response.is_err() {
match cache_result { match cache_result {
Ok(cache_result) => responder.respond(cache_result.into()), Ok(cache_result) => responder.respond(cache_result.into()),
@ -42,20 +41,11 @@ pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeRespond
CONTENT_TYPE, CONTENT_TYPE,
response.headers().get("Content-Type").unwrap(), response.headers().get("Content-Type").unwrap(),
); );
let data = Vec::from(response.bytes().unwrap()); let data = Vec::from(response.bytes().await.unwrap());
let resp = resp_builder.body(data).unwrap(); let resp = resp_builder.body(data).unwrap();
if cache_result.is_err() || cache_result.unwrap().has_expired() { if cache_result.is_err() || cache_result.unwrap().has_expired() {
cache_object::<&str, ObjectCache>(object_id, &resp.clone().into()).unwrap(); cache_object::<ObjectCache>(object_id, &resp.clone().into()).unwrap();
} }
responder.respond(resp); responder.respond(resp);
} }
pub fn fetch_object_offline(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) {
let object_id = &request.uri().path()[1..];
let data = get_cached_object::<&str, ObjectCache>(object_id);
match data {
Ok(data) => responder.respond(data.into()),
Err(e) => warn!("{e}"),
}
}

View File

@ -11,7 +11,7 @@
}, },
"app": { "app": {
"security": { "security": {
"csp": null, "csp": "",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": {} "scope": {}