perf: optimize JSON response building for ciphers and sync by appending directly to string buffers
This commit is contained in:
parent
edd8fcbd76
commit
663f2b59d2
4 changed files with 179 additions and 89 deletions
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue