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:
@@ -110,6 +110,7 @@ public class ExchangeMatchEvaluator implements ConditionEvaluator<ExchangeMatchC
|
||||
50,
|
||||
"startTime",
|
||||
"asc", // asc so we process oldest first
|
||||
null, // afterExecutionId (wired in Task 1.5)
|
||||
envSlug
|
||||
);
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ public class SearchController {
|
||||
application, null,
|
||||
offset, limit,
|
||||
sortField, sortDir,
|
||||
null,
|
||||
env.slug()
|
||||
);
|
||||
|
||||
|
||||
@@ -124,7 +124,13 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
||||
conditions.add("tenant_id = ?");
|
||||
params.add(tenantId);
|
||||
|
||||
if (request.timeFrom() != null) {
|
||||
if (request.timeFrom() != null && request.afterExecutionId() != null) {
|
||||
// composite predicate: strictly-after in (start_time, execution_id) tuple order
|
||||
conditions.add("(start_time > ? OR (start_time = ? AND execution_id > ?))");
|
||||
params.add(Timestamp.from(request.timeFrom()));
|
||||
params.add(Timestamp.from(request.timeFrom()));
|
||||
params.add(request.afterExecutionId());
|
||||
} else if (request.timeFrom() != null) {
|
||||
conditions.add("start_time >= ?");
|
||||
params.add(Timestamp.from(request.timeFrom()));
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_withNoFilters_returnsAllExecutions() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -130,7 +130,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byStatus_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"FAILED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -145,7 +145,7 @@ class ClickHouseSearchIndexIT {
|
||||
// Time window covering exec-1 and exec-2 but not exec-3
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, baseTime, baseTime.plusMillis(1500), null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -158,7 +158,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_fullTextSearch_findsInErrorMessage() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "NullPointerException", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -170,7 +170,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_fullTextSearch_findsInInputBody() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "12345", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -182,7 +182,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInBody_searchesProcessorBodies() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, "Hello World", null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -194,7 +194,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInHeaders_searchesProcessorHeaders() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, "secret-token", null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -206,7 +206,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_textInErrors_searchesErrorFields() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, "Foo.bar",
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -218,7 +218,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_withHighlight_returnsSnippet() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, "NullPointerException", null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -230,7 +230,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_pagination_works() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 2, null, null, null);
|
||||
null, null, null, null, null, 0, 2, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -244,7 +244,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byApplication_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, "other-app", null, 0, 50, null, null, null);
|
||||
null, null, null, "other-app", null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -256,7 +256,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byAgentIds_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, List.of("agent-b"), 0, 50, null, null, null);
|
||||
null, null, null, null, List.of("agent-b"), 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -268,7 +268,7 @@ class ClickHouseSearchIndexIT {
|
||||
void count_returnsMatchingCount() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"COMPLETED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
long count = searchIndex.count(request);
|
||||
|
||||
@@ -279,7 +279,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_multipleStatusFilter_works() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
"COMPLETED,FAILED", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -290,7 +290,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byCorrelationId_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, null, null, "corr-1", null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
@@ -302,7 +302,7 @@ class ClickHouseSearchIndexIT {
|
||||
void search_byDurationRange_filtersCorrectly() {
|
||||
SearchRequest request = new SearchRequest(
|
||||
null, null, null, 300L, 600L, null, null, null, null, null,
|
||||
null, null, null, null, null, 0, 50, null, null, null);
|
||||
null, null, null, null, null, 0, 50, null, null, null, null);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(request);
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ class ClickHouseChunkPipelineIT {
|
||||
null, null, null, null, null, null,
|
||||
"ORD-123", null, null, null,
|
||||
null, null, null, null, null,
|
||||
0, 50, null, null, null));
|
||||
0, 50, null, null, null, null));
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1");
|
||||
assertThat(result.data().get(0).status()).isEqualTo("COMPLETED");
|
||||
@@ -168,7 +168,7 @@ class ClickHouseChunkPipelineIT {
|
||||
null, null, null, null, null, null,
|
||||
null, "ABC-123", null, null,
|
||||
null, null, null, null, null,
|
||||
0, 50, null, null, null));
|
||||
0, 50, null, null, null, null));
|
||||
assertThat(bodyResult.total()).isEqualTo(1);
|
||||
|
||||
// Verify iteration data in processor_executions
|
||||
|
||||
@@ -28,6 +28,9 @@ import java.util.List;
|
||||
* @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(
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user