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

@@ -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
);

View File

@@ -71,6 +71,7 @@ public class SearchController {
application, null,
offset, limit,
sortField, sortDir,
null,
env.slug()
);

View File

@@ -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()));
}