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
Commit: e2c1d0359c619fb349ba0a554446ac4d4310c7ad
Parent: 9a1dec4
14 files changed, +326 insertions, -73 deletions
@@ -99,6 +99,15 @@ pub trait TaskRepository: Send + Sync {
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 @@ pub trait EventRepository: Send + Sync {
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.
@@ -27,6 +27,21 @@ const EMAIL_SELECT_COLUMNS: &str = r#"e.id, e.project_id, p.name as project_name
27 27 e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date,
28 28 e.body_truncated, e.jmap_id"#;
29 29
30 + /// Same shape as [`EMAIL_SELECT_COLUMNS`] but with the two heavy body columns
31 + /// blanked, for flat list views that never render a body (Perf S3). `body_truncated`
32 + /// is forced to 1 so the reader knows to re-fetch the full body on open.
33 + const EMAIL_LIST_COLUMNS: &str = r#"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address,
34 + e.subject, '' AS body, NULL AS html_body, e.is_read, e.is_archived, e.received_at, e.message_id,
35 + e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder,
36 + e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id,
37 + e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date,
38 + 1 AS body_truncated, e.jmap_id"#;
39 +
40 + /// Upper bound on a flat metadata list so it can never materialize an unbounded
41 + /// number of rows (the list UI paginates via `list_threaded`; this flat path is a
42 + /// safety-capped fallback).
43 + const EMAIL_LIST_CAP: i64 = 1000;
44 +
30 45 #[derive(Debug, Clone, sqlx::FromRow)]
31 46 struct EmailRow {
32 47 pub id: String,
@@ -126,6 +141,17 @@ impl EmailRepository for SqliteEmailRepository {
126 141 }
127 142
128 143 #[tracing::instrument(skip_all)]
144 + async fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
145 + let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" };
146 + let query = format!(
147 + "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {} ORDER BY e.received_at DESC LIMIT {}",
148 + EMAIL_LIST_COLUMNS, archived_filter, EMAIL_LIST_CAP
149 + );
150 + let rows = sqlx::query_as::<_, EmailRow>(&query).bind(user_id.to_string()).bind(user_id.to_string()).fetch_all(&self.pool).await.map_err(CoreError::database)?;
151 + rows.into_iter().map(Email::try_from).collect()
152 + }
153 +
154 + #[tracing::instrument(skip_all)]
129 155 async fn list_threaded(&self, user_id: UserId, include_archived: bool, offset: Option<i64>, limit: Option<i64>, folder: Option<&str>, label: Option<&str>) -> Result<(Vec<EmailThread>, i64)> {
130 156 let uid = user_id.to_string();
131 157 let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" };
@@ -13,9 +13,9 @@ use chrono::{DateTime, NaiveDate, Utc};
13 13 use sqlx::SqlitePool;
14 14
15 15 use goingson_core::{
16 - AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask, ParseableEnum, Priority,
17 - PositiveMinutes, ProjectId, Recurrence, Result, SortDirection, SubtaskId, Subtask, Task, TaskFilterQuery,
18 - TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession,
16 + calculate_urgency, AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask,
17 + ParseableEnum, Priority, PositiveMinutes, ProjectId, Recurrence, Result, SortDirection, SubtaskId,
18 + Subtask, Task, TaskFilterQuery, TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession,
19 19 TimeTrackingSummary, UpdateTask, UserId,
20 20 };
21 21
@@ -599,6 +599,68 @@ impl TaskRepository for SqliteTaskRepository {
599 599 }
600 600
601 601 #[tracing::instrument(skip_all)]
602 + async fn bulk_set_project(&self, user_id: UserId, ids: &[TaskId], project_id: Option<ProjectId>) -> Result<usize> {
603 + if ids.is_empty() {
604 + return Ok(0);
605 + }
606 + let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
607 + let sql = format!(
608 + "UPDATE tasks SET project_id = ? WHERE user_id = ? AND id IN ({placeholders})"
609 + );
610 + let mut q = sqlx::query(&sql)
611 + .bind(project_id.map(|p| p.to_string()))
612 + .bind(user_id.to_string());
613 + for id in ids {
614 + q = q.bind(id.to_string());
615 + }
616 + let result = q.execute(&self.pool).await.map_err(CoreError::database)?;
617 + Ok(result.rows_affected() as usize)
618 + }
619 +
620 + #[tracing::instrument(skip_all)]
621 + async fn bulk_set_priority(&self, user_id: UserId, ids: &[TaskId], priority: Priority) -> Result<usize> {
622 + if ids.is_empty() {
623 + return Ok(0);
624 + }
625 + // Priority feeds urgency, so each task's urgency must be recomputed. Do the
626 + // whole batch in one transaction (one connection) instead of N command
627 + // round-trips (Perf S4).
628 + let mut tx = self.pool.begin().await.map_err(CoreError::database)?;
629 + let mut affected = 0usize;
630 + for id in ids {
631 + let row: Option<(String, Option<String>, String, String)> = sqlx::query_as(
632 + "SELECT status, due, created_at, tags FROM tasks WHERE id = ? AND user_id = ?",
633 + )
634 + .bind(id.to_string())
635 + .bind(user_id.to_string())
636 + .fetch_optional(&mut *tx)
637 + .await
638 + .map_err(CoreError::database)?;
639 + let Some((status_s, due_s, created_s, tags_s)) = row else {
640 + continue;
641 + };
642 + let status = TaskStatus::from_str_or_default(&status_s);
643 + let due = due_s.as_deref().map(parse_datetime).transpose()?;
644 + let created = parse_datetime(&created_s)?;
645 + let tags = parse_tags(&tags_s);
646 + let urgency = calculate_urgency(&priority, &status, due.as_ref(), &created, &tags);
647 + let result = sqlx::query(
648 + "UPDATE tasks SET priority = ?, urgency = ? WHERE id = ? AND user_id = ?",
649 + )
650 + .bind(priority.db_value())
651 + .bind(urgency)
652 + .bind(id.to_string())
653 + .bind(user_id.to_string())
654 + .execute(&mut *tx)
655 + .await
656 + .map_err(CoreError::database)?;
657 + affected += result.rows_affected() as usize;
658 + }
659 + tx.commit().await.map_err(CoreError::database)?;
660 + Ok(affected)
661 + }
662 +
663 + #[tracing::instrument(skip_all)]
602 664 async fn delete(&self, id: TaskId, user_id: UserId) -> Result<bool> {
603 665 let result = sqlx::query("UPDATE tasks SET status = 'Deleted' WHERE id = ? AND user_id = ?")
604 666 .bind(id.to_string())
@@ -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 @@ async fn list_filtered_clamps_negative_limit() {
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 @@ async function invoke(command, args = {}) {
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 @@ const api = {
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 @@ if (window.__TAURI__) {
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 @@ if (window.__TAURI__) {
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;
288 - }
289 - if (failed > 0) {
290 - GoingsOn.ui.showToast(`${ids.length - failed} updated, ${failed} failed`, 'warning');
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();
291 280 }
292 - GoingsOn.tasks.load();
293 281 },
294 282 errorMessage: `Failed to update ${labelNoun}`,
295 283 });
@@ -87,6 +87,9 @@
87 87 writeFiltersToUrl();
88 88 GoingsOn.tasks.pagination.reset();
89 89 populateMilestoneFilter();
90 + // load() no-ops while the tasks cache is fresh (30s); a filter/sort change
91 + // must refetch with the new criteria, so invalidate first (Perf M3).
92 + GoingsOn.cache.invalidate('tasks');
90 93 GoingsOn.tasks.load();
91 94 }
92 95
@@ -104,6 +107,7 @@
104 107 clearSelectionIfAny();
105 108 GoingsOn.queryState?.clear(['status', 'project', 'priority', 'milestone', 'snoozed', 'waiting']);
106 109 GoingsOn.tasks.pagination.reset();
110 + GoingsOn.cache.invalidate('tasks'); // refetch with cleared filters (Perf M3)
107 111 GoingsOn.tasks.load();
108 112 }
109 113
@@ -256,15 +256,24 @@ pub async fn create_backup_now(
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 @@ pub async fn reconcile(pool: &SqlitePool, data_dir: &Path) {
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
@@ -112,29 +112,34 @@ pub async fn add_attachment(
112 112 )));
113 113 }
114 114
115 - // Read file and compute SHA-256
116 - let file_data = std::fs::read(source_path)
117 - .map_api_err("Failed to read file", ApiError::internal)?;
118 -
119 - let hash = {
120 - let mut hasher = Sha256::new();
121 - hasher.update(&file_data);
122 - format!("{:x}", hasher.finalize())
123 - };
124 -
125 - // Ensure blobs directory exists
115 + // Read (up to MAX_FILE_SIZE), hash, and write the blob on the blocking pool so a
116 + // large attachment doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1).
117 + let source_owned = source_path.to_path_buf();
126 118 let blobs_dir = state.data_dir.join("blobs");
127 - std::fs::create_dir_all(&blobs_dir)
128 - .map_api_err("Failed to create blobs directory", ApiError::internal)?;
129 -
130 - // Copy to blob store (skip if hash already exists — dedup). Track whether we
131 - // were the writer so a failed insert can roll back exactly the blob we added.
132 - let blob_path = blobs_dir.join(&hash);
133 - let wrote_new_blob = !blob_path.exists();
134 - if wrote_new_blob {
135 - std::fs::write(&blob_path, &file_data)
136 - .map_api_err("Failed to write blob", ApiError::internal)?;
137 - }
119 + let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking(
120 + move || -> Result<(String, i64, bool, std::path::PathBuf), String> {
121 + let file_data = std::fs::read(&source_owned).map_err(|e| format!("Failed to read file: {e}"))?;
122 + let file_size = file_data.len() as i64;
123 + let hash = {
124 + let mut hasher = Sha256::new();
125 + hasher.update(&file_data);
126 + format!("{:x}", hasher.finalize())
127 + };
128 + std::fs::create_dir_all(&blobs_dir)
129 + .map_err(|e| format!("Failed to create blobs directory: {e}"))?;
130 + // Copy to blob store (skip if hash already exists — dedup). Track whether
131 + // we wrote it so a failed insert can roll back exactly the blob we added.
132 + let blob_path = blobs_dir.join(&hash);
133 + let wrote_new_blob = !blob_path.exists();
134 + if wrote_new_blob {
135 + std::fs::write(&blob_path, &file_data).map_err(|e| format!("Failed to write blob: {e}"))?;
136 + }
137 + Ok((hash, file_size, wrote_new_blob, blob_path))
138 + },
139 + )
140 + .await
141 + .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))?
142 + .map_err(ApiError::internal)?;
138 143
139 144 // Extract filename from path
140 145 let filename = source_path
@@ -144,7 +149,6 @@ pub async fn add_attachment(
144 149 .to_string();
145 150
146 151 let mime_type = mime_from_extension(&filename).to_string();
147 - let file_size = file_data.len() as i64;
148 152
149 153 let create_result = state.attachments
150 154 .create(DESKTOP_USER_ID, NewAttachment {
@@ -245,9 +249,9 @@ pub async fn open_attachment(
245 249 .collect();
246 250 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
247 251 let temp_path = temp_dir.join(&safe_name);
248 - // Copy blob to temp with original filename (overwrite if exists)
249 - std::fs::copy(&blob_path, &temp_path)
250 - .map_api_err("Failed to prepare file for opening", ApiError::internal)?;
252 + // Copy blob to temp with original filename (overwrite if exists). On the blocking
253 + // pool — a large attachment copy must not stall the reactor (Perf S1).
254 + copy_blocking(blob_path, temp_path.clone()).await?;
251 255
252 256 open::that(&temp_path)
253 257 .map_api_err("Failed to open file", ApiError::internal)?;
@@ -276,8 +280,7 @@ pub async fn save_attachment(
276 280 return Err(ApiError::bad_request("Blob not available locally — sync required"));
277 281 }
278 282
279 - std::fs::copy(&blob_path, &destination)
280 - .map_api_err("Failed to save file", ApiError::internal)?;
283 + copy_blocking(blob_path, PathBuf::from(&destination)).await?;
281 284
282 285 Ok(())
283 286 }
@@ -397,8 +400,7 @@ pub async fn open_email_blob(
397 400 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
398 401 let temp_path = temp_dir.join(&safe_name);
399 402
400 - std::fs::copy(&blob_path, &temp_path)
401 - .map_api_err("Failed to prepare file for opening", ApiError::internal)?;
403 + copy_blocking(blob_path, temp_path.clone()).await?;
402 404
403 405 open::that(&temp_path)
404 406 .map_api_err("Failed to open file", ApiError::internal)?;
@@ -422,8 +424,7 @@ pub async fn save_email_blob(
422 424 return Err(ApiError::bad_request("Attachment not available locally — sync required"));
423 425 }
424 426
425 - std::fs::copy(&blob_path, &destination)
426 - .map_api_err("Failed to save file", ApiError::internal)?;
427 + copy_blocking(blob_path, PathBuf::from(&destination)).await?;
427 428
428 429 Ok(())
429 430 }
@@ -443,6 +444,16 @@ pub(crate) fn is_valid_blob_hash(hash: &str) -> bool {
443 444 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
444 445 }
445 446
447 + /// Copy a file on the blocking pool so a large blob copy doesn't stall the async
448 + /// reactor (ultra-fuzz Run #27 Perf S1).
449 + async fn copy_blocking(from: PathBuf, to: PathBuf) -> Result<(), ApiError> {
450 + tokio::task::spawn_blocking(move || std::fs::copy(&from, &to))
451 + .await
452 + .map_err(|e| ApiError::internal(format!("Copy task panicked: {e}")))?
453 + .map_err(|e| ApiError::internal(format!("Failed to copy file: {e}")))?;
454 + Ok(())
455 + }
456 +
446 457 fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse {
447 458 let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists();
448 459 AttachmentResponse {
@@ -296,7 +296,9 @@ pub struct SendEmailResponse {
296 296 #[tauri::command]
297 297 #[instrument(skip_all)]
298 298 pub async fn list_emails(state: State<'_, Arc<AppState>>, include_archived: Option<bool>) -> Result<Vec<EmailResponse>, ApiError> {
299 - let emails = state.emails.list_all(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?;
299 + // Body-less + capped: this flat list never renders a body (the reader fetches it
300 + // via get_by_id), so it must not materialize every body at once (Perf S3).
301 + let emails = state.emails.list_metadata(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?;
300 302 Ok(emails.into_iter().map(EmailResponse::from).collect())
301 303 }
302 304
@@ -620,14 +622,27 @@ async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Resul
620 622 .await?
621 623 .or_not_found("emailAccount", input.account_id)?;
622 624
623 - // Read attachment files
625 + // Read attachment files. Cap the combined size before buffering them all into
626 + // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up
627 + // memory or get rejected by the server after a long upload (Perf minor).
628 + const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
624 629 use crate::email::smtp_client::AttachmentFile;
625 630 let mut attachment_files = Vec::new();
631 + let mut total_bytes: u64 = 0;
626 632 for path_str in &input.attachment_paths {
627 633 let path = std::path::Path::new(path_str);
628 634 if !path.is_file() {
629 635 return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
630 636 }
637 + let meta = tokio::fs::metadata(path).await
638 + .map_api_err("Failed to read attachment file", ApiError::internal)?;
639 + total_bytes = total_bytes.saturating_add(meta.len());
640 + if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
641 + return Err(ApiError::validation(
642 + "attachmentPaths",
643 + format!("Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)),
644 + ));
645 + }
631 646 let data = tokio::fs::read(path).await
632 647 .map_api_err("Failed to read attachment file", ApiError::internal)?;
633 648 let filename = path.file_name()
@@ -517,6 +517,32 @@ pub async fn delete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<
517 517 Ok(state.tasks.delete(id, DESKTOP_USER_ID).await?)
518 518 }
519 519
520 + /// Sets the project for many tasks at once. Returns the number updated.
521 + ///
522 + /// One transaction instead of the per-task fetch+update round-trips the bulk UI
523 + /// used to do (ultra-fuzz Run #27 Perf S4).
524 + #[tauri::command]
525 + #[instrument(skip_all)]
526 + pub async fn bulk_set_task_project(
527 + state: State<'_, Arc<AppState>>,
528 + ids: Vec<TaskId>,
529 + project_id: Option<ProjectId>,
530 + ) -> Result<usize, ApiError> {
531 + Ok(state.tasks.bulk_set_project(DESKTOP_USER_ID, &ids, project_id).await?)
532 + }
533 +
534 + /// Sets the priority for many tasks at once (urgency is recomputed per task).
535 + /// Returns the number updated.
536 + #[tauri::command]
537 + #[instrument(skip_all)]
538 + pub async fn bulk_set_task_priority(
539 + state: State<'_, Arc<AppState>>,
540 + ids: Vec<TaskId>,
541 + priority: Priority,
542 + ) -> Result<usize, ApiError> {
543 + Ok(state.tasks.bulk_set_priority(DESKTOP_USER_ID, &ids, priority).await?)
544 + }
545 +
520 546 /// Marks a task as started (in progress).
521 547 ///
522 548 /// Only works for tasks in Pending status.
@@ -66,6 +66,8 @@ macro_rules! all_commands {
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,