Track media storage quota via an explicit lifecycle enum

Charge the raw upload size immediately (enforce on raw, never under-count),
correct down to the optimized size in the finalize job, and refund on delete.
Each transition is guarded by media.quota_status so retries can't double-apply.
fix/media-storage-optimized-charge
Your Name 1 week ago
parent be65cce84a
commit 252dea7016

@ -0,0 +1,43 @@
<?php
namespace App\Enums;
/**
* Lifecycle of a media row's contribution to its owner's storage quota
* (users.storage_used).
*
* The amount reflected in the quota changes over the media's life:
*
* Pending Uploaded and enforced against the quota on its raw size, but
* nothing has been added to storage_used yet.
* OriginalSize The raw upload size has been added to storage_used. This is
* charged synchronously at upload so the quota never
* under-counts while optimization is still queued.
* OptimizedSize The async finalize job has optimized the file and corrected
* the quota down by (original_size - size), so storage_used now
* reflects the optimized on-disk footprint.
* Subtracted The media was deleted and whatever it still reflected was
* refunded to storage_used.
*
* Each transition is guarded by the current status so retries and overlapping
* jobs cannot double-apply a delta.
*/
enum MediaQuotaStatus: string
{
case Pending = 'pending';
case OriginalSize = 'original_size';
case OptimizedSize = 'optimized_size';
case Subtracted = 'subtracted';
/**
* Whether this status means bytes are currently reflected in storage_used
* (and therefore a delete must refund them).
*/
public function isCharged(): bool
{
return $this === self::OriginalSize || $this === self::OptimizedSize;
}
}

@ -2144,6 +2144,7 @@ class ApiV1Controller extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
$media->original_size = $photo->getSize();
$media->mime = $mime; $media->mime = $mime;
$media->caption = $request->input('description') ?? ''; $media->caption = $request->input('description') ?? '';
$media->filter_class = $filterClass; $media->filter_class = $filterClass;
@ -2153,6 +2154,8 @@ class ApiV1Controller extends Controller
} }
$media->save(); $media->save();
UserStorageService::chargeOriginal($media);
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg': case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
@ -2170,8 +2173,6 @@ class ApiV1Controller extends Controller
break; break;
} }
UserStorageService::increaseStorageUsed($user->id, $fileSize);
Cache::forget($limitKey); Cache::forget($limitKey);
$resource = new Fractal\Resource\Item($media, new MediaTransformer); $resource = new Fractal\Resource\Item($media, new MediaTransformer);
$res = $this->fractal->createData($resource)->toArray(); $res = $this->fractal->createData($resource)->toArray();
@ -2388,6 +2389,7 @@ class ApiV1Controller extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
$media->original_size = $photo->getSize();
$media->mime = $mime; $media->mime = $mime;
$media->caption = $request->input('description') ?? ''; $media->caption = $request->input('description') ?? '';
$media->filter_class = $filterClass; $media->filter_class = $filterClass;
@ -2397,6 +2399,8 @@ class ApiV1Controller extends Controller
} }
$media->save(); $media->save();
UserStorageService::chargeOriginal($media);
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg': case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
@ -2414,8 +2418,6 @@ class ApiV1Controller extends Controller
break; break;
} }
UserStorageService::increaseStorageUsed($user->id, $fileSize);
Cache::forget($limitKey); Cache::forget($limitKey);
$resource = new Fractal\Resource\Item($media, new MediaTransformer); $resource = new Fractal\Resource\Item($media, new MediaTransformer);
$res = $this->fractal->createData($resource)->toArray(); $res = $this->fractal->createData($resource)->toArray();

@ -1301,6 +1301,7 @@ class ApiV1Dot1Controller extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
$media->original_size = $photo->getSize();
$media->mime = $mime; $media->mime = $mime;
$media->order = 1; $media->order = 1;
$media->caption = $request->input('description'); $media->caption = $request->input('description');
@ -1309,6 +1310,8 @@ class ApiV1Dot1Controller extends Controller
} }
$media->save(); $media->save();
UserStorageService::chargeOriginal($media);
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg': case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
@ -1326,8 +1329,6 @@ class ApiV1Dot1Controller extends Controller
break; break;
} }
UserStorageService::increaseStorageUsed($user->id, $fileSize);
NewStatusPipeline::dispatch($status); NewStatusPipeline::dispatch($status);
Cache::forget('user:account:id:'.$user->id); Cache::forget('user:account:id:'.$user->id);

@ -309,6 +309,7 @@ class ApiV2Controller extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
$media->original_size = $photo->getSize();
$media->mime = $mime; $media->mime = $mime;
$media->caption = $request->input('description'); $media->caption = $request->input('description');
$media->filter_class = $filterClass; $media->filter_class = $filterClass;
@ -318,6 +319,8 @@ class ApiV2Controller extends Controller
} }
$media->save(); $media->save();
UserStorageService::chargeOriginal($media);
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg': case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
@ -335,8 +338,6 @@ class ApiV2Controller extends Controller
break; break;
} }
UserStorageService::increaseStorageUsed($user->id, $fileSize);
Cache::forget($limitKey); Cache::forget($limitKey);
$fractal = new Fractal\Manager; $fractal = new Fractal\Manager;
$fractal->setSerializer(new ArraySerializer); $fractal->setSerializer(new ArraySerializer);

@ -124,6 +124,7 @@ class ComposeController extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
$media->original_size = $photo->getSize();
$media->caption = ''; $media->caption = '';
$media->mime = $mime; $media->mime = $mime;
$media->filter_class = $filterClass; $media->filter_class = $filterClass;
@ -131,6 +132,8 @@ class ComposeController extends Controller
$media->version = '3'; $media->version = '3';
$media->save(); $media->save();
UserStorageService::chargeOriginal($media);
$preview_url = $media->url().'?v='.time(); $preview_url = $media->url().'?v='.time();
$url = $media->url().'?v='.time(); $url = $media->url().'?v='.time();
@ -153,8 +156,6 @@ class ComposeController extends Controller
break; break;
} }
UserStorageService::increaseStorageUsed($user->id, $fileSize);
Cache::forget($limitKey); Cache::forget($limitKey);
$resource = new Fractal\Resource\Item($media, new MediaTransformer); $resource = new Fractal\Resource\Item($media, new MediaTransformer);
$res = $this->fractal->createData($resource)->toArray(); $res = $this->fractal->createData($resource)->toArray();

@ -499,6 +499,10 @@ class DirectMessageController extends Controller
$media->media_path = $path; $media->media_path = $path;
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->size = $photo->getSize(); $media->size = $photo->getSize();
// DM media is not run through the optimize/finalize pipeline, so its
// on-disk size equals the raw upload size. original_size mirrors it for
// consistency with the other upload paths.
$media->original_size = $photo->getSize();
$media->mime = $photo->getMimeType(); $media->mime = $photo->getMimeType();
$media->caption = null; $media->caption = null;
$media->filter_class = null; $media->filter_class = null;
@ -526,7 +530,9 @@ class DirectMessageController extends Controller
] ]
); );
UserStorageService::increaseStorageUsed($user->id, $fileSize); // DM media has no finalize job; charge the raw stored size. It stays at
// OriginalSize (no optimize correction) which is the on-disk footprint.
UserStorageService::chargeOriginal($media);
if ($recipient->domain) { if ($recipient->domain) {
$this->remoteDeliver($dm); $this->remoteDeliver($dm);

@ -59,6 +59,8 @@ class ImageOptimize implements ShouldQueue
$localFs = config('filesystems.default') === 'local'; $localFs = config('filesystems.default') === 'local';
// A skip_optimize image never reaches ImageUpdate, so its quota stays at
// the raw size charged on upload (which is its on-disk footprint).
if ($localFs) { if ($localFs) {
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
if (! is_file($path) || $media->skip_optimize) { if (! is_file($path) || $media->skip_optimize) {

@ -4,6 +4,7 @@ namespace App\Jobs\ImageOptimizePipeline;
use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Models\Media; use App\Models\Media;
use App\Services\UserStorageService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -104,6 +105,8 @@ class ImageUpdate implements ShouldQueue
$total = ($photo_size + $thumb_size); $total = ($photo_size + $thumb_size);
$media->size = $total; $media->size = $total;
$media->save(); $media->save();
UserStorageService::chargeOptimized($media);
} catch (\Exception $e) { } catch (\Exception $e) {
if (config('app.dev_log')) { if (config('app.dev_log')) {
Log::error('Failed to calculate media sizes: '.$e->getMessage()); Log::error('Failed to calculate media sizes: '.$e->getMessage());

@ -93,8 +93,6 @@ class MediaDeletePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
return 1; return 1;
} }
$ownerUserId = $media->user_id;
$ownerMediaSize = (int) $media->size;
$path = $media->media_path; $path = $media->media_path;
$thumb = $media->thumbnail_path; $thumb = $media->thumbnail_path;
@ -148,11 +146,12 @@ class MediaDeletePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
} }
} }
$media->delete(); // Refund the owner's storage_used by whatever this media currently
// reflects (raw or optimized, per quota_status). Read before delete
// so quota_status is intact; guarded so it refunds at most once.
UserStorageService::subtractMedia($media);
if ($ownerUserId) { $media->delete();
UserStorageService::decrementStorageUsed($ownerUserId, $ownerMediaSize);
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::warning('MediaDeletePipeline: Failed to delete media', [ Log::warning('MediaDeletePipeline: Failed to delete media', [
'media_id' => $media->id, 'media_id' => $media->id,

@ -139,6 +139,9 @@ class VideoThumbnail implements ShouldBeUniqueUntilProcessing, ShouldQueue
StatusService::del($media->status_id); StatusService::del($media->status_id);
} }
// Video is not size-optimized, so the raw size charged at upload already
// reflects the on-disk footprint; no quota correction is needed here.
MediaStoragePipeline::dispatch($media); MediaStoragePipeline::dispatch($media);
} }
} }

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\MediaQuotaStatus;
use App\Util\Media\License; use App\Util\Media\License;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -21,6 +22,7 @@ class Media extends Model
'srcset' => 'array', 'srcset' => 'array',
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'skip_optimize' => 'boolean', 'skip_optimize' => 'boolean',
'quota_status' => MediaQuotaStatus::class,
]; ];
} }

@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Enums\MediaQuotaStatus;
use App\Models\Media; use App\Models\Media;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
@ -117,6 +118,106 @@ class UserStorageService
return $updatedVal; return $updatedVal;
} }
/**
* Charge the raw upload size of a freshly uploaded media to its owner's
* storage_used, moving it Pending -> OriginalSize.
*
* Called synchronously at upload (after the media row is saved) so the
* quota reflects the file immediately and never under-counts while
* optimization is still queued. Guarded by quota_status so it applies once.
*
* @return bool True when the charge was applied, false when skipped.
*/
public static function chargeOriginal(Media $media): bool
{
// A freshly saved row may not have the DB default hydrated on the model
// instance yet, so treat a null status as Pending.
$status = $media->quota_status ?? MediaQuotaStatus::Pending;
if (! $media->user_id || $status !== MediaQuotaStatus::Pending) {
return false;
}
$bytes = (int) ($media->original_size ?? $media->size);
if ($bytes <= 0) {
return false;
}
if (self::increaseStorageUsed($media->user_id, $bytes) === null) {
return false;
}
$media->quota_status = MediaQuotaStatus::OriginalSize;
$media->saveQuietly();
return true;
}
/**
* Correct a charged media down to its optimized on-disk size, moving it
* OriginalSize -> OptimizedSize by subtracting (original_size - size).
*
* Called from the async finalize job once media.size holds the optimized
* value. The delta is signed: if optimization somehow grew the file the
* quota is adjusted up instead. Guarded by quota_status so it applies once.
*
* @return bool True when the correction was applied, false when skipped.
*/
public static function chargeOptimized(Media $media): bool
{
if (! $media->user_id || $media->quota_status !== MediaQuotaStatus::OriginalSize) {
return false;
}
$original = (int) ($media->original_size ?? $media->size);
$optimized = (int) $media->size;
$delta = $original - $optimized; // bytes freed by optimization (usually > 0)
if ($delta > 0) {
self::decrementStorageUsed($media->user_id, $delta);
} elseif ($delta < 0) {
self::increaseStorageUsed($media->user_id, -$delta);
}
$media->quota_status = MediaQuotaStatus::OptimizedSize;
$media->saveQuietly();
return true;
}
/**
* Refund a deleted media's currently-reflected size to its owner's
* storage_used, moving it to Subtracted.
*
* Refunds the raw size when still OriginalSize, the optimized size when
* OptimizedSize, and nothing when Pending (never charged) or already
* Subtracted. Guarded by quota_status so it refunds once.
*
* @return bool True when a refund was applied, false when skipped.
*/
public static function subtractMedia(Media $media): bool
{
if (! $media->user_id || ! $media->quota_status->isCharged()) {
return false;
}
$bytes = $media->quota_status === MediaQuotaStatus::OriginalSize
? (int) ($media->original_size ?? $media->size)
: (int) $media->size;
if ($bytes > 0) {
self::decrementStorageUsed($media->user_id, $bytes);
}
// The media row is deleted right after this in the delete pipeline, so
// only stamp the status when the row still exists (e.g. tests).
if ($media->exists) {
$media->quota_status = MediaQuotaStatus::Subtracted;
$media->saveQuietly();
}
return true;
}
/** /**
* Decrement a user's cached storage_used by the size (in bytes) of removed * Decrement a user's cached storage_used by the size (in bytes) of removed
* media, without re-summing the whole media table. * media, without re-summing the whole media table.

@ -14,11 +14,12 @@ return [
/* /*
| Account storage reconciler | Account storage reconciler
| |
| Optional weekly scheduled task that recalculates users.storage_used from | Weekly scheduled task that recalculates users.storage_used from actual
| actual media, correcting drift on accounts that never upload or delete. | media, correcting drift. The incremental charge/refund on the
| The upload/delete/read paths already self-heal stale counters, so this is | upload/delete paths is best-effort (a finalize-job retry can over-count),
| a background hygiene job and is disabled by default. | so this reconciler is the authoritative backstop and is enabled by
| default. The upload/delete/read paths also self-heal stale counters inline.
*/ */
'account_storage_reconcile' => env('ACCOUNT_STORAGE_RECONCILE', false), 'account_storage_reconcile' => env('ACCOUNT_STORAGE_RECONCILE', true),
]; ];

@ -0,0 +1,47 @@
<?php
use App\Enums\MediaQuotaStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Storage-accounting columns for the media quota lifecycle.
*
* `original_size` records the raw uploaded byte count. Upload-time quota
* enforcement is based on the raw size (a user cannot upload an original
* larger than their remaining quota).
*
* `quota_status` tracks how much of the media is currently reflected in the
* owner's users.storage_used counter (see App\Enums\MediaQuotaStatus). The
* quota is charged the raw size at upload, corrected down to the optimized
* size by the async finalize job, and refunded on delete. Each transition
* is guarded by this status so retries cannot double-apply a delta.
*/
public function up(): void
{
// New uploads begin their quota lifecycle at pending.
Schema::table('media', function (Blueprint $table) {
$table->unsignedInteger('original_size')->nullable()->after('size');
$table->string('quota_status', 20)
->default(MediaQuotaStatus::Pending->value)
->index()
->after('original_size');
});
// Existing rows were already counted at their (optimized) `size` under
// the previous scheme, so mark them optimized_size to keep the delete
// refund correct. Skipped for empty tables (fresh installs).
DB::table('media')->update(['quota_status' => MediaQuotaStatus::OptimizedSize->value]);
}
public function down(): void
{
Schema::table('media', function (Blueprint $table) {
$table->dropColumn(['original_size', 'quota_status']);
});
}
};

@ -0,0 +1,123 @@
<?php
use App\Enums\MediaQuotaStatus;
use App\Models\Media;
use App\Models\User;
use App\Services\UserStorageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Media storage accounting: enforce on raw, charge raw, correct to optimized
|--------------------------------------------------------------------------
|
| Upload-time quota enforcement uses the RAW upload size (a user cannot upload
| an original larger than their remaining quota). The raw size is charged to
| storage_used immediately at upload (quota_status = OriginalSize) so the quota
| never under-counts while optimization is queued, then the async finalize job
| corrects it down to the optimized media.size (quota_status = OptimizedSize).
|
*/
it('rejects an upload when the raw size exceeds the remaining quota', function () {
Storage::fake(config('filesystems.default'));
config([
'pixelfed.enforce_account_limit' => true,
'pixelfed.max_account_size' => 1000, // 1000 KB cap
]);
$user = User::factory()->create();
$user->refresh();
// Already near the cap.
$user->storage_used = 990;
$user->storage_used_updated_at = now();
$user->save();
// A ~1.4 MB raw image pushes the raw-size projection over the cap.
$file = UploadedFile::fake()->image('big.jpg', 4000, 4000)->size(1400);
$this->actingAs($user)
->post('/api/compose/v0/media/upload', ['file' => $file])
->assertStatus(403);
// Nothing charged: the counter is unchanged and no media row persisted.
$user->refresh();
expect((int) $user->storage_used)->toBe(990);
expect(Media::where('user_id', $user->id)->count())->toBe(0);
});
it('charges the raw size at upload and marks the media OriginalSize', function () {
Storage::fake(config('filesystems.default'));
config([
'pixelfed.enforce_account_limit' => true,
'pixelfed.max_account_size' => 1000000,
]);
// Don't run the finalize jobs; assert the upload (controller) half only.
Bus::fake();
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 100;
$user->storage_used_updated_at = now();
$user->save();
$file = UploadedFile::fake()->image('ok.jpg', 1080, 1080);
$this->actingAs($user)
->post('/api/compose/v0/media/upload', ['file' => $file])
->assertOk();
$media = Media::where('user_id', $user->id)->first();
expect($media)->not->toBeNull()
->and((int) $media->original_size)->toBeGreaterThan(0)
->and($media->quota_status)->toBe(MediaQuotaStatus::OriginalSize);
// storage_used grew by the raw upload size immediately (never under-counts).
$expected = 100 + (int) ceil($media->original_size / 1000);
$user->refresh();
expect((int) $user->storage_used)->toBe($expected);
});
it('corrects the quota down to the optimized size when the finalize job runs', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
// Uploaded (raw 900 KB) and charged at upload.
$media = Media::create([
'status_id' => null,
'profile_id' => $user->profile->id,
'user_id' => $user->id,
'media_path' => 'public/m/_v2/1/final.jpeg',
'mime' => 'image/jpeg',
'size' => 900000,
'original_size' => 900000,
'quota_status' => MediaQuotaStatus::Pending,
'order' => 1,
]);
UserStorageService::chargeOriginal($media);
$user->refresh();
expect((int) $user->storage_used)->toBe(900);
// Finalize job optimizes to 320 KB then corrects the quota.
$media->size = 320000;
$media->save();
UserStorageService::chargeOptimized($media->fresh());
$user->refresh();
expect((int) $user->storage_used)->toBe(320)
->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OptimizedSize);
});

@ -1,9 +1,11 @@
<?php <?php
use App\Enums\MediaQuotaStatus;
use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline;
use App\Models\Media; use App\Models\Media;
use App\Models\Status; use App\Models\Status;
use App\Models\User; use App\Models\User;
use App\Services\UserStorageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -77,7 +79,8 @@ it('decrements the owner storage_used by the deleted media size', function () {
$user->storage_used_updated_at = now(); $user->storage_used_updated_at = now();
$user->save(); $user->save();
// A single orphaned media row of 500,000 bytes (~500 KB). // A single orphaned media row of 500,000 bytes (~500 KB) that was charged
// (quota_status reflects the optimized size on the counter).
$media = Media::create([ $media = Media::create([
'status_id' => null, 'status_id' => null,
'profile_id' => $user->profile->id, 'profile_id' => $user->profile->id,
@ -85,6 +88,7 @@ it('decrements the owner storage_used by the deleted media size', function () {
'media_path' => 'public/m/_v2/1/orphan.jpeg', 'media_path' => 'public/m/_v2/1/orphan.jpeg',
'mime' => 'image/jpeg', 'mime' => 'image/jpeg',
'size' => 500000, 'size' => 500000,
'quota_status' => MediaQuotaStatus::OptimizedSize,
'order' => 1, 'order' => 1,
]); ]);
@ -118,6 +122,7 @@ it('clamps storage_used at zero when the deleted media is larger than the counte
'media_path' => 'public/m/_v2/1/big.jpeg', 'media_path' => 'public/m/_v2/1/big.jpeg',
'mime' => 'image/jpeg', 'mime' => 'image/jpeg',
'size' => 500000, 'size' => 500000,
'quota_status' => MediaQuotaStatus::OptimizedSize,
'order' => 1, 'order' => 1,
]); ]);
@ -160,3 +165,80 @@ it('does not change storage_used when deletion is skipped for attached media', f
$user->refresh(); $user->refresh();
expect((int) $user->storage_used)->toBe(4242); expect((int) $user->storage_used)->toBe(4242);
}); });
/*
|--------------------------------------------------------------------------
| MediaDeletePipeline refund by quota lifecycle
|--------------------------------------------------------------------------
|
| Delete refunds whatever the media currently reflects in storage_used, per its
| quota_status: the optimized size when OptimizedSize, the raw size when
| OriginalSize, and nothing when it was never charged (Pending). This keeps the
| charge and refund symmetric across the lifecycle.
|
*/
it('charge then delete cancels to baseline via the quota lifecycle', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
// Uploaded (raw 900 KB) then optimized to 250 KB.
$media = Media::create([
'status_id' => null,
'profile_id' => $user->profile->id,
'user_id' => $user->id,
'media_path' => 'public/m/_v2/1/charged.jpeg',
'mime' => 'image/jpeg',
'size' => 900000,
'original_size' => 900000,
'quota_status' => MediaQuotaStatus::Pending,
'order' => 1,
]);
UserStorageService::chargeOriginal($media);
$media->size = 250000;
$media->save();
UserStorageService::chargeOptimized($media->fresh());
$user->refresh();
expect((int) $user->storage_used)->toBe(250);
(new MediaDeletePipeline($media->fresh()))->handle();
// Back to zero: refund of the optimized size cancels the net charge.
$user->refresh();
expect((int) $user->storage_used)->toBe(0);
});
it('does not refund storage_used for a never-charged (Pending) media', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 800;
$user->storage_used_updated_at = now();
$user->save();
// Uploaded but deleted before it was charged (e.g. the DM/remote race).
$media = Media::create([
'status_id' => null,
'profile_id' => $user->profile->id,
'user_id' => $user->id,
'media_path' => 'public/m/_v2/1/pending.jpeg',
'mime' => 'image/jpeg',
'size' => 500000,
'original_size' => 500000,
'quota_status' => MediaQuotaStatus::Pending,
'order' => 1,
]);
(new MediaDeletePipeline($media))->handle();
expect(Media::whereId($media->id)->exists())->toBeFalse();
// Untouched: a Pending row never contributed to the counter.
$user->refresh();
expect((int) $user->storage_used)->toBe(800);
});

@ -1,5 +1,6 @@
<?php <?php
use App\Enums\MediaQuotaStatus;
use App\Models\Media; use App\Models\Media;
use App\Models\User; use App\Models\User;
use App\Services\UserStorageService; use App\Services\UserStorageService;
@ -533,3 +534,197 @@ it('recalculate command with --stale recomputes never-calculated users', functio
$user->refresh(); $user->refresh();
expect((int) $user->storage_used)->toBe(250); expect((int) $user->storage_used)->toBe(250);
}); });
/*
|--------------------------------------------------------------------------
| Media quota lifecycle (chargeOriginal / chargeOptimized / subtractMedia)
|--------------------------------------------------------------------------
|
| The amount a media reflects in storage_used changes over its life:
| Pending -> nothing charged
| OriginalSize -> raw size charged at upload
| OptimizedSize -> corrected down to the optimized size by the finalize job
| Subtracted -> refunded on delete
| Each transition is guarded by quota_status so it applies at most once.
|
*/
/**
* Build a pending-quota media row with distinct raw and (eventual) optimized
* sizes.
*/
function makeQuotaMedia(User $user, int $originalBytes, ?int $optimizedBytes = null): Media
{
return Media::create([
'status_id' => null,
'profile_id' => $user->profile->id,
'user_id' => $user->id,
'media_path' => 'public/m/_v2/1/q'.uniqid().'.jpeg',
'mime' => 'image/jpeg',
'size' => $optimizedBytes ?? $originalBytes,
'original_size' => $originalBytes,
'quota_status' => MediaQuotaStatus::Pending,
'order' => 1,
]);
}
it('chargeOriginal adds the raw size and moves Pending to OriginalSize', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 300;
$user->storage_used_updated_at = now();
$user->save();
// Raw 500 KB, will later optimize to 200 KB.
$media = makeQuotaMedia($user, 500000, 200000);
expect(UserStorageService::chargeOriginal($media))->toBeTrue();
$user->refresh();
expect((int) $user->storage_used)->toBe(800) // 300 + 500 raw
->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OriginalSize);
});
it('chargeOriginal is a no-op when not Pending', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 500;
$user->storage_used_updated_at = now();
$user->save();
$media = makeQuotaMedia($user, 400000);
$media->quota_status = MediaQuotaStatus::OriginalSize;
$media->save();
expect(UserStorageService::chargeOriginal($media))->toBeFalse();
$user->refresh();
expect((int) $user->storage_used)->toBe(500);
});
it('chargeOptimized corrects OriginalSize down to the optimized size', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
// Raw 900 KB charged, optimizes to 250 KB.
$media = makeQuotaMedia($user, 900000, 250000);
UserStorageService::chargeOriginal($media);
$user->refresh();
expect((int) $user->storage_used)->toBe(900);
// Correct down by (900 - 250) = 650 KB -> 250 KB remains.
expect(UserStorageService::chargeOptimized($media->fresh()))->toBeTrue();
$user->refresh();
expect((int) $user->storage_used)->toBe(250)
->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OptimizedSize);
});
it('chargeOptimized is a no-op unless the media is at OriginalSize', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 100;
$user->storage_used_updated_at = now();
$user->save();
// Still Pending -> chargeOptimized must not run.
$media = makeQuotaMedia($user, 500000, 200000);
expect(UserStorageService::chargeOptimized($media))->toBeFalse();
$user->refresh();
expect((int) $user->storage_used)->toBe(100);
});
it('chargeOptimized adjusts up when optimization grew the file', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
// Raw 100 KB, "optimized" ends up 150 KB (rare, e.g. format change).
$media = makeQuotaMedia($user, 100000, 150000);
UserStorageService::chargeOriginal($media);
UserStorageService::chargeOptimized($media->fresh());
$user->refresh();
expect((int) $user->storage_used)->toBe(150);
});
it('subtractMedia refunds the raw size for an OriginalSize media', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
// Video-like: charged raw, never optimized (stays OriginalSize).
$media = makeQuotaMedia($user, 400000);
UserStorageService::chargeOriginal($media);
$user->refresh();
expect((int) $user->storage_used)->toBe(400);
expect(UserStorageService::subtractMedia($media->fresh()))->toBeTrue();
$user->refresh();
expect((int) $user->storage_used)->toBe(0)
->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::Subtracted);
});
it('subtractMedia refunds the optimized size for an OptimizedSize media', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 0;
$user->storage_used_updated_at = now();
$user->save();
$media = makeQuotaMedia($user, 900000, 250000);
UserStorageService::chargeOriginal($media);
UserStorageService::chargeOptimized($media->fresh());
$user->refresh();
expect((int) $user->storage_used)->toBe(250);
UserStorageService::subtractMedia($media->fresh());
$user->refresh();
expect((int) $user->storage_used)->toBe(0);
});
it('subtractMedia is a no-op for a never-charged (Pending) media', function () {
$user = User::factory()->create();
$user->refresh();
$user->storage_used = 800;
$user->storage_used_updated_at = now();
$user->save();
$media = makeQuotaMedia($user, 500000);
expect(UserStorageService::subtractMedia($media))->toBeFalse();
$user->refresh();
expect((int) $user->storage_used)->toBe(800);
});
it('a full upload -> optimize -> delete lifecycle nets to zero', function () {
$user = User::factory()->create();
$user->refresh();
$baseline = (int) $user->storage_used;
$media = makeQuotaMedia($user, 900000, 250000);
UserStorageService::chargeOriginal($media); // +900
UserStorageService::chargeOptimized($media->fresh()); // -650 -> 250
UserStorageService::subtractMedia($media->fresh()); // -250 -> baseline
$user->refresh();
expect((int) $user->storage_used)->toBe($baseline);
});

Loading…
Cancel
Save