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 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 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> metadata() { + return Optional.ofNullable(metadata); + } + + public static final class Builder { + + private Map metadata; + + private Builder() { + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public ConfirmOptions build() { + return new ConfirmOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ConflictError.java b/src/main/java/dev/caerus/sdk/sre/ConflictError.java new file mode 100644 index 0000000..a00b2b3 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ConflictError.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 ConflictError extends CaerusError { + + public ConflictError(String message) { + super(message, ErrorCode.CONFLICT, CaerusErrorOptions.none()); + } + + public ConflictError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.CONFLICT, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/CreateResourceOptions.java b/src/main/java/dev/caerus/sdk/sre/CreateResourceOptions.java new file mode 100644 index 0000000..6af5173 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/CreateResourceOptions.java @@ -0,0 +1,58 @@ +package dev.caerus.sdk.sre; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +public final class CreateResourceOptions { + + private static final CreateResourceOptions NONE = builder().build(); + + private final String groupKey; + private final Map metadata; + + private CreateResourceOptions(Builder builder) { + this.groupKey = builder.groupKey; + this.metadata = builder.metadata == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.metadata)); + } + + public static CreateResourceOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional groupKey() { + return Optional.ofNullable(groupKey); + } + + public Optional> metadata() { + return Optional.ofNullable(metadata); + } + + public static final class Builder { + + private String groupKey; + private Map metadata; + + private Builder() { + } + + public Builder groupKey(String groupKey) { + this.groupKey = groupKey; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public CreateResourceOptions build() { + return new CreateResourceOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/GetResourcesByGroupOptions.java b/src/main/java/dev/caerus/sdk/sre/GetResourcesByGroupOptions.java new file mode 100644 index 0000000..1b5b0bc --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/GetResourcesByGroupOptions.java @@ -0,0 +1,55 @@ +package dev.caerus.sdk.sre; + +import java.util.Optional; + +public final class GetResourcesByGroupOptions { + + private static final GetResourcesByGroupOptions NONE = builder().build(); + + private final Integer page; + private final Integer pageSize; + + private GetResourcesByGroupOptions(Builder builder) { + this.page = builder.page; + this.pageSize = builder.pageSize; + } + + public static GetResourcesByGroupOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional page() { + return Optional.ofNullable(page); + } + + public Optional pageSize() { + return Optional.ofNullable(pageSize); + } + + public static final class Builder { + + private Integer page; + private Integer pageSize; + + private Builder() { + } + + public Builder page(Integer page) { + this.page = page; + return this; + } + + public Builder pageSize(Integer pageSize) { + this.pageSize = pageSize; + return this; + } + + public GetResourcesByGroupOptions build() { + return new GetResourcesByGroupOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/Handles.java b/src/main/java/dev/caerus/sdk/sre/Handles.java new file mode 100644 index 0000000..bff57ab --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/Handles.java @@ -0,0 +1,60 @@ +package dev.caerus.sdk.sre; + +final class Handles { + + @FunctionalInterface + interface TakeFn { + ResourceHolder take(String key, int amount, TakeOptions options); + } + + private Handles() { + } + + static UnitaryResource unitary(String key, TakeFn take) { + Validation.requireKey(key); + return new UnitaryResource() { + @Override + public String key() { + return key; + } + + @Override + public ResourceHolder take(TakeOptions options) { + return take.take(key, 1, options == null ? TakeOptions.none() : options); + } + + @Override + public String toString() { + return "UnitaryResource[" + key + "]"; + } + }; + } + + static PooledResource pooled(String key, TakeFn take) { + Validation.requireKey(key); + return new PooledResource() { + @Override + public String key() { + return key; + } + + @Override + public ResourceHolder take(TakeOptions options) { + return take.take(key, 1, options == null ? TakeOptions.none() : options); + } + + @Override + public ResourceHolder takeMany(int amount, TakeOptions options) { + if (amount <= 0) { + throw new ValidationError("amount must be greater than zero"); + } + return take.take(key, amount, options == null ? TakeOptions.none() : options); + } + + @Override + public String toString() { + return "PooledResource[" + key + "]"; + } + }; + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/HolderNotActiveError.java b/src/main/java/dev/caerus/sdk/sre/HolderNotActiveError.java new file mode 100644 index 0000000..953785b --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/HolderNotActiveError.java @@ -0,0 +1,14 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusErrorOptions; + +public class HolderNotActiveError extends ConflictError { + + public HolderNotActiveError(String message) { + super(message); + } + + public HolderNotActiveError(String message, CaerusErrorOptions options) { + super(message, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/HolderSort.java b/src/main/java/dev/caerus/sdk/sre/HolderSort.java new file mode 100644 index 0000000..b620f94 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/HolderSort.java @@ -0,0 +1,6 @@ +package dev.caerus.sdk.sre; + +public enum HolderSort { + NEWEST_FIRST, + OLDEST_FIRST +} diff --git a/src/main/java/dev/caerus/sdk/sre/InMemoryCaerusClient.java b/src/main/java/dev/caerus/sdk/sre/InMemoryCaerusClient.java new file mode 100644 index 0000000..43657a7 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/InMemoryCaerusClient.java @@ -0,0 +1,465 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.webhooks.Webhooks; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public final class InMemoryCaerusClient implements SharedResourceApi { + + private static final int DEFAULT_TTL_SECONDS = 300; + private static final int DEFAULT_PAGE_SIZE = 25; + + private final Object lock = new Object(); + private final Webhooks webhooks = new Webhooks(); + private final Map resources = new LinkedHashMap<>(); + private final Map holders = new LinkedHashMap<>(); + private final Map idempotency = new HashMap<>(); + private final Map> failures = new EnumMap<>(MockMethod.class); + private final int defaultTtlSeconds; + private final int defaultPageSize; + + private long nowSeconds = Math.floorDiv(System.currentTimeMillis(), 1000L); + private long sequence; + private boolean closed; + + public InMemoryCaerusClient() { + this(MockOptions.none()); + } + + public InMemoryCaerusClient(MockOptions options) { + MockOptions resolved = options == null ? MockOptions.none() : options; + this.defaultTtlSeconds = resolved.defaultTtlSeconds().orElse(DEFAULT_TTL_SECONDS); + this.defaultPageSize = resolved.defaultPageSize().orElse(DEFAULT_PAGE_SIZE); + + for (MockResourceSeed seed : resolved.resources().orElse(Collections.emptyList())) { + StoredResource resource = new StoredResource(); + resource.id = nextId("res"); + resource.key = seed.key(); + resource.templateId = seed.templateId().orElse("tpl-mock"); + resource.availableAmount = seed.availableAmount(); + resource.groupKey = seed.groupKey().orElse(null); + resource.metadata = seed.metadata().orElse(null); + resource.createdAtMs = nowSeconds * 1000; + resource.updatedAtMs = nowSeconds * 1000; + resources.put(seed.key(), resource); + } + } + + public Webhooks webhooks() { + return webhooks; + } + + public void failNext(MockMethod method, CaerusError error) { + synchronized (lock) { + failures.computeIfAbsent(method, ignored -> new ArrayDeque<>()).addLast(error); + } + } + + public void clearFailures() { + synchronized (lock) { + failures.clear(); + } + } + + public void advanceTime(long seconds) { + synchronized (lock) { + if (seconds < 0) { + throw new ValidationError("advanceTime needs a number of seconds that is not negative"); + } + nowSeconds += seconds; + for (StoredHolder holder : holders.values()) { + if (holder.status == ResourceHolderStatus.PENDING && holder.expiresAtSeconds <= nowSeconds) { + giveBack(holder, ResourceHolderStatus.EXPIRED); + } + } + } + } + + public void expire(String resourceHolderId) { + synchronized (lock) { + StoredHolder holder = requireHolder(resourceHolderId); + if (holder.status != ResourceHolderStatus.PENDING) { + throw new HolderNotActiveError( + "Holder " + resourceHolderId + " is " + holder.status + " and cannot expire."); + } + giveBack(holder, ResourceHolderStatus.EXPIRED); + } + } + + public MockSnapshot snapshot() { + synchronized (lock) { + return new MockSnapshot( + resources.values().stream().map(this::toResource).collect(Collectors.toList()), + holders.values().stream().map(this::toHolder).collect(Collectors.toList())); + } + } + + @Override + public Resource createUnitary(String templateName, String key, CreateResourceOptions options) { + synchronized (lock) { + guard(MockMethod.CREATE_UNITARY); + return createResource(templateName, key, 1, options); + } + } + + @Override + public Resource createMultiple(String templateName, String key, int availableAmount, CreateResourceOptions options) { + synchronized (lock) { + guard(MockMethod.CREATE_MULTIPLE); + Validation.requirePositive(availableAmount, "availableAmount"); + return createResource(templateName, key, availableAmount, options); + } + } + + private Resource createResource(String templateName, String key, int availableAmount, CreateResourceOptions options) { + Validation.requireText(templateName, "templateName"); + Validation.requireText(key, "key"); + CreateResourceOptions resolved = options == null ? CreateResourceOptions.none() : options; + + if (resources.containsKey(key)) { + throw new ConflictError("A resource with key " + key + " already exists."); + } + + StoredResource resource = new StoredResource(); + resource.id = nextId("res"); + resource.key = key; + resource.templateId = "tpl-" + templateName; + resource.availableAmount = availableAmount; + resource.groupKey = resolved.groupKey().orElse(null); + resource.metadata = resolved.metadata().orElse(null); + resource.createdAtMs = nowSeconds * 1000; + resource.updatedAtMs = nowSeconds * 1000; + resources.put(key, resource); + + return toResource(resource); + } + + @Override + public Resource updateResource(String key, int deltaAmount, UpdateResourceOptions options) { + synchronized (lock) { + guard(MockMethod.UPDATE_RESOURCE); + Validation.requireText(key, "key"); + UpdateResourceOptions resolved = options == null ? UpdateResourceOptions.none() : options; + + StoredResource resource = requireResource(key); + if ((long) resource.availableAmount + deltaAmount < 0) { + throw new ConflictError("Available amount cannot be negative for resource: " + key); + } + + resource.availableAmount += deltaAmount; + resolved.groupKey().ifPresent(groupKey -> resource.groupKey = groupKey); + resolved.metadata().ifPresent(metadata -> resource.metadata = metadata); + resource.updatedAtMs = nowSeconds * 1000; + + return toResource(resource); + } + } + + @Override + public void deleteResource(String key) { + synchronized (lock) { + guard(MockMethod.DELETE_RESOURCE); + Validation.requireText(key, "key"); + + StoredResource resource = requireResource(key); + if (resource.pendingCount > 0) { + throw new ResourceHasActiveHoldsError("Cannot delete resource with active holds: " + key); + } + + resources.remove(key); + } + } + + @Override + public UnitaryResource unitary(String key) { + return Handles.unitary(key, (resourceKey, amount, options) -> { + synchronized (lock) { + guard(MockMethod.TAKE); + return take(resourceKey, amount, options); + } + }); + } + + @Override + public PooledResource pooled(String key) { + return Handles.pooled(key, (resourceKey, amount, options) -> { + synchronized (lock) { + guard(amount == 1 ? MockMethod.TAKE : MockMethod.TAKE_MANY); + return take(resourceKey, amount, options); + } + }); + } + + @Override + public ResourceHolder confirm(String resourceHolderId, ConfirmOptions options) { + synchronized (lock) { + guard(MockMethod.CONFIRM); + StoredHolder holder = requireHolder(resourceHolderId); + ConfirmOptions resolved = options == null ? ConfirmOptions.none() : options; + + if (holder.status != ResourceHolderStatus.PENDING) { + throw new HolderNotActiveError( + "Holder " + resourceHolderId + " is " + holder.status + " and cannot be confirmed."); + } + + StoredResource resource = requireResource(holder.resourceKey); + resource.pendingCount -= holder.amount; + holder.status = ResourceHolderStatus.CONFIRMED; + resolved.metadata().ifPresent(metadata -> holder.metadata = metadata); + + return toHolder(holder); + } + } + + @Override + public void release(String resourceHolderId) { + synchronized (lock) { + guard(MockMethod.RELEASE); + StoredHolder holder = requireHolder(resourceHolderId); + + if (holder.status != ResourceHolderStatus.PENDING) { + throw new HolderNotActiveError( + "Holder " + resourceHolderId + " is " + holder.status + " and cannot be released."); + } + + giveBack(holder, ResourceHolderStatus.RELEASED); + } + } + + @Override + public ResourceHolder extend(String resourceHolderId, int extraMs) { + synchronized (lock) { + guard(MockMethod.EXTEND); + Validation.requirePositive(extraMs, "extraMs"); + StoredHolder holder = requireHolder(resourceHolderId); + + if (holder.status != ResourceHolderStatus.PENDING) { + throw new HolderNotActiveError( + "Holder " + resourceHolderId + " is " + holder.status + " and cannot be extended."); + } + + holder.expiresAtSeconds += (extraMs + 999L) / 1000L; + return toHolder(holder); + } + } + + @Override + public ResourceHolder getResourceHolder(String resourceHolderId) { + synchronized (lock) { + guard(MockMethod.GET_RESOURCE_HOLDER); + return toHolder(requireHolder(resourceHolderId)); + } + } + + @Override + public Resource getResource(String key) { + synchronized (lock) { + guard(MockMethod.GET_RESOURCE); + Validation.requireText(key, "key"); + return toResource(requireResource(key)); + } + } + + @Override + public ResourcePage getResourcesByGroup(String groupKey, GetResourcesByGroupOptions options) { + synchronized (lock) { + guard(MockMethod.GET_RESOURCES_BY_GROUP); + Validation.requireText(groupKey, "groupKey"); + GetResourcesByGroupOptions resolved = options == null ? GetResourcesByGroupOptions.none() : options; + + int page = resolved.page().orElse(0); + int pageSize = resolved.pageSize().orElse(defaultPageSize); + List matching = resources.values().stream() + .filter(resource -> groupKey.equals(resource.groupKey)) + .collect(Collectors.toList()); + + return new ResourcePage( + slice(matching, page, pageSize).stream().map(this::toResource).collect(Collectors.toList()), + hasNextPage(matching.size(), page, pageSize)); + } + } + + @Override + public ResourceHolderPage listResourceHolders(ListResourceHoldersOptions options) { + synchronized (lock) { + guard(MockMethod.LIST_RESOURCE_HOLDERS); + ListResourceHoldersOptions resolved = options == null ? ListResourceHoldersOptions.none() : options; + + List matching = new ArrayList<>(holders.values()); + resolved.resourceKey().ifPresent(key -> matching.removeIf(held -> !key.equals(held.resourceKey))); + resolved.status().ifPresent(status -> matching.removeIf(held -> held.status != status)); + if (resolved.sort().orElse(HolderSort.NEWEST_FIRST) != HolderSort.OLDEST_FIRST) { + Collections.reverse(matching); + } + + int page = resolved.page().orElse(0); + int pageSize = resolved.pageSize().orElse(defaultPageSize); + + return new ResourceHolderPage( + slice(matching, page, pageSize).stream().map(this::toHolder).collect(Collectors.toList()), + hasNextPage(matching.size(), page, pageSize)); + } + } + + @Override + public void close() { + synchronized (lock) { + closed = true; + } + } + + private ResourceHolder take(String resourceKey, int amount, TakeOptions options) { + TakeOptions resolved = options == null ? TakeOptions.none() : options; + resolved.ttlSeconds().ifPresent(ttl -> Validation.requirePositive(ttl, "ttlSeconds")); + + String idempotencyKey = resolved.idempotencyKey().orElse(null); + if (idempotencyKey != null) { + String known = idempotency.get(idempotencyKey); + if (known != null) { + StoredHolder replayed = requireHolder(known); + if (replayed.status != ResourceHolderStatus.PENDING && replayed.status != ResourceHolderStatus.QUEUED) { + throw new HolderNotActiveError("Caerus returned holder " + replayed.id + " as " + replayed.status + + ", which this call cannot use."); + } + return toHolder(replayed); + } + } + + StoredResource resource = requireResource(resourceKey); + if (resource.availableAmount < amount) { + throw new OutOfStockError("Out of stock for resource: " + resourceKey); + } + + resource.availableAmount -= amount; + resource.pendingCount += amount; + + StoredHolder holder = new StoredHolder(); + holder.id = nextId("hld"); + holder.resourceKey = resourceKey; + holder.resourceId = resource.id; + holder.amount = amount; + holder.status = ResourceHolderStatus.PENDING; + holder.expiresAtSeconds = nowSeconds + resolved.ttlSeconds().orElse(defaultTtlSeconds); + holder.metadata = resolved.metadata().orElse(null); + holder.createdAtMs = nowSeconds * 1000; + holders.put(holder.id, holder); + + if (idempotencyKey != null) { + idempotency.put(idempotencyKey, holder.id); + } + + return toHolder(holder); + } + + private void giveBack(StoredHolder holder, ResourceHolderStatus status) { + StoredResource resource = requireResource(holder.resourceKey); + resource.availableAmount += holder.amount; + resource.pendingCount -= holder.amount; + holder.status = status; + } + + private void guard(MockMethod method) { + if (closed) { + throw new CaerusError("This InMemoryCaerusClient has been closed"); + } + Deque queue = failures.get(method); + CaerusError failure = queue == null ? null : queue.pollFirst(); + if (failure != null) { + throw failure; + } + } + + private StoredResource requireResource(String key) { + StoredResource resource = resources.get(key); + if (resource == null) { + throw new ResourceNotFoundError("Resource not found: " + key); + } + return resource; + } + + private StoredHolder requireHolder(String id) { + Validation.requireText(id, "resourceHolderId"); + StoredHolder holder = holders.get(id); + if (holder == null) { + throw new ResourceNotFoundError("ResourceHolder not found: " + id); + } + return holder; + } + + private Resource toResource(StoredResource resource) { + return new Resource( + resource.id, + resource.key, + resource.templateId, + resource.availableAmount, + resource.pendingCount, + Optional.ofNullable(resource.groupKey), + Optional.ofNullable(resource.metadata), + Optional.of(Instant.ofEpochMilli(resource.createdAtMs)), + Optional.of(Instant.ofEpochMilli(resource.updatedAtMs))); + } + + private ResourceHolder toHolder(StoredHolder holder) { + return new ResourceHolder( + holder.id, + holder.resourceId, + holder.status, + holder.amount, + Instant.ofEpochSecond(holder.expiresAtSeconds), + Optional.ofNullable(holder.metadata), + Optional.of(Instant.ofEpochMilli(holder.createdAtMs))); + } + + private String nextId(String prefix) { + sequence += 1; + return prefix + "-" + sequence; + } + + private static List slice(List items, int page, int pageSize) { + long start = (long) page * pageSize; + if (start >= items.size() || start < 0 || pageSize <= 0) { + return Collections.emptyList(); + } + long end = Math.min(items.size(), start + pageSize); + return items.subList((int) start, (int) end); + } + + private static boolean hasNextPage(int total, int page, int pageSize) { + return (long) page * pageSize + pageSize < total; + } + + private static final class StoredResource { + private String id; + private String key; + private String templateId; + private int availableAmount; + private int pendingCount; + private String groupKey; + private Map metadata; + private long createdAtMs; + private long updatedAtMs; + } + + private static final class StoredHolder { + private String id; + private String resourceKey; + private String resourceId; + private int amount; + private ResourceHolderStatus status; + private long expiresAtSeconds; + private Map metadata; + private long createdAtMs; + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ListResourceHoldersOptions.java b/src/main/java/dev/caerus/sdk/sre/ListResourceHoldersOptions.java new file mode 100644 index 0000000..cf0aaa1 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ListResourceHoldersOptions.java @@ -0,0 +1,91 @@ +package dev.caerus.sdk.sre; + +import java.util.Optional; + +public final class ListResourceHoldersOptions { + + private static final ListResourceHoldersOptions NONE = builder().build(); + + private final String resourceKey; + private final ResourceHolderStatus status; + private final HolderSort sort; + private final Integer page; + private final Integer pageSize; + + private ListResourceHoldersOptions(Builder builder) { + this.resourceKey = builder.resourceKey; + this.status = builder.status; + this.sort = builder.sort; + this.page = builder.page; + this.pageSize = builder.pageSize; + } + + public static ListResourceHoldersOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional resourceKey() { + return Optional.ofNullable(resourceKey); + } + + public Optional status() { + return Optional.ofNullable(status); + } + + public Optional sort() { + return Optional.ofNullable(sort); + } + + public Optional page() { + return Optional.ofNullable(page); + } + + public Optional pageSize() { + return Optional.ofNullable(pageSize); + } + + public static final class Builder { + + private String resourceKey; + private ResourceHolderStatus status; + private HolderSort sort; + private Integer page; + private Integer pageSize; + + private Builder() { + } + + public Builder resourceKey(String resourceKey) { + this.resourceKey = resourceKey; + return this; + } + + public Builder status(ResourceHolderStatus status) { + this.status = status; + return this; + } + + public Builder sort(HolderSort sort) { + this.sort = sort; + return this; + } + + public Builder page(Integer page) { + this.page = page; + return this; + } + + public Builder pageSize(Integer pageSize) { + this.pageSize = pageSize; + return this; + } + + public ListResourceHoldersOptions build() { + return new ListResourceHoldersOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/MockMethod.java b/src/main/java/dev/caerus/sdk/sre/MockMethod.java new file mode 100644 index 0000000..7ff3e0b --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/MockMethod.java @@ -0,0 +1,17 @@ +package dev.caerus.sdk.sre; + +public enum MockMethod { + CREATE_UNITARY, + CREATE_MULTIPLE, + UPDATE_RESOURCE, + DELETE_RESOURCE, + TAKE, + TAKE_MANY, + CONFIRM, + RELEASE, + EXTEND, + GET_RESOURCE, + GET_RESOURCES_BY_GROUP, + GET_RESOURCE_HOLDER, + LIST_RESOURCE_HOLDERS +} diff --git a/src/main/java/dev/caerus/sdk/sre/MockOptions.java b/src/main/java/dev/caerus/sdk/sre/MockOptions.java new file mode 100644 index 0000000..94352f3 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/MockOptions.java @@ -0,0 +1,68 @@ +package dev.caerus.sdk.sre; + +import java.util.List; +import java.util.Optional; + +public final class MockOptions { + + private static final MockOptions NONE = builder().build(); + + private final List resources; + private final Integer defaultTtlSeconds; + private final Integer defaultPageSize; + + private MockOptions(Builder builder) { + this.resources = builder.resources; + this.defaultTtlSeconds = builder.defaultTtlSeconds; + this.defaultPageSize = builder.defaultPageSize; + } + + public static MockOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional> resources() { + return Optional.ofNullable(resources); + } + + public Optional defaultTtlSeconds() { + return Optional.ofNullable(defaultTtlSeconds); + } + + public Optional defaultPageSize() { + return Optional.ofNullable(defaultPageSize); + } + + public static final class Builder { + + private List resources; + private Integer defaultTtlSeconds; + private Integer defaultPageSize; + + private Builder() { + } + + public Builder resources(List resources) { + this.resources = resources; + return this; + } + + public Builder defaultTtlSeconds(Integer defaultTtlSeconds) { + this.defaultTtlSeconds = defaultTtlSeconds; + return this; + } + + public Builder defaultPageSize(Integer defaultPageSize) { + this.defaultPageSize = defaultPageSize; + return this; + } + + public MockOptions build() { + return new MockOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/MockResourceSeed.java b/src/main/java/dev/caerus/sdk/sre/MockResourceSeed.java new file mode 100644 index 0000000..713f3dd --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/MockResourceSeed.java @@ -0,0 +1,84 @@ +package dev.caerus.sdk.sre; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +public final class MockResourceSeed { + + private final String key; + private final int availableAmount; + private final String templateId; + private final String groupKey; + private final Map metadata; + + private MockResourceSeed(Builder builder) { + this.key = builder.key; + this.availableAmount = builder.availableAmount; + this.templateId = builder.templateId; + this.groupKey = builder.groupKey; + this.metadata = builder.metadata == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.metadata)); + } + + public static MockResourceSeed of(String key, int availableAmount) { + return builder(key, availableAmount).build(); + } + + public static Builder builder(String key, int availableAmount) { + return new Builder(key, availableAmount); + } + + public String key() { + return key; + } + + public int availableAmount() { + return availableAmount; + } + + public Optional templateId() { + return Optional.ofNullable(templateId); + } + + public Optional groupKey() { + return Optional.ofNullable(groupKey); + } + + public Optional> metadata() { + return Optional.ofNullable(metadata); + } + + public static final class Builder { + + private final String key; + private final int availableAmount; + private String templateId; + private String groupKey; + private Map metadata; + + private Builder(String key, int availableAmount) { + this.key = key; + this.availableAmount = availableAmount; + } + + public Builder templateId(String templateId) { + this.templateId = templateId; + return this; + } + + public Builder groupKey(String groupKey) { + this.groupKey = groupKey; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public MockResourceSeed build() { + return new MockResourceSeed(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/MockSnapshot.java b/src/main/java/dev/caerus/sdk/sre/MockSnapshot.java new file mode 100644 index 0000000..d0b5126 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/MockSnapshot.java @@ -0,0 +1,11 @@ +package dev.caerus.sdk.sre; + +import java.util.List; + +public record MockSnapshot(List resources, List holders) { + + public MockSnapshot { + resources = List.copyOf(resources); + holders = List.copyOf(holders); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/OutOfStockError.java b/src/main/java/dev/caerus/sdk/sre/OutOfStockError.java new file mode 100644 index 0000000..ee4f10a --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/OutOfStockError.java @@ -0,0 +1,14 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusErrorOptions; + +public class OutOfStockError extends ConflictError { + + public OutOfStockError(String message) { + super(message); + } + + public OutOfStockError(String message, CaerusErrorOptions options) { + super(message, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/PooledResource.java b/src/main/java/dev/caerus/sdk/sre/PooledResource.java new file mode 100644 index 0000000..b66bbc7 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/PooledResource.java @@ -0,0 +1,18 @@ +package dev.caerus.sdk.sre; + +public interface PooledResource { + + String key(); + + default ResourceHolder take() { + return take(TakeOptions.none()); + } + + ResourceHolder take(TakeOptions options); + + default ResourceHolder takeMany(int amount) { + return takeMany(amount, TakeOptions.none()); + } + + ResourceHolder takeMany(int amount, TakeOptions options); +} diff --git a/src/main/java/dev/caerus/sdk/sre/Resource.java b/src/main/java/dev/caerus/sdk/sre/Resource.java new file mode 100644 index 0000000..b04cde4 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/Resource.java @@ -0,0 +1,17 @@ +package dev.caerus.sdk.sre; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +public record Resource( + String id, + String key, + String templateId, + int availableAmount, + int pendingCount, + Optional groupKey, + Optional> metadata, + Optional createdAt, + Optional updatedAt) { +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceHasActiveHoldsError.java b/src/main/java/dev/caerus/sdk/sre/ResourceHasActiveHoldsError.java new file mode 100644 index 0000000..41643f5 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceHasActiveHoldsError.java @@ -0,0 +1,14 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusErrorOptions; + +public class ResourceHasActiveHoldsError extends ConflictError { + + public ResourceHasActiveHoldsError(String message) { + super(message); + } + + public ResourceHasActiveHoldsError(String message, CaerusErrorOptions options) { + super(message, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceHasQueuedRequestsError.java b/src/main/java/dev/caerus/sdk/sre/ResourceHasQueuedRequestsError.java new file mode 100644 index 0000000..fb9d3fc --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceHasQueuedRequestsError.java @@ -0,0 +1,14 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusErrorOptions; + +public class ResourceHasQueuedRequestsError extends ConflictError { + + public ResourceHasQueuedRequestsError(String message) { + super(message); + } + + public ResourceHasQueuedRequestsError(String message, CaerusErrorOptions options) { + super(message, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceHolder.java b/src/main/java/dev/caerus/sdk/sre/ResourceHolder.java new file mode 100644 index 0000000..069bd43 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceHolder.java @@ -0,0 +1,15 @@ +package dev.caerus.sdk.sre; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +public record ResourceHolder( + String id, + String resourceId, + ResourceHolderStatus status, + int amount, + Instant expiresAt, + Optional> metadata, + Optional createdAt) { +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceHolderPage.java b/src/main/java/dev/caerus/sdk/sre/ResourceHolderPage.java new file mode 100644 index 0000000..26ce6df --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceHolderPage.java @@ -0,0 +1,10 @@ +package dev.caerus.sdk.sre; + +import java.util.List; + +public record ResourceHolderPage(List holders, boolean hasNextPage) { + + public ResourceHolderPage { + holders = List.copyOf(holders); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceHolderStatus.java b/src/main/java/dev/caerus/sdk/sre/ResourceHolderStatus.java new file mode 100644 index 0000000..f83df35 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceHolderStatus.java @@ -0,0 +1,9 @@ +package dev.caerus.sdk.sre; + +public enum ResourceHolderStatus { + PENDING, + CONFIRMED, + RELEASED, + EXPIRED, + QUEUED +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourceNotFoundError.java b/src/main/java/dev/caerus/sdk/sre/ResourceNotFoundError.java new file mode 100644 index 0000000..2c37133 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourceNotFoundError.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 ResourceNotFoundError extends CaerusError { + + public ResourceNotFoundError(String message) { + super(message, ErrorCode.RESOURCE_NOT_FOUND, CaerusErrorOptions.none()); + } + + public ResourceNotFoundError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.RESOURCE_NOT_FOUND, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ResourcePage.java b/src/main/java/dev/caerus/sdk/sre/ResourcePage.java new file mode 100644 index 0000000..fe7df2e --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ResourcePage.java @@ -0,0 +1,10 @@ +package dev.caerus.sdk.sre; + +import java.util.List; + +public record ResourcePage(List resources, boolean hasNextPage) { + + public ResourcePage { + resources = List.copyOf(resources); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/SharedResourceApi.java b/src/main/java/dev/caerus/sdk/sre/SharedResourceApi.java new file mode 100644 index 0000000..8144a57 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/SharedResourceApi.java @@ -0,0 +1,57 @@ +package dev.caerus.sdk.sre; + +public interface SharedResourceApi extends AutoCloseable { + + default Resource createUnitary(String templateName, String key) { + return createUnitary(templateName, key, CreateResourceOptions.none()); + } + + Resource createUnitary(String templateName, String key, CreateResourceOptions options); + + default Resource createMultiple(String templateName, String key, int availableAmount) { + return createMultiple(templateName, key, availableAmount, CreateResourceOptions.none()); + } + + Resource createMultiple(String templateName, String key, int availableAmount, CreateResourceOptions options); + + default Resource updateResource(String key, int deltaAmount) { + return updateResource(key, deltaAmount, UpdateResourceOptions.none()); + } + + Resource updateResource(String key, int deltaAmount, UpdateResourceOptions options); + + void deleteResource(String key); + + UnitaryResource unitary(String key); + + PooledResource pooled(String key); + + default ResourceHolder confirm(String resourceHolderId) { + return confirm(resourceHolderId, ConfirmOptions.none()); + } + + ResourceHolder confirm(String resourceHolderId, ConfirmOptions options); + + void release(String resourceHolderId); + + ResourceHolder extend(String resourceHolderId, int extraMs); + + ResourceHolder getResourceHolder(String resourceHolderId); + + Resource getResource(String key); + + default ResourcePage getResourcesByGroup(String groupKey) { + return getResourcesByGroup(groupKey, GetResourcesByGroupOptions.none()); + } + + ResourcePage getResourcesByGroup(String groupKey, GetResourcesByGroupOptions options); + + default ResourceHolderPage listResourceHolders() { + return listResourceHolders(ListResourceHoldersOptions.none()); + } + + ResourceHolderPage listResourceHolders(ListResourceHoldersOptions options); + + @Override + void close(); +} diff --git a/src/main/java/dev/caerus/sdk/sre/SreCore.java b/src/main/java/dev/caerus/sdk/sre/SreCore.java new file mode 100644 index 0000000..18cc942 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/SreCore.java @@ -0,0 +1,188 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.internal.ClientSettings; +import dev.caerus.sdk.internal.GrpcTransport; +import dev.caerus.sdk.internal.proto.sre.ConfirmRequest; +import dev.caerus.sdk.internal.proto.sre.CreateResourceRequest; +import dev.caerus.sdk.internal.proto.sre.DeleteResourceRequest; +import dev.caerus.sdk.internal.proto.sre.ExtendRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHolderRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyRequest; +import dev.caerus.sdk.internal.proto.sre.ReleaseRequest; +import dev.caerus.sdk.internal.proto.sre.SharedResourceEngineGrpc; +import dev.caerus.sdk.internal.proto.sre.TakeOptionalSettings; +import dev.caerus.sdk.internal.proto.sre.TakeRequest; +import dev.caerus.sdk.internal.proto.sre.UpdateResourceRequest; +import io.grpc.MethodDescriptor; + +import java.util.concurrent.CompletableFuture; + +final class SreCore { + + private final GrpcTransport transport; + + SreCore(ClientSettings settings) { + this.transport = new GrpcTransport(settings); + } + + CompletableFuture createUnitary(String templateName, String key, CreateResourceOptions options) { + return createResource(templateName, key, 1, options); + } + + CompletableFuture createMultiple( + String templateName, String key, int availableAmount, CreateResourceOptions options) { + Validation.requirePositive(availableAmount, "availableAmount"); + return createResource(templateName, key, availableAmount, options); + } + + private CompletableFuture createResource( + String templateName, String key, int availableAmount, CreateResourceOptions options) { + Validation.requireText(templateName, "templateName"); + Validation.requireText(key, "key"); + CreateResourceOptions resolved = options == null ? CreateResourceOptions.none() : options; + + CreateResourceRequest.Builder request = CreateResourceRequest.newBuilder() + .setTemplateName(templateName) + .setKey(key) + .setAvailableAmount(availableAmount); + resolved.groupKey().ifPresent(request::setGroupKey); + SreMapping.encodeMetadata(resolved.metadata()).ifPresent(request::setMetadata); + + return call(SharedResourceEngineGrpc.getCreateResourceMethod(), request.build()) + .thenApply(SreMapping::toResource); + } + + CompletableFuture updateResource(String key, int deltaAmount, UpdateResourceOptions options) { + Validation.requireText(key, "key"); + UpdateResourceOptions resolved = options == null ? UpdateResourceOptions.none() : options; + + UpdateResourceRequest.Builder request = UpdateResourceRequest.newBuilder() + .setResourceKey(key) + .setDeltaAmount(deltaAmount); + resolved.groupKey().ifPresent(request::setGroupKey); + SreMapping.encodeMetadata(resolved.metadata()).ifPresent(request::setMetadata); + resolved.idempotencyKey().ifPresent(request::setIdempotencyKey); + + return call(SharedResourceEngineGrpc.getUpdateResourceMethod(), request.build()) + .thenApply(SreMapping::toResource); + } + + CompletableFuture deleteResource(String key) { + Validation.requireText(key, "key"); + return call(SharedResourceEngineGrpc.getDeleteResourceMethod(), + DeleteResourceRequest.newBuilder().setKey(key).build()) + .thenApply(ignored -> null); + } + + CompletableFuture take(String resourceKey, int amount, TakeOptions options) { + TakeOptions resolved = options == null ? TakeOptions.none() : options; + resolved.ttlSeconds().ifPresent(ttl -> Validation.requirePositive(ttl, "ttlSeconds")); + + TakeOptionalSettings.Builder settings = TakeOptionalSettings.newBuilder(); + resolved.idempotencyKey().ifPresent(settings::setIdempotencyKey); + resolved.ttlSeconds().ifPresent(settings::setCustomTtlSeconds); + SreMapping.encodeMetadata(resolved.metadata()).ifPresent(settings::setMetadata); + + TakeRequest request = TakeRequest.newBuilder() + .setResourceKey(resourceKey) + .setAmount(amount) + .setSettings(settings) + .build(); + + return call(SharedResourceEngineGrpc.getTakeMethod(), request) + .thenApply(response -> SreMapping.assertUsable( + SreMapping.toResourceHolder(response), + ResourceHolderStatus.PENDING, + ResourceHolderStatus.QUEUED)); + } + + CompletableFuture confirm(String resourceHolderId, ConfirmOptions options) { + Validation.requireText(resourceHolderId, "resourceHolderId"); + ConfirmOptions resolved = options == null ? ConfirmOptions.none() : options; + + ConfirmRequest.Builder request = ConfirmRequest.newBuilder().setResourceHolderId(resourceHolderId); + SreMapping.encodeMetadata(resolved.metadata()).ifPresent(request::setMetadataPatch); + + return call(SharedResourceEngineGrpc.getConfirmMethod(), request.build()) + .thenApply(response -> SreMapping.assertUsable( + SreMapping.toResourceHolder(response), + ResourceHolderStatus.CONFIRMED)); + } + + CompletableFuture release(String resourceHolderId) { + Validation.requireText(resourceHolderId, "resourceHolderId"); + return call(SharedResourceEngineGrpc.getReleaseMethod(), + ReleaseRequest.newBuilder().setResourceHolderId(resourceHolderId).build()) + .thenApply(ignored -> null); + } + + CompletableFuture extend(String resourceHolderId, int extraMs) { + Validation.requireText(resourceHolderId, "resourceHolderId"); + Validation.requirePositive(extraMs, "extraMs"); + + ExtendRequest request = ExtendRequest.newBuilder() + .setResourceHolderId(resourceHolderId) + .setExtraMs(extraMs) + .build(); + + return call(SharedResourceEngineGrpc.getExtendMethod(), request) + .thenApply(response -> SreMapping.assertUsable( + SreMapping.toResourceHolder(response), + ResourceHolderStatus.PENDING, + ResourceHolderStatus.QUEUED)); + } + + CompletableFuture getResourceHolder(String resourceHolderId) { + Validation.requireText(resourceHolderId, "resourceHolderId"); + return call(SharedResourceEngineGrpc.getGetResourceHolderMethod(), + GetResourceHolderRequest.newBuilder().setResourceHolderId(resourceHolderId).build()) + .thenApply(SreMapping::toResourceHolder); + } + + CompletableFuture getResource(String key) { + Validation.requireText(key, "key"); + return call(SharedResourceEngineGrpc.getGetResourceMethod(), + GetResourceRequest.newBuilder().setKey(key).build()) + .thenApply(SreMapping::toResource); + } + + CompletableFuture getResourcesByGroup(String groupKey, GetResourcesByGroupOptions options) { + Validation.requireText(groupKey, "groupKey"); + GetResourcesByGroupOptions resolved = options == null ? GetResourcesByGroupOptions.none() : options; + + GetResourcesByGroupKeyRequest.Builder request = GetResourcesByGroupKeyRequest.newBuilder() + .setGroupKey(groupKey) + .setPage(resolved.page().orElse(0)); + resolved.pageSize().ifPresent(request::setPageSize); + + return call(SharedResourceEngineGrpc.getGetResourcesByGroupKeyMethod(), request.build()) + .thenApply(SreMapping::toResourcePage); + } + + CompletableFuture listResourceHolders(ListResourceHoldersOptions options) { + ListResourceHoldersOptions resolved = options == null ? ListResourceHoldersOptions.none() : options; + boolean oldestFirst = resolved.sort().orElse(HolderSort.NEWEST_FIRST) == HolderSort.OLDEST_FIRST; + + GetResourceHoldersListRequest.Builder request = GetResourceHoldersListRequest.newBuilder() + .setPage(resolved.page().orElse(0)) + .setCreatedAtSortDirection(oldestFirst + ? GetResourceHoldersListRequest.SortDirection.ASCENDING + : GetResourceHoldersListRequest.SortDirection.DESCENDING); + resolved.resourceKey().ifPresent(request::setResourceKey); + resolved.pageSize().ifPresent(request::setPageSize); + resolved.status().map(SreMapping::encodeStatus).ifPresent(request::setStatusFilter); + + return call(SharedResourceEngineGrpc.getGetResourceHoldersListMethod(), request.build()) + .thenApply(SreMapping::toResourceHolderPage); + } + + void close() { + transport.close(); + } + + private CompletableFuture call(MethodDescriptor method, Req request) { + return transport.unary(method, request, SreErrors::toCaerusError, SreErrors::closed); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/SreErrors.java b/src/main/java/dev/caerus/sdk/sre/SreErrors.java new file mode 100644 index 0000000..6d48c89 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/SreErrors.java @@ -0,0 +1,78 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +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 SreErrors { + + private SreErrors() { + } + + static CaerusError toCaerusError(Throwable error, String clientRequestId) { + if (error instanceof CaerusError caerus) { + return caerus; + } + + String message = ErrorDetails.messageOf(error, "Caerus 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(); + + Status.Code code = ErrorDetails.codeOf(error); + if (code == null) { + return new CaerusError(message, ErrorCode.UNKNOWN, options); + } + switch (code) { + case NOT_FOUND: + return new ResourceNotFoundError(message, options); + case FAILED_PRECONDITION: + return conflict(reason, message, options); + case INVALID_ARGUMENT: + return new ValidationError(message, options); + case UNAUTHENTICATED: + return new AuthenticationError(message, options); + case DEADLINE_EXCEEDED: + return new TimeoutError(message, options); + default: + return new CaerusError(message, ErrorCode.UNKNOWN, options); + } + } + + static CaerusError closed() { + return new CaerusError("This CaerusClient has been closed"); + } + + static CaerusError interrupted() { + return new CaerusError("Interrupted while waiting for Caerus"); + } + + static CaerusError unexpected(Throwable error) { + return toCaerusError(error, null); + } + + private static ConflictError conflict(String reason, String message, CaerusErrorOptions options) { + if (reason == null) { + return new ConflictError(message, options); + } + switch (reason) { + case ErrorReason.OUT_OF_STOCK: + return new OutOfStockError(message, options); + case ErrorReason.HOLDER_NOT_ACTIVE: + return new HolderNotActiveError(message, options); + case ErrorReason.RESOURCE_HAS_ACTIVE_HOLDS: + return new ResourceHasActiveHoldsError(message, options); + case ErrorReason.RESOURCE_HAS_QUEUED_REQUESTS: + return new ResourceHasQueuedRequestsError(message, options); + default: + return new ConflictError(message, options); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/SreMapping.java b/src/main/java/dev/caerus/sdk/sre/SreMapping.java new file mode 100644 index 0000000..5700824 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/SreMapping.java @@ -0,0 +1,136 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.internal.Json; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListResponse; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyResponse; +import dev.caerus.sdk.internal.proto.sre.ResourceHolderResponse; +import dev.caerus.sdk.internal.proto.sre.ResourceResponse; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +final class SreMapping { + + private SreMapping() { + } + + static Optional encodeMetadata(Optional> metadata) { + return metadata.map(Json::stringify); + } + + static Optional> decodeMetadata(String raw, String context) { + if (raw == null || raw.trim().isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of(Json.parseObject(raw)); + } catch (RuntimeException cause) { + throw new CaerusError( + "The metadata stored for " + context + " is not a JSON object, so it cannot be returned as one. " + + "Raw value: " + raw, + ErrorCode.UNKNOWN, + CaerusErrorOptions.builder().cause(cause).build()); + } + } + + static Instant decodeExpiresAt(long epochSeconds) { + return Instant.ofEpochSecond(epochSeconds); + } + + static Optional decodeTimestampMs(long epochMs) { + return epochMs == 0 ? Optional.empty() : Optional.of(Instant.ofEpochMilli(epochMs)); + } + + static ResourceHolderStatus decodeStatus(int wire, String context) { + switch (wire) { + case ResourceHolderResponse.ResourceHolderStatus.PENDING_VALUE: + return ResourceHolderStatus.PENDING; + case ResourceHolderResponse.ResourceHolderStatus.CONFIRMED_VALUE: + return ResourceHolderStatus.CONFIRMED; + case ResourceHolderResponse.ResourceHolderStatus.RELEASED_VALUE: + return ResourceHolderStatus.RELEASED; + case ResourceHolderResponse.ResourceHolderStatus.QUEUED_VALUE: + return ResourceHolderStatus.QUEUED; + case ResourceHolderResponse.ResourceHolderStatus.EXPIRED_VALUE: + return ResourceHolderStatus.EXPIRED; + default: + throw new CaerusError("Caerus reported an unknown status (" + wire + ") for " + context + + ". Update io.github.caerus-dev:caerus-sdk."); + } + } + + static ResourceHolderResponse.ResourceHolderStatus encodeStatus(ResourceHolderStatus status) { + switch (status) { + case PENDING: + return ResourceHolderResponse.ResourceHolderStatus.PENDING; + case CONFIRMED: + return ResourceHolderResponse.ResourceHolderStatus.CONFIRMED; + case RELEASED: + return ResourceHolderResponse.ResourceHolderStatus.RELEASED; + case QUEUED: + return ResourceHolderResponse.ResourceHolderStatus.QUEUED; + case EXPIRED: + return ResourceHolderResponse.ResourceHolderStatus.EXPIRED; + default: + throw new IllegalArgumentException("Unknown status " + status); + } + } + + static ResourceHolder toResourceHolder(ResourceHolderResponse response) { + String context = "holder " + response.getHolderId(); + return new ResourceHolder( + response.getHolderId(), + response.getResourceId(), + decodeStatus(response.getStatusValue(), context), + response.getAmount(), + decodeExpiresAt(response.getExpiresAt()), + decodeMetadata(response.hasMetadata() ? response.getMetadata() : null, context), + decodeTimestampMs(response.getCreatedAtMs())); + } + + static Resource toResource(ResourceResponse response) { + Optional groupKey = response.hasGroupKey() && !response.getGroupKey().isEmpty() + ? Optional.of(response.getGroupKey()) + : Optional.empty(); + return new Resource( + response.getResourceId(), + response.getKey(), + response.getTemplateId(), + response.getAvailableAmount(), + response.getPendingCount(), + groupKey, + decodeMetadata(response.hasMetadata() ? response.getMetadata() : null, "resource " + response.getKey()), + decodeTimestampMs(response.getCreatedAtMs()), + decodeTimestampMs(response.getUpdatedAtMs())); + } + + static ResourcePage toResourcePage(GetResourcesByGroupKeyResponse response) { + List resources = response.getResourcesList().stream() + .map(SreMapping::toResource) + .collect(Collectors.toList()); + return new ResourcePage(resources, response.getNextPage()); + } + + static ResourceHolderPage toResourceHolderPage(GetResourceHoldersListResponse response) { + List holders = response.getResourceHoldersList().stream() + .map(SreMapping::toResourceHolder) + .collect(Collectors.toList()); + return new ResourceHolderPage(holders, response.getNextPage()); + } + + static ResourceHolder assertUsable(ResourceHolder holder, ResourceHolderStatus... usable) { + for (ResourceHolderStatus status : usable) { + if (holder.status() == status) { + return holder; + } + } + throw new ConflictError("Caerus returned holder " + holder.id() + " as " + holder.status() + + ", which this call cannot use."); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/TakeOptions.java b/src/main/java/dev/caerus/sdk/sre/TakeOptions.java new file mode 100644 index 0000000..d737345 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/TakeOptions.java @@ -0,0 +1,70 @@ +package dev.caerus.sdk.sre; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +public final class TakeOptions { + + private static final TakeOptions NONE = builder().build(); + + private final String idempotencyKey; + private final Integer ttlSeconds; + private final Map metadata; + + private TakeOptions(Builder builder) { + this.idempotencyKey = builder.idempotencyKey; + this.ttlSeconds = builder.ttlSeconds; + this.metadata = builder.metadata == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.metadata)); + } + + public static TakeOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional idempotencyKey() { + return Optional.ofNullable(idempotencyKey); + } + + public Optional ttlSeconds() { + return Optional.ofNullable(ttlSeconds); + } + + public Optional> metadata() { + return Optional.ofNullable(metadata); + } + + public static final class Builder { + + private String idempotencyKey; + private Integer ttlSeconds; + private Map metadata; + + private Builder() { + } + + public Builder idempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + public Builder ttlSeconds(Integer ttlSeconds) { + this.ttlSeconds = ttlSeconds; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public TakeOptions build() { + return new TakeOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/TimeoutError.java b/src/main/java/dev/caerus/sdk/sre/TimeoutError.java new file mode 100644 index 0000000..faeacc9 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/TimeoutError.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 TimeoutError extends CaerusError { + + public TimeoutError(String message) { + super(message, ErrorCode.TIMEOUT, CaerusErrorOptions.none()); + } + + public TimeoutError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.TIMEOUT, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/UnitaryResource.java b/src/main/java/dev/caerus/sdk/sre/UnitaryResource.java new file mode 100644 index 0000000..fb1bd16 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/UnitaryResource.java @@ -0,0 +1,12 @@ +package dev.caerus.sdk.sre; + +public interface UnitaryResource { + + String key(); + + default ResourceHolder take() { + return take(TakeOptions.none()); + } + + ResourceHolder take(TakeOptions options); +} diff --git a/src/main/java/dev/caerus/sdk/sre/UpdateResourceOptions.java b/src/main/java/dev/caerus/sdk/sre/UpdateResourceOptions.java new file mode 100644 index 0000000..8dce599 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/UpdateResourceOptions.java @@ -0,0 +1,70 @@ +package dev.caerus.sdk.sre; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +public final class UpdateResourceOptions { + + private static final UpdateResourceOptions NONE = builder().build(); + + private final String groupKey; + private final Map metadata; + private final String idempotencyKey; + + private UpdateResourceOptions(Builder builder) { + this.groupKey = builder.groupKey; + this.metadata = builder.metadata == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.metadata)); + this.idempotencyKey = builder.idempotencyKey; + } + + public static UpdateResourceOptions none() { + return NONE; + } + + public static Builder builder() { + return new Builder(); + } + + public Optional groupKey() { + return Optional.ofNullable(groupKey); + } + + public Optional> metadata() { + return Optional.ofNullable(metadata); + } + + public Optional idempotencyKey() { + return Optional.ofNullable(idempotencyKey); + } + + public static final class Builder { + + private String groupKey; + private Map metadata; + private String idempotencyKey; + + private Builder() { + } + + public Builder groupKey(String groupKey) { + this.groupKey = groupKey; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder idempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + public UpdateResourceOptions build() { + return new UpdateResourceOptions(this); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/Validation.java b/src/main/java/dev/caerus/sdk/sre/Validation.java new file mode 100644 index 0000000..36414d1 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/Validation.java @@ -0,0 +1,25 @@ +package dev.caerus.sdk.sre; + +final class Validation { + + private Validation() { + } + + static void requireText(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new ValidationError(name + " is required"); + } + } + + static void requirePositive(long value, String name) { + if (value <= 0) { + throw new ValidationError(name + " must be greater than zero"); + } + } + + static void requireKey(String key) { + if (key == null || key.trim().isEmpty()) { + throw new ValidationError("a resource key is required"); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/sre/ValidationError.java b/src/main/java/dev/caerus/sdk/sre/ValidationError.java new file mode 100644 index 0000000..f36c8b0 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/sre/ValidationError.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 ValidationError extends CaerusError { + + public ValidationError(String message) { + super(message, ErrorCode.VALIDATION, CaerusErrorOptions.none()); + } + + public ValidationError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.VALIDATION, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/CaerusEvent.java b/src/main/java/dev/caerus/sdk/webhooks/CaerusEvent.java new file mode 100644 index 0000000..e65c145 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/CaerusEvent.java @@ -0,0 +1,16 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Map; + +public record CaerusEvent( + String id, + String eventId, + String product, + String objectType, + String objectId, + String environmentId, + String occurredAt, + String eventType, + EventData data, + Map raw) { +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/CaerusSignatureError.java b/src/main/java/dev/caerus/sdk/webhooks/CaerusSignatureError.java new file mode 100644 index 0000000..7bcdbb1 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/CaerusSignatureError.java @@ -0,0 +1,16 @@ +package dev.caerus.sdk.webhooks; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; + +public class CaerusSignatureError extends CaerusError { + + public CaerusSignatureError(String message) { + super(message, ErrorCode.VALIDATION, CaerusErrorOptions.none()); + } + + public CaerusSignatureError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.VALIDATION, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookExpiredError.java b/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookExpiredError.java new file mode 100644 index 0000000..b2292a8 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookExpiredError.java @@ -0,0 +1,16 @@ +package dev.caerus.sdk.webhooks; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; + +public class CaerusWebhookExpiredError extends CaerusError { + + public CaerusWebhookExpiredError(String message) { + super(message, ErrorCode.VALIDATION, CaerusErrorOptions.none()); + } + + public CaerusWebhookExpiredError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.VALIDATION, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookPayloadError.java b/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookPayloadError.java new file mode 100644 index 0000000..f92d309 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookPayloadError.java @@ -0,0 +1,16 @@ +package dev.caerus.sdk.webhooks; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; + +public class CaerusWebhookPayloadError extends CaerusError { + + public CaerusWebhookPayloadError(String message) { + super(message, ErrorCode.VALIDATION, CaerusErrorOptions.none()); + } + + public CaerusWebhookPayloadError(String message, CaerusErrorOptions options) { + super(message, ErrorCode.VALIDATION, options); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/DeadlockDetectedData.java b/src/main/java/dev/caerus/sdk/webhooks/DeadlockDetectedData.java new file mode 100644 index 0000000..649e9da --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/DeadlockDetectedData.java @@ -0,0 +1,29 @@ +package dev.caerus.sdk.webhooks; + +import java.util.List; +import java.util.Optional; + +public record DeadlockDetectedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String victimTransactionId, + List cycleTransactionIds, + String resolutionStrategy, + Optional reason) implements EventData { + + static DeadlockDetectedData from(JsonFields fields) { + return new DeadlockDetectedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("victimTransactionId"), + fields.stringList("cycleTransactionIds"), + fields.string("resolutionStrategy"), + fields.optionalString("reason")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/EventData.java b/src/main/java/dev/caerus/sdk/webhooks/EventData.java new file mode 100644 index 0000000..a8ead41 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/EventData.java @@ -0,0 +1,37 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public sealed interface EventData permits + ResourceCreatedData, + ResourceTakenData, + ResourceConfirmedData, + ResourceReleasedData, + ResourceExtendedData, + ResourceExpiredData, + ResourceUpdatedData, + ResourceDeletedData, + ResourceQueuedData, + ResourceTakeFailedData, + LockAcquiredData, + LockReleasedData, + LockAcquireFailedData, + DeadlockDetectedData, + LockAbandonedData, + TransactionStartedData, + TransactionCompletedData, + TransactionAbortedData, + TransactionRenewedData, + TransactionExpiredData, + UnknownEventData { + + String eventId(); + + String occurredOn(); + + Optional environmentId(); + + Optional apiKeyId(); + + Optional actorId(); +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/EventTypes.java b/src/main/java/dev/caerus/sdk/webhooks/EventTypes.java new file mode 100644 index 0000000..a2848b2 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/EventTypes.java @@ -0,0 +1,57 @@ +package dev.caerus.sdk.webhooks; + +final class EventTypes { + + private EventTypes() { + } + + static EventData decode(String eventType, JsonFields fields) { + if (eventType == null) { + return UnknownEventData.from(fields); + } + switch (eventType) { + case "resource.created": + return ResourceCreatedData.from(fields); + case "resource.taken": + return ResourceTakenData.from(fields); + case "resource.confirmed": + return ResourceConfirmedData.from(fields); + case "resource.released": + return ResourceReleasedData.from(fields); + case "resource.extended": + return ResourceExtendedData.from(fields); + case "resource.expired": + return ResourceExpiredData.from(fields); + case "resource.updated": + return ResourceUpdatedData.from(fields); + case "resource.deleted": + return ResourceDeletedData.from(fields); + case "resource.queued": + return ResourceQueuedData.from(fields); + case "resource.take_failed": + return ResourceTakeFailedData.from(fields); + case "lock.acquired": + return LockAcquiredData.from(fields); + case "lock.released": + return LockReleasedData.from(fields); + case "lock.acquire_failed": + return LockAcquireFailedData.from(fields); + case "lock.deadlock_detected": + return DeadlockDetectedData.from(fields); + case "lock.abandoned": + return LockAbandonedData.from(fields); + case "transaction.started": + return TransactionStartedData.from(fields); + case "transaction.completed": + return TransactionCompletedData.from(fields); + case "transaction.aborted": + return TransactionAbortedData.from(fields); + case "transaction.renewed": + return TransactionRenewedData.from(fields); + case "transaction.expired": + return TransactionExpiredData.from(fields); + default: + return UnknownEventData.from(fields); + } + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/JsonFields.java b/src/main/java/dev/caerus/sdk/webhooks/JsonFields.java new file mode 100644 index 0000000..98522ad --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/JsonFields.java @@ -0,0 +1,87 @@ +package dev.caerus.sdk.webhooks; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import dev.caerus.sdk.internal.Json; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +final class JsonFields { + + private final JsonObject object; + + JsonFields(JsonObject object) { + this.object = object == null ? new JsonObject() : object; + } + + String string(String name) { + return optionalString(name).orElse(null); + } + + Optional optionalString(String name) { + JsonElement value = object.get(name); + if (value == null || value.isJsonNull()) { + return Optional.empty(); + } + if (value.isJsonPrimitive()) { + return Optional.of(value.getAsString()); + } + return Optional.of(value.toString()); + } + + int integer(String name) { + return optionalInteger(name).orElse(0); + } + + Optional optionalInteger(String name) { + return number(name).map(Number::intValue); + } + + long longValue(String name) { + return number(name).map(Number::longValue).orElse(0L); + } + + List stringList(String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonArray()) { + return Collections.emptyList(); + } + JsonArray array = value.getAsJsonArray(); + List items = new ArrayList<>(array.size()); + for (JsonElement item : array) { + if (item != null && !item.isJsonNull()) { + items.add(item.isJsonPrimitive() ? item.getAsString() : item.toString()); + } + } + return Collections.unmodifiableList(items); + } + + Map asMap() { + return Json.toMap(object); + } + + private Optional number(String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive()) { + return Optional.empty(); + } + JsonPrimitive primitive = value.getAsJsonPrimitive(); + if (primitive.isNumber()) { + return Optional.of(primitive.getAsNumber()); + } + if (primitive.isString()) { + try { + return Optional.of(Double.parseDouble(primitive.getAsString().trim())); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/LockAbandonedData.java b/src/main/java/dev/caerus/sdk/webhooks/LockAbandonedData.java new file mode 100644 index 0000000..e715c6a --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/LockAbandonedData.java @@ -0,0 +1,27 @@ +package dev.caerus.sdk.webhooks; + +import java.util.List; +import java.util.Optional; + +public record LockAbandonedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId, + List lockIds, + Optional reason) implements EventData { + + static LockAbandonedData from(JsonFields fields) { + return new LockAbandonedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId"), + fields.stringList("lockIds"), + fields.optionalString("reason")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/LockAcquireFailedData.java b/src/main/java/dev/caerus/sdk/webhooks/LockAcquireFailedData.java new file mode 100644 index 0000000..35fbcb1 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/LockAcquireFailedData.java @@ -0,0 +1,28 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record LockAcquireFailedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String namespace, + String lockKey, + String transactionId, + Optional reason) implements EventData { + + static LockAcquireFailedData from(JsonFields fields) { + return new LockAcquireFailedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("namespace"), + fields.string("lockKey"), + fields.string("transactionId"), + fields.optionalString("reason")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/LockAcquiredData.java b/src/main/java/dev/caerus/sdk/webhooks/LockAcquiredData.java new file mode 100644 index 0000000..074ebd3 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/LockAcquiredData.java @@ -0,0 +1,32 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record LockAcquiredData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String namespace, + String lockKey, + String lockId, + String transactionId, + long fencingToken, + String mode) implements EventData { + + static LockAcquiredData from(JsonFields fields) { + return new LockAcquiredData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("namespace"), + fields.string("lockKey"), + fields.string("lockId"), + fields.string("transactionId"), + fields.longValue("fencingToken"), + fields.string("mode")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/LockReleasedData.java b/src/main/java/dev/caerus/sdk/webhooks/LockReleasedData.java new file mode 100644 index 0000000..bc519c6 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/LockReleasedData.java @@ -0,0 +1,28 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record LockReleasedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String namespace, + String lockKey, + String lockId, + String transactionId) implements EventData { + + static LockReleasedData from(JsonFields fields) { + return new LockReleasedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("namespace"), + fields.string("lockKey"), + fields.string("lockId"), + fields.string("transactionId")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceConfirmedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceConfirmedData.java new file mode 100644 index 0000000..f46f108 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceConfirmedData.java @@ -0,0 +1,32 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceConfirmedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + String resourceKey, + int amount, + long expiresAt, + long createdAtMs, + Optional metadataPatch) implements EventData { + + static ResourceConfirmedData from(JsonFields fields) { + return new ResourceConfirmedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.longValue("expiresAt"), + fields.longValue("createdAtMs"), + fields.optionalString("metadataPatch")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceCreatedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceCreatedData.java new file mode 100644 index 0000000..7c1d906 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceCreatedData.java @@ -0,0 +1,32 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceCreatedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String resourceKey, + Optional templateId, + int availableAmount, + Optional groupKey, + Optional metadata, + long createdAtMs) implements EventData { + + static ResourceCreatedData from(JsonFields fields) { + return new ResourceCreatedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("resourceKey"), + fields.optionalString("templateId"), + fields.integer("availableAmount"), + fields.optionalString("groupKey"), + fields.optionalString("metadata"), + fields.longValue("createdAtMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceDeletedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceDeletedData.java new file mode 100644 index 0000000..abe8cff --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceDeletedData.java @@ -0,0 +1,22 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceDeletedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String resourceKey) implements EventData { + + static ResourceDeletedData from(JsonFields fields) { + return new ResourceDeletedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("resourceKey")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceExpiredData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceExpiredData.java new file mode 100644 index 0000000..132f3c4 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceExpiredData.java @@ -0,0 +1,30 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceExpiredData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + String resourceKey, + int amount, + long expiresAt, + long createdAtMs) implements EventData { + + static ResourceExpiredData from(JsonFields fields) { + return new ResourceExpiredData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.longValue("expiresAt"), + fields.longValue("createdAtMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceExtendedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceExtendedData.java new file mode 100644 index 0000000..71c0814 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceExtendedData.java @@ -0,0 +1,24 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceExtendedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + long expiresAt) implements EventData { + + static ResourceExtendedData from(JsonFields fields) { + return new ResourceExtendedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.longValue("expiresAt")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceQueuedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceQueuedData.java new file mode 100644 index 0000000..cb30450 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceQueuedData.java @@ -0,0 +1,34 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceQueuedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + String resourceKey, + int amount, + long expiresAt, + long createdAtMs, + Optional metadata, + Optional idempotencyKey) implements EventData { + + static ResourceQueuedData from(JsonFields fields) { + return new ResourceQueuedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.longValue("expiresAt"), + fields.longValue("createdAtMs"), + fields.optionalString("metadata"), + fields.optionalString("idempotencyKey")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceReleasedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceReleasedData.java new file mode 100644 index 0000000..0804b58 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceReleasedData.java @@ -0,0 +1,30 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceReleasedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + String resourceKey, + int amount, + long expiresAt, + long createdAtMs) implements EventData { + + static ResourceReleasedData from(JsonFields fields) { + return new ResourceReleasedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.longValue("expiresAt"), + fields.longValue("createdAtMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceTakeFailedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceTakeFailedData.java new file mode 100644 index 0000000..3dd53af --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceTakeFailedData.java @@ -0,0 +1,30 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceTakeFailedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + Optional resourceId, + String resourceKey, + int amount, + Optional reason, + long createdAtMs) implements EventData { + + static ResourceTakeFailedData from(JsonFields fields) { + return new ResourceTakeFailedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.optionalString("resourceId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.optionalString("reason"), + fields.longValue("createdAtMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceTakenData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceTakenData.java new file mode 100644 index 0000000..03c7d9a --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceTakenData.java @@ -0,0 +1,34 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceTakenData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String holderId, + String resourceKey, + int amount, + long expiresAt, + long createdAtMs, + Optional metadata, + Optional idempotencyKey) implements EventData { + + static ResourceTakenData from(JsonFields fields) { + return new ResourceTakenData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("holderId"), + fields.string("resourceKey"), + fields.integer("amount"), + fields.longValue("expiresAt"), + fields.longValue("createdAtMs"), + fields.optionalString("metadata"), + fields.optionalString("idempotencyKey")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/ResourceUpdatedData.java b/src/main/java/dev/caerus/sdk/webhooks/ResourceUpdatedData.java new file mode 100644 index 0000000..5358cfa --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/ResourceUpdatedData.java @@ -0,0 +1,30 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record ResourceUpdatedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String resourceKey, + Optional deltaAmount, + Optional groupKey, + Optional metadata, + long updatedAtMs) implements EventData { + + static ResourceUpdatedData from(JsonFields fields) { + return new ResourceUpdatedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("resourceKey"), + fields.optionalInteger("deltaAmount"), + fields.optionalString("groupKey"), + fields.optionalString("metadata"), + fields.longValue("updatedAtMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/TransactionAbortedData.java b/src/main/java/dev/caerus/sdk/webhooks/TransactionAbortedData.java new file mode 100644 index 0000000..57cee37 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/TransactionAbortedData.java @@ -0,0 +1,24 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record TransactionAbortedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId, + Optional reason) implements EventData { + + static TransactionAbortedData from(JsonFields fields) { + return new TransactionAbortedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId"), + fields.optionalString("reason")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/TransactionCompletedData.java b/src/main/java/dev/caerus/sdk/webhooks/TransactionCompletedData.java new file mode 100644 index 0000000..def6ca3 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/TransactionCompletedData.java @@ -0,0 +1,22 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record TransactionCompletedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId) implements EventData { + + static TransactionCompletedData from(JsonFields fields) { + return new TransactionCompletedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/TransactionExpiredData.java b/src/main/java/dev/caerus/sdk/webhooks/TransactionExpiredData.java new file mode 100644 index 0000000..f930aed --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/TransactionExpiredData.java @@ -0,0 +1,24 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record TransactionExpiredData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId, + Optional reason) implements EventData { + + static TransactionExpiredData from(JsonFields fields) { + return new TransactionExpiredData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId"), + fields.optionalString("reason")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/TransactionRenewedData.java b/src/main/java/dev/caerus/sdk/webhooks/TransactionRenewedData.java new file mode 100644 index 0000000..10b89cd --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/TransactionRenewedData.java @@ -0,0 +1,24 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record TransactionRenewedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId, + long newLifetimeMs) implements EventData { + + static TransactionRenewedData from(JsonFields fields) { + return new TransactionRenewedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId"), + fields.longValue("newLifetimeMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/TransactionStartedData.java b/src/main/java/dev/caerus/sdk/webhooks/TransactionStartedData.java new file mode 100644 index 0000000..ecca633 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/TransactionStartedData.java @@ -0,0 +1,24 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Optional; + +public record TransactionStartedData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + String transactionId, + long transactionTimeoutMs) implements EventData { + + static TransactionStartedData from(JsonFields fields) { + return new TransactionStartedData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.string("transactionId"), + fields.longValue("transactionTimeoutMs")); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/UnknownEventData.java b/src/main/java/dev/caerus/sdk/webhooks/UnknownEventData.java new file mode 100644 index 0000000..9adffe1 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/UnknownEventData.java @@ -0,0 +1,23 @@ +package dev.caerus.sdk.webhooks; + +import java.util.Map; +import java.util.Optional; + +public record UnknownEventData( + String eventId, + String occurredOn, + Optional environmentId, + Optional apiKeyId, + Optional actorId, + Map fields) implements EventData { + + static UnknownEventData from(JsonFields fields) { + return new UnknownEventData( + fields.string("eventId"), + fields.string("occurredOn"), + fields.optionalString("environmentId"), + fields.optionalString("apiKeyId"), + fields.optionalString("actorId"), + fields.asMap()); + } +} diff --git a/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java b/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java new file mode 100644 index 0000000..01cc61e --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java @@ -0,0 +1,187 @@ +package dev.caerus.sdk.webhooks; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.internal.Json; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Clock; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +public final class Webhooks { + + public static final long DEFAULT_TOLERANCE_SECONDS = 300; + + private final Clock clock; + + public Webhooks() { + this(Clock.systemUTC()); + } + + Webhooks(Clock clock) { + this.clock = clock; + } + + public CaerusEvent constructEvent(String payload, String signatureHeader, String secret) { + return constructEvent(payload, signatureHeader, secret, DEFAULT_TOLERANCE_SECONDS); + } + + public CaerusEvent constructEvent(byte[] payload, String signatureHeader, String secret) { + return constructEvent(payload, signatureHeader, secret, DEFAULT_TOLERANCE_SECONDS); + } + + public CaerusEvent constructEvent(String payload, String signatureHeader, String secret, long toleranceSeconds) { + byte[] body = payload == null ? new byte[0] : payload.getBytes(StandardCharsets.UTF_8); + return constructEvent(body, signatureHeader, secret, toleranceSeconds); + } + + public CaerusEvent constructEvent(byte[] payload, String signatureHeader, String secret, long toleranceSeconds) { + if (signatureHeader == null || signatureHeader.isEmpty()) { + throw new CaerusSignatureError("No signature header provided."); + } + + if (secret == null || secret.trim().isEmpty()) { + throw new CaerusSignatureError( + "No signing secret provided: verifying against an empty secret would accept forged events."); + } + + String timestampText = null; + List signatures = new ArrayList<>(); + for (String part : signatureHeader.split(",", -1)) { + String trimmed = part.trim(); + if (timestampText == null && trimmed.startsWith("t=")) { + timestampText = trimmed.substring(2); + } + if (trimmed.startsWith("v1=")) { + signatures.add(trimmed.substring(3)); + } + } + + if (timestampText == null || timestampText.isEmpty() || signatures.isEmpty()) { + throw new CaerusSignatureError("Invalid signature format."); + } + + Long timestamp = parseLeadingInteger(timestampText); + if (timestamp == null) { + throw new CaerusSignatureError("Invalid signature format."); + } + + long now = Math.floorDiv(clock.millis(), 1000L); + if (Math.abs(now - timestamp) > toleranceSeconds) { + throw new CaerusWebhookExpiredError("Webhook timestamp is outside of the tolerance zone."); + } + + byte[] body = payload == null ? new byte[0] : payload; + byte[] expected = sign(secret, (timestampText + ".").getBytes(StandardCharsets.UTF_8), body) + .getBytes(StandardCharsets.US_ASCII); + + boolean valid = false; + for (String signature : signatures) { + byte[] candidate = signature.getBytes(StandardCharsets.US_ASCII); + if (candidate.length == expected.length && MessageDigest.isEqual(candidate, expected)) { + valid = true; + } + } + + if (!valid) { + throw new CaerusSignatureError("No matching signature found."); + } + + String rawPayload; + try { + rawPayload = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(body)) + .toString(); + } catch (CharacterCodingException e) { + throw new CaerusWebhookPayloadError( + "The webhook signature is valid but the body is not valid UTF-8.", + CaerusErrorOptions.builder().cause(e).build()); + } + + JsonElement parsed; + try { + parsed = Json.parse(rawPayload); + } catch (RuntimeException e) { + throw new CaerusWebhookPayloadError( + "The webhook signature is valid but the body is not valid JSON.", + CaerusErrorOptions.builder().cause(e).build()); + } + + if (!parsed.isJsonObject()) { + throw new CaerusWebhookPayloadError("The webhook signature is valid but the body is not a JSON object."); + } + + return toEvent(parsed.getAsJsonObject()); + } + + static String sign(String secret, String content) { + return sign(secret, content.getBytes(StandardCharsets.UTF_8), new byte[0]); + } + + static String sign(String secret, byte[] prefix, byte[] body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update(prefix); + return HexFormat.of().formatHex(mac.doFinal(body)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HmacSHA256 is not available in this JVM", e); + } + } + + private static CaerusEvent toEvent(JsonObject envelope) { + JsonFields fields = new JsonFields(envelope); + String eventType = fields.string("eventType"); + JsonElement data = envelope.get("data"); + JsonFields dataFields = new JsonFields(data != null && data.isJsonObject() ? data.getAsJsonObject() : null); + return new CaerusEvent( + fields.string("id"), + fields.string("eventId"), + fields.string("product"), + fields.string("objectType"), + fields.string("objectId"), + fields.string("environmentId"), + fields.string("occurredAt"), + eventType, + EventTypes.decode(eventType, dataFields), + fields.asMap()); + } + + private static Long parseLeadingInteger(String text) { + int index = 0; + int length = text.length(); + while (index < length && Character.isWhitespace(text.charAt(index))) { + index++; + } + boolean negative = false; + if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { + negative = text.charAt(index) == '-'; + index++; + } + int start = index; + long value = 0; + while (index < length && Character.isDigit(text.charAt(index)) && text.charAt(index) < 128) { + if (index - start >= 18) { + return null; + } + value = value * 10 + (text.charAt(index) - '0'); + index++; + } + if (index == start) { + return null; + } + return negative ? -value : value; + } +} diff --git a/src/main/resources/dev/caerus/sdk/version.properties b/src/main/resources/dev/caerus/sdk/version.properties new file mode 100644 index 0000000..defbd48 --- /dev/null +++ b/src/main/resources/dev/caerus/sdk/version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/src/test/java/dev/caerus/sdk/NoCommentsTest.java b/src/test/java/dev/caerus/sdk/NoCommentsTest.java new file mode 100644 index 0000000..2db8c69 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/NoCommentsTest.java @@ -0,0 +1,51 @@ +package dev.caerus.sdk; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoCommentsTest { + + @Test + void theCodeCarriesNoCommentsAndNoJavadoc() throws IOException { + List offenders = new ArrayList<>(); + for (String root : List.of("src/main/java", "src/test/java")) { + List files; + try (Stream walk = Files.walk(Path.of(root))) { + files = walk.filter(path -> path.toString().endsWith(".java")).collect(Collectors.toList()); + } + for (Path file : files) { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + if (isComment(lines.get(i))) { + offenders.add(file + ":" + (i + 1)); + } + } + } + } + + assertThat(offenders).isEmpty(); + } + + private static final String SLASH = "/"; + private static final String STAR = "*"; + private static final String LINE = SLASH + SLASH; + private static final String OPEN = SLASH + STAR; + private static final String CLOSE = STAR + SLASH; + + private static boolean isComment(String line) { + String trimmed = line.trim(); + if (trimmed.startsWith(LINE) || trimmed.startsWith(OPEN) || trimmed.startsWith(CLOSE)) { + return true; + } + return trimmed.matches(".*[;{})]\\s*" + LINE + ".*") || trimmed.contains(OPEN + STAR); + } +} diff --git a/src/test/java/dev/caerus/sdk/PublicApiTest.java b/src/test/java/dev/caerus/sdk/PublicApiTest.java new file mode 100644 index 0000000..9a86a77 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/PublicApiTest.java @@ -0,0 +1,125 @@ +package dev.caerus.sdk; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaCodeUnit; +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.domain.JavaType; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicApiTest { + + private static final List FORBIDDEN = List.of( + "com.google.protobuf.", + "com.google.rpc.", + "com.google.gson.", + "com.google.common.", + "io.grpc.", + "dev.caerus.sdk.internal."); + + private static JavaClasses classes; + + @BeforeAll + static void importClasses() { + classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("dev.caerus.sdk"); + } + + private static boolean isPublicApi(JavaClass type) { + return type.getModifiers().contains(JavaModifier.PUBLIC) + && !type.getPackageName().startsWith("dev.caerus.sdk.internal") + && type.getEnclosingClass().map(PublicApiTest::isPublicApi).orElse(true); + } + + private static boolean isVisible(Set modifiers) { + return modifiers.contains(JavaModifier.PUBLIC) || modifiers.contains(JavaModifier.PROTECTED); + } + + private static void check(String where, JavaType type, List leaks) { + for (JavaClass involved : type.getAllInvolvedRawTypes()) { + String name = involved.getName(); + for (String prefix : FORBIDDEN) { + if (name.startsWith(prefix)) { + leaks.add(where + " exposes " + name); + } + } + } + } + + @Test + void nothingGeneratedFromTheProtoOrTheTransportReachesThePublicApi() { + List leaks = new ArrayList<>(); + + for (JavaClass type : classes) { + if (!isPublicApi(type)) { + continue; + } + type.getRawSuperclass().ifPresent(superclass -> check(type.getName() + " extends", superclass, leaks)); + for (JavaType implemented : type.getInterfaces()) { + check(type.getName() + " implements", implemented, leaks); + } + for (JavaField field : type.getFields()) { + if (isVisible(field.getModifiers())) { + check(field.getFullName(), field.getType(), leaks); + } + } + for (JavaCodeUnit unit : type.getCodeUnits()) { + if (!isVisible(unit.getModifiers())) { + continue; + } + check(unit.getFullName() + " returns", unit.getReturnType(), leaks); + for (JavaType parameter : unit.getParameterTypes()) { + check(unit.getFullName() + " takes", parameter, leaks); + } + for (JavaClass thrown : unit.getThrowsClause().getTypes()) { + check(unit.getFullName() + " throws", thrown, leaks); + } + } + } + + assertThat(leaks).isEmpty(); + } + + @Test + void thePublicPackagesAreTheOnesTheSdkPromises() { + Set packages = classes.stream() + .filter(PublicApiTest::isPublicApi) + .map(JavaClass::getPackageName) + .collect(Collectors.toSet()); + + assertThat(packages).containsExactlyInAnyOrder( + "dev.caerus.sdk", + "dev.caerus.sdk.sre", + "dev.caerus.sdk.dls", + "dev.caerus.sdk.webhooks"); + } + + @Test + void everyErrorIsACaerusError() { + List strays = classes.stream() + .filter(PublicApiTest::isPublicApi) + .filter(type -> type.isAssignableTo(Throwable.class)) + .filter(type -> !type.isAssignableTo(CaerusError.class)) + .map(JavaClass::getName) + .collect(Collectors.toList()); + + assertThat(strays).isEmpty(); + } + + @Test + void theVersionComesFromTheBuild() { + assertThat(CaerusSdk.VERSION).isNotBlank().isNotEqualTo("unknown").doesNotContain("${"); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsClientOptionsTest.java b/src/test/java/dev/caerus/sdk/dls/DlsClientOptionsTest.java new file mode 100644 index 0000000..2b9a531 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsClientOptionsTest.java @@ -0,0 +1,110 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.CaerusSdk; +import dev.caerus.sdk.internal.ClientSettings; +import dev.caerus.sdk.internal.StderrLogger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DlsClientOptionsTest { + + private static final String KEY = "no-es-una-clave"; + + private static ClientSettings resolve(DlsClientOptions options, Map env) { + return DlsClient.resolve(options, env::get); + } + + private static DlsClientOptions.Builder base() { + return DlsClientOptions.builder().endpoint("localhost:9090").apiKey("k"); + } + + @Test + void refusesNoOptionsAtAll() { + assertThatThrownBy(() -> new DlsClient((DlsClientOptions) null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesAMissingOrBlankApiKey() { + assertThatThrownBy(() -> new DlsClient(DlsClientOptions.builder().build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("apiKey"); + assertThatThrownBy(() -> new DlsClient(DlsClientOptions.builder().apiKey(" ").build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void keepsTheApiKeyOutOfEverythingThatGetsPrinted() { + DlsClientOptions options = DlsClientOptions.builder().endpoint("localhost:9090").apiKey(KEY).build(); + try (DlsClient client = new DlsClient(resolve(options, Map.of()))) { + assertThat(client.toString()).doesNotContain(KEY); + assertThat(options.toString()).doesNotContain(KEY); + } + } + + @Test + void needsNothingButAnApiKeyToReachTheHostedCaerus() { + assertThat(resolve(DlsClientOptions.builder().apiKey(KEY).build(), Map.of()).endpoint()) + .isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + } + + @ParameterizedTest + @ValueSource(longs = {0, -1}) + void refusesANonPositiveTimeout(long timeoutMs) { + assertThatThrownBy(() -> new DlsClient(DlsClientOptions.builder().apiKey(KEY).timeoutMs(timeoutMs).build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void encryptsAndPutsADeadlineByDefault() { + ClientSettings settings = resolve(base().build(), Map.of()); + assertThat(settings.tls()).isTrue(); + assertThat(settings.timeoutMs()).isEqualTo(CaerusSdk.DEFAULT_TIMEOUT_MS); + } + + @Test + void readsItsOwnVariablesNotTheSreOnes() { + ClientSettings fromDls = resolve(DlsClientOptions.builder().apiKey("k").build(), + Map.of("CAERUS_DLS_ENDPOINT", "dls:9090", "CAERUS_DLS_TLS", "0")); + assertThat(fromDls.endpoint()).isEqualTo("dls:9090"); + assertThat(fromDls.tls()).isFalse(); + + ClientSettings fromSre = resolve(DlsClientOptions.builder().apiKey("k").build(), + Map.of("CAERUS_ENDPOINT", "sre:9090", "CAERUS_TLS", "false")); + assertThat(fromSre.endpoint()).isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + assertThat(fromSre.tls()).isTrue(); + } + + @Test + void prefersAnExplicitEndpointOverTheEnvironment() { + assertThat(resolve(DlsClientOptions.builder().apiKey("k").endpoint("explicito:1").build(), + Map.of("CAERUS_DLS_ENDPOINT", "entorno:1")).endpoint()).isEqualTo("explicito:1"); + } + + @Test + void refusesAnExplicitBlankEndpoint() { + assertThatThrownBy(() -> new DlsClient(DlsClientOptions.builder().endpoint(" ").apiKey(KEY).build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "TRUE", "1", "yes", "", "flase"}) + void letsNothingButFalseOrZeroDisableEncryption(String on) { + assertThat(resolve(base().build(), Map.of("CAERUS_DLS_TLS", on)).tls()).isTrue(); + } + + @Test + void anExplicitTlsOptionWinsOverTheEnvironment() { + assertThat(resolve(base().tls(true).build(), Map.of("CAERUS_DLS_TLS", "false")).tls()).isTrue(); + } + + @Test + void logsWithItsOwnPrefix() { + assertThat(((StderrLogger) resolve(base().build(), Map.of()).logger()).prefix()).isEqualTo("[caerus-dls]"); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java b/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java new file mode 100644 index 0000000..cd5cfa5 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java @@ -0,0 +1,519 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.AbortController; +import dev.caerus.sdk.ErrorCode; +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.BeginTransactionResponse; +import dev.caerus.sdk.internal.proto.dls.LockStatus; +import dev.caerus.sdk.internal.proto.dls.ReleaseTransactionLocksRequest; +import dev.caerus.sdk.internal.proto.dls.RenewTransactionRequest; +import dev.caerus.sdk.internal.proto.dls.RenewTransactionResponse; +import dev.caerus.sdk.support.FakeDlsEngine; +import dev.caerus.sdk.support.GrpcErrors; +import com.google.protobuf.Empty; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static dev.caerus.sdk.support.FakeDlsEngine.acquired; +import static dev.caerus.sdk.support.FakeDlsEngine.withStatus; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class DlsClientRealTest { + + private static FakeDlsEngine engine; + private static DlsClient client; + private static ScheduledExecutorService later; + private final List calls = new CopyOnWriteArrayList<>(); + + @BeforeAll + static void start() { + engine = FakeDlsEngine.start(); + client = DlsTestClients.real(engine); + later = Executors.newSingleThreadScheduledExecutor(); + } + + @AfterAll + static void stop() { + later.shutdownNow(); + client.close(); + engine.close(); + } + + @BeforeEach + void script() { + engine.reset(); + calls.clear(); + engine.on("beginTransaction", (request, observer) -> { + calls.add("begin"); + reply(observer, BeginTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + }); + engine.on("releaseTransactionLocks", (request, observer) -> { + calls.add("releaseAll"); + reply(observer, Empty.getDefaultInstance()); + }); + engine.on("renewTransaction", (request, observer) -> { + calls.add("renew"); + reply(observer, RenewTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + }); + engine.on("acquireLock", (request, observer) -> + reply(observer, acquired("lock-1", 7))); + } + + private static void reply(StreamObserver observer, T response) { + observer.onNext(response); + observer.onCompleted(); + } + + private static TransactionOptions noRenew() { + return TransactionOptions.builder().autoRenew(false).build(); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + @Test + void opensTheTransactionRunsTheCallbackAndReleasesTheLocksAtTheEnd() { + String result = client.withTransaction(tx -> { + LockHolder lock = tx.acquireLock("ns", "k", LockMode.EXCLUSIVE); + return tx.transactionId() + ":" + lock.status() + ":" + lock.fencingToken().getAsLong(); + }, noRenew()); + + assertThat(result).isEqualTo("tx-1:ACQUIRED:7"); + assertThat(calls).containsExactly("begin", "releaseAll"); + assertThat(engine.lastRequest(ReleaseTransactionLocksRequest.class).getTransactionId()).isEqualTo("tx-1"); + } + + @Test + void releasesTheLocksEvenWhenTheCallbackBlowsUpAndRethrows() { + assertThatThrownBy(() -> client.withTransaction(tx -> { + throw new IllegalStateException("se rompio el trabajo"); + }, noRenew())).isInstanceOf(IllegalStateException.class).hasMessage("se rompio el trabajo"); + + assertThat(calls).contains("releaseAll"); + } + + @Test + void letsACheckedExceptionThroughUnwrapped() { + Throwable error = catchThrowable(() -> client.withTransaction(tx -> { + throw new java.io.IOException("disco lleno"); + }, noRenew())); + + assertThat(error).isInstanceOf(java.io.IOException.class).hasMessage("disco lleno"); + assertThat(calls).contains("releaseAll"); + } + + @Test + void aDeniedLockInsideTheTransactionCutsItAndStillReleases() { + engine.on("acquireLock", (request, observer) -> + reply(observer, withStatus(LockStatus.DENIED))); + + assertThatThrownBy(() -> client.withTransaction(tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE), noRenew())) + .isInstanceOf(LockDeniedError.class); + + assertThat(calls).contains("releaseAll"); + } + + @Test + void aFailedReleaseAtTheEndIsSwallowed() { + engine.on("releaseTransactionLocks", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.UNAVAILABLE, "se cayo"))); + + String result = client.withTransaction(tx -> "listo", noRenew()); + + assertThat(result).isEqualTo("listo"); + } + + @Test + void renewsOnItsOwnWhileTheWorkLasts() { + client.withTransaction(tx -> { + sleep(2300); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + + assertThat(calls.stream().filter("renew"::equals).count()).isGreaterThanOrEqualTo(2); + assertThat(calls).contains("releaseAll"); + } + + @Test + void renewsWithTheWholeLifetime() { + AtomicReference seen = new AtomicReference<>(); + engine.on("renewTransaction", (request, observer) -> { + seen.set(request); + reply(observer, RenewTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + }); + + client.withTransaction(tx -> { + sleep(1300); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + + assertThat(seen.get()).isNotNull(); + assertThat(seen.get().getTransactionId()).isEqualTo("tx-1"); + assertThat(seen.get().getExtraMs()).isEqualTo(2000); + } + + @Test + void withAutoRenewOffItSendsNotASingleHeartbeat() { + client.withTransaction(tx -> { + sleep(1600); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).autoRenew(false).build()); + + assertThat(calls).doesNotContain("renew"); + } + + @Test + void stopsRenewingOnceTheCallbackEnds() { + client.withTransaction(tx -> { + sleep(1200); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + long renewalsAtTheEnd = calls.stream().filter("renew"::equals).count(); + + sleep(1500); + + assertThat(calls.stream().filter("renew"::equals).count()).isEqualTo(renewalsAtTheEnd); + } + + @Test + void ifTheRenewalFailsBeyondRepairTheTransactionIsLost() { + engine.on("renewTransaction", (request, observer) -> { + calls.add("renew"); + observer.onError(GrpcErrors.status(Status.Code.NOT_FOUND, "la transaccion ya no existe")); + }); + AtomicReference notified = new AtomicReference<>(); + AtomicBoolean aborted = new AtomicBoolean(); + + Throwable error = catchThrowable(() -> client.withTransaction(tx -> { + tx.signal().onAbort(() -> aborted.set(true)); + sleep(1600); + return "el callback termino igual"; + }, TransactionOptions.builder().timeoutMs(2000L).onTransactionLost(notified::set).build())); + + assertThat(error).isInstanceOf(DlsNotFoundError.class); + assertThat(notified.get()).isInstanceOf(DlsNotFoundError.class).isSameAs(error); + assertThat(aborted).isTrue(); + assertThat(calls).contains("releaseAll"); + } + + @Test + void twoFailuresInARowOfAnyKindAlsoLoseIt() { + engine.on("renewTransaction", (request, observer) -> { + calls.add("renew"); + observer.onError(GrpcErrors.status(Status.Code.UNAVAILABLE, "se corto")); + }); + + Throwable error = catchThrowable(() -> client.withTransaction(tx -> { + sleep(2600); + return "nada"; + }, TransactionOptions.builder().timeoutMs(2000L).build())); + + assertThat(error).isInstanceOf(DlsError.class); + assertThat(((DlsError) error).code()).isEqualTo(ErrorCode.UNKNOWN); + assertThat(calls.stream().filter("renew"::equals).count()).isEqualTo(2); + } + + @Test + void oneFailureFollowedByASuccessIsForgiven() { + AtomicBoolean failedOnce = new AtomicBoolean(); + engine.on("renewTransaction", (request, observer) -> { + calls.add("renew"); + if (failedOnce.compareAndSet(false, true)) { + observer.onError(GrpcErrors.status(Status.Code.UNAVAILABLE, "se corto")); + return; + } + reply(observer, RenewTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + }); + + String result = client.withTransaction(tx -> { + sleep(3300); + return "sobrevivio"; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + + assertThat(result).isEqualTo("sobrevivio"); + assertThat(calls.stream().filter("renew"::equals).count()).isGreaterThanOrEqualTo(3); + } + + @Test + void onceLostItWillNotTakeMoreLocks() { + engine.on("renewTransaction", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.NOT_FOUND, "la transaccion ya no existe"))); + AtomicReference onTake = new AtomicReference<>(); + + assertThatThrownBy(() -> client.withTransaction(tx -> { + sleep(1600); + onTake.set(catchThrowable(() -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE))); + return "no deberia valer"; + }, TransactionOptions.builder().timeoutMs(2000L).build())).isInstanceOf(DlsNotFoundError.class); + + assertThat(onTake.get()).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void losingTheTransactionCutsALockThatIsStillWaiting() { + engine.on("renewTransaction", (request, observer) -> + observer.onError(GrpcErrors.withReason(Status.Code.FAILED_PRECONDITION, "cerrada", "TRANSACTION_NOT_ACTIVE"))); + engine.on("acquireLock", (request, observer) -> + observer.onNext(withStatus(LockStatus.QUEUED))); + + long started = System.nanoTime(); + Throwable error = catchThrowable(() -> client.withTransaction( + tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE, AcquireLockOptions.builder().timeoutMs(8000L).build()), + TransactionOptions.builder().timeoutMs(2000L).build())); + long elapsedMs = (System.nanoTime() - started) / 1_000_000; + + assertThat(error).isInstanceOf(DlsError.class); + assertThat(elapsedMs).isLessThan(4000); + } + + @Test + void losingTheTransactionAlsoCutsAWaitThatBringsItsOwnSignal() { + engine.on("renewTransaction", (request, observer) -> + observer.onError(GrpcErrors.withReason(Status.Code.FAILED_PRECONDITION, "cerrada", "TRANSACTION_NOT_ACTIVE"))); + engine.on("acquireLock", (request, observer) -> + observer.onNext(withStatus(LockStatus.QUEUED))); + AbortController own = new AbortController(); + + long started = System.nanoTime(); + Throwable error = catchThrowable(() -> client.withTransaction( + tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().signal(own.signal()).timeoutMs(8000L).build()), + TransactionOptions.builder().timeoutMs(2000L).build())); + long elapsedMs = (System.nanoTime() - started) / 1_000_000; + + assertThat(error).isInstanceOf(DlsError.class); + assertThat(elapsedMs).isLessThan(4000); + assertThat(own.signal().aborted()).isFalse(); + } + + @Test + void itsOwnSignalStillCutsTheWaitInsideATransaction() { + engine.on("acquireLock", (request, observer) -> + observer.onNext(withStatus(LockStatus.QUEUED))); + AbortController own = new AbortController(); + later.schedule(() -> own.abort(), 100, TimeUnit.MILLISECONDS); + + Throwable error = catchThrowable(() -> client.withTransaction( + tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().signal(own.signal()).timeoutMs(8000L).build()), + noRenew())); + + assertThat(error).isInstanceOf(DlsError.class).hasMessageContaining("AcquireLock aborted by user"); + } + + @Test + void aSlowRenewalIsNeverOverlappedByTheNextOne() { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + AtomicInteger started = new AtomicInteger(); + engine.on("renewTransaction", (request, observer) -> { + started.incrementAndGet(); + maxInFlight.accumulateAndGet(inFlight.incrementAndGet(), Math::max); + later.schedule(() -> { + inFlight.decrementAndGet(); + reply(observer, RenewTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + }, 2500, TimeUnit.MILLISECONDS); + }); + + client.withTransaction(tx -> { + sleep(3800); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + + assertThat(maxInFlight).hasValue(1); + assertThat(started.get()).isBetween(1, 2); + } + + @Test + void onceClosedTheContextCannotBeUsed() { + AtomicReference leaked = new AtomicReference<>(); + + client.withTransaction(tx -> { + leaked.set(tx); + return null; + }, noRenew()); + + assertThatThrownBy(() -> leaked.get().acquireLock("ns", "k", LockMode.EXCLUSIVE)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Transaction context already closed"); + assertThatThrownBy(() -> leaked.get().renewTransaction(1000)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Transaction context already closed"); + } + + @Test + void theContextRenewsOnRequest() { + client.withTransaction(tx -> tx.renewTransaction(5000), noRenew()); + + assertThat(calls).containsExactly("begin", "renew", "releaseAll"); + } + + @Test + void waitsInTheQueueAndResolvesWhenTheLockIsGranted() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(LockStatus.QUEUED)); + later.schedule(() -> observer.onNext(withStatus(LockStatus.QUEUED)), 60, TimeUnit.MILLISECONDS); + later.schedule(() -> reply(observer, acquired("lock-9", 42)), 120, TimeUnit.MILLISECONDS); + }); + + LockHolder lock = client.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().timeoutMs(5000L).build()); + + assertThat(lock).isEqualTo(new LockHolder("lock-9", java.util.OptionalLong.of(42), dev.caerus.sdk.dls.LockStatus.ACQUIRED)); + } + + @Test + void anErrorHalfwayThroughTheStreamArrivesTypedAndWithItsCode() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(LockStatus.QUEUED)); + later.schedule(() -> observer.onError(GrpcErrors.status(Status.Code.ABORTED, "la transaccion se aborto")), + 50, TimeUnit.MILLISECONDS); + }); + + long started = System.nanoTime(); + DlsError error = catchThrowableOfType(DlsError.class, () -> client.acquireLock("ns", "k", "tx-1", + LockMode.EXCLUSIVE, AcquireLockOptions.builder().timeoutMs(5000L).build())); + + assertThat(error).isInstanceOf(DlsConflictError.class); + assertThat(error.code()).isNotEqualTo(ErrorCode.TIMEOUT); + assertThat(error.requestId()).isPresent(); + assertThat((System.nanoTime() - started) / 1_000_000).isLessThan(2000); + } + + @Test + void aStreamThatEndsWithoutATerminalStatusIsNotTakenAsGranted() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(LockStatus.QUEUED)); + later.schedule(observer::onCompleted, 50, TimeUnit.MILLISECONDS); + }); + + DlsError error = catchThrowableOfType(DlsError.class, () -> client.acquireLock("ns", "k", "tx-1", + LockMode.EXCLUSIVE, AcquireLockOptions.builder().timeoutMs(5000L).build())); + + assertThat(error.getMessage()).contains("Stream ended without terminal status"); + assertThat(error.code()).isEqualTo(ErrorCode.UNKNOWN); + } + + @Test + void theAbortSignalCutsAWaitInProgress() { + engine.on("acquireLock", (request, observer) -> + observer.onNext(withStatus(LockStatus.QUEUED))); + AbortController controller = new AbortController(); + later.schedule(() -> controller.abort(), 80, TimeUnit.MILLISECONDS); + + long started = System.nanoTime(); + DlsError error = catchThrowableOfType(DlsError.class, () -> client.acquireLock("ns", "k", "tx-1", + LockMode.EXCLUSIVE, AcquireLockOptions.builder().signal(controller.signal()).timeoutMs(5000L).build())); + + assertThat(error.getMessage()).contains("AcquireLock aborted by user"); + assertThat((System.nanoTime() - started) / 1_000_000).isLessThan(2000); + } + + @Test + void anAlreadyAbortedSignalFailsWithoutCalling() { + AbortController controller = new AbortController(); + controller.abort(); + + DlsError error = catchThrowableOfType(DlsError.class, () -> client.acquireLock("ns", "k", "tx-1", + LockMode.EXCLUSIVE, AcquireLockOptions.builder().signal(controller.signal()).build())); + + assertThat(error.getMessage()).contains("AcquireLock aborted by user"); + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void withoutItsOwnTimeoutAQueuedLockUsesTheClientDeadline() { + engine.on("acquireLock", (request, observer) -> + observer.onNext(withStatus(LockStatus.QUEUED))); + + try (DlsClient impatient = new DlsClient(DlsClient.resolve(DlsClientOptions.builder() + .endpoint(engine.endpoint()).apiKey("k").tls(false).timeoutMs(300L).build(), name -> null))) { + assertThatThrownBy(() -> impatient.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE)) + .isInstanceOf(DlsTimeoutError.class); + } + } + + @Test + void itsOwnTimeoutBeatsTheClientDeadline() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(LockStatus.QUEUED)); + later.schedule(() -> reply(observer, acquired("lock-1", 1)), 600, TimeUnit.MILLISECONDS); + }); + + try (DlsClient impatient = new DlsClient(DlsClient.resolve(DlsClientOptions.builder() + .endpoint(engine.endpoint()).apiKey("k").tls(false).timeoutMs(300L).build(), name -> null))) { + LockHolder lock = impatient.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().timeoutMs(3000L).build()); + assertThat(lock.lockId()).isEqualTo("lock-1"); + } + } + + @Test + void aClosedClientRejectsInsteadOfHanging() { + DlsClient loose = DlsTestClients.real(engine); + loose.close(); + + assertThatThrownBy(loose::beginTransaction).isInstanceOf(DlsError.class).hasMessageContaining("closed"); + assertThatThrownBy(() -> loose.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE)) + .isInstanceOf(DlsError.class) + .hasMessageContaining("closed"); + } + + @Test + void closingShutsTheRenewalThreadDown() { + DlsClient loose = DlsTestClients.real(engine); + loose.withTransaction(tx -> { + sleep(50); + return null; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + assertThat(loose.core().schedulerShutDown()).isFalse(); + + loose.close(); + + assertThat(loose.core().schedulerShutDown()).isTrue(); + } + + @Test + void severalThreadsCanHoldTransactionsAtOnce() throws InterruptedException { + List results = new CopyOnWriteArrayList<>(); + Thread[] threads = new Thread[8]; + for (int i = 0; i < threads.length; i++) { + int index = i; + threads[i] = new Thread(() -> results.add(client.withTransaction(tx -> { + LockHolder lock = tx.acquireLock("ns", "k" + index, LockMode.EXCLUSIVE); + return lock.lockId(); + }, noRenew()))); + threads[i].start(); + } + for (Thread thread : threads) { + thread.join(10_000); + } + + assertThat(results).hasSize(8).containsOnly("lock-1"); + assertThat(calls.stream().filter("releaseAll"::equals).count()).isEqualTo(8); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsErrorReasonsTest.java b/src/test/java/dev/caerus/sdk/dls/DlsErrorReasonsTest.java new file mode 100644 index 0000000..57ede4c --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsErrorReasonsTest.java @@ -0,0 +1,134 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.internal.ErrorDetails; +import dev.caerus.sdk.support.GrpcErrors; +import dev.caerus.sdk.support.RealTrailers; +import io.grpc.Status; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static dev.caerus.sdk.support.RealTrailers.grpcError; +import static org.assertj.core.api.Assertions.assertThat; + +class DlsErrorReasonsTest { + + @Test + void aRealDeadlockAbortArrivesTypedAndWithItsReason() { + DlsError error = DlsErrors.toDlsError(grpcError(Status.Code.ABORTED, + "Transaction f3d239f6-ab5b-4936-9477-8ac49d32d06c marked ABORT_REQUESTED", + RealTrailers.DEADLOCK_DETECTED)); + + assertThat(error).isInstanceOf(DeadlockAbortedError.class); + assertThat(error.reason()).contains("DEADLOCK_DETECTED"); + assertThat(error.code()).isEqualTo(ErrorCode.CONFLICT); + } + + @Test + void theCapturedTrailerStillDecodes() { + assertThat(ErrorDetails.reasonOf(grpcError(Status.Code.ABORTED, "x", RealTrailers.DEADLOCK_DETECTED))) + .contains("DEADLOCK_DETECTED"); + } + + @Test + void everyDlsErrorCanBeCaughtAsCaerusError() { + List errors = List.of( + DlsErrors.toDlsError(grpcError(Status.Code.ABORTED, "abortada", RealTrailers.DEADLOCK_DETECTED)), + DlsErrors.toDlsError(grpcError(Status.Code.NOT_FOUND, "no existe")), + DlsErrors.toDlsError(grpcError(Status.Code.INVALID_ARGUMENT, "argumento malo")), + DlsErrors.toDlsError(grpcError(Status.Code.UNAUTHENTICATED, "sin credenciales")), + DlsErrors.toDlsError(grpcError(Status.Code.DEADLINE_EXCEEDED, "se paso el tiempo")), + DlsErrors.toDlsError(grpcError(Status.Code.ALREADY_EXISTS, "ya tomado")), + new LockDeniedError("denegado")); + + for (CaerusError error : errors) { + assertThat(error).isInstanceOf(DlsError.class); + } + } + + @Test + void anAbortedWithoutReasonFallsIntoTheGenericConflict() { + DlsError error = DlsErrors.toDlsError(grpcError(Status.Code.ABORTED, "algo pasó")); + + assertThat(error).isExactlyInstanceOf(DlsConflictError.class); + assertThat(error.reason()).isEmpty(); + } + + @Test + void anErrorThatIsAlreadyFromTheDlsPassesThroughUnwrapped() { + DeadlockAbortedError original = new DeadlockAbortedError("victima", + CaerusErrorOptions.builder().reason("DEADLOCK_DETECTED").build()); + + assertThat(DlsErrors.toDlsError(original)).isSameAs(original); + } + + static Stream reasons() { + return Stream.of( + Arguments.of("DEADLOCK_DETECTED", DeadlockAbortedError.class, ErrorCode.CONFLICT), + Arguments.of("LOCK_DENIED", LockDeniedError.class, ErrorCode.CONFLICT), + Arguments.of("LOCK_ALREADY_HELD_EXCLUSIVELY", LockAlreadyHeldError.class, ErrorCode.CONFLICT), + Arguments.of("TRANSACTION_NOT_ACTIVE", TransactionNotActiveError.class, ErrorCode.CONFLICT), + Arguments.of("LOCK_MODE_MISMATCH", LockModeMismatchError.class, ErrorCode.VALIDATION), + Arguments.of("LOCK_ACQUISITION_CANCELLED", LockAcquisitionCancelledError.class, ErrorCode.UNKNOWN)); + } + + @ParameterizedTest + @MethodSource("reasons") + void theReasonDecidesBeforeTheStatus(String reason, Class expected, ErrorCode code) { + DlsError error = DlsErrors.toDlsError(GrpcErrors.withReason(Status.Code.INTERNAL, "x", reason)); + + assertThat(error).isExactlyInstanceOf(expected); + assertThat(error.code()).isEqualTo(code); + assertThat(error.reason()).contains(reason); + } + + static Stream statuses() { + return Stream.of( + Arguments.of(Status.Code.NOT_FOUND, DlsNotFoundError.class, ErrorCode.RESOURCE_NOT_FOUND), + Arguments.of(Status.Code.ALREADY_EXISTS, LockAlreadyHeldError.class, ErrorCode.CONFLICT), + Arguments.of(Status.Code.ABORTED, DlsConflictError.class, ErrorCode.CONFLICT), + Arguments.of(Status.Code.FAILED_PRECONDITION, DlsConflictError.class, ErrorCode.CONFLICT), + Arguments.of(Status.Code.INVALID_ARGUMENT, DlsValidationError.class, ErrorCode.VALIDATION), + Arguments.of(Status.Code.UNAUTHENTICATED, DlsAuthenticationError.class, ErrorCode.AUTHENTICATION), + Arguments.of(Status.Code.DEADLINE_EXCEEDED, DlsTimeoutError.class, ErrorCode.TIMEOUT), + Arguments.of(Status.Code.CANCELLED, LockAcquisitionCancelledError.class, ErrorCode.UNKNOWN), + Arguments.of(Status.Code.INTERNAL, DlsError.class, ErrorCode.UNKNOWN), + Arguments.of(Status.Code.UNAVAILABLE, DlsError.class, ErrorCode.UNKNOWN)); + } + + @ParameterizedTest + @MethodSource("statuses") + void withoutAKnownReasonTheStatusDecides(Status.Code status, Class expected, ErrorCode code) { + DlsError error = DlsErrors.toDlsError(GrpcErrors.withReason(status, "x", "SOMETHING_NEW")); + + assertThat(error).isExactlyInstanceOf(expected); + assertThat(error.code()).isEqualTo(code); + assertThat(error.reason()).contains("SOMETHING_NEW"); + } + + @Test + void propagatesTheServerRequestId() { + DlsError error = DlsErrors.toDlsError( + GrpcErrors.withTrailers(Status.Code.FAILED_PRECONDITION, "Lock denied: lock already held", + GrpcErrors.requestIdTrailers("req_dls-server-777")), + "req_dls-client-fallback"); + + assertThat(error.requestId()).contains("req_dls-server-777"); + assertThat(error.getMessage()).contains("req_dls-server-777"); + } + + @Test + void fallsBackToTheClientRequestIdWhenTheServerSentNone() { + DlsError error = DlsErrors.toDlsError(new IllegalStateException("Connection refused"), "req_dls-client-timeout"); + + assertThat(error.requestId()).contains("req_dls-client-timeout"); + assertThat(error.getMessage()).contains("req_dls-client-timeout"); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsMappingTest.java b/src/test/java/dev/caerus/sdk/dls/DlsMappingTest.java new file mode 100644 index 0000000..cdcea8e --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsMappingTest.java @@ -0,0 +1,83 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.ErrorCode; +import org.junit.jupiter.api.Test; + +import java.util.OptionalLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class DlsMappingTest { + + @Test + void modesGoAndComeBackWithoutLosingAnything() { + for (LockMode mode : LockMode.values()) { + assertThat(DlsMapping.toLockMode(DlsMapping.toGrpc(mode).getNumber())).contains(mode); + } + } + + @Test + void aModeThatDoesNotExistDoesNotPassForAValidOne() { + assertThat(DlsMapping.toGrpc(null).getNumber()).isZero(); + assertThat(DlsMapping.toLockMode(99)).isEmpty(); + assertThat(DlsMapping.toLockMode(0)).isEmpty(); + } + + @Test + void translatesTheThreeStatesTheEngineDefines() { + assertThat(DlsMapping.toLockStatus(1)).isEqualTo(LockStatus.ACQUIRED); + assertThat(DlsMapping.toLockStatus(2)).isEqualTo(LockStatus.DENIED); + assertThat(DlsMapping.toLockStatus(3)).isEqualTo(LockStatus.QUEUED); + } + + @Test + void whatItDoesNotRecogniseIsUnknownNeverDenied() { + assertThat(DlsMapping.toLockStatus(0)).isEqualTo(LockStatus.UNKNOWN); + assertThat(DlsMapping.toLockStatus(77)).isEqualTo(LockStatus.UNKNOWN); + } + + @Test + void aRealFencingTokenPassesAsIs() { + assertThat(DlsMapping.decodeFencingToken(42)).hasValue(42); + assertThat(DlsMapping.decodeFencingToken(9_007_199_254_740_993L)).hasValue(9_007_199_254_740_993L); + } + + @Test + void absenceIsNeverTurnedIntoZero() { + assertThat(DlsMapping.decodeFencingToken(0)).isEmpty(); + assertThat(DlsMapping.decodeFencingToken(-1)).isEmpty(); + } + + @Test + void letsAnAcquiredLockThrough() { + LockHolder granted = new LockHolder("l1", OptionalLong.of(3), LockStatus.ACQUIRED); + assertThat(DlsMapping.assertAcquired(granted, "ns", "k")).isSameAs(granted); + } + + @Test + void aDeniedLockThrowsWithItsReason() { + LockDeniedError error = catchThrowableOfType(LockDeniedError.class, () -> + DlsMapping.assertAcquired(new LockHolder("l1", OptionalLong.empty(), LockStatus.DENIED), "ns", "k")); + + assertThat(error.reason()).contains("LOCK_DENIED"); + assertThat(error.getMessage()).contains("ns/k"); + } + + @Test + void anUnknownStatusSaysItDoesNotKnowNotThatSomeoneElseHasIt() { + DlsError error = catchThrowableOfType(DlsError.class, () -> + DlsMapping.assertAcquired(new LockHolder("l1", OptionalLong.empty(), LockStatus.UNKNOWN), "ns", "k")); + + assertThat(error).isNotInstanceOf(LockDeniedError.class); + assertThat(error.code()).isEqualTo(ErrorCode.UNKNOWN); + } + + @Test + void aValidStatusThisCallCannotUseAlsoStops() { + assertThatThrownBy(() -> + DlsMapping.assertAcquired(new LockHolder("l1", OptionalLong.empty(), LockStatus.QUEUED), "ns", "k")) + .isInstanceOf(DlsConflictError.class); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsMethodsTest.java b/src/test/java/dev/caerus/sdk/dls/DlsMethodsTest.java new file mode 100644 index 0000000..f3b03b5 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsMethodsTest.java @@ -0,0 +1,256 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +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.GetLockStatusRequest; +import dev.caerus.sdk.internal.proto.dls.GetTransactionStatusRequest; +import dev.caerus.sdk.internal.proto.dls.GetTransactionStatusResponse; +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 dev.caerus.sdk.support.FakeDlsEngine; +import dev.caerus.sdk.support.GrpcErrors; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.OptionalLong; + +import static dev.caerus.sdk.support.FakeDlsEngine.acquired; +import static dev.caerus.sdk.support.FakeDlsEngine.withStatus; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class DlsMethodsTest { + + private static FakeDlsEngine engine; + private static DlsClient client; + + @BeforeAll + static void start() { + engine = FakeDlsEngine.start(); + client = DlsTestClients.real(engine); + } + + @AfterAll + static void stop() { + client.close(); + engine.close(); + } + + @BeforeEach + void reset() { + engine.reset(); + } + + private static void reply(StreamObserver observer, T response) { + observer.onNext(response); + observer.onCompleted(); + } + + @Test + void beginTransactionPassesTimeoutMs() { + Transaction tx = client.beginTransaction(BeginTransactionOptions.builder().timeoutMs(5000L).build()); + + assertThat(tx.transactionId()).isEqualTo("tx-1"); + assertThat(engine.lastMethod()).isEqualTo("beginTransaction"); + BeginTransactionRequest request = engine.lastRequest(BeginTransactionRequest.class); + assertThat(request.hasTimeoutMs()).isTrue(); + assertThat(request.getTimeoutMs()).isEqualTo(5000); + } + + @Test + void beginTransactionWorksWithoutTimeout() { + assertThat(client.beginTransaction().transactionId()).isEqualTo("tx-1"); + assertThat(engine.lastRequest(BeginTransactionRequest.class).hasTimeoutMs()).isFalse(); + } + + @Test + void sendsTheApiKeyAndARequestIdOnEveryCall() { + client.beginTransaction(); + + Metadata metadata = engine.lastMetadata(); + assertThat(metadata.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("Bearer test-key"); + assertThat(metadata.get(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER))) + .matches("^req_[a-zA-Z0-9_\\-]{8,64}$"); + } + + @Test + void acquireLockMapsTheModeAndIgnoresQueued() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(dev.caerus.sdk.internal.proto.dls.LockStatus.QUEUED)); + observer.onNext(acquired("l-1", 5)); + observer.onCompleted(); + }); + + LockHolder lock = client.acquireLock("ns1", "k1", "tx-1", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().idempotencyKey("idem1").build()); + + assertThat(lock.lockId()).isEqualTo("l-1"); + assertThat(lock.fencingToken()).hasValue(5); + assertThat(lock.status()).isEqualTo(LockStatus.ACQUIRED); + AcquireLockRequest request = engine.lastRequest(AcquireLockRequest.class); + assertThat(request.getNamespace()).isEqualTo("ns1"); + assertThat(request.getLockKey()).isEqualTo("k1"); + assertThat(request.getTransactionId()).isEqualTo("tx-1"); + assertThat(request.getRequestedModeValue()).isEqualTo(1); + assertThat(request.getIdempotencyKey()).isEqualTo("idem1"); + } + + @Test + void acquireLockLeavesTheIdempotencyKeyOutWhenNoneIsGiven() { + client.acquireLock("ns1", "k1", "tx-1", LockMode.EXCLUSIVE); + + assertThat(engine.lastRequest(AcquireLockRequest.class).hasIdempotencyKey()).isFalse(); + } + + @Test + void acquireLockThrowsLockDeniedErrorWhenTheEngineDeniesTheLock() { + engine.on("acquireLock", (request, observer) -> + reply(observer, withStatus(dev.caerus.sdk.internal.proto.dls.LockStatus.DENIED))); + + assertThatThrownBy(() -> client.acquireLock("ns1", "k1", "tx-1", LockMode.SHARED_READ)) + .isInstanceOf(LockDeniedError.class); + assertThat(engine.lastRequest(AcquireLockRequest.class).getRequestedModeValue()).isEqualTo(2); + } + + @Test + void aDeniedLockCarriesTheReasonAndIsCatchableAsCaerusError() { + engine.on("acquireLock", (request, observer) -> + reply(observer, withStatus(dev.caerus.sdk.internal.proto.dls.LockStatus.DENIED))); + + CaerusError error = catchThrowableOfType(CaerusError.class, + () -> client.acquireLock("ns1", "k1", "tx-1", LockMode.EXCLUSIVE)); + + assertThat(error).isInstanceOf(LockDeniedError.class); + assertThat(error.reason()).contains("LOCK_DENIED"); + assertThat(error.code()).isEqualTo(ErrorCode.CONFLICT); + assertThat(error.getMessage()).contains("ns1/k1"); + } + + @Test + void anUnknownStatusInTheStreamIsLoggedAndIgnored() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(dev.caerus.sdk.internal.proto.dls.LockStatus.LOCK_STATUS_UNSPECIFIED)); + observer.onCompleted(); + }); + DlsTestClients.Recorder logger = new DlsTestClients.Recorder(); + try (DlsClient logged = DlsTestClients.real(engine, logger)) { + DlsError error = catchThrowableOfType(DlsError.class, + () -> logged.acquireLock("ns1", "k1", "tx-1", LockMode.EXCLUSIVE)); + + assertThat(error).isNotInstanceOf(LockDeniedError.class); + assertThat(error.getMessage()).contains("Stream ended without terminal status"); + assertThat(logger.messages()).contains("Received unspecified lock status in stream"); + } + } + + @Test + void anUnknownStatusIsListedAsUnknownNotAsDenied() { + engine.on("getTransactionStatus", (request, observer) -> + reply(observer, GetTransactionStatusResponse.newBuilder() + .setStatus("ACTIVE") + .addLocks(dev.caerus.sdk.internal.proto.dls.TransactionLockInfo.newBuilder() + .setNamespace("ns1").setLockKey("k1").setRequestedModeValue(1).setStatusValue(0)) + .build())); + + assertThat(client.getTransactionStatus("tx-1").locks().get(0).status()).isEqualTo(LockStatus.UNKNOWN); + } + + @Test + void aFencingTokenOfZeroComesBackEmptyNeverAsZero() { + engine.on("acquireLock", (request, observer) -> + reply(observer, acquired("lock-1", 0))); + + LockHolder lock = client.acquireLock("ns1", "k1", "tx-1", LockMode.EXCLUSIVE); + + assertThat(lock.status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(lock.fencingToken()).isEqualTo(OptionalLong.empty()); + } + + @Test + void renewTransactionPassesItsArguments() { + Transaction renewed = client.renewTransaction("tx-1", 1000); + + assertThat(renewed.transactionId()).isEqualTo("tx-1"); + RenewTransactionRequest request = engine.lastRequest(RenewTransactionRequest.class); + assertThat(request.getTransactionId()).isEqualTo("tx-1"); + assertThat(request.getExtraMs()).isEqualTo(1000); + } + + @Test + void releaseLockPassesItsArguments() { + client.releaseLock("l-1", "tx-1"); + + ReleaseLockRequest request = engine.lastRequest(ReleaseLockRequest.class); + assertThat(request.getLockId()).isEqualTo("l-1"); + assertThat(request.getTransactionId()).isEqualTo("tx-1"); + } + + @Test + void releaseTransactionLocksPassesItsArguments() { + client.releaseTransactionLocks("tx-1"); + + assertThat(engine.lastRequest(ReleaseTransactionLocksRequest.class).getTransactionId()).isEqualTo("tx-1"); + } + + @Test + void getLockStatusMapsTheResponse() { + LockStatusResponse status = client.getLockStatus("ns1", "k1"); + + assertThat(engine.lastRequest(GetLockStatusRequest.class).getNamespace()).isEqualTo("ns1"); + assertThat(status.isHeld()).isTrue(); + assertThat(status.currentMode()).contains(LockMode.EXCLUSIVE); + assertThat(status.activeHolders()).hasSize(1); + assertThat(status.activeHolders().get(0).lockId()).isEqualTo("l-1"); + assertThat(status.activeHolders().get(0).expiresAt()).isEqualTo(1000); + assertThat(status.activeHolders().get(0).fencingToken()).hasValue(5); + } + + @Test + void getTransactionStatusMapsTheResponse() { + engine.on("getTransactionStatus", (request, observer) -> + reply(observer, GetTransactionStatusResponse.newBuilder() + .setStatus("ABORTED") + .setAbortReason("timeout") + .addLocks(dev.caerus.sdk.internal.proto.dls.TransactionLockInfo.newBuilder() + .setNamespace("n1").setLockKey("k1").setRequestedModeValue(1).setStatusValue(1)) + .setExpiresAt(500) + .build())); + + TransactionStatusResponse status = client.getTransactionStatus("tx-1"); + + assertThat(status.status()).isEqualTo("ABORTED"); + assertThat(status.abortReason()).contains("timeout"); + assertThat(status.locks()).hasSize(1); + assertThat(status.locks().get(0).requestedMode()).isEqualTo(LockMode.EXCLUSIVE); + assertThat(status.locks().get(0).status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(status.expiresAt()).isEqualTo(500); + } + + @Test + void anEmptyAbortReasonReadsAsNone() { + assertThat(client.getTransactionStatus("tx-1").abortReason()).isEmpty(); + } + + @Test + void translatesAServerErrorWithItsRequestId() { + engine.on("renewTransaction", (request, observer) -> + observer.onError(GrpcErrors.withReason(Status.Code.FAILED_PRECONDITION, "closed", "TRANSACTION_NOT_ACTIVE"))); + + TransactionNotActiveError error = catchThrowableOfType(TransactionNotActiveError.class, + () -> client.renewTransaction("tx-1", 1000)); + + assertThat(error.requestId()).isPresent(); + assertThat(error.getMessage()).contains("closed"); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java b/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java new file mode 100644 index 0000000..58aad80 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java @@ -0,0 +1,345 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.AbortController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +class DlsMockTest { + + private InMemoryDlsClient client; + + @BeforeEach + void create() { + client = new InMemoryDlsClient(); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + @Test + void canBeginATransaction() { + Transaction tx = client.beginTransaction(); + + assertThat(tx.transactionId()).startsWith("tx-mock-"); + assertThat(client.getTransactionStatus(tx.transactionId()).status()).isEqualTo("ACTIVE"); + } + + @Test + void throwsWhenGettingTheStatusOfATransactionThatDoesNotExist() { + assertThatThrownBy(() -> client.getTransactionStatus("invalid")).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void canAcquireAndReleaseAnExclusiveLock() { + Transaction tx = client.beginTransaction(); + LockHolder lock = client.acquireLock("namespace", "key1", tx.transactionId(), LockMode.EXCLUSIVE); + + assertThat(lock.status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(lock.lockId()).isNotBlank(); + LockStatusResponse held = client.getLockStatus("namespace", "key1"); + assertThat(held.isHeld()).isTrue(); + assertThat(held.currentMode()).contains(LockMode.EXCLUSIVE); + + client.releaseLock(lock.lockId(), tx.transactionId()); + + assertThat(client.getLockStatus("namespace", "key1").isHeld()).isFalse(); + } + + @Test + void handsOutIncreasingFencingTokens() { + Transaction tx = client.beginTransaction(); + LockHolder first = client.acquireLock("ns", "a", tx.transactionId(), LockMode.EXCLUSIVE); + LockHolder second = client.acquireLock("ns", "b", tx.transactionId(), LockMode.EXCLUSIVE); + + assertThat(second.fencingToken().getAsLong()).isGreaterThan(first.fencingToken().getAsLong()); + assertThat(client.getLockStatus("ns", "a").activeHolders().get(0).fencingToken()).isEqualTo(first.fencingToken()); + } + + @Test + void deniesAnExclusiveLockAlreadyHeldByAnotherTransaction() { + Transaction tx1 = client.beginTransaction(); + Transaction tx2 = client.beginTransaction(); + + client.acquireLock("namespace", "key1", tx1.transactionId(), LockMode.EXCLUSIVE); + + assertThatThrownBy(() -> client.acquireLock("namespace", "key1", tx2.transactionId(), LockMode.EXCLUSIVE)) + .isInstanceOf(LockDeniedError.class) + .satisfies(error -> assertThat(((LockDeniedError) error).reason()).contains("LOCK_DENIED")); + } + + @Test + void allowsSharedReadLocksByMultipleTransactions() { + Transaction tx1 = client.beginTransaction(); + Transaction tx2 = client.beginTransaction(); + + client.acquireLock("namespace", "key1", tx1.transactionId(), LockMode.SHARED_READ); + client.acquireLock("namespace", "key1", tx2.transactionId(), LockMode.SHARED_READ); + + LockStatusResponse status = client.getLockStatus("namespace", "key1"); + assertThat(status.isHeld()).isTrue(); + assertThat(status.currentMode()).contains(LockMode.SHARED_READ); + assertThat(status.activeHolders()).hasSize(2); + } + + @Test + void canReleaseAllTheLocksOfATransactionAtOnce() { + Transaction tx = client.beginTransaction(); + client.acquireLock("ns1", "key1", tx.transactionId(), LockMode.EXCLUSIVE); + client.acquireLock("ns2", "key2", tx.transactionId(), LockMode.SHARED_READ); + + client.releaseTransactionLocks(tx.transactionId()); + + assertThat(client.getLockStatus("ns1", "key1").isHeld()).isFalse(); + assertThat(client.getLockStatus("ns2", "key2").isHeld()).isFalse(); + assertThatThrownBy(() -> client.getTransactionStatus(tx.transactionId())).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void canRenewATransaction() { + Transaction tx = client.beginTransaction(BeginTransactionOptions.builder().timeoutMs(1000L).build()); + long initial = client.getTransactionStatus(tx.transactionId()).expiresAt(); + + client.renewTransaction(tx.transactionId(), 5000); + + assertThat(client.getTransactionStatus(tx.transactionId()).expiresAt()).isEqualTo(initial + 5000); + } + + @Test + void renewingATransactionThatDoesNotExistThrows() { + assertThatThrownBy(() -> client.renewTransaction("ghost", 1000)).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void releasingALockOfATransactionThatDoesNotExistDoesNothingLikeTheEngine() { + assertThatCode(() -> client.releaseLock("l-1", "ghost")).doesNotThrowAnyException(); + } + + @Test + void releasingALockThatDoesNotExistDoesNothingLikeTheEngine() { + Transaction tx = client.beginTransaction(); + + assertThatCode(() -> client.releaseLock("ghost-lock", tx.transactionId())).doesNotThrowAnyException(); + } + + @Test + void releasingSomeoneElsesLockLeavesItHeld() { + Transaction owner = client.beginTransaction(); + Transaction other = client.beginTransaction(); + LockHolder lock = client.acquireLock("ns", "k", owner.transactionId(), LockMode.EXCLUSIVE); + + client.releaseLock(lock.lockId(), other.transactionId()); + + assertThat(client.getLockStatus("ns", "k").isHeld()).isTrue(); + } + + @Test + void releasingTheTransactionLocksCleansUp() { + Transaction tx = client.beginTransaction(); + client.acquireLock("ns1", "k1", tx.transactionId(), LockMode.EXCLUSIVE); + + client.releaseTransactionLocks(tx.transactionId()); + + assertThat(client.getLockStatus("ns1", "k1").isHeld()).isFalse(); + } + + @Test + void deniesSharedReadIfAnExclusiveLockIsHeld() { + Transaction tx1 = client.beginTransaction(); + client.acquireLock("ns", "key", tx1.transactionId(), LockMode.EXCLUSIVE); + Transaction tx2 = client.beginTransaction(); + + assertThatThrownBy(() -> client.acquireLock("ns", "key", tx2.transactionId(), LockMode.SHARED_READ)) + .isInstanceOf(LockDeniedError.class); + } + + @Test + void deniesExclusiveIfSharedReadIsHeldBySomeoneElse() { + Transaction tx1 = client.beginTransaction(); + client.acquireLock("ns", "key", tx1.transactionId(), LockMode.SHARED_READ); + Transaction tx2 = client.beginTransaction(); + + assertThatThrownBy(() -> client.acquireLock("ns", "key", tx2.transactionId(), LockMode.EXCLUSIVE)) + .isInstanceOf(LockDeniedError.class); + } + + @Test + void getTransactionStatusReturnsEveryLockOfTheTransaction() { + Transaction tx = client.beginTransaction(); + client.acquireLock("ns", "key1", tx.transactionId(), LockMode.EXCLUSIVE); + client.acquireLock("ns", "key2", tx.transactionId(), LockMode.SHARED_READ); + + TransactionStatusResponse status = client.getTransactionStatus(tx.transactionId()); + + assertThat(status.locks()).hasSize(2); + assertThat(status.locks()).anySatisfy(lock -> { + assertThat(lock.lockKey()).isEqualTo("key1"); + assertThat(lock.requestedMode()).isEqualTo(LockMode.EXCLUSIVE); + }); + assertThat(status.locks()).anySatisfy(lock -> { + assertThat(lock.lockKey()).isEqualTo("key2"); + assertThat(lock.requestedMode()).isEqualTo(LockMode.SHARED_READ); + }); + } + + @Test + void theSameIdempotencyKeyReplaysTheSameLockLikeTheEngine() { + Transaction tx = client.beginTransaction(); + AcquireLockOptions options = AcquireLockOptions.builder().idempotencyKey("idem-1").build(); + + LockHolder first = client.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE, options); + LockHolder again = client.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE, options); + + assertThat(again).isEqualTo(first); + assertThat(client.getLockStatus("ns", "k").activeHolders()).hasSize(1); + } + + @Test + void anotherKeyForALockTheTransactionAlreadyHasIsAlreadyHeld() { + Transaction tx = client.beginTransaction(); + client.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE, + AcquireLockOptions.builder().idempotencyKey("idem-1").build()); + + assertThatThrownBy(() -> client.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE, + AcquireLockOptions.builder().idempotencyKey("idem-2").build())) + .isInstanceOf(LockAlreadyHeldError.class) + .satisfies(error -> assertThat(((LockAlreadyHeldError) error).reason()) + .contains("LOCK_ALREADY_HELD_EXCLUSIVELY")); + assertThatThrownBy(() -> client.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE)) + .isInstanceOf(LockAlreadyHeldError.class); + } + + @Test + void theIdempotencyKeyOfAnotherTransactionDoesNotReplay() { + Transaction tx1 = client.beginTransaction(); + Transaction tx2 = client.beginTransaction(); + AcquireLockOptions options = AcquireLockOptions.builder().idempotencyKey("idem-1").build(); + client.acquireLock("ns", "k", tx1.transactionId(), LockMode.EXCLUSIVE, options); + + assertThatThrownBy(() -> client.acquireLock("ns", "k", tx2.transactionId(), LockMode.EXCLUSIVE, options)) + .isInstanceOf(LockDeniedError.class); + } + + @Test + void refusesALockForATransactionThatDoesNotExist() { + assertThatThrownBy(() -> client.acquireLock("ns", "k", "ghost", LockMode.EXCLUSIVE)) + .isInstanceOf(DlsNotFoundError.class); + } + + @Test + void withTransactionRenewsOnItsOwnAndReleasesAtTheEnd() { + AtomicReference txId = new AtomicReference<>(); + AtomicReference before = new AtomicReference<>(); + AtomicReference after = new AtomicReference<>(); + + String result = client.withTransaction(tx -> { + txId.set(tx.transactionId()); + tx.acquireLock("ns1", "key1", LockMode.EXCLUSIVE); + before.set(client.getTransactionStatus(tx.transactionId()).expiresAt()); + sleep(1300); + after.set(client.getTransactionStatus(tx.transactionId()).expiresAt()); + return "done"; + }, TransactionOptions.builder().timeoutMs(2000L).build()); + + assertThat(result).isEqualTo("done"); + assertThat(after.get() - before.get()).isGreaterThanOrEqualTo(2000); + assertThat(client.getLockStatus("ns1", "key1").isHeld()).isFalse(); + assertThatThrownBy(() -> client.getTransactionStatus(txId.get())).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void withTransactionReleasesOnErrorAndRethrows() { + assertThatThrownBy(() -> client.withTransaction(tx -> { + tx.acquireLock("ns2", "key2", LockMode.EXCLUSIVE); + throw new IllegalStateException("Business logic failed"); + })).hasMessage("Business logic failed"); + + assertThat(client.getLockStatus("ns2", "key2").isHeld()).isFalse(); + } + + @Test + void preventsAcquireLockOnceTheContextIsClosed() { + AtomicReference leaked = new AtomicReference<>(); + + client.withTransaction(tx -> { + leaked.set(tx); + return null; + }); + + assertThatThrownBy(() -> leaked.get().acquireLock("ns", "key", LockMode.EXCLUSIVE)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Transaction context already closed"); + } + + @Test + void abortsAcquireLockIfTheSignalIsAborted() { + AbortController controller = new AbortController(); + controller.abort(); + + assertThatThrownBy(() -> client.withTransaction(tx -> tx.acquireLock("ns3", "key3", LockMode.EXCLUSIVE), + TransactionOptions.builder().signal(controller.signal()).build())) + .isInstanceOf(DlsError.class) + .hasMessageContaining("AcquireLock aborted by user"); + } + + @Test + void ifRenewingFailsBeyondRepairTheTransactionIsLost() { + AtomicReference notified = new AtomicReference<>(); + + Throwable error = catchThrowable(() -> client.withTransaction(tx -> { + client.releaseTransactionLocks(tx.transactionId()); + sleep(1500); + return "el callback termino igual"; + }, TransactionOptions.builder().timeoutMs(2000L).onTransactionLost(notified::set).build())); + + assertThat(error).isInstanceOf(DlsNotFoundError.class); + assertThat(notified.get()).isInstanceOf(DlsNotFoundError.class); + } + + @Test + void onceLostItWillNotTakeMoreLocksNorReturnAGoodResult() { + AtomicReference onTake = new AtomicReference<>(); + AtomicBoolean abortNotice = new AtomicBoolean(); + + Throwable error = catchThrowable(() -> client.withTransaction(tx -> { + tx.signal().onAbort(() -> abortNotice.set(true)); + client.releaseTransactionLocks(tx.transactionId()); + sleep(1500); + onTake.set(catchThrowable(() -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE))); + return "no deberia llegar"; + }, TransactionOptions.builder().timeoutMs(2000L).build())); + + assertThat(error).isInstanceOf(DlsNotFoundError.class); + assertThat(onTake.get()).isInstanceOf(DlsNotFoundError.class); + assertThat(abortNotice).isTrue(); + } + + @Test + void rejectsCallsAfterBeingClosed() { + client.close(); + + assertThatThrownBy(client::beginTransaction).isInstanceOf(DlsError.class).hasMessageContaining("closed"); + } + + @Test + void isUsableWhereverTheRealClientIs() { + DlsApi api = client; + + String result = api.withTransaction(tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE).status().name()); + + assertThat(result).isEqualTo("ACQUIRED"); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsOnQueuedTest.java b/src/test/java/dev/caerus/sdk/dls/DlsOnQueuedTest.java new file mode 100644 index 0000000..015952c --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsOnQueuedTest.java @@ -0,0 +1,153 @@ +package dev.caerus.sdk.dls; + +import com.google.protobuf.Empty; +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.BeginTransactionResponse; +import dev.caerus.sdk.internal.proto.dls.LockStatus; +import dev.caerus.sdk.internal.proto.dls.ReleaseTransactionLocksRequest; +import dev.caerus.sdk.support.FakeDlsEngine; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static dev.caerus.sdk.support.FakeDlsEngine.acquired; +import static dev.caerus.sdk.support.FakeDlsEngine.withStatus; +import static org.assertj.core.api.Assertions.assertThat; + +class DlsOnQueuedTest { + + private static FakeDlsEngine engine; + private static DlsClient client; + private static ScheduledExecutorService later; + + @BeforeAll + static void start() { + engine = FakeDlsEngine.start(); + client = DlsTestClients.real(engine); + later = Executors.newSingleThreadScheduledExecutor(); + } + + @AfterAll + static void stop() { + later.shutdownNow(); + client.close(); + engine.close(); + } + + @BeforeEach + void reset() { + engine.reset(); + } + + private static void reply(StreamObserver observer, T response) { + observer.onNext(response); + observer.onCompleted(); + } + + private static void queueThenGrant() { + engine.on("acquireLock", (request, observer) -> { + observer.onNext(withStatus(LockStatus.QUEUED)); + later.schedule(() -> observer.onNext(withStatus(LockStatus.QUEUED)), 30, TimeUnit.MILLISECONDS); + later.schedule(() -> observer.onNext(withStatus(LockStatus.QUEUED)), 60, TimeUnit.MILLISECONDS); + later.schedule(() -> reply(observer, acquired("lock-1", 5)), 90, TimeUnit.MILLISECONDS); + }); + } + + private static AcquireLockOptions.Builder patient() { + return AcquireLockOptions.builder().timeoutMs(5000L); + } + + @Test + void notifiesOnceEvenWhenTheEngineSendsSeveralKeepAliveQueued() { + queueThenGrant(); + AtomicInteger notices = new AtomicInteger(); + + LockHolder lock = client.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, + patient().onQueued(notices::incrementAndGet).build()); + + assertThat(notices).hasValue(1); + assertThat(lock).isEqualTo(new LockHolder("lock-1", OptionalLong.of(5), dev.caerus.sdk.dls.LockStatus.ACQUIRED)); + } + + @Test + void theNoticeArrivesBeforeTheGrantNeverAfter() { + queueThenGrant(); + List order = new CopyOnWriteArrayList<>(); + + client.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, patient().onQueued(() -> order.add("en cola")).build()); + order.add("concedido"); + + assertThat(order).containsExactly("en cola", "concedido"); + } + + @Test + void doesNotNotifyWhenTheLockIsGrantedRightAway() { + engine.on("acquireLock", (request, observer) -> + reply(observer, acquired("lock-1", 5))); + AtomicInteger notices = new AtomicInteger(); + + client.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, + AcquireLockOptions.builder().onQueued(notices::incrementAndGet).build()); + + assertThat(notices).hasValue(0); + } + + @Test + void ifTheCallbackThrowsTheAcquisitionCarriesOnAndItIsLogged() { + queueThenGrant(); + DlsTestClients.Recorder logger = new DlsTestClients.Recorder(); + + try (DlsClient logged = DlsTestClients.real(engine, logger)) { + LockHolder lock = logged.acquireLock("ns", "k", "tx-1", LockMode.EXCLUSIVE, patient().onQueued(() -> { + throw new IllegalStateException("la interfaz se rompio"); + }).build()); + + assertThat(lock.status()).isEqualTo(dev.caerus.sdk.dls.LockStatus.ACQUIRED); + assertThat(logger.messages()).containsExactly("onQueued callback threw"); + assertThat(logger.details().get(0)[0]).isInstanceOf(IllegalStateException.class); + } + } + + @Test + void worksTheSameInsideWithTransaction() { + engine.on("beginTransaction", (request, observer) -> + reply(observer, BeginTransactionResponse.newBuilder().setTransactionId("tx-9").build())); + engine.on("releaseTransactionLocks", (request, observer) -> + reply(observer, Empty.getDefaultInstance())); + queueThenGrant(); + AtomicInteger notices = new AtomicInteger(); + + dev.caerus.sdk.dls.LockStatus status = client.withTransaction(tx -> tx.acquireLock("ns", "k", LockMode.EXCLUSIVE, + patient().onQueued(notices::incrementAndGet).build()).status(), + TransactionOptions.builder().autoRenew(false).build()); + + assertThat(status).isEqualTo(dev.caerus.sdk.dls.LockStatus.ACQUIRED); + assertThat(notices).hasValue(1); + assertThat(engine.lastRequest(ReleaseTransactionLocksRequest.class).getTransactionId()).isEqualTo("tx-9"); + } + + @Test + void theInMemoryClientAcceptsTheOptionWithoutBreaking() { + InMemoryDlsClient memory = new InMemoryDlsClient(); + Transaction tx = memory.beginTransaction(); + AtomicInteger notices = new AtomicInteger(); + + LockHolder lock = memory.acquireLock("ns", "k", tx.transactionId(), LockMode.EXCLUSIVE, + AcquireLockOptions.builder().onQueued(notices::incrementAndGet).build()); + + assertThat(lock.status()).isEqualTo(dev.caerus.sdk.dls.LockStatus.ACQUIRED); + assertThat(notices).hasValue(0); + } +} diff --git a/src/test/java/dev/caerus/sdk/dls/DlsTestClients.java b/src/test/java/dev/caerus/sdk/dls/DlsTestClients.java new file mode 100644 index 0000000..202b43e --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsTestClients.java @@ -0,0 +1,46 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.CaerusLogger; +import dev.caerus.sdk.support.FakeDlsEngine; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +final class DlsTestClients { + + private DlsTestClients() { + } + + static DlsClient real(FakeDlsEngine engine) { + return real(engine, null); + } + + static DlsClient real(FakeDlsEngine engine, CaerusLogger logger) { + return new DlsClient(DlsClient.resolve(DlsClientOptions.builder() + .endpoint(engine.endpoint()) + .apiKey("test-key") + .tls(false) + .logger(logger) + .build(), name -> null)); + } + + static final class Recorder implements CaerusLogger { + + private final List messages = new CopyOnWriteArrayList<>(); + private final List details = new CopyOnWriteArrayList<>(); + + @Override + public void error(String message, Object... rest) { + messages.add(message); + details.add(rest); + } + + List messages() { + return messages; + } + + List details() { + return details; + } + } +} diff --git a/src/test/java/dev/caerus/sdk/internal/GeneratedContractTest.java b/src/test/java/dev/caerus/sdk/internal/GeneratedContractTest.java new file mode 100644 index 0000000..9494212 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/internal/GeneratedContractTest.java @@ -0,0 +1,89 @@ +package dev.caerus.sdk.internal; + +import dev.caerus.sdk.internal.proto.dls.DistributedLockingEngineGrpc; +import dev.caerus.sdk.internal.proto.sre.ExtendRequest; +import dev.caerus.sdk.internal.proto.sre.SharedResourceEngineGrpc; +import io.grpc.MethodDescriptor; +import io.grpc.ServiceDescriptor; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeneratedContractTest { + + private static List methods(ServiceDescriptor service) { + return service.getMethods().stream() + .map(MethodDescriptor::getBareMethodName) + .sorted() + .collect(Collectors.toList()); + } + + @Test + void theSreClientExposesEveryRpcTheServiceDeclares() { + assertThat(methods(SharedResourceEngineGrpc.getServiceDescriptor())).containsExactly( + "Confirm", + "CreateResource", + "DeleteResource", + "Extend", + "GetResource", + "GetResourceHolder", + "GetResourceHoldersList", + "GetResourcesByGroupKey", + "Release", + "Take", + "UpdateResource"); + } + + @Test + void theDlsClientExposesEveryRpcTheServiceDeclares() { + assertThat(methods(DistributedLockingEngineGrpc.getServiceDescriptor())).containsExactly( + "AcquireLock", + "BeginTransaction", + "GetLockStatus", + "GetTransactionStatus", + "ReleaseLock", + "ReleaseTransactionLocks", + "RenewTransaction"); + } + + @Test + void pointsAtTheRightPackageAndPath() { + assertThat(SharedResourceEngineGrpc.getTakeMethod().getFullMethodName()) + .isEqualTo("caerus.sre.v1.SharedResourceEngine/Take"); + assertThat(DistributedLockingEngineGrpc.getAcquireLockMethod().getFullMethodName()) + .isEqualTo("caerus.dls.v1.DistributedLockingEngine/AcquireLock"); + assertThat(DistributedLockingEngineGrpc.getAcquireLockMethod().getType()) + .isEqualTo(MethodDescriptor.MethodType.SERVER_STREAMING); + } + + @Test + void takesTheExtensionInMilliseconds() { + assertThat(ExtendRequest.getDescriptor().findFieldByName("extra_ms")).isNotNull(); + assertThat(ExtendRequest.getDescriptor().findFieldByName("extra_seconds")).isNull(); + } + + @Test + void theProtoCopiesMatchTheChecksumsInTheirReadme() throws IOException, NoSuchAlgorithmException { + String readme = Files.readString(Path.of("proto", "README.md")); + for (String file : List.of("sre_service.proto", "dls_service.proto")) { + byte[] content = Files.readAllBytes(Path.of("proto", file)); + String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + assertThat(readme).as(file).contains(sha); + } + } + + @Test + void theGeneratedCodeLivesInTheInternalPackage() { + assertThat(SharedResourceEngineGrpc.class.getPackageName()).isEqualTo("dev.caerus.sdk.internal.proto.sre"); + assertThat(DistributedLockingEngineGrpc.class.getPackageName()).isEqualTo("dev.caerus.sdk.internal.proto.dls"); + } +} diff --git a/src/test/java/dev/caerus/sdk/internal/RequestIdTest.java b/src/test/java/dev/caerus/sdk/internal/RequestIdTest.java new file mode 100644 index 0000000..4566b90 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/internal/RequestIdTest.java @@ -0,0 +1,104 @@ +package dev.caerus.sdk.internal; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.CaerusErrorOptions; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.ErrorReason; +import dev.caerus.sdk.dls.DlsError; +import dev.caerus.sdk.sre.OutOfStockError; +import dev.caerus.sdk.support.GrpcErrors; +import io.grpc.Metadata; +import io.grpc.Status; +import org.junit.jupiter.api.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequestIdTest { + + @Test + void readsTheRequestIdFromTheTrailers() { + Throwable error = GrpcErrors.withTrailers(Status.Code.NOT_FOUND, "x", GrpcErrors.requestIdTrailers("req_abc-123")); + + assertThat(ErrorDetails.requestIdOf(error)).contains("req_abc-123"); + } + + @Test + void takesTheFirstWhenTheServerSentSeveral() { + Metadata trailers = new Metadata(); + trailers.put(ErrorDetails.REQUEST_ID_KEY, "req_first-0001"); + trailers.put(ErrorDetails.REQUEST_ID_KEY, "req_second-002"); + Throwable error = GrpcErrors.withTrailers(Status.Code.NOT_FOUND, "x", trailers); + + assertThat(ErrorDetails.requestIdOf(error)).contains("req_first-0001"); + } + + @Test + void isEmptyWhenTheTrailersHaveNoRequestId() { + assertThat(ErrorDetails.requestIdOf(GrpcErrors.status(Status.Code.NOT_FOUND, "x"))).isEmpty(); + } + + @Test + void isEmptyWhenTheErrorHasNoTrailers() { + assertThat(ErrorDetails.requestIdOf(new RuntimeException("Simple error"))).isEmpty(); + assertThat(ErrorDetails.requestIdOf(null)).isEmpty(); + } + + @Test + void enrichesTheMessageWithTheRequestIdAndTheSupportCopy() { + CaerusError error = new CaerusError("Stock exhausted", ErrorCode.CONFLICT, CaerusErrorOptions.builder() + .reason(ErrorReason.OUT_OF_STOCK) + .requestId("req_3fa85f64-5717-4562-b3fc-2c963f66afa6") + .build()); + + assertThat(error.requestId()).contains("req_3fa85f64-5717-4562-b3fc-2c963f66afa6"); + assertThat(error.reason()).contains("OUT_OF_STOCK"); + assertThat(error.getMessage()) + .contains("Stock exhausted") + .contains("(Request ID: req_3fa85f64-5717-4562-b3fc-2c963f66afa6)") + .contains("Contacta soporte en Discord con este ID."); + } + + @Test + void doesNotRepeatARequestIdTheMessageAlreadyHas() { + CaerusError error = new CaerusError( + "Failed (Request ID: req_123). Contacta soporte en Discord con este ID.", + ErrorCode.UNKNOWN, + CaerusErrorOptions.builder().requestId("req_123").build()); + + Matcher matcher = Pattern.compile("req_123").matcher(error.getMessage()); + int count = 0; + while (matcher.find()) { + count++; + } + assertThat(count).isEqualTo(1); + } + + @Test + void leavesTheMessageAloneWithoutARequestId() { + assertThat(new CaerusError("plain").getMessage()).isEqualTo("plain"); + } + + @Test + void pointsAtTheErrorGuide() { + String expected = "https://github.com/caerus-dev/caerus-sdk-java/blob/main/docs/errores.md"; + assertThat(new OutOfStockError("Out of stock").docUrl()).isEqualTo(expected); + assertThat(new CaerusError("Something failed").docUrl()).isEqualTo(expected); + } + + @Test + void keepsTheCause() { + RuntimeException cause = new RuntimeException("underneath"); + CaerusError error = new CaerusError("on top", ErrorCode.UNKNOWN, CaerusErrorOptions.builder().cause(cause).build()); + + assertThat(error.getCause()).isSameAs(cause); + } + + @Test + void isUncheckedSoOneCatchTakesThemAll() { + assertThat(RuntimeException.class).isAssignableFrom(CaerusError.class); + assertThat(CaerusError.class).isAssignableFrom(DlsError.class); + } +} diff --git a/src/test/java/dev/caerus/sdk/live/Live.java b/src/test/java/dev/caerus/sdk/live/Live.java new file mode 100644 index 0000000..e713999 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/live/Live.java @@ -0,0 +1,64 @@ +package dev.caerus.sdk.live; + +import dev.caerus.sdk.CaerusSdk; +import dev.caerus.sdk.dls.DlsClient; +import dev.caerus.sdk.dls.DlsClientOptions; +import dev.caerus.sdk.sre.CaerusClient; +import dev.caerus.sdk.sre.CaerusClientOptions; +import org.junit.jupiter.api.Assumptions; + +final class Live { + + private Live() { + } + + static String env(String name, String fallback) { + String value = System.getenv(name); + return value == null || value.isBlank() ? fallback : value.trim(); + } + + static String apiKey() { + String key = System.getenv("CAERUS_API_KEY"); + Assumptions.assumeTrue(key != null && !key.isBlank(), "Falta CAERUS_API_KEY"); + return key; + } + + static String endpoint() { + return env("CAERUS_ENDPOINT", CaerusSdk.DEFAULT_ENDPOINT); + } + + static boolean tls() { + String raw = env("CAERUS_TLS", "true").toLowerCase(java.util.Locale.ROOT); + return !(raw.equals("false") || raw.equals("0")); + } + + static DlsClient dls() { + return new DlsClient(DlsClientOptions.builder().apiKey(apiKey()).endpoint(endpoint()).tls(tls()).build()); + } + + static DlsClient dls(long timeoutMs) { + return new DlsClient(DlsClientOptions.builder() + .apiKey(apiKey()).endpoint(endpoint()).tls(tls()).timeoutMs(timeoutMs).build()); + } + + static CaerusClient sre() { + return new CaerusClient(CaerusClientOptions.builder().apiKey(apiKey()).endpoint(endpoint()).tls(tls()).build()); + } + + static String key(String name) { + return "verifjava_" + System.currentTimeMillis() + "_" + name; + } + + static void log(String check, String detail) { + System.out.println("[en vivo] " + check + " | " + detail); + } + + static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java new file mode 100644 index 0000000..1ab5360 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java @@ -0,0 +1,367 @@ +package dev.caerus.sdk.live; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.dls.AcquireLockOptions; +import dev.caerus.sdk.dls.DeadlockAbortedError; +import dev.caerus.sdk.dls.DlsClient; +import dev.caerus.sdk.dls.LockDeniedError; +import dev.caerus.sdk.dls.LockHolder; +import dev.caerus.sdk.dls.LockMode; +import dev.caerus.sdk.dls.LockStatus; +import dev.caerus.sdk.dls.LockStatusResponse; +import dev.caerus.sdk.dls.Transaction; +import dev.caerus.sdk.dls.TransactionOptions; +import dev.caerus.sdk.dls.TransactionStatusResponse; +import dev.caerus.sdk.dls.BeginTransactionOptions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Tag("live") +class LiveDlsTest { + + private static final String NS_FAIL = Live.env("DLS_NS_FAIL", "cuenta"); + private static final String NS_QUEUE = Live.env("DLS_NS_QUEUE", "cuenta_cola"); + private static final String NS_ALERT = Live.env("DLS_NS_ALERT", "cuenta_alerta"); + private static final String NS_READ_WRITE = Live.env("DLS_NS_READ_WRITE", "archivo"); + + private static DlsClient dls; + + @BeforeAll + static void connect() { + dls = Live.dls(); + } + + @AfterAll + static void disconnect() { + if (dls != null) { + dls.close(); + } + } + + private static AcquireLockOptions.Builder idem() { + return AcquireLockOptions.builder().idempotencyKey(UUID.randomUUID().toString()); + } + + private static Transaction begin(long timeoutMs) { + return dls.beginTransaction(BeginTransactionOptions.builder().timeoutMs(timeoutMs).build()); + } + + private static void releaseQuietly(Transaction... transactions) { + for (Transaction tx : transactions) { + try { + dls.releaseTransactionLocks(tx.transactionId()); + } catch (CaerusError ignored) { + } + } + } + + private static Throwable outcome(CompletableFuture future) { + try { + future.join(); + return null; + } catch (CompletionException e) { + return e.getCause(); + } + } + + private static CompletableFuture crossing(String namespace, String first, String second, + long pauseMs, long waitMs, long lifetimeMs) { + return CompletableFuture.supplyAsync(() -> dls.withTransaction(tx -> { + tx.acquireLock(namespace, first, LockMode.EXCLUSIVE, idem().build()); + Live.sleep(pauseMs); + return tx.acquireLock(namespace, second, LockMode.EXCLUSIVE, idem().timeoutMs(waitMs).build()); + }, TransactionOptions.builder().timeoutMs(lifetimeMs).build())); + } + + @Test + void sharedReadAndLockStatus() { + String file = Live.key("archivo"); + Transaction tx1 = begin(20_000); + Transaction tx2 = begin(20_000); + Transaction tx3 = begin(20_000); + + try { + LockHolder l1 = dls.acquireLock(NS_READ_WRITE, file, tx1.transactionId(), LockMode.SHARED_READ, idem().build()); + LockHolder l2 = dls.acquireLock(NS_READ_WRITE, file, tx2.transactionId(), LockMode.SHARED_READ, idem().build()); + Live.log("dos lectores entran a la vez", l1.status() + " " + l2.status()); + assertThat(l1.status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(l2.status()).isEqualTo(LockStatus.ACQUIRED); + + LockStatusResponse status = dls.getLockStatus(NS_READ_WRITE, file); + Live.log("el estado dice SHARED_READ y lista a los dos", status.currentMode() + " " + status.activeHolders().size()); + assertThat(status.currentMode()).contains(LockMode.SHARED_READ); + assertThat(status.activeHolders()).hasSize(2); + assertThat(status.activeHolders()).allSatisfy(holder -> assertThat(holder.fencingToken()).isPresent()); + + Throwable writer = catchThrowable(() -> dls.acquireLock(NS_READ_WRITE, file, tx3.transactionId(), + LockMode.EXCLUSIVE, idem().timeoutMs(4000L).build())); + Live.log("el escritor no entra con lectores activos", writer == null ? "entro" : writer.getClass().getSimpleName()); + assertThat(writer).isInstanceOf(CaerusError.class); + + dls.releaseLock(l1.lockId(), tx1.transactionId()); + LockStatusResponse after = dls.getLockStatus(NS_READ_WRITE, file); + Live.log("soltar un lector deja al otro", String.valueOf(after.activeHolders().size())); + assertThat(after.activeHolders()).hasSize(1); + } finally { + releaseQuietly(tx1, tx2, tx3); + } + } + + @Test + void deniedLockInAFailNamespace() { + String account = Live.key("cuenta"); + + dls.withTransaction(tx1 -> { + LockHolder own = tx1.acquireLock(NS_FAIL, account, LockMode.EXCLUSIVE, idem().build()); + Live.log("el primero lo toma, con fencing token", own.status() + " " + own.fencingToken()); + assertThat(own.status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(own.fencingToken()).isPresent(); + + dls.withTransaction(tx2 -> { + Throwable error = catchThrowable(() -> + tx2.acquireLock(NS_FAIL, account, LockMode.EXCLUSIVE, idem().timeoutMs(5000L).build())); + Live.log("el segundo recibe LockDeniedError con LOCK_DENIED", String.valueOf(error)); + assertThat(error).isInstanceOf(LockDeniedError.class); + assertThat(((LockDeniedError) error).reason()).contains("LOCK_DENIED"); + return null; + }, TransactionOptions.builder().timeoutMs(8000L).build()); + return null; + }, TransactionOptions.builder().timeoutMs(15_000L).build()); + } + + @Test + void twoWayDeadlockWithKillPriority() { + String x = Live.key("X"); + String y = Live.key("Y"); + long started = System.nanoTime(); + + CompletableFuture first = crossing(NS_QUEUE, x, y, 1500, 30_000, 30_000); + Live.sleep(700); + CompletableFuture second = crossing(NS_QUEUE, y, x, 300, 30_000, 30_000); + List victims = java.util.stream.Stream.of(outcome(first), outcome(second)) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); + double seconds = (System.nanoTime() - started) / 1e9; + + Live.log("una victima y un ganador", victims.size() + " victimas en " + String.format("%.1f", seconds) + "s"); + assertThat(victims).hasSize(1); + assertThat(victims.get(0)).isInstanceOf(DeadlockAbortedError.class); + assertThat(((CaerusError) victims.get(0)).reason()).contains("DEADLOCK_DETECTED"); + assertThat(((CaerusError) victims.get(0)).requestId()).isPresent(); + assertThat(outcome(second)).as("la segunda, la mas joven, es la victima").isNotNull(); + assertThat(seconds).isLessThan(10); + } + + @Test + void triangularDeadlock() { + String x = Live.key("TX"); + String y = Live.key("TY"); + String z = Live.key("TZ"); + + List> runs = List.of( + crossing(NS_QUEUE, x, y, 400, 30_000, 30_000), + crossing(NS_QUEUE, y, z, 400, 30_000, 30_000), + crossing(NS_QUEUE, z, x, 400, 30_000, 30_000)); + List victims = runs.stream().map(LiveDlsTest::outcome) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); + + Live.log("se rompe el ciclo sin matar a todos", victims.size() + " de 3"); + assertThat(victims).hasSizeBetween(1, 2); + assertThat(victims).allSatisfy(victim -> assertThat(((CaerusError) victim).reason()).contains("DEADLOCK_DETECTED")); + } + + @Test + void deadlockWithAlertStrategyAbortsNobody() { + String x = Live.key("LX"); + String y = Live.key("LY"); + + List results = java.util.stream.Stream.of( + crossing(NS_ALERT, x, y, 300, 6000, 20_000), + crossing(NS_ALERT, y, x, 300, 6000, 20_000)) + .map(LiveDlsTest::outcome) + .collect(Collectors.toList()); + long aborted = results.stream() + .filter(error -> error instanceof CaerusError caerus && caerus.reason().orElse("").equals("DEADLOCK_DETECTED")) + .count(); + + Live.log("nadie fue abortado por deadlock", aborted + " abortadas, " + results); + assertThat(aborted).isZero(); + for (Throwable error : results) { + if (error != null) { + assertThat(error).as("solo puede cortar por tiempo").isInstanceOf(dev.caerus.sdk.dls.DlsTimeoutError.class); + } + } + } + + @Test + void transactionStatusWithALockInTheQueueAndOnQueued() { + String key = Live.key("cola"); + Transaction tx1 = begin(20_000); + Transaction tx2 = begin(20_000); + + try { + dls.acquireLock(NS_QUEUE, key, tx1.transactionId(), LockMode.EXCLUSIVE, idem().build()); + AtomicInteger notices = new AtomicInteger(); + AtomicBoolean grantedBeforeNotice = new AtomicBoolean(); + List order = new CopyOnWriteArrayList<>(); + CompletableFuture waiting = CompletableFuture.supplyAsync(() -> { + LockHolder lock = dls.acquireLock(NS_QUEUE, key, tx2.transactionId(), LockMode.EXCLUSIVE, idem() + .timeoutMs(8000L) + .onQueued(() -> { + notices.incrementAndGet(); + order.add("en cola"); + }) + .build()); + grantedBeforeNotice.set(notices.get() == 0); + order.add("concedido"); + return lock; + }); + Live.sleep(1500); + + TransactionStatusResponse holding = dls.getTransactionStatus(tx1.transactionId()); + TransactionStatusResponse queued = dls.getTransactionStatus(tx2.transactionId()); + Live.log("las dos transacciones informan su estado", holding.status() + " / " + queued.status()); + assertThat(holding.status()).isNotBlank(); + assertThat(queued.status()).isNotBlank(); + + dls.releaseTransactionLocks(tx1.transactionId()); + LockHolder granted = waiting.join(); + + Live.log("onQueued avisa una vez y antes de conceder", notices.get() + " avisos, orden " + order); + assertThat(granted.status()).isEqualTo(LockStatus.ACQUIRED); + assertThat(notices).hasValue(1); + assertThat(grantedBeforeNotice).isFalse(); + assertThat(order).containsExactly("en cola", "concedido"); + } finally { + releaseQuietly(tx1, tx2); + } + } + + @Test + void aQueuedLockWithoutItsOwnTimeoutUsesTheClientDeadline() { + String key = Live.key("plazo"); + Transaction tx1 = begin(30_000); + Transaction tx2 = begin(30_000); + + try (DlsClient impatient = Live.dls(2000L)) { + dls.acquireLock(NS_QUEUE, key, tx1.transactionId(), LockMode.EXCLUSIVE, idem().build()); + long started = System.nanoTime(); + Throwable error = catchThrowable(() -> + impatient.acquireLock(NS_QUEUE, key, tx2.transactionId(), LockMode.EXCLUSIVE, idem().build())); + double seconds = (System.nanoTime() - started) / 1e9; + + Live.log("sin timeout propio corta con el del cliente", error + " en " + String.format("%.1f", seconds) + "s"); + assertThat(error).isInstanceOf(dev.caerus.sdk.dls.DlsTimeoutError.class); + assertThat(seconds).isBetween(1.5, 5.0); + } finally { + releaseQuietly(tx1, tx2); + } + } + + @Test + void aClosedTransactionCannotTakeLocks() { + Transaction tx = begin(20_000); + dls.releaseTransactionLocks(tx.transactionId()); + + Throwable error = catchThrowable(() -> + dls.acquireLock(NS_QUEUE, Live.key("cerrada"), tx.transactionId(), LockMode.EXCLUSIVE, idem().build())); + + Live.log("una transaccion cerrada no toma locks", String.valueOf(error)); + assertThat(error).isInstanceOf(CaerusError.class); + assertThat(((CaerusError) error).reason()).contains("TRANSACTION_NOT_ACTIVE"); + } + + @Test + void withTransactionRenewsALongJob() { + String key = Live.key("largo"); + + long[] expiresAt = new long[2]; + String result = dls.withTransaction(tx -> { + tx.acquireLock(NS_QUEUE, key, LockMode.EXCLUSIVE, idem().build()); + expiresAt[0] = dls.getTransactionStatus(tx.transactionId()).expiresAt(); + Live.sleep(7000); + TransactionStatusResponse status = dls.getTransactionStatus(tx.transactionId()); + expiresAt[1] = status.expiresAt(); + return status.status(); + }, TransactionOptions.builder().timeoutMs(4000L).build()); + + Live.log("una transaccion de 4 s sigue viva a los 7 s y su vencimiento avanza", + result + ", +" + (expiresAt[1] - expiresAt[0]) + " ms"); + assertThat(result).isEqualTo("ACTIVE"); + assertThat(expiresAt[1] - expiresAt[0]).isGreaterThanOrEqualTo(8000); + } + + @Test + void aWrongApiKeyIsAnAuthenticationError() { + try (DlsClient stranger = new DlsClient(dev.caerus.sdk.dls.DlsClientOptions.builder() + .apiKey("caer_dev_no_existe_" + UUID.randomUUID()) + .endpoint(Live.endpoint()) + .tls(Live.tls()) + .build())) { + Throwable error = catchThrowable(stranger::beginTransaction); + + Live.log("una API Key que no existe da error de autenticacion", String.valueOf(error)); + assertThat(error).isInstanceOf(dev.caerus.sdk.dls.DlsAuthenticationError.class); + assertThat(((CaerusError) error).requestId()).isPresent(); + } + } + + @Test + void anEngineErrorCarriesARequestId() { + Throwable error = catchThrowable(() -> dls.renewTransaction(UUID.randomUUID().toString(), 1000)); + + Live.log("el error trae el requestId", String.valueOf(error)); + assertThat(error).isInstanceOf(CaerusError.class); + assertThat(((CaerusError) error).requestId()).hasValueSatisfying(id -> assertThat(id).matches("^req_[0-9a-f-]{36}$")); + } + + @Test + void anIdempotencyKeyReplaysTheLockAndAnotherKeyIsAlreadyHeld() { + String key = Live.key("idem"); + + dls.withTransaction(tx -> { + AcquireLockOptions same = idem().build(); + LockHolder first = tx.acquireLock(NS_QUEUE, key, LockMode.EXCLUSIVE, same); + LockHolder again = tx.acquireLock(NS_QUEUE, key, LockMode.EXCLUSIVE, same); + Throwable other = catchThrowable(() -> tx.acquireLock(NS_QUEUE, key, LockMode.EXCLUSIVE, idem().build())); + + Live.log("la misma clave devuelve el mismo lock y otra clave da LOCK_ALREADY_HELD_EXCLUSIVELY", + first.equals(again) + " / " + other); + assertThat(again).isEqualTo(first); + assertThat(other).isInstanceOf(dev.caerus.sdk.dls.LockAlreadyHeldError.class); + assertThat(((CaerusError) other).reason()).contains("LOCK_ALREADY_HELD_EXCLUSIVELY"); + return null; + }, TransactionOptions.builder().timeoutMs(15_000L).build()); + } + + @Test + void fencingTokensGrowAcrossHandoffs() { + String key = Live.key("fencing"); + List tokens = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < 3; i++) { + dls.withTransaction(tx -> { + tokens.add(tx.acquireLock(NS_QUEUE, key, LockMode.EXCLUSIVE, idem().build()).fencingToken().orElseThrow()); + return null; + }, TransactionOptions.builder().timeoutMs(10_000L).autoRenew(false).build()); + } + + Live.log("los fencing tokens crecen", tokens.toString()); + assertThat(tokens).isSorted().doesNotHaveDuplicates(); + } +} diff --git a/src/test/java/dev/caerus/sdk/live/LiveSreTest.java b/src/test/java/dev/caerus/sdk/live/LiveSreTest.java new file mode 100644 index 0000000..ac3dae6 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/live/LiveSreTest.java @@ -0,0 +1,168 @@ +package dev.caerus.sdk.live; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.sre.AuthenticationError; +import dev.caerus.sdk.sre.CaerusClient; +import dev.caerus.sdk.sre.CaerusClientOptions; +import dev.caerus.sdk.sre.ConfirmOptions; +import dev.caerus.sdk.sre.CreateResourceOptions; +import dev.caerus.sdk.sre.ListResourceHoldersOptions; +import dev.caerus.sdk.sre.OutOfStockError; +import dev.caerus.sdk.sre.Resource; +import dev.caerus.sdk.sre.ResourceHasActiveHoldsError; +import dev.caerus.sdk.sre.ResourceHolder; +import dev.caerus.sdk.sre.ResourceHolderPage; +import dev.caerus.sdk.sre.ResourceHolderStatus; +import dev.caerus.sdk.sre.ResourceNotFoundError; +import dev.caerus.sdk.sre.ResourcePage; +import dev.caerus.sdk.sre.TakeOptions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Tag("live") +class LiveSreTest { + + private static final String TEMPLATE = System.getenv("SRE_TEMPLATE"); + + private static CaerusClient caerus; + + @BeforeAll + static void connect() { + Assumptions.assumeTrue(TEMPLATE != null && !TEMPLATE.isBlank(), "Falta SRE_TEMPLATE"); + caerus = Live.sre(); + } + + @AfterAll + static void disconnect() { + if (caerus != null) { + caerus.close(); + } + } + + private static TakeOptions.Builder idem() { + return TakeOptions.builder().idempotencyKey(UUID.randomUUID().toString()); + } + + @Test + void theWholeLifeOfAPooledResource() { + String key = Live.key("pool"); + String group = Live.key("grupo"); + + Resource created = caerus.createMultiple(TEMPLATE, key, 3, CreateResourceOptions.builder() + .groupKey(group) + .metadata(Map.of("zona", "platea")) + .build()); + Live.log("crea el recurso", created.key() + " " + created.availableAmount() + " " + created.groupKey()); + assertThat(created.availableAmount()).isEqualTo(3); + assertThat(created.groupKey()).contains(group); + + try { + String idempotencyKey = UUID.randomUUID().toString(); + ResourceHolder first = caerus.pooled(key).takeMany(2, TakeOptions.builder() + .idempotencyKey(idempotencyKey) + .metadata(Map.of("orden", "A-1", "items", 2)) + .build()); + ResourceHolder replay = caerus.pooled(key).takeMany(2, TakeOptions.builder() + .idempotencyKey(idempotencyKey) + .build()); + Live.log("la misma clave devuelve el mismo holder", first.id() + " / " + replay.id()); + assertThat(first.status()).isEqualTo(ResourceHolderStatus.PENDING); + assertThat(replay.id()).isEqualTo(first.id()); + assertThat(caerus.getResource(key).availableAmount()).isEqualTo(1); + + Instant expiresAt = first.expiresAt(); + Live.log("el vencimiento es una fecha razonable", expiresAt.toString()); + assertThat(expiresAt).isAfter(Instant.now()).isBefore(Instant.now().plus(Duration.ofDays(2))); + + ResourceHolder extended = caerus.extend(first.id(), 1500); + Live.log("extend redondea a segundos hacia arriba", Duration.between(expiresAt, extended.expiresAt()).toString()); + assertThat(Duration.between(expiresAt, extended.expiresAt())).isEqualTo(Duration.ofSeconds(2)); + + ResourceHolder second = caerus.pooled(key).take(idem().build()); + Throwable outOfStock = catchThrowable(() -> caerus.pooled(key).take(idem().build())); + Live.log("sin stock llega OutOfStockError", String.valueOf(outOfStock)); + assertThat(outOfStock).isInstanceOf(OutOfStockError.class); + assertThat(((CaerusError) outOfStock).reason()).contains("OUT_OF_STOCK"); + + ResourceHolder confirmed = caerus.confirm(first.id(), ConfirmOptions.builder() + .metadata(Map.of("pago", "pay_9")) + .build()); + Live.log("confirm devuelve CONFIRMED", confirmed.status().name()); + assertThat(confirmed.status()).isEqualTo(ResourceHolderStatus.CONFIRMED); + + Throwable busy = catchThrowable(() -> caerus.deleteResource(key)); + Live.log("no deja borrar con holders activos", String.valueOf(busy)); + assertThat(busy).isInstanceOf(ResourceHasActiveHoldsError.class); + + caerus.release(second.id()); + assertThat(caerus.getResourceHolder(second.id()).status()).isEqualTo(ResourceHolderStatus.RELEASED); + Throwable replayReleased = catchThrowable(() -> caerus.extend(second.id(), 1000)); + Live.log("un holder liberado no se puede extender", String.valueOf(replayReleased)); + assertThat(replayReleased).isInstanceOf(CaerusError.class); + + ResourceHolderPage holders = caerus.listResourceHolders(ListResourceHoldersOptions.builder() + .resourceKey(key) + .build()); + Live.log("lista los holders del recurso", String.valueOf(holders.holders().size())); + assertThat(holders.holders()).extracting(ResourceHolder::id).contains(first.id(), second.id()); + + ResourcePage page = waitForGroup(group); + Live.log("el grupo encuentra el recurso", String.valueOf(page.resources().size())); + assertThat(page.resources()).extracting(Resource::key).contains(key); + + Resource updated = caerus.updateResource(key, 4, + dev.caerus.sdk.sre.UpdateResourceOptions.builder().idempotencyKey(UUID.randomUUID().toString()).build()); + Live.log("updateResource suma stock", String.valueOf(updated.availableAmount())); + assertThat(updated.availableAmount()).isEqualTo(caerus.getResource(key).availableAmount()); + } finally { + try { + caerus.deleteResource(key); + } catch (CaerusError ignored) { + } + } + } + + @Test + void aResourceThatDoesNotExistIsNotFound() { + Throwable error = catchThrowable(() -> caerus.getResource(Live.key("no_existe"))); + + Live.log("un recurso inexistente da ResourceNotFoundError", String.valueOf(error)); + assertThat(error).isInstanceOf(ResourceNotFoundError.class); + assertThat(((CaerusError) error).requestId()).isPresent(); + } + + @Test + void aWrongApiKeyIsAnAuthenticationError() { + try (CaerusClient stranger = new CaerusClient(CaerusClientOptions.builder() + .apiKey("caer_dev_no_existe_" + UUID.randomUUID()) + .endpoint(Live.endpoint()) + .tls(Live.tls()) + .build())) { + Throwable error = catchThrowable(() -> stranger.getResource("x")); + + Live.log("una API Key que no existe da error de autenticacion", String.valueOf(error)); + assertThat(error).isInstanceOf(AuthenticationError.class); + } + } + + private static ResourcePage waitForGroup(String group) { + long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + ResourcePage page = caerus.getResourcesByGroup(group); + while (page.resources().isEmpty() && System.nanoTime() < deadline) { + Live.sleep(500); + page = caerus.getResourcesByGroup(group); + } + return page; + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/ClientOptionsTest.java b/src/test/java/dev/caerus/sdk/sre/ClientOptionsTest.java new file mode 100644 index 0000000..0a6d025 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/ClientOptionsTest.java @@ -0,0 +1,162 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusSdk; +import dev.caerus.sdk.internal.ClientSettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ClientOptionsTest { + + private static final String KEY = "no-es-una-clave"; + + private static UnaryOperator env(Map values) { + return values::get; + } + + private static UnaryOperator noEnv() { + return name -> null; + } + + private static CaerusClientOptions.Builder base() { + return CaerusClientOptions.builder().endpoint("localhost:9090").apiKey("k"); + } + + @Test + void refusesNoOptionsAtAll() { + assertThatThrownBy(() -> new CaerusClient((CaerusClientOptions) null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesAnEmptyBuilder() { + assertThatThrownBy(() -> new CaerusClient(CaerusClientOptions.builder().build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesABlankApiKey() { + assertThatThrownBy(() -> new CaerusClient(CaerusClientOptions.builder().apiKey(" ").build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void saysWhatIsMissingWhenApiKeyIsAbsent() { + assertThatThrownBy(() -> new CaerusClient(CaerusClientOptions.builder().endpoint("localhost:9090").build())) + .hasMessageContaining("apiKey"); + } + + @Test + void keepsTheApiKeyOutOfEverythingThatGetsPrinted() { + CaerusClientOptions.Builder builder = CaerusClientOptions.builder().endpoint("localhost:9090").apiKey(KEY); + CaerusClientOptions options = builder.build(); + ClientSettings settings = CaerusClient.resolve(options, noEnv()); + + try (CaerusClient client = new CaerusClient(settings)) { + assertThat(client.toString()).doesNotContain(KEY); + assertThat(options.toString()).doesNotContain(KEY); + assertThat(builder.toString()).doesNotContain(KEY); + assertThat(settings.toString()).doesNotContain(KEY); + } + } + + @Test + void needsNothingButAnApiKeyToReachTheHostedCaerus() { + ClientSettings settings = CaerusClient.resolve(CaerusClientOptions.builder().apiKey(KEY).build(), noEnv()); + try (CaerusClient client = new CaerusClient(settings)) { + assertThat(client.endpoint()).isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + } + } + + @ParameterizedTest + @ValueSource(longs = {0, -1}) + void refusesANonPositiveTimeout(long timeoutMs) { + assertThatThrownBy(() -> new CaerusClient(CaerusClientOptions.builder().apiKey(KEY).timeoutMs(timeoutMs).build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void trimsTheApiKey() { + ClientSettings settings = CaerusClient.resolve(base().apiKey(" k ").build(), noEnv()); + assertThat(settings.apiKey()).isEqualTo("k"); + } + + @Test + void encryptsTheConnectionUnlessToldOtherwise() { + assertThat(CaerusClient.resolve(base().build(), noEnv()).tls()).isTrue(); + assertThat(CaerusClient.resolve(base().tls(false).build(), noEnv()).tls()).isFalse(); + } + + @Test + void putsADeadlineOnCallsEvenWhenNoneIsAskedFor() { + assertThat(CaerusClient.resolve(base().build(), noEnv()).timeoutMs()).isEqualTo(CaerusSdk.DEFAULT_TIMEOUT_MS); + } + + @Test + void prefersAnExplicitEndpointThenTheEnvironmentThenTheBuiltInOne() { + Map values = new HashMap<>(); + values.put("CAERUS_ENDPOINT", "del-entorno:9090"); + + assertThat(CaerusClient.resolve( + CaerusClientOptions.builder().apiKey("k").endpoint("explicito:9090").build(), env(values)).endpoint()) + .isEqualTo("explicito:9090"); + assertThat(CaerusClient.resolve(CaerusClientOptions.builder().apiKey("k").build(), env(values)).endpoint()) + .isEqualTo("del-entorno:9090"); + + values.remove("CAERUS_ENDPOINT"); + assertThat(CaerusClient.resolve(CaerusClientOptions.builder().apiKey("k").build(), env(values)).endpoint()) + .isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + } + + @Test + void ignoresABlankEndpointInTheEnvironment() { + Map values = Map.of("CAERUS_ENDPOINT", " "); + assertThat(CaerusClient.resolve(CaerusClientOptions.builder().apiKey("k").build(), env(values)).endpoint()) + .isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + } + + @Test + void refusesAnExplicitBlankEndpoint() { + assertThatThrownBy(() -> new CaerusClient(CaerusClientOptions.builder().endpoint(" ").apiKey(KEY).build())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void honorsEnvironmentOverridesForEndpointAndTls() { + Map values = Map.of("CAERUS_ENDPOINT", "localhost:9090", "CAERUS_TLS", "false"); + ClientSettings resolved = CaerusClient.resolve(CaerusClientOptions.builder().apiKey("k").build(), env(values)); + assertThat(resolved.endpoint()).isEqualTo("localhost:9090"); + assertThat(resolved.tls()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "FALSE", " false ", "0"}) + void letsAnExplicitFalseOrZeroDisableEncryption(String off) { + assertThat(CaerusClient.resolve(base().build(), env(Map.of("CAERUS_TLS", off))).tls()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "TRUE", "True", "1", "yes", "on", "", "flase"}) + void letsNothingElseDisableEncryption(String on) { + assertThat(CaerusClient.resolve(base().build(), env(Map.of("CAERUS_TLS", on))).tls()).isTrue(); + } + + @Test + void anExplicitTlsOptionWinsOverTheEnvironment() { + assertThat(CaerusClient.resolve(base().tls(true).build(), env(Map.of("CAERUS_TLS", "false"))).tls()).isTrue(); + } + + @Test + void ignoresTheDlsVariables() { + Map values = Map.of("CAERUS_DLS_ENDPOINT", "dls:1", "CAERUS_DLS_TLS", "false"); + ClientSettings resolved = CaerusClient.resolve(CaerusClientOptions.builder().apiKey("k").build(), env(values)); + assertThat(resolved.endpoint()).isEqualTo(CaerusSdk.DEFAULT_ENDPOINT); + assertThat(resolved.tls()).isTrue(); + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/ErrorReasonsTest.java b/src/test/java/dev/caerus/sdk/sre/ErrorReasonsTest.java new file mode 100644 index 0000000..1b941e9 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/ErrorReasonsTest.java @@ -0,0 +1,190 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.internal.ErrorDetails; +import dev.caerus.sdk.support.GrpcErrors; +import dev.caerus.sdk.support.RealTrailers; +import io.grpc.Metadata; +import io.grpc.Status; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.stream.Stream; + +import static dev.caerus.sdk.support.RealTrailers.SRE; +import static dev.caerus.sdk.support.RealTrailers.bytes; +import static dev.caerus.sdk.support.RealTrailers.grpcError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class ErrorReasonsTest { + + static Stream realTrailers() { + return SRE.entrySet().stream().map(entry -> Arguments.of(entry.getKey(), entry.getValue())); + } + + @ParameterizedTest + @MethodSource("realTrailers") + void decodesTheReasonFromARealTrailer(String expected, String payload) { + assertThat(ErrorDetails.decodeReason(bytes(payload))).contains(expected); + } + + @Test + void pullsTheReasonOutOfAGrpcError() { + assertThat(ErrorDetails.reasonOf(grpcError(Status.Code.FAILED_PRECONDITION, "nope", SRE.get("OUT_OF_STOCK")))) + .contains("OUT_OF_STOCK"); + } + + @Test + void saysNothingWhenThereIsNoTrailer() { + assertThat(ErrorDetails.reasonOf(grpcError(Status.Code.FAILED_PRECONDITION, "nope"))).isEmpty(); + } + + static Stream garbage() { + byte[] allSet = new byte[64]; + Arrays.fill(allSet, (byte) 0xff); + return Stream.of( + Arguments.of("empty", new byte[0]), + Arguments.of("a single stray byte", new byte[]{0x1a}), + Arguments.of("a length that runs past the end", new byte[]{0x1a, 0x7f, 0x01}), + Arguments.of("random noise", new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}), + Arguments.of("a truncated real trailer", Arrays.copyOf(bytes(SRE.get("OUT_OF_STOCK")), 20)), + Arguments.of("every byte set", allSet)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("garbage") + void theDecoderRefusesToBecomeASecondFailure(String name, byte[] input) { + assertThatCode(() -> ErrorDetails.decodeReason(input)).doesNotThrowAnyException(); + assertThat(ErrorDetails.decodeReason(input)).isEmpty(); + } + + @Test + void survivesAnErrorWithNoMetadataAtAll() { + assertThat(ErrorDetails.reasonOf(Status.FAILED_PRECONDITION.asRuntimeException())).isEmpty(); + assertThat(ErrorDetails.reasonOf(null)).isEmpty(); + assertThat(ErrorDetails.reasonOf(new IllegalStateException("not an error"))).isEmpty(); + } + + @Test + void ignoresDetailsThatAreNotErrorInfo() { + Metadata trailers = new Metadata(); + trailers.put(ErrorDetails.STATUS_DETAILS_KEY, com.google.rpc.Status.newBuilder() + .setCode(9) + .addDetails(com.google.protobuf.Any.pack(com.google.rpc.DebugInfo.newBuilder().setDetail("x").build())) + .build() + .toByteArray()); + assertThat(ErrorDetails.reasonOf(GrpcErrors.withTrailers(Status.Code.FAILED_PRECONDITION, "x", trailers))).isEmpty(); + } + + @Test + void turnsOutOfStockIntoOutOfStockError() { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.FAILED_PRECONDITION, "Out of stock", SRE.get("OUT_OF_STOCK")), null); + + assertThat(error).isInstanceOf(OutOfStockError.class); + assertThat(error.reason()).contains("OUT_OF_STOCK"); + } + + @Test + void turnsHolderNotActiveIntoHolderNotActiveError() { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.FAILED_PRECONDITION, "not active", SRE.get("HOLDER_NOT_ACTIVE")), null); + + assertThat(error).isInstanceOf(HolderNotActiveError.class); + assertThat(error.reason()).contains("HOLDER_NOT_ACTIVE"); + } + + @Test + void turnsResourceHasActiveHoldsIntoItsOwnClass() { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.FAILED_PRECONDITION, "busy", SRE.get("RESOURCE_HAS_ACTIVE_HOLDS")), null); + + assertThat(error).isInstanceOf(ResourceHasActiveHoldsError.class); + } + + @Test + void turnsResourceHasQueuedRequestsIntoItsOwnClass() { + CaerusError error = SreErrors.toCaerusError( + GrpcErrors.withReason(Status.Code.FAILED_PRECONDITION, "queued", "RESOURCE_HAS_QUEUED_REQUESTS"), null); + + assertThat(error).isInstanceOf(ResourceHasQueuedRequestsError.class); + } + + @Test + void keepsCatchingEveryOneOfThemAsConflictError() { + for (String reason : new String[]{"OUT_OF_STOCK", "HOLDER_NOT_ACTIVE", "RESOURCE_HAS_ACTIVE_HOLDS"}) { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.FAILED_PRECONDITION, "x", SRE.get(reason)), null); + assertThat(error).isInstanceOf(ConflictError.class); + assertThat(error.code()).isEqualTo(ErrorCode.CONFLICT); + } + } + + @Test + void fallsBackToAPlainConflictErrorWhenTheReasonIsUnknown() { + CaerusError error = SreErrors.toCaerusError( + grpcError(Status.Code.FAILED_PRECONDITION, "something new", SRE.get("OUT_OF_STOCK").substring(0, 12)), null); + + assertThat(error).isExactlyInstanceOf(ConflictError.class); + } + + @Test + void fallsBackWhenTheEngineSendsNoReasonAtAll() { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.FAILED_PRECONDITION, "old engine"), null); + + assertThat(error).isExactlyInstanceOf(ConflictError.class); + assertThat(error.reason()).isEmpty(); + } + + @Test + void carriesTheReasonOnStatusesThatHaveNoSubclass() { + CaerusError error = SreErrors.toCaerusError(grpcError(Status.Code.NOT_FOUND, "no template", SRE.get("TEMPLATE_NOT_FOUND")), null); + + assertThat(error).isInstanceOf(ResourceNotFoundError.class); + assertThat(error.reason()).contains("TEMPLATE_NOT_FOUND"); + } + + @Test + void keepsTheMessageTheEngineWrote() { + CaerusError error = SreErrors.toCaerusError( + grpcError(Status.Code.FAILED_PRECONDITION, "Out of stock for resource: seat_A12", SRE.get("OUT_OF_STOCK")), null); + + assertThat(error.getMessage()).isEqualTo("Out of stock for resource: seat_A12"); + } + + @Test + void anErrorThatIsAlreadyACaerusErrorPassesThrough() { + OutOfStockError original = new OutOfStockError("gone"); + + assertThat(SreErrors.toCaerusError(original, "req_whatever-1234")).isSameAs(original); + } + + @Test + void propagatesTheServerRequestId() { + Metadata trailers = GrpcErrors.requestIdTrailers("req_server-trailer-001"); + CaerusError error = SreErrors.toCaerusError( + GrpcErrors.withTrailers(Status.Code.FAILED_PRECONDITION, "Out of stock for resource: seats", trailers), + "req_client-fallback-999"); + + assertThat(error.requestId()).contains("req_server-trailer-001"); + assertThat(error.getMessage()).contains("req_server-trailer-001"); + } + + @Test + void fallsBackToTheClientRequestIdWhenTheServerSentNone() { + CaerusError error = SreErrors.toCaerusError( + RealTrailers.grpcError(Status.Code.DEADLINE_EXCEEDED, "Deadline exceeded"), "req_client-timeout-123"); + + assertThat(error.requestId()).contains("req_client-timeout-123"); + assertThat(error.getMessage()).contains("req_client-timeout-123"); + } + + @Test + void aNonGrpcFailureIsTheBaseErrorWithItsOwnMessage() { + CaerusError error = SreErrors.toCaerusError(new IllegalStateException("Connection refused"), null); + + assertThat(error).isExactlyInstanceOf(CaerusError.class); + assertThat(error.getMessage()).isEqualTo("Connection refused"); + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/MethodsTest.java b/src/test/java/dev/caerus/sdk/sre/MethodsTest.java new file mode 100644 index 0000000..961a833 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/MethodsTest.java @@ -0,0 +1,593 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.internal.proto.sre.ConfirmRequest; +import dev.caerus.sdk.internal.proto.sre.CreateResourceRequest; +import dev.caerus.sdk.internal.proto.sre.DeleteResourceRequest; +import dev.caerus.sdk.internal.proto.sre.ExtendRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHolderRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListResponse; +import dev.caerus.sdk.internal.proto.sre.GetResourceRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyResponse; +import dev.caerus.sdk.internal.proto.sre.ReleaseRequest; +import dev.caerus.sdk.internal.proto.sre.ResourceHolderResponse; +import dev.caerus.sdk.internal.proto.sre.ResourceResponse; +import dev.caerus.sdk.internal.proto.sre.TakeRequest; +import dev.caerus.sdk.internal.proto.sre.UpdateResourceRequest; +import dev.caerus.sdk.support.FakeEngine; +import dev.caerus.sdk.support.GrpcErrors; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import static dev.caerus.sdk.support.FakeEngine.aHolderResponse; +import static dev.caerus.sdk.support.FakeEngine.aResourceResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class MethodsTest { + + private static FakeEngine engine; + private CaerusClient caerus; + + @BeforeAll + static void startEngine() { + engine = FakeEngine.start(); + } + + @AfterAll + static void stopEngine() { + engine.close(); + } + + @BeforeEach + void connect() { + engine.reset(); + caerus = new CaerusClient(CaerusClient.resolve(CaerusClientOptions.builder() + .endpoint(engine.endpoint()) + .apiKey("no-es-una-clave") + .tls(false) + .build(), name -> null)); + } + + @AfterEach + void disconnect() { + caerus.close(); + } + + private static void answerTake(ResourceHolderResponse response) { + engine.on("take", (request, observer) -> reply(observer, response)); + } + + private static void reply(StreamObserver observer, T response) { + observer.onNext(response); + observer.onCompleted(); + } + + private static Map map(Object... pairs) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + result.put((String) pairs[i], pairs[i + 1]); + } + return result; + } + + @Test + void takeAsksForExactlyOneUnit() { + caerus.unitary("seat_A12").take(); + + assertThat(engine.lastMethod()).isEqualTo("take"); + TakeRequest request = engine.lastRequest(TakeRequest.class); + assertThat(request.getResourceKey()).isEqualTo("seat_A12"); + assertThat(request.getAmount()).isEqualTo(1); + } + + @Test + void takePassesTheOptionsThrough() { + caerus.unitary("seat_A12").take(TakeOptions.builder() + .idempotencyKey("order-99") + .ttlSeconds(120) + .metadata(map("orderId", "12345")) + .build()); + + TakeRequest request = engine.lastRequest(TakeRequest.class); + assertThat(request.getSettings().getIdempotencyKey()).isEqualTo("order-99"); + assertThat(request.getSettings().getCustomTtlSeconds()).isEqualTo(120); + } + + @Test + void takeLeavesUnsetOptionsOffTheWire() { + caerus.unitary("seat_A12").take(); + + TakeRequest request = engine.lastRequest(TakeRequest.class); + assertThat(request.hasSettings()).isTrue(); + assertThat(request.getSettings().hasIdempotencyKey()).isFalse(); + assertThat(request.getSettings().hasCustomTtlSeconds()).isFalse(); + assertThat(request.getSettings().hasMetadata()).isFalse(); + } + + @Test + void refusesToBuildAHandleWithoutAKey() { + assertThatThrownBy(() -> caerus.unitary(" ")).isInstanceOf(ValidationError.class); + assertThatThrownBy(() -> caerus.pooled("")).isInstanceOf(ValidationError.class); + assertThatThrownBy(() -> caerus.pooled(null)).isInstanceOf(ValidationError.class); + } + + @Test + void buildingAHandleCallsNothing() { + caerus.unitary("seat_A12"); + caerus.pooled("general_admission"); + + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void theHandleRemembersItsKey() { + assertThat(caerus.unitary("seat_A12").key()).isEqualTo("seat_A12"); + assertThat(caerus.pooled("general_admission").key()).isEqualTo("general_admission"); + } + + @Test + void rejectsANonPositiveTtl() { + assertThatThrownBy(() -> caerus.unitary("seat_A12").take(TakeOptions.builder().ttlSeconds(0).build())) + .isInstanceOf(ValidationError.class); + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void takeManyAsksForTheAmountItWasGiven() { + caerus.pooled("general_admission").takeMany(4); + + TakeRequest request = engine.lastRequest(TakeRequest.class); + assertThat(request.getResourceKey()).isEqualTo("general_admission"); + assertThat(request.getAmount()).isEqualTo(4); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void takeManyRefusesANonPositiveAmount(int amount) { + assertThatThrownBy(() -> caerus.pooled("general_admission").takeMany(amount)) + .isInstanceOf(ValidationError.class) + .hasMessage("amount must be greater than zero"); + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void throwsWhenTheEngineReportsExpired() { + answerTake(aHolderResponse().setStatus(ResourceHolderResponse.ResourceHolderStatus.EXPIRED).build()); + + ConflictError error = catchThrowableOfType(ConflictError.class, () -> caerus.unitary("seat_A12").take()); + + assertThat(error.getMessage()).contains("EXPIRED"); + } + + @Test + void returnsAQueuedHolderWithTheStatusVisible() { + answerTake(aHolderResponse().setStatus(ResourceHolderResponse.ResourceHolderStatus.QUEUED).build()); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + + assertThat(holder.status()).isEqualTo(ResourceHolderStatus.QUEUED); + assertThat(holder.id()).isEqualTo("hld-1"); + } + + @Test + void returnsPendingOnTheHappyPath() { + answerTake(aHolderResponse().build()); + + assertThat(caerus.unitary("seat_A12").take().status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void doesNotThrowWhenAQueryFindsAnExpiredHolder() { + engine.on("getResourceHolder", (request, observer) -> + reply(observer, aHolderResponse().setStatus(ResourceHolderResponse.ResourceHolderStatus.EXPIRED).build())); + + assertThat(caerus.getResourceHolder("hld-1").status()).isEqualTo(ResourceHolderStatus.EXPIRED); + } + + @Test + void refusesToGuessAtAStatusItDoesNotKnow() { + answerTake(aHolderResponse().setStatusValue(99).build()); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()) + .isInstanceOf(CaerusError.class) + .hasMessageContaining("unknown status"); + } + + @Test + void metadataGoesOutAsJsonText() { + caerus.unitary("seat_A12").take(TakeOptions.builder().metadata(map("orderId", "12345", "items", 2)).build()); + + assertThat(engine.lastRequest(TakeRequest.class).getSettings().getMetadata()) + .isEqualTo("{\"orderId\":\"12345\",\"items\":2}"); + } + + @Test + void metadataComesBackAsAnObject() { + answerTake(aHolderResponse().setMetadata("{\"orderId\":\"12345\",\"items\":2}").build()); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + + assertThat(holder.metadata()).contains(map("orderId", "12345", "items", 2L)); + } + + @Test + void metadataSurvivesARoundTripUnchanged() { + Map sent = map("orderId", "12345", "nested", map("seat", "A12"), "count", 3L, "ratio", 0.5, + "flag", true, "nothing", null); + engine.on("take", (request, observer) -> + reply(observer, aHolderResponse().setMetadata(request.getSettings().getMetadata()).build())); + + ResourceHolder holder = caerus.unitary("seat_A12").take(TakeOptions.builder().metadata(sent).build()); + + assertThat(holder.metadata()).contains(sent); + } + + @Test + void readsAMissingMetadataAsNone() { + answerTake(aHolderResponse().build()); + + assertThat(caerus.unitary("seat_A12").take().metadata()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void readsABlankMetadataAsNone(String raw) { + answerTake(aHolderResponse().setMetadata(raw).build()); + + assertThat(caerus.unitary("seat_A12").take().metadata()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"not json at all", "[1,2]", "42", "\"text\"", "null"}) + void complainsLoudlyAboutMetadataThatIsNotAJsonObject(String raw) { + answerTake(aHolderResponse().setMetadata(raw).build()); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()) + .isInstanceOf(CaerusError.class) + .hasMessageContaining("not a JSON object") + .hasMessageContaining(raw); + } + + @Test + void sendsNoMetadataWhenNoneWasGiven() { + caerus.unitary("seat_A12").take(); + + assertThat(engine.lastRequest(TakeRequest.class).getSettings().hasMetadata()).isFalse(); + } + + @Test + void turnsEpochSecondsIntoTheMatchingInstant() { + long epochSeconds = Instant.parse("2026-08-02T15:00:00Z").getEpochSecond(); + answerTake(aHolderResponse().setExpiresAt(epochSeconds).build()); + + assertThat(caerus.unitary("seat_A12").take().expiresAt()).isEqualTo(Instant.parse("2026-08-02T15:00:00Z")); + } + + @Test + void doesNotLandIn1970() { + answerTake(aHolderResponse().setExpiresAt(1_785_164_400L).build()); + + int year = caerus.unitary("seat_A12").take().expiresAt().atZone(ZoneOffset.UTC).getYear(); + + assertThat(year).isBetween(2021, 2099); + } + + @Test + void createResourceSendsWhatTheEngineNeedsAndReturnsAResource() { + engine.on("createResource", (request, observer) -> + reply(observer, aResourceResponse().setKey("seat_A12").setAvailableAmount(5).build())); + + Resource resource = caerus.createMultiple("seat", "seat_A12", 5, CreateResourceOptions.builder() + .groupKey("row_A") + .metadata(map("zone", "platea")) + .build()); + + CreateResourceRequest request = engine.lastRequest(CreateResourceRequest.class); + assertThat(request.getTemplateName()).isEqualTo("seat"); + assertThat(request.getKey()).isEqualTo("seat_A12"); + assertThat(request.getAvailableAmount()).isEqualTo(5); + assertThat(request.getGroupKey()).isEqualTo("row_A"); + assertThat(request.getMetadata()).isEqualTo("{\"zone\":\"platea\"}"); + assertThat(resource.key()).isEqualTo("seat_A12"); + assertThat(resource.availableAmount()).isEqualTo(5); + assertThat(resource.templateId()).isEqualTo("tpl-1"); + } + + @Test + void createUnitaryAsksForExactlyOne() { + caerus.createUnitary("seat", "seat_A12"); + + CreateResourceRequest request = engine.lastRequest(CreateResourceRequest.class); + assertThat(request.getAvailableAmount()).isEqualTo(1); + assertThat(request.hasGroupKey()).isFalse(); + assertThat(request.hasMetadata()).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {0, -3}) + void createMultipleRefusesANonPositiveAmount(int amount) { + assertThatThrownBy(() -> caerus.createMultiple("seat", "seat_A12", amount)) + .isInstanceOf(ValidationError.class) + .hasMessage("availableAmount must be greater than zero"); + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void createRefusesABlankTemplateOrKey() { + assertThatThrownBy(() -> caerus.createUnitary(" ", "seat_A12")).hasMessage("templateName is required"); + assertThatThrownBy(() -> caerus.createUnitary("seat", "")).hasMessage("key is required"); + assertThat(engine.lastMethod()).isNull(); + } + + @Test + void confirmSendsTheMetadataPatchAsJsonText() { + caerus.confirm("hld-1", ConfirmOptions.builder().metadata(map("paymentId", "pay_9")).build()); + + assertThat(engine.lastMethod()).isEqualTo("confirm"); + ConfirmRequest request = engine.lastRequest(ConfirmRequest.class); + assertThat(request.getResourceHolderId()).isEqualTo("hld-1"); + assertThat(request.getMetadataPatch()).isEqualTo("{\"paymentId\":\"pay_9\"}"); + } + + @Test + void confirmRefusesAHolderThatDidNotEndConfirmed() { + engine.on("confirm", (request, observer) -> + reply(observer, aHolderResponse().setStatus(ResourceHolderResponse.ResourceHolderStatus.RELEASED).build())); + + assertThatThrownBy(() -> caerus.confirm("hld-1")) + .isInstanceOf(ConflictError.class) + .hasMessageContaining("RELEASED"); + } + + @Test + void releaseSendsTheHolder() { + caerus.release("hld-1"); + + assertThat(engine.lastRequest(ReleaseRequest.class).getResourceHolderId()).isEqualTo("hld-1"); + } + + @Test + void extendSendsTheExtensionInMilliseconds() { + caerus.extend("hld-1", 300_000); + + ExtendRequest request = engine.lastRequest(ExtendRequest.class); + assertThat(request.getResourceHolderId()).isEqualTo("hld-1"); + assertThat(request.getExtraMs()).isEqualTo(300_000); + } + + @ParameterizedTest + @ValueSource(ints = {0, -30}) + void extendRefusesANonPositiveAmountWithoutCalling(int extraMs) { + AtomicBoolean called = new AtomicBoolean(); + engine.on("extend", (request, observer) -> { + called.set(true); + reply(observer, aHolderResponse().build()); + }); + + assertThatThrownBy(() -> caerus.extend("hld-1", extraMs)).isInstanceOf(ValidationError.class); + assertThat(called).isFalse(); + } + + @Test + void readsAResource() { + engine.on("getResource", (request, observer) -> + reply(observer, aResourceResponse().setKey("seat_A12").setPendingCount(2).build())); + + Resource resource = caerus.getResource("seat_A12"); + + assertThat(engine.lastRequest(GetResourceRequest.class).getKey()).isEqualTo("seat_A12"); + assertThat(resource.pendingCount()).isEqualTo(2); + } + + @Test + void readsAPageOfAGroupAndSaysWhetherThereIsAnother() { + engine.on("getResourcesByGroupKey", (request, observer) -> + reply(observer, GetResourcesByGroupKeyResponse.newBuilder() + .addResources(aResourceResponse().setKey("seat_A12")) + .addResources(aResourceResponse().setKey("seat_A13")) + .setNextPage(true) + .build())); + + ResourcePage page = caerus.getResourcesByGroup("row_A", + GetResourcesByGroupOptions.builder().page(2).pageSize(10).build()); + + GetResourcesByGroupKeyRequest request = engine.lastRequest(GetResourcesByGroupKeyRequest.class); + assertThat(request.getGroupKey()).isEqualTo("row_A"); + assertThat(request.getPage()).isEqualTo(2); + assertThat(request.getPageSize()).isEqualTo(10); + assertThat(page.resources().stream().map(Resource::key).collect(Collectors.toList())) + .containsExactly("seat_A12", "seat_A13"); + assertThat(page.hasNextPage()).isTrue(); + } + + @Test + void startsAtTheFirstPageWhenNoneIsAskedFor() { + caerus.getResourcesByGroup("row_A"); + + GetResourcesByGroupKeyRequest request = engine.lastRequest(GetResourcesByGroupKeyRequest.class); + assertThat(request.getPage()).isZero(); + assertThat(request.hasPageSize()).isFalse(); + } + + @Test + void listsHoldersAndSaysWhetherThereIsAnotherPage() { + engine.on("getResourceHoldersList", (request, observer) -> + reply(observer, GetResourceHoldersListResponse.newBuilder() + .addResourceHolders(aHolderResponse().setHolderId("hld-1")) + .addResourceHolders(aHolderResponse().setHolderId("hld-2")) + .setNextPage(true) + .build())); + + ResourceHolderPage page = caerus.listResourceHolders(ListResourceHoldersOptions.builder() + .resourceKey("seat_A12") + .status(ResourceHolderStatus.PENDING) + .page(1) + .pageSize(5) + .build()); + + GetResourceHoldersListRequest request = engine.lastRequest(GetResourceHoldersListRequest.class); + assertThat(request.getResourceKey()).isEqualTo("seat_A12"); + assertThat(request.getPage()).isEqualTo(1); + assertThat(request.getPageSize()).isEqualTo(5); + assertThat(request.hasStatusFilter()).isTrue(); + assertThat(request.getStatusFilterValue()).isZero(); + assertThat(page.holders().stream().map(ResourceHolder::id).collect(Collectors.toList())) + .containsExactly("hld-1", "hld-2"); + assertThat(page.hasNextPage()).isTrue(); + } + + @Test + void listsNewestFirstUnlessAskedForTheOtherOrder() { + caerus.listResourceHolders(); + GetResourceHoldersListRequest first = engine.lastRequest(GetResourceHoldersListRequest.class); + assertThat(first.getPage()).isZero(); + assertThat(first.getCreatedAtSortDirection()).isEqualTo(GetResourceHoldersListRequest.SortDirection.DESCENDING); + + caerus.listResourceHolders(ListResourceHoldersOptions.builder().sort(HolderSort.OLDEST_FIRST).build()); + assertThat(engine.lastRequest(GetResourceHoldersListRequest.class).getCreatedAtSortDirection()) + .isEqualTo(GetResourceHoldersListRequest.SortDirection.ASCENDING); + } + + @Test + void leavesTheStatusFilterOutWhenNoneIsAskedFor() { + caerus.listResourceHolders(ListResourceHoldersOptions.builder().resourceKey("seat_A12").build()); + + assertThat(engine.lastRequest(GetResourceHoldersListRequest.class).hasStatusFilter()).isFalse(); + } + + @Test + void encodesEveryStatusFilterTheWayTheEngineNumbersThem() { + Map wire = Map.of( + ResourceHolderStatus.PENDING, 0, + ResourceHolderStatus.CONFIRMED, 1, + ResourceHolderStatus.RELEASED, 2, + ResourceHolderStatus.QUEUED, 3, + ResourceHolderStatus.EXPIRED, 4); + for (Map.Entry entry : wire.entrySet()) { + caerus.listResourceHolders(ListResourceHoldersOptions.builder().status(entry.getKey()).build()); + assertThat(engine.lastRequest(GetResourceHoldersListRequest.class).getStatusFilterValue()) + .as(entry.getKey().name()) + .isEqualTo(entry.getValue()); + } + } + + @Test + void readsAHolder() { + engine.on("getResourceHolder", (request, observer) -> + reply(observer, aHolderResponse().setHolderId("hld-7") + .setStatus(ResourceHolderResponse.ResourceHolderStatus.CONFIRMED).build())); + + ResourceHolder holder = caerus.getResourceHolder("hld-7"); + + assertThat(engine.lastRequest(GetResourceHolderRequest.class).getResourceHolderId()).isEqualTo("hld-7"); + assertThat(holder.status()).isEqualTo(ResourceHolderStatus.CONFIRMED); + } + + @Test + void translatesAServerErrorTheSameWayThroughABusinessMethod() { + engine.on("getResource", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.FAILED_PRECONDITION, "Holder already confirmed"))); + + ConflictError error = catchThrowableOfType(ConflictError.class, () -> caerus.getResource("seat_A12")); + + assertThat(error.getMessage()).contains("Holder already confirmed"); + assertThat(error.requestId()).isPresent(); + } + + @Test + void runningOutOfStockArrivesAsAConflictCarryingTheEngineMessage() { + engine.on("take", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.FAILED_PRECONDITION, "Out of stock for resource: seat_A12"))); + + ConflictError error = catchThrowableOfType(ConflictError.class, () -> caerus.unitary("seat_A12").take()); + + assertThat(error.code()).isEqualTo(ErrorCode.CONFLICT); + assertThat(error.code()).isNotEqualTo(ErrorCode.UNKNOWN); + assertThat(error.getMessage()).contains("Out of stock for resource: seat_A12"); + assertThat(error.requestId()).isPresent(); + } + + @Test + void runningOutOfStockReachesTakeManyTheSameWay() { + engine.on("take", (request, observer) -> + observer.onError(GrpcErrors.withReason(Status.Code.FAILED_PRECONDITION, "Out of stock", "OUT_OF_STOCK"))); + + assertThatThrownBy(() -> caerus.pooled("general_admission").takeMany(4)).isInstanceOf(OutOfStockError.class); + } + + @Test + void sendsUpdateResourceCorrectly() { + engine.on("updateResource", (request, observer) -> + reply(observer, aResourceResponse() + .setKey("general_admission") + .setAvailableAmount(150) + .setGroupKey("main_hall") + .setMetadata("") + .build())); + + Resource resource = caerus.updateResource("general_admission", 50, + UpdateResourceOptions.builder().groupKey("main_hall").idempotencyKey("upd-1").build()); + + UpdateResourceRequest request = engine.lastRequest(UpdateResourceRequest.class); + assertThat(request.getResourceKey()).isEqualTo("general_admission"); + assertThat(request.getDeltaAmount()).isEqualTo(50); + assertThat(request.getGroupKey()).isEqualTo("main_hall"); + assertThat(request.getIdempotencyKey()).isEqualTo("upd-1"); + assertThat(resource.availableAmount()).isEqualTo(150); + assertThat(resource.groupKey()).contains("main_hall"); + assertThat(resource.metadata()).isEmpty(); + } + + @Test + void updateResourceAcceptsANegativeDelta() { + caerus.updateResource("general_admission", -20); + + assertThat(engine.lastRequest(UpdateResourceRequest.class).getDeltaAmount()).isEqualTo(-20); + } + + @Test + void sendsDeleteResourceCorrectly() { + caerus.deleteResource("seat_A12"); + + assertThat(engine.lastRequest(DeleteResourceRequest.class).getKey()).isEqualTo("seat_A12"); + } + + @Test + void anEmptyGroupKeyReadsAsNone() { + engine.on("getResource", (request, observer) -> + reply(observer, aResourceResponse().setGroupKey("").build())); + + assertThat(caerus.getResource("seat_A12").groupKey()).isEmpty(); + } + + @Test + void theQueriesRefuseBlankArgumentsWithoutCalling() { + assertThatThrownBy(() -> caerus.getResource(" ")).hasMessage("key is required"); + assertThatThrownBy(() -> caerus.getResourceHolder("")).hasMessage("resourceHolderId is required"); + assertThatThrownBy(() -> caerus.getResourcesByGroup(null)).hasMessage("groupKey is required"); + assertThatThrownBy(() -> caerus.confirm(" ")).hasMessage("resourceHolderId is required"); + assertThatThrownBy(() -> caerus.release(null)).hasMessage("resourceHolderId is required"); + assertThatThrownBy(() -> caerus.deleteResource("")).hasMessage("key is required"); + assertThatThrownBy(() -> caerus.updateResource("", 1)).hasMessage("key is required"); + assertThat(engine.lastMethod()).isNull(); + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/MockTest.java b/src/test/java/dev/caerus/sdk/sre/MockTest.java new file mode 100644 index 0000000..79ce8c1 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/MockTest.java @@ -0,0 +1,518 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class MockTest { + + private static InMemoryCaerusClient aMock(int availableAmount) { + return new InMemoryCaerusClient(MockOptions.builder() + .resources(List.of(MockResourceSeed.builder("seat_A12", availableAmount).groupKey("row_A").build())) + .build()); + } + + private static InMemoryCaerusClient aMock() { + return aMock(1); + } + + private static TakeOptions ttl(int seconds) { + return TakeOptions.builder().ttlSeconds(seconds).build(); + } + + private static String checkout(SharedResourceApi caerus) { + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.confirm(holder.id()); + return holder.id(); + } + + @Test + void isUsableWhereverTheRealClientIs() { + assertThat(checkout(aMock())).startsWith("hld-"); + } + + @Test + void movesUnitsFromAvailableToPendingWhenTaken() { + InMemoryCaerusClient caerus = aMock(5); + + caerus.pooled("seat_A12").takeMany(2); + Resource resource = caerus.getResource("seat_A12"); + + assertThat(resource.availableAmount()).isEqualTo(3); + assertThat(resource.pendingCount()).isEqualTo(2); + } + + @Test + void givesThemBackOnRelease() { + InMemoryCaerusClient caerus = aMock(5); + + ResourceHolder holder = caerus.pooled("seat_A12").takeMany(2); + caerus.release(holder.id()); + + Resource resource = caerus.getResource("seat_A12"); + assertThat(resource.availableAmount()).isEqualTo(5); + assertThat(resource.pendingCount()).isZero(); + } + + @Test + void keepsThemTakenOnConfirm() { + InMemoryCaerusClient caerus = aMock(5); + + ResourceHolder holder = caerus.pooled("seat_A12").takeMany(2); + caerus.confirm(holder.id()); + + Resource resource = caerus.getResource("seat_A12"); + assertThat(resource.availableAmount()).isEqualTo(3); + assertThat(resource.pendingCount()).isZero(); + } + + @Test + void refusesToTakeMoreThanExists() { + InMemoryCaerusClient caerus = aMock(1); + + caerus.unitary("seat_A12").take(); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()).isInstanceOf(ConflictError.class); + } + + @Test + void reportsItAsTheEngineDoesDownToTheErrorType() { + InMemoryCaerusClient caerus = aMock(1); + caerus.unitary("seat_A12").take(); + + OutOfStockError error = catchThrowableOfType(OutOfStockError.class, () -> caerus.unitary("seat_A12").take()); + + assertThat(error).isInstanceOf(ConflictError.class); + assertThat(error.code()).isEqualTo(ErrorCode.CONFLICT); + assertThat(error.getMessage()).isEqualTo("Out of stock for resource: seat_A12"); + } + + @Test + void appliesTheSameRuleToTakeMany() { + assertThatThrownBy(() -> aMock(3).pooled("seat_A12").takeMany(4)).isInstanceOf(ConflictError.class); + } + + @Test + void doesNotKnowAboutResourcesNobodyCreated() { + assertThatThrownBy(() -> aMock().unitary("seat_ZZZ").take()).isInstanceOf(ResourceNotFoundError.class); + } + + @Test + void createsResourcesThroughTheApiToo() { + InMemoryCaerusClient caerus = new InMemoryCaerusClient(); + + Resource created = caerus.createMultiple("seat", "seat_B1", 3); + ResourceHolder holder = caerus.unitary("seat_B1").take(); + + assertThat(created.availableAmount()).isEqualTo(3); + assertThat(created.templateId()).isEqualTo("tpl-seat"); + assertThat(holder.status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void refusesToCreateTheSameKeyTwice() { + assertThatThrownBy(() -> aMock().createMultiple("seat", "seat_A12", 1)).isInstanceOf(ConflictError.class); + } + + @Test + void givesHoldersARealExpiresAt() { + ResourceHolder holder = aMock().unitary("seat_A12").take(ttl(600)); + + long secondsAway = Duration.between(Instant.now(), holder.expiresAt()).getSeconds(); + assertThat(secondsAway).isBetween(590L, 610L); + } + + @Test + void doesNotExpireAnythingOnItsOwn() throws InterruptedException { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(ttl(1)); + Thread.sleep(30); + + assertThat(caerus.getResourceHolder(holder.id()).status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void expiresWhatIsPastDueWhenTheClockMoves() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(ttl(300)); + caerus.advanceTime(301); + + assertThat(caerus.getResourceHolder(holder.id()).status()).isEqualTo(ResourceHolderStatus.EXPIRED); + } + + @Test + void leavesAloneWhatIsNotDueYet() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(ttl(300)); + caerus.advanceTime(299); + + assertThat(caerus.getResourceHolder(holder.id()).status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void returnsTheUnitsWhenAHolderExpires() { + InMemoryCaerusClient caerus = aMock(2); + + caerus.pooled("seat_A12").takeMany(2); + caerus.advanceTime(1000); + + Resource resource = caerus.getResource("seat_A12"); + assertThat(resource.availableAmount()).isEqualTo(2); + assertThat(resource.pendingCount()).isZero(); + } + + @Test + void expiresOneHolderOnDemand() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.expire(holder.id()); + + assertThat(caerus.getResourceHolder(holder.id()).status()).isEqualTo(ResourceHolderStatus.EXPIRED); + } + + @Test + void refusesToExpireAHolderThatIsNotPending() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.release(holder.id()); + + assertThatThrownBy(() -> caerus.expire(holder.id())).isInstanceOf(HolderNotActiveError.class); + } + + @Test + void refusesToConfirmOneThatExpired() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.expire(holder.id()); + + assertThatThrownBy(() -> caerus.confirm(holder.id())).isInstanceOf(ConflictError.class); + } + + @Test + void extendsTheExpiryByTheMillisecondsItIsGiven() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder taken = caerus.unitary("seat_A12").take(ttl(300)); + ResourceHolder extended = caerus.extend(taken.id(), 300_000); + caerus.advanceTime(301); + + assertThat(Duration.between(taken.expiresAt(), extended.expiresAt())).isEqualTo(Duration.ofMillis(300_000)); + assertThat(caerus.getResourceHolder(taken.id()).status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void roundsASubSecondExtensionUpToOneSecond() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder taken = caerus.unitary("seat_A12").take(ttl(300)); + ResourceHolder extended = caerus.extend(taken.id(), 300); + + assertThat(Duration.between(taken.expiresAt(), extended.expiresAt())).isEqualTo(Duration.ofSeconds(1)); + } + + @Test + void refusesANegativeJump() { + assertThatThrownBy(() -> aMock().advanceTime(-1)).isInstanceOf(ValidationError.class); + } + + @Test + void failsTheNextCallToTheNamedMethod() { + InMemoryCaerusClient caerus = aMock(); + caerus.failNext(MockMethod.TAKE, new ConflictError("Out of stock for resource: seat_A12")); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()).hasMessageContaining("Out of stock"); + assertThat(caerus.unitary("seat_A12").take().status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void queuesOneFailurePerCall() { + InMemoryCaerusClient caerus = aMock(5); + caerus.failNext(MockMethod.TAKE, new ConflictError("first")); + caerus.failNext(MockMethod.TAKE, new ConflictError("second")); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()).hasMessage("first"); + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()).hasMessage("second"); + assertThat(caerus.unitary("seat_A12").take()).isNotNull(); + } + + @Test + void forgetsThemWhenAsked() { + InMemoryCaerusClient caerus = aMock(); + caerus.failNext(MockMethod.TAKE, new ConflictError("nope")); + caerus.clearFailures(); + + assertThat(caerus.unitary("seat_A12").take()).isNotNull(); + } + + @Test + void letsACallerExerciseAReleaseThatFails() { + InMemoryCaerusClient caerus = aMock(); + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.failNext(MockMethod.RELEASE, new ConflictError("release exploded")); + + assertThatThrownBy(() -> caerus.release(holder.id())).hasMessage("release exploded"); + } + + @Test + void takeManyFailsSeparatelyFromTake() { + InMemoryCaerusClient caerus = aMock(5); + caerus.failNext(MockMethod.TAKE_MANY, new ConflictError("many")); + + assertThat(caerus.pooled("seat_A12").take()).isNotNull(); + assertThatThrownBy(() -> caerus.pooled("seat_A12").takeMany(2)).hasMessage("many"); + } + + @Test + void keepsTheUnitsTakenAfterAConfirm() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.confirm(holder.id()); + + Resource resource = caerus.getResource("seat_A12"); + assertThat(resource.availableAmount()).isZero(); + assertThat(resource.pendingCount()).isZero(); + } + + @Test + void reportsExpiredFromAQueryRatherThanThrowing() { + InMemoryCaerusClient caerus = aMock(); + + ResourceHolder holder = caerus.unitary("seat_A12").take(); + caerus.expire(holder.id()); + + assertThat(caerus.getResourceHolder(holder.id()).status()).isEqualTo(ResourceHolderStatus.EXPIRED); + } + + @Test + void givesBackTheFirstHolderForARepeatedIdempotencyKey() { + InMemoryCaerusClient caerus = aMock(5); + TakeOptions options = TakeOptions.builder().idempotencyKey("order-1").build(); + + ResourceHolder first = caerus.unitary("seat_A12").take(options); + ResourceHolder again = caerus.unitary("seat_A12").take(options); + + assertThat(again.id()).isEqualTo(first.id()); + assertThat(caerus.getResource("seat_A12").pendingCount()).isEqualTo(1); + } + + @Test + void keepsMetadataAsAnObject() { + ResourceHolder holder = aMock().unitary("seat_A12") + .take(TakeOptions.builder().metadata(Map.of("orderId", "12345")).build()); + + assertThat(holder.metadata()).contains(Map.of("orderId", "12345")); + } + + @Test + void rejectsAZeroAmountTheSameWay() { + assertThatThrownBy(() -> aMock().pooled("seat_A12").takeMany(0)).isInstanceOf(ValidationError.class); + } + + @Test + void rejectsAZeroExtensionTheSameWay() { + assertThatThrownBy(() -> aMock().extend("hld-1", 0)).isInstanceOf(ValidationError.class); + } + + @Test + void rejectsCallsAfterBeingClosed() { + InMemoryCaerusClient caerus = aMock(); + caerus.close(); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()) + .isExactlyInstanceOf(CaerusError.class) + .hasMessageContaining("closed"); + } + + @Test + void showsItsStateForAssertionsTheApiCannotMake() { + InMemoryCaerusClient caerus = aMock(3); + caerus.unitary("seat_A12").take(); + + MockSnapshot snapshot = caerus.snapshot(); + + assertThat(snapshot.resources()).hasSize(1); + assertThat(snapshot.holders()).hasSize(1); + assertThat(snapshot.holders().get(0).status()).isEqualTo(ResourceHolderStatus.PENDING); + } + + @Test + void pagesAGroup() { + InMemoryCaerusClient caerus = new InMemoryCaerusClient(MockOptions.builder() + .resources(List.of( + MockResourceSeed.builder("a", 1).groupKey("row_A").build(), + MockResourceSeed.builder("b", 1).groupKey("row_A").build(), + MockResourceSeed.builder("c", 1).groupKey("row_B").build())) + .build()); + + ResourcePage first = caerus.getResourcesByGroup("row_A", + GetResourcesByGroupOptions.builder().page(0).pageSize(1).build()); + ResourcePage second = caerus.getResourcesByGroup("row_A", + GetResourcesByGroupOptions.builder().page(1).pageSize(1).build()); + + assertThat(first.resources().stream().map(Resource::key).collect(Collectors.toList())).containsExactly("a"); + assertThat(first.hasNextPage()).isTrue(); + assertThat(second.resources().stream().map(Resource::key).collect(Collectors.toList())).containsExactly("b"); + assertThat(second.hasNextPage()).isFalse(); + } + + @Test + void updatesTheResourceAvailableAmount() { + Resource updated = aMock(10).updateResource("seat_A12", 5, UpdateResourceOptions.builder().groupKey("row_X").build()); + + assertThat(updated.availableAmount()).isEqualTo(15); + assertThat(updated.groupKey()).contains("row_X"); + } + + @Test + void refusesAnUpdateThatResultsInNegativeStock() { + assertThatThrownBy(() -> aMock(5).updateResource("seat_A12", -10)).isInstanceOf(ConflictError.class); + } + + @Test + void deletesAResourceWithNoActiveHolds() { + InMemoryCaerusClient caerus = aMock(5); + + caerus.deleteResource("seat_A12"); + + assertThatThrownBy(() -> caerus.getResource("seat_A12")).hasMessageContaining("not found"); + } + + @Test + void refusesToDeleteAResourceWithPendingHolds() { + InMemoryCaerusClient caerus = aMock(5); + caerus.unitary("seat_A12").take(); + + assertThatThrownBy(() -> caerus.deleteResource("seat_A12")).isInstanceOf(ResourceHasActiveHoldsError.class); + } + + private static final class ThreeHolders { + private final InMemoryCaerusClient caerus = aMock(10); + private final ResourceHolder first = caerus.pooled("seat_A12").take(); + private final ResourceHolder second = caerus.pooled("seat_A12").take(); + private final ResourceHolder third = caerus.pooled("seat_A12").take(); + + private ThreeHolders() { + caerus.confirm(second.id()); + } + } + + @Test + void listsNewestFirstByDefault() { + ThreeHolders three = new ThreeHolders(); + + ResourceHolderPage page = three.caerus.listResourceHolders(); + + assertThat(page.holders()).hasSize(3); + assertThat(page.holders().get(0).id()).isEqualTo(three.third.id()); + assertThat(page.holders().get(2).id()).isEqualTo(three.first.id()); + } + + @Test + void canBeAskedForTheOtherOrder() { + ThreeHolders three = new ThreeHolders(); + + ResourceHolderPage page = three.caerus.listResourceHolders( + ListResourceHoldersOptions.builder().sort(HolderSort.OLDEST_FIRST).build()); + + assertThat(page.holders().get(0).id()).isEqualTo(three.first.id()); + } + + @Test + void narrowsByStatus() { + ThreeHolders three = new ThreeHolders(); + + ResourceHolderPage confirmed = three.caerus.listResourceHolders( + ListResourceHoldersOptions.builder().status(ResourceHolderStatus.CONFIRMED).build()); + + assertThat(confirmed.holders().stream().map(ResourceHolder::id).collect(Collectors.toList())) + .containsExactly(three.second.id()); + } + + @Test + void narrowsByResource() { + ThreeHolders three = new ThreeHolders(); + + assertThat(three.caerus.listResourceHolders( + ListResourceHoldersOptions.builder().resourceKey("seat_A12").build()).holders()).hasSize(3); + assertThat(three.caerus.listResourceHolders( + ListResourceHoldersOptions.builder().resourceKey("otra").build()).holders()).isEmpty(); + } + + @Test + void pagesAndSaysWhenThereIsMore() { + ThreeHolders three = new ThreeHolders(); + + ResourceHolderPage first = three.caerus.listResourceHolders(ListResourceHoldersOptions.builder().pageSize(2).build()); + assertThat(first.holders()).hasSize(2); + assertThat(first.hasNextPage()).isTrue(); + + ResourceHolderPage second = three.caerus.listResourceHolders( + ListResourceHoldersOptions.builder().pageSize(2).page(1).build()); + assertThat(second.holders()).hasSize(1); + assertThat(second.hasNextPage()).isFalse(); + } + + @Test + void neverSellsTheSameUnitTwiceUnderConcurrency() throws InterruptedException { + InMemoryCaerusClient caerus = aMock(10); + ExecutorService pool = Executors.newFixedThreadPool(16); + CountDownLatch start = new CountDownLatch(1); + ConcurrentLinkedQueue taken = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue refused = new ConcurrentLinkedQueue<>(); + + for (int i = 0; i < 100; i++) { + pool.submit(() -> { + start.await(); + try { + taken.add(caerus.pooled("seat_A12").take().id()); + } catch (OutOfStockError e) { + refused.add(e); + } + return null; + }); + } + start.countDown(); + pool.shutdown(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + + assertThat(taken).hasSize(10).doesNotHaveDuplicates(); + assertThat(refused).hasSize(90); + Resource resource = caerus.getResource("seat_A12"); + assertThat(resource.availableAmount()).isZero(); + assertThat(resource.pendingCount()).isEqualTo(10); + } + + @Test + void returnsImmutableSnapshots() { + InMemoryCaerusClient caerus = aMock(3); + caerus.unitary("seat_A12").take(); + + List holders = caerus.snapshot().holders(); + + assertThatThrownBy(() -> holders.add(holders.get(0))).isInstanceOf(UnsupportedOperationException.class); + assertThat(new ArrayList<>(holders)).hasSize(1); + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/TimestampsAndTerminalTest.java b/src/test/java/dev/caerus/sdk/sre/TimestampsAndTerminalTest.java new file mode 100644 index 0000000..dfdf768 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/TimestampsAndTerminalTest.java @@ -0,0 +1,211 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.internal.proto.sre.ConfirmRequest; +import dev.caerus.sdk.internal.proto.sre.ExtendRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHolderRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceRequest; +import dev.caerus.sdk.internal.proto.sre.ResourceHolderResponse; +import dev.caerus.sdk.internal.proto.sre.ResourceHolderResponse.ResourceHolderStatus; +import dev.caerus.sdk.internal.proto.sre.ResourceResponse; +import dev.caerus.sdk.internal.proto.sre.TakeRequest; +import dev.caerus.sdk.support.FakeEngine; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.Instant; + +import static dev.caerus.sdk.support.FakeEngine.aHolderResponse; +import static dev.caerus.sdk.support.FakeEngine.aResourceResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TimestampsAndTerminalTest { + + private static FakeEngine engine; + private static CaerusClient caerus; + + @BeforeAll + static void start() { + engine = FakeEngine.start(); + caerus = new CaerusClient(CaerusClient.resolve(CaerusClientOptions.builder() + .apiKey("caer_test_key") + .endpoint(engine.endpoint()) + .tls(false) + .build(), name -> null)); + } + + @AfterAll + static void stop() { + caerus.close(); + engine.close(); + } + + @BeforeEach + void reset() { + engine.reset(); + } + + private static void reply(StreamObserver observer, T response) { + observer.onNext(response); + observer.onCompleted(); + } + + private static void answer(String method, ResourceHolderResponse response) { + engine.on(method, (request, observer) -> reply(observer, response)); + } + + @Test + void readsAResourceCreatedAndUpdatedAtAsInstants() { + engine.on("getResource", (request, observer) -> reply(observer, + aResourceResponse().setCreatedAtMs(1_700_000_000_000L).setUpdatedAtMs(1_700_000_060_000L).build())); + + Resource resource = caerus.getResource("seat_A12"); + + assertThat(resource.createdAt()).contains(Instant.ofEpochMilli(1_700_000_000_000L)); + assertThat(resource.updatedAt()).contains(Instant.ofEpochMilli(1_700_000_060_000L)); + } + + @Test + void readsAHolderCreatedAtAsAnInstant() { + engine.on("getResourceHolder", (request, observer) -> + reply(observer, aHolderResponse().setCreatedAtMs(1_700_000_000_000L).build())); + + assertThat(caerus.getResourceHolder("hld-1").createdAt()).contains(Instant.ofEpochMilli(1_700_000_000_000L)); + } + + @Test + void leavesTheTimestampsEmptyWhenTheEngineSendsNone() { + Resource resource = caerus.getResource("seat_A12"); + ResourceHolder holder = caerus.getResourceHolder("hld-1"); + + assertThat(resource.createdAt()).isEmpty(); + assertThat(resource.updatedAt()).isEmpty(); + assertThat(holder.createdAt()).isEmpty(); + } + + @Test + void neverTurnsAMissingTimestampInto1970() { + engine.on("getResource", (request, observer) -> + reply(observer, aResourceResponse().setCreatedAtMs(0).setUpdatedAtMs(0).build())); + + Resource resource = caerus.getResource("seat_A12"); + + assertThat(resource.createdAt()).isEmpty(); + assertThat(resource.updatedAt()).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = ResourceHolderStatus.class, names = {"RELEASED", "EXPIRED", "CONFIRMED"}) + void takeRefusesAFinishedHolder(ResourceHolderStatus status) { + engine.on("take", (request, observer) -> + reply(observer, aHolderResponse().setStatus(status).build())); + + assertThatThrownBy(() -> caerus.unitary("seat_A12").take()) + .isInstanceOf(ConflictError.class) + .hasMessageContaining(status.name()); + } + + @ParameterizedTest + @EnumSource(value = ResourceHolderStatus.class, names = {"PENDING", "QUEUED"}) + void takeAcceptsAnActiveHolder(ResourceHolderStatus status) { + engine.on("take", (request, observer) -> + reply(observer, aHolderResponse().setStatus(status).build())); + + assertThat(caerus.unitary("seat_A12").take().status().name()).isEqualTo(status.name()); + } + + @Test + void confirmAcceptsTheConfirmedHolderItIsMeantToProduce() { + engine.on("confirm", (request, observer) -> + reply(observer, aHolderResponse().setStatus(ResourceHolderStatus.CONFIRMED).build())); + + assertThat(caerus.confirm("hld-1").status()).isEqualTo(dev.caerus.sdk.sre.ResourceHolderStatus.CONFIRMED); + } + + @Test + void confirmRefusesAReleasedHolder() { + engine.on("confirm", (request, observer) -> + reply(observer, aHolderResponse().setStatus(ResourceHolderStatus.RELEASED).build())); + + assertThatThrownBy(() -> caerus.confirm("hld-1")).isInstanceOf(ConflictError.class); + } + + @Test + void extendRefusesAReleasedHolder() { + engine.on("extend", (request, observer) -> + reply(observer, aHolderResponse().setStatus(ResourceHolderStatus.RELEASED).build())); + + assertThatThrownBy(() -> caerus.extend("hld-1", 60_000)).isInstanceOf(ConflictError.class); + } + + @Test + void extendAcceptsAQueuedHolder() { + answer("extend", aHolderResponse().setStatus(ResourceHolderStatus.QUEUED).build()); + + assertThat(caerus.extend("hld-1", 60_000).status()).isEqualTo(dev.caerus.sdk.sre.ResourceHolderStatus.QUEUED); + } + + @Test + void aQueryStillAnswersWithTheFinishedHolderInsteadOfThrowing() { + answer("getResourceHolder", aHolderResponse().setStatus(ResourceHolderStatus.RELEASED).build()); + + assertThat(caerus.getResourceHolder("hld-1").status()).isEqualTo(dev.caerus.sdk.sre.ResourceHolderStatus.RELEASED); + } + + @Test + void theMockStampsCreatedAtAndUpdatedAtOnAResource() { + InMemoryCaerusClient mock = new InMemoryCaerusClient(); + Resource created = mock.createUnitary("butaca", "seat_A12"); + + assertThat(created.createdAt()).isPresent(); + assertThat(created.updatedAt()).isPresent(); + } + + @Test + void theMockMovesUpdatedAtWhenTheResourceChanges() { + InMemoryCaerusClient mock = new InMemoryCaerusClient(); + Resource created = mock.createMultiple("entrada", "general", 10); + + mock.advanceTime(60); + Resource updated = mock.updateResource("general", 5); + + assertThat(updated.updatedAt().orElseThrow()).isAfter(created.updatedAt().orElseThrow()); + assertThat(updated.createdAt()).isEqualTo(created.createdAt()); + } + + @Test + void theMockStampsCreatedAtOnAHolder() { + InMemoryCaerusClient mock = new InMemoryCaerusClient(); + mock.createUnitary("butaca", "seat_A12"); + + assertThat(mock.unitary("seat_A12").take().createdAt()).isPresent(); + } + + @Test + void theMockRefusesToReplayAnIdempotencyKeyWhoseHolderWasReleased() { + InMemoryCaerusClient mock = new InMemoryCaerusClient(); + mock.createUnitary("butaca", "seat_A12"); + + ResourceHolder first = mock.unitary("seat_A12").take(TakeOptions.builder().idempotencyKey("k1").build()); + mock.release(first.id()); + + assertThatThrownBy(() -> mock.unitary("seat_A12").take(TakeOptions.builder().idempotencyKey("k1").build())) + .isInstanceOf(ConflictError.class); + } + + @Test + void theMockStillReplaysAnIdempotencyKeyWhoseHolderIsAlive() { + InMemoryCaerusClient mock = new InMemoryCaerusClient(); + mock.createUnitary("butaca", "seat_A12"); + + ResourceHolder first = mock.unitary("seat_A12").take(TakeOptions.builder().idempotencyKey("k1").build()); + ResourceHolder again = mock.unitary("seat_A12").take(TakeOptions.builder().idempotencyKey("k1").build()); + + assertThat(again.id()).isEqualTo(first.id()); + } +} diff --git a/src/test/java/dev/caerus/sdk/sre/TransportTest.java b/src/test/java/dev/caerus/sdk/sre/TransportTest.java new file mode 100644 index 0000000..aa78726 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/sre/TransportTest.java @@ -0,0 +1,175 @@ +package dev.caerus.sdk.sre; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.support.FakeEngine; +import dev.caerus.sdk.support.GrpcErrors; +import io.grpc.Metadata; +import io.grpc.Status; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class TransportTest { + + private static final String API_KEY = "no-es-una-clave"; + + private static FakeEngine engine; + private CaerusClient client; + + @BeforeAll + static void startEngine() { + engine = FakeEngine.start(); + } + + @AfterAll + static void stopEngine() { + engine.close(); + } + + @BeforeEach + void connect() { + engine.reset(); + client = clientWith(CaerusClientOptions.builder()); + } + + @AfterEach + void disconnect() { + client.close(); + } + + static CaerusClient clientWith(CaerusClientOptions.Builder builder) { + return new CaerusClient(CaerusClient.resolve( + builder.endpoint(engine.endpoint()).apiKey(API_KEY).tls(false).build(), name -> null)); + } + + private static Metadata.Key key(String name) { + return Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER); + } + + @Test + void sendsTheApiKeyAsABearerTokenInTheMetadata() { + client.getResource("seat_A12"); + + assertThat(engine.lastMetadata().getAll(key("authorization"))).containsExactly("Bearer " + API_KEY); + } + + @Test + void sendsNoEnvironmentOfItsOwn() { + client.getResource("seat_A12"); + + assertThat(engine.lastMetadata().get(key("environment_id"))).isNull(); + assertThat(engine.lastMetadata().get(key("environment"))).isNull(); + } + + @Test + void sendsARequestIdOnEveryCall() { + client.getResource("seat_A12"); + String first = engine.lastMetadata().get(key("x-request-id")); + client.getResource("seat_A12"); + String second = engine.lastMetadata().get(key("x-request-id")); + + assertThat(first).matches("^req_[a-zA-Z0-9_\\-]{8,64}$"); + assertThat(second).matches("^req_[a-zA-Z0-9_\\-]{8,64}$"); + assertThat(first).isNotEqualTo(second); + } + + @Test + void turnsANotFoundFromTheServerIntoResourceNotFoundError() { + engine.on("getResource", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.NOT_FOUND, "Resource not found: seat_A12"))); + + ResourceNotFoundError error = catchThrowableOfType(ResourceNotFoundError.class, + () -> client.getResource("seat_A12")); + + assertThat(error).isInstanceOf(CaerusError.class); + assertThat(error.code()).isEqualTo(ErrorCode.RESOURCE_NOT_FOUND); + assertThat(error.getMessage()).contains("Resource not found: seat_A12"); + assertThat(error.requestId()).isPresent(); + } + + static Stream statuses() { + return Stream.of( + Arguments.of(Status.Code.FAILED_PRECONDITION, ConflictError.class, ErrorCode.CONFLICT), + Arguments.of(Status.Code.INVALID_ARGUMENT, ValidationError.class, ErrorCode.VALIDATION), + Arguments.of(Status.Code.UNAUTHENTICATED, AuthenticationError.class, ErrorCode.AUTHENTICATION), + Arguments.of(Status.Code.DEADLINE_EXCEEDED, TimeoutError.class, ErrorCode.TIMEOUT), + Arguments.of(Status.Code.NOT_FOUND, ResourceNotFoundError.class, ErrorCode.RESOURCE_NOT_FOUND)); + } + + @ParameterizedTest + @MethodSource("statuses") + void mapsEachStatusToTheMatchingError(Status.Code code, Class expected, ErrorCode expectedCode) { + engine.on("getResource", (request, observer) -> observer.onError(GrpcErrors.status(code, "from the server"))); + + CaerusError error = catchThrowableOfType(CaerusError.class, () -> client.getResource("seat_A12")); + + assertThat(error).isExactlyInstanceOf(expected); + assertThat(error.code()).isEqualTo(expectedCode); + assertThat(error.getMessage()).contains("from the server"); + assertThat(error.requestId()).isPresent(); + } + + @Test + void leavesAnythingElseAsTheBaseError() { + engine.on("getResource", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.INTERNAL, "Unexpected gRPC error"))); + + CaerusError error = catchThrowableOfType(CaerusError.class, () -> client.getResource("seat_A12")); + + assertThat(error).isExactlyInstanceOf(CaerusError.class); + assertThat(error.code()).isEqualTo(ErrorCode.UNKNOWN); + } + + @Test + void givesUpOnACallThatRunsPastItsDeadline() { + engine.on("getResource", (request, observer) -> { + }); + + try (CaerusClient impatient = clientWith(CaerusClientOptions.builder().timeoutMs(150L))) { + long started = System.nanoTime(); + TimeoutError error = catchThrowableOfType(TimeoutError.class, () -> impatient.getResource("seat_A12")); + long elapsedMs = (System.nanoTime() - started) / 1_000_000; + + assertThat(error.code()).isEqualTo(ErrorCode.TIMEOUT); + assertThat(elapsedMs).isLessThan(5_000); + } + } + + @Test + void rejectsCallsMadeAfterItIsClosed() { + client.close(); + + CaerusError error = catchThrowableOfType(CaerusError.class, () -> client.getResource("seat_A12")); + + assertThat(error.getMessage()).contains("has been closed"); + assertThat(error.code()).isEqualTo(ErrorCode.UNKNOWN); + } + + @Test + void closingTwiceIsHarmless() { + client.close(); + client.close(); + } + + @Test + void rethrowsTheOriginalErrorOnTheCallingThread() { + engine.on("getResource", (request, observer) -> + observer.onError(GrpcErrors.status(Status.Code.NOT_FOUND, "Resource not found: seat_A12"))); + + Throwable error = org.assertj.core.api.Assertions.catchThrowable(() -> client.getResource("seat_A12")); + + assertThat(error).isInstanceOf(ResourceNotFoundError.class); + assertThat(error).isNotInstanceOf(java.util.concurrent.CompletionException.class); + } +} diff --git a/src/test/java/dev/caerus/sdk/support/FakeDlsEngine.java b/src/test/java/dev/caerus/sdk/support/FakeDlsEngine.java new file mode 100644 index 0000000..93faef2 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/support/FakeDlsEngine.java @@ -0,0 +1,103 @@ +package dev.caerus.sdk.support; + +import com.google.protobuf.Empty; +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.BeginTransactionResponse; +import dev.caerus.sdk.internal.proto.dls.DistributedLockingEngineGrpc; +import dev.caerus.sdk.internal.proto.dls.GetLockStatusRequest; +import dev.caerus.sdk.internal.proto.dls.GetLockStatusResponse; +import dev.caerus.sdk.internal.proto.dls.GetTransactionStatusRequest; +import dev.caerus.sdk.internal.proto.dls.GetTransactionStatusResponse; +import dev.caerus.sdk.internal.proto.dls.LockHolderInfo; +import dev.caerus.sdk.internal.proto.dls.LockMode; +import dev.caerus.sdk.internal.proto.dls.LockStatus; +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 dev.caerus.sdk.internal.proto.dls.RenewTransactionResponse; +import io.grpc.stub.StreamObserver; + +public final class FakeDlsEngine extends RecordingServer { + + private FakeDlsEngine() { + } + + public static FakeDlsEngine start() { + FakeDlsEngine engine = new FakeDlsEngine(); + engine.start(engine.new Service()); + return engine; + } + + public static AcquireLockResponse acquired(String lockId, long fencingToken) { + return AcquireLockResponse.newBuilder() + .setLockId(lockId) + .setFencingToken(fencingToken) + .setStatus(LockStatus.ACQUIRED) + .build(); + } + + public static AcquireLockResponse withStatus(LockStatus status) { + return AcquireLockResponse.newBuilder().setStatus(status).build(); + } + + private final class Service extends DistributedLockingEngineGrpc.DistributedLockingEngineImplBase { + + @Override + public void beginTransaction(BeginTransactionRequest request, StreamObserver observer) { + dispatch("beginTransaction", request, observer, + BeginTransactionResponse.newBuilder().setTransactionId("tx-1").build()); + } + + @Override + public void acquireLock(AcquireLockRequest request, StreamObserver observer) { + if (dispatchStream("acquireLock", request, observer)) { + return; + } + observer.onNext(acquired("lock-1", 1234)); + observer.onCompleted(); + } + + @Override + public void renewTransaction(RenewTransactionRequest request, StreamObserver observer) { + dispatch("renewTransaction", request, observer, RenewTransactionResponse.newBuilder() + .setTransactionId("tx-1") + .setNewExpiresAt(1_234_567_890L) + .build()); + } + + @Override + public void releaseLock(ReleaseLockRequest request, StreamObserver observer) { + dispatch("releaseLock", request, observer, Empty.getDefaultInstance()); + } + + @Override + public void releaseTransactionLocks(ReleaseTransactionLocksRequest request, StreamObserver observer) { + dispatch("releaseTransactionLocks", request, observer, Empty.getDefaultInstance()); + } + + @Override + public void getLockStatus(GetLockStatusRequest request, StreamObserver observer) { + dispatch("getLockStatus", request, observer, GetLockStatusResponse.newBuilder() + .setIsHeld(true) + .setCurrentMode(LockMode.EXCLUSIVE) + .addActiveHolders(LockHolderInfo.newBuilder() + .setLockId("l-1") + .setExpiresAt(1000) + .setFencingToken(5)) + .setPendingQueueSize(0) + .build()); + } + + @Override + public void getTransactionStatus( + GetTransactionStatusRequest request, StreamObserver observer) { + dispatch("getTransactionStatus", request, observer, GetTransactionStatusResponse.newBuilder() + .setStatus("ACTIVE") + .setAbortReason("") + .setExpiresAt(1000) + .build()); + } + } +} diff --git a/src/test/java/dev/caerus/sdk/support/FakeEngine.java b/src/test/java/dev/caerus/sdk/support/FakeEngine.java new file mode 100644 index 0000000..714ec54 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/support/FakeEngine.java @@ -0,0 +1,118 @@ +package dev.caerus.sdk.support; + +import com.google.protobuf.Empty; +import dev.caerus.sdk.internal.proto.sre.ConfirmRequest; +import dev.caerus.sdk.internal.proto.sre.CreateResourceRequest; +import dev.caerus.sdk.internal.proto.sre.DeleteResourceRequest; +import dev.caerus.sdk.internal.proto.sre.ExtendRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHolderRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourceHoldersListResponse; +import dev.caerus.sdk.internal.proto.sre.GetResourceRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyRequest; +import dev.caerus.sdk.internal.proto.sre.GetResourcesByGroupKeyResponse; +import dev.caerus.sdk.internal.proto.sre.ReleaseRequest; +import dev.caerus.sdk.internal.proto.sre.ResourceHolderResponse; +import dev.caerus.sdk.internal.proto.sre.ResourceResponse; +import dev.caerus.sdk.internal.proto.sre.SharedResourceEngineGrpc; +import dev.caerus.sdk.internal.proto.sre.TakeRequest; +import dev.caerus.sdk.internal.proto.sre.UpdateResourceRequest; +import io.grpc.stub.StreamObserver; + +public final class FakeEngine extends RecordingServer { + + private FakeEngine() { + } + + public static FakeEngine start() { + FakeEngine engine = new FakeEngine(); + engine.start(engine.new Service()); + return engine; + } + + public static ResourceResponse.Builder aResourceResponse() { + return ResourceResponse.newBuilder() + .setResourceId("res-1") + .setKey("seat_A12") + .setTemplateId("tpl-1") + .setAvailableAmount(1) + .setPendingCount(0); + } + + public static ResourceHolderResponse.Builder aHolderResponse() { + return ResourceHolderResponse.newBuilder() + .setHolderId("hld-1") + .setResourceId("res-1") + .setStatus(ResourceHolderResponse.ResourceHolderStatus.PENDING) + .setAmount(1) + .setExpiresAt(1_785_164_400L); + } + + private final class Service extends SharedResourceEngineGrpc.SharedResourceEngineImplBase { + + @Override + public void createResource(CreateResourceRequest request, StreamObserver observer) { + dispatch("createResource", request, observer, aResourceResponse().build()); + } + + @Override + public void updateResource(UpdateResourceRequest request, StreamObserver observer) { + dispatch("updateResource", request, observer, aResourceResponse().build()); + } + + @Override + public void deleteResource(DeleteResourceRequest request, StreamObserver observer) { + dispatch("deleteResource", request, observer, Empty.getDefaultInstance()); + } + + @Override + public void take(TakeRequest request, StreamObserver observer) { + dispatch("take", request, observer, aHolderResponse().build()); + } + + @Override + public void confirm(ConfirmRequest request, StreamObserver observer) { + dispatch("confirm", request, observer, aHolderResponse() + .setStatus(ResourceHolderResponse.ResourceHolderStatus.CONFIRMED) + .build()); + } + + @Override + public void release(ReleaseRequest request, StreamObserver observer) { + dispatch("release", request, observer, Empty.getDefaultInstance()); + } + + @Override + public void extend(ExtendRequest request, StreamObserver observer) { + dispatch("extend", request, observer, aHolderResponse().build()); + } + + @Override + public void getResource(GetResourceRequest request, StreamObserver observer) { + dispatch("getResource", request, observer, aResourceResponse().build()); + } + + @Override + public void getResourcesByGroupKey( + GetResourcesByGroupKeyRequest request, StreamObserver observer) { + dispatch("getResourcesByGroupKey", request, observer, GetResourcesByGroupKeyResponse.newBuilder() + .addResources(aResourceResponse()) + .setNextPage(false) + .build()); + } + + @Override + public void getResourceHolder(GetResourceHolderRequest request, StreamObserver observer) { + dispatch("getResourceHolder", request, observer, aHolderResponse().build()); + } + + @Override + public void getResourceHoldersList( + GetResourceHoldersListRequest request, StreamObserver observer) { + dispatch("getResourceHoldersList", request, observer, GetResourceHoldersListResponse.newBuilder() + .addResourceHolders(aHolderResponse()) + .setNextPage(false) + .build()); + } + } +} diff --git a/src/test/java/dev/caerus/sdk/support/GrpcErrors.java b/src/test/java/dev/caerus/sdk/support/GrpcErrors.java new file mode 100644 index 0000000..015b173 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/support/GrpcErrors.java @@ -0,0 +1,50 @@ +package dev.caerus.sdk.support; + +import com.google.protobuf.Any; +import com.google.rpc.ErrorInfo; +import dev.caerus.sdk.internal.ErrorDetails; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +public final class GrpcErrors { + + private GrpcErrors() { + } + + public static StatusRuntimeException status(Status.Code code, String description) { + return Status.fromCode(code).withDescription(description).asRuntimeException(new Metadata()); + } + + public static StatusRuntimeException withReason(Status.Code code, String description, String reason) { + return Status.fromCode(code).withDescription(description).asRuntimeException(reasonTrailers(code, reason)); + } + + public static StatusRuntimeException withTrailers(Status.Code code, String description, Metadata trailers) { + return Status.fromCode(code).withDescription(description).asRuntimeException(trailers); + } + + public static Metadata reasonTrailers(Status.Code code, String reason) { + Metadata trailers = new Metadata(); + trailers.put(ErrorDetails.STATUS_DETAILS_KEY, statusBytes(code, reason)); + return trailers; + } + + public static Metadata requestIdTrailers(String requestId) { + Metadata trailers = new Metadata(); + trailers.put(ErrorDetails.REQUEST_ID_KEY, requestId); + return trailers; + } + + public static byte[] statusBytes(Status.Code code, String reason) { + return com.google.rpc.Status.newBuilder() + .setCode(code.value()) + .setMessage("from the server") + .addDetails(Any.pack(ErrorInfo.newBuilder() + .setReason(reason) + .setDomain("caerus.dev") + .build())) + .build() + .toByteArray(); + } +} diff --git a/src/test/java/dev/caerus/sdk/support/RealTrailers.java b/src/test/java/dev/caerus/sdk/support/RealTrailers.java new file mode 100644 index 0000000..9149a91 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/support/RealTrailers.java @@ -0,0 +1,53 @@ +package dev.caerus.sdk.support; + +import dev.caerus.sdk.internal.ErrorDetails; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class RealTrailers { + + public static final Map SRE = new LinkedHashMap<>(); + public static final String DEADLOCK_DETECTED = + "CAoSR1RyYW5zYWN0aW9uIGYzZDIzOWY2LWFiNWItNDkzNi05NDc3LThhYzQ5ZDMyZDA2YyBtYXJrZWQgQUJPUlRfUkVRVUVTVEVEGksKKHR5cGUuZ29vZ2xlYXBpcy5jb20vZ29vZ2xlLnJwYy5FcnJvckluZm8SHwoRREVBRExPQ0tfREVURUNURUQSCmNhZXJ1cy5kZXY="; + + static { + SRE.put("OUT_OF_STOCK", + "CAkSLk91dCBvZiBzdG9jayBmb3IgcmVzb3VyY2U6IGZ1bmNpb25ob3Jpem9udGVfRTgaRgoodHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUucnBjLkVycm9ySW5mbxIaCgxPVVRfT0ZfU1RPQ0sSCmNhZXJ1cy5kZXY="); + SRE.put("HOLDER_NOT_ACTIVE", + "CAkSNVJlc291cmNlSG9sZGVyIGlzIG5vdCBpbiBhIHZhbGlkIHN0YXRlIHRvIGJlIGV4dGVuZGVkGksKKHR5cGUuZ29vZ2xlYXBpcy5jb20vZ29vZ2xlLnJwYy5FcnJvckluZm8SHwoRSE9MREVSX05PVF9BQ1RJVkUSCmNhZXJ1cy5kZXY="); + SRE.put("RESOURCE_NOT_FOUND", + "CAUSLlJlc291cmNlIG5vdCBmb3VuZCB3aXRoIGlkOiBub19leGlzdGVfamFtYXNfenoaTAoodHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUucnBjLkVycm9ySW5mbxIgChJSRVNPVVJDRV9OT1RfRk9VTkQSCmNhZXJ1cy5kZXY="); + SRE.put("TEMPLATE_NOT_FOUND", + "CAUSNlRlbXBsYXRlIG5vdCBmb3VuZCB3aXRoIG5hbWU6IHBsYW50aWxsYV9pbmV4aXN0ZW50ZV96ehpMCih0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5ycGMuRXJyb3JJbmZvEiAKElRFTVBMQVRFX05PVF9GT1VORBIKY2FlcnVzLmRldg=="); + SRE.put("RESOURCE_HAS_ACTIVE_HOLDS", + "CAkSPUNhbm5vdCBkZWxldGUgcmVzb3VyY2Ugd2l0aCBhY3RpdmUgaG9sZHM6IGZ1bmNpb25ob3Jpem9udGVfRTcaUwoodHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUucnBjLkVycm9ySW5mbxInChlSRVNPVVJDRV9IQVNfQUNUSVZFX0hPTERTEgpjYWVydXMuZGV2"); + } + + private RealTrailers() { + } + + public static byte[] bytes(String base64) { + return Base64.getDecoder().decode(base64); + } + + public static StatusRuntimeException grpcError(Status.Code code, String message, byte[] detail) { + Metadata trailers = new Metadata(); + if (detail != null) { + trailers.put(ErrorDetails.STATUS_DETAILS_KEY, detail); + } + return Status.fromCode(code).withDescription(message).asRuntimeException(trailers); + } + + public static StatusRuntimeException grpcError(Status.Code code, String message, String base64Detail) { + return grpcError(code, message, base64Detail == null ? null : bytes(base64Detail)); + } + + public static StatusRuntimeException grpcError(Status.Code code, String message) { + return grpcError(code, message, (byte[]) null); + } +} diff --git a/src/test/java/dev/caerus/sdk/support/RecordingServer.java b/src/test/java/dev/caerus/sdk/support/RecordingServer.java new file mode 100644 index 0000000..dbeeab8 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/support/RecordingServer.java @@ -0,0 +1,112 @@ +package dev.caerus.sdk.support; + +import io.grpc.BindableService; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public abstract class RecordingServer implements AutoCloseable { + + @FunctionalInterface + public interface Handler { + void handle(Req request, StreamObserver observer); + } + + private final Map> handlers = new ConcurrentHashMap<>(); + private volatile Metadata lastMetadata; + private volatile Object lastRequest; + private volatile String lastMethod; + private Server server; + + protected final void start(BindableService service) { + ServerInterceptor capture = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + lastMetadata = headers; + return next.startCall(call, headers); + } + }; + try { + server = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(ServerInterceptors.intercept(service, capture)) + .build() + .start(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public final String endpoint() { + return "127.0.0.1:" + server.getPort(); + } + + public final Metadata lastMetadata() { + return lastMetadata; + } + + public final T lastRequest(Class type) { + return type.cast(lastRequest); + } + + public final String lastMethod() { + return lastMethod; + } + + public final void on(String method, Handler handler) { + handlers.put(method, handler); + } + + public final void reset() { + handlers.clear(); + lastMetadata = null; + lastRequest = null; + lastMethod = null; + } + + @SuppressWarnings("unchecked") + protected final void dispatch(String method, Req request, StreamObserver observer, Res fallback) { + lastRequest = request; + lastMethod = method; + Handler handler = (Handler) handlers.get(method); + if (handler != null) { + handler.handle(request, observer); + return; + } + observer.onNext(fallback); + observer.onCompleted(); + } + + @SuppressWarnings("unchecked") + protected final boolean dispatchStream(String method, Req request, StreamObserver observer) { + lastRequest = request; + lastMethod = method; + Handler handler = (Handler) handlers.get(method); + if (handler != null) { + handler.handle(request, observer); + return true; + } + return false; + } + + @Override + public final void close() { + try { + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/dev/caerus/sdk/webhooks/WebhooksInteropTest.java b/src/test/java/dev/caerus/sdk/webhooks/WebhooksInteropTest.java new file mode 100644 index 0000000..6547742 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/webhooks/WebhooksInteropTest.java @@ -0,0 +1,82 @@ +package dev.caerus.sdk.webhooks; + +import dev.caerus.sdk.CaerusError; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +class WebhooksInteropTest { + + private static final String ENGINE_HEX = "5e3b7a684499bb93c4402adc4c4c6914c9c19be915d55179ba6b881175fe8775"; + private static final long TS = 1_724_425_200L; + private static final String PAYLOAD = "{\"event_id\":\"123\",\"eventType\":\"resource.taken\"}"; + private static final String SECRET = "whsec_test_secret_key_123456789"; + private static final long NO_EXPIRY = 1_000_000_000L; + + private final Webhooks webhooks = new Webhooks(); + + @Test + void signsExactlyTheSameAsTheEngineAndTheTypeScriptSdk() { + assertThat(Webhooks.sign(SECRET, TS + "." + PAYLOAD)).isEqualTo(ENGINE_HEX); + assertThat(WebhooksTest.engineSign(TS, PAYLOAD, SECRET)).isEqualTo(ENGINE_HEX); + } + + @Test + void acceptsASignatureGeneratedByTheEngine() { + CaerusEvent event = webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + ENGINE_HEX, SECRET, NO_EXPIRY); + + assertThat(event.raw()).containsEntry("event_id", "123"); + assertThat(event.eventType()).isEqualTo("resource.taken"); + assertThat(event.data()).isInstanceOf(ResourceTakenData.class); + } + + @Test + void refusesASignatureThatDoesNotMatch() { + assertThatThrownBy(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + "0".repeat(64), SECRET, NO_EXPIRY)) + .isInstanceOf(CaerusSignatureError.class); + } + + @Test + void refusesAnOldEventWithTheDefaultTolerance() { + assertThatThrownBy(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + ENGINE_HEX, SECRET)) + .isInstanceOf(CaerusWebhookExpiredError.class); + } + + @Test + void refusesAnEmptySecretInsteadOfVerifyingAgainstNothing() { + String withEmpty = WebhooksTest.engineSign(TS, PAYLOAD, "x").replace('x', 'y'); + + assertThatThrownBy(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + withEmpty, "", NO_EXPIRY)) + .isInstanceOf(CaerusSignatureError.class); + assertThatThrownBy(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + withEmpty, " ", NO_EXPIRY)) + .isInstanceOf(CaerusSignatureError.class); + assertThatThrownBy(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + withEmpty, null, NO_EXPIRY)) + .isInstanceOf(CaerusSignatureError.class); + } + + @Test + void aSignedBodyThatIsNotJsonIsAPackageError() { + String body = "esto no es json"; + String signature = Webhooks.sign(SECRET, TS + "." + body); + + assertThatThrownBy(() -> webhooks.constructEvent(body, "t=" + TS + ",v1=" + signature, SECRET, NO_EXPIRY)) + .isInstanceOf(CaerusWebhookPayloadError.class); + } + + @Test + void everyWebhookErrorIsCaughtAsCaerusError() { + List errors = List.of( + catchThrowable(() -> webhooks.constructEvent(PAYLOAD, "", SECRET)), + catchThrowable(() -> webhooks.constructEvent(PAYLOAD, "sin formato", SECRET)), + catchThrowable(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + ENGINE_HEX, SECRET)), + catchThrowable(() -> webhooks.constructEvent(PAYLOAD, "t=" + TS + ",v1=" + ENGINE_HEX, "", NO_EXPIRY))); + + for (Throwable error : errors) { + assertThat(error).isInstanceOf(CaerusError.class); + } + } +} diff --git a/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java b/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java new file mode 100644 index 0000000..ef8addd --- /dev/null +++ b/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java @@ -0,0 +1,313 @@ +package dev.caerus.sdk.webhooks; + +import dev.caerus.sdk.CaerusError; +import dev.caerus.sdk.ErrorCode; +import dev.caerus.sdk.sre.CaerusClientOptions; +import dev.caerus.sdk.sre.InMemoryCaerusClient; +import org.junit.jupiter.api.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +class WebhooksTest { + + private static final String SECRET = "test_secret_key"; + private static final long NOW = 1_790_000_000L; + + private final Webhooks webhooks = new Webhooks(Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC)); + + static String engineSign(long timestampSeconds, String payload, String secretKey) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal((timestampSeconds + "." + payload).getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String header(String payload, long timestamp, String secret) { + return "t=" + timestamp + ",v1=" + engineSign(timestamp, payload, secret); + } + + private static String header(String payload, long timestamp) { + return header(payload, timestamp, SECRET); + } + + @Test + void constructsAnEventWithAValidSignatureAndPayload() { + String payload = "{\"id\":\"evt_123\",\"eventId\":\"evt_123\",\"eventType\":\"resource.taken\",\"product\":\"SRE\"," + + "\"objectType\":\"RESOURCE_HOLDER\",\"objectId\":\"h_123\",\"environmentId\":\"env_abc\"," + + "\"occurredAt\":\"2026-08-29T15:00:00Z\",\"data\":{\"eventId\":\"evt_123\",\"occurredOn\":\"2026-08-29T15:00:00Z\"," + + "\"holderId\":\"h_123\",\"resourceKey\":\"seat_A12\",\"amount\":1,\"expiresAt\":1000," + + "\"createdAtMs\":1700000000000,\"idempotencyKey\":null}}"; + + CaerusEvent event = webhooks.constructEvent(payload, header(payload, NOW), SECRET); + + assertThat(event.eventType()).isEqualTo("resource.taken"); + assertThat(event.eventId()).isEqualTo("evt_123"); + assertThat(event.product()).isEqualTo("SRE"); + assertThat(event.objectId()).isEqualTo("h_123"); + assertThat(event.data()).isInstanceOf(ResourceTakenData.class); + ResourceTakenData data = (ResourceTakenData) event.data(); + assertThat(data.holderId()).isEqualTo("h_123"); + assertThat(data.resourceKey()).isEqualTo("seat_A12"); + assertThat(data.amount()).isEqualTo(1); + assertThat(data.expiresAt()).isEqualTo(1000); + assertThat(data.createdAtMs()).isEqualTo(1_700_000_000_000L); + assertThat(data.idempotencyKey()).isEmpty(); + assertThat(data.metadata()).isEmpty(); + assertThat(data.occurredOn()).isEqualTo("2026-08-29T15:00:00Z"); + } + + @Test + void handlesThePayloadAsBytes() { + String payload = "{\"eventType\":\"resource.taken\"}"; + + CaerusEvent event = webhooks.constructEvent(payload.getBytes(StandardCharsets.UTF_8), header(payload, NOW), SECRET); + + assertThat(event.eventType()).isEqualTo("resource.taken"); + } + + @Test + void verifiesTheRawUtf8Bytes() { + String payload = "{\"eventType\":\"resource.created\",\"data\":{\"resourceKey\":\"butaca_ñ_€\"}}"; + + CaerusEvent event = webhooks.constructEvent(payload.getBytes(StandardCharsets.UTF_8), header(payload, NOW), SECRET); + + assertThat(((ResourceCreatedData) event.data()).resourceKey()).isEqualTo("butaca_ñ_€"); + } + + @Test + void verifiesTheSignatureOverTheRawBytesEvenWhenTheyAreNotValidUtf8() throws Exception { + java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream(); + body.write("{\"eventType\":\"x\",\"b\":\"".getBytes(StandardCharsets.UTF_8)); + body.write(0xff); + body.write("\"}".getBytes(StandardCharsets.UTF_8)); + byte[] raw = body.toByteArray(); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update((NOW + ".").getBytes(StandardCharsets.UTF_8)); + String signed = "t=" + NOW + ",v1=" + HexFormat.of().formatHex(mac.doFinal(raw)); + + assertThatThrownBy(() -> webhooks.constructEvent(raw, signed, SECRET)) + .isInstanceOf(CaerusWebhookPayloadError.class) + .hasMessageContaining("UTF-8"); + } + + @Test + void throwsASignatureErrorIfTheHeaderIsMissing() { + assertThatThrownBy(() -> webhooks.constructEvent("{}", "", SECRET)).isInstanceOf(CaerusSignatureError.class); + assertThatThrownBy(() -> webhooks.constructEvent("{}", (String) null, SECRET)).isInstanceOf(CaerusSignatureError.class); + } + + @Test + void throwsASignatureErrorOnAnInvalidFormat() { + assertThatThrownBy(() -> webhooks.constructEvent("{}", "t=123,v2=invalid", SECRET)) + .isInstanceOf(CaerusSignatureError.class) + .hasMessage("Invalid signature format."); + assertThatThrownBy(() -> webhooks.constructEvent("{}", "t=,v1=abc", SECRET)) + .hasMessage("Invalid signature format."); + assertThatThrownBy(() -> webhooks.constructEvent("{}", "t=nope,v1=abc", SECRET)) + .hasMessage("Invalid signature format."); + assertThatThrownBy(() -> webhooks.constructEvent("{}", "v1=abc", SECRET)) + .hasMessage("Invalid signature format."); + } + + @Test + void throwsAnExpiredErrorOutsideTheToleranceZone() { + String payload = "{}"; + + assertThatThrownBy(() -> webhooks.constructEvent(payload, header(payload, NOW - 360), SECRET, 300)) + .isInstanceOf(CaerusWebhookExpiredError.class); + assertThatThrownBy(() -> webhooks.constructEvent(payload, header(payload, NOW + 360), SECRET, 300)) + .isInstanceOf(CaerusWebhookExpiredError.class); + } + + @Test + void acceptsAnEventWithinTheToleranceZone() { + String payload = "{\"eventType\":\"test\"}"; + + CaerusEvent event = webhooks.constructEvent(payload, header(payload, NOW - 240), SECRET, 300); + + assertThat(event.eventType()).isEqualTo("test"); + } + + @Test + void theDefaultToleranceIsFiveMinutes() { + String payload = "{\"eventType\":\"test\"}"; + + assertThat(webhooks.constructEvent(payload, header(payload, NOW - 300), SECRET).eventType()).isEqualTo("test"); + assertThatThrownBy(() -> webhooks.constructEvent(payload, header(payload, NOW - 301), SECRET)) + .isInstanceOf(CaerusWebhookExpiredError.class); + } + + @Test + void supportsKeyRolloverWithSeveralSignatures() { + String payload = "{\"eventType\":\"test\"}"; + String oldSignature = engineSign(NOW, payload, "old_secret"); + String newSignature = engineSign(NOW, payload, SECRET); + + CaerusEvent event = webhooks.constructEvent(payload, + "t=" + NOW + ", v1=" + oldSignature + " , v1=" + newSignature, SECRET); + + assertThat(event.eventType()).isEqualTo("test"); + } + + @Test + void throwsASignatureErrorForTheWrongSecret() { + String payload = "{\"eventType\":\"test\"}"; + + assertThatThrownBy(() -> webhooks.constructEvent(payload, header(payload, NOW, "wrong_secret"), SECRET)) + .isInstanceOf(CaerusSignatureError.class) + .hasMessage("No matching signature found."); + } + + @Test + void refusesSignaturesOfTheWrongLength() { + String payload = "{\"eventType\":\"test\"}"; + + assertThatThrownBy(() -> webhooks.constructEvent(payload, "t=" + NOW + ",v1=1234567890abcdef", SECRET)) + .isInstanceOf(CaerusSignatureError.class); + assertThatThrownBy(() -> webhooks.constructEvent(payload, "t=" + NOW + ",v1=" + "a".repeat(100), SECRET)) + .isInstanceOf(CaerusSignatureError.class); + } + + @Test + void aTamperedBodyFailsTheSignature() { + String payload = "{\"eventType\":\"test\",\"amount\":1}"; + String signed = header(payload, NOW); + + assertThatThrownBy(() -> webhooks.constructEvent(payload.replace("1", "9"), signed, SECRET)) + .isInstanceOf(CaerusSignatureError.class); + } + + @Test + void decodesEveryKnownEventIntoItsOwnType() { + Map> expected = Map.ofEntries( + Map.entry("resource.created", ResourceCreatedData.class), + Map.entry("resource.taken", ResourceTakenData.class), + Map.entry("resource.confirmed", ResourceConfirmedData.class), + Map.entry("resource.released", ResourceReleasedData.class), + Map.entry("resource.extended", ResourceExtendedData.class), + Map.entry("resource.expired", ResourceExpiredData.class), + Map.entry("resource.updated", ResourceUpdatedData.class), + Map.entry("resource.deleted", ResourceDeletedData.class), + Map.entry("resource.queued", ResourceQueuedData.class), + Map.entry("resource.take_failed", ResourceTakeFailedData.class), + Map.entry("lock.acquired", LockAcquiredData.class), + Map.entry("lock.released", LockReleasedData.class), + Map.entry("lock.acquire_failed", LockAcquireFailedData.class), + Map.entry("lock.deadlock_detected", DeadlockDetectedData.class), + Map.entry("lock.abandoned", LockAbandonedData.class), + Map.entry("transaction.started", TransactionStartedData.class), + Map.entry("transaction.completed", TransactionCompletedData.class), + Map.entry("transaction.aborted", TransactionAbortedData.class), + Map.entry("transaction.renewed", TransactionRenewedData.class), + Map.entry("transaction.expired", TransactionExpiredData.class)); + + for (Map.Entry> entry : expected.entrySet()) { + String payload = "{\"eventType\":\"" + entry.getKey() + "\",\"data\":{\"eventId\":\"e1\"}}"; + CaerusEvent event = webhooks.constructEvent(payload, header(payload, NOW), SECRET); + assertThat(event.data()).as(entry.getKey()).isExactlyInstanceOf(entry.getValue()); + assertThat(event.data().eventId()).isEqualTo("e1"); + } + } + + @Test + void readsTheDeadlockCycle() { + String payload = "{\"eventType\":\"lock.deadlock_detected\",\"data\":{\"victimTransactionId\":\"tx-2\"," + + "\"cycleTransactionIds\":[\"tx-1\",\"tx-2\"],\"resolutionStrategy\":\"ABORT\"}}"; + + DeadlockDetectedData data = (DeadlockDetectedData) webhooks.constructEvent(payload, header(payload, NOW), SECRET).data(); + + assertThat(data.victimTransactionId()).isEqualTo("tx-2"); + assertThat(data.cycleTransactionIds()).containsExactly("tx-1", "tx-2"); + assertThat(data.resolutionStrategy()).isEqualTo("ABORT"); + assertThat(data.reason()).isEmpty(); + } + + @Test + void readsAFencingTokenBeyondTheIntRange() { + String payload = "{\"eventType\":\"lock.acquired\",\"data\":{\"fencingToken\":9007199254740993,\"mode\":\"EXCLUSIVE\"}}"; + + LockAcquiredData data = (LockAcquiredData) webhooks.constructEvent(payload, header(payload, NOW), SECRET).data(); + + assertThat(data.fencingToken()).isEqualTo(9_007_199_254_740_993L); + assertThat(data.mode()).isEqualTo("EXCLUSIVE"); + } + + @Test + void anEventTheSdkDoesNotKnowYetDoesNotBreakTheConsumer() { + String payload = "{\"eventType\":\"queue.something_new\",\"data\":{\"eventId\":\"e9\",\"whatever\":[1,2]}}"; + + CaerusEvent event = webhooks.constructEvent(payload, header(payload, NOW), SECRET); + + assertThat(event.data()).isInstanceOf(UnknownEventData.class); + UnknownEventData data = (UnknownEventData) event.data(); + assertThat(data.eventId()).isEqualTo("e9"); + assertThat(data.fields()).containsEntry("whatever", List.of(1L, 2L)); + } + + @Test + void keepsTheWholeEnvelope() { + String payload = "{\"eventType\":\"test\",\"futureField\":true}"; + + assertThat(webhooks.constructEvent(payload, header(payload, NOW), SECRET).raw()).containsEntry("futureField", true); + } + + @Test + void aSignedBodyThatIsNotJsonGivesAPackageErrorWithItsCause() { + String body = "esto no es json"; + + CaerusWebhookPayloadError error = catchThrowableOfType(CaerusWebhookPayloadError.class, + () -> webhooks.constructEvent(body, header(body, NOW), SECRET)); + + assertThat(error.getMessage()).isEqualTo("The webhook signature is valid but the body is not valid JSON."); + assertThat(error.getCause()).isNotNull(); + assertThat(error.code()).isEqualTo(ErrorCode.VALIDATION); + } + + @Test + void aSignedBodyThatIsJsonButNotAnObjectIsAPayloadError() { + String body = "[1,2,3]"; + + assertThatThrownBy(() -> webhooks.constructEvent(body, header(body, NOW), SECRET)) + .isInstanceOf(CaerusWebhookPayloadError.class); + } + + @Test + void theClientsExposeWebhooks() { + assertThat(new InMemoryCaerusClient().webhooks()).isNotNull(); + try (dev.caerus.sdk.sre.CaerusClient client = new dev.caerus.sdk.sre.CaerusClient( + CaerusClientOptions.builder().apiKey("k").endpoint("localhost:9").tls(false).build())) { + assertThat(client.webhooks()).isNotNull(); + } + } + + @Test + void everyWebhookErrorIsAValidationCaerusError() { + List errors = List.of( + catchThrowable(() -> webhooks.constructEvent("{}", "", SECRET)), + catchThrowable(() -> webhooks.constructEvent("{}", "sin formato", SECRET)), + catchThrowable(() -> webhooks.constructEvent("{}", header("{}", NOW - 1000), SECRET)), + catchThrowable(() -> webhooks.constructEvent("{}", header("{}", NOW), ""))); + + for (Throwable error : errors) { + assertThat(error).isInstanceOf(CaerusError.class); + assertThat(((CaerusError) error).code()).isEqualTo(ErrorCode.VALIDATION); + } + } +}