Skip to content

Commit b9c6edf

Browse files
vanroguclaude
andauthored
Document why append may pair RETURNING rows with its input positionally (#120)
append walks the result set in lockstep with the list it was given, while importEvents in the same class keys on the id it supplied and says why. Only one of the two was explained, so the other read as an oversight. It is not one: the NOT EXISTS is uncorrelated, so PostgreSQL evaluates it once as an InitPlan and applies it as a One-Time Filter over the VALUES list, and every node in that plan preserves order. Verified on PG16/17/18. Correlating that predicate would let the planner turn it into an anti-join, which reorders -- measured at 1999 of 2000 rows mispaired, with positions still ascending, so a defensive monotonicity check would not catch it either. Javadoc only, no behaviour change. Claude-Session: https://claude.ai/code/session_01JBdkFdTW5xgSmwMkSeiG9H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3946f82 commit b9c6edf

1 file changed

Lines changed: 20 additions & 0 deletions

File tree

sliceworkz-eventstore-infra-postgres/src/main/java/org/sliceworkz/eventstore/infra/postgres/PostgresEventStorageImpl.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,26 @@ private long advisoryLockKey ( String scope ) {
13291329
}
13301330
}
13311331

1332+
/**
1333+
* Appends events, pairing each {@code RETURNING} row with the input event at the same index.
1334+
* <p>
1335+
* Nothing promises that order — {@code RETURNING} is not in the SQL standard, and PostgreSQL
1336+
* documents only "a row per row actually inserted". It holds because the {@code NOT EXISTS} below
1337+
* is <em>uncorrelated</em>: it references the events table and bound parameters, never a column of
1338+
* {@code new_events}. PostgreSQL therefore evaluates it once as an InitPlan and applies it as a
1339+
* One-Time Filter over the VALUES list, and every node in that plan preserves order. Being
1340+
* all-or-nothing is also what makes the {@code storedEvents.size() != events.size()} check below a
1341+
* sound conflict detector — the statement inserts every row or none, never a subset.
1342+
* <p>
1343+
* Correlating that predicate with {@code new_events} would let the planner turn it into an
1344+
* anti-join, which reorders and would silently mispair every event with another's id and position.
1345+
* Note the rows would still carry ascending positions, since the reordering precedes the insert, so
1346+
* a defensive monotonicity check would not catch it.
1347+
* <p>
1348+
* {@code importEvents} keys on the id it supplied instead, because {@code ON CONFLICT} makes its
1349+
* result a subset of its input. That is not an option here: from PG18 the id comes from a
1350+
* server-side {@code uuidv7()}, and {@code RETURNING} cannot return a source-only ordinal to key on.
1351+
*/
13321352
@Override
13331353
public List<StoredEvent> append(AppendCriteria appendCriteria, Optional<EventStreamId> streamId, List<EventToStore> events) {
13341354
checkNotClosed();

0 commit comments

Comments
 (0)