Class JdbcIdempotencyStore
- All Implemented Interfaces:
IdempotencyStore
The one InMemoryIdempotencyStore cannot replace. An in-process store deduplicates
within one JVM, which is enough when duplicates arrive seconds apart on the same instance and
useless the moment there are three instances behind one queue: the redelivery lands on a
different machine from the original, finds an empty map, and does the work again. Charging a
card twice is not a caching problem.
The failure this has and the in-memory one does not
A shared store outlives the process using it, and that cuts both ways. If a consumer claims a message and then dies mid-handler, the claim stays in the table. With a naive implementation every redelivery of that message — forever — is discarded as a duplicate, and a crash that should have cost one retry has silently deleted a message instead. The in-memory store never had this problem only because a crash wiped it.
So a claim here is a lease, not a lock. It expires after
claimTimeout, after which another consumer may take it over. That makes the timeout a
real decision: too short and two consumers process the same message concurrently while the
first is still working; too long and a crashed consumer stalls that message for the duration.
It should comfortably exceed the slowest handler, and the default is deliberately generous.
Confirmations are kept for retention and then forgotten, because a table that
remembers every identifier ever seen is a disk-space incident waiting to happen. A duplicate
arriving after retention is handled again — retention is the window within which duplicates
are actually expected to arrive, not a permanent record.
What it does not do
This deduplicates the delivery, not the work. If the handler writes to a different database
from this table, a crash between the write committing and confirm(java.lang.String) landing leaves the
work done and unrecorded, and the redelivery repeats it. Putting this table in the same
database as the handler's writes, in the handler's transaction, is what closes that gap — and
doing so is the caller's decision, since only the caller knows what the handler touches.
JdbcIdempotencyStore store = new JdbcIdempotencyStore(dataSource);
store.createSchemaIfAbsent(); // development and tests only
mq.consume("orders.new", OrderPlaced.class,
ConsumerOptions.prefetch(20).idempotent(store),
message -> payments.charge(message.payload()));
Schedule purgeExpired() — nothing here deletes rows on its own, because a store
that deletes on the hot path pays for it on every single message.
-
Nested Class Summary
Nested classes/interfaces inherited from interface org.acemq.amqp.api.IdempotencyStore
IdempotencyStore.ClaimResult -
Constructor Summary
ConstructorsConstructorDescriptionJdbcIdempotencyStore(DataSource dataSource) JdbcIdempotencyStore(DataSource dataSource, Duration retention, Duration claimTimeout, String table) -
Method Summary
Modifier and TypeMethodDescriptionbooleanTakes ownership of a message identifier, if nobody else has it.voidRecords that the work for a claimed identifier completed.voidCreates the table if it is not already there.booleanisConfirmed(String messageId) intDeletes rows nobody is bound by any more: confirmations past their retention, and claims whose lease has run out.voidGives up a claim without recording completion, so the message can be tried again.longsize()toString()Methods inherited from class java.lang.Object
clone, equals, finalize, getClass, hashCode, notify, notifyAll, wait, wait, waitMethods inherited from interface org.acemq.amqp.api.IdempotencyStore
tryClaim
-
Constructor Details
-
JdbcIdempotencyStore
- Parameters:
dataSource- where the table lives
-
JdbcIdempotencyStore
public JdbcIdempotencyStore(DataSource dataSource, Duration retention, Duration claimTimeout, String table) - Parameters:
dataSource- where the table livesretention- how long a confirmed identifier is remembered; duplicates arriving later than this are handled againclaimTimeout- how long one consumer may hold a message before another may take it over. Must exceed the slowest handler, or two consumers will process the same message at oncetable- table name; must be a plain SQL identifier
-
-
Method Details
-
createSchemaIfAbsent
public void createSchemaIfAbsent()Creates the table if it is not already there.For development and tests. In production the table belongs in the migration tool that owns the rest of the schema.
- Throws:
AceMqException- if the schema cannot be created
-
claim
Description copied from interface:IdempotencyStoreTakes ownership of a message identifier, if nobody else has it.- Specified by:
claimin interfaceIdempotencyStore- Parameters:
messageId- the identifier, normallyEnvelope.id()- Returns:
truewhen the caller now owns this identifier and should do the work;falsewhen it is already confirmed or claimed elsewhere. Which of the two it is matters — only a confirmed one is a duplicate — so consumers callIdempotencyStore.tryClaim(java.lang.String)
-
confirm
Description copied from interface:IdempotencyStoreRecords that the work for a claimed identifier completed.After this,
IdempotencyStore.claim(java.lang.String)returnsfalsefor the identifier until the store forgets it, which is what stops a redelivery from repeating the work.- Specified by:
confirmin interfaceIdempotencyStore- Parameters:
messageId- the identifier being confirmed
-
release
Description copied from interface:IdempotencyStoreGives up a claim without recording completion, so the message can be tried again.Called when a handler fails. Without it a failed attempt would poison the identifier and the retry would be discarded as a duplicate, which turns a transient failure into permanent message loss.
- Specified by:
releasein interfaceIdempotencyStore- Parameters:
messageId- the identifier being released
-
isConfirmed
- Specified by:
isConfirmedin interfaceIdempotencyStore- Parameters:
messageId- the identifier to test- Returns:
- whether the work for this identifier is already known to have completed
-
purgeExpired
public int purgeExpired()Deletes rows nobody is bound by any more: confirmations past their retention, and claims whose lease has run out.Schedule this — hourly is ample. Nothing on the message path deletes anything, because a store that tidies up on the hot path makes every message pay for it.
- Returns:
- how many rows were removed
-
size
public long size()- Returns:
- how many identifiers the table currently holds, expired ones included
-
toString
-