Local-Filesystem-Sharding
Stand seit 2026-05-28 — Production läuft auf
PUBLIC_STORAGE_DRIVER=local. Profil-/Video-/OG-Image-Files leben physisch unterstorage/app/public/und werden über ein 2-Level-Sharding-Pyramide auf bucket-Subverzeichnisse verteilt.Roll-out-Hinweis (tatsächlich gegangener Weg): Der Bestand wurde nicht per
storage:shard-migrate-*umgezogen, sondern perstorage:reset-imagesverworfen und durch die regulären Scraper-Runden frisch + bereits sharded neu geschrieben. Grund: auf dem CIFS/SMB-Mount ist ein file-weisesStorage::move()(~382 ms/Move) bei Millionen Files praktisch unbrauchbar (Wochen). Dieshard-migrate-*-Commands weiter unten bleiben für In-Place-Setups (echtes ext4 ohne Netz-Mount) verfügbar.Pflicht nach dem Switch auf local:
PUBLIC_STORAGE_URLmuss auf${APP_URL}/storagezeigen (nicht mehr auf die alte RustFS/R2-Domain) und der Symlink viaphp artisan storage:linkgesetzt sein — sonst liefern alleStorage::url()-Links 404, obwohl die Files lokal vorhanden sind.
Anlass
Vor Plan 84 wurden alle Files in flachen Top-Level-Ordnern abgelegt:
storage/app/public/social_profiles/{social_profile_id}/{sha256}.jpg
storage/app/public/youtube_videos/{video_id}/{sha256}.jpg
storage/app/public/og-images/{platform}/{social_profile_id}.png
Bei 500k+ Profilen und 5M+ Videos summierte sich das auf eine Million Unterordner pro Top-Level — readdir() auf ext4 läuft linear über alle Directory-Entries (O(n)) und kollidiert mit max_execution_time bzw. blockiert Backup-Jobs (rsync, tar, Forge-Snapshots). Dentry-Cache-Thrash bei neuen Writes verschlimmert das.
Sharding-Schema
Die zentrale App\Services\Storage\ShardedPathBuilder-Klasse berechnet alle Pfade deterministisch:
| Asset | Algorithmus | Beispiel |
|---|---|---|
| Profile-Image | social_profiles/{b1}/{b2}/{id} mit b1=id%1000, b2=(id/1000)%1000 (jeweils 3-stellig zero-padded) | id=1234567 → social_profiles/567/234/1234567 |
| Instagram-Post | analog zu Profile, prefix instagram_posts/ (für Plan 44) | social_profile_id=42 → instagram_posts/042/000/42 |
| YouTube-Video-Thumbnail | youtube_videos/{sha1[0..1]}/{sha1[2..3]}/{video_id} | video_id="dQw4w9WgXcQ" → youtube_videos/2f/c7/dQw4w9WgXcQ |
| OG-Image | og-images/{platform}/{b1}/{b2}/{id}.png (Config-Präfix bleibt unverändert) | platform="youtube", id=42 → og-images/youtube/042/000/42.png |
Effekt: Pro Verzeichnis maximal ~1000 Einträge → readdir() läuft in <50 ms statt Minuten.
Schema-Versionierung
ShardedPathBuilder::SCHEMA_VERSION = 1 und storage_shard_migrations.schema_version werden von Tag 1 an gepflegt. Spätere Schema-Wechsel (z.B. tieferes Sharding bei 10⁹+ Profilen) können gezielt re-migriert werden.
Variant-Pfad-Derivation (Phase 1.5 + 7)
Die 3 Variant-Spalten path_large/medium/thumbnail (bzw. thumbnail_path_large/_medium/_thumbnail) wurden gedroppt (Migration 2026_05_28_180000_drop_variant_path_columns). Variant-Pfade werden zur Laufzeit aus dem Original-path via variantPath($size) abgeleitet:
$image->variantPath('large'); // hash_lg.webp
$image->variantPath('medium'); // hash_md.webp
$image->variantPath('thumbnail'); // hash_sm.webp
Backward-Compat: die Accessor-Hüllen getPathLargeAttribute() etc. liefern weiterhin via $image->path_large den derived Pfad zurück. Reader-Code muss nicht angefasst werden.
Effekt: ~1 GB DB-Speicher gespart (6 Spalten × ~1,5M Rows × ~120 Bytes).
Console-Commands
| Command | Phase | Beschreibung |
|---|---|---|
storage:shard-audit [--report] [--audit-only] | 0 | Pre-Migration-Audit: Driver-Check, Row-Counts, Existenz-Sampling, Filesystem-Check, Move-Probe, Migrationsdauer-Schätzung |
storage:shard-migrate-profiles [--dry-run] [--queue-refetch] [--limit=N] | 2 | Migriert social_profile_images.path von flat auf sharded |
storage:shard-migrate-videos | 3 | Analog für youtube_videos.thumbnail_path |
storage:shard-migrate-og-images | 4 | Analog für public_explorer_profiles.og_image_path |
storage:shard-verify [--dry-run] | 5 | Finaler DB-vs-Filesystem-Konsistenz-Pass |
storage:cloudflare-purge | 5 | Cloudflare-Cache-Purge (Future-Ready-Stub, heute no-op ohne CDN) |
storage:purge-orphans [--orphan-also-old-paths] | 5 | Erweitert um Orphan-Suche an alten flachen Pfaden |
storage:health [--json] | 8.2 | Misst Listing-Performance + Disk-Usage; nutzbar in App-Monitoring |
storage:shard-status | 8.3 | Migrations-Fortschritts-Report pro Asset-Typ |
Migrations-Tabellen
storage_shard_migrations— Single Source of Truth für Resumability (type,record_id,schema_version,status∈ done/missing/error)storage_shard_locks— Worker-Lock-Tabelle (PID + Timestamp); 10-Min-Stale-Cleanup automatisch
Ablauf einer kompletten Migration
# 1. Audit + Schema-Prep (1x)
php artisan storage:shard-audit --report
# 2. Profile-Migration (~35-40k Rows × 4 Files = ~140-160k Files, ~10 Min)
php artisan storage:shard-migrate-profiles
# 3. Video-Migration (~500k-1.5M Rows × 4 Files = ~2-6M Files, ~30 Min - 1,5 h)
php artisan storage:shard-migrate-videos --queue-refetch
# 4. OG-Image-Migration (~15k Rows, ~2 Min)
php artisan storage:shard-migrate-og-images
# 5. Verifikation
php artisan storage:shard-verify --dry-run
php artisan storage:shard-verify
# 6. Alte flache Pfade aufräumen
php artisan storage:purge-orphans --orphan-also-old-paths
# 7. (optional) Cloudflare-Cache purgen wenn CDN aktiv
php artisan storage:cloudflare-purge
Migrations-Status während/nach dem Lauf:
php artisan storage:shard-status
Storage-Health (Phase 8.2)
Der StorageHealthCheck-Service misst die Listing-Performance auf den 3 sharded Top-Level-Präfixen. Er ist als Sub-System "Storage" auf /admin/app-monitoring eingebunden und erzeugt einen DEGRADED-Status wenn ein Listing > 1s dauert (Symptom eines flacher gewordenen Pfads oder Filesystem-Druck).
Cloudflare-Purge-Stub
storage:cloudflare-purge ist ein Future-Ready-Command:
- Heute kein CDN aktiv → no-op Warning + exit 0
- Sobald
CLOUDFLARE_API_TOKEN+CLOUDFLARE_ZONE_IDin.envgesetzt sind: führtpurge_everything: true-Call gegen die Cloudflare-API aus (beide Variablen existieren bereits für AI-Crawl-Analytics/R2 — Plan 84 bringt keine neuen.env-Einträge mit)
Voraussetzungen
PUBLIC_STORAGE_DRIVER=local(sieheconfig/filesystems.php:50+.env.example:117)- Storage-Volume mit ausreichend Inodes (ext4 mit Default-Density 1/16k = ~64M auf 1TB)
- Migration läuft auf dem gleichen Mount-Point (atomare
rename()syscalls); Cross-Device-Setups fallen automatisch auf langsame copy+delete-Logik zurück (Phase 0 Move-Probe detektiert das)
Migrations-Performance
Storage::move() = PHP rename() = atomare Inode-Operation auf ext4. Pro File ~0,5–2 ms, gesamt ~7–15 ms pro Row (4 Files + 1 DB-Update). Realistisch:
| Szenario | Rows | 1 Worker | 4 Worker | 8 Worker |
|---|---|---|---|---|
| Best (550k Rows = 2,2M Files) | 550.000 | ~64 Min | ~16 Min | ~8 Min |
| Mittel (1M Rows = 4M Files) | 1.000.000 | ~2,8 h | ~42 Min | ~21 Min |
| Worst (1,5M Rows = 6M Files) | 1.500.000 | ~6,3 h | ~94 Min | ~47 Min |
Kein Duplikat-Spike — rename() belegt keinen zusätzlichen Plattenplatz.
Verwandte Dateien
app/Services/Storage/ShardedPathBuilder.php— Pfad-Berechnungapp/Services/Storage/StorageHealthCheck.php— Health-Check-Serviceapp/Console/Commands/Storage/— alle 8 Phase-Commandsapp/Models/SocialProfileImage.php—variantPath()+ Accessor-Hüllenapp/Models/YouTubeVideo.php—variantThumbnailPath()+ Accessor-Hüllendocs-agents/plans/aktiv/84-filesystem-sharding-migration.md— vollständiger Plandocs-agents/plans/aktiv/44-instagram-post-thumbnails-r2.md— wartet auf Plan-84-Phase-1