mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: Upstash Redis shared caching + cache key contamination fixes (#232)
* fix(sentry): add noise filters for 5 non-actionable error patterns Filter dynamic import alt phrasing, script parse errors, maplibre style/WebGL crashes, and CustomEvent promise rejections. Also fix beforeSend to catch short Firefox null messages like "E is null". * fix: cache write race, settings stale key status, yahoo gate concurrency P1: Replace async background thread cache write with synchronous fs::write to prevent out-of-order writes and dirty flag cleared before persistence. P2: Add WorldMonitorTab.refresh() called after loadDesktopSecrets() so the API key badge reflects actual keychain state. P3: Replace timestamp-based Yahoo gate with promise queue to ensure sequential execution under concurrent callers. * feat: add Upstash Redis shared caching to all RPC handlers + fix cache key contamination - Add Redis L2 cache (getCachedJson/setCachedJson) to 28 RPC handlers across all service domains (market, conflict, cyber, economic, etc.) - Fix 10 P1 cache key contamination bugs where under-specified keys caused cross-request data pollution (e.g. filtered requests returning unfiltered cached data) - Restructure list-internet-outages to cache-then-filter pattern so country/timeRange filters always apply after cache read - Add write_lock mutex to PersistentCache in main.rs to prevent desktop cache write-race conditions - Document FMP (Financial Modeling Prep) as Yahoo Finance fallback TODO in market/v1/_shared.ts * fix: cache-key contamination and PizzINT/GDELT partial-failure regression - tech-events: fetch with limit=0 and cache full result, apply limit slice after cache read to prevent low-limit requests poisoning cache - pizzint: restore try-catch around PizzINT fetch so GDELT tension pairs are still returned when PizzINT API is down * fix: remove extra closing brace in pizzint try-catch * fix: recompute conferenceCount/mappableCount after limit slice * fix: bypass WM API key gate for registration endpoint /api/register-interest must reach cloud without a WorldMonitor API key, otherwise desktop users can never register (circular dependency).
This commit is contained in:
@@ -61,6 +61,15 @@ struct SecretsCache {
|
||||
secrets: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// In-memory mirror of persistent-cache.json. The file can grow to 10+ MB,
|
||||
/// so reading/parsing/writing it on every IPC call blocks the main thread.
|
||||
/// Instead, load once into RAM and serialize writes to preserve ordering.
|
||||
struct PersistentCache {
|
||||
data: Mutex<Map<String, Value>>,
|
||||
dirty: Mutex<bool>,
|
||||
write_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl SecretsCache {
|
||||
fn load_from_keychain() -> Self {
|
||||
// Try consolidated vault first — single keychain prompt
|
||||
@@ -116,6 +125,53 @@ impl SecretsCache {
|
||||
}
|
||||
}
|
||||
|
||||
impl PersistentCache {
|
||||
fn load(path: &Path) -> Self {
|
||||
let data = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Map::new()
|
||||
};
|
||||
PersistentCache {
|
||||
data: Mutex::new(data),
|
||||
dirty: Mutex::new(false),
|
||||
write_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<Value> {
|
||||
let data = self.data.lock().unwrap_or_else(|e| e.into_inner());
|
||||
data.get(key).cloned()
|
||||
}
|
||||
|
||||
/// Flush to disk only if dirty. Returns Ok(true) if written.
|
||||
fn flush(&self, path: &Path) -> Result<bool, String> {
|
||||
let _write_guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let is_dirty = {
|
||||
let dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dirty
|
||||
};
|
||||
if !is_dirty {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let data = self.data.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let serialized = serde_json::to_string(&Value::Object(data.clone()))
|
||||
.map_err(|e| format!("Failed to serialize cache: {e}"))?;
|
||||
drop(data);
|
||||
std::fs::write(path, serialized)
|
||||
.map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?;
|
||||
let mut dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dirty = false;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesktopRuntimeInfo {
|
||||
os: String,
|
||||
@@ -254,46 +310,37 @@ fn cache_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_cache_entry(app: AppHandle, key: String) -> Result<Option<Value>, String> {
|
||||
let path = cache_file_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read cache store {}: {e}", path.display()))?;
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&contents).unwrap_or_else(|_| Value::Object(Map::new()));
|
||||
let Some(root) = parsed.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(root.get(&key).cloned())
|
||||
fn read_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> Result<Option<Value>, String> {
|
||||
Ok(cache.get(&key))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_cache_entry(app: AppHandle, key: String, value: String) -> Result<(), String> {
|
||||
fn write_cache_entry(app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> {
|
||||
let parsed_value: Value = serde_json::from_str(&value)
|
||||
.map_err(|e| format!("Invalid cache payload JSON: {e}"))?;
|
||||
let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
{
|
||||
let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner());
|
||||
data.insert(key, parsed_value);
|
||||
}
|
||||
{
|
||||
let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dirty = true;
|
||||
}
|
||||
|
||||
// Flush synchronously under write lock so concurrent writes cannot reorder.
|
||||
let path = cache_file_path(&app)?;
|
||||
|
||||
let mut root: Map<String, Value> = if path.exists() {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read cache store {}: {e}", path.display()))?;
|
||||
serde_json::from_str::<Value>(&contents)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Map::new()
|
||||
};
|
||||
|
||||
let parsed_value: Value =
|
||||
serde_json::from_str(&value).map_err(|e| format!("Invalid cache payload JSON: {e}"))?;
|
||||
root.insert(key, parsed_value);
|
||||
|
||||
let serialized = serde_json::to_string_pretty(&Value::Object(root))
|
||||
.map_err(|e| format!("Failed to serialize cache store: {e}"))?;
|
||||
std::fs::write(&path, serialized)
|
||||
.map_err(|e| format!("Failed to write cache store {}: {e}", path.display()))
|
||||
let data = cache.data.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let serialized = serde_json::to_string(&Value::Object(data.clone()))
|
||||
.map_err(|e| format!("Failed to serialize cache: {e}"))?;
|
||||
drop(data);
|
||||
std::fs::write(&path, &serialized)
|
||||
.map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?;
|
||||
{
|
||||
let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dirty = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn logs_dir_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -455,14 +502,14 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _settings_window =
|
||||
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
|
||||
.title("World Monitor Settings")
|
||||
.inner_size(980.0, 760.0)
|
||||
.min_inner_size(820.0, 620.0)
|
||||
.resizable(true)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create settings window: {e}"))?;
|
||||
let _settings_window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
|
||||
.title("World Monitor Settings")
|
||||
.inner_size(980.0, 760.0)
|
||||
.min_inner_size(820.0, 620.0)
|
||||
.resizable(true)
|
||||
.background_color(tauri::webview::Color(26, 28, 30, 255))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create settings window: {e}"))?;
|
||||
|
||||
// On Windows/Linux, menus are per-window. Remove the inherited app menu
|
||||
// from the settings window (macOS uses a shared app-wide menu bar instead).
|
||||
@@ -848,6 +895,10 @@ fn main() {
|
||||
fetch_polymarket
|
||||
])
|
||||
.setup(|app| {
|
||||
// Load persistent cache into memory (avoids 14MB file I/O on every IPC call)
|
||||
let cache_path = cache_file_path(&app.handle()).unwrap_or_default();
|
||||
app.manage(PersistentCache::load(&cache_path));
|
||||
|
||||
if let Err(err) = start_local_api(&app.handle()) {
|
||||
append_desktop_log(
|
||||
&app.handle(),
|
||||
@@ -898,6 +949,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
||||
// Flush in-memory cache to disk before quitting
|
||||
if let Ok(path) = cache_file_path(app) {
|
||||
if let Some(cache) = app.try_state::<PersistentCache>() {
|
||||
let _ = cache.flush(&path);
|
||||
}
|
||||
}
|
||||
stop_local_api(app);
|
||||
}
|
||||
_ => {}
|
||||
|
||||
Reference in New Issue
Block a user