ReplayToken present event after deleting token and restarting application

We’ve had some problems with a replay that lead to the tokens for the processor never reset to org.axonframework.eventhandling.GapAwareTrackingToken (and no new events were processed). We’ve deleted the token from token_entry and restarted the application, which lead to a replay. However, after this finished the replay token is still there. The gap count also differs between the tokenAtReset and currentToken.

From what I understand this processor is now in a perpetual replay mode, correct? How can this be fixed?

New events are processed after we deleted the token and restarted the application, however, the token is never unwrapped.

Axon version is 4.13.

EDIT:

I’ve read up on how gaps occur, but I don’t understand how, after a complete replay, the gaps for the currentToken and tokenAtReset can differ. Is there somewhere a documentation that could explain this?

Besides the token still being a replay token, how does this manifest in your application?

It might take a while for gaps to clear. There are some cleanup tasks that will run when the number of gaps exceeds a threshold to see if gaps have “timed out”. Still, Axon should be able to distinguish an event being replayed from one that is delivered for the first time.

A nice write-up with the help of Claude:

The behaviour you are seeing is expected once you look at how a ReplayToken decides it is “done”.

First, the reassuring part: you are most likely not in a true perpetual replay. A ReplayToken wraps two positions, tokenAtReset (the head at the moment of reset) and currentToken (where the replay has progressed to). Events are only delivered to your handlers as replay while currentToken is behind tokenAtReset. Once you are past that point, new events are delivered as normal live events, which matches what you are seeing (“new events are processed”). So functionally the processor is working. What is not happening is the final step where the wrapper is discarded and the stored token becomes a plain GapAwareTrackingToken again.

The reason it does not unwrap is the condition used to detect “replay finished”. Simplified, the wrapper is only shed when the new token is strictly after tokenAtReset, and part of that check is currentToken.covers(tokenAtReset). For a GapAwareTrackingToken, A.covers(B) is only true when every gap A holds below B’s index is also known to B. In other words, for the replay to complete cleanly, the freshly rebuilt currentToken may not contain any gap that tokenAtReset did not already have. If it does, covers(...) returns false, the isStrictlyAfter check never passes, and the token stays wrapped. That is exactly the “gap counts differ” symptom you noticed. The differing gaps are the cause, not a side effect.

Now the EDIT, which is the more interesting question: why do the gaps differ after a full replay, when intuitively they should match?

The key point is that gaps are not a fixed property of the event stream. They are a runtime artifact of when and how the stream was read, combined with gap cleaning. A gap is a global index that was handed out but not (yet) visible when the reader passed that position, typically from a transaction that committed out of order, was slow, or rolled back. When a streaming processor reads events it uses the global sequence to track progress, and if events become visible out of order Axon tracks the gaps and revisits them later. Axon also ages gaps out using gapTimeout, maxGapOffset and gapCleaningThreshold (defaults 60000 ms, 10000 and 250 respectively). So: (Axoniq Docs, Axoniq ApiDocs)

  • tokenAtReset is the head token your application accumulated over its whole lifetime. Many of the original gaps in it had already timed out and been cleaned away long before the reset.

  • currentToken is rebuilt from scratch during the replay. As it re-reads the stream it re-derives gaps against the data and timeouts as they are now. Any index that is permanently missing below the reset head (a rolled back or never committed sequence value) gets re-discovered by the replay, even though tokenAtReset had long since forgotten it.

That is enough to leave the two tokens at the same index but with different gap sets, which blocks the covers(...) check. One extra detail that makes this stickier across your restart: the token’s gapTruncationIndex (which normally lets covers forgive old truncated gaps) is a transient field, so it is reset to 0 when the token is deserialized from token_entry. After a restart that shortcut cannot help you.

Two practical notes:

  1. Check you are on the latest 4.13 patch (4.13.2). The wasProcessedBeforeReset logic in ReplayToken was adjusted between 4.13.0 and 4.13.2 (an extra covers fallback was added), so the delivery and advancement behaviour differs slightly. It does not change the final covers-based unwrap gate, but it is worth ruling out.

  2. Judge processor state from processingStatus() rather than from the raw token class. The Event Tracker Status exposes isReplaying(), isCaughtUp() and isErrorState() per segment, which reflects real state better than whether the stored token is still a ReplayToken. (Axoniq Docs)

How to recover the current situation: if the differing gaps are permanent (indexes that will never be committed), the replay will keep re-discovering them and the wrapper will never drop on its own. The clean options are to let it be (it is cosmetic once live events flow as non-replay), or, if you want the state tidied, stop/claim the processor once it is caught up and overwrite the serialized token in token_entry with a plain GapAwareTrackingToken at the current index (dropping the wrapper), then restart. To reduce how often these spurious gaps form in the first place, keep the sequence generator allocation size at 1 so the global sequence stays predictable across instances. (Axoniq Docs)

Hi,

Yes, I’ve also asked Claude to provide a summary, however, at one point it was wrong (I guess; It stated that gaps occur if event handlers have a guard for a selective replay, a statement that Claude then backed off), so I wanted to ask here.

The explanation makes sense: the currentToken shows older gaps then the tokenAtReset, so it came across old gaps that the latest token already have “forgotten”.

But what is then the “tokenAtReset”? I’ve deleted the token table entries and restarted the application, how is there a “tokenAtReset” and where is it stored? The only thing I can imagine is that between deleting the token from the table and triggering the restart Axon inserted the token again into the table)…

The problem we faced was this: We wanted to perform a selective replay of just some handlers inside a processing group. So we used a replay context for this. The replay worked, however, now we were stuck with a replay token that contained the replay context. Every event since then has been delivered together with the replay context, which we tested for being not null in case a “real” event arrived.

But the replay context were never null, so no new events were processed as the replay context filtered for events of a specific type and aggregate ID.

We needed to delete the token from the store. Now there is still a replay token, but without a replay context, so the filter is not triggered.

Did we use the replay context incorrectly in this case?

In your event handlers, do you also check for ReplayStatus? Could you share the signature of one of those handlers where you check the Replay Context?

Gaps are tricky. It is not unlikely that the gaps during replay are different than during the normal run. It would also be helpful if you could share the replay token (the one without the Replay Context). I’d like to investigate the combination of gaps to see if it makes sense.

Lastly, Axon Framework uses a ReplayToken by default when a processor starts up with an empy token. It uses the current “HEAD” as TokenAtReset, and the TAIL as currentPosition. That way, all the events that were previously in the store at the moment the processor started are considered a Replay, and ignored by Sagas, for example. That’s why you will see a ReplayToken after clearing the table.

Hi,

Thanks for the reply. We did not check the replay status, the signature is:

@EventHandler  
fun on(event: MyEvent, @ReplayContext replayContext: MyReplayContext?) {
    ...
}

According to the documentation, replayContext should be null if we are not in a replay (and we check for this inside the event handler), but that’s not the case for these “dangling ReplayToken”.

If we add the replay status, will this then be set to “false” in case of a ReplayToken with a replay context?

Hi Matthias,

I have good news, and “bad” news:

The good:

You are right about the documentation, but unfortunately the Framework doesn’t correctly take gaps into account when injecting the ReplayContext parameter. It will provide a value even when specific events aren’t part of a replay. The ReplayStatus is reported correctly.

The “bad”:

Axon 4 is no longer publicly maintained. We focus our efforts completely on Axon 5. We only provide LTS releases for our customers, as we help them migrate to Axon 5. If you are a customer already, then please contact us on support@axoniq.io using your work email, so we can help you set things up. If you’re not, maybe you could consider? :wink:

Thanks for clarifying this, so we will add the replay status in addition to the replay context and check for the status and not the context.

We discussed the migration to 5, however, apart from the code changes we are not sure how to convert our XStream events to JSON. We need to analyse this further and come up with a migration strategy that will work for us.