Implementasi Sistem Upload & Proses Secara Asynchronous dengan Golang
Upload file yang langsung diproses secara synchronous sering jadi sumber masalah ketika ukuran file membesar atau logic pemrosesan makin berat. Request HTTP yang menunggu proses parsing Excel, validasi ribuan baris, atau transformasi data bisa timeout, membuat user frustasi, dan membebani server tanpa alasan yang jelas. Artikel ini membahas implementasi praktis pola upload-dan-proses secara asynchronous menggunakan Golang, dengan dua pendekatan worker yang punya trade-off berbeda: CLI Worker yang melakukan polling ke database, dan AWS SQS Worker yang bersifat event-driven. Kamu akan melihat struktur project, data model, contoh kode untuk masing-masing pendekatan, serta panduan kapan memilih yang mana berdasarkan karakteristik traffic dan infrastruktur yang tersedia.
Mengapa Upload Perlu Dipisah dari Proses
Ketika user upload file, ada dua hal yang sebenarnya berbeda: menerima dan menyimpan file, lalu memprosesnya. Banyak implementasi naif menggabungkan keduanya dalam satu request HTTP — file diterima, langsung diparsing, divalidasi baris per baris, disimpan ke database, baru kemudian response dikirim. Pendekatan ini bekerja untuk file kecil, tapi runtuh begitu ukuran data bertambah.
Masalah utamanya ada tiga. Pertama, timeout. Load balancer, reverse proxy, atau bahkan browser punya batas waktu tunggu response. Proses yang butuh 30 detik atau lebih akan terpotong sebelum selesai, padahal file sudah ter-upload dan resource sudah terpakai. Kedua, resource contention. Selama request HTTP itu berjalan, satu worker dari pool HTTP server tertahan hanya untuk memproses satu file. Jika ada lonjakan upload bersamaan, seluruh API bisa ikut melambat karena semua worker tersita untuk pemrosesan, bukan untuk melayani request lain. Ketiga, pengalaman pengguna yang buruk — user harus menunggu di depan layar tanpa tahu progress, dan jika koneksi putus di tengah jalan, status pemrosesan jadi tidak jelas.
Solusinya adalah memisahkan upload dari proses menjadi dua tahap independen:
TAHAP 1 (Synchronous, cepat):
✓ Terima file
✓ Simpan ke object storage
✓ Catat task ke database dengan status PENDING
✓ Response langsung ke user
TAHAP 2 (Asynchronous, di belakang):
✓ Worker mengambil task PENDING
✓ Proses file (parsing, validasi, transformasi)
✓ Update status ke DONE atau FAILED
Dengan pemisahan ini, request upload selesai dalam hitungan milidetik sampai detik, sementara pemrosesan berat berjalan di proses yang sama sekali berbeda. User mendapat task_id sebagai referensi, dan bisa mengecek statusnya kapan saja tanpa perlu menunggu di halaman yang sama.
sequenceDiagram
participant User
participant API
participant Storage
participant DB
participant Worker
User->>API: POST /upload (multipart file)
API->>Storage: Save(file)
Storage-->>API: file path
API->>DB: INSERT upload_task (status=PENDING)
DB-->>API: task_id
API-->>User: { task_id, status: PENDING }
Note over Worker: Berjalan independen
Worker->>DB: FetchPendingTask()
DB-->>Worker: task
Worker->>DB: MarkProcessing(task_id)
Worker->>Storage: Download(file path)
Worker->>Worker: Process(file)
Worker->>DB: MarkDone(task_id)
User->>API: GET /uploads/{id}
API->>DB: Query status
DB-->>API: status=DONE
API-->>User: { status: DONE }Struktur High-Level Project
Pemisahan upload dan proses sebaiknya tercermin juga di struktur kode, bukan hanya di alur eksekusi. Dua entry point berbeda — satu untuk HTTP API, satu untuk worker — memungkinkan keduanya di-deploy, di-scale, dan di-restart secara independen tanpa saling mengganggu.
/cmd
/api -> HTTP API (upload, status)
/worker -> CLI worker (polling)
/internal
/handler -> HTTP handlers
/service -> Business logic
/repository -> DB access
/model -> Struct DB
/processor -> File processing logic
/storage -> S3 / object storage abstraction
Folder /cmd berisi dua binary terpisah. /cmd/api menjalankan HTTP server yang menangani upload dan query status — proses ini ringan dan harus selalu responsif. /cmd/worker menjalankan proses background yang melakukan polling dan pemrosesan file — proses ini bisa berat dan boleh sedikit lambat, karena tidak ada user yang menunggu response langsung dari sini.
Struktur /internal mengikuti layering yang umum: handler menerima request HTTP dan melakukan parsing input, service berisi business logic (validasi, orkestrasi antar repository), repository murni untuk akses database, model berisi struct yang merepresentasikan tabel, processor khusus untuk logic pemrosesan file yang berat, dan storage sebagai abstraksi terhadap S3 atau object storage lain agar kode tidak terikat langsung ke SDK tertentu.
Pemisahan/cmd/apidan/cmd/workerjuga berarti kamu bisa men-scale keduanya secara berbeda. Misalnya menjalankan 5 replica API tapi hanya 2 replica worker, atau sebaliknya, sesuai kebutuhan beban masing-masing.
Data Model
Satu tabel upload_tasks cukup untuk melacak seluruh siklus hidup sebuah task, dari diterima hingga selesai diproses atau gagal.
type UploadTask struct {
ID int64
UserID int64
FilePath string
Status string // PENDING, PROCESSING, DONE, FAILED
ErrorMessage *string
CreatedAt time.Time
UpdatedAt time.Time
}
Field Status adalah jantung dari sistem ini. Empat nilai yang mungkin merepresentasikan state machine sederhana:
stateDiagram-v2
[*] --> PENDING: task dibuat
PENDING --> PROCESSING: worker mengambil task
PROCESSING --> DONE: proses berhasil
PROCESSING --> FAILED: proses gagal
FAILED --> PENDING: retry (opsional)
DONE --> [*]
FAILED --> [*]ErrorMessage bertipe pointer (*string) karena nilainya opsional — hanya terisi ketika status FAILED. Menggunakan pointer di sini lebih tepat dibanding string kosong, karena membedakan secara eksplisit antara “tidak ada error” dan “error dengan pesan kosong”. CreatedAt dan UpdatedAt penting untuk observability — kamu bisa menghitung berapa lama sebuah task menunggu di status PENDING, atau berapa lama proses rata-rata berjalan.
API: Upload File
Endpoint upload menjalankan flow yang sudah dijelaskan di awal: terima file, simpan ke storage, catat sebagai task, lalu response. Tidak ada logic pemrosesan berat di sini sama sekali.
Flow
1. Terima multipart upload
2. Simpan file ke object storage
3. Insert record ke DB (status = PENDING)
4. (Opsional) Push message ke SQS
5. Response ke user
Contoh Handler
func UploadHandler(w http.ResponseWriter, r *http.Request) {
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
// ANTI-PATTERN: memproses file langsung di handler
// records := parseExcel(file)
// for _, rec := range records { validateAndSave(rec) }
// -- ini memblokir request HTTP sampai seluruh file selesai diproses
// BENAR: simpan file, catat task, langsung response
path, err := storage.Save(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
taskID, err := repo.CreateUploadTask(r.Context(), path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Jika pakai SQS, publish event di sini
// sqs.Publish(taskID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"task_id": taskID,
"status": "PENDING",
})
}
Komentar anti-pattern di atas sengaja ditunjukkan sebagai kontras langsung. Memanggil parseExcel dan validasi di dalam handler akan membuat request menunggu sampai seluruh file selesai diproses — persis masalah yang ingin kita hindari. Versi yang benar berhenti setelah file tersimpan dan task tercatat; tidak ada logic berat yang dijalankan di sini.
Selalu validasi ukuran dan tipe file sebelum menyimpan ke storage. Tanpa validasi awal, user bisa mengirim file yang jauh lebih besar dari yang seharusnya didukung, atau format yang tidak bisa diproses processor, yang baru ketahuan saat worker sudah mulai bekerja.
API: Status & History
Setelah task dibuat, user butuh cara untuk memantau progress-nya. Dua endpoint sederhana sudah cukup untuk kebutuhan ini.
| Endpoint | Fungsi | Response |
|---|---|---|
GET /uploads | List history upload milik user | Array task dengan status masing-masing |
GET /uploads/{id} | Detail satu task spesifik | Status, error message (jika ada), timestamp |
Keduanya query langsung ke tabel upload_tasks, tidak perlu join kompleks atau cache tambahan untuk kasus penggunaan dasar. Karena tabel ini relatif kecil ukurannya per baris dan query-nya sederhana (filter by user_id, optional filter by status), index pada kolom user_id dan status biasanya sudah cukup untuk performa yang baik.
-- BENAR: index untuk query yang sering dipakai
CREATE INDEX idx_upload_tasks_user_id ON upload_tasks (user_id);
CREATE INDEX idx_upload_tasks_status ON upload_tasks (status);
-- ANTI-PATTERN: query tanpa index pada tabel yang terus bertambah
-- SELECT * FROM upload_tasks WHERE user_id = ? ORDER BY created_at DESC;
-- -- tanpa index, query ini melakukan full table scan begitu tabel membesar
Worker Opsi 1: CLI Worker dengan Polling DB
Pendekatan pertama adalah worker sederhana yang berjalan sebagai proses long-running, secara berkala mengecek database untuk mencari task yang menunggu diproses.
Konsep
Worker berjalan dalam infinite loop. Setiap iterasi, ia mencoba mengambil satu task dengan status PENDING. Jika ada, task langsung diubah statusnya menjadi PROCESSING agar worker lain (jika ada lebih dari satu instance) tidak mengambil task yang sama. Setelah pemrosesan selesai, status diperbarui menjadi DONE atau FAILED. Jika tidak ada task yang menunggu, worker tidur sebentar sebelum mencoba lagi.
flowchart TD
A[Mulai loop] --> B{Ada task PENDING?}
B -- Tidak --> C[Sleep 5 detik]
C --> A
B -- Ya --> D[Mark PROCESSING]
D --> E[Process file]
E --> F{Berhasil?}
F -- Ya --> G[Mark DONE]
F -- Tidak --> H[Mark FAILED + error message]
G --> A
H --> AContoh Loop Worker
for {
task, err := repo.FetchPendingTask(ctx)
if err == sql.ErrNoRows {
time.Sleep(5 * time.Second)
continue
}
if err != nil {
log.Printf("error fetching task: %v", err)
time.Sleep(5 * time.Second)
continue
}
if err := repo.MarkProcessing(ctx, task.ID); err != nil {
log.Printf("error marking task %d as processing: %v", task.ID, err)
continue
}
if err := processor.Process(task); err != nil {
repo.MarkFailed(ctx, task.ID, err.Error())
continue
}
repo.MarkDone(ctx, task.ID)
}
Loop ini sederhana, tapi punya satu kelemahan tersembunyi yang harus diwaspadai: jika ada lebih dari satu instance worker berjalan bersamaan (misalnya untuk redundancy atau scaling), keduanya bisa saja mengambil task yang sama persis di waktu yang hampir bersamaan, sebelum salah satunya selesai menandai task itu sebagai PROCESSING.
Mencegah Double Processing
Solusi paling umum adalah menggunakan row-level locking di level database saat mengambil task, sehingga hanya satu worker yang berhasil “mengklaim” task tertentu.
-- ANTI-PATTERN: SELECT biasa tanpa locking
-- SELECT * FROM upload_tasks WHERE status = 'PENDING' LIMIT 1;
-- -- dua worker bisa membaca baris yang sama sebelum salah satunya update status
-- BENAR: SELECT FOR UPDATE SKIP LOCKED di dalam transaction
BEGIN;
SELECT * FROM upload_tasks
WHERE status = 'PENDING'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE upload_tasks SET status = 'PROCESSING' WHERE id = $1;
COMMIT;
FOR UPDATE SKIP LOCKED (didukung PostgreSQL dan MySQL versi baru) memastikan satu baris hanya bisa “dipegang” oleh satu transaction pada satu waktu, dan worker lain yang menjalankan query serupa akan otomatis melompati baris yang sedang terkunci — bukannya menunggu atau gagal. Ini membuat beberapa instance worker bisa berjalan bersamaan tanpa risiko mengambil task yang sama.
Catatan Penting
SEBELUM DEPLOY CLI WORKER, PASTIKAN:
□ Query fetch task menggunakan transaction + locking (FOR UPDATE SKIP LOCKED)
□ Interval polling disesuaikan dengan SLA yang dibutuhkan (lebih pendek = lebih responsif, tapi lebih banyak query)
□ Ada mekanisme untuk task yang "stuck" di PROCESSING terlalu lama (worker crash di tengah proses)
□ Logging mencatat task_id di setiap langkah untuk memudahkan debugging
Jika worker crash tepat setelah menandai task sebagaiPROCESSINGtapi sebelum menyelesaikan pemrosesan, task itu akan “stuck” selamanya kecuali ada mekanisme tambahan — misalnya job terpisah yang mengecek task dengan statusPROCESSINGlebih lama dari threshold tertentu, lalu mengembalikannya kePENDINGuntuk diproses ulang.
CLI Worker dengan polling sangat cocok untuk workload kecil hingga menengah, terutama ketika infrastruktur belum membutuhkan kompleksitas message queue. Tidak ada dependency eksternal selain database yang sudah ada, sehingga lebih mudah dijalankan on-premise atau di environment yang sederhana.
Worker Opsi 2: AWS SQS Worker (Event-Driven)
Pendekatan kedua menggantikan polling dengan model event-driven menggunakan AWS SQS (Simple Queue Service). Daripada worker terus-menerus bertanya “ada task baru?”, API langsung mengirim notifikasi begitu task dibuat, dan worker bereaksi terhadap notifikasi tersebut.
Flow
1. Upload -> push message ke SQS
2. SQS trigger Lambda / container worker
3. Worker ambil task_id dari message
4. Proses file
5. Update DB
sequenceDiagram
participant API
participant SQS
participant Worker
participant DB
participant Storage
API->>SQS: Publish({ task_id })
SQS->>Worker: Trigger (poll atau event source mapping)
Worker->>DB: GetTask(task_id)
Worker->>DB: MarkProcessing(task_id)
Worker->>Storage: Download(file)
Worker->>Worker: Process(file)
alt Berhasil
Worker->>DB: MarkDone(task_id)
Worker->>SQS: Delete message (ack)
else Gagal
Worker->>DB: MarkFailed(task_id, error)
Worker->>SQS: Message kembali visible (nack implicit)
endPerbedaan mendasar dengan CLI Worker adalah siapa yang memulai aksi. Pada CLI Worker, worker yang aktif bertanya ke database. Pada SQS Worker, API yang aktif memberi tahu worker bahwa ada pekerjaan baru. Model ini menghilangkan delay dari interval polling — task bisa langsung diproses begitu message tiba di queue, bukan menunggu siklus polling berikutnya.
Payload Message
Payload SQS sengaja dibuat minimal — hanya task_id. Detail lengkap task tetap diambil dari database saat diproses, bukan dititipkan di payload message.
{
"task_id": 123
}
// ANTI-PATTERN: menitipkan seluruh data task di payload message
// {
// "task_id": 123,
// "user_id": 456,
// "file_path": "s3://bucket/file.xlsx",
// "metadata": { ... }
// }
// -- jika data di DB berubah setelah message dikirim, worker memproses data basi
// BENAR: payload minimal, worker fetch data terbaru dari DB
// { "task_id": 123 }
Pendekatan payload minimal ini penting karena message di SQS bisa tertunda diproses (misalnya saat ada lonjakan traffic), dan kamu tidak ingin worker memproses data yang sudah tidak relevan dengan state terbaru di database.
Contoh SQS Handler
func HandleMessage(ctx context.Context, taskID int64) error {
task, err := repo.GetTask(ctx, taskID)
if err != nil {
return fmt.Errorf("get task %d: %w", taskID, err)
}
if err := repo.MarkProcessing(ctx, task.ID); err != nil {
return fmt.Errorf("mark processing %d: %w", taskID, err)
}
if err := processor.Process(task); err != nil {
repo.MarkFailed(ctx, task.ID, err.Error())
return err
}
return repo.MarkDone(ctx, task.ID)
}
Mengembalikan error dari fungsi ini penting karena SQS menggunakan mekanisme acknowledgment berbasis hasil. Jika handler mengembalikan error, message tidak dihapus dari queue, sehingga SQS akan membuat message itu kembali visible setelah visibility timeout berakhir — secara efektif memicu retry otomatis tanpa logic tambahan dari sisi kamu.
Konfigurasi Penting
| Konfigurasi | Fungsi | Rekomendasi |
|---|---|---|
| Visibility timeout | Berapa lama message “disembunyikan” dari worker lain setelah diambil | Lebih besar dari estimasi waktu proses maksimum |
| Dead Letter Queue (DLQ) | Menampung message yang gagal diproses berulang kali | Selalu aktifkan untuk task yang gagal |
| Max receive count | Berapa kali message dicoba ulang sebelum masuk DLQ | 3–5 kali, sesuaikan dengan karakteristik error |
| Retry/backoff | Jeda antar percobaan retry | Exponential backoff untuk menghindari thundering herd |
Jika visibility timeout terlalu pendek dibanding waktu proses sebenarnya, message yang sama bisa diambil oleh worker lain sebelum worker pertama selesai — menyebabkan task yang sama diproses dua kali secara bersamaan. Selalu set visibility timeout dengan margin aman di atas waktu proses maksimum yang realistis, dan pastikan processor bersifat idempotent sebagai lapisan pertahanan kedua.
DLQ (Dead Letter Queue) berfungsi sebagai “tempat penampungan” untuk message yang berulang kali gagal diproses, alih-alih terus-menerus di-retry tanpa henti atau hilang begitu saja. Task yang masuk DLQ bisa diinvestigasi manual, dan ini mencegah satu task yang konsisten gagal menghabiskan resource worker secara berulang.
File Processor
Logic pemrosesan file sebaiknya benar-benar terpisah dari mekanisme pengambilan task — baik task itu datang dari polling DB maupun dari SQS, logic intinya harus sama persis. Ini memungkinkan kedua pendekatan worker memanggil fungsi Process yang identik.
func Process(task UploadTask) error {
file, err := storage.Download(task.FilePath)
if err != nil {
return fmt.Errorf("download file: %w", err)
}
defer file.Close()
records, err := parseExcel(file)
if err != nil {
return fmt.Errorf("parse excel: %w", err)
}
for _, r := range records {
if err := validate(r); err != nil {
// ANTI-PATTERN: menghentikan seluruh proses karena satu baris invalid
// return fmt.Errorf("validation failed: %w", err)
// BENAR: catat baris yang gagal, lanjutkan proses baris lain
log.Printf("task %d: baris invalid, dilewati: %v", task.ID, err)
continue
}
if err := repo.SaveRecord(r); err != nil {
return fmt.Errorf("save record: %w", err)
}
}
return nil
}
Keputusan untuk melewati baris yang invalid daripada menghentikan seluruh proses adalah pilihan desain yang penting untuk dipertimbangkan secara sadar. Pada file dengan ribuan baris, satu baris yang format tanggalnya salah seharusnya tidak membuat seluruh file gagal diproses — lebih baik baris itu dicatat sebagai gagal secara individual, sementara baris lain yang valid tetap tersimpan. Trade-off-nya: kamu perlu mekanisme tambahan untuk melaporkan baris mana saja yang gagal, agar user tetap punya visibility terhadap hasil parsial ini.
Jika satu file bisa menghasilkan campuran baris berhasil dan gagal, pertimbangkan menambahkan field sepertisuccess_countdanfailed_countdi tabelupload_tasks, atau tabel terpisahupload_task_errorsyang mencatat detail baris mana yang gagal dan alasannya.
Observability & Monitoring
Sistem asynchronous secara desain memisahkan waktu eksekusi dari waktu request, yang berarti debugging tidak bisa lagi hanya mengandalkan response HTTP. Tanpa observability yang baik, kamu hanya akan tahu sesuatu salah ketika user mengeluh task-nya tidak pernah selesai.
Empat hal yang sebaiknya selalu ada:
Logging per task_id. Setiap log yang berkaitan dengan pemrosesan task harus menyertakan task_id sebagai identifier, sehingga kamu bisa men-trace seluruh siklus hidup satu task — dari diterima, masuk antrian, mulai diproses, hingga selesai atau gagal — hanya dengan memfilter berdasarkan ID tersebut.
Metric success vs failed. Hitung rasio task yang berhasil dibanding gagal dalam rentang waktu tertentu. Lonjakan tiba-tiba pada rasio gagal biasanya menandakan ada masalah sistemik — bukan sekadar file individual yang corrupt — seperti perubahan format data dari sumber upstream, atau bug yang baru di-deploy.
Timeout guard. Proses yang seharusnya selesai dalam beberapa detik tapi berjalan jauh lebih lama dari itu kemungkinan besar mengalami masalah (deadlock, infinite loop, atau dependency eksternal yang tidak merespons). Gunakan context.WithTimeout agar proses yang macet bisa dihentikan secara paksa daripada menggantung selamanya.
Context cancellation. Pastikan setiap operasi I/O (database query, download dari storage, panggilan API eksternal) menerima dan menghormati context.Context yang diteruskan, agar proses bisa dibatalkan secara graceful ketika timeout tercapai atau aplikasi shutdown.
func Process(ctx context.Context, task UploadTask) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
file, err := storage.Download(ctx, task.FilePath)
if err != nil {
return fmt.Errorf("task %d: download file: %w", task.ID, err)
}
defer file.Close()
// ... lanjutan proses menggunakan ctx yang sama
return nil
}
Perbandingan CLI Worker vs SQS Worker
| Aspek | CLI Worker (Polling) | SQS Worker (Event-Driven) |
|---|---|---|
| Latensi mulai proses | Tergantung interval polling (detik) | Hampir instan setelah message dikirim |
| Dependency eksternal | Hanya database | AWS SQS + IAM + (opsional) Lambda |
| Kompleksitas setup | Rendah | Menengah — perlu konfigurasi queue, DLQ, IAM |
| Skalabilitas terhadap burst traffic | Terbatas, beban polling tetap walau task sedikit | Baik, queue menyerap lonjakan secara natural |
| Cocok untuk on-premise | Ya | Tidak langsung, perlu akses AWS |
| Retry & DLQ | Harus dibangun manual | Built-in dari SQS |
| Biaya saat idle | Tetap berjalan, ada biaya compute walau tidak ada task | Bisa nol jika menggunakan Lambda (pay-per-invocation) |
Kapan Memilih Opsi Mana?
flowchart TD
A{Infrastruktur sudah di AWS?} -- Tidak --> B[CLI Worker]
A -- Ya --> C{Traffic upload bersifat burst / tidak terduga?}
C -- Tidak, stabil dan kecil --> B
C -- Ya --> D{Proses per file sangat lama, contoh > 15 menit?}
D -- Ya --> E[AWS Batch]
D -- Tidak --> F{Sensitif terhadap biaya idle?}
F -- Ya --> G[SQS + Lambda]
F -- Tidak --> H[SQS + Container Worker]| Kondisi | Rekomendasi |
|---|---|
| Traffic kecil dan stabil | CLI Worker |
| Burst traffic tidak terduga | SQS + Lambda |
| Burst traffic + proses sangat lama berjalan | AWS Batch |
| Cost-sensitive, ingin bayar hanya saat ada task | Serverless (SQS/EventBridge + Lambda) |
| Infrastruktur on-premise, belum pakai cloud | CLI Worker |
Tidak ada jawaban universal di antara kedua pendekatan ini — keduanya valid untuk konteks yang berbeda. CLI Worker unggul dalam kesederhanaan dan kemandirian dari vendor cloud tertentu, sementara SQS Worker unggul dalam skalabilitas dan penanganan lonjakan traffic secara natural tanpa logic tambahan.
Kedua pendekatan ini bukan saling eksklusif. Beberapa tim memulai dengan CLI Worker untuk MVP atau tahap awal produk, kemudian bermigrasi ke SQS Worker setelah traffic bertambah dan kebutuhan skalabilitas menjadi lebih nyata. Karena logic Process dipisah dari mekanisme pengambilan task, migrasi ini relatif tidak mengganggu kode inti pemrosesan.Catatan Implementasi
Contoh kode pada artikel ini sengaja disederhanakan dan tidak mengimplementasikan full service-repository pattern secara ketat, agar fokus pembahasan tetap pada alur upload-proses-asynchronous itu sendiri. Pada implementasi produksi nyata, kamu kemungkinan akan menambahkan lapisan service untuk orkestrasi business logic yang lebih kompleks, dependency injection untuk testability, dan interface eksplisit pada repository serta storage agar lebih mudah di-mock saat unit testing.
Pola upload-proses-asynchronous ini sangat umum dijumpai di sistem enterprise — mulai dari import data massal, generate laporan, hingga transformasi file media. Karakteristik intinya selalu sama: upload harus cepat dan responsif, sedangkan proses berat berjalan di belakang tanpa membebani request HTTP, dan user tetap punya visibility penuh terhadap status pekerjaannya melalui task_id.
Ringkasan
- Pisahkan upload dari proses — request HTTP hanya menerima file dan mencatat task, pemrosesan berat berjalan di proses terpisah.
- Struktur project memisahkan entry point —
/cmd/apiuntuk HTTP server,/cmd/workeruntuk proses background, masing-masing bisa di-scale independen.- State machine sederhana dengan status
PENDING → PROCESSING → DONE/FAILEDcukup untuk melacak siklus hidup task.- CLI Worker (polling DB) cocok untuk traffic kecil-menengah dan infrastruktur on-premise; gunakan
FOR UPDATE SKIP LOCKEDuntuk mencegah double processing antar instance worker.- SQS Worker (event-driven) cocok untuk burst traffic dan lingkungan cloud; manfaatkan visibility timeout, DLQ, dan retry built-in dari SQS.
- Payload message sebaiknya minimal — cukup
task_id, detail lengkap diambil ulang dari database saat diproses.- Logic pemrosesan file (
Process) harus terpisah dari mekanisme pengambilan task, agar bisa dipanggil dari kedua pendekatan worker tanpa duplikasi.- Observability wajib — logging per
task_id, metric success/failed, timeout guard, dan context cancellation mencegah task “hilang” tanpa jejak.- Tidak ada pilihan universal antara CLI Worker dan SQS Worker — keputusan bergantung pada karakteristik traffic, infrastruktur yang tersedia, dan sensitivitas terhadap biaya.