Repository navigation
feat(web): charge each Cap Infra video one month of storage when it becomes ready - #4145
Merged
Merged
Conversation
…gregate index to developer videos
…ed from daily charged figures The storage aggregate groups in index order and folds in the uploading count, so GET /usage reads one aggregate. Upload admission needs the first month of the video being admitted and of every admitted video not yet ready, plus 7 days for videos past their first month, read under the account lock. Credit refusals start with "Not enough credit ".
…irst becomes ready chargeDeveloperVideoFirstMonth takes the account lock, debits the month at the video's tier and length (part paid or unpaid like a short daily charge), writes one video_create ledger row and sets storagePaidUntil. The ready transition calls it after the row update; the storage cron charges any ready video it missed, deleted ones included, and emails a first month that fell short.
Amounts by tier and capped tier, one charge under racing calls, exactly 30 billed days excluded before daily charging resumes, deletes after and before ready, old and pre-encoding videos never charged, the cron backstop, part paid and unpaid months, and concurrent completions admitted under the account lock. The charge reads under the account lock before any plain read, so its balance check sees committed debits.
… rule and the two storage figures
…on backstop never stalls on them
… month is never charged again that day
…aid in the same second
|
Sandchest reviewed 1 issue found in This repository is public, so the details stay private: only people who can push to it can see them. Reviewed · 10 credits. |
…d only while the app is still short
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Member
Author
|
hey @greptileai, please re-review the PR |
Member
Author
|
hey @greptileai, please re-review the PR |
… of charging them
Member
Author
|
hey @greptileai, please re-review the PR |
Member
Author
|
hey @greptileai, please re-review the PR |
Member
Author
|
hey @greptileai, please re-review the PR |
Member
Author
|
hey @greptileai, please re-review the PR |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What a customer notices
From today, every Cap Infra video is charged a minimum of one month of storage when it first becomes ready to play, then daily after that month. Uploading and encoding stay free.
Deleting a video inside its first month gives none of the month back, and a video deleted while it is being processed is charged its first month too. That closes the upload, encode, download, delete loop that cost nothing before. Videos that were already ready before today keep the daily charge they have now. Completing an upload now needs credit for the first month of that video and of every other upload still encoding, so a 20 minute 4K upload needs $0.05600 and three in flight need $0.16800.
The rule
video_createledger row per video (label "Storage, first month", reference typedeveloper_video, reference the video id). A retry, a restart of an errored video or a second ready writes nothing.durationat that moment (the declared length until the probe overwrites it), capped at 4 hours, at itsmaxResolutionTierwhile its dimensions are unmeasured. The charge runs after the delete commits on every path (console video and app delete, REST, agent API) and never fails the delete.storagePaidUntilis set so nothing charges it again. A ready video with no marker at delete is pre-feature by definition and is marked exempt with no ledger row. App delete stamps one deletedAt for the whole operation and, after the commit, settles only the videos carrying that stamp that were still processing, never earlier deletes.owedMicroCredits,shortSincein the row's metadata). The balance never goes below zero (the column is unsigned). The video still becomes ready and keeps its marker. The account goes intograce: uploads and new transcripts are refused, playback continues for 7 days, then pauses. Once the available balance covers the amount owed (credit added or a transcript hold released) it returns toactive, and the next storage charge latches that shortfall as resolved (resolvedAton the shortfall's ledger row, written once under the account lock) so it never comes back. Later paid days alone do not clear a month that is still uncovered, and a new shortfall starts its own grace.Pieces and proof
Schema, migration 0059 (
0059_developer_video_first_month), after main's 0058 (#4139). Its snapshot'sprevIdis main's 0058 snapshot and itswhenis above every migration on main.migrate.tson an empty scratch database applied all 60, and a second run applied nothing. Not run against any shared database.app_storage_idxmakesapp_deleted_idxredundant. Nothing is dropped here.Storage aggregate (
lib/infra-storage.ts). GROUP BY in index order, the uploading count folded in (COUNT(*)andCOUNT(duration), noduration IS NOT NULLfilter), so GET /usage runs one aggregate instead of two queries.getAppStoragereturnsstored(what customers see as stored),dailyCharge(videos past their first month) andfirstMonthMicroCredits(the sum of each admitted, still encoding video's own first-month charge, floored per video with the 1 micro-credit minimum, computed per group in the same aggregate). EXPLAIN on a seeded scratch database:ref app_storage_idx,Using where; Using index, 17 ms medianid <> ?(admission)ref app_storage_idx,Using where; Using index, 19 msrange app_storage_idx,Using where; Using index, 63 mscreatedAtrange app_storage_idx,Using where; Using index, 66 ms (113 ms with row lookups beforecreatedAtjoined the index)range first_month_due_idx,Using where; Using indexrange first_month_due_idx,Using where; Using indexIndex only, no temporary table. On the same seeded app, stored totals and the uploading count are identical to main's two queries.
Upload admission (
lib/infra-standing.ts,lib/developer-pricing.ts). A ready video whose month is not charged yet (nostoragePaidUntil, created on or after first month charging began) is reserved as a first month and left out of the daily figure until it is charged, so the moment between the ready update and the charge cannot let another upload through. The start is one row query passed into the aggregate, which stays index-only (40,000 videos: 19.9 ms to 29.2 ms, plus 2.1 ms for the start). Required = first month of the video being admitted + the actual first-month charge of every admitted video still encoding + 7 days of the daily charge for videos past their first month, minimum 1. Totals are read inside the account lock inrecordAdmittedInfraUpload, never passed in.upload.requiredMicroCreditsandshortfallMicroCreditskeep their names. Credit refusals now start with "Not enough credit ". Proof: four completions for one app run at once on a real database with a balance that covers one 20 minute 4K month: one admitted, three refused withNot enough credit ...,requiredMicroCredits11,200.The charge (
lib/infra-storage-minimum.ts,chargeDeveloperVideoFirstMonth(videoId, { executor?, deletedBeforeReady?, now? })). Takes the account row lock, then the video row lock, debits, writes the row and setsstoragePaidUntilin one transaction. Safe twice and concurrently: eight racing calls on a real database charge once.The ready hook (one call in
lib/developer-video-processing.ts, nothing else in that file).mutateDeveloperVideonotes a not-ready to ready transition and, after the row update commits, callssettleFirstMonthAtReady(updated, executor), which never throws. When the caller passed its own transaction it already holds the account lock (every such caller runs insideunderAccountLock), so the lock order stays account then video. Other branches adding a call at the same spot (video.ready webhook, first HLS slice as ready) should keep it beside this one; the per-video idempotency makes repeated passes no-ops.Merged with #4142. Its storage-day ordering is kept as the daily read; the first-month read stays separate (first-month rows reference a video id, not a day). Its batched idle check also opens the day for accounts with an open first-month shortfall, so a recovery can be latched. The under-lock measure uses its
createdBeforewith the prepaid exclusion keyed to the start of the date being billed. The backstop runs on the first invocation of a run; shortfall emails are sent per page during today's sweep.Daily charge.
chargeStorageDayre-measures the app's daily usage under the account lock, the same lock the first-month charge takes, so a video that becomes ready between the cron's batch read and its charge is never billed for a day its month covers. Standing treats a short first month as in effect when another month was paid in the same second. A first month charged while a first month shortfall is still open carries it: the row keeps the originalshortSinceand recordsoutstandingMicroCredits, the larger of what is still unpaid and its own unpaid month, so a later paid month never hides an unpaid one and grace keeps its date. When the available balance covers a shortfall, one UPDATE marks every unresolved daily and first month row of the account with thatshortSinceresolved (account_type_created_idx), for daily and first month latches alike, so no copy of a covered shortfall can bring grace back. A shortfall has one size everywhere:shortfallOwedreads the largest effective owed amount among its unresolved daily and first month rows (the larger ofowedMicroCreditsandoutstandingMicroCredits) with one indexed query, only while a shortfall is open.latestStorageChargesizes the charge it returns with it, so standing, the restore check, the cron and the latch use the same number, and when two first months land in the same second the standing read picks the unresolved short row with the largest owed amount.Auto top-up after a first month. When
chargeDeveloperVideoFirstMonthtakes a nonzero charge, it callsscheduleAutoTopUp({ accountId })(#4156) once, through a dynamic import, after its transaction resolves; errors are caught, so a ready or a delete never fails because of it. When the caller passed its own transaction (which holds the account lock), the call is not awaited, so it cannot wait on that lock; the top-up runs after the caller commits. Step bundle: the workflow builder makes one esbuild bundle of all 18 step files. Measured with esbuild's metafile against main, that shipped steps bundle is unchanged at 44 external packages (172 to 174 local modules), because main already ships Stripe and Resend in it since #4156. The encode workflow's own import graph grows from 23 to 28 external packages (stripe,resend,@react-email/components,clsx,tailwind-merge), all already in that bundle.Deletes after #4177. Every delete path charges the first month, then cancels the encode jobs; the cancel only calls the media server, so the charge reads the same encode state. The agent's app delete now calls
settleFirstMonthsForDeletedApp(appId, stamp)with the second-truncated stamp it wrote, after the delete commits and before the cancel.Cron backstop (
app/api/cron/developer-storage/route.ts). Each run first settles up to 200 ready videos with no marker created since the feature started. A live one is charged its month; a deleted one is marked exempt with no ledger row, because the backstop cannot tell whether it was deleted before or after it became ready. The start is the earlieststoragePaidUntilminus 29 days, which is always after the deploy, so a video ready before the deploy can never match. Ready rows it cannot charge (no encode state, or no credit account) get the exempt marker so they never block the queue. The out-of-credit email goes out once per new first month shortfall, as for a daily one: the row recordsfellShort, the cron sends only for rows with it and nonotifiedAt(looking back over the 7 day grace period), stampsnotifiedAtonly after the email service accepted the email. The daily shortfall email works the same way now: its row recordsfellShort, a sent email stamps it, and the sweep retries any daily or first month shortfall still unsent, and uses the same pause date (shortSinceplus 7 days). Its copy now reads "A storage charge for {appName} was more than its Cap Infra balance, so the balance is now zero." and "Adding credits brings the app straight back. What it could not pay is not charged afterwards."Landing page.
components/pages/infra/content.tscarriesSTORAGE_RULEandDELETED_WHILE_PROCESSING_RULE, used in the FAQ and the pricing note inPricing.tsx; a test pins them word for word against the billing and get-started docs. The calculator and the Cap Pro credit line stay true.Agent API. The agent's billing answer now returns
storage.dailyChargeMinutesByTier; a test pins that its storage fields equal GET /usage's.Ledger, docs, console. The Overview's and Usage page's storage spending for the month include first-month charges. Labels "Storage, first month", "..., part paid", "..., unpaid". Billing docs (what is charged, Storage, the first month with worked examples, the daily charge, credit needed to upload, standing, ledger rows), REST reference (402 example, usage paragraphs and example, Complete Upload, transactions table), OpenAPI (new
storage.dailyChargeMinutesByTier, descriptions), llms-full.txt, the console Usage and Billing notes and the Infra MCP tool text. The docs test recomputes every new example from the pricing functions.Tests
__tests__/integration/infra-first-month-database.test.ts, 34 tests on a real scratch database: an unpaid 16,800 month stays in grace from its first date when a later 850 month is paid, and a top-up returns it to active; two copies of a covered shortfall in one second are resolved together and an unrelated shortfall is not; a daily shortfall carried into a first month is resolved with it, and a first month carried by a daily charge is resolved by a daily latch; an 850 and a 16,800 month short in the same second stay in grace with 900 available and are resolved together once 16,800 is covered; 500 seeded sequences of daily charges, first months (including two of different sizes in one second), holds, releases and top-ups and a daily charge part-paid inside an open first month grace period keep standing equal to an independent model after every step, and every row of a latched shortfall resolved (the previous latch fails it); a charged month that crosses the auto top-up threshold creates exactly one top-up, a repeat or a month above the threshold none, and inside a caller's transaction holding the account lock the charge returns at once and the top-up lands after commit; a pre-feature ready delete is exempt and charged nothing; an app delete leaves videos deleted long before untouched; a pre-feature mid-encode delete is charged nothing; a post-feature ready delete charges nothing more; a new first month shortfall is found for its email once and never afternotifiedAt, and a carried one is never emailed; a delete mid-encode charges once at the declared length and created tier, then a late ready, the backstop and a repeat delete charge nothing; a delete before the encode was sent is free; a failed encode is free; an app deleted mid-encode is charged once; a delete mid-encode the balance cannot pay is recorded as owed; the month is charged ondurationas stored at first ready (a 4 hour claim overwritten to 60 seconds is charged 85 micro-credits, the probe state is never read); a shortfall covered by a released hold is latched once and stays settled; a day measured before a video's month was charged is not billed for it; amounts for 1080p, 1440p, 4K, 4K capped at 1080p and the 1 micro-credit floor; idempotent and eight racing calls; 30 excluded days then a daily charge with no gap, for a video ready before and after the day's charge; deleted an hour after ready keeps the month; errored, encoding and deleted-before-ready pay nothing; pre-feature and pre-encoding videos never charged and the old daily charge unchanged; backstop charges a missed live video once, exempts a missed deleted one, and works through unchargeable rows; part paid and unpaid months leave the video ready and the app ingrace, a top-up restoresactive; the ready path never throws; concurrent completions; admission with one and with several in flight, and with sub-second and one-second clips at their floored charges; console spending includes first months; stored versus daily figures.organization-settings-ownership.test.ts. Biome clean.Not covered
__tests__/integration/infra-billing-database.test.tswas not run: its fixture inserts a credit account with nodeveloper_appsrow and the migrations' foreign key rejects it, before any billing code runs (same on main).completeInfraUpload's cheap pre-check before the lock belongs to the pipeline thread; this PR only changes the admission it calls.restoredSinceChargetreats a top-up in the same second as a later short charge as restoring it, since createdAt has second precision. This existed on main before this PR. It can only delay a grace period, never pause a paying app. Fixed in a follow-up.

Confidence Score: 5/5
The PR appears safe to merge; no new blocking issue was established.Summary
Adds a first-month storage charge when a Cap Infra video becomes ready, then excludes its prepaid days from daily billing.
Reviews (23) · Last reviewed commit: "Merge origin/main into feat/infra-minimu..." · Reviewed by Greptile