Add self-checkpointing support for pooled streaming processors#4636
Add self-checkpointing support for pooled streaming processors#4636abuijze wants to merge 12 commits into
Conversation
Introduce the Checkpointing operations interface and the per-segment CheckpointTrigger (messaging streaming.checkpoint package), letting an event-handling unit manage when its segment's TrackingToken advances. Add the EventHandlingComponent#unwrap(Class) convention to detect such a unit, forwarded through DelegatingEventHandlingComponent and SequenceOverridingEventHandlingComponent and bridged to an annotated POJO via AnnotatedEventHandlingComponent / AnnotatedHandlerInspector. Extend WorkPackage and Coordinator to request, reconcile and store checkpoints and to flush on segment release, and have PooledStreamingEventProcessor resolve the participants and select auto vs fully-deferred mode. Register CheckpointTrigger and Segment handler-parameter resolvers (checkpoint.annotation and segmenting.annotation).
Place the volatile modifier before the @nullable type-use annotation on lastConsumedToken, so the annotation sits adjacent to its type. This follows the JLS modifier ordering and matches the field nullability convention used elsewhere in the codebase.
laura-devriendt-lemon
left a comment
There was a problem hiding this comment.
some suggestions and questions
| }); | ||
| unitOfWork.onInvocation(ctx -> batchProcessor.process(eventBatch, ctx).asCompletableFuture()); | ||
| // One transaction handles the batch AND stores the checkpoint for this cycle (if any). | ||
| unitOfWork.onPrepareCommit(this::checkpoint); |
There was a problem hiding this comment.
Folding checkpoint() into the batch transaction via onPrepareCommit means the batch tx stays open while we await participant.onCheckpointAdvanced(...) (awaited without a timeout). In auto mode this happens every batch, so a slow participant flush holds the batch's connection/locks open, and a participant failure rolls back the co-located ordinary handler's work too.
I think it's forced by the shared-token atomicity, so not asking for a code change but could we document the behavior? The "awaits without a timeout, can stall the segment" risk is already well covered in the Checkpointing javadoc. What I don't see documented is that this await sits inside the batch transaction (onPrepareCommit), so in auto mode it holds the batch's connection/locks open every batch, and a participant failure rolls back the co-located ordinary handler's work too. Could we add a line on that, plus a note recommending async/deferred handlers go in their own processor to avoid it?
| * @see CheckpointTrigger | ||
| * @since 5.2.0 | ||
| */ | ||
| public interface Checkpointing { |
There was a problem hiding this comment.
will there come a separate pr for antora docs?
There was a problem hiding this comment.
I decided to mark the new classes as @Internal, and therefore leave it undocumented. For the time being, we'll use this for internal components (such as workflows) before we decide to open it up and support it as a full-fledged feature.
There was a problem hiding this comment.
I wanted to ensure the same thing, that documentation is not present on purpose. I agree that it's best not to document this yet. Let's first use this thoroughly with our workflow integration.
|
Something that could be usefull => I ran claude to ask if there where some behavior tests missing this was the output: 1. Multi-participant release. Every release test uses a single participant, so 2. Auto-mode participant failure. 3. Multiple segments, one projection instance. The whole "retain the trigger keyed by segment" contract is untested — both ITs use 4. Mixed-mode end-to-end (ordinary + checkpointing in one processor). Every e2e test is a single checkpointing handler (fully-deferred). There's no test of a real processor running auto mode with a mix — verifying the ordinary handler stores every batch and the checkpointing one is dragged up to batch-end. This is the configuration most likely to surprise user Moderate5. Auto mode with multiple participants — reconcile running through the auto path (batch-end floor + several participants at different positions). Currently auto mode is only tested with one participant. 6. 7. Running-path regression guard. 8. Concurrent requests merging via Lower / edge9. Non-comparable tokens still stored. 10. Segment split/merge with an active 11. Reset/replay interaction — what happens to a 12. |
Align the checkpointing code and its documentation after review of #4636. Several javadocs described behaviour the code does not have. The release path claimed it stored the lowerBound of the reported tokens, while it actually reconciles to the highest reported position and only falls back to the lowerBound when reconciliation is impossible; the documentation now matches, and two release tests pin both branches. The CheckpointTrigger parameter resolver promised the trigger was scoped to a self-checkpointing component, but the processor exposes it to every handler on a segment whenever it has at least one checkpointing component; the class javadoc, inner-class doc and exception message now state the real, segment-scoped contract. Rename for intent: autoMode -> autoCheckpointing, finalCheckpoint -> checkpointOnRelease, and WorkPackage.participants -> checkpointingParticipants (matching the field the processor already uses). Compute the requested token once in checkpoint() so the lambda no longer needs an effectively-final alias. A processor that mixes self-checkpointing and ordinary handlers silently downgraded to auto-checkpointing, where a self-checkpointing component cannot defer its token. Log this at construction and document the transactional coupling (the checkpoint rides the batch commit, so a failure rolls back co-located handlers), recommending such components be isolated in their own processor. Self-checkpointing stays internal-facing for now: mark Checkpointing,CheckpointTrigger and CheckpointTriggerParameterResolverFactory @internal and add @SInCE 5.2.0 to the new EventHandlingComponent#unwrap and AnnotatedHandlerInspector#resolveBehavior seams. Add coverage for the previously untested paths: multi-segment trigger keying, a multi-participant release reconciliation (advance and fall-back variants), and a mixed-mode end-to-end integration test.
|
A work package should be aborted when it fails to process the "segment claimed" call. Since there may be exception is that call, we must proceed defensively. Any thrown exception should cause the work package to be aborted, allowing the coordinator to assign it to another instance in the next run.
The WorkPackage checkpoint guard only rejected tokens that strictly
regressed below the stored token (lastStored.covers(safe) &&
!safe.covers(lastStored)). Because covers() defines a partial order,
that test misses incomparable tokens: a multi-source token where one
source advanced while another fell behind covers neither way, so it
slipped through and was stored, silently rewinding the regressed
source. The previous behaviour was also documented as intentional
("store on any change"), masking the issue.
The guard now stores a token only when it covers the last stored
checkpoint, rejecting both strict regressions and incomparable
positions, so a misbehaving component can never rewind progress on any
source. The comparison is made on the unwrapped upper-bound positions
so a concluding replay (stored ReplayToken vs. plain stream token) is
still recognised as an advance rather than throwing or reporting a
false regression.
Adds coverage for the incomparable case via a minimal two-axis token
and for the replay-to-live boundary.
smcvb
left a comment
There was a problem hiding this comment.
An incredibly powerful feature! But also terrible complex to explain. The JavaDoc does it's best, but I still feel that a simple explanation is lacking. Granted, this is still internal, so we're good, but sharing this pointer feels important nonetheless.
Apart from the complexity concern, I have numerous comments. Tons of nits, some questions, and definitely sufficient to request changes before we go ahead with this.
| // when -- the processor is killed (shut down) with c3/c4 handled but not yet checkpointed, then restarted | ||
| projection.handledSinceReset.clear(); | ||
| projection.confirmLimit = 5; | ||
| processor.shutdown().join(); |
There was a problem hiding this comment.
Nit: I'd add a timeout to these join invocations.
| // when -- killed with c3/c4 handled but not checkpointed, then restarted | ||
| projection.handledSinceReset.clear(); | ||
| projection.confirmLimit = 5; | ||
| processor.shutdown().join(); |
There was a problem hiding this comment.
Nit: I'd add a timeout to these joins to ensure we're not blocking longer than we want.
| // when -- the processor is restarted | ||
| checkpointing.handledSinceReset.clear(); | ||
| ordinary.handledSinceReset.clear(); | ||
| processor.shutdown().join(); |
There was a problem hiding this comment.
Nit: Another location where a timeout for the join wouldn't hurt.
| * {@link #onSegmentReleased(Segment, TrackingToken)}. | ||
| * <p> | ||
| * Defaults to a no-op: a unit that obtains its trigger another way -- typically through a {@link CheckpointTrigger} | ||
| * handler-method parameter -- does not need to retain it here. |
There was a problem hiding this comment.
Nits — [MID] — @param segment The segment that was claimed. and @param trigger The handle to request checkpoints... in onSegmentClaimed() also use sentence/capitalized style. Same issue in onSegmentReleased() (@param segment The segment being released., @param upTo The segment's...). Fragment style required throughout.
| * Requests a checkpoint covering everything handed to the handler so far (the segment's current | ||
| * {@code lastConsumedToken}). | ||
| */ | ||
| void requestCheckpoint(); |
There was a problem hiding this comment.
Isn't this just:
| void requestCheckpoint(); | |
| default void requestCheckpoint() { | |
| this.requestCheckpoint(TrackingToken.LATEST); | |
| } |
?
| /** | ||
| * Stores {@code safe}, keeping the stored {@link TrackingToken} monotonic. A {@code null} or already-stored token | ||
| * is ignored. A token that does not {@link #coversWhenUnwrapped(TrackingToken, TrackingToken) cover} the last | ||
| * stored checkpoint -- whether a strict regression or an <em>incomparable</em> position, such as a |
There was a problem hiding this comment.
Nit: Another use of double-dashes, which I don't think we should do in the JavaDoc.
| * @return {@code true} if {@code candidate} covers {@code reference} once both are unwrapped to their raw | ||
| * upper-bound positions | ||
| */ | ||
| private static boolean coversWhenUnwrapped(TrackingToken candidate, TrackingToken reference) { |
There was a problem hiding this comment.
Nit: Perhaps this is a utility more suited on the WrappedToken? Or the aforementioned TrackingTokenUtils suggestion.
| /** | ||
| * Invokes {@code request} on every participant concurrently, returning their reported tokens keyed by participant. | ||
| */ | ||
| private CompletableFuture<Map<Checkpointing, TrackingToken>> requestEach( |
There was a problem hiding this comment.
Super nit: Following what's done in a PR is tough, as the private methods keep jumping back and forth. Perhaps some ordering, like invocation order, would simplify things for future readers.
There was a problem hiding this comment.
I see how the changes to this file support the check pointing, but I do think they make the WorkPackage an awful lot more complex to read. Furthermore, it's behavior that would be required for any StreamingEventProcessor we'd derive in the future.
If we could move any of this behavior to a component the WorkPackage uses, instead of expanding the WorkPackage, I'd be for that. If we deem that to much work, then we can leave the state of the WorkPackage as a plain fact.
| } | ||
|
|
||
| @Override | ||
| public void onSegmentClaimed(@NonNull Segment segment, @NonNull CheckpointTrigger trigger) { |
There was a problem hiding this comment.
Doing a bit of back and forth, but it seems the only difference between this CheckpointResumeInMemoryIT and the CheckpointTriggerResumeInMemoryIT is the use of onSegmentClaimed. Other than that, all seems to be identical. To be frank, I lack the understanding why this exact method has such a massive impact that it merits two virtually identical test cases.
Can you elaborate why we need both?
Apply the actionable feedback from PR #4636 that does not need the larger WorkPackage decomposition (tracked separately), and close the remaining gaps in self-checkpointing test coverage. - Convert the new checkpoint javadoc to fragment-style tags and drop the double-dash em-dash usages. - Make CheckpointTrigger#requestCheckpoint() a default delegating to requestCheckpoint(LATEST), removing the duplicated override. - Document the CheckpointTrigger resolver as a general streaming-processor concern, and fix the Segment resolver comment that claimed it throws when it resolves to null. - Extract the token-combining helpers from WorkPackage into a reusable TrackingTokenUtils, and generalise the unwrap test to an arbitrary capability type. - Bound the join() calls in the checkpoint integration tests with a timeout. - Cover the untested paths: auto-mode checkpoint failure, auto-mode multi-participant reconciliation, the running-path regression guard, concurrent request merging, onCheckpointAdvanced returning LATEST, and Checkpointing inertness in a subscribing processor.
Delegate the decision of what to persist around a batch to a per-segment SegmentProgressStrategy, selected per processor via a configurable factory builder. WorkPackage keeps the transaction and the monotonic token store behind SegmentProgressContext; the default strategy stores the batch-end token, while the checkpoint engine moves into CheckpointingProgressStrategy. Split and merge now run the release hook before reading the token, so participants store a final reconciled checkpoint first. Idle upkeep skips the strategy when nothing is unstored. Checkpoint tests are restructured into an abstract suite on a published harness.
The checkpoint types, tests, and ITs now live in axoniq-event-streaming. The default progress strategy reverts to plain token storing; the Axoniq enhancer reinstates checkpointing detection when present. Adds a harness test keeping the published SegmentProgressStrategyTestSupport exercised in-repo.
A TokenStore may only apply stores on transaction commit, so running the release flush and the token fetch in one unit of work made the fetch read the stale token: split halves and merged segments inherited the pre-flush position. The flush now commits in its own transaction before the fetch opens.
Both bound helpers accept null elements, as their javadoc already described. lowerBound now returns TrackingToken.FIRST instead of null when no tokens remain, making it total.




Background
A pooled streaming processor stores a segment's
TrackingTokenonce per batch, in the same transaction as the batch. That's correct for a synchronous, transactional projection, but wrong for a handler whose work isn't durable when the event handler returns -- one that persists asynchronously (off the processing thread, or via a longer-running process), or builds an in-memory model it snapshots later. Committing the batch doesn't make that work durable, so storing the batch-end token advertises progress that hasn't actually been made, and a restart would skip events that were never persisted.This change lets an event-handling unit manage its own progress. A unit that implements
Checkpointingdecides when its work for a segment is durable and asks the processor to advance the stored token through aCheckpointTrigger; the processor stores the token only after the unit confirms durability, under a transaction it controls, so the stored token is always a genuine resume point. A processor whose every handler is self-checkpointing advances only on request ("fully-deferred"); one that also has an ordinary handler keeps checkpointing every batch as before.Usage
A projection carries its
@EventHandlermethods and implementsCheckpointing. The example below processes entirely in memory and persists only when the segment is released -- the simplest opt-in, and a clear case of deferral:onCheckpointAdvancedis the only required method, and since the projection never requests a checkpoint while running, the framework invokes it only on release (onSegmentReleaseddelegates to it by default):It's registered like any other projection; implementing the interface is the entire opt-in, with no checkpoint-specific configuration:
Because the token advances only on release, a hard crash (no graceful release) rebuilds the in-memory model by reprocessing from the last stored token -- the trade-off for full-speed in-memory processing, and the expected behaviour for an in-memory read model.
To advance the stored token during processing instead (bounding the replay window) -- e.g. once an asynchronous write is durable, or on a timer -- request it through a
CheckpointTrigger: take it as a handler parameter to request while handling an event, or retain it fromonSegmentClaimedto request out-of-band (an async callback, a@Scheduledsnapshot).