feat(query-support): Reflector Phase 3 — ReflectorPartition, ReflectorPartitionCommandBus, AkcesReflectorController#276
Conversation
…classes Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/6348079c-1a83-4024-90f0-bef5784778b6 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/6348079c-1a83-4024-90f0-bef5784778b6 Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Implements Phase 3 of the query-support “Reflector” pipeline by adding a partition-level processor that can consume external domain events, run reflector handlers, and (optionally) send follow-up commands back to aggregates, plus a top-level controller that discovers routing info from Akces-Control and manages partition lifecycle.
Changes:
- Added
ReflectorPartition(per-partition runnable) with per-event Kafka transactions, pause/resume retry backoff, and thread-localCommandBusregistration forReflector.onSuccess/onFailure. - Added
AkcesReflectorControllerto consumeAkces-Control, discoverAggregateServiceRecords, and implementAkcesRegistry(type/topic/partition routing) for reflector command sends. - Introduced controller/partition state enums for lifecycle visibility.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
main/query-support/src/main/java/org/elasticsoftware/akces/query/reflector/ReflectorPartitionState.java |
Adds lifecycle enum for reflector partition processing phases. |
main/query-support/src/main/java/org/elasticsoftware/akces/query/reflector/ReflectorPartitionCommandBus.java |
Registers the partition as the thread-local CommandBus for reflector callbacks. |
main/query-support/src/main/java/org/elasticsoftware/akces/query/reflector/ReflectorPartition.java |
Implements per-partition event consumption, retry pause/resume, and transactional offset+command atomicity. |
main/query-support/src/main/java/org/elasticsoftware/akces/query/reflector/AkcesReflectorControllerState.java |
Adds controller lifecycle enum (init/rebalancing/running/error/shutdown). |
main/query-support/src/main/java/org/elasticsoftware/akces/query/reflector/AkcesReflectorController.java |
Adds the controller that discovers services via Akces-Control, spawns/stops partitions on rebalance, and routes commands. |
| try { | ||
| result = runtime.apply(domainEventRecord, topicPartition, gdprContextRepository::get); | ||
| } catch (IOException e) { | ||
| logger.error("IOException deserializing event '{}' on partition {} of {}Reflector", | ||
| domainEventRecord.name(), topicPartition, runtime.getName(), e); | ||
| // treat deserialization failure as a permanent failure — skip the event | ||
| result = FAILURE; | ||
| } |
There was a problem hiding this comment.
In the IOException path, runtime.apply(...) clears lastEvent/lastException at the start of each call (see KafkaReflectorRuntime.apply). When an IOException is caught here you set result = FAILURE, but then later FAILURE handling calls commitEventWithTransaction(..., runtime.getLastEvent(), ..., runtime.getLastException()), which will be null and can trigger an NPE inside runtime.handleFailure (it calls event.getClass()). Consider treating deserialization IOException as a terminal “skip” that only commits the offset (no success/failure callback), or plumb the IOException through and handle it without relying on getLastEvent().
| logger.error("Error beginning transaction for event '{}' on partition {} of {}Reflector", | ||
| domainEventRecord.name(), topicPartition, runtime.getName(), e); |
There was a problem hiding this comment.
If producer.beginTransaction() throws (caught by the outer catch (KafkaException e)), the method only logs and returns. At that point the consumer position has already advanced past this record, and without a seek/pause/shutdown this event will be skipped for the lifetime of this process (until a restart/rebalance replays from the last committed offset). Consider seeking back to offset (and possibly pausing/backing off or transitioning to SHUTTING_DOWN) when starting the transaction fails so the record is deterministically retried or the partition stops.
| logger.error("Error beginning transaction for event '{}' on partition {} of {}Reflector", | |
| domainEventRecord.name(), topicPartition, runtime.getName(), e); | |
| consumer.seek(topicPartition, offset); | |
| logger.error("Error beginning transaction for event '{}' on partition {} of {}Reflector; seeking back to offset {} for retry", | |
| domainEventRecord.name(), topicPartition, runtime.getName(), offset, e); |
| @Override | ||
| @Nonnull | ||
| public Integer resolvePartition(@Nonnull String aggregateId) { | ||
| return Math.abs(hashFunction.hashString(aggregateId, UTF_8).asInt()) % partitions; |
There was a problem hiding this comment.
Math.abs(hashString(...).asInt()) can return a negative value when the hash is Integer.MIN_VALUE, which would yield a negative partition index after % partitions. Use a non-negative conversion such as Math.floorMod(hash, partitions) or (hash & 0x7fffffff) % partitions to guarantee 0..partitions-1 routing.
| return Math.abs(hashFunction.hashString(aggregateId, UTF_8).asInt()) % partitions; | |
| int hash = hashFunction.hashString(aggregateId, UTF_8).asInt(); | |
| return Math.floorMod(hash, partitions); |
| } catch (WakeupException | InterruptException ignore) { | ||
| // non-fatal, ignore | ||
| } catch (KafkaException e) { |
There was a problem hiding this comment.
The PR description mentions that InterruptException catch blocks restore the thread interrupt status, but this catch currently ignores InterruptException without calling Thread.currentThread().interrupt(). If you want to preserve interrupt semantics (and align with the description), restore the interrupt flag here (and/or transition to shutdown).
| } catch (Exception e) { | ||
| logger.error("Error in AkcesReflectorController", e); | ||
| processState = ERROR; | ||
| } |
There was a problem hiding this comment.
If an exception escapes the main loop, the catch (Exception e) sets processState = ERROR but does not count down shutdownLatch. As a result, callers of close() may block for the full await timeout even though the controller thread has already terminated. Consider counting down the latch (and/or performing the same shutdown path) in the error handler.
| @Override | ||
| public void run() { | ||
| try { | ||
| controlConsumer = controlRecordConsumerFactory.createConsumer( | ||
| reflectorRuntime.getName() + "Reflector-Akces-Control", | ||
| reflectorRuntime.getName() + "-" + HostUtils.getHostName() + "Reflector-Akces-Control", | ||
| null); | ||
| controlConsumer.subscribe(List.of("Akces-Control"), this); | ||
| // determine the number of partitions once metadata is available | ||
| this.partitions = controlConsumer.partitionsFor("Akces-Control").size(); | ||
| this.schemaStorage = new KafkaTopicSchemaStorage( | ||
| schemaRecordConsumerFactory.createConsumer( | ||
| HostUtils.getHostName() + "-AkcesReflectorController-Schemas", | ||
| HostUtils.getHostName() + "-AkcesReflectorController-Schemas", | ||
| null)); | ||
| schemaStorage.initialize(); | ||
| this.schemaRegistry = new KafkaSchemaRegistry(schemaStorage); | ||
| // validate domain event schemas | ||
| reflectorRuntime.validateDomainEventSchemas(schemaRegistry); | ||
| while (processState != SHUTTING_DOWN) { | ||
| process(); | ||
| } |
There was a problem hiding this comment.
This PR introduces substantial new concurrency/transactional Kafka behavior (rebalance handling, per-event transactions, retry/pause semantics, and command routing), but there are currently no tests for the reflector package in main/query-support (the module has tests for similar controllers/runtimes under src/test/java). Adding at least one integration-style test that exercises: (1) control-topic discovery → partition assignment, (2) processing a DomainEventRecord and committing offsets transactionally, and (3) RETRY_PENDING pause/resume behavior would help prevent regressions.
Implements the partition-level event processor and top-level controller for the Reflector feature, completing Phase 3 of the reflector pipeline in
main/query-support.New Components
Enums
ReflectorPartitionState—INITIALIZING | LOADING_GDPR_KEYS | PROCESSING | SHUTTING_DOWNAkcesReflectorControllerState—INITIALIZING | INITIAL_REBALANCING | REBALANCING | RUNNING | SHUTTING_DOWN | ERRORReflectorPartitionCommandBusExtends
CommandBusHolderto register theReflectorPartitionas the thread-localCommandBus, allowingReflector.onSuccess()/onFailure()to send commands. MirrorsAggregatePartitionCommandBusinmain/runtime.ReflectorPartitionImplements
Runnable,AutoCloseable,CommandBus. Core design:{reflector}Reflector-partition-{id}-{hostname}) — same pattern asAggregatePartition; offset commits and command sends are atomic in a single Kafka transactionpausedUntilmap: onRETRY_PENDING, pauses the partition, seeks back to the event offset, and resumes afterattempt × retryBackoffBaseMsmsAkcesReflectorControllerMirrors
AkcesDatabaseModelControllerbut fully implementsAkcesRegistryfor command routing (unlikeAkcesDatabaseModelControllerwhich throwsUnsupportedOperationException):resolveType/resolveTopic(CommandType)— resolved from discoveredAggregateServiceRecords onAkces-ControlresolvePartition— Murmur3 hash ofaggregateId % partitions; partition count obtained viacontrolConsumer.partitionsFor("Akces-Control").size()(nokafkaAdmindependency needed)Notable Details
InterruptExceptioncatch blocks properly restore thread interrupt status viaThread.currentThread().interrupt()rather than silently ignoringDatabaseModelPartition