Yomu
Java Backend

pattern Outbox

Sinkronisasi fault-tolerant ke engine Rust melalui pattern outbox dengan retry terjadwal

pattern outbox memastikan sinkronisasi yang andal ke engine gamifikasi Rust bahkan ketika Rust tidak tersedia. Tanpa pattern ini, downtime Rust akan menyebabkan registrasi pengguna atau pengiriman kuis gagal sepenuhnya.

Pernyataan Masalah

Ketika registrasi pengguna berhasil tetapi Rust sedang down:

  • Tanpa outbox: Registrasi pengguna gagal, pengguna melihat error
  • Dengan outbox: Registrasi pengguna berhasil, sinkronisasi Rust dijadwalkan untuk retry

Sistem harus tahan terhadap:

  • Downtime layanan Rust
  • Masalah konektivitas jaringan
  • HTTP error yang dapat di-retry (408, 429, 500+)
  • Kegagalan koneksi database

Alur pattern Outbox

FailedSyncEventEntity

Event sinkronisasi yang gagal disimpan di tabel failed_sync_events:

@Entity
@Table(name = "failed_sync_events")
public class FailedSyncEventEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long eventId;
    
    @Enumerated(EnumType.STRING)
    private SyncEventType eventType;  // USER_SYNC, QUIZ_SYNC
    
    @Column(nullable = false, length = 4000)
    private String payloadJson;       // {"user_id": "uuid"}
    
    @Enumerated(EnumType.STRING)
    private SyncEventStatus status;   // FAILED, PENDING, DONE
    
    private int retryCount = 0;       // Ditambah pada setiap retry
    
    private String lastError;         // Pesan error terakhir
    
    private Instant nextRetryAt;      // Untuk exponential backoff
    
    private Instant createdAt;
    private Instant updatedAt;
}

Enum SyncEventType

public enum SyncEventType {
    USER_SYNC,    // Sinkronisasi data pengguna ke Rust
    QUIZ_SYNC     // Sinkronisasi percobaan kuis (mendatang)
}

Enum SyncEventStatus

public enum SyncEventStatus {
    FAILED,  // Percobaan sinkronisasi gagal
    PENDING, // Menunggu retry
    DONE     // Berhasil disinkronkan
}

OutboxService

Mencatat percobaan sinkronisasi yang gagal:

@Service
public class OutboxService {
    public void recordUserSyncFailure(UUID userId, String lastError) {
        FailedSyncEventEntity event = new FailedSyncEventEntity();
        event.setEventType(SyncEventType.USER_SYNC);
        event.setPayloadJson(buildPayloadJson(userId));  // {"user_id": "uuid"}
        event.setStatus(SyncEventStatus.FAILED);
        event.setRetryCount(0);
        event.setLastError(lastError);
        failedSyncEventRepository.save(event);
    }
}

Dipanggil dari AuthService.syncNewUser() ketika sinkronisasi Rust gagal.

OutboxScheduler

Job terjadwal berjalan setiap 5 menit untuk me-retry event yang gagal:

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "outbox.scheduler.enabled", havingValue = "true", matchIfMissing = true)
public class OutboxScheduler {
    @Scheduled(cron = "0 */5 * * * *")
    public void retryFailedSyncEvents() {
        outboxRetryService.retryFailedFromScheduler(maxRetry);
    }
}

Konfigurasi:

  • Default enabled: Ya (matchIfMissing = true)
  • Max retry: Dapat dikonfigurasi melalui outbox.retry.max-attempts (default: 5)
  • Cron schedule: Setiap 5 menit (0 */5 * * * *)

Scheduler berjalan di production secara default. Untuk menonaktifkan, atur outbox.scheduler.enabled=false di application properties Anda.

OutboxRetryService

Logika retry inti:

@Service
public class OutboxRetryService {
    // Public API
    public FailedSyncEventsData listFailedSyncEvents() {
        // Mengembalikan 100 event FAILED/PENDING teratas
    }
    
    public RetrySummary retryEvents(Collection<Long> eventIds, boolean retryAll) {
        // Me-retry event tertentu atau semua yang pending
    }
    
    public int retryFailedFromScheduler(int maxRetry) {
        // Dipanggil oleh scheduler, menghormati batas maxRetry
    }
}

Logika Retry

  1. Mengambil event dengan status FAILED atau PENDING (maks 100)
  2. Untuk setiap event:
    • Memeriksa retryCount < maxRetry
    • Mencoba sinkronisasi ke Rust
    • Jika berhasil: status=DONE, hapus lastError
    • Jika gagal: retryCount++, status=FAILED, perbarui lastError

Endpoint Admin

EndpointMethodKeterangan
/api/v1/admin/failed-sync-eventsGETDaftar semua event pending/failed
/api/v1/admin/failed-sync-events/retryPOSTMe-retry event tertentu atau semua

Request body untuk retry:

{
  "event_ids": [1, 2, 3],    // Opsional: event ID tertentu
  "retry_all": true          // Opsional: retry semua event (jika event_ids kosong)
}

Response:

{
  "success": true,
  "message": "Retry failed sync events selesai",
  "data": {
    "processed_count": 10,
    "done_count": 7,
    "failed_count": 3
  }
}

Interface RustEngineClient

Mendefinisikan kontrak sync client:

public interface RustEngineClient {
    SyncResult syncUser(UUID userId);
    
    record SyncResult(int statusCode, String responseBody) {}
}

Implementasi:

  • RestClientRustEngineClient: HTTP/REST client menggunakan Spring RestClient
  • GrpcUserSyncClient: gRPC client (alternatif, mendatang)

Konfigurasi RestClientRustEngineClient

@Component
public class RestClientRustEngineClient implements RustEngineClient {
    private final RestClient restClient;
    
    public RestClientRustEngineClient(
        @Value("${rust.engine.base-url:http://localhost:8081}") String baseUrl,
        @Value("${internal.api.key:}") String internalApiKey
    ) {
        // Mengonfigurasi timeout (2s connect, 3s read)
    }
}

Properti konfigurasi:

  • rust.engine.base-url: URL engine Rust (default: http://localhost:8081)
  • internal.api.key: Header API key untuk endpoint internal

Endpoint sinkronisasi: POST /api/internal/users/sync dengan header x-api-key

On this page