Queued Import
File dengan jumlah row melebihi Importer::queueAfter() diberikan ke PandaPanel\Jobs\RunPanelImport alih-alih dibaca di dalam request. Pengguna langsung diberi tahu bahwa import sudah dimulai, kemudian hasil akhir — termasuk link menuju row yang gagal diimport — datang sebagai notification setelah worker selesai.
Gunakan halaman ini ketika menentukan threshold, ketika queued import tidak pernah tiba, atau sebelum memutuskan untuk melakukan retry.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Imports;
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Imports\ImportColumn;
use PandaPanel\Actions\Imports\Importer;
final class ProductImporter extends Importer
{
/**
* @return class-string<Model>
*/
public static function model(): string
{
return Product::class;
}
/**
* @return list<ImportColumn>
*/
public static function columns(): array
{
return [
ImportColumn::make('sku')->required()->rules(['string', 'max:64']),
ImportColumn::make('name')->required(),
];
}
/**
* Anything over two hundred rows goes to a worker.
*/
public static function queueAfter(): int
{
return 200;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
php artisan queue:workThreshold
File benar-benar dibaca untuk menghitung row sebelum keputusan queue dibuat, bukan diperkirakan. Perkiraan dapat membuat file besar tetap berjalan di request atau file kecil masuk queue yang tidak sedang diproses. Untuk XLSX, proses hitung row tetap menerapkan batas 64 MiB per XML part; queue bukan jalur untuk melewati workbook yang terlalu besar untuk diparsing dengan aman.
$rows = ImportRun::countRows($local); // the header is not a row
if ($importer::queueAfter() >= 0 && $rows > $importer::queueAfter()) {
RunPanelImport::dispatch($importer, $stored, $mapping, $owner, $panel->getId());
}2
3
4
5
queueAfter() | Behavior |
|---|---|
0 | selalu queued |
500 | default — queued jika lebih dari 500 row |
| angka negatif apa pun | tidak pernah queued, berapa pun isi file |
Request kemudian langsung mengembalikan toast informatif:
Your import has started. You will be notified when it finishes.Job
use PandaPanel\Jobs\RunPanelImport;
new RunPanelImport(
string $importer, // class-string<Importer>
string $path, // where the uploaded file is
array $mapping, // array<string, int> — column name => position
int|string $owner, // the key of the user who uploaded it
string $panelId,
);2
3
4
5
6
7
8
9
File sudah berada di disk ketika job berjalan — proses upload telah menyimpannya, dan job membawa path, bukan isi spreadsheet. Memasukkan seluruh spreadsheet ke queue payload berarti menyimpan spreadsheet itu sendiri di backend queue/database.
handle() melakukan:
- me-resolve panel berdasarkan id dan menjadikannya current panel, karena model importer, scope, dan URL panel dibaca melalui konteks tersebut;
- menjalankan
ImportRun::run()— reader yang sama dengan jalur inline; - menghapus file upload, karena file tersebut adalah sarana proses, bukan record;
- mencari owner menggunakan
Auth::getProvider()->retrieveById()lalu mengirim notification.
Retry: tepat satu attempt
public int $tries = 1;Ini disengaja. Import menulis row; jika proses gagal setengah jalan, sebagian row mungkin sudah tersimpan dan tidak ada cara generik untuk mengetahui mana yang berhasil — importer sendiri yang menentukan arti sebuah row, dan hanya importer yang memiliki unique key yang mungkin aman untuk direplay. Retry otomatis dapat mengubah satu import bermasalah menjadi dua kali penulisan, sementara kegagalan kedua terlihat sama seperti yang pertama.
Karena itu kegagalan dilaporkan, bukan diretry. Pengguna menerima informasi tentang hasil yang sudah sempat masuk lalu meng-upload sisanya kembali. Dari sisi otomatisasi memang kurang nyaman, tetapi secara manual jauh lebih aman. Export merupakan kasus sebaliknya dan menggunakan konfigurasi retry berbeda — lihat Queued export.
Failure per-row bukan job failure. ImportRun mengumpulkannya, sehingga file dengan 400 row invalid tetap dianggap job berhasil dan menghasilkan failure report. Job hanya gagal ketika file tidak dapat dibaca.
Yang diterima pengguna
Saat selesai, notification selalu dikirim — berbeda dari inline path yang tidak menambah notification jika semua row berhasil:
$notification = Notification::make('import-finished')
->title($importer::completedMessage($result['imported'], $result['failed']))
->icon($result['failed'] === 0 ? 'check' : 'triangle-alert')
->persistent();
$result['failed'] === 0
? $notification->success()
: $notification->warning();
if ($result['report'] !== null) {
$notification->actions([
NotificationAction::make('failed-rows')
->label('Download failed rows')
->url(/* panel.{id}.import-file */),
]);
}
$notification->send($user);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Request yang memulai import sudah lama selesai, sehingga notification adalah satu-satunya saluran untuk memberi tahu hasil. Failure report ikut dibawa sebagai action pada notification agar pengguna tidak perlu mencarinya sendiri — report inilah yang membuat partial import tetap dapat diterima.
Jika job gagal:
Notification::make('import-failed')
->title('Import failed')
->body($exception?->getMessage() ?? 'The file could not be read.')
->danger()
->icon('triangle-alert')
->persistent()
->send($user);2
3
4
5
6
7
failed() juga menghapus file upload. Tanpa itu, import gagal akan meninggalkan salinan data pelanggan di disk tanpa ada proses lain yang akan membersihkannya, dan justru pada kondisi failure biasanya tidak ada orang yang sedang mengawasi file tersebut. Pesan exception dipertahankan karena reader yang mengatakan "That file is not a readable spreadsheet." memberi pengguna langkah perbaikan yang konkret.
Jika owner sudah tidak ada — account dihapus antara upload dan completion — tidak ada notification dan tidak ada error tambahan. Ini race condition yang normal, bukan kegagalan kedua yang perlu dilempar dari failure handler.
Path yang diterima queued import
Ini adalah satu bagian di mana jalur inline dan queued tidak identik, dan penting dipahami sebelum mengandalkan queued import.
| Jalur | Path yang diberikan |
|---|---|
| inline | Storage::disk($importer::disk())->path($stored) — absolute filesystem path |
| queued | $stored — path relatif terhadap disk root, sesuai hasil upload |
ImportRun membuka path yang diberikan menggunakan fopen() atau ZipArchive::open(). Akibatnya, pada jalur queued, relative path tersebut di-resolve terhadap working directory proses worker, bukan terhadap root disk. Dengan default disk local yang root-nya storage_path('app/private'), path panel-imports/abc.csv tidak resolve dari project root: pembacaan melempar SpreadsheetException, job gagal, upload dihapus, dan pengguna mendapat pesan "Cannot read panel-imports/abc.csv."
Sampai perbedaan ini ditutup, ada dua pilihan yang dapat diandalkan:
// 1. Keep the read in the request, where the path is absolute.
public static function queueAfter(): int
{
return -1;
}2
3
4
5
// 2. Dispatch the job yourself with an absolute path.
use Illuminate\Support\Facades\Storage;
use PandaPanel\Jobs\RunPanelImport;
RunPanelImport::dispatch(
ProductImporter::class,
Storage::disk(ProductImporter::disk())->path('panel-imports/prices.csv'),
['sku' => 0, 'name' => 1],
$user->getKey(),
'admin',
)->onQueue('imports');2
3
4
5
6
7
8
9
10
11
Pada pilihan kedua, pemanggilan Storage::disk(…)->delete($path) milik job tidak lagi cocok dengan path relative pada disk, sehingga Anda perlu menghapus upload sendiri setelah import selesai.
Apa pun pilihan Anda, jalankan satu alur end-to-end di staging sebelum mengaktifkan threshold di production. Menemukan perbedaan ini melalui notification "Import failed" setelah deployment adalah cara yang buruk untuk mengetahuinya.
Mendispatch job sendiri
Constructor bersifat public, sehingga console command dapat mengantrikan import untuk file yang sudah dimiliki aplikasi:
use App\Panels\Admin\Resources\Products\Imports\ProductImporter;
use PandaPanel\Actions\Imports\ImportRun;
use PandaPanel\Jobs\RunPanelImport;
$path = storage_path('app/private/panel-imports/prices.csv');
RunPanelImport::dispatch(
ProductImporter::class,
$path,
ImportRun::guessMapping(ProductImporter::class, ImportRun::headings($path)),
$user->getKey(),
'admin',
);2
3
4
5
6
7
8
9
10
11
12
13
RunPanelImport menggunakan trait Laravel Queueable dan tidak menentukan connection atau queue sendiri, sehingga mengikuti default aplikasi kecuali Anda override saat dispatch. Panel id harus terdaftar; id yang tidak dikenal melempar PanelRegistrationException::unknownPanel() di dalam job.
Testing
use Illuminate\Support\Facades\Storage;
use PandaPanel\Jobs\RunPanelImport;
it('deletes the uploaded file when an import fails', function (): void {
Storage::fake(UserImporter::disk());
Storage::disk(UserImporter::disk())->put('imports/people.csv', "name\nAda\n");
$job = new RunPanelImport(
UserImporter::class,
'imports/people.csv',
['name' => 0],
$this->user->getKey(),
'admin',
);
$job->failed(new RuntimeException('unsupported file format'));
expect(Storage::disk(UserImporter::disk())->exists('imports/people.csv'))->toBeFalse();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fakePanelNotifications();
$job->failed(new RuntimeException('column count mismatch on row 12'));
assertPanelNotificationSentTo($this->user, 'Import failed');2
3
4
5
Gotchas
- Queued import membutuhkan worker. Dengan
QUEUE_CONNECTION=sync, job berjalan inline — yang secara kebetulan melewati perbedaan path di atas karenasyncberjalan pada proses dan working directory yang sama dengan request, tetapi sekaligus menghilangkan manfaat queue threshold. - Satu attempt, disengaja. Jangan menaikkan
$triesdengan meng-extend job ini. Row yang sudah ditulis oleh attempt gagal akan ditulis lagi. - Job mengikat panel, bukan tenant.
Tenancy::bind()terjadi di middlewareResolveTenant, sedangkan worker tidak menjalankan middleware tersebut. Importer yangresolve()-nya melakukan query tenant-scoped model tidak memiliki tenant, danResource::query()pada tenant-scoped resource akan melemparPanelRegistrationException::noCurrentTenant(). Bungkus dispatch sendiri menggunakanPandaPanel\Tenancy\Tenancy::for($tenant, …)jika membutuhkan tenant context. - Mapping diputuskan di request awal. Job menerima posisi, bukan heading, sehingga file yang kolomnya berubah antara upload dan worker run tidak melakukan mapping ulang.
- Upload dihapus pada kedua hasil. Success maupun failure sama-sama menghapusnya. Jika file asli perlu dipertahankan, copy ke lokasi lain sebelum dispatch.
- Queued import bersih tetap mengirim notification. Ini berbeda dari inline path secara sengaja karena sudah tidak ada response HTTP yang dapat membawa kabar selesai.
Lihat juga
- Class importer —
queueAfter(),chunkSize(),completedMessage() - ImportAction
- Failure report
- Queued export
- Notification import dan export
- Storage dan cleanup
- Queue di production
- Testing notification