From 663f2b59d2b7793b53a2fdfe56341508fddbe565 Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Fri, 2 Jan 2026 16:21:14 +0000 Subject: [PATCH] perf: optimize JSON response building for ciphers and sync by appending directly to string buffers --- src/handlers/ciphers.rs | 163 ++++++++++++++++++++++++---------------- src/handlers/mod.rs | 24 ++++++ src/handlers/sync.rs | 77 +++++++++++++------ wrangler.toml | 4 + 4 files changed, 179 insertions(+), 89 deletions(-) diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 0cb71af..5fc6934 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -233,7 +233,11 @@ pub async fn list_ciphers( let db = db::get_db(&env)?; let include_attachments = attachments::attachments_enabled(env.as_ref()); let force_row_query = super::ciphers_default_row_query(env.as_ref()); - let ciphers_json = fetch_cipher_json_array_raw( + // Response schema: {"data":[...],"object":"list","continuationToken":null} + let mut response = String::new(); + response.push_str("{\"data\":"); + append_cipher_json_array_raw( + &mut response, &db, include_attachments, "WHERE c.user_id = ?1 AND c.deleted_at IS NULL", @@ -242,12 +246,7 @@ pub async fn list_ciphers( force_row_query, ) .await?; - - // Build response JSON via string concatenation (no parsing!) - let response = format!( - r#"{{"data":{},"object":"list","continuationToken":null}}"#, - ciphers_json - ); + response.push_str(",\"object\":\"list\",\"continuationToken\":null}"); Ok(RawJson(response)) } @@ -535,7 +534,15 @@ pub async fn restore_ciphers_bulk( let include_attachments = attachments::attachments_enabled(env.as_ref()); let force_row_query = super::ciphers_default_row_query(env.as_ref()); - let ciphers_json = fetch_cipher_json_array_raw( + + db::touch_user_updated_at(&db, &claims.sub).await?; + + // Build response JSON via string concatenation (no parsing!) + // Response schema: {"data":[...],"object":"list","continuationToken":null} + let mut response = String::new(); + response.push_str("{\"data\":"); + append_cipher_json_array_raw( + &mut response, &db, include_attachments, "WHERE c.user_id = ?1 AND c.id IN (SELECT value FROM json_each(?2, '$.ids'))", @@ -544,14 +551,7 @@ pub async fn restore_ciphers_bulk( force_row_query, ) .await?; - - db::touch_user_updated_at(&db, &claims.sub).await?; - - // Build response JSON via string concatenation (no parsing!) - let response = format!( - r#"{{"data":{},"object":"list","continuationToken":null}}"#, - ciphers_json - ); + response.push_str(",\"object\":\"list\",\"continuationToken\":null}"); Ok(RawJson(response)) } @@ -738,11 +738,6 @@ struct CipherJsonArrayRow { ciphers_json: String, } -#[derive(Deserialize)] -struct CipherJsonRow { - cipher_json: String, -} - /// Build the SQL expression for a single cipher as JSON. fn cipher_json_expr(attachments_enabled: bool) -> String { let attachments_expr = if attachments_enabled { @@ -851,53 +846,27 @@ fn is_sqlite_toobig(err: &worker::Error) -> bool { msg.contains("sqlite_toobig") || msg.contains("string or blob too big") } -/// Execute a cipher JSON projection query and return the raw JSON array string. +/// Append ciphers JSON array to an existing buffer. /// This avoids JSON parsing in Rust, significantly reducing CPU time. -pub(crate) async fn fetch_cipher_json_array_raw( +pub(crate) async fn append_cipher_json_array_raw( + out: &mut String, db: &worker::D1Database, attachments_enabled: bool, where_clause: &str, params: &[JsValue], order_clause: &str, force_row_query: bool, -) -> Result { - async fn fetch_from_rows( - db: &worker::D1Database, - attachments_enabled: bool, - where_clause: &str, - params: &[JsValue], - order_clause: &str, - ) -> Result { - let sql = cipher_json_rows_sql(attachments_enabled, where_clause, order_clause); - let rows: Vec = db - .prepare(&sql) - .bind(params)? - .all() - .await - .map_err(db::map_d1_json_error)? - .results() - .map_err(|_| AppError::Database)?; - - if rows.is_empty() { - return Ok("[]".to_string()); - } - - let total_len: usize = rows.iter().map(|r| r.cipher_json.len()).sum(); - let separators_len = rows.len().saturating_sub(1); - let mut out = String::with_capacity(total_len + separators_len + 2); - out.push('['); - for (idx, row) in rows.into_iter().enumerate() { - if idx > 0 { - out.push(','); - } - out.push_str(&row.cipher_json); - } - out.push(']'); - Ok(out) - } - +) -> Result<(), AppError> { if force_row_query { - return fetch_from_rows(db, attachments_enabled, where_clause, params, order_clause).await; + return append_from_rows( + out, + db, + attachments_enabled, + where_clause, + params, + order_clause, + ) + .await; } let sql = cipher_json_array_sql(attachments_enabled, where_clause, order_clause); @@ -906,12 +875,78 @@ pub(crate) async fn fetch_cipher_json_array_raw( db.prepare(&sql).bind(params)?.first(None).await; match row { - Ok(row) => Ok(row - .map(|r| r.ciphers_json) - .unwrap_or_else(|| "[]".to_string())), + Ok(row) => { + if let Some(r) = row { + out.reserve(r.ciphers_json.len()); + out.push_str(&r.ciphers_json); + } else { + out.push_str("[]"); + } + Ok(()) + } Err(err) if is_sqlite_toobig(&err) => { - fetch_from_rows(db, attachments_enabled, where_clause, params, order_clause).await + append_from_rows( + out, + db, + attachments_enabled, + where_clause, + params, + order_clause, + ) + .await } Err(err) => Err(db::map_d1_json_error(err)), } } + +/// Append ciphers JSON array to an existing buffer row by row. +/// This avoids JSON array exceeding the maximum size that can be returned in a single string. +/// +/// Uses `raw_js_value()` to bypass Serde deserialization entirely, which should reduce +/// CPU time for large payloads. Each row from `raw_js_value()` is a JS array `[cipher_json]` +/// where the first element is the JSON string we need. +pub(crate) async fn append_from_rows( + out: &mut String, + db: &worker::D1Database, + attachments_enabled: bool, + where_clause: &str, + params: &[JsValue], + order_clause: &str, +) -> Result<(), AppError> { + use js_sys::Array; + use wasm_bindgen::JsCast; + + let sql = cipher_json_rows_sql(attachments_enabled, where_clause, order_clause); + + // Use raw_js_value() to get Vec without Serde deserialization. + // Each JsValue is a JS array: [cipher_json_string] + let raw_rows: Vec = db + .prepare(&sql) + .bind(params)? + .raw_js_value() + .await + .map_err(db::map_d1_json_error)?; + + if raw_rows.is_empty() { + out.push_str("[]"); + return Ok(()); + } + + out.push('['); + for (idx, row_js) in raw_rows.iter().enumerate() { + if idx > 0 { + out.push(','); + } + // Each row is a JS array [column0, column1, ...]. We only select one column (cipher_json). + let row_array = row_js + .dyn_ref::() + .ok_or_else(|| AppError::Internal)?; + let cipher_json_js = row_array.get(0); + let cipher_json = cipher_json_js + .as_string() + .ok_or_else(|| AppError::Internal)?; + out.push_str(&cipher_json); + } + out.push(']'); + Ok(()) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index cb8ab12..c05dcf2 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -85,6 +85,30 @@ pub(crate) fn ciphers_default_row_query(env: &worker::Env) -> bool { .unwrap_or(false) } +/// Optional capacity hint for building the `/api/sync` JSON response. +/// +/// When set, this value is used as the initial `String` capacity (in bytes). +/// When unset (or invalid), the Worker uses a default capacity and grows as needed. +pub(crate) fn sync_response_prealloc_bytes(env: &worker::Env) -> Option { + match env.var("SYNC_RESPONSE_PREALLOC_BYTES") { + Ok(v) => { + let raw = v.to_string(); + match raw.parse::() { + Ok(value) => Some(value), + Err(err) => { + log::warn!( + "Invalid SYNC_RESPONSE_PREALLOC_BYTES='{}' ({}); ignoring", + raw, + err + ); + None + } + } + } + Err(_) => None, + } +} + /// Whether the user has 2FA enabled. pub(crate) async fn two_factor_enabled( db: &worker::D1Database, diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs index 313976d..9d32e52 100644 --- a/src/handlers/sync.rs +++ b/src/handlers/sync.rs @@ -6,7 +6,10 @@ use crate::{ auth::Claims, db, error::AppError, - handlers::{attachments, ciphers, ciphers_default_row_query, domains, two_factor_enabled}, + handlers::{ + attachments, ciphers, ciphers_default_row_query, domains, sync_response_prealloc_bytes, + two_factor_enabled, + }, models::{ folder::{Folder, FolderResponse}, sync::Profile, @@ -80,15 +83,6 @@ pub async fn get_sync_data( // Fetch ciphers as raw JSON array string (no parsing in Rust!) let include_attachments = attachments::attachments_enabled(env.as_ref()); let force_row_query = ciphers_default_row_query(env.as_ref()); - let ciphers_json = ciphers::fetch_cipher_json_array_raw( - &db, - include_attachments, - "WHERE c.user_id = ?1", - &[user_id.clone().into()], - "", - force_row_query, - ) - .await?; // Serialize profile and folders (small data, acceptable CPU cost) let mut profile = Profile::from_user(user, two_factor_enabled)?; @@ -104,26 +98,59 @@ pub async fn get_sync_data( })) .map_err(|_| AppError::Internal)?; - let response = if query.exclude_domains { - format!( - r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"sends":[],"userDecryption":{},"object":"sync"}}"#, - profile_json, folders_json, ciphers_json, user_decryption_json - ) - } else { + const DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES: usize = 1024 * 1024; + + let capacity = sync_response_prealloc_bytes(env.as_ref()) + .filter(|v| *v > 0) + .unwrap_or(DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES); + + // `/api/sync` response schema (Bitwarden-compatible): + // { + // "profile": {...}, + // "folders": [...], + // "collections": [], + // "policies": [], + // "ciphers": [...], + // "domains": {...}, // omitted when excludeDomains=true + // "sends": [], + // "userDecryption": {...}, + // "object": "sync" + // } + // + // We build this as a JSON string to avoid parsing/re-serializing the (potentially huge) ciphers array. + let mut response = String::with_capacity(capacity); + response.push_str("{\"profile\":"); + response.push_str(&profile_json); + response.push_str(",\"folders\":"); + response.push_str(&folders_json); + response.push_str(",\"collections\":[],\"policies\":[],\"ciphers\":"); + ciphers::append_cipher_json_array_raw( + &mut response, + &db, + include_attachments, + "WHERE c.user_id = ?1", + &[user_id.clone().into()], + "", + force_row_query, + ) + .await?; + + if !query.exclude_domains { // Match vaultwarden sync semantics: // - mark excluded in /api/settings/domains // - filter excluded out of sync payload let global_equivalent_domains = domains::global_equivalent_domains_json(&db, &excluded_globals, false).await; - let domains_json = format!( - r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#, - equivalent_domains, global_equivalent_domains - ); - format!( - r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{},"sends":[],"userDecryption":{},"object":"sync"}}"#, - profile_json, folders_json, ciphers_json, domains_json, user_decryption_json - ) - }; + response.push_str(",\"domains\":{\"equivalentDomains\":"); + response.push_str(&equivalent_domains); + response.push_str(",\"globalEquivalentDomains\":"); + response.push_str(&global_equivalent_domains); + response.push_str(",\"object\":\"domains\"}"); + } + + response.push_str(",\"sends\":[],\"userDecryption\":"); + response.push_str(&user_decryption_json); + response.push_str(",\"object\":\"sync\"}"); Ok(RawJson(response)) } diff --git a/wrangler.toml b/wrangler.toml index 7193cf8..d1cfeb3 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -58,6 +58,10 @@ run_worker_first = ["/api/*", "/identity/*"] # Defaults to false (use `json_group_array` and fallback automatically on `SQLITE_TOOBIG`). # CIPHERS_DEFAULT_ROW_QUERY = "true" +# Optional: preallocate the `/api/sync` response String capacity (bytes). +# If unset/invalid, the Worker uses a default capacity and grows as needed. +# SYNC_RESPONSE_PREALLOC_BYTES = "1048576" + # Number of days to keep soft-deleted items before auto-purging. # Defaults to 30 days if not set. Set to 0 to disable auto-purge. # TRASH_AUTO_DELETE_DAYS = "30"