Allow annotation APIs and queue writers to run and scale independently (#4057)

This commit is contained in:
Drew Farris authored and GitHub committed 2026-10-30 02:28:48 +00:00
1 parent addcd54dad
commit e259554904
61 files changed
+2819 -438

No files matched your search

+18
View File
@@ -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
+11
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Application annotation + profile combined. Shared settings retain both responsibilities.
annotation:
deployment:
overlays:
combined: true
+5
View File
@@ -0,0 +1,5 @@
# Application annotation + profile writer. Backend selection and tuning remain shared.
annotation:
deployment:
overlays:
writer: true
+32 -8
View File
@@ -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
+1 -1
View File
@@ -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}
+49 -56
View File
@@ -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'
@@ -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
@@ -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");
}
}
}
@@ -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;
}
}
@@ -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];
}
}
@@ -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 + "'");
}
}
@@ -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() {}
}
@@ -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());
}
}
}
@@ -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();
}
}
@@ -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);
}
/**
@@ -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();
}
}
@@ -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();
}
@@ -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;
@@ -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
@@ -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)
@@ -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;
@@ -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")
@@ -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")
@@ -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")
@@ -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"
@@ -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();
}
}
}
@@ -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();
}
}
@@ -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 {}
}
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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";
}
}
}
@@ -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;
}
}
@@ -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/";
@@ -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
@@ -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 {}
}
@@ -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;
}
}
@@ -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
@@ -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();
}
}
}
@@ -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;
});
}
@@ -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 {
@@ -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"),
@@ -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
@@ -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
@@ -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";
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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