fix(server): config & update handle (#15173)

#### PR Dependency Tree


* **PR #15173** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added native document update validation to check incoming Yjs updates
for decodability before applying them.
* Introduced support for validation timeouts and cancellation during
update checks.
* Blob maintenance jobs now detect when object storage is unavailable
and skip related work gracefully.

* **Bug Fixes**
* Invalid (and oversized) updates are now filtered out earlier during
document ingestion.
* Background blob maintenance continues processing other work even if
one workspace fails.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-29 22:59:17 +08:00
committed by GitHub
parent 1b9e21f2de
commit a1363b3873
13 changed files with 517 additions and 60 deletions
@@ -6,6 +6,8 @@ use std::{
use napi::Result;
use serde::Deserialize;
use serde_json::Map;
use sqlx::{PgPool, Row};
use super::{
error::napi_error,
@@ -20,26 +22,56 @@ pub(super) struct RuntimeConfig {
impl RuntimeConfig {
pub(super) fn from_config_files() -> Result<Self> {
let app_config = app_config_from_config_files()?;
let database_url = database_url_from_env()
.or(database_url_from_config_files()?)
.or(app_config.database_url())
.unwrap_or_else(|| "postgresql://localhost:5432/affine".to_string());
let storage = ObjectStorageConfig::from_config_files()?;
let storage = ObjectStorageConfig::from_provider_config(app_config.blob_storage_provider_config())?;
Ok(Self { database_url, storage })
}
pub(super) async fn with_db_overrides(&self, pool: &PgPool) -> Result<Self> {
let mut app_config = app_config_from_config_files()?;
app_config.apply_file_config(load_app_config_overrides_from_db(pool).await?);
Ok(Self {
// The DB override is loaded after this connection already exists, so it
// must not rewrite the active datasource URL.
database_url: self.database_url.clone(),
storage: ObjectStorageConfig::from_provider_config(app_config.blob_storage_provider_config())?,
})
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
struct AppConfigFile {
db: Option<DbConfigFile>,
#[serde(default)]
storages: Option<HashMap<String, StorageProviderConfig>>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DbConfigFile {
datasource_url: Option<String>,
}
impl AppConfigFile {
fn database_url(&self) -> Option<String> {
self
.db
.as_ref()
.and_then(|db| db.datasource_url.clone())
.and_then(non_empty_string)
}
fn blob_storage_provider_config(&self) -> Option<StorageProviderConfig> {
self
.storages
.as_ref()
.and_then(|storages| storages.get("blob.storage").cloned())
}
}
fn database_url_from_env() -> Option<String> {
env::var("DATABASE_URL").ok().and_then(non_empty_string)
}
@@ -48,8 +80,8 @@ fn non_empty_string(value: String) -> Option<String> {
if value.trim().is_empty() { None } else { Some(value) }
}
fn database_url_from_config_files() -> Result<Option<String>> {
let mut database_url = None;
fn app_config_from_config_files() -> Result<AppConfigFile> {
let mut merged = AppConfigFile::default();
for path in config_json_paths() {
if !path.exists() {
continue;
@@ -58,30 +90,59 @@ fn database_url_from_config_files() -> Result<Option<String>> {
.map_err(|err| napi_error(format!("failed to read config file {}: {err}", path.display())))?;
let config: AppConfigFile = serde_json::from_str(&raw)
.map_err(|err| napi_error(format!("failed to parse config file {}: {err}", path.display())))?;
if let Some(next) = config.db.and_then(|db| db.datasource_url).and_then(non_empty_string) {
database_url = Some(next);
}
merged.apply_file_config(config);
}
Ok(database_url)
Ok(merged)
}
pub(super) fn blob_storage_config_from_config_files() -> Result<Option<StorageProviderConfig>> {
let mut storage = None;
for path in config_json_paths() {
if !path.exists() {
continue;
impl AppConfigFile {
fn apply_file_config(&mut self, config: AppConfigFile) {
if config.db.is_some() {
self.db = config.db;
}
let raw = fs::read_to_string(&path)
.map_err(|err| napi_error(format!("failed to read config file {}: {err}", path.display())))?;
let config: AppConfigFile = serde_json::from_str(&raw)
.map_err(|err| napi_error(format!("failed to parse config file {}: {err}", path.display())))?;
if let Some(next) = config.storages.and_then(|mut storages| storages.remove("blob.storage")) {
storage = Some(next);
if let Some(storages) = config.storages
&& !storages.is_empty()
{
self.storages.get_or_insert_with(HashMap::new).extend(storages);
}
}
}
async fn load_app_config_overrides_from_db(pool: &PgPool) -> Result<AppConfigFile> {
let rows = match sqlx::query("SELECT id, value FROM app_configs").fetch_all(pool).await {
Ok(rows) => rows,
Err(sqlx::Error::Database(err)) if err.code().as_deref() == Some("42P01") => return Ok(AppConfigFile::default()),
Err(err) => return Err(napi_error(format!("failed to load app config overrides: {err}"))),
};
app_config_from_flat_overrides(rows.into_iter().map(|row| {
let id: String = row.get("id");
let value: serde_json::Value = row.get("value");
(id, value)
}))
}
fn app_config_from_flat_overrides<I, S>(rows: I) -> Result<AppConfigFile>
where
I: IntoIterator<Item = (S, serde_json::Value)>,
S: AsRef<str>,
{
let mut root = Map::new();
for (path, value) in rows {
let Some((module, key)) = path.as_ref().split_once('.') else {
continue;
};
root
.entry(module.to_string())
.or_insert_with(|| serde_json::Value::Object(Map::new()));
if let Some(serde_json::Value::Object(module_object)) = root.get_mut(module) {
module_object.insert(key.to_string(), value);
}
}
Ok(storage)
serde_json::from_value(serde_json::Value::Object(root))
.map_err(|err| napi_error(format!("invalid app config overrides: {err}")))
}
pub(super) fn config_json_paths() -> Vec<PathBuf> {
@@ -142,4 +203,48 @@ mod tests {
Some("postgresql://affine:affine@localhost:5432/affine".to_string())
);
}
#[test]
fn parses_blob_storage_app_config_value() {
let app_config = app_config_from_flat_overrides([
(
"unknown.future.config",
serde_json::json!({
"shape": "ignored"
}),
),
(
"storages.blob.storage",
serde_json::json!({
"provider": "cloudflare-r2",
"bucket": "workspace-blobs-canary",
"config": {
"accountId": "account",
"credentials": {
"accessKeyId": "key",
"secretAccessKey": "secret"
},
"usePresignedURL": {
"enabled": true
}
}
}),
),
])
.unwrap();
let storage = app_config.blob_storage_provider_config().unwrap();
let config = ObjectStorageConfig::from_provider_config(Some(storage))
.unwrap()
.unwrap();
let health = config.health();
assert!(health.configured);
assert_eq!(health.provider.as_deref(), Some("cloudflare-r2"));
assert_eq!(health.bucket.as_deref(), Some("workspace-blobs-canary"));
assert_eq!(
health.endpoint.as_deref(),
Some("https://account.r2.cloudflarestorage.com")
);
assert!(health.use_presigned_url);
}
}
@@ -18,7 +18,7 @@ mod tests;
mod types;
mod workspace_stats;
use std::time::Duration;
use std::{sync::RwLock, time::Duration};
use napi::Result;
use sha2::{Digest, Sha256};
@@ -33,7 +33,7 @@ pub(super) fn token_hash(token: &str) -> String {
#[napi_derive::napi]
pub struct BackendRuntime {
config: RuntimeConfig,
config: RwLock<RuntimeConfig>,
pool: Mutex<Option<PgPool>>,
}
@@ -42,7 +42,7 @@ impl BackendRuntime {
#[napi(constructor)]
pub fn new() -> Result<Self> {
Ok(Self {
config: RuntimeConfig::from_config_files()?,
config: RwLock::new(RuntimeConfig::from_config_files()?),
pool: Mutex::new(None),
})
}
@@ -54,10 +54,11 @@ impl BackendRuntime {
return Ok(());
}
let database_url = self.config()?.database_url;
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect(&self.config.database_url)
.connect(&database_url)
.await
.map_err(|err| napi_error(format!("BackendRuntime failed to connect postgres: {err}")))?;
@@ -66,6 +67,9 @@ impl BackendRuntime {
.await
.map_err(|err| napi_error(format!("BackendRuntime postgres health check failed: {err}")))?;
let config = self.config()?.with_db_overrides(&pool).await?;
self.update_config(config)?;
*guard = Some(pool);
Ok(())
}
@@ -94,7 +98,7 @@ impl BackendRuntime {
Ok(BackendRuntimeHealth {
started: pool.is_some(),
database_connected,
object_storage_configured: self.config.storage.is_some(),
object_storage_configured: self.config()?.storage.is_some(),
})
}
@@ -113,6 +117,22 @@ impl BackendRuntime {
.cloned()
.ok_or_else(|| napi_error("BackendRuntime must be started before using postgres operations"))
}
pub(in crate::backend_runtime) fn config(&self) -> Result<RuntimeConfig> {
self
.config
.read()
.map(|config| config.clone())
.map_err(|_| napi_error("BackendRuntime config lock poisoned"))
}
fn update_config(&self, config: RuntimeConfig) -> Result<()> {
*self
.config
.write()
.map_err(|_| napi_error("BackendRuntime config lock poisoned"))? = config;
Ok(())
}
}
async fn migrate_runtime_tables(pool: &PgPool) -> Result<()> {
@@ -5,9 +5,7 @@ use napi::Result;
use serde::Deserialize;
use super::{client::ObjectStorageClient, types::StorageProviderConfig};
use crate::backend_runtime::{
config::blob_storage_config_from_config_files, error::napi_error, types::RuntimeObjectStorageHealth,
};
use crate::backend_runtime::{error::napi_error, types::RuntimeObjectStorageHealth};
#[derive(Clone, Debug)]
pub(in crate::backend_runtime) struct ObjectStorageConfig {
@@ -75,8 +73,10 @@ struct UsePresignedUrlConfigFile {
}
impl ObjectStorageConfig {
pub(in crate::backend_runtime) fn from_config_files() -> Result<Option<Self>> {
let Some(storage) = blob_storage_config_from_config_files()? else {
pub(in crate::backend_runtime) fn from_provider_config(
storage: Option<StorageProviderConfig>,
) -> Result<Option<Self>> {
let Some(storage) = storage else {
return Ok(None);
};
@@ -190,7 +190,7 @@ impl ObjectStorageConfig {
))
}
pub(super) fn health(&self) -> RuntimeObjectStorageHealth {
pub(in crate::backend_runtime) fn health(&self) -> RuntimeObjectStorageHealth {
let client_buildable = self
.build_client()
.map(|client| client.non_destructive_health())
@@ -21,12 +21,11 @@ use super::{
#[napi_derive::napi]
impl BackendRuntime {
fn object_storage_client(&self) -> Result<ObjectStorageClient> {
self
.config
let storage = self
.config()?
.storage
.as_ref()
.ok_or_else(|| super::error::napi_error("ObjectStorageClient is not configured"))?
.build_client()
.ok_or_else(|| super::error::napi_error("ObjectStorageClient is not configured"))?;
storage.build_client()
}
pub(super) async fn object_storage_delete_object(&self, key: &str) -> Result<()> {
@@ -55,7 +54,7 @@ impl BackendRuntime {
#[napi]
pub fn object_storage_health(&self) -> RuntimeObjectStorageHealth {
match &self.config.storage {
match self.config().ok().and_then(|config| config.storage) {
Some(storage) => storage.health(),
None => RuntimeObjectStorageHealth {
configured: false,
@@ -70,10 +70,10 @@ async fn runtime_from_database_url() -> AnyResult<Option<BackendRuntime>> {
.context("cleanup runtime_leases for backend runtime tests")?;
Ok(Some(BackendRuntime {
config: RuntimeConfig {
config: std::sync::RwLock::new(RuntimeConfig {
database_url,
storage: None,
},
}),
pool: Mutex::new(Some(pool)),
}))
}
@@ -130,7 +130,7 @@ async fn runtime_gate_sql_semantics_are_atomic_and_ttl_bound() {
let mut tasks = Vec::new();
for _ in 0..16 {
let runtime = BackendRuntime {
config: runtime.config.clone(),
config: std::sync::RwLock::new(runtime.config().unwrap()),
pool: Mutex::new(Some(runtime.pool().await.unwrap())),
};
tasks.push(tokio::spawn(async move {
@@ -189,7 +189,7 @@ async fn coordination_lease_sql_semantics_are_fenced_and_ttl_bound() {
let mut tasks = Vec::new();
for index in 0..16 {
let runtime = BackendRuntime {
config: runtime.config.clone(),
config: std::sync::RwLock::new(runtime.config().unwrap()),
pool: Mutex::new(Some(runtime.pool().await.unwrap())),
};
tasks.push(tokio::spawn(async move {
@@ -393,7 +393,7 @@ async fn verification_token_sql_state_machine_handles_keep_verify_and_cleanup()
let mut tasks = Vec::new();
for _ in 0..16 {
let runtime = BackendRuntime {
config: runtime.config.clone(),
config: std::sync::RwLock::new(runtime.config().unwrap()),
pool: Mutex::new(Some(runtime.pool().await.unwrap())),
};
let token = concurrent_token.clone();
+17 -1
View File
@@ -18,7 +18,7 @@ pub mod tiktoken;
use affine_common::napi_utils::map_napi_err;
use napi::{Result, Status, bindgen_prelude::*};
use y_octo::Doc;
use y_octo::{Doc, Update};
#[cfg(not(target_arch = "arm"))]
#[global_allocator]
@@ -41,6 +41,16 @@ pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
Ok(buf.into())
}
/// Check whether a Yjs update binary can be decoded without applying it to a
/// document state.
#[napi(catch_unwind)]
pub async fn validate_doc_update(update: Buffer) -> Result<bool> {
let update = update.to_vec();
tokio::task::spawn_blocking(move || Update::decode_v1(update).is_ok())
.await
.map_err(|err| napi::Error::from_reason(format!("Doc update validation task failed: {err}")))
}
#[napi]
pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY");
@@ -59,4 +69,10 @@ mod tests {
};
assert_eq!(err.status, Status::GenericFailure);
}
#[test]
fn y_octo_update_decode_accepts_valid_update_and_rejects_invalid_update() {
assert!(Update::decode_v1(vec![0, 0]).is_ok());
assert!(Update::decode_v1(vec![0]).is_err());
}
}