From 0ed537bfb6dc070aacb7f8165fd615736e5e0f3a Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:17 -0300 Subject: [PATCH 1/9] Generar el cliente gRPC desde una copia del contrato El .proto se copia byte a byte de caerus-back, que es privado, y su README registra commit y SHA-256 para poder comparar. El build lo copia a target, cambia el java_package a dev.caerus.sdk.internal.proto y genera desde ahi: las clases generadas quedan marcadas como internas y no chocan con las del motor si alguien tiene las dos en el classpath. Java 17 como minimo, protobuf 3.25 para no forzarle a nadie la 4.x (es la que pide grpc-java), y la version del jar inyectada desde el pom. --- .github/workflows/tests.yml | 38 +++ pom.xml | 248 ++++++++++++++++++ proto/README.md | 62 +++++ proto/dls_service.proto | 201 ++++++++++++++ proto/sre_service.proto | 143 ++++++++++ .../dev/caerus/sdk/version.properties | 1 + 6 files changed, 693 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 pom.xml create mode 100644 proto/README.md create mode 100644 proto/dls_service.proto create mode 100644 proto/sre_service.proto create mode 100644 src/main/resources/dev/caerus/sdk/version.properties 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/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/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} From fad9fddd3c765351af19186c2ac4c05ccc83b113 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:17 -0300 Subject: [PATCH 2/9] Armar los errores y el transporte que comparten SRE y DLS CaerusError es unchecked para que un solo catch alcance, con code, reason y requestId como en TypeScript. El reason se lee del ErrorInfo del trailer y nunca lanza: un trailer roto deja el error como estaba en vez de taparlo con otro. Cada llamada lleva su API Key, un x-request-id propio y un deadline. No hay reintentos. Un solo nucleo asincrono sobre grpc-java, para que la API asincrona de la etapa 2 sea una fachada y no una segunda implementacion. Dos tests cuidan las reglas: nada de protobuf, gRPC, Gson ni del paquete internal aparece en una firma publica, y el codigo no lleva comentarios. --- .../java/dev/caerus/sdk/AbortController.java | 18 +++ src/main/java/dev/caerus/sdk/AbortSignal.java | 66 +++++++++ src/main/java/dev/caerus/sdk/CaerusError.java | 56 ++++++++ .../dev/caerus/sdk/CaerusErrorOptions.java | 67 ++++++++++ .../java/dev/caerus/sdk/CaerusLogger.java | 7 + src/main/java/dev/caerus/sdk/CaerusSdk.java | 30 +++++ src/main/java/dev/caerus/sdk/ErrorCode.java | 10 ++ src/main/java/dev/caerus/sdk/ErrorReason.java | 23 ++++ .../caerus/sdk/internal/ClientProfile.java | 23 ++++ .../caerus/sdk/internal/ClientSettings.java | 100 ++++++++++++++ .../dev/caerus/sdk/internal/ErrorDetails.java | 98 ++++++++++++++ .../java/dev/caerus/sdk/internal/Futures.java | 52 ++++++++ .../caerus/sdk/internal/GrpcTransport.java | 118 +++++++++++++++++ .../java/dev/caerus/sdk/internal/Json.java | 71 ++++++++++ .../dev/caerus/sdk/internal/StderrLogger.java | 40 ++++++ .../java/dev/caerus/sdk/NoCommentsTest.java | 51 +++++++ .../java/dev/caerus/sdk/PublicApiTest.java | 125 ++++++++++++++++++ .../sdk/internal/GeneratedContractTest.java | 89 +++++++++++++ .../caerus/sdk/internal/RequestIdTest.java | 104 +++++++++++++++ .../dev/caerus/sdk/support/FakeDlsEngine.java | 103 +++++++++++++++ .../dev/caerus/sdk/support/FakeEngine.java | 118 +++++++++++++++++ .../dev/caerus/sdk/support/GrpcErrors.java | 50 +++++++ .../dev/caerus/sdk/support/RealTrailers.java | 53 ++++++++ .../caerus/sdk/support/RecordingServer.java | 112 ++++++++++++++++ 24 files changed, 1584 insertions(+) create mode 100644 src/main/java/dev/caerus/sdk/AbortController.java create mode 100644 src/main/java/dev/caerus/sdk/AbortSignal.java create mode 100644 src/main/java/dev/caerus/sdk/CaerusError.java create mode 100644 src/main/java/dev/caerus/sdk/CaerusErrorOptions.java create mode 100644 src/main/java/dev/caerus/sdk/CaerusLogger.java create mode 100644 src/main/java/dev/caerus/sdk/CaerusSdk.java create mode 100644 src/main/java/dev/caerus/sdk/ErrorCode.java create mode 100644 src/main/java/dev/caerus/sdk/ErrorReason.java create mode 100644 src/main/java/dev/caerus/sdk/internal/ClientProfile.java create mode 100644 src/main/java/dev/caerus/sdk/internal/ClientSettings.java create mode 100644 src/main/java/dev/caerus/sdk/internal/ErrorDetails.java create mode 100644 src/main/java/dev/caerus/sdk/internal/Futures.java create mode 100644 src/main/java/dev/caerus/sdk/internal/GrpcTransport.java create mode 100644 src/main/java/dev/caerus/sdk/internal/Json.java create mode 100644 src/main/java/dev/caerus/sdk/internal/StderrLogger.java create mode 100644 src/test/java/dev/caerus/sdk/NoCommentsTest.java create mode 100644 src/test/java/dev/caerus/sdk/PublicApiTest.java create mode 100644 src/test/java/dev/caerus/sdk/internal/GeneratedContractTest.java create mode 100644 src/test/java/dev/caerus/sdk/internal/RequestIdTest.java create mode 100644 src/test/java/dev/caerus/sdk/support/FakeDlsEngine.java create mode 100644 src/test/java/dev/caerus/sdk/support/FakeEngine.java create mode 100644 src/test/java/dev/caerus/sdk/support/GrpcErrors.java create mode 100644 src/test/java/dev/caerus/sdk/support/RealTrailers.java create mode 100644 src/test/java/dev/caerus/sdk/support/RecordingServer.java 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/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/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/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/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(); + } + } +} From 1d7666701bb8fc27d2d90cc1970057a44651665e Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:35 -0300 Subject: [PATCH 3/9] Portar el cliente del Shared Resource Engine Mismos metodos que en TypeScript, con builders en lugar de objetos de opciones y una sobrecarga sin opciones para cada uno. Valida antes de llamar y nombra el argumento; un take o un extend que vuelven en un estado que no sirven lanzan ConflictError, y un estado desconocido lanza en vez de adivinar. La metadata viaja como JSON y vuelve como Map; los tiempos en cero vuelven vacios, nunca como 1970. InMemoryCaerusClient copia al de TypeScript y ademas es thread-safe, porque en Java los tests usan hilos de verdad. Los tests de TypeScript portados corren contra un servidor gRPC real en 127.0.0.1, no contra un stub. --- .../caerus/sdk/sre/AuthenticationError.java | 16 + .../java/dev/caerus/sdk/sre/CaerusClient.java | 132 ++++ .../caerus/sdk/sre/CaerusClientOptions.java | 97 +++ .../dev/caerus/sdk/sre/ConfirmOptions.java | 46 ++ .../dev/caerus/sdk/sre/ConflictError.java | 16 + .../caerus/sdk/sre/CreateResourceOptions.java | 58 ++ .../sdk/sre/GetResourcesByGroupOptions.java | 55 ++ src/main/java/dev/caerus/sdk/sre/Handles.java | 60 ++ .../caerus/sdk/sre/HolderNotActiveError.java | 14 + .../java/dev/caerus/sdk/sre/HolderSort.java | 6 + .../caerus/sdk/sre/InMemoryCaerusClient.java | 465 ++++++++++++++ .../sdk/sre/ListResourceHoldersOptions.java | 91 +++ .../java/dev/caerus/sdk/sre/MockMethod.java | 17 + .../java/dev/caerus/sdk/sre/MockOptions.java | 68 ++ .../dev/caerus/sdk/sre/MockResourceSeed.java | 84 +++ .../java/dev/caerus/sdk/sre/MockSnapshot.java | 11 + .../dev/caerus/sdk/sre/OutOfStockError.java | 14 + .../dev/caerus/sdk/sre/PooledResource.java | 18 + .../java/dev/caerus/sdk/sre/Resource.java | 17 + .../sdk/sre/ResourceHasActiveHoldsError.java | 14 + .../sre/ResourceHasQueuedRequestsError.java | 14 + .../dev/caerus/sdk/sre/ResourceHolder.java | 15 + .../caerus/sdk/sre/ResourceHolderPage.java | 10 + .../caerus/sdk/sre/ResourceHolderStatus.java | 9 + .../caerus/sdk/sre/ResourceNotFoundError.java | 16 + .../java/dev/caerus/sdk/sre/ResourcePage.java | 10 + .../dev/caerus/sdk/sre/SharedResourceApi.java | 57 ++ src/main/java/dev/caerus/sdk/sre/SreCore.java | 188 ++++++ .../java/dev/caerus/sdk/sre/SreErrors.java | 78 +++ .../java/dev/caerus/sdk/sre/SreMapping.java | 136 ++++ .../java/dev/caerus/sdk/sre/TakeOptions.java | 70 +++ .../java/dev/caerus/sdk/sre/TimeoutError.java | 16 + .../dev/caerus/sdk/sre/UnitaryResource.java | 12 + .../caerus/sdk/sre/UpdateResourceOptions.java | 70 +++ .../java/dev/caerus/sdk/sre/Validation.java | 25 + .../dev/caerus/sdk/sre/ValidationError.java | 16 + .../dev/caerus/sdk/sre/ClientOptionsTest.java | 162 +++++ .../dev/caerus/sdk/sre/ErrorReasonsTest.java | 190 ++++++ .../java/dev/caerus/sdk/sre/MethodsTest.java | 593 ++++++++++++++++++ .../java/dev/caerus/sdk/sre/MockTest.java | 518 +++++++++++++++ .../sdk/sre/TimestampsAndTerminalTest.java | 211 +++++++ .../dev/caerus/sdk/sre/TransportTest.java | 175 ++++++ 42 files changed, 3890 insertions(+) create mode 100644 src/main/java/dev/caerus/sdk/sre/AuthenticationError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/CaerusClient.java create mode 100644 src/main/java/dev/caerus/sdk/sre/CaerusClientOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ConfirmOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ConflictError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/CreateResourceOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/GetResourcesByGroupOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/Handles.java create mode 100644 src/main/java/dev/caerus/sdk/sre/HolderNotActiveError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/HolderSort.java create mode 100644 src/main/java/dev/caerus/sdk/sre/InMemoryCaerusClient.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ListResourceHoldersOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/MockMethod.java create mode 100644 src/main/java/dev/caerus/sdk/sre/MockOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/MockResourceSeed.java create mode 100644 src/main/java/dev/caerus/sdk/sre/MockSnapshot.java create mode 100644 src/main/java/dev/caerus/sdk/sre/OutOfStockError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/PooledResource.java create mode 100644 src/main/java/dev/caerus/sdk/sre/Resource.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceHasActiveHoldsError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceHasQueuedRequestsError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceHolder.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceHolderPage.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceHolderStatus.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourceNotFoundError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ResourcePage.java create mode 100644 src/main/java/dev/caerus/sdk/sre/SharedResourceApi.java create mode 100644 src/main/java/dev/caerus/sdk/sre/SreCore.java create mode 100644 src/main/java/dev/caerus/sdk/sre/SreErrors.java create mode 100644 src/main/java/dev/caerus/sdk/sre/SreMapping.java create mode 100644 src/main/java/dev/caerus/sdk/sre/TakeOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/TimeoutError.java create mode 100644 src/main/java/dev/caerus/sdk/sre/UnitaryResource.java create mode 100644 src/main/java/dev/caerus/sdk/sre/UpdateResourceOptions.java create mode 100644 src/main/java/dev/caerus/sdk/sre/Validation.java create mode 100644 src/main/java/dev/caerus/sdk/sre/ValidationError.java create mode 100644 src/test/java/dev/caerus/sdk/sre/ClientOptionsTest.java create mode 100644 src/test/java/dev/caerus/sdk/sre/ErrorReasonsTest.java create mode 100644 src/test/java/dev/caerus/sdk/sre/MethodsTest.java create mode 100644 src/test/java/dev/caerus/sdk/sre/MockTest.java create mode 100644 src/test/java/dev/caerus/sdk/sre/TimestampsAndTerminalTest.java create mode 100644 src/test/java/dev/caerus/sdk/sre/TransportTest.java 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/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); + } +} From 2167d1961207758cc51f7fa8a84b5f9da3913c04 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:35 -0300 Subject: [PATCH 4/9] Portar el cliente del Distributed Lock Service acquireLock consume el stream: avisa onQueued una sola vez y nunca despues de conceder, corta con un AbortSignal y convierte un DENIED en LockDeniedError. Sin timeout propio usa el deadline del cliente, como en TypeScript. withTransaction renueva desde un ScheduledExecutorService del cliente (Java 17 no tiene virtual threads) que se apaga en close(), suelta los locks en el finally y lanza la perdida de la transaccion aunque el callback haya terminado bien. Una excepcion checked del callback sale tal cual. El cliente en memoria responde OK al soltar un lock que no existe, como el motor, en lugar de lanzar DlsNotFoundError como el de TypeScript. --- .../caerus/sdk/dls/AcquireLockOptions.java | 81 +++ .../dev/caerus/sdk/dls/ActiveLockHolder.java | 6 + .../sdk/dls/BeginTransactionOptions.java | 43 ++ .../caerus/sdk/dls/DeadlockAbortedError.java | 14 + src/main/java/dev/caerus/sdk/dls/DlsApi.java | 36 ++ .../sdk/dls/DlsAuthenticationError.java | 15 + .../java/dev/caerus/sdk/dls/DlsClient.java | 97 ++++ .../dev/caerus/sdk/dls/DlsClientOptions.java | 97 ++++ .../dev/caerus/sdk/dls/DlsConflictError.java | 15 + src/main/java/dev/caerus/sdk/dls/DlsCore.java | 267 ++++++++++ .../java/dev/caerus/sdk/dls/DlsError.java | 20 + .../java/dev/caerus/sdk/dls/DlsErrors.java | 107 ++++ .../java/dev/caerus/sdk/dls/DlsMapping.java | 80 +++ .../dev/caerus/sdk/dls/DlsNotFoundError.java | 15 + .../dev/caerus/sdk/dls/DlsOperations.java | 22 + .../dev/caerus/sdk/dls/DlsTimeoutError.java | 15 + .../caerus/sdk/dls/DlsValidationError.java | 15 + .../dev/caerus/sdk/dls/InMemoryDlsClient.java | 284 +++++++++++ .../dls/LockAcquisitionCancelledError.java | 15 + .../caerus/sdk/dls/LockAlreadyHeldError.java | 14 + .../dev/caerus/sdk/dls/LockDeniedError.java | 14 + .../java/dev/caerus/sdk/dls/LockHolder.java | 6 + .../java/dev/caerus/sdk/dls/LockMode.java | 6 + .../caerus/sdk/dls/LockModeMismatchError.java | 14 + .../java/dev/caerus/sdk/dls/LockStatus.java | 8 + .../caerus/sdk/dls/LockStatusResponse.java | 15 + .../java/dev/caerus/sdk/dls/Transaction.java | 4 + .../caerus/sdk/dls/TransactionCallback.java | 7 + .../caerus/sdk/dls/TransactionContext.java | 18 + .../caerus/sdk/dls/TransactionLockInfo.java | 4 + .../sdk/dls/TransactionNotActiveError.java | 14 + .../caerus/sdk/dls/TransactionOptions.java | 82 ++++ .../dev/caerus/sdk/dls/TransactionScope.java | 195 ++++++++ .../sdk/dls/TransactionStatusResponse.java | 15 + .../caerus/sdk/dls/DlsClientOptionsTest.java | 110 +++++ .../dev/caerus/sdk/dls/DlsClientRealTest.java | 460 ++++++++++++++++++ .../caerus/sdk/dls/DlsErrorReasonsTest.java | 134 +++++ .../dev/caerus/sdk/dls/DlsMappingTest.java | 83 ++++ .../dev/caerus/sdk/dls/DlsMethodsTest.java | 256 ++++++++++ .../java/dev/caerus/sdk/dls/DlsMockTest.java | 307 ++++++++++++ .../dev/caerus/sdk/dls/DlsOnQueuedTest.java | 153 ++++++ .../dev/caerus/sdk/dls/DlsTestClients.java | 46 ++ 42 files changed, 3209 insertions(+) create mode 100644 src/main/java/dev/caerus/sdk/dls/AcquireLockOptions.java create mode 100644 src/main/java/dev/caerus/sdk/dls/ActiveLockHolder.java create mode 100644 src/main/java/dev/caerus/sdk/dls/BeginTransactionOptions.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DeadlockAbortedError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsApi.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsAuthenticationError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsClient.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsClientOptions.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsConflictError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsCore.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsErrors.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsMapping.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsNotFoundError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsOperations.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsTimeoutError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/DlsValidationError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockAcquisitionCancelledError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockAlreadyHeldError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockDeniedError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockHolder.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockMode.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockModeMismatchError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockStatus.java create mode 100644 src/main/java/dev/caerus/sdk/dls/LockStatusResponse.java create mode 100644 src/main/java/dev/caerus/sdk/dls/Transaction.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionCallback.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionContext.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionLockInfo.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionNotActiveError.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionOptions.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionScope.java create mode 100644 src/main/java/dev/caerus/sdk/dls/TransactionStatusResponse.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsClientOptionsTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsErrorReasonsTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsMappingTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsMethodsTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsMockTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsOnQueuedTest.java create mode 100644 src/test/java/dev/caerus/sdk/dls/DlsTestClients.java 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..71fc481 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java @@ -0,0 +1,284 @@ +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)); + + 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; + 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; + } +} 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..b1154d2 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/dls/TransactionScope.java @@ -0,0 +1,195 @@ +package dev.caerus.sdk.dls; + +import dev.caerus.sdk.AbortController; +import dev.caerus.sdk.AbortSignal; +import dev.caerus.sdk.internal.Futures; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +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 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; + AbortSignal signal = given.signal() + .or(options::signal) + .orElse(lostController.signal()); + + AcquireLockOptions.Builder merged = AcquireLockOptions.builder().signal(signal); + given.idempotencyKey().ifPresent(merged::idempotencyKey); + given.timeoutMs().ifPresent(merged::timeoutMs); + given.onQueued().ifPresent(merged::onQueued); + + return await(operations.acquireLockAsync(namespace, lockKey, transactionId, mode, merged.build())); + } + + @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) { + return; + } + CompletableFuture renewal; + try { + renewal = operations.renewTransactionAsync(transactionId, lifetime); + } catch (RuntimeException e) { + renewal = CompletableFuture.failedFuture(e); + } + renewal.whenComplete((ignored, error) -> { + 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/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..655eef8 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java @@ -0,0 +1,460 @@ +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.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 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..1a4c633 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java @@ -0,0 +1,307 @@ +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 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; + } + } +} From 29d996ce21428e44bd6384da737f0bb5f4dce140 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:35 -0300 Subject: [PATCH 5/9] Verificar webhooks con la misma firma que el motor HMAC-SHA256 sobre el cuerpo crudo, comparado en tiempo constante y con cinco minutos de tolerancia. El vector cruzado de TypeScript y del motor da la misma firma byte a byte. Cada evento tiene su record y un tipo que el SDK no conoce llega como UnknownEventData, para que un evento nuevo del motor no rompa a nadie. --- .../dev/caerus/sdk/webhooks/CaerusEvent.java | 16 + .../sdk/webhooks/CaerusSignatureError.java | 16 + .../webhooks/CaerusWebhookExpiredError.java | 16 + .../webhooks/CaerusWebhookPayloadError.java | 16 + .../sdk/webhooks/DeadlockDetectedData.java | 29 ++ .../dev/caerus/sdk/webhooks/EventData.java | 37 +++ .../dev/caerus/sdk/webhooks/EventTypes.java | 57 ++++ .../dev/caerus/sdk/webhooks/JsonFields.java | 87 +++++ .../sdk/webhooks/LockAbandonedData.java | 27 ++ .../sdk/webhooks/LockAcquireFailedData.java | 28 ++ .../caerus/sdk/webhooks/LockAcquiredData.java | 32 ++ .../caerus/sdk/webhooks/LockReleasedData.java | 28 ++ .../sdk/webhooks/ResourceConfirmedData.java | 32 ++ .../sdk/webhooks/ResourceCreatedData.java | 32 ++ .../sdk/webhooks/ResourceDeletedData.java | 22 ++ .../sdk/webhooks/ResourceExpiredData.java | 30 ++ .../sdk/webhooks/ResourceExtendedData.java | 24 ++ .../sdk/webhooks/ResourceQueuedData.java | 34 ++ .../sdk/webhooks/ResourceReleasedData.java | 30 ++ .../sdk/webhooks/ResourceTakeFailedData.java | 30 ++ .../sdk/webhooks/ResourceTakenData.java | 34 ++ .../sdk/webhooks/ResourceUpdatedData.java | 30 ++ .../sdk/webhooks/TransactionAbortedData.java | 24 ++ .../webhooks/TransactionCompletedData.java | 22 ++ .../sdk/webhooks/TransactionExpiredData.java | 24 ++ .../sdk/webhooks/TransactionRenewedData.java | 24 ++ .../sdk/webhooks/TransactionStartedData.java | 24 ++ .../caerus/sdk/webhooks/UnknownEventData.java | 23 ++ .../dev/caerus/sdk/webhooks/Webhooks.java | 165 ++++++++++ .../sdk/webhooks/WebhooksInteropTest.java | 82 +++++ .../dev/caerus/sdk/webhooks/WebhooksTest.java | 296 ++++++++++++++++++ 31 files changed, 1371 insertions(+) create mode 100644 src/main/java/dev/caerus/sdk/webhooks/CaerusEvent.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/CaerusSignatureError.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookExpiredError.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/CaerusWebhookPayloadError.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/DeadlockDetectedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/EventData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/EventTypes.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/JsonFields.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/LockAbandonedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/LockAcquireFailedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/LockAcquiredData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/LockReleasedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceConfirmedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceCreatedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceDeletedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceExpiredData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceExtendedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceQueuedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceReleasedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceTakeFailedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceTakenData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/ResourceUpdatedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/TransactionAbortedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/TransactionCompletedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/TransactionExpiredData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/TransactionRenewedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/TransactionStartedData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/UnknownEventData.java create mode 100644 src/main/java/dev/caerus/sdk/webhooks/Webhooks.java create mode 100644 src/test/java/dev/caerus/sdk/webhooks/WebhooksInteropTest.java create mode 100644 src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java 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..b07bf05 --- /dev/null +++ b/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java @@ -0,0 +1,165 @@ +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.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(byte[] payload, String signatureHeader, String secret, long toleranceSeconds) { + String raw = payload == null ? "" : new String(payload, StandardCharsets.UTF_8); + return constructEvent(raw, signatureHeader, secret, toleranceSeconds); + } + + public CaerusEvent constructEvent(String 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."); + } + + String rawPayload = payload == null ? "" : payload; + byte[] expected = sign(secret, timestampText + "." + rawPayload).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."); + } + + 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) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(content.getBytes(StandardCharsets.UTF_8))); + } 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/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..1d761aa --- /dev/null +++ b/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java @@ -0,0 +1,296 @@ +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 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); + } + } +} From 424c41782c7311b012d7ee0530169af6fcbdf8f4 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:35 -0300 Subject: [PATCH 6/9] Agregar la verificacion contra el motor real Los tests de TypeScript contra el mock pasaban las dos veces que el SDK tuvo un bug de contrato, asi que esto no es opcional. Corre con mvn test -Plive y CAERUS_API_KEY; queda fuera de mvn verify. Contra el motor hospedado pasaron los escenarios de lectura compartida, deadlock de dos y de tres, cola con onQueued, deadline del cliente, transaccion cerrada, renovacion, fencing tokens, API Key invalida y requestId. Faltan las plantillas FAIL, ALERT y una de recursos para el resto. --- src/test/java/dev/caerus/sdk/live/Live.java | 64 ++++ .../java/dev/caerus/sdk/live/LiveDlsTest.java | 343 ++++++++++++++++++ .../java/dev/caerus/sdk/live/LiveSreTest.java | 167 +++++++++ 3 files changed, 574 insertions(+) create mode 100644 src/test/java/dev/caerus/sdk/live/Live.java create mode 100644 src/test/java/dev/caerus/sdk/live/LiveDlsTest.java create mode 100644 src/test/java/dev/caerus/sdk/live/LiveSreTest.java 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..0b35d1b --- /dev/null +++ b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java @@ -0,0 +1,343 @@ +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"); + assertThat(aborted).isZero(); + } + + @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 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..2e83a34 --- /dev/null +++ b/src/test/java/dev/caerus/sdk/live/LiveSreTest.java @@ -0,0 +1,167 @@ +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); + 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; + } +} From b495c0036c830f655572cdf815eaa9aa5985be92 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Wed, 30 Sep 2026 21:42:36 -0300 Subject: [PATCH 7/9] Documentar el SDK, sus errores y en que se aparta del de TypeScript paridad.md deja escrito que cambia por ser Java y que se decidio con cada inconsistencia del SDK de TypeScript, para que la proxima version no las redescubra. --- README.md | 192 +++++++++++++++++++++++++++++++++++ docs/errores.md | 104 +++++++++++++++++++ docs/paridad.md | 51 ++++++++++ docs/verificacion-en-vivo.md | 70 +++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 README.md create mode 100644 docs/errores.md create mode 100644 docs/paridad.md create mode 100644 docs/verificacion-en-vivo.md 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..01cf845 --- /dev/null +++ b/docs/errores.md @@ -0,0 +1,104 @@ +# 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; mirá +su `status()`, porque puede volver terminado. + +## 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..43438aa --- /dev/null +++ b/docs/paridad.md @@ -0,0 +1,51 @@ +# 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 chicas, a favor de parecerse al motor + +- `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. From 217234bd8e5bd0e6a748ac0c97cd51fea4980b89 Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Sat, 3 Oct 2026 12:51:41 -0300 Subject: [PATCH 8/9] Cerrar un falso verde en la verificacion en vivo de ALERT El escenario contaba cuantas transacciones abortaba el motor por deadlock y daba bien con cero, pero tambien daba cero cuando la plantilla no existia y las dos fallaban por NOT_FOUND. Ahora la unica falla aceptada es el timeout. El SRE en vivo paso entero contra la plantilla butaca, salvo updateResource, que esa plantilla exige con clave de idempotencia: el test ahora la manda. --- src/test/java/dev/caerus/sdk/live/LiveDlsTest.java | 7 ++++++- src/test/java/dev/caerus/sdk/live/LiveSreTest.java | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java index 0b35d1b..0283819 100644 --- a/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java +++ b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java @@ -199,8 +199,13 @@ void deadlockWithAlertStrategyAbortsNobody() { .filter(error -> error instanceof CaerusError caerus && caerus.reason().orElse("").equals("DEADLOCK_DETECTED")) .count(); - Live.log("nadie fue abortado por deadlock", aborted + " abortadas"); + 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 diff --git a/src/test/java/dev/caerus/sdk/live/LiveSreTest.java b/src/test/java/dev/caerus/sdk/live/LiveSreTest.java index 2e83a34..ac3dae6 100644 --- a/src/test/java/dev/caerus/sdk/live/LiveSreTest.java +++ b/src/test/java/dev/caerus/sdk/live/LiveSreTest.java @@ -121,7 +121,8 @@ void theWholeLifeOfAPooledResource() { 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); + 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 { From f00d091bb09b63d320b884792923c5597ebb5a3a Mon Sep 17 00:00:00 2001 From: Thomas Luca Date: Sat, 3 Oct 2026 13:02:03 -0300 Subject: [PATCH 9/9] Atender la revision del PR: senales, renovaciones, firma y mock Una espera de acquireLock con senal propia dentro de withTransaction ahora tambien se corta si la transaccion se pierde; antes la senal propia reemplazaba a la de la transaccion y la espera quedaba colgada hasta el deadline. Una renovacion lenta ya no se superpone con la siguiente. La firma de un webhook se calcula sobre los bytes crudos: un cuerpo autentico con UTF-8 invalido daba error de firma en vez de error de contenido. InMemoryDlsClient replica al motor con la clave de idempotencia: la misma clave en la misma transaccion devuelve el mismo lock y otra clave da LOCK_ALREADY_HELD_EXCLUSIVELY, verificado contra el motor. La guia de errores ya no promete que un reintento devuelve un holder terminado: lanza ConflictError. --- docs/errores.md | 10 +++- docs/paridad.md | 21 ++++++- .../dev/caerus/sdk/dls/InMemoryDlsClient.java | 17 +++++- .../dev/caerus/sdk/dls/TransactionScope.java | 26 +++++--- .../dev/caerus/sdk/webhooks/Webhooks.java | 36 ++++++++--- .../dev/caerus/sdk/dls/DlsClientRealTest.java | 59 +++++++++++++++++++ .../java/dev/caerus/sdk/dls/DlsMockTest.java | 38 ++++++++++++ .../java/dev/caerus/sdk/live/LiveDlsTest.java | 19 ++++++ .../dev/caerus/sdk/webhooks/WebhooksTest.java | 17 ++++++ 9 files changed, 225 insertions(+), 18 deletions(-) diff --git a/docs/errores.md b/docs/errores.md index 01cf845..6f7813e 100644 --- a/docs/errores.md +++ b/docs/errores.md @@ -90,8 +90,14 @@ 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; mirá -su `status()`, porque puede volver terminado. +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 diff --git a/docs/paridad.md b/docs/paridad.md index 43438aa..2944c08 100644 --- a/docs/paridad.md +++ b/docs/paridad.md @@ -37,8 +37,27 @@ sigue es lo que cambia, y por qué. | 7 | `renewTransaction` devuelve solo el `transactionId` | Igual | | 8 | `webhooks` solo en `CaerusClient` (y el mock del SRE) | Igual | -## Otras diferencias chicas, a favor de parecerse al motor +## 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 diff --git a/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java b/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java index 71fc481..016d00f 100644 --- a/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java +++ b/src/main/java/dev/caerus/sdk/dls/InMemoryDlsClient.java @@ -88,6 +88,19 @@ public LockHolder acquireLock( .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( @@ -101,7 +114,8 @@ public LockHolder acquireLock( created.transactionId = transactionId; created.namespace = namespace; created.lockKey = lockKey; - created.mode = mode; + created.mode = mode == null ? LockMode.EXCLUSIVE : mode; + created.idempotencyKey = idempotencyKey; created.fencingToken = nextFencingToken++; created.expiresAt = txExpiration.getOrDefault(transactionId, now + DEFAULT_TTL_MS); @@ -280,5 +294,6 @@ private static final class MockLock { private LockMode mode; private long fencingToken; private long expiresAt; + private String idempotencyKey; } } diff --git a/src/main/java/dev/caerus/sdk/dls/TransactionScope.java b/src/main/java/dev/caerus/sdk/dls/TransactionScope.java index b1154d2..45c36a0 100644 --- a/src/main/java/dev/caerus/sdk/dls/TransactionScope.java +++ b/src/main/java/dev/caerus/sdk/dls/TransactionScope.java @@ -4,9 +4,12 @@ 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; @@ -20,6 +23,7 @@ final class TransactionScope implements TransactionContext { 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; @@ -90,16 +94,23 @@ public LockHolder acquireLock(String namespace, String lockKey, LockMode mode, A throw lostError; } AcquireLockOptions given = acquireOptions == null ? AcquireLockOptions.none() : acquireOptions; - AbortSignal signal = given.signal() - .or(options::signal) - .orElse(lostController.signal()); - - AcquireLockOptions.Builder merged = AcquireLockOptions.builder().signal(signal); + 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); - return await(operations.acquireLockAsync(namespace, lockKey, transactionId, mode, merged.build())); + try { + return await(operations.acquireLockAsync(namespace, lockKey, transactionId, mode, merged.build())); + } finally { + stopListening.forEach(Runnable::run); + } } @Override @@ -125,7 +136,7 @@ private void startHeartbeat() { } private void renew(long lifetime) { - if (closed || lost != null) { + if (closed || lost != null || !renewing.compareAndSet(false, true)) { return; } CompletableFuture renewal; @@ -135,6 +146,7 @@ private void renew(long lifetime) { renewal = CompletableFuture.failedFuture(e); } renewal.whenComplete((ignored, error) -> { + renewing.set(false); if (error == null) { consecutiveFailures.set(0); return; diff --git a/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java b/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java index b07bf05..01cc61e 100644 --- a/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java +++ b/src/main/java/dev/caerus/sdk/webhooks/Webhooks.java @@ -7,6 +7,9 @@ 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; @@ -37,12 +40,12 @@ public CaerusEvent constructEvent(byte[] payload, String signatureHeader, String return constructEvent(payload, signatureHeader, secret, DEFAULT_TOLERANCE_SECONDS); } - public CaerusEvent constructEvent(byte[] payload, String signatureHeader, String secret, long toleranceSeconds) { - String raw = payload == null ? "" : new String(payload, StandardCharsets.UTF_8); - return constructEvent(raw, signatureHeader, secret, toleranceSeconds); + 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(String payload, String signatureHeader, String secret, long toleranceSeconds) { + public CaerusEvent constructEvent(byte[] payload, String signatureHeader, String secret, long toleranceSeconds) { if (signatureHeader == null || signatureHeader.isEmpty()) { throw new CaerusSignatureError("No signature header provided."); } @@ -78,8 +81,9 @@ public CaerusEvent constructEvent(String payload, String signatureHeader, String throw new CaerusWebhookExpiredError("Webhook timestamp is outside of the tolerance zone."); } - String rawPayload = payload == null ? "" : payload; - byte[] expected = sign(secret, timestampText + "." + rawPayload).getBytes(StandardCharsets.US_ASCII); + 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) { @@ -93,6 +97,19 @@ public CaerusEvent constructEvent(String payload, String signatureHeader, String 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); @@ -110,10 +127,15 @@ public CaerusEvent constructEvent(String payload, String signatureHeader, String } 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")); - return HexFormat.of().formatHex(mac.doFinal(content.getBytes(StandardCharsets.UTF_8))); + mac.update(prefix); + return HexFormat.of().formatHex(mac.doFinal(body)); } catch (GeneralSecurityException e) { throw new IllegalStateException("HmacSHA256 is not available in this JVM", e); } diff --git a/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java b/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java index 655eef8..cd5cfa5 100644 --- a/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java +++ b/src/test/java/dev/caerus/sdk/dls/DlsClientRealTest.java @@ -26,6 +26,7 @@ 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; @@ -289,6 +290,64 @@ void losingTheTransactionCutsALockThatIsStillWaiting() { 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<>(); diff --git a/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java b/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java index 1a4c633..58aad80 100644 --- a/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java +++ b/src/test/java/dev/caerus/sdk/dls/DlsMockTest.java @@ -195,6 +195,44 @@ void getTransactionStatusReturnsEveryLockOfTheTransaction() { }); } + @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)) diff --git a/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java index 0283819..1ab5360 100644 --- a/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java +++ b/src/test/java/dev/caerus/sdk/live/LiveDlsTest.java @@ -330,6 +330,25 @@ void anEngineErrorCarriesARequestId() { 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"); diff --git a/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java b/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java index 1d761aa..ef8addd 100644 --- a/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java +++ b/src/test/java/dev/caerus/sdk/webhooks/WebhooksTest.java @@ -90,6 +90,23 @@ void verifiesTheRawUtf8Bytes() { 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);