Setiap sistem pemrosesan pekerjaan dimulai dari satu struktur data sederhana: daftar pekerjaan yang menunggu, diambil sesuai urutan kedatangan. Pada bagian ini kita membangun fondasi tersebut — antrean FIFO di memori lengkap dengan perpindahan statusnya — lalu memakainya dari luar lewat API HTTP. Satu hal penting sebelum mulai: pada tahap ini belum ada worker, jadi pekerjaan yang masuk akan tetap berstatus menunggu selamanya. Itu bukan kekurangan, melainkan desain — antrean dan pelaksananya adalah dua hal terpisah, dan memisahkannya membuat keduanya bisa diuji sendiri-sendiri.

Pekerjaan dan Statusnya #

Satu pekerjaan adalah object { id, jenis, data, status, percobaan } ditambah field hasil atau pesan yang muncul di akhir hidupnya. Nilai status bergerak mengikuti satu jalur:

StatusArtiDipindahkan oleh
menungguada di barisan, menunggu gilirantambah dan jadwalkanUlang
diprosessedang dijalankan handlerambilBerikutnya
selesaihandler berhasil, hasil terisitandaiSelesai
gagalpercobaan habis, pesan terisitandaiGagal

Dua struktur di dalam antrean menopang jalur itu: pekerjaan, sebuah Map yang menyimpan seluruh data pekerjaan agar bisa dicari lewat id, dan barisan, array id yang mencatat urutan giliran. Pemisahan ini penting: daftar dan cari membaca dari Map tanpa peduli urutan, sementara ambilBerikutnya hanya melihat barisan.

Antrean FIFO dalam Satu Pabrik #

Fungsi buatAntrean membungkus kedua struktur tersebut dan hanya mengembalikan method — tanpa class, tanpa state yang bisa diubah dari luar:

js src/antrean.js
export function buatAntrean() {
  let idBerikutnya = 1;
  const pekerjaan = new Map();
  const barisan = [];

  function salin(job) {
    return { ...job };
  }

  return {
    tambah(jenis, data) {
      const job = { id: idBerikutnya, jenis, data, status: 'menunggu', percobaan: 0 };
      idBerikutnya += 1;
      pekerjaan.set(job.id, job);
      barisan.push(job.id);
      return salin(job);
    },

    ambilBerikutnya() {
      const id = barisan.shift();
      if (id === undefined) return null;
      const job = pekerjaan.get(id);
      job.status = 'diproses';
      job.percobaan += 1;
      return salin(job);
    },

Mari kita bedah baris-barisnya:

  1. Baris 6 (idBerikutnya): id dihasilkan dari penghitung yang terus maju, bukan dari panjang barisan — pekerjaan yang dihapus atau diulang tidak membuat id terpakai ulang.
  2. Baris 10-12 (salin): setiap method mengembalikan salinan, bukan object internal. Pemanggil boleh mengubah salinannya tanpa merusak keadaan antrean; test pada Bagian 4 memastikan hal ini.
  3. Baris 15-21 (tambah): pekerjaan baru lahir dengan status: 'menunggu' dan percobaan: 0, disimpan ke Map, lalu id-nya didorong ke ujung barisan — pekerjaan terakhir menunggu paling akhir.
  4. Baris 23-30 (ambilBerikutnya): shift mengambil id paling depan; bila barisan kosong, nilai baliknya null. Pekerjaan yang diambil berubah menjadi diproses dan percobaan bertambah satu — percobaan dihitung sejak pengambilan, sehingga pekerjaan yang sedang dikerjakan sudah tercatat sebagai percobaan pertamanya.

Menandai Hasil dan Penjadwalan Ulang #

Tiga method lainnya memindahkan pekerjaan ke status akhir atau mengembalikannya ke barisan:

js src/antrean.js
    tandaiSelesai(id, hasil) {
      const job = pekerjaan.get(id);
      if (job === undefined) return undefined;
      job.status = 'selesai';
      job.hasil = hasil;
      return salin(job);
    },

    jadwalkanUlang(id) {
      const job = pekerjaan.get(id);
      if (job === undefined) return undefined;
      job.status = 'menunggu';
      barisan.push(id);
      return salin(job);
    },

    tandaiGagal(id, pesan) {
      const job = pekerjaan.get(id);
      if (job === undefined) return undefined;
      job.status = 'gagal';
      job.pesan = pesan;
      return salin(job);
    },

Perhatikan baris 43-44: jadwalkanUlang mendorong id ke ujung barisan, bukan ke depan. Pekerjaan yang gagal harus antre ulang di belakang pekerjaan lain yang sudah menunggu — kalau didorong ke depan, satu pekerjaan bermasalah akan mengunci seluruh antrean dengan percobaannya sendiri. Method daftar, cari, dan statistik melengkapi antrean dengan pembacaan; seluruh mekanisme ini bisa dilihat langsung tanpa HTTP:

bash
node src/demo-antrean.mjs

Output yang Dihasilkan:

text
statistik awal     : {"menunggu":3,"diproses":0,"selesai":0,"gagal":0,"total":3}
diambil pertama    : {"id":1,"jenis":"kirim-email","data":{"penerima":"[email protected]","subjek":"Kwitansi"},"status":"diproses","percobaan":1}
setelah pengambilan: {"menunggu":2,"diproses":1,"selesai":0,"gagal":0,"total":3}
tandai selesai #1  : {"id":1,"jenis":"kirim-email","data":{"penerima":"[email protected]","subjek":"Kwitansi"},"status":"selesai","percobaan":1,"hasil":"Email terkirim"}
diambil kedua      : {"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan bulanan"},"status":"diproses","percobaan":1}
jadwalkan ulang #2 : {"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan bulanan"},"status":"menunggu","percobaan":1}
diambil ketiga     : {"id":3,"jenis":"kirim-email","data":{"penerima":"siti","subjek":"Undangan"},"status":"diproses","percobaan":1}
tandai gagal #3    : {"id":3,"jenis":"kirim-email","data":{"penerima":"siti","subjek":"Undangan"},"status":"gagal","percobaan":1,"pesan":"Penerima email tidak valid: siti"}
#2 kembali terambil: {"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan bulanan"},"status":"diproses","percobaan":2}
tandai selesai #2  : {"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan bulanan"},"status":"selesai","percobaan":2,"hasil":"Laporan siap"}
barisan kosong     : null
statistik akhir    : {"menunggu":0,"diproses":0,"selesai":2,"gagal":1,"total":3}

Bacaan output: tiga pekerjaan masuk lalu menunggu: 3; pengambilan pertama mengubah #1 menjadi diproses dengan percobaan: 1; setelah #2 dijadwalkan ulang, giliran berikutnya jatuh ke #3 — bukti penjadwalan ulang menempatkan #2 di ujung barisan; akhirnya #2 terambil kembali dengan percobaan: 2.

Pabrik Pemroses dan Server HTTP #

Antrean tidak berbicara HTTP, dan server tidak boleh tahu detail penyimpanan. Perantara keduanya adalah buatPemroses — pabrik yang merangkai antrean, handler, dan worker (yang kita bangun pada Bagian 2), lalu memaparkan method yang dibutuhkan server:

js src/pemroses.js
export function buatPemroses(opsi = {}) {
  const antrean = opsi.antrean ?? buatAntrean();
  const handler = opsi.handler ?? HANDLER_BAWAAN;
  const maksimalPercobaan = opsi.maksimalPercobaan ?? 3;
  const pekerja = buatPekerja({ antrean, handler, maksimalPercobaan });

  return {
    tambahPekerjaan(jenis, data) {
      const galat = validasiPekerjaan(jenis, data, handler);
      if (galat.length > 0) throw new GalatValidasi(galat);
      const pekerjaanBaru = antrean.tambah(jenis, data);
      pekerja.bangunkan();
      return pekerjaanBaru;
    },

    daftarPekerjaan() {
      return antrean.daftar();
    },

    cariPekerjaan(id) {
      return antrean.cari(id);
    },

    statistik() {
      return antrean.statistik();
    },

Dua keputusan penting berada di potongan ini. Pertama, baris 36-38: input divalidasi sebelum masuk antrean — jenis yang tidak terdaftar pada handler ditolak lewat GalatValidasi yang membawa daftar galat per field, sehingga server cukup menangkap satu jenis error untuk menjawab 422. Kedua, baris 39: setelah pekerjaan masuk, pekerja.bangunkan() memberi tahu worker — panggilan ini tidak berbuat apa pun selama worker belum dinyalakan, dan justru itulah alasan pekerjaan bisa tetap menunggu. Server-nya sendiri tipis:

js src/server.js
export function buatServer(pemroses) {
  return http.createServer(async (req, res) => {
    try {
      const url = new URL(req.url, `http://${HOST}`);
      const { aksi, cocok, pathDikenal } = cocokRute(req.method, url.pathname);

      if (!aksi) {
        if (pathDikenal) {
          return kirimError(res, 405, 'METODE_TIDAK_DIIZINKAN',
            `Metode ${req.method} tidak didukung pada endpoint ini`);
        }
        return kirimError(res, 404, 'ENDPOINT_TIDAK_ADA', 'Endpoint tidak ditemukan');
      }

      await aksi({ req, res, url, pemroses, cocok });
    } catch (err) {
      if (err instanceof GalatValidasi) {
        return kirimError(res, 422, 'VALIDASI_GAGAL', err.message, err.detail);
      }
      if (err instanceof SyntaxError) {
        return kirimError(res, 400, 'JSON_TIDAK_VALID', 'Body permintaan bukan JSON valid');
      }
      console.error(`[kesalahan internal] ${err.message}`);
      if (res.headersSent) return res.end();
      kirimError(res, 500, 'KESALAHAN_INTERNAL', 'Terjadi kesalahan tak terduga di sisi server');
    }
  });
}

Handler menerima satu object konteks { req, res, url, pemroses, cocok } lewat dispatch di baris 95 — pola yang sama dengan Proyek REST API Manajemen Tugas — sehingga penambahan endpoint tidak mengubah signature. Tiga penanganan error di baris 96-105 memetakan error ke kode HTTP: GalatValidasi menjadi 422, SyntaxError dari JSON.parse menjadi 400, dan sisanya dicatat ke log server lalu dijawab 500.

bash
node src/demo-api.mjs

Output yang Dihasilkan:

text
POST /pekerjaan -> 201 (lokasi: /pekerjaan/1) {"id":1,"jenis":"kirim-email","data":{"penerima":"[email protected]","subjek":"Kwitansi"},"status":"menunggu","percobaan":0}
POST /pekerjaan -> 201 {"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan penjualan"},"status":"menunggu","percobaan":0}
POST /pekerjaan (jenis asing) -> 422 {"error":{"kode":"VALIDASI_GAGAL","pesan":"Data pekerjaan tidak lolos validasi","detail":[{"field":"jenis","pesan":"jenis tidak dikenal: kirim-faks"}]}}
POST /pekerjaan (bukan JSON) -> 400 {"error":{"kode":"JSON_TIDAK_VALID","pesan":"Body permintaan bukan JSON valid"}}
GET /pekerjaan -> 200 [{"id":1,"jenis":"kirim-email","data":{"penerima":"[email protected]","subjek":"Kwitansi"},"status":"menunggu","percobaan":0},{"id":2,"jenis":"buat-laporan","data":{"judul":"Laporan penjualan"},"status":"menunggu","percobaan":0}]
GET /pekerjaan/1 -> 200 {"id":1,"jenis":"kirim-email","data":{"penerima":"[email protected]","subjek":"Kwitansi"},"status":"menunggu","percobaan":0}
GET /pekerjaan/99 -> 404 {"error":{"kode":"PEKERJAAN_TIDAK_ADA","pesan":"Pekerjaan 99 tidak ditemukan"}}
DELETE /pekerjaan -> 405 {"error":{"kode":"METODE_TIDAK_DIIZINKAN","pesan":"Metode DELETE tidak didukung pada endpoint ini"}}

Delapan baris output itu merangkum kontrak bagian ini: POST yang valid menjawab 201 dengan header Location, jenis asing ditolak 422 beserta detailnya, body yang bukan JSON ditolak 400, dan GET /pekerjaan menampilkan kedua pekerjaan masih menunggu — persis seperti yang dijanjikan, karena belum ada yang mengerjakan. Antrean dan pintu masuknya sudah berdiri; yang belum ada adalah tangan yang mengambil pekerjaan dari barisan. Itu pekerjaan Bagian 2.