Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/main/software/amazon/event/ruler/ACFinder.java
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,23 @@ private static void moveFrom(final Set<SubRuleContext> candidateSubRuleIdsForNex
tryMustNotExistMatch(candidateSubRuleIdsForNextStep, nameState, task, fieldIndex, arrayMembership,
subRuleContextGenerator);

while (fieldIndex < task.fieldCount) {
task.addStep(fieldIndex++, nameState, candidateSubRuleIdsForNextStep, arrayMembership);
// Use the field name index to jump directly to fields the NameState transitions on,
// instead of iterating all remaining fields. For each matching field, pre-check array
// consistency before enqueuing. This makes moveFrom() O(T * M) where T = number of
// transitions and M = number of array-consistent fields per name, instead of O(F)
// where F = total remaining fields.
for (final String transitionKey : nameState.getValueTransitionKeys()) {
final int[] range = task.event.getFieldRange(transitionKey);
if (range == null) {
continue;
}
final int start = Math.max(range[0], fieldIndex);
for (int i = start; i < range[1]; i++) {
final Field field = task.event.fields.get(i);
if (ArrayMembership.checkArrayConsistency(arrayMembership, field.arrayMembership) != null) {
task.addStep(i, nameState, candidateSubRuleIdsForNextStep, arrayMembership);
}
}
}
}

Expand Down
53 changes: 44 additions & 9 deletions src/main/software/amazon/event/ruler/Event.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
package software.amazon.event.ruler;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.ThreadSafe;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
Expand All @@ -20,6 +12,16 @@
import java.util.StringJoiner;
import java.util.TreeMap;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.ThreadSafe;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Prepares events for Ruler rule matching.
*
Expand Down Expand Up @@ -54,6 +56,11 @@ final class Event {
// the fields of the event
final List<Field> fields = new ArrayList<>();

// Index: field name -> [startIndex, endIndex) in the fields list.
// Fields are sorted by name (from TreeMap), so same-name fields are contiguous.
// Used by ACFinder to jump directly to relevant fields in moveFrom().
private final Map<String, int[]> fieldNameRanges = new HashMap<>();

/**
* represents the current state of an Event-constructor project
*/
Expand Down Expand Up @@ -102,6 +109,7 @@ static class Value {
fields.add(new Field(entry.getKey(), val.val, val.membership));
}
}
buildFieldNameRanges();
}

// as above, only with the JSON already parsed into a ObjectMapper tree
Expand All @@ -118,6 +126,33 @@ static class Value {
fields.add(new Field(entry.getKey(), val.val, val.membership));
}
}
buildFieldNameRanges();
}

/**
* Build an index from field name to [startIndex, endIndex) in the fields list.
* Since fields are sorted by name (from TreeMap), same-name fields are contiguous.
*/
private void buildFieldNameRanges() {
if (fields.isEmpty()) return;
String currentName = fields.get(0).name;
int start = 0;
for (int i = 1; i <= fields.size(); i++) {
String name = (i < fields.size()) ? fields.get(i).name : null;
if (!currentName.equals(name)) {
fieldNameRanges.put(currentName, new int[]{start, i});
start = i;
currentName = name;
}
}
}

/**
* Get the index range [start, end) for fields with the given name.
* Returns null if no fields have that name.
*/
int[] getFieldRange(final String fieldName) {
return fieldNameRanges.get(fieldName);
}

private void traverseObject(final JsonParser parser, final TreeMap<String, List<Value>> fieldMap, final Progress progress) throws IOException {
Expand Down
37 changes: 33 additions & 4 deletions src/main/software/amazon/event/ruler/GenericMachine.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package software.amazon.event.ruler;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonNode;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
Expand All @@ -19,6 +15,11 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonNode;

import static software.amazon.event.ruler.SetOperations.intersection;

/**
Expand Down Expand Up @@ -79,18 +80,46 @@ protected GenericMachine(GenericMachineConfiguration configuration) {
* of different elements of the same JSON array in the event are matched.
* @param jsonEvent The JSON representation of the event
* @return list of rule names that match. The list may be empty but never null.
* @deprecated Use {@link #rulesForJSONEventNonBlocking(String)} instead, which provides the same array-consistent
* matching semantics with linear performance regardless of array size. This method can exhibit O(N^2) performance
* on events with large arrays matching multi-field rules.
*/
@Deprecated
@SuppressWarnings("unchecked")
public List<T> rulesForJSONEvent(final String jsonEvent) throws Exception {
final Event event = new Event(jsonEvent, this);
return (List<T>) ACFinder.matchRules(event, this, subRuleContextGenerator);
}

/**
* @deprecated Use {@link #rulesForJSONEventNonBlocking(String)} instead.
*/
@Deprecated
@SuppressWarnings("unchecked")
public List<T> rulesForJSONEvent(final JsonNode eventRoot) {
final Event event = new Event(eventRoot, this);
return (List<T>) ACFinder.matchRules(event, this, subRuleContextGenerator);
}

/**
* Return any rules that match the fields in the event, with array-consistent matching
* and linear performance regardless of array size.
*
* <p>This method walks the JSON tree structurally, splitting on object arrays and matching
* per-element. It avoids the O(N^2) step queue growth that {@link #rulesForJSONEvent(String)}
* can exhibit on events with large arrays matching multi-field rules.</p>
*
* <p>Semantically equivalent to {@link #rulesForJSONEvent(String)} — same rules match
* same events — but with guaranteed linear performance in event size.</p>
*
* @param jsonEvent The JSON representation of the event
* @return list of rule names that match. The list may be empty but never null.
*/
@SuppressWarnings("unchecked")
public List<T> rulesForJSONEventNonBlocking(final String jsonEvent) throws Exception {
return (List<T>) StructuredFinder.matchRules(jsonEvent, this, subRuleContextGenerator);
}

/**
* Return any rules that match the fields in the event.
*
Expand Down
11 changes: 10 additions & 1 deletion src/main/software/amazon/event/ruler/NameState.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package software.amazon.event.ruler;

import javax.annotation.concurrent.ThreadSafe;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import javax.annotation.concurrent.ThreadSafe;

/**
* Represents a state in the machine.
*
Expand Down Expand Up @@ -48,6 +49,14 @@ ByteMachine getTransitionOn(final String token) {
return valueTransitions.get(token);
}

/**
* Get all field names that have value transitions from this state.
* Used by ACFinder to iterate only relevant fields in moveFrom().
*/
Set<String> getValueTransitionKeys() {
return valueTransitions.keySet();
}

/**
* Get all the terminal patterns that have led to this NameState. "Terminal" means the pattern was used by the last
* field of a rule to lead to this NameState, and thus, the rule's matching criteria have been fully satisfied.
Expand Down
186 changes: 186 additions & 0 deletions src/main/software/amazon/event/ruler/StructuredFinder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package software.amazon.event.ruler;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Array-consistent matching without the O(N^2) step queue growth of ACFinder.
*
* <p>Instead of flattening the entire event into a single field list and tracking
* array membership (which causes quadratic blowup when large arrays match
* multi-field rules), this class walks the JSON tree structurally:</p>
*
* <ul>
* <li>Scalar fields and primitive arrays are accumulated into a field context</li>
* <li>Object arrays trigger per-element recursion with inherited context</li>
* <li>At leaf level (no more object arrays), matches using {@link Finder}
* (non-AC, with seenSteps dedup)</li>
* </ul>
*
* <p>Array consistency is correct by construction: each Finder call only sees
* fields from one element per array level, so cross-element matching is
* impossible.</p>
*
* <p>For events without object arrays (e.g., primitive arrays + scalars), falls
* back to {@link ACFinder} which handles those cases efficiently.</p>
*/
class StructuredFinder {

private static final ObjectMapper MAPPER = new ObjectMapper();

private StructuredFinder() {}

/**
* Match rules against an event using structured tree walking.
*
* @param json the event as a JSON string
* @param machine the compiled rule machine
* @param gen sub-rule context generator
* @return list of matched rule names (empty if none, never null)
*/
static List<Object> matchRules(final String json, final GenericMachine<?> machine,
final SubRuleContext.Generator gen) throws Exception {
final JsonNode root = MAPPER.readTree(json);
if (!root.isObject()) {
return new ArrayList<>();
}

// If no object arrays anywhere, use Finder directly.
// Without object arrays there is no array-consistency concern,
// so the non-AC Finder (with seenSteps dedup) is both correct and fast.
// If no object arrays anywhere, use ACFinder directly.
// With the step-pruning fix, ACFinder is efficient for primitive arrays
// because it skips irrelevant fields during JSON parsing (isFieldStepUsed)
// and prunes steps for non-matching field names (getTransitionOn).
if (!hasObjectArray(root)) {
final Event event = new Event(json, machine);
return ACFinder.matchRules(event, machine, gen);
}

final Set<Object> matched = new HashSet<>();
walkObject(root, "", new ArrayList<>(), machine, gen, matched);
return new ArrayList<>(matched);
}

private static boolean hasObjectArray(final JsonNode node) {
final Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
final JsonNode val = fields.next().getValue();
if (val.isArray()) {
for (final JsonNode elem : val) {
if (elem.isObject()) return true;
}
}
if (val.isObject() && hasObjectArray(val)) return true;
}
return false;
}

/**
* Walk a JSON object node. Scalars and primitive array values go into the
* field context. Sub-objects are recursed into. Object arrays trigger
* per-element recursion.
*/
private static void walkObject(final JsonNode node, final String prefix,
final List<String> inherited,
final GenericMachine<?> machine,
final SubRuleContext.Generator gen,
final Set<Object> matched) throws Exception {
final List<String> ctx = new ArrayList<>(inherited);
final List<ObjArray> objArrays = new ArrayList<>();

final Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
final Map.Entry<String, JsonNode> field = fields.next();
final String name = field.getKey();
final JsonNode val = field.getValue();
final String path = prefix.isEmpty() ? name : prefix + "." + name;

if (!machine.isFieldStepUsed(name)) continue;

if (val.isObject()) {
walkObject(val, path, ctx, machine, gen, matched);
} else if (val.isArray()) {
classifyArray(val, path, ctx, objArrays);
} else if (val.isTextual()) {
ctx.add(path);
ctx.add("\"" + val.asText() + "\"");
} else if (!val.isNull()) {
ctx.add(path);
ctx.add(val.asText());
}
}

// No object arrays at this level: match with accumulated context
if (objArrays.isEmpty()) {
if (!ctx.isEmpty()) {
matched.addAll(Finder.rulesForEvent(sortPairs(ctx), machine, gen));
}
return;
}

// Per-element recursion for each object array
for (final ObjArray arr : objArrays) {
for (final JsonNode elem : arr.elements) {
if (elem.isObject()) {
walkObject(elem, arr.path, ctx, machine, gen, matched);
}
}
}
}

/**
* Classify array elements as objects (for per-element recursion) or
* primitives (added directly to context).
*/
private static void classifyArray(final JsonNode arrayNode, final String path,
final List<String> ctx,
final List<ObjArray> objArrays) {
final List<JsonNode> objElements = new ArrayList<>();
for (final JsonNode elem : arrayNode) {
if (elem.isObject()) {
objElements.add(elem);
} else if (elem.isTextual()) {
ctx.add(path);
ctx.add("\"" + elem.asText() + "\"");
} else if (!elem.isNull()) {
ctx.add(path);
ctx.add(elem.asText());
}
}
if (!objElements.isEmpty()) {
objArrays.add(new ObjArray(path, objElements));
}
}

/** Sort name/value pairs by name for Finder (which expects sorted input). */
private static String[] sortPairs(final List<String> pairs) {
if (pairs.size() <= 2) return pairs.toArray(new String[0]);
final TreeMap<String, List<String>> sorted = new TreeMap<>();
for (int i = 0; i < pairs.size(); i += 2) {
sorted.computeIfAbsent(pairs.get(i), k -> new ArrayList<>()).add(pairs.get(i + 1));
}
final List<String> result = new ArrayList<>();
for (final Map.Entry<String, List<String>> e : sorted.entrySet()) {
for (final String v : e.getValue()) {
result.add(e.getKey());
result.add(v);
}
}
return result.toArray(new String[0]);
}

private static class ObjArray {
final String path;
final List<JsonNode> elements;
ObjArray(final String p, final List<JsonNode> e) { path = p; elements = e; }
}
}
Loading
Loading