Zum Hauptinhalt springen

Local-Filesystem-Sharding

Stand seit 2026-05-28 — Production läuft auf PUBLIC_STORAGE_DRIVER=local. Profil-/Video-/OG-Image-Files leben physisch unter storage/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 per storage:reset-images verworfen und durch die regulären Scraper-Runden frisch + bereits sharded neu geschrieben. Grund: auf dem CIFS/SMB-Mount ist ein file-weises Storage::move() (~382 ms/Move) bei Millionen Files praktisch unbrauchbar (Wochen). Die shard-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_URL muss auf ${APP_URL}/storage zeigen (nicht mehr auf die alte RustFS/R2-Domain) und der Symlink via php artisan storage:link gesetzt sein — sonst liefern alle Storage::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:

AssetAlgorithmusBeispiel
Profile-Imagesocial_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-Postanalog zu Profile, prefix instagram_posts/ (für Plan 44)social_profile_id=42 → instagram_posts/042/000/42
YouTube-Video-Thumbnailyoutube_videos/{sha1[0..1]}/{sha1[2..3]}/{video_id}video_id="dQw4w9WgXcQ" → youtube_videos/2f/c7/dQw4w9WgXcQ
OG-Imageog-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

CommandPhaseBeschreibung
storage:shard-audit [--report] [--audit-only]0Pre-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]2Migriert social_profile_images.path von flat auf sharded
storage:shard-migrate-videos3Analog für youtube_videos.thumbnail_path
storage:shard-migrate-og-images4Analog für public_explorer_profiles.og_image_path
storage:shard-verify [--dry-run]5Finaler DB-vs-Filesystem-Konsistenz-Pass
storage:cloudflare-purge5Cloudflare-Cache-Purge (Future-Ready-Stub, heute no-op ohne CDN)
storage:purge-orphans [--orphan-also-old-paths]5Erweitert um Orphan-Suche an alten flachen Pfaden
storage:health [--json]8.2Misst Listing-Performance + Disk-Usage; nutzbar in App-Monitoring
storage:shard-status8.3Migrations-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_ID in .env gesetzt sind: führt purge_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 (siehe config/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:

SzenarioRows1 Worker4 Worker8 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-Berechnung
  • app/Services/Storage/StorageHealthCheck.php — Health-Check-Service
  • app/Console/Commands/Storage/ — alle 8 Phase-Commands
  • app/Models/SocialProfileImage.phpvariantPath() + Accessor-Hüllen
  • app/Models/YouTubeVideo.phpvariantThumbnailPath() + Accessor-Hüllen
  • docs-agents/plans/aktiv/84-filesystem-sharding-migration.md — vollständiger Plan
  • docs-agents/plans/aktiv/44-instagram-post-thumbnails-r2.md — wartet auf Plan-84-Phase-1