Per-project storage usage snapshot scheduler v platform talkide-be (každých 30 min) iteruje aktivní projekty, volá storage-svc GET /internal/usage v každém tenant-env namespace, append-only zapisuje řádky do storage_usage_snapshot ledger tabulky (cluster A control-plane). Admin read endpoint GET /api/v1/admin/projects/{slug}/storage-usage vrací aktuální + historický usage. Snapshot ledger je primary vstup pro budoucí storage billing line item (UC-10019, mimo scope tohoto UC).
- Snapshot pattern parita s UC-10012 Environment Billing F2 (hosting cost events + accrual reconciler) a UC-08005 Reconciliation Log (drift detection terminologie a single source of truth flow).
- 30 min frequency (OD-9 z README) — sladěno s existing usage tracking v platformě (“snapshoty po 30m” — feedback od usera).
- Source of truth pro usage = storage-svc
ListObjectsV2paginated SUM (OD-10 z README) — NIKOLI Cloudflare R2 Dashboard API, které není real-time. Storage-svc agreguje aktuální stav R2 bucketu při každém volání. - Forward-only growth ledger:
storage_usage_snapshotje append-only (žádné UPDATE/DELETE), parita susage_eventsv UC-08001. - Best-effort scheduler: pokud storage-svc v některém namespace je nedostupné (deployment down, namespace ve fázi cut-overu), scheduler logguje WARN a pokračuje dalším projektem — žádné fail-fast (parita s UC-08005 reconciliation log).
- Billing integration: storage je “billable resource” stejně jako DO Spaces / NFS / Anthropic API. Tento UC NEimplementuje billing wiring — pouze definuje snapshot ledger, který bude vstup pro budoucí UC-10019 Storage Billing Line Item (follow-up issue v UC-10 Stripe Billing, založí PM po dokončení tohoto UC + UC-12001 v prod).
- Není v scope tohoto UC: end-user-facing usage view (uvidí jen admin). End-user usage je v billing FE (UC-10016 BillingSection.vue) — později rozšířeno o storage line.
Flow 1 — Snapshot scheduler (30 min cron)
sequenceDiagram
participant SCH as StorageUsageSnapshotScheduler<br/>(platform talkide-be)
participant DB as DB cluster A<br/>(control-plane)
participant SS as storage-svc<br/>(tenant-env ns)
participant CF as Cloudflare R2
SCH->>+SCH: @Scheduled(fixedDelay = 1_800_000) // 30 min
SCH->>+DB: SELECT asc.id, asc.project_id, asc.bucket_name, p.slug,<br/>t.slug AS tenant_slug, e.slug AS env_slug,<br/>asc.platform_internal_token_hash<br/>FROM app_storage_config asc<br/>JOIN projects p ON p.id = asc.project_id<br/>JOIN tenants t ON t.id = p.tenant_id<br/>JOIN environments e ON e.id = p.environment_id<br/>WHERE p.status = 'ACTIVE'
DB->>-SCH: List{ProjectStorageHandle}
loop pro každý ProjectStorageHandle
SCH->>SCH: read PLATFORM_INTERNAL_TOKEN from K8s Secret<br/>"storage-creds" in ns "{tenantSlug}-{envSlug}"
Note over SCH: read přes K8s Java client;<br/>NE z DB hash (DB má jen SHA-256 pro verify)
SCH->>+SS: GET http://storage-svc-{projectSlug}.{tenantSlug}-{envSlug}.svc.cluster.local/internal/usage<br/>Authorization: Bearer {PLATFORM_INTERNAL_TOKEN}
alt storage-svc dosažitelný
SS->>+CF: ListObjectsV2 (paginated) {Bucket, ContinuationToken}
CF->>-SS: { Contents: [...], NextContinuationToken, IsTruncated }
Note over SS: storage-svc iteruje všechny stránky,<br/>akumuluje bytesUsed += Σ Contents[].Size<br/>+ objectCount += Contents.length
SS->>-SCH: 200 OK { bucketName, bytesUsed, objectCount, computedAt }
else storage-svc nedostupné nebo 5xx
SS-->>SCH: error (HTTP 5xx, connect timeout)
SCH->>SCH: log WARN with projectId, bucketName, error<br/>continue to next project (best-effort)
end
SCH->>DB: INSERT INTO storage_usage_snapshot<br/>(project_id, bucket_name, snapshot_at, bytes_used,<br/>object_count, source='STORAGE_SVC_LISTOBJECTS')
SCH->>DB: UPDATE app_storage_config<br/>SET bytes_used = ?, updated_at = NOW()<br/>WHERE id = ?
Note over SCH,DB: app_storage_config.bytes_used = nejnovější<br/>snapshot (best-effort live counter pro<br/>UC-12002 quota check; authoritative<br/>je samotný ledger)
end
SCH->>-SCH: log INFO total projects scanned, failures count
Flow 2 — Admin čte storage usage report
sequenceDiagram
actor Admin
Admin->>+FE: otevře /admin/projects/{slug}/storage-usage
FE->>+BE: GET /api/v1/admin/projects/{slug}/storage-usage<br/>Authorization: Bearer {accessToken}
BE->>BE: silent probe — ověř roli ADMIN
alt uživatel není ADMIN nebo není autentizován
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>+StorageUsageReportUseCase: execute(slug)
StorageUsageReportUseCase->>DB: SELECT p.id, p.slug, asc.bucket_name, asc.quota_bytes,<br/>asc.bytes_used (live), asc.updated_at<br/>FROM projects p JOIN app_storage_config asc ON asc.project_id = p.id<br/>WHERE p.slug = ?
alt project nebo storage config neexistuje
StorageUsageReportUseCase-->>BE: throws NotFoundException
BE-->>FE: 404 Not Found <br> ErrorResponse
end
StorageUsageReportUseCase->>DB: SELECT snapshot_at, bytes_used, object_count<br/>FROM storage_usage_snapshot<br/>WHERE project_id = ?<br/>ORDER BY snapshot_at DESC<br/>LIMIT 168 -- posledních ~3.5 dne při 30min frequency
StorageUsageReportUseCase->>DB: SELECT MIN(snapshot_at), MAX(bytes_used), AVG(bytes_used)<br/>FROM storage_usage_snapshot<br/>WHERE project_id = ?<br/> AND snapshot_at >= date_trunc('month', NOW())
Note over StorageUsageReportUseCase: month-to-date statistiky pro<br/>budoucí billing line item integration
StorageUsageReportUseCase-->>-BE: StorageUsageReport
BE->>-FE: 200 OK <br> StorageUsageReportResponse
FE->>-Admin: zobraz current usage + sparkline z historie + month-to-date statistiky
DB Schema
Nový Liquibase changeset v talkide-be/src/main/resources/db/changelog/changes/ — produkční fáze, immutable, vždy nový soubor. Při psaní tohoto UC další volné slot pravděpodobně 0054 (po 0053-create-app-storage-config.xml z UC-12001). Před implementací ověř aktuální stav adresáře.
Tabulka storage_usage_snapshot (cluster A — control-plane, append-only ledger):
| Sloupec | Typ | Constraints | Poznámka |
|---|---|---|---|
id | BIGSERIAL | PK | Auto-generated |
project_id | BIGINT | NOT NULL, FK → projects(id) ON DELETE CASCADE | Cascade s projektem |
bucket_name | VARCHAR(63) | NOT NULL | Snapshot bucket name v okamžiku snapshotu (denormalizace pro audit — pattern parity s environment_name_snapshot v UC-10012) |
snapshot_at | TIMESTAMPTZ | NOT NULL | Okamžik snapshotu (storage-svc computedAt field) |
bytes_used | BIGINT | NOT NULL | Součet Contents[].Size ze ListObjectsV2 |
object_count | INTEGER | NOT NULL | Počet objektů v bucketu v okamžiku snapshotu |
source | VARCHAR(40) | NOT NULL, DEFAULT ‘STORAGE_SVC_LISTOBJECTS’ | Pro budoucí varianty (např. CLOUDFLARE_DASHBOARD_API pokud OD-10 přehodnotíme); aktuálně jediná hodnota |
created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | Wall-clock zápisu do DB (může se lišit od snapshot_at, např. při delayed pull) |
Indexy:
PRIMARY KEY (id)INDEX idx_storage_usage_snapshot_project_at ON storage_usage_snapshot(project_id, snapshot_at DESC)— primary read path (history pro projekt)INDEX idx_storage_usage_snapshot_snapshot_at ON storage_usage_snapshot(snapshot_at DESC)— pro budoucí cross-project reporting (UC-10019)
Liquibase XML skeleton:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.0.xsd">
<changeSet id="0054-create-storage-usage-snapshot" author="talkide">
<createTable tableName="storage_usage_snapshot">
<column name="id" type="BIGSERIAL"><constraints primaryKey="true" nullable="false"/></column>
<column name="project_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bucket_name" type="VARCHAR(63)"><constraints nullable="false"/></column>
<column name="snapshot_at" type="TIMESTAMP WITH TIME ZONE"><constraints nullable="false"/></column>
<column name="bytes_used" type="BIGINT"><constraints nullable="false"/></column>
<column name="object_count" type="INTEGER"><constraints nullable="false"/></column>
<column name="source" type="VARCHAR(40)" defaultValue="STORAGE_SVC_LISTOBJECTS"><constraints nullable="false"/></column>
<column name="created_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()"><constraints nullable="false"/></column>
</createTable>
<addForeignKeyConstraint baseTableName="storage_usage_snapshot" baseColumnNames="project_id"
constraintName="fk_storage_usage_snapshot_project"
referencedTableName="projects" referencedColumnNames="id"
onDelete="CASCADE"/>
<createIndex indexName="idx_storage_usage_snapshot_project_at" tableName="storage_usage_snapshot">
<column name="project_id"/>
<column name="snapshot_at" descending="true"/>
</createIndex>
<createIndex indexName="idx_storage_usage_snapshot_snapshot_at" tableName="storage_usage_snapshot">
<column name="snapshot_at" descending="true"/>
</createIndex>
</changeSet>
</databaseChangeLog>
Retention policy: ledger forward-only roste cca 48 rows/projekt/den (= 30min frequency × 24h × 2). Pro 1000 aktivních projektů = ~48k řádků/den, ~17.5M/rok. Při průměrné velikosti řádku ~64B = ~1 GB/rok. Žádná retention politika v MVP — billing potřebuje historii. Pokud trable, archive table po 12 měsících (follow-up po UC-10019 deploy).
Storage-svc GET /internal/usage endpoint
Nový endpoint na storage-svc (per-namespace mikroservis, viz UC-12001). NIKDY volaný z user-app, jen z platform scheduler.
API contract
GET /internal/usage
Headers:
Authorization: Bearer <PLATFORM_INTERNAL_TOKEN>— required, validuje se constant-time compare proti env varPLATFORM_INTERNAL_TOKEN.
Response — 200 OK UsageReportResponse:
{
"data": {
"bucketName": "talkide-app-popelkam-todo-list",
"bytesUsed": 12345678,
"objectCount": 42,
"computedAt": "2026-05-24T10:00:00Z",
"pagesScanned": 1
}
}
pagesScanned = počet ListObjectsV2 stránek, které storage-svc musel projít — diagnostika pro velké buckety.
Error responses
401 Unauthorized (missing or invalid platform internal token) ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Invalid or missing platform internal token"
}
502 Bad Gateway (R2 ListObjectsV2 failure) ErrorResponse:
{
"status": 502,
"code": "STORAGE_UPSTREAM_ERROR",
"message": "Failed to list objects from R2"
}
Internal implementation
@RestController
@RequestMapping("/internal/usage")
class InternalUsageController(
private val s3: S3Client, // Cloudflare R2 S3-compatible
@Value("\${cf.r2.bucket}") private val bucket: String,
) {
@GetMapping
fun usage(): UsageReportResponse {
var totalBytes = 0L
var totalObjects = 0
var pagesScanned = 0
var continuationToken: String? = null
do {
val request = ListObjectsV2Request.builder()
.bucket(bucket)
.continuationToken(continuationToken)
.maxKeys(1000)
.build()
val response = s3.listObjectsV2(request)
response.contents().forEach { obj ->
totalBytes += obj.size()
totalObjects++
}
pagesScanned++
continuationToken = if (response.isTruncated) response.nextContinuationToken() else null
} while (continuationToken != null)
return UsageReportResponse(
UsageReport(
bucketName = bucket,
bytesUsed = totalBytes,
objectCount = totalObjects,
computedAt = Instant.now(),
pagesScanned = pagesScanned,
)
)
}
}
@Component
class PlatformInternalAuthFilter(
@Value("\${app.platform.internal-token}") private val expectedToken: String,
) : OncePerRequestFilter() {
override fun shouldNotFilter(req: HttpServletRequest): Boolean =
!req.requestURI.startsWith("/internal/")
override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
val header = req.getHeader("Authorization")?.removePrefix("Bearer ")?.trim()
if (header == null || !MessageDigest.isEqual(header.toByteArray(), expectedToken.toByteArray())) {
res.status = 401
res.contentType = "application/json"
res.writer.write("""{"status":401,"code":"AUTHENTICATION_FAILED","message":"Invalid or missing platform internal token"}""")
return
}
chain.doFilter(req, res)
}
}
Pozn.: PlatformInternalAuthFilter je oddělený od AppStorageTokenAuthFilter (UC-12002) — používá Authorization: Bearer (standard pattern pro service-to-service), zatímco user API používá X-Talkide-App-Token. Path-based routing v shouldNotFilter zajišťuje, že každý filter chrání jen svou rodinu endpointů.
Platform talkide-be scheduler
Bean a scheduler config
@Service
class StorageUsageSnapshotService(
private val appStorageConfigRepo: AppStorageConfigRepository,
private val storageUsageSnapshotRepo: StorageUsageSnapshotRepository,
private val k8sSecretReader: K8sSecretReader, // existing infra util
private val internalUsageClient: InternalUsageClient,
) {
@Transactional
fun captureSnapshotsForAllActiveProjects() {
val handles = appStorageConfigRepo.findActiveProjectsForSnapshot()
var failures = 0
handles.forEach { handle ->
try {
captureOne(handle)
} catch (ex: Exception) {
log.warn("Snapshot failed for project {} bucket {} — continuing", handle.projectId, handle.bucketName, ex)
failures++
}
}
log.info("Storage snapshot batch done: total={}, failures={}", handles.size, failures)
}
private fun captureOne(h: ProjectStorageHandle) {
val token = k8sSecretReader.readKey(
namespace = "${h.tenantSlug}-${h.envSlug}",
secretName = "storage-creds",
key = "PLATFORM_INTERNAL_TOKEN",
)
val usage = internalUsageClient.fetchUsage(
url = "http://storage-svc-${h.projectSlug}.${h.tenantSlug}-${h.envSlug}.svc.cluster.local/internal/usage",
token = token,
)
storageUsageSnapshotRepo.insert(
StorageUsageSnapshot(
projectId = h.projectId,
bucketName = h.bucketName,
snapshotAt = usage.computedAt,
bytesUsed = usage.bytesUsed,
objectCount = usage.objectCount,
source = "STORAGE_SVC_LISTOBJECTS",
)
)
appStorageConfigRepo.updateLiveBytesUsed(h.appStorageConfigId, usage.bytesUsed)
}
}
@Component
class StorageUsageSnapshotScheduler(
private val service: StorageUsageSnapshotService,
) {
// 30 min frequency (OD-9) — konfigurovatelné v application-production.yaml přes
// talkide.storage.snapshot.interval-ms (Spring @Scheduled neumí dynamic — value se
// bind-uje při startup, pro change vyžaduje restart). Vystačí MVP, pro hot-reload
// přejít na Quartz později.
@Scheduled(fixedDelayString = "\${talkide.storage.snapshot.interval-ms:1800000}")
fun run() {
// ŠPATNĚ (ZAKÁZÁNO): volat @Scheduled metodu z téže třídy — Spring AOP ji neodchytí.
// Proto je @Transactional na service.captureSnapshotsForAllActiveProjects(), ne tady.
service.captureSnapshotsForAllActiveProjects()
}
}
Pattern parita s HostingAccrualReconcilerBatch v UC-10012 (@Scheduled v jedné třídě → delegace na @Transactional service v jiné).
Required K8s labels (pro NetworkPolicy v UC-12001)
Platform talkide-be Deployment musí mít na podu (v talkide ns) label app.kubernetes.io/component: platform-scheduler. Jinak NetworkPolicy storage-svc odmítne příchozí traffic z platform podu (a snapshot scheduler bude získávat connection refused — fail-open path, viz “Failure handling” níže).
Failure handling
| Scénář | Chování |
|---|---|
| Storage-svc Deployment v ns je down (0 podů ready) | DNS resolve uspěje, TCP connect fail → IOException → log WARN, skip projekt, continue |
| Storage-svc vrátí 5xx (R2 ListObjects fail) | log WARN, skip projekt |
| Storage-svc vrátí 401 (token drift po manuálním zásahu) | log ERROR (token rotation needed — manuální runbook), skip projekt |
K8s Secret storage-creds neexistuje v ns | log ERROR (provisioning broken!), skip projekt, alert PagerDuty |
Single project bucket je velký a ListObjectsV2 trvá > 60s | RestClient timeout 90s; pokud expirne, log WARN, skip projekt — snapshot dorovná next 30min iterace |
DB INSERT do storage_usage_snapshot selže (např. cluster A spadl) | exception propaguje, scheduler skončí, log ERROR — appearance v Spring Actuator metrics; next 30min iterace zkusí znovu |
Admin read endpoint
GET /api/v1/admin/projects/{slug}/storage-usage
Headers:
Authorization: Bearer <accessToken>— ADMIN role required (silent probe — non-admin dostane 401, ne 403; parita s UC-08005).
Query params:
| Param | Type | Required | Default | Note |
|---|---|---|---|---|
historyLimit | int | no | 168 | Max počet snapshot řádků v history[] (168 = ~3.5 dne při 30min frequency). Max 1440 (~30 dní). |
Response — 200 OK StorageUsageReportResponse:
{
"data": {
"project": {
"slug": "todo-list",
"bucketName": "talkide-app-popelkam-todo-list",
"quotaBytes": null,
"liveBytesUsed": 12345678,
"liveUpdatedAt": "2026-05-24T10:00:00Z"
},
"current": {
"snapshotAt": "2026-05-24T10:00:00Z",
"bytesUsed": 12345678,
"objectCount": 42
},
"monthToDate": {
"monthStart": "2026-05-01T00:00:00Z",
"snapshotCount": 1152,
"minBytesUsed": 8000000,
"maxBytesUsed": 13000000,
"avgBytesUsed": 10500000
},
"history": [
{ "snapshotAt": "2026-05-24T10:00:00Z", "bytesUsed": 12345678, "objectCount": 42 },
{ "snapshotAt": "2026-05-24T09:30:00Z", "bytesUsed": 12300000, "objectCount": 41 },
{ "snapshotAt": "2026-05-24T09:00:00Z", "bytesUsed": 12000000, "objectCount": 40 }
]
}
}
current může být null, pokud pro projekt zatím neexistuje žádný snapshot (nový projekt, scheduler ještě neproběhl). FE pak místo current zobrazí “no snapshots yet — first snapshot expected within 30 min”.
quotaBytes: null znamená unlimited (OD-1).
Error responses
401 Unauthorized — silent probe (non-admin nebo neautentizovaný):
{ "status": 401, "code": "AUTHENTICATION_FAILED", "message": "Authentication required" }
404 Not Found (project slug neexistuje, nebo nemá storage config):
{ "status": 404, "code": "NOT_FOUND_PROJECT", "message": "Project not found or has no storage config" }
Frontend (admin only)
Tento UC nemá end-user FE. Admin FE v /admin/projects/{slug}/storage-usage:
| Komponenta | Účel |
|---|---|
StorageUsageHeader.vue | Bucket name + quota (formatted: unlimited pro null, 10 GB jinak) + last snapshot timestamp |
StorageUsageCurrent.vue | Velký highlight “current usage” — bytes (KB/MB/GB), object count |
StorageUsageSparkline.vue | Mini-chart history[] — bytes_used přes čas (X = snapshotAt, Y = bytesUsed). Použij chart.js nebo apexcharts (existing FE dependency). |
StorageUsageMTDStats.vue | Month-to-date stats panel: min/max/avg + snapshot count |
Žádné FE form, žádné mutations — read-only view.
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| slug (path) | not_blank, valid project slug | 1 – 63 chars | RFC 1123 | Path variable; non-admin → 401 silent probe |
| historyLimit (query) | optional, positive | 1 – 1440 | — | Default 168 |
| Authorization (header) | not_blank, valid JWT, role ADMIN | — | — | Silent probe: 401 pro non-admin (NE 403) |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| 3 aktivní projekty s storage configem, všechny storage-svc dostupné | scheduler run() je volán | 3 INSERTy do storage_usage_snapshot; 3 UPDATEs app_storage_config.bytes_used; log INFO total=3, failures=0 |
| 5 projektů, 1 storage-svc je down (connection refused) | scheduler run() je volán | 4 INSERTy + 4 UPDATEs (úspěšné projekty); pro down storage-svc log WARN; log INFO total=5, failures=1; scheduler neházel exception |
| storage-svc v některém ns vrátí 401 (token drift) | scheduler run() je volán pro ten projekt | log ERROR; skip; ostatní projekty pokračují |
K8s Secret storage-creds chybí v ns | scheduler run() je volán pro ten projekt | log ERROR (provisioning broken alert); skip; ostatní projekty pokračují |
| projekt status != ‘ACTIVE’ (ARCHIVED, DELETED) | scheduler run() | projekt vynechán v SELECTu, žádný snapshot |
validní HMAC token v Authorization header pro /internal/usage | GET /internal/usage je volán | 401 — endpoint vyžaduje PLATFORM_INTERNAL_TOKEN, ne APP_STORAGE_TOKEN (cross-token-misuse) |
validní PLATFORM_INTERNAL_TOKEN v X-Talkide-App-Token header | GET /internal/usage je volán | 401 — endpoint vyžaduje Authorization: Bearer, ne X-Talkide-App-Token |
validní PLATFORM_INTERNAL_TOKEN, bucket je prázdný | GET /internal/usage je volán | 200 OK; bytesUsed=0, objectCount=0, pagesScanned=1 |
| validní token, bucket s 2500 objekty | GET /internal/usage je volán | 200 OK; bytesUsed=Σ, objectCount=2500, pagesScanned=3 (max 1000/page) |
| R2 ListObjectsV2 vrátí 5xx | GET /internal/usage je volán | 502 STORAGE_UPSTREAM_ERROR |
| admin user, valid slug, snapshot history existuje | GET /admin/projects/{slug}/storage-usage je volán | 200 OK; obsahuje current, monthToDate, history[] |
| admin user, valid slug, žádný snapshot zatím | GET /admin/projects/{slug}/storage-usage je volán | 200 OK; current=null, monthToDate s snapshotCount=0, history=[] |
| non-admin user | GET /admin/projects/{slug}/storage-usage je volán | 401 AUTHENTICATION_FAILED (silent probe) |
| neautentizovaný request | GET /admin/projects/{slug}/storage-usage je volán | 401 AUTHENTICATION_FAILED |
| admin user, neznámý slug | GET /admin/projects/{slug}/storage-usage je volán | 404 NOT_FOUND_PROJECT |
admin user, slug existuje, ale projekt nemá app_storage_config | GET /admin/projects/{slug}/storage-usage je volán | 404 NOT_FOUND_PROJECT (Project not found or has no storage config) |
| historyLimit=200, k dispozici 50 snapshots | GET /admin/projects/{slug}/storage-usage?historyLimit=200 | 200 OK; history.length=50 (nemáme víc) |
| historyLimit=1441 | GET /admin/projects/{slug}/storage-usage?historyLimit=1441 | 400 VALIDATION_ERROR (max 1440) |
| projekt smazán (UC-03007) | po cascade delete | všechny řádky v storage_usage_snapshot jsou taky smazány (FK ON DELETE CASCADE) — history nezůstává jako orphan |
| scheduler běží 24h | celkový růst ledgeru | 48 řádků/projekt/den (30min × 24h × 2 = 48); ledger forward-only |
| dva paralelní scheduler runs (např. po missed iteration) | scheduler.run() volaný 2× | Spring @Scheduled(fixedDelay) zaručuje sériové běhy — druhý nestartuje, dokud první neskončí (no @Async) |
Billing integration (out of scope, plánuje se v UC-10019)
storage_usage_snapshot je primary vstup pro budoucí UC-10019 Storage Billing Line Item (založí PM jako follow-up issue v UC-10 Stripe Billing po dokončení tohoto UC + UC-12001 v prod). Plánovaný flow:
-
Měsíční reconciler (1. v měsíci, paralelně s
HostingInvoiceBatchz UC-10012):- Pro každý projekt s aktivním
app_storage_configspočítá time-weighted average bytesUsed přes uzavřený měsíc zstorage_usage_snapshot. - Vypočítá
charged_amount_usd = avgBytesUsedGB × storage_price_per_gb_month × (1 + markup_percent)(markup zpricing_markup_config). - INSERT do
hosting_invoice_linejako další line item per environment (paralelně s compute/hosting line).
- Pro každý projekt s aktivním
-
Storage je tedy postpaid stejně jako compute — žádný prepaid storage fund, žádné scale-to-zero při překročení (na rozdíl od compute v UC-10015, kde scale-to-zero existuje — storage je perzistentní data, nelze “scale to zero” beze ztráty).
-
Default free tier: TBD v UC-10019 (např. prvních 5 GB zdarma per projekt, snadno implementovatelné jako subtraction před charge calc).
Tento UC NEimplementuje žádnou z těchto věcí — pouze garantuje, že snapshot ledger bude dostupný a deterministicky čitelný pro UC-10019.
References
- UC-12001 (Provision Storage) — definuje
app_storage_configtable +PLATFORM_INTERNAL_TOKENgenerování + K8s Secret + NetworkPolicy ingress pro/internal/*. - UC-12002 (Presign Upload) — quota check využívá
app_storage_config.bytes_usedlive counter, který tento UC updatuje po každém snapshotu. - UC-10012 (Environment Billing F2) — pattern parita pro
@Scheduledreconciler + accrual ledger + invoice line snapshot suffix. - UC-10016 (Usage Breakdown Per-Project) — budoucí FE integrace storage line do BillingSection.vue.
- UC-08005 (Reconciliation Log) — pattern parity pro best-effort scheduler (skip-on-failure) + silent probe ADMIN auth.
- OD-9 / OD-10 v README — frequency + source-of-truth rozhodnutí.
Out of scope (this UC)
- End-user FE view storage usage — později v UC-10016 (admin-only v tomto UC).
- Storage billing line item v měsíční Stripe faktuře — UC-10019 (follow-up).
- Alerty na rychlý růst usage (např. 10× over avg ve 24h) — follow-up po prvních real-world dat.
- Retention / archive policy pro
storage_usage_snapshot— follow-up po 12 měsících provozu. - Cross-tenant agregát reporting (např. total storage per tenant pro admin dashboard) — follow-up po UC-10019.
- Soft-delete / versioning awareness — pokud R2 versioning někdy zapneme, snapshot musí zohlednit non-current versions (mimo MVP).
- Replikace snapshot ledgeru do data-plane DB cluster B — control-plane je dostatečné pro billing.
Thanks for the feedback.