Skip to content

Commit e2c6d54

Browse files
committed
feat: add optional per-collection storage limits
This commit: - adds a third field to the elements of STD_COLLS for per-collection storage limit - adds an optional `limits.collections` map in settings that overrides the default limits from STD_COLLS - enforces the per-collection limits separately from account-wide usage
1 parent dd21025 commit e2c6d54

27 files changed

Lines changed: 829 additions & 268 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/local.example.spanner.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ syncstorage.database_url = "spanner://projects/SAMPLE_GCP_PROJECT/instances/SAMP
5959
# Max size of the syncstorage DB connection pool. Default: 10.
6060
# syncstorage.database_pool_max_size = 10
6161

62-
# Quota tracking/enforcement (Spanner-only). Both default to false. Enable
63-
# tracking to record per-user usage; additionally enable enforcement to reject
64-
# writes over `limits.max_quota_limit` (default 2 GB).
62+
# Quota tracking/enforcement. Both default to false. Enable tracking to record
63+
# per-user usage; additionally enable enforcement to reject writes over
64+
# `limits.max_quota_limit` (default 2GB) or configured per-collection limits.
6565
syncstorage.enable_quota = 0
6666
# syncstorage.enforce_quota = 0
6767

config/local.example.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ syncstorage.database_url = "mysql://sample_user:sample_password@localhost/syncst
4949
# Max size of the syncstorage DB connection pool. Default: 10.
5050
# syncstorage.database_pool_max_size = 10
5151

52-
# Quota tracking/enforcement are Spanner-only features and are force-disabled
53-
# for non-Spanner backends, so they have no effect on the MySQL build.
52+
# Quota tracking/enforcement. It is disabled by default; enable tracking to
53+
# record per-user usage and, optionally, enforcement to reject writes over the
54+
# configured quota limits.
5455
syncstorage.enable_quota = 0
56+
# syncstorage.enforce_quota = 0
5557

5658
# Request/batch limits. Defaults shown in docs/src/config.md (Syncstorage
5759
# Limits). `max_total_records` is also used to cap MySQL `get_bsos` result

docs/src/config.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,16 @@ The following configuration options are available.
7272
| <span id="SYNC_SYNCSTORAGE__LIMITS__MAX_REQUEST_BYTES"></span>SYNC_SYNCSTORAGE__LIMITS__MAX_REQUEST_BYTES | 2,625,536 | Max Content-Length for requests |
7373
| <span id="SYNC_SYNCSTORAGE__LIMITS__MAX_TOTAL_BYTES"></span>SYNC_SYNCSTORAGE__LIMITS__MAX_TOTAL_BYTES | 262,144,000 | Max BSO payload size per batch |
7474
| <span id="SYNC_SYNCSTORAGE__LIMITS__MAX_TOTAL_RECORDS"></span>SYNC_SYNCSTORAGE__LIMITS__MAX_TOTAL_RECORDS | 10,000 | Max BSO count per batch |
75-
| <span id="SYNC_SYNCSTORAGE__LIMITS__MAX_QUOTA_LIMIT"></span>SYNC_SYNCSTORAGE__LIMITS__MAX_QUOTA_LIMIT | 2,147,483,648 | Max storage quota per user (2 GB) |
75+
| <span id="SYNC_SYNCSTORAGE__LIMITS__MAX_QUOTA_LIMIT"></span>SYNC_SYNCSTORAGE__LIMITS__MAX_QUOTA_LIMIT | 2,147,483,648 | Account-wide storage quota for collections without their own per-collection quota (2 GB) |
76+
| <span id="SYNC_SYNCSTORAGE__LIMITS__COLLECTIONS__COLL_NAME"></span>SYNC_SYNCSTORAGE__LIMITS__COLLECTIONS__COLL_NAME | unset | Optional per-collection storage quota override, in bytes. Replace `COLL_NAME` with collection name. |
7677

7778
### Syncstorage Features
7879

7980
| Env Var | Default Value | Description |
8081
| --- | --- | --- |
8182
| <span id="SYNC_SYNCSTORAGE__ENABLED"></span>SYNC_SYNCSTORAGE__ENABLED | true | Enable syncstorage service |
82-
| <span id="SYNC_SYNCSTORAGE__ENABLE_QUOTA"></span>SYNC_SYNCSTORAGE__ENABLE_QUOTA | false | Enable quota tracking (Spanner only) |
83-
| <span id="SYNC_SYNCSTORAGE__ENFORCE_QUOTA"></span>SYNC_SYNCSTORAGE__ENFORCE_QUOTA | false | Enforce quota limits (Spanner only) |
83+
| <span id="SYNC_SYNCSTORAGE__ENABLE_QUOTA"></span>SYNC_SYNCSTORAGE__ENABLE_QUOTA | false | Enable quota tracking and configured per-collection quota limits |
84+
| <span id="SYNC_SYNCSTORAGE__ENFORCE_QUOTA"></span>SYNC_SYNCSTORAGE__ENFORCE_QUOTA | false | Enforce account-wide and per-collection quota limits |
8485
| <span id="SYNC_SYNCSTORAGE__GLEAN_ENABLED"></span>SYNC_SYNCSTORAGE__GLEAN_ENABLED | true | Enable Glean telemetry |
8586
| <span id="SYNC_SYNCSTORAGE__LBHEARTBEAT_TTL"></span>SYNC_SYNCSTORAGE__LBHEARTBEAT_TTL | None | Load balancer heartbeat period in seconds |
8687
| <span id="SYNC_SYNCSTORAGE__LBHEARTBEAT_TTL_JITTER"></span>SYNC_SYNCSTORAGE__LBHEARTBEAT_TTL_JITTER | 25 | Jitter percentage for the load balancer heartbeat period |

docs/src/syncstorage/syncstorage-spanner-db.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,14 @@ Set under `[syncstorage.limits]` in TOML, or as `SYNC_SYNCSTORAGE__LIMITS__*` en
4848
| `max_request_bytes` | 2,625,536 (≈ 2.5 MB + 4 KB) | HTTP `Content-Length` ceiling also enforced upstream of the API (e.g., nginx). |
4949
| `max_total_bytes` | 250 MB declared, clamped | Combined batch payload size. **`Settings::normalize()` clamps this to `MAX_SPANNER_LOAD_SIZE` (100 MB) for Spanner deployments.** |
5050
| `max_total_records` | 10,000 default; 9,984 in Spanner prod | BSOs per batch. The Spanner ceiling is driven by the per-commit mutation budget, see [below](#batch-commit-mutation-budget). |
51-
| `max_quota_limit` | 2 GB | Per-collection quota; only enforced when `enforce_quota = true`. |
51+
| `max_quota_limit` | 2 GB | Account-wide quota for collections without their own per-collection limit; only enforced when `enforce_quota = true`. |
5252

5353
### Quota toggles
5454

5555
| Setting | Default | Effect |
5656
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57-
| `enable_quota` | false | Enables `count` / `total_bytes` tracking on `user_collections`. Adds 6 mutations per batch commit (Steps 1 & 4 in the budget below). |
58-
| `enforce_quota` | false | When true, returns 403 once a user's collection exceeds `max_quota_limit`. When false but `enable_quota` is true, the server only logs a warning. |
59-
60-
`Settings::normalize()` forces `max_quota_limit = 0` and disables both quota flags for non-Spanner deployments, quota is a Spanner-only feature in this codebase.
57+
| `enable_quota` | false | Enables `count` / `total_bytes` tracking on `user_collections` and activates configured per-collection quota limits. Adds 6 mutations per batch commit (Steps 1 & 4 in the budget below). |
58+
| `enforce_quota` | false | When true, returns 403 once a user's account-wide or per-collection usage reaches its configured quota. When false but `enable_quota` is true, the server only logs a warning. |
6159

6260
### Compile-time constants
6361

syncserver-settings/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ slog-scope.workspace = true
1212
config = "0.15"
1313
num_cpus = "1"
1414
syncserver-common = { path = "../syncserver-common" }
15+
syncstorage-db-common = { path = "../syncstorage-db-common" }
1516
syncstorage-settings = { path = "../syncstorage-settings" }
1617
tokenserver-settings = { path = "../tokenserver-settings" }
1718
url = "2.5"

syncserver-settings/src/lib.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use syncserver_common::{
77
X_LAST_MODIFIED, X_VERIFY_CODE, X_WEAVE_BYTES, X_WEAVE_NEXT_OFFSET, X_WEAVE_RECORDS,
88
X_WEAVE_TIMESTAMP, X_WEAVE_TOTAL_BYTES, X_WEAVE_TOTAL_RECORDS,
99
};
10+
use syncstorage_db_common::STD_COLLS;
1011
use syncstorage_settings::Settings as SyncstorageSettings;
1112
use tokenserver_settings::Settings as TokenserverSettings;
1213
use url::Url;
@@ -168,6 +169,16 @@ impl Settings {
168169
));
169170
}
170171

172+
// Per-collection limits may only target standard collections in STD_COLLS.
173+
for name in self.syncstorage.limits.collections.keys() {
174+
if !STD_COLLS.iter().any(|(_, std_name, _)| std_name == name) {
175+
return Err(ConfigError::Message(format!(
176+
"Invalid SYNC_SYNCSTORAGE__LIMITS__COLLECTIONS: \
177+
\"{name}\" is not a standard collection name"
178+
)));
179+
}
180+
}
181+
171182
if let Some(init_node_url) = &self.tokenserver.init_node_url {
172183
let url = Url::parse(init_node_url).map_err(|e| {
173184
ConfigError::Message(format!("Invalid SYNC_TOKENSERVER__INIT_NODE_URL: {e}"))
@@ -410,6 +421,49 @@ mod test {
410421
);
411422
}
412423

424+
#[test]
425+
fn test_unknown_collection_limit_name_fails_fast() {
426+
temp_env::with_vars(
427+
[
428+
(
429+
"SYNC_SYNCSTORAGE__DATABASE_URL",
430+
Some(TEST_SYNCSTORAGE_DATABASE_URL),
431+
),
432+
("SYNC_TOKENSERVER__DATABASE_URL", None),
433+
("SYNC_TOKENSERVER__ENABLED", None),
434+
("SYNC_SYNCSTORAGE__LIMITS__COLLECTIONS__BATS", Some("9001")),
435+
],
436+
|| {
437+
let err = Settings::with_env_and_config_file(None)
438+
.expect_err("an unknown collection limit name should fail validation");
439+
assert!(err.to_string().contains("not a standard collection name"));
440+
},
441+
);
442+
}
443+
444+
#[test]
445+
fn test_known_collection_limit_name_validates() {
446+
temp_env::with_vars(
447+
[
448+
(
449+
"SYNC_SYNCSTORAGE__DATABASE_URL",
450+
Some(TEST_SYNCSTORAGE_DATABASE_URL),
451+
),
452+
("SYNC_TOKENSERVER__DATABASE_URL", None),
453+
("SYNC_TOKENSERVER__ENABLED", None),
454+
("SYNC_SYNCSTORAGE__LIMITS__COLLECTIONS__TABS", Some("1024")),
455+
],
456+
|| {
457+
let settings = Settings::with_env_and_config_file(None)
458+
.expect("a standard collection limit name should validate");
459+
assert_eq!(
460+
settings.syncstorage.limits.collections.get("tabs"),
461+
Some(&1024)
462+
);
463+
},
464+
);
465+
}
466+
413467
#[test]
414468
fn test_disabled_tokenserver_does_not_require_database_url() {
415469
// A disabled Tokenserver should not require a DATABASE_URL.

syncserver/src/server/mod.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use syncserver_common::{
2121
};
2222
use syncserver_db_common::GetPoolStatus;
2323
use syncserver_settings::Settings;
24-
use syncstorage_db::{DbError, DbPool, DbPoolImpl};
24+
use syncstorage_db::{DbError, DbPool, DbPoolImpl, STD_COLLS};
2525
use syncstorage_settings::{Deadman, ServerLimits};
2626
use tokio::{sync::RwLock, time};
2727
use utoipa::OpenApi;
@@ -398,11 +398,10 @@ impl Server {
398398
let gcs_endpoint = settings.syncstorage.gcs_endpoint.clone();
399399
let worker_thread_count =
400400
calculate_worker_max_blocking_threads(settings.worker_max_blocking_threads);
401+
let quota_enabled = settings.syncstorage.enable_quota;
402+
let limits_json = build_limits_json(&settings.syncstorage.limits, quota_enabled);
401403
let limits = Arc::new(settings.syncstorage.limits);
402-
let limits_json =
403-
serde_json::to_string(&*limits).expect("ServerLimits failed to serialize");
404404
let secrets = Arc::new(settings.master_secret);
405-
let quota_enabled = settings.syncstorage.enable_quota;
406405
let actix_keep_alive = settings.actix_keep_alive;
407406
let tokenserver_state = if settings.tokenserver.enabled {
408407
let mut state = tokenserver::ServerState::from_settings(
@@ -529,6 +528,29 @@ fn calculate_worker_max_blocking_threads(count: usize) -> usize {
529528
std::cmp::max(count / parallelism, 1)
530529
}
531530

531+
/// Build the `info/configuration` client-limits JSON.
532+
///
533+
/// When `quota_enabled` and at least one collection resolves to a limit, injects a `collections`
534+
/// section (name -> `{max_quota_limit}`).
535+
pub(crate) fn build_limits_json(limits: &ServerLimits, quota_enabled: bool) -> String {
536+
let collection_limits = if quota_enabled {
537+
limits.resolved_collection_limits_by_name(&STD_COLLS)
538+
} else {
539+
Default::default()
540+
};
541+
let mut limits_value = serde_json::to_value(limits).expect("ServerLimits failed to serialize");
542+
if !collection_limits.is_empty()
543+
&& let Some(obj) = limits_value.as_object_mut()
544+
{
545+
obj.insert(
546+
"collections".to_owned(),
547+
serde_json::to_value(&collection_limits)
548+
.expect("collection limits failed to serialize"),
549+
);
550+
}
551+
serde_json::to_string(&limits_value).expect("ServerLimits failed to serialize")
552+
}
553+
532554
fn build_cors(settings: &Settings) -> Cors {
533555
// Followed by the "official middleware" so they run first.
534556
// actix is getting increasingly tighter about CORS headers. Our server is

syncserver/src/server/test.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,3 +979,36 @@ async fn put_bso_offloads_to_gcs() {
979979
resp.response()
980980
);
981981
}
982+
983+
fn limits_with_collection(name: &str, limit: usize) -> ServerLimits {
984+
let mut limits = ServerLimits::default();
985+
limits.collections.insert(name.to_owned(), limit);
986+
limits
987+
}
988+
989+
#[::core::prelude::v1::test]
990+
fn limits_json_advertises_collections_when_quota_enabled() {
991+
let limits = limits_with_collection("tabs", 5_242_880);
992+
let json: Value = serde_json::from_str(&build_limits_json(&limits, true)).unwrap();
993+
assert!(json["collections"]["tabs"].is_object());
994+
assert_eq!(
995+
json["collections"]["tabs"]["max_quota_limit"],
996+
json!(5_242_880)
997+
);
998+
}
999+
1000+
#[::core::prelude::v1::test]
1001+
fn limits_json_omits_collections_when_quota_disabled() {
1002+
// quota is off, so the section is omitted
1003+
let limits = limits_with_collection("tabs", 5_242_880);
1004+
let json: Value = serde_json::from_str(&build_limits_json(&limits, false)).unwrap();
1005+
assert!(json.get("collections").is_none());
1006+
}
1007+
1008+
#[::core::prelude::v1::test]
1009+
fn limits_json_omits_collections_when_none_limited() {
1010+
// quota enabled but nothing limited
1011+
let json: Value =
1012+
serde_json::from_str(&build_limits_json(&ServerLimits::default(), true)).unwrap();
1013+
assert!(json.get("collections").is_none());
1014+
}

syncserver/src/web/handlers.rs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -509,19 +509,9 @@ pub async fn post_collection_batch(
509509
.await?;
510510

511511
if is_valid {
512-
let usage = db
513-
.get_quota_usage(params::GetQuotaUsage {
514-
user_id: coll.user_id.clone(),
515-
collection: coll.collection.clone(),
516-
})
517-
.await?;
518512
CreateBatch {
519513
id: id.clone(),
520-
size: if coll.quota_enabled {
521-
Some(usage.total_bytes)
522-
} else {
523-
None
524-
},
514+
size: None, // will be seeded with value from `check_quota` later
525515
}
526516
} else {
527517
return Err(ApiErrorKind::Db(DbError::batch_not_found()).into());

0 commit comments

Comments
 (0)