Skip to content

Commit ea3422c

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 e508549 commit ea3422c

7 files changed

Lines changed: 289 additions & 36 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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -651,14 +651,14 @@ async fn get_collection_usage() -> Result<(), DbError> {
651651
let quota = db
652652
.get_quota_usage(params::GetQuotaUsage {
653653
user_id: hid(uid),
654-
collection: "ignored".to_owned(),
654+
collection: "bookmarks".to_owned(),
655655
})
656656
.await?;
657657
assert_eq!(
658658
&(quota.total_bytes as i64),
659659
expected.get("bookmarks").unwrap()
660660
);
661-
assert_eq!(quota.count, 5); // 3 collections, 5 records
661+
assert_eq!(quota.count, 5); // 5 records in the collection
662662
}
663663
Ok(())
664664
})

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 collection size from `check_quota`.
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 collection size from `check_quota`.
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's collection ({} 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/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's collection: ({} 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

syncstorage-postgres/src/db/batch_impl.rs

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use async_trait::async_trait;
24
use diesel::{
35
self, ExpressionMethods, OptionalExtension, QueryDsl, delete,
@@ -45,7 +47,10 @@ impl BatchDb for PgDb {
4547

4648
let batch = results::CreateBatch {
4749
id: batch_id.to_string(),
48-
size: None,
50+
// `size` is the committed collection size from `check_quota`.
51+
size: self
52+
.check_quota(&params.user_id, &params.collection)
53+
.await?,
4954
};
5055

5156
do_append(
@@ -54,6 +59,7 @@ impl BatchDb for PgDb {
5459
collection_id,
5560
batch.clone(),
5661
params.bsos,
62+
&params.collection,
5763
)
5864
.await?;
5965

@@ -100,12 +106,19 @@ impl BatchDb for PgDb {
100106

101107
let collection_id = self.get_or_create_collection_id(&params.collection).await?;
102108

109+
// `batch.size` is the committed collection size from `check_quota`.
110+
let mut batch = params.batch;
111+
batch.size = self
112+
.check_quota(&params.user_id, &params.collection)
113+
.await?;
114+
103115
do_append(
104116
self,
105117
params.user_id,
106118
collection_id,
107-
params.batch,
119+
batch,
108120
params.bsos,
121+
&params.collection,
109122
)
110123
.await?;
111124

@@ -132,13 +145,7 @@ impl BatchDb for PgDb {
132145
let user_id = params.user_id.legacy_id as i64;
133146
let collection_id = self.get_or_create_collection_id(&params.collection).await?;
134147

135-
let timestamp = self
136-
.update_collection(params::UpdateCollection {
137-
user_id: params.user_id.clone(),
138-
collection_id,
139-
collection: params.collection.clone(),
140-
})
141-
.await?;
148+
let timestamp = self.checked_timestamp()?;
142149
let default_ttl_seconds = DEFAULT_BSO_TTL as i64;
143150
let ts_datetime = timestamp.as_datetime()?;
144151

@@ -151,6 +158,14 @@ impl BatchDb for PgDb {
151158
.execute(&mut self.conn)
152159
.await?;
153160

161+
let timestamp = self
162+
.update_collection(params::UpdateCollection {
163+
user_id: params.user_id.clone(),
164+
collection_id,
165+
collection: params.collection.clone(),
166+
})
167+
.await?;
168+
154169
self.delete_batch(params::DeleteBatch {
155170
user_id: params.user_id,
156171
collection: params.collection,
@@ -194,14 +209,39 @@ pub async fn do_append(
194209
collection_id: i32,
195210
batch: results::CreateBatch,
196211
bsos: Vec<params::PostCollectionBso>,
212+
collection: &str,
197213
) -> DbResult<()> {
198214
let batch_id = Uuid::parse_str(&batch.id)
199215
.map_err(|e| DbError::internal(format!("Invalid batch_id in batch: {}", e)))?;
216+
let user_id_i64 = user_id.legacy_id as i64;
217+
218+
if db.quota.enabled
219+
&& let Some(size) = batch.size
220+
{
221+
let running_size: usize = bsos
222+
.iter()
223+
.filter_map(|bso| bso.payload.as_ref().map(|p| p.len()))
224+
.sum();
225+
let incoming_ids: Vec<String> = bsos.iter().map(|bso| bso.id.clone()).collect();
226+
let pending_size =
227+
pending_batch_size(db, user_id_i64, collection_id, &batch_id, &incoming_ids).await?;
228+
let projected_total = size + pending_size + running_size;
229+
if projected_total >= db.quota.size {
230+
let mut tags = HashMap::default();
231+
tags.insert("collection".to_owned(), collection.to_owned());
232+
db.metrics.incr_with_tags("storage.quota.at_limit", tags);
233+
if db.quota.enforced {
234+
return Err(DbError::quota());
235+
} else {
236+
warn!("Quota at limit for user's collection ({} bytes)", projected_total;
237+
"collection" => collection);
238+
}
239+
}
240+
}
200241

201242
for bso in bsos {
202243
let ttl = bso.ttl.map(|t| t as i64);
203244
let sortindex = bso.sortindex;
204-
let user_id_i64 = user_id.legacy_id as i64;
205245

206246
sql_query(
207247
"INSERT INTO batch_bsos (user_id, collection_id, batch_id, batch_bso_id, sortindex, payload, ttl)
@@ -225,6 +265,29 @@ pub async fn do_append(
225265
Ok(())
226266
}
227267

268+
/// Sum the pending payload bytes, excluding any ids in `incoming_ids`.
269+
async fn pending_batch_size(
270+
db: &mut PgDb,
271+
user_id: i64,
272+
collection_id: i32,
273+
batch_id: &Uuid,
274+
incoming_ids: &[String],
275+
) -> DbResult<usize> {
276+
let current_batch_size: i64 = batch_bsos::table
277+
.select(sql::<BigInt>(
278+
"COALESCE(SUM(LENGTH(COALESCE(payload, ''))), 0)::BIGINT",
279+
))
280+
.filter(batch_bsos::user_id.eq(user_id))
281+
.filter(batch_bsos::collection_id.eq(collection_id))
282+
.filter(batch_bsos::batch_id.eq(batch_id))
283+
.filter(batch_bsos::batch_bso_id.ne_all(incoming_ids))
284+
.get_result(&mut db.conn)
285+
.await
286+
.optional()?
287+
.unwrap_or_default();
288+
Ok(current_batch_size.max(0) as usize)
289+
}
290+
228291
pub fn validate_batch_id(id: &str) -> DbResult<Uuid> {
229292
Uuid::parse_str(id).map_err(|e| DbError::internal(format!("Invalid batch_id: {}", e)))
230293
}

0 commit comments

Comments
 (0)