Skip to content

Commit 0951f51

Browse files
committed
feat: allow quota enforcement for all db backends
This commit: - implements storage quotas for MySQL and Postgres - fixes a bug in Spanner's batch quota calculation
1 parent b28317e commit 0951f51

12 files changed

Lines changed: 295 additions & 51 deletions

File tree

syncstorage-db/src/tests/batch.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,53 @@ async fn test_append_upsert_overwrites_same_batch_bso_id() -> Result<(), DbError
420420
.await
421421
}
422422

423+
#[tokio::test]
424+
async fn test_quota_pending_batch_accumulates() -> Result<(), DbError> {
425+
let settings = Settings::test_settings();
426+
if !settings.syncstorage.enable_quota {
427+
debug!("[test] Skipping test");
428+
return Ok(());
429+
}
430+
431+
with_test_transaction(
432+
settings.syncstorage,
433+
async |db: &mut dyn Db<Error = DbError>| {
434+
let uid = 9001;
435+
let coll = "clients";
436+
437+
let chunk = 3838;
438+
let payload = "z".repeat(chunk);
439+
// hits quota on second append.
440+
db.set_quota(true, chunk + chunk / 2, true);
441+
442+
let new_batch = db.create_batch(cb(uid, coll, vec![])).await?;
443+
444+
db.append_to_batch(ab(
445+
uid,
446+
coll,
447+
new_batch.clone(),
448+
vec![postbso("b0", Some(&payload), None, None)],
449+
))
450+
.await?;
451+
452+
let result = db
453+
.append_to_batch(ab(
454+
uid,
455+
coll,
456+
new_batch.clone(),
457+
vec![postbso("b1", Some(&payload), None, None)],
458+
))
459+
.await;
460+
assert!(
461+
result.as_ref().err().is_some_and(|e| e.is_quota()),
462+
"expected an over-quota error, got {result:?}"
463+
);
464+
Ok(())
465+
},
466+
)
467+
.await
468+
}
469+
423470
#[tokio::test]
424471
async fn test_commit_batch_partial_overlap() -> Result<(), DbError> {
425472
let settings = Settings::test_settings().syncstorage;

syncstorage-db/src/tests/db.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -654,11 +654,8 @@ async fn get_collection_usage() -> Result<(), DbError> {
654654
collection: "ignored".to_owned(),
655655
})
656656
.await?;
657-
assert_eq!(
658-
&(quota.total_bytes as i64),
659-
expected.get("bookmarks").unwrap()
660-
);
661-
assert_eq!(quota.count, 5); // 3 collections, 5 records
657+
assert_eq!(&(quota.total_bytes as i64), &sum);
658+
assert_eq!(quota.count, 15); // 3 collections of 5 records
662659
}
663660
Ok(())
664661
})

syncstorage-mysql/src/db/batch_impl.rs

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use base64::Engine;
2-
use std::collections::HashSet;
2+
use std::collections::{HashMap, HashSet};
33

44
use async_trait::async_trait;
55
use diesel::{
@@ -69,11 +69,25 @@ impl BatchDb for MysqlDb {
6969
}
7070
}
7171

72-
do_append(self, batch_id, params.user_id, collection_id, params.bsos).await?;
73-
Ok(results::CreateBatch {
72+
let batch = results::CreateBatch {
7473
id: encode_id(batch_id),
75-
size: None,
76-
})
74+
// `size` is the committed size
75+
size: self
76+
.check_quota(&params.user_id, &params.collection)
77+
.await?,
78+
};
79+
80+
do_append(
81+
self,
82+
params.user_id,
83+
collection_id,
84+
batch.clone(),
85+
params.bsos,
86+
&params.collection,
87+
)
88+
.await?;
89+
90+
Ok(batch)
7791
}
7892

7993
async fn validate_batch(&mut self, params: params::ValidateBatch) -> DbResult<bool> {
@@ -110,9 +124,24 @@ impl BatchDb for MysqlDb {
110124
return Err(DbError::batch_not_found());
111125
}
112126

113-
let batch_id = decode_id(&params.batch.id)?;
114127
let collection_id = self._get_collection_id(&params.collection).await?;
115-
do_append(self, batch_id, params.user_id, collection_id, params.bsos).await?;
128+
129+
// `batch.size` is the committed size
130+
let mut batch = params.batch;
131+
batch.size = self
132+
.check_quota(&params.user_id, &params.collection)
133+
.await?;
134+
135+
do_append(
136+
self,
137+
params.user_id,
138+
collection_id,
139+
batch,
140+
params.bsos,
141+
&params.collection,
142+
)
143+
.await?;
144+
116145
Ok(())
117146
}
118147

@@ -191,10 +220,11 @@ impl BatchDb for MysqlDb {
191220

192221
pub async fn do_append(
193222
db: &mut MysqlDb,
194-
batch_id: i64,
195223
user_id: UserIdentifier,
196224
_collection_id: i32,
225+
batch: results::CreateBatch,
197226
bsos: Vec<params::PostCollectionBso>,
227+
collection: &str,
198228
) -> DbResult<()> {
199229
fn exist_idx(user_id: u64, batch_id: i64, bso_id: &str) -> String {
200230
// Construct something that matches the key for batch_upload_items
@@ -206,6 +236,32 @@ pub async fn do_append(
206236
)
207237
}
208238

239+
let batch_id = decode_id(&batch.id)?;
240+
let uid = user_id.legacy_id as i64;
241+
242+
if db.quota.enabled
243+
&& let Some(size) = batch.size
244+
{
245+
let running_size: usize = bsos
246+
.iter()
247+
.filter_map(|bso| bso.payload.as_ref().map(|p| p.len()))
248+
.sum();
249+
let incoming_ids: Vec<String> = bsos.iter().map(|bso| bso.id.clone()).collect();
250+
let pending_size = pending_batch_size(db, uid, batch_id, &incoming_ids).await?;
251+
let projected_total = size + pending_size + running_size;
252+
if projected_total >= db.quota.size {
253+
let mut tags = HashMap::default();
254+
tags.insert("collection".to_owned(), collection.to_owned());
255+
db.metrics.incr_with_tags("storage.quota.at_limit", tags);
256+
if db.quota.enforced {
257+
return Err(DbError::quota());
258+
} else {
259+
warn!("Quota at limit for user ({} bytes)", projected_total;
260+
"collection" => collection);
261+
}
262+
}
263+
}
264+
209265
// It's possible for the list of items to contain a duplicate key entry.
210266
// This means that we can't really call `ON DUPLICATE` here, because that's
211267
// more about inserting one item at a time. (e.g. it works great if the
@@ -281,6 +337,25 @@ pub async fn do_append(
281337
Ok(())
282338
}
283339

340+
/// Sum the pending payload bytes, excluding any ids in `incoming_ids`.
341+
async fn pending_batch_size(
342+
db: &mut MysqlDb,
343+
user_id: i64,
344+
batch_id: i64,
345+
incoming_ids: &[String],
346+
) -> DbResult<usize> {
347+
let current_batch_size: i64 = batch_upload_items::table
348+
.select(sql::<BigInt>("COALESCE(SUM(payload_size), 0)"))
349+
.filter(batch_upload_items::user_id.eq(user_id))
350+
.filter(batch_upload_items::batch_id.eq(batch_id))
351+
.filter(batch_upload_items::id.ne_all(incoming_ids))
352+
.get_result(&mut db.conn)
353+
.await
354+
.optional()?
355+
.unwrap_or_default();
356+
Ok(current_batch_size.max(0) as usize)
357+
}
358+
284359
pub fn validate_batch_id(id: &str) -> DbResult<()> {
285360
decode_id(id).map(|_| ())
286361
}

syncstorage-mysql/src/db/db_impl.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ impl Db for MysqlDb {
210210
if self.quota.enforced {
211211
return Err(DbError::quota());
212212
} else {
213-
warn!("Quota at limit for user's collection ({} bytes)", usage.total_bytes; "collection"=>bso.collection.clone());
213+
warn!("Quota at limit for user ({} bytes)", usage.total_bytes; "collection"=>bso.collection.clone());
214214
}
215215
}
216216
}
@@ -682,14 +682,12 @@ impl Db for MysqlDb {
682682
params: params::GetQuotaUsage,
683683
) -> DbResult<results::GetQuotaUsage> {
684684
let uid = params.user_id.legacy_id as i64;
685-
let collection_id = self._get_collection_id(&params.collection).await?;
686685
let (total_bytes, count): (i64, i32) = user_collections::table
687686
.select((
688687
sql::<BigInt>("COALESCE(SUM(COALESCE(total_bytes, 0)), 0)"),
689688
sql::<Integer>("COALESCE(SUM(COALESCE(count, 0)), 0)"),
690689
))
691690
.filter(user_collections::user_id.eq(uid))
692-
.filter(user_collections::collection_id.eq(collection_id))
693691
.get_result(&mut self.conn)
694692
.await
695693
.optional()?

syncstorage-mysql/src/db/mod.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use diesel::{
99
use diesel_async::RunQueryDsl;
1010
use syncserver_common::Metrics;
1111
use syncstorage_db_common::{
12-
FIRST_CUSTOM_COLLECTION_ID, UserIdentifier, error::DbErrorIntrospect, results,
12+
Db, FIRST_CUSTOM_COLLECTION_ID, UserIdentifier, error::DbErrorIntrospect, params, results,
1313
util::SyncTimestamp,
1414
};
1515
use syncstorage_settings::Quota;
@@ -237,6 +237,34 @@ impl MysqlDb {
237237
count,
238238
})
239239
}
240+
241+
async fn check_quota(
242+
&mut self,
243+
user_id: &UserIdentifier,
244+
collection: &str,
245+
) -> DbResult<Option<usize>> {
246+
if !self.quota.enabled {
247+
return Ok(None);
248+
}
249+
let usage = self
250+
.get_quota_usage(params::GetQuotaUsage {
251+
user_id: user_id.clone(),
252+
collection: collection.to_owned(),
253+
})
254+
.await?;
255+
if usage.total_bytes >= self.quota.size {
256+
let mut tags = HashMap::default();
257+
tags.insert("collection".to_owned(), collection.to_owned());
258+
self.metrics.incr_with_tags("storage.quota.at_limit", tags);
259+
if self.quota.enforced {
260+
return Err(DbError::quota());
261+
} else {
262+
warn!("Quota at limit for user ({} bytes)", usage.total_bytes;
263+
"collection" => collection);
264+
}
265+
}
266+
Ok(Some(usage.total_bytes))
267+
}
240268
}
241269

242270
#[allow(dead_code)] // Not really dead, Rust can't see the use above

0 commit comments

Comments
 (0)