Skip to content

With other Spring annotations

Garvit Joshi edited this page Oct 9, 2026 · 1 revision

Spring applies annotations such as @Transactional through advice, code that runs around the method. When a method has several of them, the advice with the lower order runs first and wraps the rest. Locksmith's advice runs at order Ordered.LOWEST_PRECEDENCE - 1:

  • After Spring Security's method checks, such as @PreAuthorize and @Secured, so a call they deny never reaches Redis.
  • Before @Transactional and @Cacheable at their default order, so a transaction that starts at the locked method commits before the lock is released. Leave @Transactional at its default order: an order below LOWEST_PRECEDENCE - 1 puts the transaction outside the lock, and the lock is then released before the commit.
  • @Async, and Spring Framework's @Retryable and @ConcurrencyLimit, always run outside Locksmith, so each retry takes the lock again.

Transactions

A transaction the caller already started is not covered. The locked method joins it, and the lock is released when the method returns, before the caller commits; in that gap another instance can take the lock and read data that is not committed yet. Put @Transactional on the locked method itself, or on a method it calls, not only on its caller. If a caller may already hold a transaction, use @Transactional(propagation = Propagation.REQUIRES_NEW) on the locked method: its own transaction then commits before the unlock, and its changes stay committed even if the caller's transaction later rolls back.

Caching

@Cacheable does not skip the lock. The cache runs inside the lock, so every call takes the lock, a cache hit included. With the default try-once, two callers that hit the cache for one key at the same moment collide, and one of them gets LockNotAcquiredException: in a test, 1,760 of 2,000 concurrent hits on a cached key failed. To serve cache hits without the lock, put @Cacheable on a method of another bean that calls the locked method.

@Async and methods that return a future

A method that returns a future must be @Async, on the method or on the class that declares it, and declare Future or CompletableFuture; otherwise startup fails. Spring's @Async interceptor runs first, so Locksmith runs on the worker thread, and the lock and permit cover the whole method body:

@Async
@DistributedLock(key = "order:#{#orderId}", waitTime = "5s")
public CompletableFuture<Receipt> process(String orderId) {
    return CompletableFuture.completedFuture(Receipt.from(client.send(orderId)));
}

The lock is released when the method body returns. Work the body starts but does not wait for, such as a future it returns before that future completes, runs on without the lock. With SKIP, the caller's future completes with null.

Future and CompletableFuture are the only future types Spring's @Async returns: it fails every call of a method declared to return CompletionStage, and a CompletableFuture subclass fails with a ClassCastException after the method has run. For the same reason, an annotated @Async method that returns anything but void, Kotlin Unit, Future or CompletableFuture fails startup. @Async on a subclass does not count for a method the subclass inherits, although Spring would run that method asynchronously: put @Async on the method there.

@PostAuthorize and @PostFilter

@PostAuthorize and @PostFilter see what SKIP or HANDLER returns. SKIP returns null for a collection type, which @PostFilter rejects; on such a method use HANDLER and return an empty collection.

Related limitations

Clone this wiki locally