diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a0e38e9
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,38 @@
+name: Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: tests-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Java ${{ matrix.java }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ strategy:
+ fail-fast: false
+ matrix:
+ java: ['17', '21']
+
+ steps:
+ - name: Traer el codigo
+ uses: actions/checkout@v4
+
+ - name: Preparar Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ cache: maven
+
+ - name: Generar, compilar, revisar la API publica y correr los tests
+ run: mvn -B verify
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..db5755e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,192 @@
+# Caerus SDK para Java
+
+El cliente oficial de Caerus para Java. Reserva stock limitado (asientos, cupos, turnos,
+inventario) y coordina locks distribuidos sin escribir la lógica de concurrencia que evita
+que dos clientes compren o modifiquen lo mismo al mismo tiempo.
+
+```java
+ResourceHolder holder = caerus.unitary("butaca_A12").take();
+chargeCard(4500);
+caerus.confirm(holder.id());
+```
+
+Es el mismo producto que [`@caerus-dev/sdk`](https://github.com/caerus-dev/caerus-sdk-ts):
+mismos métodos, mismos errores y mismos bordes. Lo que cambia es lo que tiene que cambiar
+para que se sienta Java.
+
+**Corre en tu servidor.** La API Key autentica cada llamada e identifica tu entorno, así
+que nunca tiene que llegar a un cliente o a un navegador.
+
+---
+
+## Instalación
+
+Requiere Java 17 o superior.
+
+```xml
+
+ io.github.caerus-dev
+ caerus-sdk
+ 0.1.0
+
+```
+
+Trae gRPC (con Netty incluido), protobuf y Gson. Nada de eso aparece en la API pública: un
+test del build lo hace cumplir.
+
+---
+
+## Conexión
+
+```java
+CaerusClient caerus = new CaerusClient(CaerusClientOptions.builder()
+ .apiKey(System.getenv("CAERUS_API_KEY"))
+ .build());
+```
+
+Esa es toda la configuración. La API Key identifica el entorno, así que el cliente ya sabe
+adónde conectarse.
+
+Un cliente por proceso: el canal multiplexa todas las llamadas. Implementa `AutoCloseable`;
+`close()` es idempotente y después de cerrar toda llamada falla con un `CaerusError`.
+
+### Apuntar a otro motor
+
+```java
+CaerusClient caerus = new CaerusClient(CaerusClientOptions.builder()
+ .endpoint("localhost:9090")
+ .apiKey(apiKey)
+ .tls(false)
+ .build());
+```
+
+| Opción | Default | Regla |
+|---|---|---|
+| `apiKey` | — | Obligatoria. Nunca se lee del entorno ni aparece en `toString()` |
+| `endpoint` | `CAERUS_ENDPOINT`, si no `caerus.dev.ar.sdk.apps.disilab.ar:443` | `host:puerto`, sin esquema |
+| `tls` | `CAERUS_TLS`, si no `true` | En la variable solo `false` o `0` lo apagan |
+| `timeoutMs` | `10000` | Deadline de cada llamada |
+| `logger` | `System.err` con prefijo `[caerus]` | Solo para lo que no conviene lanzar |
+
+El `DlsClient` lee `CAERUS_DLS_ENDPOINT` y `CAERUS_DLS_TLS`, y su prefijo es `[caerus-dls]`.
+
+---
+
+## Shared Resource Engine
+
+```java
+caerus.createMultiple("entrada", "general", 500, CreateResourceOptions.builder()
+ .groupKey("funcion_20")
+ .build());
+
+ResourceHolder holder = caerus.pooled("general").takeMany(2, TakeOptions.builder()
+ .idempotencyKey(orderId)
+ .ttlSeconds(600)
+ .metadata(Map.of("orderId", orderId))
+ .build());
+
+try {
+ pay(holder);
+ caerus.confirm(holder.id(), ConfirmOptions.builder().metadata(Map.of("paymentId", paymentId)).build());
+} catch (PaymentFailed e) {
+ caerus.release(holder.id());
+}
+```
+
+| Método | Qué hace |
+|---|---|
+| `createUnitary(template, key)` / `createMultiple(template, key, amount)` | Declara un recurso |
+| `updateResource(key, delta)` | Suma o resta stock |
+| `deleteResource(key)` | Lo borra; falla si alguien lo tiene tomado |
+| `unitary(key).take()` | Toma la única unidad |
+| `pooled(key).take()` / `.takeMany(n)` | Toma una o varias |
+| `confirm(id)` | Las deja tomadas para siempre |
+| `release(id)` | Las devuelve |
+| `extend(id, extraMs)` | Corre el vencimiento, en **milisegundos** |
+| `getResourceHolder(id)` | Lee un holder; es el único que devuelve un `EXPIRED` sin lanzar |
+| `getResource(key)` / `getResourcesByGroup(group)` / `listResourceHolders()` | Consultas |
+
+Un `take` o un `extend` que vuelven en un estado que no sirve (por ejemplo, una clave de
+idempotencia cuyo holder ya terminó) lanzan `ConflictError`: nunca se devuelve un holder que
+no podés usar. Un estado que el SDK no conoce también lanza: no se adivina.
+
+`QUEUED` es real: la plantilla encola y no había stock. El SDK no espera a que se asigne;
+devuelve el holder `QUEUED` y decidís vos.
+
+## Distributed Lock Service
+
+```java
+DlsClient dls = new DlsClient(DlsClientOptions.builder().apiKey(apiKey).build());
+
+String result = dls.withTransaction(tx -> {
+ LockHolder lock = tx.acquireLock("cuentas", "cuenta-42", LockMode.EXCLUSIVE,
+ AcquireLockOptions.builder().idempotencyKey(requestId).timeoutMs(30_000L).build());
+ return transfer(lock.fencingToken().getAsLong());
+}, TransactionOptions.builder().timeoutMs(20_000L).build());
+```
+
+`withTransaction` abre la transacción, la renueva sola mientras el callback trabaja y suelta
+todos los locks al final, salga bien o salga mal. Si la renovación falla sin remedio, la
+transacción se da por perdida: se cancela `tx.signal()`, se avisa a `onTransactionLost` y la
+llamada lanza ese error aunque el callback haya terminado bien. Las excepciones checked del
+callback salen tal cual, sin envolver.
+
+Un lock denegado nunca vuelve como valor: es `LockDeniedError`. Una espera en la cola se
+corta con un `AbortSignal` (`AbortController.abort()`), y `onQueued` avisa una sola vez.
+
+## Webhooks
+
+```java
+CaerusEvent event = caerus.webhooks().constructEvent(rawBody, request.getHeader("Caerus-Signature"), secret);
+
+if (event.data() instanceof ResourceTakenData taken) {
+ log.info("holder {} tomó {} unidades", taken.holderId(), taken.amount());
+}
+```
+
+Verifica la firma HMAC-SHA256 contra el cuerpo crudo, en tiempo constante, con 5 minutos de
+tolerancia. Cada tipo de evento tiene su `record`; un evento que el SDK todavía no conoce
+llega como `UnknownEventData` en vez de romper al consumidor.
+
+## Errores
+
+Todo lo que lanza el SDK extiende `CaerusError`, que es unchecked: un solo
+`catch (CaerusError e)` los junta a todos, y `e.code()` sirve para un `switch`.
+`e.reason()` trae el código del motor (`OUT_OF_STOCK`, `DEADLOCK_DETECTED`...) y
+`e.requestId()` el identificador para pedir soporte. Detalle en
+[docs/errores.md](docs/errores.md).
+
+El SDK no reintenta nada. Reintentar es decisión de quien llama, con clave de idempotencia.
+
+## Testing sin Caerus
+
+`InMemoryCaerusClient` e `InMemoryDlsClient` implementan `SharedResourceApi` y `DlsApi`, así
+que el código bajo test no se entera.
+
+```java
+InMemoryCaerusClient caerus = new InMemoryCaerusClient(MockOptions.builder()
+ .resources(List.of(MockResourceSeed.of("butaca_A12", 1)))
+ .build());
+
+caerus.failNext(MockMethod.CONFIRM, new ConflictError("pago rechazado"));
+caerus.advanceTime(600);
+```
+
+El tiempo no pasa solo: lo mueve el test. Son thread-safe.
+
+---
+
+## Desarrollo
+
+```bash
+mvn verify
+```
+
+Genera el cliente gRPC desde `proto/`, compila y corre los tests contra un motor gRPC falso
+en `127.0.0.1`. La verificación contra un motor real está en
+[docs/verificacion-en-vivo.md](docs/verificacion-en-vivo.md) y las diferencias con el SDK
+de TypeScript en [docs/paridad.md](docs/paridad.md).
+
+## Licencia
+
+MIT
diff --git a/docs/errores.md b/docs/errores.md
new file mode 100644
index 0000000..6f7813e
--- /dev/null
+++ b/docs/errores.md
@@ -0,0 +1,110 @@
+# Errores
+
+Todo lo que lanza el SDK extiende `CaerusError`, que extiende `RuntimeException`. Un solo
+`catch` los junta a todos, y cada uno lleva un `code()` para usar en un `switch` sin
+depender de `instanceof`.
+
+```java
+try {
+ caerus.pooled("curso-3").takeMany(1, TakeOptions.builder().idempotencyKey(k).build());
+} catch (ConflictError e) {
+ return "no hay lugar";
+}
+```
+
+Las excepciones de configuración (una API Key vacía, un `timeoutMs` negativo) son
+`IllegalArgumentException` y salen al construir el cliente, no en la primera llamada. Usar
+un `TransactionContext` después de que terminó `withTransaction` es
+`IllegalStateException`.
+
+## El mapeo
+
+Refleja lo que hace el servidor en `GrpcGlobalExceptionHandler`, no una convención general
+de gRPC.
+
+| Estado gRPC | Error del SDK | `code()` | Cuándo |
+|---|---|---|---|
+| `NOT_FOUND` | `ResourceNotFoundError` | `RESOURCE_NOT_FOUND` | El recurso, la plantilla o el holder no existen |
+| `FAILED_PRECONDITION` | `ConflictError` | `CONFLICT` | El estado no permite la operación |
+| `INVALID_ARGUMENT` | `ValidationError` | `VALIDATION` | El pedido está mal formado |
+| `UNAUTHENTICATED` | `AuthenticationError` | `AUTHENTICATION` | API Key ausente, desconocida o revocada |
+| `DEADLINE_EXCEEDED` | `TimeoutError` | `TIMEOUT` | La llamada pasó su deadline |
+| cualquier otro | `CaerusError` | `UNKNOWN` | Incluye `INTERNAL` |
+
+## `ConflictError` junta varias cosas, y se distinguen
+
+| Subclase | `reason()` | Cuándo |
+|---|---|---|
+| `OutOfStockError` | `OUT_OF_STOCK` | No quedan unidades libres |
+| `HolderNotActiveError` | `HOLDER_NOT_ACTIVE` | El holder está `RELEASED`, `CONFIRMED` o `EXPIRED` |
+| `ResourceHasActiveHoldsError` | `RESOURCE_HAS_ACTIVE_HOLDS` | No se puede borrar: alguien lo tiene tomado |
+| `ResourceHasQueuedRequestsError` | `RESOURCE_HAS_QUEUED_REQUESTS` | No se puede borrar: hay gente en la cola |
+
+Las cuatro siguen siendo `ConflictError`. Cualquier error trae `reason()` con el código tal
+cual lo mandó el motor (`Optional` vacío si no mandó ninguno). El mensaje está escrito para
+personas y puede cambiar; `reason()` es contrato.
+
+## Errores del DLS
+
+Todos extienden `DlsError`, que extiende `CaerusError`. Primero decide el `reason`, después
+el estado gRPC.
+
+| Subclase | `reason()` | Estado gRPC sin `reason` | Cuándo |
+|---|---|---|---|
+| `DeadlockAbortedError` | `DEADLOCK_DETECTED` | — | El detector eligió esta transacción como víctima |
+| `LockDeniedError` | `LOCK_DENIED` | — | El lock lo tiene otro y la plantilla no encola |
+| `LockAlreadyHeldError` | `LOCK_ALREADY_HELD_EXCLUSIVELY` | `ALREADY_EXISTS` | La transacción ya tiene ese lock |
+| `TransactionNotActiveError` | `TRANSACTION_NOT_ACTIVE` | — | La transacción terminó o venció |
+| `LockModeMismatchError` | `LOCK_MODE_MISMATCH` | — | El modo no corresponde a la plantilla |
+| `LockAcquisitionCancelledError` | `LOCK_ACQUISITION_CANCELLED` | `CANCELLED` | Se canceló la espera |
+| `DlsConflictError` | — | `ABORTED`, `FAILED_PRECONDITION` | Otro conflicto |
+| `DlsNotFoundError` | — | `NOT_FOUND` | La transacción o el lock no existen |
+| `DlsValidationError` | — | `INVALID_ARGUMENT` | Pedido mal formado |
+| `DlsAuthenticationError` | — | `UNAUTHENTICATED` | API Key inválida |
+| `DlsTimeoutError` | — | `DEADLINE_EXCEEDED` | Se pasó el deadline |
+| `DlsError` | — | otro | `UNKNOWN` |
+
+El motor manda un `DENIED` como respuesta exitosa del stream, no como error gRPC. El SDK no
+lo devuelve como valor: `acquireLock` lanza `LockDeniedError` con `reason()` `LOCK_DENIED`.
+
+Un `acquireLock` sin `timeoutMs` propio usa el deadline del cliente (10 s por defecto),
+también dentro de `withTransaction`. Un lock en cola más tiempo que eso termina en
+`DlsTimeoutError`.
+
+## `requestId` y soporte
+
+Cada llamada manda un `x-request-id` (`req_` + UUID) y el motor lo devuelve en los trailers.
+Todo error lo trae en `requestId()` y lo agrega al mensaje:
+
+```java
+try {
+ caerus.unitary("butaca_1").take();
+} catch (CaerusError e) {
+ log.error("Caerus {} ({}) request {}", e.code(), e.reason().orElse("-"), e.requestId().orElse("-"));
+}
+```
+
+Con ese ID el equipo encuentra la traza exacta en los logs del motor sin que tengas que
+compartir claves ni datos. `docUrl()` apunta a este documento.
+
+## `TimeoutError` no significa que no pasó nada
+
+Significa que la llamada pasó su deadline. Si el servidor llegó a hacer el trabajo, no se
+sabe. Reintentar con la misma clave de idempotencia devuelve el holder que ya existía si
+sigue `PENDING` o `QUEUED`. Si ya terminó (`CONFIRMED`, `RELEASED` o `EXPIRED`), el motor
+lo devuelve igual pero el SDK lanza `ConflictError`: nunca te entrega un holder que no
+podés usar. Para ver en qué quedó, usá `getResourceHolder`.
+
+En el DLS pasa lo mismo con `acquireLock`: repetir la misma clave de idempotencia en la
+misma transacción devuelve el mismo lock (mismo `lockId` y fencing token); pedir el mismo
+lock con otra clave da `LockAlreadyHeldError`.
+
+## El SDK no reintenta
+
+Ninguna operación se reintenta sola, ni siquiera las lecturas. En un sistema de reservas los
+reintentos silenciosos son exactamente cómo se entrega la misma butaca dos veces.
+
+## Llamadas interrumpidas
+
+Si el hilo que espera una llamada se interrumpe, el SDK cancela la llamada gRPC, vuelve a
+marcar la interrupción del hilo y lanza `CaerusError` (o `DlsError`) con código `UNKNOWN`.
diff --git a/docs/paridad.md b/docs/paridad.md
new file mode 100644
index 0000000..2944c08
--- /dev/null
+++ b/docs/paridad.md
@@ -0,0 +1,70 @@
+# Paridad con el SDK de TypeScript
+
+Este SDK copia el comportamiento de `@caerus-dev/sdk` 0.5.0 (`caerus-sdk-ts@367e0d1`). Lo que
+sigue es lo que cambia, y por qué.
+
+## Lo que cambia por ser Java
+
+| TypeScript | Java | Por qué |
+|---|---|---|
+| `Promise` | `T`, bloqueante | Etapa 1. La API asíncrona (`CompletableFuture`) llega antes de 1.0 sobre el mismo núcleo |
+| Objeto de opciones literal | `Options.builder()...build()` | Java no tiene parámetros con nombre. Cada método sin opciones tiene su sobrecarga sin ellas |
+| `field?: T` | `Optional` / `OptionalLong` | La ausencia queda en el tipo |
+| `Date` | `Instant` | |
+| `number` (fencing token) | `long` | Es el `czxid` de ZooKeeper: no entra en un `int` |
+| `AbortSignal` de la plataforma | `dev.caerus.sdk.AbortController` / `AbortSignal` | Java no tiene uno estándar; mismos nombres |
+| `onTransactionLost: (error: Error) => void` | `Consumer` | |
+| Callback `async (tx) => T` | `TransactionCallback` | Una excepción checked del callback sale tal cual, sin envolver |
+| `CaerusError extends Error` | `CaerusError extends RuntimeException` | Unchecked: un solo `catch` |
+| `code: 'CONFLICT'` (string) | `ErrorCode.CONFLICT` (enum) | Mismos nombres |
+| `TypeError` en la configuración | `IllegalArgumentException` | Lo equivalente en Java |
+| `InMemory*` de un hilo | `InMemory*` thread-safe | En Java los tests usan hilos de verdad |
+| `MockMethod` `'takeMany'` | `MockMethod.TAKE_MANY` | Convención de enums |
+| `close()` solo en `CaerusClient` y `DlsClient` | `SharedResourceApi` y `DlsApi` extienden `AutoCloseable` | `try-with-resources` |
+| Renovación con `setInterval` | `ScheduledExecutorService` del cliente | Java 17 no tiene virtual threads. Un hilo daemon por cliente, que se apaga en `close()`. Las renovaciones no se superponen |
+| `CaerusEvent` = JSON parseado | `CaerusEvent` con un `record` por tipo de evento y `raw()` con todo el sobre | `UnknownEventData` para tipos nuevos |
+
+## Inconsistencias del SDK de TS: qué se decidió (Thomas, 30/09/2026)
+
+| # | En TS | En Java |
+|---|---|---|
+| 1 | SRE lee `CAERUS_ENDPOINT`/`CAERUS_TLS`, DLS lee `CAERUS_DLS_*`; prefijos `[caerus]` y `[caerus-dls]` | Igual |
+| 2 | Usar un contexto cerrado lanza un `Error` pelado | `IllegalStateException`, lo equivalente en Java |
+| 3 | `InMemoryDlsClient.releaseLock` de un lock inexistente lanza `DlsNotFoundError` | No hace nada, como el motor (`DistributedLockEngineService.releaseLock` responde OK aunque no haya nada que soltar). Se propone el mismo cambio para TS |
+| 4 | Sufijo en castellano en el mensaje de todo error con `requestId` | Igual |
+| 5 | `docs/webhooks.md` dice que los errores de webhooks extienden `Error` | Extienden `CaerusError` con `VALIDATION`, como el código |
+| 6 | `LockAcquisitionCancelledError` con `code` `UNKNOWN` | Igual |
+| 7 | `renewTransaction` devuelve solo el `transactionId` | Igual |
+| 8 | `webhooks` solo en `CaerusClient` (y el mock del SRE) | Igual |
+
+## Otras diferencias, a favor de parecerse al motor
+
+Salieron de la revisión del PR #1. Las tres primeras también pasan en TypeScript; vale la
+pena proponer el mismo cambio allá.
+
+- **La pérdida de la transacción corta también una espera con señal propia.** En TS, si un
+ `acquireLock` dentro de `withTransaction` trae su propio `signal`, ese reemplaza al de la
+ transacción y una transacción perdida no corta la espera: queda colgada hasta el deadline.
+ En Java la espera escucha las dos señales.
+- **Las renovaciones no se superponen.** Si una renovación tarda más que el intervalo, el
+ siguiente latido se saltea en vez de mandar otra en paralelo.
+- **La firma de un webhook se calcula sobre los bytes crudos.** TS decodifica el `Buffer`
+ como UTF-8 antes de firmar, así que un cuerpo con UTF-8 inválido falla como firma
+ incorrecta. En Java la firma se verifica primero y un UTF-8 inválido da
+ `CaerusWebhookPayloadError`.
+- **`InMemoryDlsClient` respeta la clave de idempotencia de `acquireLock`**: la misma clave en
+ la misma transacción devuelve el mismo lock, y otra clave da `LockAlreadyHeldError`
+ (`LOCK_ALREADY_HELD_EXCLUSIVELY`), como `ZooKeeperDistributedLockAdapter` (verificado
+ contra el motor el 03/10). El de TS lanza `LockDeniedError` en los dos casos.
+- `getTransactionStatus` informa `EXCLUSIVE` cuando el modo pedido llega sin especificar,
+ igual que TS. No es inventar: el motor trata un modo sin especificar como `EXCLUSIVE`.
+- `InMemoryDlsClient.getLockStatus` devuelve el fencing token real de cada holder; el de TS
+ devuelve `0`, que el cliente real nunca devolvería.
+- `InMemoryDlsClient.acquireLock` con una señal ya cancelada lanza `DlsError` ("AcquireLock
+ aborted by user"), como el cliente real; el de TS lanza un `Error` pelado.
+- `TransactionStatusResponse.abortReason()` vacío cuando el motor manda `""`; TS devuelve
+ `""`.
+- `constructEvent` con un cuerpo firmado que es JSON pero no un objeto lanza
+ `CaerusWebhookPayloadError`; TS lo devuelve como si fuera un evento.
+- El mensaje de "estado desconocido" dice `Update io.github.caerus-dev:caerus-sdk.` en vez de
+ `@caerus-dev/sdk`, y `docUrl()` apunta a la documentación de este repo.
diff --git a/docs/verificacion-en-vivo.md b/docs/verificacion-en-vivo.md
new file mode 100644
index 0000000..d29b564
--- /dev/null
+++ b/docs/verificacion-en-vivo.md
@@ -0,0 +1,70 @@
+# Verificación contra un motor real
+
+Los tests del build corren contra un motor gRPC falso: son rápidos y no necesitan
+infraestructura. Lo que no pueden probar es que el motor de verdad se comporte como el SDK
+espera. Las dos veces que el SDK de TypeScript tuvo un defecto de contrato, los tests contra
+el mock pasaban.
+
+```bash
+CAERUS_API_KEY=tu_clave mvn test -Plive
+```
+
+Los tests en vivo llevan el tag `live` y quedan fuera de `mvn verify`. Sin `CAERUS_API_KEY`
+se saltean. El endpoint sale de `CAERUS_ENDPOINT` (por defecto el motor hospedado) y TLS de
+`CAERUS_TLS` (por defecto prendido).
+
+Detrás de un antivirus que intercepta HTTPS (Avast), la JVM tiene que confiar en los
+certificados de Windows:
+
+```bash
+mvn test -Plive -DargLine="-Djavax.net.ssl.trustStoreType=Windows-ROOT"
+```
+
+## DLS (`LiveDlsTest`)
+
+| Escenario | Plantilla | Qué demuestra |
+|---|---|---|
+| `sharedReadAndLockStatus` | `READ_WRITE` | Dos lectores entran a la vez, `getLockStatus` los lista, un escritor no entra, soltar uno deja al otro |
+| `deniedLockInAFailNamespace` | `FAIL` | Un lock denegado llega como `LockDeniedError` con `LOCK_DENIED` |
+| `twoWayDeadlockWithKillPriority` | `QUEUE` | Una sola víctima, la más joven, con `DEADLOCK_DETECTED`, en menos de 10 s |
+| `triangularDeadlock` | `QUEUE` | Se rompe el ciclo sin matar a las tres |
+| `deadlockWithAlertStrategyAbortsNobody` | `ALERT` | No se aborta a nadie |
+| `transactionStatusWithALockInTheQueueAndOnQueued` | `QUEUE` | `getTransactionStatus` responde con un lock en cola; `onQueued` avisa una vez y antes de conceder |
+| `aQueuedLockWithoutItsOwnTimeoutUsesTheClientDeadline` | `QUEUE` | Sin `timeoutMs` propio, la espera corta con el deadline del cliente |
+| `aClosedTransactionCannotTakeLocks` | `QUEUE` | Una transacción cerrada da `TRANSACTION_NOT_ACTIVE` |
+| `withTransactionRenewsALongJob` | `QUEUE` | Una transacción de 4 s sigue viva a los 7 s y su vencimiento avanza |
+| `fencingTokensGrowAcrossHandoffs` | `QUEUE` | Los fencing tokens crecen de un dueño al siguiente |
+| `aWrongApiKeyIsAnAuthenticationError` | — | Una API Key que no existe da `DlsAuthenticationError` |
+| `anEngineErrorCarriesARequestId` | — | El error trae el `requestId` |
+
+Los namespaces se eligen por variable de entorno:
+
+| Variable | Default | `lock_type` | `conflict_resolution` | `deadlock_resolution_strategy` |
+|---|---|---|---|---|
+| `DLS_NS_FAIL` | `cuenta` | `EXCLUSIVE` | `FAIL` | `KILL_PRIORITY` |
+| `DLS_NS_QUEUE` | `cuenta_cola` | `EXCLUSIVE` | `QUEUE` | `KILL_PRIORITY` |
+| `DLS_NS_ALERT` | `cuenta_alerta` | `EXCLUSIVE` | `QUEUE` | `ALERT` |
+| `DLS_NS_READ_WRITE` | `archivo` | `READ_WRITE` | `QUEUE` | `KILL_PRIORITY` |
+
+Cada `acquireLock` manda una clave de idempotencia, así que también sirven plantillas con
+fencing token obligatorio. Una plantilla `READ_WRITE` con `QUEUE` sirve como `DLS_NS_QUEUE`:
+los escenarios piden `EXCLUSIVE`.
+
+## SRE (`LiveSreTest`)
+
+Necesita `SRE_TEMPLATE` con el nombre de una plantilla de recursos del mismo entorno que la
+key. Sin ella se saltea.
+
+| Escenario | Qué demuestra |
+|---|---|
+| `theWholeLifeOfAPooledResource` | Crear, tomar con idempotencia y metadata, repetir la clave, extender (redondeo a segundos), quedarse sin stock, confirmar, no poder borrar con holders activos, liberar, listar, buscar por grupo, actualizar stock y borrar |
+| `aResourceThatDoesNotExistIsNotFound` | `ResourceNotFoundError` con `requestId` |
+| `aWrongApiKeyIsAnAuthenticationError` | `AuthenticationError` |
+
+`getResourcesByGroup` lee de PostgreSQL, que se sincroniza por write-behind: el test
+reintenta hasta 15 s antes de dar por perdido un recurso recién creado.
+
+## No medir tiempos con otra corrida en marcha
+
+Dos verificaciones contra el mismo motor se reparten el motor y los tiempos cambian. Los
+umbrales de tiempo de estos tests asumen que corren solos.
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..c4b3727
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,248 @@
+
+
+ 4.0.0
+
+ io.github.caerus-dev
+ caerus-sdk
+ 0.1.0-SNAPSHOT
+ jar
+
+ Caerus SDK for Java
+ Caerus SDK for Java: Shared Resource Engine and Distributed Lock Service clients
+ https://github.com/caerus-dev/caerus-sdk-java
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ https://github.com/caerus-dev/caerus-sdk-java
+ scm:git:https://github.com/caerus-dev/caerus-sdk-java.git
+
+
+
+ 17
+ UTF-8
+ UTF-8
+
+ 1.84.0
+ 3.25.9
+ 2.14.0
+
+ 6.1.3
+ 3.27.7
+ 1.5.1
+
+
+ live
+
+
+
+
+
+ io.grpc
+ grpc-bom
+ ${grpc.version}
+ pom
+ import
+
+
+ org.junit
+ junit-bom
+ ${junit.version}
+ pom
+ import
+
+
+
+
+
+
+ io.grpc
+ grpc-netty-shaded
+
+
+ io.grpc
+ grpc-protobuf
+
+
+ io.grpc
+ grpc-stub
+
+
+ com.google.protobuf
+ protobuf-java
+ ${protobuf.version}
+
+
+ com.google.code.gson
+ gson
+ ${gson.version}
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+ test
+
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.5.0
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+ 3.6.3
+
+
+ enforce
+
+ enforce
+
+
+
+
+ [17,)
+
+
+ [3.9,)
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.2.0
+
+
+ internalize-proto
+ generate-sources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ io.github.ascopes
+ protobuf-maven-plugin
+ 5.1.11
+
+ ${protobuf.version}
+
+ ${project.build.directory}/proto-sdk
+
+ false
+
+
+ io.grpc
+ protoc-gen-grpc-java
+ ${grpc.version}
+ @generated=omit
+
+
+
+
+
+
+ generate
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.16.0
+
+ 17
+
+ -Xlint:all,-processing,-serial
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.6.0
+
+ ${surefire.excludedGroups}
+ ${surefire.groups}
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.5.1
+
+
+
+ dev.caerus.sdk
+
+
+
+
+
+
+
+
+
+ live
+
+ live
+
+
+
+
+
diff --git a/proto/README.md b/proto/README.md
new file mode 100644
index 0000000..08c8c1c
--- /dev/null
+++ b/proto/README.md
@@ -0,0 +1,62 @@
+# El contrato
+
+`sre_service.proto` y `dls_service.proto` son **copias**. Los originales viven en
+`caerus-back`, en `grpc-contracts/src/main/proto/`, y de ahí se construye el motor. Estas
+copias existen solo para generar el cliente.
+
+## Por qué una copia
+
+`caerus-back` es privado y este repositorio es público: un repositorio público que nadie
+puede compilar no le sirve a nadie. La copia es byte a byte igual al original, así que se
+puede comparar con un checksum.
+
+## De dónde salió esta copia
+
+| | SRE | DLS |
+|---|---|---|
+| Repositorio | `caerus-dev/caerus-back` | `caerus-dev/caerus-back` |
+| Ruta | `grpc-contracts/src/main/proto/sre_service.proto` | `grpc-contracts/src/main/proto/dls_service.proto` |
+| Commit | `d37216d12b6a25ec411c08ec69cf9c23d0148fbf` | `d37216d12b6a25ec411c08ec69cf9c23d0148fbf` |
+| SHA-256 | `632ec22f01e56ebb537caec43fa189351e0b797212cb5b166a7f00d23439d0f7` | `b0f1af5a4ae3abf85e8b4ca0ee0d6b82db6a7b45d970df6b907107a01f199928` |
+
+> `.gitattributes` marca estos archivos `-text` para que git nunca les cambie el fin de
+> línea. Sin eso, una copia hecha en Windows vuelve con CRLF, los checksums dejan de
+> coincidir y la comparación avisa de un cambio que no existe.
+
+## Por qué el paquete Java no es el del `.proto`
+
+El `.proto` declara `java_package = "dev.caerus.proto.sre"` (y `.dls`), que es el paquete
+que usa el motor. El build copia los archivos a `target/proto-sdk`, cambia ese paquete por
+`dev.caerus.sdk.internal.proto.*` y genera desde ahí. Así las clases generadas quedan
+marcadas como internas y no chocan con las del motor si alguien tiene las dos en el
+classpath. Las copias de este directorio no se tocan.
+
+## Ver si sigue vigente
+
+Con un checkout de `caerus-back` al lado de este:
+
+```bash
+sha256sum proto/sre_service.proto
+git -C ../caerus-back show HEAD:grpc-contracts/src/main/proto/sre_service.proto | sha256sum
+```
+
+Mismo hash, nada que hacer. Distinto, el contrato se movió y esta copia quedó vieja.
+Comparar siempre lo que guarda git, o ignorando los fines de línea (`tr -d '\r'`): una
+diferencia de CRLF no es un cambio de contrato.
+
+## Actualizarla
+
+Copiar el archivo desde `caerus-back`, correr `mvn verify` y dejar que el compilador
+encuentre lo que se rompió. Después actualizar el commit y el checksum de la tabla:
+`GeneratedContractTest` falla si la tabla no coincide con los archivos.
+
+El SDK se versiona aparte del motor, así que un cambio de contrato obliga a decidir qué le
+hace a la API pública: un campo opcional nuevo es una versión menor, una firma que cambia es
+un cambio que rompe.
+
+## Una advertencia
+
+El build no puede decir que esta copia está vieja: genera el cliente con lo que haya en
+este directorio y compila contra eso, igual de contento con un contrato viejo que con uno
+actual. Lo que detecta la diferencia es la verificación contra el motor real, o la
+comparación de arriba.
diff --git a/proto/dls_service.proto b/proto/dls_service.proto
new file mode 100644
index 0000000..b41d24d
--- /dev/null
+++ b/proto/dls_service.proto
@@ -0,0 +1,201 @@
+syntax = "proto3";
+
+package caerus.dls.v1;
+
+import "google/protobuf/empty.proto";
+
+option java_multiple_files = true;
+option java_package = "dev.caerus.proto.dls";
+option java_outer_classname = "DistributedLockingEngineProto";
+
+// The Distributed Locking Engine service provides the hot path execution
+// for acquiring, renewing, and releasing distributed locks based on
+// configured lock templates.
+service DistributedLockingEngine {
+ // Begins a transaction and returns a unique transaction_id.
+ rpc BeginTransaction(BeginTransactionRequest) returns (BeginTransactionResponse);
+
+ // Acquires a distributed lock.
+ rpc AcquireLock(AcquireLockRequest) returns (stream AcquireLockResponse);
+
+ // Renews an active transaction to extend its time-to-live.
+ rpc RenewTransaction(RenewTransactionRequest) returns (RenewTransactionResponse);
+
+ // Releases a lock so others can acquire it.
+ rpc ReleaseLock(ReleaseLockRequest) returns (google.protobuf.Empty);
+
+ // Releases all locks held or requested by a specific transaction.
+ rpc ReleaseTransactionLocks(ReleaseTransactionLocksRequest) returns (google.protobuf.Empty);
+
+ // Gets the current status of a lock.
+ rpc GetLockStatus(GetLockStatusRequest) returns (GetLockStatusResponse);
+
+ // Gets the current status of a transaction and its associated locks.
+ rpc GetTransactionStatus(GetTransactionStatusRequest) returns (GetTransactionStatusResponse);
+}
+
+// Request to begin a transaction
+message BeginTransactionRequest {
+ optional int64 timeout_ms = 1;
+}
+
+// Response containing the newly generated transaction ID.
+message BeginTransactionResponse {
+ string transaction_id = 1;
+}
+
+// Request to release all locks owned by a specific transaction.
+message ReleaseTransactionLocksRequest {
+ string transaction_id = 1;
+}
+
+// Represents the mode of the lock.
+enum LockMode {
+ MODE_UNSPECIFIED = 0;
+
+ // Exclusive lock (only one holder allowed). Used for Writers or purely Exclusive templates.
+ EXCLUSIVE = 1;
+
+ // Shared lock (multiple holders allowed). Used for Readers.
+ SHARED_READ = 2;
+}
+
+// Request to acquire a distributed lock.
+message AcquireLockRequest {
+ // The namespace corresponds to the DistributedLockTemplate name.
+ string namespace = 1;
+
+ // The dynamic resource key being locked.
+ string lock_key = 2;
+
+ // Optional idempotency key to prevent duplicate acquisitions.
+ optional string idempotency_key = 3;
+
+
+ // Specifies if the client wants to Read or Write.
+ // Required if the namespace template is configured as READ_WRITE.
+ optional LockMode requested_mode = 5;
+
+ // The correlation transaction_id returned by BeginTransaction.
+ string transaction_id = 6;
+}
+
+// Represents the acquisition status of a lock.
+enum LockStatus {
+ // The lock status is unknown or unspecified.
+ LOCK_STATUS_UNSPECIFIED = 0;
+
+ // The lock was successfully acquired.
+ ACQUIRED = 1;
+
+ // The lock was denied (e.g., already held by another process).
+ DENIED = 2;
+
+ // The lock request has been queued (queued mode).
+ QUEUED = 3;
+}
+
+// Response from an acquire lock request.
+// Note on Server Streaming: The server will send a first QUEUED event if the lock cannot be acquired immediately.
+// It will then send multiple QUEUED events periodically (keep-alive) to prevent idle timeouts during long waits.
+// Finally, it will send a single terminal ACQUIRED or DENIED event before closing the stream.
+message AcquireLockResponse {
+ // The unique identifier for the acquired lock.
+ string lock_id = 1;
+
+ // Monotonically increasing fencing token (generated by ZooKeeper's czxid).
+ // Useful for preventing split-brain scenarios in downstream storage.
+ int64 fencing_token = 2;
+
+ // The status of the lock acquisition.
+ LockStatus status = 3;
+}
+
+// Request to renew an existing transaction.
+message RenewTransactionRequest {
+ // The unique identifier for the transaction to renew.
+ string transaction_id = 1;
+
+ // The extra milliseconds to add to the transaction's maximum lifetime.
+ int64 extra_ms = 2;
+}
+
+// Response from a renew transaction request.
+message RenewTransactionResponse {
+ // The unique identifier of the renewed transaction.
+ string transaction_id = 1;
+
+ // The new absolute maximum lifetime in epoch milliseconds.
+ int64 new_expires_at = 2;
+}
+
+// Request to release a held lock.
+message ReleaseLockRequest {
+ // The unique identifier of the lock to release.
+ string lock_id = 1;
+
+ // The correlation transaction_id returned by BeginTransaction.
+ string transaction_id = 2;
+}
+
+// Request to get the current status of a specific lock.
+message GetLockStatusRequest {
+ // The namespace corresponding to the DistributedLockTemplate.
+ string namespace = 1;
+
+ // The dynamic resource key.
+ string lock_key = 2;
+}
+
+// Information about a specific process holding the lock.
+message LockHolderInfo {
+ string lock_id = 1;
+ int64 expires_at = 2;
+ int64 fencing_token = 3;
+}
+
+// Response containing the status of a queried lock.
+message GetLockStatusResponse {
+ // Indicates if the lock is currently held by ANY process.
+ bool is_held = 1;
+
+ // If held, indicates if it's currently locked for EXCLUSIVE (write) or SHARED_READ.
+ LockMode current_mode = 2;
+
+ // List of active holders. If EXCLUSIVE, this will have at most 1 item.
+ // If SHARED_READ, it can contain multiple items.
+ repeated LockHolderInfo active_holders = 3;
+
+ // Observability: How many processes are currently queued/waiting for this lock.
+ int32 pending_queue_size = 4;
+}
+
+// Request to get the current status of a specific transaction.
+message GetTransactionStatusRequest {
+ // The transaction ID.
+ string transaction_id = 1;
+}
+
+// Information about a lock requested or held by a transaction.
+message TransactionLockInfo {
+ string namespace = 1;
+ string lock_key = 2;
+ LockMode requested_mode = 3;
+ LockStatus status = 4;
+}
+
+// Response containing the status of a queried transaction.
+message GetTransactionStatusResponse {
+ // The current status of the transaction (e.g. ACTIVE, ABORT_REQUESTED, ABORTED)
+ string status = 1;
+
+ // If aborted, the reason for the abort.
+ string abort_reason = 2;
+
+ // Locks currently requested or held by this transaction.
+ repeated TransactionLockInfo locks = 3;
+
+ // Absolute maximum lifetime of the transaction in epoch milliseconds.
+ int64 expires_at = 4;
+}
+
diff --git a/proto/sre_service.proto b/proto/sre_service.proto
new file mode 100644
index 0000000..8351ce9
--- /dev/null
+++ b/proto/sre_service.proto
@@ -0,0 +1,143 @@
+syntax = "proto3";
+package caerus.sre.v1;
+
+import "google/protobuf/empty.proto";
+
+// --- ESTAS 3 LÍNEAS SON LA CLAVE ---
+option java_multiple_files = true; // Fuerza a crear un .java por cada message
+option java_package = "dev.caerus.proto.sre"; // El paquete donde vivirán en tu proyecto
+option java_outer_classname = "SharedResourceEngineProto"; // Nombre del archivo contenedor
+
+service SharedResourceEngine {
+ // Configuración de inventario
+ rpc CreateResource(CreateResourceRequest) returns (ResourceResponse);
+ rpc UpdateResource(UpdateResourceRequest) returns (ResourceResponse);
+ rpc DeleteResource(DeleteResourceRequest) returns (google.protobuf.Empty);
+
+ // Operaciones de negocio (Transaccionales)
+ rpc Take(TakeRequest) returns (ResourceHolderResponse);
+ rpc Confirm(ConfirmRequest) returns (ResourceHolderResponse);
+ rpc Release(ReleaseRequest) returns (google.protobuf.Empty);
+ rpc Extend(ExtendRequest) returns (ResourceHolderResponse);
+
+ // Queries
+ rpc GetResource(GetResourceRequest) returns (ResourceResponse);
+ rpc GetResourcesByGroupKey(GetResourcesByGroupKeyRequest) returns (GetResourcesByGroupKeyResponse);
+ rpc GetResourceHolder(GetResourceHolderRequest) returns (ResourceHolderResponse);
+ rpc GetResourceHoldersList(GetResourceHoldersListRequest) returns (GetResourceHoldersListResponse);
+}
+
+// --- MENSAJES DE PETICIÓN ---
+
+message CreateResourceRequest {
+ string template_name = 1;
+ string key = 2;
+ int32 available_amount = 3;
+ optional string group_key = 4;
+ optional string metadata = 5;
+}
+
+message UpdateResourceRequest {
+ string resource_key = 1;
+ int32 delta_amount = 2; // Can be positive or negative
+ optional string group_key = 3;
+ optional string metadata = 4; // JSON string
+ optional string idempotency_key = 5;
+}
+
+message DeleteResourceRequest {
+ string key = 1;
+}
+
+message TakeRequest {
+ string resource_key = 1;
+ int32 amount = 2;
+ TakeOptionalSettings settings = 3;
+}
+
+message TakeOptionalSettings {
+ optional string idempotency_key = 1;
+ optional int32 custom_ttl_seconds = 2;
+ optional string metadata = 3;
+}
+
+message ConfirmRequest {
+ string resource_holder_id = 1;
+ optional string metadata_patch = 2; // Opcional: Actualiza la metadata final (ej. payment_id)
+}
+
+message ReleaseRequest {
+ string resource_holder_id = 1;
+}
+
+message ExtendRequest {
+ string resource_holder_id = 1;
+ int32 extra_ms = 2;
+}
+
+message GetResourceRequest {
+ string key = 1;
+}
+
+message GetResourcesByGroupKeyRequest {
+ string group_key = 1;
+ int32 page = 2;
+ optional int32 page_size = 3;
+}
+
+message GetResourceHolderRequest {
+ string resource_holder_id = 1;
+}
+
+message GetResourceHoldersListRequest {
+ optional string resource_key = 1;
+ int32 page = 2;
+ optional int32 page_size = 3;
+ enum SortDirection {
+ DESCENDING = 0;
+ ASCENDING = 1;
+ }
+ optional SortDirection created_at_sort_direction = 4;
+ optional ResourceHolderResponse.ResourceHolderStatus status_filter = 5;
+}
+
+// --- MENSAJES DE RESPUESTA ---
+
+message ResourceResponse {
+ string resource_id = 1;
+ string key = 2;
+ string template_id = 3;
+ int32 available_amount = 4;
+ int32 pending_count = 5;
+ optional string group_key = 6;
+ optional string metadata = 7;
+ int64 created_at_ms = 8;
+ int64 updated_at_ms = 9;
+}
+
+message GetResourcesByGroupKeyResponse {
+ repeated ResourceResponse resources = 1;
+ bool next_page = 2;
+}
+
+message GetResourceHoldersListResponse {
+ repeated ResourceHolderResponse resource_holders = 1;
+ bool next_page = 2;
+}
+
+message ResourceHolderResponse {
+ enum ResourceHolderStatus {
+ PENDING = 0;
+ CONFIRMED = 1;
+ RELEASED = 2;
+ QUEUED = 3;
+ EXPIRED = 4;
+ }
+ string holder_id = 1;
+ string resource_id = 2;
+ ResourceHolderStatus status = 3;
+ int32 amount = 4;
+ int64 expires_at = 5;
+ optional string metadata = 6;
+ int64 created_at_ms = 7;
+}
\ No newline at end of file
diff --git a/src/main/java/dev/caerus/sdk/AbortController.java b/src/main/java/dev/caerus/sdk/AbortController.java
new file mode 100644
index 0000000..f83f0c4
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/AbortController.java
@@ -0,0 +1,18 @@
+package dev.caerus.sdk;
+
+public final class AbortController {
+
+ private final AbortSignal signal = new AbortSignal();
+
+ public AbortSignal signal() {
+ return signal;
+ }
+
+ public void abort() {
+ signal.abort(null);
+ }
+
+ public void abort(Throwable reason) {
+ signal.abort(reason);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/AbortSignal.java b/src/main/java/dev/caerus/sdk/AbortSignal.java
new file mode 100644
index 0000000..1b5f7e2
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/AbortSignal.java
@@ -0,0 +1,66 @@
+package dev.caerus.sdk;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public final class AbortSignal {
+
+ private final Object lock = new Object();
+ private final List listeners = new ArrayList<>();
+ private volatile boolean aborted;
+ private volatile Throwable reason;
+
+ AbortSignal() {
+ }
+
+ public boolean aborted() {
+ return aborted;
+ }
+
+ public Optional reason() {
+ return Optional.ofNullable(reason);
+ }
+
+ public Runnable onAbort(Runnable listener) {
+ synchronized (lock) {
+ if (!aborted) {
+ listeners.add(listener);
+ return () -> {
+ synchronized (lock) {
+ listeners.remove(listener);
+ }
+ };
+ }
+ }
+ listener.run();
+ return () -> {
+ };
+ }
+
+ void abort(Throwable cause) {
+ List toRun;
+ synchronized (lock) {
+ if (aborted) {
+ return;
+ }
+ reason = cause;
+ aborted = true;
+ toRun = new ArrayList<>(listeners);
+ listeners.clear();
+ }
+ RuntimeException first = null;
+ for (Runnable listener : toRun) {
+ try {
+ listener.run();
+ } catch (RuntimeException e) {
+ if (first == null) {
+ first = e;
+ }
+ }
+ }
+ if (first != null) {
+ throw first;
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/CaerusError.java b/src/main/java/dev/caerus/sdk/CaerusError.java
new file mode 100644
index 0000000..c7480c7
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/CaerusError.java
@@ -0,0 +1,56 @@
+package dev.caerus.sdk;
+
+import java.util.Optional;
+
+public class CaerusError extends RuntimeException {
+
+ private static final String DOC_URL = "https://github.com/caerus-dev/caerus-sdk-java/blob/main/docs/errores.md";
+
+ private final ErrorCode code;
+ private final String reason;
+ private final String requestId;
+
+ public CaerusError(String message) {
+ this(message, ErrorCode.UNKNOWN, CaerusErrorOptions.none());
+ }
+
+ public CaerusError(String message, ErrorCode code) {
+ this(message, code, CaerusErrorOptions.none());
+ }
+
+ public CaerusError(String message, ErrorCode code, CaerusErrorOptions options) {
+ super(enrich(message, options), options == null ? null : options.cause().orElse(null));
+ CaerusErrorOptions resolved = options == null ? CaerusErrorOptions.none() : options;
+ this.code = code == null ? ErrorCode.UNKNOWN : code;
+ this.reason = resolved.reason().orElse(null);
+ this.requestId = resolved.requestId().orElse(null);
+ }
+
+ public ErrorCode code() {
+ return code;
+ }
+
+ public Optional reason() {
+ return Optional.ofNullable(reason);
+ }
+
+ public Optional requestId() {
+ return Optional.ofNullable(requestId);
+ }
+
+ public String docUrl() {
+ return DOC_URL;
+ }
+
+ private static String enrich(String message, CaerusErrorOptions options) {
+ String base = message == null ? "" : message;
+ if (options == null) {
+ return base;
+ }
+ String id = options.requestId().orElse(null);
+ if (id == null || id.isEmpty() || base.contains(id)) {
+ return base;
+ }
+ return base + " (Request ID: " + id + "). Contacta soporte en Discord con este ID.";
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/CaerusErrorOptions.java b/src/main/java/dev/caerus/sdk/CaerusErrorOptions.java
new file mode 100644
index 0000000..e19b4d9
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/CaerusErrorOptions.java
@@ -0,0 +1,67 @@
+package dev.caerus.sdk;
+
+import java.util.Optional;
+
+public final class CaerusErrorOptions {
+
+ private static final CaerusErrorOptions NONE = builder().build();
+
+ private final Throwable cause;
+ private final String reason;
+ private final String requestId;
+
+ private CaerusErrorOptions(Builder builder) {
+ this.cause = builder.cause;
+ this.reason = builder.reason;
+ this.requestId = builder.requestId;
+ }
+
+ public static CaerusErrorOptions none() {
+ return NONE;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional cause() {
+ return Optional.ofNullable(cause);
+ }
+
+ public Optional reason() {
+ return Optional.ofNullable(reason);
+ }
+
+ public Optional requestId() {
+ return Optional.ofNullable(requestId);
+ }
+
+ public static final class Builder {
+
+ private Throwable cause;
+ private String reason;
+ private String requestId;
+
+ private Builder() {
+ }
+
+ public Builder cause(Throwable cause) {
+ this.cause = cause;
+ return this;
+ }
+
+ public Builder reason(String reason) {
+ this.reason = reason;
+ return this;
+ }
+
+ public Builder requestId(String requestId) {
+ this.requestId = requestId;
+ return this;
+ }
+
+ public CaerusErrorOptions build() {
+ return new CaerusErrorOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/CaerusLogger.java b/src/main/java/dev/caerus/sdk/CaerusLogger.java
new file mode 100644
index 0000000..724eb55
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/CaerusLogger.java
@@ -0,0 +1,7 @@
+package dev.caerus.sdk;
+
+@FunctionalInterface
+public interface CaerusLogger {
+
+ void error(String message, Object... details);
+}
diff --git a/src/main/java/dev/caerus/sdk/CaerusSdk.java b/src/main/java/dev/caerus/sdk/CaerusSdk.java
new file mode 100644
index 0000000..1147abc
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/CaerusSdk.java
@@ -0,0 +1,30 @@
+package dev.caerus.sdk;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+public final class CaerusSdk {
+
+ public static final String VERSION = readVersion();
+
+ public static final String DEFAULT_ENDPOINT = "caerus.dev.ar.sdk.apps.disilab.ar:443";
+
+ public static final long DEFAULT_TIMEOUT_MS = 10_000L;
+
+ private CaerusSdk() {
+ }
+
+ private static String readVersion() {
+ try (InputStream in = CaerusSdk.class.getResourceAsStream("version.properties")) {
+ if (in == null) {
+ return "unknown";
+ }
+ Properties properties = new Properties();
+ properties.load(in);
+ return properties.getProperty("version", "unknown");
+ } catch (IOException e) {
+ return "unknown";
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/ErrorCode.java b/src/main/java/dev/caerus/sdk/ErrorCode.java
new file mode 100644
index 0000000..0a57386
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/ErrorCode.java
@@ -0,0 +1,10 @@
+package dev.caerus.sdk;
+
+public enum ErrorCode {
+ RESOURCE_NOT_FOUND,
+ CONFLICT,
+ VALIDATION,
+ AUTHENTICATION,
+ TIMEOUT,
+ UNKNOWN
+}
diff --git a/src/main/java/dev/caerus/sdk/ErrorReason.java b/src/main/java/dev/caerus/sdk/ErrorReason.java
new file mode 100644
index 0000000..cccc427
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/ErrorReason.java
@@ -0,0 +1,23 @@
+package dev.caerus.sdk;
+
+public final class ErrorReason {
+
+ public static final String OUT_OF_STOCK = "OUT_OF_STOCK";
+ public static final String HOLDER_NOT_ACTIVE = "HOLDER_NOT_ACTIVE";
+ public static final String RESOURCE_HAS_ACTIVE_HOLDS = "RESOURCE_HAS_ACTIVE_HOLDS";
+ public static final String RESOURCE_HAS_QUEUED_REQUESTS = "RESOURCE_HAS_QUEUED_REQUESTS";
+ public static final String TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND";
+
+ public static final String DEADLOCK_DETECTED = "DEADLOCK_DETECTED";
+ public static final String TRANSACTION_NOT_ACTIVE = "TRANSACTION_NOT_ACTIVE";
+ public static final String LOCK_ALREADY_HELD_EXCLUSIVELY = "LOCK_ALREADY_HELD_EXCLUSIVELY";
+ public static final String LOCK_MODE_MISMATCH = "LOCK_MODE_MISMATCH";
+ public static final String LOCK_ACQUISITION_CANCELLED = "LOCK_ACQUISITION_CANCELLED";
+ public static final String LOCK_DENIED = "LOCK_DENIED";
+
+ public static final String IDEMPOTENCY_KEY_REQUIRED = "IDEMPOTENCY_KEY_REQUIRED";
+ public static final String RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND";
+
+ private ErrorReason() {
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/AcquireLockOptions.java b/src/main/java/dev/caerus/sdk/dls/AcquireLockOptions.java
new file mode 100644
index 0000000..95b6f7a
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/AcquireLockOptions.java
@@ -0,0 +1,81 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.AbortSignal;
+
+import java.util.Optional;
+
+public final class AcquireLockOptions {
+
+ private static final AcquireLockOptions NONE = builder().build();
+
+ private final String idempotencyKey;
+ private final AbortSignal signal;
+ private final Long timeoutMs;
+ private final Runnable onQueued;
+
+ private AcquireLockOptions(Builder builder) {
+ this.idempotencyKey = builder.idempotencyKey;
+ this.signal = builder.signal;
+ this.timeoutMs = builder.timeoutMs;
+ this.onQueued = builder.onQueued;
+ }
+
+ public static AcquireLockOptions none() {
+ return NONE;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional idempotencyKey() {
+ return Optional.ofNullable(idempotencyKey);
+ }
+
+ public Optional signal() {
+ return Optional.ofNullable(signal);
+ }
+
+ public Optional timeoutMs() {
+ return Optional.ofNullable(timeoutMs);
+ }
+
+ public Optional onQueued() {
+ return Optional.ofNullable(onQueued);
+ }
+
+ public static final class Builder {
+
+ private String idempotencyKey;
+ private AbortSignal signal;
+ private Long timeoutMs;
+ private Runnable onQueued;
+
+ private Builder() {
+ }
+
+ public Builder idempotencyKey(String idempotencyKey) {
+ this.idempotencyKey = idempotencyKey;
+ return this;
+ }
+
+ public Builder signal(AbortSignal signal) {
+ this.signal = signal;
+ return this;
+ }
+
+ public Builder timeoutMs(Long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public Builder onQueued(Runnable onQueued) {
+ this.onQueued = onQueued;
+ return this;
+ }
+
+ public AcquireLockOptions build() {
+ return new AcquireLockOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/ActiveLockHolder.java b/src/main/java/dev/caerus/sdk/dls/ActiveLockHolder.java
new file mode 100644
index 0000000..de2fe77
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/ActiveLockHolder.java
@@ -0,0 +1,6 @@
+package dev.caerus.sdk.dls;
+
+import java.util.OptionalLong;
+
+public record ActiveLockHolder(String lockId, long expiresAt, OptionalLong fencingToken) {
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/BeginTransactionOptions.java b/src/main/java/dev/caerus/sdk/dls/BeginTransactionOptions.java
new file mode 100644
index 0000000..4e62ff8
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/BeginTransactionOptions.java
@@ -0,0 +1,43 @@
+package dev.caerus.sdk.dls;
+
+import java.util.Optional;
+
+public final class BeginTransactionOptions {
+
+ private static final BeginTransactionOptions NONE = builder().build();
+
+ private final Long timeoutMs;
+
+ private BeginTransactionOptions(Builder builder) {
+ this.timeoutMs = builder.timeoutMs;
+ }
+
+ public static BeginTransactionOptions none() {
+ return NONE;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional timeoutMs() {
+ return Optional.ofNullable(timeoutMs);
+ }
+
+ public static final class Builder {
+
+ private Long timeoutMs;
+
+ private Builder() {
+ }
+
+ public Builder timeoutMs(Long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public BeginTransactionOptions build() {
+ return new BeginTransactionOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DeadlockAbortedError.java b/src/main/java/dev/caerus/sdk/dls/DeadlockAbortedError.java
new file mode 100644
index 0000000..411e548
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DeadlockAbortedError.java
@@ -0,0 +1,14 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+
+public class DeadlockAbortedError extends DlsConflictError {
+
+ public DeadlockAbortedError(String message) {
+ super(message);
+ }
+
+ public DeadlockAbortedError(String message, CaerusErrorOptions options) {
+ super(message, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsApi.java b/src/main/java/dev/caerus/sdk/dls/DlsApi.java
new file mode 100644
index 0000000..85f5205
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsApi.java
@@ -0,0 +1,36 @@
+package dev.caerus.sdk.dls;
+
+public interface DlsApi extends AutoCloseable {
+
+ default T withTransaction(TransactionCallback callback) throws E {
+ return withTransaction(callback, TransactionOptions.none());
+ }
+
+ T withTransaction(TransactionCallback callback, TransactionOptions options) throws E;
+
+ default Transaction beginTransaction() {
+ return beginTransaction(BeginTransactionOptions.none());
+ }
+
+ Transaction beginTransaction(BeginTransactionOptions options);
+
+ default LockHolder acquireLock(String namespace, String lockKey, String transactionId, LockMode mode) {
+ return acquireLock(namespace, lockKey, transactionId, mode, AcquireLockOptions.none());
+ }
+
+ LockHolder acquireLock(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options);
+
+ Transaction renewTransaction(String transactionId, long extraMs);
+
+ void releaseLock(String lockId, String transactionId);
+
+ void releaseTransactionLocks(String transactionId);
+
+ LockStatusResponse getLockStatus(String namespace, String lockKey);
+
+ TransactionStatusResponse getTransactionStatus(String transactionId);
+
+ @Override
+ void close();
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsAuthenticationError.java b/src/main/java/dev/caerus/sdk/dls/DlsAuthenticationError.java
new file mode 100644
index 0000000..9a7085c
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsAuthenticationError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsAuthenticationError extends DlsError {
+
+ public DlsAuthenticationError(String message) {
+ super(message, ErrorCode.AUTHENTICATION, CaerusErrorOptions.none());
+ }
+
+ public DlsAuthenticationError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.AUTHENTICATION, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsClient.java b/src/main/java/dev/caerus/sdk/dls/DlsClient.java
new file mode 100644
index 0000000..0e1377a
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsClient.java
@@ -0,0 +1,97 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.internal.ClientProfile;
+import dev.caerus.sdk.internal.ClientSettings;
+import dev.caerus.sdk.internal.Futures;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.UnaryOperator;
+
+public final class DlsClient implements DlsApi {
+
+ private final DlsCore core;
+ private final String endpoint;
+
+ public DlsClient(DlsClientOptions options) {
+ this(resolve(options, ClientSettings.systemEnvironment()));
+ }
+
+ DlsClient(ClientSettings settings) {
+ this.endpoint = settings.endpoint();
+ this.core = new DlsCore(settings);
+ }
+
+ static ClientSettings resolve(DlsClientOptions options, UnaryOperator environment) {
+ if (options == null) {
+ throw new IllegalArgumentException("DlsClient requires an options object with an apiKey");
+ }
+ return ClientSettings.resolve(
+ ClientProfile.DLS,
+ options.apiKey(),
+ options.endpoint().orElse(null),
+ options.tls().orElse(null),
+ options.timeoutMs().orElse(null),
+ options.logger().orElse(null),
+ environment);
+ }
+
+ @Override
+ public T withTransaction(TransactionCallback callback, TransactionOptions options)
+ throws E {
+ return TransactionScope.run(core, callback, options);
+ }
+
+ @Override
+ public Transaction beginTransaction(BeginTransactionOptions options) {
+ return await(core.beginTransactionAsync(options));
+ }
+
+ @Override
+ public LockHolder acquireLock(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options) {
+ return await(core.acquireLockAsync(namespace, lockKey, transactionId, mode, options));
+ }
+
+ @Override
+ public Transaction renewTransaction(String transactionId, long extraMs) {
+ return await(core.renewTransactionAsync(transactionId, extraMs));
+ }
+
+ @Override
+ public void releaseLock(String lockId, String transactionId) {
+ await(core.releaseLockAsync(lockId, transactionId));
+ }
+
+ @Override
+ public void releaseTransactionLocks(String transactionId) {
+ await(core.releaseTransactionLocksAsync(transactionId));
+ }
+
+ @Override
+ public LockStatusResponse getLockStatus(String namespace, String lockKey) {
+ return await(core.getLockStatusAsync(namespace, lockKey));
+ }
+
+ @Override
+ public TransactionStatusResponse getTransactionStatus(String transactionId) {
+ return await(core.getTransactionStatusAsync(transactionId));
+ }
+
+ @Override
+ public void close() {
+ core.close();
+ }
+
+ @Override
+ public String toString() {
+ return "DlsClient[endpoint=" + endpoint + "]";
+ }
+
+ DlsCore core() {
+ return core;
+ }
+
+ private static T await(CompletableFuture future) {
+ return Futures.await(future, DlsErrors::interrupted, DlsErrors::unexpected);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsClientOptions.java b/src/main/java/dev/caerus/sdk/dls/DlsClientOptions.java
new file mode 100644
index 0000000..97e8bbc
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsClientOptions.java
@@ -0,0 +1,97 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusLogger;
+
+import java.util.Optional;
+
+public final class DlsClientOptions {
+
+ private final String apiKey;
+ private final String endpoint;
+ private final Boolean tls;
+ private final Long timeoutMs;
+ private final CaerusLogger logger;
+
+ private DlsClientOptions(Builder builder) {
+ this.apiKey = builder.apiKey;
+ this.endpoint = builder.endpoint;
+ this.tls = builder.tls;
+ this.timeoutMs = builder.timeoutMs;
+ this.logger = builder.logger;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String apiKey() {
+ return apiKey;
+ }
+
+ public Optional endpoint() {
+ return Optional.ofNullable(endpoint);
+ }
+
+ public Optional tls() {
+ return Optional.ofNullable(tls);
+ }
+
+ public Optional timeoutMs() {
+ return Optional.ofNullable(timeoutMs);
+ }
+
+ public Optional logger() {
+ return Optional.ofNullable(logger);
+ }
+
+ @Override
+ public String toString() {
+ return "DlsClientOptions[endpoint=" + endpoint + ", tls=" + tls + ", timeoutMs=" + timeoutMs + "]";
+ }
+
+ public static final class Builder {
+
+ private String apiKey;
+ private String endpoint;
+ private Boolean tls;
+ private Long timeoutMs;
+ private CaerusLogger logger;
+
+ private Builder() {
+ }
+
+ public Builder apiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ public Builder endpoint(String endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ public Builder tls(Boolean tls) {
+ this.tls = tls;
+ return this;
+ }
+
+ public Builder timeoutMs(Long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public Builder logger(CaerusLogger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "DlsClientOptions.Builder[endpoint=" + endpoint + ", tls=" + tls + ", timeoutMs=" + timeoutMs + "]";
+ }
+
+ public DlsClientOptions build() {
+ return new DlsClientOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsConflictError.java b/src/main/java/dev/caerus/sdk/dls/DlsConflictError.java
new file mode 100644
index 0000000..7c59a6a
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsConflictError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsConflictError extends DlsError {
+
+ public DlsConflictError(String message) {
+ super(message, ErrorCode.CONFLICT, CaerusErrorOptions.none());
+ }
+
+ public DlsConflictError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.CONFLICT, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsCore.java b/src/main/java/dev/caerus/sdk/dls/DlsCore.java
new file mode 100644
index 0000000..b73c3ad
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsCore.java
@@ -0,0 +1,267 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.AbortSignal;
+import dev.caerus.sdk.CaerusLogger;
+import dev.caerus.sdk.internal.ClientSettings;
+import dev.caerus.sdk.internal.GrpcTransport;
+import dev.caerus.sdk.internal.proto.dls.AcquireLockRequest;
+import dev.caerus.sdk.internal.proto.dls.AcquireLockResponse;
+import dev.caerus.sdk.internal.proto.dls.BeginTransactionRequest;
+import dev.caerus.sdk.internal.proto.dls.DistributedLockingEngineGrpc;
+import dev.caerus.sdk.internal.proto.dls.GetLockStatusRequest;
+import dev.caerus.sdk.internal.proto.dls.GetTransactionStatusRequest;
+import dev.caerus.sdk.internal.proto.dls.ReleaseLockRequest;
+import dev.caerus.sdk.internal.proto.dls.ReleaseTransactionLocksRequest;
+import dev.caerus.sdk.internal.proto.dls.RenewTransactionRequest;
+import io.grpc.ClientCall;
+import io.grpc.MethodDescriptor;
+import io.grpc.stub.ClientCalls;
+import io.grpc.stub.StreamObserver;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+final class DlsCore implements DlsOperations {
+
+ private final GrpcTransport transport;
+ private final CaerusLogger logger;
+ private final Object schedulerLock = new Object();
+ private ScheduledThreadPoolExecutor scheduler;
+
+ DlsCore(ClientSettings settings) {
+ this.transport = new GrpcTransport(settings);
+ this.logger = settings.logger();
+ }
+
+ @Override
+ public CompletableFuture beginTransactionAsync(BeginTransactionOptions options) {
+ BeginTransactionOptions resolved = options == null ? BeginTransactionOptions.none() : options;
+ BeginTransactionRequest.Builder request = BeginTransactionRequest.newBuilder();
+ resolved.timeoutMs().ifPresent(request::setTimeoutMs);
+ return call(DistributedLockingEngineGrpc.getBeginTransactionMethod(), request.build())
+ .thenApply(response -> new Transaction(response.getTransactionId()));
+ }
+
+ @Override
+ public CompletableFuture acquireLockAsync(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options) {
+ AcquireLockOptions resolved = options == null ? AcquireLockOptions.none() : options;
+
+ AcquireLockRequest.Builder request = AcquireLockRequest.newBuilder()
+ .setNamespace(DlsMapping.text(namespace))
+ .setLockKey(DlsMapping.text(lockKey))
+ .setRequestedMode(DlsMapping.toGrpc(mode))
+ .setTransactionId(DlsMapping.text(transactionId));
+ resolved.idempotencyKey().ifPresent(request::setIdempotencyKey);
+
+ return acquireLockStream(
+ request.build(),
+ resolved.timeoutMs().orElse(null),
+ resolved.signal().orElse(null),
+ resolved.onQueued().orElse(null))
+ .thenApply(response -> DlsMapping.assertAcquired(
+ new LockHolder(
+ response.getLockId(),
+ DlsMapping.decodeFencingToken(response.getFencingToken()),
+ DlsMapping.toLockStatus(response.getStatusValue())),
+ namespace,
+ lockKey));
+ }
+
+ @Override
+ public CompletableFuture renewTransactionAsync(String transactionId, long extraMs) {
+ RenewTransactionRequest request = RenewTransactionRequest.newBuilder()
+ .setTransactionId(DlsMapping.text(transactionId))
+ .setExtraMs(extraMs)
+ .build();
+ return call(DistributedLockingEngineGrpc.getRenewTransactionMethod(), request)
+ .thenApply(response -> new Transaction(response.getTransactionId()));
+ }
+
+ CompletableFuture releaseLockAsync(String lockId, String transactionId) {
+ ReleaseLockRequest request = ReleaseLockRequest.newBuilder()
+ .setLockId(DlsMapping.text(lockId))
+ .setTransactionId(DlsMapping.text(transactionId))
+ .build();
+ return call(DistributedLockingEngineGrpc.getReleaseLockMethod(), request).thenApply(ignored -> null);
+ }
+
+ @Override
+ public CompletableFuture releaseTransactionLocksAsync(String transactionId) {
+ ReleaseTransactionLocksRequest request = ReleaseTransactionLocksRequest.newBuilder()
+ .setTransactionId(DlsMapping.text(transactionId))
+ .build();
+ return call(DistributedLockingEngineGrpc.getReleaseTransactionLocksMethod(), request)
+ .thenApply(ignored -> null);
+ }
+
+ CompletableFuture getLockStatusAsync(String namespace, String lockKey) {
+ GetLockStatusRequest request = GetLockStatusRequest.newBuilder()
+ .setNamespace(DlsMapping.text(namespace))
+ .setLockKey(DlsMapping.text(lockKey))
+ .build();
+ return call(DistributedLockingEngineGrpc.getGetLockStatusMethod(), request)
+ .thenApply(response -> {
+ List holders = response.getActiveHoldersList().stream()
+ .map(holder -> new ActiveLockHolder(
+ holder.getLockId(),
+ holder.getExpiresAt(),
+ DlsMapping.decodeFencingToken(holder.getFencingToken())))
+ .collect(Collectors.toList());
+ return new LockStatusResponse(
+ response.getIsHeld(),
+ DlsMapping.toLockMode(response.getCurrentModeValue()),
+ holders,
+ response.getPendingQueueSize());
+ });
+ }
+
+ CompletableFuture getTransactionStatusAsync(String transactionId) {
+ GetTransactionStatusRequest request = GetTransactionStatusRequest.newBuilder()
+ .setTransactionId(DlsMapping.text(transactionId))
+ .build();
+ return call(DistributedLockingEngineGrpc.getGetTransactionStatusMethod(), request)
+ .thenApply(response -> {
+ List locks = response.getLocksList().stream()
+ .map(lock -> new TransactionLockInfo(
+ lock.getNamespace(),
+ lock.getLockKey(),
+ DlsMapping.toLockMode(lock.getRequestedModeValue()).orElse(LockMode.EXCLUSIVE),
+ DlsMapping.toLockStatus(lock.getStatusValue())))
+ .collect(Collectors.toList());
+ Optional abortReason = response.getAbortReason().isEmpty()
+ ? Optional.empty()
+ : Optional.of(response.getAbortReason());
+ return new TransactionStatusResponse(
+ response.getStatus(), abortReason, locks, response.getExpiresAt());
+ });
+ }
+
+ @Override
+ public ScheduledExecutorService renewalScheduler() {
+ synchronized (schedulerLock) {
+ if (scheduler == null) {
+ scheduler = new ScheduledThreadPoolExecutor(1, runnable -> {
+ Thread thread = new Thread(runnable, "caerus-dls-renewal");
+ thread.setDaemon(true);
+ return thread;
+ });
+ scheduler.setRemoveOnCancelPolicy(true);
+ }
+ return scheduler;
+ }
+ }
+
+ @Override
+ public CaerusLogger logger() {
+ return logger;
+ }
+
+ void close() {
+ transport.close();
+ synchronized (schedulerLock) {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ }
+ }
+
+ boolean schedulerShutDown() {
+ synchronized (schedulerLock) {
+ return scheduler == null || scheduler.isShutdown();
+ }
+ }
+
+ private CompletableFuture acquireLockStream(
+ AcquireLockRequest request, Long timeoutMs, AbortSignal signal, Runnable onQueued) {
+ if (transport.isClosed()) {
+ return CompletableFuture.failedFuture(DlsErrors.closed());
+ }
+ if (signal != null && signal.aborted()) {
+ return CompletableFuture.failedFuture(DlsErrors.abortedByUser(null));
+ }
+
+ String requestId = GrpcTransport.newRequestId();
+ long deadline = timeoutMs != null && timeoutMs > 0 ? timeoutMs : transport.timeoutMs();
+ ClientCall call = transport.newCall(
+ DistributedLockingEngineGrpc.getAcquireLockMethod(), requestId, deadline);
+
+ CompletableFuture result = new CompletableFuture<>();
+ AtomicBoolean terminal = new AtomicBoolean();
+ AtomicBoolean queuedNotified = new AtomicBoolean();
+ AtomicReference stopListening = new AtomicReference<>(() -> {
+ });
+
+ ClientCalls.asyncServerStreamingCall(call, request, new StreamObserver<>() {
+ @Override
+ public void onNext(AcquireLockResponse response) {
+ int status = response.getStatusValue();
+ if (status == dev.caerus.sdk.internal.proto.dls.LockStatus.ACQUIRED_VALUE
+ || status == dev.caerus.sdk.internal.proto.dls.LockStatus.DENIED_VALUE) {
+ if (terminal.compareAndSet(false, true)) {
+ stopListening.get().run();
+ result.complete(response);
+ call.cancel("Terminal lock status received", null);
+ }
+ } else if (status == dev.caerus.sdk.internal.proto.dls.LockStatus.QUEUED_VALUE) {
+ if (!terminal.get() && queuedNotified.compareAndSet(false, true) && onQueued != null) {
+ try {
+ onQueued.run();
+ } catch (RuntimeException e) {
+ logger.error("onQueued callback threw", e);
+ }
+ }
+ } else {
+ logger.error("Received unspecified lock status in stream");
+ }
+ }
+
+ @Override
+ public void onError(Throwable error) {
+ if (terminal.compareAndSet(false, true)) {
+ stopListening.get().run();
+ result.completeExceptionally(DlsErrors.toDlsError(error, requestId));
+ }
+ }
+
+ @Override
+ public void onCompleted() {
+ if (terminal.compareAndSet(false, true)) {
+ stopListening.get().run();
+ result.completeExceptionally(DlsErrors.toDlsError(
+ new IllegalStateException("Stream ended without terminal status"), requestId));
+ }
+ }
+ });
+
+ if (signal != null) {
+ stopListening.set(signal.onAbort(() -> {
+ if (terminal.compareAndSet(false, true)) {
+ call.cancel(DlsErrors.ABORTED_BY_USER, null);
+ result.completeExceptionally(DlsErrors.abortedByUser(requestId));
+ }
+ }));
+ if (terminal.get()) {
+ stopListening.get().run();
+ }
+ }
+
+ result.whenComplete((ignored, error) -> {
+ if (result.isCancelled() && terminal.compareAndSet(false, true)) {
+ stopListening.get().run();
+ call.cancel("Cancelled by the caller", null);
+ }
+ });
+ return result;
+ }
+
+ private CompletableFuture call(MethodDescriptor method, Req request) {
+ return transport.unary(method, request, DlsErrors::toDlsError, DlsErrors::closed);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsError.java b/src/main/java/dev/caerus/sdk/dls/DlsError.java
new file mode 100644
index 0000000..ad385a6
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsError.java
@@ -0,0 +1,20 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusError;
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsError extends CaerusError {
+
+ public DlsError(String message) {
+ super(message);
+ }
+
+ public DlsError(String message, ErrorCode code) {
+ super(message, code);
+ }
+
+ public DlsError(String message, ErrorCode code, CaerusErrorOptions options) {
+ super(message, code, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsErrors.java b/src/main/java/dev/caerus/sdk/dls/DlsErrors.java
new file mode 100644
index 0000000..4e352e9
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsErrors.java
@@ -0,0 +1,107 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+import dev.caerus.sdk.ErrorReason;
+import dev.caerus.sdk.internal.ErrorDetails;
+import io.grpc.Status;
+
+final class DlsErrors {
+
+ static final String ABORTED_BY_USER = "AcquireLock aborted by user";
+
+ private DlsErrors() {
+ }
+
+ static DlsError toDlsError(Throwable error) {
+ return toDlsError(error, null);
+ }
+
+ static DlsError toDlsError(Throwable error, String clientRequestId) {
+ if (error instanceof DlsError dls) {
+ return dls;
+ }
+
+ String message = ErrorDetails.messageOf(error, "DLS call failed");
+ String reason = ErrorDetails.reasonOf(error).orElse(null);
+ String requestId = ErrorDetails.requestIdOf(error).orElse(clientRequestId);
+ CaerusErrorOptions options = CaerusErrorOptions.builder()
+ .cause(error)
+ .reason(reason)
+ .requestId(requestId)
+ .build();
+
+ DlsError byReason = byReason(reason, message, options);
+ if (byReason != null) {
+ return byReason;
+ }
+
+ Status.Code code = ErrorDetails.codeOf(error);
+ if (code == null) {
+ return new DlsError(message, ErrorCode.UNKNOWN, options);
+ }
+ switch (code) {
+ case NOT_FOUND:
+ return new DlsNotFoundError(message, options);
+ case ALREADY_EXISTS:
+ return new LockAlreadyHeldError(message, options);
+ case ABORTED:
+ case FAILED_PRECONDITION:
+ return new DlsConflictError(message, options);
+ case INVALID_ARGUMENT:
+ return new DlsValidationError(message, options);
+ case UNAUTHENTICATED:
+ return new DlsAuthenticationError(message, options);
+ case DEADLINE_EXCEEDED:
+ return new DlsTimeoutError(message, options);
+ case CANCELLED:
+ return new LockAcquisitionCancelledError(message, options);
+ default:
+ return new DlsError(message, ErrorCode.UNKNOWN, options);
+ }
+ }
+
+ static DlsError closed() {
+ return new DlsError("This DlsClient has been closed", ErrorCode.UNKNOWN);
+ }
+
+ static DlsError abortedByUser(String requestId) {
+ return toDlsError(new IllegalStateException(ABORTED_BY_USER), requestId);
+ }
+
+ static DlsError interrupted() {
+ return new DlsError("Interrupted while waiting for Caerus", ErrorCode.UNKNOWN);
+ }
+
+ static DlsError unexpected(Throwable error) {
+ return toDlsError(error);
+ }
+
+ static boolean isGone(Throwable error) {
+ return error instanceof DlsNotFoundError
+ || error instanceof TransactionNotActiveError
+ || error instanceof DeadlockAbortedError;
+ }
+
+ private static DlsError byReason(String reason, String message, CaerusErrorOptions options) {
+ if (reason == null) {
+ return null;
+ }
+ switch (reason) {
+ case ErrorReason.DEADLOCK_DETECTED:
+ return new DeadlockAbortedError(message, options);
+ case ErrorReason.LOCK_DENIED:
+ return new LockDeniedError(message, options);
+ case ErrorReason.LOCK_ALREADY_HELD_EXCLUSIVELY:
+ return new LockAlreadyHeldError(message, options);
+ case ErrorReason.LOCK_MODE_MISMATCH:
+ return new LockModeMismatchError(message, options);
+ case ErrorReason.TRANSACTION_NOT_ACTIVE:
+ return new TransactionNotActiveError(message, options);
+ case ErrorReason.LOCK_ACQUISITION_CANCELLED:
+ return new LockAcquisitionCancelledError(message, options);
+ default:
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsMapping.java b/src/main/java/dev/caerus/sdk/dls/DlsMapping.java
new file mode 100644
index 0000000..4922a9c
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsMapping.java
@@ -0,0 +1,80 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+import dev.caerus.sdk.ErrorReason;
+
+import java.util.Optional;
+import java.util.OptionalLong;
+
+final class DlsMapping {
+
+ private DlsMapping() {
+ }
+
+ static dev.caerus.sdk.internal.proto.dls.LockMode toGrpc(LockMode mode) {
+ if (mode == null) {
+ return dev.caerus.sdk.internal.proto.dls.LockMode.MODE_UNSPECIFIED;
+ }
+ switch (mode) {
+ case EXCLUSIVE:
+ return dev.caerus.sdk.internal.proto.dls.LockMode.EXCLUSIVE;
+ case SHARED_READ:
+ return dev.caerus.sdk.internal.proto.dls.LockMode.SHARED_READ;
+ default:
+ return dev.caerus.sdk.internal.proto.dls.LockMode.MODE_UNSPECIFIED;
+ }
+ }
+
+ static Optional toLockMode(int wire) {
+ switch (wire) {
+ case dev.caerus.sdk.internal.proto.dls.LockMode.EXCLUSIVE_VALUE:
+ return Optional.of(LockMode.EXCLUSIVE);
+ case dev.caerus.sdk.internal.proto.dls.LockMode.SHARED_READ_VALUE:
+ return Optional.of(LockMode.SHARED_READ);
+ default:
+ return Optional.empty();
+ }
+ }
+
+ static LockStatus toLockStatus(int wire) {
+ switch (wire) {
+ case dev.caerus.sdk.internal.proto.dls.LockStatus.ACQUIRED_VALUE:
+ return LockStatus.ACQUIRED;
+ case dev.caerus.sdk.internal.proto.dls.LockStatus.DENIED_VALUE:
+ return LockStatus.DENIED;
+ case dev.caerus.sdk.internal.proto.dls.LockStatus.QUEUED_VALUE:
+ return LockStatus.QUEUED;
+ default:
+ return LockStatus.UNKNOWN;
+ }
+ }
+
+ static OptionalLong decodeFencingToken(long raw) {
+ return raw > 0 ? OptionalLong.of(raw) : OptionalLong.empty();
+ }
+
+ static LockHolder assertAcquired(LockHolder holder, String namespace, String lockKey) {
+ switch (holder.status()) {
+ case ACQUIRED:
+ return holder;
+ case DENIED:
+ throw new LockDeniedError(
+ "Caerus denied the lock on " + namespace + "/" + lockKey
+ + ": it is already held by another transaction.",
+ CaerusErrorOptions.builder().reason(ErrorReason.LOCK_DENIED).build());
+ case UNKNOWN:
+ throw new DlsError(
+ "Caerus returned a lock status for " + namespace + "/" + lockKey
+ + " that this SDK does not recognise, so whether the lock was granted is unknown.",
+ ErrorCode.UNKNOWN);
+ default:
+ throw new DlsConflictError("Caerus returned the lock on " + namespace + "/" + lockKey + " as "
+ + holder.status() + ", which this call cannot use.");
+ }
+ }
+
+ static String text(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsNotFoundError.java b/src/main/java/dev/caerus/sdk/dls/DlsNotFoundError.java
new file mode 100644
index 0000000..2abcb2b
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsNotFoundError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsNotFoundError extends DlsError {
+
+ public DlsNotFoundError(String message) {
+ super(message, ErrorCode.RESOURCE_NOT_FOUND, CaerusErrorOptions.none());
+ }
+
+ public DlsNotFoundError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.RESOURCE_NOT_FOUND, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsOperations.java b/src/main/java/dev/caerus/sdk/dls/DlsOperations.java
new file mode 100644
index 0000000..1ea0595
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsOperations.java
@@ -0,0 +1,22 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusLogger;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+
+interface DlsOperations {
+
+ CompletableFuture beginTransactionAsync(BeginTransactionOptions options);
+
+ CompletableFuture acquireLockAsync(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options);
+
+ CompletableFuture renewTransactionAsync(String transactionId, long extraMs);
+
+ CompletableFuture releaseTransactionLocksAsync(String transactionId);
+
+ ScheduledExecutorService renewalScheduler();
+
+ CaerusLogger logger();
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsTimeoutError.java b/src/main/java/dev/caerus/sdk/dls/DlsTimeoutError.java
new file mode 100644
index 0000000..a7bc12b
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsTimeoutError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsTimeoutError extends DlsError {
+
+ public DlsTimeoutError(String message) {
+ super(message, ErrorCode.TIMEOUT, CaerusErrorOptions.none());
+ }
+
+ public DlsTimeoutError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.TIMEOUT, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/DlsValidationError.java b/src/main/java/dev/caerus/sdk/dls/DlsValidationError.java
new file mode 100644
index 0000000..2193a5f
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/DlsValidationError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class DlsValidationError extends DlsError {
+
+ public DlsValidationError(String message) {
+ super(message, ErrorCode.VALIDATION, CaerusErrorOptions.none());
+ }
+
+ public DlsValidationError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.VALIDATION, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java b/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java
new file mode 100644
index 0000000..016d00f
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java
@@ -0,0 +1,299 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.CaerusLogger;
+import dev.caerus.sdk.ErrorCode;
+import dev.caerus.sdk.ErrorReason;
+import dev.caerus.sdk.internal.ClientProfile;
+import dev.caerus.sdk.internal.StderrLogger;
+
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+public final class InMemoryDlsClient implements DlsApi {
+
+ private static final long DEFAULT_TTL_MS = 10_000L;
+
+ private final Object lock = new Object();
+ private final Clock clock;
+ private final CaerusLogger logger = new StderrLogger(ClientProfile.DLS.loggerPrefix());
+ private final Set activeTransactions = new HashSet<>();
+ private final Map> locks = new LinkedHashMap<>();
+ private final Map txExpiration = new HashMap<>();
+ private final Operations operations = new Operations();
+
+ private long nextLockId = 1;
+ private long nextFencingToken = 1000;
+ private boolean closed;
+ private ScheduledThreadPoolExecutor scheduler;
+
+ public InMemoryDlsClient() {
+ this(Clock.systemUTC());
+ }
+
+ InMemoryDlsClient(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public T withTransaction(TransactionCallback callback, TransactionOptions options)
+ throws E {
+ return TransactionScope.run(operations, callback, options);
+ }
+
+ @Override
+ public Transaction beginTransaction(BeginTransactionOptions options) {
+ synchronized (lock) {
+ guard();
+ BeginTransactionOptions resolved = options == null ? BeginTransactionOptions.none() : options;
+ String transactionId = "tx-mock-" + clock.millis() + "-" + randomSuffix();
+ activeTransactions.add(transactionId);
+ long timeout = resolved.timeoutMs().orElse(0L);
+ txExpiration.put(transactionId, clock.millis() + (timeout > 0 ? timeout : DEFAULT_TTL_MS));
+ return new Transaction(transactionId);
+ }
+ }
+
+ @Override
+ public LockHolder acquireLock(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options) {
+ synchronized (lock) {
+ guard();
+ AcquireLockOptions resolved = options == null ? AcquireLockOptions.none() : options;
+ if (resolved.signal().map(signal -> signal.aborted()).orElse(false)) {
+ throw DlsErrors.abortedByUser(null);
+ }
+
+ if (!activeTransactions.contains(transactionId)) {
+ throw new DlsNotFoundError("Transaction not found or expired");
+ }
+
+ String key = namespace + ":" + lockKey;
+ long now = clock.millis();
+ List active = locks.getOrDefault(key, new ArrayList<>()).stream()
+ .filter(held -> held.expiresAt > now)
+ .collect(Collectors.toCollection(ArrayList::new));
+
+ String idempotencyKey = resolved.idempotencyKey().orElse(null);
+ for (MockLock held : active) {
+ if (!held.transactionId.equals(transactionId)) {
+ continue;
+ }
+ if (idempotencyKey != null && idempotencyKey.equals(held.idempotencyKey)) {
+ return new LockHolder(held.lockId, OptionalLong.of(held.fencingToken), LockStatus.ACQUIRED);
+ }
+ throw new LockAlreadyHeldError(
+ "Transaction " + transactionId + " already holds or requested lock on " + lockKey,
+ CaerusErrorOptions.builder().reason(ErrorReason.LOCK_ALREADY_HELD_EXCLUSIVELY).build());
+ }
+
+ if (!active.isEmpty()
+ && (mode == LockMode.EXCLUSIVE || active.stream().anyMatch(held -> held.mode == LockMode.EXCLUSIVE))) {
+ throw new LockDeniedError(
+ "Caerus denied the lock on " + namespace + "/" + lockKey
+ + ": it is already held by another transaction.",
+ CaerusErrorOptions.builder().reason(ErrorReason.LOCK_DENIED).build());
+ }
+
+ MockLock created = new MockLock();
+ created.lockId = "lock-mock-" + nextLockId++;
+ created.transactionId = transactionId;
+ created.namespace = namespace;
+ created.lockKey = lockKey;
+ created.mode = mode == null ? LockMode.EXCLUSIVE : mode;
+ created.idempotencyKey = idempotencyKey;
+ created.fencingToken = nextFencingToken++;
+ created.expiresAt = txExpiration.getOrDefault(transactionId, now + DEFAULT_TTL_MS);
+
+ active.add(created);
+ locks.put(key, active);
+
+ return new LockHolder(created.lockId, OptionalLong.of(created.fencingToken), LockStatus.ACQUIRED);
+ }
+ }
+
+ @Override
+ public Transaction renewTransaction(String transactionId, long extraMs) {
+ synchronized (lock) {
+ guard();
+ if (!activeTransactions.contains(transactionId)) {
+ throw new DlsNotFoundError("Transaction not found");
+ }
+ long current = txExpiration.getOrDefault(transactionId, clock.millis());
+ txExpiration.put(transactionId, current + extraMs);
+ for (List held : locks.values()) {
+ for (MockLock mockLock : held) {
+ if (mockLock.transactionId.equals(transactionId)) {
+ mockLock.expiresAt += extraMs;
+ }
+ }
+ }
+ return new Transaction(transactionId);
+ }
+ }
+
+ @Override
+ public void releaseLock(String lockId, String transactionId) {
+ synchronized (lock) {
+ guard();
+ for (List held : locks.values()) {
+ if (held.removeIf(mockLock -> mockLock.lockId.equals(lockId)
+ && mockLock.transactionId.equals(transactionId))) {
+ return;
+ }
+ }
+ }
+ }
+
+ @Override
+ public void releaseTransactionLocks(String transactionId) {
+ synchronized (lock) {
+ guard();
+ for (List held : locks.values()) {
+ held.removeIf(mockLock -> mockLock.transactionId.equals(transactionId));
+ }
+ activeTransactions.remove(transactionId);
+ txExpiration.remove(transactionId);
+ }
+ }
+
+ @Override
+ public LockStatusResponse getLockStatus(String namespace, String lockKey) {
+ synchronized (lock) {
+ guard();
+ long now = clock.millis();
+ List active = locks.getOrDefault(namespace + ":" + lockKey, List.of()).stream()
+ .filter(held -> held.expiresAt > now)
+ .collect(Collectors.toList());
+ return new LockStatusResponse(
+ !active.isEmpty(),
+ active.isEmpty() ? Optional.empty() : Optional.of(active.get(0).mode),
+ active.stream()
+ .map(held -> new ActiveLockHolder(held.lockId, held.expiresAt,
+ OptionalLong.of(held.fencingToken)))
+ .collect(Collectors.toList()),
+ 0);
+ }
+ }
+
+ @Override
+ public TransactionStatusResponse getTransactionStatus(String transactionId) {
+ synchronized (lock) {
+ guard();
+ if (!activeTransactions.contains(transactionId)) {
+ throw new DlsNotFoundError("Transaction not found");
+ }
+ List owned = new ArrayList<>();
+ for (List held : locks.values()) {
+ for (MockLock mockLock : held) {
+ if (mockLock.transactionId.equals(transactionId)) {
+ owned.add(new TransactionLockInfo(
+ mockLock.namespace, mockLock.lockKey, mockLock.mode, LockStatus.ACQUIRED));
+ }
+ }
+ }
+ return new TransactionStatusResponse(
+ "ACTIVE", Optional.empty(), owned, txExpiration.getOrDefault(transactionId, 0L));
+ }
+ }
+
+ @Override
+ public void close() {
+ synchronized (lock) {
+ closed = true;
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ }
+ }
+
+ private void guard() {
+ if (closed) {
+ throw new DlsError("This InMemoryDlsClient has been closed", ErrorCode.UNKNOWN);
+ }
+ }
+
+ private static String randomSuffix() {
+ return Long.toString(ThreadLocalRandom.current().nextLong(36L * 36 * 36 * 36 * 36 * 36 * 36), 36);
+ }
+
+ private static CompletableFuture attempt(Supplier action) {
+ try {
+ return CompletableFuture.completedFuture(action.get());
+ } catch (RuntimeException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ private final class Operations implements DlsOperations {
+
+ @Override
+ public CompletableFuture beginTransactionAsync(BeginTransactionOptions options) {
+ return attempt(() -> beginTransaction(options));
+ }
+
+ @Override
+ public CompletableFuture acquireLockAsync(
+ String namespace, String lockKey, String transactionId, LockMode mode, AcquireLockOptions options) {
+ return attempt(() -> acquireLock(namespace, lockKey, transactionId, mode, options));
+ }
+
+ @Override
+ public CompletableFuture renewTransactionAsync(String transactionId, long extraMs) {
+ return attempt(() -> renewTransaction(transactionId, extraMs));
+ }
+
+ @Override
+ public CompletableFuture releaseTransactionLocksAsync(String transactionId) {
+ return attempt(() -> {
+ releaseTransactionLocks(transactionId);
+ return null;
+ });
+ }
+
+ @Override
+ public ScheduledExecutorService renewalScheduler() {
+ synchronized (lock) {
+ if (scheduler == null) {
+ scheduler = new ScheduledThreadPoolExecutor(1, runnable -> {
+ Thread thread = new Thread(runnable, "caerus-dls-mock-renewal");
+ thread.setDaemon(true);
+ return thread;
+ });
+ scheduler.setRemoveOnCancelPolicy(true);
+ }
+ return scheduler;
+ }
+ }
+
+ @Override
+ public CaerusLogger logger() {
+ return logger;
+ }
+ }
+
+ private static final class MockLock {
+ private String lockId;
+ private String transactionId;
+ private String namespace;
+ private String lockKey;
+ private LockMode mode;
+ private long fencingToken;
+ private long expiresAt;
+ private String idempotencyKey;
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockAcquisitionCancelledError.java b/src/main/java/dev/caerus/sdk/dls/LockAcquisitionCancelledError.java
new file mode 100644
index 0000000..ddf9f46
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockAcquisitionCancelledError.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class LockAcquisitionCancelledError extends DlsError {
+
+ public LockAcquisitionCancelledError(String message) {
+ super(message, ErrorCode.UNKNOWN, CaerusErrorOptions.none());
+ }
+
+ public LockAcquisitionCancelledError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.UNKNOWN, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockAlreadyHeldError.java b/src/main/java/dev/caerus/sdk/dls/LockAlreadyHeldError.java
new file mode 100644
index 0000000..05a60cc
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockAlreadyHeldError.java
@@ -0,0 +1,14 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+
+public class LockAlreadyHeldError extends DlsConflictError {
+
+ public LockAlreadyHeldError(String message) {
+ super(message);
+ }
+
+ public LockAlreadyHeldError(String message, CaerusErrorOptions options) {
+ super(message, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockDeniedError.java b/src/main/java/dev/caerus/sdk/dls/LockDeniedError.java
new file mode 100644
index 0000000..c6f75cf
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockDeniedError.java
@@ -0,0 +1,14 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+
+public class LockDeniedError extends DlsConflictError {
+
+ public LockDeniedError(String message) {
+ super(message);
+ }
+
+ public LockDeniedError(String message, CaerusErrorOptions options) {
+ super(message, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockHolder.java b/src/main/java/dev/caerus/sdk/dls/LockHolder.java
new file mode 100644
index 0000000..a3fdb16
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockHolder.java
@@ -0,0 +1,6 @@
+package dev.caerus.sdk.dls;
+
+import java.util.OptionalLong;
+
+public record LockHolder(String lockId, OptionalLong fencingToken, LockStatus status) {
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockMode.java b/src/main/java/dev/caerus/sdk/dls/LockMode.java
new file mode 100644
index 0000000..c905d33
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockMode.java
@@ -0,0 +1,6 @@
+package dev.caerus.sdk.dls;
+
+public enum LockMode {
+ EXCLUSIVE,
+ SHARED_READ
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockModeMismatchError.java b/src/main/java/dev/caerus/sdk/dls/LockModeMismatchError.java
new file mode 100644
index 0000000..eb151d0
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockModeMismatchError.java
@@ -0,0 +1,14 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+
+public class LockModeMismatchError extends DlsValidationError {
+
+ public LockModeMismatchError(String message) {
+ super(message);
+ }
+
+ public LockModeMismatchError(String message, CaerusErrorOptions options) {
+ super(message, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockStatus.java b/src/main/java/dev/caerus/sdk/dls/LockStatus.java
new file mode 100644
index 0000000..45ac2ca
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockStatus.java
@@ -0,0 +1,8 @@
+package dev.caerus.sdk.dls;
+
+public enum LockStatus {
+ ACQUIRED,
+ DENIED,
+ QUEUED,
+ UNKNOWN
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/LockStatusResponse.java b/src/main/java/dev/caerus/sdk/dls/LockStatusResponse.java
new file mode 100644
index 0000000..fbc527e
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/LockStatusResponse.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import java.util.List;
+import java.util.Optional;
+
+public record LockStatusResponse(
+ boolean isHeld,
+ Optional currentMode,
+ List activeHolders,
+ int pendingQueueSize) {
+
+ public LockStatusResponse {
+ activeHolders = List.copyOf(activeHolders);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/Transaction.java b/src/main/java/dev/caerus/sdk/dls/Transaction.java
new file mode 100644
index 0000000..9299430
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/Transaction.java
@@ -0,0 +1,4 @@
+package dev.caerus.sdk.dls;
+
+public record Transaction(String transactionId) {
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionCallback.java b/src/main/java/dev/caerus/sdk/dls/TransactionCallback.java
new file mode 100644
index 0000000..10cc714
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionCallback.java
@@ -0,0 +1,7 @@
+package dev.caerus.sdk.dls;
+
+@FunctionalInterface
+public interface TransactionCallback {
+
+ T run(TransactionContext tx) throws E;
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionContext.java b/src/main/java/dev/caerus/sdk/dls/TransactionContext.java
new file mode 100644
index 0000000..4745ec0
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionContext.java
@@ -0,0 +1,18 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.AbortSignal;
+
+public interface TransactionContext {
+
+ String transactionId();
+
+ AbortSignal signal();
+
+ default LockHolder acquireLock(String namespace, String lockKey, LockMode mode) {
+ return acquireLock(namespace, lockKey, mode, AcquireLockOptions.none());
+ }
+
+ LockHolder acquireLock(String namespace, String lockKey, LockMode mode, AcquireLockOptions options);
+
+ Transaction renewTransaction(long extraMs);
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionLockInfo.java b/src/main/java/dev/caerus/sdk/dls/TransactionLockInfo.java
new file mode 100644
index 0000000..10e720c
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionLockInfo.java
@@ -0,0 +1,4 @@
+package dev.caerus.sdk.dls;
+
+public record TransactionLockInfo(String namespace, String lockKey, LockMode requestedMode, LockStatus status) {
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionNotActiveError.java b/src/main/java/dev/caerus/sdk/dls/TransactionNotActiveError.java
new file mode 100644
index 0000000..f019482
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionNotActiveError.java
@@ -0,0 +1,14 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.CaerusErrorOptions;
+
+public class TransactionNotActiveError extends DlsConflictError {
+
+ public TransactionNotActiveError(String message) {
+ super(message);
+ }
+
+ public TransactionNotActiveError(String message, CaerusErrorOptions options) {
+ super(message, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionOptions.java b/src/main/java/dev/caerus/sdk/dls/TransactionOptions.java
new file mode 100644
index 0000000..3ccf3f6
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionOptions.java
@@ -0,0 +1,82 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.AbortSignal;
+
+import java.util.function.Consumer;
+import java.util.Optional;
+
+public final class TransactionOptions {
+
+ private static final TransactionOptions NONE = builder().build();
+
+ private final Long timeoutMs;
+ private final Boolean autoRenew;
+ private final AbortSignal signal;
+ private final Consumer onTransactionLost;
+
+ private TransactionOptions(Builder builder) {
+ this.timeoutMs = builder.timeoutMs;
+ this.autoRenew = builder.autoRenew;
+ this.signal = builder.signal;
+ this.onTransactionLost = builder.onTransactionLost;
+ }
+
+ public static TransactionOptions none() {
+ return NONE;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional timeoutMs() {
+ return Optional.ofNullable(timeoutMs);
+ }
+
+ public Optional autoRenew() {
+ return Optional.ofNullable(autoRenew);
+ }
+
+ public Optional signal() {
+ return Optional.ofNullable(signal);
+ }
+
+ public Optional> onTransactionLost() {
+ return Optional.ofNullable(onTransactionLost);
+ }
+
+ public static final class Builder {
+
+ private Long timeoutMs;
+ private Boolean autoRenew;
+ private AbortSignal signal;
+ private Consumer onTransactionLost;
+
+ private Builder() {
+ }
+
+ public Builder timeoutMs(Long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public Builder autoRenew(Boolean autoRenew) {
+ this.autoRenew = autoRenew;
+ return this;
+ }
+
+ public Builder signal(AbortSignal signal) {
+ this.signal = signal;
+ return this;
+ }
+
+ public Builder onTransactionLost(Consumer onTransactionLost) {
+ this.onTransactionLost = onTransactionLost;
+ return this;
+ }
+
+ public TransactionOptions build() {
+ return new TransactionOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionScope.java b/src/main/java/dev/caerus/sdk/dls/TransactionScope.java
new file mode 100644
index 0000000..45c36a0
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionScope.java
@@ -0,0 +1,207 @@
+package dev.caerus.sdk.dls;
+
+import dev.caerus.sdk.AbortController;
+import dev.caerus.sdk.AbortSignal;
+import dev.caerus.sdk.internal.Futures;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+final class TransactionScope implements TransactionContext {
+
+ static final long DEFAULT_LIFETIME_MS = 10_000L;
+ static final long MIN_RENEW_INTERVAL_MS = 1_000L;
+
+ private final DlsOperations operations;
+ private final String transactionId;
+ private final TransactionOptions options;
+ private final AbortController lostController = new AbortController();
+ private final AtomicInteger consecutiveFailures = new AtomicInteger();
+ private final AtomicBoolean renewing = new AtomicBoolean();
+ private final Object heartbeatLock = new Object();
+
+ private volatile boolean closed;
+ private volatile DlsError lost;
+ private ScheduledFuture> heartbeat;
+
+ private TransactionScope(DlsOperations operations, String transactionId, TransactionOptions options) {
+ this.operations = operations;
+ this.transactionId = transactionId;
+ this.options = options;
+ }
+
+ static T run(
+ DlsOperations operations, TransactionCallback callback, TransactionOptions options) throws E {
+ TransactionOptions resolved = options == null ? TransactionOptions.none() : options;
+
+ BeginTransactionOptions.Builder begin = BeginTransactionOptions.builder();
+ resolved.timeoutMs().ifPresent(begin::timeoutMs);
+ Transaction transaction = await(operations.beginTransactionAsync(begin.build()));
+
+ TransactionScope scope = new TransactionScope(operations, transaction.transactionId(), resolved);
+ if (resolved.autoRenew().orElse(true)) {
+ scope.startHeartbeat();
+ }
+
+ try {
+ T result = callback.run(scope);
+ DlsError lostError = scope.lost;
+ if (lostError != null) {
+ throw lostError;
+ }
+ return result;
+ } finally {
+ scope.close();
+ try {
+ await(operations.releaseTransactionLocksAsync(scope.transactionId));
+ } catch (RuntimeException ignored) {
+ }
+ }
+ }
+
+ static long lifetimeMs(TransactionOptions options) {
+ long timeout = options.timeoutMs().orElse(0L);
+ return timeout > 0 ? timeout : DEFAULT_LIFETIME_MS;
+ }
+
+ static long renewIntervalMs(long lifetimeMs) {
+ return Math.max(MIN_RENEW_INTERVAL_MS, lifetimeMs / 2);
+ }
+
+ @Override
+ public String transactionId() {
+ return transactionId;
+ }
+
+ @Override
+ public AbortSignal signal() {
+ return lostController.signal();
+ }
+
+ @Override
+ public LockHolder acquireLock(String namespace, String lockKey, LockMode mode, AcquireLockOptions acquireOptions) {
+ if (closed) {
+ throw new IllegalStateException("Transaction context already closed");
+ }
+ DlsError lostError = lost;
+ if (lostError != null) {
+ throw lostError;
+ }
+ AcquireLockOptions given = acquireOptions == null ? AcquireLockOptions.none() : acquireOptions;
+ AbortController combined = new AbortController();
+ List stopListening = new ArrayList<>();
+ given.signal().or(options::signal).ifPresent(own ->
+ stopListening.add(own.onAbort(() -> combined.abort(own.reason().orElse(null)))));
+ AbortSignal lostSignal = lostController.signal();
+ stopListening.add(lostSignal.onAbort(() -> combined.abort(lostSignal.reason().orElse(null))));
+
+ AcquireLockOptions.Builder merged = AcquireLockOptions.builder().signal(combined.signal());
+ given.idempotencyKey().ifPresent(merged::idempotencyKey);
+ given.timeoutMs().ifPresent(merged::timeoutMs);
+ given.onQueued().ifPresent(merged::onQueued);
+
+ try {
+ return await(operations.acquireLockAsync(namespace, lockKey, transactionId, mode, merged.build()));
+ } finally {
+ stopListening.forEach(Runnable::run);
+ }
+ }
+
+ @Override
+ public Transaction renewTransaction(long extraMs) {
+ if (closed) {
+ throw new IllegalStateException("Transaction context already closed");
+ }
+ return await(operations.renewTransactionAsync(transactionId, extraMs));
+ }
+
+ @Override
+ public String toString() {
+ return "TransactionContext[" + transactionId + "]";
+ }
+
+ private void startHeartbeat() {
+ long lifetime = lifetimeMs(options);
+ long interval = renewIntervalMs(lifetime);
+ synchronized (heartbeatLock) {
+ heartbeat = operations.renewalScheduler().scheduleAtFixedRate(
+ () -> renew(lifetime), interval, interval, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void renew(long lifetime) {
+ if (closed || lost != null || !renewing.compareAndSet(false, true)) {
+ return;
+ }
+ CompletableFuture renewal;
+ try {
+ renewal = operations.renewTransactionAsync(transactionId, lifetime);
+ } catch (RuntimeException e) {
+ renewal = CompletableFuture.failedFuture(e);
+ }
+ renewal.whenComplete((ignored, error) -> {
+ renewing.set(false);
+ if (error == null) {
+ consecutiveFailures.set(0);
+ return;
+ }
+ Throwable cause = Futures.unwrap(error);
+ int failures = consecutiveFailures.incrementAndGet();
+ if (DlsErrors.isGone(cause) || failures >= 2) {
+ giveUp(cause);
+ }
+ });
+ }
+
+ private void giveUp(Throwable error) {
+ if (closed) {
+ return;
+ }
+ DlsError lostError;
+ synchronized (heartbeatLock) {
+ if (lost != null) {
+ return;
+ }
+ lostError = DlsErrors.toDlsError(error);
+ lost = lostError;
+ stopHeartbeat();
+ }
+ try {
+ lostController.abort(lostError);
+ } catch (RuntimeException e) {
+ operations.logger().error("signal listener threw", e);
+ }
+ Consumer onLost = options.onTransactionLost().orElse(null);
+ if (onLost != null) {
+ try {
+ onLost.accept(lostError);
+ } catch (RuntimeException e) {
+ operations.logger().error("onTransactionLost callback threw", e);
+ }
+ }
+ }
+
+ private void close() {
+ closed = true;
+ synchronized (heartbeatLock) {
+ stopHeartbeat();
+ }
+ }
+
+ private void stopHeartbeat() {
+ if (heartbeat != null) {
+ heartbeat.cancel(false);
+ heartbeat = null;
+ }
+ }
+
+ private static T await(CompletableFuture future) {
+ return Futures.await(future, DlsErrors::interrupted, DlsErrors::unexpected);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionStatusResponse.java b/src/main/java/dev/caerus/sdk/dls/TransactionStatusResponse.java
new file mode 100644
index 0000000..d2f0f32
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/dls/TransactionStatusResponse.java
@@ -0,0 +1,15 @@
+package dev.caerus.sdk.dls;
+
+import java.util.List;
+import java.util.Optional;
+
+public record TransactionStatusResponse(
+ String status,
+ Optional abortReason,
+ List locks,
+ long expiresAt) {
+
+ public TransactionStatusResponse {
+ locks = List.copyOf(locks);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/ClientProfile.java b/src/main/java/dev/caerus/sdk/internal/ClientProfile.java
new file mode 100644
index 0000000..9476bab
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/ClientProfile.java
@@ -0,0 +1,23 @@
+package dev.caerus.sdk.internal;
+
+public record ClientProfile(
+ String clientName,
+ String missingApiKeyMessage,
+ String endpointVariable,
+ String tlsVariable,
+ String loggerPrefix) {
+
+ public static final ClientProfile SRE = new ClientProfile(
+ "CaerusClient",
+ "CaerusClient requires an apiKey. Create one in the Caerus dashboard",
+ "CAERUS_ENDPOINT",
+ "CAERUS_TLS",
+ "[caerus]");
+
+ public static final ClientProfile DLS = new ClientProfile(
+ "DlsClient",
+ "DlsClient requires an apiKey",
+ "CAERUS_DLS_ENDPOINT",
+ "CAERUS_DLS_TLS",
+ "[caerus-dls]");
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/ClientSettings.java b/src/main/java/dev/caerus/sdk/internal/ClientSettings.java
new file mode 100644
index 0000000..da7efc5
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/ClientSettings.java
@@ -0,0 +1,100 @@
+package dev.caerus.sdk.internal;
+
+import dev.caerus.sdk.CaerusLogger;
+import dev.caerus.sdk.CaerusSdk;
+
+import java.util.Locale;
+import java.util.function.UnaryOperator;
+
+public final class ClientSettings {
+
+ private final String endpoint;
+ private final String apiKey;
+ private final boolean tls;
+ private final long timeoutMs;
+ private final CaerusLogger logger;
+
+ private ClientSettings(String endpoint, String apiKey, boolean tls, long timeoutMs, CaerusLogger logger) {
+ this.endpoint = endpoint;
+ this.apiKey = apiKey;
+ this.tls = tls;
+ this.timeoutMs = timeoutMs;
+ this.logger = logger;
+ }
+
+ public static ClientSettings resolve(
+ ClientProfile profile,
+ String apiKey,
+ String endpoint,
+ Boolean tls,
+ Long timeoutMs,
+ CaerusLogger logger,
+ UnaryOperator environment) {
+ String key = apiKey == null ? "" : apiKey.trim();
+ if (key.isEmpty()) {
+ throw new IllegalArgumentException(profile.missingApiKeyMessage());
+ }
+
+ String resolvedEndpoint;
+ if (endpoint != null) {
+ if (endpoint.trim().isEmpty()) {
+ throw new IllegalArgumentException("endpoint cannot be blank");
+ }
+ resolvedEndpoint = endpoint.trim();
+ } else {
+ String fromEnv = environment.apply(profile.endpointVariable());
+ fromEnv = fromEnv == null ? "" : fromEnv.trim();
+ resolvedEndpoint = fromEnv.isEmpty() ? CaerusSdk.DEFAULT_ENDPOINT : fromEnv;
+ }
+
+ Boolean envTls = readTls(environment.apply(profile.tlsVariable()));
+
+ long resolvedTimeout = timeoutMs == null ? CaerusSdk.DEFAULT_TIMEOUT_MS : timeoutMs;
+ if (resolvedTimeout <= 0) {
+ throw new IllegalArgumentException("timeoutMs must be a positive number of milliseconds");
+ }
+
+ CaerusLogger resolvedLogger = logger == null ? new StderrLogger(profile.loggerPrefix()) : logger;
+
+ boolean resolvedTls = tls != null ? tls : envTls != null ? envTls : true;
+
+ return new ClientSettings(resolvedEndpoint, key, resolvedTls, resolvedTimeout, resolvedLogger);
+ }
+
+ public static UnaryOperator systemEnvironment() {
+ return System::getenv;
+ }
+
+ private static Boolean readTls(String raw) {
+ if (raw == null) {
+ return null;
+ }
+ String value = raw.trim().toLowerCase(Locale.ROOT);
+ return !(value.equals("false") || value.equals("0"));
+ }
+
+ public String endpoint() {
+ return endpoint;
+ }
+
+ public String apiKey() {
+ return apiKey;
+ }
+
+ public boolean tls() {
+ return tls;
+ }
+
+ public long timeoutMs() {
+ return timeoutMs;
+ }
+
+ public CaerusLogger logger() {
+ return logger;
+ }
+
+ @Override
+ public String toString() {
+ return "ClientSettings[endpoint=" + endpoint + ", tls=" + tls + ", timeoutMs=" + timeoutMs + "]";
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/ErrorDetails.java b/src/main/java/dev/caerus/sdk/internal/ErrorDetails.java
new file mode 100644
index 0000000..735e7cd
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/ErrorDetails.java
@@ -0,0 +1,98 @@
+package dev.caerus.sdk.internal;
+
+import com.google.protobuf.Any;
+import com.google.rpc.ErrorInfo;
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.StatusException;
+import io.grpc.StatusRuntimeException;
+
+import java.util.Iterator;
+import java.util.Optional;
+
+public final class ErrorDetails {
+
+ public static final Metadata.Key STATUS_DETAILS_KEY =
+ Metadata.Key.of("grpc-status-details-bin", Metadata.BINARY_BYTE_MARSHALLER);
+
+ public static final Metadata.Key REQUEST_ID_KEY =
+ Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER);
+
+ private static final String ERROR_INFO_SUFFIX = "google.rpc.ErrorInfo";
+
+ private ErrorDetails() {
+ }
+
+ public static Optional reasonOf(Throwable error) {
+ try {
+ Metadata trailers = Status.trailersFromThrowable(error);
+ if (trailers == null) {
+ return Optional.empty();
+ }
+ byte[] raw = first(trailers.getAll(STATUS_DETAILS_KEY));
+ if (raw == null) {
+ return Optional.empty();
+ }
+ return decodeReason(raw);
+ } catch (RuntimeException e) {
+ return Optional.empty();
+ }
+ }
+
+ public static Optional decodeReason(byte[] statusBytes) {
+ try {
+ com.google.rpc.Status status = com.google.rpc.Status.parseFrom(statusBytes);
+ for (Any detail : status.getDetailsList()) {
+ if (!detail.getTypeUrl().endsWith(ERROR_INFO_SUFFIX)) {
+ continue;
+ }
+ String reason = ErrorInfo.parseFrom(detail.getValue()).getReason();
+ if (!reason.isEmpty()) {
+ return Optional.of(reason);
+ }
+ }
+ return Optional.empty();
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+
+ public static Optional requestIdOf(Throwable error) {
+ try {
+ Metadata trailers = Status.trailersFromThrowable(error);
+ if (trailers == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(first(trailers.getAll(REQUEST_ID_KEY)));
+ } catch (RuntimeException e) {
+ return Optional.empty();
+ }
+ }
+
+ public static String messageOf(Throwable error, String fallback) {
+ if (isGrpc(error)) {
+ String description = Status.fromThrowable(error).getDescription();
+ if (description != null && !description.isEmpty()) {
+ return description;
+ }
+ }
+ String message = error == null ? null : error.getMessage();
+ return message == null || message.isEmpty() ? fallback : message;
+ }
+
+ public static Status.Code codeOf(Throwable error) {
+ return isGrpc(error) ? Status.fromThrowable(error).getCode() : null;
+ }
+
+ private static boolean isGrpc(Throwable error) {
+ return error instanceof StatusRuntimeException || error instanceof StatusException;
+ }
+
+ private static T first(Iterable values) {
+ if (values == null) {
+ return null;
+ }
+ Iterator iterator = values.iterator();
+ return iterator.hasNext() ? iterator.next() : null;
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/Futures.java b/src/main/java/dev/caerus/sdk/internal/Futures.java
new file mode 100644
index 0000000..0dac754
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/Futures.java
@@ -0,0 +1,52 @@
+package dev.caerus.sdk.internal;
+
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public final class Futures {
+
+ private Futures() {
+ }
+
+ public static T await(
+ CompletableFuture future,
+ Supplier extends RuntimeException> onInterrupt,
+ Function onUnexpected) {
+ try {
+ return future.get();
+ } catch (InterruptedException e) {
+ future.cancel(true);
+ Thread.currentThread().interrupt();
+ throw onInterrupt.get();
+ } catch (ExecutionException e) {
+ throw rethrowable(e.getCause(), onUnexpected);
+ } catch (CancellationException e) {
+ throw onInterrupt.get();
+ }
+ }
+
+ public static RuntimeException rethrowable(
+ Throwable error, Function onUnexpected) {
+ Throwable cause = unwrap(error);
+ if (cause instanceof RuntimeException runtime) {
+ return runtime;
+ }
+ if (cause instanceof Error fatal) {
+ throw fatal;
+ }
+ return onUnexpected.apply(cause);
+ }
+
+ public static Throwable unwrap(Throwable error) {
+ Throwable current = error;
+ while ((current instanceof CompletionException || current instanceof ExecutionException)
+ && current.getCause() != null) {
+ current = current.getCause();
+ }
+ return current;
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/GrpcTransport.java b/src/main/java/dev/caerus/sdk/internal/GrpcTransport.java
new file mode 100644
index 0000000..bf760b9
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/GrpcTransport.java
@@ -0,0 +1,118 @@
+package dev.caerus.sdk.internal;
+
+import io.grpc.CallOptions;
+import io.grpc.ChannelCredentials;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptors;
+import io.grpc.Grpc;
+import io.grpc.InsecureChannelCredentials;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.TlsChannelCredentials;
+import io.grpc.stub.ClientCalls;
+import io.grpc.stub.MetadataUtils;
+import io.grpc.stub.StreamObserver;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+
+public final class GrpcTransport {
+
+ public static final Metadata.Key AUTHORIZATION_KEY =
+ Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
+
+ private final ManagedChannel channel;
+ private final String apiKey;
+ private final long timeoutMs;
+ private volatile boolean closed;
+
+ public GrpcTransport(ClientSettings settings) {
+ ChannelCredentials credentials = settings.tls()
+ ? TlsChannelCredentials.create()
+ : InsecureChannelCredentials.create();
+ this.channel = Grpc.newChannelBuilder(settings.endpoint(), credentials).build();
+ this.apiKey = settings.apiKey();
+ this.timeoutMs = settings.timeoutMs();
+ }
+
+ public static String newRequestId() {
+ return "req_" + UUID.randomUUID();
+ }
+
+ public boolean isClosed() {
+ return closed;
+ }
+
+ public long timeoutMs() {
+ return timeoutMs;
+ }
+
+ public Metadata headers(String requestId) {
+ Metadata metadata = new Metadata();
+ metadata.put(AUTHORIZATION_KEY, "Bearer " + apiKey);
+ if (requestId != null && !requestId.isEmpty()) {
+ metadata.put(ErrorDetails.REQUEST_ID_KEY, requestId);
+ }
+ return metadata;
+ }
+
+ public ClientCall newCall(
+ MethodDescriptor method, String requestId, long deadlineMs) {
+ CallOptions options = CallOptions.DEFAULT.withDeadlineAfter(deadlineMs, TimeUnit.MILLISECONDS);
+ return ClientInterceptors
+ .intercept(channel, MetadataUtils.newAttachHeadersInterceptor(headers(requestId)))
+ .newCall(method, options);
+ }
+
+ public CompletableFuture unary(
+ MethodDescriptor method,
+ Req request,
+ BiFunction mapError,
+ Supplier extends RuntimeException> closedError) {
+ if (closed) {
+ return CompletableFuture.failedFuture(closedError.get());
+ }
+
+ String requestId = newRequestId();
+ ClientCall call = newCall(method, requestId, timeoutMs);
+ CompletableFuture result = new CompletableFuture<>();
+
+ ClientCalls.asyncUnaryCall(call, request, new StreamObserver<>() {
+ private Res value;
+
+ @Override
+ public void onNext(Res response) {
+ value = response;
+ }
+
+ @Override
+ public void onError(Throwable error) {
+ result.completeExceptionally(mapError.apply(error, requestId));
+ }
+
+ @Override
+ public void onCompleted() {
+ result.complete(value);
+ }
+ });
+
+ result.whenComplete((ignored, error) -> {
+ if (result.isCancelled()) {
+ call.cancel("Cancelled by the caller", null);
+ }
+ });
+ return result;
+ }
+
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ channel.shutdown();
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/Json.java b/src/main/java/dev/caerus/sdk/internal/Json.java
new file mode 100644
index 0000000..70cdd8c
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/Json.java
@@ -0,0 +1,71 @@
+package dev.caerus.sdk.internal;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.Strictness;
+import com.google.gson.ToNumberPolicy;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.lang.reflect.Type;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class Json {
+
+ private static final Type MAP_TYPE = new TypeToken>() {
+ }.getType();
+
+ private static final Gson GSON = new GsonBuilder()
+ .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
+ .serializeNulls()
+ .disableHtmlEscaping()
+ .create();
+
+ private Json() {
+ }
+
+ public static Gson gson() {
+ return GSON;
+ }
+
+ public static String stringify(Object value) {
+ return GSON.toJson(value);
+ }
+
+ public static JsonElement parse(String raw) {
+ try (JsonReader reader = new JsonReader(new StringReader(raw))) {
+ reader.setStrictness(Strictness.STRICT);
+ JsonElement element = GSON.getAdapter(JsonElement.class).read(reader);
+ if (reader.peek() != JsonToken.END_DOCUMENT) {
+ throw new JsonSyntaxException("Unexpected content after the JSON value");
+ }
+ return element;
+ } catch (IOException e) {
+ throw new JsonSyntaxException(e);
+ }
+ }
+
+ public static Map parseObject(String raw) {
+ JsonElement element = parse(raw);
+ if (element == null || !element.isJsonObject()) {
+ throw new IllegalArgumentException("not a JSON object");
+ }
+ Map map = GSON.fromJson(element, MAP_TYPE);
+ return Collections.unmodifiableMap(map);
+ }
+
+ public static Map toMap(JsonElement element) {
+ if (element == null || !element.isJsonObject()) {
+ return Collections.emptyMap();
+ }
+ Map map = GSON.fromJson(element, MAP_TYPE);
+ return Collections.unmodifiableMap(map);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/internal/StderrLogger.java b/src/main/java/dev/caerus/sdk/internal/StderrLogger.java
new file mode 100644
index 0000000..ce9c425
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/internal/StderrLogger.java
@@ -0,0 +1,40 @@
+package dev.caerus.sdk.internal;
+
+import dev.caerus.sdk.CaerusLogger;
+
+import java.io.PrintStream;
+
+public final class StderrLogger implements CaerusLogger {
+
+ private final String prefix;
+
+ public StderrLogger(String prefix) {
+ this.prefix = prefix;
+ }
+
+ @Override
+ public void error(String message, Object... details) {
+ PrintStream err = System.err;
+ StringBuilder line = new StringBuilder(prefix).append(' ').append(message);
+ Throwable throwable = null;
+ if (details != null) {
+ for (Object detail : details) {
+ if (detail instanceof Throwable t && throwable == null) {
+ throwable = t;
+ } else {
+ line.append(' ').append(detail);
+ }
+ }
+ }
+ synchronized (err) {
+ err.println(line);
+ if (throwable != null) {
+ throwable.printStackTrace(err);
+ }
+ }
+ }
+
+ public String prefix() {
+ return prefix;
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/sre/AuthenticationError.java b/src/main/java/dev/caerus/sdk/sre/AuthenticationError.java
new file mode 100644
index 0000000..075215c
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/sre/AuthenticationError.java
@@ -0,0 +1,16 @@
+package dev.caerus.sdk.sre;
+
+import dev.caerus.sdk.CaerusError;
+import dev.caerus.sdk.CaerusErrorOptions;
+import dev.caerus.sdk.ErrorCode;
+
+public class AuthenticationError extends CaerusError {
+
+ public AuthenticationError(String message) {
+ super(message, ErrorCode.AUTHENTICATION, CaerusErrorOptions.none());
+ }
+
+ public AuthenticationError(String message, CaerusErrorOptions options) {
+ super(message, ErrorCode.AUTHENTICATION, options);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/sre/CaerusClient.java b/src/main/java/dev/caerus/sdk/sre/CaerusClient.java
new file mode 100644
index 0000000..dac279b
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/sre/CaerusClient.java
@@ -0,0 +1,132 @@
+package dev.caerus.sdk.sre;
+
+import dev.caerus.sdk.internal.ClientProfile;
+import dev.caerus.sdk.internal.ClientSettings;
+import dev.caerus.sdk.internal.Futures;
+import dev.caerus.sdk.webhooks.Webhooks;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.UnaryOperator;
+
+public final class CaerusClient implements SharedResourceApi {
+
+ private final SreCore core;
+ private final String endpoint;
+ private final long timeoutMs;
+ private final Webhooks webhooks = new Webhooks();
+
+ public CaerusClient(CaerusClientOptions options) {
+ this(resolve(options, ClientSettings.systemEnvironment()));
+ }
+
+ CaerusClient(ClientSettings settings) {
+ this.endpoint = settings.endpoint();
+ this.timeoutMs = settings.timeoutMs();
+ this.core = new SreCore(settings);
+ }
+
+ static ClientSettings resolve(CaerusClientOptions options, UnaryOperator environment) {
+ if (options == null) {
+ throw new IllegalArgumentException("CaerusClient requires an options object with an apiKey");
+ }
+ return ClientSettings.resolve(
+ ClientProfile.SRE,
+ options.apiKey(),
+ options.endpoint().orElse(null),
+ options.tls().orElse(null),
+ options.timeoutMs().orElse(null),
+ options.logger().orElse(null),
+ environment);
+ }
+
+ public String endpoint() {
+ return endpoint;
+ }
+
+ public long timeoutMs() {
+ return timeoutMs;
+ }
+
+ public Webhooks webhooks() {
+ return webhooks;
+ }
+
+ @Override
+ public Resource createUnitary(String templateName, String key, CreateResourceOptions options) {
+ return await(core.createUnitary(templateName, key, options));
+ }
+
+ @Override
+ public Resource createMultiple(String templateName, String key, int availableAmount, CreateResourceOptions options) {
+ return await(core.createMultiple(templateName, key, availableAmount, options));
+ }
+
+ @Override
+ public Resource updateResource(String key, int deltaAmount, UpdateResourceOptions options) {
+ return await(core.updateResource(key, deltaAmount, options));
+ }
+
+ @Override
+ public void deleteResource(String key) {
+ await(core.deleteResource(key));
+ }
+
+ @Override
+ public UnitaryResource unitary(String key) {
+ return Handles.unitary(key, (resourceKey, amount, options) -> await(core.take(resourceKey, amount, options)));
+ }
+
+ @Override
+ public PooledResource pooled(String key) {
+ return Handles.pooled(key, (resourceKey, amount, options) -> await(core.take(resourceKey, amount, options)));
+ }
+
+ @Override
+ public ResourceHolder confirm(String resourceHolderId, ConfirmOptions options) {
+ return await(core.confirm(resourceHolderId, options));
+ }
+
+ @Override
+ public void release(String resourceHolderId) {
+ await(core.release(resourceHolderId));
+ }
+
+ @Override
+ public ResourceHolder extend(String resourceHolderId, int extraMs) {
+ return await(core.extend(resourceHolderId, extraMs));
+ }
+
+ @Override
+ public ResourceHolder getResourceHolder(String resourceHolderId) {
+ return await(core.getResourceHolder(resourceHolderId));
+ }
+
+ @Override
+ public Resource getResource(String key) {
+ return await(core.getResource(key));
+ }
+
+ @Override
+ public ResourcePage getResourcesByGroup(String groupKey, GetResourcesByGroupOptions options) {
+ return await(core.getResourcesByGroup(groupKey, options));
+ }
+
+ @Override
+ public ResourceHolderPage listResourceHolders(ListResourceHoldersOptions options) {
+ return await(core.listResourceHolders(options));
+ }
+
+ @Override
+ public void close() {
+ core.close();
+ }
+
+ @Override
+ public String toString() {
+ return "CaerusClient[endpoint=" + endpoint + "]";
+ }
+
+ private static T await(CompletableFuture future) {
+ return Futures.await(future, SreErrors::interrupted, SreErrors::unexpected);
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/sre/CaerusClientOptions.java b/src/main/java/dev/caerus/sdk/sre/CaerusClientOptions.java
new file mode 100644
index 0000000..be52762
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/sre/CaerusClientOptions.java
@@ -0,0 +1,97 @@
+package dev.caerus.sdk.sre;
+
+import dev.caerus.sdk.CaerusLogger;
+
+import java.util.Optional;
+
+public final class CaerusClientOptions {
+
+ private final String apiKey;
+ private final String endpoint;
+ private final Boolean tls;
+ private final Long timeoutMs;
+ private final CaerusLogger logger;
+
+ private CaerusClientOptions(Builder builder) {
+ this.apiKey = builder.apiKey;
+ this.endpoint = builder.endpoint;
+ this.tls = builder.tls;
+ this.timeoutMs = builder.timeoutMs;
+ this.logger = builder.logger;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String apiKey() {
+ return apiKey;
+ }
+
+ public Optional endpoint() {
+ return Optional.ofNullable(endpoint);
+ }
+
+ public Optional tls() {
+ return Optional.ofNullable(tls);
+ }
+
+ public Optional timeoutMs() {
+ return Optional.ofNullable(timeoutMs);
+ }
+
+ public Optional logger() {
+ return Optional.ofNullable(logger);
+ }
+
+ @Override
+ public String toString() {
+ return "CaerusClientOptions[endpoint=" + endpoint + ", tls=" + tls + ", timeoutMs=" + timeoutMs + "]";
+ }
+
+ public static final class Builder {
+
+ private String apiKey;
+ private String endpoint;
+ private Boolean tls;
+ private Long timeoutMs;
+ private CaerusLogger logger;
+
+ private Builder() {
+ }
+
+ public Builder apiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ public Builder endpoint(String endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ public Builder tls(Boolean tls) {
+ this.tls = tls;
+ return this;
+ }
+
+ public Builder timeoutMs(Long timeoutMs) {
+ this.timeoutMs = timeoutMs;
+ return this;
+ }
+
+ public Builder logger(CaerusLogger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "CaerusClientOptions.Builder[endpoint=" + endpoint + ", tls=" + tls + ", timeoutMs=" + timeoutMs + "]";
+ }
+
+ public CaerusClientOptions build() {
+ return new CaerusClientOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/dev/caerus/sdk/sre/ConfirmOptions.java b/src/main/java/dev/caerus/sdk/sre/ConfirmOptions.java
new file mode 100644
index 0000000..8b24859
--- /dev/null
+++ b/src/main/java/dev/caerus/sdk/sre/ConfirmOptions.java
@@ -0,0 +1,46 @@
+package dev.caerus.sdk.sre;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public final class ConfirmOptions {
+
+ private static final ConfirmOptions NONE = builder().build();
+
+ private final Map metadata;
+
+ private ConfirmOptions(Builder builder) {
+ this.metadata = builder.metadata == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.metadata));
+ }
+
+ public static ConfirmOptions none() {
+ return NONE;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional