search: SearchRequest.afterExecutionId — composite (startTime, execId) predicate

Adds an optional afterExecutionId field to SearchRequest. When combined
with a non-null timeFrom, ClickHouseSearchIndex applies a strictly-after
tuple predicate (start_time > ts OR (start_time = ts AND execution_id > id))
so same-millisecond exchanges can be consumed exactly once across ticks.

When afterExecutionId is null, timeFrom keeps its existing >= semantics —
no behaviour change for any current caller.

Also adds the SearchRequest.withCursor(ts, id) wither. Threads the field
through existing withInstanceIds / withEnvironment witheres. All existing
positional call-sites (SearchController, ExchangeMatchEvaluator,
ClickHouseSearchIndexIT, ClickHouseChunkPipelineIT) pass null for the new
slot.

Task 1.2 of docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md.
The evaluator-side wiring that actually supplies the cursor is Task 1.5.
This commit is contained in:
hsiegeln
2026-04-22 15:49:05 +02:00
parent 6fa8e3aa30
commit b41f34c090
6 changed files with 69 additions and 41 deletions

View File

@@ -9,26 +9,29 @@ import java.util.List;
* All filter fields are nullable/optional. When null, the filter is not applied.
* The compact constructor validates and normalizes pagination parameters.
*
* @param status execution status filter (COMPLETED, FAILED, RUNNING)
* @param timeFrom inclusive start of time range
* @param timeTo exclusive end of time range
* @param durationMin minimum duration in milliseconds (inclusive)
* @param durationMax maximum duration in milliseconds (inclusive)
* @param correlationId exact correlation ID match
* @param text global full-text search across all text fields
* @param textInBody full-text search scoped to exchange bodies
* @param textInHeaders full-text search scoped to exchange headers
* @param textInErrors full-text search scoped to error messages and stack traces
* @param routeId exact match on route_id
* @param instanceId exact match on instance_id
* @param processorType matches processor_types array via has()
* @param applicationId exact match on application_id
* @param instanceIds list of instance IDs for an IN clause (only set when drilling down to specific agents)
* @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500)
* @param sortField column to sort by (default: startTime)
* @param sortDir sort direction: asc or desc (default: desc)
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
* @param status execution status filter (COMPLETED, FAILED, RUNNING)
* @param timeFrom inclusive start of time range
* @param timeTo exclusive end of time range
* @param durationMin minimum duration in milliseconds (inclusive)
* @param durationMax maximum duration in milliseconds (inclusive)
* @param correlationId exact correlation ID match
* @param text global full-text search across all text fields
* @param textInBody full-text search scoped to exchange bodies
* @param textInHeaders full-text search scoped to exchange headers
* @param textInErrors full-text search scoped to error messages and stack traces
* @param routeId exact match on route_id
* @param instanceId exact match on instance_id
* @param processorType matches processor_types array via has()
* @param applicationId exact match on application_id
* @param instanceIds list of instance IDs for an IN clause (only set when drilling down to specific agents)
* @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500)
* @param sortField column to sort by (default: startTime)
* @param sortDir sort direction: asc or desc (default: desc)
* @param afterExecutionId when combined with a non-null {@code timeFrom}, applies the composite predicate
* {@code (start_time > timeFrom) OR (start_time = timeFrom AND execution_id > afterExecutionId)}.
* When null, {@code timeFrom} is applied as a plain {@code >=} lower bound (existing behaviour).
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
*/
public record SearchRequest(
String status,
@@ -50,6 +53,7 @@ public record SearchRequest(
int limit,
String sortField,
String sortDir,
String afterExecutionId,
String environment
) {
@@ -92,7 +96,7 @@ public record SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors,
routeId, instanceId, processorType, applicationId, resolvedInstanceIds,
offset, limit, sortField, sortDir, environment
offset, limit, sortField, sortDir, afterExecutionId, environment
);
}
@@ -102,7 +106,23 @@ public record SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors,
routeId, instanceId, processorType, applicationId, instanceIds,
offset, limit, sortField, sortDir, env
offset, limit, sortField, sortDir, afterExecutionId, env
);
}
/**
* Create a copy with a composite {@code (start_time, execution_id)} cursor.
* <p>
* The resulting request applies a strictly-after tuple predicate
* {@code (start_time > ts) OR (start_time = ts AND execution_id > afterExecutionId)},
* enabling exactly-once consumption of same-millisecond exchanges across scheduler ticks.
*/
public SearchRequest withCursor(Instant ts, String afterExecutionId) {
return new SearchRequest(
status, ts, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors,
routeId, instanceId, processorType, applicationId, instanceIds,
offset, limit, sortField, sortDir, afterExecutionId, environment
);
}
}