Merge pull request #55 from yusufipk/feat/storage-upgrade-nudge

fix(storage): count a finished Bunny upload, and offer the upgrade when a trial is full
This commit is contained in:
Yusuf İpek
2026-08-18 11:47:16 +03:00
committed by GitHub
18 changed files with 567 additions and 105 deletions
@@ -77,6 +77,8 @@ interface StorageInfo {
usedBytes: string; usedBytes: string;
limitBytes: string; limitBytes: string;
percentage: number; percentage: number;
/** False on the free trial, where the way out is subscribing rather than deleting. */
isPaid: boolean;
} }
function formatBytes(bytesStr: string): string { function formatBytes(bytesStr: string): string {
@@ -501,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
Storage Storage
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Combined usage across video files and media attachments (200 GB limit) Combined usage across video files and media attachments
{storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: '' : ''
} }
/> />
{storageInfo.percentage >= 90 && ( {storageInfo.percentage >= 90 &&
(storageInfo.isPaid ? (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
Storage is almost full. Delete unused files or contact support. Storage is almost full. Delete unused files or contact support.
</p> </p>
)} ) : (
<p className="text-xs text-destructive">
Your free trial storage is almost full. Subscribe above for more room, or
delete unused files.
</p>
))}
</> </>
)} )}
</CardContent> </CardContent>
+9 -2
View File
@@ -1,6 +1,6 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getUserStorageInfo } from '@/lib/storage-quota'; import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota';
import { hasBillingAccess } from '@/lib/billing'; import { hasBillingAccess } from '@/lib/billing';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -27,12 +27,19 @@ export async function GET() {
return apiErrors.forbidden(); return apiErrors.forbidden();
} }
const info = await getUserStorageInfo(session.user.id); const [info, storage] = await Promise.all([
getUserStorageInfo(session.user.id),
getStorageContextForUser(session.user.id),
]);
const response = successResponse({ const response = successResponse({
usedBytes: info.usedBytes.toString(), usedBytes: info.usedBytes.toString(),
limitBytes: info.limitBytes.toString(), limitBytes: info.limitBytes.toString(),
percentage: info.percentage, percentage: info.percentage,
// Which ceiling this is, so the card can name it and say what to do about it.
// A trial has 3 GB because it has not subscribed; deleting files is the wrong
// advice there, and "200 GB limit" was the wrong caption.
isPaid: storage.isPaid,
}); });
// Cache for 60s — stale data is acceptable for a usage meter // Cache for 60s — stale data is acceptable for a usage meter
+16 -9
View File
@@ -32,11 +32,13 @@ import {
enforceStorageQuota, enforceStorageQuota,
reserveStorageQuota, reserveStorageQuota,
releaseStorageReservation, releaseStorageReservation,
getStorageLimitForUser, getStorageContextForUser,
storageExceededResponse,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
type StorageContext,
type UploadReservationPurpose, type UploadReservationPurpose,
} from '@/lib/storage-quota'; } from '@/lib/storage-quota';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
// Sentinel thrown inside a Prisma transaction when a fake reservationId is // Sentinel thrown inside a Prisma transaction when a fake reservationId is
@@ -307,6 +309,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// was still in flight. // was still in flight.
let reservationPurpose: UploadReservationPurpose | null = null; let reservationPurpose: UploadReservationPurpose | null = null;
let reservationBilledUserId: string | null = null; let reservationBilledUserId: string | null = null;
// Carried out of the try so the quota refusal in the catch can be worded for
// the account it is refusing, rather than telling a trial to delete files.
let storageForRefusal: StorageContext | null = null;
let finalizedR2AssetSession: { let finalizedR2AssetSession: {
sessionId: string; sessionId: string;
reservationId: string | null; reservationId: string | null;
@@ -582,14 +587,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// quota check below, Bunny included. Leaving Bunny out read its own storage as // quota check below, Bunny included. Leaving Bunny out read its own storage as
// zero, and on an account whose storage is all Bunny that made the fallback a // zero, and on an account whose storage is all Bunny that made the fallback a
// check that could not fail. // check that could not fail.
const preFetchedBunnyData = const preFetchedBunnyBytes =
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage(); provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
// The ceiling this account is actually held to, read for the fallback below. // The ceiling this account is actually held to, read for the fallback below.
// It used to compare against the plan limit, which is 200 GiB whoever is // It used to compare against the plan limit, which is 200 GiB whoever is
// asking: a caller who quoted a reservation id that no longer existed was // asking: a caller who quoted a reservation id that no longer existed was
// measured against the paid ceiling even on a trial worth 3 GiB. // measured against the paid ceiling even on a trial worth 3 GiB.
const storageLimitBytes = await getStorageLimitForUser(billedUserId); const storage = await getStorageContextForUser(billedUserId);
storageForRefusal = storage;
// Create the VideoAsset and atomically consume the upload reservation (if any) // Create the VideoAsset and atomically consume the upload reservation (if any)
// so the spot is never double-counted. // so the spot is never double-counted.
@@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
WHERE "billedUserId" = ${billedUserId} WHERE "billedUserId" = ${billedUserId}
AND "expiresAt" > NOW() AND "expiresAt" > NOW()
`; `;
const bunnyData = preFetchedBunnyData ?? {};
const totalUsed = const totalUsed =
(r2Row?.total ?? BigInt(0)) + (r2Row?.total ?? BigInt(0)) +
(resRow?.total ?? BigInt(0)) + (resRow?.total ?? BigInt(0)) +
BigInt(bunnyData[billedUserId] ?? 0); BigInt(preFetchedBunnyBytes ?? 0);
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) { if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
throw new QuotaExceededInTxError(); throw new QuotaExceededInTxError();
} }
} }
@@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
if (error instanceof QuotaExceededInTxError) { if (error instanceof QuotaExceededInTxError) {
return apiErrors.storageExceeded() as NextResponse; return storageForRefusal
? storageExceededResponse(storageForRefusal)
: (apiErrors.storageExceeded() as NextResponse);
} }
await releaseStorageReservation( await releaseStorageReservation(
reservationId, reservationId,
+16 -12
View File
@@ -3,6 +3,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { toastApiError } from '@/lib/client/api-error';
import { import {
Download, Download,
FileVideo, FileVideo,
@@ -56,6 +57,7 @@ function formatTime(seconds: number): string {
type UploadAudioResponse = { type UploadAudioResponse = {
data?: { url?: string; reservationId?: string | null }; data?: { url?: string; reservationId?: string | null };
error?: string; error?: string;
code?: string;
}; };
function getAudioUploadValidationError(file: Blob): string | null { function getAudioUploadValidationError(file: Blob): string | null {
@@ -369,10 +371,11 @@ export const AssetsPane = memo(function AssetsPane({
const uploadPayload = (await uploadRes.json().catch(() => null)) as { const uploadPayload = (await uploadRes.json().catch(() => null)) as {
data?: { url?: string; reservationId?: string | null }; data?: { url?: string; reservationId?: string | null };
error?: string; error?: string;
code?: string;
} | null; } | null;
const uploadedImageUrl = uploadPayload?.data?.url; const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) { if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`); toastApiError(uploadPayload, 'Failed to upload image', { prefix: file.name });
return false; return false;
} }
@@ -385,7 +388,7 @@ export const AssetsPane = memo(function AssetsPane({
return !!created; return !!created;
} catch (error) { } catch (error) {
console.error('Failed to upload image asset:', error); console.error('Failed to upload image asset:', error);
toast.error(`${file.name}: Failed to upload image`); toastApiError(error, 'Failed to upload image', { prefix: file.name });
return false; return false;
} }
}, },
@@ -523,10 +526,11 @@ export const AssetsPane = memo(function AssetsPane({
uploadToken: string; uploadToken: string;
}; };
error?: string; error?: string;
code?: string;
} | null; } | null;
if (!initRes.ok || !initPayload?.data) { if (!initRes.ok || !initPayload?.data) {
toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`); toastApiError(initPayload, 'Failed to initialize Bunny upload', { prefix: file.name });
return false; return false;
} }
@@ -578,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to upload Bunny asset:', error); console.error('Failed to upload Bunny asset:', error);
toast.error(`${file.name}: Failed to upload Bunny video`); toastApiError(error, 'Failed to upload Bunny video', { prefix: file.name });
if (uploadedVideoId && uploadToken) { if (uploadedVideoId && uploadToken) {
await fetch(`/api/videos/${videoId}/assets/bunny-init`, { await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'DELETE', method: 'DELETE',
@@ -631,7 +635,7 @@ export const AssetsPane = memo(function AssetsPane({
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to upload R2 asset video:', error); console.error('Failed to upload R2 asset video:', error);
toast.error(`${file.name}: Failed to upload video`); toastApiError(error, 'Failed to upload video', { prefix: file.name });
return false; return false;
} finally { } finally {
setIsUploadingBunny(false); setIsUploadingBunny(false);
@@ -801,12 +805,12 @@ export const AssetsPane = memo(function AssetsPane({
const uploadPayload = await readUploadAudioResponse(uploadRes); const uploadPayload = await readUploadAudioResponse(uploadRes);
const uploadedUrl = uploadPayload?.data?.url; const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) { if (!uploadRes.ok || !uploadedUrl) {
toast.error( toastApiError(
`${fileName}: ${ uploadPayload,
uploadPayload?.error || uploadRes.status === 413
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) || ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE
'Failed to upload voice recording' : 'Failed to upload voice recording',
}` { prefix: fileName }
); );
return false; return false;
} }
@@ -821,7 +825,7 @@ export const AssetsPane = memo(function AssetsPane({
return !!created; return !!created;
} catch (error) { } catch (error) {
console.error('Failed to upload voice asset:', error); console.error('Failed to upload voice asset:', error);
toast.error(`${fileName}: Failed to upload voice recording`); toastApiError(error, 'Failed to upload voice recording', { prefix: fileName });
return false; return false;
} }
}, },
@@ -28,6 +28,7 @@ import {
} from '@/components/video-page/image-upload-utils'; } from '@/components/video-page/image-upload-utils';
import { validateAnnotationStrokes } from '@/lib/validation'; import { validateAnnotationStrokes } from '@/lib/validation';
import { withWebmDuration } from '@/lib/webm-duration'; import { withWebmDuration } from '@/lib/webm-duration';
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
interface UseCommentActionsParams extends CommentActionsConfig { interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>; setVideo: Dispatch<SetStateAction<VideoData | null>>;
@@ -61,17 +62,6 @@ function getAudioUploadFilename(blob: Blob): string {
return 'recording.webm'; return 'recording.webm';
} }
/**
* A step of the submit that failed with something worth reading out.
*
* The attachment goes up before the comment does, so a full account fails on the
* image and never reaches the comment at all. Reporting that as "failed to add
* comment" tells the uploader to try again, which is the one thing that cannot
* work. Carried as its own error type so a network fault, which has no message
* anybody wants to see, still falls back to the generic line.
*/
class CommentSubmitError extends Error {}
export function useCommentActions({ export function useCommentActions({
videoId, videoId,
setVideo, setVideo,
@@ -192,7 +182,7 @@ export function useCommentActions({
} | null; } | null;
const token = payload?.data?.token; const token = payload?.data?.token;
if (!response.ok || !token) { if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload'); throw apiRequestError(payload, 'Failed to prepare upload');
} }
return token; return token;
}, },
@@ -274,10 +264,14 @@ export function useCommentActions({
}); });
if (!imageRes.ok) { if (!imageRes.ok) {
// The attachment goes up before the comment does, so a full account
// fails here and never reaches the comment at all. Thrown with the
// code attached so the catch below can offer the way out.
const imagePayload = (await imageRes.json().catch(() => null)) as { const imagePayload = (await imageRes.json().catch(() => null)) as {
error?: string; error?: string;
code?: string;
} | null; } | null;
throw new CommentSubmitError(imagePayload?.error || 'Failed to upload image'); throw apiRequestError(imagePayload, 'Failed to upload image');
} }
const imageDataResponse = await imageRes.json(); const imageDataResponse = await imageRes.json();
imageData = { url: imageDataResponse.data.url }; imageData = { url: imageDataResponse.data.url };
@@ -336,8 +330,11 @@ export function useCommentActions({
), ),
}; };
}); });
const payload = (await res.json().catch(() => null)) as { error?: string } | null; const payload = (await res.json().catch(() => null)) as {
toast.error(payload?.error || 'Failed to add comment'); error?: string;
code?: string;
} | null;
toastApiError(payload, 'Failed to add comment');
} }
} catch (error) { } catch (error) {
setVideo((prev) => { setVideo((prev) => {
@@ -351,7 +348,9 @@ export function useCommentActions({
), ),
}; };
}); });
toast.error(error instanceof CommentSubmitError ? error.message : 'Failed to add comment'); // A network fault has no message anybody wants to see, so only an
// ApiRequestError speaks for itself; toastApiError falls back for the rest.
toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to add comment');
} finally { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
setIsUploadingImage(false); setIsUploadingImage(false);
@@ -12,6 +12,7 @@ import {
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types'; import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload'; import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
/** What a failed version upload has to undo, depending on which provider it started on. */ /** What a failed version upload has to undo, depending on which provider it started on. */
type PendingVersionCleanup = type PendingVersionCleanup =
@@ -113,7 +114,13 @@ export function useVersionActions({
body: JSON.stringify({ title, sizeBytes: file.size.toString() }), body: JSON.stringify({ title, sizeBytes: file.size.toString() }),
}); });
if (!initRes.ok) throw new Error('Failed to initialize upload'); if (!initRes.ok) {
const initPayload = (await initRes.json().catch(() => null)) as {
error?: string;
code?: string;
} | null;
throw apiRequestError(initPayload, 'Failed to initialize upload');
}
const { const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken }, data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json(); } = await initRes.json();
@@ -236,7 +243,7 @@ export function useVersionActions({
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to create version'); throw apiRequestError(data, 'Failed to create version');
} }
const versionData = await res.json(); const versionData = await res.json();
@@ -277,7 +284,7 @@ export function useVersionActions({
} }
} }
console.error('Failed to create version:', errorObj); console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version'); toastApiError(errorObj, 'Failed to create version');
} finally { } finally {
setIsCreatingVersion(false); setIsCreatingVersion(false);
setNewVersionUploadProgress(0); setNewVersionUploadProgress(0);
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { VideoAsset } from '@/components/video-page/types'; import type { VideoAsset } from '@/components/video-page/types';
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
type BunnyDownloadPreference = 'original' | 'compressed'; type BunnyDownloadPreference = 'original' | 'compressed';
@@ -43,6 +44,7 @@ interface AssetsListResponse {
interface AssetCreateResponse { interface AssetCreateResponse {
data?: VideoAsset; data?: VideoAsset;
error?: string; error?: string;
code?: string;
} }
const ASSET_PAGE_SIZE = 40; const ASSET_PAGE_SIZE = 40;
@@ -185,7 +187,7 @@ export function useVideoAssets({
}); });
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null; const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
if (!res.ok || !body?.data) { if (!res.ok || !body?.data) {
toast.error(body?.error || 'Failed to create asset'); toastApiError(body, 'Failed to create asset');
return null; return null;
} }
@@ -288,7 +290,7 @@ export function useVideoAssets({
} | null; } | null;
const token = payload?.data?.token; const token = payload?.data?.token;
if (!response.ok || !token) { if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload'); throw apiRequestError(payload, 'Failed to prepare upload');
} }
return token; return token;
}, },
+89 -23
View File
@@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache(
{ revalidate: STORAGE_CACHE_SECONDS } { revalidate: STORAGE_CACHE_SECONDS }
); );
/**
* What this video costs us, as the larger of the two numbers we have.
*
* Bunny reports nothing for a video until it starts encoding, and what it reports
* while encoding is partial: `storageSize` counts what has been written so far and
* climbs as each rendition lands. A six minute cut uploaded at 2.5 GB read as
* 475 MB midway through and settled above 3 GB once it finished, because Bunny
* keeps the original alongside every rendition it makes.
*
* Both halves of the rule follow from that. Taking Bunny's figure whenever it is
* non-zero would hand back most of the quota in the middle of an encode, which is
* the hole the declared size exists to close. Taking the declared size forever
* would ignore the renditions, which are the actual bill and end up larger than
* the source. The larger of the two is right at every point: the declared size
* covers the encode, and Bunny's own number takes over the moment it passes it.
*/
function chargeableSize(reported: number, declared: bigint | null): number {
const declaredBytes = declared === null ? 0 : Number(declared);
return reported > declaredBytes ? reported : declaredBytes;
}
/**
* Bunny's reported sizes, or an empty map when the call to Bunny failed.
*
* A failed stats call is not a reason to bill an account for nothing. Bunny's own
* figure is unavailable; the sizes declared at upload are sitting in our database
* either way, and reading the whole account as empty is how a full account gets
* waved through. Used to be an early return that skipped the rows entirely.
*/
function reportedSizes(stats: BunnyStorageStats): Record<string, number> {
return stats.totalBytes < 0 ? {} : stats.byVideoId;
}
/**
* What one account's Bunny videos cost, read fresh.
*
* This deliberately does not come from the cached per-user map. The declared size
* lands on the row at the moment an upload finalizes, and a map computed up to two
* minutes earlier does not have that row in it. For those two minutes the
* reservation is already gone and the row is not yet visible, so an upload that
* just succeeded reads as zero: the uploader watches their usage fall back to
* nothing, and the next upload is measured against a total that ignores the one
* before it.
*
* The call to Bunny stays cached. It is the slow half and its answer is the same
* for everybody. Only the join against our own rows has to be current.
*/
export async function getUserBunnyStorageBytes(userId: string): Promise<number> {
try {
const [bunnyStats, bunnyVersions, bunnyAssets] = await Promise.all([
getCachedBunnyStorageStats(),
db.videoVersion.findMany({
where: {
providerId: 'bunny',
// The workspace owner, not the project owner: this feeds
// getUserTotalStorageBytes, which bills every other provider the same way.
video: { project: { workspace: { ownerId: userId } } },
},
select: { videoId: true, sizeBytes: true },
}),
db.videoAsset.findMany({
where: { provider: 'BUNNY', providerVideoId: { not: null }, billedUserId: userId },
select: { providerVideoId: true, sizeBytes: true },
}),
]);
const reported = reportedSizes(bunnyStats);
const seenVideoIds = new Set<string>();
let total = 0;
for (const row of [
...bunnyVersions.map((v) => ({ videoId: v.videoId, sizeBytes: v.sizeBytes })),
...bunnyAssets.map((a) => ({ videoId: a.providerVideoId!, sizeBytes: a.sizeBytes })),
]) {
if (!row.videoId || seenVideoIds.has(row.videoId)) continue;
seenVideoIds.add(row.videoId);
total += chargeableSize(reported[row.videoId] || 0, row.sizeBytes);
}
return total;
} catch (err) {
logError('Failed to calculate Bunny storage for user:', err);
return 0;
}
}
export const getCachedUserBunnyStorage = unstable_cache( export const getCachedUserBunnyStorage = unstable_cache(
async () => { async () => {
const perUserStorage: Record<string, number> = {}; const perUserStorage: Record<string, number> = {};
try { try {
const bunnyStats = await getCachedBunnyStorageStats(); const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
const [bunnyVersions, bunnyAssets] = await Promise.all([ const [bunnyVersions, bunnyAssets] = await Promise.all([
db.videoVersion.findMany({ db.videoVersion.findMany({
@@ -262,23 +347,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
}), }),
]); ]);
/** const reported = reportedSizes(bunnyStats);
* What this video costs us, as the larger of the two numbers we have.
*
* Bunny reports nothing for a video until it has finished encoding it,
* which on a half-hour source is most of an hour, and reading that zero
* literally meant an upload was free for as long as it was being
* processed: it did not show on the uploader's storage page and it did not
* count against the next upload's quota check. The size declared when the
* upload was admitted stands in until Bunny has a figure of its own, and
* Bunny's wins once it arrives, because the renditions it makes are the
* real bill and they are larger than the source.
*/
const chargeableSize = (reported: number, declared: bigint | null): number => {
const declaredBytes = declared === null ? 0 : Number(declared);
return reported > declaredBytes ? reported : declaredBytes;
};
const seenVideoIds = new Set<string>(); const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) { for (const version of bunnyVersions) {
const ownerId = version.video.project.workspace.ownerId; const ownerId = version.video.project.workspace.ownerId;
@@ -286,7 +355,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
if (seenVideoIds.has(dedupeKey)) continue; if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey); seenVideoIds.add(dedupeKey);
const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes); const size = chargeableSize(reported[version.videoId] || 0, version.sizeBytes);
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size; perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
} }
@@ -297,10 +366,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
if (seenVideoIds.has(dedupeKey)) continue; if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey); seenVideoIds.add(dedupeKey);
const size = chargeableSize( const size = chargeableSize(reported[asset.providerVideoId] || 0, asset.sizeBytes);
bunnyStats.byVideoId[asset.providerVideoId] || 0,
asset.sizeBytes
);
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size; perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
} }
} catch (err) { } catch (err) {
+15
View File
@@ -67,6 +67,12 @@ export const ErrorCode = {
// Storage errors // Storage errors
STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED', STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED',
/**
* Out of room because the account has not paid, rather than because the plan
* is full. Its own code so the client can offer the upgrade, which is the
* actual remedy here and is no help at all on the paid ceiling.
*/
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
} as const; } as const;
/** /**
@@ -175,4 +181,13 @@ export const apiErrors = {
storageExceeded: ( storageExceeded: (
message = 'Storage limit exceeded. Please delete some files to free up space.' message = 'Storage limit exceeded. Please delete some files to free up space.'
) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED), ) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
/**
* The same 507, for an account that is out of room because it is on the free
* trial. Telling this caller to delete files is advice that does not apply:
* they have three gigabytes because they have not subscribed, not because they
* have filled two hundred.
*/
trialStorageExceeded: (message: string) =>
errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.TRIAL_STORAGE_LIMIT_EXCEEDED),
}; };
+101
View File
@@ -0,0 +1,101 @@
// What a failed API call is worth showing the person who made it.
//
// Every route answers with `{ error, code? }`, and the code is the part that
// says whether there is anything the caller can do. Losing it on the way to a
// toast is how a full trial account ends up reading "Failed to upload" and
// trying the same upload again.
import { toast } from 'sonner';
/** The machine-readable half of an error response. Mirrors ErrorCode in lib/api-response.ts. */
export const API_ERROR_CODES = {
/** Out of room because the account has not subscribed, not because the plan is full. */
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
} as const;
export interface ApiErrorPayload {
error?: string;
code?: string;
}
/**
* A failure that came back from our own API, with the code still attached.
*
* The upload helpers throw rather than return, so without this the code is gone
* by the time anything is in a position to show it.
*/
export class ApiRequestError extends Error {
readonly code: string | null;
constructor(message: string, code?: string | null) {
super(message);
this.name = 'ApiRequestError';
this.code = code ?? null;
}
}
/** Builds the error to throw from a parsed `{ error, code }` body. */
export function apiRequestError(
payload: ApiErrorPayload | null,
fallback: string
): ApiRequestError {
return new ApiRequestError(payload?.error || fallback, payload?.code);
}
function codeOf(source: unknown): string | null {
if (source instanceof ApiRequestError) return source.code;
if (source && typeof source === 'object' && 'code' in source) {
const code = (source as ApiErrorPayload).code;
return typeof code === 'string' ? code : null;
}
return null;
}
function messageOf(source: unknown, fallback: string): string {
if (source instanceof Error) return source.message || fallback;
if (source && typeof source === 'object' && 'error' in source) {
const message = (source as ApiErrorPayload).error;
if (typeof message === 'string' && message) return message;
}
return fallback;
}
export interface ToastApiErrorOptions {
/** Prepended as `${prefix}: ${message}`, for per-file failures in a batch. */
prefix?: string;
}
/**
* Shows an API failure, with a way out attached when there is one.
*
* Takes either a parsed response body or a thrown error, so the same call works
* whether the caller checked `res.ok` itself or caught what an upload helper
* threw. On the trial storage ceiling it adds a link to the billing settings,
* because subscribing is the only thing that makes the upload possible and the
* uploader has no way to know that from "storage limit exceeded".
*/
export function toastApiError(
source: unknown,
fallback: string,
options: ToastApiErrorOptions = {}
): void {
const message = messageOf(source, fallback);
const text = options.prefix ? `${options.prefix}: ${message}` : message;
if (codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED) {
toast.error(text, {
// Longer than a plain error: this one is asking for a decision rather than
// just reporting, and it disappears under the cursor at the usual timing.
duration: 12000,
action: {
label: 'See plans',
onClick: () => {
window.location.href = '/settings';
},
},
});
return;
}
toast.error(text);
}
+4 -3
View File
@@ -3,6 +3,7 @@
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload'; import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
import type { DirectUploadProvider } from '@/components/video-page/types'; import type { DirectUploadProvider } from '@/components/video-page/types';
import { apiRequestError } from '@/lib/client/api-error';
export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv']; export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
@@ -143,7 +144,7 @@ export async function uploadProjectVideo(
} | null; } | null;
if (!createResponse.ok) { if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video'); throw apiRequestError(createPayload, 'Failed to create video');
} }
pendingCleanup = null; pendingCleanup = null;
@@ -171,7 +172,7 @@ export async function uploadProjectVideo(
} | null; } | null;
if (!initResponse.ok || !initPayload?.data) { if (!initResponse.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize upload'); throw apiRequestError(initPayload, 'Failed to initialize upload');
} }
const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data; const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data;
@@ -243,7 +244,7 @@ export async function uploadProjectVideo(
} | null; } | null;
if (!createResponse.ok) { if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video'); throw apiRequestError(createPayload, 'Failed to create video');
} }
pendingCleanup = null; pendingCleanup = null;
+2 -1
View File
@@ -1,4 +1,5 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail'; import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
import { apiRequestError } from '@/lib/client/api-error';
import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload'; import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload';
export type R2AssetVideoInitResponse = { export type R2AssetVideoInitResponse = {
@@ -39,7 +40,7 @@ export async function initR2AssetVideoUpload(
error?: string; error?: string;
} | null; } | null;
if (!initRes.ok || !initPayload?.data) { if (!initRes.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize video upload'); throw apiRequestError(initPayload, 'Failed to initialize video upload');
} }
return initPayload.data; return initPayload.data;
+3 -2
View File
@@ -1,4 +1,5 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail'; import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
import { apiRequestError } from '@/lib/client/api-error';
import { import {
getMultipartProgressPercent, getMultipartProgressPercent,
getPartByteRange, getPartByteRange,
@@ -151,7 +152,7 @@ async function completeMultipartUpload(
if (!res.ok) { if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null; const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || 'Failed to complete multipart upload'); throw apiRequestError(payload, 'Failed to complete multipart upload');
} }
} }
@@ -243,7 +244,7 @@ export async function initR2VideoUpload(
error?: string; error?: string;
} | null; } | null;
if (!initRes.ok || !initPayload?.data) { if (!initRes.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize video upload'); throw apiRequestError(initPayload, 'Failed to initialize video upload');
} }
return initPayload.data; return initPayload.data;
+62 -15
View File
@@ -2,7 +2,7 @@ import type { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { apiErrors } from '@/lib/api-response'; import { apiErrors } from '@/lib/api-response';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { isPaidTier } from '@/lib/billing'; import { isPaidTier } from '@/lib/billing';
import { getStorageLimitBytes } from '@/lib/trial-limits'; import { getStorageLimitBytes } from '@/lib/trial-limits';
@@ -18,12 +18,59 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024
* forget to pass it. * forget to pass it.
*/ */
export async function getStorageLimitForUser(userId: string): Promise<bigint> { export async function getStorageLimitForUser(userId: string): Promise<bigint> {
return (await getStorageContextForUser(userId)).limitBytes;
}
export interface StorageContext {
/** The ceiling this account is held to. */
limitBytes: bigint;
/** Whether that ceiling is the plan's or the trial's. */
isPaid: boolean;
}
/**
* The ceiling and the reason for it, read together.
*
* The two travel as a pair because a refusal has to say which one it is: an
* unpaid account is out of room because it has not subscribed, and telling it to
* delete files is advice that does not apply.
*/
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
}); });
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES); const isPaid = user ? isPaidTier(user) : false;
return { limitBytes: getStorageLimitBytes(isPaid, PLAN_STORAGE_LIMIT_BYTES), isPaid };
}
/**
* Whole gigabytes where the number is whole, which every ceiling we ship is.
* A host that sets an odd one gets a decimal rather than a rounded lie.
*/
function formatStorageLimit(bytes: bigint): string {
const gigabytes = Number(bytes) / 1024 ** 3;
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`;
}
/**
* The refusal, in the words that fit the account it is being given to.
*
* A paying account that has filled 200 GB has to delete something. An unpaid one
* has three gigabytes because it has not subscribed, so the way out is the
* subscription, and the response says so under its own error code rather than
* leaving the client to guess from the number.
*/
export function storageExceededResponse(context: StorageContext): NextResponse {
if (context.isPaid) {
return apiErrors.storageExceeded() as NextResponse;
}
return apiErrors.trialStorageExceeded(
`Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` +
`Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.`
) as NextResponse;
} }
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads // TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
@@ -66,7 +113,7 @@ class QuotaExceededError extends Error {}
* every upload. * every upload.
*/ */
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> { export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([ const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
db.$queryRaw<[{ total: bigint }]>` db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets FROM video_assets
@@ -82,7 +129,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
WHERE w."ownerId" = ${userId} WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2' AND vv."providerId" = 'r2'
`, `,
getCachedUserBunnyStorage(), getUserBunnyStorageBytes(userId),
db.$queryRaw<[{ total: bigint }]>` db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
FROM upload_reservations FROM upload_reservations
@@ -93,7 +140,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0); const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0); const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0); const bunnyBytes = BigInt(bunnyUserBytes);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0); const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes; return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
@@ -136,13 +183,13 @@ export async function enforceStorageQuota(
return null; return null;
} }
const [usedBytes, limitBytes] = await Promise.all([ const [usedBytes, storage] = await Promise.all([
getUserTotalStorageBytes(userId), getUserTotalStorageBytes(userId),
getStorageLimitForUser(userId), getStorageContextForUser(userId),
]); ]);
if (usedBytes + incomingSizeBytes >= limitBytes) { if (usedBytes + incomingSizeBytes >= storage.limitBytes) {
return apiErrors.storageExceeded() as NextResponse; return storageExceededResponse(storage);
} }
return null; return null;
@@ -176,11 +223,11 @@ export async function reserveStorageQuota(
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction, // Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
// to avoid holding the advisory lock during a potentially slow/failing HTTP call // to avoid holding the advisory lock during a potentially slow/failing HTTP call
// on cache miss or an extra round trip to Postgres. // on cache miss or an extra round trip to Postgres.
const [bunnyData, limitBytes] = await Promise.all([ const [bunnyUserBytes, storage] = await Promise.all([
getCachedUserBunnyStorage(), getUserBunnyStorageBytes(userId),
getStorageLimitForUser(userId), getStorageContextForUser(userId),
]); ]);
const bunnyBytes = BigInt(bunnyData[userId] ?? 0); const bunnyBytes = BigInt(bunnyUserBytes);
try { try {
const reservationId = await db.$transaction(async (tx) => { const reservationId = await db.$transaction(async (tx) => {
@@ -222,7 +269,7 @@ export async function reserveStorageQuota(
const reservedBytes = resRow?.total ?? BigInt(0); const reservedBytes = resRow?.total ?? BigInt(0);
const totalUsed = r2Bytes + reservedBytes + bunnyBytes; const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
if (totalUsed + incomingSizeBytes >= limitBytes) { if (totalUsed + incomingSizeBytes >= storage.limitBytes) {
throw new QuotaExceededError(); throw new QuotaExceededError();
} }
@@ -237,7 +284,7 @@ export async function reserveStorageQuota(
return { reservationId }; return { reservationId };
} catch (e) { } catch (e) {
if (e instanceof QuotaExceededError) { if (e instanceof QuotaExceededError) {
return { error: apiErrors.storageExceeded() as NextResponse }; return { error: storageExceededResponse(storage) };
} }
throw e; throw e;
} }
+69 -1
View File
@@ -16,9 +16,15 @@ import {
} from '@/app/api/projects/[projectId]/videos/bunny-init/route'; } from '@/app/api/projects/[projectId]/videos/bunny-init/route';
import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route'; import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota'; import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
import { PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota';
import { apiRequest, callRoute, readData, readError } from '../helpers/request'; import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs } from '../helpers/session'; import { signedInAs } from '../helpers/session';
import { createVideo, seedProject } from '../factories'; import {
createSubscribedUser,
createUploadReservation,
createVideo,
seedProject,
} from '../factories';
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
@@ -261,3 +267,65 @@ describe('a Bunny hold cannot be consumed by another flow', () => {
expect(second.status).toBe(507); expect(second.status).toBe(507);
}); });
}); });
// What a refusal says, and to whom.
//
// A trial account is out of room because it has not subscribed, so "delete some
// files" is advice that does not apply and the upgrade is the only way through.
// The two cases carry different codes because the client offers a link on one of
// them and must not on the other.
describe('what the storage refusal says', () => {
it('names the trial ceiling and its own code for an unpaid account', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await initUpload(scenario.project.id, BigInt(4) * GIB);
expect(response.status).toBe(507);
const body = (await response.json()) as { error: string; code: string };
expect(body.code).toBe('TRIAL_STORAGE_LIMIT_EXCEEDED');
expect(body.error).toContain('3 GB');
expect(body.error).toContain('Upgrade');
});
it('tells a paying account to free up space instead', async () => {
const scenario = await seedProject({ ownerUser: await createSubscribedUser() });
signedInAs(scenario.owner);
// Full at the paid ceiling rather than the trial one.
await createUploadReservation({
billedUserId: scenario.owner.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
});
const response = await initUpload(scenario.project.id, BigInt(1) * GIB);
expect(response.status).toBe(507);
const body = (await response.json()) as { error: string; code: string };
expect(body.code).toBe('STORAGE_LIMIT_EXCEEDED');
expect(body.error).toContain('delete some files');
});
});
// Asked directly: five one-gigabyte uploads started at once against a three
// gigabyte trial. Whether they go up in one piece or in parts makes no
// difference, because r2-init takes the reservation before it decides on
// multipart, and bunny-init holds one for the size the client declared.
describe('several uploads started at once', () => {
it('grants only the ones that fit and refuses the rest', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const results = await Promise.all(
Array.from({ length: 5 }, () => initUpload(scenario.project.id, BigInt(1) * GIB))
);
const granted = results.filter((response) => response.status === 200);
const refused = results.filter((response) => response.status === 507);
// Two fit: the check is >=, so the third would land exactly on 3 GiB.
expect(granted).toHaveLength(2);
expect(refused).toHaveLength(3);
expect(await db.uploadReservation.count()).toBe(2);
});
});
+131 -5
View File
@@ -36,6 +36,7 @@ import {
getCachedUserBunnyStorage, getCachedUserBunnyStorage,
getCachedUserDownloadEgress, getCachedUserDownloadEgress,
getCachedUserMediaStorage, getCachedUserMediaStorage,
getUserBunnyStorageBytes,
refreshR2StorageSnapshot, refreshR2StorageSnapshot,
} from '@/lib/admin-stats'; } from '@/lib/admin-stats';
import { import {
@@ -603,21 +604,23 @@ describe('getCachedUserBunnyStorage', () => {
}); });
// The -1 sentinel means "Bunny did not answer", and the module must not turn // The -1 sentinel means "Bunny did not answer", and the module must not turn
// that into "this user stores nothing", because lib/storage-quota.ts would // that into "this user stores nothing", because lib/storage-quota.ts would then
// then hand out headroom the user does not have. An empty map at least leaves // hand out headroom the user does not have. Bunny's figure is gone; the size
// the R2 figures intact. // declared when the upload was admitted is still in our own rows, so that is
it('answers an empty map when the Bunny library could not be read', async () => { // what the account is charged until Bunny can be reached again.
it('falls back to the declared sizes when the Bunny library could not be read', async () => {
const scenario = await seedProject(); const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id }); const video = await createVideo({ projectId: scenario.project.id });
await createVersion({ await createVersion({
videoParentId: video.id, videoParentId: video.id,
providerId: 'bunny', providerId: 'bunny',
providerVideoId: 'bunny-first', providerVideoId: 'bunny-first',
sizeBytes: BigInt(2048),
}); });
bunnyCredentials(); bunnyCredentials();
stubBunnyPages([{ status: 503, body: {} }]); stubBunnyPages([{ status: 503, body: {} }]);
expect(await getCachedUserBunnyStorage()).toEqual({}); expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 2048 });
}); });
it('is empty on a database with no videos at all', async () => { it('is empty on a database with no videos at all', async () => {
@@ -987,3 +990,126 @@ describe('getCachedStripeStats', () => {
expect(await getCachedStripeStats()).toBeNull(); expect(await getCachedStripeStats()).toBeNull();
}); });
}); });
// The per-user figure the quota checks read, which deliberately does not come
// from the cached map above.
//
// The bug this exists for: the declared size lands on the row at the moment an
// upload finalizes and the reservation is deleted in the same transaction. A map
// computed up to two minutes earlier does not have that row in it, so for those
// two minutes the upload that just succeeded counted as nothing. The uploader
// watched their usage fall back to zero and the next upload was measured against
// a total that ignored the one before it.
describe('getUserBunnyStorageBytes', () => {
it('counts a finished upload straight away, without waiting for Bunny', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
// Bunny has the video but reports nothing for it yet, which is what an
// encode in progress looks like.
stubBunnyLibrary({ 'bunny-encoding': 0 });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-encoding',
sizeBytes: BigInt(2_500_000_000),
});
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
});
// What Bunny reports mid-encode is partial: it counts what has been written so
// far and climbs as each rendition lands. A 2.5 GB source read as 475 MB
// halfway through and settled above 3 GB once it finished. Letting the partial
// figure displace the declared size would hand most of the quota back in the
// middle of an encode, which is exactly what the declared size is there to stop.
it('keeps the declared size while Bunny figure is still climbing', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-encoding',
sizeBytes: BigInt(2_500_000_000),
});
stubBunnyLibrary({ 'bunny-encoding': 474_900_000 });
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
});
// And gets out of the way once the renditions are all there, because they are
// the actual bill and they add up to more than the source.
it('takes Bunny own figure once it passes the declared size', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-encoded',
sizeBytes: BigInt(2_500_000_000),
});
stubBunnyLibrary({ 'bunny-encoded': 3_600_000_000 });
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(3_600_000_000);
});
it('counts only the videos billed to the user asked about', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const myVideo = await createVideo({ projectId: mine.project.id });
const theirVideo = await createVideo({ projectId: theirs.project.id });
await createVersion({
videoParentId: myVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-mine',
sizeBytes: BigInt(700),
});
await createVersion({
videoParentId: theirVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-theirs',
sizeBytes: BigInt(900),
});
stubBunnyLibrary({ 'bunny-mine': 0, 'bunny-theirs': 0 });
expect(await getUserBunnyStorageBytes(mine.owner.id)).toBe(700);
expect(await getUserBunnyStorageBytes(theirs.owner.id)).toBe(900);
});
it('adds Bunny assets to the user they are billed to, deduped against versions', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
sizeBytes: BigInt(500),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-shared',
sizeBytes: BigInt(500),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-asset-only',
sizeBytes: BigInt(300),
});
stubBunnyLibrary({ 'bunny-shared': 0, 'bunny-asset-only': 0 });
// The shared id is one video however many rows point at it.
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(800);
});
it('is zero for a user with no Bunny videos', async () => {
const scenario = await seedProject();
stubBunnyLibrary({ 'bunny-someone-else': 999 });
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(0);
});
});
+2 -2
View File
@@ -12,7 +12,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { import {
PLAN_STORAGE_LIMIT_BYTES, PLAN_STORAGE_LIMIT_BYTES,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
@@ -41,7 +41,7 @@ import {
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
function bunnyStorage(map: Record<string, number>): void { function bunnyStorage(map: Record<string, number>): void {
vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map); vi.mocked(getUserBunnyStorageBytes).mockImplementation(async (userId) => map[userId] ?? 0);
} }
// The mock implementation is module state, so it survives afterEach. Reset it so // The mock implementation is module state, so it survives afterEach. Reset it so
+4 -3
View File
@@ -146,14 +146,15 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bunny storage stats // Bunny storage stats
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in // Both of these reach the Bunny API, and getUserBunnyStorageBytes() sits in the
// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota // middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota suite
// suite overrides it to prove Bunny usage counts against the limit. // overrides them to prove Bunny usage counts against the limit.
vi.mock('@/lib/admin-stats', async (importOriginal) => { vi.mock('@/lib/admin-stats', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/admin-stats')>(); const actual = await importOriginal<typeof import('@/lib/admin-stats')>();
return { return {
...actual, ...actual,
getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>), getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>),
getUserBunnyStorageBytes: vi.fn(async () => 0),
}; };
}); });