Arsitektur Pyodide pada Background Worker

Menjalankan kode Python di dalam WebAssembly (WASM) via Pyodide menawarkan lingkungan eksekusi tersandbox tanpa overhead virtual machine atau container terpisah. Pola ini populer untuk multi-tenant job queue, evaluasi plugin, dan data processing dinamis. Namun, deployment Pyodide di level produksi memunculkan dua masalah operasional utama: latensi jaringan akibat resolusi wheel package berulang dan state bleed (kebocoran variabel/memori) antar-task pada instance runtime yang dipakai bersama.

Eliminasi Cold Start: Strategi Wheel Pre-Caching

Secara default, instalasi package via micropip.install() mengunduh wheel WebAssembly dari remote PyPI atau CDN Pyodide setiap kali runtime diinisialisasi. Operasi I/O jaringan ini membuat throughput worker anjlok dan memperkenalkan single point of failure jika registry mengalami rate limiting atau downtime.

1. Local Filesystem Wheel Store

Simpan wheel yang dibutuhkan (baik pure-Python maupun build WASM/Emscripten) langsung ke layer kontainer worker saat fase build Docker:

# Dockerfile: Cache dependencies ke filesystem lokal
RUN mkdir -p /opt/pyodide-cache && \
    pip download \
      --only-binary=:all: \
      --dest /opt/pyodide-cache \
      pandas numpy pydantic

2. Injeksi Wheel Tanpa Jaringan

Di runtime Node.js, mapping direktori lokal tersebut ke file path internal Pyodide atau sajikan via web server loopback lokal (http://127.0.0.1:8080). Pyodide dapat langsung memuat wheel dari path disk tanpa DNS lookup atau negosiasi TLS:

// Inisialisasi package langsung dari local file buffer
const fs = require('fs');
const wheelBuffer = fs.readFileSync('/opt/pyodide-cache/pydantic-2.0-py3-none-any.whl');

await pyodide.unpackArchive(wheelBuffer.buffer, 'wheel');
// Alternatif via micropip lokal:
await pyodide.runPythonAsync(`
  import micropip
  await micropip.install('file:///opt/pyodide-cache/pydantic-2.0-py3-none-any.whl')
`);

Mitigasi State Bleed dan Memory Leaks

Pyodide mengeksekusi interpreter CPython di dalam satu linear memory buffer WebAssembly (WebAssembly.Memory). Menggunakan satu instance runtime untuk ribuan job secara beruntun memicu dua risiko:

  • State Bleed: Job A mendefinisikan variabel global, memodifikasi sys.modules, atau mengubah environment variable. Job B yang berjalan setelahnya dapat membaca atau terpengaruh oleh state Job A tersebut.
  • Memory Bloat: CPython garbage collection mengembalikan objek ke allocator internal, namun runtime WebAssembly tidak otomatis melepaskan alokasi heap linear memory kembali ke sistem operasi host.

Solusi: Fresh Dict Scope dan Scheduled Recycling

Isolasi total hanya bisa dijamin dengan me-recreate instance Pyodide. Namun, inisialisasi WASM baru pada setiap job memakan waktu sekitar 100-300 ms. Solusi optimalnya adalah pendekatan hibrida: gunakan dictionary scope yang bersih untuk setiap eksekusi task, dipadukan dengan worker recycling setelah ambang batas tertentu.

Implementasi Queue Runner Node.js

Contoh runner background queue berikut mengimplementasikan eksekusi task terisolasi, injeksi wheel lokal, proteksi timeout, dan automatic worker recycling:

const { loadPyodide } = require('pyodide');
const { Worker } = require('bullmq');

const MAX_JOBS_BEFORE_RECYCLE = 100;
const TASK_TIMEOUT_MS = 5000;

let pyodideInstance = null;
let jobCounter = 0;

async function getPyodide() {
  if (!pyodideInstance || jobCounter >= MAX_JOBS_BEFORE_RECYCLE) {
    // Recycle instance untuk mengosongkan WASM linear memory
    pyodideInstance = await loadPyodide({
      packageCacheDir: '/opt/pyodide-cache'
    });
    jobCounter = 0;
  }
  return pyodideInstance;
}

const worker = new Worker('python-tasks', async (job) => {
  const py = await getPyodide();
  jobCounter++;

  const { code, params } = job.data;

  // Buat isolated global dict agar tidak mencemari scope default
  const cleanDict = py.toPy({});
  const pyParams = py.toPy(params);
  cleanDict.set('params', pyParams);

  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Pyodide execution timeout')), TASK_TIMEOUT_MS)
  );

  try {
    const executionPromise = py.runPythonAsync(code, { globals: cleanDict });
    const result = await Promise.race([executionPromise, timeoutPromise]);
    
    return result ? (typeof result.toJs === 'function' ? result.toJs() : result) : null;
  } finally {
    // Destroy PyProxy handles untuk mencegah memory leak di bridge JS-WASM
    cleanDict.destroy();
    pyParams.destroy();
  }
}, { connection: { host: 'localhost', port: 6379 } });

Evaluasi Trade-off: Throughput vs Isolasi

Strategi Eksekusi Throughput Isolasi State Skenario Ideal
Full Sandbox (New Pyodide per Task) Rendah (~3-8 job/detik) Sempurna Eksekusi untrusted user code dengan payload arbitrer.
Warm Runtime + Clean Scope + Periodic Recycle Tinggi (~50-200 job/detik) Tinggi (Terbatas pada single context) Pipeline data internal, transformasi JSON, evaluasi rule engine.
Persistent Shared Scope Sangat Tinggi Tidak Ada (Rentan State Bleed) Tidak disarankan untuk background job queue.

Kombinasi bundling wheel lokal dan rotasi instance berkala memberikan keseimbangan optimal: menghilangkan dependensi eksternal saat runtime serta membatasi dampak memory fragmentation WebAssembly tanpa mengorbankan kecepatan throughput eksekusi.