ADR-073: Storage footprint accounting at the BlobStorage socle
Date: 2026-07-01 Authors: Jean-Francois Meyers Scope:
Granit.BlobStorage.*, newGranit.BlobStorage.Featuresbridge,Granit.Features(granit-dotnet); downstreamGranit.Documents.*and an optional DB-volume producer (granit-business) Status: Proposed
Context
Section titled “Context”A SaaS operator needs two things the framework does not yet provide as a socle-level capability:
- A total tenant footprint — bytes stored per tenant, across all blobs (documents, avatars, exports, imports, any module) plus database volume — including the fact that tenant-owned rows also live in host tables (ADR-063).
- A per-user footprint — files only (blob bytes) attributed to the user who owns them.
- Hard limits driven by
Granit.Features— the feature carries the numeric limit (cascade Tenant → Plan → Default); an upload that would exceed the tenant or the user limit is rejected.
Today the only real implementation is Granit.Documents.TenantStorageQuota (granit-business):
a per-tenant atomic counter (UsageBytes / LimitBytes, default 5 GB), hard-enforced via
ITenantQuotaService.TryReserveAsync, surfaced by GET /quota, reconciled weekly. It works
well — but it is business-layer, tenant-only, and scoped to blobs that pass through the
Documents module. It cannot answer the operator’s question when Documents is absent, when
blobs are uploaded by other modules, or at per-user granularity.
The relevant facts about the socle:
Granit.BlobStorage(framework) is where every blob passes.BlobDescriptoris aCreationAuditedAggregateRootcarryingTenantId,ContainerName,SizeBytes(validated),MaxAllowedBytes(declared up front), andCreatedBy(the uploading user). So both tenant and user attribution are already present in the data.- BlobStorage has no aggregation today, and deliberately does not reference
Granit.Features. Granit.Features.IFeatureLimitGuardalready resolves numeric limits with a Tenant → Plan → Default cascade and throws a 403 on breach.Granit.Metering(business) is a generic metering engine, but its quota model is monitoring + alert on hourly aggregates — unfit for a real-time hard cap.
Decision
Section titled “Decision”Move storage accounting down to the socle and keep it limit-agnostic; drive blocking from Features through an opt-in bridge; keep DB volume as an approximate monitoring signal.
-
Account at the socle (
Granit.BlobStorage). Introduce two counters, ported from the provenGranit.Documentspattern (atomic counter + reconciliation job), one layer lower:TenantStorageUsagekeyed byTenantId.UserStorageUsagekeyed by(TenantId, CreatedBy)— the per-user “files only” footprint.
IStorageUsageServiceexposesTryConsumeAsync(scope, bytes, limit)(atomicUPDATE ... WHERE UsageBytes + @bytes <= @limit),IncrementAsync/DecrementAsync, and readers. The lifecycle mirrors Documents: reserveMaxAllowedBytesatInitiateUploadAsync, reconcile to the actualSizeBytesatConfirmUploadAsync, release on rejection/deletion/orphan-cleanup. A recurringstorage-usage-reconcilejob recomputes the truth fromSUM(SizeBytes) GROUP BY TenantIdandGROUP BY (TenantId, CreatedBy). -
The counter is limit-agnostic.
TryConsumeAsyncreceives thelimitas a parameter (long.MaxValue= unlimited).Granit.BlobStoragetherefore keeps no dependency onGranit.Features, while still enforcing atomically. The decision of which limit applies lives outside the socle. -
Enforcement via Features through an opt-in bridge. A new
Granit.BlobStorage.Featurespackage implements anIStorageQuotaEvaluatorseam (no-op default in BlobStorage). At upload initiation it reads the effective limits from two numeric features —Granit.Storage.MaxTenantBytesandGranit.Storage.MaxUserBytes— resolves the current user viaICurrentUser, and callsTryConsumeAsyncfor both the tenant and the user scope. Either breach throwsStorageQuotaExceededException→ HTTP 403. No bridge referenced ⇒ accounting still runs, no blocking. Features absent ⇒ unlimited. -
Hard blocking is files-only; DB volume is monitoring-only. Per-tenant DB volume is produced by a separate, optional producer (a recurring PostgreSQL-introspection job:
row_count × avg_row_sizefrompg_stats, orpg_column_sizeGROUP BY TenantIdoff-peak), attributingIMultiTenantrows to their tenant and bucketing host-shared rows (TenantId == null) as global. It is approximate and delayed, feeds the total-footprint view and optionallyGranit.Metering/threshold alerts, and never blocks a request — refusing a business write on an hourly DB estimate is not actionable. -
Documents becomes a consumer.
Granit.Documents.TenantStorageQuotais refactored to delegate to the socle counter (scoped to its containers) rather than maintain its own, eliminating the double-count. The existingGET /quotaresponse is preserved for the UI. Pre-1.0, this is a clean internal change, not an[Obsolete]graduation.
Evaluated Alternatives
Section titled “Evaluated Alternatives”- Generalize the Documents quota into a shared business module. Rejected: it cannot answer the operator when Documents is absent, and the socle-level “bytes on disk” concern belongs to the framework, not to a business module that sits above it.
- Build on
Granit.Metering. Rejected: metering aggregates are hourly and soft — unfit for a real-time hard cap — and it would pull a business dependency into a framework concern. Metering remains the right home for history and billing, fed as a gauge, not the enforcement path. - Reference
Granit.Featuresdirectly fromGranit.BlobStorage. Rejected: couples the socle to the limit source. The limit-as-parameter seam + opt-in bridge keepsTryConsumeAsyncatomic while leaving BlobStorage limit-agnostic. - Hard-block on total tenant footprint including DB volume. Rejected: DB attribution is coarse and delayed; a real-time upload guard can only be trustworthy against the precise blob counter.
Justification
Section titled “Justification”The socle is the single source of truth for bytes on disk, so accounting there captures every
blob for free and works with or without Documents — directly answering “what if Documents isn’t
in the solution?”. CreatedBy already gives per-user attribution with no schema change. The
reservation flow is not new design — it is the Granit.Documents pattern proven in production,
ported one layer down. Limit-as-parameter preserves strict layer purity (no Features edge on the
storage core) while the opt-in bridge makes blocking a deliberate host choice. Splitting precise,
blocking, files from approximate, monitoring, DB keeps each guarantee honest.
Consequences
Section titled “Consequences”- New counter entities + configs in
Granit.BlobStorage/.EntityFrameworkCore; the host owns the migration (framework ships no migrations). IStorageQuotaEvaluatorseam in BlobStorage; the upload-initiation flow gains an evaluator call and, in the bridge, anICurrentUserread (BlobStorage does not know the user today).- New opt-in package
Granit.BlobStorage.Features; two numeric feature definitions. Granit.Documents.TenantStorageQuotadelegates to the socle counter — a granit-business change (handoff), with theGET /quotacontract unchanged.- Optional DB-volume producer + optional
Granit.Meteringgauge push — both monitoring-only. - Blobs authored by background/system flows carry a system
CreatedBy; they are attributed to a reserved system/global user bucket, excluded from per-user limits.
Status
Section titled “Status”Proposed. Framework work tracked in granit-dotnet (Epic TBD); Documents delegation and the optional DB/Metering producers handed off to granit-business.
References
Section titled “References”- ADR-063 — Tenant/Host data storage modes (tenant rows in host tables;
BlobDescriptoris a tenant invariant) - ADR-061 —
IConcurrencyAwareas the concurrency primitive (atomic counter updates) - ADR-020 — Declarative definitions placement (usage query/export live in the base module)
Granit.Documents.TenantStorageQuota/ITenantQuotaService— the proven counter+reconciliation pattern being ported to the socle