feat(group-quota): per-group shared-collection quotas (M3)
Global env-var ceilings enforced in the group write path (mirrors the per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row count, checked only on a NEW key/doc — updates exempt) and _FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres); QuotaExceeded error variants on the three group error enums; a with_max_rows test builder. Pinned by a group_kv_service unit test (3rd key rejected, update of an existing key at the cap allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,9 @@ Environment variables consumed by the `picloud` binary:
|
|||||||
| `PICLOUD_DOCS_MAX_VALUE_BYTES` | `262144` (256 KB) | Per-document JSON-encoded data cap for `docs::create`/`update`. |
|
| `PICLOUD_DOCS_MAX_VALUE_BYTES` | `262144` (256 KB) | Per-document JSON-encoded data cap for `docs::create`/`update`. |
|
||||||
| `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `pubsub::publish_durable`. Prevents one publish from amplifying into N outbox rows × M MB. |
|
| `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `pubsub::publish_durable`. Prevents one publish from amplifying into N outbox rows × M MB. |
|
||||||
| `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `queue::enqueue`. |
|
| `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `queue::enqueue`. |
|
||||||
|
| `PICLOUD_GROUP_KV_MAX_ROWS` | `100000` | §11.6 M3 per-group quota: max total keys across a group's shared-KV collections. Checked only on a NEW key (`kv::shared_collection().set`); an update is exempt. |
|
||||||
|
| `PICLOUD_GROUP_DOCS_MAX_ROWS` | `100000` | §11.6 M3 per-group quota: max total docs across a group's shared-docs collections (`docs::shared_collection().create`). |
|
||||||
|
| `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES` | `10737418240` (10 GiB) | §11.6 M3 per-group quota: max total stored bytes across a group's shared-files collections (`files::shared_collection().create`). |
|
||||||
|
|
||||||
## Out of MVP
|
## Out of MVP
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ pub trait GroupDocsRepo: Send + Sync {
|
|||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<DocsListPage, GroupDocsRepoError>;
|
) -> Result<DocsListPage, GroupDocsRepoError>;
|
||||||
|
|
||||||
|
/// §11.6 quota: total doc count across the group's shared-docs collections.
|
||||||
|
/// Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||||
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||||
|
let _ = group_id;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresGroupDocsRepo {
|
pub struct PostgresGroupDocsRepo {
|
||||||
@@ -266,6 +273,14 @@ impl GroupDocsRepo for PostgresGroupDocsRepo {
|
|||||||
|
|
||||||
Ok(DocsListPage { docs, next_cursor })
|
Ok(DocsListPage { docs, next_cursor })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||||
|
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(u64::try_from(n).unwrap_or(0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_cursor(last_id: &Uuid) -> String {
|
fn encode_cursor(last_id: &Uuid) -> String {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub struct GroupDocsServiceImpl {
|
|||||||
resolver: Arc<dyn GroupCollectionResolver>,
|
resolver: Arc<dyn GroupCollectionResolver>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
max_value_bytes: usize,
|
max_value_bytes: usize,
|
||||||
|
/// §11.6 per-group quota: max total docs across the group's shared-docs
|
||||||
|
/// collections (`PICLOUD_GROUP_DOCS_MAX_ROWS`).
|
||||||
|
max_rows: u64,
|
||||||
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ impl GroupDocsServiceImpl {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: Arc::new(NoopEventEmitter),
|
events: Arc::new(NoopEventEmitter),
|
||||||
|
max_rows: crate::group_quota::group_docs_max_rows_from_env(),
|
||||||
repo,
|
repo,
|
||||||
resolver,
|
resolver,
|
||||||
authz,
|
authz,
|
||||||
@@ -189,6 +193,14 @@ impl GroupDocsService for GroupDocsServiceImpl {
|
|||||||
validate_data(&data)?;
|
validate_data(&data)?;
|
||||||
self.check_data_size(&data)?;
|
self.check_data_size(&data)?;
|
||||||
self.check_write(cx, group_id).await?;
|
self.check_write(cx, group_id).await?;
|
||||||
|
// §11.6 quota: a new doc must fit under the group's row ceiling.
|
||||||
|
let count = self.repo.count_rows(group_id).await?;
|
||||||
|
if count >= self.max_rows {
|
||||||
|
return Err(GroupDocsError::QuotaExceeded {
|
||||||
|
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||||
|
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||||
|
});
|
||||||
|
}
|
||||||
let created = self.repo.create(group_id, collection, data.clone()).await?;
|
let created = self.repo.create(group_id, collection, data.clone()).await?;
|
||||||
self.emit_shared(
|
self.emit_shared(
|
||||||
cx,
|
cx,
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ pub trait GroupFilesRepo: Send + Sync {
|
|||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<FilesListPage, GroupFilesRepoError>;
|
) -> Result<FilesListPage, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
/// §11.6 quota: total stored bytes across the group's shared-files
|
||||||
|
/// collections. Default `Ok(0)` so non-Postgres impls skip the check.
|
||||||
|
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||||
|
let _ = group_id;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
||||||
@@ -369,6 +376,16 @@ impl GroupFilesRepo for FsGroupFilesRepo {
|
|||||||
};
|
};
|
||||||
Ok(FilesListPage { files, next_cursor })
|
Ok(FilesListPage { files, next_cursor })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||||
|
let (n,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM group_files WHERE group_id = $1",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(u64::try_from(n).unwrap_or(0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ pub struct GroupFilesServiceImpl {
|
|||||||
resolver: Arc<dyn GroupCollectionResolver>,
|
resolver: Arc<dyn GroupCollectionResolver>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
max_file_size_bytes: usize,
|
max_file_size_bytes: usize,
|
||||||
|
/// §11.6 per-group quota: max total stored bytes across the group's
|
||||||
|
/// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
|
||||||
|
max_total_bytes: u64,
|
||||||
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
|
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,7 @@ impl GroupFilesServiceImpl {
|
|||||||
resolver,
|
resolver,
|
||||||
authz,
|
authz,
|
||||||
max_file_size_bytes,
|
max_file_size_bytes,
|
||||||
|
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
|
||||||
events: Arc::new(NoopEventEmitter),
|
events: Arc::new(NoopEventEmitter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +164,15 @@ impl GroupFilesService for GroupFilesServiceImpl {
|
|||||||
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
||||||
new.content_type = sanitize_stored_content_type(&new.content_type);
|
new.content_type = sanitize_stored_content_type(&new.content_type);
|
||||||
self.check_write(cx, group_id).await?;
|
self.check_write(cx, group_id).await?;
|
||||||
|
// §11.6 quota: the new file's bytes must fit under the group's total.
|
||||||
|
let used = self.repo.total_bytes(group_id).await?;
|
||||||
|
let incoming = new.data.len() as u64;
|
||||||
|
if used.saturating_add(incoming) > self.max_total_bytes {
|
||||||
|
return Err(GroupFilesError::QuotaExceeded {
|
||||||
|
limit: self.max_total_bytes,
|
||||||
|
actual: used.saturating_add(incoming),
|
||||||
|
});
|
||||||
|
}
|
||||||
let meta = self.repo.create(group_id, collection, new).await?;
|
let meta = self.repo.create(group_id, collection, new).await?;
|
||||||
self.emit_shared(cx, group_id, "create", collection, &meta, None)
|
self.emit_shared(cx, group_id, "create", collection, &meta, None)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ pub trait GroupKvRepo: Send + Sync {
|
|||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<KvListPage, GroupKvRepoError>;
|
) -> Result<KvListPage, GroupKvRepoError>;
|
||||||
|
|
||||||
|
/// §11.6 quota: total key count across all of the group's shared-KV
|
||||||
|
/// collections. Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||||
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||||
|
let _ = group_id;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresGroupKvRepo {
|
pub struct PostgresGroupKvRepo {
|
||||||
@@ -208,6 +215,15 @@ impl GroupKvRepo for PostgresGroupKvRepo {
|
|||||||
|
|
||||||
Ok(KvListPage { keys, next_cursor })
|
Ok(KvListPage { keys, next_cursor })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||||
|
let (n,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1")
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(u64::try_from(n).unwrap_or(0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_cursor(last_key: &str) -> String {
|
fn encode_cursor(last_key: &str) -> String {
|
||||||
|
|||||||
@@ -13,11 +13,10 @@
|
|||||||
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
||||||
//! (anonymous fails closed — shared mutation always needs an authenticated
|
//! (anonymous fails closed — shared mutation always needs an authenticated
|
||||||
//! editor+ on the owning group).
|
//! editor+ on the owning group).
|
||||||
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`).
|
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
|
||||||
//!
|
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
|
||||||
//! No event emission in the MVP: a write to a shared collection has no single
|
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
|
||||||
//! app to attribute a trigger to (the "group trigger has no app to watch"
|
//! triggers on the owning group, under the writer app (`emit_shared`).
|
||||||
//! problem). Documented as a deferral in docs §11.6.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -40,6 +39,10 @@ pub struct GroupKvServiceImpl {
|
|||||||
resolver: Arc<dyn GroupCollectionResolver>,
|
resolver: Arc<dyn GroupCollectionResolver>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
max_value_bytes: usize,
|
max_value_bytes: usize,
|
||||||
|
/// §11.6 per-group quota: max total keys across the group's shared-KV
|
||||||
|
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
|
||||||
|
/// NEW key.
|
||||||
|
max_rows: u64,
|
||||||
/// §11.6: fires `shared = true` triggers on a shared-collection write.
|
/// §11.6: fires `shared = true` triggers on a shared-collection write.
|
||||||
/// Defaults to the noop emitter; the host wires the outbox emitter via
|
/// Defaults to the noop emitter; the host wires the outbox emitter via
|
||||||
/// [`Self::with_events`].
|
/// [`Self::with_events`].
|
||||||
@@ -64,6 +67,14 @@ impl GroupKvServiceImpl {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
|
||||||
|
/// the env-var default.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
|
||||||
|
self.max_rows = max_rows;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_max_value_bytes(
|
pub fn with_max_value_bytes(
|
||||||
repo: Arc<dyn GroupKvRepo>,
|
repo: Arc<dyn GroupKvRepo>,
|
||||||
@@ -73,6 +84,7 @@ impl GroupKvServiceImpl {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: Arc::new(NoopEventEmitter),
|
events: Arc::new(NoopEventEmitter),
|
||||||
|
max_rows: crate::group_quota::group_kv_max_rows_from_env(),
|
||||||
repo,
|
repo,
|
||||||
resolver,
|
resolver,
|
||||||
authz,
|
authz,
|
||||||
@@ -196,6 +208,17 @@ impl GroupKvService for GroupKvServiceImpl {
|
|||||||
// per-app path — the has+set is non-transactional but triggers are
|
// per-app path — the has+set is non-transactional but triggers are
|
||||||
// fire-and-forget).
|
// fire-and-forget).
|
||||||
let existed = self.repo.has(group_id, collection, key).await?;
|
let existed = self.repo.has(group_id, collection, key).await?;
|
||||||
|
// §11.6 quota: a NEW key must fit under the group's row ceiling; an
|
||||||
|
// update of an existing key is net-zero and exempt.
|
||||||
|
if !existed {
|
||||||
|
let count = self.repo.count_rows(group_id).await?;
|
||||||
|
if count >= self.max_rows {
|
||||||
|
return Err(GroupKvError::QuotaExceeded {
|
||||||
|
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||||
|
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
self.repo
|
self.repo
|
||||||
.set(group_id, collection, key, value.clone())
|
.set(group_id, collection, key, value.clone())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -342,6 +365,11 @@ mod tests {
|
|||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||||
|
let data = self.data.lock().await;
|
||||||
|
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
||||||
@@ -417,6 +445,49 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
|
||||||
|
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
|
||||||
|
// cap is rejected, but updating an existing key (net-zero) still works.
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
let mut resolver = FakeResolver::default();
|
||||||
|
resolver.map.insert((app, "catalog".into()), group);
|
||||||
|
let kv = GroupKvServiceImpl::new(
|
||||||
|
Arc::new(InMemoryGroupKvRepo::default()),
|
||||||
|
Arc::new(resolver),
|
||||||
|
Arc::new(DenyingAuthzRepo),
|
||||||
|
)
|
||||||
|
.with_max_rows(2);
|
||||||
|
let cx = cx_with(app, Some(owner()));
|
||||||
|
|
||||||
|
kv.set(&cx, "catalog", "a", serde_json::json!(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
kv.set(&cx, "catalog", "b", serde_json::json!(2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// A third distinct key exceeds the cap.
|
||||||
|
let err = kv
|
||||||
|
.set(&cx, "catalog", "c", serde_json::json!(3))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
GroupKvError::QuotaExceeded {
|
||||||
|
limit: 2,
|
||||||
|
actual: 2
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"got {err:?}"
|
||||||
|
);
|
||||||
|
// Updating an existing key is exempt (net-zero).
|
||||||
|
kv.set(&cx, "catalog", "a", serde_json::json!(11))
|
||||||
|
.await
|
||||||
|
.expect("update of an existing key must be allowed at the cap");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unrelated_app_gets_collection_not_shared() {
|
async fn unrelated_app_gets_collection_not_shared() {
|
||||||
// app_a's chain declares "catalog"; app_b's does not.
|
// app_a's chain declares "catalog"; app_b's does not.
|
||||||
|
|||||||
44
crates/manager-core/src/group_quota.rs
Normal file
44
crates/manager-core/src/group_quota.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//! §11.6 per-group quotas — global env-var ceilings on a group's shared
|
||||||
|
//! collections, enforced in the group write path (mirrors the per-value
|
||||||
|
//! `PICLOUD_*_MAX_*_BYTES` caps). One default applies to every group; per-group
|
||||||
|
//! configurable limits are deferred.
|
||||||
|
//!
|
||||||
|
//! - KV / docs: a per-group ROW-count ceiling (across the group's collections of
|
||||||
|
//! that kind). An update of an existing key/doc is net-zero and exempt.
|
||||||
|
//! - files: a per-group TOTAL-BYTES ceiling (sum of stored blob sizes).
|
||||||
|
|
||||||
|
/// Default per-group shared-KV row ceiling. Override `PICLOUD_GROUP_KV_MAX_ROWS`.
|
||||||
|
pub const DEFAULT_GROUP_KV_MAX_ROWS: u64 = 100_000;
|
||||||
|
/// Default per-group shared-docs row ceiling. Override `PICLOUD_GROUP_DOCS_MAX_ROWS`.
|
||||||
|
pub const DEFAULT_GROUP_DOCS_MAX_ROWS: u64 = 100_000;
|
||||||
|
/// Default per-group shared-files total-bytes ceiling (10 GiB). Override
|
||||||
|
/// `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`.
|
||||||
|
pub const DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn u64_from_env(var: &str, default: u64) -> u64 {
|
||||||
|
if let Ok(v) = std::env::var(var) {
|
||||||
|
match v.trim().parse::<u64>() {
|
||||||
|
Ok(n) if n > 0 => return n,
|
||||||
|
_ => tracing::warn!(value = %v, "ignoring invalid {var} (want a positive integer)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn group_kv_max_rows_from_env() -> u64 {
|
||||||
|
u64_from_env("PICLOUD_GROUP_KV_MAX_ROWS", DEFAULT_GROUP_KV_MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn group_docs_max_rows_from_env() -> u64 {
|
||||||
|
u64_from_env("PICLOUD_GROUP_DOCS_MAX_ROWS", DEFAULT_GROUP_DOCS_MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn group_files_max_total_bytes_from_env() -> u64 {
|
||||||
|
u64_from_env(
|
||||||
|
"PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES",
|
||||||
|
DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ pub mod group_files_service;
|
|||||||
pub mod group_kv_repo;
|
pub mod group_kv_repo;
|
||||||
pub mod group_kv_service;
|
pub mod group_kv_service;
|
||||||
pub mod group_members_repo;
|
pub mod group_members_repo;
|
||||||
|
pub mod group_quota;
|
||||||
pub mod group_repo;
|
pub mod group_repo;
|
||||||
pub mod group_scripts_api;
|
pub mod group_scripts_api;
|
||||||
pub mod groups_api;
|
pub mod groups_api;
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ pub enum GroupDocsError {
|
|||||||
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
|
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
|
||||||
ValueTooLarge { limit: usize, actual: usize },
|
ValueTooLarge { limit: usize, actual: usize },
|
||||||
|
|
||||||
|
/// The owning group's shared-docs store is at its row quota
|
||||||
|
/// (`PICLOUD_GROUP_DOCS_MAX_ROWS`); a new doc is rejected.
|
||||||
|
#[error("group docs: quota exceeded ({actual} rows; limit {limit})")]
|
||||||
|
QuotaExceeded { limit: usize, actual: usize },
|
||||||
|
|
||||||
#[error("group docs backend error: {0}")]
|
#[error("group docs backend error: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ pub enum GroupFilesError {
|
|||||||
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
|
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
|
||||||
TooLarge { size: usize, limit: usize },
|
TooLarge { size: usize, limit: usize },
|
||||||
|
|
||||||
|
/// The owning group's shared-files store is at its total-bytes quota
|
||||||
|
/// (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`); the write is rejected.
|
||||||
|
#[error("group files: quota exceeded ({actual} bytes; limit {limit})")]
|
||||||
|
QuotaExceeded { limit: u64, actual: u64 },
|
||||||
|
|
||||||
#[error("file name too long: {0} bytes exceeds 255")]
|
#[error("file name too long: {0} bytes exceeds 255")]
|
||||||
NameTooLong(usize),
|
NameTooLong(usize),
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ pub enum GroupKvError {
|
|||||||
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
|
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
|
||||||
ValueTooLarge { limit: usize, actual: usize },
|
ValueTooLarge { limit: usize, actual: usize },
|
||||||
|
|
||||||
|
/// The owning group's shared-KV store is at its row quota
|
||||||
|
/// (`PICLOUD_GROUP_KV_MAX_ROWS`); a new key is rejected (an update of an
|
||||||
|
/// existing key is exempt).
|
||||||
|
#[error("group kv: quota exceeded ({actual} rows; limit {limit})")]
|
||||||
|
QuotaExceeded { limit: usize, actual: usize },
|
||||||
|
|
||||||
/// Anything else — Postgres unavailable, serialization failure, etc.
|
/// Anything else — Postgres unavailable, serialization failure, etc.
|
||||||
#[error("group kv backend error: {0}")]
|
#[error("group kv backend error: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
|
|||||||
Reference in New Issue
Block a user