shashank created CAMEL-26708:
--------------------------------

             Summary: camel-sql - JdbcKeyValueRepository: two nodes can both 
win putIfAbsent, and reading an expired entry deletes a live one
                 Key: CAMEL-26708
                 URL: https://issues.apache.org/jira/browse/CAMEL-26708
             Project: Camel
          Issue Type: Bug
          Components: camel-sql
            Reporter: shashank
            Assignee: shashank


Two {{JdbcKeyValueRepository}} instances on one database (two nodes of a 
cluster) can both "win" {{putIfAbsent}} for the same key, and reading an 
expired entry can delete the entry another node has just stored for that key. 
The repository was added in 4.23.0; the KeyValueRepository page lists it as 
distributed-safe for clusters.

*putIfAbsent.* When the INSERT fails with a {{DuplicateKeyException}}, the 
method marks its transaction rollback-only and reads the key again inside that 
same transaction, returning the result. {{null}} means "the put succeeded" 
({{KeyValueRepository}} javadoc), but the INSERT is rolled back, so nothing is 
stored:
* Under REPEATABLE READ (the MySQL/MariaDB default; also H2) the re-read uses 
the snapshot taken by the first SELECT, which does not show the winner's row. 
Both callers get {{null}}.
* Under READ COMMITTED it returns {{null}} when the winner deletes its row 
between the duplicate key and the re-read, for example because the Idempotent 
Consumer removes the key of a failed message ({{removeOnFailure}}).
* On PostgreSQL the re-read fails, as the transaction is aborted after the 
error.

{{KeyValueIdempotentRepository.add}} is {{putIfAbsent(...) == null}}, so the 
same message is processed on both nodes; the {{camel-state-store}} 
{{putIfAbsent}} operation reports the key acquired on both.

Short trace (REPEATABLE READ): A {{SELECT}} key: no row. B {{putIfAbsent}}: 
{{SELECT}}, {{INSERT}}, commit, returns null. A {{INSERT}}: duplicate key, 
re-read in its snapshot: no row, returns null. Two winners, B's value stored.

*Lazy expiry.* {{doGet}} reads the row, and if it has expired it runs {{DELETE 
FROM CAMEL_KEYVALUE WHERE ITEM_KEY = ?}}. Between the SELECT and the DELETE 
another node can store the key again ({{put}}, {{putIfAbsent}}); the DELETE 
then removes that live entry. The writer was told its write succeeded, and a 
third caller's {{putIfAbsent}} also returns {{null}}. Exposure needs entries 
written with a TTL ({{camel-state-store}}, Cache EIP).

Both reproduce deterministically on the embedded H2 database with two 
repositories and a {{JdbcTemplate}} that pauses one node's statement, and also 
in a plain concurrent stress loop without pausing (hundreds of double wins per 
20,000 races on H2).

Suggested fix:
* {{putIfAbsent}}: let the duplicate key roll back the transaction and read the 
key again in a new transaction (return the stored value, or insert again if it 
was deleted), with a small bound on the attempts. When it joined a transaction 
of the caller, rethrow the duplicate key.
* {{doGet}}: delete the expired row with the expiry condition in the WHERE 
clause ({{... WHERE ITEM_KEY = ? AND EXPIRES_AT > 0 AND EXPIRES_AT <= ?}}, as 
{{deleteExpiredString}} already does for the whole table), as a new 
configurable statement.

_Filed with Claude Code on behalf of allthingssecurity._



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to