Pengujian batch job sering kali menghasilkan status hijau semu (false pass) saat pengujian hanya menggunakan in-memory database atau mock repository. Masalah muncul saat sebagian data berhasil masuk, tetapi baris berikutnya memicu pelanggaran constraint di PostgreSQL. Jika penanganan transaksi salah, worker menandai job sebagai sukses penuh padahal basis data berada dalam status aborted transaction atau data hanya tersimpan sebagian.
Akar Masalah: Mengapa Mock DB Menyembunyikan Transaction Failure
PostgreSQL memberlakukan aturan transaksi yang ketat. Ketika sebuah statement di dalam blok transaksi gagal (misalnya foreign_key_violation atau check_violation), PostgreSQL langsung mengubah status transaksi menjadi error state:
ERROR: current transaction is aborted, commands ignored until end of transaction blockDalam status ini, semua statement berikutnya akan ditolak sampai transaksi menerima instruksi ROLLBACK. Engine mock seperti SQLite atau mock library tingkat aplikasi sering kali tidak mereplikasi mekanisme ini secara presisi. Mock biasanya tetap mengeksekusi query berikutnya atau menelan error di level repository, sehingga unit test melaporkan batch berhasil 100%.
Pola Penanganan: Chunked Checkpoint dengan SAVEPOINT
Untuk menghindari all-or-nothing rollback pada jutaan baris data sekaligus mencegah silent corruption, gunakan transaksi per chunk atau manfaatkan SAVEPOINT di dalam master transaksi. Pola ini mengisolasi kegagalan chunk tertentu tanpa membatalkan chunk yang telah valid.
-- Skema minimal tabel batch audit dan target data
CREATE TABLE batch_jobs (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
processed_count INT DEFAULT 0,
failed_count INT DEFAULT 0
);
CREATE TABLE batch_items (
id SERIAL PRIMARY KEY,
batch_id INT REFERENCES batch_jobs(id) ON DELETE CASCADE,
payload TEXT NOT NULL,
val INT CHECK (val >= 0) -- Constraint untuk simulasi partial failure
);Setup Integration Test dengan Testcontainers
Gunakan instance PostgreSQL asli melalui Testcontainers untuk menangkap perilaku InFailedSqlTransaction secara akurat. Implementasi berikut menggunakan Python dan psycopg (native v3).
# test_batch_worker.py
import pytest
import psycopg
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="module")
def postgres_container():
with PostgresContainer("postgres:16-alpine") as postgres:
yield postgres
@pytest.fixture
def db_conn(postgres_container):
conn = psycopg.connect(postgres_container.get_connection_url())
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS batch_jobs (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
processed_count INT DEFAULT 0,
failed_count INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS batch_items (
id SERIAL PRIMARY KEY,
batch_id INT REFERENCES batch_jobs(id) ON DELETE CASCADE,
val INT CHECK (val >= 0)
);
""")
conn.commit()
yield conn
conn.close()Implementasi Worker dan Uji Simulasi Partial Crash
Worker berikut memproses data dalam potongan (chunk). Setiap chunk dilindungi SAVEPOINT. Jika terjadi error constraint pada row ke-8 dari 10 row, SAVEPOINT chunk tersebut di-rollback, tabel audit diperbarui, dan status akhir batch diset ke PARTIAL_FAILURE, bukan COMPLETED.
def process_batch(conn, batch_id: int, records: list[int]):
with conn.cursor() as cur:
cur.execute("UPDATE batch_jobs SET status = 'PROCESSING' WHERE id = %s", (batch_id,))
conn.commit()
processed = 0
failed = 0
# ponytail: Chunking statis per item untuk demonstrasi; upgrade ke dynamic batch slice untuk high throughput.
for val in records:
try:
with conn.transaction(): # Membuka subtransaksi (SAVEPOINT)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO batch_items (batch_id, val) VALUES (%s, %s)",
(batch_id, val)
)
processed += 1
except psycopg.DatabaseError:
failed += 1
final_status = "COMPLETED" if failed == 0 else ("FAILED" if processed == 0 else "PARTIAL_FAILURE")
with conn.cursor() as cur:
cur.execute("""
UPDATE batch_jobs
SET status = %s, processed_count = %s, failed_count = %s
WHERE id = %s
""", (final_status, processed, failed, batch_id))
conn.commit()Runnable Assertion: Verifikasi Konsistensi State
Test case berikut memverifikasi bahwa crash di tengah batch tidak menghasilkan false pass pada status worker maupun data state di database.
def test_batch_partial_failure_detection(db_conn):
# Setup batch job
with db_conn.cursor() as cur:
cur.execute("INSERT INTO batch_jobs DEFAULT VALUES RETURNING id;")
batch_id = cur.fetchone()[0]
db_conn.commit()
# Payload 10 baris: item ke-8 bernilai negatif (-5), memicu CHECK constraint error
records_payload = [10, 20, 30, 40, 50, 60, 70, -5, 90, 100]
# Eksekusi worker
process_batch(db_conn, batch_id, records_payload)
# Verifikasi konsistensi audit state
with db_conn.cursor() as cur:
cur.execute("SELECT status, processed_count, failed_count FROM batch_jobs WHERE id = %s", (batch_id,))
job = cur.fetchone()
cur.execute("SELECT COUNT(*) FROM batch_items WHERE batch_id = %s", (batch_id,))
total_inserted = cur.fetchone()[0]
# Assertions
assert job[0] == "PARTIAL_FAILURE", f"False pass terdeteksi! Status job: {job[0]}"
assert job[1] == 9, "Jumlah record sukses harus tepat 9"
assert job[2] == 1, "Jumlah record gagal harus tepat 1"
assert total_inserted == 9, "Data fisik di tabel harus sesuai dengan commit valid"Catatan Performa: Penggunaan
SAVEPOINTper baris memiliki overhead I/O logik pada Write-Ahead Log (WAL). Di lingkungan produksi, bungkus data dalam kelompok chunk (misal 500-1000 row per SAVEPOINT) daripada per single insert.
Pertimbangan Teknis dan Trade-offs
- Atomic Single Transaction vs Chunked: Jika kebutuhan bisnis menuntut seluruh batch batal saat ada satu baris gagal, hindari
SAVEPOINT. Gunakan single transaction block dan tangkap error untuk menandai batch sebagaiFAILEDpenuh. - Deadlock Hazard: Saat menjalankan batch worker secara paralel pada tabel yang sama, pastikan pengurutan data input deterministik (misalnya sorting by primary key) sebelum eksekusi chunk untuk mencegah transaksi saling mengunci baris (deadlock).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!