An app's OWN store had no ceiling of any kind — only the group-SHARED
collections did, which is the inversion of what you'd expect. And
`authz::script_gate` returns `Ok(())` when there is no principal, so a
PUBLIC UNAUTHENTICATED route could reach `files::collection(..).create(..)`
and write blobs in a loop until the disk filled. `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`
caps ONE file at 100 MB; nothing capped the count. Same for KV keys and docs
against Postgres.
Ceilings: `PICLOUD_APP_KV_MAX_ROWS` / `PICLOUD_APP_DOCS_MAX_ROWS` (100k) and
`PICLOUD_APP_FILES_MAX_TOTAL_BYTES` (10 GiB).
They are enforced DIFFERENTLY from the group ones, on purpose — porting the
group design as-is would have been a serious regression:
* KV/docs check a ROW count, on INSERT only, with NO lock. `kv::set` is the
hottest write path in the system; taking a per-app advisory lock on every
set would serialize an app's entire data plane, and a `SUM(...)` scan would
make write cost grow with the app's stored size. What the lock buys is
small — unlocked overshoot is bounded by write concurrency (32) against a
ceiling of 100k, ~0.03%. These are anti-DoS rails, not billing. An update
adds no row and is bounded by the per-value cap, so it pays nothing at all.
* No per-app BYTE ceiling for KV/docs: every value is already capped at
`PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows x max_value_bytes` is ALREADY a
finite bound. A second scan would buy nothing.
* FILES are the exception and DO lock + sum: one blob may be 100 MB, so 32
racing uploads could overshoot by gigabytes of real disk, and a file write
is heavy enough that the lock and the SUM are lost in the noise. Checked on
create AND update — an update that skipped the ceiling would be a free
bypass (the same bug just fixed for group files: grow a 1-byte file to
100 MB, repeat).
`group_quota` is renamed `quota`: it now serves both owners, and the module
doc is where the two enforcement strategies are contrasted.
Pinned by tests/atomic_write.rs: the key ceiling refuses a new key while
still allowing an update at the ceiling (rejecting updates would brick an app
the moment it filled up — worse than the DoS the rail exists to stop), and 10
concurrent uploads cannot exceed the disk ceiling, nor can an update grow past it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
8.8 KiB
Rust
213 lines
8.8 KiB
Rust
//! Storage ceilings — per-app and §11.6 per-group.
|
||
//!
|
||
//! # Why the two are enforced DIFFERENTLY
|
||
//!
|
||
//! The group ceilings serialize (a `pg_advisory_xact_lock` across the check and
|
||
//! the write) because they must be exact: a group's shared collections are the
|
||
//! cross-app blast radius, and with a small ceiling the check-then-write race
|
||
//! overshoots wildly (measured: 19 rows stored against a ceiling of 5). Group
|
||
//! writes are comparatively rare, so the lock costs little.
|
||
//!
|
||
//! The per-app KV/docs ceilings deliberately do **not** lock, and check only a
|
||
//! ROW count, on INSERT only:
|
||
//!
|
||
//! * `kv::set` is the hottest write path in the system. Taking a per-app lock
|
||
//! on every set would serialize an app's entire data plane; a `SUM(...)` scan
|
||
//! on every set would make write cost grow with the app's stored size. Both
|
||
//! are far worse trades than what the lock buys here.
|
||
//! * What it buys is small: without the lock, overshoot is bounded by write
|
||
//! concurrency (`PICLOUD_MAX_CONCURRENT_EXECUTIONS`, 32 by default) against a
|
||
//! ceiling of 100k — roughly 0.03%. These are anti-DoS rails, not billing.
|
||
//! * A byte ceiling is unnecessary: every value is already capped at
|
||
//! `PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows × max_value_bytes` is ALREADY a
|
||
//! finite bound on an app's stored bytes. Add a second scan for nothing.
|
||
//! * An update adds no row and cannot exceed the per-value cap, so it is exempt
|
||
//! and pays nothing at all — the COUNT runs only when a key/doc is created.
|
||
//!
|
||
//! Per-app FILES are the exception and DO lock: a single blob may be 100 MB, so
|
||
//! 32 racing uploads could overshoot by gigabytes of real disk, and a file write
|
||
//! is heavy enough that a lock and a `SUM(size_bytes)` are lost in the noise.
|
||
//!
|
||
//! §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 AND a per-group TOTAL-BYTES ceiling
|
||
//! (across the group's collections of that kind). Row count exempts an update
|
||
//! of an existing key/doc (net-zero rows); the byte ceiling is checked on the
|
||
//! projected total (old value's bytes subtracted, new value's added) so a
|
||
//! same-or-smaller update near the cap is still allowed.
|
||
//! - 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-KV total-bytes ceiling (256 MiB). Override
|
||
/// `PICLOUD_GROUP_KV_MAX_TOTAL_BYTES`.
|
||
pub const DEFAULT_GROUP_KV_MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
|
||
/// Default per-group shared-docs total-bytes ceiling (256 MiB). Override
|
||
/// `PICLOUD_GROUP_DOCS_MAX_TOTAL_BYTES`.
|
||
pub const DEFAULT_GROUP_DOCS_MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
|
||
/// 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;
|
||
|
||
/// Default per-app KV key ceiling. Override `PICLOUD_APP_KV_MAX_ROWS`.
|
||
pub const DEFAULT_APP_KV_MAX_ROWS: u64 = 100_000;
|
||
/// Default per-app doc ceiling. Override `PICLOUD_APP_DOCS_MAX_ROWS`.
|
||
pub const DEFAULT_APP_DOCS_MAX_ROWS: u64 = 100_000;
|
||
/// Default per-app stored-blob ceiling (10 GiB). Override
|
||
/// `PICLOUD_APP_FILES_MAX_TOTAL_BYTES`.
|
||
pub const DEFAULT_APP_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 app_kv_max_rows_from_env() -> u64 {
|
||
u64_from_env("PICLOUD_APP_KV_MAX_ROWS", DEFAULT_APP_KV_MAX_ROWS)
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn app_docs_max_rows_from_env() -> u64 {
|
||
u64_from_env("PICLOUD_APP_DOCS_MAX_ROWS", DEFAULT_APP_DOCS_MAX_ROWS)
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn app_files_max_total_bytes_from_env() -> u64 {
|
||
u64_from_env(
|
||
"PICLOUD_APP_FILES_MAX_TOTAL_BYTES",
|
||
DEFAULT_APP_FILES_MAX_TOTAL_BYTES,
|
||
)
|
||
}
|
||
|
||
#[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_kv_max_total_bytes_from_env() -> u64 {
|
||
u64_from_env(
|
||
"PICLOUD_GROUP_KV_MAX_TOTAL_BYTES",
|
||
DEFAULT_GROUP_KV_MAX_TOTAL_BYTES,
|
||
)
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn group_docs_max_total_bytes_from_env() -> u64 {
|
||
u64_from_env(
|
||
"PICLOUD_GROUP_DOCS_MAX_TOTAL_BYTES",
|
||
DEFAULT_GROUP_DOCS_MAX_TOTAL_BYTES,
|
||
)
|
||
}
|
||
|
||
#[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,
|
||
)
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// The row/byte decision, shared by every backend
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// The ceilings one shared-collection write must fit under.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct GroupWriteQuota {
|
||
pub max_rows: u64,
|
||
pub max_total_bytes: u64,
|
||
/// The per-value cap. Not a ceiling of its own here — it is what makes the
|
||
/// fast path below sound.
|
||
pub max_value_bytes: usize,
|
||
}
|
||
|
||
/// The two usage reads a quota decision needs. Implemented over the writing
|
||
/// transaction's connection (the real path — see `crate::atomic_write`) and over
|
||
/// the in-memory test store, so the POLICY below has one home regardless of
|
||
/// backend.
|
||
///
|
||
/// **The reads must come from the same transaction as the write they gate.**
|
||
/// Reading usage on one connection and writing on another is the check-then-write
|
||
/// race that lets N concurrent writers each observe the pre-write total, each
|
||
/// pass, and together blow the ceiling.
|
||
#[async_trait::async_trait]
|
||
pub trait GroupUsage: Send {
|
||
/// Current row count for the group, across its collections of this kind.
|
||
async fn count_rows(&mut self) -> Result<u64, String>;
|
||
|
||
/// Total stored bytes for the group AFTER this write — the current total,
|
||
/// minus the bytes of the row being replaced (if any), plus the new value's.
|
||
async fn projected_total_bytes(&mut self) -> Result<u64, String>;
|
||
}
|
||
|
||
/// What the ceilings say about a pending write.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum QuotaOutcome {
|
||
Allowed,
|
||
RowsExceeded { limit: u64, actual: u64 },
|
||
BytesExceeded { limit: u64, actual: u64 },
|
||
}
|
||
|
||
/// Decide whether a shared-collection write fits. `adds_row` = the write will
|
||
/// ADD a row rather than replace one (a new key, or a doc create) — a
|
||
/// compare-and-swap against an absent key with a `Some` precondition adds
|
||
/// nothing, because it cannot swap at all.
|
||
///
|
||
/// # Errors
|
||
/// Propagates a backend failure from either usage read, as a string.
|
||
pub async fn check_group_write(
|
||
q: GroupWriteQuota,
|
||
adds_row: bool,
|
||
usage: &mut dyn GroupUsage,
|
||
) -> Result<QuotaOutcome, String> {
|
||
// One cheap COUNT backs BOTH the row ceiling and the byte fast path below.
|
||
let count = usage.count_rows().await?;
|
||
|
||
// Row ceiling: only a write that adds a row consumes one. An update is
|
||
// net-zero and exempt.
|
||
if adds_row && count >= q.max_rows {
|
||
return Ok(QuotaOutcome::RowsExceeded {
|
||
limit: q.max_rows,
|
||
actual: count,
|
||
});
|
||
}
|
||
|
||
// Byte ceiling, on the PROJECTED total — so an update that is the same size
|
||
// or smaller still goes through when the group is sitting near its cap.
|
||
//
|
||
// Fast path: every stored value is capped at `max_value_bytes`, so the group
|
||
// can hold at most `rows_after * max_value_bytes`. When even that upper bound
|
||
// fits under the ceiling, no write can breach it — skip the
|
||
// O(collection-size) `SUM(octet_length(...))` scan entirely. The scan runs
|
||
// only for a group actually near its byte cap.
|
||
let rows_after = if adds_row { count + 1 } else { count };
|
||
let upper_bound = rows_after.saturating_mul(q.max_value_bytes as u64);
|
||
if upper_bound > q.max_total_bytes {
|
||
let projected = usage.projected_total_bytes().await?;
|
||
if projected > q.max_total_bytes {
|
||
return Ok(QuotaOutcome::BytesExceeded {
|
||
limit: q.max_total_bytes,
|
||
actual: projected,
|
||
});
|
||
}
|
||
}
|
||
Ok(QuotaOutcome::Allowed)
|
||
}
|