Skip to main content

max / goingson

Performance: stop the self-write refetch storm, unblock the reactor Performance axis remediation (ultra-fuzz Run #27): CHRONIC-E (re-fetch storm): the DB file-watcher fired on the app's own writes, and the db:external-change handler nuked every cache entity + refetched. Mark JS-initiated writes in the invoke wrapper and skip db:external-change within 2.5s of one; cloud-sync's selective apply marks too. Genuinely external writes (the Rust background email-sync scheduler) never mark, so they still refresh. - S1/S6/M4: move attachment read/hash/write + copies, the manual backup gzip, and startup blob GC onto spawn_blocking so large files don't freeze the UI. - S3: list_emails now uses a body-less, capped projection (list_metadata); list_all keeps bodies for backup only. - S4: batched bulk_set_task_project / bulk_set_task_priority commands replace the per-task 2N get+update round-trips (priority recomputes urgency in one tx). - M3: filter/sort changes invalidate the tasks cache before load (was a 30s no-op while the cache stayed fresh). - Cap total SMTP attachment bytes before buffering them into memory. Tests: bulk set project/priority (urgency recompute), email list_metadata via the existing gather round-trip.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 23:58 UTC
Signed with PGP, not checked
Commit: e2c1d0359c619fb349ba0a554446ac4d4310c7ad
Parent: 9a1dec4
14 files changed, +326 insertions, -73 deletions
@@ -256,15 +256,24 @@
256 256 .map_err(|e| format!("Failed to get app data dir: {}", e))?
257 257 .join("backups");
258 258
259 - std::fs::create_dir_all(&backup_dir)
260 - .map_err(|e| format!("Failed to create backup directory: {}", e))?;
261 -
262 259 let filename = backup_filename(now);
263 260 let file_path = backup_dir.join(&filename);
264 261
265 262 let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?;
266 263 let item_count = export.total_count();
267 - let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
264 +
265 + // Directory creation + gzip serialization are blocking and take seconds on a
266 + // large DB; run them on the blocking pool so the manual "Backup now" action
267 + // doesn't freeze the UI (the scheduled path already does this — Perf S6).
268 + let backup_dir_task = backup_dir.clone();
269 + let file_path_task = file_path.clone();
270 + let size_bytes = tokio::task::spawn_blocking(move || -> Result<u64, String> {
271 + std::fs::create_dir_all(&backup_dir_task)
272 + .map_err(|e| format!("Failed to create backup directory: {}", e))?;
273 + write_backup(&export, &file_path_task).map_err(|e| format!("Failed to write backup: {}", e))
274 + })
275 + .await
276 + .map_err(|e| format!("Backup task panicked: {}", e))??;
268 277
269 278 // Update last backup timestamp
270 279 state
@@ -91,11 +91,14 @@
91 91 }
92 92 };
93 93
94 + // read_dir + remove_file are blocking; run on the blocking pool so the
95 + // startup GC doesn't stall the reactor / delay first paint (Perf M4).
94 96 let blobs_dir = data_dir.join("blobs");
95 - match reclaim_orphans(&blobs_dir, &referenced) {
96 - Ok(0) => {}
97 - Ok(n) => info!("Blob GC reclaimed {n} orphaned blob(s)"),
98 - Err(e) => warn!("Blob GC: could not scan blob dir: {e}"),
97 + match tokio::task::spawn_blocking(move || reclaim_orphans(&blobs_dir, &referenced)).await {
98 + Ok(Ok(0)) => {}
99 + Ok(Ok(n)) => info!("Blob GC reclaimed {n} orphaned blob(s)"),
100 + Ok(Err(e)) => warn!("Blob GC: could not scan blob dir: {e}"),
101 + Err(e) => warn!("Blob GC task panicked: {e}"),
99 102 }
100 103 }
101 104
@@ -66,6 +66,8 @@
66 66 $crate::commands::quick_add_task,
67 67 $crate::commands::update_task,
68 68 $crate::commands::delete_task,
69 + $crate::commands::bulk_set_task_project,
70 + $crate::commands::bulk_set_task_priority,
69 71 $crate::commands::start_task,
70 72 $crate::commands::complete_task,
71 73 $crate::commands::list_snoozed_tasks,
@@ -99,6 +99,15 @@
99 99 /// Updates an existing task.
100 100 async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>;
101 101
102 + /// Sets the project for many tasks in one transaction. Returns rows affected.
103 + /// Avoids the per-task fetch+update round-trips the bulk UI did before
104 + /// (ultra-fuzz Run #27 Perf S4). Project does not affect urgency.
105 + async fn bulk_set_project(&self, user_id: UserId, ids: &[TaskId], project_id: Option<ProjectId>) -> Result<usize>;
106 +
107 + /// Sets the priority for many tasks in one transaction, recomputing each task's
108 + /// urgency (priority is an urgency input). Returns rows affected.
109 + async fn bulk_set_priority(&self, user_id: UserId, ids: &[TaskId], priority: crate::models::Priority) -> Result<usize>;
110 +
102 111 /// Soft-deletes a task.
103 112 async fn delete(&self, id: TaskId, user_id: UserId) -> Result<bool>;
104 113
@@ -316,9 +325,17 @@
316 325 /// snoozing, and follow-up tracking.
317 326 #[async_trait]
318 327 pub trait EmailRepository: Send + Sync {
319 - /// Lists all emails, optionally including archived.
328 + /// Lists all emails, optionally including archived. Selects full bodies and is
329 + /// unbounded -- intended for backup export, NOT for list views (use
330 + /// [`list_metadata`](Self::list_metadata) there to avoid materializing every
331 + /// body at once).
320 332 async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>;
321 333
334 + /// Lists emails for a flat list view: body/html_body are omitted (empty) and the
335 + /// result is capped, so a large mailbox doesn't load every body into memory
336 + /// (ultra-fuzz Run #27 Perf S3). Open an email via `get_by_id` for its body.
337 + async fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>;
338 +
322 339 /// Lists emails grouped by thread, with metadata pre-computed and pagination.
323 340 /// Returns (threads, total_count) sorted by most recent email (newest first).
324 341 /// Optional `folder` filter restricts to emails from a specific source_folder.
@@ -3,8 +3,11 @@
3 3 mod common;
4 4
5 5 use chrono::{Duration, Utc};
6 - use goingson_core::{NewTask, Priority, Recurrence, TaskFilterQuery, TaskRepository, TaskStatus, UpdateTask};
7 - use goingson_db_sqlite::SqliteTaskRepository;
6 + use goingson_core::{
7 + NewProject, NewTask, Priority, ProjectRepository, Recurrence, TaskFilterQuery, TaskRepository,
8 + TaskStatus, UpdateTask,
9 + };
10 + use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
8 11
9 12 #[tokio::test]
10 13 async fn test_create_and_get_task() {
@@ -472,3 +475,53 @@
472 475 assert!(page.is_empty(), "negative limit must not return rows unbounded");
473 476 assert_eq!(total, 3, "total count is independent of the clamped limit");
474 477 }
478 +
479 + #[tokio::test]
480 + async fn test_bulk_set_priority_recomputes_urgency() {
481 + let pool = common::setup_test_db().await;
482 + let user_id = common::create_test_user(&pool).await;
483 + let repo = SqliteTaskRepository::new(pool);
484 +
485 + let a = repo.create(user_id, NewTask::builder("A").priority(Priority::Low).build()).await.unwrap();
486 + let b = repo.create(user_id, NewTask::builder("B").priority(Priority::Low).build()).await.unwrap();
487 + let low_urgency = a.urgency;
488 +
489 + let n = repo.bulk_set_priority(user_id, &[a.id, b.id], Priority::High).await.unwrap();
490 + assert_eq!(n, 2);
491 +
492 + let a2 = repo.get_by_id(a.id, user_id).await.unwrap().unwrap();
493 + let b2 = repo.get_by_id(b.id, user_id).await.unwrap().unwrap();
494 + assert_eq!(a2.priority, Priority::High);
495 + assert_eq!(b2.priority, Priority::High);
496 + assert!(a2.urgency > low_urgency, "urgency recomputed upward for higher priority");
497 + }
498 +
499 + #[tokio::test]
500 + async fn test_bulk_set_project() {
501 + let pool = common::setup_test_db().await;
502 + let user_id = common::create_test_user(&pool).await;
503 + let tasks = SqliteTaskRepository::new(pool.clone());
504 + let projects = SqliteProjectRepository::new(pool);
505 +
506 + let project = projects
507 + .create(user_id, NewProject {
508 + name: "Bulk".into(),
509 + description: String::new(),
510 + project_type: Default::default(),
511 + status: Default::default(),
512 + })
513 + .await
514 + .unwrap();
515 +
516 + let a = tasks.create(user_id, NewTask::builder("A").build()).await.unwrap();
517 + let b = tasks.create(user_id, NewTask::builder("B").build()).await.unwrap();
518 +
519 + let n = tasks.bulk_set_project(user_id, &[a.id, b.id], Some(project.id)).await.unwrap();
520 + assert_eq!(n, 2);
521 + assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, Some(project.id));
522 +
523 + // Clearing the project (None) also works.
524 + let n2 = tasks.bulk_set_project(user_id, &[a.id], None).await.unwrap();
525 + assert_eq!(n2, 1);
526 + assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, None);
527 + }
@@ -40,13 +40,28 @@
40 40 throw new Error('Tauri not available');
41 41 }
42 42 try {
43 - return await tauriInvoke(command, args);
43 + const result = await tauriInvoke(command, args);
44 + // Mark JS-initiated writes so the DB file-watcher can ignore its own echo
45 + // (CHRONIC-E): a successful mutation already refreshed the UI, so the
46 + // db:external-change it triggers is a redundant invalidate-everything storm.
47 + // Reads never mark (so a background-sync write the JS didn't initiate still
48 + // refreshes via db:external-change). Conservative prefix match: only commands
49 + // we are confident write the DB mark.
50 + if (MUTATION_COMMAND.test(command)) {
51 + window.__goLastLocalWriteAt = Date.now();
52 + }
53 + return result;
44 54 } catch (err) {
45 55 console.error(`[api] invoke '${command}' failed:`, err, 'args:', JSON.stringify(args));
46 56 throw err;
47 57 }
48 58 }
49 59
60 + /// Commands that write the local DB. Matching this marks a local write for the
61 + /// file-watcher echo-suppression (CHRONIC-E). Read commands (list_/get_/search/
62 + /// validate) deliberately do not match.
63 + const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink)_/;
64 +
50 65 // ============ API Object ============
51 66
52 67 const api = {
@@ -70,6 +85,8 @@
70 85 quickAdd: (text) => invoke('quick_add_task', { input: { text } }), // Natural language: "Fix bug +work @tomorrow !high"
71 86 update: (id, input) => invoke('update_task', { id, input }),
72 87 delete: (id) => invoke('delete_task', { id }),
88 + bulkSetProject: (ids, projectId) => invoke('bulk_set_task_project', { ids, projectId }),
89 + bulkSetPriority: (ids, priority) => invoke('bulk_set_task_priority', { ids, priority }),
73 90 start: (id) => invoke('start_task', { id }), // Pending → Started (sets started_at)
74 91 complete: (id) => invoke('complete_task', { id }), // Spawns next instance for recurring tasks
75 92 listSnoozed: () => invoke('list_snoozed_tasks'),
@@ -6,6 +6,12 @@
6 6 (function() {
7 7 'use strict';
8 8
9 + // How long after a JS-initiated DB write to treat a db:external-change event as the
10 + // app's own echo and skip it (CHRONIC-E). Comfortably covers the watcher's debounce
11 + // (it rate-limits to ~1/sec plus a trailing emit) without masking a genuinely
12 + // external write that arrives later.
13 + const LOCAL_WRITE_SUPPRESS_MS = 2500;
14 +
9 15 // Maps a synced DB table (from the `sync:changes-applied` payload) to the view
10 16 // cache entities it affects. A table mapping to `[]` has no cached view to bust
11 17 // (the re-render alone refetches it). Any table NOT present here triggers a full
@@ -165,8 +171,19 @@
165 171 listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts());
166 172 listen('menu:about', () => GoingsOn.app.openAboutModal());
167 173
168 - // Database external change detection
174 + // Database external change detection.
175 + //
176 + // The watcher fires on ANY change to the DB file, including this app's own
177 + // writes. A JS-initiated mutation already refreshed the UI, so the echo here
178 + // would be a redundant invalidate-everything storm (CHRONIC-E). Skip it when a
179 + // local write happened in the last LOCAL_WRITE_SUPPRESS_MS. A genuinely external
180 + // write (e.g. the background email-sync scheduler, which runs in Rust and never
181 + // marks a JS write) is NOT recent-marked, so it still refreshes.
169 182 listen('db:external-change', () => {
183 + const sinceLocalWrite = Date.now() - (window.__goLastLocalWriteAt || 0);
184 + if (sinceLocalWrite < LOCAL_WRITE_SUPPRESS_MS) {
185 + return;
186 + }
170 187 GoingsOn.cache.invalidateAll();
171 188 refreshCurrentViewData();
172 189 });
@@ -195,6 +212,9 @@
195 212 GoingsOn.cache.invalidate(...entities);
196 213 }
197 214 }
215 + // Cloud sync already refreshed selectively here; mark it so the redundant
216 + // db:external-change echo from the same writes is suppressed (CHRONIC-E).
217 + window.__goLastLocalWriteAt = Date.now();
198 218 refreshCurrentViewData();
199 219 });
200 220
@@ -266,30 +266,18 @@
266 266 ));
267 267 },
268 268 commit: async (ids) => {
269 - const results = await Promise.allSettled(ids.map(async (id) => {
270 - const task = await GoingsOn.api.tasks.get(id);
271 - if (!task) return;
272 - return GoingsOn.api.tasks.update(id, {
273 - description: task.description,
274 - priority: field === 'priority' ? newValue : task.priority,
275 - status: task.status,
276 - projectId: field === 'projectId' ? newValue : task.projectId,
277 - due: task.due,
278 - tags: task.tags,
279 - recurrence: task.recurrence,
280 - estimatedMinutes: task.estimatedMinutes,
281 - contactId: task.contactId,
282 - milestoneId: task.milestoneId,
283 - });
284 - }));
285 - const failed = results.filter(r => r.status === 'rejected').length;
286 - if (failed === ids.length) {
287 - throw results.find(r => r.status === 'rejected').reason;
269 + // One batched, transactional command instead of the old 2N
270 + // get+update round-trips (Perf S4). The optimistic apply() above
271 + // already updated the visible rows in place.
272 + if (field === 'projectId') {
273 + await GoingsOn.api.tasks.bulkSetProject(ids, newValue);
274 + // Project doesn't affect urgency/sort, so the in-place update stands.
275 + } else if (field === 'priority') {
276 + await GoingsOn.api.tasks.bulkSetPriority(ids, newValue);
277 + // Priority feeds urgency, which is the sort key — one refetch
278 + // re-sorts the list (one round-trip, not 2N).
279 + GoingsOn.tasks.load();
288 280 }
289 - if (failed > 0) {
290 - GoingsOn.ui.showToast(`${ids.length - failed} updated, ${failed} failed`, 'warning');
291 - }
292 - GoingsOn.tasks.load();
293 281 },
294 282 errorMessage: `Failed to update ${labelNoun}`,
295 283 });