crates/gunnar-grpc/src/service/usage_batch.rs
//! `UsageBatch`. Many namespaces' meters in one call (2026-10-06).
//!
//! **Why.** monetize's poller asked `Usage.Read` once per tenant every 300 s;
//! the load test on the UpCloud mockup measured ~25–30 s of 1.4 cores on main
//! per period at 1,000 tenants, and a per-call cost that itself grew with the
//! tenant count. `ReadMany` answers a page of namespaces out of the server's
//! usage ledger (gunnar-server `usage_ledger`), which re-measures only what
//! changed, and with `changed_since` it answers only the namespaces whose row
//! moved — so a poll is O(pages) calls and a quiet estate costs almost nothing.
//!
//! **The grant is `Usage.ReadAll`'s, and so is the shield**: server-wide `Read`
//! (it spans tenants), and an account's NESTED namespaces are kept from a grant
//! that does not reach inside the account.
use gunnar_proto::usage_batch_server::UsageBatch;
use gunnar_proto::{GrantLevel, ReadManyUsageRequest, ReadManyUsageResponse};
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::service::UsageBatchService;
/// The page size when the caller names none.
pub const DEFAULT_PAGE_SIZE: u32 = 500;
/// The most rows one page carries, whatever the caller asked for.
pub const MAX_PAGE_SIZE: u32 = 5_000;
/// The page size actually served for an asked one.
pub fn page_size(asked: u32) -> usize {
match asked {
0 => DEFAULT_PAGE_SIZE as usize,
n => n.min(MAX_PAGE_SIZE) as usize,
}
}
#[tonic::async_trait]
impl UsageBatch for UsageBatchService {
async fn read_many(
&self,
request: Request<ReadManyUsageRequest>,
) -> Result<Response<ReadManyUsageResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Read,
"UsageBatch.ReadMany",
)?;
let request = request.into_inner();
let mut page = self.ctx.control.usage_page(
&request.page_token,
page_size(request.page_size),
&request.changed_since,
)?;
// As `Usage.ReadAll`: an account's size is the operator's aggregate; a
// namespace NESTED in it names the account's structure.
page.usages = self.ctx.retain_unshielded(&caller, page.usages, |row| {
row.namespace.contains('/').then(|| row.namespace.clone())
})?;
Ok(Response::new(page))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_page_size_defaults_and_is_clamped() {
assert_eq!(page_size(0), 500);
assert_eq!(page_size(7), 7);
assert_eq!(page_size(1_000_000), 5_000);
}
}