A versioned tenant update changes a row only when its owner, identifier and expected version all match.
Spring JDBC versioned tenant update: inspect the affected row count
A zero-row update is a decision
The fixture issues one parameterized UPDATE with tenant_id, receipt_id and version in the WHERE clause. If it changes one row, the new version is returned. If it changes zero, the service returns 409 and writes no event or replay record. The stale-version test asks for version 3 while the stored row is version 1; the state remains new. A wrong tenant is denied before SQL, and the tenant predicate is still present as a storage boundary.
This is compare-and-swap at one row. The earlier If-Match lesson uses synchronized in-memory state; it is not a database atomicity proof. JPA optimistic locking is another way to express a version contract, with different flush and exception timing.
Separate absent from stale only when safe
A zero affected count can mean wrong version, missing ID or missing tenant ownership. The fixture returns one conflict response and does not disclose which condition occurred. A public API should choose its disclosure policy deliberately. Index the actual tenant-and-ID access path, then inspect the plan on the target database. H2 results cannot settle lock waits or deadlock behavior under concurrent load.
Checked source
int changed = jdbc.update(
"update receipt_state set state = ?, version = version + 1 "
+ "where tenant_id = ? and receipt_id = ? and version = ?",
nextState, trustedTenant, receiptId, expectedVersion);
if (changed != 1) throw new ResponseStatusException(HttpStatus.CONFLICT);Verification boundary
TenantReceiptCommandFlowTest.staleVersionLeavesNoOutboxRow and TenantReceiptCommandFlowTest.authorizedWriteCommitsReceiptOutboxAndReplayRecord in the downloadable Spring source kit. The excerpt is shortened; the kit contains the complete test.
Costs and limits
The H2 fixture checks one stale request and one accepted request. It does not run competing threads or prove behavior on the future target database. The update is a bounded indexed point operation in the fixture; verify real index selectivity and lock duration separately.
Common Mistakes
- Do not select, compare and update in separate unprotected steps.
- Do not treat zero affected rows as a successful write.
- Do not reveal another tenant's row while explaining a conflict.
Read next
Spring tenant command transaction: keep state, event and replay record together, Spring If-Match writes: reject a stale receipt revision, Spring JPA optimistic locking: reject a stale stock update, Spring JdbcTemplate tenant predicates: put ownership in the SQL query.
