Membangun Fitur Export Data Asynchronous dengan Cursor-Based Pagination

  • 11 min read
  • Jul 23, 2026
export-data-Asynchronous-menggunakan-Golang-hero-banner

Ketika saya mengembangkan fitur export untuk laporan “Data Integrasi Device”, tantangannya bukan sekadar mengubah hasil query menjadi CSV atau XLSX. Laporan tersebut dapat memiliki hingga 128 kolom, membutuhkan pengambilan data dalam beberapa batch, dan tidak ideal jika seluruh pekerjaan dilakukan dalam satu HTTP request. Saya kemudian memindahkan proses ke background menggunakan RabbitMQ, mengambil data secara berurutan dengan composite cursor, membuat satu file untuk setiap batch, lalu menyatukannya ke dalam ZIP yang diunggah ke object storage. Artikel ini membahas alur implementasi, alasan di balik keputusan desain, validasi yang diperlukan, serta keterbatasan yang masih perlu diperbaiki.

Ketika fitur export tidak lagi sesederhana tombol download

Dalam salah satu pekerjaan sebagai backend engineer, saya mendapat kebutuhan untuk membangun fitur export laporan “Data Implementasi Suatu Bisnis” pada service berbasis Go (Golang). Sekilas, fiturnya terdengar umum: pengguna memilih filter, menekan tombol export, lalu sistem menghasilkan file CSV atau XLSX.

Namun, struktur laporan ini cukup besar. Jumlah kolomnya dapat mencapai 128 kolom. Pada kondisi seperti ini, pekerjaan export tidak hanya dipengaruhi oleh jumlah baris, tetapi juga oleh lebar setiap baris, serialisasi data, pembuatan workbook, kompresi ZIP, dan proses upload. Karena itu, istilah “data besar” yang saya gunakan dalam artikel ini berarti workload export yang cukup berat dan lebar untuk ditangani sebagai request biasa—bukan klaim bahwa sistem tersebut merupakan platform Big Data.

Implementasi saat ini membatasi hasil maksimal 10.000 baris. Batas awal setiap batch adalah 1.000 baris, kemudian saya membuatnya configurable, dan konfigurasi yang digunakan saat ini adalah 3.000 baris per batch. Seluruh proses export diberi timeout 10 menit. Sementara itu, operasi pembaruan status history menggunakan context terpisah dengan timeout 5 detik agar pencatatan hasil tidak bergantung sepenuhnya pada context pekerjaan utama.

Mengapa export sinkron menjadi masalah

Jika export dikerjakan langsung dalam satu HTTP request, koneksi harus tetap terbuka sejak query dimulai sampai file selesai dibuat dan dikirim. Semakin panjang prosesnya, semakin besar risiko request melewati timeout pada aplikasi, reverse proxy, gateway, atau client. Bahkan jika timeout diperpanjang, request panjang tetap membuat pengalaman pengguna kurang baik karena tidak ada pemisahan yang jelas antara penerimaan permintaan dan penyelesaian pekerjaan.

Mengambil seluruh data sekaligus juga berpotensi meningkatkan penggunaan memory. Akan tetapi, saya perlu memberi catatan penting: mengganti offset pagination dengan cursor pagination tidak otomatis membuat aplikasi hemat memory. Penggunaan memory baru dapat lebih terkendali jika data yang sudah diambil per batch juga diproses dan ditulis secara bertahap, lalu referensinya dilepaskan. Jika semua batch tetap dikumpulkan ke satu slice besar, keuntungan tersebut praktis hilang.

Pada kasus saya, cursor membantu pengambilan data secara bertahap dan stabil. Namun, file batch serta arsip ZIP final masih dibuat menggunakan bytes.Buffer. Artinya, keseluruhan isi ZIP masih berada di memory sebelum upload. Jadi, desain ini sudah memisahkan pekerjaan dari HTTP request dan membatasi pengambilan per batch, tetapi belum menyelesaikan seluruh persoalan memory secara end-to-end.

Offset pagination dan cursor-based pagination

Sebelum menentukan mekanisme pengambilan data, saya membandingkan pagination berbasis offset dengan cursor-based pagination atau keyset pagination.

Pada offset pagination, database melewati sejumlah baris sesuai nilai OFFSET, lalu mengembalikan data sebanyak LIMIT. Dokumentasi resmi PostgreSQL menegaskan bahwa baris yang dilewati tetap harus dihitung di dalam server. Karena itu, nilai offset yang semakin besar dapat menjadi tidak efisien. Dokumentasi yang sama juga mengingatkan bahwa pagination harus menggunakan ORDER BY yang menghasilkan urutan unik dan dapat diprediksi; tanpa itu, halaman yang diperoleh bisa tidak konsisten. Penjelasan lengkapnya dapat dibaca di PostgreSQL: LIMIT and OFFSET.

Cursor-based pagination yang dibahas di artikel ini menggunakan pola keyset pagination atau seek method, bukan SQL cursor yang mempertahankan transaksi di sisi database. Halaman berikutnya dicari dengan membandingkan kolom pengurutan terhadap nilai dari baris terakhir pada batch sebelumnya. Dokumentasi pagination CockroachDB menjelaskan pola WHERE key > last_value ORDER BY key LIMIT n serta pentingnya kunci yang unik dan terindeks agar pencarian dapat dimulai dari posisi cursor secara efisien.

AspekPage/offset paginationCursor-based pagination
Cara mengambil halaman berikutnyaLIMIT ... OFFSET ...Mengambil data setelah nilai kunci terakhir
Biaya pada halaman yang jauhDatabase dapat tetap melewati banyak baris sebelum mengembalikan hasilQuery bergerak dari posisi cursor yang terindeks dengan baik
Stabilitas ketika data berubahBaris dapat bergeser sehingga berisiko terlewat atau terbaca ulangLebih stabil bila urutan dan cursor konsisten, tetapi perubahan data tetap harus diperhitungkan
Kemudahan lompat ke halaman tertentuMudahTidak dirancang untuk lompat bebas
Kecocokan untuk export berurutanKurang ideal untuk traversal panjangCocok untuk membaca data maju secara bertahap
Syarat utamaCukup sederhanaMembutuhkan sorting stabil dan cursor yang unik/deterministik

Export tidak membutuhkan pengguna melompat ke halaman ke-20. Sistem hanya perlu membaca data dari awal sampai akhir secara berurutan. Karena itu, cursor lebih sesuai dengan pola aksesnya.

Sumber tentang offset dan cursor pagination bisa dibaca di

  • PostgreSQL Documentation — LIMIT and OFFSET: menjelaskan cara kerja LIMIT/OFFSET, kebutuhan urutan hasil yang unik, dan alasan offset besar dapat tidak efisien.
  • CockroachDB Documentation — Paginate Results: menjelaskan keyset pagination, contoh query untuk halaman berikutnya, perbandingan dengan offset, kebutuhan indeks, dan konsistensi snapshot ketika data berubah.
  • GitLab Docs — Keyset Pagination: memberikan contoh implementasi keyset pagination di aplikasi nyata, penggunaan primary key sebagai tie-breaker, keterbatasan untuk lompat berdasarkan nomor halaman, serta pentingnya memeriksa indeks dan performa query.

Mengapa saya memilih asynchronous processing

Saya memisahkan proses export menjadi dua tahap. Endpoint publik hanya menerima permintaan, menyimpan filter ke tabel history download, memberi status awal QUEUE, lalu mengirim message ke RabbitMQ. Consumer mengerjakan proses berat di background.

Asynchronous processing di sini tidak sama dengan parallel processing. Endpoint tidak menunggu export selesai, sehingga prosesnya asynchronous dari sudut pandang pengguna. Namun, batch cursor tetap diambil dan diproses satu per satu secara berurutan. Saya sengaja tidak menyebutnya parallel karena urutan cursor berikutnya bergantung pada response batch sebelumnya.

Message RabbitMQ juga tidak membawa seluruh filter atau data laporan. Message hanya berisi ID history dan communication headers yang diperlukan. Consumer menggunakan ID tersebut untuk membaca kembali data_payload dari tabel history. Pendekatan ini membuat message lebih kecil dan menjadikan database sebagai sumber informasi permintaan export. Konsekuensinya, consumer bergantung pada ketersediaan serta konsistensi record history tersebut.

Arsitektur solusi

Report Service menerima request dan menyimpan history sebelum message dipublikasikan. Consumer kemudian membaca history, memanggil endpoint export internal pada Data Service, membuat file, mengunggah ZIP, dan memperbarui history. Pengguna tidak memperoleh file langsung dari request awal; aplikasi perlu menyediakan mekanisme untuk melihat status dan mengambil hasil setelah selesai.

Alur end-to-end

Proses dimulai saat pengguna mengirim filter dan memilih tipe export. Service terlebih dahulu memvalidasi apakah format yang diminta didukung, yaitu CSV atau XLSX. Filter dan metadata request disimpan ke history download dengan file_status = QUEUE. Setelah itu, message berisi history ID dan communication headers dipublikasikan ke RabbitMQ.

Consumer menerima message, mem-parsing isinya, lalu mencari record history. Nilai data_payload diubah kembali menjadi DTO filter. Dari titik ini, consumer memanggil Data Service tanpa cursor untuk mengambil batch pertama. Response konseptualnya berisi columns, rows, next_cursor, has_more, dan limit.

Kolom dari response pertama menjadi acuan. Setiap batch berikutnya harus memiliki daftar kolom yang sama, baik jumlah maupun urutannya. Setiap batch langsung diubah menjadi file tersendiri, misalnya report-app-inisiasi-part-0001.csv dan report-app-inisiasi-part-0002.csv. Semua file dimasukkan ke satu ZIP sehingga pengguna hanya mengunduh satu artefak.

Jika has_more bernilai true, consumer memvalidasi next_cursor, memastikan cursor tersebut belum pernah diproses, lalu mengirim nilainya pada request berikutnya. Loop berakhir ketika has_more=false atau ketika terjadi kegagalan. Akumulasi baris juga diperiksa agar tidak melampaui 10.000.

Setelah ZIP berhasil dibuat, arsip diunggah ke object storage berbasis MinIO. Lokasi file kemudian disimpan pada history. Hasil sukses ditandai dengan file_status = DONE dan publish_status = SUCCESS. Kegagalan pada tahap mana pun menghasilkan file_status = FAILED, publish_status = ERROR, dan alasan yang telah disanitasi pada log_remark.

Cara kerja composite cursor

Cursor yang saya gunakan tersusun dari order_value, init_id, dan app_id. Composite cursor diperlukan karena satu nilai pengurutan belum tentu unik. order_value menjadi urutan utama, kemudian init_id dan app_id berfungsi sebagai tie-breaker agar posisi terakhir dapat ditentukan secara deterministik.

Pendekatan tie-breaker tersebut sejalan dengan panduan keyset pagination GitLab, yang menambahkan primary key ke ORDER BY ketika kolom pengurutan utama dapat berisi nilai duplikat. Tujuannya adalah memastikan setiap baris mempunyai posisi yang berbeda dan urutan pagination tetap deterministik.

Misalnya data diurutkan menaik dengan kombinasi berikut:

ORDER BY order_value ASC, init_id ASC, app_id ASC

Request pertama tidak mengirim cursor. Anggap batch pertama berakhir pada:

order_value = 2026-07-01T10:00:00Z
init_id     = 150
app_id      = 9001

Response mengembalikan nilai tersebut sebagai next_cursor. Request kedua kemudian mencari data yang berada setelah tuple itu. Jika batch kedua berakhir pada (2026-07-01T10:00:00Z, 155, 9020), ketiga nilai baru itulah yang dikirim untuk request ketiga. Loop terus berjalan sampai Data Service mengembalikan has_more=false.

Secara konseptual, kondisi query untuk urutan menaik dapat ditulis sebagai berikut:

WHERE order_value > :order_value
   OR (order_value = :order_value AND init_id > :init_id)
   OR (order_value = :order_value AND init_id = :init_id AND app_id > :app_id)
ORDER BY order_value ASC, init_id ASC, app_id ASC
LIMIT :limit

Kolom dan arah ORDER BY harus identik dengan logika cursor. Data Service juga perlu memiliki indeks yang sesuai agar keyset pagination memberi manfaat. Jika urutan berubah antar-request, cursor dapat menghasilkan baris duplikat atau kehilangan baris.

Pseudocode loop-nya sederhana, tetapi validasinya tidak boleh diabaikan:

cursor = kosong
seen = set kosong

ulang:
    batch = ambil_data(filter, cursor, limit)
    validasi_columns(batch)
    validasi_total_rows(batch)
    tulis_file_batch(batch)

    jika batch.has_more == false:
        berhenti

    jika batch.next_cursor kosong atau tidak valid:
        gagal

    jika batch.next_cursor sudah ada di seen:
        gagal untuk mencegah infinite loop

    tambahkan batch.next_cursor ke seen
    cursor = batch.next_cursor

Contoh kode Go yang disederhanakan

Contoh berikut bersifat generik dan hanya menunjukkan pola desain, bukan source code aplikasi asli.

type Cursor struct {
	OrderValue string `json:"order_value"`
	InitID     int64  `json:"init_id"`
	AppID      int64  `json:"app_id"`
}

type ExportBatch struct {
	Columns    []string          `json:"columns"`
	Rows       [][]any           `json:"rows"`
	NextCursor *Cursor           `json:"next_cursor"`
	HasMore    bool              `json:"has_more"`
	Limit      int               `json:"limit"`
}

func (c Cursor) Key() string {
	return fmt.Sprintf("%s|%d|%d", c.OrderValue, c.InitID, c.AppID)
}

Pembentukan parameter request dibuat tanpa page. Cursor hanya ditambahkan setelah batch pertama.

func buildQuery(filter Filter, limit int, cursor *Cursor) url.Values {
	q := url.Values{}
	q.Set("limit", strconv.Itoa(limit))
	q.Set("export_type", filter.ExportType)

	if cursor != nil {
		q.Set("cursor_order_value", cursor.OrderValue)
		q.Set("cursor_init_id", strconv.FormatInt(cursor.InitID, 10))
		q.Set("cursor_app_id", strconv.FormatInt(cursor.AppID, 10))
	}
	return q
}

Loop pengambilan batch dapat digeneralisasi seperti ini:

func processExport(ctx context.Context, filter Filter, limit int, zw *zip.Writer) error {
	var cursor *Cursor
	var expectedColumns []string
	seen := make(map[string]struct{})
	totalRows := 0

	for part := 1; ; part++ {
		batch, err := fetchBatch(ctx, buildQuery(filter, limit, cursor))
		if err != nil {
			return fmt.Errorf("fetch part %d: %w", part, err)
		}

		if err := validateColumns(batch.Columns, expectedColumns); err != nil {
			return fmt.Errorf("validate part %d: %w", part, err)
		}
		if expectedColumns == nil {
			expectedColumns = append([]string(nil), batch.Columns...)
		}

		totalRows += len(batch.Rows)
		if totalRows > 10_000 {
			return fmt.Errorf("export exceeds row limit")
		}

		if err := writeBatchToZIP(zw, filter.ExportType, part, batch); err != nil {
			return fmt.Errorf("write part %d: %w", part, err)
		}
		if !batch.HasMore {
			break
		}
		if batch.NextCursor == nil || batch.NextCursor.OrderValue == "" {
			return fmt.Errorf("missing next cursor at part %d", part)
		}

		key := batch.NextCursor.Key()
		if _, exists := seen[key]; exists {
			return fmt.Errorf("repeated cursor detected")
		}
		seen[key] = struct{}{}
		cursor = batch.NextCursor
	}
	return nil
}

Satu entry ZIP dibuat untuk setiap batch. Implementasi aktual dapat memilih writer CSV atau fungsi pembuat XLSX.

func writeBatchToZIP(zw *zip.Writer, kind string, part int, b ExportBatch) error {
	ext := strings.ToLower(kind)
	name := fmt.Sprintf("report-app-inisiasi-part-%04d.%s", part, ext)
	w, err := zw.Create(name)
	if err != nil {
		return err
	}

	switch ext {
	case "csv":
		return writeCSV(w, b.Columns, b.Rows)
	case "xlsx":
		return writeXLSX(w, b.Columns, b.Rows)
	default:
		return fmt.Errorf("unsupported export type: %s", kind)
	}
}

Status sukses dan gagal diperbarui dengan context terpisah selama lima detik. Tujuannya agar pencatatan kegagalan masih memiliki kesempatan berjalan meskipun context export utama sudah timeout.

func finishSuccess(repo HistoryRepository, id int64, location string) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	return repo.MarkDone(ctx, id, "DONE", "SUCCESS", location)
}

func finishFailed(repo HistoryRepository, id int64, cause error) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	remark := sanitizeError(cause) // jangan simpan token atau payload sensitif
	return repo.MarkFailed(ctx, id, "FAILED", "ERROR", remark)
}

Membuat CSV dan XLSX

Untuk CSV, setiap file batch dimulai dengan header yang berasal dari columns, lalu diikuti rows. Penulisannya dapat menggunakan encoding/csv langsung ke writer milik entry ZIP. Nilai non-string perlu dikonversi secara konsisten, terutama nil, tanggal, angka, dan boolean. Jika file akan banyak dibuka melalui aplikasi spreadsheet, aturan delimiter, encoding, dan kemungkinan formula injection juga perlu dipertimbangkan.

Untuk XLSX, saya menggunakan Excelize StreamWriter. API ini memungkinkan baris ditulis berurutan tanpa menyusun seluruh worksheet sebagai struktur cell di memory. Setelah header dibuat, setiap row dikonversi menjadi slice dan ditulis ke nomor baris berikutnya. Stream harus di-flush dan workbook harus ditutup dengan benar sebelum hasilnya dianggap valid.

Streaming XLSX membantu mengurangi beban saat membangun workbook, tetapi tidak berarti seluruh pipeline sudah streaming. Pada implementasi saat ini, output file batch dan ZIP akhir masih berkaitan dengan bytes.Buffer. Buffer ZIP final tetap menjadi bottleneck memory sampai proses upload selesai.

Error handling dan status history

Background job harus meninggalkan status yang dapat dipahami pengguna maupun engineer yang melakukan troubleshooting. Karena itu, kegagalan saya tangani pada setiap batas proses: parsing message RabbitMQ, membaca history, parsing data_payload, memanggil Data Service, parsing response, membuat CSV atau XLSX, membuat ZIP, upload ke object storage, dan update history.

Validasi response juga bersifat defensif. Response tanpa kolom ditolak. Jumlah kolom tidak boleh melebihi 128. Daftar kolom harus konsisten pada seluruh batch. Data kosong dianggap gagal sesuai aturan bisnis implementasi ini, bukan menghasilkan ZIP kosong. Jika has_more=true, next_cursor wajib lengkap. Cursor yang sama tidak boleh muncul dua kali karena dapat membuat loop tidak pernah selesai.

log_remark sebaiknya cukup informatif untuk menunjukkan tahap kegagalan dan root cause, tetapi tidak menyimpan keseluruhan payload, communication headers, token, atau nilai sensitif dari laporan. Structured logging dengan history ID, nomor batch, jumlah row, dan tahap proses jauh lebih aman daripada mencetak isi data.

Ada satu kegagalan yang perlu diperlakukan khusus: update status history itu sendiri dapat gagal. Pada kondisi tersebut, job mungkin telah gagal atau bahkan file telah berhasil di-upload, tetapi status database tidak mencerminkan keadaan sebenarnya. Karena itu, operasi update status perlu dipantau dan kelak idealnya memiliki rekonsiliasi.

Strategi pengujian

Pengujian saya fokuskan pada kontrak antar-batch dan isi artefak. Test pertama memastikan request awal tidak memiliki parameter cursor. Response pertama kemudian dibuat mengembalikan composite cursor, dan test memastikan request kedua membawa cursor_order_value, cursor_init_id, serta cursor_app_id yang tepat. Parameter page juga dipastikan tidak pernah digunakan.

Pada sisi file, saya memeriksa bahwa setiap batch menghasilkan satu entry di dalam ZIP dan nama part berurutan dengan zero padding. CSV dibuka kembali untuk memastikan header tersedia. XLSX juga dibaca kembali menggunakan library agar test tidak hanya membuktikan fungsi writer selesai tanpa error, tetapi memastikan workbook valid serta data dapat diakses.

Skenario negatif sama pentingnya. Salah satu test utama mengembalikan has_more=true tanpa next_cursor dan mengharapkan proses gagal. Test tambahan yang relevan mencakup cursor berulang, perubahan daftar kolom, jumlah kolom di atas batas, total row di atas 10.000, data kosong, kegagalan upload, dan timeout.

Pengujian tersebut belum menggantikan load test. Sebelum menyimpulkan bahwa solusi meningkatkan performa atau menurunkan konsumsi memory, saya tetap perlu mengukur waktu setiap tahap, peak memory, ukuran artefak, serta perilaku saat beberapa export berjalan bersamaan. Angkanya harus berasal dari pengujian, bukan asumsi.

Trade-off dan keterbatasan yang masih ada

  • Satu file dibuat untuk setiap batch sehingga proses dan penamaan file lebih sederhana.
  • Pengguna menerima beberapa file CSV/XLSX dalam satu ZIP, bukan satu worksheet tunggal.
  • Batas maksimal 10.000 baris membuat fitur ini belum mendukung unlimited export.
  • Penggunaan bytes.Buffer menyebabkan file dan ZIP akhir masih tersimpan sepenuhnya di memory.
  • RabbitMQ menggunakan auto-ack, sehingga message dianggap selesai sebelum proses export benar-benar berhasil.
  • Jika consumer berhenti saat membuat atau mengunggah file, message tidak otomatis dikirim ulang.
  • Sebaiknya gunakan manual acknowledgment, kemudian tentukan strategi nack, requeue, dan dead-letter queue untuk kegagalan.
  • Belum tersedia retry, dead-letter queue, resumable export, dan checkpoint per batch.
  • Belum ada idempotency guard yang kuat sehingga pekerjaan yang sama berpotensi diproses oleh lebih dari satu consumer.
  • Composite cursor memerlukan urutan data yang stabil.
  • Perubahan data selama export dapat menyebabkan record baru masuk atau record lama berpindah posisi.
  • Cursor pagination tidak otomatis menghasilkan snapshot data yang konsisten.
  • File hasil export harus dilindungi dengan pemeriksaan otorisasi, masa berlaku, dan signed URL

Improvement berikutnya

Perbaikan pertama yang akan saya prioritaskan adalah mengganti auto-ack dengan manual acknowledgment, kemudian menambahkan retry dengan exponential backoff dan dead-letter queue. Retry harus membedakan error sementara, seperti gangguan network, dari error permanen, seperti format payload yang tidak valid.

Proses juga perlu dibuat idempotent berdasarkan history ID. Consumer dapat melakukan transisi status secara atomik dari QUEUE ke PROCESSING; hanya satu worker yang berhasil mengklaim pekerjaan. Penamaan object atau idempotency key juga sebaiknya deterministik sehingga pemrosesan ulang tidak menghasilkan artefak liar.

Untuk mengurangi bottleneck memory, ZIP dapat dialirkan langsung ke object storage menggunakan io.Pipe atau multipart upload. Dengan desain tersebut, ZIP writer menulis ke pipe sementara uploader membaca dari sisi lain. Implementasinya perlu menangani propagasi error dan pembatalan context dengan hati-hati agar goroutine tidak menggantung.

Checkpoint cursor per batch akan memungkinkan proses dilanjutkan setelah kegagalan. History dapat menyimpan cursor terakhir, nomor part, jumlah row selesai, dan daftar object sementara. Informasi yang sama dapat digunakan untuk menampilkan progress, misalnya jumlah batch dan baris yang telah diproses.

Observability juga perlu diperkuat melalui metrics dan distributed tracing. Metrik yang berguna mencakup durasi antrean, durasi setiap tahap, jumlah batch, jumlah row, kegagalan per tahap, ukuran file, dan peak memory dari hasil pengujian. Download sebaiknya menggunakan signed URL dengan expiration dan tetap memverifikasi bahwa pengguna berhak mengakses history tersebut.

Terakhir, saya perlu menguji konsistensi ketika terjadi insert atau update selama export. Beberapa opsi yang dapat dievaluasi adalah snapshot database, read replica dengan semantics yang sesuai, atau menetapkan batas waktu data—misalnya hanya mengekspor record dengan waktu pembuatan hingga saat request diterima. Pilihan terbaik bergantung pada kebutuhan konsistensi dan kemampuan Data Service.

Pelajaran yang saya peroleh

Dari pekerjaan ini, saya belajar bahwa fitur export bukan sekadar urusan memilih library CSV atau XLSX. Tantangan utamanya justru berada pada lifecycle pekerjaan: bagaimana request diterima, bagaimana data dibaca secara deterministik, bagaimana kegagalan tercatat, bagaimana proses dapat diulang dengan aman, dan bagaimana artefak didistribusikan tanpa membuka akses yang tidak semestinya.

Cursor-based pagination membantu membagi traversal data menjadi langkah yang terkontrol, tetapi manfaatnya bergantung pada sorting, indeks, dan cara output diproses. RabbitMQ memisahkan pekerjaan dari HTTP request, tetapi penggunaan broker baru benar-benar andal jika acknowledgment, retry, dead-letter queue, dan idempotency dirancang dengan baik. StreamWriter membantu pembuatan XLSX, tetapi satu komponen streaming tidak membuat seluruh pipeline otomatis hemat memory.

Implementasi saat ini menyelesaikan kebutuhan dasar: export diproses di background, data diambil per batch, hasil tersedia sebagai satu ZIP, dan statusnya tercatat. Pada saat yang sama, saya melihat dengan jelas batasnya. Bagi saya, kejujuran terhadap keterbatasan tersebut justru bagian penting dari engineering—karena desain yang baik bukan desain yang mengaku selesai, melainkan desain yang mengetahui risiko dan arah perbaikannya.

Kata kunci: export data Golang, cursor-based pagination Go, asynchronous processing RabbitMQ, export XLSX Excelize, background job Golang