mirror of
https://github.com/NationalSecurityAgency/datawave.git
synced 2026-11-07 05:56:25 -10:00
Allow annotation APIs and queue writers to run and scale independently (#4057)
This commit is contained in:
1 parent
addcd54dad
commit
e259554904
61 files changed
+2819
-438
No files matched your search
@@ -293,6 +293,24 @@ Start all services:
|
||||
|
||||
```docker compose --profile datawave-stack --profile full up -d```
|
||||
|
||||
To scale annotation API and writer pools independently, use the split profile
|
||||
in place of the combined `annotation` profile:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose-annotation-split.yml \
|
||||
--profile datawave-stack --profile annotation-split up -d \
|
||||
--scale annotation-api=3 --scale annotation-writer=1
|
||||
```
|
||||
|
||||
Publish shared `config/annotation.yml` and the matching role overlays before
|
||||
starting. The API pool registers as `annotation-api` in Consul; writers register
|
||||
as `annotation-writer`. API host ports are dynamic; use a load balancer. Writers
|
||||
publish no host ports. All annotation roles use local caches.
|
||||
|
||||
See the [service quick start](../microservices/services/annotation/README.md#deployment-quick-start)
|
||||
and [deployment reference](../microservices/services/annotation/docs/deployment.md)
|
||||
for role requirements, storage, routes, tuning, and cutover.
|
||||
|
||||
### Shard table splits
|
||||
|
||||
`stack/shard-splits.txt` holds the split points for `datawave.shard`, one full `yyyyMMdd_num` shard id
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Application annotation + profile api. Keep shared destinations and tables in annotation.yml.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
api: true
|
||||
annotation-ack-enabled: true
|
||||
writers:
|
||||
file:
|
||||
# Split API deployments require broker-confirmed acceptance by default.
|
||||
# Enable fallback only with persistent per-replica storage and a replay procedure.
|
||||
enabled: false
|
||||
@@ -0,0 +1,5 @@
|
||||
# Application annotation + profile combined. Shared settings retain both responsibilities.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
combined: true
|
||||
@@ -0,0 +1,5 @@
|
||||
# Application annotation + profile writer. Backend selection and tuning remain shared.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
writer: true
|
||||
@@ -1,8 +1,18 @@
|
||||
spring:
|
||||
cache:
|
||||
type: caffeine
|
||||
caffeine:
|
||||
spec: maximumSize=1000,expireAfterWrite=5m
|
||||
rabbitmq:
|
||||
publisher-confirm-type: correlated
|
||||
publisher-returns: true
|
||||
cloud:
|
||||
stream:
|
||||
rabbit:
|
||||
bindings:
|
||||
annotationSource-out-0:
|
||||
producer:
|
||||
useConfirmHeader: true
|
||||
logAnnotationSink-in-0:
|
||||
consumer:
|
||||
autoBindDlq: true
|
||||
@@ -16,26 +26,35 @@ spring:
|
||||
bindings:
|
||||
annotationSource-out-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
producer:
|
||||
errorChannelEnabled: true
|
||||
logAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'log'
|
||||
accumuloAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'accumulo'
|
||||
dumpAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'dump'
|
||||
consumer:
|
||||
concurrency: 5
|
||||
function:
|
||||
definition: annotationSource;logAnnotationSink;accumuloAnnotationSink;dumpAnnotationSink;busConsumer
|
||||
jackson:
|
||||
default-property-inclusion: non-null
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
hazelcast:
|
||||
client:
|
||||
# Annotation has no distributed cache state; all roles use local caches.
|
||||
enabled: false
|
||||
|
||||
annotation:
|
||||
# Activate exactly one of api, writer, or combined.
|
||||
# The matching Config Server overlay is required.
|
||||
system-from: "datawave-annotation-service"
|
||||
|
||||
health:
|
||||
@@ -44,15 +63,18 @@ annotation:
|
||||
attemptRecovery: true
|
||||
fixMissing: true
|
||||
fixInvalid: true
|
||||
# Both roles require the persistence route, even while no writer is running.
|
||||
# Keep these lists aligned with the required stream destinations/groups.
|
||||
# Deployment overrides replace lists; include every required route.
|
||||
queues:
|
||||
- name: 'annotation.log'
|
||||
- name: 'annotation.accumulo'
|
||||
durable: true
|
||||
exclusive: false
|
||||
autoDelete: false
|
||||
arguments:
|
||||
'x-dead-letter-exchange': 'DLX'
|
||||
'x-dead-letter-routing-key': 'annotation.log'
|
||||
- name: 'annotation.log.dlq'
|
||||
'x-dead-letter-routing-key': 'annotation.accumulo'
|
||||
- name: 'annotation.accumulo.dlq'
|
||||
durable: true
|
||||
exclusive: false
|
||||
autoDelete: false
|
||||
@@ -70,14 +92,14 @@ annotation:
|
||||
internal: false
|
||||
delayed: false
|
||||
bindings:
|
||||
- destination: 'annotation.log'
|
||||
- destination: 'annotation.accumulo'
|
||||
destinationType: 'queue'
|
||||
source: 'annotation'
|
||||
routingKey: '#'
|
||||
- destination: 'annotation.log.dlq'
|
||||
- destination: 'annotation.accumulo.dlq'
|
||||
destinationType: 'queue'
|
||||
source: 'DLX'
|
||||
routingKey: 'annotation.log'
|
||||
routingKey: 'annotation.accumulo'
|
||||
cluster:
|
||||
expectedNodes: 1
|
||||
failIfNodeMissing: true
|
||||
@@ -117,9 +139,11 @@ annotation:
|
||||
username: '${accumulo.username}'
|
||||
password: '${accumulo.password}'
|
||||
file:
|
||||
# Direct API fallback on publish failure; disabled automatically in the writer role.
|
||||
enabled: true
|
||||
pathUri: 'file:///annotation'
|
||||
dump:
|
||||
# Queue-driven file output in writer/combined mode; provision its queue (bindQueue: false).
|
||||
enabled: false
|
||||
pathUri: 'file:///annotation-dump'
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Use with the base file instead of its combined "annotation" profile:
|
||||
# docker compose -f docker-compose.yml -f docker-compose-annotation-split.yml \
|
||||
# --profile datawave-stack --profile annotation-split up -d --scale annotation-api=3 --scale annotation-writer=1
|
||||
# Both roles keep spring.application.name=annotation to share Config Server defaults and queue destinations.
|
||||
# Consul REST discovery uses annotation-api; worker management discovery uses annotation-writer.
|
||||
# Each Spring role profile loads its matching Config Server overlay.
|
||||
services:
|
||||
annotation-api:
|
||||
image: nationalsecurityagency/datawave/annotation-service:1.0.0-SNAPSHOT
|
||||
entrypoint: [ "java", "-jar", "app.jar" ]
|
||||
profiles:
|
||||
- annotation-split
|
||||
command:
|
||||
- --spring.profiles.active=api,consul,compose,remoteauth,log-disabled
|
||||
- --spring.output.ansi.enabled=NEVER
|
||||
- --spring.cloud.consul.host=consul
|
||||
- --spring.cloud.consul.discovery.service-name=annotation-api
|
||||
- --spring.cloud.consul.discovery.instance-id=$${spring.application.name}:$${random.value}
|
||||
environment:
|
||||
ZOOKEEPER_HOST: ${DW_ZOOKEEPER_HOST}
|
||||
BACKEND: ${BACKEND:-rabbitmq}
|
||||
# Dynamic host ports allow multiple replicas. Place a load balancer in front of HTTPS.
|
||||
ports:
|
||||
- "8443"
|
||||
volumes:
|
||||
- ${PKI_DIR:-./pki}:/etc/pki:ro
|
||||
networks:
|
||||
- demo
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:8080/annotation/mgmt/health
|
||||
interval: 10s
|
||||
timeout: 1s
|
||||
start_period: 45s
|
||||
retries: 3
|
||||
depends_on:
|
||||
configuration:
|
||||
condition: service_healthy
|
||||
|
||||
annotation-writer:
|
||||
image: nationalsecurityagency/datawave/annotation-service:1.0.0-SNAPSHOT
|
||||
entrypoint: [ "java", "-jar", "app.jar" ]
|
||||
profiles:
|
||||
- annotation-split
|
||||
command:
|
||||
- --spring.profiles.active=writer,consul,compose,remoteauth,log-disabled
|
||||
- --spring.output.ansi.enabled=NEVER
|
||||
- --spring.cloud.consul.host=consul
|
||||
- --spring.cloud.consul.discovery.service-name=annotation-writer
|
||||
- --spring.cloud.consul.discovery.instance-id=$${spring.application.name}:$${random.value}
|
||||
environment:
|
||||
ZOOKEEPER_HOST: ${DW_ZOOKEEPER_HOST}
|
||||
BACKEND: ${BACKEND:-rabbitmq}
|
||||
volumes:
|
||||
- ${PKI_DIR:-./pki}:/etc/pki:ro
|
||||
networks:
|
||||
- demo
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:8080/annotation/mgmt/health
|
||||
interval: 10s
|
||||
timeout: 1s
|
||||
start_period: 45s
|
||||
retries: 3
|
||||
depends_on:
|
||||
configuration:
|
||||
condition: service_healthy
|
||||
@@ -479,7 +479,7 @@ services:
|
||||
image: nationalsecurityagency/datawave/annotation-service:1.0.0-SNAPSHOT
|
||||
scale: 1
|
||||
command:
|
||||
- --spring.profiles.active=consul,compose,remoteauth,log-disabled
|
||||
- --spring.profiles.active=combined,consul,compose,remoteauth,log-disabled
|
||||
- --spring.output.ansi.enabled=NEVER
|
||||
- --spring.cloud.consul.host=consul
|
||||
- --spring.cloud.consul.discovery.instance-id=$${spring.application.name}:$${random.value}
|
||||
|
||||
@@ -11,6 +11,45 @@ ack/retry write pipeline with optional file-backup fallback.
|
||||
|
||||
---
|
||||
|
||||
### Deployment quick start
|
||||
|
||||
Use the same jar or image for all three roles:
|
||||
|
||||
| Profile | Responsibilities |
|
||||
|:--------|:-----------------|
|
||||
| `api` | REST reads and writes; publishes updates; no queue consumers. |
|
||||
| `writer` | All enabled queue consumers; no REST API or direct API file fallback. |
|
||||
| `combined` | API and all enabled queue consumers in one instance. |
|
||||
|
||||
Before starting a role, publish shared `annotation.yml` and its matching
|
||||
`annotation-<role>.yml` overlay to Config Server. The files are supplied
|
||||
in `service/src/main/config` and `docker/config`. Exactly one role profile and its
|
||||
matching overlay marker are required. Keep `spring.application.name=annotation`.
|
||||
|
||||
```bash
|
||||
java -jar service/target/annotation-service*-exec.jar \
|
||||
--spring.profiles.active=api,dev,remoteauth
|
||||
|
||||
java -jar service/target/annotation-service*-exec.jar \
|
||||
--spring.profiles.active=writer,dev,remoteauth
|
||||
```
|
||||
|
||||
For a single instance, select `combined` explicitly. Put the role first
|
||||
and environment/backend profiles afterward so deployment tuning can override its
|
||||
defaults. The role and backend flags select messaging functions automatically;
|
||||
remove explicit `spring.cloud.function.definition` and
|
||||
`spring.cloud.stream.function.definition` settings from external configuration.
|
||||
|
||||
API instances need RabbitMQ routes, Accumulo for reads, and the identifier lookup
|
||||
service. Writers need the configured output backend. All roles use local caches,
|
||||
retain secured management endpoints, and can queue updates while writers are
|
||||
stopped if the required durable routes exist.
|
||||
|
||||
For independent Compose pools, use the
|
||||
[Docker split setup](../../../docker/README.md). The
|
||||
[deployment reference](docs/deployment.md) covers backend flags, required routes,
|
||||
health, discovery, scaling, file storage, and cutover.
|
||||
|
||||
### User API
|
||||
|
||||
| Method | Operation | Description | Path Params | Request Body |
|
||||
@@ -64,13 +103,17 @@ with configurable retry (`maxAttempts`, `failTimeoutMillis`,
|
||||
`backoffIntervalMillis`) and optional file-backup fallback
|
||||
(`fileAnnotationWriter`, injected as `@Autowired(required = false)`).
|
||||
|
||||
#### Ack / Retry Protocol
|
||||
#### Write acceptance
|
||||
|
||||
The `AnnotationAckTracker` singleton bean (replacing the static
|
||||
`correlationLatchMap`) tracks in-flight writes by correlation ID. The
|
||||
`processConfirmAck` handler counts down latches when acks arrive from the
|
||||
message broker. This protocol is microservice-only; the legacy
|
||||
`AnnotationManagerBean` has no write path.
|
||||
`AnnotationPublisher` reports broker-confirmed acceptance only when RabbitMQ
|
||||
acknowledges the message and has not returned it as unroutable. The writer saves
|
||||
queued annotations asynchronously. Concurrent identical requests share a send;
|
||||
retries use separate confirmations. Publication failures feed the existing retry
|
||||
and optional file-fallback policy.
|
||||
|
||||
Annotation bindings use `application/x-protobuf`. See the
|
||||
[deployment reference](docs/deployment.md#write-acceptance-and-fallback) for
|
||||
confirmation, retry, and storage requirements.
|
||||
|
||||
### Test Data
|
||||
|
||||
@@ -99,53 +142,3 @@ Query operations (`GET`) are available to any authenticated user.
|
||||
Configuration is managed via `AnnotationProperties` (Spring `@ConfigurationProperties`
|
||||
with prefix `annotation`). See [AnnotationProperties] for the full set of options,
|
||||
including retry settings and ack timeout configuration.
|
||||
|
||||
### Docker
|
||||
|
||||
For local containerized integration testing, refer to the
|
||||
[DATAWAVE Docker Compose README][docker-compose] for instructions on building
|
||||
and running DATAWAVE microservices. The annotation service is opt-in through
|
||||
the Compose `annotation` profile; these repository assets do not enable a
|
||||
production deployment.
|
||||
|
||||
[docker-compose]:../../../docker/README.md
|
||||
|
||||
---
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Prepare the supporting services required by the selected configuration.
|
||||
A typical development environment includes Config Server, authorization,
|
||||
RabbitMQ, Accumulo, Hazelcast, and the DATAWAVE web service used for internal
|
||||
identifier lookups. The [Docker Compose guide][docker-compose] is the
|
||||
authoritative in-repository setup for the complete local environment.
|
||||
|
||||
2. Make the annotation configuration available to Config Server. The checked-in
|
||||
examples are `service/src/main/config/annotation.yml` and
|
||||
`docker/config/annotation.yml`; select and adapt the one appropriate for the
|
||||
development environment.
|
||||
|
||||
3. Launch this service with the `remoteauth` profile to enable client-certificate
|
||||
authentication:
|
||||
|
||||
```
|
||||
java -jar service/target/annotation-service*-exec.jar --spring.profiles.active=dev,remoteauth
|
||||
```
|
||||
|
||||
4. Ensure that the repository's [testUser.p12][testUser] (password: *ChangeIt*) cert is
|
||||
imported into your browser, and then visit any of the following:
|
||||
|
||||
* https://localhost:8643/annotation/v1/source/testAnalyticHash
|
||||
* https://localhost:8643/annotation/v1/DOCUMENT/20250704_249:testDataType:abcde.fghij.klmno
|
||||
* https://localhost:8643/annotation/v1/DOCUMENT/20250704_249:testDataType:abcde.fghij.klmno/annotation/testAnnotationId
|
||||
|
||||
[AnnotationControllerV1]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[AnnotationProperties]:service/src/main/java/datawave/microservice/annotation/service/config/AnnotationProperties.java
|
||||
[testUser]:../../starters/datawave/src/main/resources/testUser.p12
|
||||
[analyticHash]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[idType]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[id]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[annotationId]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[segmentHash]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[annotationType]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
[annotation]:service/src/main/java/datawave/microservice/annotation/service/AnnotationControllerV1.java
|
||||
@@ -0,0 +1,141 @@
|
||||
# Annotation deployment reference
|
||||
|
||||
Start with the [service quick start](../README.md#deployment-quick-start).
|
||||
The same executable supports API, writer, and combined roles.
|
||||
|
||||
## Role and configuration selection
|
||||
|
||||
Activate exactly one of `api`, `writer`, or `combined`. Missing, conflicting, or
|
||||
default-only role selection fails startup. Changing roles requires a restart.
|
||||
|
||||
These profiles replace `annotation-api`, `annotation-writer`, and
|
||||
`annotation-combined`; the old names are not role aliases. Before deploying,
|
||||
publish the renamed files below with their updated overlay markers and update
|
||||
the active role profile in launch settings. Keep `spring.application.name=annotation`.
|
||||
Consul and Compose service names remain `annotation-api` and `annotation-writer`.
|
||||
|
||||
Config Server combines application name `annotation` with the active profiles:
|
||||
|
||||
| File | Purpose |
|
||||
|:-----|:--------|
|
||||
| `annotation.yml` | Shared destinations, tables, TLS, authorization, backend flags, and tuning. |
|
||||
| `annotation-api.yml` | Required API marker; broker acknowledgement enabled; direct fallback disabled by default. |
|
||||
| `annotation-writer.yml` | Required writer marker; shared tuning remains inherited. |
|
||||
| `annotation-combined.yml` | Required combined marker; shared tuning remains inherited. |
|
||||
|
||||
Each overlay declares `annotation.deployment.overlays.<profile>: true`. Keep the
|
||||
marker in the matching Config Server overlay; putting it in packaged or shared
|
||||
configuration defeats the missing-overlay check. Select the role first, followed
|
||||
by environment/backend profiles. Timeout, retry, backend, and consumer concurrency
|
||||
settings remain shared unless a later deployment profile changes them.
|
||||
|
||||
## Functions and backends
|
||||
|
||||
The service constructs its function list from the role, backend flags, and
|
||||
`spring.cloud.bus.enabled`. API and combined always include `annotationSource`;
|
||||
writer and combined include every enabled log, Accumulo, and dump consumer. An
|
||||
API has no annotation consumers. A writer with no enabled consumer fails startup.
|
||||
`busConsumer` is included when Spring Bus is enabled.
|
||||
|
||||
Explicit `spring.cloud.function.definition` and
|
||||
`spring.cloud.stream.function.definition` properties are unsupported, including
|
||||
empty strings. Remove them from shared or deployment configuration. Custom
|
||||
subsets, extra functions, and function chains are not part of this service's
|
||||
configuration contract.
|
||||
|
||||
| Backend flag | Effect |
|
||||
|:-------------|:-------|
|
||||
| `annotation.writers.accumulo.enabled` | Consume updates and persist them to truthmark tables. |
|
||||
| `annotation.writers.log.enabled` | Consume updates and write log output. |
|
||||
| `annotation.writers.dump.enabled` | Consume updates and write files. |
|
||||
| `annotation.writers.file.enabled` | Direct API/combined file fallback after publication retries fail. |
|
||||
|
||||
Queue-backend flags accept only `true`/`false`, case-insensitively. Missing flags
|
||||
mean disabled. Aliases such as `yes` or `1`, blanks, and surrounding whitespace
|
||||
fail startup, even when another backend is enabled. Direct fallback is separate
|
||||
from the dump consumer and is excluded from writer roles.
|
||||
|
||||
Messaging settings use the fixed bindings `annotationSource-out-0`,
|
||||
`logAnnotationSink-in-0`, `accumuloAnnotationSink-in-0`, and
|
||||
`dumpAnnotationSink-in-0`. Binding aliases may be absent or map a binding to itself;
|
||||
renaming them fails startup. Configure destinations, stable groups, conversion,
|
||||
and Rabbit settings under those names. `annotationSource` means message publisher;
|
||||
it is separate from the provenance protobuf type and table with similar names.
|
||||
|
||||
## Required routes and health
|
||||
|
||||
The default persistence backend is Accumulo. Shared health configuration requires
|
||||
the durable `annotation.accumulo` queue, its `#` binding to the `annotation`
|
||||
exchange, and its dead-letter queue and route. Recovery can provision those routes
|
||||
before writers start. Zero active consumers is healthy when the required durable
|
||||
routes exist; the queue buffers updates until a writer resumes.
|
||||
|
||||
For another destination, group, or backend, supply matching health topology in a
|
||||
later deployment profile. APIs must check every required persistence route;
|
||||
writers must check their selected backends. `queues`, `exchanges`, and `bindings`
|
||||
are replaced as complete lists, not appended. Enabling a backend does not update
|
||||
those lists automatically. The dump binding uses `bindQueue: false`, so provision
|
||||
its queue and routing rule explicitly.
|
||||
|
||||
Topology checks are periodic. A positive broker confirmation does not identify
|
||||
which groups received copies, leaving a possible race when bindings change between
|
||||
a health check and publication.
|
||||
|
||||
Aggregate health and `/annotation/mgmt/health/readiness` return HTTP 503 for
|
||||
`RABBITMQ_UNHEALTHY`, including before the first topology check finishes. Readiness
|
||||
includes all enabled indicators and application availability. Liveness at
|
||||
`/annotation/mgmt/health/liveness` checks process availability without requiring
|
||||
external dependencies. Use separate probes so a broker outage stops traffic
|
||||
without restarting the process. Consul uses aggregate health. The status order is
|
||||
`DOWN, OUT_OF_SERVICE, RABBITMQ_UNHEALTHY, UP, UNKNOWN`.
|
||||
|
||||
## Write acceptance and fallback
|
||||
|
||||
The Rabbit binder supplies `CorrelationData` confirmations through
|
||||
`useConfirmHeader`; correlated publisher confirms and returns are enabled in
|
||||
packaged, service, and Docker configuration. Acceptance requires a positive
|
||||
confirmation and no returned message. No custom acknowledgement callback runs
|
||||
on the secured REST controller. The writer persists accepted messages later.
|
||||
|
||||
Concurrent identical messages share an in-flight publication. Each physical send,
|
||||
including a retry, has a unique correlation ID so a late confirmation cannot
|
||||
confirm another attempt. Timeouts, negative confirmations, returns, and local
|
||||
failures use the existing retry/backoff and optional fallback policy. Interrupted
|
||||
waits preserve the thread's interrupt status. Explicitly disabling
|
||||
`annotation.annotation-ack-enabled` accepts local enqueueing without broker
|
||||
confirmation; the API overlay enables confirmation by default.
|
||||
|
||||
Direct API file fallback defaults off; a later profile can enable it. Combined
|
||||
retains its configured fallback policy. File fallback needs persistent per-replica
|
||||
storage and a replay procedure; dump consumers need persistent storage and unique
|
||||
replica subpaths. Compose examples provide neither volumes nor a replay service.
|
||||
Align retry budgets with client/proxy timeouts because fallback runs after retries.
|
||||
Preserving an edit when a broker failure cannot be reported to the caller remains
|
||||
a separate policy follow-up; its default is pending scenario validation.
|
||||
|
||||
## Dependencies, discovery, and cutover
|
||||
|
||||
All roles use bounded local Caffeine caches (1,000 entries per default cache,
|
||||
five-minute expiry after write). Hazelcast and its health indicator are disabled.
|
||||
Marking and federated-authorization caches retain their separate local policies.
|
||||
Annotations do not use distributed cache state or locks. API reads still require
|
||||
Accumulo and the configured identifier lookup service; file-only writers create
|
||||
no Accumulo pool when that backend is disabled.
|
||||
|
||||
For split Compose deployments, run the command in the
|
||||
[Docker README](../../../../docker/README.md). APIs register in Consul as
|
||||
`annotation-api`; writers register as `annotation-writer`. REST clients discover
|
||||
the API pool. Both keep application name `annotation` and HTTP context
|
||||
`/annotation`. API host ports are assigned dynamically; use a load balancer for a
|
||||
stable address. Writers expose management internally and publish no host port.
|
||||
There is no legacy discovery alias because the split service is pre-production.
|
||||
|
||||
Provision durable queues and align protobuf content types before switching
|
||||
traffic. Replicas within the stable `accumulo`, `log`, or `dump` group compete for
|
||||
messages; different groups receive copies. Bring up writers, verify APIs, switch
|
||||
REST traffic, then retire combined replicas. During overlap, consumers use the
|
||||
same groups. Rollback restores combined mode with the same format and groups.
|
||||
|
||||
Tune API replicas by request load and read-connection budgets; tune writer replicas
|
||||
and consumer concurrency by backlog and storage throughput. Confirmations return
|
||||
to the originating API replica, so writes do not require sticky routing.
|
||||
@@ -192,6 +192,11 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>gov.nsa.datawave.microservice</groupId>
|
||||
<artifactId>annotation-messaging</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>gov.nsa.datawave.microservice</groupId>
|
||||
<artifactId>hazelcast-client</artifactId>
|
||||
@@ -509,6 +514,10 @@
|
||||
<groupId>gov.nsa.datawave.microservice</groupId>
|
||||
<artifactId>annotation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>gov.nsa.datawave.microservice</groupId>
|
||||
<artifactId>annotation-messaging</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>gov.nsa.datawave.microservice</groupId>
|
||||
<artifactId>hazelcast-client</artifactId>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Application annotation + profile api. Keep shared destinations and tables in annotation.yml.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
api: true
|
||||
annotation-ack-enabled: true
|
||||
writers:
|
||||
file:
|
||||
# File fallback is disabled: the API reports success only after RabbitMQ
|
||||
# confirms acceptance. If publication fails after retries, the write fails.
|
||||
# To enable file fallback, give each API instance storage that survives
|
||||
# restarts and a procedure for reading those files and submitting the
|
||||
# saved updates for persistence.
|
||||
enabled: false
|
||||
@@ -0,0 +1,8 @@
|
||||
# Config Server loads this file for application "annotation" when the
|
||||
# combined profile is active. A combined instance serves REST
|
||||
# requests and runs the enabled queue consumers. Shared settings remain
|
||||
# in annotation.yml.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
combined: true
|
||||
@@ -0,0 +1,8 @@
|
||||
# Config Server loads this file for application "annotation" when the
|
||||
# writer profile is active. A writer instance runs the enabled
|
||||
# queue consumers without serving REST requests. Backend selection and
|
||||
# tuning settings remain in annotation.yml.
|
||||
annotation:
|
||||
deployment:
|
||||
overlays:
|
||||
writer: true
|
||||
@@ -1,8 +1,18 @@
|
||||
spring:
|
||||
cache:
|
||||
type: caffeine
|
||||
caffeine:
|
||||
spec: maximumSize=1000,expireAfterWrite=5m
|
||||
rabbitmq:
|
||||
publisher-confirm-type: correlated
|
||||
publisher-returns: true
|
||||
cloud:
|
||||
stream:
|
||||
rabbit:
|
||||
bindings:
|
||||
annotationSource-out-0:
|
||||
producer:
|
||||
useConfirmHeader: true
|
||||
logAnnotationSink-in-0:
|
||||
consumer:
|
||||
autoBindDlq: true
|
||||
@@ -16,26 +26,35 @@ spring:
|
||||
bindings:
|
||||
annotationSource-out-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
producer:
|
||||
errorChannelEnabled: true
|
||||
logAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'log'
|
||||
accumuloAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'accumulo'
|
||||
dumpAnnotationSink-in-0:
|
||||
destination: 'annotation'
|
||||
content-type: application/x-protobuf
|
||||
group: 'dump'
|
||||
consumer:
|
||||
concurrency: 5
|
||||
function:
|
||||
definition: annotationSource;logAnnotationSink;accumuloAnnotationSink;dumpAnnotationSink;busConsumer
|
||||
jackson:
|
||||
default-property-inclusion: non-null
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
hazelcast:
|
||||
client:
|
||||
# Annotation has no distributed cache state; all roles use local caches.
|
||||
enabled: false
|
||||
|
||||
annotation:
|
||||
# Activate exactly one of api, writer, or combined.
|
||||
# The matching Config Server overlay is required.
|
||||
system-from: "datawave-annotation-service"
|
||||
|
||||
health:
|
||||
@@ -44,15 +63,22 @@ annotation:
|
||||
attemptRecovery: true
|
||||
fixMissing: true
|
||||
fixInvalid: true
|
||||
# API, writer, and combined instances check that the required RabbitMQ
|
||||
# queues, exchanges, and routing rules exist. Writers do not need to be
|
||||
# running: the durable annotation.accumulo queue holds updates until
|
||||
# a writer starts and saves them to Accumulo.
|
||||
# If a deployment changes destinations or consumer groups, update these
|
||||
# health-check lists to match. Overrides replace entire lists, so include
|
||||
# every required queue, exchange, and routing rule.
|
||||
queues:
|
||||
- name: 'annotation.log'
|
||||
- name: 'annotation.accumulo'
|
||||
durable: true
|
||||
exclusive: false
|
||||
autoDelete: false
|
||||
arguments:
|
||||
'x-dead-letter-exchange': 'DLX'
|
||||
'x-dead-letter-routing-key': 'annotation.log'
|
||||
- name: 'annotation.log.dlq'
|
||||
'x-dead-letter-routing-key': 'annotation.accumulo'
|
||||
- name: 'annotation.accumulo.dlq'
|
||||
durable: true
|
||||
exclusive: false
|
||||
autoDelete: false
|
||||
@@ -70,14 +96,14 @@ annotation:
|
||||
internal: false
|
||||
delayed: false
|
||||
bindings:
|
||||
- destination: 'annotation.log'
|
||||
- destination: 'annotation.accumulo'
|
||||
destinationType: 'queue'
|
||||
source: 'annotation'
|
||||
routingKey: '#'
|
||||
- destination: 'annotation.log.dlq'
|
||||
- destination: 'annotation.accumulo.dlq'
|
||||
destinationType: 'queue'
|
||||
source: 'DLX'
|
||||
routingKey: 'annotation.log'
|
||||
routingKey: 'annotation.accumulo'
|
||||
cluster:
|
||||
expectedNodes: 1
|
||||
failIfNodeMissing: true
|
||||
@@ -117,9 +143,11 @@ annotation:
|
||||
username: '${accumulo.username}'
|
||||
password: '${accumulo.password}'
|
||||
file:
|
||||
# Direct API fallback on publish failure; disabled automatically in the writer role.
|
||||
enabled: true
|
||||
pathUri: 'file:///annotation'
|
||||
dump:
|
||||
# Queue-driven file output in writer/combined mode; provision its queue (bindQueue: false).
|
||||
enabled: false
|
||||
pathUri: 'file:///annotation-dump'
|
||||
|
||||
|
||||
+10
-2
@@ -9,11 +9,19 @@ import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
/**
|
||||
* Supplies REST annotation updates to the messaging stream. The bean is named {@code annotationSource} because it is the source of messages entering the
|
||||
* stream; queue consumers such as {@code accumuloAnnotationSink} are its sinks. This messaging name is separate from annotation provenance data.
|
||||
*/
|
||||
public class AnnotationSupplier implements Supplier<Flux<Message<AnnotationMessage>>> {
|
||||
private final Sinks.Many<Message<AnnotationMessage>> messagingSink = Sinks.many().multicast().onBackpressureBuffer();
|
||||
|
||||
public boolean send(Message<AnnotationMessage> auditMessage) {
|
||||
return messagingSink.tryEmitNext(auditMessage).isSuccess();
|
||||
// Only one REST request at a time may add a message to this stream.
|
||||
// Without synchronization, simultaneous requests can cause Reactor to reject
|
||||
// a message even when messaging is healthy. The lock covers adding the message;
|
||||
// callers release it before waiting for RabbitMQ confirmation.
|
||||
public synchronized boolean send(Message<AnnotationMessage> annotationMessage) {
|
||||
return messagingSink.tryEmitNext(annotationMessage).isSuccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+18
@@ -1,16 +1,25 @@
|
||||
package datawave.microservice.annotation.common.config;
|
||||
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ACCUMULO_WRITER_ENABLED;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.core.common.connection.AccumuloConnectionFactoryImpl;
|
||||
import datawave.core.common.result.ConnectionPoolsProperties;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
|
||||
/** Accumulo connection classes used for obtaining connections to Accumulo */
|
||||
@Configuration
|
||||
@Conditional(AccumuloConfiguration.AccumuloRequiredCondition.class)
|
||||
public class AccumuloConfiguration {
|
||||
@Bean
|
||||
@ConfigurationProperties("datawave.connection.factory")
|
||||
@@ -24,4 +33,13 @@ public class AccumuloConfiguration {
|
||||
// no need for metadata cache here.
|
||||
return AccumuloConnectionFactoryImpl.getInstance(null, connectionPoolsProperties);
|
||||
}
|
||||
|
||||
public static class AccumuloRequiredCondition extends SpringBootCondition {
|
||||
@Override
|
||||
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
boolean required = AnnotationRole.from(context.getEnvironment()).includesApi()
|
||||
|| context.getEnvironment().getProperty(ACCUMULO_WRITER_ENABLED, Boolean.class, false);
|
||||
return new ConditionOutcome(required, "Accumulo is required for API reads or an enabled Accumulo writer");
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package datawave.microservice.annotation.common.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.Module;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
|
||||
import datawave.annotation.protobuf.v1.Annotation;
|
||||
import datawave.annotation.protobuf.v1.AnnotationSource;
|
||||
import datawave.annotation.protobuf.v1.Segment;
|
||||
import datawave.annotation.util.v1.AnnotationJsonUtils;
|
||||
import datawave.annotation.util.v1.JacksonAnnotationDeserializer;
|
||||
import datawave.annotation.util.v1.JacksonAnnotationSerializer;
|
||||
import datawave.annotation.util.v1.JacksonSegmentDeserializer;
|
||||
import datawave.annotation.util.v1.JacksonSegmentSerializer;
|
||||
|
||||
/**
|
||||
* Keeps annotation JSON support available to both HTTP handling and queue processing, independently of the deployment role.
|
||||
*/
|
||||
@Configuration
|
||||
public class AnnotationJsonConfiguration {
|
||||
@Bean
|
||||
public Module jacksonAnnotationModule() {
|
||||
final SimpleModule simpleModule = new SimpleModule();
|
||||
simpleModule.addDeserializer(Annotation.class, new JacksonAnnotationDeserializer());
|
||||
simpleModule.addSerializer(Annotation.class, new JacksonAnnotationSerializer());
|
||||
simpleModule.addDeserializer(Segment.class, new JacksonSegmentDeserializer());
|
||||
simpleModule.addSerializer(Segment.class, new JacksonSegmentSerializer());
|
||||
simpleModule.addSerializer(AnnotationSource.class, new JsonSerializer<>() {
|
||||
@Override
|
||||
public void serialize(AnnotationSource source, JsonGenerator generator, SerializerProvider provider) throws IOException {
|
||||
generator.writeRawValue(AnnotationJsonUtils.getPrinter().print(source));
|
||||
}
|
||||
});
|
||||
return simpleModule;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Selects the responsibilities of an annotation deployment from exactly one explicit role profile.
|
||||
*/
|
||||
public enum AnnotationRole {
|
||||
COMBINED, API, WRITER;
|
||||
|
||||
// @formatter:off
|
||||
private static final String ALL_PROFILES = Arrays.stream(values())
|
||||
.map(AnnotationRole::profile)
|
||||
.collect(Collectors.joining(", "));
|
||||
// @formatter:on
|
||||
|
||||
private final String profile;
|
||||
|
||||
AnnotationRole() {
|
||||
this.profile = name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public boolean includesApi() {
|
||||
return this != WRITER;
|
||||
}
|
||||
|
||||
public boolean includesWriter() {
|
||||
return this != API;
|
||||
}
|
||||
|
||||
public String profile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public static AnnotationRole from(Environment environment) {
|
||||
// @formatter:off
|
||||
AnnotationRole[] selected = Arrays.stream(values())
|
||||
.filter(role -> Arrays.asList(environment.getActiveProfiles()).contains(role.profile()))
|
||||
.toArray(AnnotationRole[]::new);
|
||||
// @formatter:on
|
||||
if (selected.length != 1) {
|
||||
throw new IllegalStateException("Activate exactly one of " + ALL_PROFILES + "; selected " + Arrays.toString(selected));
|
||||
}
|
||||
return selected[0];
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ACCUMULO_ANNOTATION_SINK;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ACCUMULO_WRITER_ENABLED;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ANNOTATION_SOURCE;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.BUS_CONSUMER;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.BUS_ENABLED;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.BUS_PROPERTY_SOURCE;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.DUMP_ANNOTATION_SINK;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.DUMP_WRITER_ENABLED;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.FUNCTION_DEFINITION;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.LOG_ANNOTATION_SINK;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.LOG_WRITER_ENABLED;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ROLE_FUNCTION_PROPERTY_SOURCE;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ROLE_OVERLAY_PREFIX;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.STREAM_FUNCTION_AUTODETECT;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.STREAM_FUNCTION_BINDINGS;
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.STREAM_FUNCTION_DEFINITION;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||
import org.springframework.boot.context.properties.bind.Bindable;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
|
||||
/**
|
||||
* Selects stream functions after external/bootstrap configuration is available and before any stream properties or bindings are instantiated.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class AnnotationRoleConfiguration {
|
||||
/**
|
||||
* Runs role-based function selection after external configuration is available and before Spring creates stream bindings.
|
||||
*
|
||||
* @param environment
|
||||
* the application environment, including the selected role and its Config Server overlay
|
||||
* @return the post-processor that validates the role settings and installs the function definitions
|
||||
*/
|
||||
@Bean
|
||||
public static BeanFactoryPostProcessor annotationRoleFunctions(ConfigurableEnvironment environment) {
|
||||
return beanFactory -> configureFunctions(environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the HTTP message producer for API roles and the enabled queue consumers for writer roles; combined roles include both. When Cloud Bus is enabled,
|
||||
* its consumer is included as well. The selected functions are installed as separate functions in both Spring function-definition properties, and automatic
|
||||
* function discovery is disabled.
|
||||
* <p>
|
||||
* Startup fails for a missing or conflicting role, a missing matching overlay, user-supplied function definitions, invalid backend flags, a writer with no
|
||||
* enabled queue consumer, or aliases that rename the fixed annotation bindings. Cloud Bus's generated {@code busConsumer} definition is allowed only in its
|
||||
* own property source; the original property sources are still checked for user overrides.
|
||||
*
|
||||
* @param environment
|
||||
* the application environment to validate and update before stream bindings are created
|
||||
* @throws IllegalStateException
|
||||
* if the role or messaging settings violate these startup requirements
|
||||
*/
|
||||
static void configureFunctions(ConfigurableEnvironment environment) {
|
||||
AnnotationRole role = AnnotationRole.from(environment);
|
||||
String overlayMarker = ROLE_OVERLAY_PREFIX + role.profile();
|
||||
if (!environment.getProperty(overlayMarker, Boolean.class, false)) {
|
||||
throw new IllegalStateException("Profile " + role.profile() + " requires its matching Config Server overlay annotation-" + role.profile()
|
||||
+ ".yml; missing " + overlayMarker);
|
||||
}
|
||||
for (String property : List.of(FUNCTION_DEFINITION, STREAM_FUNCTION_DEFINITION)) {
|
||||
for (PropertySource<?> source : environment.getPropertySources()) {
|
||||
// Boot's attached source combines the other sources. Inspect the originals so Bus cannot hide a user override.
|
||||
if (ConfigurationPropertySources.isAttachedConfigurationPropertySource(source)) {
|
||||
continue;
|
||||
}
|
||||
// Bus adds its own consumer before startup. The service includes that consumer in the selected role's function list below.
|
||||
// @formatter:off
|
||||
if (BUS_PROPERTY_SOURCE.equals(source.getName()) &&
|
||||
FUNCTION_DEFINITION.equals(property) &&
|
||||
BUS_CONSUMER.equals(source.getProperty(property))) {
|
||||
continue;
|
||||
}
|
||||
// @formatter:on
|
||||
if (source.containsProperty(property)) {
|
||||
throw new IllegalStateException(property + " is managed by the annotation service; remove the override and select consumers with "
|
||||
+ LOG_WRITER_ENABLED + ", " + ACCUMULO_WRITER_ENABLED + ", and " + DUMP_WRITER_ENABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use the same flag semantics as the backend bean conditions.
|
||||
boolean logEnabled = enabled(environment, LOG_WRITER_ENABLED);
|
||||
boolean accumuloEnabled = enabled(environment, ACCUMULO_WRITER_ENABLED);
|
||||
boolean dumpEnabled = enabled(environment, DUMP_WRITER_ENABLED);
|
||||
Map<String,Boolean> annotationFunctions = new LinkedHashMap<>();
|
||||
annotationFunctions.put(ANNOTATION_SOURCE, role.includesApi());
|
||||
annotationFunctions.put(LOG_ANNOTATION_SINK, role.includesWriter() && logEnabled);
|
||||
annotationFunctions.put(ACCUMULO_ANNOTATION_SINK, role.includesWriter() && accumuloEnabled);
|
||||
annotationFunctions.put(DUMP_ANNOTATION_SINK, role.includesWriter() && dumpEnabled);
|
||||
|
||||
// @formatter:off
|
||||
List<String> functions = annotationFunctions.entrySet()
|
||||
.stream()
|
||||
.filter(Map.Entry::getValue)
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
// @formatter:on
|
||||
if (role == AnnotationRole.WRITER && functions.isEmpty()) {
|
||||
throw new IllegalStateException("Profile writer requires an enabled log, accumulo, or dump queue consumer");
|
||||
}
|
||||
if (environment.getProperty(BUS_ENABLED, Boolean.class, true)) {
|
||||
functions.add(BUS_CONSUMER);
|
||||
}
|
||||
|
||||
// Bind aliases exactly as Stream does, including bracket notation in property sources.
|
||||
// @formatter:off
|
||||
Map<String,String> aliases = Binder.get(environment)
|
||||
.bind(STREAM_FUNCTION_BINDINGS, Bindable.mapOf(String.class, String.class))
|
||||
.orElse(Map.of());
|
||||
// @formatter:on
|
||||
for (String name : annotationFunctions.keySet()) {
|
||||
String binding = name + (ANNOTATION_SOURCE.equals(name) ? "-out-0" : "-in-0");
|
||||
String alias = aliases.get(binding);
|
||||
if (alias != null && !binding.equals(alias)) {
|
||||
throw new IllegalStateException(
|
||||
STREAM_FUNCTION_BINDINGS + "." + binding + " must not rename the fixed annotation binding; received '" + alias + "'");
|
||||
}
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
Map<String,Object> properties = Map.of(
|
||||
FUNCTION_DEFINITION, String.join(";", functions),
|
||||
STREAM_FUNCTION_DEFINITION, String.join(";", functions),
|
||||
STREAM_FUNCTION_AUTODETECT, false);
|
||||
// @formatter:on
|
||||
environment.getPropertySources().addFirst(new MapPropertySource(ROLE_FUNCTION_PROPERTY_SOURCE, properties));
|
||||
}
|
||||
|
||||
private static boolean enabled(ConfigurableEnvironment environment, String property) {
|
||||
// Match @ConditionalOnProperty(havingValue = "true"), without Boolean aliases or whitespace trimming.
|
||||
String value = environment.getProperty(property);
|
||||
if (value == null || "false".equalsIgnoreCase(value)) {
|
||||
return false;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(value)) {
|
||||
return true;
|
||||
}
|
||||
throw new IllegalStateException(property + " must be true or false; received '" + value + "'");
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
/**
|
||||
* Property names and function names shared by annotation role selection and backend configuration.
|
||||
*/
|
||||
public final class AnnotationRoleConstants {
|
||||
public static final String ROLE_OVERLAY_PREFIX = "annotation.deployment.overlays.";
|
||||
public static final String FUNCTION_DEFINITION = "spring.cloud.function.definition";
|
||||
public static final String STREAM_FUNCTION_DEFINITION = "spring.cloud.stream.function.definition";
|
||||
public static final String STREAM_FUNCTION_BINDINGS = "spring.cloud.stream.function.bindings";
|
||||
public static final String STREAM_FUNCTION_AUTODETECT = "spring.cloud.stream.function.autodetect";
|
||||
public static final String LOG_WRITER_ENABLED = "annotation.writers.log.enabled";
|
||||
public static final String ACCUMULO_WRITER_ENABLED = "annotation.writers.accumulo.enabled";
|
||||
public static final String DUMP_WRITER_ENABLED = "annotation.writers.dump.enabled";
|
||||
public static final String BUS_ENABLED = "spring.cloud.bus.enabled";
|
||||
public static final String BUS_PROPERTY_SOURCE = "springCloudBusOverridesProperties";
|
||||
public static final String ROLE_FUNCTION_PROPERTY_SOURCE = "annotationRoleFunctions";
|
||||
public static final String ANNOTATION_SOURCE = "annotationSource";
|
||||
public static final String LOG_ANNOTATION_SINK = "logAnnotationSink";
|
||||
public static final String ACCUMULO_ANNOTATION_SINK = "accumuloAnnotationSink";
|
||||
public static final String DUMP_ANNOTATION_SINK = "dumpAnnotationSink";
|
||||
public static final String BUS_CONSUMER = "busConsumer";
|
||||
|
||||
private AnnotationRoleConstants() {}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
|
||||
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Conditional(ConditionalOnAnnotationRole.RoleCondition.class)
|
||||
public @interface ConditionalOnAnnotationRole {
|
||||
AnnotationRole[] value();
|
||||
|
||||
class RoleCondition extends SpringBootCondition {
|
||||
@Override
|
||||
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
AnnotationRole role = AnnotationRole.from(context.getEnvironment());
|
||||
// The annotation requires value(), so its class and method metadata always contain a non-null enum array.
|
||||
// @formatter:off
|
||||
AnnotationRole[] allowedRoles = metadata.getAnnotations()
|
||||
.get(ConditionalOnAnnotationRole.class)
|
||||
.getEnumArray("value", AnnotationRole.class);
|
||||
// @formatter:on
|
||||
boolean matches = Arrays.asList(allowedRoles).contains(role);
|
||||
return new ConditionOutcome(matches, "annotation deployment profile is " + role.profile());
|
||||
}
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
package datawave.microservice.annotation.service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Tracks in-flight annotation write operations by correlation ID so that async ack/timeout callbacks can resolve the original request.
|
||||
*/
|
||||
@Component
|
||||
public class AnnotationAckTracker {
|
||||
|
||||
private final Map<String,CountDownLatch> correlationLatchMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Register a new latch for the given correlation ID. If a latch already exists for this ID, the existing latch is returned instead.
|
||||
*
|
||||
* @param correlationId
|
||||
* the correlation ID to register
|
||||
* @param latch
|
||||
* the latch to register
|
||||
* @return the previously-registered latch for this ID, or {@code null} if none existed
|
||||
*/
|
||||
public CountDownLatch putIfAbsent(String correlationId, CountDownLatch latch) {
|
||||
return correlationLatchMap.putIfAbsent(correlationId, latch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the latch for the given correlation ID.
|
||||
*
|
||||
* @param correlationId
|
||||
* the correlation ID whose latch should be removed
|
||||
* @param latch
|
||||
* the expected latch instance
|
||||
* @return {@code true} if the latch was removed, {@code false} otherwise
|
||||
*/
|
||||
public boolean remove(String correlationId, CountDownLatch latch) {
|
||||
return correlationLatchMap.remove(correlationId, latch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count down the latch associated with the given correlation ID, if one exists. Uses {@link ConcurrentHashMap#computeIfPresent} to atomically fetch and
|
||||
* count down the latch, eliminating the {@code containsKey()} → {@code get()} race that could occur if another thread removes the latch between the check
|
||||
* and the count-down.
|
||||
*
|
||||
* @param correlationId
|
||||
* the correlation ID whose latch should be counted down
|
||||
* @return {@code true} if a latch was found and counted down, {@code false} otherwise
|
||||
*/
|
||||
public boolean countdown(String correlationId) {
|
||||
return correlationLatchMap.computeIfPresent(correlationId, (key, latch) -> {
|
||||
latch.countDown();
|
||||
return latch;
|
||||
}) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a latch exists for the given correlation ID.
|
||||
*
|
||||
* @param correlationId
|
||||
* the correlation ID to check
|
||||
* @return {@code true} if a latch exists, {@code false} otherwise
|
||||
*/
|
||||
public boolean contains(String correlationId) {
|
||||
return correlationLatchMap.containsKey(correlationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether there are no tracked latches.
|
||||
*
|
||||
* @return {@code true} if no latches are currently tracked
|
||||
*/
|
||||
public boolean isEmpty() {
|
||||
return correlationLatchMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracked latches. Used between tests to prevent leakage.
|
||||
*/
|
||||
public void clear() {
|
||||
correlationLatchMap.clear();
|
||||
}
|
||||
}
|
||||
+8
-86
@@ -11,23 +11,16 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.accumulo.core.client.AccumuloClient;
|
||||
import org.apache.accumulo.core.security.Authorizations;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.integration.IntegrationMessageHeaderAccessor;
|
||||
import org.springframework.integration.annotation.ServiceActivator;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
@@ -62,8 +55,8 @@ import datawave.annotation.util.v1.AnnotationUtils;
|
||||
import datawave.annotation.util.v1.AnnotationValidators;
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.core.query.runner.AccumuloConnectionRequestMap;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.health.HealthChecker;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.service.config.AnnotationProperties;
|
||||
import datawave.microservice.annotation.util.Metadata;
|
||||
import datawave.microservice.annotation.util.lookup.service.LookupService;
|
||||
@@ -81,12 +74,10 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Tag(name = "Annotation Controller /v1", description = "Operations related to annotations")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
@Secured({"AuthorizedUser", "AuthorizedQueryServer", "InternalUser", "Administrator"})
|
||||
@RequestMapping(path = "/v1", produces = {MediaType.APPLICATION_JSON_VALUE})
|
||||
public class AnnotationControllerV1 {
|
||||
// Note: This must match 'annotationAckChannel' in the service configuration. Default set in bootstrap.yml.
|
||||
public static final String ANNOTATION_ACK_CHANNEL = "annotationAckChannel";
|
||||
|
||||
public static final String ANNOTATION_SERVICE_SYSTEM_FROM = "annotation";
|
||||
|
||||
/** used to serialize error response bodies (see {@link #jsonError(String)}/{@link #jsonNotFound(String)}) */
|
||||
@@ -110,32 +101,24 @@ public class AnnotationControllerV1 {
|
||||
private final TimestampTransformer timestampTransformer;
|
||||
private final VisibilityTransformer visibilityTransformer;
|
||||
|
||||
private final AnnotationAckTracker annotationAckTracker;
|
||||
|
||||
/** used as a 'sink' for annotation writes */
|
||||
private final AnnotationSupplier annotationSource;
|
||||
private final AnnotationPublisher annotationPublisher;
|
||||
|
||||
/** used as a backup writer for annotations */
|
||||
@Autowired(required = false)
|
||||
@Qualifier("fileAnnotationWriter")
|
||||
private AnnotationWriter fileAnnotationWriter;
|
||||
|
||||
/** gates message publishing on messaging-infrastructure health, when the rabbit health checker is enabled */
|
||||
@Autowired(required = false)
|
||||
private HealthChecker healthChecker;
|
||||
|
||||
@Autowired
|
||||
public AnnotationControllerV1(AccumuloConnectionFactory factory, LookupService lookupService, AnnotationProperties annotationProperties,
|
||||
TimestampTransformer timestampTransformer, VisibilityTransformer visibilityTransformer, AnnotationSupplier annotationSource,
|
||||
ExecutorService federatedReadExecutorService, AnnotationAckTracker annotationAckTracker) {
|
||||
TimestampTransformer timestampTransformer, VisibilityTransformer visibilityTransformer, AnnotationPublisher annotationPublisher,
|
||||
ExecutorService federatedReadExecutorService) {
|
||||
this.connectionFactory = factory;
|
||||
this.lookupService = lookupService;
|
||||
this.annotationProperties = annotationProperties;
|
||||
this.timestampTransformer = timestampTransformer;
|
||||
this.visibilityTransformer = visibilityTransformer;
|
||||
this.annotationSource = annotationSource;
|
||||
this.annotationPublisher = annotationPublisher;
|
||||
this.federatedReadExecutorService = federatedReadExecutorService;
|
||||
this.annotationAckTracker = annotationAckTracker;
|
||||
}
|
||||
|
||||
@GetMapping("/source/{analyticHash}")
|
||||
@@ -616,68 +599,7 @@ public class AnnotationControllerV1 {
|
||||
|
||||
/* package-private for unit testing */
|
||||
Optional<AnnotationMessage> sendAnnotationMessage(AnnotationMessage annotationMessage) {
|
||||
// If a rabbit health checker is configured and reports the messaging infrastructure as unhealthy, skip attempting to send
|
||||
// entirely -- the caller's retry/backoff loop in writeAnnotation will keep re-checking on subsequent attempts, and will
|
||||
// eventually fall back to the file writer once its retry budget is exhausted.
|
||||
if (healthChecker != null && !healthChecker.isHealthy()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
AnnotationMessage identifiedMessage = annotationMessage.getAnnotationMessageId().isBlank()
|
||||
? AnnotationUtils.injectAnnotationMessageHash(annotationMessage)
|
||||
: annotationMessage;
|
||||
String annotationMessageId = identifiedMessage.getAnnotationMessageId();
|
||||
|
||||
boolean success;
|
||||
if (annotationProperties.isAnnotationAckEnabled()) {
|
||||
final CountDownLatch newLatch = new CountDownLatch(1);
|
||||
// if a send for this exact annotationMessageId is already in flight, reuse its latch instead of overwriting it -- overwriting
|
||||
// would orphan the earlier caller's latch so that it never gets counted down and always times out.
|
||||
final CountDownLatch existingLatch = annotationAckTracker.putIfAbsent(annotationMessageId, newLatch);
|
||||
final boolean isOriginalSender = existingLatch == null;
|
||||
final CountDownLatch latch = isOriginalSender ? newLatch : existingLatch;
|
||||
|
||||
success = !isOriginalSender || annotationSource.send(MessageBuilder.withPayload(identifiedMessage).setCorrelationId(annotationMessageId).build());
|
||||
|
||||
try {
|
||||
success = success && latch.await(annotationProperties.getAnnotationAckTimeoutMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
// Restore the interrupt status so callers/executors can observe that this thread was asked to stop.
|
||||
Thread.currentThread().interrupt();
|
||||
success = false;
|
||||
} finally {
|
||||
// only the original sender owns this latch entry and should remove it; a duplicate caller must leave it alone
|
||||
// so the original sender's await(...) above can still be satisfied by the eventual ack.
|
||||
if (isOriginalSender) {
|
||||
annotationAckTracker.remove(annotationMessageId, latch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
success = annotationSource.send(MessageBuilder.withPayload(identifiedMessage).setCorrelationId(annotationMessageId).build());
|
||||
}
|
||||
|
||||
return success ? Optional.of(identifiedMessage) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives producer confirm acknowledgements, and disengages the latch associated with the given correlation ID.
|
||||
*
|
||||
* @param message
|
||||
* the confirmation acknowledgements message
|
||||
*/
|
||||
@ConditionalOnProperty(value = "annotation.annotationAckEnabled", havingValue = "true", matchIfMissing = true)
|
||||
@ServiceActivator(inputChannel = ANNOTATION_ACK_CHANNEL)
|
||||
public void processConfirmAck(Message<?> message) {
|
||||
Object headerObj = message.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
|
||||
if (headerObj != null) {
|
||||
String correlationId = headerObj.toString();
|
||||
if (annotationAckTracker.countdown(correlationId)) {} else {
|
||||
log.warn("Unable to decrement latch for audit ID [{}]", correlationId);
|
||||
}
|
||||
} else {
|
||||
log.warn("No correlation ID found in confirm ack message");
|
||||
}
|
||||
return annotationPublisher.publishAndConfirm(annotationMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package datawave.microservice.annotation.service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import datawave.annotation.util.v1.AnnotationUtils;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.health.HealthChecker;
|
||||
import datawave.microservice.annotation.service.config.AnnotationProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Publishes annotations and waits for broker acceptance; persistence is performed separately by queue consumers. */
|
||||
@Slf4j
|
||||
public class AnnotationPublisher {
|
||||
private final AnnotationSupplier annotationSource;
|
||||
private final AnnotationProperties properties;
|
||||
private final HealthChecker healthChecker;
|
||||
private final ConcurrentMap<String,CorrelationData> inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
public AnnotationPublisher(AnnotationSupplier annotationSource, AnnotationProperties properties, HealthChecker healthChecker) {
|
||||
this.annotationSource = annotationSource;
|
||||
this.properties = properties;
|
||||
this.healthChecker = healthChecker;
|
||||
}
|
||||
|
||||
public Optional<AnnotationMessage> publishAndConfirm(AnnotationMessage message) {
|
||||
if (healthChecker != null && !healthChecker.isHealthy()) {
|
||||
log.debug("Skipping annotation publication because the messaging health check is unhealthy");
|
||||
return Optional.empty();
|
||||
}
|
||||
AnnotationMessage identifiedMessage = message.getAnnotationMessageId().isBlank() ? AnnotationUtils.injectAnnotationMessageHash(message) : message;
|
||||
String messageId = identifiedMessage.getAnnotationMessageId();
|
||||
// Each physical publication needs a unique correlation id, including retries of the same logical annotation.
|
||||
// A late confirmation for a timed-out attempt must never confirm a later attempt.
|
||||
CorrelationData newCorrelation = new CorrelationData(UUID.randomUUID().toString());
|
||||
boolean awaitConfirm = properties.isAnnotationAckEnabled();
|
||||
CorrelationData existing = awaitConfirm ? inFlight.putIfAbsent(messageId, newCorrelation) : null;
|
||||
boolean owner = existing == null;
|
||||
CorrelationData correlation = owner ? newCorrelation : existing;
|
||||
try {
|
||||
// @formatter:off
|
||||
if (owner && !annotationSource.send(
|
||||
MessageBuilder.withPayload(identifiedMessage)
|
||||
.setCorrelationId(messageId)
|
||||
.setHeader(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, correlation)
|
||||
.build())) {
|
||||
// Release concurrent duplicate callers as well as the original sender.
|
||||
correlation.getFuture().set(new CorrelationData.Confirm(false, "Local publication rejected"));
|
||||
return Optional.empty();
|
||||
}
|
||||
// @formatter:on
|
||||
if (!awaitConfirm) {
|
||||
return Optional.of(identifiedMessage);
|
||||
}
|
||||
CorrelationData.Confirm confirm = correlation.getFuture().get(properties.getAnnotationAckTimeoutMillis(), TimeUnit.MILLISECONDS);
|
||||
// Rabbit can return an unroutable message and still acknowledge its publication to the exchange.
|
||||
// Spring records the return before completing this future.
|
||||
if (confirm.isAck() && correlation.getReturned() == null) {
|
||||
return Optional.of(identifiedMessage);
|
||||
}
|
||||
log.warn("Annotation publication {} was rejected or returned by the broker", messageId);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
log.warn("Annotation publication {} was not confirmed: {}", messageId, e.toString());
|
||||
} catch (RuntimeException e) {
|
||||
if (owner) {
|
||||
correlation.getFuture().setException(e);
|
||||
}
|
||||
log.warn("Annotation publication {} failed: {}", messageId, e.toString());
|
||||
} finally {
|
||||
if (awaitConfirm && owner) {
|
||||
inFlight.remove(messageId, correlation);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/* package-private for delivery tests */
|
||||
boolean isEmpty() {
|
||||
return inFlight.isEmpty();
|
||||
}
|
||||
}
|
||||
+21
-24
@@ -3,30 +3,40 @@ package datawave.microservice.annotation.service.config;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.web.firewall.StrictHttpFirewall;
|
||||
|
||||
import com.fasterxml.jackson.databind.Module;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
|
||||
import datawave.annotation.protobuf.v1.Annotation;
|
||||
import datawave.annotation.protobuf.v1.Segment;
|
||||
import datawave.annotation.util.v1.JacksonAnnotationDeserializer;
|
||||
import datawave.annotation.util.v1.JacksonAnnotationSerializer;
|
||||
import datawave.annotation.util.v1.JacksonSegmentDeserializer;
|
||||
import datawave.annotation.util.v1.JacksonSegmentSerializer;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.common.config.AnnotationJsonConfiguration;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.health.HealthChecker;
|
||||
import datawave.microservice.annotation.service.AnnotationPublisher;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AnnotationProperties.class)
|
||||
@EnableConfigurationProperties
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
@Import({AnnotationJsonConfiguration.class, AnnotationProperties.class})
|
||||
public class AnnotationServiceConfig {
|
||||
/**
|
||||
* The message source where REST updates enter the stream. Its standalone function name selects the configured {@code annotationSource-out-0} producer
|
||||
* binding.
|
||||
*/
|
||||
@Bean
|
||||
public AnnotationSupplier annotationSource() {
|
||||
return new AnnotationSupplier();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AnnotationPublisher annotationPublisher(AnnotationSupplier annotationSource, AnnotationProperties properties,
|
||||
ObjectProvider<HealthChecker> healthChecker) {
|
||||
return new AnnotationPublisher(annotationSource, properties, healthChecker.getIfAvailable());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public StrictHttpFirewall httpFirewall() {
|
||||
// override the default strict http firewall to allow forward slashes. These are used in internal identifiers
|
||||
@@ -40,20 +50,7 @@ public class AnnotationServiceConfig {
|
||||
return firewall;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Module jacksonAnnotationModule() {
|
||||
final SimpleModule simpleModule = new SimpleModule();
|
||||
|
||||
// Added for Annotation and Segment serialization and deserialization.
|
||||
simpleModule.addDeserializer(Annotation.class, new JacksonAnnotationDeserializer());
|
||||
simpleModule.addSerializer(Annotation.class, new JacksonAnnotationSerializer());
|
||||
simpleModule.addDeserializer(Segment.class, new JacksonSegmentDeserializer());
|
||||
simpleModule.addSerializer(Segment.class, new JacksonSegmentSerializer());
|
||||
|
||||
return simpleModule;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public ExecutorService federatedReadExecutorService() {
|
||||
return Executors.newCachedThreadPool();
|
||||
}
|
||||
|
||||
+3
@@ -32,6 +32,8 @@ import com.google.protobuf.util.JsonFormat;
|
||||
import datawave.annotation.protobuf.v1.Annotation;
|
||||
import datawave.annotation.protobuf.v1.AnnotationList;
|
||||
import datawave.annotation.util.v1.AnnotationJsonUtils;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.util.exceptions.AuthenticationException;
|
||||
import datawave.microservice.annotation.util.exceptions.AuthorizationException;
|
||||
import datawave.microservice.annotation.util.exceptions.BadRequestException;
|
||||
@@ -52,6 +54,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
public class LookupRequest {
|
||||
private final CloseableHttpClient httpClient;
|
||||
private final LookupProperties lookupProperties;
|
||||
|
||||
+4
@@ -14,8 +14,12 @@ import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
|
||||
/** Configuration for the HttpClient that will be used by the LookupService */
|
||||
@Configuration
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
public class HttpClientConfig {
|
||||
|
||||
@Bean
|
||||
|
||||
+4
@@ -5,7 +5,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
public class LookupConfig {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(LookupProperties.class)
|
||||
|
||||
+3
@@ -10,6 +10,8 @@ import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.util.Metadata;
|
||||
import datawave.microservice.annotation.util.exceptions.ResourceNotFoundException;
|
||||
import datawave.microservice.annotation.util.lookup.common.Lookup;
|
||||
@@ -24,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
/** Used to perform lookupUUID request against a remote instance of Datawave */
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
public class LookupService {
|
||||
|
||||
private final LookupRequest request;
|
||||
|
||||
+6
-1
@@ -1,5 +1,7 @@
|
||||
package datawave.microservice.annotation.writers.accumulo.config;
|
||||
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.ACCUMULO_WRITER_ENABLED;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -12,6 +14,8 @@ import datawave.annotation.data.v1.AccumuloAnnotationSerializer;
|
||||
import datawave.annotation.data.v1.AccumuloAnnotationSourceSerializer;
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.microservice.annotation.common.AnnotationConsumer;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.writers.AnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.accumulo.AccumuloAnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.accumulo.health.AccumuloHealthChecker;
|
||||
@@ -23,7 +27,8 @@ import datawave.microservice.annotation.writers.accumulo.health.AccumuloHealthCh
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AccumuloAnnotationWriterProperties.class)
|
||||
@ConditionalOnProperty(name = "annotation.writers.accumulo.enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(name = ACCUMULO_WRITER_ENABLED, havingValue = "true")
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.WRITER, AnnotationRole.COMBINED})
|
||||
public class AccumuloAnnotationWriterConfig {
|
||||
|
||||
@Bean("accumuloAnnotationWriterProperties")
|
||||
|
||||
+6
-1
@@ -1,5 +1,7 @@
|
||||
package datawave.microservice.annotation.writers.file.config;
|
||||
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.DUMP_WRITER_ENABLED;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.validation.Valid;
|
||||
@@ -11,6 +13,8 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import datawave.microservice.annotation.common.AnnotationConsumer;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.writers.AnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.file.FileAnnotationWriter;
|
||||
|
||||
@@ -21,7 +25,8 @@ import datawave.microservice.annotation.writers.file.FileAnnotationWriter;
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(DumpAnnotationWriterProperties.class)
|
||||
@ConditionalOnProperty(name = "annotation.writers.dump.enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(name = DUMP_WRITER_ENABLED, havingValue = "true")
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.WRITER, AnnotationRole.COMBINED})
|
||||
public class DumpAnnotationWriterConfig {
|
||||
|
||||
@Bean("dumpAnnotationWriterProperties")
|
||||
|
||||
+3
@@ -10,6 +10,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.writers.AnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.file.FileAnnotationWriter;
|
||||
|
||||
@@ -21,6 +23,7 @@ import datawave.microservice.annotation.writers.file.FileAnnotationWriter;
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(FileAnnotationWriterProperties.class)
|
||||
@ConditionalOnProperty(name = "annotation.writers.file.enabled", havingValue = "true")
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.API, AnnotationRole.COMBINED})
|
||||
public class FileAnnotationWriterConfig {
|
||||
|
||||
@Bean("fileAnnotationWriterProperties")
|
||||
|
||||
+6
-1
@@ -1,11 +1,15 @@
|
||||
package datawave.microservice.annotation.writers.log.config;
|
||||
|
||||
import static datawave.microservice.annotation.config.AnnotationRoleConstants.LOG_WRITER_ENABLED;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import datawave.microservice.annotation.common.AnnotationConsumer;
|
||||
import datawave.microservice.annotation.config.AnnotationRole;
|
||||
import datawave.microservice.annotation.config.ConditionalOnAnnotationRole;
|
||||
import datawave.microservice.annotation.writers.AnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.log.LogAnnotationWriter;
|
||||
|
||||
@@ -15,7 +19,8 @@ import datawave.microservice.annotation.writers.log.LogAnnotationWriter;
|
||||
* annotation binding, as specified in the annotation config.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "annotation.writers.log.enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(name = LOG_WRITER_ENABLED, havingValue = "true")
|
||||
@ConditionalOnAnnotationRole({AnnotationRole.WRITER, AnnotationRole.COMBINED})
|
||||
public class LogAnnotationWriterProperties {
|
||||
@Bean
|
||||
public AnnotationConsumer logAnnotationSink(@Qualifier("logAnnotationWriter") AnnotationWriter annotationWriter) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
spring:
|
||||
application:
|
||||
name: annotation
|
||||
rabbitmq:
|
||||
# Required by annotationSource's confirmation future and the REST acceptance boundary.
|
||||
publisher-confirm-type: correlated
|
||||
publisher-returns: true
|
||||
cloud:
|
||||
config:
|
||||
# Disable consul-first config by default. We'll turn it back on in the consul profile if that profile is enabled.
|
||||
@@ -17,14 +21,24 @@ spring:
|
||||
bindings:
|
||||
annotationSource-out-0:
|
||||
producer:
|
||||
# Note: This must match ANNOTATION_ACK_CHANNEL in AnnotationControllerV1 or producer confirms will not work.
|
||||
confirmAckChannel: 'annotationAckChannel'
|
||||
# Complete the CorrelationData future supplied by AnnotationPublisher.
|
||||
useConfirmHeader: true
|
||||
management:
|
||||
health:
|
||||
status:
|
||||
order: DOWN, OUT_OF_SERVICE, UNKNOWN, RABBITMQ_UNHEALTHY, UP
|
||||
http-mapping:
|
||||
RABBITMQ_UNHEALTHY: 200
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
readiness:
|
||||
# Include required Rabbit topology and any enabled backend health indicators.
|
||||
include: "*"
|
||||
status:
|
||||
# Preserve Boot's UP-before-UNKNOWN order while making required Rabbit failures decisive.
|
||||
order: DOWN, OUT_OF_SERVICE, RABBITMQ_UNHEALTHY, UP, UNKNOWN
|
||||
http-mapping:
|
||||
DOWN: 503
|
||||
OUT_OF_SERVICE: 503
|
||||
RABBITMQ_UNHEALTHY: 503
|
||||
|
||||
annotation:
|
||||
system-from: "datawave-annotation-service"
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package datawave.microservice.annotation.common;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.support.GenericMessage;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
class AnnotationSupplierTest {
|
||||
@Test
|
||||
void overlappingRequestsAreAllDeliveredWithoutRetrying() throws Exception {
|
||||
AnnotationSupplier supplier = new AnnotationSupplier();
|
||||
Set<String> delivered = ConcurrentHashMap.newKeySet();
|
||||
CountDownLatch received = new CountDownLatch(64);
|
||||
Disposable subscription = supplier.get().subscribe(message -> {
|
||||
// Model a downstream publisher that takes time to process an emission.
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2));
|
||||
delivered.add(message.getPayload().getAnnotationMessageId());
|
||||
received.countDown();
|
||||
});
|
||||
ExecutorService requests = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
List<Future<Boolean>> accepted = new ArrayList<>();
|
||||
for (int i = 0; i < 64; i++) {
|
||||
AnnotationMessage message = AnnotationMessage.newBuilder().setAnnotationMessageId("concurrent-" + i).build();
|
||||
accepted.add(requests.submit(() -> {
|
||||
start.await();
|
||||
return supplier.send(new GenericMessage<>(message));
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (Future<Boolean> result : accepted) {
|
||||
assertThat(result.get(10, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
assertThat(received.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(delivered).hasSize(64);
|
||||
} finally {
|
||||
requests.shutdownNow();
|
||||
subscription.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package datawave.microservice.annotation.common.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.protobuf.util.JsonFormat;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationSource;
|
||||
import datawave.annotation.test.v1.AnnotationTestDataUtil;
|
||||
|
||||
class AnnotationJsonConfigurationTest {
|
||||
@Test
|
||||
void standaloneSourceResponseUsesProtobufJsonAndPreservesMetadata() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new AnnotationJsonConfiguration().jacksonAnnotationModule());
|
||||
AnnotationSource source = AnnotationTestDataUtil.generateTestAnnotation().getSource();
|
||||
String json = mapper.writeValueAsString(source);
|
||||
AnnotationSource.Builder parsed = AnnotationSource.newBuilder();
|
||||
JsonFormat.parser().merge(json, parsed);
|
||||
assertThat(parsed.build()).isEqualTo(source);
|
||||
assertThat(mapper.readTree(json).has("unknownFields")).isFalse();
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
import com.hazelcast.client.config.ClientConfig;
|
||||
import com.hazelcast.core.HazelcastInstance;
|
||||
|
||||
import datawave.autoconfigure.DatawaveCacheAutoConfiguration;
|
||||
import datawave.microservice.authorization.federation.config.FederatedAuthorizationServiceRegistrarConfiguration;
|
||||
import datawave.microservice.cached.HazelcastCacheClientHealthIndicator;
|
||||
import datawave.microservice.cached.HazelcastDefaultClientAutoConfiguration;
|
||||
import datawave.microservice.cached.HazelcastDiscoveryClientAutoConfiguration;
|
||||
import datawave.microservice.cached.HazelcastK8sClientAutoConfiguration;
|
||||
import datawave.microservice.config.marking.MarkingConfig;
|
||||
|
||||
/** Exercises ordinary role YAML and real cache auto-configuration, without test-profile cache overrides or mocked Hazelcast beans. */
|
||||
class AnnotationCachePolicyTest {
|
||||
@ParameterizedTest
|
||||
@MethodSource("ordinaryProfiles")
|
||||
void allRolesUseLocalCachesWithoutARemoteClient(String layout, String role, String discovery) {
|
||||
Path service = Path.of(System.getProperty("basedir")).toAbsolutePath();
|
||||
Path config = "service".equals(layout) ? service.resolve("src/main/config") : service.resolve("../../../../docker/config");
|
||||
// @formatter:off
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
HazelcastDefaultClientAutoConfiguration.class,
|
||||
HazelcastDiscoveryClientAutoConfiguration.class,
|
||||
HazelcastK8sClientAutoConfiguration.class,
|
||||
HazelcastAutoConfiguration.class,
|
||||
DatawaveCacheAutoConfiguration.class,
|
||||
CacheAutoConfiguration.class))
|
||||
.withUserConfiguration(MarkingConfig.class, FederatedAuthorizationServiceRegistrarConfiguration.class, HealthComponents.class)
|
||||
.withInitializer(context -> {
|
||||
context.getEnvironment().setActiveProfiles(role, discovery);
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
try {
|
||||
context.getEnvironment().getPropertySources()
|
||||
.addFirst(loader.load("shared", new FileSystemResource(config.resolve("annotation.yml"))).get(0));
|
||||
context.getEnvironment().getPropertySources()
|
||||
.addFirst(loader.load("role", new FileSystemResource(config.resolve("annotation-" + role + ".yml"))).get(0));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot load ordinary annotation configuration", e);
|
||||
}
|
||||
})
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.doesNotHaveBean(ClientConfig.class)
|
||||
.doesNotHaveBean(HazelcastInstance.class)
|
||||
.doesNotHaveBean(HazelcastCacheClientHealthIndicator.class);
|
||||
assertThat(context.getBean("cacheManager")).isInstanceOf(CaffeineCacheManager.class);
|
||||
assertThat(context.getBean("markings-cache-manager")).isInstanceOf(CaffeineCacheManager.class);
|
||||
assertThat(context.getBean("remoteOperationsCacheManager")).isInstanceOf(CaffeineCacheManager.class);
|
||||
for (String manager : new String[] {"cacheManager", "markings-cache-manager", "remoteOperationsCacheManager"}) {
|
||||
CaffeineCacheManager caches = context.getBean(manager, CaffeineCacheManager.class);
|
||||
String name = "remoteOperationsCacheManager".equals(manager) ? "getRemoteUser" : "annotation-cache-probe";
|
||||
Cache cache = caches.getCache(name);
|
||||
assertThat(cache).isNotNull();
|
||||
cache.put("key", "value");
|
||||
assertThat(cache.get("key", String.class)).isEqualTo("value");
|
||||
cache.evict("key");
|
||||
assertThat(cache.get("key")).isNull();
|
||||
}
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static Stream<Arguments> ordinaryProfiles() {
|
||||
// @formatter:off
|
||||
return Stream.of("service", "docker")
|
||||
.flatMap(layout -> Stream.of("api", "writer", "combined")
|
||||
.flatMap(role -> Stream.of("default", "k8s", "consul")
|
||||
.map(discovery -> Arguments.of(layout, role, discovery))));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
@Import(HazelcastCacheClientHealthIndicator.class)
|
||||
static class HealthComponents {}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.actuate.autoconfigure.availability.AvailabilityProbesAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.endpoint.SecurityContext;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthComponent;
|
||||
import org.springframework.boot.actuate.health.HealthContributorRegistry;
|
||||
import org.springframework.boot.actuate.health.HealthEndpointGroups;
|
||||
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration;
|
||||
import org.springframework.boot.availability.AvailabilityChangeEvent;
|
||||
import org.springframework.boot.availability.LivenessState;
|
||||
import org.springframework.boot.availability.ReadinessState;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
/** Verifies production YAML with Boot's real health aggregation, HTTP mapping, and availability groups. */
|
||||
class AnnotationHealthPolicyTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void requiredRouteHealthControlsReadinessWithoutChangingLiveness(String role) {
|
||||
AtomicReference<Status> route = new AtomicReference<>(new Status("RABBITMQ_UNHEALTHY"));
|
||||
AtomicReference<Status> backend = new AtomicReference<>(Status.UP);
|
||||
// @formatter:off
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
ApplicationAvailabilityAutoConfiguration.class,
|
||||
HealthEndpointAutoConfiguration.class,
|
||||
AvailabilityProbesAutoConfiguration.class))
|
||||
.withBean("healthChecker", HealthIndicator.class, () -> () -> Health.status(route.get()).build())
|
||||
.withBean("backend", HealthIndicator.class, () -> () -> Health.status(backend.get()).build())
|
||||
.withPropertyValues(productionProperties())
|
||||
.withPropertyValues("spring.profiles.active=" + role)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getEnvironment().getProperty("management.endpoint.health.probes.enabled", Boolean.class)).isTrue();
|
||||
assertThat(context)
|
||||
.hasBean("livenessStateHealthIndicator")
|
||||
.hasBean("readinessStateHealthIndicator");
|
||||
assertThat(context.getBean(HealthEndpointGroups.class).getNames()).contains("readiness", "liveness");
|
||||
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
|
||||
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
|
||||
HealthEndpointWebExtension web = new HealthEndpointWebExtension(
|
||||
context.getBean(HealthContributorRegistry.class),
|
||||
context.getBean(HealthEndpointGroups.class));
|
||||
backend.set(Status.UNKNOWN);
|
||||
assertHealth(web, null, "RABBITMQ_UNHEALTHY", 503);
|
||||
assertHealth(web, "readiness", "RABBITMQ_UNHEALTHY", 503);
|
||||
assertHealth(web, "liveness", "UP", 200);
|
||||
|
||||
// Topology health does not depend on consumer count; RabbitHealthCheckerTest covers that distinction.
|
||||
route.set(Status.UP);
|
||||
assertHealth(web, null, "UP", 200);
|
||||
assertHealth(web, "readiness", "UP", 200);
|
||||
backend.set(Status.UP);
|
||||
|
||||
backend.set(Status.DOWN);
|
||||
assertHealth(web, null, "DOWN", 503);
|
||||
assertHealth(web, "readiness", "DOWN", 503);
|
||||
assertHealth(web, "liveness", "UP", 200);
|
||||
backend.set(Status.OUT_OF_SERVICE);
|
||||
assertHealth(web, "readiness", "OUT_OF_SERVICE", 503);
|
||||
backend.set(Status.UP);
|
||||
|
||||
AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC);
|
||||
assertHealth(web, "readiness", "OUT_OF_SERVICE", 503);
|
||||
assertHealth(web, "liveness", "UP", 200);
|
||||
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
|
||||
route.set(new Status("RABBITMQ_UNHEALTHY"));
|
||||
assertHealth(web, "readiness", "RABBITMQ_UNHEALTHY", 503);
|
||||
assertHealth(web, "liveness", "UP", 200);
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static String[] productionProperties() {
|
||||
Path file = Path.of(System.getProperty("basedir"), "src/main/resources/config/bootstrap.yml");
|
||||
try {
|
||||
EnumerablePropertySource<?> source = (EnumerablePropertySource<?>) new YamlPropertySourceLoader()
|
||||
.load("production-bootstrap", new FileSystemResource(file)).get(0);
|
||||
// @formatter:off
|
||||
return Stream.of(source.getPropertyNames())
|
||||
.map(name -> name + "=" + source.getProperty(name))
|
||||
.toArray(String[]::new);
|
||||
// @formatter:on
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot load annotation bootstrap configuration", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertHealth(HealthEndpointWebExtension web, String group, String status, int httpStatus) {
|
||||
WebEndpointResponse<HealthComponent> response = group == null ? web.health(ApiVersion.V3, WebServerNamespace.MANAGEMENT, SecurityContext.NONE)
|
||||
: web.health(ApiVersion.V3, WebServerNamespace.MANAGEMENT, SecurityContext.NONE, group);
|
||||
assertThat(response.getStatus()).isEqualTo(httpStatus);
|
||||
assertThat(response.getBody().getStatus().getCode()).isEqualTo(status);
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cloud.bus.BusEnvironmentPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.microservice.annotation.common.config.AccumuloConfiguration;
|
||||
import datawave.microservice.annotation.common.config.AnnotationJsonConfiguration;
|
||||
import datawave.microservice.annotation.common.config.AnnotationSerializerConfiguration;
|
||||
import datawave.microservice.annotation.service.AnnotationControllerV1;
|
||||
import datawave.microservice.annotation.service.AnnotationPublisher;
|
||||
import datawave.microservice.annotation.service.config.AnnotationProperties;
|
||||
import datawave.microservice.annotation.service.config.AnnotationServiceConfig;
|
||||
import datawave.microservice.annotation.util.lookup.common.LookupRequest;
|
||||
import datawave.microservice.annotation.util.lookup.config.HttpClientConfig;
|
||||
import datawave.microservice.annotation.util.lookup.config.LookupConfig;
|
||||
import datawave.microservice.annotation.util.lookup.service.LookupService;
|
||||
import datawave.microservice.annotation.writers.accumulo.config.AccumuloAnnotationWriterConfig;
|
||||
import datawave.microservice.annotation.writers.file.config.DumpAnnotationWriterConfig;
|
||||
import datawave.microservice.annotation.writers.file.config.FileAnnotationWriterConfig;
|
||||
import datawave.microservice.annotation.writers.log.config.LogAnnotationWriterProperties;
|
||||
|
||||
class AnnotationRoleConfigurationTest {
|
||||
@TempDir
|
||||
java.nio.file.Path files;
|
||||
|
||||
// @formatter:off
|
||||
private final ApplicationContextRunner roles = new ApplicationContextRunner()
|
||||
.withUserConfiguration(RoleComponents.class)
|
||||
.withInitializer(context -> {
|
||||
ConfigurationPropertySources.attach(context.getEnvironment());
|
||||
new BusEnvironmentPostProcessor().postProcessEnvironment(context.getEnvironment(), new SpringApplication(RoleComponents.class));
|
||||
});
|
||||
// @formatter:on
|
||||
|
||||
@Test
|
||||
void apiBootsWithComponentScannedProperties() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(ApiDependencies.class, AnnotationProperties.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=api",
|
||||
"annotation.deployment.overlays.api=true",
|
||||
"annotation.annotation-ack-timeout-millis=1234")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(AnnotationProperties.class)
|
||||
.hasSingleBean(AnnotationPublisher.class)
|
||||
.hasSingleBean(AnnotationControllerV1.class);
|
||||
assertThat(context.getBean(AnnotationProperties.class).getAnnotationAckTimeoutMillis()).isEqualTo(1234);
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRoleDoesNotInitializeAnyQueueWriterEvenWhenSharedConfigEnablesThem() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(ApiDependencies.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=api",
|
||||
"annotation.deployment.overlays.api=true",
|
||||
"annotation.writers.accumulo.enabled=true",
|
||||
"annotation.writers.log.enabled=true",
|
||||
"annotation.writers.dump.enabled=true",
|
||||
"annotation.writers.file.enabled=false")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(AnnotationControllerV1.class)
|
||||
.hasSingleBean(AnnotationPublisher.class)
|
||||
.hasSingleBean(LookupService.class)
|
||||
.hasSingleBean(ExecutorService.class)
|
||||
.hasBean("annotationSource")
|
||||
.doesNotHaveBean("accumuloAnnotationWriter")
|
||||
.doesNotHaveBean("accumuloAnnotationSink")
|
||||
.doesNotHaveBean("logAnnotationSink")
|
||||
.doesNotHaveBean("dumpAnnotationSink")
|
||||
.doesNotHaveBean("fileAnnotationWriter")
|
||||
.doesNotHaveBean("accumuloHealthChecker");
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition"))
|
||||
.isEqualTo("annotationSource;busConsumer");
|
||||
Mockito.verifyNoInteractions(context.getBean(AccumuloConnectionFactory.class));
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerRoleStartsWithoutApiDependenciesOrDirectFileFallback() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(AccumuloConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=writer",
|
||||
"annotation.deployment.overlays.writer=true",
|
||||
"annotation.writers.log.enabled=true",
|
||||
"annotation.writers.file.enabled=true",
|
||||
"annotation.writers.file.pathUri=invalid://must-not-be-created")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasBean("logAnnotationWriter")
|
||||
.hasBean("logAnnotationSink")
|
||||
.hasBean("jacksonAnnotationModule")
|
||||
.doesNotHaveBean(AnnotationControllerV1.class)
|
||||
.doesNotHaveBean(AnnotationPublisher.class)
|
||||
.doesNotHaveBean(LookupService.class)
|
||||
.doesNotHaveBean(LookupRequest.class)
|
||||
.doesNotHaveBean("httpClient")
|
||||
.doesNotHaveBean("lookupProperties")
|
||||
.doesNotHaveBean("annotationSource")
|
||||
.doesNotHaveBean("federatedReadExecutorService")
|
||||
.doesNotHaveBean("fileAnnotationWriter")
|
||||
.doesNotHaveBean(AccumuloConnectionFactory.class)
|
||||
.doesNotHaveBean("connectionPoolsProperties");
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition"))
|
||||
.isEqualTo("logAnnotationSink;busConsumer");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitCombinedRoleRetainsApiAndConfiguredConsumersAndFileFallback() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(ApiDependencies.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=combined",
|
||||
"annotation.deployment.overlays.combined=true",
|
||||
"annotation.writers.log.enabled=true",
|
||||
"annotation.writers.file.enabled=true",
|
||||
"annotation.writers.file.pathUri=" + files.toUri())
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(AnnotationControllerV1.class)
|
||||
.hasBean("annotationSource")
|
||||
.hasBean("logAnnotationSink")
|
||||
.hasBean("fileAnnotationWriter");
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition"))
|
||||
.isEqualTo("annotationSource;logAnnotationSink;busConsumer");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void dumpConsumerProvidesQueueDrivenFileOutputInWriterRole() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(AccumuloConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=writer",
|
||||
"annotation.deployment.overlays.writer=true",
|
||||
"annotation.writers.accumulo.enabled=false",
|
||||
"annotation.writers.dump.enabled=true",
|
||||
"annotation.writers.dump.pathUri=" + files.toUri(),
|
||||
"spring.cloud.bus.enabled=false")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasBean("dumpAnnotationSink")
|
||||
.hasBean("dumpAnnotationWriter")
|
||||
.doesNotHaveBean("annotationSource")
|
||||
.doesNotHaveBean("fileAnnotationWriter")
|
||||
.doesNotHaveBean(AccumuloConnectionFactory.class)
|
||||
.doesNotHaveBean("connectionPoolsProperties");
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition")).isEqualTo("dumpAnnotationSink");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerWithoutEnabledQueueConsumerFailsClearly() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=writer",
|
||||
"annotation.deployment.overlays.writer=true",
|
||||
"annotation.writers.file.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure()).hasMessage("Profile writer requires an enabled log, accumulo, or dump queue consumer");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidQueueWriterFlags")
|
||||
void invalidQueueWriterFlagsFailEvenWithOtherBackendsEnabled(String backend, String value) {
|
||||
Map<String,Object> flags = new HashMap<>();
|
||||
flags.put("annotation.writers.log.enabled", true);
|
||||
flags.put("annotation.writers.dump.enabled", true);
|
||||
flags.put("annotation.writers.accumulo.enabled", false);
|
||||
String property = "annotation.writers." + backend + ".enabled";
|
||||
flags.put(property, value);
|
||||
for (String role : new String[] {"api", "writer", "combined"}) {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(ApiDependencies.class, AccumuloConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=" + role,
|
||||
"annotation.deployment.overlays." + role + "=true",
|
||||
"annotation.writers.dump.pathUri=" + files.toUri())
|
||||
.withInitializer(context -> context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("backend-flags", flags)))
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure()).hasMessage(property + " must be true or false; received '" + value + "'");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
}
|
||||
|
||||
static Stream<Arguments> invalidQueueWriterFlags() {
|
||||
// @formatter:off
|
||||
return Stream.of("log", "accumulo", "dump")
|
||||
.flatMap(backend -> Stream.of("1", "yes", "on", "0", "no", "off", "", " true ", "false ", "invalid")
|
||||
.map(value -> Arguments.of(backend, value)));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingQueueWriterFlagsRemainDisabledInCombinedRole() {
|
||||
// @formatter:off
|
||||
roles
|
||||
.withUserConfiguration(ApiDependencies.class)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=combined",
|
||||
"annotation.deployment.overlays.combined=true")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasBean("annotationSource")
|
||||
.doesNotHaveBean("logAnnotationSink")
|
||||
.doesNotHaveBean("accumuloAnnotationSink")
|
||||
.doesNotHaveBean("dumpAnnotationSink");
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition"))
|
||||
.isEqualTo("annotationSource;busConsumer");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Import({AnnotationRoleConfiguration.class, AnnotationServiceConfig.class, AnnotationJsonConfiguration.class, AnnotationSerializerConfiguration.class,
|
||||
AnnotationControllerV1.class, LookupConfig.class, HttpClientConfig.class, LookupRequest.class, LookupService.class,
|
||||
LogAnnotationWriterProperties.class, AccumuloAnnotationWriterConfig.class, FileAnnotationWriterConfig.class, DumpAnnotationWriterConfig.class})
|
||||
static class RoleComponents {}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ApiDependencies {
|
||||
@Bean
|
||||
AccumuloConnectionFactory connectionFactory() {
|
||||
return Mockito.mock(AccumuloConnectionFactory.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SSLContext sslContext() throws Exception {
|
||||
return SSLContext.getDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.cloud.bus.BusEnvironmentPostProcessor;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
import datawave.microservice.annotation.service.config.AnnotationProperties;
|
||||
|
||||
class AnnotationRoleProfileTest {
|
||||
@Test
|
||||
void apiProfileLoadsPublisherSettingsWithoutConsumers() throws IOException {
|
||||
StandardEnvironment environment = configuration("api");
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
assertThat(AnnotationRole.from(environment)).isEqualTo(AnnotationRole.API);
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).isEqualTo("annotationSource;busConsumer");
|
||||
assertThat(environment.getProperty("annotation.writers.file.enabled", Boolean.class)).isFalse();
|
||||
assertThat(environment.getProperty("annotation.annotation-ack-timeout-millis", Integer.class)).isEqualTo(500);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerProfileLoadsConsumerSettingsWithoutPublisher() throws IOException {
|
||||
StandardEnvironment environment = configuration("writer");
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
assertThat(AnnotationRole.from(environment)).isEqualTo(AnnotationRole.WRITER);
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).isEqualTo("accumuloAnnotationSink;busConsumer");
|
||||
assertThat(environment.getProperty("annotation.writers.accumulo.concurrency", Integer.class)).isEqualTo(1);
|
||||
assertThat(Binder.get(environment).bind("annotation", AnnotationProperties.class).get().isEnableInternalIdLookup()).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void roleProfilesPreserveSharedTuningAndBackendSelection(String profile) throws IOException {
|
||||
// @formatter:off
|
||||
StandardEnvironment environment = configuration(Map.of(
|
||||
"annotation.annotation-ack-timeout-millis", 2500,
|
||||
"annotation.retry.max-attempts", 3,
|
||||
"annotation.retry.backoff-interval-millis", 1700,
|
||||
"annotation.retry.fail-timeout-millis", 19000,
|
||||
"annotation.writers.accumulo.enabled", false,
|
||||
"annotation.writers.log.enabled", true,
|
||||
"annotation.writers.dump.enabled", true,
|
||||
"spring.cloud.stream.bindings.accumuloAnnotationSink-in-0.consumer.concurrency", 8), profile);
|
||||
// @formatter:on
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
AnnotationProperties properties = Binder.get(environment).bind("annotation", AnnotationProperties.class).get();
|
||||
assertThat(properties.getAnnotationAckTimeoutMillis()).isEqualTo(2500);
|
||||
assertThat(properties.getRetry().getMaxAttempts()).isEqualTo(3);
|
||||
assertThat(properties.getRetry().getBackoffIntervalMillis()).isEqualTo(1700);
|
||||
assertThat(properties.getRetry().getFailTimeoutMillis()).isEqualTo(19000);
|
||||
assertThat(environment.getProperty("annotation.writers.accumulo.enabled", Boolean.class)).isFalse();
|
||||
assertThat(environment.getProperty("annotation.writers.log.enabled", Boolean.class)).isTrue();
|
||||
assertThat(environment.getProperty("annotation.writers.dump.enabled", Boolean.class)).isTrue();
|
||||
assertThat(environment.getProperty("spring.cloud.stream.bindings.accumuloAnnotationSink-in-0.consumer.concurrency", Integer.class)).isEqualTo(8);
|
||||
assertThat(environment.getProperty("annotation.writers.accumulo.concurrency", Integer.class)).isEqualTo(8);
|
||||
String expected = "api".equals(profile) ? "annotationSource;busConsumer"
|
||||
: "writer".equals(profile) ? "logAnnotationSink;dumpAnnotationSink;busConsumer"
|
||||
: "annotationSource;logAnnotationSink;dumpAnnotationSink;busConsumer";
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void onlyTheApiOverlayChangesAcceptancePolicyDefaults(String profile) throws IOException {
|
||||
StandardEnvironment environment = configuration(Map.of("annotation.annotation-ack-enabled", false, "annotation.writers.file.enabled", true), profile);
|
||||
|
||||
AnnotationProperties properties = Binder.get(environment).bind("annotation", AnnotationProperties.class).get();
|
||||
boolean api = "api".equals(profile);
|
||||
assertThat(properties.isAnnotationAckEnabled()).isEqualTo(api);
|
||||
assertThat(environment.getProperty("annotation.writers.file.enabled", Boolean.class)).isEqualTo(!api);
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterDeploymentProfileCanEnableApiFileFallback() throws IOException {
|
||||
StandardEnvironment environment = configuration("api", "deployment-fallback");
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("deployment-fallback", Map.of("annotation.writers.file.enabled", true)));
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
assertThat(environment.getProperty("annotation.writers.file.enabled", Boolean.class)).isTrue();
|
||||
assertThat(Binder.get(environment).bind("annotation", AnnotationProperties.class).get().isAnnotationAckEnabled()).isTrue();
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).isEqualTo("annotationSource;busConsumer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterDeploymentProfileCanSelectAFileOnlyWriter() throws IOException {
|
||||
StandardEnvironment environment = configuration("writer", "deployment-files");
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("deployment-files", Map.of("annotation.writers.accumulo.enabled", false,
|
||||
"annotation.writers.dump.enabled", true, "spring.cloud.stream.bindings.dumpAnnotationSink-in-0.consumer.concurrency", 3)));
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).isEqualTo("dumpAnnotationSink;busConsumer");
|
||||
assertThat(environment.getProperty("spring.cloud.stream.bindings.dumpAnnotationSink-in-0.consumer.concurrency", Integer.class)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api,writer", "api,combined", "writer,combined", "api,writer,combined"})
|
||||
void multipleRoleProfilesFail(String profiles) throws IOException {
|
||||
StandardEnvironment environment = configuration(profiles.split(","));
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRole.from(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageStartingWith("Activate exactly one of combined, api, writer; selected ");
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void missingRoleOverlayFailsBeforeFunctionsAreConfigured(String profile) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setActiveProfiles(profile);
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRoleConfiguration.configureFunctions(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("requires its matching Config Server overlay annotation-" + profile + ".yml");
|
||||
// @formatter:on
|
||||
assertThat(environment.getPropertySources().contains("annotationRoleFunctions")).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "k8s,remoteauth", "annotation-write", "annotation-api", "annotation-writer", "annotation-combined"})
|
||||
void missingRoleProfileFailsEvenWithEnvironmentProfiles(String profiles) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setActiveProfiles(profiles.isEmpty() ? new String[0] : profiles.split(","));
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRole.from(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageStartingWith("Activate exactly one of combined, api, writer; selected ");
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherRolesOverlayCannotSatisfyTheSelectedRole() throws IOException {
|
||||
StandardEnvironment environment = configuration("api");
|
||||
environment.setActiveProfiles("writer");
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRoleConfiguration.configureFunctions(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("annotation-writer.yml");
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void oldOverlayMarkersCannotSatisfyTheRenamedRole(String profile) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setActiveProfiles(profile);
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("old-overlay", Map.of("annotation.deployment.overlays.annotation-" + profile, true)));
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRoleConfiguration.configureFunctions(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("missing annotation.deployment.overlays." + profile);
|
||||
// @formatter:on
|
||||
assertThat(environment.getPropertySources().contains("annotationRoleFunctions")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultProfilesCannotSupplyAnImplicitRole() {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setDefaultProfiles("combined");
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRole.from(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Activate exactly one");
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void combinedProfileRetainsSharedSettingsAndBothResponsibilities() throws IOException {
|
||||
StandardEnvironment environment = configuration("combined", "dev");
|
||||
AnnotationRoleConfiguration.configureFunctions(environment);
|
||||
|
||||
assertThat(AnnotationRole.from(environment)).isEqualTo(AnnotationRole.COMBINED);
|
||||
assertThat(environment.getProperty("spring.cloud.function.definition")).contains("annotationSource", "accumuloAnnotationSink");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("functionOverrides")
|
||||
void explicitFunctionListsAreRejected(String profile, String property, String definition) throws IOException {
|
||||
StandardEnvironment environment = configuration(profile);
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("unsupported-functions", Map.of(property, definition)));
|
||||
ConfigurationPropertySources.attach(environment);
|
||||
new BusEnvironmentPostProcessor().postProcessEnvironment(environment, new SpringApplication(AnnotationRoleConfiguration.class));
|
||||
|
||||
// @formatter:off
|
||||
assertThatThrownBy(() -> AnnotationRoleConfiguration.configureFunctions(environment))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(property + " is managed by the annotation service")
|
||||
.hasMessageContaining("remove the override");
|
||||
// @formatter:on
|
||||
assertThat(environment.getPropertySources().contains("annotationRoleFunctions")).isFalse();
|
||||
}
|
||||
|
||||
private static Stream<Arguments> functionOverrides() {
|
||||
// @formatter:off
|
||||
return Stream.of("api", "writer", "combined")
|
||||
.flatMap(profile -> Stream.of("spring.cloud.function.definition", "spring.cloud.stream.function.definition")
|
||||
.flatMap(property -> Stream.of(
|
||||
"",
|
||||
"annotationSource",
|
||||
"identity|logAnnotationSink",
|
||||
"custom;other",
|
||||
"busConsumer",
|
||||
"annotationSource;busConsumer")
|
||||
.map(definition -> Arguments.of(profile, property, definition))));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceAndDockerRoleOverlaysHaveIdenticalSettings() throws IOException {
|
||||
Path service = Path.of(System.getProperty("basedir")).toAbsolutePath();
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
for (String profile : new String[] {"api", "writer", "combined"}) {
|
||||
String filename = "annotation-" + profile + ".yml";
|
||||
assertThat(loader.load(profile, new FileSystemResource(service.resolve("src/main/config").resolve(filename))).get(0).getSource()).isEqualTo(
|
||||
loader.load(profile, new FileSystemResource(service.resolve("../../../../docker/config").resolve(filename))).get(0).getSource());
|
||||
}
|
||||
}
|
||||
|
||||
private static StandardEnvironment configuration(String... profiles) throws IOException {
|
||||
return configuration(Map.of(), profiles);
|
||||
}
|
||||
|
||||
private static StandardEnvironment configuration(Map<String,Object> sharedOverrides, String... profiles) throws IOException {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setActiveProfiles(profiles);
|
||||
Path config = Path.of(System.getProperty("basedir"), "src/main/config");
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
environment.getPropertySources().addLast(loader
|
||||
.load("packaged", new FileSystemResource(Path.of(System.getProperty("basedir"), "src/main/resources/config/bootstrap.yml"))).get(0));
|
||||
environment.getPropertySources().addFirst(loader.load("shared", new FileSystemResource(config.resolve("annotation.yml"))).get(0));
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("shared-tuning", sharedOverrides));
|
||||
for (String profile : profiles) {
|
||||
Path overlay = config.resolve("annotation-" + profile + ".yml");
|
||||
if (java.nio.file.Files.exists(overlay)) {
|
||||
environment.getPropertySources().addFirst(loader.load(profile, new FileSystemResource(overlay)).get(0));
|
||||
}
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration;
|
||||
import org.springframework.cloud.stream.binder.Binder;
|
||||
import org.springframework.cloud.stream.binder.BinderFactory;
|
||||
import org.springframework.cloud.stream.binder.Binding;
|
||||
import org.springframework.cloud.stream.binder.BindingCreatedEvent;
|
||||
import org.springframework.cloud.stream.binder.ConsumerProperties;
|
||||
import org.springframework.cloud.stream.binder.ProducerProperties;
|
||||
import org.springframework.cloud.stream.config.BindingServiceConfiguration;
|
||||
import org.springframework.cloud.stream.function.FunctionConfiguration;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.MessageHeaders;
|
||||
import org.springframework.messaging.SubscribableChannel;
|
||||
import org.springframework.messaging.support.GenericMessage;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import datawave.annotation.test.v1.AnnotationTestDataUtil;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.common.config.AnnotationJsonConfiguration;
|
||||
import datawave.microservice.annotation.messaging.AnnotationMessagingConfiguration;
|
||||
import datawave.microservice.annotation.service.AnnotationPublisher;
|
||||
import datawave.microservice.annotation.service.config.AnnotationServiceConfig;
|
||||
import datawave.microservice.annotation.writers.log.config.LogAnnotationWriterProperties;
|
||||
|
||||
/**
|
||||
* Initializes the real Spring Cloud function catalog and stream binding lifecycle with a recording binder instead of an external RabbitMQ broker.
|
||||
*/
|
||||
class AnnotationStreamRoleTest {
|
||||
private ApplicationContextRunner streams(RecordingBinder binder) {
|
||||
BinderFactory factory = mock(BinderFactory.class);
|
||||
doReturn(binder).when(factory).getBinder(any(), any());
|
||||
// @formatter:off
|
||||
return new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
IntegrationAutoConfiguration.class,
|
||||
ContextFunctionCatalogAutoConfiguration.class,
|
||||
ClassUtils.resolveClassName(
|
||||
"org.springframework.cloud.function.cloudevent.CloudEventsFunctionExtensionConfiguration",
|
||||
getClass().getClassLoader()),
|
||||
BindingServiceConfiguration.class,
|
||||
FunctionConfiguration.class))
|
||||
.withUserConfiguration(
|
||||
AnnotationRoleConfiguration.class,
|
||||
AnnotationServiceConfig.class,
|
||||
AnnotationJsonConfiguration.class,
|
||||
AnnotationMessagingConfiguration.class,
|
||||
LogAnnotationWriterProperties.class)
|
||||
.withBean(BinderFactory.class, () -> factory)
|
||||
.withPropertyValues(
|
||||
"spring.cloud.bus.enabled=false",
|
||||
"annotation.writers.log.enabled=true",
|
||||
"spring.cloud.stream.bindings.annotationSource-out-0.destination=annotation",
|
||||
"spring.cloud.stream.bindings.annotationSource-out-0.content-type=application/x-protobuf",
|
||||
"spring.cloud.stream.bindings.logAnnotationSink-in-0.destination=annotation",
|
||||
"spring.cloud.stream.bindings.logAnnotationSink-in-0.content-type=application/x-protobuf",
|
||||
"spring.cloud.stream.bindings.logAnnotationSink-in-0.group=log");
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"api,1,0", "writer,0,1", "combined,1,1"})
|
||||
void roleSelectsOnlyItsMessagingResponsibilities(String role, int producers, int consumers) {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=" + role,
|
||||
"annotation.deployment.overlays." + role + "=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(binder.producers).isEqualTo(producers);
|
||||
assertThat(binder.consumers).isEqualTo(consumers);
|
||||
assertThat(context.containsBean("annotationSource-out-0")).isEqualTo(producers == 1);
|
||||
assertThat(context.containsBean("logAnnotationSink-in-0")).isEqualTo(consumers == 1);
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"spring.cloud.function.definition", "spring.cloud.stream.function.definition"})
|
||||
void functionOverridesFailBeforeBindingsInitialize(String property) {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=api",
|
||||
"annotation.deployment.overlays.api=true",
|
||||
property + "=annotationSource")
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure()).hasMessageContaining(property + " is managed by the annotation service");
|
||||
assertThat(binder.producers).isZero();
|
||||
assertThat(binder.consumers).isZero();
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("bindingAliases")
|
||||
void aliasesForFixedAnnotationBindingsFailBeforeBindingsInitialize(String role, String binding, boolean brackets) {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
String property = "spring.cloud.stream.function.bindings." + (brackets ? "[" + binding + "]" : binding);
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=" + role,
|
||||
"annotation.deployment.overlays." + role + "=true",
|
||||
property + "=renamed")
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure())
|
||||
.hasMessageContaining("must not rename the fixed annotation binding")
|
||||
.hasMessageContaining(binding);
|
||||
assertThat(binder.producers).isZero();
|
||||
assertThat(binder.consumers).isZero();
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static Stream<Arguments> bindingAliases() {
|
||||
// @formatter:off
|
||||
return Stream.of("api", "writer", "combined")
|
||||
.flatMap(role -> Stream.of("annotationSource-out-0", "logAnnotationSink-in-0", "accumuloAnnotationSink-in-0", "dumpAnnotationSink-in-0")
|
||||
.flatMap(binding -> Stream.of(false, true)
|
||||
.map(brackets -> Arguments.of(role, binding, brackets))));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityAliasesPreserveBindingsAndConfirmationConversion() {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=combined",
|
||||
"annotation.deployment.overlays.combined=true",
|
||||
"spring.cloud.stream.function.bindings.annotationSource-out-0=annotationSource-out-0",
|
||||
"spring.cloud.stream.function.bindings.logAnnotationSink-in-0=logAnnotationSink-in-0")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(binder.producers).isEqualTo(1);
|
||||
assertThat(binder.consumers).isEqualTo(1);
|
||||
context.publishEvent(new BindingCreatedEvent(binder.producerBinding));
|
||||
AnnotationMessage expected = AnnotationMessage.newBuilder()
|
||||
.setAnnotationMessageId("identity-binding")
|
||||
.build();
|
||||
CorrelationData correlation = new CorrelationData("identity-binding-confirmation");
|
||||
assertThat(context.getBean(AnnotationSupplier.class)
|
||||
.send(new GenericMessage<>(expected, Map.of(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, correlation)))).isTrue();
|
||||
Message<?> wire = binder.messages.poll(3, TimeUnit.SECONDS);
|
||||
assertThat(wire).isNotNull();
|
||||
assertThat(AnnotationMessage.parseFrom((byte[]) wire.getPayload())).isEqualTo(expected);
|
||||
assertThat(wire.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION)).isSameAs(correlation);
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"yes"})
|
||||
void noncanonicalTrueFlagFailsBeforeBindingsInitialize(String value) {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=writer",
|
||||
"annotation.deployment.overlays.writer=true",
|
||||
"annotation.writers.log.enabled=" + value)
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context.getStartupFailure())
|
||||
.hasMessage("annotation.writers.log.enabled must be true or false; received '" + value + "'");
|
||||
assertThat(binder.producers).isZero();
|
||||
assertThat(binder.consumers).isZero();
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"true,1", "TRUE,1", "TrUe,1", "false,0", "FALSE,0", "FaLsE,0"})
|
||||
void canonicalFlagsAgreeWithActualConsumerBeansAndBindings(String value, int expectedConsumers) {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=combined",
|
||||
"annotation.deployment.overlays.combined=true",
|
||||
"annotation.writers.log.enabled=" + value)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(binder.producers).isEqualTo(1);
|
||||
assertThat(binder.consumers).isEqualTo(expectedConsumers);
|
||||
assertThat(context.containsBean("logAnnotationSink")).isEqualTo(expectedConsumers == 1);
|
||||
assertThat(context.getEnvironment().getProperty("spring.cloud.stream.function.definition").contains("logAnnotationSink"))
|
||||
.isEqualTo(expectedConsumers == 1);
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmationHeaderSurvivesOutboundProtobufConversion() {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=api",
|
||||
"annotation.deployment.overlays.api=true",
|
||||
"annotation.annotation-ack-timeout-millis=5000")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
context.publishEvent(new BindingCreatedEvent(binder.producerBinding));
|
||||
AnnotationMessage expected = AnnotationMessage.newBuilder()
|
||||
.setAnnotationMessageId("converted-confirm")
|
||||
.addAnnotations(AnnotationTestDataUtil.generateTestAnnotation())
|
||||
.build();
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var result = executor.submit(() -> context.getBean(AnnotationPublisher.class).publishAndConfirm(expected));
|
||||
Message<?> wire = binder.messages.poll(3, TimeUnit.SECONDS);
|
||||
assertThat(wire).isNotNull();
|
||||
assertThat(wire.getPayload()).isInstanceOf(byte[].class);
|
||||
assertThat(AnnotationMessage.parseFrom((byte[]) wire.getPayload())).isEqualTo(expected);
|
||||
CorrelationData correlation = wire.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, CorrelationData.class);
|
||||
assertThat(correlation).isNotNull();
|
||||
correlation.getFuture().set(new CorrelationData.Confirm(true, null));
|
||||
assertThat(result.get(3, TimeUnit.SECONDS)).contains(expected);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamBindingsSerializeAndConsumeProtobufBytes() {
|
||||
RecordingBinder binder = new RecordingBinder();
|
||||
// @formatter:off
|
||||
streams(binder)
|
||||
.withPropertyValues(
|
||||
"spring.profiles.active=combined",
|
||||
"annotation.deployment.overlays.combined=true")
|
||||
.run(context -> {
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasBean("annotationProtobufMessageConverter");
|
||||
AnnotationMessage expected = AnnotationMessage.newBuilder()
|
||||
.setSource("annotation-role-conversion-test")
|
||||
.addAnnotations(AnnotationTestDataUtil.generateTestAnnotation())
|
||||
.build();
|
||||
// Rabbit's binder publishes this event to start the reactive supplier.
|
||||
context.publishEvent(new BindingCreatedEvent(binder.producerBinding));
|
||||
assertThat(context.getBean(AnnotationSupplier.class).send(new GenericMessage<>(expected))).isTrue();
|
||||
|
||||
Message<?> wire = binder.messages.poll(3, TimeUnit.SECONDS);
|
||||
assertThat(wire).isNotNull();
|
||||
assertThat(wire.getPayload()).isInstanceOf(byte[].class);
|
||||
assertThat(AnnotationMessage.parseFrom((byte[]) wire.getPayload())).isEqualTo(expected);
|
||||
// Deliver broker-shaped bytes through the real inbound conversion and function invocation chain.
|
||||
assertThat(binder.consumerChannel.send(new GenericMessage<>(wire.getPayload(), Map.of(MessageHeaders.CONTENT_TYPE, "application/x-protobuf"))))
|
||||
.isTrue();
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
static class RecordingBinder implements Binder<MessageChannel,ConsumerProperties,ProducerProperties> {
|
||||
int producers;
|
||||
int consumers;
|
||||
final BlockingQueue<Message<?>> messages = new LinkedBlockingQueue<>();
|
||||
MessageChannel consumerChannel;
|
||||
Binding<MessageChannel> producerBinding;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Binding<MessageChannel> bindConsumer(String destination, String group, MessageChannel channel, ConsumerProperties properties) {
|
||||
assertThat(destination).isEqualTo("annotation");
|
||||
assertThat(group).isEqualTo("log");
|
||||
consumers++;
|
||||
consumerChannel = channel;
|
||||
return mock(Binding.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Binding<MessageChannel> bindProducer(String destination, MessageChannel channel, ProducerProperties properties) {
|
||||
assertThat(destination).isEqualTo("annotation");
|
||||
producers++;
|
||||
((SubscribableChannel) channel).subscribe(messages::add);
|
||||
producerBinding = mock(Binding.class);
|
||||
return producerBinding;
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package datawave.microservice.annotation.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.AnnotationAttributes;
|
||||
import org.springframework.core.type.AnnotationMetadata;
|
||||
import org.springframework.core.type.MethodMetadata;
|
||||
import org.springframework.core.type.classreading.SimpleMetadataReaderFactory;
|
||||
|
||||
class ConditionalOnAnnotationRoleTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void requiredRoleArraysArePresentInReflectionAndClassFileMetadata(boolean readClassFile) throws IOException {
|
||||
String annotationName = ConditionalOnAnnotationRole.class.getName();
|
||||
AnnotationMetadata classMetadata = metadata(ApiOnlyConfiguration.class, readClassFile);
|
||||
AnnotationAttributes attributes = AnnotationAttributes.fromMap(classMetadata.getAnnotationAttributes(annotationName));
|
||||
assertThat(attributes).isNotNull();
|
||||
assertThat((AnnotationRole[]) attributes.get("value")).containsExactly(AnnotationRole.API);
|
||||
|
||||
AnnotationMetadata methodMetadata = metadata(RoleBeans.class, readClassFile);
|
||||
assertThat(methodMetadata.getAnnotatedMethods(annotationName)).hasSize(4);
|
||||
for (MethodMetadata method : methodMetadata.getAnnotatedMethods(annotationName)) {
|
||||
AnnotationAttributes methodAttributes = AnnotationAttributes.fromMap(method.getAnnotationAttributes(annotationName));
|
||||
assertThat(methodAttributes).isNotNull();
|
||||
AnnotationRole[] roles = (AnnotationRole[]) methodAttributes.get("value");
|
||||
assertThat(roles).isNotNull();
|
||||
// @formatter:off
|
||||
assertThat(method.getAnnotations()
|
||||
.get(ConditionalOnAnnotationRole.class)
|
||||
.getEnumArray("value", AnnotationRole.class))
|
||||
.containsExactly(roles);
|
||||
// @formatter:on
|
||||
if ("noRolesBean".equals(method.getMethodName())) {
|
||||
assertThat(roles).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(AnnotationRole.class)
|
||||
void classAndBeanMethodConditionsSelectTheirDeclaredRoles(AnnotationRole role) {
|
||||
// @formatter:off
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(RoleBeans.class, ApiOnlyConfiguration.class)
|
||||
.withPropertyValues("spring.profiles.active=" + role.profile())
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.containsBean("apiBean")).isEqualTo(role == AnnotationRole.API);
|
||||
assertThat(context.containsBean("writerBean")).isEqualTo(role == AnnotationRole.WRITER);
|
||||
assertThat(context.containsBean("combinedBean")).isEqualTo(role == AnnotationRole.COMBINED);
|
||||
assertThat(context.containsBean("apiOnlyBean")).isEqualTo(role == AnnotationRole.API);
|
||||
assertThat(context).doesNotHaveBean("noRolesBean");
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private AnnotationMetadata metadata(Class<?> type, boolean readClassFile) throws IOException {
|
||||
return readClassFile ? new SimpleMetadataReaderFactory().getMetadataReader(type.getName()).getAnnotationMetadata()
|
||||
: AnnotationMetadata.introspect(type);
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class RoleBeans {
|
||||
@Bean
|
||||
@ConditionalOnAnnotationRole(AnnotationRole.API)
|
||||
String apiBean() {
|
||||
return "api";
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAnnotationRole(AnnotationRole.WRITER)
|
||||
String writerBean() {
|
||||
return "writer";
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAnnotationRole(AnnotationRole.COMBINED)
|
||||
String combinedBean() {
|
||||
return "combined";
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAnnotationRole({})
|
||||
String noRolesBean() {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnAnnotationRole(AnnotationRole.API)
|
||||
static class ApiOnlyConfiguration {
|
||||
@Bean
|
||||
String apiOnlyBean() {
|
||||
return "api only";
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package datawave.microservice.annotation.health.rabbit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.DirectFieldAccessor;
|
||||
import org.springframework.boot.context.properties.bind.Bindable;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import datawave.microservice.annotation.health.rabbit.config.RabbitHealthProperties;
|
||||
|
||||
class AnnotationRabbitTopologyTest {
|
||||
private static final String MANAGEMENT_URL = "http://rabbitmq:15672/api/";
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private RabbitHealthChecker checker;
|
||||
private MockRestServiceServer server;
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void logQueueAloneDoesNotSatisfyTheRequiredPersistenceRoute(String profile) throws IOException {
|
||||
StandardEnvironment environment = configuration(profile);
|
||||
assertThat(environment.getProperty("annotation.writers.accumulo.enabled", Boolean.class)).isTrue();
|
||||
assertThat(environment.getProperty("annotation.writers.log.enabled", Boolean.class)).isFalse();
|
||||
initialize(environment);
|
||||
|
||||
expectTopology("log", true);
|
||||
checker.runHealthCheck();
|
||||
|
||||
assertThat(checker.isHealthy()).isFalse();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void requiredDurableRouteIsHealthyWithZeroConsumers(String profile) throws IOException {
|
||||
initialize(configuration(profile));
|
||||
expectTopology("accumulo", true);
|
||||
|
||||
checker.runHealthCheck();
|
||||
|
||||
assertThat(checker.isHealthy()).isTrue();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"api", "writer", "combined"})
|
||||
void recoveryProvisionsThePersistenceRouteWithoutCreatingLogQueues(String profile) throws IOException {
|
||||
initialize(configuration(profile));
|
||||
expectTopology(null, false);
|
||||
checker.runHealthCheck();
|
||||
assertThat(checker.isHealthy()).isFalse();
|
||||
server.verify();
|
||||
server.reset();
|
||||
|
||||
server.expect(requestTo(MANAGEMENT_URL + "queues/%2F/annotation.accumulo")).andExpect(method(HttpMethod.PUT))
|
||||
.andExpect(content().json(mapper.writeValueAsString(Map.of("durable", true, "auto_delete", false, "arguments",
|
||||
Map.of("x-dead-letter-exchange", "DLX", "x-dead-letter-routing-key", "annotation.accumulo"))), false))
|
||||
.andRespond(withSuccess());
|
||||
server.expect(requestTo(MANAGEMENT_URL + "queues/%2F/annotation.accumulo.dlq")).andExpect(method(HttpMethod.PUT))
|
||||
.andExpect(content().json("{\"durable\":true,\"auto_delete\":false}", false)).andRespond(withSuccess());
|
||||
server.expect(requestTo(MANAGEMENT_URL + "bindings/%2F/e/annotation/q/annotation.accumulo")).andExpect(method(HttpMethod.POST))
|
||||
.andExpect(content().json("{\"routing_key\":\"#\"}", false)).andRespond(withSuccess());
|
||||
server.expect(requestTo(MANAGEMENT_URL + "bindings/%2F/e/DLX/q/annotation.accumulo.dlq")).andExpect(method(HttpMethod.POST))
|
||||
.andExpect(content().json("{\"routing_key\":\"annotation.accumulo\"}", false)).andRespond(withSuccess());
|
||||
|
||||
checker.recover();
|
||||
server.verify();
|
||||
server.reset();
|
||||
expectTopology("accumulo", true);
|
||||
checker.runHealthCheck();
|
||||
assertThat(checker.isHealthy()).isTrue();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queueWithoutItsPersistenceBindingIsUnhealthy() throws IOException {
|
||||
initialize(configuration("api"));
|
||||
expectTopology("accumulo", false);
|
||||
|
||||
checker.runHealthCheck();
|
||||
|
||||
assertThat(checker.isHealthy()).isFalse();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"log", "dump"})
|
||||
void deploymentTopologyOverrideChecksTheSelectedBackend(String backend) throws IOException {
|
||||
StandardEnvironment environment = configuration("writer");
|
||||
// A deployment profile supplies complete lists, including its own DLQ route.
|
||||
Map<String,Object> deployment = Map.of("annotation",
|
||||
Map.of("writers", Map.of("accumulo", Map.of("enabled", false), backend, Map.of("enabled", true)), "health",
|
||||
Map.of("rabbit", Map.of(
|
||||
"queues", List.of(
|
||||
Map.of("name", "annotation." + backend, "durable", true, "exclusive", false,
|
||||
"autoDelete", false, "arguments",
|
||||
Map.of("x-dead-letter-exchange", "DLX", "x-dead-letter-routing-key",
|
||||
"annotation." + backend)),
|
||||
Map.of("name", "annotation." + backend + ".dlq", "durable", true, "exclusive", false,
|
||||
"autoDelete", false)),
|
||||
"bindings",
|
||||
List.of(Map.of("source", "annotation", "destination", "annotation." + backend, "destinationType",
|
||||
"queue", "routingKey", "#"),
|
||||
Map.of("source", "DLX", "destination", "annotation." + backend + ".dlq",
|
||||
"destinationType", "queue", "routingKey", "annotation." + backend))))));
|
||||
environment.getPropertySources().addFirst(
|
||||
new YamlPropertySourceLoader().load("deployment-backend", new ByteArrayResource(mapper.writeValueAsBytes(deployment))).get(0));
|
||||
assertThat(environment.getProperty("annotation.writers." + backend + ".enabled", Boolean.class)).isTrue();
|
||||
initialize(environment);
|
||||
|
||||
expectTopology("accumulo", true);
|
||||
checker.runHealthCheck();
|
||||
assertThat(checker.isHealthy()).isFalse();
|
||||
server.verify();
|
||||
server.reset();
|
||||
|
||||
expectTopology(backend, true);
|
||||
checker.runHealthCheck();
|
||||
assertThat(checker.isHealthy()).isTrue();
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private void initialize(StandardEnvironment environment) {
|
||||
RabbitHealthProperties properties = Binder.get(environment).bind("annotation.health.rabbit", Bindable.of(RabbitHealthProperties.class)).get();
|
||||
checker = new RabbitHealthChecker(properties, "localhost", "guest", "guest");
|
||||
RestTemplate restTemplate = (RestTemplate) new DirectFieldAccessor(new DirectFieldAccessor(checker).getPropertyValue("rabbitClient"))
|
||||
.getPropertyValue("rt");
|
||||
server = MockRestServiceServer.createServer(restTemplate);
|
||||
}
|
||||
|
||||
private void expectTopology(String group, boolean bindingsPresent) throws IOException {
|
||||
expect("nodes/", List.of(Map.of("name", "rabbit@localhost", "running", true, "type", "disc")));
|
||||
expect("exchanges/", List.of(exchange("annotation", "topic"), exchange("DLX", "direct")));
|
||||
expect("queues/", group == null ? List.of() : List.of(queue("annotation." + group, true), queue("annotation." + group + ".dlq", false)));
|
||||
expect("bindings/", !bindingsPresent ? List.of()
|
||||
: List.of(binding("annotation", "annotation." + group, "#"), binding("DLX", "annotation." + group + ".dlq", "annotation." + group)));
|
||||
}
|
||||
|
||||
private void expect(String endpoint, Object response) throws IOException {
|
||||
server.expect(requestTo(MANAGEMENT_URL + endpoint)).andRespond(withSuccess(mapper.writeValueAsString(response), MediaType.APPLICATION_JSON));
|
||||
}
|
||||
|
||||
private static Map<String,Object> exchange(String name, String type) {
|
||||
return Map.of("name", name, "type", type, "durable", true, "auto_delete", false, "internal", false);
|
||||
}
|
||||
|
||||
private static Map<String,Object> queue(String name, boolean deadLettering) {
|
||||
return Map.of("name", name, "durable", true, "exclusive", false, "auto_delete", false, "consumers", 0, "arguments",
|
||||
deadLettering ? Map.of("x-dead-letter-exchange", "DLX", "x-dead-letter-routing-key", name) : Map.of());
|
||||
}
|
||||
|
||||
private static Map<String,Object> binding(String source, String destination, String routingKey) {
|
||||
return Map.of("source", source, "destination", destination, "destination_type", "queue", "routing_key", routingKey, "arguments", Map.of());
|
||||
}
|
||||
|
||||
private static StandardEnvironment configuration(String profile) throws IOException {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
Path config = Path.of(System.getProperty("basedir"), "src/main/config");
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
environment.getPropertySources().addFirst(loader.load("shared", new FileSystemResource(config.resolve("annotation.yml"))).get(0));
|
||||
environment.setActiveProfiles(profile);
|
||||
environment.getPropertySources().addFirst(loader.load(profile, new FileSystemResource(config.resolve("annotation-" + profile + ".yml"))).get(0));
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ import datawave.microservice.annotation.health.rabbit.config.RabbitHealthPropert
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ContextConfiguration(classes = RabbitHealthCheckerTest.RabbitHealthCheckerTestConfiguration.class)
|
||||
@ActiveProfiles({"RabbitHealthTest", "rabbit-config"})
|
||||
@ActiveProfiles({"combined", "RabbitHealthTest", "rabbit-config"})
|
||||
public class RabbitHealthCheckerTest {
|
||||
|
||||
private static final String NODES_URL = "http://localhost:15672/api/nodes/";
|
||||
|
||||
+2
-2
@@ -42,8 +42,8 @@ class AnnotationControllerV1SecurityTest {
|
||||
@Bean
|
||||
AnnotationControllerV1 annotationController() {
|
||||
return new AnnotationControllerV1(mock(AccumuloConnectionFactory.class), mock(LookupService.class), new AnnotationProperties(),
|
||||
new DefaultTimestampTransformer(), new DefaultVisibilityTransformer(), new AnnotationSupplier(), mock(ExecutorService.class),
|
||||
new AnnotationAckTracker());
|
||||
new DefaultTimestampTransformer(), new DefaultVisibilityTransformer(),
|
||||
new AnnotationPublisher(new AnnotationSupplier(), new AnnotationProperties(), null), mock(ExecutorService.class));
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package datawave.microservice.annotation.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.common.config.AnnotationSerializerConfiguration;
|
||||
import datawave.microservice.annotation.service.config.AnnotationServiceConfig;
|
||||
import datawave.microservice.annotation.util.lookup.service.LookupService;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
class AnnotationPublisherSecurityTest {
|
||||
@Test
|
||||
void brokerConfirmationCompletesWithoutRestAuthentication() {
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(MethodSecurity.class, AnnotationControllerV1.class, AnnotationServiceConfig.class,
|
||||
AnnotationSerializerConfiguration.class)
|
||||
.withPropertyValues("spring.profiles.active=combined", "annotation.annotation-ack-timeout-millis=5000")
|
||||
.withBean(AccumuloConnectionFactory.class, () -> mock(AccumuloConnectionFactory.class))
|
||||
.withBean(LookupService.class, () -> mock(LookupService.class)).run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(AopUtils.isAopProxy(context.getBean(AnnotationControllerV1.class))).isTrue();
|
||||
ExecutorService brokerThread = Executors.newSingleThreadExecutor();
|
||||
Disposable subscription = context.getBean(AnnotationSupplier.class).get().subscribe(sent -> {
|
||||
brokerThread.submit(() -> {
|
||||
SecurityContextHolder.clearContext();
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
sent.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, CorrelationData.class).getFuture()
|
||||
.set(new CorrelationData.Confirm(true, null));
|
||||
});
|
||||
});
|
||||
try {
|
||||
SecurityContextHolder.clearContext();
|
||||
AnnotationPublisher publisher = context.getBean(AnnotationPublisher.class);
|
||||
assertThat(publisher.publishAndConfirm(AnnotationMessage.newBuilder().setAnnotationMessageId("broker-confirm").build()))
|
||||
.isPresent();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
} finally {
|
||||
subscription.dispose();
|
||||
brokerThread.shutdownNow();
|
||||
assertThat(brokerThread.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(securedEnabled = true)
|
||||
static class MethodSecurity {}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package datawave.microservice.annotation.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.core.ReturnedMessage;
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.service.config.AnnotationProperties;
|
||||
|
||||
class AnnotationPublisherTest {
|
||||
private final AnnotationSupplier source = mock(AnnotationSupplier.class);
|
||||
private final AnnotationProperties properties = new AnnotationProperties();
|
||||
private final AnnotationPublisher publisher = new AnnotationPublisher(source, properties, null);
|
||||
private final AnnotationMessage message = AnnotationMessage.newBuilder().setAnnotationMessageId("logical-message").build();
|
||||
|
||||
@BeforeEach
|
||||
void configureTimeout() {
|
||||
properties.setAnnotationAckTimeoutMillis(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void positiveConfirmSucceeds() {
|
||||
when(source.send(any())).thenAnswer(invocation -> {
|
||||
correlation(invocation.getArgument(0)).getFuture().set(new CorrelationData.Confirm(true, null));
|
||||
return true;
|
||||
});
|
||||
assertThat(publisher.publishAndConfirm(message)).contains(message);
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nackFails() {
|
||||
when(source.send(any())).thenAnswer(invocation -> {
|
||||
correlation(invocation.getArgument(0)).getFuture().set(new CorrelationData.Confirm(false, "broker rejection"));
|
||||
return true;
|
||||
});
|
||||
assertThat(publisher.publishAndConfirm(message)).isEmpty();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unroutableReturnFailsEvenWithPositiveConfirm() {
|
||||
when(source.send(any())).thenAnswer(invocation -> {
|
||||
CorrelationData correlation = correlation(invocation.getArgument(0));
|
||||
correlation.setReturned(new ReturnedMessage(new org.springframework.amqp.core.Message(new byte[0], new MessageProperties()), 312, "NO_ROUTE",
|
||||
"annotation", "annotation"));
|
||||
correlation.getFuture().set(new CorrelationData.Confirm(true, null));
|
||||
return true;
|
||||
});
|
||||
assertThat(publisher.publishAndConfirm(message)).isEmpty();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedConfirmationFutureFails() {
|
||||
when(source.send(any())).thenAnswer(invocation -> {
|
||||
correlation(invocation.getArgument(0)).getFuture().setException(new IllegalStateException("connection closed"));
|
||||
return true;
|
||||
});
|
||||
assertThat(publisher.publishAndConfirm(message)).isEmpty();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localSendExceptionFailsAndCleansUp() {
|
||||
when(source.send(any())).thenThrow(new IllegalStateException("sink closed"));
|
||||
assertThat(publisher.publishAndConfirm(message)).isEmpty();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryUsesIndependentConfirmationAndIgnoresLateAck() throws Exception {
|
||||
AtomicReference<CorrelationData> previous = new AtomicReference<>();
|
||||
when(source.send(any())).thenAnswer(invocation -> {
|
||||
previous.set(correlation(invocation.getArgument(0)));
|
||||
return true;
|
||||
});
|
||||
assertThat(publisher.publishAndConfirm(message)).isEmpty();
|
||||
CorrelationData firstAttempt = previous.get();
|
||||
|
||||
properties.setAnnotationAckTimeoutMillis(5000);
|
||||
CountDownLatch retryPublished = new CountDownLatch(1);
|
||||
AtomicReference<CorrelationData> retryCorrelation = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
retryCorrelation.set(correlation(invocation.getArgument(0)));
|
||||
retryPublished.countDown();
|
||||
return true;
|
||||
}).when(source).send(any());
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<Optional<AnnotationMessage>> retry = executor.submit(() -> publisher.publishAndConfirm(message));
|
||||
assertThat(retryPublished.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(retryCorrelation.get().getId()).isNotEqualTo(firstAttempt.getId());
|
||||
firstAttempt.getFuture().set(new CorrelationData.Confirm(true, null));
|
||||
assertThat(retryCorrelation.get().getFuture().isDone()).isFalse();
|
||||
assertThat(retry.isDone()).isFalse();
|
||||
retryCorrelation.get().getFuture().set(new CorrelationData.Confirm(false, "retry rejected"));
|
||||
assertThat(retry.get(5, TimeUnit.SECONDS)).isEmpty();
|
||||
assertThat(publisher.isEmpty()).isTrue();
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static CorrelationData correlation(Message<?> message) {
|
||||
CorrelationData correlation = message.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, CorrelationData.class);
|
||||
assertThat(correlation).isNotNull();
|
||||
return correlation;
|
||||
}
|
||||
}
|
||||
+22
-20
@@ -7,18 +7,18 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.integration.IntegrationMessageHeaderAccessor;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
@@ -36,22 +36,22 @@ import datawave.microservice.annotation.util.lookup.service.LookupService;
|
||||
import datawave.microservice.annotation.writers.accumulo.config.AccumuloAnnotationWriterConfig;
|
||||
import datawave.microservice.annotation.writers.accumulo.config.AccumuloAnnotationWriterProperties;
|
||||
import datawave.microservice.annotation.writers.file.config.FileAnnotationWriterConfig;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
/**
|
||||
* Boots the annotation service using the same writer topology shipped in {@code annotation.yml} for deployment (Accumulo and file writers enabled, log and dump
|
||||
* writers disabled), with only external network dependencies (Accumulo, remote lookup) replaced by lightweight in-memory/mocked implementations. This proves
|
||||
* that the production-shape configuration and writer topology assembled across T05-T09 actually resolves into a working Spring context - all expected
|
||||
* writer/sink beans are present with no ambiguous or missing bindings - and that one authorized write reaches its defined acceptance boundary (Q01: Rabbit
|
||||
* producer-confirmed ingress, not committed Accumulo persistence). The producer-confirm acknowledgement is simulated by subscribing directly to the real,
|
||||
* fully-wired {@link AnnotationSupplier} bean's outbound {@code Flux} and immediately acking each message, exactly mirroring what the real Rabbit binder/broker
|
||||
* does via the {@code annotationAckChannel} in production, without requiring a live broker in this test.
|
||||
* Starts a Spring context for the combined role with Accumulo writing and file fallback enabled, and log and dump writers disabled. Checks that the expected
|
||||
* controller, publisher, writer, and consumer beans are present.
|
||||
*
|
||||
* Uses in-memory Accumulo and a mocked lookup service. Calls the controller directly to submit an annotation, then simulates RabbitMQ accepting the publication
|
||||
* by completing its confirmation future. Checks that the write succeeds after that confirmation.
|
||||
*
|
||||
* This test does not use a live RabbitMQ broker or verify that a queue consumer saves the annotation to Accumulo.
|
||||
*/
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = {AnnotationServiceConfig.class, AnnotationSerializerConfiguration.class, AccumuloAnnotationWriterConfig.class,
|
||||
FileAnnotationWriterConfig.class, AnnotationAckTracker.class, AnnotationControllerV1.class,
|
||||
AnnotationServiceStartupTest.AnnotationServiceStartupTestConfiguration.class})
|
||||
FileAnnotationWriterConfig.class, AnnotationControllerV1.class, AnnotationServiceStartupTest.AnnotationServiceStartupTestConfiguration.class})
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"AnnotationServiceStartupTest", "accumulo-enabled", "file-enabled"})
|
||||
@ActiveProfiles({"combined", "AnnotationServiceStartupTest", "accumulo-enabled", "file-enabled"})
|
||||
public class AnnotationServiceStartupTest {
|
||||
|
||||
@Autowired
|
||||
@@ -63,15 +63,17 @@ public class AnnotationServiceStartupTest {
|
||||
@Autowired
|
||||
private AnnotationSupplier annotationSource;
|
||||
|
||||
private Disposable brokerSubscription;
|
||||
|
||||
@BeforeEach
|
||||
void simulateHealthyBrokerProducerConfirms() {
|
||||
// Every message the controller hands to the real annotationSource bean is immediately "confirmed", just as a
|
||||
// healthy Rabbit broker would confirm receipt via the annotationAckChannel in production.
|
||||
annotationSource.get().subscribe(sent -> {
|
||||
Object correlationId = sent.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
Message<String> ack = MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build();
|
||||
annotationController.processConfirmAck(ack);
|
||||
});
|
||||
brokerSubscription = annotationSource.get().subscribe(sent -> sent.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, CorrelationData.class)
|
||||
.getFuture().set(new CorrelationData.Confirm(true, null)));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void disconnectBroker() {
|
||||
brokerSubscription.dispose();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package datawave.microservice.annotation.service;
|
||||
|
||||
import static datawave.annotation.test.v1.AnnotationAssertions.assertAnnotationSourcesEqual;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.accumulo.core.client.AccumuloClient;
|
||||
import org.apache.accumulo.core.security.Authorizations;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import datawave.accumulo.inmemory.InMemoryAccumuloClient;
|
||||
import datawave.accumulo.inmemory.InMemoryInstance;
|
||||
import datawave.annotation.data.v1.AnnotationDataAccess;
|
||||
import datawave.annotation.protobuf.v1.Annotation;
|
||||
import datawave.annotation.protobuf.v1.AnnotationMessage;
|
||||
import datawave.annotation.protobuf.v1.AnnotationSource;
|
||||
import datawave.annotation.test.v1.AnnotationTestDataUtil;
|
||||
import datawave.core.common.connection.AccumuloConnectionFactory;
|
||||
import datawave.microservice.annotation.common.AnnotationConsumer;
|
||||
import datawave.microservice.annotation.common.AnnotationSupplier;
|
||||
import datawave.microservice.annotation.common.config.AnnotationSerializerConfiguration;
|
||||
import datawave.microservice.annotation.config.AnnotationRoleConfiguration;
|
||||
import datawave.microservice.annotation.service.config.AnnotationServiceConfig;
|
||||
import datawave.microservice.annotation.util.lookup.service.LookupService;
|
||||
import datawave.microservice.annotation.writers.accumulo.AccumuloAnnotationWriter;
|
||||
import datawave.microservice.annotation.writers.accumulo.config.AccumuloAnnotationWriterConfig;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
/**
|
||||
* Uses independent API and writer contexts with simulated broker confirms and queued protobuf transport. Persistence is deliberately deferred until after API
|
||||
* acceptance, so this test also guards the asynchronous acceptance boundary. Live RabbitMQ routing and competing consumers require an integration environment.
|
||||
*/
|
||||
class AnnotationSplitDeliveryTest {
|
||||
@Test
|
||||
void confirmedApiWriteIsConsumedAndPersistedBySeparateWriterContext() throws Exception {
|
||||
AccumuloClient client = new InMemoryAccumuloClient("writer", new InMemoryInstance("annotation-split-" + UUID.randomUUID()));
|
||||
client.securityOperations().changeUserAuthorizations("writer", new Authorizations("PUBLIC", "PRIVATE"));
|
||||
AccumuloConnectionFactory writerFactory = Mockito.mock(AccumuloConnectionFactory.class);
|
||||
Mockito.when(writerFactory.getClient(anyString(), anyCollection(), anyString(), any(), anyMap())).thenReturn(client);
|
||||
|
||||
ApplicationContextRunner writer = new ApplicationContextRunner()
|
||||
.withUserConfiguration(AnnotationRoleConfiguration.class, AnnotationSerializerConfiguration.class, AccumuloAnnotationWriterConfig.class,
|
||||
AnnotationServiceConfig.class, AnnotationControllerV1.class)
|
||||
.withBean(AccumuloConnectionFactory.class, () -> writerFactory).withPropertyValues("spring.profiles.active=writer",
|
||||
"annotation.deployment.overlays.writer=true", "annotation.writers.accumulo.enabled=true",
|
||||
"annotation.writers.accumulo.health.enabled=true", "spring.cloud.bus.enabled=false");
|
||||
ApplicationContextRunner api = new ApplicationContextRunner()
|
||||
.withUserConfiguration(AnnotationRoleConfiguration.class, AnnotationSerializerConfiguration.class, AnnotationServiceConfig.class,
|
||||
AnnotationControllerV1.class, AccumuloAnnotationWriterConfig.class)
|
||||
.withBean(AccumuloConnectionFactory.class, () -> Mockito.mock(AccumuloConnectionFactory.class))
|
||||
.withBean(LookupService.class, () -> Mockito.mock(LookupService.class)).withPropertyValues("spring.profiles.active=api",
|
||||
"annotation.deployment.overlays.api=true", "annotation.writers.accumulo.enabled=true",
|
||||
"annotation.annotation-ack-timeout-millis=2000", "annotation.retry.max-attempts=1", "spring.cloud.bus.enabled=false");
|
||||
|
||||
try {
|
||||
writer.run(writerContext -> {
|
||||
assertThat(writerContext).hasNotFailed().hasBean("accumuloAnnotationSink").hasBean("accumuloHealthChecker")
|
||||
.doesNotHaveBean(AnnotationControllerV1.class).doesNotHaveBean("annotationSource");
|
||||
AnnotationConsumer consumer = writerContext.getBean("accumuloAnnotationSink", AnnotationConsumer.class);
|
||||
AnnotationDataAccess stored = writerContext.getBean(AccumuloAnnotationWriter.class).getDataAccess();
|
||||
|
||||
api.run(apiContext -> {
|
||||
assertThat(apiContext).hasNotFailed().hasSingleBean(AnnotationControllerV1.class).doesNotHaveBean("accumuloAnnotationWriter")
|
||||
.doesNotHaveBean("accumuloAnnotationSink");
|
||||
AnnotationControllerV1 controller = apiContext.getBean(AnnotationControllerV1.class);
|
||||
BlockingQueue<Message<AnnotationMessage>> brokerQueue = new LinkedBlockingQueue<>();
|
||||
Disposable producer = apiContext.getBean(AnnotationSupplier.class).get().subscribe(message -> {
|
||||
brokerQueue.add(message);
|
||||
message.getHeaders()
|
||||
.get(org.springframework.amqp.support.AmqpHeaders.PUBLISH_CONFIRM_CORRELATION,
|
||||
org.springframework.amqp.rabbit.connection.CorrelationData.class)
|
||||
.getFuture().set(new org.springframework.amqp.rabbit.connection.CorrelationData.Confirm(true, null));
|
||||
});
|
||||
try {
|
||||
Optional<Annotation> accepted = controller.writeAnnotation(AnnotationTestDataUtil.generateTestAnnotation());
|
||||
assertThat(accepted).isPresent();
|
||||
Annotation expected = accepted.orElseThrow();
|
||||
assertThat(apiContext.getBean(AnnotationPublisher.class).isEmpty()).isTrue();
|
||||
assertThat(stored.getAnnotation(expected.getShard(), expected.getDataType(), expected.getUid(), expected.getAnnotationId())).isEmpty();
|
||||
|
||||
Message<AnnotationMessage> queued = brokerQueue.poll(2, TimeUnit.SECONDS);
|
||||
assertThat(queued).isNotNull();
|
||||
consumer.accept(AnnotationMessage.parseFrom(queued.getPayload().toByteArray()));
|
||||
|
||||
Optional<Annotation> persisted = stored.getAnnotation(expected.getShard(), expected.getDataType(), expected.getUid(),
|
||||
expected.getAnnotationId());
|
||||
assertThat(persisted).isPresent();
|
||||
assertThat(persisted.orElseThrow().getSegmentsList()).isEqualTo(expected.getSegmentsList());
|
||||
Optional<AnnotationSource> source = stored.getAnnotationSource(expected.getAnalyticSourceHash());
|
||||
assertThat(source).isPresent();
|
||||
assertAnnotationSourcesEqual(expected.getSource(), source.orElseThrow());
|
||||
Mockito.verifyNoInteractions(apiContext.getBean(AccumuloConnectionFactory.class));
|
||||
} finally {
|
||||
producer.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Mockito.verify(writerFactory).returnClient(client);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -186,7 +186,7 @@ public class TestAnnotationControllerV1 {
|
||||
|
||||
ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
annotationController = new AnnotationControllerV1(connectionFactory, lookupService, annotationProperties, timestampTransformer, visibilityTransformer,
|
||||
annotationSink, executorService, new AnnotationAckTracker());
|
||||
new AnnotationPublisher(annotationSink, annotationProperties, null), executorService);
|
||||
lenient().when(connectionFactory.getClient(any(), any(), any(), any(), any())).thenReturn(client);
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ public class TestAnnotationControllerV1 {
|
||||
private void configureImmediateAck() {
|
||||
when(annotationSink.send(any())).thenAnswer(invocation -> {
|
||||
org.springframework.messaging.Message<?> sent = invocation.getArgument(0);
|
||||
Object correlationId = sent.getHeaders().get(org.springframework.integration.IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
org.springframework.messaging.Message<String> ack = org.springframework.integration.support.MessageBuilder.withPayload("ack")
|
||||
.setCorrelationId(correlationId).build();
|
||||
annotationController.processConfirmAck(ack);
|
||||
org.springframework.amqp.rabbit.connection.CorrelationData correlation = sent.getHeaders().get(
|
||||
org.springframework.amqp.support.AmqpHeaders.PUBLISH_CONFIRM_CORRELATION,
|
||||
org.springframework.amqp.rabbit.connection.CorrelationData.class);
|
||||
assertNotNull(correlation);
|
||||
correlation.getFuture().set(new org.springframework.amqp.rabbit.connection.CorrelationData.Confirm(true, null));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
+74
-115
@@ -7,9 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -33,8 +31,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||
import org.springframework.amqp.support.AmqpHeaders;
|
||||
import org.springframework.integration.IntegrationMessageHeaderAccessor;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import datawave.annotation.data.transform.DefaultTimestampTransformer;
|
||||
@@ -54,8 +53,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Focused unit tests for the message delivery path in {@link AnnotationControllerV1}: {@code writeAnnotation}, {@code sendAnnotation}, and
|
||||
* {@code sendAnnotationMessage}. These methods are package-private (visible for testing), so they are called directly here. The durable-write acknowledgement
|
||||
* protocol is simulated by invoking {@link AnnotationControllerV1#processConfirmAck(Message)} directly rather than standing up a real message broker.
|
||||
* {@code sendAnnotationMessage}. These methods are package-private (visible for testing), so they are called directly here. The broker-confirmation protocol is
|
||||
* simulated by completing the broker confirmation future rather than standing up a real message broker.
|
||||
*/
|
||||
@SuppressWarnings("SpellCheckingInspection")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -90,10 +89,8 @@ public class TestAnnotationControllerV1Delivery {
|
||||
annotationProperties.getRetry().setFailTimeoutMillis(TimeUnit.SECONDS.toMillis(10));
|
||||
|
||||
annotationController = new AnnotationControllerV1(connectionFactory, lookupService, annotationProperties, timestampTransformer, visibilityTransformer,
|
||||
annotationSink, Executors.newCachedThreadPool(), new AnnotationAckTracker());
|
||||
new AnnotationPublisher(annotationSink, annotationProperties, null), Executors.newCachedThreadPool());
|
||||
|
||||
// ensure no latches leak between tests
|
||||
getAnnotationAckTracker().clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,18 +104,17 @@ public class TestAnnotationControllerV1Delivery {
|
||||
Message<AnnotationMessage> sent = invocation.getArgument(0);
|
||||
Object correlationId = sent.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
Message<String> ack = MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build();
|
||||
annotationController.processConfirmAck(ack);
|
||||
confirm(sent);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Use reflection to access the AnnotationAckTracker and assert that no latches leak between test invocations.
|
||||
private AnnotationAckTracker getAnnotationAckTracker() {
|
||||
// Inspect the publisher to verify completed publications do not retain in-flight entries.
|
||||
private AnnotationPublisher getAnnotationPublisher() {
|
||||
try {
|
||||
Field field = AnnotationControllerV1.class.getDeclaredField("annotationAckTracker");
|
||||
Field field = AnnotationControllerV1.class.getDeclaredField("annotationPublisher");
|
||||
field.setAccessible(true);
|
||||
return (AnnotationAckTracker) field.get(annotationController);
|
||||
return (AnnotationPublisher) field.get(annotationController);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -135,15 +131,15 @@ public class TestAnnotationControllerV1Delivery {
|
||||
}
|
||||
}
|
||||
|
||||
// healthChecker is normally populated via @Autowired(required = false); inject a mock directly with reflection
|
||||
private void setHealthChecker(HealthChecker checker) {
|
||||
try {
|
||||
Field field = AnnotationControllerV1.class.getDeclaredField("healthChecker");
|
||||
field.setAccessible(true);
|
||||
field.set(annotationController, checker);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
annotationController = new AnnotationControllerV1(connectionFactory, lookupService, annotationProperties, timestampTransformer, visibilityTransformer,
|
||||
new AnnotationPublisher(annotationSink, annotationProperties, checker), mock(ExecutorService.class));
|
||||
}
|
||||
|
||||
private static void confirm(Message<?> sent) {
|
||||
CorrelationData correlation = sent.getHeaders().get(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, CorrelationData.class);
|
||||
assertNotNull(correlation);
|
||||
correlation.getFuture().set(new CorrelationData.Confirm(true, null));
|
||||
}
|
||||
|
||||
private static AnnotationMessage buildMessage(Annotation annotation) {
|
||||
@@ -191,7 +187,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
Message<AnnotationMessage> sent = invocation.getArgument(0);
|
||||
Object correlationId = sent.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
annotationController.processConfirmAck(MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build());
|
||||
confirm(sent);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -212,82 +208,46 @@ public class TestAnnotationControllerV1Delivery {
|
||||
assertFalse(idTwo.isBlank());
|
||||
assertNotEquals(idOne, idTwo, "distinct annotation messages must not share a correlation id");
|
||||
|
||||
// and the map should be clean afterward -- no leaked latches
|
||||
assertTrue(getAnnotationAckTracker().isEmpty());
|
||||
// and the map should be clean afterward -- no leaked in-flight entries
|
||||
assertTrue(getAnnotationPublisher().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendAnnotationMessage_DuplicateConcurrentSendsForSameMessageIdShareLatchAndBothSucceed() throws Exception {
|
||||
// Two callers attempt to send the exact same (pre-assigned) annotationMessageId concurrently, simulating a
|
||||
// client retry racing with the original in-flight send. Only one ack ever arrives for that correlation id.
|
||||
// Both callers must be satisfied by that single ack -- the second call must not silently overwrite/orphan
|
||||
// the first caller's latch, which would otherwise leave the first caller waiting out the full ack timeout
|
||||
// and incorrectly reporting failure.
|
||||
//
|
||||
// This is made deterministic (rather than depending on thread-scheduling timing, which previously made this
|
||||
// test intermittently flaky: if the original sender's entire send-ack-remove cycle completed before the
|
||||
// "duplicate" caller's putIfAbsent even ran, the duplicate would legitimately see no in-flight latch and
|
||||
// become a second original sender). A spy around AnnotationAckTracker signals exactly when the duplicate
|
||||
// caller has observed the original sender's in-flight latch, and the mocked send(...) is held open until
|
||||
// that has been confirmed before delivering the single ack.
|
||||
AnnotationMessage annotationMessage = buildMessage(AnnotationUtils.injectAllHashes(generateTestAnnotation())).toBuilder()
|
||||
.setAnnotationMessageId("DUPLICATE-ID").build();
|
||||
|
||||
AnnotationAckTracker spyTracker = spy(new AnnotationAckTracker());
|
||||
CountDownLatch duplicateObservedExistingLatch = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
CountDownLatch existing = (CountDownLatch) invocation.callRealMethod();
|
||||
if (existing != null) {
|
||||
// this call found an already-registered latch, i.e. it is the "duplicate" caller
|
||||
duplicateObservedExistingLatch.countDown();
|
||||
}
|
||||
return existing;
|
||||
}).when(spyTracker).putIfAbsent(any(), any());
|
||||
|
||||
AnnotationControllerV1 controller = new AnnotationControllerV1(connectionFactory, lookupService, annotationProperties, timestampTransformer,
|
||||
visibilityTransformer, annotationSink, Executors.newCachedThreadPool(), spyTracker);
|
||||
|
||||
AtomicInteger sendCount = new AtomicInteger(0);
|
||||
CountDownLatch originalSenderReachedSend = new CountDownLatch(1);
|
||||
CountDownLatch releaseAck = new CountDownLatch(1);
|
||||
AnnotationMessage message = buildMessage(AnnotationUtils.injectAllHashes(generateTestAnnotation())).toBuilder().setAnnotationMessageId("DUPLICATE-ID")
|
||||
.build();
|
||||
annotationProperties.setAnnotationAckTimeoutMillis(10000);
|
||||
CountDownLatch firstSend = new CountDownLatch(1);
|
||||
CountDownLatch releaseConfirm = new CountDownLatch(1);
|
||||
AtomicReference<Thread> duplicateThread = new AtomicReference<>();
|
||||
when(annotationSink.send(any())).thenAnswer(invocation -> {
|
||||
// only the original sender should ever reach annotationSink.send(...); a duplicate should observe the
|
||||
// existing latch and skip sending entirely.
|
||||
sendCount.incrementAndGet();
|
||||
Message<AnnotationMessage> sent = invocation.getArgument(0);
|
||||
Object correlationId = sent.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
|
||||
// Let the test know the original sender's latch is registered and it has reached send(...), then hold
|
||||
// the ack back until the test confirms the duplicate caller has observed that latch -- this removes any
|
||||
// dependency on thread-scheduling timing for reliably reproducing the race being tested.
|
||||
originalSenderReachedSend.countDown();
|
||||
assertTrue(releaseAck.await(10, TimeUnit.SECONDS), "test did not release the ack in time");
|
||||
|
||||
controller.processConfirmAck(MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build());
|
||||
firstSend.countDown();
|
||||
assertTrue(releaseConfirm.await(10, TimeUnit.SECONDS));
|
||||
confirm(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Optional<AnnotationMessage>> futureOne = executor.submit(() -> controller.sendAnnotationMessage(annotationMessage));
|
||||
|
||||
assertTrue(originalSenderReachedSend.await(10, TimeUnit.SECONDS), "original sender did not reach send(...) in time");
|
||||
|
||||
Future<Optional<AnnotationMessage>> futureTwo = executor.submit(() -> controller.sendAnnotationMessage(annotationMessage));
|
||||
|
||||
assertTrue(duplicateObservedExistingLatch.await(10, TimeUnit.SECONDS),
|
||||
"duplicate caller did not observe the original sender's in-flight latch in time");
|
||||
releaseAck.countDown();
|
||||
|
||||
Optional<AnnotationMessage> resultOne = futureOne.get(10, TimeUnit.SECONDS);
|
||||
Optional<AnnotationMessage> resultTwo = futureTwo.get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertTrue(resultOne.isPresent(), "the original sender must be satisfied by the single ack");
|
||||
assertTrue(resultTwo.isPresent(), "the duplicate sender must also be satisfied by the same ack rather than timing out");
|
||||
assertEquals(1, sendCount.get(), "only the original sender should have actually sent a message; the duplicate should reuse its latch");
|
||||
assertTrue(spyTracker.isEmpty(), "no latch should remain once both duplicate callers complete");
|
||||
Future<Optional<AnnotationMessage>> first = executor.submit(() -> annotationController.sendAnnotationMessage(message));
|
||||
assertTrue(firstSend.await(5, TimeUnit.SECONDS));
|
||||
Future<Optional<AnnotationMessage>> second = executor.submit(() -> {
|
||||
duplicateThread.set(Thread.currentThread());
|
||||
return annotationController.sendAnnotationMessage(message);
|
||||
});
|
||||
// The only timed wait in the duplicate task is the shared confirmation future.
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while ((duplicateThread.get() == null || duplicateThread.get().getState() != Thread.State.TIMED_WAITING) && System.nanoTime() < deadline) {
|
||||
Thread.sleep(1);
|
||||
}
|
||||
assertNotNull(duplicateThread.get());
|
||||
assertEquals(Thread.State.TIMED_WAITING, duplicateThread.get().getState());
|
||||
releaseConfirm.countDown();
|
||||
assertTrue(first.get(5, TimeUnit.SECONDS).isPresent());
|
||||
assertTrue(second.get(5, TimeUnit.SECONDS).isPresent());
|
||||
verify(annotationSink, times(1)).send(any());
|
||||
assertTrue(getAnnotationPublisher().isEmpty());
|
||||
} finally {
|
||||
releaseConfirm.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -304,11 +264,11 @@ public class TestAnnotationControllerV1Delivery {
|
||||
assertTrue(result.isEmpty());
|
||||
// failed sends should short-circuit rather than waiting out the ack timeout
|
||||
assertTrue(elapsed < annotationProperties.getAnnotationAckTimeoutMillis(), "send failure should not wait for the ack timeout");
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "latch should be cleaned up even when send fails");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "in-flight entry should be cleaned up even when send fails");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendAnnotationMessage_AckTimeoutReturnsEmptyAndCleansUpLatch() {
|
||||
public void testSendAnnotationMessage_AckTimeoutReturnsEmptyAndCleansUpPublication() {
|
||||
// send succeeds, but no acknowledgement ever arrives
|
||||
when(annotationSink.send(any())).thenReturn(true);
|
||||
Annotation annotation = AnnotationUtils.injectAllHashes(generateTestAnnotation());
|
||||
@@ -319,12 +279,12 @@ public class TestAnnotationControllerV1Delivery {
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
assertTrue(elapsed >= annotationProperties.getAnnotationAckTimeoutMillis(), "should have waited for the full ack timeout");
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "latch should be removed after timeout");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "in-flight entry should be removed after timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendAnnotationMessage_InterruptedWhileAwaitingAckReturnsEmptyRestoresInterruptStatusAndCleansUpLatch() throws Exception {
|
||||
// send succeeds, but no ack ever arrives -- the calling thread is interrupted while awaiting the latch instead.
|
||||
public void testSendAnnotationMessage_InterruptedWhileAwaitingAckReturnsEmptyRestoresInterruptStatusAndCleansUpPublication() throws Exception {
|
||||
// send succeeds, but no ack ever arrives -- the calling thread is interrupted while awaiting the confirmation future instead.
|
||||
annotationProperties.setAnnotationAckTimeoutMillis(TimeUnit.SECONDS.toMillis(30));
|
||||
when(annotationSink.send(any())).thenReturn(true);
|
||||
Annotation annotation = AnnotationUtils.injectAllHashes(generateTestAnnotation());
|
||||
@@ -344,7 +304,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
});
|
||||
|
||||
assertTrue(threadStarted.await(5, TimeUnit.SECONDS));
|
||||
// give the worker thread a moment to actually enter latch.await(...) before interrupting it
|
||||
// give the worker thread a moment to begin waiting on the confirmation future before interrupting it
|
||||
Thread.sleep(50);
|
||||
executor.shutdownNow();
|
||||
|
||||
@@ -353,7 +313,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
assertNotNull(resultRef.get());
|
||||
assertTrue(resultRef.get().isEmpty(), "an interrupted wait for the ack must be treated as a failed send");
|
||||
assertTrue(interruptedStatusObservedAfterReturn.get(), "the thread's interrupt status must be restored, not swallowed, by sendAnnotationMessage");
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "latch should still be cleaned up when the wait is interrupted");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "in-flight entry should still be cleaned up when the wait is interrupted");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
@@ -362,13 +322,13 @@ public class TestAnnotationControllerV1Delivery {
|
||||
@Test
|
||||
public void testSendAnnotationMessage_LateAckAfterTimeoutIsNoop() {
|
||||
// send succeeds, no ack arrives for the duration of the timeout window,
|
||||
// then a late ack arrives. The late ack must be safely ignored (no NPE,
|
||||
// no exception) because the sender's finally block has already removed the latch.
|
||||
// then a late ack arrives. Completing the old future must be safe
|
||||
// without recreating the completed publication or changing the previous result.
|
||||
when(annotationSink.send(any())).thenReturn(true);
|
||||
Annotation annotation = AnnotationUtils.injectAllHashes(generateTestAnnotation());
|
||||
|
||||
// Send the message — this will register a latch, wait for the ack timeout,
|
||||
// receive no ack, and remove the latch in the finally block
|
||||
// Send the message — this will register an in-flight entry, wait for the ack timeout,
|
||||
// receive no ack, and remove the in-flight entry in the finally block
|
||||
long start = System.currentTimeMillis();
|
||||
Optional<AnnotationMessage> result = annotationController.sendAnnotationMessage(buildMessage(annotation));
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
@@ -381,13 +341,12 @@ public class TestAnnotationControllerV1Delivery {
|
||||
verify(annotationSink, times(1)).send(captor.capture());
|
||||
Object correlationId = captor.getValue().getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
String correlationIdString = correlationId.toString();
|
||||
|
||||
// Now simulate the late ack arriving after the latch was already removed
|
||||
annotationController.processConfirmAck(MessageBuilder.withPayload("late-ack").setCorrelationId(correlationIdString).build());
|
||||
// Now simulate the late ack arriving after the in-flight entry was already removed
|
||||
confirm(captor.getValue());
|
||||
|
||||
// Verify no exceptions were thrown and the tracker is clean
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "no latch should remain after late ack");
|
||||
// Verify no exceptions were thrown and no publication remains in flight
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "no in-flight entry should remain after late ack");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -399,7 +358,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
Optional<AnnotationMessage> result = annotationController.sendAnnotationMessage(buildMessage(annotation));
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "no latch should be registered when acks are disabled");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "no in-flight entry should be registered when acks are disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -425,7 +384,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
|
||||
assertTrue(result.isEmpty(), "an unhealthy messaging infrastructure should short-circuit to failure");
|
||||
verify(annotationSink, times(0)).send(any());
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "no latch should be registered when the send is skipped due to health");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "no in-flight entry should be registered when the send is skipped due to health");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -494,7 +453,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
AnnotationProperties defaultProperties = new AnnotationProperties();
|
||||
defaultProperties.setAnnotationAckEnabled(false);
|
||||
AnnotationControllerV1 defaultController = new AnnotationControllerV1(connectionFactory, lookupService, defaultProperties, timestampTransformer,
|
||||
visibilityTransformer, annotationSink, mock(ExecutorService.class), new AnnotationAckTracker());
|
||||
visibilityTransformer, new AnnotationPublisher(annotationSink, defaultProperties, null), mock(ExecutorService.class));
|
||||
when(annotationSink.send(any())).thenReturn(true);
|
||||
|
||||
Optional<Annotation> result = defaultController.sendAnnotation(AnnotationUtils.injectAllHashes(generateTestAnnotation()));
|
||||
@@ -543,7 +502,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
Message<AnnotationMessage> sent = invocation.getArgument(0);
|
||||
Object correlationId = sent.getHeaders().get(IntegrationMessageHeaderAccessor.CORRELATION_ID);
|
||||
assertNotNull(correlationId);
|
||||
annotationController.processConfirmAck(MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build());
|
||||
confirm(sent);
|
||||
}
|
||||
return succeeds;
|
||||
});
|
||||
@@ -579,7 +538,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
Optional<Annotation> result = annotationController.writeAnnotation(annotation);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "no latches should remain after exhausting retries");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "no in-flight entries should remain after exhausting retries");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -608,7 +567,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
longBackoffProperties.getRetry().setFailTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
|
||||
|
||||
AnnotationControllerV1 controller = new AnnotationControllerV1(connectionFactory, lookupService, longBackoffProperties, timestampTransformer,
|
||||
visibilityTransformer, annotationSink, Executors.newCachedThreadPool(), new AnnotationAckTracker());
|
||||
visibilityTransformer, new AnnotationPublisher(annotationSink, longBackoffProperties, null), Executors.newCachedThreadPool());
|
||||
|
||||
CountDownLatch firstSendAttempted = new CountDownLatch(1);
|
||||
when(annotationSink.send(any())).thenAnswer(invocation -> {
|
||||
@@ -650,7 +609,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------------------
|
||||
// concurrency: two independent writes must not cross-satisfy each other's latches
|
||||
// concurrency: two independent writes must not cross-satisfy each other's in-flight entries
|
||||
// ----------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -663,7 +622,7 @@ public class TestAnnotationControllerV1Delivery {
|
||||
sendCounts.merge(String.valueOf(correlationId), 1, Integer::sum);
|
||||
Thread.sleep(10);
|
||||
assertNotNull(correlationId);
|
||||
annotationController.processConfirmAck(MessageBuilder.withPayload("ack").setCorrelationId(correlationId).build());
|
||||
confirm(sent);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -681,10 +640,10 @@ public class TestAnnotationControllerV1Delivery {
|
||||
assertTrue(resultOne.isPresent());
|
||||
assertTrue(resultTwo.isPresent());
|
||||
assertNotEquals(resultOne.get().getAnnotationId(), resultTwo.get().getAnnotationId());
|
||||
assertTrue(getAnnotationAckTracker().isEmpty(), "no latches should remain once both concurrent writes complete");
|
||||
assertTrue(getAnnotationPublisher().isEmpty(), "no in-flight entries should remain once both concurrent writes complete");
|
||||
|
||||
// each write should have used its own distinct correlation id, and been sent exactly once (no unnecessary retries,
|
||||
// and no cross-talk where one write's ack satisfies the other's latch)
|
||||
// and no cross-talk where one write's ack satisfies the other's confirmation future)
|
||||
assertEquals(2, sendCounts.size(), "expected two distinct correlation ids to have been used: " + sendCounts.keySet());
|
||||
sendCounts.values().forEach(count -> assertEquals(1, count, "expected each correlation id to have been sent exactly once"));
|
||||
} finally {
|
||||
|
||||
+19
-1
@@ -29,6 +29,10 @@ public class TestAnnotationConfigParity {
|
||||
public void testPackagedBootstrapConfigurationLoads() throws Exception {
|
||||
var propertySources = loadYaml("bootstrap", projectDirectory().resolve("src/main/resources/config/bootstrap.yml"));
|
||||
assertEquals(4, propertySources.size(), "bootstrap.yml should contain its base, dev, consul, and nomessaging documents");
|
||||
assertEquals("correlated", propertySources.get(0).getProperty("spring.rabbitmq.publisher-confirm-type"));
|
||||
assertEquals(true, propertySources.get(0).getProperty("spring.rabbitmq.publisher-returns"));
|
||||
assertEquals(true, propertySources.get(0).getProperty("spring.cloud.stream.rabbit.bindings.annotationSource-out-0.producer.useConfirmHeader"));
|
||||
assertNull(propertySources.get(0).getProperty("spring.cloud.stream.rabbit.bindings.annotationSource-out-0.producer.confirmAckChannel"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,7 +40,21 @@ public class TestAnnotationConfigParity {
|
||||
PropertySource<?> serviceConfig = loadYaml("service-config", projectDirectory().resolve("src/main/config/annotation.yml")).get(0);
|
||||
PropertySource<?> dockerConfig = loadYaml("docker-config", projectDirectory().resolve("../../../../docker/config/annotation.yml")).get(0);
|
||||
|
||||
assertEquals(serviceConfig.getProperty("spring.cloud.function.definition"), dockerConfig.getProperty("spring.cloud.function.definition"));
|
||||
for (PropertySource<?> config : List.of(serviceConfig, dockerConfig)) {
|
||||
assertNull(config.getProperty("spring.cloud.function.definition"), "the role and backend flags select functions automatically");
|
||||
assertNull(config.getProperty("spring.cloud.stream.function.definition"), "explicit function lists are unsupported");
|
||||
}
|
||||
for (PropertySource<?> config : List.of(serviceConfig, dockerConfig)) {
|
||||
assertEquals("correlated", config.getProperty("spring.rabbitmq.publisher-confirm-type"));
|
||||
assertEquals(true, config.getProperty("spring.rabbitmq.publisher-returns"));
|
||||
assertEquals(true, config.getProperty("spring.cloud.stream.rabbit.bindings.annotationSource-out-0.producer.useConfirmHeader"));
|
||||
assertNull(config.getProperty("spring.cloud.stream.rabbit.bindings.annotationSource-out-0.producer.confirmAckChannel"));
|
||||
}
|
||||
for (String binding : List.of("annotationSource-out-0", "logAnnotationSink-in-0", "accumuloAnnotationSink-in-0", "dumpAnnotationSink-in-0")) {
|
||||
String contentType = "spring.cloud.stream.bindings." + binding + ".content-type";
|
||||
assertEquals("application/x-protobuf", serviceConfig.getProperty(contentType));
|
||||
assertEquals(serviceConfig.getProperty(contentType), dockerConfig.getProperty(contentType));
|
||||
}
|
||||
assertEquals(serviceConfig.getProperty("annotation.system-from"), dockerConfig.getProperty("annotation.system-from"));
|
||||
assertEquals(serviceConfig.getProperty("annotation.writers.accumulo.enabled"), dockerConfig.getProperty("annotation.writers.accumulo.enabled"));
|
||||
assertEquals(serviceConfig.getProperty("annotation.writers.accumulo.health.enabled"),
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ import datawave.microservice.annotation.writers.log.config.LogAnnotationWriterPr
|
||||
@ContextConfiguration(classes = {LogAnnotationWriterProperties.class, AccumuloAnnotationWriterConfig.class, AccumuloConfiguration.class,
|
||||
AnnotationSerializerConfiguration.class, WriterTopologyBothEnabledTest.WriterTopologyTestConfiguration.class})
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = "spring.main.allow-bean-definition-overriding=true")
|
||||
@ActiveProfiles({"WriterTopologyBothEnabledTest", "log-enabled", "accumulo-enabled"})
|
||||
@ActiveProfiles({"combined", "WriterTopologyBothEnabledTest", "log-enabled", "accumulo-enabled"})
|
||||
public class WriterTopologyBothEnabledTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import datawave.microservice.annotation.writers.accumulo.config.AccumuloAnnotati
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = {AccumuloAnnotationWriterConfig.class, AccumuloConfiguration.class, AnnotationSerializerConfiguration.class})
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"AccumuloAnnotationWriterConfigDisabledTest", "accumulo-disabled"})
|
||||
@ActiveProfiles({"combined", "AccumuloAnnotationWriterConfigDisabledTest", "accumulo-disabled"})
|
||||
public class AccumuloAnnotationWriterConfigDisabledTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@ContextConfiguration(classes = {AccumuloAnnotationWriterConfig.class, AccumuloConfiguration.class, AnnotationSerializerConfiguration.class,
|
||||
AccumuloAnnotationWriterTest.AccumuloAnnotationWriterTestConfiguration.class})
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = "spring.main.allow-bean-definition-overriding=true")
|
||||
@ActiveProfiles({"AccumuloAnnotationWriterTest", "accumulo-enabled"})
|
||||
@ActiveProfiles({"combined", "AccumuloAnnotationWriterTest", "accumulo-enabled"})
|
||||
public class AccumuloAnnotationWriterTest {
|
||||
private static final String annotationTableName = "truthmark";
|
||||
private static final String annotationSourceTableName = "truthmarkSource";
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import datawave.microservice.annotation.writers.file.config.DumpAnnotationWriter
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = DumpAnnotationWriterConfig.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"DumpAnnotationWriterConfigDisabledTest", "dump-disabled"})
|
||||
@ActiveProfiles({"combined", "DumpAnnotationWriterConfigDisabledTest", "dump-disabled"})
|
||||
public class DumpAnnotationWriterConfigDisabledTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import datawave.microservice.annotation.writers.file.config.DumpAnnotationWriter
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = DumpAnnotationWriterConfig.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"DumpAnnotationWriterTest", "dump-enabled"})
|
||||
@ActiveProfiles({"combined", "DumpAnnotationWriterTest", "dump-enabled"})
|
||||
public class DumpAnnotationWriterTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import datawave.microservice.annotation.writers.file.config.FileAnnotationWriter
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = FileAnnotationWriterConfig.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"FileAnnotationWriterConfigDisabledTest", "file-disabled"})
|
||||
@ActiveProfiles({"combined", "FileAnnotationWriterConfigDisabledTest", "file-disabled"})
|
||||
public class FileAnnotationWriterConfigDisabledTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import datawave.microservice.annotation.writers.file.config.FileAnnotationWriter
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = FileAnnotationWriterConfig.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"FileAnnotationWriterTest", "file-enabled"})
|
||||
@ActiveProfiles({"combined", "FileAnnotationWriterTest", "file-enabled"})
|
||||
public class FileAnnotationWriterTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import lombok.Getter;
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = LogAnnotationWriterProperties.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles({"LogAnnotationWriterTest", "log-enabled"})
|
||||
@ActiveProfiles({"combined", "LogAnnotationWriterTest", "log-enabled"})
|
||||
public class LogAnnotationWriterTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -12,8 +12,16 @@ spring:
|
||||
banner-mode: "OFF"
|
||||
|
||||
management:
|
||||
health:
|
||||
status:
|
||||
order: DOWN, OUT_OF_SERVICE, UNKNOWN, RABBITMQ_UNHEALTHY, UP
|
||||
http-mapping:
|
||||
RABBITMQ_UNHEALTHY: 200
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
readiness:
|
||||
include: "*"
|
||||
status:
|
||||
order: DOWN, OUT_OF_SERVICE, RABBITMQ_UNHEALTHY, UP, UNKNOWN
|
||||
http-mapping:
|
||||
DOWN: 503
|
||||
OUT_OF_SERVICE: 503
|
||||
RABBITMQ_UNHEALTHY: 503
|
||||
Reference in new issue
Block a user