perf: optimize JSON response building for ciphers and sync by appending directly to string buffers

This commit is contained in:
qaz741wsd856 2026-01-02 16:21:14 +00:00
parent edd8fcbd76
commit 663f2b59d2
4 changed files with 179 additions and 89 deletions

View file

@ -233,7 +233,11 @@ pub async fn list_ciphers(
let db = db::get_db(&env)?; let db = db::get_db(&env)?;
let include_attachments = attachments::attachments_enabled(env.as_ref()); let include_attachments = attachments::attachments_enabled(env.as_ref());
let force_row_query = super::ciphers_default_row_query(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, &db,
include_attachments, include_attachments,
"WHERE c.user_id = ?1 AND c.deleted_at IS NULL", "WHERE c.user_id = ?1 AND c.deleted_at IS NULL",
@ -242,12 +246,7 @@ pub async fn list_ciphers(
force_row_query, force_row_query,
) )
.await?; .await?;
response.push_str(",\"object\":\"list\",\"continuationToken\":null}");
// Build response JSON via string concatenation (no parsing!)
let response = format!(
r#"{{"data":{},"object":"list","continuationToken":null}}"#,
ciphers_json
);
Ok(RawJson(response)) Ok(RawJson(response))
} }
@ -535,7 +534,15 @@ pub async fn restore_ciphers_bulk(
let include_attachments = attachments::attachments_enabled(env.as_ref()); let include_attachments = attachments::attachments_enabled(env.as_ref());
let force_row_query = super::ciphers_default_row_query(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, &db,
include_attachments, include_attachments,
"WHERE c.user_id = ?1 AND c.id IN (SELECT value FROM json_each(?2, '$.ids'))", "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, force_row_query,
) )
.await?; .await?;
response.push_str(",\"object\":\"list\",\"continuationToken\":null}");
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
);
Ok(RawJson(response)) Ok(RawJson(response))
} }
@ -738,11 +738,6 @@ struct CipherJsonArrayRow {
ciphers_json: String, ciphers_json: String,
} }
#[derive(Deserialize)]
struct CipherJsonRow {
cipher_json: String,
}
/// Build the SQL expression for a single cipher as JSON. /// Build the SQL expression for a single cipher as JSON.
fn cipher_json_expr(attachments_enabled: bool) -> String { fn cipher_json_expr(attachments_enabled: bool) -> String {
let attachments_expr = if attachments_enabled { 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") 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. /// 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, db: &worker::D1Database,
attachments_enabled: bool, attachments_enabled: bool,
where_clause: &str, where_clause: &str,
params: &[JsValue], params: &[JsValue],
order_clause: &str, order_clause: &str,
force_row_query: bool, force_row_query: bool,
) -> Result<String, AppError> { ) -> Result<(), AppError> {
async fn fetch_from_rows(
db: &worker::D1Database,
attachments_enabled: bool,
where_clause: &str,
params: &[JsValue],
order_clause: &str,
) -> Result<String, AppError> {
let sql = cipher_json_rows_sql(attachments_enabled, where_clause, order_clause);
let rows: Vec<CipherJsonRow> = 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)
}
if force_row_query { 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); 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; db.prepare(&sql).bind(params)?.first(None).await;
match row { match row {
Ok(row) => Ok(row Ok(row) => {
.map(|r| r.ciphers_json) if let Some(r) = row {
.unwrap_or_else(|| "[]".to_string())), out.reserve(r.ciphers_json.len());
out.push_str(&r.ciphers_json);
} else {
out.push_str("[]");
}
Ok(())
}
Err(err) if is_sqlite_toobig(&err) => { 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)), 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<JsValue> without Serde deserialization.
// Each JsValue is a JS array: [cipher_json_string]
let raw_rows: Vec<JsValue> = 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::<Array>()
.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(())
}

View file

@ -85,6 +85,30 @@ pub(crate) fn ciphers_default_row_query(env: &worker::Env) -> bool {
.unwrap_or(false) .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<usize> {
match env.var("SYNC_RESPONSE_PREALLOC_BYTES") {
Ok(v) => {
let raw = v.to_string();
match raw.parse::<usize>() {
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. /// Whether the user has 2FA enabled.
pub(crate) async fn two_factor_enabled( pub(crate) async fn two_factor_enabled(
db: &worker::D1Database, db: &worker::D1Database,

View file

@ -6,7 +6,10 @@ use crate::{
auth::Claims, auth::Claims,
db, db,
error::AppError, 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::{ models::{
folder::{Folder, FolderResponse}, folder::{Folder, FolderResponse},
sync::Profile, sync::Profile,
@ -80,15 +83,6 @@ pub async fn get_sync_data(
// Fetch ciphers as raw JSON array string (no parsing in Rust!) // Fetch ciphers as raw JSON array string (no parsing in Rust!)
let include_attachments = attachments::attachments_enabled(env.as_ref()); let include_attachments = attachments::attachments_enabled(env.as_ref());
let force_row_query = ciphers_default_row_query(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) // Serialize profile and folders (small data, acceptable CPU cost)
let mut profile = Profile::from_user(user, two_factor_enabled)?; let mut profile = Profile::from_user(user, two_factor_enabled)?;
@ -104,26 +98,59 @@ pub async fn get_sync_data(
})) }))
.map_err(|_| AppError::Internal)?; .map_err(|_| AppError::Internal)?;
let response = if query.exclude_domains { const DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES: usize = 1024 * 1024;
format!(
r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"sends":[],"userDecryption":{},"object":"sync"}}"#, let capacity = sync_response_prealloc_bytes(env.as_ref())
profile_json, folders_json, ciphers_json, user_decryption_json .filter(|v| *v > 0)
) .unwrap_or(DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES);
} else {
// `/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: // Match vaultwarden sync semantics:
// - mark excluded in /api/settings/domains // - mark excluded in /api/settings/domains
// - filter excluded out of sync payload // - filter excluded out of sync payload
let global_equivalent_domains = let global_equivalent_domains =
domains::global_equivalent_domains_json(&db, &excluded_globals, false).await; domains::global_equivalent_domains_json(&db, &excluded_globals, false).await;
let domains_json = format!( response.push_str(",\"domains\":{\"equivalentDomains\":");
r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#, response.push_str(&equivalent_domains);
equivalent_domains, global_equivalent_domains response.push_str(",\"globalEquivalentDomains\":");
); response.push_str(&global_equivalent_domains);
format!( response.push_str(",\"object\":\"domains\"}");
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(",\"sends\":[],\"userDecryption\":");
}; response.push_str(&user_decryption_json);
response.push_str(",\"object\":\"sync\"}");
Ok(RawJson(response)) Ok(RawJson(response))
} }

View file

@ -58,6 +58,10 @@ run_worker_first = ["/api/*", "/identity/*"]
# Defaults to false (use `json_group_array` and fallback automatically on `SQLITE_TOOBIG`). # Defaults to false (use `json_group_array` and fallback automatically on `SQLITE_TOOBIG`).
# CIPHERS_DEFAULT_ROW_QUERY = "true" # 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. # 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. # Defaults to 30 days if not set. Set to 0 to disable auto-purge.
# TRASH_AUTO_DELETE_DAYS = "30" # TRASH_AUTO_DELETE_DAYS = "30"