Implementasi container Linux berbasis Incus di atas host NixOS menghadirkan efisiensi infrastruktur yang tinggi melalui isolasi ringan dan manajemen konfigurasi deklaratif. Masalah muncul ketika siklus nixos-rebuild switch atau peremajaan container mengeksekusi penghentian proses secara mendadak. Sinyal terminasi default sering kali memotong eksekusi worker di tengah proses, meninggalkan distributed lock menggantung di Redis (zombie lock) dan memicu penarikan ulang task yang belum rampung oleh worker lain (task race condition).

Akar Masalah: Rebuild Deklaratif dan Terminasi Worker

Saat konfigurasi NixOS pada host atau instance Incus diperbarui, systemd menerapkan perubahan unit dengan mengirimkan SIGTERM dan langsung menindaklanjutinya dengan SIGKILL jika batas waktu default (biasanya 90 detik, atau lebih pendek jika dibatasi Incus) terlampaui. Worker yang dihentikan paksa saat memegang kunci eksekusi terdistribusi tidak sempat menjalankan blok pembersihan (finally atau defer).

Dampak langsung dari siklus ini meliputi:

  • Zombie Lock: Kunci di Redis atau database tetap terkunci selama sisa Time-To-Live (TTL). Task berikutnya tertahan dan memicu antrean macet.
  • Task Race: Message broker menganggap worker mati dan mendistribusikan ulang task ke node lain sementara mutasi data pertama belum sepenuhnya rollback atau selesai.

Konfigurasi Declarative Systemd Draining di NixOS

Untuk mencegah terminasi mendadak, unit worker harus dikonfigurasi secara deklaratif di NixOS agar menangani graceful draining. Worker wajib menerima SIGTERM, berhenti menarik task baru dari broker, merampungkan task aktif, melepaskan lock, lalu keluar secara normal.

Tambahkan konfigurasi berikut pada deklarasi NixOS instance Incus:

# /etc/nixos/configuration.nix atau modul worker
{ pkgs, ... }:

{
  systemd.services.queue-worker = {
    description = "Distributed Queue Worker";
    after = [ "network-online.target" ];
    wants = [ "network-online.target" ];
    wantedBy = [ "multi-user.target" ];

    serviceConfig = {
      Type = "simple";
      ExecStart = "${pkgs.python3}/bin/python /srv/worker/main.py";
      KillSignal = "SIGTERM";
      TimeoutStopSec = 45;
      KillMode = "mixed";
      SendSIGKILL = true;
      Restart = "on-failure";
      RestartSec = 5;
    };
  };
}

Pengaturan TimeoutStopSec = 45; memberikan jendela waktu yang cukup bagi worker untuk menyelesaikan task, sementara KillMode = "mixed"; memastikan hanya proses utama yang menerima SIGTERM awal, sedangkan sub-proses yang membangkang akan dihentikan paksa setelah batas waktu habis.

Distributed Lock Berbasis Leasing dan Background Heartbeat

Menyetel TTL lock terlalu lama menyebabkan latency pemulihan yang tinggi ketika worker mengalami hard crash. Sebaliknya, TTL yang terlalu pendek berisiko melepaskan lock sebelum task selesai dikerjakan. Solusi standar industri adalah menggunakan TTL pendek (misalnya 5 detik) yang diperpanjang secara periodik via background heartbeat thread/goroutine.

Berikut implementasi akuisisi, perpanjangan masa berlaku (lease renewal), dan pelepasan lock menggunakan Redis dan Lua script agar atomic:

import time
import uuid
import threading
import redis

class DistributedLock:
    def __init__(self, redis_client: redis.Redis, lock_key: str, ttl_ms: int = 5000):
        self.redis = redis_client
        self.key = lock_key
        self.ttl_ms = ttl_ms
        self.owner_id = str(uuid.uuid4())
        self._running = False
        self._renew_thread = None

        self._release_lua = self.redis.register_script("""
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        """)

        self._renew_lua = self.redis.register_script("""
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('pexpire', KEYS[1], ARGV[2])
            else
                return 0
            end
        """)

    def acquire(self) -> bool:
        acquired = self.redis.set(self.key, self.owner_id, nx=True, px=self.ttl_ms)
        if acquired:
            self._running = True
            self._renew_thread = threading.Thread(target=self._heartbeat, daemon=True)
            self._renew_thread.start()
            return True
        return False

    def _heartbeat(self):
        interval = (self.ttl_ms / 1000.0) / 2.0
        while self._running:
            time.sleep(interval)
            if not self._running:
                break
            result = self._renew_lua(keys=[self.key], args=[self.owner_id, self.ttl_ms])
            if result != 1:
                # Kunci hilang atau diambil alih worker lain
                self._running = False
                break

    def release(self):
        self._running = False
        if self._renew_thread and self._renew_thread.is_alive():
            self._renew_thread.join(timeout=1.0)
        self._release_lua(keys=[self.key], args=[self.owner_id])

Jika node Incus dimatikan mendadak, thread heartbeat berhenti, dan lock secara otomatis kedaluwarsa tepat pada detik ke-5 tanpa meninggalkan zombie lock berkepanjangan.

Strategi Idempotency Key pada Consumer

Distributed lock mengurangi resiko konkurensi, namun tidak menjamin exactly-once delivery pada sistem terdistribusi. Mekanisme pengiriman pesan broker (seperti RabbitMQ, Redis Streams, atau Kafka) bersifat at-least-once. Jika worker terputus tepat setelah selesai menulis mutasi tetapi sebelum mengirim ACK ke broker, task akan didistribusikan ulang.

Setiap task wajib membawa idempotency_key unik. Consumer harus memvalidasi status mutasi di dalam database transaction:

-- Skema tabel deduplikasi
CREATE TABLE processed_tasks (
    task_id VARCHAR(64) PRIMARY KEY,
    status VARCHAR(20) NOT NULL,
    processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Handler eksekusi di consumer
BEGIN;

INSERT INTO processed_tasks (task_id, status)
VALUES ($1, 'PROCESSING')
ON CONFLICT (task_id) DO NOTHING;

-- Jika tidak ada row terpengaruh, task adalah duplikat
-- Worker dapat langsung commit/ACK tanpa re-proses
COMMIT;

Skrip Pengujian Failover

Pengujian ketahanan kluster dilakukan dengan memicu hard restart pada container Incus ketika worker sedang memproses payload panjang. Gunakan skrip Bash berikut untuk memverifikasi bahwa lock bersih secara otomatis dan task diproses ulang tanpa tabrakan data:

#!/usr/bin/env bash
set -euo pipefail

CONTAINER_NAME="worker-node-01"
REDIS_HOST="10.0.100.1"
LOCK_KEY="lock:task:payment-9821"

echo "[1] Memulai task di container $CONTAINER_NAME..."
incus exec "$CONTAINER_NAME" -- python /srv/worker/job_runner.py &
WORKER_PID=$!

sleep 2

echo "[2] Verifikasi status lock aktif di Redis:"
redis-cli -h "$REDIS_HOST" GET "$LOCK_KEY"

echo "[3] Memaksa restart container (simulasi crash)..."
incus restart "$CONTAINER_NAME" --force

echo "[4] Menunggu TTL kedaluwarsa..."
sleep 6

STATUS=$(redis-cli -h "$REDIS_HOST" GET "$LOCK_KEY")
if [ -z "$STATUS" ]; then
    echo "[PASS] Zombie lock terhapus otomatis via TTL expiration."
else
    echo "[FAIL] Zombie lock masih tertinggal: $STATUS"
    exit 1
fi

Melalui kombinasi penanganan TimeoutStopSec pada konfigurasi NixOS systemd, distributed lease lock dengan heartbeat renewal, dan pencatatan state idempotensi di database, kluster worker di atas container Incus dapat bertahan dari restart mendadak tanpa menyebabkan task race maupun kebuntuan sistem.