Skip to content

Add self-checkpointing support for pooled streaming processors#4636

Open
abuijze wants to merge 12 commits into
mainfrom
feature/event-handler-checkpointing
Open

Add self-checkpointing support for pooled streaming processors#4636
abuijze wants to merge 12 commits into
mainfrom
feature/event-handler-checkpointing

Conversation

@abuijze

@abuijze abuijze commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Background

A pooled streaming processor stores a segment's TrackingToken once 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 Checkpointing decides when its work for a segment is durable and asks the processor to advance the stored token through a CheckpointTrigger; 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 @EventHandler methods and implements Checkpointing. 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: onCheckpointAdvanced is the only required method, and since the projection never requests a checkpoint while running, the framework invokes it only on release (onSegmentReleased delegates to it by default):

class AccountProjection implements Checkpointing {

    private final ExternalStore store;
    private final Map<String, Long> balances = new ConcurrentHashMap<>();

    AccountProjection(ExternalStore store) {
        this.store = store;
    }

    @EventHandler
    void on(AccountCredited event) {
        balances.merge(event.accountId(), event.amount(), Long::sum); // pure in-memory; nothing persisted per batch
    }

    @Override
    public CompletableFuture<TrackingToken> onCheckpointAdvanced(Segment segment, TrackingToken requested) {
        return store.saveAsync(balances).thenApply(ignored -> TrackingToken.LATEST);
    }
}

It's registered like any other projection; implementing the interface is the entire opt-in, with no checkpoint-specific configuration:

EventProcessorModule.pooledStreaming("account-projection")
                    .eventHandlingComponents(c -> c.autodetected(
                            "accountProjection",
                            cfg -> new AccountProjection(cfg.getComponent(ExternalStore.class))));

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 from onSegmentClaimed to request out-of-band (an async callback, a @Scheduled snapshot).

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).
@abuijze abuijze requested a review from a team as a code owner June 4, 2026 16:16
@abuijze abuijze requested review from MateuszNaKodach, hatzlj and smcvb and removed request for a team June 4, 2026 16:16
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 laura-devriendt-lemon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will there come a separate pr for antora docs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@laura-devriendt-lemon

Copy link
Copy Markdown
Contributor

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 reconcile short-circuits (size == 1) and the release reconcile/lowerBound logic is never exercised. Add a release test with 2+ participants reporting different positions.

2. Auto-mode participant failure. AutoModeWithParticipant only has the happy path. Missing: a participant that can't cover the batch-end token in auto mode → the checkpoint fails in onPrepareCommit → the batch rolls back and the worker aborts, nothing stored.(it's only tested in deferred mode via aParticipantThatCannotBeReconciled).

3. Multiple segments, one projection instance. The whole "retain the trigger keyed by segment" contract is untested — both ITs use initialSegmentCount(1). Add a test with 2+ segments where one projection instance gets a distinct trigger per segment and checkpoints them at independent positions. This would catch the classic bug where someone stores this.trigger = trigger and clobbers segment 0 when segment 1 is claimed.

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

Moderate

5. 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. Checkpointing component in a subscribing (non-streaming) processor. The Checkpointing javadoc claims the callbacks never fire and the behavior is "inert" there. No test verifies that — i.e. a Checkpointing component runs as a plain handler in a SubscribingEventProcessor with no checkpoint calls, and a CheckpointTrigger parameter in that setting fails loudly. (The resolver's "no trigger present" case is unit-tested, but not end-to-end in a real non-streaming processor.)

7. Running-path regression guard. storeIfAdvanced's "ignore a token that regresses below the stored one" is only tested on release (aReleaseTokenBehindTheLastCheckpointIsIgnored...). Add a running-path case: request a checkpoint at position 5 (stored), then request at 3 → ignored, stored token stays 5.

8. Concurrent requests merging via upperBound. "only rises" behavior isn't directly tested: requests for 5 and 8 racing in from different threads should collapse to 8; a request for 3 after 8 should be a no-op. aRequestArrivingWhileTheWorkerIsRunning covers the re-entrant scheduling race but not the merge semantics.

Lower / edge

9. Non-comparable tokens still stored. storeIfAdvanced explicitly documents that tokens "merely not comparable" (replay boundary, partial multi-source advances) are still stored — but every test uses GlobalSequenceTrackingToken (always comparable), so that branch is untested.

10. Segment split/merge with an active Checkpointing component — does split/merge fire onSegmentReleased on the old work package and onSegmentClaimed on the new ones correctly? Untested.

11. Reset/replay interaction — what happens to a Checkpointing component when the processor is reset (token rewound)? Untested; may be fine, but worth a sanity test.

12. onCheckpointAdvanced returning LATEST as its return value. resolveLatest is applied to the return of onCheckpointAdvanced (not just the trigger and onSegmentReleased), but only the trigger/release LATEST paths are tested.

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.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

abuijze added 2 commits June 17, 2026 14:47
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 smcvb added Type: Feature Use to signal an issue is completely new to the project. Priority 1: Must Highest priority. A release cannot be made if this issue isn’t resolved. labels Jun 18, 2026
@smcvb smcvb added this to the Release 5.3.0 milestone Jun 18, 2026

@smcvb smcvb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this just:

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

abuijze added 6 commits June 27, 2026 11:52
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority 1: Must Highest priority. A release cannot be made if this issue isn’t resolved. Type: Feature Use to signal an issue is completely new to the project.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants