fix(core): cron double-fire, materialize warnings, group quota fast path, SSE channel cap
Remediate the MEDIUM backend correctness/perf findings from the 2026-07-11 audit. B1 — a group cron template toggled disabled→enabled no longer re-fires every descendant's cron. Materialization now KEEPS a disabled cron template's copies (syncing `enabled` in place) instead of delete+recreate, preserving `last_fired_at`; the scheduler already skips disabled copies, so keeping them is inert. Queue/email arms still delete-on-disable (freeing the one-consumer slot). B2 — `rematerialize_stateful_templates` warnings are now logged at the app-create/delete and group-reparent chokepoints (were silently dropped); only the `Err` arm was handled before. B3 — the group KV/docs byte-quota check skips the O(rows) `SUM(octet_length(..))` scan when a cheap upper bound (`rows_after * max_value_bytes`) is already under the cap, so the full aggregate only runs near-cap. Every row is validated ≤ max_value_bytes, so the bound is sound (never lets an over-quota write through). B6 — the in-process SSE broadcaster caps live channels per map (`PICLOUD_REALTIME_MAX_CHANNELS`, default 100k); a subscribe that would open a NEW channel past the cap is refused with 503 + `Retry-After: 1` rather than growing the (app,topic)/(group,topic) maps unboundedly. Check+insert is atomic under the map mutex (no TOCTOU); an existing-channel subscribe is exempt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -222,31 +222,41 @@ impl GroupKvService for GroupKvServiceImpl {
|
||||
// are fire-and-forget and quotas are safety rails, not exact accounting).
|
||||
let existing = self.repo.get(group_id, collection, key).await?;
|
||||
let existed = existing.is_some();
|
||||
// One cheap COUNT backs BOTH the row quota AND the byte-quota fast path
|
||||
// below (an index-only count, far lighter than the per-value text SUM).
|
||||
let count = self.repo.count_rows(group_id).await?;
|
||||
// §11.6 row 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),
|
||||
});
|
||||
}
|
||||
if !existed && 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),
|
||||
});
|
||||
}
|
||||
// §11.6 M4 byte quota: the PROJECTED total (old value's bytes subtracted,
|
||||
// new value's added) must fit under the group's total-bytes ceiling, so a
|
||||
// same-or-smaller update near the cap is still allowed.
|
||||
let old_len = existing
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_vec(v).ok())
|
||||
.map_or(0u64, |b| b.len() as u64);
|
||||
let used = self.repo.total_bytes(group_id).await?;
|
||||
let projected = used.saturating_sub(old_len) + encoded_len as u64;
|
||||
if projected > self.max_total_bytes {
|
||||
return Err(GroupKvError::TotalBytesQuotaExceeded {
|
||||
limit: self.max_total_bytes,
|
||||
actual: projected,
|
||||
});
|
||||
//
|
||||
// Fast path: every stored value is capped at `max_value_bytes`, so the
|
||||
// whole collection can hold at most `rows_after * max_value_bytes`. When
|
||||
// that exact upper bound already fits under the ceiling there is no way to
|
||||
// exceed 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 existed { count } else { count + 1 };
|
||||
let upper_bound = rows_after.saturating_mul(self.max_value_bytes as u64);
|
||||
if upper_bound > self.max_total_bytes {
|
||||
let old_len = existing
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_vec(v).ok())
|
||||
.map_or(0u64, |b| b.len() as u64);
|
||||
let used = self.repo.total_bytes(group_id).await?;
|
||||
let projected = used.saturating_sub(old_len) + encoded_len as u64;
|
||||
if projected > self.max_total_bytes {
|
||||
return Err(GroupKvError::TotalBytesQuotaExceeded {
|
||||
limit: self.max_total_bytes,
|
||||
actual: projected,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.repo
|
||||
.set(group_id, collection, key, value.clone())
|
||||
|
||||
Reference in New Issue
Block a user