diff --git a/README.md b/README.md index 934ec50..2420857 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ This repository contains API for global DBeaver ecosystem. OSGI APIs and Service for DataDam integration Used by DBeaver, dbvr, CloudBeaver and by DataDam itself +## Remote DAM API + +[`apis/com.dbeaver.datadam.dam.remote.api`](apis/com.dbeaver.datadam.dam.remote.api/README.md) contains the Remote DAM +contracts, clients and transport adapters, together with the messaging implementation. Java packages are +`com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. + ## SSO API `apis/com.dbeaver.datadam.sso.api` exports the shared SSO contracts in diff --git a/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md new file mode 100644 index 0000000..cb933f8 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md @@ -0,0 +1,284 @@ +# Messaging: request/reply and typed RPC + +The `com.dbeaver.messaging.requestreply` and `com.dbeaver.messaging.rpc` packages live in the +`com.dbeaver.datadam.dam.remote.api` module. They supply transport-independent request/reply and typed RPC for Java 21. +These packages do not depend on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services; their only non-JDK +production dependency supplies nullability annotations. Unsupported RPC methods are logged using the JDK logging API. +DataDam adapters and codecs live in separate packages in the same module. + +## Usage + +Create an endpoint over a transport already scoped to the intended peer and logical request topic: + +```java +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +The receiving peer installs one handler on its own endpoint: + +```java +try (var requests = MessagingRequestReplyEndpoint.open(transport); + var registration = requests.handle(request -> process(request))) { + serveApplication(); +} +``` + +An endpoint can initiate and handle requests concurrently. `request` blocks the calling thread and returns the response +payload, or null for successful completion without a value. Use a worker or virtual thread. Incoming handlers run +on the receiving thread by default. The endpoint owns no executor; the transport adapter chooses an execution +boundary when its receiving loop must remain nonblocking. Future objects are internal. + +`MessagingRequestReplyEndpoint

` leaves the payload representation to the application and transport adapter. For example, +`P` can be a string, an immutable application value, or an encoded JSON object. The core does not copy arbitrary `P` +values: payloads must remain immutable while a request is in use, or the adapter must take defensive snapshots. + +The default `MessagingRequestReplyOptions` use a 30-second deadline, at most 256 outgoing requests, and 64 concurrent incoming +handlers. Supply options to `open`, or a deadline to an individual `request(payload, timeout)` call. Deadlines are +measured with monotonic time and include local sending. The handler limit has no waiting queue: saturation replies +with `OVERLOADED`. Outgoing saturation fails locally without sending. +The handler slot covers application-handler execution and is released before response sending. Each transport adapter +separately bounds its encoding and outstanding send work. + +## Completion and failure + +Pending correlation is installed before sending, including when the adapter delivers an immediate response before +its send stage completes. Request IDs and reply endpoint IDs are independent UUIDs. A response must match both. +Unknown, late, and duplicate responses do not complete another request. + +`MessagingRequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout, +and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Raw +request/reply handler exception classes, messages, and stack traces are not sent to the peer; the typed RPC layer below +adds its own diagnostic error payload. Local send failures retain their causes. + +Interruption propagates as `InterruptedException` and releases the pending slot. A thread already interrupted when +calling `request` does not send. Timeout, interruption, or a send error can leave the remote outcome unknown: none +promises remote cancellation or makes a non-idempotent request safe to repeat. The library never retries requests, +replays results, or provides exactly-once execution. Duplicate requests still being handled are ignored; completed +request IDs are not retained as a replay history. + +Closing a handler registration removes only that handler; already accepted work can finish. The returned registration +is identity-scoped, so closing it again cannot remove a replacement handler. Closing the endpoint fails pending calls, +removes its transport subscription, and interrupts accepted work. It does not close the borrowed physical transport. +Closing is idempotent and does not wait for application code that ignores interruption. + +A disconnect notification fails pending calls and invalidates replies from work accepted before that notification. +Handlers are interrupted, but continue counting against capacity until their execution actually stops. The receiving +subscription can stay installed across reconnects. New calls use the adapter's normal send readiness checks. If an +adapter cannot observe peer disconnection, outstanding calls expire through their deadlines instead. + +## Transport adapter contract + +Implement `MessagingRequestReplyTransport

` to integrate a message transport: + +- `subscribe(endpointId, listener)` establishes receiving routes before returning. Requests go to one designated + handler endpoint; responses go to the original request's `replyTo` endpoint. A transport may limit an adapter + instance to one endpoint subscription. +- Failed subscription establishment must roll back every partially installed route and owned resource. +- `send(message)` is nonblocking and returns a bounded transport send stage. Completing that stage confirms sending, + not remote business completion. A response can arrive before this stage completes. +- The adapter retains peer scope, verifies the source of received frames, and uses `replyTo` only as a logical reply + destination. Correlation UUIDs do not replace authentication or account/peer authorization. +- Disconnection is reported through `listener.onDisconnect()`. Incoming callbacks can be concurrent and can occur + synchronously during subscription establishment or sending. +- `executeRequest(request)` runs admitted incoming requests inline by default, outside the endpoint lock. An adapter + can override it to use a borrowed executor or platform job service. It must execute the supplied task or throw + `RejectedExecutionException`; the core maps rejection to `OVERLOADED`. The core never closes that execution service. + Responses and disconnects do not use this dispatch hook. A serialized receiver must dispatch requests elsewhere + if handlers can wait synchronously for replies arriving through that same receiver. +- Closing the returned `MessagingSubscription` removes receiving routes and releases adapter-owned resources. It must + leave a caller-owned physical session available to other consumers. +- The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must + not introduce automatic retries or an offline business-message queue. + +Transport write failure or a lost reply can prevent a caller from seeing a completed remote operation. Response send +failure is not retried. An already claimed transport send can finish while closure or disconnection is taking place. + +## Typed RPC + +Both peers use the same classes. A local service registration and a proxy for a remote service are independent: +neither requires a service in the reverse direction, and either peer can initiate calls after transport establishment. +There is no client/server distinction in the RPC API. + +The application shares an interface between peers and supplies its implementation on the receiving side: + +```java +public interface Documents { + Document load(DocumentRequest request); + void save(SaveRequest request); +} +``` + +The session owner creates one reusable endpoint over a topic-scoped transport factory. The receiver supplies +its own codec when registering an implementation: + +```java +try (var rpc = MessagingRpcEndpoint.open(transports); + var registration = rpc.register("documents", Documents.class, implementation, codec)) { + serveApplication(); +} +``` + +The caller creates its own endpoint over the corresponding transport and obtains a proxy: + +```java +try (var rpc = MessagingRpcEndpoint.open(transports)) { + Documents documents = rpc.proxy("documents", Documents.class, codec); + Document document = documents.load(request); + documents.save(saveRequest); +} +``` + +An owner can provide a default codec with `MessagingRpcEndpoint.open(transports, defaultCodec)`. Its consumers can then use +`rpc.proxy("documents", Documents.class)` and `rpc.register("documents", Documents.class, implementation)`. +The overloads with an explicit codec override that fallback only for the new proxy or registration. The core does +not construct a serialization framework. Without a configured default, no-codec overloads fail before opening routes. + +The same endpoint can register other local services or obtain proxies for services on the opposite peer. The supplied +topic is the actual transport topic, not a service key inside another lane. The factory opens a request/reply transport +for each topic on demand; repeated use shares its local topic scope. A topic has at most one local implementation, +and different topics can use the same interface with different implementations and codecs. Topic naming and recipient +subscriptions belong to the application. Proxy construction establishes local receiving routes but does not contact +the remote implementation or prove service presence. +Opening a topic can establish subscriptions synchronously; use a worker thread when the adapter performs network I/O. + +Proxy methods block the calling thread. They may run in concurrent HTTP request handlers or worker/virtual threads, +but not on an event loop or serialized receiver needed to deliver their replies. + +`MessagingRpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped +`MessagingRequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that +payload representation. Business code receives an ordinary `MessagingRpcEndpoint` and uses typed interfaces. The transport +adapter encodes the neutral protocol envelope; the local codec encodes application arguments and results. A TCP, WebSocket, +long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or +JSON library are used here. + +Requests use `MessagingRpcMessage.request(operation, arguments)`. The `arguments` list is an immutable positional +snapshot that preserves null entries, and the request's `payload` is null. Responses and errors have an empty +`arguments` list; a response result is carried in `payload`. + +### Application-owned registrations and callers + +Obtain an endpoint once from the application's session owner, then pass it to ordinary Java objects. Registration +and proxy creation are runtime operations and do not require Spring or a separate bean for each business interface. + +```java +public final class DocumentReceiver { + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec documentCodec; + + public DocumentReceiver(MessagingRpcEndpoint rpc, MessagingRpcCodec documentCodec) { + this.rpc = rpc; + this.documentCodec = documentCodec; + } + + public MessagingSubscription enable(Documents implementation) { + return rpc.register("documents", Documents.class, implementation, documentCodec); + } +} +``` + +The opposite peer uses its own endpoint in a separate caller: + +```java +public final class DocumentCaller { + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec documentCodec; + + public DocumentCaller(MessagingRpcEndpoint rpc, MessagingRpcCodec documentCodec) { + this.rpc = rpc; + this.documentCodec = documentCodec; + } + + public Document load(DocumentRequest request) { + Documents documents = rpc.proxy("documents", Documents.class, documentCodec); + return documents.load(request); + } +} +``` + +Keep the receiving `MessagingSubscription` while the implementation is available, then close it to unregister. A proxy +is an ordinary business interface, not a registration handle, and requires no separate close. Application code sharing +an endpoint closes its own registrations; the session owner closes the endpoint when its scope ends. Owners can +use `isClosed()` to reject new use once closure starts, and `isCloseComplete()` before releasing a cached endpoint's +scope reservation. The latter becomes true after handler and receiving-subscription cleanup finishes, including when +cleanup throws. A repeated `close()` does not wait for another thread's ongoing cleanup. + +### Contracts and codecs + +- Contracts are public, nonsealed interfaces with at least one abstract operation. Methods can take zero, one, or multiple + arguments, including varargs. + Return values can be ordinary reference or primitive types, or `void`. Reference arguments and results can be null. +- Inherited abstract operations and overloads are supported. Operation IDs combine the method name and its erased + argument types in declaration order as `methodName(type1,type2,...)`; a zero-argument method uses `foo()`. + A varargs parameter uses its array type and occupies one argument position. Only methods from the registered interface + can be invoked. Incoming names never trigger class loading or arbitrary reflective lookup on the implementation. +- Concrete generic argument and result types, such as `List`, reach the codec as `Type` values for each argument + and result. Methods with unresolved type variables, generic methods, default methods, and `Future`/`CompletionStage` + returns are logged and skipped during proxy construction or registration; supported methods remain available. + Calling a skipped method on a proxy fails locally with `IllegalArgumentException` without encoding or sending a request; + incoming calls to skipped operations receive `UNKNOWN_OPERATION`. Contracts with no usable operations are rejected. + Contracts must still be public, nonsealed interfaces without unresolved interface type parameters. Generic-parent + substitutions are not inferred; unresolved erased bridges remain invalid. Inherited operations with ambiguous generic + argument or result types must be explicitly redeclared in the public contract. +- `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration + and invoked independently for each argument in declaration order and for the result. Zero-argument calls do not invoke + the argument codec; varargs are encoded and decoded as one array value. Implementations must handle + nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application + types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply. + Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed. +- Implementations may receive concurrent calls and must manage their own application state safely. Incoming execution + uses the existing bounded request/reply handlers; the RPC layer adds no executor or unbounded message queue. +- `equals`, `hashCode`, and `toString` on a proxy are local identity operations and do not send requests or call codecs. + +### Failure and ownership + +`void` still waits for remote handler completion and a successful reply. It is not fire-and-forget, and its response +does not invoke the application codec. `MessagingRpcException` is unchecked, so a business interface does not need a library +exception declaration. Its reason distinguishes timeout, interruption, disconnection, overload, closure, send failure, +codec failure, invalid response, and remote failure. Caller interruption restores the interrupt flag. + +An absent logical service returns `UNKNOWN_SERVICE`; an absent operation returns `UNKNOWN_OPERATION`. Invalid arguments, +handler failures, and remote codec failures retain their fixed error codes. Handler and remote codec exceptions also +carry immutable `MessagingRpcExceptionDetails`: the exception class name (`type`), nullable original `message`, formatted +`stackTrace`, and a `truncated` flag. The receiving side formats the trace where the failure occurs, including causes, +suppressed exceptions, and circular-reference markers. Reflection's `InvocationTargetException` is unwrapped before capture. + +The caller receives `MessagingRpcException` with `reason() == REMOTE_ERROR`, the existing `remoteError()` code, and optional +`remoteException()` details. Its message includes the remote type and message. Its cause is a local diagnostic wrapper +with the remote text and no invented local frames, so ordinary exception logging includes both the local call stack and +the remote trace. Neither the original `Throwable` nor its object graph crosses the transport. The caller never loads or +instantiates the named remote class. Application codecs handle neither exception details nor diagnostic text. + +Type names are limited to 256 UTF-16 code units, messages to 1,024, and stack traces to 8,192. Formatting stops when the +trace budget is exhausted rather than accumulating an unlimited string first. Truncation of any field, or incomplete +formatting, sets `truncated`; valid surrogate pairs are not split at a truncation boundary. These text bounds leave room +for JSON escaping and the surrounding DataDam frame under its default 64 KiB message limit. Diagnostic contents are not +redacted or replaced with a generic message. A code-only error has null `remoteException()`. + +Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be +detected through transport failure or timeout; an error reply lost in transport cannot expose its remote diagnostics. +Local failures retain their original causes. + +`MessagingRpcEndpoint.open(transports, options)` accepts the existing `MessagingRequestReplyOptions` for default deadlines and shared +capacity. Without explicit options, the default timeout is 30 seconds. Each proxy can override the request timeout +with a positive `Duration` representable in nanoseconds: + +```java +Documents documents = rpc.proxy("documents", Documents.class, Duration.ofMinutes(2)); +Documents customDocuments = rpc.proxy("documents", Documents.class, documentCodec, Duration.ofMinutes(2)); +``` + +Overloads without a timeout retain the endpoint default. A timeout is fixed for that proxy and does not change other +proxies, registrations, or shared topic scopes. Multiple proxies for the same interface and topic can use different +timeouts without creating another remote implementation or changing the business interface. Topic establishment and +capacity still use the endpoint options. + +The timeout covers each underlying request/reply call, not arbitrary application codec execution. Codecs should complete +promptly. Timeout or interruption leaves the remote outcome potentially unknown; late replies are ignored, and the RPC +layer adds no retries or remote cancellation promises. + +Closing a service registration removes only that local implementation. It does not close the endpoint or its proxies, +and work already dispatched to the implementation can finish. Repeatedly closing an old registration cannot remove a +replacement. Closing the RPC endpoint invalidates its proxies, clears service registrations, and closes its underlying +request/reply scopes, failing pending calls. The borrowed physical transport remains open. diff --git a/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF new file mode 100644 index 0000000..512e18a --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF @@ -0,0 +1,21 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Vendor: DBeaver Corp +Bundle-Name: DataDam Remote DAM API +Bundle-SymbolicName: com.dbeaver.datadam.dam.remote.api +Bundle-Version: 0.0.17.qualifier +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Export-Package: com.dbeaver.datadam.dam.remote.api, + com.dbeaver.datadam.dam.remote.api.client, + com.dbeaver.datadam.dam.remote.api.exception, + com.dbeaver.datadam.dam.remote.api.model, + com.dbeaver.datadam.dam.remote.api.requestreply, + com.dbeaver.datadam.dam.remote.api.rpc, + com.dbeaver.datadam.dam.remote.api.transport, + com.dbeaver.messaging.requestreply, + com.dbeaver.messaging.rpc +Bundle-ActivationPolicy: lazy +Require-Bundle: org.jkiss.utils, + com.dbeaver.rest.client;visibility:=reexport, + com.google.gson +Automatic-Module-Name: com.dbeaver.datadam.dam.remote.api diff --git a/apis/com.dbeaver.datadam.dam.remote.api/README.md b/apis/com.dbeaver.datadam.dam.remote.api/README.md new file mode 100644 index 0000000..c7f1ab0 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/README.md @@ -0,0 +1,249 @@ +# Remote DAM API + +The `com.dbeaver.datadam.dam.remote.api` module contains Remote DAM WebSocket contracts, backend-only GraphQL clients, +and transport-independent messaging packages. A Remote DAM peer can be a Desktop application or a future Managed cluster. +Its session identifies the authenticated DAM transport scope, independently of any database connection. + +Java package names are `com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`. +See [Messaging](MESSAGING.md) for the neutral request/reply and typed RPC contracts. + +## Session identity and handshake + +The WebSocket handshake sends `DDRemoteDamHandshakeRequest` with `appDeploymentId`, `appInstanceId`, `product`, and +`version`. `DDRemoteDamHandshakeResponse` returns the assigned `damSessionId`. Wire control types remain `hello` and +`ready`, and the WebSocket path remains `/ws`. Control messages have `type` and an object `payload`; application +messages additionally have `topic`. Application messages may use any nonblank type, including the control type names. + +`DDRemoteDamSessionInfo` contains `appDeploymentId`, `appInstanceId`, and `damSessionId`. +`DDRemoteDamSession` adds `product` and `version` for registry presentation. All fields are nonblank strings. +Concurrent sessions may share application deployment and instance IDs; consumers must use `damSessionId` to distinguish +them. Product and version are peer-supplied presentation metadata, not authenticated identity or authorization claims. + +## Internal GraphQL contract + +`DDRemoteDamClient` posts JSON requests to `/internal/graphql` on the configured relay service root URL. +The service implementation must expose this contract; adding a client alone does not deploy the endpoint. + +```graphql +scalar JSON + +type Query { + remoteDamSessions(accountId: ID!): [RemoteDamSession!]! + remoteDamSessionAccount(damSessionId: ID!): ID +} + +type Mutation { + sendRemoteDamMessage(accountId: ID!, damSessionId: ID!, message: RemoteDamMessageInput!): Boolean! + registerRemoteDamSubscription(input: RemoteDamSubscriptionInput!): Int! + unregisterRemoteDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! +} + +type RemoteDamSession { + appDeploymentId: String! + appInstanceId: String! + damSessionId: ID! + product: String! + version: String! +} + +input RemoteDamMessageInput { + topic: String! + type: String! + payload: JSON! +} + +input RemoteDamSubscriptionInput { + subscriberId: String! + subscriptionId: ID! + accountId: ID! + damSessionId: ID + topic: String! + callbackUrl: String! + callbackSecret: String! +} +``` + +- `accountId` is a UUID, mandatory for account-wide session lists, sends, and subscription registration. Sends use the + account and DAM session ID together. +- `remoteDamSessionAccount` resolves a unique session ID to the account from its verified identity. It returns null + for a missing or not-ready session; the client reports this as checked `DDRemoteDamException`. This backend-only lookup + does not authorize end-user access to that account. +- Session lists are immutable client-side snapshots of currently ready transport scopes, not persisted installations + or session history. +- Message payloads are JSON objects passed through GraphQL variables, never interpolated into query text or encoded as + JSON strings. `DDRemoteDamMessage` takes defensive payload snapshots. +- A send result of `true` confirms completion of the server's WebSocket write, not peer receipt or processing. + `false` means no ready session exists for that account and session ID, including a session belonging to another account. +- A session closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error. + HTTP errors, GraphQL errors including partial data, and malformed responses become `DDRemoteDamException`. +- The client never retries mutations or queues messages offline. After timeout or network failure, delivery may be + unknown; callers must not blindly replay non-idempotent events. +- Sessions currently live in one relay process. Until owner-aware routing exists, calls must reach the process owning + the session; a missing result from another replica does not prove global offline status. + +## Authentication and configuration + +`DDRemoteDamClient` takes a service root URL and the Remote DAM service secret. It never logs in or acquires a peer token. +The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. +HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. + +The service secret contains 32–512 printable, non-space ASCII characters. It authenticates backend requests and callbacks +through `X-DD-Service-Token`. Subscription registration also carries that same secret in its `callbackSecret` variable; +protect that backend-only request and never log its body. Secrets must not appear in URLs or GraphQL query text. +Redirects are disabled. HTTP establishment uses the shared REST client's 5-second connect timeout; responses have a +10-second deadline. HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client +exceptions. + +The secret authenticates a trusted backend, not an end user. Backends exposing operations to users must derive `accountId` +from authorized account context. A DAM session ID alone is not end-user authorization; expose only authorized account +selections. The server validates the secret before internal GraphQL execution and fails closed when it is missing or +invalid. Browser sessions and peer JWTs must not grant access. Keep the service secret in backend runtime configuration; +it is separate from the Account backchannel secret used to verify peer JWTs. + +```java +var client = new DDRemoteDamClient(serviceUrl, serviceSecret); +var sessions = client.getSessions(accountId); +boolean written = client.sendMessage(accountId, damSessionId, message); +``` + +Use one configured client per service endpoint. No deployment-specific URL or secret is packaged here. +`getSessionsAsync`, `getSessionAccountAsync`, `sendMessageAsync`, `registerSubscriptionAsync`, and +`unregisterSubscriptionAsync` use the JDK HTTP client's nonblocking API without a wrapper executor. Synchronous methods +remain available. The deadline includes the response body. Cancelling a stage attempts to stop the HTTP exchange but +does not promise cancellation of an operation already running remotely. + +## Backend callback subscriptions + +`registerSubscription(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret)` returns +the lease in seconds. The receiving process generates the UUID `subscriptionId`; repeated registration with the same +scope and callback renews it. A null `damSessionId` subscribes to matching account messages, including future sessions. +A non-null ID filters one physical session and does not follow a replacement session. + +Callback service root URLs and secrets follow the same validation as relay URLs and service secrets. Only authenticated +trusted backends may register callbacks. Restrict outbound relay network access and use HTTPS outside trusted networks. +The relay keeps registrations in memory, with no configured subscriber list or separate service-registration step. +The subscriber ID identifies the receiver and does not determine business topics. + +`DDRemoteDamCallbackClient` posts to `/internal/remote-dam/graphql`. Callback variables are `subscriptionId`, `accountId`, +`session`, and `message`. The session input is `RemoteDamSessionInfoInput` with `appDeploymentId`, `appInstanceId`, and +`damSessionId`. The mutation is `deliverRemoteDamMessage(subscriptionId: ..., accountId: ..., session: ..., message: ...)`. + +The receiver invokes its listener on the HTTP request thread and acknowledges after it returns. Successful acknowledgement +extends the lease. Concurrent callbacks may invoke a listener concurrently without ordering guarantees; listeners must be +thread-safe. The RPC reply carries the business result. `unregisterSubscription` handles cancellation and cleanup of +unknown local registrations. The relay stops starting callbacks on cancellation or expiry; already started callbacks may +finish. Failed callbacks are not replayed or retried. In-flight callbacks are bounded without a per-subscription executor +or mailbox. `deliverAsync` is nonblocking, with a default 30-second deadline including listener execution; explicit +durations are supported. Timeout does not promise remote handler cancellation. + +Spring services use the shared `com.dbeaver.datadam.dam.remote.base` server module and its +`DDRemoteDamClientConfiguration`. It supplies callback reception, local dispatch, subscription cancellation, and idle +renewal. Configure `datadam.remote-dam-client.service-url`, `datadam.remote-dam-client.service-secret`, +and `datadam.remote-dam-client.callback-url`. There is no separate callback-secret setting. The configured service secret +is used for requests, callback authentication, and the registration's `callbackSecret` field. + +Select `subscriptions.forSession(accountId, damSessionId)` or `subscriptions.forAccount(accountId)`, then subscribe by +topic. The protocol retains account and session scope for ownership checks. Handlers do not renew registrations. +Every callback destination must reach the exact receiving process owning its local registration. +Peer-side subscriptions remain local: authenticated messages are dispatched to local topic listeners without announcing +handlers to the relay. + +## Channels and request/reply + +`DDRemoteDamChannel.getStatus()` returns immutable `DDRemoteDamChannelStatus` in `DISCONNECTED`, `CONNECTING`, `CONNECTED`, +or `CLOSED` state. Only `CONNECTED` carries `session()` information; `isReady()` tests readiness. +`observeState(observer)` delivers the current state and ordered changes without a snapshot/subscription race. Its returned +subscription closes only that observer. Initial notification can be synchronous; later callbacks run on the notifying +thread. `addSessionListener` remains transitions-only, calling `onConnected` and `onDisconnected` with session information. + +`DDRemoteDamRequestReplyTransport` adapts a scoped channel to `MessagingRequestReplyEndpoint`: + +```java +var transport = DDRemoteDamRequestReplyTransport.forChannel(channel, "documents"); +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +A backend can instead obtain its transport from `subscriptions.forSession(accountId, damSessionId).requestReply("documents")`. +Open a physical-channel endpoint after readiness. Opening while disconnected or closed fails without retaining +subscriptions. Endpoints can stay installed across reconnects, but pending calls fail at disconnection and are not replayed. +The peer installs `requests.handle(payload -> process(payload))`. Use distinct topics for independently handled services. +Each endpoint installs its private reply route before accepting calls; replies return to the originating backend process. +Null responses represent successful completion without a value. This lower layer exposes protocol error codes without +application exception details; typed RPC adds its own diagnostics. + +The bridge message type remains `datadam.requestreply.v1`. Envelopes contain `requestId`, `replyTo`, `type`, and either +`data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private topics remain +`datadam.reply.v1..` and fit metadata limits for maximum-length topics. +Closing an endpoint removes both receiving routes and owned work without closing a shared channel or subscription facade. +Backend request/reply is exposed only for `forSession`, with no implicit selection from account subscriptions. + +`request` blocks a worker or virtual thread. Ordinary sends and HTTP callback acknowledgements have transport-only +meaning. Backend sends use nonblocking HTTP, limited to 128 in flight without a send pool or waiting queue. Facade closure +rejects new sends and invalidates later completions; an already started HTTP send can finish. Physical channels notify +disconnect immediately; backend callbacks have no physical session-state feed, so pending calls expire by deadline. +Timeout or interruption can leave remote outcome unknown; requests and responses are never automatically retried. + +The direct-channel adapter orders messages and disconnections through one bounded receiving mailbox. Source validation +and mailbox insertion share a lifecycle lock; callbacks run outside it. A newly visible source inserts its generation +boundary before its first message. Delayed observers read current status rather than reverting to an old source. +The mailbox holds 128 callbacks without an executor. Excess frames are dropped; disconnection takes priority over a full +batch. Listener failure closes the receiving routes. Backend callbacks invoke listeners directly on concurrent HTTP threads. +The neutral endpoint owns no executor. Blocking handlers must stay off a serialized receiver needed for their replies. + +## Typed RPC + +`com.dbeaver.messaging.rpc` supplies typed proxies and implementation registration independently of DataDam and WebSocket. +`DDRemoteDamRpcTransport` adapts request/reply to `MessagingRpcMessage`. Application serialization is selected +through a codec, for example `new DDRemoteDamGsonRpcCodec(new Gson())`. The codec retains the application's Gson adapters +and naming policies, preserves declared generic types, and handles each argument independently. A varargs array is one +argument; JSON null is a nullable payload. Each proxy and registration can use a different compatible codec. + +```java +try (var rpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), codec); + var registration = rpc.register("dam.projects", DamProjects.class, implementation)) { + ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class); + serveApplication(documents); +} +``` + +Backend scopes wrap `scope.requestReply(topic)` in `DDRemoteDamRpcTransport` and use the same endpoint API. An optional +borrowed `requestExecutor` runs admitted incoming requests; otherwise the receiving thread runs them. Responses and +disconnects never use that executor. Application/platform owners must keep blocking handlers off WebSocket delivery. +The transport owns no execution service. + +The developer's topic is the actual request topic. Routes open lazily and are locally reused, with no automatic Account, +Storage, or service prefix. Registering locally does not register a remote service. Arrange the intended receiver's +subscription before calling; duplicate backend receivers are not service discovery. Exact callback-owner routing still +applies. + +Backend `DDRemoteDamRpc` owners expose `getSessions(accountId)`, `forSession(damSessionId)`, and `closeSession(damSessionId)`. +Listing returns an immutable snapshot without opening endpoints. Selection is application-owned; opening resolves the +account through the authenticated relay API and preserves ownership checks. Known session IDs work without listing or +local discovery caches. Repeated lookup shares an open endpoint; identifiers are not repeated on each business call. +Session owners provide a default `DDRemoteDamGsonRpcCodec`; explicit codec overloads override it for one binding only. +Proxy timeout overloads accept `Duration`, without changing other proxies or the shared session; the default is 30 seconds +unless owner options specify otherwise. + +Obtain an endpoint from the application's ready session owner and pass it to ordinary objects. Endpoint access does not +start login or wait for readiness. Register implementations at runtime and retain their registration handles until they +should stop accepting calls. Proxies need no separate close. The session owner owns the shared endpoint. Transient +reconnects retain endpoints and registrations; authentication identity changes and shutdown invalidate them. Replacing a +closed endpoint does not transfer registrations. Backend owners close missing sessions after successful reconciliation; +failed registry reads preserve endpoints. Asynchronous opening results are shared while callers wait with a timeout. +Reconciliation and renewal use platform scheduling, without owner-created executors. Owners and caches remain process-local. + +The inner envelope remains `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, with `operation` on requests or a fixed `error` +code on failures. Request data is positional: `[]`, `[value]`, `[null]`, or `[value1, value2]`; varargs can be `[["a", "b"]]`. +Error `exception` is null or an object with string `type`, nullable `message`, string `stackTrace`, and boolean `truncated`. +Causes and suppressed exceptions are formatted remotely; no `Throwable` is serialized and application codecs are not used. +Each argument and response can be any JSON value, while the outer topic message remains an object. Existing correlation, +source validation, and authentication still apply. Malformed envelopes are ignored without exposing raw contents. + +Framing, snapshot copying, and outstanding sends are bounded to 128 operations without a waiting queue. Cancellation does +not release a send slot until its underlying operation finishes. Business contracts use ordinary synchronous methods; +`void` waits for completion. Failures become unchecked `MessagingRpcException`; remote handler/codec failures preserve +their codes and bounded `remoteException()` diagnostics, also exposed through a local diagnostic cause for logging. +Bounds are 256 UTF-16 units for type, 1,024 for message, and 8,192 for trace. `truncated` marks incomplete details; code-only +errors have null diagnostics. See [Messaging](MESSAGING.md) for contract shapes, concurrency, ownership, and timeout semantics. diff --git a/apis/com.dbeaver.datadam.dam.remote.api/build.properties b/apis/com.dbeaver.datadam.dam.remote.api/build.properties new file mode 100644 index 0000000..e5d65d5 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/build.properties @@ -0,0 +1,4 @@ +source.. = src/main/java +output.. = target/classes/ +bin.includes = .,\ + META-INF/ diff --git a/apis/com.dbeaver.datadam.dam.remote.api/pom.xml b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml new file mode 100644 index 0000000..f990c5b --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.dbeaver.datadam + api + 0.0.17-SNAPSHOT + ../pom.xml + + + com.dbeaver.datadam.dam.remote.api + eclipse-plugin + DataDam Remote DAM API + + + + com.dbeaver.common + org.jkiss.utils + + + com.google.code.gson + gson + + + com.dbeaver.common + com.dbeaver.rest.client + ${dbeaver.common.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + ${project.basedir}/src/main/java + ${project.basedir}/src/test/java + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test + test + + test + + + + + + + diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java new file mode 100644 index 0000000..5d55395 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java @@ -0,0 +1,60 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api; + +import org.jkiss.code.Nullable; + +import java.net.URI; + +public final class DDRemoteDamApi { + public static final String GRAPHQL_PATH = "/internal/graphql"; + public static final String CALLBACK_GRAPHQL_PATH = "/internal/remote-dam/graphql"; + public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; + public static final int MIN_SERVICE_SECRET_LENGTH = 32; + public static final int MAX_SERVICE_SECRET_LENGTH = 512; + public static final int MIN_SUBSCRIPTION_LEASE_SECONDS = 15; + public static final int MAX_SUBSCRIPTION_LEASE_SECONDS = 300; + private static final int MAX_SUBSCRIBER_ID_LENGTH = 256; + private static final int MAX_SERVICE_URL_LENGTH = 4096; + + private DDRemoteDamApi() { + } + + public static boolean isValidSubscriberId(@Nullable String subscriberId) { + return subscriberId != null && !subscriberId.isBlank() && subscriberId.length() <= MAX_SUBSCRIBER_ID_LENGTH; + } + + public static boolean isValidServiceSecret(@Nullable String secret) { + return secret != null && secret.length() >= MIN_SERVICE_SECRET_LENGTH && secret.length() <= MAX_SERVICE_SECRET_LENGTH + && secret.chars().allMatch(value -> value >= 33 && value <= 126); + } + + public static boolean isValidServiceUrl(@Nullable String serviceUrl) { + if (serviceUrl == null || serviceUrl.length() > MAX_SERVICE_URL_LENGTH) { + return false; + } + try { + URI uri = URI.create(serviceUrl); + return uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null + && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) + && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535); + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java new file mode 100644 index 0000000..80d1e32 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java @@ -0,0 +1,143 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.jkiss.code.NotNull; + +public final class DDRemoteDamProtocol { + public static final String WEBSOCKET_PATH = "/ws"; + public static final String TYPE_HANDSHAKE_REQUEST = "hello"; + public static final String TYPE_HANDSHAKE_RESPONSE = "ready"; + + private static final String FIELD_TOPIC = "topic"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_PAYLOAD = "payload"; + private static final String FIELD_APP_DEPLOYMENT_ID = "appDeploymentId"; + private static final String FIELD_APP_INSTANCE_ID = "appInstanceId"; + private static final String FIELD_PRODUCT = "product"; + private static final String FIELD_VERSION = "version"; + private static final String FIELD_DAM_SESSION_ID = "damSessionId"; + + private DDRemoteDamProtocol() { + } + + @NotNull + public static String encode(@NotNull DDRemoteDamMessage message) { + JsonObject envelope = envelope(message.type(), message.payload()); + envelope.addProperty(FIELD_TOPIC, message.topic()); + return envelope.toString(); + } + + @NotNull + public static DDRemoteDamMessage decode(@NotNull String message) { + JsonObject envelope = parseEnvelope(message); + return new DDRemoteDamMessage( + readString(envelope, FIELD_TOPIC), + readString(envelope, FIELD_TYPE), + readPayload(envelope) + ); + } + + @NotNull + public static String encodeHandshakeRequest(@NotNull DDRemoteDamHandshakeRequest hello) { + JsonObject payload = new JsonObject(); + payload.addProperty(FIELD_APP_DEPLOYMENT_ID, hello.appDeploymentId()); + payload.addProperty(FIELD_APP_INSTANCE_ID, hello.appInstanceId()); + payload.addProperty(FIELD_PRODUCT, hello.product()); + payload.addProperty(FIELD_VERSION, hello.version()); + return envelope(TYPE_HANDSHAKE_REQUEST, payload).toString(); + } + + @NotNull + public static DDRemoteDamHandshakeRequest decodeHandshakeRequest(@NotNull String message) { + JsonObject payload = readControlPayload(message, TYPE_HANDSHAKE_REQUEST); + return new DDRemoteDamHandshakeRequest( + readString(payload, FIELD_APP_DEPLOYMENT_ID), + readString(payload, FIELD_APP_INSTANCE_ID), + readString(payload, FIELD_PRODUCT), + readString(payload, FIELD_VERSION) + ); + } + + @NotNull + public static String encodeHandshakeResponse(@NotNull DDRemoteDamHandshakeResponse ready) { + JsonObject payload = new JsonObject(); + payload.addProperty(FIELD_DAM_SESSION_ID, ready.damSessionId()); + return envelope(TYPE_HANDSHAKE_RESPONSE, payload).toString(); + } + + @NotNull + public static DDRemoteDamHandshakeResponse decodeHandshakeResponse(@NotNull String message) { + return new DDRemoteDamHandshakeResponse(readString(readControlPayload(message, TYPE_HANDSHAKE_RESPONSE), FIELD_DAM_SESSION_ID)); + } + + @NotNull + private static JsonObject envelope(@NotNull String type, @NotNull JsonObject payload) { + JsonObject envelope = new JsonObject(); + envelope.addProperty(FIELD_TYPE, type); + envelope.add(FIELD_PAYLOAD, payload); + return envelope; + } + + @NotNull + private static JsonObject parseEnvelope(@NotNull String message) { + try { + JsonElement parsed = JsonParser.parseString(message); + if (!parsed.isJsonObject()) { + throw new IllegalArgumentException("Expected a message object"); + } + return parsed.getAsJsonObject(); + } catch (JsonParseException e) { + throw new IllegalArgumentException("Invalid message JSON", e); + } + } + + @NotNull + private static JsonObject readControlPayload(@NotNull String message, @NotNull String expectedType) { + JsonObject envelope = parseEnvelope(message); + if (envelope.has(FIELD_TOPIC) || !expectedType.equals(readString(envelope, FIELD_TYPE))) { + throw new IllegalArgumentException("Expected a " + expectedType + " message"); + } + return readPayload(envelope); + } + + @NotNull + private static JsonObject readPayload(@NotNull JsonObject envelope) { + JsonElement payload = envelope.get(FIELD_PAYLOAD); + if (payload == null || !payload.isJsonObject()) { + throw new IllegalArgumentException("Expected a payload object"); + } + return payload.getAsJsonObject(); + } + + @NotNull + private static String readString(@NotNull JsonObject object, @NotNull String field) { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() + || value.getAsString().isBlank()) { + throw new IllegalArgumentException("Expected a non-blank string for " + field); + } + return value.getAsString(); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java new file mode 100644 index 0000000..557dfad --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java @@ -0,0 +1,102 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.client; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.google.gson.JsonElement; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +public final class DDRemoteDamCallbackClient extends DDRemoteDamGraphQlClient { + private static final String DELIVER_FIELD = "deliverRemoteDamMessage"; + private static final String DELIVER_OPERATION = "DeliverRemoteDamMessage"; + private static final String DELIVER_MUTATION = """ + mutation DeliverRemoteDamMessage($subscriptionId: ID!, $accountId: ID!, + $session: RemoteDamSessionInfoInput!, $message: RemoteDamMessageInput!) { + deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, session: $session, message: $message) + } + """; + + public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + this(serviceUrl, serviceSecret, Duration.ofSeconds(30)); + } + + public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { + super(serviceUrl, serviceSecret, DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); + } + + public void deliver( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message + ) throws DDRemoteDamException { + readDeliveryResult(request(DELIVER_OPERATION, DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD)); + } + + @NotNull + public CompletionStage deliverAsync( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message + ) { + return requestAsync(DELIVER_OPERATION, DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD, + DDRemoteDamCallbackClient::readDeliveryResult); + } + + @NotNull + private static Map deliveryVariables( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message + ) { + return Map.of( + "subscriptionId", Objects.requireNonNull(subscriptionId).toString(), + "accountId", Objects.requireNonNull(accountId).toString(), + "session", Objects.requireNonNull(session), "message", Objects.requireNonNull(message) + ); + } + + @Nullable + private static Void readDeliveryResult(@NotNull JsonElement result) throws DDRemoteDamException { + if (!readBoolean(result)) { + throw new DDRemoteDamException("Remote DAM callback was not accepted"); + } + return null; + } + + private static int timeoutMillis(@NotNull Duration timeout) { + if (timeout.isNegative() || timeout.compareTo(Duration.ofMillis(1)) < 0 + || timeout.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) > 0) { + throw new IllegalArgumentException("Invalid callback delivery timeout"); + } + return (int) timeout.toMillis(); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java new file mode 100644 index 0000000..499cfc1 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java @@ -0,0 +1,286 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.client; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +public final class DDRemoteDamClient extends DDRemoteDamGraphQlClient { + private static final int READ_TIMEOUT_MILLIS = 10_000; + private static final String SESSIONS_FIELD = "remoteDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount"; + private static final String SEND_FIELD = "sendRemoteDamMessage"; + private static final String SESSIONS_OPERATION = "RemoteDamSessions"; + private static final String SESSION_ACCOUNT_OPERATION = "RemoteDamSessionAccount"; + private static final String SEND_OPERATION = "SendRemoteDamMessage"; + private static final String SESSIONS_QUERY = """ + query RemoteDamSessions($accountId: ID!) { + remoteDamSessions(accountId: $accountId) { + appDeploymentId appInstanceId damSessionId product version + } + } + """; + private static final String SESSION_ACCOUNT_QUERY = """ + query RemoteDamSessionAccount($damSessionId: ID!) { + remoteDamSessionAccount(damSessionId: $damSessionId) + } + """; + private static final String SEND_MUTATION = """ + mutation SendRemoteDamMessage($accountId: ID!, $damSessionId: ID!, $message: RemoteDamMessageInput!) { + sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message) + } + """; + private static final String REGISTER_MUTATION = """ + mutation RegisterRemoteDamSubscription($input: RemoteDamSubscriptionInput!) { + registerRemoteDamSubscription(input: $input) + } + """; + private static final String UNREGISTER_MUTATION = """ + mutation UnregisterRemoteDamSubscription($subscriberId: String!, $subscriptionId: ID!) { + unregisterRemoteDamSubscription(subscriberId: $subscriberId, subscriptionId: $subscriptionId) + } + """; + + public DDRemoteDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + super(serviceUrl, serviceSecret, DDRemoteDamApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); + } + + @NotNull + public List getSessions(@NotNull UUID accountId) throws DDRemoteDamException { + return readSessions(request(SESSIONS_OPERATION, SESSIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD)); + } + + @NotNull + public CompletionStage> getSessionsAsync(@NotNull UUID accountId) { + return requestAsync(SESSIONS_OPERATION, SESSIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD, DDRemoteDamClient::readSessions); + } + + @NotNull + public UUID getSessionAccount(@NotNull String damSessionId) throws DDRemoteDamException { + return readSessionAccount(request(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY, + sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD)); + } + + @NotNull + public CompletionStage getSessionAccountAsync(@NotNull String damSessionId) { + return requestAsync(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY, + sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD, DDRemoteDamClient::readSessionAccount); + } + + public boolean sendMessage(@NotNull UUID accountId, @NotNull String damSessionId, @NotNull DDRemoteDamMessage message) + throws DDRemoteDamException { + return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD)); + } + + @NotNull + public CompletionStage sendMessageAsync( + @NotNull UUID accountId, + @NotNull String damSessionId, + @NotNull DDRemoteDamMessage message + ) { + return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD, + DDRemoteDamGraphQlClient::readBoolean); + } + + public int registerSubscription( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String damSessionId, + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret + ) throws DDRemoteDamException { + return readLease(request("RegisterRemoteDamSubscription", REGISTER_MUTATION, + subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), + "registerRemoteDamSubscription")); + } + + @NotNull + public CompletionStage registerSubscriptionAsync( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String damSessionId, + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret + ) { + return requestAsync("RegisterRemoteDamSubscription", REGISTER_MUTATION, + subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), + "registerRemoteDamSubscription", DDRemoteDamClient::readLease); + } + + public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDRemoteDamException { + return readBoolean(request("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription")); + } + + @NotNull + public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) { + return requestAsync("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription", + DDRemoteDamGraphQlClient::readBoolean); + } + + @NotNull + private static List readSessions(@NotNull JsonElement result) throws DDRemoteDamException { + if (!result.isJsonArray()) { + throw invalidResponse(); + } + List sessions = new ArrayList<>(); + for (JsonElement value : result.getAsJsonArray()) { + JsonObject session = readObject(value); + sessions.add(new DDRemoteDamSession( + readString(session, "appDeploymentId"), readString(session, "appInstanceId"), + readString(session, "damSessionId"), readString(session, "product"), readString(session, "version") + )); + } + return List.copyOf(sessions); + } + + @NotNull + private static UUID readSessionAccount(@NotNull JsonElement result) throws DDRemoteDamException { + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) { + throw invalidResponse(); + } + UUID accountId; + try { + accountId = UUID.fromString(result.getAsString()); + } catch (IllegalArgumentException e) { + throw invalidResponse(); + } + if (!accountId.toString().equalsIgnoreCase(result.getAsString())) { + throw invalidResponse(); + } + return accountId; + } + + private static int readLease(@NotNull JsonElement result) throws DDRemoteDamException { + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { + throw invalidResponse(); + } + try { + int leaseSeconds = result.getAsBigDecimal().intValueExact(); + if (leaseSeconds >= DDRemoteDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS + && leaseSeconds <= DDRemoteDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { + return leaseSeconds; + } + } catch (ArithmeticException | NumberFormatException e) { + throw invalidResponse(); + } + throw invalidResponse(); + } + + @NotNull + private static Map sessionAccountVariables(@NotNull String damSessionId) { + if (damSessionId.isBlank() || damSessionId.length() > 256) { + throw new IllegalArgumentException("Invalid session ID"); + } + return Map.of("damSessionId", damSessionId); + } + + @NotNull + private static Map messageVariables( + @NotNull UUID accountId, + @NotNull String damSessionId, + @NotNull DDRemoteDamMessage message + ) { + if (damSessionId.isBlank()) { + throw new IllegalArgumentException("A session ID is required"); + } + return Map.of( + "accountId", Objects.requireNonNull(accountId).toString(), "damSessionId", damSessionId, + "message", Objects.requireNonNull(message) + ); + } + + @NotNull + private static Map subscriptionVariables( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String damSessionId, + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret + ) { + requireSubscriberId(subscriberId); + requireText(topic); + if (damSessionId != null) { + requireText(damSessionId); + } + if (!DDRemoteDamApi.isValidServiceUrl(callbackUrl)) { + throw new IllegalArgumentException("Invalid Remote DAM callback URL"); + } + if (!DDRemoteDamApi.isValidServiceSecret(callbackSecret)) { + throw new IllegalArgumentException("Invalid Remote DAM callback secret"); + } + Map input = new HashMap<>(); + input.put("subscriberId", subscriberId); + input.put("subscriptionId", Objects.requireNonNull(subscriptionId).toString()); + input.put("accountId", Objects.requireNonNull(accountId).toString()); + input.put("damSessionId", damSessionId); + input.put("topic", topic); + input.put("callbackUrl", callbackUrl); + input.put("callbackSecret", callbackSecret); + return Map.of("input", input); + } + + @NotNull + private static Map unsubscriptionVariables(@NotNull String subscriberId, @NotNull UUID subscriptionId) { + requireSubscriberId(subscriberId); + return Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString()); + } + + @NotNull + private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDRemoteDamException { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { + throw invalidResponse(); + } + return value.getAsString(); + } + + private static void requireSubscriberId(@Nullable String subscriberId) { + if (!DDRemoteDamApi.isValidSubscriberId(subscriberId)) { + throw new IllegalArgumentException("Invalid subscription metadata"); + } + } + + private static void requireText(@NotNull String value) { + if (value.isBlank() || value.length() > 256) { + throw new IllegalArgumentException("Invalid subscription metadata"); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java new file mode 100644 index 0000000..e35a424 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java @@ -0,0 +1,243 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.client; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.rest.client.AbstractRestClient; +import com.dbeaver.rest.client.MediaType; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.utils.CommonUtils; +import org.jkiss.utils.HttpConstants; + +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +abstract class DDRemoteDamGraphQlClient extends AbstractRestClient { + private final String serviceSecret; + private final String endpoint; + private final Duration requestTimeout; + + DDRemoteDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { + super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, + List.of(), HttpClient.Redirect.NEVER); + if (!DDRemoteDamApi.isValidServiceSecret(serviceSecret)) { + throw new IllegalArgumentException("A valid Remote DAM service secret is required"); + } + this.serviceSecret = serviceSecret; + this.endpoint = endpoint; + this.requestTimeout = Duration.ofMillis(timeoutMillis > 0 ? timeoutMillis : DEFAULT_READ_TIMEOUT); + } + + @NotNull + final JsonElement request( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field + ) throws DDRemoteDamException { + String response; + try { + response = execute(createRequest(operationName, query, variables), String.class); + } catch (DBException e) { + throw requestFailure(e); + } + return readResult(response, field); + } + + @NotNull + final CompletionStage requestAsync( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field + ) { + try { + HttpRequest request = createRequest(operationName, query, variables).build(); + var exchange = getHttpClient().sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); + var bounded = exchange.copy().orTimeout(requestTimeout.toMillis(), TimeUnit.MILLISECONDS); + var completed = bounded.whenComplete((response, error) -> { + if (error instanceof TimeoutException || bounded.isCancelled()) { + exchange.cancel(true); + } + }); + return propagateCancellation(completed.handle((response, error) -> { + if (error != null) { + throw new CompletionException(requestFailure(error)); + } + try { + int code = response.statusCode(); + if (code < HttpConstants.CODE_OK || code >= HttpURLConnection.HTTP_MULT_CHOICE) { + throw mapErrorResponse(code, "", request.uri()); + } + return readResult(response.body(), field); + } catch (DDRemoteDamException e) { + throw new CompletionException(e); + } + }), bounded); + } catch (DBException | RuntimeException e) { + return CompletableFuture.failedFuture(requestFailure(e)); + } + } + + @NotNull + final CompletionStage requestAsync( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field, + @NotNull ResultReader reader + ) { + CompletableFuture response = requestAsync(operationName, query, variables, field).toCompletableFuture(); + return propagateCancellation(response.thenApply(result -> { + try { + return reader.read(result); + } catch (DDRemoteDamException e) { + throw new CompletionException(e); + } + }), response); + } + + @NotNull + private static CompletionStage propagateCancellation( + @NotNull CompletableFuture result, + @NotNull CompletableFuture source + ) { + CompletableFuture completion = new CompletableFuture<>(); + result.whenComplete((value, error) -> { + if (error == null) { + completion.complete(value); + } else { + completion.completeExceptionally(error); + } + }); + completion.whenComplete((value, error) -> { + if (completion.isCancelled()) { + result.cancel(true); + source.cancel(true); + } + }); + return completion; + } + + @NotNull + private HttpRequest.Builder createRequest( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables + ) throws DBException { + return HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) + .timeout(requestTimeout) + .header(DDRemoteDamApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) + .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); + } + + @NotNull + private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDRemoteDamException { + JsonObject envelope; + try { + envelope = readObject(JsonParser.parseString(response)); + } catch (JsonParseException e) { + throw invalidResponse(); + } + JsonElement errors = envelope.get("errors"); + if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { + throw new DDRemoteDamException("Remote DAM GraphQL request failed"); + } + JsonElement result = readObject(envelope.get("data")).get(field); + if (result == null || result.isJsonNull()) { + throw invalidResponse(); + } + return result; + } + + @Override + @NotNull + protected DDRemoteDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + return new DDRemoteDamException("Remote DAM request failed: HTTP " + code); + } + + static boolean readBoolean(@NotNull JsonElement value) throws DDRemoteDamException { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + throw invalidResponse(); + } + return value.getAsBoolean(); + } + + @NotNull + static JsonObject readObject(@Nullable JsonElement value) throws DDRemoteDamException { + if (value == null || !value.isJsonObject()) { + throw invalidResponse(); + } + return value.getAsJsonObject(); + } + + @NotNull + static DDRemoteDamException invalidResponse() { + return new DDRemoteDamException("Invalid Remote DAM response"); + } + + @NotNull + private static DDRemoteDamException requestFailure(@NotNull Throwable error) { + while (error instanceof CompletionException && error.getCause() != null) { + error = error.getCause(); + } + if (error instanceof TimeoutException) { + HttpTimeoutException timeout = new HttpTimeoutException("Remote DAM service request timed out"); + timeout.initCause(error); + error = timeout; + } + return error instanceof DDRemoteDamException failure + ? failure : new DDRemoteDamException("Remote DAM service request failed", error); + } + + @NotNull + private static String validateServiceUrl(@NotNull String serviceUrl) { + if (!DDRemoteDamApi.isValidServiceUrl(serviceUrl)) { + throw new IllegalArgumentException("Invalid Remote DAM service URL"); + } + return serviceUrl; + } + + @FunctionalInterface + interface ResultReader { + @Nullable + T read(@NotNull JsonElement result) throws DDRemoteDamException; + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java new file mode 100644 index 0000000..25fa401 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java @@ -0,0 +1,30 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.exception; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; + +public class DDRemoteDamException extends DBException { + public DDRemoteDamException(@NotNull String message) { + super(message); + } + + public DDRemoteDamException(@NotNull String message, @NotNull Throwable cause) { + super(message, cause); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java new file mode 100644 index 0000000..ff22925 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java @@ -0,0 +1,33 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import org.jkiss.code.NotNull; + +public record DDRemoteDamHandshakeRequest( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, + @NotNull String product, + @NotNull String version +) { + public DDRemoteDamHandshakeRequest { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(product, "product"); + DDRemoteDamMessage.requireText(version, "version"); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java new file mode 100644 index 0000000..2d38996 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java @@ -0,0 +1,25 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import org.jkiss.code.NotNull; + +public record DDRemoteDamHandshakeResponse(@NotNull String damSessionId) { + public DDRemoteDamHandshakeResponse { + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java new file mode 100644 index 0000000..4eaa3cf --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public record DDRemoteDamMessage( + @NotNull String topic, + @NotNull String type, + @NotNull JsonObject payload +) { + public DDRemoteDamMessage { + requireText(topic, "topic"); + requireText(type, "type"); + payload = Objects.requireNonNull(payload, "payload").deepCopy(); + } + + @Override + @NotNull + public JsonObject payload() { + return payload.deepCopy(); + } + + static void requireText(@Nullable String value, @NotNull String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing " + name); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java new file mode 100644 index 0000000..3735860 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java @@ -0,0 +1,35 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import org.jkiss.code.NotNull; + +public record DDRemoteDamSession( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, + @NotNull String damSessionId, + @NotNull String product, + @NotNull String version +) { + public DDRemoteDamSession { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); + DDRemoteDamMessage.requireText(product, "product"); + DDRemoteDamMessage.requireText(version, "version"); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java new file mode 100644 index 0000000..c712772 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java @@ -0,0 +1,31 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import org.jkiss.code.NotNull; + +public record DDRemoteDamSessionInfo( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, + @NotNull String damSessionId +) { + public DDRemoteDamSessionInfo { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java new file mode 100644 index 0000000..ec55475 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java @@ -0,0 +1,448 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.requestreply; + +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Semaphore; + +public final class DDRemoteDamRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { + private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; + private static final String FIELD_REQUEST_ID = "requestId"; + private static final String FIELD_REPLY_TO = "replyTo"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_ERROR = "error"; + private static final String FIELD_DATA = "data"; + private static final int MAX_PENDING_SENDS = 128; + private static final int MAX_PENDING_DELIVERIES = 128; + private final Object lock = new Object(); + private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); + private final String topic; + private final String replyPrefix; + private final Sender sender; + private final TopicSubscriber subscriber; + @Nullable + private final DDRemoteDamChannel channel; + @Nullable + private Registration registration; + private boolean claimed; + + /** Sends messages asynchronously to a selected peer. */ + @FunctionalInterface + public interface Sender { + @NotNull + CompletionStage send(@NotNull DDRemoteDamMessage message); + } + + /** Registers topic listeners for a selected peer. */ + @FunctionalInterface + public interface TopicSubscriber { + @NotNull + DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) throws DDRemoteDamException; + } + + public DDRemoteDamRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { + this(topic, sender, subscriber, null); + } + + private DDRemoteDamRequestReplyTransport( + @NotNull String topic, + @NotNull Sender sender, + @NotNull TopicSubscriber subscriber, + @Nullable DDRemoteDamChannel channel + ) { + if (topic.isBlank() || topic.length() > 256) { + throw new IllegalArgumentException("Expected a nonblank request/reply topic of at most 256 characters"); + } + this.topic = topic; + this.replyPrefix = replyPrefix(topic); + this.sender = Objects.requireNonNull(sender); + this.subscriber = Objects.requireNonNull(subscriber); + this.channel = channel; + } + + @NotNull + public static DDRemoteDamRequestReplyTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) { + Objects.requireNonNull(channel); + return new DDRemoteDamRequestReplyTransport(topic, channel::send, channel::subscribe, channel); + } + + @Override + @NotNull + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws MessagingRequestReplyException { + Registration opened; + synchronized (lock) { + if (claimed) { + throw new IllegalStateException("A request/reply adapter supports only one endpoint and cannot be reopened"); + } + claimed = true; + opened = new Registration(Objects.requireNonNull(endpointId), Objects.requireNonNull(listener)); + registration = opened; + } + try { + if (channel != null) { + opened.state(channel.getStatus()); + opened.requireReady(); + opened.add(channel.observeState(opened::state)); + } + opened.add(subscriber.subscribe(opened.replyTopic, (session, message) -> opened.receive(session, message, false))); + opened.add(subscriber.subscribe(topic, (session, message) -> opened.receive(session, message, true))); + if (channel != null) { + opened.state(channel.getStatus()); + } + synchronized (lock) { + opened.requireReady(); + opened.opening = false; + } + return opened; + } catch (DDRemoteDamException | MessagingRequestReplyException | RuntimeException e) { + try { + opened.close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + if (e instanceof MessagingRequestReplyException failure) { + throw failure; + } + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); + } + } + + @Override + @NotNull + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { + synchronized (lock) { + if (registration == null || registration.closed) { + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED)); + } + if (channel != null && !channel.getStatus().isReady()) { + return CompletableFuture.failedFuture( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED)); + } + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST && !registration.endpointId.equals(message.replyTo())) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Request origin does not match its endpoint")); + } + } + if (!sendBudget.tryAcquire()) { + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED)); + } + try { + JsonObject envelope = new JsonObject(); + envelope.addProperty(FIELD_REQUEST_ID, message.requestId().toString()); + envelope.addProperty(FIELD_REPLY_TO, message.replyTo().toString()); + envelope.addProperty(FIELD_TYPE, message.type().name()); + if (message.type() == MessagingRequestReplyMessage.Type.ERROR) { + envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); + } else { + envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload()); + } + String target = message.type() == MessagingRequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo(); + CompletableFuture result = new CompletableFuture<>(); + Objects.requireNonNull(sender.send(new DDRemoteDamMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { + sendBudget.release(); + if (error == null) { + result.complete(null); + } else { + result.completeExceptionally(error); + } + }); + return result; + } catch (RuntimeException | Error e) { + sendBudget.release(); + return CompletableFuture.failedFuture(e); + } + } + + @Override + public void close() { + Registration stopped; + synchronized (lock) { + claimed = true; + stopped = registration; + } + if (stopped != null) { + stopped.close(); + } + } + + @NotNull + private static String replyPrefix(@NotNull String topic) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(topic.getBytes(StandardCharsets.UTF_8)); + return "datadam.reply.v1." + HexFormat.of().formatHex(digest) + "."; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + @Nullable + private static MessagingRequestReplyMessage decode(@NotNull DDRemoteDamMessage message) { + if (!MESSAGE_TYPE.equals(message.type())) { + return null; + } + try { + JsonObject envelope = message.payload(); + UUID requestId = uuid(envelope, FIELD_REQUEST_ID); + UUID replyTo = uuid(envelope, FIELD_REPLY_TO); + MessagingRequestReplyMessage.Type type = MessagingRequestReplyMessage.Type.valueOf(string(envelope, FIELD_TYPE)); + JsonElement data = envelope.get(FIELD_DATA); + MessagingRequestReplyMessage.Error error = null; + JsonObject payload = null; + if (type == MessagingRequestReplyMessage.Type.ERROR) { + if (data != null && !data.isJsonNull()) { + return null; + } + error = MessagingRequestReplyMessage.Error.valueOf(string(envelope, FIELD_ERROR)); + } else { + if (envelope.has(FIELD_ERROR) || data == null || !data.isJsonObject() && !data.isJsonNull() + || type == MessagingRequestReplyMessage.Type.REQUEST && data.isJsonNull()) { + return null; + } + payload = data.isJsonNull() ? null : data.getAsJsonObject(); + } + return new MessagingRequestReplyMessage<>(requestId, replyTo, type, payload, error); + } catch (IllegalArgumentException e) { + return null; + } + } + + @NotNull + private static String string(@NotNull JsonObject envelope, @NotNull String field) { + JsonElement value = envelope.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Invalid request/reply field"); + } + return value.getAsString(); + } + + @NotNull + private static UUID uuid(@NotNull JsonObject envelope, @NotNull String field) { + String value = string(envelope, field); + UUID id = UUID.fromString(value); + if (value.length() != 36 || !id.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Invalid request/reply UUID"); + } + return id; + } + + private final class Registration implements MessagingSubscription { + private final UUID endpointId; + private final String replyTopic; + private final Listener listener; + private final List subscriptions = new ArrayList<>(); + private final ArrayDeque deliveries = new ArrayDeque<>(); + @Nullable + private DDRemoteDamChannelStatus status; + private boolean opening = true; + private boolean openingDisconnected; + private boolean delivering; + private boolean closed; + + private Registration(@NotNull UUID endpointId, @NotNull Listener listener) { + this.endpointId = endpointId; + this.replyTopic = replyPrefix + endpointId; + this.listener = listener; + } + + private void add(@NotNull DDRemoteDamSubscription subscription) { + synchronized (lock) { + if (!closed) { + subscriptions.add(Objects.requireNonNull(subscription)); + return; + } + } + subscription.close(); + } + + private void requireReady() throws MessagingRequestReplyException { + synchronized (lock) { + if (closed) { + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED); + } + if (openingDisconnected || channel != null && (status == null || !status.isReady())) { + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + } + } + } + + private void state(@NotNull DDRemoteDamChannelStatus next) { + boolean drain; + synchronized (lock) { + if (closed) { + return; + } + openingDisconnected |= opening && !next.isReady(); + drain = updateState(channel == null ? next : channel.getStatus()); + } + if (drain) { + drain(); + } + } + + private boolean updateState(@NotNull DDRemoteDamChannelStatus next) { + if (next.equals(status)) { + return false; + } + boolean disconnected = !next.isReady() || status != null && status.isReady() + && !Objects.equals(status.session(), next.session()); + status = next; + openingDisconnected |= opening && disconnected; + return disconnected && enqueue(null); + } + + private void receive(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message, boolean requestTopic) { + MessagingRequestReplyMessage frame = decode(message); + if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST + : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { + return; + } + boolean drain = false; + synchronized (lock) { + if (closed || !(requestTopic ? topic : replyTopic).equals(message.topic())) { + return; + } + if (channel != null) { + DDRemoteDamChannelStatus current = channel.getStatus(); + if (!current.isReady() || !session.equals(current.session())) { + return; + } + drain = updateState(current); + drain |= enqueue(frame); + } + } + if (channel == null) { + try { + listener.onMessage(frame); + } catch (RuntimeException | Error e) { + try { + close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } + } else if (drain) { + drain(); + } + } + + private boolean enqueue(@Nullable MessagingRequestReplyMessage message) { + if (message == null) { + if (!deliveries.isEmpty() && deliveries.getLast().message() == null) { + return false; + } + if (deliveries.size() >= MAX_PENDING_DELIVERIES) { + deliveries.clear(); + } + } else if (deliveries.size() >= MAX_PENDING_DELIVERIES) { + return false; + } + deliveries.add(new Delivery(message)); + if (delivering) { + return false; + } + delivering = true; + return true; + } + + private void drain() { + try { + while (true) { + Delivery delivery; + synchronized (lock) { + delivery = deliveries.poll(); + if (closed || delivery == null) { + delivering = false; + return; + } + } + if (delivery.message() == null) { + listener.onDisconnect(); + } else { + listener.onMessage(delivery.message()); + } + } + } catch (RuntimeException | Error e) { + try { + close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } + } + + @Override + public void close() { + List stopped; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + deliveries.clear(); + stopped = List.copyOf(subscriptions); + subscriptions.clear(); + } + RuntimeException failure = null; + for (DDRemoteDamSubscription subscription : stopped.reversed()) { + try { + subscription.close(); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + private record Delivery(@Nullable MessagingRequestReplyMessage message) { + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java new file mode 100644 index 0000000..2c5cd95 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.messaging.rpc.MessagingRpcCodec; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.Type; +import java.util.Objects; + +public final class DDRemoteDamGsonRpcCodec implements MessagingRpcCodec { + private final Gson gson; + + public DDRemoteDamGsonRpcCodec(@NotNull Gson gson) { + this.gson = Objects.requireNonNull(gson); + } + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + JsonElement encoded = gson.toJsonTree(value, Objects.requireNonNull(type)); + return encoded == null || encoded.isJsonNull() ? null : encoded; + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return gson.fromJson(payload, Objects.requireNonNull(type)); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java new file mode 100644 index 0000000..a82c0ed --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java @@ -0,0 +1,266 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; +import com.dbeaver.messaging.rpc.MessagingRpcTransport; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.Semaphore; + +public final class DDRemoteDamRpcTransport implements MessagingRequestReplyTransport> { + private static final String PROTOCOL = "dbeaver.rpc.v2"; + private static final String FIELD_PROTOCOL = "protocol"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_ERROR = "error"; + private static final String FIELD_OPERATION = "operation"; + private static final String FIELD_DATA = "data"; + private static final String FIELD_EXCEPTION = "exception"; + private static final String FIELD_MESSAGE = "message"; + private static final String FIELD_STACK_TRACE = "stackTrace"; + private static final String FIELD_TRUNCATED = "truncated"; + private static final int MAX_PENDING_SENDS = 128; + private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); + private final MessagingRequestReplyTransport delegate; + @Nullable + private final Executor requestExecutor; + + public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate) { + this(delegate, null); + } + + public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { + this.delegate = Objects.requireNonNull(delegate); + this.requestExecutor = requestExecutor; + } + + @NotNull + public static MessagingRpcTransport forChannel(@NotNull DDRemoteDamChannel channel) { + Objects.requireNonNull(channel); + return topic -> forChannel(channel, topic); + } + + @NotNull + public static DDRemoteDamRpcTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) { + return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic)); + } + + @NotNull + public static DDRemoteDamRpcTransport forChannel( + @NotNull DDRemoteDamChannel channel, + @NotNull String topic, + @NotNull Executor requestExecutor + ) { + return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic), + Objects.requireNonNull(requestExecutor)); + } + + @Override + public void executeRequest(@NotNull Runnable request) { + if (requestExecutor == null) { + delegate.executeRequest(request); + } else { + requestExecutor.execute(request); + } + } + + @Override + @NotNull + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) + throws MessagingRequestReplyException { + Objects.requireNonNull(endpointId); + Objects.requireNonNull(listener); + return delegate.subscribe(endpointId, new Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + MessagingRequestReplyMessage> decoded = decode(message); + if (decoded != null) { + listener.onMessage(decoded); + } + } + + @Override + public void onDisconnect() { + listener.onDisconnect(); + } + }); + } + + @Override + @NotNull + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { + if (!sendBudget.tryAcquire()) { + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED)); + } + try { + MessagingRpcMessage payload = message.payload(); + if (payload != null) { + requireType(message.type(), payload.type()); + } + MessagingRequestReplyMessage frame = new MessagingRequestReplyMessage<>( + message.requestId(), message.replyTo(), message.type(), + payload == null ? null : encode(payload), message.error()); + CompletableFuture result = new CompletableFuture<>(); + Objects.requireNonNull(delegate.send(frame)).whenComplete((ignored, error) -> { + sendBudget.release(); + if (error == null) { + result.complete(null); + } else { + result.completeExceptionally(error); + } + }); + return result; + } catch (RuntimeException | Error e) { + sendBudget.release(); + return CompletableFuture.failedFuture(e); + } + } + + @NotNull + private static JsonObject encode(@NotNull MessagingRpcMessage message) { + JsonObject envelope = new JsonObject(); + envelope.addProperty(FIELD_PROTOCOL, PROTOCOL); + envelope.addProperty(FIELD_TYPE, message.type().name()); + if (message.type() == MessagingRpcMessage.Type.ERROR) { + envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); + envelope.add(FIELD_EXCEPTION, message.exception() == null ? JsonNull.INSTANCE : encodeException(message.exception())); + } else { + if (message.type() == MessagingRpcMessage.Type.REQUEST) { + envelope.addProperty(FIELD_OPERATION, message.operation()); + JsonArray arguments = new JsonArray(); + for (JsonElement argument : message.arguments()) { + arguments.add(argument == null ? JsonNull.INSTANCE : argument.deepCopy()); + } + envelope.add(FIELD_DATA, arguments); + } else { + envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); + } + } + return envelope; + } + + @NotNull + private static JsonObject encodeException(@NotNull MessagingRpcExceptionDetails details) { + JsonObject exception = new JsonObject(); + exception.addProperty(FIELD_TYPE, details.type()); + exception.addProperty(FIELD_MESSAGE, details.message()); + exception.addProperty(FIELD_STACK_TRACE, details.stackTrace()); + exception.addProperty(FIELD_TRUNCATED, details.truncated()); + return exception; + } + + @Nullable + private static MessagingRequestReplyMessage> decode( + @NotNull MessagingRequestReplyMessage message + ) { + if (message.type() == MessagingRequestReplyMessage.Type.ERROR || message.payload() == null) { + return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), null, message.error()); + } + try { + JsonObject envelope = message.payload(); + if (!PROTOCOL.equals(string(envelope, FIELD_PROTOCOL))) { + return null; + } + MessagingRpcMessage.Type type = MessagingRpcMessage.Type.valueOf(string(envelope, FIELD_TYPE)); + requireType(message.type(), type); + MessagingRpcMessage payload; + if (type == MessagingRpcMessage.Type.ERROR) { + JsonElement exception = envelope.get(FIELD_EXCEPTION); + if (envelope.size() != 4 || exception == null) { + return null; + } + payload = MessagingRpcMessage.failure(MessagingRpcMessage.Error.valueOf(string(envelope, FIELD_ERROR)), + decodeException(exception)); + } else { + JsonElement data = envelope.get(FIELD_DATA); + if (data == null || envelope.size() != (type == MessagingRpcMessage.Type.REQUEST ? 4 : 3)) { + return null; + } + if (type == MessagingRpcMessage.Type.REQUEST) { + if (!data.isJsonArray()) { + return null; + } + List arguments = new ArrayList<>(data.getAsJsonArray().size()); + for (JsonElement argument : data.getAsJsonArray()) { + arguments.add(argument.isJsonNull() ? null : argument.deepCopy()); + } + payload = MessagingRpcMessage.request(string(envelope, FIELD_OPERATION), arguments); + } else { + payload = MessagingRpcMessage.response(data.isJsonNull() ? null : data.deepCopy()); + } + } + return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Nullable + private static MessagingRpcExceptionDetails decodeException(@NotNull JsonElement value) { + if (value.isJsonNull()) { + return null; + } + if (!value.isJsonObject()) { + throw new IllegalArgumentException("Expected RPC exception details"); + } + JsonObject exception = value.getAsJsonObject(); + JsonElement message = exception.get(FIELD_MESSAGE); + JsonElement truncated = exception.get(FIELD_TRUNCATED); + if (exception.size() != 4 || message == null || truncated == null || !truncated.isJsonPrimitive() + || !truncated.getAsJsonPrimitive().isBoolean()) { + throw new IllegalArgumentException("Invalid RPC exception details"); + } + return new MessagingRpcExceptionDetails(string(exception, FIELD_TYPE), + message.isJsonNull() ? null : string(exception, FIELD_MESSAGE), string(exception, FIELD_STACK_TRACE), + truncated.getAsBoolean()); + } + + private static void requireType(@NotNull MessagingRequestReplyMessage.Type outer, @NotNull MessagingRpcMessage.Type inner) { + if (outer == MessagingRequestReplyMessage.Type.REQUEST ? inner != MessagingRpcMessage.Type.REQUEST + : outer != MessagingRequestReplyMessage.Type.RESPONSE || inner == MessagingRpcMessage.Type.REQUEST) { + throw new IllegalArgumentException("RPC message type does not match its request/reply frame"); + } + } + + @NotNull + private static String string(@NotNull JsonObject envelope, @NotNull String field) { + JsonElement value = envelope.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Invalid RPC field"); + } + return value.getAsString(); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java new file mode 100644 index 0000000..939a3d9 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java @@ -0,0 +1,45 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import org.jkiss.code.NotNull; + +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; + +/** Provides asynchronous DataDam messaging and session-state observation. */ +public interface DDRemoteDamChannel extends AutoCloseable { + + @NotNull + DDRemoteDamChannelStatus getStatus(); + + @NotNull + DDRemoteDamSubscription observeState(@NotNull Consumer observer); + + @NotNull + CompletionStage send(@NotNull DDRemoteDamMessage message); + + @NotNull + DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener); + + @NotNull + DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener); + + @Override + void close(); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java new file mode 100644 index 0000000..abfc0b2 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +public enum DDRemoteDamChannelState { + DISCONNECTED, + CONNECTING, + CONNECTED, + CLOSED +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java new file mode 100644 index 0000000..4622d25 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java @@ -0,0 +1,36 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public record DDRemoteDamChannelStatus(@NotNull DDRemoteDamChannelState state, @Nullable DDRemoteDamSessionInfo session) { + public DDRemoteDamChannelStatus { + Objects.requireNonNull(state); + if ((state == DDRemoteDamChannelState.CONNECTED) != (session != null)) { + throw new IllegalArgumentException("Only a connected channel has session information"); + } + } + + public boolean isReady() { + return state == DDRemoteDamChannelState.CONNECTED; + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java new file mode 100644 index 0000000..970fd7f --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java @@ -0,0 +1,27 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import org.jkiss.code.NotNull; + +/** Receives topic messages with their source session. */ +@FunctionalInterface +public interface DDRemoteDamMessageListener { + void onMessage(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java new file mode 100644 index 0000000..7d289fb --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java @@ -0,0 +1,27 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import org.jkiss.code.NotNull; + +/** Receives physical session lifecycle notifications. */ +public interface DDRemoteDamSessionListener { + void onConnected(@NotNull DDRemoteDamSessionInfo session); + + void onDisconnected(@NotNull DDRemoteDamSessionInfo session); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java new file mode 100644 index 0000000..1d8d261 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +/** Controls the lifetime of a DataDam listener registration. */ +@FunctionalInterface +public interface DDRemoteDamSubscription extends AutoCloseable { + @Override + void close(); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java new file mode 100644 index 0000000..e69099e --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java @@ -0,0 +1,27 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +/** Handles incoming requests and returns response payloads. */ +@FunctionalInterface +public interface MessagingRequestHandler

{ + @Nullable + P handle(@NotNull P request) throws Exception; +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java new file mode 100644 index 0000000..b25fe65 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java @@ -0,0 +1,374 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.LongSupplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class MessagingRequestReplyEndpoint

implements AutoCloseable { + private static final Logger log = Logger.getLogger(MessagingRequestReplyEndpoint.class.getName()); + private final Object lock = new Object(); + private final UUID endpointId = UUID.randomUUID(); + private final MessagingRequestReplyTransport

transport; + private final MessagingRequestReplyOptions options; + private final LongSupplier nanoTime; + private final Map pending = new HashMap<>(); + private final Map incoming = new HashMap<>(); + private final MessagingSubscription subscription; + @Nullable + private HandlerRegistration handler; + private boolean closed; + private long generation; + + MessagingRequestReplyEndpoint( + @NotNull MessagingRequestReplyTransport

transport, + @NotNull MessagingRequestReplyOptions options, + @NotNull LongSupplier nanoTime + ) throws MessagingRequestReplyException { + this.transport = Objects.requireNonNull(transport); + this.options = Objects.requireNonNull(options); + this.nanoTime = Objects.requireNonNull(nanoTime); + this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage

message) { + receive(message); + } + + @Override + public void onDisconnect() { + disconnect(); + } + })); + } + + @NotNull + public static

MessagingRequestReplyEndpoint

open( + @NotNull MessagingRequestReplyTransport

transport + ) throws MessagingRequestReplyException { + return open(transport, MessagingRequestReplyOptions.DEFAULT); + } + + @NotNull + public static

MessagingRequestReplyEndpoint

open( + @NotNull MessagingRequestReplyTransport

transport, + @NotNull MessagingRequestReplyOptions options + ) throws MessagingRequestReplyException { + return new MessagingRequestReplyEndpoint<>(transport, options, System::nanoTime); + } + + @Nullable + public P request(@NotNull P payload) throws MessagingRequestReplyException, InterruptedException { + return request(payload, options.timeout()); + } + + @Nullable + public P request(@NotNull P payload, @NotNull Duration timeout) throws MessagingRequestReplyException, InterruptedException { + Objects.requireNonNull(payload); + long nanos = MessagingRequestReplyOptions.requireTimeout(timeout); + if (Thread.interrupted()) { + throw new InterruptedException(); + } + long started = nanoTime.getAsLong(); + UUID requestId = UUID.randomUUID(); + Pending call = new Pending(started, nanos); + synchronized (lock) { + if (closed) { + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED); + } + if (pending.size() >= options.maxPendingRequests()) { + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED); + } + pending.put(requestId, call); + } + try { + if (call.remaining() <= 0) { + fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); + } else { + sendRequest(requestId, payload, call); + } + try { + return call.result.get(Math.max(0, call.remaining()), TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); + return call.result.get(); + } + } catch (ExecutionException e) { + if (e.getCause() instanceof MessagingRequestReplyException failure) { + throw failure; + } + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e.getCause()); + } finally { + synchronized (lock) { + pending.remove(requestId, call); + } + } + } + + @NotNull + public MessagingSubscription handle(@NotNull MessagingRequestHandler

requestHandler) { + Objects.requireNonNull(requestHandler); + synchronized (lock) { + if (closed || handler != null) { + throw new IllegalStateException("Request/reply handler registration is unavailable"); + } + handler = new HandlerRegistration(requestHandler); + return handler; + } + } + + private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull Pending call) { + synchronized (lock) { + if (pending.get(requestId) != call) { + return; + } + } + try { + transport.send(MessagingRequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> { + if (error != null) { + fail(requestId, call, sendFailure(error)); + } + }); + } catch (RuntimeException e) { + fail(requestId, call, sendFailure(e)); + } + } + + @NotNull + private static MessagingRequestReplyException sendFailure(@NotNull Throwable error) { + Throwable cause = error; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause instanceof MessagingRequestReplyException failure + ? failure : new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, error); + } + + private void fail( + @NotNull UUID requestId, + @NotNull Pending expected, + @NotNull MessagingRequestReplyException failure + ) { + synchronized (lock) { + if (pending.remove(requestId, expected)) { + expected.result.completeExceptionally(failure); + } + } + } + + private void receive(@NotNull MessagingRequestReplyMessage

message) { + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) { + accept(message); + return; + } + synchronized (lock) { + if (!closed && endpointId.equals(message.replyTo())) { + Pending call = pending.remove(message.requestId()); + if (call != null) { + if (call.remaining() <= 0) { + call.result.completeExceptionally( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); + } else if (message.type() == MessagingRequestReplyMessage.Type.RESPONSE) { + call.result.complete(message.payload()); + } else { + call.result.completeExceptionally(new MessagingRequestReplyException(Objects.requireNonNull(message.error()))); + } + } + } + } + } + + private void accept(@NotNull MessagingRequestReplyMessage

message) { + Incoming accepted = null; + MessagingRequestReplyMessage.Error rejection = MessagingRequestReplyMessage.Error.OVERLOADED; + synchronized (lock) { + if (closed) { + return; + } + RequestKey key = new RequestKey(message.replyTo(), message.requestId()); + if (incoming.containsKey(key)) { + return; + } + if (handler == null) { + rejection = MessagingRequestReplyMessage.Error.NO_HANDLER; + } else if (incoming.size() < options.maxConcurrentHandlers()) { + accepted = new Incoming(key, message, handler.requestHandler, generation); + incoming.put(key, accepted); + } + } + if (accepted != null) { + try { + transport.executeRequest(accepted); + return; + } catch (RejectedExecutionException e) { + accepted.task.cancel(false); + synchronized (lock) { + incoming.remove(accepted.key, accepted); + } + } + } + sendReply(message.failure(rejection)); + } + + private void sendReply(@NotNull MessagingRequestReplyMessage

message) { + try { + transport.send(message).whenComplete((ignored, error) -> { + if (error != null) { + log.log(Level.FINE, "Reply could not be sent"); + } + }); + } catch (RuntimeException e) { + log.log(Level.FINE, "Reply could not be sent"); + } + } + + private void disconnect() { + List stopped; + synchronized (lock) { + generation++; + failPending(MessagingRequestReplyException.Reason.DISCONNECTED); + stopped = List.copyOf(incoming.values()); + } + stopped.forEach(request -> request.task.cancel(true)); + } + + private void failPending(@NotNull MessagingRequestReplyException.Reason reason) { + pending.values().forEach(call -> call.result.completeExceptionally(new MessagingRequestReplyException(reason))); + pending.clear(); + } + + @Override + public void close() { + List stopped; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + handler = null; + failPending(MessagingRequestReplyException.Reason.CLOSED); + stopped = List.copyOf(incoming.values()); + } + stopped.forEach(request -> request.task.cancel(true)); + subscription.close(); + } + + private record RequestKey(@NotNull UUID replyTo, @NotNull UUID requestId) { + } + + private final class Pending { + private final long started; + private final long nanos; + private final CompletableFuture

result = new CompletableFuture<>(); + + private Pending(long started, long nanos) { + this.started = started; + this.nanos = nanos; + } + + private long remaining() { + return nanos - (nanoTime.getAsLong() - started); + } + } + + private final class HandlerRegistration implements MessagingSubscription { + private final MessagingRequestHandler

requestHandler; + + private HandlerRegistration(@NotNull MessagingRequestHandler

requestHandler) { + this.requestHandler = requestHandler; + } + + @Override + public void close() { + synchronized (lock) { + if (handler == this) { + handler = null; + } + } + } + } + + private final class Incoming implements Runnable { + private final RequestKey key; + private final MessagingRequestReplyMessage

message; + private final MessagingRequestHandler

requestHandler; + private final long acceptedGeneration; + private final FutureTask task; + + private Incoming( + @NotNull RequestKey key, + @NotNull MessagingRequestReplyMessage

message, + @NotNull MessagingRequestHandler

requestHandler, + long acceptedGeneration + ) { + this.key = key; + this.message = message; + this.requestHandler = requestHandler; + this.acceptedGeneration = acceptedGeneration; + this.task = new FutureTask<>(() -> { + process(); + return null; + }); + } + + @Override + public void run() { + try { + task.run(); + } finally { + synchronized (lock) { + incoming.remove(key, this); + } + } + } + + private void process() { + MessagingRequestReplyMessage

reply; + try { + reply = message.response(requestHandler.handle(Objects.requireNonNull(message.payload()))); + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + reply = message.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED); + } + synchronized (lock) { + if (closed || acceptedGeneration != generation) { + return; + } + incoming.remove(key, this); + } + sendReply(reply); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java new file mode 100644 index 0000000..c915310 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java @@ -0,0 +1,70 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public class MessagingRequestReplyException extends Exception { + public enum Reason { + CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, REMOTE_ERROR + } + + private final Reason reason; + @Nullable + private final MessagingRequestReplyMessage.Error remoteError; + + public MessagingRequestReplyException(@NotNull Reason reason) { + this(reason, null); + } + + public MessagingRequestReplyException(@NotNull Reason reason, @Nullable Throwable cause) { + super(message(Objects.requireNonNull(reason)), cause); + this.reason = reason; + this.remoteError = null; + } + + public MessagingRequestReplyException(@NotNull MessagingRequestReplyMessage.Error remoteError) { + super("Remote request failed: " + Objects.requireNonNull(remoteError)); + this.reason = Reason.REMOTE_ERROR; + this.remoteError = remoteError; + } + + @NotNull + public Reason reason() { + return reason; + } + + @Nullable + public MessagingRequestReplyMessage.Error remoteError() { + return remoteError; + } + + @NotNull + private static String message(@NotNull Reason reason) { + return switch (reason) { + case CLOSED -> "Request/reply endpoint is closed"; + case DISCONNECTED -> "Request/reply transport was disconnected"; + case OVERLOADED -> "Request/reply capacity is unavailable"; + case SEND_FAILED -> "Request/reply message could not be sent"; + case TIMEOUT -> "Request/reply deadline expired"; + case REMOTE_ERROR -> "Remote request failed"; + }; + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java new file mode 100644 index 0000000..5289c75 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java @@ -0,0 +1,72 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; +import java.util.UUID; + +public record MessagingRequestReplyMessage

( + @NotNull UUID requestId, + @NotNull UUID replyTo, + @NotNull Type type, + @Nullable P payload, + @Nullable Error error +) { + public enum Type { + REQUEST, RESPONSE, ERROR + } + + public enum Error { + NO_HANDLER, OVERLOADED, HANDLER_FAILED + } + + public MessagingRequestReplyMessage { + Objects.requireNonNull(requestId); + Objects.requireNonNull(replyTo); + Objects.requireNonNull(type); + if (type == Type.REQUEST && payload == null || type == Type.ERROR && (error == null || payload != null) + || type != Type.ERROR && error != null) { + throw new IllegalArgumentException("Invalid request/reply message"); + } + } + + @NotNull + public static

MessagingRequestReplyMessage

request(@NotNull UUID requestId, @NotNull UUID replyTo, @NotNull P payload) { + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.REQUEST, Objects.requireNonNull(payload), null); + } + + @NotNull + public MessagingRequestReplyMessage

response(@Nullable P result) { + requireRequest(); + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.RESPONSE, result, null); + } + + @NotNull + public MessagingRequestReplyMessage

failure(@NotNull Error reason) { + requireRequest(); + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.ERROR, null, Objects.requireNonNull(reason)); + } + + private void requireRequest() { + if (type != Type.REQUEST) { + throw new IllegalStateException("Only a request can be answered"); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java new file mode 100644 index 0000000..4c7a84e --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java @@ -0,0 +1,46 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; + +import java.time.Duration; +import java.util.Objects; + +public record MessagingRequestReplyOptions(@NotNull Duration timeout, int maxPendingRequests, int maxConcurrentHandlers) { + public static final MessagingRequestReplyOptions DEFAULT = new MessagingRequestReplyOptions(Duration.ofSeconds(30), 256, 64); + + public MessagingRequestReplyOptions { + requireTimeout(timeout); + if (maxPendingRequests < 1 || maxConcurrentHandlers < 1) { + throw new IllegalArgumentException("Request/reply limits must be positive"); + } + } + + static long requireTimeout(@NotNull Duration timeout) { + Objects.requireNonNull(timeout); + try { + long nanos = timeout.toNanos(); + if (nanos > 0) { + return nanos; + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Request/reply timeout is too large", e); + } + throw new IllegalArgumentException("Request/reply timeout must be positive"); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java new file mode 100644 index 0000000..e60ce9d --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java @@ -0,0 +1,42 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; + +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** Provides asynchronous request/reply message transport for a selected peer. */ +public interface MessagingRequestReplyTransport

{ + @NotNull + MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener

listener) throws MessagingRequestReplyException; + + @NotNull + CompletionStage send(@NotNull MessagingRequestReplyMessage

message); + + default void executeRequest(@NotNull Runnable request) { + request.run(); + } + + /** Receives endpoint messages and disconnection notifications. */ + interface Listener

{ + void onMessage(@NotNull MessagingRequestReplyMessage

message); + + void onDisconnect(); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java new file mode 100644 index 0000000..756a1b1 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +/** Controls the lifetime of a listener or handler registration. */ +@FunctionalInterface +public interface MessagingSubscription extends AutoCloseable { + @Override + void close(); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java new file mode 100644 index 0000000..c8e3741 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java @@ -0,0 +1,31 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.Type; + +/** Encodes and decodes declared RPC types using the payload representation accepted by the endpoint transport. */ +public interface MessagingRpcCodec

{ + @Nullable + P encode(@Nullable Object value, @NotNull Type type) throws Exception; + + @Nullable + Object decode(@Nullable P payload, @NotNull Type type) throws Exception; +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java new file mode 100644 index 0000000..b118095 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java @@ -0,0 +1,173 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +final class MessagingRpcContract { + private static final Logger log = Logger.getLogger(MessagingRpcContract.class.getName()); + private static final Map, Class> BOXED = Map.of( + boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, char.class, Character.class, + int.class, Integer.class, long.class, Long.class, float.class, Float.class, double.class, Double.class + ); + + private final Map operations; + + private MessagingRpcContract(@NotNull Map operations) { + this.operations = Collections.unmodifiableMap(new LinkedHashMap<>(operations)); + } + + @NotNull + static MessagingRpcContract create(@NotNull Class type) { + Objects.requireNonNull(type); + if (!type.isInterface() || !Modifier.isPublic(type.getModifiers()) || type.isSealed() || type.getTypeParameters().length != 0) { + throw new IllegalArgumentException("RPC contract must be a public, nonsealed interface with concrete types"); + } + Map> declarations = new LinkedHashMap<>(); + Set skipped = new HashSet<>(); + for (Method method : type.getMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isBridge() || method.isSynthetic() || isObjectMethod(method)) { + continue; + } + try { + validate(method); + } catch (IllegalArgumentException e) { + skipped.add(operationId(method)); + log.log(Level.WARNING, "Skipping unsupported RPC method " + method.toGenericString(), e); + continue; + } + declarations.computeIfAbsent(operationId(method), ignored -> new ArrayList<>()).add(method); + } + if (declarations.isEmpty()) { + throw new IllegalArgumentException("RPC contract must declare at least one usable operation"); + } + Map operations = new LinkedHashMap<>(); + declarations.forEach((id, methods) -> operations.put(id, selectOperation(methods))); + for (Method method : type.getMethods()) { + if (method.isBridge() && !operations.containsKey(operationId(method)) && !skipped.contains(operationId(method))) { + throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation"); + } + } + return new MessagingRpcContract<>(operations); + } + + void requireAccessible(@NotNull Object implementation) { + for (Method method : operations.values()) { + if (!method.canAccess(implementation) && !method.trySetAccessible()) { + throw new IllegalArgumentException("RPC contract operation is not accessible"); + } + } + } + + @Nullable + Method operation(@NotNull String id) { + return operations.get(id); + } + + @NotNull + static String operationId(@NotNull Method method) { + return method.getName() + Arrays.stream(method.getParameterTypes()) + .map(Class::getTypeName).collect(Collectors.joining(",", "(", ")")); + } + + static boolean accepts(@NotNull Class type, @Nullable Object value) { + return value == null ? !type.isPrimitive() : BOXED.getOrDefault(type, type).isInstance(value); + } + + static boolean isObjectMethod(@NotNull Method method) { + return switch (method.getName()) { + case "equals" -> Arrays.equals(method.getParameterTypes(), new Class[] {Object.class}); + case "hashCode", "toString" -> method.getParameterCount() == 0; + default -> false; + }; + } + + @NotNull + private static Method selectOperation(@NotNull List declarations) { + List effective = declarations.stream().filter(method -> declarations.stream().noneMatch(other -> + method.getDeclaringClass() != other.getDeclaringClass() + && method.getDeclaringClass().isAssignableFrom(other.getDeclaringClass()))).toList(); + Method selected = effective.getFirst(); + for (Method method : effective) { + if (selected.getReturnType() != method.getReturnType() && selected.getReturnType().isAssignableFrom(method.getReturnType()) + || selected.getReturnType() == method.getReturnType() && !Modifier.isPublic(selected.getDeclaringClass().getModifiers()) + && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + selected = method; + } + } + for (Method method : effective) { + if (!Arrays.equals(selected.getGenericParameterTypes(), method.getGenericParameterTypes()) + || !method.getReturnType().isAssignableFrom(selected.getReturnType()) + || selected.getReturnType() == method.getReturnType() + && !selected.getGenericReturnType().equals(method.getGenericReturnType())) { + throw new IllegalArgumentException("RPC contract must explicitly redeclare operations with ambiguous generic types"); + } + } + return selected; + } + + private static void validate(@NotNull Method method) { + if (method.isDefault() || method.getTypeParameters().length != 0 + || !Arrays.stream(method.getGenericParameterTypes()).allMatch(MessagingRpcContract::isConcrete) + || !isConcrete(method.getGenericReturnType()) + || CompletionStage.class.isAssignableFrom(method.getReturnType()) || Future.class.isAssignableFrom(method.getReturnType()) + || operationId(method).length() > 1024) { + throw new IllegalArgumentException( + "RPC operations must be abstract, synchronous methods with concrete argument and result types"); + } + } + + private static boolean isConcrete(@NotNull Type type) { + if (type instanceof Class) { + return true; + } + if (type instanceof ParameterizedType parameterized) { + return (parameterized.getOwnerType() == null || isConcrete(parameterized.getOwnerType())) + && Arrays.stream(parameterized.getActualTypeArguments()).allMatch(MessagingRpcContract::isConcrete); + } + if (type instanceof GenericArrayType array) { + return isConcrete(array.getGenericComponentType()); + } + if (type instanceof WildcardType wildcard) { + return Arrays.stream(wildcard.getUpperBounds()).allMatch(MessagingRpcContract::isConcrete) + && Arrays.stream(wildcard.getLowerBounds()).allMatch(MessagingRpcContract::isConcrete); + } + return false; + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java new file mode 100644 index 0000000..664f06d --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java @@ -0,0 +1,567 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class MessagingRpcEndpoint implements AutoCloseable { + private final State state; + + private MessagingRpcEndpoint(@NotNull State state) { + this.state = state; + } + + @NotNull + public static

MessagingRpcEndpoint open(@NotNull MessagingRpcTransport

transport) { + return open(transport, MessagingRequestReplyOptions.DEFAULT); + } + + @NotNull + public static

MessagingRpcEndpoint open(@NotNull MessagingRpcTransport

transport, @NotNull MessagingRpcCodec

defaultCodec) { + return open(transport, defaultCodec, MessagingRequestReplyOptions.DEFAULT); + } + + @NotNull + public static

MessagingRpcEndpoint open( + @NotNull MessagingRpcTransport

transport, + @NotNull MessagingRequestReplyOptions options + ) { + return new MessagingRpcEndpoint(new State<>(transport, null, options)); + } + + @NotNull + public static

MessagingRpcEndpoint open( + @NotNull MessagingRpcTransport

transport, + @NotNull MessagingRpcCodec

defaultCodec, + @NotNull MessagingRequestReplyOptions options + ) { + return new MessagingRpcEndpoint(new State<>(transport, Objects.requireNonNull(defaultCodec), options)); + } + + @NotNull + public MessagingSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { + return register(topic, type, implementation, state.defaultCodec()); + } + + @NotNull + public MessagingSubscription register( + @NotNull String topic, + @NotNull Class type, + @NotNull T implementation, + @NotNull MessagingRpcCodec codec + ) { + return state.register(topic, type, implementation, codec); + } + + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type) { + return proxy(topic, type, state.defaultCodec()); + } + + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type, @NotNull Duration timeout) { + return proxy(topic, type, state.defaultCodec(), timeout); + } + + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type, @NotNull MessagingRpcCodec codec) { + return proxy(topic, type, codec, state.options.timeout()); + } + + @NotNull + public T proxy( + @NotNull String topic, + @NotNull Class type, + @NotNull MessagingRpcCodec codec, + @NotNull Duration timeout + ) { + return state.proxy(topic, type, codec, timeout); + } + + public boolean isClosed() { + return state.isClosed(); + } + + public boolean isCloseComplete() { + return state.isCloseComplete(); + } + + @Override + public void close() { + state.close(); + } + + private static final class State

{ + private final Object lock = new Object(); + private final MessagingRpcTransport

transport; + @Nullable + private final MessagingRpcCodec

defaultCodec; + private final MessagingRequestReplyOptions options; + private final Map topics = new HashMap<>(); + private boolean closed; + private boolean closeComplete; + + private State( + @NotNull MessagingRpcTransport

transport, + @Nullable MessagingRpcCodec

defaultCodec, + @NotNull MessagingRequestReplyOptions options + ) { + this.transport = Objects.requireNonNull(transport); + this.defaultCodec = defaultCodec; + this.options = Objects.requireNonNull(options); + } + + @NotNull + private MessagingRpcCodec

defaultCodec() { + if (defaultCodec == null) { + throw new IllegalStateException("RPC endpoint has no default codec"); + } + return defaultCodec; + } + + @NotNull + private MessagingSubscription register( + @NotNull String topic, + @NotNull Class type, + @NotNull T implementation, + @NotNull MessagingRpcCodec codec + ) { + requireTopic(topic); + MessagingRpcCodec

bindingCodec = bindingCodec(codec); + MessagingRpcContract contract = MessagingRpcContract.create(type); + if (!type.isInstance(Objects.requireNonNull(implementation))) { + throw new IllegalArgumentException("RPC implementation does not implement its contract"); + } + contract.requireAccessible(implementation); + Topic selected = topic(topic); + synchronized (lock) { + requireOpen(); + if (selected.registration != null) { + throw new IllegalStateException("RPC topic already has a registered service"); + } + ServiceRegistration registration = new ServiceRegistration(selected, contract, implementation, bindingCodec); + selected.registration = registration; + return registration; + } + } + + @NotNull + private T proxy( + @NotNull String topic, + @NotNull Class type, + @NotNull MessagingRpcCodec codec, + @NotNull Duration timeout + ) { + requireTopic(topic); + requireTimeout(timeout); + MessagingRpcCodec

bindingCodec = bindingCodec(codec); + MessagingRpcContract contract = MessagingRpcContract.create(type); + Topic selected = topic(topic); + synchronized (lock) { + requireOpen(); + } + Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (instance, method, arguments) -> { + if (MessagingRpcContract.isObjectMethod(method)) { + return switch (method.getName()) { + case "equals" -> instance == Objects.requireNonNull(arguments)[0]; + case "hashCode" -> System.identityHashCode(instance); + case "toString" -> "RPC proxy[" + topic + ", " + type.getName() + "]"; + default -> throw new IllegalStateException("Unsupported proxy object method"); + }; + } + Method operation = contract.operation(MessagingRpcContract.operationId(method)); + if (operation == null) { + throw new IllegalArgumentException("Unsupported RPC operation: " + method.toGenericString()); + } + return invoke(selected, operation, arguments == null ? new Object[0] : arguments, bindingCodec, timeout); + }); + return type.cast(proxy); + } + + @Nullable + private Object invoke( + @NotNull Topic topic, + @NotNull Method operation, + @NotNull Object[] arguments, + @NotNull MessagingRpcCodec

codec, + @NotNull Duration timeout + ) { + synchronized (lock) { + requireOpen(); + } + if (Thread.currentThread().isInterrupted()) { + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED); + } + MessagingRpcMessage

request; + try { + Type[] types = operation.getGenericParameterTypes(); + List

encoded = new ArrayList<>(arguments.length); + for (int index = 0; index < arguments.length; index++) { + encoded.add(codec.encode(arguments[index], types[index])); + } + request = MessagingRpcMessage.request(MessagingRpcContract.operationId(operation), encoded); + } catch (Exception e) { + throw codecFailure(e); + } + MessagingRpcMessage

response; + try { + response = Objects.requireNonNull(topic.requests).request(request, timeout); + } catch (MessagingRequestReplyException e) { + throw new MessagingRpcException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e); + } + if (response == null || response.type() == MessagingRpcMessage.Type.REQUEST) { + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); + } + if (response.type() == MessagingRpcMessage.Type.ERROR) { + throw new MessagingRpcException(Objects.requireNonNull(response.error()), response.exception()); + } + if (operation.getReturnType() == void.class) { + if (response.payload() != null) { + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); + } + return null; + } + Object result; + try { + result = codec.decode(response.payload(), operation.getGenericReturnType()); + } catch (Exception e) { + throw codecFailure(e); + } + if (!MessagingRpcContract.accepts(operation.getReturnType(), result)) { + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); + } + return result; + } + + @NotNull + private MessagingRpcMessage

dispatch(@NotNull Topic topic, @NotNull MessagingRpcMessage

request) { + if (request.type() != MessagingRpcMessage.Type.REQUEST) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); + } + ServiceRegistration registration; + synchronized (lock) { + if (closed) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE); + } + registration = topic.registration; + } + if (registration == null) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE); + } + Method operation = registration.contract.operation(Objects.requireNonNull(request.operation())); + if (operation == null) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION); + } + if (request.arguments().size() != operation.getParameterCount()) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); + } + Object[] arguments = new Object[operation.getParameterCount()]; + Type[] types = operation.getGenericParameterTypes(); + Class[] parameterTypes = operation.getParameterTypes(); + try { + for (int index = 0; index < arguments.length; index++) { + Object argument = registration.codec.decode(request.arguments().get(index), types[index]); + if (!MessagingRpcContract.accepts(parameterTypes[index], argument)) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); + } + arguments[index] = argument; + } + } catch (Exception e) { + restoreInterruption(e); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e)); + } + Object result; + try { + result = operation.invoke(registration.implementation, arguments); + } catch (InvocationTargetException e) { + restoreInterruption(e.getCause()); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, + MessagingRpcExceptionDetails.capture(Objects.requireNonNullElse(e.getCause(), e))); + } catch (ReflectiveOperationException | RuntimeException e) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, MessagingRpcExceptionDetails.capture(e)); + } + if (operation.getReturnType() == void.class) { + return MessagingRpcMessage.response(null); + } + try { + return MessagingRpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType())); + } catch (Exception e) { + restoreInterruption(e); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e)); + } + } + + @NotNull + @SuppressWarnings("unchecked") + private MessagingRpcCodec

bindingCodec(@NotNull MessagingRpcCodec codec) { + return (MessagingRpcCodec

) Objects.requireNonNull(codec); + } + + private static void requireTopic(@NotNull String topic) { + Objects.requireNonNull(topic); + if (topic.isBlank() || topic.length() > 256) { + throw new IllegalArgumentException("Expected a nonblank RPC topic of at most 256 characters"); + } + } + + private static void requireTimeout(@NotNull Duration timeout) { + Objects.requireNonNull(timeout); + try { + if (timeout.toNanos() > 0) { + return; + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException("RPC timeout is too large", e); + } + throw new IllegalArgumentException("RPC timeout must be positive"); + } + + @NotNull + private Topic topic(@NotNull String name) { + Topic selected; + boolean opening = false; + synchronized (lock) { + requireOpen(); + selected = topics.get(name); + if (selected == null) { + selected = new Topic(name); + topics.put(name, selected); + opening = true; + } + } + if (opening) { + open(selected); + } + try { + Topic ready = selected.result.get(options.timeout().toNanos(), TimeUnit.NANOSECONDS); + synchronized (lock) { + requireOpen(); + } + return ready; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e); + } catch (TimeoutException e) { + throw new MessagingRpcException(MessagingRpcException.Reason.TIMEOUT, e); + } catch (ExecutionException e) { + if (e.getCause() instanceof MessagingRpcException failure) { + throw failure; + } + if (e.getCause() instanceof Error failure) { + throw failure; + } + throw new MessagingRpcException(MessagingRpcException.Reason.SEND_FAILED, e.getCause()); + } + } + + private void open(@NotNull Topic topic) { + synchronized (lock) { + if (closed) { + finish(topic); + return; + } + } + MessagingRequestReplyEndpoint> requests = null; + try { + requests = MessagingRequestReplyEndpoint.open(Objects.requireNonNull(transport.forTopic(topic.name)), options); + requests.handle(request -> dispatch(topic, request)); + synchronized (lock) { + topic.requests = requests; + topic.opening = false; + if (!closed) { + topic.result.complete(topic); + return; + } + } + } catch (Throwable e) { + if (requests != null) { + try { + requests.close(); + } catch (RuntimeException | Error closeFailure) { + if (closeFailure != e) { + e.addSuppressed(closeFailure); + } + } + } + synchronized (lock) { + finish(topic); + topic.result.completeExceptionally( + e instanceof MessagingRequestReplyException failure ? new MessagingRpcException(failure) : e); + } + return; + } + try { + close(topic); + } catch (RuntimeException e) { + throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED, e); + } + } + + private void finish(@NotNull Topic topic) { + topics.remove(topic.name, topic); + closeComplete = closed && topics.isEmpty(); + } + + @NotNull + private static MessagingRpcException codecFailure(@NotNull Exception cause) { + restoreInterruption(cause); + MessagingRpcException.Reason reason = cause instanceof InterruptedException + ? MessagingRpcException.Reason.INTERRUPTED : MessagingRpcException.Reason.CODEC_FAILED; + return new MessagingRpcException(reason, cause); + } + + private static void restoreInterruption(@Nullable Throwable failure) { + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + + private void requireOpen() { + if (closed) { + throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED); + } + } + + private boolean isClosed() { + synchronized (lock) { + return closed; + } + } + + private boolean isCloseComplete() { + synchronized (lock) { + return closeComplete; + } + } + + private void close() { + List stopped; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + stopped = topics.values().stream().filter(topic -> !topic.opening).toList(); + topics.values().forEach(topic -> { + topic.registration = null; + topic.result.completeExceptionally(new MessagingRpcException(MessagingRpcException.Reason.CLOSED)); + }); + closeComplete = topics.isEmpty(); + } + Throwable failure = null; + for (Topic topic : stopped) { + try { + close(topic); + } catch (RuntimeException | Error e) { + if (failure == null) { + failure = e; + } else if (failure != e) { + failure.addSuppressed(e); + } + } + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + } + + private void close(@NotNull Topic topic) { + synchronized (lock) { + if (topic.closing) { + return; + } + topic.closing = true; + } + try { + Objects.requireNonNull(topic.requests).close(); + } finally { + synchronized (lock) { + finish(topic); + } + } + } + + private final class Topic { + private final String name; + private final CompletableFuture result = new CompletableFuture<>(); + @Nullable + private MessagingRequestReplyEndpoint> requests; + @Nullable + private ServiceRegistration registration; + private boolean opening = true; + private boolean closing; + + private Topic(@NotNull String name) { + this.name = name; + } + } + + private final class ServiceRegistration implements MessagingSubscription { + private final Topic topic; + private final MessagingRpcContract contract; + private final Object implementation; + private final MessagingRpcCodec

codec; + + private ServiceRegistration( + @NotNull Topic topic, + @NotNull MessagingRpcContract contract, + @NotNull Object implementation, + @NotNull MessagingRpcCodec

codec + ) { + this.topic = topic; + this.contract = contract; + this.implementation = implementation; + this.codec = codec; + } + + @Override + public void close() { + synchronized (lock) { + if (topic.registration == this) { + topic.registration = null; + } + } + } + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java new file mode 100644 index 0000000..c90341e --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java @@ -0,0 +1,93 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public class MessagingRpcException extends RuntimeException { + public enum Reason { + CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, INTERRUPTED, CODEC_FAILED, INVALID_RESPONSE, REMOTE_ERROR + } + + private final Reason reason; + @Nullable + private final MessagingRpcMessage.Error remoteError; + @Nullable + private final MessagingRpcExceptionDetails remoteException; + + public MessagingRpcException(@NotNull Reason reason) { + this(reason, null); + } + + public MessagingRpcException(@NotNull Reason reason, @Nullable Throwable cause) { + super("RPC call failed: " + Objects.requireNonNull(reason), cause); + this.reason = reason; + this.remoteError = null; + this.remoteException = null; + } + + public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError) { + this(remoteError, null); + } + + public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError, @Nullable MessagingRpcExceptionDetails remoteException) { + super("Remote RPC call failed: " + Objects.requireNonNull(remoteError) + + (remoteException == null ? "" : ": " + remoteException.type() + + (remoteException.message() == null ? "" : ": " + remoteException.message())), + remoteException == null ? null : new RemoteException(remoteException)); + this.reason = Reason.REMOTE_ERROR; + this.remoteError = remoteError; + this.remoteException = remoteException; + } + + MessagingRpcException(@NotNull MessagingRequestReplyException cause) { + this(switch (cause.reason()) { + case CLOSED -> Reason.CLOSED; + case DISCONNECTED -> Reason.DISCONNECTED; + case OVERLOADED -> Reason.OVERLOADED; + case SEND_FAILED -> Reason.SEND_FAILED; + case TIMEOUT -> Reason.TIMEOUT; + case REMOTE_ERROR -> Reason.REMOTE_ERROR; + }, cause); + } + + @NotNull + public Reason reason() { + return reason; + } + + @Nullable + public MessagingRpcMessage.Error remoteError() { + return remoteError; + } + + @Nullable + public MessagingRpcExceptionDetails remoteException() { + return remoteException; + } + + private static final class RemoteException extends Exception { + private RemoteException(@NotNull MessagingRpcExceptionDetails details) { + super("Remote stack trace" + (details.truncated() ? " (truncated)" : "") + ":\n" + details.stackTrace(), + null, false, false); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java new file mode 100644 index 0000000..ddcae1e --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java @@ -0,0 +1,124 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.io.PrintWriter; +import java.io.Writer; +import java.util.Objects; + +public record MessagingRpcExceptionDetails( + @NotNull String type, + @Nullable String message, + @NotNull String stackTrace, + boolean truncated +) { + public static final int MAX_TYPE_LENGTH = 256; + public static final int MAX_MESSAGE_LENGTH = 1024; + public static final int MAX_STACK_TRACE_LENGTH = 8192; + + public MessagingRpcExceptionDetails { + Objects.requireNonNull(type); + Objects.requireNonNull(stackTrace); + if (type.isBlank() || type.length() > MAX_TYPE_LENGTH || message != null && message.length() > MAX_MESSAGE_LENGTH + || stackTrace.length() > MAX_STACK_TRACE_LENGTH) { + throw new IllegalArgumentException("Invalid RPC exception details"); + } + } + + @NotNull + static MessagingRpcExceptionDetails capture(@NotNull Throwable failure) { + String originalType = Objects.requireNonNull(failure).getClass().getName(); + String originalMessage; + boolean incomplete = false; + try { + originalMessage = failure.getMessage(); + } catch (RuntimeException e) { + originalMessage = null; + incomplete = true; + } + String type = prefix(originalType, MAX_TYPE_LENGTH); + String message = originalMessage == null ? null : prefix(originalMessage, MAX_MESSAGE_LENGTH); + TraceWriter trace = new TraceWriter(); + try { + failure.printStackTrace(new PrintWriter(trace)); + } catch (TraceLimit e) { + incomplete = true; + } catch (RuntimeException e) { + incomplete = true; + } + String stackTrace = trace.text.toString(); + if (incomplete && !stackTrace.isEmpty() && Character.isHighSurrogate(stackTrace.charAt(stackTrace.length() - 1))) { + stackTrace = stackTrace.substring(0, stackTrace.length() - 1); + } + if (stackTrace.isEmpty()) { + stackTrace = type + (message == null ? "" : ": " + message); + } + return new MessagingRpcExceptionDetails(type, message, stackTrace, + incomplete || !type.equals(originalType) || !Objects.equals(message, originalMessage)); + } + + @NotNull + private static String prefix(@NotNull String value, int limit) { + if (value.length() <= limit) { + return value; + } + int end = Character.isHighSurrogate(value.charAt(limit - 1)) && Character.isLowSurrogate(value.charAt(limit)) + ? limit - 1 : limit; + return value.substring(0, end); + } + + private static final class TraceWriter extends Writer { + private final StringBuilder text = new StringBuilder(); + + @Override + public void write(@NotNull char[] value, int offset, int length) { + int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length()); + text.append(value, offset, count); + requireComplete(length, count); + } + + @Override + public void write(@NotNull String value, int offset, int length) { + int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length()); + text.append(value, offset, offset + count); + requireComplete(length, count); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + private static void requireComplete(int length, int count) { + if (length != count) { + throw new TraceLimit(); + } + } + } + + private static final class TraceLimit extends RuntimeException { + private TraceLimit() { + super(null, null, false, false); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java new file mode 100644 index 0000000..71bbe65 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java @@ -0,0 +1,78 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public record MessagingRpcMessage

( + @NotNull Type type, + @Nullable String operation, + @NotNull List

arguments, + @Nullable P payload, + @Nullable Error error, + @Nullable MessagingRpcExceptionDetails exception +) { + public enum Type { + REQUEST, RESPONSE, ERROR + } + + public enum Error { + UNKNOWN_SERVICE, UNKNOWN_OPERATION, INVALID_REQUEST, HANDLER_FAILED, CODEC_FAILED + } + + public MessagingRpcMessage { + Objects.requireNonNull(type); + arguments = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(arguments))); + if (exception != null && type != Type.ERROR) { + throw new IllegalArgumentException("RPC exception details require an error response"); + } + if (type == Type.REQUEST) { + if (operation == null || operation.isBlank() || operation.length() > 1024 || payload != null || error != null) { + throw new IllegalArgumentException("Invalid RPC request"); + } + } else if (operation != null || !arguments.isEmpty() || type == Type.ERROR && (error == null || payload != null) + || type == Type.RESPONSE && error != null) { + throw new IllegalArgumentException("Invalid RPC response"); + } + } + + @NotNull + public static

MessagingRpcMessage

request(@NotNull String operation, @NotNull List

arguments) { + return new MessagingRpcMessage<>(Type.REQUEST, operation, arguments, null, null, null); + } + + @NotNull + public static

MessagingRpcMessage

response(@Nullable P payload) { + return new MessagingRpcMessage<>(Type.RESPONSE, null, List.of(), payload, null, null); + } + + @NotNull + public static

MessagingRpcMessage

failure(@NotNull Error error) { + return failure(error, null); + } + + @NotNull + public static

MessagingRpcMessage

failure(@NotNull Error error, @Nullable MessagingRpcExceptionDetails exception) { + return new MessagingRpcMessage<>(Type.ERROR, null, List.of(), null, Objects.requireNonNull(error), exception); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java new file mode 100644 index 0000000..d5f6735 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java @@ -0,0 +1,28 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import org.jkiss.code.NotNull; + +/** Opens topic-scoped RPC transports for a selected peer. */ +@FunctionalInterface +public interface MessagingRpcTransport

{ + @NotNull + MessagingRequestReplyTransport> forTopic(@NotNull String topic) throws MessagingRequestReplyException; +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java new file mode 100644 index 0000000..a4a13bd --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java @@ -0,0 +1,93 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDRemoteDamApiTest { + @Test + void acceptsSubscriberIdsWithSubscriptionMetadataCharactersAndLengthBoundaries() { + for (String subscriberId : new String[] {"s", "service:account", "service account", "s".repeat(129), "s".repeat(256)}) { + Assertions.assertTrue(DDRemoteDamApi.isValidSubscriberId(subscriberId)); + } + } + + @Test + void rejectsMissingBlankAndOversizedSubscriberIds() { + for (String subscriberId : new String[] {null, "", " ", "\t\n", "s".repeat(257)}) { + Assertions.assertFalse(DDRemoteDamApi.isValidSubscriberId(subscriberId)); + } + } + + @Test + void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() { + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("!".repeat(32))); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("~".repeat(512))); + StringBuilder printable = new StringBuilder(); + for (char value = 33; value <= 126; value++) { + printable.append(value); + } + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret(printable.toString())); + } + + @Test + void rejectsMissingShortAndOversizedSecrets() { + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret(null)); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("")); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31))); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(513))); + } + + @Test + void rejectsSpacesControlCharactersAndNonAsciiSecrets() { + for (String invalid : new String[] {" ", "\t", "\r", "\n", "\0", "\u007f", "\u0080", "é"}) { + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31) + invalid)); + } + } + + @Test + void acceptsHttpAndHttpsServiceOrigins() { + for (String url : new String[] { + "http://localhost", "http://localhost/", "https://example.com", "HTTPS://example.com/", + "http://127.0.0.1:1", "https://example.com:65535/", "http://[::1]", "https://[::1]:8443/" + }) { + Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(url)); + } + } + + @Test + void rejectsMalformedServiceOrigins() { + for (String url : new String[] { + null, "", " ", "/relative", "example.com", "//example.com", "http://", "http:///", "http://bad host", + "ftp://example.com", "ws://example.com", "wss://example.com", "https://user@example.com", + "https://user:password@example.com", "https://example.com?", "https://example.com?token=value", + "https://example.com#", "https://example.com#fragment", "https://example.com/path", "https://example.com//", + "https://example.com/%2f", "https://example.com:0", "https://example.com:65536", "https://example.com:-1", + "https://example.com:port", "http://[::1", "http://example.com/\n" + }) { + Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(url)); + } + } + + @Test + void boundsServiceUrlLength() { + String prefix = "https://"; + Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java new file mode 100644 index 0000000..8c3118c --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java @@ -0,0 +1,187 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DDRemoteDamProtocolTest { + @Test + void roundTripsApplicationMessagesWithTopicTypeAndPayload() { + JsonObject payload = new JsonObject(); + payload.addProperty("projectId", "project-1"); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "project.updated", payload); + String encoded = DDRemoteDamProtocol.encode(message); + + assertEquals(JsonParser.parseString(""" + {"topic":"projects","type":"project.updated","payload":{"projectId":"project-1"}} + """), JsonParser.parseString(encoded)); + assertEquals(message, DDRemoteDamProtocol.decode(encoded)); + } + + @Test + void roundTripsHandshakeRequestWithoutAccountCredentialsOrProtocolVersion() { + DDRemoteDamHandshakeRequest hello = new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4"); + String encoded = DDRemoteDamProtocol.encodeHandshakeRequest(hello); + JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); + + assertEquals(hello, DDRemoteDamProtocol.decodeHandshakeRequest(encoded)); + assertEquals("hello", envelope.get("type").getAsString()); + assertEquals(2, envelope.size()); + assertEquals(4, envelope.getAsJsonObject("payload").size()); + assertFalse(encoded.contains("accountId")); + assertFalse(encoded.contains("accessToken")); + assertFalse(encoded.contains("protocolVersion")); + } + + @Test + void roundTripsHandshakeResponseWithOnlyTheAssignedSessionId() { + DDRemoteDamHandshakeResponse ready = new DDRemoteDamHandshakeResponse("session-1"); + String encoded = DDRemoteDamProtocol.encodeHandshakeResponse(ready); + JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); + + assertEquals(ready, DDRemoteDamProtocol.decodeHandshakeResponse(encoded)); + assertEquals("ready", envelope.get("type").getAsString()); + assertEquals(1, envelope.getAsJsonObject("payload").size()); + } + + @Test + void keepsControlMessagesSeparateFromTopicMessages() { + String hello = DDRemoteDamProtocol.encodeHandshakeRequest( + new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")); + String ready = DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1")); + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(hello)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(ready)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(ready)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(hello)); + String event = """ + {"topic":"projects","type":"project.updated","payload":{}} + """; + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(event)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(event)); + } + + @Test + void rejectsControlMessagesWithAnyTopicField() { + for (String control : List.of( + DDRemoteDamProtocol.encodeHandshakeRequest(new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")), + DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1")) + )) { + for (String topic : List.of("\"projects\"", "null", "\"\"", "\" \"", "42", "true", "[]", "{}")) { + JsonObject envelope = JsonParser.parseString(control).getAsJsonObject(); + envelope.add("topic", JsonParser.parseString(topic)); + String message = envelope.toString(); + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(message)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message)); + } + } + } + + @Test + void allowsControlTypeNamesInApplicationMessages() { + for (String type : List.of(DDRemoteDamProtocol.TYPE_HANDSHAKE_REQUEST, DDRemoteDamProtocol.TYPE_HANDSHAKE_RESPONSE)) { + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", type, new JsonObject()); + + assertEquals(message, DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + } + } + + @Test + void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() { + DDRemoteDamMessage message = DDRemoteDamProtocol.decode(""" + {"topic":"future","type":"future.event","payload":{"value":42},"additional":true} + """); + + assertEquals("future", message.topic()); + assertEquals("future.event", message.type()); + assertEquals(42, message.payload().get("value").getAsInt()); + } + + @Test + void rejectsMalformedEnvelopesAndMissingFields() { + for (String message : List.of( + "", "null", "[]", "true", "42", "{", "{}", + "{\"topic\":\"projects\",\"type\":\"updated\"}", + "{\"topic\":\"projects\",\"payload\":{}}", + "{\"type\":\"updated\",\"payload\":{}}" + )) { + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message), message); + } + } + + @Test + void rejectsNonStringAndBlankTopicAndTypeFields() { + for (String field : List.of("topic", "type")) { + for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \"")) { + JsonObject envelope = JsonParser.parseString(""" + {"topic":"projects","type":"updated","payload":{}} + """).getAsJsonObject(); + envelope.add(field, JsonParser.parseString(value)); + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(envelope.toString())); + } + } + } + + @Test + void rejectsNonObjectPayloads() { + for (String payload : List.of("null", "true", "42", "[]", "\"value\"")) { + String message = "{\"topic\":\"projects\",\"type\":\"updated\",\"payload\":" + payload + "}"; + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message)); + } + } + + @Test + void rejectsMissingAndIncorrectlyTypedHandshakeRequestFields() { + String valid = DDRemoteDamProtocol.encodeHandshakeRequest( + new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")); + for (String field : List.of("appDeploymentId", "appInstanceId", "product", "version")) { + JsonObject missing = JsonParser.parseString(valid).getAsJsonObject(); + missing.getAsJsonObject("payload").remove(field); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(missing.toString())); + + for (String value : List.of("null", "true", "42", "[]", "{}", "\" \"")) { + JsonObject invalid = JsonParser.parseString(valid).getAsJsonObject(); + invalid.getAsJsonObject("payload").add(field, JsonParser.parseString(value)); + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(invalid.toString())); + } + } + } + + @Test + void rejectsHandshakeResponseWithoutAStringSessionId() { + for (String payload : List.of("{}", "{\"damSessionId\":null}", "{\"damSessionId\":42}", "{\"damSessionId\":\" \"}")) { + String message = "{\"type\":\"ready\",\"payload\":" + payload + "}"; + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message)); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java new file mode 100644 index 0000000..1b8ab2e --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java @@ -0,0 +1,389 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.client; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class DDRemoteDamCallbackClientTest { + private static final String SECRET = "remote-dam-callback-secret-2026-test"; + private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); + private static final UUID ACCOUNT_ID = UUID.randomUUID(); + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket"); + private static final DDRemoteDamMessage MESSAGE = new DDRemoteDamMessage("projects", "updated", + JsonParser.parseString(""" + {"nested":{"value":42},"nothing":null,"integer":9007199254740993, + "decimal":1.2345678901234567890123456789,"text":"Привет"} + """).getAsJsonObject()); + private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverRemoteDamMessage\":true}}"); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicInteger responseDelay = new AtomicInteger(); + private final AtomicReference request = new AtomicReference<>(); + private final CountDownLatch requestReceived = new CountDownLatch(1); + private final AtomicReference responseGate = new AtomicReference<>(); + private HttpServer server; + private DDRemoteDamCallbackClient client; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + client = new DDRemoteDamCallbackClient(url(), SECRET); + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws Exception { + for (String suffix : List.of("", "/")) { + client = new DDRemoteDamCallbackClient(url() + suffix, SECRET); + deliver(); + CapturedRequest synchronous = request.get(); + assertNull(deliverAsync().toCompletableFuture().get(5, TimeUnit.SECONDS)); + CapturedRequest captured = request.get(); + assertEquals(synchronous, captured); + assertEquals("POST", captured.method()); + assertEquals("/internal/remote-dam/graphql", captured.path()); + assertEquals(SECRET, captured.secret()); + assertEquals("application/json", captured.contentType()); + assertEquals("no-store", captured.cacheControl()); + JsonObject body = captured.body(); + assertEquals("DeliverRemoteDamMessage", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("$session: RemoteDamSessionInfoInput!")); + assertTrue(query.contains("$message: RemoteDamMessageInput!")); + assertTrue(query.contains("deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, " + + "session: $session, message: $message)")); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("subscriptionId", "accountId", "session", "message"), variables.keySet()); + assertEquals(SUBSCRIPTION_ID.toString(), variables.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); + assertEquals(Set.of("appDeploymentId", "appInstanceId", "damSessionId"), variables.getAsJsonObject("session").keySet()); + assertEquals("socket", variables.getAsJsonObject("session").get("damSessionId").getAsString()); + assertEquals(MESSAGE.payload(), variables.getAsJsonObject("message").get("payload")); + JsonObject payload = variables.getAsJsonObject("message").getAsJsonObject("payload"); + assertEquals("9007199254740993", payload.get("integer").getAsString()); + assertEquals("1.2345678901234567890123456789", payload.get("decimal").getAsString()); + assertTrue(payload.get("nothing").isJsonNull()); + assertFalse(body.toString().contains(SECRET)); + } + assertEquals(4, requestCount.get()); + } + + @Test + void returnsBeforeTheCallbackHandlerCompletesAndWaitsForItsResponse() throws Exception { + CountDownLatch release = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + AtomicReference> stage = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + responseGate.set(release); + Thread caller = new Thread(() -> { + try { + stage.set(deliverAsync()); + } catch (Throwable e) { + failure.set(e); + } finally { + returned.countDown(); + } + }); + caller.start(); + try { + assertTrue(requestReceived.await(5, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertNotNull(stage.get()); + assertFalse(stage.get().toCompletableFuture().isDone()); + release.countDown(); + assertNull(stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(1, requestCount.get()); + } finally { + release.countDown(); + caller.join(5000); + } + } + + @Test + void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws Exception { + responseDelay.set(5500); + + assertNull(deliverAsync().toCompletableFuture().get(8, TimeUnit.SECONDS)); + + assertEquals(1, requestCount.get()); + } + + @Test + void rejectsFalseAcknowledgementsAsProtocolFailures() { + response.set("{\"data\":{\"deliverRemoteDamMessage\":false}}"); + assertFailureOnce(); + DDRemoteDamException failure = assertAsyncFailureOnce(); + assertEquals("Remote DAM callback was not accepted", failure.getMessage()); + } + + @Test + void rejectsMalformedEnvelopesPartialErrorsAndNonBooleanResultsWithoutRetries() { + for (String body : List.of("", "{", "null", "[]", "{}", "{\"data\":null}", "{\"data\":{}}", + "{\"data\":{\"deliverRemoteDamMessage\":null}}", "{\"data\":{\"deliverRemoteDamMessage\":\"true\"}}", + "{\"data\":{\"deliverRemoteDamMessage\":1}}", "{\"errors\":null}", "{\"" + SECRET + "\":[", + "{\"data\":{\"deliverRemoteDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) { + response.set(body); + assertFailureOnce(); + assertAsyncFailureOnce(); + } + } + + @Test + void rejectsHttpFailuresAndNeverFollowsRedirects() { + for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) { + status.set(code); + assertFailureOnce(); + DDRemoteDamException failure = assertAsyncFailureOnce(); + assertEquals("Remote DAM request failed: HTTP " + code, failure.getMessage()); + } + } + + @Test + void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() { + responseDelay.set(1000); + client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + assertFailureOnce(); + } + + @Test + void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() { + responseDelay.set(1000); + client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + + DDRemoteDamException failure = assertAsyncFailureOnce(); + + assertEquals("Remote DAM service request failed", failure.getMessage()); + assertInstanceOf(HttpTimeoutException.class, failure.getCause()); + } + + @Test + void boundsTheFullAsyncExchangeWhenHeadersArriveButTheBodyStalls() throws Exception { + assertStalledBodyIsCancelled(false); + } + + @Test + void cancelsTheUnderlyingBodyReadWhenTheCallerCancelsDelivery() throws Exception { + assertStalledBodyIsCancelled(true); + } + + @Test + void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() { + assertThrows(NullPointerException.class, () -> client.deliverAsync(null, ACCOUNT_ID, SESSION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, null, SESSION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, null, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, null)); + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() { + for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment", + "http://user:password@127.0.0.1", "ftp://127.0.0.1", "http://127.0.0.1:65536")) { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(origin, SECRET)); + } + for (String secret : List.of("", "short", " ".repeat(32), "x".repeat(31) + "\n")) { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), secret)); + } + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), SECRET, Duration.ZERO)); + assertEquals(0, requestCount.get()); + } + + private void deliver() throws DDRemoteDamException { + client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE); + } + + @NotNull + private CompletionStage deliverAsync() { + return client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE); + } + + private void assertFailureOnce() { + int before = requestCount.get(); + DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, this::deliver); + assertFalse(failure.toString().contains(SECRET)); + assertEquals(before + 1, requestCount.get()); + } + + @NotNull + private DDRemoteDamException assertAsyncFailureOnce() { + int before = requestCount.get(); + CompletionStage stage = deliverAsync(); + ExecutionException completion = assertThrows(ExecutionException.class, + () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SECRET)); + assertEquals(before + 1, requestCount.get()); + return failure; + } + + private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Exception { + CountDownLatch headersSent = new CountDownLatch(1); + CountDownLatch resumeBody = new CountDownLatch(1); + CountDownLatch probeFinished = new CountDownLatch(1); + AtomicReference bodyFailure = new AtomicReference<>(); + server.createContext(DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + requestCount.incrementAndGet(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write("{\"data\":{\"deliverRemoteDamMessage\":".getBytes(StandardCharsets.UTF_8)); + exchange.getResponseBody().flush(); + headersSent.countDown(); + if (!resumeBody.await(5, TimeUnit.SECONDS)) { + return; + } + byte[] padding = " ".repeat(8192).getBytes(StandardCharsets.UTF_8); + for (int i = 0; i < 128; i++) { + exchange.getResponseBody().write(padding); + exchange.getResponseBody().flush(); + Thread.sleep(10); + } + } catch (IOException e) { + bodyFailure.set(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + probeFinished.countDown(); + } + }); + client = new DDRemoteDamCallbackClient(url(), SECRET, + cancelDelivery ? Duration.ofSeconds(5) : Duration.ofSeconds(1)); + CompletableFuture delivery = deliverAsync().toCompletableFuture(); + try { + assertTrue(headersSent.await(3, TimeUnit.SECONDS)); + if (cancelDelivery) { + assertTrue(delivery.cancel(true)); + assertTrue(delivery.isCancelled()); + assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS)); + } else { + ExecutionException completion = assertThrows(ExecutionException.class, + () -> delivery.get(3, TimeUnit.SECONDS)); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); + assertEquals("Remote DAM service request failed", failure.getMessage()); + assertInstanceOf(HttpTimeoutException.class, failure.getCause()); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SECRET)); + } + resumeBody.countDown(); + assertTrue(probeFinished.await(5, TimeUnit.SECONDS)); + assertNotNull(bodyFailure.get()); + assertEquals(1, requestCount.get()); + if (cancelDelivery) { + assertTrue(delivery.isCancelled()); + assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS)); + } + } finally { + resumeBody.countDown(); + delivery.cancel(true); + } + } + + @NotNull + private String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(@NotNull HttpExchange exchange) throws IOException { + try { + request.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().toString(), + exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN), + exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"), + JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject())); + requestCount.incrementAndGet(); + requestReceived.countDown(); + CountDownLatch gate = responseGate.get(); + if (gate != null && !gate.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release the response"); + } + if (responseDelay.get() > 0) { + Thread.sleep(responseDelay.get()); + } + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Location", url() + "/redirected"); + exchange.sendResponseHeaders(status.get(), bytes.length); + exchange.getResponseBody().write(bytes); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + } + + private record CapturedRequest( + @NotNull String method, + @NotNull String path, + @Nullable String secret, + @Nullable String contentType, + @Nullable String cacheControl, + @NotNull JsonObject body + ) { + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java new file mode 100644 index 0000000..18c433b --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java @@ -0,0 +1,1014 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.client; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +class DDRemoteDamClientTest { + private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); + private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); + private static final String SERVICE_SECRET = "remote-dam-service-secret-2026-test"; + private static final String CALLBACK_URL = "https://callback.example.com"; + private static final String CALLBACK_SECRET = "remote-dam-callback-secret-2026-test"; + private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; + private static final String SESSIONS_FIELD = "remoteDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount"; + private static final String SEND_FIELD = "sendRemoteDamMessage"; + private final AtomicReference responseJson = new AtomicReference<>("{}"); + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference redirect = new AtomicReference<>(); + private final AtomicBoolean truncateResponse = new AtomicBoolean(); + private final AtomicReference latestRequest = new AtomicReference<>(); + private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicReference handlerFailure = new AtomicReference<>(); + private final AtomicReference requestReceived = new AtomicReference<>(); + private final AtomicReference responseGate = new AtomicReference<>(); + private HttpServer server; + private DDRemoteDamClient client; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handleRequest); + server.start(); + client = new DDRemoteDamClient(serviceUrl(), SERVICE_SECRET); + } + + @AfterEach + void stop() { + if (server != null) { + server.stop(0); + } + assertNull(handlerFailure.get()); + } + + @Test + void postsSessionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDRemoteDamException { + respond(SESSIONS_FIELD, new JsonArray()); + for (String suffix : List.of("", "/")) { + var configured = new DDRemoteDamClient(serviceUrl() + suffix, SERVICE_SECRET); + + assertEquals(List.of(), configured.getSessions(ACCOUNT_ID)); + + JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); + assertEquals("RemoteDamSessions", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("query RemoteDamSessions($accountId: ID!)")); + assertTrue(query.contains("remoteDamSessions(accountId: $accountId)")); + for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) { + assertTrue(query.contains(field), field); + } + assertEquals(Set.of("accountId"), body.getAsJsonObject("variables").keySet()); + assertEquals(ACCOUNT_ID.toString(), body.getAsJsonObject("variables").get("accountId").getAsString()); + assertFalse(query.contains(ACCOUNT_ID.toString())); + } + assertEquals(2, requestCount.get()); + } + + @Test + void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() throws Exception { + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + respond(SESSIONS_FIELD, sessions); + + List result = awaitDelayedResponse(() -> client.getSessionsAsync(ACCOUNT_ID)); + + assertEquals(List.of(new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4")), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); + assertEquals("RemoteDamSessions", posted().get("operationName").getAsString()); + assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); + + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\"")); + assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getSessionAccountAsync("session-1"))); + assertEquals("RemoteDamSessionAccount", posted().get("operationName").getAsString()); + assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); + + respond(SEND_FIELD, JsonParser.parseString("false")); + assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()))); + assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString()); + assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); + assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); + + UUID subscriptionId = UUID.randomUUID(); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); + assertEquals(60, awaitDelayedResponse(() -> client.registerSubscriptionAsync("service account", subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)).intValue()); + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("RegisterRemoteDamSubscription", posted().get("operationName").getAsString()); + assertEquals("service account", input.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); + assertEquals("projects", input.get("topic").getAsString()); + assertEquals(CALLBACK_URL, input.get("callbackUrl").getAsString()); + assertEquals(CALLBACK_SECRET, input.get("callbackSecret").getAsString()); + assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull()); + + respond("unregisterRemoteDamSubscription", JsonParser.parseString("false")); + assertFalse(awaitDelayedResponse(() -> client.unregisterSubscriptionAsync("service account", subscriptionId))); + assertEquals("UnregisterRemoteDamSubscription", posted().get("operationName").getAsString()); + assertEquals(Set.of("subscriberId", "subscriptionId"), posted().getAsJsonObject("variables").keySet()); + assertEquals("service account", posted().getAsJsonObject("variables").get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), posted().getAsJsonObject("variables").get("subscriptionId").getAsString()); + assertEquals(5, requestCount.get()); + } + + @Test + void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { + JsonObject payload = JsonParser.parseString(""" + {"integer":9007199254740993,"decimal":1.2345678901234567890123456789, + "nothing":null,"nested":[null,{"text":"Привет"}]} + """).getAsJsonObject(); + DDRemoteDamMessage message = new DDRemoteDamMessage("topic-\"\\\n", "type-\"\\\n", payload); + respond(SEND_FIELD, JsonParser.parseString("true")); + + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS)); + + JsonObject variables = posted().getAsJsonObject("variables"); + assertEquals("session-\"\\\n", variables.get("damSessionId").getAsString()); + JsonObject sent = variables.getAsJsonObject("message"); + assertEquals(message.topic(), sent.get("topic").getAsString()); + assertEquals(message.type(), sent.get("type").getAsString()); + assertEquals(payload, sent.getAsJsonObject("payload")); + assertEquals("9007199254740993", sent.getAsJsonObject("payload").get("integer").getAsString()); + assertEquals("1.2345678901234567890123456789", sent.getAsJsonObject("payload").get("decimal").getAsString()); + assertTrue(sent.getAsJsonObject("payload").get("nothing").isJsonNull()); + } + + @Test + void resolvesTheSessionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDRemoteDamException { + String damSessionId = "session-\"\\\n) { injectedSession }"; + for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\"")); + + assertEquals(accountId, client.getSessionAccount(damSessionId)); + + JsonObject body = posted(); + assertEquals("RemoteDamSessionAccount", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("query RemoteDamSessionAccount($damSessionId: ID!)")); + assertTrue(query.contains("remoteDamSessionAccount(damSessionId: $damSessionId)")); + assertEquals(Set.of("damSessionId"), body.getAsJsonObject("variables").keySet()); + assertEquals(damSessionId, body.getAsJsonObject("variables").get("damSessionId").getAsString()); + assertFalse(query.contains("injectedSession")); + } + assertEquals(2, requestCount.get()); + } + + @Test + void rejectsUnavailableNonStringAndMalformedSessionOwnersWithoutRetrying() { + for (String value : List.of("null", "true", "false", "42", "{}", "[]", "\"\"", "\" \\t\"", + "\"not-a-uuid\"", "\"1-1-1-1-1\"", "\"" + ACCOUNT_ID + " \"", "\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"")) { + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); + } + responseJson.set("{\"data\":{}}"); + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); + } + + @Test + void rejectsBlankAndOversizedSessionOwnerLookupIdsBeforeSendingAnyRequest() { + for (String damSessionId : List.of("", " ", "\t\n", "s".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> client.getSessionAccount(damSessionId)); + assertThrows(IllegalArgumentException.class, () -> client.getSessionAccountAsync(damSessionId)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void preservesDistinctConcurrentSessionsAndReturnsAnImmutableSnapshot() throws DDRemoteDamException { + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + sessions.add(session("session-2")); + respond(SESSIONS_FIELD, sessions); + + List result = client.getSessions(ACCOUNT_ID); + + assertEquals(List.of( + new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4"), + new DDRemoteDamSession("DI_remoteDam", "run-1", "session-2", "DBeaver", "26.2.4") + ), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); + assertThrows(UnsupportedOperationException.class, () -> result.set(0, result.get(1))); + assertThrows(UnsupportedOperationException.class, () -> result.remove(0)); + + respond(SESSIONS_FIELD, new JsonArray()); + List empty = client.getSessions(ACCOUNT_ID); + + assertEquals(List.of(), empty); + assertThrows(UnsupportedOperationException.class, () -> empty.add(result.get(0))); + assertEquals(2, result.size()); + assertEquals("session-1", result.get(0).damSessionId()); + assertEquals(2, requestCount.get()); + } + + @Test + void scopesBothOperationsToTheAccountOnEveryRequest() throws DDRemoteDamException { + String sessionsQuery = null; + String sendQuery = null; + for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { + respond(SESSIONS_FIELD, new JsonArray()); + assertEquals(List.of(), client.getSessions(accountId)); + JsonObject sessionsBody = posted(); + assertEquals(accountId.toString(), sessionsBody.getAsJsonObject("variables").get("accountId").getAsString()); + String currentSessionsQuery = sessionsBody.get("query").getAsString(); + if (sessionsQuery != null) { + assertEquals(sessionsQuery, currentSessionsQuery); + } + sessionsQuery = currentSessionsQuery; + assertFalse(sessionsQuery.contains(accountId.toString())); + + respond(SEND_FIELD, JsonParser.parseString("true")); + assertTrue(client.sendMessage(accountId, "session-1", message())); + JsonObject sendBody = posted(); + assertEquals(accountId.toString(), sendBody.getAsJsonObject("variables").get("accountId").getAsString()); + assertEquals("session-1", sendBody.getAsJsonObject("variables").get("damSessionId").getAsString()); + String currentSendQuery = sendBody.get("query").getAsString(); + if (sendQuery != null) { + assertEquals(sendQuery, currentSendQuery); + } + sendQuery = currentSendQuery; + assertFalse(sendQuery.contains(accountId.toString())); + } + assertEquals(4, requestCount.get()); + } + + @Test + void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDRemoteDamException { + JsonObject nested = new JsonObject(); + nested.addProperty("name", "quoted \"value\""); + nested.addProperty("path", "C:\\temp\\file"); + JsonArray items = new JsonArray(); + items.add(nested); + items.add(true); + items.add(42); + items.add(JsonNull.INSTANCE); + JsonObject payload = new JsonObject(); + payload.add("items", items); + payload.addProperty("text", "Привет"); + String topic = "topic-\"\\\n{ injectedTopic }"; + String type = "type-\"\\\n{ injectedType }"; + DDRemoteDamMessage message = new DDRemoteDamMessage(topic, type, payload); + respond(SEND_FIELD, JsonParser.parseString("true")); + + String damSessionId = "session-\"\\\n) { injectedSession }"; + assertTrue(client.sendMessage(ACCOUNT_ID, damSessionId, message)); + + JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); + assertEquals("SendRemoteDamMessage", body.get("operationName").getAsString()); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("accountId", "damSessionId", "message"), variables.keySet()); + assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); + assertEquals(damSessionId, variables.get("damSessionId").getAsString()); + JsonObject input = variables.getAsJsonObject("message"); + assertEquals(Set.of("topic", "type", "payload"), input.keySet()); + assertEquals(topic, input.get("topic").getAsString()); + assertEquals(type, input.get("type").getAsString()); + assertEquals(payload, input.get("payload")); + assertTrue(input.get("payload").isJsonObject()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("mutation SendRemoteDamMessage(")); + assertTrue(query.contains("$accountId: ID!")); + assertTrue(query.contains("$damSessionId: ID!")); + assertTrue(query.contains("$message: RemoteDamMessageInput!")); + assertTrue(query.contains("sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)")); + for (String value : List.of(ACCOUNT_ID.toString(), damSessionId, topic, type, "injectedSession", "injectedTopic", + "injectedType", "Привет", "C:\\temp\\file")) { + assertFalse(query.contains(value), value); + } + assertEquals(1, requestCount.get()); + } + + @Test + void returnsTheSendBooleanWithoutAdditionalRequests() throws DDRemoteDamException { + for (boolean written : List.of(true, false)) { + respond(SEND_FIELD, JsonParser.parseString(Boolean.toString(written))); + int before = requestCount.get(); + + assertEquals(written, client.sendMessage(ACCOUNT_ID, "session-1", message())); + + assertEquals(before + 1, requestCount.get()); + assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString()); + } + } + + @Test + void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalSessionScope() throws DDRemoteDamException { + UUID subscriptionId = UUID.randomUUID(); + for (String damSessionId : List.of("", "socket")) { + String callbackUrl = damSessionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; + String callbackSecret = damSessionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\"; + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); + + assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, + damSessionId.isEmpty() ? null : damSessionId, "projects", callbackUrl, callbackSecret)); + + JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); + assertEquals("RegisterRemoteDamSubscription", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("$input: RemoteDamSubscriptionInput!")); + assertTrue(query.contains("registerRemoteDamSubscription(input: $input)")); + assertEquals(Set.of("input"), body.getAsJsonObject("variables").keySet()); + JsonObject input = body.getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("account", input.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); + assertEquals("projects", input.get("topic").getAsString()); + assertEquals(callbackUrl, input.get("callbackUrl").getAsString()); + assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString())); + if (damSessionId.isEmpty()) { + assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull()); + } else { + assertEquals(damSessionId, input.get("damSessionId").getAsString()); + } + assertEquals(input.has("damSessionId") ? 7 : 6, input.size()); + assertFalse(input.has("url")); + assertFalse(input.has("serviceSecret")); + assertFalse(query.contains(subscriptionId.toString())); + assertFalse(query.contains(callbackUrl)); + assertFalse(query.contains(callbackSecret)); + } + assertEquals(2, requestCount.get()); + } + + @Test + void registersTheServiceSecretAsTheCallbackSecret() throws DDRemoteDamException { + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); + + assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, SERVICE_SECRET)); + + CapturedRequest request = latestRequest.get(); + assertNotNull(request); + assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); + JsonObject body = JsonParser.parseString(request.body()).getAsJsonObject(); + assertEquals(SERVICE_SECRET, body.getAsJsonObject("variables").getAsJsonObject("input") + .get("callbackSecret").getAsString()); + assertFalse(body.get("query").getAsString().contains(SERVICE_SECRET)); + assertEquals(1, requestCount.get()); + } + + @Test + void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDRemoteDamException { + UUID subscriptionId = UUID.randomUUID(); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); + for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) { + for (String callbackSecret : List.of("!".repeat(32), "~".repeat(512))) { + assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, callbackSecret)); + + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals(callbackUrl, input.get("callbackUrl").getAsString()); + assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString())); + } + } + assertEquals(4, requestCount.get()); + } + + @Test + void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDRemoteDamException { + for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) { + UUID subscriptionId = UUID.randomUUID(); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); + assertEquals(60, client.registerSubscription(subscriberId, subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertEquals(subscriberId, posted().getAsJsonObject("variables").getAsJsonObject("input") + .get("subscriberId").getAsString()); + + respond("unregisterRemoteDamSubscription", JsonParser.parseString("true")); + assertTrue(client.unregisterSubscription(subscriberId, subscriptionId)); + assertEquals(subscriberId, posted().getAsJsonObject("variables").get("subscriberId").getAsString()); + } + assertEquals(8, requestCount.get()); + } + + @Test + void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() { + UUID subscriptionId = UUID.randomUUID(); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(null, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(null, subscriptionId)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(null, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(null, subscriptionId)); + for (String value : List.of("", " ", "\t\n", "s".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(value, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(value, subscriptionId)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, + ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, + ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(value, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(value, subscriptionId)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId, + ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId, + ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecrets() { + UUID subscriptionId = UUID.randomUUID(); + for (String callbackUrl : new String[] { + null, "", " ", "/relative", "callback.example.com", "//callback.example.com", "http://", "http:///", + "ftp://callback.example.com", "ws://callback.example.com", "wss://callback.example.com", + CALLBACK_URL + "/internal/remote-dam/graphql", CALLBACK_URL + "/prefix/", CALLBACK_URL + "//", + CALLBACK_URL + "/%2f", CALLBACK_URL + "?secret=" + CALLBACK_SECRET, CALLBACK_URL + "#fragment", + "http://user:" + CALLBACK_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536", + "http://127.0.0.1:-1", "http://127.0.0.1:port", "http://[::1", "https://" + "a".repeat(4096) + }) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, CALLBACK_SECRET)); + assertFalse(failure.toString().contains(CALLBACK_SECRET)); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { + UUID subscriptionId = UUID.randomUUID(); + for (String callbackSecret : new String[] { + null, "", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n", + "s".repeat(31) + "\t", "s".repeat(31) + "\r", "s".repeat(31) + "\0", + "s".repeat(31) + "\u007f", "s".repeat(31) + "é" + }) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, callbackSecret)); + assertFalse(failure.toString().contains(CALLBACK_SECRET)); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, callbackSecret)); + assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDRemoteDamException { + UUID subscriptionId = UUID.randomUUID(); + for (boolean removed : List.of(true, false)) { + respond("unregisterRemoteDamSubscription", JsonParser.parseString(Boolean.toString(removed))); + + assertEquals(removed, client.unregisterSubscription("storage", subscriptionId)); + + JsonObject body = posted(); + assertEquals("UnregisterRemoteDamSubscription", body.get("operationName").getAsString()); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("subscriberId", "subscriptionId"), variables.keySet()); + assertEquals("storage", variables.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), variables.get("subscriptionId").getAsString()); + } + } + + @Test + void acceptsBothSubscriptionLeaseBoundaries() throws Exception { + for (int lease : List.of(15, 300)) { + respond("registerRemoteDamSubscription", JsonParser.parseString(Integer.toString(lease))); + assertEquals(lease, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertEquals(lease, client.registerSubscriptionAsync("s".repeat(256), UUID.randomUUID(), ACCOUNT_ID, "socket", + "projects", CALLBACK_URL, CALLBACK_SECRET).toCompletableFuture().get(5, TimeUnit.SECONDS).intValue()); + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("s".repeat(256), input.get("subscriberId").getAsString()); + assertEquals("socket", input.get("damSessionId").getAsString()); + } + } + + @Test + void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() { + UUID subscriptionId = UUID.randomUUID(); + for (String value : List.of("null", "true", "\"60\"", "14", "301", "60.5", "1e100", "{}", "[]")) { + respond("registerRemoteDamSubscription", JsonParser.parseString(value)); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + } + for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) { + respond("unregisterRemoteDamSubscription", JsonParser.parseString(value)); + assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId)); + } + for (String body : List.of("{", "null", "{}", "{\"data\":null}", + "{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + CALLBACK_SECRET + "\"}]}")) { + responseJson.set(body); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId)); + } + } + + @Test + void rejectsTheWholeListWhenAnyRequiredSessionFieldIsInvalid() { + for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) { + JsonObject missing = session("session-2"); + missing.remove(field); + assertInvalidSession(missing); + for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \\t\\n\"")) { + JsonObject invalid = session("session-2"); + invalid.add(field, JsonParser.parseString(value)); + assertInvalidSession(invalid); + } + } + } + + @Test + void rejectsNonArraySessionResultsAndNonObjectEntries() { + for (String value : List.of( + "null", "true", "42", "\"sessions\"", "{}", "[null]", "[true]", "[42]", "[\"session\"]", "[[]]" + )) { + respond(SESSIONS_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + } + } + + @Test + void rejectsMalformedJsonRootsAndMissingDataForBothOperations() { + for (String body : List.of( + "", "{", "not-json", "null", "[]", "true", "42", "\"root\"", "{}", + "{\"data\":null}", "{\"data\":[]}", "{\"data\":true}", "{\"data\":{}}", + "{\"data\":{\"remoteDamSessions\":null,\"sendRemoteDamMessage\":null}}", + "{\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\":[" + )) { + responseJson.set(body); + + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + } + } + + @Test + void rejectsMalformedErrorsAndGraphqlErrorsEvenAlongsideUsablePartialData() { + JsonObject upstreamError = new JsonObject(); + upstreamError.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET); + JsonArray errors = new JsonArray(); + errors.add(upstreamError); + for (JsonElement value : List.of( + JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser.parseString("42"), + JsonParser.parseString("\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\""), JsonParser.parseString("[null]"), errors + )) { + JsonObject envelope = successfulEnvelope(); + envelope.add("errors", value); + responseJson.set(envelope.toString()); + + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + assertEquals("Remote DAM GraphQL request failed", failure.getMessage()); + } + } + + @Test + void acceptsAnEmptyGraphqlErrorsArray() throws Exception { + JsonObject envelope = successfulEnvelope(); + envelope.add("errors", new JsonArray()); + responseJson.set(envelope.toString()); + + assertEquals(List.of(), client.getSessions(ACCOUNT_ID)); + assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(4, requestCount.get()); + } + + @Test + void rejectsSendResultsThatAreNotJsonBooleans() { + for (String value : List.of("null", "\"true\"", "\"false\"", "0", "1", "{}", "[]")) { + respond(SEND_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + } + } + + @Test + void acceptsSuccessfulHttpStatusesThroughoutTheTwoHundredRange() throws Exception { + responseJson.set(successfulEnvelope().toString()); + for (int status : List.of(200, 201, 202, 299)) { + responseStatus.set(status); + assertEquals(List.of(), client.getSessions(ACCOUNT_ID)); + assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + } + } + + @Test + void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { + JsonObject envelope = successfulEnvelope(); + envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET + CALLBACK_SECRET); + responseJson.set(envelope.toString()); + for (int status : List.of(204, 300, 400, 401, 403, 404, 429, 500, 503)) { + responseStatus.set(status); + + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + assertEquals(status == 204 ? "Invalid Remote DAM response" : "Remote DAM request failed: HTTP " + status, + failure.getMessage()); + } + } + + @Test + void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { + var forwarded = new AtomicInteger(); + var forwardedSecret = new AtomicReference(); + HttpServer destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + destination.createContext("/", exchange -> { + forwarded.incrementAndGet(); + forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); + try { + exchange.sendResponseHeaders(200, -1); + } catch (IOException e) { + handlerFailure.compareAndSet(null, e); + } finally { + exchange.close(); + } + }); + destination.start(); + try { + redirect.set("http://127.0.0.1:" + destination.getAddress().getPort() + "/destination"); + responseJson.set(successfulEnvelope().toString()); + for (int status : List.of(301, 302, 303, 307, 308)) { + responseStatus.set(status); + + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertEquals(0, forwarded.get()); + assertNull(forwardedSecret.get()); + } + } finally { + destination.stop(0); + } + } + + @Test + void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDRemoteDamException { + responseJson.set("{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"}]}"); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + + responseJson.set(successfulEnvelope().toString()); + responseStatus.set(503); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + + responseStatus.set(200); + truncateResponse.set(true); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + + truncateResponse.set(false); + assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertEquals(4, requestCount.get()); + } + + @Test + void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exception { + responseJson.set(successfulEnvelope().toString()); + truncateResponse.set(true); + + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + + assertEquals("Remote DAM service request failed", failure.getMessage()); + assertInstanceOf(IOException.class, failure.getCause()); + truncateResponse.set(false); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(2, requestCount.get()); + } + + @Test + void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() { + server.stop(0); + server = null; + + ExecutionException completion = assertThrows(ExecutionException.class, + () -> client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); + assertEquals("Remote DAM service request failed", failure.getMessage()); + assertInstanceOf(IOException.class, failure.getCause()); + assertSafeFailure(failure); + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { + for (String url : List.of( + "", "/relative", "127.0.0.1", "//127.0.0.1", "http:///", "http://", + "ftp://127.0.0.1", "ws://127.0.0.1", "wss://127.0.0.1", + serviceUrl() + "/internal/graphql", serviceUrl() + "/ws", serviceUrl() + "/prefix/", serviceUrl() + "//", + serviceUrl() + "/%2f", serviceUrl() + "?secret=" + SERVICE_SECRET, serviceUrl() + "#fragment", + "http://user:" + SERVICE_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536" + )) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> new DDRemoteDamClient(url, SERVICE_SECRET), url); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() { + for (String url : List.of(serviceUrl(), serviceUrl() + "/", "https://127.0.0.1", "https://127.0.0.1:8443/")) { + for (String secret : List.of("!".repeat(32), "~".repeat(512))) { + assertDoesNotThrow(() -> new DDRemoteDamClient(url, secret)); + } + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsInvalidSecretsAndBlankSessionIdsBeforeSendingAnyRequest() { + for (String secret : List.of("", "s".repeat(31), "s".repeat(513), " ".repeat(32), + "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\u007f", "s".repeat(31) + "é")) { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamClient(serviceUrl(), secret)); + } + for (String damSessionId : List.of("", " ", "\t\n")) { + assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, damSessionId, message())); + assertThrows(IllegalArgumentException.class, () -> client.sendMessageAsync(ACCOUNT_ID, damSessionId, message())); + } + assertThrows(NullPointerException.class, () -> client.getSessionsAsync(null)); + assertThrows(NullPointerException.class, () -> client.getSessionAccountAsync(null)); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(null, "session-1", message())); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(ACCOUNT_ID, "session-1", null)); + assertEquals(0, requestCount.get()); + } + + @Nullable + private T awaitDelayedResponse(@NotNull Supplier> action) throws Exception { + CountDownLatch received = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + AtomicReference> stage = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + requestReceived.set(received); + responseGate.set(release); + Thread caller = new Thread(() -> { + try { + stage.set(action.get()); + } catch (Throwable e) { + failure.set(e); + } finally { + returned.countDown(); + } + }); + caller.start(); + try { + assertTrue(received.await(5, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertNotNull(stage.get()); + assertFalse(stage.get().toCompletableFuture().isDone()); + release.countDown(); + return stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + caller.join(5000); + requestReceived.set(null); + responseGate.set(null); + } + } + + private void handleRequest(@NotNull HttpExchange exchange) { + try { + Map> headers = new HashMap<>(); + exchange.getRequestHeaders().forEach((name, values) -> headers.put(name, List.copyOf(values))); + latestRequest.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI(), Map.copyOf(headers), + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))); + requestCount.incrementAndGet(); + CountDownLatch received = requestReceived.get(); + CountDownLatch gate = responseGate.get(); + if (received != null) { + received.countDown(); + } + if (gate != null && !gate.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release the response"); + } + String location = redirect.get(); + if (location != null) { + exchange.getResponseHeaders().set("Location", location); + } + exchange.getResponseHeaders().set("Content-Type", "application/json"); + int status = responseStatus.get(); + if (status == 204) { + exchange.sendResponseHeaders(status, -1); + } else { + byte[] bytes = responseJson.get().getBytes(StandardCharsets.UTF_8); + boolean truncated = truncateResponse.get(); + if (truncated) { + exchange.getResponseHeaders().set("Connection", "close"); + } + exchange.sendResponseHeaders(status, bytes.length + (truncated ? 16 : 0)); + exchange.getResponseBody().write(bytes); + } + } catch (IOException e) { + handlerFailure.compareAndSet(null, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + handlerFailure.compareAndSet(null, new IOException(e)); + } finally { + exchange.close(); + } + } + + @NotNull + private String serviceUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @NotNull + private JsonObject posted() { + CapturedRequest request = latestRequest.get(); + assertNotNull(request); + assertEquals("POST", request.method()); + assertEquals(DDRemoteDamApi.GRAPHQL_PATH, request.uri().toString()); + assertEquals("application/json", request.header("Content-Type")); + assertEquals("application/json", request.header("Accept")); + assertEquals("no-store", request.header("Cache-Control")); + assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); + assertNull(request.header("Authorization")); + assertNull(request.header("Cookie")); + assertFalse(request.body().contains(SERVICE_SECRET)); + assertFalse(request.uri().toString().contains(SERVICE_SECRET)); + return JsonParser.parseString(request.body()).getAsJsonObject(); + } + + private void respond(@NotNull String field, @NotNull JsonElement value) { + JsonObject data = new JsonObject(); + data.add(field, value); + JsonObject envelope = new JsonObject(); + envelope.add("data", data); + responseJson.set(envelope.toString()); + } + + private void assertInvalidSession(@NotNull JsonObject invalid) { + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + sessions.add(invalid); + respond(SESSIONS_FIELD, sessions); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + } + + private void assertRequestFails(@NotNull Executable action) { + int before = requestCount.get(); + DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, action); + assertEquals(before + 1, requestCount.get()); + assertSafeFailure(failure); + } + + @NotNull + private DDRemoteDamException assertAsyncRequestFails(@NotNull Supplier> action) { + int before = requestCount.get(); + CompletionStage stage = action.get(); + ExecutionException completion = assertThrows(ExecutionException.class, + () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); + assertEquals(before + 1, requestCount.get()); + assertSafeFailure(failure); + return failure; + } + + private static void assertSafeFailure(@NotNull DDRemoteDamException failure) { + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SERVICE_SECRET)); + assertFalse(trace.toString().contains(CALLBACK_SECRET)); + assertFalse(trace.toString().contains(UPSTREAM_MARKER)); + } + + @NotNull + private static JsonObject session(@NotNull String damSessionId) { + JsonObject session = new JsonObject(); + session.addProperty("appDeploymentId", "DI_remoteDam"); + session.addProperty("appInstanceId", "run-1"); + session.addProperty("damSessionId", damSessionId); + session.addProperty("product", "DBeaver"); + session.addProperty("version", "26.2.4"); + return session; + } + + @NotNull + private static JsonObject successfulEnvelope() { + JsonObject data = new JsonObject(); + data.add(SESSIONS_FIELD, new JsonArray()); + data.addProperty(SEND_FIELD, true); + JsonObject envelope = new JsonObject(); + envelope.add("data", data); + return envelope; + } + + @NotNull + private static DDRemoteDamMessage message() { + return new DDRemoteDamMessage("projects", "project.updated", new JsonObject()); + } + + private record CapturedRequest( + @NotNull String method, + @NotNull URI uri, + @NotNull Map> headers, + @NotNull String body + ) { + @Nullable + private String header(@NotNull String name) { + return headers.entrySet().stream() + .filter(entry -> name.equalsIgnoreCase(entry.getKey())) + .map(entry -> entry.getValue().get(0)) + .findFirst() + .orElse(null); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java new file mode 100644 index 0000000..66ec5ae --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java @@ -0,0 +1,62 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DDRemoteDamMessageTest { + @Test + void isolatesThePayloadFromChangesToTheOriginalObject() { + JsonObject project = new JsonObject(); + project.addProperty("name", "original"); + JsonObject payload = new JsonObject(); + payload.add("project", project); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload); + + project.addProperty("name", "changed"); + + assertEquals("original", message.payload().getAsJsonObject("project").get("name").getAsString()); + } + + @Test + void isolatesListenersFromEachOthersPayloadChanges() { + JsonObject payload = new JsonObject(); + payload.addProperty("projectId", "project-1"); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload); + + message.payload().addProperty("projectId", "changed"); + + assertEquals("project-1", message.payload().get("projectId").getAsString()); + } + + @Test + void rejectsBlankRoutingFields() { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage(" ", "updated", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage("projects", " ", new JsonObject())); + } + + @Test + void rejectsMissingSessionIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo(" ", "run-1", "session-1")); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", " ", "session-1")); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", "run-1", " ")); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java new file mode 100644 index 0000000..2346381 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDRemoteDamSessionTest { + @Test + void preservesSeparateDeploymentRunAndSocketIdentities() { + DDRemoteDamSession session = new DDRemoteDamSession("deployment", "run", "socket", "DBeaver", "26.2"); + + Assertions.assertEquals("deployment", session.appDeploymentId()); + Assertions.assertEquals("run", session.appInstanceId()); + Assertions.assertEquals("socket", session.damSessionId()); + Assertions.assertEquals("DBeaver", session.product()); + Assertions.assertEquals("26.2", session.version()); + Assertions.assertNotEquals(session, new DDRemoteDamSession("deployment", "run", "another-socket", "DBeaver", "26.2")); + } + + @Test + void rejectsMissingOrBlankMetadataInEveryField() { + for (int field = 0; field < 5; field++) { + for (String invalid : new String[] {null, "", " ", "\t\n"}) { + String[] values = {"deployment", "run", "socket", "DBeaver", "26.2"}; + values[field] = invalid; + + Assertions.assertThrows(IllegalArgumentException.class, () -> + new DDRemoteDamSession(values[0], values[1], values[2], values[3], values[4])); + } + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java new file mode 100644 index 0000000..d365811 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java @@ -0,0 +1,819 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.requestreply; + +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Timeout(10) +class DDRemoteDamRequestReplyTransportTest { + private static final String TOPIC = "projects"; + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket"); + + @Test + void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception { + FakeChannel callerChannel = new FakeChannel(); + FakeChannel handlerChannel = new FakeChannel(); + callerChannel.forward = message -> handlerChannel.deliver(SESSION, message); + handlerChannel.forward = message -> callerChannel.deliver(SESSION, message); + JsonObject payload = new JsonObject(); + payload.addProperty("value", "request"); + try (var handler = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(handlerChannel, TOPIC)); + var handlerRegistration = handler.handle(request -> { + request.addProperty("handled", true); + return request; + }); + var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(callerChannel, TOPIC)); + var callerRegistration = caller.handle(request -> null)) { + JsonObject response = caller.request(payload, Duration.ofSeconds(2)); + + Assertions.assertNotNull(response); + Assertions.assertEquals("request", response.get("value").getAsString()); + Assertions.assertTrue(response.get("handled").getAsBoolean()); + Assertions.assertFalse(payload.has("handled")); + Assertions.assertNull(handler.request(new JsonObject(), Duration.ofSeconds(2))); + } + Assertions.assertFalse(callerChannel.closed); + Assertions.assertFalse(handlerChannel.closed); + Assertions.assertTrue(callerChannel.observers.isEmpty()); + Assertions.assertTrue(handlerChannel.observers.isEmpty()); + Assertions.assertTrue(callerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(handlerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @Test + void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport first = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport second = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + Received firstReceived = new Received(); + Received secondReceived = new Received(); + try (var firstSubscription = first.subscribe(firstId, firstReceived); + var secondSubscription = second.subscribe(secondId, secondReceived)) { + Assertions.assertEquals(4, channel.subscriptions.size()); + String firstReplyTopic = channel.subscriptions.get(0).topic; + String secondReplyTopic = channel.subscriptions.get(2).topic; + Assertions.assertNotEquals(firstReplyTopic, secondReplyTopic); + Assertions.assertTrue(firstReplyTopic.endsWith(firstId.toString())); + Assertions.assertTrue(secondReplyTopic.endsWith(secondId.toString())); + Assertions.assertEquals(TOPIC, channel.subscriptions.get(1).topic); + Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic); + MessagingRequestReplyMessage request = request(firstId); + first.send(request).toCompletableFuture().join(); + DDRemoteDamMessage sent = channel.sent.getLast(); + Assertions.assertEquals(TOPIC, sent.topic()); + channel.deliver(SESSION, sent); + Assertions.assertEquals(List.of(request), firstReceived.messages); + Assertions.assertEquals(List.of(request), secondReceived.messages); + second.send(request.response(null)).toCompletableFuture().join(); + Assertions.assertEquals(firstReplyTopic, channel.sent.getLast().topic()); + channel.deliver(SESSION, channel.sent.getLast()); + Assertions.assertEquals(List.of(request, request.response(null)), firstReceived.messages); + Assertions.assertEquals(List.of(request), secondReceived.messages); + MessagingRequestReplyMessage otherRequest = request(secondId); + first.send(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join(); + Assertions.assertEquals(secondReplyTopic, channel.sent.getLast().topic()); + channel.deliver(SESSION, channel.sent.getLast()); + Assertions.assertEquals(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast()); + } + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + } + + @Test + void keepsDerivedTopicsWithinRegistrationMetadataLimit() throws Exception { + FakeChannel channel = new FakeChannel(); + String topic = "x".repeat(256); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, topic); + try (var subscription = adapter.subscribe(UUID.randomUUID(), new Received())) { + Assertions.assertEquals(topic, channel.subscriptions.get(1).topic); + Assertions.assertTrue(channel.subscriptions.getFirst().topic.length() <= 256); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, " ")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, topic + "x")); + } + + @Test + void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = TOPIC; + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); + Assertions.assertFalse(channel.subscriptions.getFirst().active); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(UUID.randomUUID()))); + } + + @Test + void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { + DDRemoteDamException unavailable = new DDRemoteDamException("Unavailable"); + AtomicInteger closed = new AtomicInteger(); + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, + message -> CompletableFuture.completedFuture(null), (topic, listener) -> { + if (topic.equals(TOPIC)) { + throw unavailable; + } + return closed::incrementAndGet; + }); + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(unavailable, failure.getCause()); + Assertions.assertEquals(1, closed.get()); + } + + @Test + void rejectsDisconnectedAndClosedChannelsAtOpening() { + for (DDRemoteDamChannelState state : List.of( + DDRemoteDamChannelState.DISCONNECTED, DDRemoteDamChannelState.CONNECTING, DDRemoteDamChannelState.CLOSED + )) { + FakeChannel channel = new FakeChannel(); + channel.setStatus(new DDRemoteDamChannelStatus(state, null)); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertTrue(channel.subscriptions.isEmpty()); + Assertions.assertTrue(channel.observers.isEmpty()); + } + } + + @Test + void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects() { + FakeChannel channel = new FakeChannel(); + channel.onSubscribe = topic -> { + if (TOPIC.equals(topic)) { + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION)); + } + }; + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertEquals(2, channel.subscriptions.size()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + } + + @Test + void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + channel.deliver(new DDRemoteDamSessionInfo("other", "run", "socket"), wire); + channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "other", "socket"), wire); + channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "run", "other"), wire); + Assertions.assertTrue(received.messages.isEmpty()); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + Assertions.assertEquals(1, received.disconnects); + assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); + channel.deliver(SESSION, wire); + Assertions.assertTrue(received.messages.isEmpty()); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "new-run", "new-socket"); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement)); + channel.deliver(SESSION, wire); + channel.deliver(replacement, wire); + Assertions.assertEquals(1, received.messages.size()); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION)); + Assertions.assertEquals(2, received.disconnects); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CLOSED, null)); + Assertions.assertEquals(3, received.disconnects); + } + } + + @Test + void completesMessageDeliveryBeforeNotifyingAConcurrentSessionReplacement() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch messageEntered = new CountDownLatch(1); + CountDownLatch releaseMessage = new CountDownLatch(1); + List delivered = new CopyOnWriteArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + messageEntered.countDown(); + await(releaseMessage); + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + FutureTask message = async(() -> channel.deliver(SESSION, wire)); + try { + await(messageEntered); + CountDownLatch replacing = new CountDownLatch(1); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); + FutureTask state = async(() -> { + replacing.countDown(); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement)); + }); + try { + await(replacing); + state.get(2, TimeUnit.SECONDS); + Assertions.assertTrue(delivered.isEmpty()); + } finally { + releaseMessage.countDown(); + } + message.get(2, TimeUnit.SECONDS); + state.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + + channel.deliver(SESSION, wire); + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + } finally { + releaseMessage.countDown(); + message.get(2, TimeUnit.SECONDS); + } + } + } + + @Test + void notifiesSessionReplacementBeforeDeliveringANewSourceMessage() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch disconnectEntered = new CountDownLatch(1); + CountDownLatch releaseDisconnect = new CountDownLatch(1); + List delivered = new CopyOnWriteArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + delivered.add("message"); + } + + @Override + public void onDisconnect() { + disconnectEntered.countDown(); + await(releaseDisconnect); + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); + FutureTask state = async(() -> + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement))); + try { + await(disconnectEntered); + CountDownLatch receiving = new CountDownLatch(1); + FutureTask message = async(() -> { + receiving.countDown(); + channel.deliver(replacement, wire); + }); + try { + await(receiving); + message.get(2, TimeUnit.SECONDS); + Assertions.assertTrue(delivered.isEmpty()); + } finally { + releaseDisconnect.countDown(); + } + state.get(2, TimeUnit.SECONDS); + message.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + } finally { + releaseDisconnect.countDown(); + state.get(2, TimeUnit.SECONDS); + } + } + } + + @Test + void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + List delivered = new ArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + + channel.deliver(SESSION, channel.sent.getLast()); + + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + } + } + + @Test + void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + List delivered = new ArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); + DDRemoteDamChannelStatus ready = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement); + channel.status = ready; + + channel.deliver(replacement, wire); + + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + for (Consumer observer : List.copyOf(channel.observers)) { + observer.accept(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + observer.accept(ready); + } + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + channel.deliver(SESSION, wire); + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + channel.deliver(replacement, wire); + Assertions.assertEquals(List.of("disconnect", "message", "message"), delivered); + } + } + + @Test + void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exception { + FakeChannel leftChannel = new FakeChannel(); + FakeChannel rightChannel = new FakeChannel(); + DDRemoteDamRequestReplyTransport left = DDRemoteDamRequestReplyTransport.forChannel(leftChannel, TOPIC); + DDRemoteDamRequestReplyTransport right = DDRemoteDamRequestReplyTransport.forChannel(rightChannel, TOPIC); + UUID leftId = UUID.randomUUID(); + UUID rightId = UUID.randomUUID(); + CountDownLatch requests = new CountDownLatch(2); + AtomicInteger replies = new AtomicInteger(); + try (var leftSubscription = left.subscribe(leftId, inlineReplies(left, requests, replies)); + var rightSubscription = right.subscribe(rightId, inlineReplies(right, requests, replies))) { + left.send(request(leftId)).toCompletableFuture().join(); + right.send(request(rightId)).toCompletableFuture().join(); + DDRemoteDamMessage toRight = leftChannel.sent.getLast(); + DDRemoteDamMessage toLeft = rightChannel.sent.getLast(); + leftChannel.forward = message -> rightChannel.deliver(SESSION, message); + rightChannel.forward = message -> leftChannel.deliver(SESSION, message); + + FutureTask leftDelivery = async(() -> leftChannel.deliver(SESSION, toLeft)); + FutureTask rightDelivery = async(() -> rightChannel.deliver(SESSION, toRight)); + + leftDelivery.get(2, TimeUnit.SECONDS); + rightDelivery.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(2, replies.get()); + } + } + + @Test + void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exception { + for (boolean disconnect : List.of(false, true)) { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger messages = new AtomicInteger(); + AtomicInteger disconnects = new AtomicInteger(); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + entered.countDown(); + await(release); + messages.incrementAndGet(); + } + + @Override + public void onDisconnect() { + disconnects.incrementAndGet(); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + FutureTask delivery = async(() -> channel.deliver(SESSION, wire)); + try { + await(entered); + for (int i = 0; i < 130; i++) { + channel.deliver(SESSION, wire); + } + if (disconnect) { + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + } + } finally { + release.countDown(); + delivery.get(2, TimeUnit.SECONDS); + } + Assertions.assertEquals(disconnect ? 1 : 129, messages.get()); + Assertions.assertEquals(disconnect ? 1 : 0, disconnects.get()); + } + } + } + + @Test + void releasesReceivingResourcesWhenAListenerFails() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("Listener failed"); + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + throw failure; + } + + @Override + public void onDisconnect() { + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + + Assertions.assertSame(failure, Assertions.assertThrows(IllegalStateException.class, + () -> channel.deliver(SESSION, channel.sent.getLast()))); + + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(value -> value.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + } + } + + @Test + void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + MessagingRequestReplyMessage request = request(endpoint); + adapter.send(request).toCompletableFuture().join(); + DDRemoteDamMessage requestWire = channel.sent.getLast(); + adapter.send(request.response(new JsonObject())).toCompletableFuture().join(); + final DDRemoteDamMessage responseWire = channel.sent.getLast(); + List malformed = new ArrayList<>(); + JsonObject abbreviatedId = requestWire.payload(); + abbreviatedId.addProperty("requestId", "1-1-1-1-1"); + malformed.add(abbreviatedId); + JsonObject invalidOrigin = requestWire.payload(); + invalidOrigin.addProperty("replyTo", "not-a-uuid"); + malformed.add(invalidOrigin); + JsonObject numericId = requestWire.payload(); + numericId.addProperty("requestId", 1); + malformed.add(numericId); + JsonObject unknownType = requestWire.payload(); + unknownType.addProperty("type", "OTHER"); + malformed.add(unknownType); + JsonObject missingData = requestWire.payload(); + missingData.remove("data"); + malformed.add(missingData); + JsonObject nullRequest = requestWire.payload(); + nullRequest.add("data", JsonNull.INSTANCE); + malformed.add(nullRequest); + JsonObject primitiveData = requestWire.payload(); + primitiveData.addProperty("data", "text"); + malformed.add(primitiveData); + JsonObject requestError = requestWire.payload(); + requestError.addProperty("error", "NO_HANDLER"); + malformed.add(requestError); + for (JsonObject envelope : malformed) { + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, requestWire.type(), envelope)); + } + JsonObject responseError = responseWire.payload(); + responseError.addProperty("error", "NO_HANDLER"); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseError)); + JsonObject responsePrimitive = responseWire.payload(); + responsePrimitive.addProperty("data", 42); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); + JsonObject responseMissingData = responseWire.payload(); + responseMissingData.remove("data"); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseMissingData)); + JsonObject wrongOrigin = responseWire.payload(); + wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString()); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); + adapter.send(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); + DDRemoteDamMessage errorWire = channel.sent.getLast(); + JsonObject errorData = errorWire.payload(); + errorData.add("data", new JsonObject()); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), errorData)); + JsonObject unknownError = errorWire.payload(); + unknownError.addProperty("error", "OTHER"); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), unknownError)); + JsonObject missingError = errorWire.payload(); + missingError.remove("error"); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), missingError)); + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, "unrelated", requestWire.payload())); + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, responseWire.type(), responseWire.payload())); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); + channel.subscriptions.getFirst().listener.onMessage(SESSION, requestWire); + channel.subscriptions.get(1).listener.onMessage(SESSION, responseWire); + Assertions.assertTrue(received.messages.isEmpty()); + channel.deliver(SESSION, requestWire); + channel.deliver(SESSION, responseWire); + channel.deliver(SESSION, errorWire); + Assertions.assertEquals(List.of(request, request.response(new JsonObject()), + request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)), received.messages); + } + } + + @Test + void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { + FakeChannel channel = new FakeChannel(); + final DDRemoteDamSubscription borrowed = channel.subscribe("other", (session, message) -> { }); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + final MessagingSubscription subscription = adapter.subscribe(endpoint, new Received()); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertThrows(CompletionException.class, + () -> adapter.send(request(UUID.randomUUID())).toCompletableFuture().join()); + adapter.close(); + subscription.close(); + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + Assertions.assertTrue(channel.subscriptions.getFirst().active); + Assertions.assertFalse(channel.closed); + borrowed.close(); + } + + @Test + void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exception { + List> pending = new ArrayList<>(); + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> { + CompletableFuture result = new CompletableFuture<>(); + pending.add(result); + return result; + }, (topic, listener) -> () -> { }); + UUID endpoint = UUID.randomUUID(); + try (var subscription = adapter.subscribe(endpoint, new Received())) { + List> sends = new ArrayList<>(); + for (int i = 0; i < 128; i++) { + sends.add(adapter.send(request(endpoint))); + } + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + Assertions.assertEquals(128, pending.size()); + pending.getFirst().complete(null); + sends.getFirst().toCompletableFuture().get(2, TimeUnit.SECONDS); + adapter.send(request(endpoint)); + Assertions.assertEquals(129, pending.size()); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + pending.get(1).completeExceptionally(unavailable); + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> sends.get(1).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + adapter.send(request(endpoint)); + Assertions.assertEquals(130, pending.size()); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + Assertions.assertTrue(sends.get(2).toCompletableFuture().cancel(false)); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + pending.get(2).complete(null); + adapter.send(request(endpoint)); + Assertions.assertEquals(131, pending.size()); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + } finally { + pending.forEach(result -> result.complete(null)); + } + } + + @Test + void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception { + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + AtomicInteger attempts = new AtomicInteger(); + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> { + if (attempts.incrementAndGet() <= 129) { + throw unavailable; + } + return CompletableFuture.completedFuture(null); + }, (topic, listener) -> () -> { }); + UUID endpoint = UUID.randomUUID(); + try (var subscription = adapter.subscribe(endpoint, new Received())) { + for (int i = 0; i < 129; i++) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + } + adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(130, attempts.get()); + } + } + + @NotNull + private static MessagingRequestReplyMessage request(@NotNull UUID endpoint) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, new JsonObject()); + } + + private static void assertFailure(@NotNull MessagingRequestReplyException.Reason reason, @NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertEquals(reason, Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()).reason()); + } + + @NotNull + private static FutureTask async(@NotNull Runnable action) { + FutureTask task = new FutureTask<>(action, null); + Thread.ofPlatform().daemon().start(task); + return task; + } + + @NotNull + private static MessagingRequestReplyTransport.Listener inlineReplies( + @NotNull DDRemoteDamRequestReplyTransport adapter, + @NotNull CountDownLatch requests, + @NotNull AtomicInteger replies + ) { + return new MessagingRequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) { + requests.countDown(); + await(requests); + adapter.send(message.response(null)).toCompletableFuture().join(); + } else { + replies.incrementAndGet(); + } + } + + @Override + public void onDisconnect() { + } + }; + } + + private static void await(@NotNull CountDownLatch latch) { + try { + Assertions.assertTrue(latch.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + + private static final class Received implements MessagingRequestReplyTransport.Listener { + private final List> messages = new ArrayList<>(); + private int disconnects; + + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + messages.add(message); + } + + @Override + public void onDisconnect() { + disconnects++; + } + } + + private static final class FakeChannel implements DDRemoteDamChannel { + private final List subscriptions = new ArrayList<>(); + private final List> observers = new ArrayList<>(); + private final List sent = new ArrayList<>(); + private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); + private volatile DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION); + @Nullable + private String failTopic; + @Nullable + private Consumer onSubscribe; + @Nullable + private Consumer forward; + private boolean closed; + + @Override + @NotNull + public DDRemoteDamChannelStatus getStatus() { + return status; + } + + @Override + @NotNull + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(status); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDRemoteDamMessage message) { + sent.add(message); + if (forward != null) { + forward.accept(message); + } + return CompletableFuture.completedFuture(null); + } + + @Override + @NotNull + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + if (topic.equals(failTopic)) { + throw subscriptionFailure; + } + TopicSubscription subscription = new TopicSubscription(topic, listener); + subscriptions.add(subscription); + if (onSubscribe != null) { + onSubscribe.accept(topic); + } + return () -> subscription.active = false; + } + + @Override + @NotNull + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { + throw new UnsupportedOperationException(); + } + + private void setStatus(@NotNull DDRemoteDamChannelStatus next) { + status = next; + List.copyOf(observers).forEach(observer -> observer.accept(next)); + } + + private void deliver(@NotNull DDRemoteDamSessionInfo source, @NotNull DDRemoteDamMessage message) { + for (TopicSubscription subscription : List.copyOf(subscriptions)) { + if (subscription.active && subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(source, message); + } + } + } + + @Override + public void close() { + closed = true; + } + } + + private static final class TopicSubscription { + private final String topic; + private final DDRemoteDamMessageListener listener; + private boolean active = true; + + private TopicSubscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + this.topic = topic; + this.listener = listener; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java new file mode 100644 index 0000000..b7d87d2 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java @@ -0,0 +1,83 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonPrimitive; +import com.google.gson.reflect.TypeToken; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.List; + +class DDRemoteDamGsonRpcCodecTest { + private final DDRemoteDamGsonRpcCodec codec = new DDRemoteDamGsonRpcCodec(new Gson()); + + @Test + void encodesOrdinaryAndPrimitiveValues() { + Assertions.assertEquals(new JsonPrimitive("Hello"), codec.encode("Hello", String.class)); + Assertions.assertEquals("Hello", codec.decode(new JsonPrimitive("Hello"), String.class)); + Assertions.assertEquals(new JsonPrimitive(2), codec.encode(2, int.class)); + Assertions.assertEquals(2, codec.decode(new JsonPrimitive(2), int.class)); + } + + @Test + void normalizesJsonNullAndSupportsNullableValues() { + Assertions.assertNull(codec.encode(null, String.class)); + Assertions.assertNull(codec.encode(JsonNull.INSTANCE, JsonElement.class)); + Assertions.assertNull(codec.decode(null, String.class)); + Assertions.assertNull(codec.decode(JsonNull.INSTANCE, String.class)); + } + + @Test + void usesDeclaredGenericTypesForApplicationRecords() { + Type type = new TypeToken>() { }.getType(); + List values = List.of(new Item("Hello", List.of(1, 2))); + + Object decoded = codec.decode(codec.encode(values, type), type); + + Assertions.assertEquals(values, decoded); + Assertions.assertInstanceOf(Item.class, ((List) decoded).getFirst()); + } + + @Test + void preservesApplicationGsonConfiguration() { + var configured = new DDRemoteDamGsonRpcCodec(new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create()); + Item value = new Item("configured", List.of(3)); + + JsonElement encoded = configured.encode(value, Item.class); + + Assertions.assertEquals("configured", encoded.getAsJsonObject().get("message_text").getAsString()); + Assertions.assertEquals(value, configured.decode(encoded, Item.class)); + } + + @Test + void requiresAnExplicitGsonAndDeclaredTypes() { + Assertions.assertThrows(NullPointerException.class, () -> new DDRemoteDamGsonRpcCodec(null)); + Assertions.assertThrows(NullPointerException.class, () -> codec.encode("Hello", null)); + Assertions.assertThrows(NullPointerException.class, () -> codec.decode(null, null)); + } + + private record Item(String messageText, List numbers) { + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java new file mode 100644 index 0000000..32323ff --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java @@ -0,0 +1,194 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.rpc.MessagingRpcCodec; +import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; +import com.dbeaver.messaging.rpc.MessagingRpcException; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +@Timeout(10) +class DDRemoteDamRpcTopicsIntegrationTest { + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "physical-session"); + private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); + + @Test + void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { + Channel remoteDam = new Channel(); + Channel firstBackend = new Channel(); + Channel secondBackend = new Channel(); + remoteDam.forward = message -> { + firstBackend.receive(message); + secondBackend.receive(message); + }; + firstBackend.forward = remoteDam::receive; + secondBackend.forward = remoteDam::receive; + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + String firstTopic = "hello.files.v2"; + String secondTopic = "developer..chosen-topic"; + var serviceCodec = new PrefixCodec("response:", "argument:"); + var firstProxyCodec = new PrefixCodec("first:", "one:"); + var secondProxyCodec = new PrefixCodec("second:", "two:"); + try (var remoteDamRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(remoteDam), codec, OPTIONS); + var firstRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(firstBackend), OPTIONS); + var secondRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(secondBackend), OPTIONS); + var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec); + var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec); + var reverseGreeting = remoteDamRpc.register(firstTopic, Greeting.class, value -> "remoteDam:" + value)) { + Greeting first = remoteDamRpc.proxy(firstTopic, Greeting.class, firstProxyCodec); + Greeting second = remoteDamRpc.proxy(firstTopic, Greeting.class, secondProxyCodec); + Assertions.assertEquals("one:response:argument:first:Hello", first.hello("Hello")); + Assertions.assertEquals("two:response:argument:second:Hello", second.hello("Hello")); + Assertions.assertEquals("other:Hello", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("Hello")); + Assertions.assertEquals("remoteDam:reverse", firstRpc.proxy(firstTopic, Greeting.class, codec).hello("reverse")); + Assertions.assertEquals("response:argument:fallback", remoteDamRpc.proxy(firstTopic, Greeting.class).hello("fallback")); + Assertions.assertEquals(4, remoteDam.subscriptions.size()); + Assertions.assertEquals(2, firstBackend.subscriptions.size()); + Assertions.assertEquals(2, secondBackend.subscriptions.size()); + Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic))); + Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic))); + for (DDRemoteDamMessage message : remoteDam.sent) { + if ("REQUEST".equals(message.payload().get("type").getAsString())) { + Assertions.assertTrue(List.of(firstTopic, secondTopic).contains(message.topic())); + JsonElement envelope = message.payload().get("data"); + Assertions.assertFalse(envelope.getAsJsonObject().has("topic")); + Assertions.assertEquals("dbeaver.rpc.v2", envelope.getAsJsonObject().get("protocol").getAsString()); + Assertions.assertEquals(1, envelope.getAsJsonObject().getAsJsonArray("data").size()); + } + } + firstGreeting.close(); + MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class, + () -> first.hello("closed")); + Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals("other:still", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("still")); + } + for (Channel channel : List.of(remoteDam, firstBackend, secondBackend)) { + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.subscriptions.isEmpty()); + Assertions.assertTrue(channel.observers.isEmpty()); + } + } + + /** Answers a greeting on the selected backend. */ + public interface Greeting { + @NotNull + String hello(@NotNull String value); + } + + private record PrefixCodec(@NotNull String encodedPrefix, @NotNull String decodedPrefix) implements MessagingRpcCodec { + @Override + @NotNull + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + return new JsonPrimitive(encodedPrefix + value); + } + + @Override + @NotNull + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return decodedPrefix + payload.getAsString(); + } + } + + private static final class Channel implements DDRemoteDamChannel { + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); + private Consumer forward = message -> { }; + private boolean closed; + + @Override + @NotNull + public DDRemoteDamChannelStatus getStatus() { + return new DDRemoteDamChannelStatus( + closed ? DDRemoteDamChannelState.CLOSED : DDRemoteDamChannelState.CONNECTED, closed ? null : SESSION); + } + + @Override + @NotNull + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(getStatus()); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDRemoteDamMessage message) { + sent.add(message); + forward.accept(message); + return CompletableFuture.completedFuture(null); + } + + private void receive(@NotNull DDRemoteDamMessage message) { + for (Subscription subscription : subscriptions) { + if (subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(SESSION, message); + } + } + } + + @Override + @NotNull + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + var subscription = new Subscription(topic, listener); + subscriptions.add(subscription); + return () -> subscriptions.remove(subscription); + } + + @Override + @NotNull + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { + return () -> { }; + } + + @Override + public void close() { + closed = true; + observers.forEach(observer -> observer.accept(getStatus())); + subscriptions.clear(); + observers.clear(); + } + } + + private record Subscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java new file mode 100644 index 0000000..8eb610c --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java @@ -0,0 +1,1648 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamProtocol; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.dbeaver.messaging.rpc.MessagingRpcCodec; +import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; +import com.dbeaver.messaging.rpc.MessagingRpcException; +import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +@Timeout(10) +class DDRemoteDamRpcTransportTest { + private static final String TOPIC = "custom.files"; + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket"); + private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); + private static final MessagingRpcCodec CODEC = new GsonCodec(); + + @Test + void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { + MemoryTransport delegate = new MemoryTransport(); + List queued = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + delegate.requestExecutor = queued::add; + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + adapter.executeRequest(calls::incrementAndGet); + Assertions.assertEquals(1, queued.size()); + Assertions.assertEquals(0, calls.get()); + queued.removeFirst().run(); + Assertions.assertEquals(1, calls.get()); + + RejectedExecutionException unavailable = new RejectedExecutionException("Request execution unavailable"); + delegate.requestExecutor = request -> { + throw unavailable; + }; + Assertions.assertSame(unavailable, + Assertions.assertThrows(RejectedExecutionException.class, () -> adapter.executeRequest(calls::incrementAndGet))); + + DDRemoteDamRpcTransport inline = new DDRemoteDamRpcTransport(delegate, Runnable::run); + inline.executeRequest(calls::incrementAndGet); + Assertions.assertEquals(2, calls.get()); + } + + @Test + void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Exception { + try (var executor = Executors.newSingleThreadExecutor()) { + for (boolean supplied : List.of(false, true)) { + FakeChannel channel = new FakeChannel(); + channel.forward = channel::deliver; + AtomicReference handlerThread = new AtomicReference<>(); + try (var endpoint = MessagingRpcEndpoint.open(topic -> supplied + ? DDRemoteDamRpcTransport.forChannel(channel, topic, executor) + : DDRemoteDamRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); + var service = endpoint.register(TOPIC, Text.class, value -> { + handlerThread.set(Thread.currentThread()); + return "reply:" + value; + })) { + Assertions.assertEquals("reply:value", endpoint.proxy(TOPIC, Text.class).apply("value")); + if (supplied) { + Assertions.assertNotEquals(Thread.currentThread(), handlerThread.get()); + } else { + Assertions.assertSame(Thread.currentThread(), handlerThread.get()); + } + } + Assertions.assertFalse(executor.isShutdown()); + Assertions.assertEquals("still available", executor.submit(() -> "still available").get(2, TimeUnit.SECONDS)); + Assertions.assertFalse(channel.closed); + } + } + } + + @Test + void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() { + FakeChannel channel = new FakeChannel(); + channel.forward = channel::deliver; + AtomicBoolean rejecting = new AtomicBoolean(true); + AtomicInteger calls = new AtomicInteger(); + Executor executor = request -> { + if (rejecting.get()) { + throw new RejectedExecutionException("Request execution unavailable"); + } + request.run(); + }; + var options = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 1, 1); + try (var endpoint = MessagingRpcEndpoint.open( + topic -> DDRemoteDamRpcTransport.forChannel(channel, topic, executor), CODEC, options); + var service = endpoint.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + return value; + })) { + Text proxy = endpoint.proxy(TOPIC, Text.class); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("rejected")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertNull(failure.remoteError()); + MessagingRequestReplyException outer = Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); + Assertions.assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, outer.remoteError()); + Assertions.assertEquals(0, calls.get()); + + rejecting.set(false); + Assertions.assertEquals("recovered", proxy.apply("recovered")); + Assertions.assertEquals(1, calls.get()); + } + } + + @Test + void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChannelDelivery() throws Exception { + try (var deliveries = Executors.newCachedThreadPool(); + var requests = Executors.newCachedThreadPool()) { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> deliveries.execute(() -> right.deliver(message)); + right.forward = message -> deliveries.execute(() -> left.deliver(message)); + try (var caller = MessagingRpcEndpoint.open( + topic -> DDRemoteDamRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open( + topic -> DDRemoteDamRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); + var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value); + var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) { + Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value")); + } + Assertions.assertFalse(requests.isShutdown()); + Assertions.assertFalse(deliveries.isShutdown()); + Assertions.assertFalse(left.closed); + Assertions.assertFalse(right.closed); + } + } + + @Test + void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + AtomicInteger nullArguments = new AtomicInteger(); + AtomicInteger notifications = new AtomicInteger(); + final DDRemoteDamSubscription borrowed = left.subscribe("other", (session, message) -> { }); + try (var caller = MessagingRpcEndpoint.open( + topic -> new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); + var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); + var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); + var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); + var records = handler.register("records", Records.class, value -> new Item(value.name(), List.of("record"))); + var nullable = handler.register("nullable", Text.class, value -> { + if (value == null) { + nullArguments.incrementAndGet(); + } + return null; + }); + var notification = handler.register("notifications", Notification.class, value -> notifications.incrementAndGet())) { + Assertions.assertEquals(List.of("right", "request"), caller.proxy("names", Names.class).apply(List.of("request"))); + Assertions.assertEquals(List.of("left", "reverse"), handler.proxy("names", Names.class).apply(List.of("reverse"))); + Assertions.assertEquals(List.of("other"), caller.proxy("other-names", Names.class).apply(List.of())); + Assertions.assertEquals(new Item("name", List.of("record")), + caller.proxy("records", Records.class).apply(new Item("name", List.of("argument")))); + Assertions.assertNull(caller.proxy("nullable", Text.class).apply(null)); + Assertions.assertEquals(1, nullArguments.get()); + caller.proxy("notifications", Notification.class).accept(null); + Assertions.assertEquals(1, notifications.get()); + Assertions.assertEquals(11, left.subscriptions.size()); + Assertions.assertEquals(10, right.subscriptions.size()); + Assertions.assertEquals("notifications", left.subscriptions.getLast().topic); + Assertions.assertEquals("notifications", right.subscriptions.getLast().topic); + otherNames.close(); + MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy("other-names", Names.class).apply(List.of())); + Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals(List.of("right", "still-open"), + caller.proxy("names", Names.class).apply(List.of("still-open"))); + } + Assertions.assertFalse(left.closed); + Assertions.assertFalse(right.closed); + Assertions.assertTrue(left.observers.isEmpty()); + Assertions.assertTrue(right.observers.isEmpty()); + Assertions.assertTrue(left.subscriptions.getFirst().active); + Assertions.assertEquals(1, left.subscriptions.stream().filter(subscription -> subscription.active).count()); + Assertions.assertTrue(right.subscriptions.stream().noneMatch(subscription -> subscription.active)); + borrowed.close(); + } + + @Test + void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + AtomicReference received = new AtomicReference<>(); + Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); + var service = handler.register("records", Records.class, value -> { + received.set(value); + value.tags().add("handler"); + return value; + })) { + Item result = caller.proxy("records", Records.class).apply(argument); + Assertions.assertEquals(List.of("initial"), argument.tags()); + Assertions.assertEquals(List.of("initial", "handler"), result.tags()); + argument.tags().add("caller"); + Assertions.assertEquals(List.of("initial", "handler"), received.get().tags()); + received.get().tags().add("later"); + Assertions.assertEquals(List.of("initial", "handler"), result.tags()); + result.tags().add("result"); + Assertions.assertEquals(List.of("initial", "handler", "later"), received.get().tags()); + } + } + + @Test + void roundTripsZeroArgumentsAndOverloadsUsingEveryParameterAcrossJsonFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + Overloaded implementation = new Overloaded() { + @Override + @NotNull + public String apply() { + return "zero"; + } + + @Override + @NotNull + public String apply(@Nullable String first) { + return "one:" + first; + } + + @Override + @NotNull + public String apply(@Nullable String first, @Nullable String second) { + return "two:" + first + ":" + second; + } + + @Override + @NotNull + public String apply(@Nullable String first, int second) { + return "number:" + first + ":" + second; + } + + @Override + @NotNull + public String apply(@Nullable String first, @Nullable String second, @Nullable String third) { + return "three:" + first + ":" + second + ":" + third; + } + }; + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Overloaded.class, implementation)) { + Overloaded proxy = caller.proxy(TOPIC, Overloaded.class); + Assertions.assertEquals("zero", proxy.apply()); + Assertions.assertEquals("one:first", proxy.apply("first")); + Assertions.assertEquals("two:first:second", proxy.apply("first", "second")); + Assertions.assertEquals("number:first:2", proxy.apply("first", 2)); + Assertions.assertEquals("three:first:null:third", proxy.apply("first", null, "third")); + Assertions.assertEquals("one:null", proxy.apply(null)); + Assertions.assertEquals("two:null:null", proxy.apply(null, null)); + List expectedData = List.of("[]", "[\"first\"]", "[\"first\",\"second\"]", "[\"first\",2]", + "[\"first\",null,\"third\"]", "[null]", "[null,null]"); + String text = String.class.getTypeName(); + List expectedOperations = List.of("apply()", "apply(" + text + ")", "apply(" + text + "," + text + ")", + "apply(" + text + ",int)", "apply(" + text + "," + text + "," + text + ")", "apply(" + text + ")", + "apply(" + text + "," + text + ")"); + Assertions.assertEquals(expectedData.size(), left.sent.size()); + Assertions.assertEquals(expectedData.size(), right.sent.size()); + for (int i = 0; i < expectedData.size(); i++) { + JsonObject request = left.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), request.keySet()); + Assertions.assertEquals(expectedOperations.get(i), request.get("operation").getAsString()); + Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data")); + JsonObject response = right.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals(Set.of("protocol", "type", "data"), response.keySet()); + Assertions.assertEquals("RESPONSE", response.get("type").getAsString()); + Assertions.assertTrue(response.get("data").isJsonPrimitive()); + } + } + } + + @Test + void roundTripsVarargsAsOneArrayArgumentAcrossJsonFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Varargs.class, + (prefix, values) -> prefix + ":" + (values == null ? "null" : Arrays.toString(values)))) { + Varargs proxy = caller.proxy(TOPIC, Varargs.class); + Assertions.assertEquals("empty:[]", proxy.apply("empty")); + Assertions.assertEquals("many:[first, null, third]", proxy.apply("many", "first", null, "third")); + Assertions.assertEquals("null:null", proxy.apply("null", (String[]) null)); + List expectedData = List.of("[\"empty\",[]]", "[\"many\",[\"first\",null,\"third\"]]", "[\"null\",null]"); + Assertions.assertEquals(expectedData.size(), left.sent.size()); + for (int i = 0; i < expectedData.size(); i++) { + JsonObject request = left.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals("apply(" + String.class.getTypeName() + "," + String[].class.getTypeName() + ")", + request.get("operation").getAsString()); + Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data")); + Assertions.assertEquals(2, request.getAsJsonArray("data").size()); + } + } + } + + @Test + void roundTripsEachArgumentsDeclaredRecordAndGenericTypesAcrossJsonFraming() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + CountingCodec callerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson())); + CountingCodec handlerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson())); + Item selected = new Item("selected", List.of("chosen")); + List items = List.of(new Item("nested", List.of("tag"))); + List numbers = List.of(7, 8); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, RecordArguments.class, (label, item, values, counts) -> { + Assertions.assertEquals(items, values); + Assertions.assertEquals(numbers, counts); + Assertions.assertInstanceOf(Item.class, values.getFirst()); + Assertions.assertInstanceOf(Integer.class, counts.getFirst()); + return new Item(label + ":" + (item == null ? "null" : item.name()) + ":" + counts.getFirst(), + values.getFirst().tags()); + })) { + RecordArguments proxy = caller.proxy(TOPIC, RecordArguments.class); + Assertions.assertEquals(new Item("label:selected:7", List.of("tag")), proxy.apply("label", selected, items, numbers)); + Assertions.assertEquals(new Item("null:null:7", List.of("tag")), proxy.apply(null, null, items, numbers)); + List argumentTypes = Arrays.asList(RecordArguments.class + .getMethod("apply", String.class, Item.class, List.class, List.class).getGenericParameterTypes()); + Assertions.assertEquals(8, callerCodec.encodedTypes.size()); + Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(0, 4)); + Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(4, 8)); + Assertions.assertEquals(callerCodec.encodedTypes, handlerCodec.decodedTypes); + Assertions.assertEquals(List.of(Item.class, Item.class), handlerCodec.encodedTypes); + Assertions.assertEquals(handlerCodec.encodedTypes, callerCodec.decodedTypes); + Assertions.assertEquals(JsonParser.parseString("[\"label\",{\"name\":\"selected\",\"tags\":[\"chosen\"]}," + + "[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"), + left.sent.getFirst().payload().getAsJsonObject("data").get("data")); + Assertions.assertEquals(JsonParser.parseString("[null,null,[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"), + left.sent.getLast().payload().getAsJsonObject("data").get("data")); + } + } + + @Test + void rejectsWrongArgumentCountsAndNullPrimitiveArgumentsAcrossJsonFraming() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + AtomicInteger calls = new AtomicInteger(); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Numbers.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + second + third; + })) { + for (List arguments : List.>of(List.of(), List.of(new JsonPrimitive(1)), + List.of(new JsonPrimitive(1), new JsonPrimitive(2)), + List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3), new JsonPrimitive(4)), + Arrays.asList(new JsonPrimitive(1), null, new JsonPrimitive(3)))) { + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + caller.request(MessagingRpcMessage.request("sum(int,int,int)", arguments))); + } + Assertions.assertEquals(0, calls.get()); + Assertions.assertEquals(MessagingRpcMessage.response(new JsonPrimitive(6)), + caller.request(MessagingRpcMessage.request("sum(int,int,int)", + List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3))))); + Assertions.assertEquals(1, calls.get()); + } + } + + @Test + void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + MessagingRequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); + try (var subscription = adapter.subscribe(endpoint, received)) { + Assertions.assertEquals(endpoint, delegate.endpoint); + adapter.send(request).toCompletableFuture().join(); + MessagingRequestReplyMessage sent = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), sent.requestId()); + Assertions.assertEquals(request.replyTo(), sent.replyTo()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, sent.type()); + Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); + JsonObject expectedRequest = requestEnvelope(); + expectedRequest.add("data", JsonParser.parseString("[\"argument\"]")); + Assertions.assertEquals(expectedRequest, sent.payload()); + Assertions.assertFalse(sent.payload().has("topic")); + delegate.deliver(sent); + Assertions.assertEquals(request, received.messages.getLast()); + MessagingRequestReplyMessage> response = + request.response(MessagingRpcMessage.response(new JsonPrimitive(42))); + adapter.send(response).toCompletableFuture().join(); + JsonObject expectedResponse = responseEnvelope(); + expectedResponse.addProperty("data", 42); + Assertions.assertEquals(expectedResponse, delegate.sent.getLast().payload()); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(response, received.messages.getLast()); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + MessagingRequestReplyMessage> failure = + request.response(MessagingRpcMessage.failure(error)); + adapter.send(failure).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), wire.requestId()); + Assertions.assertEquals(request.replyTo(), wire.replyTo()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertNull(wire.error()); + Assertions.assertFalse(Objects.requireNonNull(wire.payload()).has("data")); + Assertions.assertEquals(error.name(), wire.payload().get("error").getAsString()); + Assertions.assertEquals(JsonNull.INSTANCE, wire.payload().get("exception")); + Assertions.assertEquals(errorEnvelope(error), wire.payload()); + delegate.deliver(wire); + Assertions.assertEquals(failure, received.messages.getLast()); + } + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + MessagingRequestReplyMessage> failure = request.failure(error); + adapter.send(failure).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertNull(wire.payload()); + Assertions.assertEquals(error, wire.error()); + delegate.deliver(wire); + Assertions.assertEquals(failure, received.messages.getLast()); + } + adapter.send(request.response(null)).toCompletableFuture().join(); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(request.response(null), received.messages.getLast()); + } + Assertions.assertTrue(delegate.closed); + } + + @Test + void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterFrames() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + MessagingRequestReplyMessage> request = request(endpoint, null); + List diagnostics = List.of( + new MessagingRpcExceptionDetails("application.Failure", "quoted \"message\"\n雪😀", "trace\r\n\t\0\\雪😀", false), + new MessagingRpcExceptionDetails("application.Failure", null, "", false), + new MessagingRpcExceptionDetails("application.Failure", "", "bounded trace", true) + ); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + for (MessagingRpcExceptionDetails details : diagnostics) { + MessagingRequestReplyMessage> failure = + request.response(MessagingRpcMessage.failure(error, details)); + adapter.send(failure).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), wire.requestId()); + Assertions.assertEquals(request.replyTo(), wire.replyTo()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertNull(wire.error()); + JsonObject envelope = Objects.requireNonNull(wire.payload()); + Assertions.assertEquals(Set.of("protocol", "type", "error", "exception"), envelope.keySet()); + final JsonObject expected = errorEnvelope(error); + JsonObject exception = new JsonObject(); + exception.addProperty("type", details.type()); + exception.add("message", details.message() == null ? JsonNull.INSTANCE : new JsonPrimitive(details.message())); + exception.addProperty("stackTrace", details.stackTrace()); + exception.addProperty("truncated", details.truncated()); + expected.add("exception", exception); + Assertions.assertEquals(expected, envelope); + delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), + JsonParser.parseString(envelope.toString()).getAsJsonObject(), wire.error())); + Assertions.assertEquals(failure, received.messages.getLast()); + } + } + Assertions.assertEquals(MessagingRpcMessage.Error.values().length * diagnostics.size(), received.messages.size()); + } + } + + @Test + void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, "x".repeat(256)); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + MessagingRequestReplyMessage> request = request(endpoint, null); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (String unit : List.of("\0", "\"\\\n\r\t\b\f\37", "\u2028x\u2029x", "雪", "😀")) { + MessagingRpcExceptionDetails details = new MessagingRpcExceptionDetails( + unit.repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH / unit.length()), + unit.repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH / unit.length()), + unit.repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH / unit.length()), true); + Assertions.assertEquals(256, details.type().length()); + Assertions.assertEquals(1024, Objects.requireNonNull(details.message()).length()); + Assertions.assertEquals(8192, details.stackTrace().length()); + MessagingRequestReplyMessage> failure = request.response( + MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION, details)); + adapter.send(failure).toCompletableFuture().join(); + DDRemoteDamMessage wire = channel.sent.getLast(); + Assertions.assertEquals("datadam.requestreply.v1", wire.type()); + JsonObject outer = wire.payload(); + Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), outer.keySet()); + Assertions.assertEquals(request.requestId().toString(), outer.get("requestId").getAsString()); + Assertions.assertEquals(endpoint.toString(), outer.get("replyTo").getAsString()); + Assertions.assertEquals("RESPONSE", outer.get("type").getAsString()); + Assertions.assertEquals("dbeaver.rpc.v2", outer.getAsJsonObject("data").get("protocol").getAsString()); + String encoded = DDRemoteDamProtocol.encode(wire); + Assertions.assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= 65_536); + channel.deliver(DDRemoteDamProtocol.decode(encoded)); + Assertions.assertEquals(failure, received.messages.getLast()); + } + Assertions.assertEquals(5, channel.sent.size()); + Assertions.assertEquals(5, received.messages.size()); + } + } + + @Test + void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + List malformed = malformedExceptions(); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + for (JsonElement details : malformed) { + JsonObject envelope = errorEnvelope(error); + envelope.add("exception", details); + delegate.deliver(request.response(envelope)); + } + } + Assertions.assertTrue(received.messages.isEmpty()); + } + } + + @Test + void roundTripsZeroSingleAndMultipleArgumentsAsJsonArrays() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + List> cases = List.of(List.of(), Collections.singletonList(null), List.of(new JsonPrimitive("single")), + Arrays.asList(new JsonPrimitive("first"), null, new JsonPrimitive(3)), + List.of(JsonParser.parseString("[1,null,3]"), JsonNull.INSTANCE, JsonParser.parseString("{\"name\":\"record\"}"))); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (List arguments : cases) { + MessagingRequestReplyMessage> request = MessagingRequestReplyMessage.request( + UUID.randomUUID(), endpoint, MessagingRpcMessage.request("apply", arguments)); + adapter.send(request).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + JsonObject envelope = JsonParser.parseString(Objects.requireNonNull(wire.payload()).toString()).getAsJsonObject(); + Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), envelope.keySet()); + JsonArray expected = new JsonArray(); + arguments.forEach(expected::add); + Assertions.assertEquals(expected, envelope.get("data")); + delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), envelope, wire.error())); + MessagingRequestReplyMessage> decoded = received.messages.getLast(); + Assertions.assertEquals(request.requestId(), decoded.requestId()); + Assertions.assertEquals(endpoint, decoded.replyTo()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, decoded.type()); + MessagingRpcMessage rpc = Objects.requireNonNull(decoded.payload()); + Assertions.assertEquals(MessagingRpcMessage.Type.REQUEST, rpc.type()); + Assertions.assertEquals("apply", rpc.operation()); + Assertions.assertEquals(arguments.stream().map(value -> value == null || value.isJsonNull() ? null : value).toList(), + rpc.arguments()); + Assertions.assertNull(rpc.payload()); + Assertions.assertNull(rpc.error()); + Assertions.assertNull(rpc.exception()); + } + Assertions.assertEquals(cases.size(), received.messages.size()); + } + } + + @Test + void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + List values = List.of(JsonParser.parseString("{\"items\":[1,true,null]}"), + JsonParser.parseString("[\"a\",2,null]"), new JsonPrimitive("text"), new JsonPrimitive(42), + new JsonPrimitive(true), JsonNull.INSTANCE); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (JsonElement value : values) { + MessagingRequestReplyMessage> request = request(endpoint, value); + adapter.send(request).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + JsonArray arguments = Objects.requireNonNull(wire.payload()).getAsJsonArray("data"); + Assertions.assertEquals(1, arguments.size()); + Assertions.assertEquals(value, arguments.get(0)); + delegate.deliver(wire); + MessagingRpcMessage decoded = Objects.requireNonNull(received.messages.getLast().payload()); + Assertions.assertNull(decoded.payload()); + Assertions.assertEquals(1, decoded.arguments().size()); + Assertions.assertEquals(value.isJsonNull() ? null : value, + decoded.arguments().getFirst()); + adapter.send(request.response(MessagingRpcMessage.response(value))).toCompletableFuture().join(); + Assertions.assertEquals(value, Objects.requireNonNull(delegate.sent.getLast().payload()).get("data")); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(MessagingRpcMessage.Type.RESPONSE, + Objects.requireNonNull(received.messages.getLast().payload()).type()); + Assertions.assertTrue(received.messages.getLast().payload().arguments().isEmpty()); + Assertions.assertEquals(value.isJsonNull() ? null : value, received.messages.getLast().payload().payload()); + } + } + } + + @Test + void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (MessagingRequestReplyMessage.Type type : List.of( + MessagingRequestReplyMessage.Type.REQUEST, MessagingRequestReplyMessage.Type.RESPONSE)) { + JsonObject argument = JsonParser.parseString("{\"items\":[\"original\"]}").getAsJsonObject(); + MessagingRequestReplyMessage> request = request(endpoint, argument); + adapter.send(type == MessagingRequestReplyMessage.Type.REQUEST + ? request : request.response(MessagingRpcMessage.response(argument))); + JsonObject wire = Objects.requireNonNull(delegate.sent.getLast().payload()); + JsonObject wireValue = (type == MessagingRequestReplyMessage.Type.REQUEST + ? wire.getAsJsonArray("data").get(0) : wire.get("data")).getAsJsonObject(); + argument.getAsJsonArray("items").add("caller"); + Assertions.assertEquals(1, wireValue.getAsJsonArray("items").size()); + delegate.deliver(delegate.sent.getLast()); + MessagingRpcMessage receivedMessage = Objects.requireNonNull(received.messages.getLast().payload()); + JsonObject decoded = Objects.requireNonNull(type == MessagingRequestReplyMessage.Type.REQUEST + ? receivedMessage.arguments().getFirst() : receivedMessage.payload()).getAsJsonObject(); + wireValue.getAsJsonArray("items").add("delegate"); + Assertions.assertEquals(1, decoded.getAsJsonArray("items").size()); + decoded.getAsJsonArray("items").add("listener"); + Assertions.assertEquals("delegate", wireValue.getAsJsonArray("items").get(1).getAsString()); + Assertions.assertEquals("caller", argument.getAsJsonArray("items").get(1).getAsString()); + } + } + } + + @Test + void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + Received received = new Received(); + UUID endpoint = UUID.randomUUID(); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, + new JsonObject(), new JsonArray()); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (String field : List.of("protocol", "type", "operation")) { + JsonObject missing = requestEnvelope(); + missing.remove(field); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, missing)); + for (JsonElement value : nonStrings) { + JsonObject invalid = requestEnvelope(); + invalid.add(field, value); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } + } + List> malformed = List.of( + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), + envelope -> envelope.addProperty("protocol", "unrelated"), + envelope -> envelope.addProperty("type", "OTHER"), + envelope -> envelope.addProperty("type", "request"), + envelope -> envelope.addProperty("type", "RESPONSE"), + envelope -> envelope.addProperty("type", "ERROR"), + envelope -> envelope.addProperty("topic", "legacy.topic"), + envelope -> envelope.addProperty("operation", "\t"), + envelope -> envelope.addProperty("operation", "x".repeat(1025)), + envelope -> envelope.remove("data"), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("exception", exceptionEnvelope()), + envelope -> envelope.addProperty("extra", true) + ); + for (Consumer mutation : malformed) { + JsonObject invalid = requestEnvelope(); + mutation.accept(invalid); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } + for (JsonElement data : List.of(JsonNull.INSTANCE, new JsonPrimitive("legacy scalar"), + new JsonPrimitive("[]"), new JsonPrimitive(1), new JsonPrimitive(true), new JsonObject())) { + JsonObject invalid = requestEnvelope(); + invalid.add("data", data); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } + Assertions.assertTrue(received.messages.isEmpty()); + JsonObject boundary = requestEnvelope(); + boundary.addProperty("operation", "x".repeat(1024)); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, boundary)); + Assertions.assertEquals(1, received.messages.size()); + MessagingRpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); + Assertions.assertEquals("x".repeat(1024), accepted.operation()); + Assertions.assertEquals(Collections.singletonList(null), accepted.arguments()); + Assertions.assertNull(accepted.payload()); + } + } + + @Test + void dropsMalformedResponseAndNestedErrorShapes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + Received received = new Received(); + UUID endpoint = UUID.randomUUID(); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + try (var subscription = adapter.subscribe(endpoint, received)) { + List> malformedResponses = List.of( + envelope -> envelope.remove("data"), + envelope -> envelope.add("topic", JsonNull.INSTANCE), + envelope -> envelope.addProperty("operation", "apply"), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("exception", exceptionEnvelope()), + envelope -> envelope.addProperty("protocol", 1), + envelope -> envelope.addProperty("type", true), + envelope -> envelope.addProperty("type", "OTHER") + ); + for (Consumer mutation : malformedResponses) { + JsonObject invalid = responseEnvelope(); + mutation.accept(invalid); + delegate.deliver(request.response(invalid)); + } + delegate.deliver(request.response(requestEnvelope())); + List> malformedErrors = List.of( + envelope -> envelope.remove("error"), + envelope -> envelope.remove("exception"), + envelope -> envelope.addProperty("error", "OTHER"), + envelope -> envelope.addProperty("error", 1), + envelope -> envelope.addProperty("error", true), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("error", new JsonObject()), + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), + envelope -> envelope.addProperty("type", "OTHER"), + envelope -> envelope.add("data", JsonNull.INSTANCE), + envelope -> envelope.addProperty("topic", "names"), + envelope -> envelope.add("operation", JsonNull.INSTANCE) + ); + for (Consumer mutation : malformedErrors) { + for (JsonElement details : List.of(JsonNull.INSTANCE, exceptionEnvelope())) { + JsonObject invalid = errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED); + invalid.add("exception", details); + mutation.accept(invalid); + delegate.deliver(request.response(invalid)); + } + } + Assertions.assertTrue(received.messages.isEmpty()); + delegate.deliver(request.response(responseEnvelope())); + delegate.deliver(request.response(errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED))); + Assertions.assertEquals(2, received.messages.size()); + } + } + + @Test + void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + adapter.send(request(endpoint, null)).toCompletableFuture().join(); + DDRemoteDamMessage valid = channel.sent.getLast(); + for (JsonElement value : List.of(new JsonPrimitive("text"), new JsonArray(), JsonNull.INSTANCE)) { + JsonObject invalid = valid.payload(); + invalid.add("data", value); + channel.deliver(new DDRemoteDamMessage(valid.topic(), valid.type(), invalid)); + } + Assertions.assertTrue(received.messages.isEmpty()); + channel.deliver(valid); + Assertions.assertEquals(1, received.messages.size()); + } + } + + @Test + void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, OPTIONS)) { + Text proxy = endpoint.proxy("text", Text.class); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(error, failure.remoteError()); + Assertions.assertNull(failure.remoteException()); + Assertions.assertNull(failure.getCause()); + Assertions.assertEquals("Remote RPC call failed: " + error, failure.getMessage()); + } + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + delegate.forward = request -> delegate.deliver(request.failure(error)); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertNull(failure.remoteError()); + Assertions.assertNull(failure.remoteException()); + MessagingRequestReplyException outer = + Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); + Assertions.assertEquals(error, outer.remoteError()); + } + delegate.forward = request -> delegate.deliver(request.response(null)); + MessagingRpcException invalid = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.INVALID_RESPONSE, invalid.reason()); + } + } + + @Test + void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + MessagingRequestReplyOptions options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 4, 2); + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, options)) { + for (Consumer mutation : List.>of( + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), + envelope -> envelope.addProperty("type", "UNKNOWN"), + envelope -> envelope.remove("data") + )) { + JsonObject invalid = responseEnvelope(); + mutation.accept(invalid); + delegate.forward = request -> delegate.deliver(request.response(invalid)); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, + () -> endpoint.proxy("text", Text.class).apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.TIMEOUT, failure.reason()); + } + Assertions.assertEquals(3, delegate.sent.size()); + } + } + + @Test + void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS); + var missingCaller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, "missing"), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { + throw new IllegalStateException("Handler failed"); + })) { + handler.proxy("missing", Text.class); + MessagingRpcMessage missingService = + missingCaller.request(MessagingRpcMessage.request("apply", Collections.singletonList(null))); + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE), missingService); + MessagingRpcMessage missingOperation = + caller.request(MessagingRpcMessage.request("missing", Collections.singletonList(null))); + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), missingOperation); + String operation = "apply(" + String.class.getTypeName() + ")"; + final JsonSyntaxException expectedCodecFailure = Assertions.assertThrows(JsonSyntaxException.class, + () -> CODEC.decode(new JsonObject(), String.class)); + MessagingRpcMessage codecFailure = + caller.request(MessagingRpcMessage.request(operation, List.of(new JsonObject()))); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, codecFailure.error()); + MessagingRpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); + Assertions.assertEquals(expectedCodecFailure.getClass().getName(), codecDetails.type()); + Assertions.assertEquals(expectedCodecFailure.getMessage(), codecDetails.message()); + Assertions.assertTrue(codecDetails.stackTrace().startsWith(expectedCodecFailure + System.lineSeparator())); + MessagingRpcMessage handlerFailure = + caller.request(MessagingRpcMessage.request(operation, List.of(new JsonPrimitive("value")))); + Assertions.assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); + MessagingRpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); + Assertions.assertEquals(IllegalStateException.class.getName(), handlerDetails.type()); + Assertions.assertEquals("Handler failed", handlerDetails.message()); + Assertions.assertTrue(handlerDetails.stackTrace().contains("IllegalStateException: Handler failed")); + Assertions.assertTrue(right.sent.stream().allMatch(message -> + "RESPONSE".equals(message.payload().get("type").getAsString()))); + } + } + + @Test + void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcrossReferenceFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + Throwable root = new UnsupportedOperationException("root cause"); + Throwable nested = new IllegalArgumentException("nested cause", root); + Throwable suppressed = new IllegalArgumentException("suppressed failure"); + IllegalStateException application = new IllegalStateException("application \"failure\"\n雪😀", nested); + application.addSuppressed(suppressed); + for (Throwable failure : List.of(root, nested, suppressed, application)) { + failure.setStackTrace(new StackTraceElement[] {new StackTraceElement("Application", "execute", "Application.java", 42)}); + } + StringWriter trace = new StringWriter(); + application.printStackTrace(new PrintWriter(trace)); + CountingCodec callerCodec = new CountingCodec(); + CountingCodec handlerCodec = new CountingCodec(); + AtomicInteger calls = new AtomicInteger(); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + throw application; + })) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + Assertions.assertEquals(new MessagingRpcExceptionDetails(application.getClass().getName(), application.getMessage(), + trace.toString(), false), failure.remoteException()); + Assertions.assertTrue(failure.getMessage().contains(application.getClass().getName())); + Assertions.assertTrue(failure.getMessage().contains(application.getMessage())); + Throwable remote = Objects.requireNonNull(failure.getCause()); + Assertions.assertEquals("Remote stack trace:\n" + trace, remote.getMessage()); + Assertions.assertNull(remote.getCause()); + Assertions.assertEquals(0, remote.getStackTrace().length); + Assertions.assertEquals(0, remote.getSuppressed().length); + Assertions.assertEquals(0, failure.getSuppressed().length); + Assertions.assertEquals(1, calls.get()); + Assertions.assertEquals(1, callerCodec.encodes.get()); + Assertions.assertEquals(0, callerCodec.decodes.get()); + Assertions.assertEquals(0, handlerCodec.encodes.get()); + Assertions.assertEquals(1, handlerCodec.decodes.get()); + Assertions.assertEquals(1, left.sent.size()); + Assertions.assertEquals(1, right.sent.size()); + JsonObject request = left.sent.getFirst().payload(); + JsonObject response = right.sent.getFirst().payload(); + Assertions.assertEquals(request.get("requestId"), response.get("requestId")); + Assertions.assertEquals(request.get("replyTo"), response.get("replyTo")); + Assertions.assertEquals("datadam.requestreply.v1", right.sent.getFirst().type()); + Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), response.keySet()); + Assertions.assertEquals("RESPONSE", response.get("type").getAsString()); + JsonObject rpc = response.getAsJsonObject("data"); + Assertions.assertEquals("dbeaver.rpc.v2", rpc.get("protocol").getAsString()); + Assertions.assertEquals("ERROR", rpc.get("type").getAsString()); + Assertions.assertEquals("HANDLER_FAILED", rpc.get("error").getAsString()); + } + } + + @Test + void remoteArgumentAndResultCodecFailuresCarryDiagnosticsWithoutEncodingErrorsOrRetrying() { + for (boolean encoding : List.of(false, true)) { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + CountingCodec callerCodec = new CountingCodec(); + AtomicInteger encodes = new AtomicInteger(); + AtomicInteger decodes = new AtomicInteger(); + AtomicInteger calls = new AtomicInteger(); + IllegalStateException unavailable = new IllegalStateException(encoding ? "Result encoding failed" : "Argument decoding failed"); + MessagingRpcCodec handlerCodec = new MessagingRpcCodec<>() { + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + encodes.incrementAndGet(); + throw unavailable; + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws Exception { + decodes.incrementAndGet(); + if (!encoding) { + throw unavailable; + } + return CODEC.decode(payload, type); + } + }; + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + return value; + })) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, failure.remoteError()); + MessagingRpcExceptionDetails details = Objects.requireNonNull(failure.remoteException()); + Assertions.assertEquals(IllegalStateException.class.getName(), details.type()); + Assertions.assertEquals(unavailable.getMessage(), details.message()); + Assertions.assertTrue(details.stackTrace().contains("IllegalStateException: " + unavailable.getMessage())); + Assertions.assertTrue(Objects.requireNonNull(failure.getCause()).getMessage().contains(unavailable.getMessage())); + Assertions.assertEquals(encoding ? 1 : 0, calls.get()); + Assertions.assertEquals(encoding ? 1 : 0, encodes.get()); + Assertions.assertEquals(1, decodes.get()); + Assertions.assertEquals(1, callerCodec.encodes.get()); + Assertions.assertEquals(0, callerCodec.decodes.get()); + Assertions.assertEquals(1, left.sent.size()); + Assertions.assertEquals(1, right.sent.size()); + } + } + } + + @Test + void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + MessagingRequestReplyMessage> request = request(endpoint, null); + List>> malformed = List.of( + MessagingRequestReplyMessage.request(request.requestId(), endpoint, MessagingRpcMessage.response(null)), + MessagingRequestReplyMessage.request(request.requestId(), endpoint, + MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST)), + request.response(request.payload()) + ); + for (MessagingRequestReplyMessage> message : malformed) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> adapter.send(message).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertInstanceOf(IllegalArgumentException.class, failure.getCause()); + } + Assertions.assertTrue(delegate.sent.isEmpty()); + } + + @Test + void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + MessagingRequestReplyMessage> request = request(UUID.randomUUID(), null); + CompletableFuture pending = new CompletableFuture<>(); + delegate.sendResult = pending; + CompletionStage pendingResult = adapter.send(request); + Assertions.assertFalse(pendingResult.toCompletableFuture().isDone()); + pending.complete(null); + Assertions.assertNull(pendingResult.toCompletableFuture().get(2, TimeUnit.SECONDS)); + for (MessagingRequestReplyException.Reason reason : List.of(MessagingRequestReplyException.Reason.DISCONNECTED, + MessagingRequestReplyException.Reason.CLOSED, MessagingRequestReplyException.Reason.OVERLOADED, + MessagingRequestReplyException.Reason.SEND_FAILED)) { + MessagingRequestReplyException unavailable = new MessagingRequestReplyException(reason); + delegate.sendResult = CompletableFuture.failedFuture(unavailable); + CompletionStage result = adapter.send(request); + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + } + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + delegate.sendFailure = unavailable; + for (int i = 0; i < 129; i++) { + CompletionStage result = Assertions.assertDoesNotThrow(() -> adapter.send(request)); + assertSendFailure(unavailable, result); + } + delegate.sendFailure = null; + delegate.sendResult = CompletableFuture.completedFuture(null); + adapter.send(request).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(135, delegate.sent.size()); + } + + @Test + void boundsFramingBeforeCopyingAndHoldsPermitsUntilActualSendCompletion() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + AtomicInteger copies = new AtomicInteger(); + JsonElement value = new JsonElement() { + @Override + @NotNull + public JsonElement deepCopy() { + copies.incrementAndGet(); + return new JsonPrimitive("snapshot"); + } + }; + MessagingRequestReplyMessage> request = request(UUID.randomUUID(), value); + MessagingRequestReplyMessage> response = request.response(MessagingRpcMessage.response(value)); + List> pending = new ArrayList<>(); + List> results = new ArrayList<>(); + try { + for (int i = 0; i < 128; i++) { + CompletableFuture send = new CompletableFuture<>(); + pending.add(send); + delegate.sendResult = send; + results.add(adapter.send(response).toCompletableFuture()); + } + Assertions.assertEquals(128, delegate.sent.size()); + Assertions.assertEquals(128, copies.get()); + Assertions.assertTrue(results.stream().noneMatch(CompletableFuture::isDone)); + assertOverloaded(adapter.send(response)); + assertOverloaded(adapter.send(request)); + assertOverloaded(adapter.send(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED))); + Assertions.assertEquals(128, delegate.sent.size()); + Assertions.assertEquals(128, copies.get()); + + pending.getFirst().complete(null); + Assertions.assertNull(results.getFirst().get(2, TimeUnit.SECONDS)); + CompletableFuture afterCompletion = new CompletableFuture<>(); + pending.add(afterCompletion); + delegate.sendResult = afterCompletion; + results.add(adapter.send(request).toCompletableFuture()); + Assertions.assertEquals(129, delegate.sent.size()); + assertOverloaded(adapter.send(response)); + + MessagingRequestReplyException unavailable = + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + pending.get(1).completeExceptionally(unavailable); + assertSendFailure(unavailable, results.get(1)); + CompletableFuture afterFailure = new CompletableFuture<>(); + pending.add(afterFailure); + delegate.sendResult = afterFailure; + results.add(adapter.send(response).toCompletableFuture()); + Assertions.assertEquals(130, delegate.sent.size()); + assertOverloaded(adapter.send(response)); + + Assertions.assertTrue(results.get(2).cancel(false)); + Assertions.assertFalse(pending.get(2).isDone()); + assertOverloaded(adapter.send(response)); + Assertions.assertEquals(130, delegate.sent.size()); + Assertions.assertEquals(130, copies.get()); + pending.get(2).complete(null); + Assertions.assertTrue(results.get(2).isCancelled()); + CompletableFuture afterCancellation = new CompletableFuture<>(); + pending.add(afterCancellation); + delegate.sendResult = afterCancellation; + results.add(adapter.send(response).toCompletableFuture()); + Assertions.assertEquals(131, delegate.sent.size()); + Assertions.assertEquals(131, copies.get()); + assertOverloaded(adapter.send(response)); + } finally { + pending.forEach(send -> send.complete(null)); + } + } + + @Test + void releasesPermitsAfterSnapshotFramingFailures() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + IllegalStateException unavailable = new IllegalStateException("Snapshot unavailable"); + JsonElement value = new JsonElement() { + @Override + @NotNull + public JsonElement deepCopy() { + throw unavailable; + } + }; + UUID endpoint = UUID.randomUUID(); + MessagingRequestReplyMessage> request = request(endpoint, value); + for (int i = 0; i < 129; i++) { + CompletionStage result = Assertions.assertDoesNotThrow(() -> adapter.send(request)); + assertSendFailure(unavailable, result); + } + Assertions.assertTrue(delegate.sent.isEmpty()); + adapter.send(request(endpoint, new JsonPrimitive("recovered"))).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(1, delegate.sent.size()); + } + + @Test + void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); + Received received = new Received(); + MessagingSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); + Assertions.assertSame(delegate.subscription, subscription); + Objects.requireNonNull(delegate.listener).onDisconnect(); + Assertions.assertEquals(1, received.disconnects); + subscription.close(); + Assertions.assertTrue(delegate.closed); + MessagingRequestReplyException unavailable = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + MemoryTransport failing = new MemoryTransport(); + failing.subscribeFailure = unavailable; + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> new DDRemoteDamRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(unavailable, failure); + } + + @Test + void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = TOPIC; + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, TOPIC); + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); + Assertions.assertEquals(1, channel.subscriptions.size()); + Assertions.assertFalse(channel.subscriptions.getFirst().active); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + } + + @Test + void failedLazyTopicSubscriptionRollsBackAndCanBeRetried() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = TOPIC; + try (var endpoint = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); + Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, failure.reason()); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause().getCause()); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + channel.failTopic = null; + endpoint.proxy(TOPIC, Text.class); + Assertions.assertEquals(2, channel.subscriptions.stream().filter(subscription -> subscription.active).count()); + } + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @Test + void incompatibleCodecPayloadsUseNormalSendAndRemoteCodecFailures() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + MessagingRpcCodec incompatible = new MessagingRpcCodec<>() { + @Override + @Nullable + public String encode(@Nullable Object value, @NotNull Type type) { + return String.class.cast(value); + } + + @Override + @Nullable + public Object decode(@Nullable String payload, @NotNull Type type) { + return payload; + } + }; + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> value)) { + MessagingRpcException sendFailure = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy(TOPIC, Text.class, incompatible).apply("incompatible")); + Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, sendFailure.reason()); + Assertions.assertEquals("compatible", caller.proxy(TOPIC, Text.class).apply("compatible")); + service.close(); + try (var replacement = handler.register(TOPIC, Text.class, value -> value, incompatible)) { + MessagingRpcException codecFailure = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("incompatible")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, codecFailure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, codecFailure.remoteError()); + MessagingRpcExceptionDetails details = Objects.requireNonNull(codecFailure.remoteException()); + Assertions.assertEquals(ClassCastException.class.getName(), details.type()); + Assertions.assertTrue(details.stackTrace().contains(ClassCastException.class.getName())); + Assertions.assertTrue(Objects.requireNonNull(codecFailure.getCause()).getMessage() + .contains(ClassCastException.class.getName())); + } + } + } + + @Test + void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { + FakeChannel channel = new FakeChannel(); + channel.forward = message -> channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + try (var endpoint = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, + () -> endpoint.proxy("text", Text.class).apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.DISCONNECTED, failure.reason()); + } + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @NotNull + private static MessagingRequestReplyMessage> request( + @NotNull UUID endpoint, + @Nullable JsonElement value + ) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, + MessagingRpcMessage.request("apply", Collections.singletonList(value))); + } + + private static void assertSendFailure(@NotNull Throwable expected, @NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(expected, failure.getCause()); + } + + private static void assertOverloaded(@NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + MessagingRequestReplyException unavailable = Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); + Assertions.assertEquals(MessagingRequestReplyException.Reason.OVERLOADED, unavailable.reason()); + } + + @NotNull + private static JsonObject requestEnvelope() { + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"REQUEST\"," + + "\"operation\":\"apply\",\"data\":[null]}").getAsJsonObject(); + } + + @NotNull + private static JsonObject responseEnvelope() { + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"RESPONSE\",\"data\":null}").getAsJsonObject(); + } + + @NotNull + private static JsonObject errorEnvelope(@NotNull MessagingRpcMessage.Error error) { + JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"ERROR\"}").getAsJsonObject(); + result.addProperty("error", error.name()); + result.add("exception", JsonNull.INSTANCE); + return result; + } + + @NotNull + private static JsonObject exceptionEnvelope() { + return JsonParser.parseString("{\"type\":\"application.Failure\",\"message\":\"failure\"," + + "\"stackTrace\":\"trace\",\"truncated\":false}").getAsJsonObject(); + } + + @NotNull + private static List malformedExceptions() { + List invalid = new ArrayList<>(List.of(new JsonPrimitive("failure"), + new JsonPrimitive(1), new JsonPrimitive(true), new JsonArray(), new JsonObject())); + List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, + new JsonObject(), new JsonArray()); + for (String field : List.of("type", "message", "stackTrace", "truncated")) { + JsonObject missing = exceptionEnvelope(); + missing.remove(field); + invalid.add(missing); + JsonObject unknown = missing.deepCopy(); + unknown.addProperty("extra", "unexpected"); + invalid.add(unknown); + List wrongTypes = field.equals("truncated") + ? List.of(new JsonPrimitive("true"), new JsonPrimitive(1), JsonNull.INSTANCE, new JsonObject(), new JsonArray()) + : nonStrings; + for (JsonElement value : wrongTypes) { + if (field.equals("message") && value.isJsonNull()) { + continue; + } + JsonObject wrongType = exceptionEnvelope(); + wrongType.add(field, value); + invalid.add(wrongType); + } + } + List> malformed = List.of( + exception -> exception.addProperty("type", ""), + exception -> exception.addProperty("type", " \t\n\u2003"), + exception -> exception.addProperty("type", "x".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH + 1)), + exception -> exception.addProperty("message", "x".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH + 1)), + exception -> exception.addProperty("stackTrace", "x".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1)), + exception -> exception.addProperty("type", "😀".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH / 2 + 1)), + exception -> exception.addProperty("message", "😀".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH / 2 + 1)), + exception -> exception.addProperty("stackTrace", "😀".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH / 2 + 1)), + exception -> exception.addProperty("extra", true) + ); + for (Consumer mutation : malformed) { + JsonObject exception = exceptionEnvelope(); + mutation.accept(exception); + invalid.add(exception); + } + return invalid; + } + + /** Exchanges concretely typed generic values. */ + public interface Names { + @NotNull + List apply(@NotNull List values); + } + + /** Exchanges application records. */ + public interface Records { + @NotNull + Item apply(@NotNull Item value); + } + + /** Exchanges nullable application values. */ + public interface Text { + @Nullable + String apply(@Nullable String value); + } + + /** Completes calls without an application result. */ + public interface Notification { + void accept(@Nullable String value); + } + + public interface Overloaded { + @NotNull + String apply(); + + @NotNull + String apply(@Nullable String first); + + @NotNull + String apply(@Nullable String first, @Nullable String second); + + @NotNull + String apply(@Nullable String first, int second); + + @NotNull + String apply(@Nullable String first, @Nullable String second, @Nullable String third); + } + + public interface Varargs { + @NotNull + String apply(@NotNull String prefix, @Nullable String... values); + } + + public interface RecordArguments { + @NotNull + Item apply(@Nullable String label, @Nullable Item selected, @NotNull List items, @NotNull List numbers); + } + + public interface Numbers { + int sum(int first, int second, int third); + } + + public record Item(@NotNull String name, @NotNull List tags) { + } + + private static final class GsonCodec implements MessagingRpcCodec { + private final Gson gson = new Gson(); + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + JsonElement encoded = gson.toJsonTree(value, type); + return encoded.isJsonNull() ? null : encoded.deepCopy(); + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return gson.fromJson(payload == null ? JsonNull.INSTANCE : payload.deepCopy(), type); + } + } + + private static final class CountingCodec implements MessagingRpcCodec { + private final MessagingRpcCodec codec; + private final AtomicInteger encodes = new AtomicInteger(); + private final AtomicInteger decodes = new AtomicInteger(); + private final List encodedTypes = new ArrayList<>(); + private final List decodedTypes = new ArrayList<>(); + + private CountingCodec() { + this(CODEC); + } + + private CountingCodec(@NotNull MessagingRpcCodec codec) { + this.codec = codec; + } + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) throws Exception { + encodes.incrementAndGet(); + encodedTypes.add(type); + return codec.encode(value, type); + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws Exception { + decodes.incrementAndGet(); + decodedTypes.add(type); + return codec.decode(payload, type); + } + } + + private static final class Received implements MessagingRequestReplyTransport.Listener> { + private final List>> messages = new ArrayList<>(); + private int disconnects; + + @Override + public void onMessage(@NotNull MessagingRequestReplyMessage> message) { + messages.add(message); + } + + @Override + public void onDisconnect() { + disconnects++; + } + } + + private static final class MemoryTransport implements MessagingRequestReplyTransport { + private final List> sent = new ArrayList<>(); + private final MessagingSubscription subscription = () -> this.closed = true; + private CompletionStage sendResult = CompletableFuture.completedFuture(null); + private Executor requestExecutor = Runnable::run; + @Nullable + private UUID endpoint; + @Nullable + private Listener listener; + @Nullable + private Consumer> forward; + @Nullable + private MessagingRequestReplyException subscribeFailure; + @Nullable + private RuntimeException sendFailure; + private boolean closed; + + @Override + @NotNull + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) + throws MessagingRequestReplyException { + if (subscribeFailure != null) { + throw subscribeFailure; + } + endpoint = endpointId; + listener = receiver; + return subscription; + } + + @Override + @NotNull + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { + sent.add(message); + if (sendFailure != null) { + throw sendFailure; + } + if (forward != null) { + forward.accept(message); + } + return sendResult; + } + + @Override + public void executeRequest(@NotNull Runnable request) { + requestExecutor.execute(request); + } + + private void deliver(@NotNull MessagingRequestReplyMessage message) { + Objects.requireNonNull(listener).onMessage(message); + } + } + + private static final class FakeChannel implements DDRemoteDamChannel { + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); + private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); + private DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION); + @Nullable + private String failTopic; + @Nullable + private Consumer forward; + private boolean closed; + + @Override + @NotNull + public DDRemoteDamChannelStatus getStatus() { + return status; + } + + @Override + @NotNull + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(status); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDRemoteDamMessage message) { + sent.add(message); + if (forward != null) { + forward.accept(message); + } + return CompletableFuture.completedFuture(null); + } + + @Override + @NotNull + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + if (topic.equals(failTopic)) { + throw subscriptionFailure; + } + TopicSubscription subscription = new TopicSubscription(topic, listener); + subscriptions.add(subscription); + return () -> subscription.active = false; + } + + @Override + @NotNull + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { + throw new UnsupportedOperationException(); + } + + private void deliver(@NotNull DDRemoteDamMessage message) { + for (TopicSubscription subscription : subscriptions) { + if (subscription.active && subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(SESSION, message); + } + } + } + + private void setStatus(@NotNull DDRemoteDamChannelStatus next) { + status = next; + observers.forEach(observer -> observer.accept(next)); + } + + @Override + public void close() { + closed = true; + } + } + + private static final class TopicSubscription { + private final String topic; + private final DDRemoteDamMessageListener listener; + private volatile boolean active = true; + + private TopicSubscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { + this.topic = topic; + this.listener = listener; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java new file mode 100644 index 0000000..fc58fe0 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java @@ -0,0 +1,43 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.dam.remote.api.transport; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDRemoteDamChannelStatusTest { + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket"); + + @Test + void onlyConnectedStatusIsReadyAndCarriesSessionInformation() { + for (DDRemoteDamChannelState state : DDRemoteDamChannelState.values()) { + boolean connected = state == DDRemoteDamChannelState.CONNECTED; + DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(state, connected ? SESSION : null); + + Assertions.assertEquals(connected, status.isReady()); + Assertions.assertEquals(connected ? SESSION : null, status.session()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new DDRemoteDamChannelStatus(state, connected ? null : SESSION)); + } + } + + @Test + void rejectsMissingState() { + Assertions.assertThrows(NullPointerException.class, () -> new DDRemoteDamChannelStatus(null, null)); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java new file mode 100644 index 0000000..09e56be --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java @@ -0,0 +1,139 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MessagingRequestReplyDeadlineTest { + @Test + void rejectsAnExpiredResponseBeforeTheCallerStartsWaiting() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + nanos.set(Duration.ofSeconds(2).toNanos()); + transport.listener.onMessage(request.response("late")); + }; + + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, nanos::get)) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, + () -> endpoint.request("request", Duration.ofSeconds(1))); + assertEquals(MessagingRequestReplyException.Reason.TIMEOUT, failure.reason()); + } + } + + @Test + void preservesAResponseAcceptedBeforeTheDeadlineWhenTheCallerResumesLater() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + transport.listener.onMessage(request.response("on time")); + nanos.set(Duration.ofSeconds(2).toNanos()); + }; + + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, nanos::get)) { + assertEquals("on time", endpoint.request("request", Duration.ofSeconds(1))); + } + } + + @Test + void doesNotSendWhenTheDeadlineExpiresBeforeSending() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Expired request was sent"); + }; + + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, + () -> nanos.getAndAdd(Duration.ofSeconds(2).toNanos()))) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, + () -> endpoint.request("request", Duration.ofSeconds(1))); + assertEquals(MessagingRequestReplyException.Reason.TIMEOUT, failure.reason()); + } + } + + @Test + void doesNotSendAPendingRequestAlreadyFailedByDisconnect() throws Exception { + AtomicInteger reads = new AtomicInteger(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Disconnected request was sent"); + }; + + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, () -> { + if (reads.incrementAndGet() == 2) { + transport.listener.onDisconnect(); + } + return 0; + })) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request")); + assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); + } + } + + @Test + void doesNotSendAPendingRequestAlreadyFailedByClosure() throws Exception { + AtomicInteger reads = new AtomicInteger(); + AtomicReference> current = new AtomicReference<>(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Closed request was sent"); + }; + + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, () -> { + if (reads.incrementAndGet() == 2) { + current.get().close(); + } + return 0; + })) { + current.set(endpoint); + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request")); + assertEquals(MessagingRequestReplyException.Reason.CLOSED, failure.reason()); + } + } + + private static final class Transport implements MessagingRequestReplyTransport { + private Listener listener; + private Consumer> sending; + + @Override + @NotNull + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) { + listener = receiver; + return () -> { }; + } + + @Override + @NotNull + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { + sending.accept(message); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java new file mode 100644 index 0000000..39fd0a2 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java @@ -0,0 +1,1036 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +class MessagingRequestReplyEndpointTest { + private static final Duration WAIT = Duration.ofSeconds(5); + private final List callers = new CopyOnWriteArrayList<>(); + + @AfterEach + void stopCallers() throws InterruptedException { + callers.forEach(Thread::interrupt); + for (Thread caller : callers) { + join(caller); + } + } + + @Test + void subscribesBeforeReturningAndAcceptsAReplyBeforeSendCompletes() throws Exception { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport)) { + assertEquals(1, transport.listeners.size()); + transport.sender = message -> { + assertTrue(transport.listeners.containsKey(message.replyTo())); + transport.deliver(message.response("immediate reply")); + return acknowledgement; + }; + + assertEquals("immediate reply", endpoint.request("request")); + assertFalse(acknowledgement.isDone()); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, transport.next().type()); + assertTrue(transport.sent.isEmpty()); + + assertTrue(acknowledgement.completeExceptionally(new IllegalStateException("late send failure"))); + assertEquals("immediate reply", endpoint.request("another request")); + assertEquals(1, transport.sent.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void failsOnAnAsynchronousSendErrorBeforeTheReplyAndReleasesCapacity() throws Exception { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + var cause = new IllegalStateException("send failure"); + transport.sender = message -> acknowledgement; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> endpoint.request("request")); + MessagingRequestReplyMessage request = transport.next(); + + acknowledgement.completeExceptionally(cause); + + assertSame(cause, failure(MessagingRequestReplyException.Reason.SEND_FAILED, pending).getCause()); + transport.deliver(request.response("too late")); + transport.deliver(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + assertTrue(transport.sent.isEmpty()); + + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void convertsSynchronousSendFailuresWithoutRetryingOrLeakingCapacity() throws Exception { + var transport = new MemoryTransport(); + var cause = new IllegalStateException("synchronous send failure"); + transport.sender = message -> { + throw cause; + }; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(cause, failure(MessagingRequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + assertEquals("request", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void preservesTypedAsynchronousSendFailuresAndReleasesCapacityBeforeLateNotifications() throws Exception { + for (MessagingRequestReplyException expected : List.of( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED), + new MessagingRequestReplyException(MessagingRequestReplyMessage.Error.OVERLOADED))) { + for (Throwable error : List.of(expected, new CompletionException(expected), new ExecutionException(expected), + new CompletionException(new ExecutionException(new CompletionException(expected))))) { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + transport.sender = message -> acknowledgement; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> endpoint.request("request")); + final MessagingRequestReplyMessage request = transport.next(); + + acknowledgement.completeExceptionally(error); + + assertSame(expected, failure(expected.reason(), pending)); + transport.disconnect(); + transport.deliver(request.response("too late")); + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals("next request", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + } + } + } + } + + @Test + void preservesTypedSynchronousSendFailuresWrappedByCompletionStages() throws Exception { + for (MessagingRequestReplyException expected : List.of( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED), + new MessagingRequestReplyException(MessagingRequestReplyMessage.Error.HANDLER_FAILED))) { + for (CompletionException error : List.of(new CompletionException(expected), + new CompletionException(new ExecutionException(expected)))) { + var transport = new MemoryTransport(); + transport.sender = message -> { + throw error; + }; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(expected, failure(expected.reason(), () -> endpoint.request("first"))); + assertSame(expected, failure(expected.reason(), () -> endpoint.request("second"))); + assertEquals(2, transport.sent.size()); + } + } + } + } + + @Test + void keepsUntypedAndUnrelatedWrappedSendErrorsAsSendFailures() throws Exception { + var cause = new IllegalStateException("untyped send failure"); + for (RuntimeException error : List.of(cause, new CompletionException(cause), + new CompletionException(new ExecutionException(cause)), new CompletionException((Throwable) null), + new IllegalStateException(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED)))) { + for (boolean asynchronous : List.of(false, true)) { + var transport = new MemoryTransport(); + transport.sender = message -> { + if (asynchronous) { + return CompletableFuture.failedFuture(error); + } + throw error; + }; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(error, + failure(MessagingRequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + assertEquals(1, transport.sent.size()); + } + } + } + } + + @Test + void correlatesConcurrentResponsesDeliveredInReverseOrder() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(8, 1))) { + Map> results = new HashMap<>(); + for (int index = 0; index < 8; index++) { + String payload = "request-" + index; + results.put(payload, async(() -> endpoint.request(payload))); + } + List> requests = new ArrayList<>(); + Set requestIds = new HashSet<>(); + Set replyTargets = new HashSet<>(); + for (int index = 0; index < results.size(); index++) { + MessagingRequestReplyMessage request = transport.next(); + requests.add(request); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, request.type()); + assertTrue(requestIds.add(request.requestId())); + replyTargets.add(request.replyTo()); + } + assertEquals(1, replyTargets.size()); + for (int index = requests.size() - 1; index >= 0; index--) { + MessagingRequestReplyMessage request = requests.get(index); + transport.deliver(request.response("reply:" + request.payload())); + } + + for (var entry : results.entrySet()) { + assertEquals("reply:" + entry.getKey(), result(entry.getValue())); + } + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void invokesTheHandlerInlineOnTheReceivingThreadAndReturnsANullResult() throws Exception { + var transport = new MemoryTransport(); + var handlerThread = new AtomicReference(); + var handled = new AtomicReference(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport); + var registration = endpoint.handle(payload -> { + handlerThread.set(Thread.currentThread()); + handled.set(payload); + return null; + })) { + var request = request("nullable result"); + transport.deliver(request); + + assertEquals("nullable result", handled.get()); + assertSame(Thread.currentThread(), handlerThread.get()); + assertEquals(request.response(null), transport.next()); + } + } + + @Test + void reportsMissingAndFailingHandlersWithoutExposingHandlerDetails() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport); + var server = MessagingRequestReplyEndpoint.open(serverTransport)) { + MessagingRequestReplyException missing = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, + () -> client.request("missing")); + assertEquals(MessagingRequestReplyMessage.Error.NO_HANDLER, missing.remoteError()); + assertNull(missing.getCause()); + assertEquals(clientTransport.next().failure(MessagingRequestReplyMessage.Error.NO_HANDLER), serverTransport.next()); + + try (var registration = server.handle(payload -> { + throw new IllegalStateException("private handler details: " + payload); + })) { + MessagingRequestReplyException failed = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, + () -> client.request("secret")); + + assertEquals(MessagingRequestReplyMessage.Error.HANDLER_FAILED, failed.remoteError()); + assertNull(failed.getCause()); + assertFalse(failed.toString().contains("private")); + assertFalse(failed.toString().contains("secret")); + assertEquals(clientTransport.next().failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED), serverTransport.next()); + } + } + } + + @Test + void releasesHandlerCapacityWhenReplySendingFails() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> "reply:" + payload)) { + for (boolean asynchronous : List.of(false, true)) { + transport.sender = message -> { + var failure = new IllegalStateException("reply send failure"); + if (asynchronous) { + return CompletableFuture.failedFuture(failure); + } + throw failure; + }; + var request = request("request"); + + assertDoesNotThrow(() -> transport.deliver(request)); + assertEquals(request.response("reply:request"), transport.next()); + } + transport.sender = message -> CompletableFuture.completedFuture(null); + var request = request("recovered"); + transport.deliver(request); + assertEquals(request.response("reply:recovered"), transport.next()); + } + } + + @Test + void mapsTransportExecutionRejectionToOverloadAndReleasesAdmission() throws Exception { + var transport = new MemoryTransport(); + var rejected = new AtomicReference(); + var calls = new AtomicInteger(); + transport.requestExecutor = request -> { + rejected.set(request); + throw new RejectedExecutionException("Request execution unavailable"); + }; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + return "reply:" + payload; + })) { + var request = request("rejected"); + assertDoesNotThrow(() -> transport.deliver(request)); + assertEquals(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(0, calls.get()); + rejected.get().run(); + assertEquals(0, calls.get()); + assertTrue(transport.sent.isEmpty()); + + transport.requestExecutor = null; + transport.deliver(request); + assertEquals(request.response("reply:rejected"), transport.next()); + assertEquals(1, calls.get()); + } + } + + @Test + void countsQueuedRequestsAndCancelsThemBeforeExecutionOnDisconnectOrClose() throws Exception { + for (boolean closing : List.of(false, true)) { + var transport = new MemoryTransport(); + var queued = new LinkedBlockingQueue(); + var calls = new AtomicInteger(); + transport.requestExecutor = queued::add; + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + return "reply:" + payload; + })) { + var first = request("queued"); + transport.deliver(first); + transport.deliver(first); + assertEquals(1, queued.size()); + var excess = request("excess"); + transport.deliver(excess); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(0, calls.get()); + + if (closing) { + endpoint.close(); + } else { + transport.disconnect(); + } + queued.remove().run(); + assertEquals(0, calls.get()); + assertTrue(transport.sent.isEmpty()); + + if (!closing) { + var fresh = request("fresh"); + transport.deliver(fresh); + queued.remove().run(); + assertEquals(fresh.response("reply:fresh"), transport.next()); + assertEquals(1, calls.get()); + } + } + } + } + + @Test + void isolatesReplyTargetsAndIgnoresUnknownAndDuplicateResponses() throws Exception { + var transport = new MemoryTransport(); + try (var first = MessagingRequestReplyEndpoint.open(transport); + var second = MessagingRequestReplyEndpoint.open(transport)) { + final Future firstResult = async(() -> first.request("first")); + MessagingRequestReplyMessage firstRequest = transport.next(); + final Future secondResult = async(() -> second.request("second")); + MessagingRequestReplyMessage secondRequest = transport.next(); + assertNotEquals(firstRequest.replyTo(), secondRequest.replyTo()); + + transport.deliver(new MessagingRequestReplyMessage<>(firstRequest.requestId(), secondRequest.replyTo(), + MessagingRequestReplyMessage.Type.RESPONSE, "wrong endpoint", null)); + transport.deliver(new MessagingRequestReplyMessage<>(secondRequest.requestId(), firstRequest.replyTo(), + MessagingRequestReplyMessage.Type.ERROR, null, MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + var unknown = MessagingRequestReplyMessage.request(UUID.randomUUID(), firstRequest.replyTo(), "unknown"); + transport.deliver(unknown.response("unknown response")); + transport.deliver(unknown.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)); + assertFalse(firstResult.isDone()); + assertFalse(secondResult.isDone()); + + transport.deliver(firstRequest.response("first reply")); + transport.deliver(firstRequest.response("duplicate")); + transport.deliver(firstRequest.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + assertEquals("first reply", result(firstResult)); + assertFalse(secondResult.isDone()); + transport.deliver(secondRequest.response("second reply")); + assertEquals("second reply", result(secondResult)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void usesTheDefaultTimeoutAndReleasesCapacityBeforeIgnoringLateReplies() throws Exception { + var transport = new MemoryTransport(); + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options)) { + failure(MessagingRequestReplyException.Reason.TIMEOUT, () -> endpoint.request("timed out")); + MessagingRequestReplyMessage expired = transport.next(); + assertTrue(transport.sent.isEmpty()); + + Future next = async(() -> endpoint.request("next", WAIT)); + final MessagingRequestReplyMessage request = transport.next(); + transport.deliver(expired.response("late response")); + transport.deliver(expired.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + assertFalse(next.isDone()); + transport.deliver(request.response("next reply")); + + assertEquals("next reply", result(next)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void timesOutWhileAnIndependentReceivingThreadIsStillHandlingTheRequest() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var handlerThread = new AtomicReference(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport, options(1, 1)); + var server = MessagingRequestReplyEndpoint.open(serverTransport, options(1, 1)); + var registration = server.handle(payload -> { + handlerThread.set(Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + Future expired = async(() -> client.request("expired", Duration.ofMillis(100))); + await(entered); + failure(MessagingRequestReplyException.Reason.TIMEOUT, expired); + assertTrue(handlerThread.get().isAlive()); + release.countDown(); + join(handlerThread.get()); + assertEquals(clientTransport.next().response("reply:expired"), serverTransport.next()); + assertEquals("reply:fresh", client.request("fresh")); + assertEquals(clientTransport.next().response("reply:fresh"), serverTransport.next()); + } finally { + release.countDown(); + } + } + } + + @Test + void rejectsRepliesAtTheDeadlineEvenWhenTheyArriveBeforeSendReturns() throws Exception { + var transport = new MemoryTransport(); + var clock = new AtomicLong(); + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, options(1, 1), clock::get)) { + for (boolean error : List.of(false, true)) { + transport.sender = message -> { + clock.addAndGet(WAIT.toNanos()); + transport.deliver(error + ? message.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED) : message.response("late")); + return CompletableFuture.completedFuture(null); + }; + + failure(MessagingRequestReplyException.Reason.TIMEOUT, () -> endpoint.request("expired")); + + assertEquals("expired", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + } + transport.sender = message -> { + transport.deliver(message.response("on time")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("on time", endpoint.request("fresh")); + } + } + + @Test + void doesNotSendOrConsumeCapacityWhenTheCallerIsAlreadyInterrupted() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> { + Thread.currentThread().interrupt(); + return endpoint.request("already interrupted"); + }); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> result(pending)); + assertInstanceOf(InterruptedException.class, failure.getCause()); + assertTrue(transport.sent.isEmpty()); + transport.sender = message -> { + transport.deliver(message.response("next reply")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("next reply", endpoint.request("next")); + } + } + + @Test + void propagatesInterruptionAndReleasesThePendingSlotWithoutSendingCancellation() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + var pending = new FutureTask<>(() -> endpoint.request("interrupted")); + Thread caller = start(pending); + final MessagingRequestReplyMessage interrupted = transport.next(); + + caller.interrupt(); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> result(pending)); + assertInstanceOf(InterruptedException.class, failure.getCause()); + join(caller); + assertTrue(transport.sent.isEmpty()); + Future next = async(() -> endpoint.request("next")); + MessagingRequestReplyMessage request = transport.next(); + transport.deliver(interrupted.response("too late")); + assertFalse(next.isDone()); + transport.deliver(request.response("next reply")); + assertEquals("next reply", result(next)); + } + } + + @Test + void rejectsExcessPendingRequestsBeforeSendingAndReusesReleasedSlots() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(2, 1))) { + final Future first = async(() -> endpoint.request("first")); + final MessagingRequestReplyMessage firstRequest = transport.next(); + final Future second = async(() -> endpoint.request("second")); + MessagingRequestReplyMessage secondRequest = transport.next(); + + failure(MessagingRequestReplyException.Reason.OVERLOADED, () -> endpoint.request("excess")); + assertTrue(transport.sent.isEmpty()); + transport.deliver(secondRequest.response("second reply")); + assertEquals("second reply", result(second)); + + final Future third = async(() -> endpoint.request("third")); + MessagingRequestReplyMessage thirdRequest = transport.next(); + assertFalse(first.isDone()); + transport.deliver(thirdRequest.response("third reply")); + transport.deliver(firstRequest.response("first reply")); + assertEquals("third reply", result(third)); + assertEquals("first reply", result(first)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var handlerThread = new AtomicReference(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + handlerThread.set(Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + var first = request("first"); + Thread firstDelivery = start(() -> transport.deliver(first)); + await(entered); + assertSame(firstDelivery, handlerThread.get()); + var excess = request("excess"); + + transport.deliver(excess); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); + transport.deliver(first); + assertEquals(1, calls.get()); + assertTrue(transport.sent.isEmpty()); + + release.countDown(); + assertEquals(first.response("reply:first"), transport.next()); + join(firstDelivery); + var next = request("next"); + transport.deliver(next); + assertEquals(next.response("reply:next"), transport.next()); + assertSame(Thread.currentThread(), handlerThread.get()); + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void treatsIncomingRequestIdentityAsThePairOfOriginAndRequestId() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var handlerThreads = new ConcurrentHashMap(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 2)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + handlerThreads.put(payload, Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + UUID requestId = UUID.randomUUID(); + var first = MessagingRequestReplyMessage.request(requestId, UUID.randomUUID(), "first origin"); + var second = MessagingRequestReplyMessage.request(requestId, UUID.randomUUID(), "second origin"); + Thread firstDelivery = start(() -> transport.deliver(first)); + Thread secondDelivery = start(() -> transport.deliver(second)); + await(entered); + assertSame(firstDelivery, handlerThreads.get("first origin")); + assertSame(secondDelivery, handlerThreads.get("second origin")); + transport.deliver(first); + transport.deliver(second); + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + + release.countDown(); + + assertEquals(Set.of(first.response("reply:first origin"), second.response("reply:second origin")), + Set.of(transport.next(), transport.next())); + for (Thread thread : handlerThreads.values()) { + join(thread); + } + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 2))) { + try (var original = endpoint.handle(payload -> { + entered.countDown(); + await(release); + return "original reply"; + })) { + try { + assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> "duplicate handler")); + var accepted = request("accepted"); + Thread delivery = start(() -> transport.deliver(accepted)); + await(entered); + original.close(); + try (var replacement = endpoint.handle(payload -> "replacement reply")) { + original.close(); + var next = request("next"); + transport.deliver(next); + assertEquals(next.response("replacement reply"), transport.next()); + release.countDown(); + assertEquals(accepted.response("original reply"), transport.next()); + join(delivery); + assertEquals(1, transport.listeners.size()); + assertEquals(0, transport.subscriptionCloses.get()); + } + var unhandled = request("unhandled"); + transport.deliver(unhandled); + assertEquals(unhandled.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), transport.next()); + } finally { + release.countDown(); + } + } + } + } + + @Test + void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() throws Exception { + try (var transport = new MemoryTransport(); + var endpoint = MessagingRequestReplyEndpoint.open(transport); + var other = MessagingRequestReplyEndpoint.open(transport)) { + var entered = new CountDownLatch(1); + var interrupted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var handlerThread = new AtomicReference(); + try (var registration = endpoint.handle(payload -> { + handlerThread.set(Thread.currentThread()); + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + interrupted.countDown(); + } + return "stale reply"; + })) { + try { + final Future pending = async(() -> endpoint.request("pending")); + MessagingRequestReplyMessage outgoing = transport.next(); + MessagingRequestReplyTransport.Listener oldListener = transport.listeners.get(outgoing.replyTo()); + assertNotNull(oldListener); + start(() -> oldListener.onMessage(request("incoming"))); + await(entered); + + endpoint.close(); + + failure(MessagingRequestReplyException.Reason.CLOSED, pending); + await(interrupted); + join(handlerThread.get()); + assertEquals(1, transport.subscriptionCloses.get()); + assertEquals(1, transport.listeners.size()); + assertFalse(transport.closed); + assertFalse(transport.listeners.containsKey(outgoing.replyTo())); + failure(MessagingRequestReplyException.Reason.CLOSED, () -> endpoint.request("closed")); + assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> payload)); + oldListener.onMessage(request("after close")); + oldListener.onMessage(outgoing.response("late reply")); + oldListener.onDisconnect(); + endpoint.close(); + assertEquals(1, transport.subscriptionCloses.get()); + assertTrue(transport.sent.isEmpty()); + + Future surviving = async(() -> other.request("surviving endpoint")); + MessagingRequestReplyMessage request = transport.next(); + transport.deliver(request.response("still open")); + assertEquals("still open", result(surviving)); + } finally { + release.countDown(); + } + } + } + } + + @Test + void disconnectFailsAllPendingRequestsAndAllowsFreshWorkWithoutReplayingOldRequests() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(2, 1))) { + Future first = async(() -> endpoint.request("first")); + final MessagingRequestReplyMessage firstRequest = transport.next(); + Future second = async(() -> endpoint.request("second")); + final MessagingRequestReplyMessage secondRequest = transport.next(); + + transport.disconnect(); + + failure(MessagingRequestReplyException.Reason.DISCONNECTED, first); + failure(MessagingRequestReplyException.Reason.DISCONNECTED, second); + assertTrue(transport.sent.isEmpty()); + final Future fresh = async(() -> endpoint.request("fresh")); + MessagingRequestReplyMessage freshRequest = transport.next(); + assertEquals("fresh", freshRequest.payload()); + assertEquals(firstRequest.replyTo(), freshRequest.replyTo()); + transport.deliver(firstRequest.response("old first reply")); + transport.deliver(secondRequest.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + assertFalse(fresh.isDone()); + transport.deliver(freshRequest.response("fresh reply")); + + assertEquals("fresh reply", result(fresh)); + assertTrue(transport.sent.isEmpty()); + assertEquals(0, transport.subscriptionCloses.get()); + assertEquals(1, transport.listeners.size()); + } + } + + @Test + void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() throws Exception { + var transport = new MemoryTransport(); + transport.sender = message -> { + start(() -> transport.deliver(message)); + return CompletableFuture.completedFuture(null); + }; + var entered = new CountDownLatch(1); + var interrupted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var oldHandler = new AtomicReference(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + if ("old".equals(payload)) { + oldHandler.set(Thread.currentThread()); + entered.countDown(); + awaitIgnoringInterrupts(release, interrupted); + } + return "reply:" + payload; + })) { + try { + Future pending = async(() -> endpoint.request("old")); + final MessagingRequestReplyMessage oldRequest = transport.next(); + await(entered); + + transport.disconnect(); + + failure(MessagingRequestReplyException.Reason.DISCONNECTED, pending); + await(interrupted); + for (int index = 0; index < 4; index++) { + transport.disconnect(); + MessagingRequestReplyException failure = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, + () -> endpoint.request("excess")); + assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, failure.remoteError()); + MessagingRequestReplyMessage excess = transport.next(); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(1, calls.get()); + assertTrue(oldHandler.get().isAlive()); + } + + release.countDown(); + join(oldHandler.get()); + assertTrue(transport.sent.isEmpty()); + transport.deliver(oldRequest.response("late reply")); + assertEquals("reply:fresh", endpoint.request("fresh")); + MessagingRequestReplyMessage fresh = transport.next(); + assertEquals(fresh.response("reply:fresh"), transport.next()); + assertEquals(2, calls.get()); + assertEquals(1, transport.listeners.size()); + } finally { + release.countDown(); + } + } + } + + @Test + void rejectsInvalidRequestArgumentsBeforeSendingOrConsumingCapacity() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertThrows(NullPointerException.class, () -> endpoint.request(null)); + assertThrows(NullPointerException.class, () -> endpoint.request("request", null)); + assertThrows(NullPointerException.class, () -> endpoint.handle(null)); + for (Duration timeout : List.of(Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(Long.MAX_VALUE))) { + assertThrows(IllegalArgumentException.class, () -> endpoint.request("request", timeout)); + } + assertTrue(transport.sent.isEmpty()); + transport.sender = message -> { + transport.deliver(message.response("valid reply")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("valid reply", endpoint.request("valid", WAIT)); + } + } + + @Test + void propagatesSubscriptionFailureBeforePublishingAnEndpoint() { + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + var transport = new MessagingRequestReplyTransport() { + @NotNull + @Override + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws MessagingRequestReplyException { + throw expected; + } + + @NotNull + @Override + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { + throw new AssertionError("Subscription failure must prevent sending"); + } + }; + + assertSame(expected, assertThrows(MessagingRequestReplyException.class, () -> MessagingRequestReplyEndpoint.open(transport))); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyEndpoint.open(null)); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyEndpoint.open(new MemoryTransport(), null)); + } + + @NotNull + private static MessagingRequestReplyOptions options(int pending, int handlers) { + return new MessagingRequestReplyOptions(WAIT, pending, handlers); + } + + @NotNull + private static MessagingRequestReplyMessage request(@NotNull String payload) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), UUID.randomUUID(), payload); + } + + @NotNull + private FutureTask async(@NotNull Callable action) { + var result = new FutureTask<>(action); + start(result); + return result; + } + + @NotNull + private Thread start(@NotNull Runnable action) { + Thread thread = Thread.ofVirtual().start(action); + callers.add(thread); + return thread; + } + + @Nullable + private static T result(@NotNull Future future) throws InterruptedException, ExecutionException, TimeoutException { + return future.get(WAIT.toMillis(), TimeUnit.MILLISECONDS); + } + + @NotNull + private static MessagingRequestReplyException failure( + @NotNull MessagingRequestReplyException.Reason reason, + @NotNull Executable action + ) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, action); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + private static MessagingRequestReplyException failure( + @NotNull MessagingRequestReplyException.Reason reason, + @NotNull Future future + ) { + ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); + MessagingRequestReplyException failure = assertInstanceOf(MessagingRequestReplyException.class, wrapper.getCause()); + assertEquals(reason, failure.reason()); + return failure; + } + + private static void await(@NotNull CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(WAIT.toMillis(), TimeUnit.MILLISECONDS), "Latch was not released"); + } + + private static void join(@NotNull Thread thread) throws InterruptedException { + assertTrue(thread.join(WAIT), "Thread did not finish"); + } + + private static void awaitIgnoringInterrupts(@NotNull CountDownLatch release, @NotNull CountDownLatch interrupted) { + long deadline = System.nanoTime() + WAIT.toNanos(); + boolean released = false; + while (!released) { + try { + released = release.await(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + assertTrue(released, "Interrupt-ignoring handler was not released"); + } catch (InterruptedException e) { + interrupted.countDown(); + } + } + } + + private void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + first.sender = message -> { + start(() -> second.deliver(message)); + return CompletableFuture.completedFuture(null); + }; + second.sender = message -> { + start(() -> first.deliver(message)); + return CompletableFuture.completedFuture(null); + }; + } + + private static final class MemoryTransport implements MessagingRequestReplyTransport, AutoCloseable { + private final Map> listeners = new ConcurrentHashMap<>(); + private final BlockingQueue> sent = new LinkedBlockingQueue<>(); + private final AtomicInteger subscriptionCloses = new AtomicInteger(); + private volatile Function, CompletionStage> sender = + message -> CompletableFuture.completedFuture(null); + @Nullable + private Executor requestExecutor; + private boolean closed; + + @NotNull + @Override + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) { + assertFalse(closed); + assertNull(listeners.putIfAbsent(endpointId, listener)); + return () -> { + if (listeners.remove(endpointId, listener)) { + subscriptionCloses.incrementAndGet(); + } + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { + assertFalse(closed); + sent.add(message); + return sender.apply(message); + } + + @Override + public void executeRequest(@NotNull Runnable request) { + if (requestExecutor == null) { + MessagingRequestReplyTransport.super.executeRequest(request); + } else { + requestExecutor.execute(request); + } + } + + private void deliver(@NotNull MessagingRequestReplyMessage message) { + List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); + } + + private void disconnect() { + List.copyOf(listeners.values()).forEach(Listener::onDisconnect); + } + + @NotNull + private MessagingRequestReplyMessage next() throws InterruptedException { + MessagingRequestReplyMessage message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull(message, "No message was sent"); + return message; + } + + @Override + public void close() { + closed = true; + listeners.clear(); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java new file mode 100644 index 0000000..064d93d --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java @@ -0,0 +1,128 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Timeout(10) +class MessagingRequestReplyMessageTest { + private static final UUID REQUEST_ID = UUID.randomUUID(); + private static final UUID ENDPOINT_ID = UUID.randomUUID(); + + @Test + void preservesTheOpaquePayloadAndCorrelationWhenCreatingReplies() { + Object payload = new Object(); + MessagingRequestReplyMessage request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, payload); + + assertEquals(REQUEST_ID, request.requestId()); + assertEquals(ENDPOINT_ID, request.replyTo()); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, request.type()); + assertSame(payload, request.payload()); + assertNull(request.error()); + + Object result = new Object(); + MessagingRequestReplyMessage response = request.response(result); + + assertEquals(REQUEST_ID, response.requestId()); + assertEquals(ENDPOINT_ID, response.replyTo()); + assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, response.type()); + assertSame(result, response.payload()); + assertNull(response.error()); + assertSame(payload, request.payload()); + } + + @Test + void permitsANullResponseWithoutTurningItIntoAnError() { + MessagingRequestReplyMessage response = + MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request").response(null); + + assertEquals(REQUEST_ID, response.requestId()); + assertEquals(ENDPOINT_ID, response.replyTo()); + assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, response.type()); + assertNull(response.payload()); + assertNull(response.error()); + } + + @Test + void exposesOnlyTheErrorCodeAndOriginalCorrelationInFailures() { + var request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "private request"); + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + MessagingRequestReplyMessage failure = request.failure(error); + + assertEquals(REQUEST_ID, failure.requestId()); + assertEquals(ENDPOINT_ID, failure.replyTo()); + assertEquals(MessagingRequestReplyMessage.Type.ERROR, failure.type()); + assertEquals(error, failure.error()); + assertNull(failure.payload()); + } + } + + @Test + void rejectsMissingCorrelationAndType() { + assertThrows(NullPointerException.class, + () -> new MessagingRequestReplyMessage<>(null, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, null, null)); + assertThrows(NullPointerException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, null, MessagingRequestReplyMessage.Type.RESPONSE, null, null)); + assertThrows(NullPointerException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, null, null, null)); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, null)); + } + + @Test + void rejectsMalformedPayloadAndErrorCombinations() { + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.ERROR, null, null)); + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, "request", error)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, null, error)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, "response", error)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, null, error)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.ERROR, "private payload", error)); + } + } + + @Test + void onlyAllowsRequestsToProduceReplies() { + var request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request"); + assertThrows(NullPointerException.class, () -> request.failure(null)); + for (MessagingRequestReplyMessage reply : List.of( + request.response("response"), request.response(null), request.failure(MessagingRequestReplyMessage.Error.NO_HANDLER) + )) { + assertThrows(IllegalStateException.class, () -> reply.response("another response")); + assertThrows(IllegalStateException.class, () -> reply.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java new file mode 100644 index 0000000..e47d930 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java @@ -0,0 +1,65 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class MessagingRequestReplyOptionsTest { + @Test + void acceptsPositiveLimitsAndRepresentableTimeoutBoundaries() { + for (Duration timeout : List.of(Duration.ofNanos(1), Duration.ofNanos(Long.MAX_VALUE))) { + for (int limit : List.of(1, Integer.MAX_VALUE)) { + var options = new MessagingRequestReplyOptions(timeout, limit, limit); + + assertEquals(timeout, options.timeout()); + assertEquals(limit, options.maxPendingRequests()); + assertEquals(limit, options.maxConcurrentHandlers()); + } + } + assertTrue(MessagingRequestReplyOptions.DEFAULT.timeout().isPositive()); + assertTrue(MessagingRequestReplyOptions.DEFAULT.maxPendingRequests() > 0); + assertTrue(MessagingRequestReplyOptions.DEFAULT.maxConcurrentHandlers() > 0); + } + + @Test + void rejectsNonPositiveAndOverflowingTimeouts() { + for (Duration timeout : List.of( + Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), + Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) + )) { + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(timeout, 1, 1)); + } + assertThrows(NullPointerException.class, () -> new MessagingRequestReplyOptions(null, 1, 1)); + } + + @Test + void rejectsNonPositivePendingAndHandlerLimitsIndependently() { + for (int limit : List.of(0, -1, Integer.MIN_VALUE)) { + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(Duration.ofSeconds(1), limit, 1)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(Duration.ofSeconds(1), 1, limit)); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java new file mode 100644 index 0000000..5c7b4ad --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java @@ -0,0 +1,846 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class MessagingRpcContractTest extends MessagingRpcTestSupport { + @Test + void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + for (Class type : List.of( + String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, + EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, DefaultOnlyService.class, + GenericMethodService.class, FutureService.class, StageService.class, CompletableService.class + )) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type, codec), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", type, null, codec), type.getName()); + } + try (var registration = endpoint.register("service", StaticService.class, value -> value, codec)) { + assertTrue(transport.sent.isEmpty()); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + } + } + } + + @Test + void logsAndSkipsEachUnsupportedMethodWithoutDiscardingUsableOperations() throws Exception { + List records = new ArrayList<>(); + Logger logger = Logger.getLogger(MessagingRpcContract.class.getName()); + Handler handler = new Handler() { + @Override + public void publish(@NotNull LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + var contract = MessagingRpcContract.create(MixedService.class); + assertEquals(DefaultService.class.getMethod("echo", String.class), contract.operation("echo(java.lang.String)")); + assertEquals(5, records.size()); + for (String name : List.of("local", "generic", "future", "stage", "asynchronous")) { + var method = Arrays.stream(MixedService.class.getMethods()).filter(value -> value.getName().equals(name)) + .findFirst().orElseThrow(); + assertNull(contract.operation(MessagingRpcContract.operationId(method))); + assertTrue(records.stream().anyMatch(record -> record.getLevel() == Level.WARNING + && record.getMessage().contains(method.toGenericString()) + && record.getThrown() instanceof IllegalArgumentException)); + } + } finally { + logger.removeHandler(handler); + handler.close(); + } + } + + @Test + void skipsUnsupportedCovariantReturnBridgesWithoutDiscardingUsableOperations() throws Exception { + assertTrue(Arrays.stream(MixedCovariantService.class.getDeclaredMethods()).anyMatch(method -> method.isBridge())); + var contract = MessagingRpcContract.create(MixedCovariantService.class); + assertEquals(MixedCovariantService.class.getMethod("supported", String.class), + contract.operation("supported(java.lang.String)")); + assertNull(contract.operation("echo(java.lang.String)")); + } + + @Test + void dispatchesSupportedMethodsAndRejectsSkippedMethodsWithoutSendingOrDecodingThem() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var raw = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("raw"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("mixed", DefaultService.class, value -> "reply:" + value, codec); + var rawRegistration = server.register("raw", DefaultService.class, value -> value, codec)) { + DefaultService proxy = client.proxy("mixed", DefaultService.class, codec); + assertEquals("reply:value", proxy.echo("value")); + int sent = clientTransport.sent.size(); + int encoded = codec.encoded.size(); + final int decoded = codec.decoded.size(); + assertThrows(IllegalArgumentException.class, () -> proxy.local("local")); + assertEquals(sent, clientTransport.sent.size()); + assertEquals(encoded, codec.encoded.size()); + assertEquals(decoded, codec.decoded.size()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), + raw.request(MessagingRpcMessage.request("local(java.lang.String)", List.of("local")))); + assertEquals(encoded, codec.encoded.size()); + assertEquals(decoded, codec.decoded.size()); + assertEquals("reply:again", proxy.echo("again")); + } + } + + @Test + void rejectsContractsWithoutUsableMethodsWithTheSameGuardAsEmptyContracts() { + for (Class type : List.of(EmptyService.class, DefaultOnlyService.class, GenericMethodService.class, + FutureService.class, StageService.class, CompletableService.class, RawGenericService.class)) { + var failure = assertThrows(IllegalArgumentException.class, () -> MessagingRpcContract.create(type)); + assertEquals("RPC contract must declare at least one usable operation", failure.getMessage()); + } + } + + @Test + void invokesZeroAndTwoArgumentOperationsWithTheDeclaredArgumentLists() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var zero = server.register("zero", NoArgumentService.class, () -> "zero", serverCodec); + var two = server.register("two", TwoArgumentService.class, (first, second) -> first + ":" + second, serverCodec)) { + assertEquals("zero", client.proxy("zero", NoArgumentService.class, clientCodec).echo()); + assertEquals(MessagingRpcMessage.request("echo()", List.of()), clientTransport.next().payload()); + assertTrue(clientCodec.encoded.isEmpty()); + assertTrue(serverCodec.decoded.isEmpty()); + + assertEquals("first:second", client.proxy("two", TwoArgumentService.class, clientCodec).echo("first", "second")); + assertEquals(MessagingRpcMessage.request("echo(java.lang.String,java.lang.String)", List.of("first", "second")), + clientTransport.next().payload()); + assertEquals(List.of(new CodecCall("first", String.class), new CodecCall("second", String.class)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(List.of(new CodecCall("zero", String.class), new CodecCall("first:second", String.class)), serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void preservesEachArgumentsGenericTypeAndNullableReferenceValue() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("generic", ParameterizedArgumentsService.class, (values, groups, offset) -> { + calls.incrementAndGet(); + return values == null || groups == null ? null : Map.of("total", values.size() + groups.get("group").size() + offset); + }, serverCodec)) { + ParameterizedArgumentsService proxy = client.proxy("generic", ParameterizedArgumentsService.class, clientCodec); + List values = List.of("one", "two"); + Map> groups = Map.of("group", List.of(3, 4, 5)); + Map expected = Map.of("total", 12); + + assertEquals(expected, proxy.count(values, groups, 7)); + assertNull(proxy.count(null, groups, 8)); + assertNull(proxy.count(values, null, 9)); + assertEquals(3, calls.get()); + + var operation = ParameterizedArgumentsService.class.getMethod("count", List.class, Map.class, int.class); + var parameterTypes = operation.getGenericParameterTypes(); + assertEquals("java.util.List", parameterTypes[0].getTypeName()); + assertEquals("java.util.Map>", parameterTypes[1].getTypeName()); + assertEquals(int.class, parameterTypes[2]); + assertEquals(List.of( + new CodecCall(values, parameterTypes[0]), new CodecCall(groups, parameterTypes[1]), new CodecCall(7, int.class), + new CodecCall(null, parameterTypes[0]), new CodecCall(groups, parameterTypes[1]), new CodecCall(8, int.class), + new CodecCall(values, parameterTypes[0]), new CodecCall(null, parameterTypes[1]), new CodecCall(9, int.class) + ), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + var returnType = operation.getGenericReturnType(); + assertEquals(List.of(new CodecCall(expected, returnType), new CodecCall(null, returnType), new CodecCall(null, returnType)), + serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + assertEquals(List.of(values, groups, 7), clientTransport.next().payload().arguments()); + assertEquals(Arrays.asList(null, groups, 8), clientTransport.next().payload().arguments()); + assertEquals(Arrays.asList(values, null, 9), clientTransport.next().payload().arguments()); + } + } + + @Test + void treatsVarargsAsOneFinalArrayArgumentIncludingEmptyAndNullArrays() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var arrays = server.register("array", VarargsService.class, + values -> values == null ? "null" : String.join(",", values), serverCodec); + var prefixed = server.register("prefixed", PrefixedVarargsService.class, + (prefix, values) -> prefix + String.join(",", values), serverCodec)) { + VarargsService proxy = client.proxy("array", VarargsService.class, clientCodec); + String[] values = {"one", "two"}; + assertEquals("one,two", proxy.echo(values)); + assertEquals("", proxy.echo()); + assertEquals("null", proxy.echo((String[]) null)); + assertEquals("prefix:three,four", + client.proxy("prefixed", PrefixedVarargsService.class, clientCodec).echo("prefix:", "three", "four")); + + var arrayRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String[])", arrayRequest.operation()); + assertEquals(1, arrayRequest.arguments().size()); + assertSame(values, arrayRequest.arguments().get(0)); + var emptyRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String[])", emptyRequest.operation()); + assertEquals(1, emptyRequest.arguments().size()); + assertEquals(0, assertInstanceOf(String[].class, emptyRequest.arguments().get(0)).length); + assertEquals(Arrays.asList((Object) null), clientTransport.next().payload().arguments()); + var prefixedRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String,java.lang.String[])", prefixedRequest.operation()); + assertEquals(2, prefixedRequest.arguments().size()); + assertEquals("prefix:", prefixedRequest.arguments().get(0)); + assertEquals(List.of("three", "four"), Arrays.asList(assertInstanceOf(String[].class, prefixedRequest.arguments().get(1)))); + assertEquals(List.of(String[].class, String[].class, String[].class, String.class, String[].class), + clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void dispatchesOverloadsByEveryParameterAndArityWhileAcceptingCovariantBridges() throws Exception { + for (int arity : List.of(0, 2)) { + assertTrue(Arrays.stream(OverloadedArgumentsService.class.getDeclaredMethods()).anyMatch(method -> + method.isBridge() && method.isSynthetic() && method.getParameterCount() == arity)); + } + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + var implementation = new OverloadedArgumentsService() { + @NotNull + @Override + public String echo() { + return "zero"; + } + + @NotNull + @Override + public String echo(@NotNull String first) { + return "one:" + first; + } + + @NotNull + @Override + public String echo(@NotNull String first, int second) { + return "int:" + first + ":" + second; + } + + @NotNull + @Override + public String echo(@NotNull String first, long second) { + return "long:" + first + ":" + second; + } + + @NotNull + @Override + public String echo(@NotNull String first, int second, @NotNull String third) { + return "three:" + first + ":" + second + ":" + third; + } + }; + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("overloads", OverloadedArgumentsService.class, implementation, new TestCodec())) { + OverloadedArgumentsService proxy = client.proxy("overloads", OverloadedArgumentsService.class, new TestCodec()); + BaseArgumentsService base = proxy; + + assertEquals("zero", base.echo()); + assertEquals("zero", proxy.echo()); + assertEquals("one:value", proxy.echo("value")); + assertEquals("int:base:1", base.echo("base", 1)); + assertEquals("int:value:2", proxy.echo("value", 2)); + assertEquals("long:value:3", proxy.echo("value", 3L)); + assertEquals("three:value:4:last", proxy.echo("value", 4, "last")); + + assertEquals("echo()", clientTransport.next().payload().operation()); + assertEquals("echo()", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,long)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int,java.lang.String)", clientTransport.next().payload().operation()); + } + } + + @Test + void rejectsWrongArgumentCountsBeforeDecodingAndFreshCallsRecover() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("arguments"), OPTIONS); + var zeroClient = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("zero"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("arguments", MixedArgumentsService.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + ":" + second + ":" + third; + }, codec); + var zero = server.register("zero", NoArgumentService.class, () -> { + calls.incrementAndGet(); + return "zero"; + }, codec)) { + List> invalidArguments = List.of(List.of(), List.of("first"), List.of("first", 2), + List.of("first", 2, "third", "extra")); + for (List arguments : invalidArguments) { + int beforeCalls = calls.get(); + int beforeDecoded = codec.decoded.size(); + final int beforeEncoded = codec.encoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", arguments))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + assertEquals(beforeEncoded, codec.encoded.size()); + + assertEquals(MessagingRpcMessage.response("first:2:third"), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", + List.of("first", 2, "third")))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeDecoded + 3, codec.decoded.size()); + } + int beforeCalls = calls.get(); + int beforeDecoded = codec.decoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + zeroClient.request(MessagingRpcMessage.request("echo()", List.of("extra")))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + assertEquals(MessagingRpcMessage.response("zero"), zeroClient.request(MessagingRpcMessage.request("echo()", List.of()))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + } + } + + @Test + void rejectsWrongDecodedValuesAtEveryArgumentPositionAndFreshCallsRecover() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("arguments"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("arguments", MixedArgumentsService.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + ":" + second + ":" + third; + }, codec)) { + List> invalidArguments = List.of(List.of(42, 2, "third"), Arrays.asList("first", null, "third"), + List.of("first", "wrong primitive", "third"), List.of("first", 2L, "third"), List.of("first", 2, 42)); + for (List arguments : invalidArguments) { + int beforeCalls = calls.get(); + final int beforeEncoded = codec.encoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", arguments))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeEncoded, codec.encoded.size()); + + assertEquals(MessagingRpcMessage.response("null:2:null"), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", + Arrays.asList(null, 2, null)))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeEncoded + 1, codec.encoded.size()); + } + } + } + + @Test + void rejectsUnresolvedTypeVariablesInParameterizedOwnerTypes() { + assertThrows(IllegalArgumentException.class, () -> MessagingRpcContract.create(RawOwnedGenericService.class)); + } + + @Test + void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() throws Exception { + var bridge = GenericBridgeChild.class.getDeclaredMethod("echo", Object.class); + assertTrue(bridge.isBridge()); + assertTrue(bridge.isSynthetic()); + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericBridgeChild.class, codec)); + assertThrows(IllegalArgumentException.class, + () -> endpoint.register("bridge", GenericBridgeChild.class, value -> value, codec)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsGenericParentBridgesWhoseLaterErasedArgumentHasNoConcreteOperation() throws Exception { + var bridge = GenericPairBridgeChild.class.getDeclaredMethod("echo", String.class, Object.class); + assertTrue(bridge.isBridge()); + assertTrue(bridge.isSynthetic()); + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericPairBridgeChild.class, codec)); + assertThrows(IllegalArgumentException.class, + () -> endpoint.register("bridge", GenericPairBridgeChild.class, (prefix, value) -> prefix + value, codec)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsUnrelatedParentsWithAmbiguousGenericParameterOrReturnTypesBeforeSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + for (Class type : List.of(AmbiguousListCounter.class, AmbiguousListLoader.class)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("ambiguous", type, codec), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("ambiguous", type, null, codec), type.getName()); + } + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void selectsTheUniquelyNarrowerGenericReturnAcrossAllParentDeclarationOrders() throws Exception { + var expectedType = ArrayListLoader.class.getDeclaredMethod("load", String.class).getGenericReturnType(); + assertEquals("java.util.ArrayList", expectedType.getTypeName()); + + for (Class type : List.of(WideNarrowArrayLoader.class, WideArrayNarrowLoader.class, + NarrowWideArrayLoader.class, NarrowArrayWideLoader.class, ArrayWideNarrowLoader.class, ArrayNarrowWideLoader.class)) { + var operation = MessagingRpcContract.create(type).operation("load(java.lang.String)"); + + assertNotNull(operation, type.getName()); + assertEquals(ArrayList.class, operation.getReturnType(), type.getName()); + assertEquals(expectedType, operation.getGenericReturnType(), type.getName()); + } + } + + @Test + void explicitChildRedeclarationsResolveGenericTypesForCallsThroughParentAndChildReferences() throws Exception { + var count = ResolvedListCounter.class.getDeclaredMethod("count", List.class); + var load = ResolvedListLoader.class.getDeclaredMethod("load", String.class); + assertEquals(count, MessagingRpcContract.create(ResolvedListCounter.class).operation("count(java.util.List)")); + assertEquals(load, MessagingRpcContract.create(ResolvedListLoader.class).operation("load(java.lang.String)")); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var counter = server.register("count", ResolvedListCounter.class, List::size, serverCodec); + var loader = server.register("load", ResolvedListLoader.class, List::of, serverCodec)) { + ResolvedListCounter childCounter = client.proxy("count", ResolvedListCounter.class, clientCodec); + TypedListCounter typedCounter = childCounter; + assertEquals(2, typedCounter.count(List.of("one", "two"))); + assertEquals(1, childCounter.count(List.of("child"))); + + ResolvedListLoader childLoader = client.proxy("load", ResolvedListLoader.class, clientCodec); + WideListLoader wideLoader = childLoader; + NarrowListLoader narrowLoader = childLoader; + assertEquals(List.of("wide"), wideLoader.load("wide")); + assertEquals(List.of("narrow"), narrowLoader.load("narrow")); + assertEquals(List.of("child"), childLoader.load("child")); + + assertEquals(List.of(List.class, List.class, String.class, String.class, String.class), + clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + var returnType = load.getGenericReturnType(); + assertEquals("java.util.List", returnType.getTypeName()); + assertEquals(List.of(int.class, int.class, returnType, returnType, returnType), + serverCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void ignoresStaticMethodsAndKeepsConcreteDeclaredTypes() throws Exception { + MessagingRpcContract contract = MessagingRpcContract.create(StaticService.class); + assertEquals(StaticService.class.getMethod("echo", String.class), contract.operation("echo(java.lang.String)")); + assertNull(contract.operation("ignored()")); + + MessagingRpcContract parameterized = MessagingRpcContract.create(ParameterizedService.class); + var method = ParameterizedService.class.getMethod("echo", List.class); + assertEquals(method, parameterized.operation("echo(java.util.List)")); + assertEquals(method.getGenericParameterTypes()[0], parameterized.operation("echo(java.util.List)").getGenericParameterTypes()[0]); + assertEquals(method.getGenericReturnType(), parameterized.operation("echo(java.util.List)").getGenericReturnType()); + } + + @Test + void prefersTheConcreteParameterizedReturnRedeclaredByAChildContract() throws Exception { + MessagingRpcContract contract = MessagingRpcContract.create(NumberService.class); + + assertEquals(NumberService.class.getDeclaredMethod("numbers", String.class), contract.operation("numbers(java.lang.String)")); + assertEquals("java.util.List", + contract.operation("numbers(java.lang.String)").getGenericReturnType().getTypeName()); + } + + interface HiddenService { + @NotNull + String echo(@NotNull String value); + } + + /** Defines a sealed contract that cannot be proxied. */ + public sealed interface SealedService permits PermittedService { + @NotNull + String echo(@NotNull String value); + } + + /** Supplies a permitted subtype for the sealed fixture. */ + public non-sealed interface PermittedService extends SealedService { + } + + /** Defines an unresolved generic operation. */ + public interface GenericService { + @NotNull + T echo(@NotNull T value); + } + + /** Specializes a generic argument and produces an erased argument bridge. */ + public interface GenericBridgeChild extends GenericService { + @NotNull + @Override + String echo(@NotNull String value); + } + + public interface GenericPairService { + @NotNull + String echo(@NotNull String prefix, @NotNull T value); + } + + public interface GenericPairBridgeChild extends GenericPairService { + @NotNull + @Override + String echo(@NotNull String prefix, @NotNull String value); + } + + /** Inherits unresolved generic operation types. */ + @SuppressWarnings("rawtypes") + public interface RawGenericService extends GenericService { + } + + public static final class GenericOwner { + public final class Value { + } + } + + /** Uses an unresolved type in a parameterized owner. */ + public interface OwnedGenericService { + @NotNull + String echo(@NotNull GenericOwner.Value value); + } + + /** Inherits an unresolved parameterized owner type. */ + @SuppressWarnings("rawtypes") + public interface RawOwnedGenericService extends OwnedGenericService { + } + + /** Declares no remote operations. */ + public interface EmptyService { + } + + /** Declares only a static operation. */ + public interface StaticOnlyService { + @NotNull + static String ignored() { + return "local"; + } + } + + /** Declares only local object operations. */ + public interface ObjectOnlyService { + boolean equals(@Nullable Object value); + + int hashCode(); + + @NotNull + String toString(); + } + + /** Mixes an abstract operation with an unsupported default method. */ + public interface DefaultService { + @NotNull + String echo(@NotNull String value); + + @NotNull + default String local(@NotNull String value) { + return value; + } + } + + public interface DefaultOnlyService { + @NotNull + default String local(@NotNull String value) { + return value; + } + } + + public interface MixedService extends DefaultService { + @NotNull + T generic(@NotNull T value); + + @NotNull + Future future(); + + @NotNull + CompletionStage stage(); + + @NotNull + CompletableFuture asynchronous(); + } + + public interface MixedCovariantService extends FutureService { + @NotNull + @Override + CompletableFuture echo(@NotNull String value); + + @NotNull + String supported(@NotNull String value); + } + + public interface VarargsService { + @NotNull + String echo(@Nullable String... values); + } + + /** Defines an operation without an argument. */ + public interface NoArgumentService { + @NotNull + String echo(); + } + + /** Defines an operation with multiple arguments. */ + public interface TwoArgumentService { + @NotNull + String echo(@NotNull String first, @NotNull String second); + } + + public interface PrefixedVarargsService { + @NotNull + String echo(@NotNull String prefix, @NotNull String... values); + } + + public interface ParameterizedArgumentsService { + @Nullable + Map count(@Nullable List values, @Nullable Map> groups, int offset); + } + + public interface MixedArgumentsService { + @NotNull + String echo(@Nullable String first, int second, @Nullable String third); + } + + public interface BaseArgumentsService { + @NotNull + CharSequence echo(); + + @NotNull + CharSequence echo(@NotNull String first, int second); + } + + public interface OverloadedArgumentsService extends BaseArgumentsService { + @NotNull + @Override + String echo(); + + @NotNull + String echo(@NotNull String first); + + @NotNull + @Override + String echo(@NotNull String first, int second); + + @NotNull + String echo(@NotNull String first, long second); + + @NotNull + String echo(@NotNull String first, int second, @NotNull String third); + } + + /** Defines an unresolved generic method. */ + public interface GenericMethodService { + @NotNull + T echo(@NotNull T value); + } + + /** Defines an unsupported future return type. */ + public interface FutureService { + @NotNull + Future echo(@NotNull String value); + } + + /** Defines an unsupported completion stage return type. */ + public interface StageService { + @NotNull + CompletionStage echo(@NotNull String value); + } + + /** Defines an unsupported concrete asynchronous return type. */ + public interface CompletableService { + @NotNull + CompletableFuture echo(@NotNull String value); + } + + /** Mixes a valid operation with an ignored static method. */ + public interface StaticService { + @NotNull + String echo(@NotNull String value); + + static void ignored() { + } + } + + /** Preserves concrete parameterized operation types. */ + public interface ParameterizedService { + @NotNull + List echo(@NotNull List value); + } + + /** Defines a parameterized return with a concrete wildcard bound. */ + public interface BaseNumberService { + @NotNull + List numbers(@NotNull String value); + } + + /** Narrows an inherited parameterized return to integers. */ + public interface NumberService extends BaseNumberService { + @NotNull + @Override + List numbers(@NotNull String value); + } + + /** Defines a counter with a concrete parameterized argument. */ + public interface TypedListCounter { + int count(@NotNull List values); + } + + /** Defines a counter with a raw list argument. */ + @SuppressWarnings("rawtypes") + public interface RawListCounter { + int count(@NotNull List values); + } + + /** Inherits unrelated counter methods with unequal generic parameter types. */ + public interface AmbiguousListCounter extends TypedListCounter, RawListCounter { + } + + /** Resolves inherited counter signatures by explicitly redeclaring the raw argument. */ + @SuppressWarnings("rawtypes") + public interface ResolvedListCounter extends AmbiguousListCounter { + @Override + int count(@NotNull List values); + } + + /** Defines a loader with a wildcard list result. */ + public interface WideListLoader { + @NotNull + List load(@NotNull String value); + } + + /** Defines a loader with a concrete parameterized result. */ + public interface NarrowListLoader { + @NotNull + List load(@NotNull String value); + } + + /** Inherits unrelated loader methods with unequal generic return types. */ + public interface AmbiguousListLoader extends WideListLoader, NarrowListLoader { + } + + /** Resolves inherited loader results by explicitly redeclaring the concrete type. */ + public interface ResolvedListLoader extends AmbiguousListLoader { + @NotNull + @Override + List load(@NotNull String value); + } + + /** Defines a loader with a uniquely narrower raw return class. */ + public interface ArrayListLoader { + @NotNull + ArrayList load(@NotNull String value); + } + + /** Orders the wildcard and typed list parents before the array list parent. */ + public interface WideNarrowArrayLoader extends WideListLoader, NarrowListLoader, ArrayListLoader { + } + + /** Orders the wildcard parent before the array list and typed list parents. */ + public interface WideArrayNarrowLoader extends WideListLoader, ArrayListLoader, NarrowListLoader { + } + + /** Orders the typed and wildcard list parents before the array list parent. */ + public interface NarrowWideArrayLoader extends NarrowListLoader, WideListLoader, ArrayListLoader { + } + + /** Orders the typed parent before the array list and wildcard list parents. */ + public interface NarrowArrayWideLoader extends NarrowListLoader, ArrayListLoader, WideListLoader { + } + + /** Orders the array list parent before the wildcard and typed list parents. */ + public interface ArrayWideNarrowLoader extends ArrayListLoader, WideListLoader, NarrowListLoader { + } + + /** Orders the array list parent before the typed and wildcard list parents. */ + public interface ArrayNarrowWideLoader extends ArrayListLoader, NarrowListLoader, WideListLoader { + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java new file mode 100644 index 0000000..aeaa026 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java @@ -0,0 +1,531 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +public class MessagingRpcEndpointFacadeTest extends MessagingRpcTestSupport { + @Test + void usesTheSameFacadeForTextAndBinaryPayloadRepresentations() throws Exception { + var textTransport = new LoopbackTransport(() -> { }); + var binaryTransport = new LoopbackTransport(() -> { }); + try (MessagingRpcEndpoint text = MessagingRpcEndpoint.open(textTransport, new TextCodec()); + MessagingRpcEndpoint binary = MessagingRpcEndpoint.open(binaryTransport, new BinaryCodec(), OPTIONS)) { + for (MessagingRpcEndpoint endpoint : List.of(text, binary)) { + assertFalse(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + try (var registration = endpoint.register("text", TextService.class, value -> value)) { + TextService proxy = endpoint.proxy("text", TextService.class); + assertEquals("value:é", proxy.apply("value:é")); + assertNull(proxy.apply(null)); + endpoint.close(); + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + failure(MessagingRpcException.Reason.CLOSED, () -> proxy.apply("closed")); + } + } + } + } + + @Test + void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws Exception { + var cleanupStarted = new CountDownLatch(1); + var releaseCleanup = new CountDownLatch(1); + var transport = new LoopbackTransport(() -> { + cleanupStarted.countDown(); + try { + await(releaseCleanup); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + try (MessagingRpcEndpoint endpoint = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS)) { + endpoint.proxy("text", TextService.class); + assertFalse(endpoint.isClosed()); + Future closing = async(() -> { + endpoint.close(); + return null; + }); + try { + await(cleanupStarted); + assertTrue(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + assertFalse(closing.isDone()); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.proxy("text", TextService.class)); + endpoint.close(); + } finally { + releaseCleanup.countDown(); + } + assertNull(result(closing)); + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + } + try (MessagingRpcEndpoint reopened = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS); + var registration = reopened.register("text", TextService.class, value -> "fresh:" + value)) { + assertFalse(reopened.isClosed()); + assertEquals("fresh:value", reopened.proxy("text", TextService.class).apply("value")); + } + } + + @Test + void publishesCleanupCompletionEvenWhenTheTransportCleanupFails() throws Exception { + IllegalStateException failure = new IllegalStateException("Cleanup failed"); + var transport = new LoopbackTransport(() -> { + throw failure; + }); + MessagingRpcEndpoint endpoint = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS); + endpoint.proxy("text", TextService.class); + + assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); + + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + endpoint.close(); + } + + @Test + void defaultsAndIndependentBindingCodecsNeverReplaceEachOther() { + var transport = new MemoryTransport(); + transport.sender = message -> { + transport.deliver(message); + return CompletableFuture.completedFuture(null); + }; + var fallback = new TestCodec(); + var service = new TestCodec(); + var first = new TestCodec(); + final var second = new TestCodec(); + service.decoder = (value, type) -> "service:" + value; + service.encoder = (value, type) -> "result:" + value; + first.encoder = (value, type) -> "first:" + value; + first.decoder = (value, type) -> "one:" + value; + second.encoder = (value, type) -> "second:" + value; + second.decoder = (value, type) -> "two:" + value; + try (var endpoint = MessagingRpcEndpoint.open(transport, fallback, OPTIONS); + var defaultService = endpoint.register("default", TextService.class, value -> value); + var customService = endpoint.register("custom", TextService.class, value -> value, service)) { + TextService defaultProxy = endpoint.proxy("default", TextService.class); + TextService firstProxy = endpoint.proxy("custom", TextService.class, first); + TextService secondProxy = endpoint.proxy("custom", TextService.class, second); + + assertEquals("before", defaultProxy.apply("before")); + assertEquals("one:result:service:first:value", firstProxy.apply("value")); + assertEquals("two:result:service:second:value", secondProxy.apply("value")); + assertEquals("result:service:fallback", endpoint.proxy("custom", TextService.class).apply("fallback")); + assertEquals("after", defaultProxy.apply("after")); + assertEquals(1, first.encoded.size()); + assertEquals(1, first.decoded.size()); + assertEquals(1, second.encoded.size()); + assertEquals(1, second.decoded.size()); + assertEquals(3, service.encoded.size()); + assertEquals(3, service.decoded.size()); + assertEquals(5, fallback.encoded.size()); + assertEquals(5, fallback.decoded.size()); + assertEquals(2, transport.listeners.size()); + } + } + + @Test + void shorterProxyTimeoutDoesNotChangeOtherProxiesSharingTheTopic() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, codec, OPTIONS)) { + TextService first = endpoint.proxy("text", TextService.class); + TextService expiring = endpoint.proxy("text", TextService.class, Duration.ofMillis(100)); + TextService second = endpoint.proxy("text", TextService.class); + Future firstCall = async(() -> first.apply("first")); + final var firstRequest = transport.next(); + Future secondCall = async(() -> second.apply("second")); + final var secondRequest = transport.next(); + + failure(MessagingRpcException.Reason.TIMEOUT, () -> expiring.apply("expire")); + var expiredRequest = transport.next(); + + assertFalse(firstCall.isDone()); + assertFalse(secondCall.isDone()); + transport.deliver(expiredRequest.response(MessagingRpcMessage.response("late"))); + assertFalse(firstCall.isDone()); + assertFalse(secondCall.isDone()); + transport.deliver(firstRequest.response(MessagingRpcMessage.response("first reply"))); + transport.deliver(secondRequest.response(MessagingRpcMessage.response("second reply"))); + assertEquals("first reply", result(firstCall)); + assertEquals("second reply", result(secondCall)); + assertEquals(3, codec.encoded.size()); + assertEquals(2, codec.decoded.size()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.listeners.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void longerProxyTimeoutAndExplicitCodecDoNotChangeTheEndpointDefaults() throws Exception { + var transport = new MemoryTransport(); + var fallback = new TestCodec(); + var custom = new TestCodec(); + custom.encoder = (value, type) -> "custom:" + value; + custom.decoder = (value, type) -> "decoded:" + value; + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 16, 16); + try (var endpoint = MessagingRpcEndpoint.open(transport, fallback, options)) { + TextService first = endpoint.proxy("text", TextService.class); + TextService extended = endpoint.proxy("text", TextService.class, custom, WAIT); + TextService second = endpoint.proxy("text", TextService.class, fallback); + final Future extendedCall = async(() -> extended.apply("extended")); + var extendedRequest = transport.next(); + assertEquals(List.of("custom:extended"), extendedRequest.payload().arguments()); + + failure(MessagingRpcException.Reason.TIMEOUT, () -> first.apply("first")); + var firstRequest = transport.next(); + failure(MessagingRpcException.Reason.TIMEOUT, () -> second.apply("second")); + var secondRequest = transport.next(); + + assertFalse(extendedCall.isDone()); + transport.deliver(firstRequest.response(MessagingRpcMessage.response("first late reply"))); + transport.deliver(secondRequest.response(MessagingRpcMessage.response("second late reply"))); + assertFalse(extendedCall.isDone()); + transport.deliver(extendedRequest.response(MessagingRpcMessage.response("extended reply"))); + assertEquals("decoded:extended reply", result(extendedCall)); + assertEquals(1, custom.encoded.size()); + assertEquals(1, custom.decoded.size()); + assertEquals(2, fallback.encoded.size()); + assertTrue(fallback.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.listeners.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void validatesProxyTimeoutsBeforeOpeningAnyTopic() { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, codec, OPTIONS)) { + for (Duration timeout : List.of( + Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), + Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) + )) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("text", TextService.class, timeout)); + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("text", TextService.class, codec, timeout)); + } + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, (Duration) null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, codec, null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, null, WAIT)); + assertTrue(transport.listeners.isEmpty()); + for (Duration timeout : List.of(Duration.ofNanos(1), Duration.ofNanos(Long.MAX_VALUE))) { + endpoint.proxy("text", TextService.class, timeout); + endpoint.proxy("text", TextService.class, codec, timeout); + } + assertEquals(1, transport.listeners.size()); + } + } + + @Test + void closesEveryTopicWhenOneSubscriptionCleanupFails() { + var closes = new AtomicInteger(); + var failure = new IllegalStateException("Cleanup failed"); + var transport = new LoopbackTransport(() -> { + if (closes.incrementAndGet() == 1) { + throw failure; + } + }); + var endpoint = MessagingRpcEndpoint.open(transport, new TextCodec()); + endpoint.proxy("one", TextService.class); + endpoint.proxy("two", TextService.class); + endpoint.proxy("three", TextService.class); + assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); + assertEquals(3, closes.get()); + assertTrue(transport.listeners.isEmpty()); + assertTrue(endpoint.isCloseComplete()); + } + + @Test + void missingDefaultFailsBeforeOpeningAnyTopic() { + var openings = new AtomicInteger(); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { + openings.incrementAndGet(); + return new MemoryTransport().forTopic(topic); + })) { + IllegalStateException proxyFailure = assertThrows(IllegalStateException.class, + () -> endpoint.proxy("text", TextService.class)); + assertTrue(proxyFailure.getMessage().contains("default codec")); + assertThrows(IllegalStateException.class, () -> endpoint.proxy("text", TextService.class, WAIT)); + assertThrows(IllegalStateException.class, () -> endpoint.register("text", TextService.class, value -> value)); + assertEquals(0, openings.get()); + } + } + + @Test + void incompatibleBindingPayloadsFailThroughTheCodecErrorBoundary() { + var transport = new LoopbackTransport(() -> { }); + try (var endpoint = MessagingRpcEndpoint.open(transport, new TextCodec()); + var registration = endpoint.register("text", TextService.class, value -> value)) { + remoteFailure(MessagingRpcMessage.Error.CODEC_FAILED, + () -> endpoint.proxy("text", TextService.class, new BinaryCodec()).apply("incompatible")); + assertEquals("compatible", endpoint.proxy("text", TextService.class).apply("compatible")); + } + } + + @Test + void concurrentBindingsShareOneOpeningWithoutBlockingOtherTopics() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var openings = new AtomicInteger(); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { + openings.incrementAndGet(); + if (topic.equals("blocked")) { + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); + } + } + return transport.forTopic(topic); + }, OPTIONS)) { + Future first = async(() -> endpoint.proxy("blocked", TextService.class, new TestCodec())); + try { + await(entered); + final Future second = async(() -> endpoint.proxy("blocked", TextService.class, new TestCodec())); + endpoint.proxy("independent", TextService.class, new TestCodec()); + assertEquals(2, openings.get()); + release.countDown(); + assertFalse(result(first).equals(result(second))); + assertEquals(2, openings.get()); + assertEquals(2, transport.listeners.size()); + } finally { + release.countDown(); + } + } + } + + @Test + void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Exception { + var opening = new CountDownLatch(1); + var releaseOpening = new CountDownLatch(1); + var cleanup = new CountDownLatch(1); + var releaseCleanup = new CountDownLatch(1); + var closes = new AtomicInteger(); + var transport = new LoopbackTransport(() -> { + closes.incrementAndGet(); + cleanup.countDown(); + try { + await(releaseCleanup); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { + opening.countDown(); + try { + await(releaseOpening); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); + } + return transport.forTopic(topic); + }, OPTIONS)) { + Future pending = async(() -> endpoint.proxy("text", TextService.class, new TextCodec())); + try { + await(opening); + endpoint.close(); + assertTrue(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + releaseOpening.countDown(); + await(cleanup); + endpoint.close(); + assertFalse(endpoint.isCloseComplete()); + releaseCleanup.countDown(); + failure(MessagingRpcException.Reason.CLOSED, pending); + assertTrue(endpoint.isCloseComplete()); + assertEquals(1, closes.get()); + assertTrue(transport.listeners.isEmpty()); + } finally { + releaseOpening.countDown(); + releaseCleanup.countDown(); + } + } + } + + @Test + void supportsMoreThanSixtyFourReceiverTopicsAndReusesSharedSubscriptions() { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRpcEndpoint.open(transport, new TestCodec())) { + for (int index = 0; index < 128; index++) { + endpoint.register("topic." + index, TextService.class, value -> value); + endpoint.proxy("topic." + index, TextService.class); + } + assertEquals(128, transport.listeners.size()); + endpoint.proxy("account", TextService.class); + try (var registration = endpoint.register("storage", TextService.class, value -> value)) { + endpoint.proxy("storage", TextService.class); + assertEquals(130, transport.listeners.size()); + } + endpoint.proxy("storage", TextService.class); + endpoint.proxy("topic.0", TextService.class); + assertEquals(130, transport.listeners.size()); + } + assertEquals(130, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); + } + + @Test + void supportsMoreThanSixtyFourProxyTopicsAndWaitsForLateOpeningRollback() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var openings = new AtomicInteger(); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { + openings.incrementAndGet(); + if (topic.equals("reserved")) { + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); + } + } + return transport.forTopic(topic); + }, new TestCodec(), OPTIONS)) { + for (int index = 0; index < 128; index++) { + endpoint.proxy("topic." + index, TextService.class); + } + Future pending = async(() -> endpoint.proxy("reserved", TextService.class)); + try { + await(entered); + endpoint.proxy("independent", TextService.class); + endpoint.proxy("topic.0", TextService.class); + assertEquals(130, openings.get()); + assertEquals(129, transport.listeners.size()); + endpoint.close(); + assertTrue(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + assertEquals(129, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); + release.countDown(); + failure(MessagingRpcException.Reason.CLOSED, pending); + assertTrue(endpoint.isCloseComplete()); + assertEquals(130, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); + } finally { + release.countDown(); + } + } + } + + /** Exchanges nullable text values. */ + public interface TextService { + @Nullable + String apply(@Nullable String value); + } + + private static final class TextCodec implements MessagingRpcCodec { + @Nullable + @Override + public String encode(@Nullable Object value, @NotNull Type type) { + return String.class.cast(value); + } + + @Nullable + @Override + public Object decode(@Nullable String payload, @NotNull Type type) { + return payload; + } + } + + private static final class BinaryCodec implements MessagingRpcCodec { + @Nullable + @Override + public byte[] encode(@Nullable Object value, @NotNull Type type) { + return value == null ? null : String.class.cast(value).getBytes(StandardCharsets.UTF_8); + } + + @Nullable + @Override + public Object decode(@Nullable byte[] payload, @NotNull Type type) { + return payload == null ? null : new String(payload, StandardCharsets.UTF_8); + } + } + + private static final class LoopbackTransport

implements MessagingRpcTransport

{ + private final Map>> listeners = new ConcurrentHashMap<>(); + private final Map topics = new ConcurrentHashMap<>(); + private final Runnable onClose; + + private LoopbackTransport(@NotNull Runnable onClose) { + this.onClose = onClose; + } + + @NotNull + @Override + public MessagingRequestReplyTransport> forTopic(@NotNull String topic) { + return new MessagingRequestReplyTransport<>() { + @NotNull + @Override + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + listeners.put(endpointId, listener); + topics.put(endpointId, topic); + return () -> { + listeners.remove(endpointId, listener); + topics.remove(endpointId); + onClose.run(); + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { + List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(topics.get(entry.getKey()))) + .forEach(entry -> entry.getValue().onMessage(message)); + return CompletableFuture.completedFuture(null); + } + }; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java new file mode 100644 index 0000000..6701847 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java @@ -0,0 +1,1005 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.dbeaver.messaging.rpc.fixtures.PublicInheritedService; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +public class MessagingRpcEndpointTest extends MessagingRpcTestSupport { + @Test + void supportsSynchronousReentrantCallbacksOnIndependentReceivingThreads() throws Exception { + var leftTransport = new MemoryTransport(); + var rightTransport = new MemoryTransport(); + var serviceThread = new AtomicReference(); + var callbackThread = new AtomicReference(); + connect(leftTransport, rightTransport); + try (var left = MessagingRpcEndpoint.open(leftTransport, OPTIONS); + var right = MessagingRpcEndpoint.open(rightTransport, OPTIONS); + var callback = left.register("echo", EchoService.class, value -> { + callbackThread.set(Thread.currentThread()); + return "callback:" + value; + }, new TestCodec()); + var service = right.register("echo", EchoService.class, value -> { + serviceThread.set(Thread.currentThread()); + return right.proxy("echo", EchoService.class, new TestCodec()).echo(value); + }, new TestCodec())) { + assertEquals("callback:value", left.proxy("echo", EchoService.class, new TestCodec()).echo("value")); + assertNotEquals(Thread.currentThread(), serviceThread.get()); + assertNotEquals(serviceThread.get(), callbackThread.get()); + assertNotEquals(Thread.currentThread(), callbackThread.get()); + } + } + + @Test + void servesIndependentServicesInBothDirectionsAndAcrossTopics() throws Exception { + var leftTransport = new MemoryTransport(); + var rightTransport = new MemoryTransport(); + connect(leftTransport, rightTransport); + try (var left = MessagingRpcEndpoint.open(leftTransport); + var right = MessagingRpcEndpoint.open(rightTransport, OPTIONS); + var leftService = left.register("echo", EchoService.class, value -> "left:" + value, new TestCodec()); + var rightService = right.register("echo", EchoService.class, value -> "right:" + value, new TestCodec()); + var otherService = right.register("other", EchoService.class, value -> "other:" + value, new TestCodec())) { + EchoService fromLeft = left.proxy("echo", EchoService.class, new TestCodec()); + EchoService fromRight = right.proxy("echo", EchoService.class, new TestCodec()); + + assertEquals("right:first", fromLeft.echo("first")); + assertEquals("left:second", fromRight.echo("second")); + assertEquals("other:third", left.proxy("other", EchoService.class, new TestCodec()).echo("third")); + assertEquals(2, leftTransport.listeners.size()); + assertEquals(2, rightTransport.listeners.size()); + } + assertEquals(2, leftTransport.subscriptionCloses.get()); + assertEquals(2, rightTransport.subscriptionCloses.get()); + } + + @Test + void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("collection", CollectionService.class, + values -> values == null ? null : Map.of("size", values.size()), serverCodec)) { + CollectionService proxy = client.proxy("collection", CollectionService.class, clientCodec); + var values = new ArrayList<>(List.of("one", "two")); + Map expected = Map.of("size", 2); + + assertEquals(expected, proxy.count(values)); + assertNull(proxy.count(null)); + + var operation = CollectionService.class.getMethod("count", List.class); + Type parameterType = operation.getGenericParameterTypes()[0]; + Type returnType = operation.getGenericReturnType(); + assertEquals(List.of(new CodecCall(values, parameterType), new CodecCall(null, parameterType)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(List.of(new CodecCall(expected, returnType), new CodecCall(null, returnType)), serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + assertEquals("count(java.util.List)", clientTransport.next().payload().operation()); + assertEquals(Arrays.asList((Object) null), clientTransport.next().payload().arguments()); + assertEquals(MessagingRpcMessage.Type.RESPONSE, serverTransport.next().payload().type()); + assertEquals(MessagingRpcMessage.response(null), serverTransport.next().payload()); + } + } + + @Test + void supportsEveryPrimitiveWithoutChangingItsDeclaredCodecType() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("primitive", PrimitiveService.class, new PrimitiveEcho(), serverCodec)) { + PrimitiveService proxy = client.proxy("primitive", PrimitiveService.class, clientCodec); + + assertTrue(proxy.echo(true)); + assertEquals((byte) 12, proxy.echo((byte) 12)); + assertEquals((short) 123, proxy.echo((short) 123)); + assertEquals('x', proxy.echo('x')); + assertEquals(1234, proxy.echo(1234)); + assertEquals(12345L, proxy.echo(12345L)); + assertEquals(1.25f, proxy.echo(1.25f)); + assertEquals(2.5d, proxy.echo(2.5d)); + + List expected = List.of(boolean.class, byte.class, short.class, char.class, + int.class, long.class, float.class, double.class); + assertEquals(expected, clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(clientCodec.encoded, serverCodec.encoded); + assertEquals(clientCodec.encoded, clientCodec.decoded); + } + } + + @Test + void dispatchesOverloadsAndInheritedMethodsWithTheNarrowestCovariantReturn() throws Exception { + assertTrue(Arrays.stream(OverloadedService.class.getDeclaredMethods()).anyMatch(method -> + method.isBridge() && method.isSynthetic() && Arrays.equals(method.getParameterTypes(), new Class[] {String.class}))); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + var implementation = new OverloadedService() { + @NotNull + @Override + public String convert(@NotNull String value) { + return "text:" + value; + } + + @NotNull + @Override + public String convert(int value) { + return "number:" + value; + } + + @NotNull + @Override + public String inherited(@NotNull Long value) { + return "inherited:" + value; + } + }; + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("overload", OverloadedService.class, implementation, serverCodec)) { + OverloadedService proxy = client.proxy("overload", OverloadedService.class, clientCodec); + BaseService base = proxy; + + assertEquals("text:value", base.convert("value")); + assertEquals("text:child", proxy.convert("child")); + assertEquals("number:42", proxy.convert(42)); + assertEquals("inherited:9", proxy.inherited(9L)); + + assertEquals("convert(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("convert(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("convert(int)", clientTransport.next().payload().operation()); + assertEquals("inherited(java.lang.Long)", clientTransport.next().payload().operation()); + assertEquals(List.of(String.class, String.class, String.class, String.class), + clientCodec.decoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.decoded, serverCodec.encoded); + } + } + + @Test + void invokesAnOperationInheritedFromANonPublicParentInAnotherPackage() throws Exception { + PublicInheritedService implementation = value -> "inherited:" + value; + var operation = PublicInheritedService.class.getMethod("inherited", String.class); + assertFalse(Modifier.isPublic(operation.getDeclaringClass().getModifiers())); + assertNotEquals(MessagingRpcEndpoint.class.getPackageName(), operation.getDeclaringClass().getPackageName()); + assertFalse(operation.canAccess(implementation)); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("inherited", PublicInheritedService.class, implementation, new TestCodec())) { + PublicInheritedService proxy = client.proxy("inherited", PublicInheritedService.class, new TestCodec()); + + assertEquals("inherited:value", proxy.inherited("value")); + assertEquals("inherited(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals(MessagingRpcMessage.response("inherited:value"), serverTransport.next().payload()); + } + } + + @Test + void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("void", VoidService.class, value -> { + assertEquals("work", value); + entered.countDown(); + await(release); + }, serverCodec)) { + try { + Future pending = async(() -> { + client.proxy("void", VoidService.class, clientCodec).accept("work"); + return null; + }); + await(entered); + assertFalse(pending.isDone()); + assertTrue(serverTransport.sent.isEmpty()); + + release.countDown(); + + assertNull(result(pending)); + assertEquals(MessagingRpcMessage.response(null), serverTransport.next().payload()); + assertEquals(List.of(new CodecCall("work", String.class)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertTrue(serverCodec.encoded.isEmpty()); + assertTrue(clientCodec.decoded.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetrying() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS)) { + for (Throwable cause : List.of(new Exception("private checked detail"), + new IllegalStateException("private runtime detail"), new AssertionError("private error detail"))) { + try (var registration = server.register("failure", FailingService.class, value -> { + calls.incrementAndGet(); + if (cause instanceof Exception exception) { + throw exception; + } + throw (Error) cause; + }, new TestCodec())) { + MessagingRpcException failure = remoteFailure(MessagingRpcMessage.Error.HANDLER_FAILED, + () -> client.proxy("failure", FailingService.class, new TestCodec()).fail("secret argument")); + + MessagingRpcExceptionDetails details = failure.remoteException(); + assertNotNull(details); + assertEquals(cause.getClass().getName(), details.type()); + assertEquals(cause.getMessage(), details.message()); + assertTrue(details.stackTrace().contains(cause.toString())); + assertFalse(details.stackTrace().contains(InvocationTargetException.class.getName())); + assertNotSame(cause, failure.getCause()); + assertFalse(failure.toString().contains("secret")); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, details), + serverTransport.next().payload()); + assertEquals(List.of("secret argument"), clientTransport.next().payload().arguments()); + assertTrue(clientTransport.sent.isEmpty()); + assertTrue(serverTransport.sent.isEmpty()); + } + } + assertEquals(3, calls.get()); + } + } + + @Test + void distinguishesLocalAndRemoteCodecFailuresAtAllFourBoundariesAndRecovers() throws Exception { + for (int boundary = 0; boundary < 4; boundary++) { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var cause = new Exception("private codec detail"); + var calls = new AtomicInteger(); + Conversion broken = (value, type) -> { + throw cause; + }; + switch (boundary) { + case 0 -> clientCodec.encoder = broken; + case 1 -> serverCodec.decoder = broken; + case 2 -> serverCodec.encoder = broken; + case 3 -> clientCodec.decoder = broken; + default -> throw new AssertionError("Unexpected codec boundary"); + } + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + }, serverCodec)) { + EchoService proxy = client.proxy("echo", EchoService.class, clientCodec); + if (boundary == 0 || boundary == 3) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); + assertSame(cause, failure.getCause()); + assertNull(failure.remoteError()); + assertNull(failure.remoteException()); + } else { + MessagingRpcException failure = remoteFailure(MessagingRpcMessage.Error.CODEC_FAILED, + () -> proxy.echo("secret argument")); + MessagingRpcExceptionDetails details = failure.remoteException(); + assertNotNull(details); + assertEquals(cause.getClass().getName(), details.type()); + assertEquals(cause.getMessage(), details.message()); + assertNotSame(cause, failure.getCause()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, details), + serverTransport.next().payload()); + } + assertEquals(boundary < 2 ? 0 : 1, calls.get()); + assertEquals(boundary == 0 ? 0 : 1, clientTransport.sent.size()); + + clientCodec.encoder = (value, type) -> value; + clientCodec.decoder = (value, type) -> value; + serverCodec.encoder = (value, type) -> value; + serverCodec.decoder = (value, type) -> value; + assertEquals("recovered", proxy.echo("recovered")); + assertEquals(boundary < 2 ? 1 : 2, calls.get()); + } + } + } + + @Test + void rejectsPrimitiveNullsAndWrongDecodedArgumentTypesBeforeInvokingAService() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var references = server.register("reference", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + }, codec); + var primitives = server.register("primitive", IntService.class, value -> { + calls.incrementAndGet(); + return value; + }, codec)) { + for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { + codec.decoder = (value, type) -> invalid; + remoteFailure(MessagingRpcMessage.Error.INVALID_REQUEST, + () -> client.proxy("primitive", IntService.class, new TestCodec()).echo(1)); + } + codec.decoder = (value, type) -> 42; + remoteFailure(MessagingRpcMessage.Error.INVALID_REQUEST, + () -> client.proxy("reference", EchoService.class, new TestCodec()).echo("value")); + + assertEquals(0, calls.get()); + assertTrue(codec.encoded.isEmpty()); + } + } + + @Test + void rejectsPrimitiveNullsAndWrongDecodedResultTypesAsInvalidResponses() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var references = server.register("reference", EchoService.class, value -> value, new TestCodec()); + var primitives = server.register("primitive", IntService.class, value -> value, new TestCodec())) { + for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { + codec.decoder = (value, type) -> invalid; + MessagingRpcException failure = failure(MessagingRpcException.Reason.INVALID_RESPONSE, + () -> client.proxy("primitive", IntService.class, codec).echo(1)); + assertNull(failure.remoteError()); + } + codec.decoder = (value, type) -> 42; + failure(MessagingRpcException.Reason.INVALID_RESPONSE, + () -> client.proxy("reference", EchoService.class, codec).echo("value")); + } + } + + @Test + void rejectsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("echo"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + }, codec)) { + for (String operation : List.of("missing(java.lang.String)", "echo(int)")) { + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), + client.request(MessagingRpcMessage.request(operation, List.of("value")))); + } + for (MessagingRpcMessage invalid : List.of(MessagingRpcMessage.response("not a request"), + MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED))) { + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), client.request(invalid)); + } + assertEquals(0, calls.get()); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + } + } + + @Test + void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); + List> invalid = Arrays.asList(null, + MessagingRpcMessage.request("echo(java.lang.String)", List.of("value"))); + for (MessagingRpcMessage reply : invalid) { + transport.sender = request -> { + transport.deliver(request.response(reply)); + return CompletableFuture.completedFuture(null); + }; + failure(MessagingRpcException.Reason.INVALID_RESPONSE, () -> proxy.echo("value")); + } + transport.sender = request -> { + transport.deliver(request.response(MessagingRpcMessage.response("unexpected void payload"))); + return CompletableFuture.completedFuture(null); + }; + failure(MessagingRpcException.Reason.INVALID_RESPONSE, + () -> endpoint.proxy("void", VoidService.class, codec).accept("value")); + assertTrue(codec.decoded.isEmpty()); + assertEquals(3, transport.sent.size()); + } + } + + @Test + void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregistering() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var originalCodec = new TestCodec(); + var replacementCodec = new TestCodec(); + originalCodec.encoder = (value, type) -> "old-codec:" + value; + replacementCodec.encoder = (value, type) -> "new-codec:" + value; + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var other = server.register("other", EchoService.class, value -> "other:" + value, new TestCodec()); + var original = server.register("echo", EchoService.class, value -> { + entered.countDown(); + await(release); + return "original:" + value; + }, originalCodec)) { + try { + EchoService proxy = client.proxy("echo", EchoService.class, new TestCodec()); + assertThrows(IllegalStateException.class, + () -> server.register("echo", EchoService.class, value -> value, new TestCodec())); + Future accepted = async(() -> proxy.echo("accepted")); + await(entered); + + original.close(); + remoteFailure(MessagingRpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("unregistered")); + try (var replacement = server.register("echo", EchoService.class, value -> "replacement:" + value, replacementCodec)) { + original.close(); + assertEquals("new-codec:replacement:next", proxy.echo("next")); + assertEquals("other:next", client.proxy("other", EchoService.class, new TestCodec()).echo("next")); + release.countDown(); + assertEquals("old-codec:original:accepted", result(accepted)); + } + remoteFailure(MessagingRpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("removed replacement")); + assertEquals("other:surviving", client.proxy("other", EchoService.class, new TestCodec()).echo("surviving")); + assertEquals(2, serverTransport.listeners.size()); + assertEquals(0, serverTransport.subscriptionCloses.get()); + } finally { + release.countDown(); + } + } + } + + @Test + void closingAnEndpointFailsPendingCallsAndClosesItsTopicSubscriptions() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); + Future pending = async(() -> proxy.echo("pending")); + transport.next(); + + endpoint.close(); + + MessagingRpcException failure = failure(MessagingRpcException.Reason.CLOSED, pending); + assertNull(failure.remoteError()); + failure(MessagingRpcException.Reason.CLOSED, () -> proxy.echo("closed")); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class, codec)); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value, codec)); + assertTrue(transport.listeners.isEmpty()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.subscriptionCloses.get()); + assertEquals(1, codec.encoded.size()); + endpoint.close(); + assertEquals(1, transport.subscriptionCloses.get()); + } + } + + @Test + void failedTopicOpeningReleasesItsReservationAndAllowsRetry() throws Exception { + var transport = new MemoryTransport(); + var attempts = new AtomicInteger(); + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { + if (attempts.getAndIncrement() == 0) { + throw expected; + } + return transport.forTopic(topic); + }, OPTIONS)) { + assertSame(expected, + failure(MessagingRpcException.Reason.DISCONNECTED, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + endpoint.proxy("echo", EchoService.class, new TestCodec()); + assertEquals(2, attempts.get()); + assertEquals(1, transport.listeners.size()); + } + } + + @Test + void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); + Future pending = async(() -> proxy.echo("old")); + final var oldRequest = transport.next(); + + transport.disconnect(); + + MessagingRpcException failure = failure(MessagingRpcException.Reason.DISCONNECTED, pending); + assertNull(failure.remoteError()); + assertTrue(transport.sent.isEmpty()); + Future fresh = async(() -> proxy.echo("fresh")); + var freshRequest = transport.next(); + assertEquals(List.of("fresh"), freshRequest.payload().arguments()); + transport.deliver(oldRequest.response(MessagingRpcMessage.response("late"))); + assertFalse(fresh.isDone()); + transport.deliver(freshRequest.response(MessagingRpcMessage.response("fresh reply"))); + + assertEquals("fresh reply", result(fresh)); + assertTrue(transport.sent.isEmpty()); + assertEquals(0, transport.subscriptionCloses.get()); + } + } + + @Test + void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = MessagingRpcEndpoint.open(transport, options)) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.TIMEOUT, + () -> endpoint.proxy("echo", EchoService.class, codec).echo("expire")); + + assertNull(failure.remoteError()); + assertEquals(List.of("expire"), transport.next().payload().arguments()); + assertTrue(transport.sent.isEmpty()); + assertEquals(List.of(new CodecCall("expire", String.class)), codec.encoded); + assertTrue(codec.decoded.isEmpty()); + } + } + + @Test + void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exception { + var transport = new MemoryTransport(); + var cause = new IllegalStateException("send failed"); + var options = new MessagingRequestReplyOptions(WAIT, 1, 1); + transport.sender = request -> CompletableFuture.failedFuture(cause); + try (var endpoint = MessagingRpcEndpoint.open(transport, options)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); + MessagingRpcException failed = failure(MessagingRpcException.Reason.SEND_FAILED, () -> proxy.echo("failed")); + assertSame(cause, assertInstanceOf(MessagingRequestReplyException.class, failed.getCause()).getCause()); + assertNull(failed.remoteError()); + transport.next(); + assertTrue(transport.sent.isEmpty()); + + transport.sender = request -> CompletableFuture.completedFuture(null); + final Future pending = async(() -> proxy.echo("pending")); + var request = transport.next(); + failure(MessagingRpcException.Reason.OVERLOADED, () -> proxy.echo("excess")); + assertTrue(transport.sent.isEmpty()); + transport.deliver(request.response(MessagingRpcMessage.response("completed"))); + assertEquals("completed", result(pending)); + } + } + + @Test + void preservesTypedSendFailureReasonsAtTheProxyBoundary() throws Exception { + for (var mapping : Map.of( + MessagingRequestReplyException.Reason.OVERLOADED, MessagingRpcException.Reason.OVERLOADED, + MessagingRequestReplyException.Reason.DISCONNECTED, MessagingRpcException.Reason.DISCONNECTED, + MessagingRequestReplyException.Reason.CLOSED, MessagingRpcException.Reason.CLOSED).entrySet()) { + var expected = new MessagingRequestReplyException(mapping.getKey()); + var transport = new MemoryTransport(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); + for (Throwable error : List.of(expected, new CompletionException(new ExecutionException(expected)))) { + transport.sender = request -> CompletableFuture.failedFuture(error); + + assertSame(expected, failure(mapping.getValue(), () -> proxy.echo("asynchronous failure")).getCause()); + } + transport.sender = request -> { + throw new CompletionException(expected); + }; + assertSame(expected, failure(mapping.getValue(), () -> proxy.echo("synchronous failure")).getCause()); + assertEquals(3, transport.sent.size()); + } + } + } + + @Test + void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Exception { + var transport = new MemoryTransport(); + transport.sender = request -> { + transport.deliver(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED)); + return CompletableFuture.completedFuture(null); + }; + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.REMOTE_ERROR, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec()).echo("value")); + assertNull(failure.remoteError()); + assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, + assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()).remoteError()); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); + var pending = new FutureTask<>(() -> { + MessagingRpcException failure = failure(MessagingRpcException.Reason.INTERRUPTED, () -> proxy.echo("interrupt")); + assertInstanceOf(InterruptedException.class, failure.getCause()); + assertNull(failure.remoteError()); + return Thread.currentThread().isInterrupted(); + }); + Thread caller = start(pending); + transport.next(); + + caller.interrupt(); + + assertEquals(Boolean.TRUE, result(pending)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void alreadyInterruptedCallersKeepTheirFlagWithoutInvokingTheCodecOrSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); + Future flag = async(() -> { + Thread.currentThread().interrupt(); + try { + failure(MessagingRpcException.Reason.INTERRUPTED, () -> proxy.echo("value")); + return Thread.currentThread().isInterrupted(); + } finally { + Thread.interrupted(); + } + }); + + assertEquals(Boolean.TRUE, result(flag)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void restoresTheCallerInterruptFlagWhenEitherLocalCodecBoundaryIsInterrupted() throws Exception { + for (boolean encoding : List.of(false, true)) { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + Conversion interrupted = (value, type) -> { + throw new InterruptedException("codec interrupted"); + }; + if (encoding) { + codec.encoder = interrupted; + } else { + codec.decoder = interrupted; + } + transport.sender = request -> { + transport.deliver(request.response(MessagingRpcMessage.response("value"))); + return CompletableFuture.completedFuture(null); + }; + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + Future flag = async(() -> { + failure(MessagingRpcException.Reason.INTERRUPTED, + () -> endpoint.proxy("echo", EchoService.class, codec).echo("value")); + return Thread.currentThread().isInterrupted(); + }); + + assertEquals(Boolean.TRUE, result(flag)); + assertEquals(encoding ? 0 : 1, transport.sent.size()); + } + } + } + + @Test + void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var serverCodec = new TestCodec(); + var decoding = new CountDownLatch(4); + var releaseDecoding = new CountDownLatch(1); + var entered = new CountDownLatch(4); + var release = new LinkedHashMap(); + for (String value : List.of("one", "two", "three", "four")) { + release.put(value, new CountDownLatch(1)); + } + serverCodec.decoder = (value, type) -> { + decoding.countDown(); + await(releaseDecoding); + return value; + }; + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + entered.countDown(); + await(release.get(value)); + return "reply:" + value; + }, serverCodec)) { + try { + EchoService proxy = client.proxy("echo", EchoService.class, new TestCodec()); + var results = new LinkedHashMap>(); + for (String value : release.keySet()) { + results.put(value, async(() -> proxy.echo(value))); + } + var order = new ArrayList(); + for (int index = 0; index < results.size(); index++) { + order.add(assertInstanceOf(String.class, clientTransport.next().payload().arguments().get(0))); + } + await(decoding); + releaseDecoding.countDown(); + await(entered); + assertTrue(results.values().stream().noneMatch(Future::isDone)); + + for (int index = order.size() - 1; index >= 0; index--) { + String value = order.get(index); + release.get(value).countDown(); + assertEquals("reply:" + value, result(results.get(value))); + assertEquals(MessagingRpcMessage.response("reply:" + value), serverTransport.next().payload()); + } + assertEquals(4, serverCodec.decoded.size()); + assertEquals(4, serverCodec.encoded.size()); + assertTrue(serverTransport.sent.isEmpty()); + } finally { + releaseDecoding.countDown(); + release.values().forEach(CountDownLatch::countDown); + } + } + } + + @Test + void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + EchoService first = endpoint.proxy("echo", EchoService.class, codec); + EchoService second = endpoint.proxy("echo", EchoService.class, codec); + + assertTrue(first.equals(first)); + assertFalse(first.equals(second)); + assertFalse(first.equals(null)); + assertFalse(first.equals(new Object())); + assertEquals(System.identityHashCode(first), first.hashCode()); + assertTrue(first.toString().contains("echo")); + assertTrue(first.toString().contains(EchoService.class.getName())); + assertNotEquals(first, second); + endpoint.close(); + assertTrue(first.equals(first)); + assertEquals(System.identityHashCode(first), first.hashCode()); + assertTrue(first.toString().contains("echo")); + + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(transport, (MessagingRequestReplyOptions) null)); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(transport, null, OPTIONS)); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(null, OPTIONS)); + assertTrue(transport.listeners.isEmpty()); + + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + for (String topic : List.of("", " ", "\n", "t".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy(topic, EchoService.class, codec)); + assertThrows(IllegalArgumentException.class, () -> endpoint.register(topic, EchoService.class, value -> value, codec)); + } + assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class, codec)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null, codec)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, (MessagingRpcCodec) null)); + assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value, codec)); + assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null, codec)); + assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, value -> value, null)); + assertTrue(transport.listeners.isEmpty()); + assertTrue(transport.sent.isEmpty()); + assertTrue(codec.encoded.isEmpty()); + } + } + + @Test + void propagatesSubscriptionFailureWithoutSending() { + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + var transport = new MessagingRequestReplyTransport>() { + @NotNull + @Override + public MessagingSubscription subscribe( + @NotNull UUID endpointId, + @NotNull Listener> listener + ) throws MessagingRequestReplyException { + throw expected; + } + + @NotNull + @Override + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { + throw new AssertionError("Subscription failure must prevent sending"); + } + }; + + try (var endpoint = MessagingRpcEndpoint.open(topic -> transport, OPTIONS)) { + assertSame(expected, + failure(MessagingRpcException.Reason.DISCONNECTED, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + } + } + + /** Defines nullable reference calls and redeclares local object methods. */ + public interface EchoService { + @Nullable + String echo(@Nullable String value) throws InterruptedException; + + boolean equals(@Nullable Object value); + + int hashCode(); + + @NotNull + String toString(); + } + + /** Defines a concrete parameterized request and response. */ + public interface CollectionService { + @Nullable + Map count(@Nullable List values); + } + + /** Defines synchronous completion without a result. */ + public interface VoidService { + void accept(@Nullable String value) throws InterruptedException; + } + + /** Defines an operation that may fail with a checked exception. */ + public interface FailingService { + @NotNull + String fail(@NotNull String value) throws Exception; + } + + /** Defines an integer request and response. */ + public interface IntService { + int echo(int value); + } + + /** Defines inherited and covariant remote operations. */ + public interface BaseService { + @NotNull + CharSequence convert(@NotNull String value); + + @NotNull + String inherited(@NotNull Long value); + } + + /** Narrows an inherited return type and adds a primitive overload. */ + public interface OverloadedService extends BaseService { + @NotNull + @Override + String convert(@NotNull String value); + + @NotNull + String convert(int value); + } + + /** Defines overloads for every primitive request and response. */ + public interface PrimitiveService { + boolean echo(boolean value); + + byte echo(byte value); + + short echo(short value); + + char echo(char value); + + int echo(int value); + + long echo(long value); + + float echo(float value); + + double echo(double value); + } + + private static final class PrimitiveEcho implements PrimitiveService { + @Override + public boolean echo(boolean value) { + return value; + } + + @Override + public byte echo(byte value) { + return value; + } + + @Override + public short echo(short value) { + return value; + } + + @Override + public char echo(char value) { + return value; + } + + @Override + public int echo(int value) { + return value; + } + + @Override + public long echo(long value) { + return value; + } + + @Override + public float echo(float value) { + return value; + } + + @Override + public double echo(double value) { + return value; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java new file mode 100644 index 0000000..3051824 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java @@ -0,0 +1,149 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class MessagingRpcExceptionDetailsTest { + @Test + void capturesStandardTextIncludingCausesSuppressedExceptionsAndOriginalFrames() { + var cause = new IllegalArgumentException("Invalid argument"); + var failure = new IllegalStateException("Operation failed", cause); + var suppressed = new IOException("Cleanup failed"); + failure.setStackTrace(new StackTraceElement[] {new StackTraceElement("Receiver", "handle", "Receiver.java", 42)}); + cause.setStackTrace(new StackTraceElement[] {new StackTraceElement("Repository", "load", "Repository.java", 17)}); + suppressed.setStackTrace(new StackTraceElement[] {new StackTraceElement("Resource", "close", "Resource.java", 9)}); + failure.addSuppressed(suppressed); + StringWriter expected = new StringWriter(); + failure.printStackTrace(new PrintWriter(expected)); + + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); + + assertEquals(IllegalStateException.class.getName(), details.type()); + assertEquals("Operation failed", details.message()); + assertEquals(expected.toString(), details.stackTrace()); + assertTrue(details.stackTrace().contains("Caused by: " + cause)); + assertTrue(details.stackTrace().contains("Suppressed: " + IOException.class.getName())); + assertFalse(details.truncated()); + } + + @Test + void preservesNullMessagesAndTerminatesCircularExceptionGraphs() { + var failure = new Exception(); + var cause = new Exception("Cause", failure); + failure.setStackTrace(new StackTraceElement[0]); + cause.setStackTrace(new StackTraceElement[0]); + failure.initCause(cause); + failure.addSuppressed(cause); + + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); + + assertNull(details.message()); + assertTrue(details.stackTrace().contains("CIRCULAR REFERENCE")); + assertFalse(details.truncated()); + } + + @Test + void boundsMessagesAndTraceTextWithoutSplittingSurrogatePairs() { + String message = "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH - 1) + "😀tail"; + var failure = new IllegalStateException(message) { + @Override + public void printStackTrace(@NotNull PrintWriter writer) { + writer.print("s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1) + "😀tail"); + } + }; + + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); + + assertEquals("m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH - 1), details.message()); + assertEquals("s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1), details.stackTrace()); + assertTrue(details.truncated()); + } + + @Test + void stopsFormattingAtTheLimitInsteadOfBuildingAnUnboundedString() { + AtomicInteger progress = new AtomicInteger(); + var failure = new IllegalStateException("Failure") { + @Override + public void printStackTrace(@NotNull PrintWriter writer) { + writer.print("x".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH)); + progress.incrementAndGet(); + writer.write(new char[] {'y'}, 0, 1); + progress.incrementAndGet(); + throw new AssertionError("Formatting continued past the limit"); + } + }; + + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); + + assertEquals(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH, details.stackTrace().length()); + assertEquals(1, progress.get()); + assertTrue(details.truncated()); + } + + @Test + void preservesTheFailureCodeEvenIfCustomExceptionFormattingFails() { + var failure = new IllegalStateException() { + @Override + @Nullable + public String getMessage() { + throw new IllegalStateException("Message unavailable"); + } + }; + + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); + + assertEquals(failure.getClass().getName(), details.type()); + assertNull(details.message()); + assertEquals(details.type(), details.stackTrace()); + assertTrue(details.truncated()); + } + + @Test + void validatesAllBoundsWithoutRejectingTheirExactLimits() { + var details = new MessagingRpcExceptionDetails("t".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH), + "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH), + "s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH), false); + + assertFalse(details.truncated()); + assertThrows(NullPointerException.class, () -> new MessagingRpcExceptionDetails(null, null, "", false)); + assertThrows(NullPointerException.class, () -> new MessagingRpcExceptionDetails("type", null, null, false)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRpcExceptionDetails(" ", null, "", false)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcExceptionDetails("t".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH + 1), null, "", false)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcExceptionDetails("type", "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH + 1), "", false)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcExceptionDetails("type", null, + "s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1), false)); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java new file mode 100644 index 0000000..e0e2006 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java @@ -0,0 +1,67 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.junit.jupiter.api.Test; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessagingRpcExceptionTest { + @Test + void includesRemoteTextInOrdinaryExceptionLoggingWithoutLoadingTheRemoteClass() { + var details = new MessagingRpcExceptionDetails("unavailable.application.Failure", "Application failed", + "unavailable.application.Failure: Application failed\n\tat remote.Receiver.call(Receiver.java:42)", true); + + var failure = new MessagingRpcException(MessagingRpcMessage.Error.HANDLER_FAILED, details); + StringWriter printed = new StringWriter(); + failure.printStackTrace(new PrintWriter(printed)); + + assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + assertSame(details, failure.remoteException()); + assertTrue(failure.getMessage().contains(details.type())); + assertTrue(failure.getMessage().contains(details.message())); + assertTrue(printed.toString().contains(details.stackTrace())); + assertTrue(printed.toString().contains("Remote stack trace (truncated)")); + assertNotEquals(details.type(), failure.getCause().getClass().getName()); + assertEquals(0, failure.getCause().getStackTrace().length); + assertTrue(failure.getStackTrace().length > 0); + } + + @Test + void preservesCodeOnlyErrorsAndLocalCausesWithoutInventingRemoteDiagnostics() { + var remote = new MessagingRpcException(MessagingRpcMessage.Error.UNKNOWN_SERVICE); + assertEquals("Remote RPC call failed: UNKNOWN_SERVICE", remote.getMessage()); + assertNull(remote.getCause()); + assertNull(remote.remoteException()); + + var cause = new IllegalStateException("Local failure"); + var local = new MessagingRpcException(MessagingRpcException.Reason.SEND_FAILED, cause); + assertSame(cause, local.getCause()); + assertNull(local.remoteException()); + assertNull(local.remoteError()); + assertFalse(local.getMessage().contains(cause.getMessage())); + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java new file mode 100644 index 0000000..5a14236 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java @@ -0,0 +1,157 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Timeout(10) +class MessagingRpcMessageTest { + @Test + void preservesOpaquePayloadsAndAllowsNullApplicationValues() { + Object payload = new Object(); + MessagingRpcMessage request = MessagingRpcMessage.request("echo(java.lang.String)", List.of(payload)); + + assertEquals(MessagingRpcMessage.Type.REQUEST, request.type()); + assertEquals("echo(java.lang.String)", request.operation()); + assertEquals(List.of(payload), request.arguments()); + assertSame(payload, request.arguments().get(0)); + assertNull(request.payload()); + assertNull(request.error()); + assertNull(request.exception()); + assertEquals(Arrays.asList((Object) null), + MessagingRpcMessage.request("echo(java.lang.String)", Arrays.asList((Object) null)).arguments()); + + MessagingRpcMessage response = MessagingRpcMessage.response(payload); + assertEquals(MessagingRpcMessage.Type.RESPONSE, response.type()); + assertSame(payload, response.payload()); + assertEquals(List.of(), response.arguments()); + assertNull(response.operation()); + assertNull(response.error()); + assertEquals(new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), null, null, null), + MessagingRpcMessage.response(null)); + } + + @Test + void snapshotsArgumentsWithoutCopyingApplicationValuesAndAllowsNullElements() { + for (boolean factory : List.of(false, true)) { + var value = new ArrayList<>(List.of("original")); + var arguments = new ArrayList(Arrays.asList(value, null)); + MessagingRpcMessage request = factory + ? MessagingRpcMessage.request("echo(java.util.List,java.lang.String)", arguments) + : new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, + "echo(java.util.List,java.lang.String)", arguments, null, null, null); + + arguments.set(0, "replacement"); + arguments.clear(); + + assertEquals(Arrays.asList(value, null), request.arguments()); + assertSame(value, request.arguments().get(0)); + value.add("updated"); + assertEquals(List.of("original", "updated"), request.arguments().get(0)); + assertThrows(UnsupportedOperationException.class, () -> request.arguments().add("extra")); + assertThrows(UnsupportedOperationException.class, () -> request.arguments().set(0, "replacement")); + } + } + + @Test + void requiresANonNullArgumentListForEveryMessageType() { + assertThrows(NullPointerException.class, () -> MessagingRpcMessage.request("echo()", null)); + for (MessagingRpcMessage.Type type : MessagingRpcMessage.Type.values()) { + String operation = type == MessagingRpcMessage.Type.REQUEST ? "echo()" : null; + MessagingRpcMessage.Error error = type == MessagingRpcMessage.Type.ERROR ? MessagingRpcMessage.Error.HANDLER_FAILED : null; + assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(type, operation, null, null, error, null)); + } + } + + @Test + void failuresCanCarryACodeWithoutExceptionDetails() { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + MessagingRpcMessage failure = MessagingRpcMessage.failure(error); + + assertEquals(MessagingRpcMessage.Type.ERROR, failure.type()); + assertEquals(error, failure.error()); + assertEquals(List.of(), failure.arguments()); + assertNull(failure.operation()); + assertNull(failure.payload()); + assertNull(failure.exception()); + } + assertThrows(NullPointerException.class, () -> MessagingRpcMessage.failure(null)); + } + + @Test + void exceptionDetailsBelongOnlyToErrorResponsesAndNeverToApplicationPayloads() { + var details = new MessagingRpcExceptionDetails("test.ApplicationException", "failure", "remote stack trace", false); + MessagingRpcMessage failure = MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, details); + + assertSame(details, failure.exception()); + assertNull(failure.payload()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED), + MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), null, null, details)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), 1, null, details)); + } + + @Test + void validatesOperationBoundsWithoutRejectingTheBoundary() { + String operation = "o".repeat(1024); + MessagingRpcMessage request = MessagingRpcMessage.request(operation, List.of()); + assertEquals(operation, request.operation()); + + for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(invalid, List.of())); + } + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(null, List.of())); + assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(null, null, List.of(), null, null, null)); + } + + @Test + void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { + for (MessagingRpcMessage.Type type : List.of(MessagingRpcMessage.Type.RESPONSE, MessagingRpcMessage.Type.ERROR)) { + MessagingRpcMessage.Error error = type == MessagingRpcMessage.Type.ERROR ? MessagingRpcMessage.Error.HANDLER_FAILED : null; + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, "echo(int)", List.of(), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, null, List.of("argument"), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, null, Arrays.asList((Object) null), null, error, null)); + } + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, List.of(), null, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), "private payload", null, null)); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, List.of(), "private payload", error, null)); + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java new file mode 100644 index 0000000..e309955 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java @@ -0,0 +1,220 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.function.Executable; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessagingRpcTestSupport { + static final Duration WAIT = Duration.ofSeconds(5); + static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(WAIT, 16, 16); + private final List callers = new CopyOnWriteArrayList<>(); + + @AfterEach + void stopCallers() throws InterruptedException { + callers.forEach(Thread::interrupt); + for (Thread caller : callers) { + assertTrue(caller.join(WAIT), "Caller did not finish"); + } + } + + @NotNull + final FutureTask async(@NotNull Callable action) { + var future = new FutureTask<>(action); + start(future); + return future; + } + + @NotNull + final Thread start(@NotNull Runnable action) { + Thread thread = Thread.ofVirtual().start(action); + callers.add(thread); + return thread; + } + + @Nullable + static T result(@NotNull Future future) throws InterruptedException, ExecutionException, TimeoutException { + return future.get(WAIT.toMillis(), TimeUnit.MILLISECONDS); + } + + @NotNull + static MessagingRpcException failure(@NotNull MessagingRpcException.Reason reason, @NotNull Executable action) { + MessagingRpcException failure = assertThrows(MessagingRpcException.class, action); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + static MessagingRpcException failure(@NotNull MessagingRpcException.Reason reason, @NotNull Future future) { + ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); + MessagingRpcException failure = assertInstanceOf(MessagingRpcException.class, wrapper.getCause()); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + static MessagingRpcException remoteFailure(@NotNull MessagingRpcMessage.Error error, @NotNull Executable action) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.REMOTE_ERROR, action); + assertEquals(error, failure.remoteError()); + if (failure.remoteException() == null) { + assertNull(failure.getCause()); + } else { + Throwable cause = failure.getCause(); + assertNotNull(cause); + assertEquals(0, cause.getStackTrace().length); + assertTrue(cause.getMessage().contains(failure.remoteException().stackTrace())); + } + return failure; + } + + static void await(@NotNull CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(WAIT.toMillis(), TimeUnit.MILLISECONDS), "Latch was not released"); + } + + final void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + first.sender = message -> { + start(() -> second.deliver(first.routes.get(message.requestId()), message)); + return CompletableFuture.completedFuture(null); + }; + second.sender = message -> { + start(() -> first.deliver(second.routes.get(message.requestId()), message)); + return CompletableFuture.completedFuture(null); + }; + } + + @FunctionalInterface + interface Conversion { + @Nullable + Object apply(@Nullable Object value, @NotNull Type type) throws Exception; + } + + record CodecCall(@Nullable Object value, @NotNull Type type) { + } + + static final class TestCodec implements MessagingRpcCodec { + final List encoded = new CopyOnWriteArrayList<>(); + final List decoded = new CopyOnWriteArrayList<>(); + volatile Conversion encoder = (value, type) -> value; + volatile Conversion decoder = (value, type) -> value; + + @Nullable + @Override + public Object encode(@Nullable Object value, @NotNull Type type) throws Exception { + encoded.add(new CodecCall(value, type)); + return encoder.apply(value, type); + } + + @Nullable + @Override + public Object decode(@Nullable Object payload, @NotNull Type type) throws Exception { + decoded.add(new CodecCall(payload, type)); + return decoder.apply(payload, type); + } + } + + static final class MemoryTransport implements MessagingRpcTransport { + final Map>> listeners = new ConcurrentHashMap<>(); + final Map listenerTopics = new ConcurrentHashMap<>(); + final Map routes = new ConcurrentHashMap<>(); + final BlockingQueue>> sent = new LinkedBlockingQueue<>(); + final AtomicInteger subscriptionCloses = new AtomicInteger(); + volatile Function>, CompletionStage> sender = + message -> CompletableFuture.completedFuture(null); + + @NotNull + @Override + public MessagingRequestReplyTransport> forTopic(@NotNull String topic) { + return new MessagingRequestReplyTransport<>() { + @NotNull + @Override + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + assertNull(listeners.putIfAbsent(endpointId, listener)); + listenerTopics.put(endpointId, topic); + return () -> { + if (listeners.remove(endpointId, listener)) { + listenerTopics.remove(endpointId); + subscriptionCloses.incrementAndGet(); + } + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { + routes.put(message.requestId(), topic); + sent.add(message); + return sender.apply(message); + } + }; + } + + void deliver(@NotNull MessagingRequestReplyMessage> message) { + deliver(routes.get(message.requestId()), message); + } + + void deliver(@NotNull String topic, @NotNull MessagingRequestReplyMessage> message) { + routes.put(message.requestId(), topic); + List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(listenerTopics.get(entry.getKey()))) + .forEach(entry -> entry.getValue().onMessage(message)); + } + + void disconnect() { + List.copyOf(listeners.values()).forEach(MessagingRequestReplyTransport.Listener::onDisconnect); + } + + @NotNull + MessagingRequestReplyMessage> next() throws InterruptedException { + MessagingRequestReplyMessage> message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull(message, "No message was sent"); + return message; + } + } +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java new file mode 100644 index 0000000..d0d00a1 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc.fixtures; + +import org.jkiss.code.NotNull; + +interface HiddenInheritedService { + @NotNull + String inherited(@NotNull String value); +} diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java new file mode 100644 index 0000000..92246b7 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java @@ -0,0 +1,21 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc.fixtures; + +/** Exposes an operation inherited from a non-public parent in a separate package. */ +public interface PublicInheritedService extends HiddenInheritedService { +} diff --git a/apis/pom.xml b/apis/pom.xml index d559603..4b60748 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -20,6 +20,7 @@ com.dbeaver.datadam.dm.api + com.dbeaver.datadam.dam.remote.api com.dbeaver.datadam.share.api com.dbeaver.datadam.lm.api com.dbeaver.datadam.sso.api