CEP In Flink (2) - Matching CEP Rules

Liao Jiayi Liao Jiayi

How Apache Flink matches CEP rules.

Translated from Chinese with AI · Read the original

The previous post explained that Flink’s implementation follows this paper. Let’s first examine the data structures built on that theory.

Data Structures

ShareBuffer

After parsing, a CEP rule is essentially a nondeterministic state machine. During matching, each state corresponds to one or more elements. Consider three states, omitting their where conditions:

Pattern.begin("A").followedBy("B").followedBy("C")

The diagram is: cep2-1

This structure is called SharedBuffer. Each event is represented by a NodeId composed of an EventId and pageName, the state name. Each NodeId maps to a SharedBufferNode containing edges to preceding events. Reference counting controls its lifetime. The reason for this structure is discussed below.

NFAState

Another important structure is NFAState, which retains the current matching state. Its two key variables are:

private Queue<ComputationState> partialMatches; // 正在进行的匹配
private Queue<ComputationState> completedMatches; // 完成的匹配

For each new event, partialMatches is traversed to see whether any partial match can become a completed match.


Matching Process

Given these structures, the process is fairly predictable. On receiving an event, traverse partialMatches, compute each match’s next state, and add the current event to SharedBuffer. Reaching a final state turns a partial match into a completed match.

final PriorityQueue<ComputationState> newPartialMatches = new PriorityQueue<>(NFAState.COMPUTATION_STATE_COMPARATOR);
final PriorityQueue<ComputationState> potentialMatches = new PriorityQueue<>(NFAState.COMPUTATION_STATE_COMPARATOR);
// iterate over all current computations
for (ComputationState computationState : nfaState.getPartialMatches()) {
final Collection<ComputationState> newComputationStates = computeNextStates(
sharedBuffer,
computationState,
event,
event.getTimestamp());
if (newComputationStates.size() != 1) {
nfaState.setStateChanged();
} else if (!newComputationStates.iterator().next().equals(computationState)) {
nfaState.setStateChanged();
}
//delay adding new computation states in case a stop state is reached and we discard the path.
final Collection<ComputationState> statesToRetain = new ArrayList<>();
//if stop state reached in this path
boolean shouldDiscardPath = false;
for (final ComputationState newComputationState : newComputationStates) {
if (isFinalState(newComputationState)) {
potentialMatches.add(newComputationState);
} else if (isStopState(newComputationState)) {
//reached stop state. release entry for the stop state
shouldDiscardPath = true;
sharedBuffer.releaseNode(newComputationState.getPreviousBufferEntry());
} else {
// add new computation state; it will be processed once the next event arrives
statesToRetain.add(newComputationState);
}
}
if (shouldDiscardPath) {
// a stop state was reached in this branch. release branch which results in removing previous event from
// the buffer
for (final ComputationState state : statesToRetain) {
sharedBuffer.releaseNode(state.getPreviousBufferEntry());
}
} else {
newPartialMatches.addAll(statesToRetain);
}
}

In NFA.java, computeNextStates determines the next states for the current partial match. isFinalState and isStopState then classify and handle them, while nodes in SharedBuffer are released.

We can simplify computeNextStates to the following:

final OutgoingEdges<T> outgoingEdges = createDecisionGraph(context, computationState, event.getEvent())
final List<StateTransition<T>> edges = outgoingEdges.getEdges();
final List<ComputationState> resultingComputationStates = new ArrayList<>();
for (StateTransition<T> edge : edges) {
switch (edge.getAction()) {
case IGNORE:
// ...处理ignore
break;
case TAKE:
// ...处理take
break;
}
}

createDecisionGraph evaluates the current event against the transition graph described in CEP In Flink (1), producing eligible outgoingEdges. Each edge has an action and targetState. Traversing them creates new states returned to the previous code fragment.


Matching Optimization

As events arrive, more partial matches coexist. To avoid duplicating events, SharedBuffer gives each SharedBufferNode versioned SharedBufferEdges pointing to different preceding NodeIds, as shown:

CEP diagram

Events arrive in the order e0 -> e1 -> e2 -> e3. Here, e3 follows both e1 and e2, distinguished by version numbers. Reality is slightly more complex, for example version 1.0 is omitted from the diagram, but the overall optimization follows the same idea.