Internal Documentation internal
TalkIDE internal documentation

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 ListObjectsV2 paginated 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_snapshot je append-only (žádné UPDATE/DELETE), parita s usage_events v 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):

SloupecTypConstraintsPoznámka
idBIGSERIALPKAuto-generated
project_idBIGINTNOT NULL, FK → projects(id) ON DELETE CASCADECascade s projektem
bucket_nameVARCHAR(63)NOT NULLSnapshot bucket name v okamžiku snapshotu (denormalizace pro audit — pattern parity s environment_name_snapshot v UC-10012)
snapshot_atTIMESTAMPTZNOT NULLOkamžik snapshotu (storage-svc computedAt field)
bytes_usedBIGINTNOT NULLSoučet Contents[].Size ze ListObjectsV2
object_countINTEGERNOT NULLPočet objektů v bucketu v okamžiku snapshotu
sourceVARCHAR(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_atTIMESTAMPTZNOT 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 var PLATFORM_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 nslog ERROR (provisioning broken!), skip projekt, alert PagerDuty
Single project bucket je velký a ListObjectsV2 trvá > 60sRestClient 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:

ParamTypeRequiredDefaultNote
historyLimitintno168Max 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.vueBucket name + quota (formatted: unlimited pro null, 10 GB jinak) + last snapshot timestamp
StorageUsageCurrent.vueVelký highlight “current usage” — bytes (KB/MB/GB), object count
StorageUsageSparkline.vueMini-chart history[] — bytes_used přes čas (X = snapshotAt, Y = bytesUsed). Použij chart.js nebo apexcharts (existing FE dependency).
StorageUsageMTDStats.vueMonth-to-date stats panel: min/max/avg + snapshot count

Žádné FE form, žádné mutations — read-only view.


Backend

Validations

FieldConstraintsSizePatternNote
slug (path)not_blank, valid project slug1 – 63 charsRFC 1123Path variable; non-admin → 401 silent probe
historyLimit (query)optional, positive1 – 1440Default 168
Authorization (header)not_blank, valid JWT, role ADMINSilent probe: 401 pro non-admin (NE 403)

Test Cases

GIVENWHENTHEN
3 aktivní projekty s storage configem, všechny storage-svc dostupnéscheduler run() je volán3 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án4 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 projektlog ERROR; skip; ostatní projekty pokračují
K8s Secret storage-creds chybí v nsscheduler run() je volán pro ten projektlog 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/usageGET /internal/usage je volán401 — endpoint vyžaduje PLATFORM_INTERNAL_TOKEN, ne APP_STORAGE_TOKEN (cross-token-misuse)
validní PLATFORM_INTERNAL_TOKEN v X-Talkide-App-Token headerGET /internal/usage je volán401 — endpoint vyžaduje Authorization: Bearer, ne X-Talkide-App-Token
validní PLATFORM_INTERNAL_TOKEN, bucket je prázdnýGET /internal/usage je volán200 OK; bytesUsed=0, objectCount=0, pagesScanned=1
validní token, bucket s 2500 objektyGET /internal/usage je volán200 OK; bytesUsed=Σ, objectCount=2500, pagesScanned=3 (max 1000/page)
R2 ListObjectsV2 vrátí 5xxGET /internal/usage je volán502 STORAGE_UPSTREAM_ERROR
admin user, valid slug, snapshot history existujeGET /admin/projects/{slug}/storage-usage je volán200 OK; obsahuje current, monthToDate, history[]
admin user, valid slug, žádný snapshot zatímGET /admin/projects/{slug}/storage-usage je volán200 OK; current=null, monthToDate s snapshotCount=0, history=[]
non-admin userGET /admin/projects/{slug}/storage-usage je volán401 AUTHENTICATION_FAILED (silent probe)
neautentizovaný requestGET /admin/projects/{slug}/storage-usage je volán401 AUTHENTICATION_FAILED
admin user, neznámý slugGET /admin/projects/{slug}/storage-usage je volán404 NOT_FOUND_PROJECT
admin user, slug existuje, ale projekt nemá app_storage_configGET /admin/projects/{slug}/storage-usage je volán404 NOT_FOUND_PROJECT (Project not found or has no storage config)
historyLimit=200, k dispozici 50 snapshotsGET /admin/projects/{slug}/storage-usage?historyLimit=200200 OK; history.length=50 (nemáme víc)
historyLimit=1441GET /admin/projects/{slug}/storage-usage?historyLimit=1441400 VALIDATION_ERROR (max 1440)
projekt smazán (UC-03007)po cascade deletevšechny řádky v storage_usage_snapshot jsou taky smazány (FK ON DELETE CASCADE) — history nezůstává jako orphan
scheduler běží 24hcelkový růst ledgeru48 řá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:

  1. Měsíční reconciler (1. v měsíci, paralelně s HostingInvoiceBatch z UC-10012):

    • Pro každý projekt s aktivním app_storage_config spočítá time-weighted average bytesUsed přes uzavřený měsíc z storage_usage_snapshot.
    • Vypočítá charged_amount_usd = avgBytesUsedGB × storage_price_per_gb_month × (1 + markup_percent) (markup z pricing_markup_config).
    • INSERT do hosting_invoice_line jako další line item per environment (paralelně s compute/hosting line).
  2. 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).

  3. 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_config table + PLATFORM_INTERNAL_TOKEN generování + K8s Secret + NetworkPolicy ingress pro /internal/*.
  • UC-12002 (Presign Upload) — quota check využívá app_storage_config.bytes_used live counter, který tento UC updatuje po každém snapshotu.
  • UC-10012 (Environment Billing F2) — pattern parita pro @Scheduled reconciler + 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.

Was this page helpful?

Thanks for the feedback.