Production bug report: "Why does the webhook error say 'Executing an update/delete query'?"
That's a JPA internal error. It should never reach users. But there it was, stored in the database and visible in the admin panel. Here's how an innocent-looking @Async method broke everything.
The Code That Looked Fine
@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {
private final WebhookRepository webhookRepository;
private final WebhookEventRepository webhookEventRepository;
@Async
public void fireEvent(Long userId, String event, Map<String, Object> payload) {
List<WebhookEntity> webhooks = webhookRepository
.findByUserIdAndEnabledTrue(userId);
for (WebhookEntity webhook : webhooks) {
WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
deliverWebhook(webhookEvent, webhook);
}
}
@Transactional
public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
// ... send HTTP request ...
if (success) {
event.setStatus(WebhookEventStatus.DELIVERED);
webhookEventRepository.save(event);
// Reset failure count atomically
webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
}
}
}
The repository method:
@Modifying
@Query("UPDATE WebhookEntity w SET w.failureCount = 0, w.modifiedTime = :now WHERE w.id = :id")
void resetFailureCount(@Param("id") Long id, @Param("now") Long now);
Looks reasonable, right? @Async for background processing, @Transactional for database consistency, @Modifying for atomic updates.
The Error
javax.persistence.TransactionRequiredException:
Executing an update/delete query
This was getting stored in the lastError field of webhook events. Users could see it. Security and UX nightmare.
The Root Cause: Self-Invocation Bypasses Proxies
Spring's @Transactional works through proxies. When you call a @Transactional method, you're actually calling a proxy that:
- Starts a transaction
- Calls your actual method
- Commits or rolls back
But here's the catch: self-invocation bypasses the proxy.
@Async
public void fireEvent(...) {
// This runs in a new thread, outside any transaction
for (WebhookEntity webhook : webhooks) {
// This calls the method DIRECTLY, not through the proxy
deliverWebhook(webhookEvent, webhook); // @Transactional is ignored!
}
}
When fireEvent() calls deliverWebhook(), it's calling this.deliverWebhook() - the actual method on the instance, not the Spring proxy. The @Transactional annotation is invisible.
The @Modifying query requires an active transaction. No transaction = exception.
The Fix: TransactionTemplate
When you can't rely on @Transactional, use programmatic transaction management:
@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {
private final WebhookRepository webhookRepository;
private final WebhookEventRepository webhookEventRepository;
private final TransactionTemplate transactionTemplate; // Inject this
@Async
public void fireEvent(Long userId, String event, Map<String, Object> payload) {
List<WebhookEntity> webhooks = webhookRepository.findByUserIdAndEnabledTrue(userId);
for (WebhookEntity webhook : webhooks) {
WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
deliverWebhook(webhookEvent, webhook);
}
}
public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
// ... send HTTP request ...
if (success) {
// Wrap database operations in explicit transaction
transactionTemplate.executeWithoutResult(status -> {
event.setStatus(WebhookEventStatus.DELIVERED);
webhookEventRepository.save(event);
webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
});
}
}
}
TransactionTemplate doesn't rely on proxies. It explicitly starts and commits transactions. Works everywhere, including:
- Self-invoked methods
-
@Asyncmethods - Lambda callbacks
- Anywhere proxy magic fails
Bonus: Sanitize Your Error Messages
Even after fixing the transaction issue, you should never leak internal errors to users. Add a sanitizer:
private String sanitizeErrorMessage(String message) {
if (message == null) {
return "Unknown error";
}
// Detect internal errors
if (message.contains("Executing an update/delete query") ||
message.contains("javax.persistence") ||
message.contains("org.hibernate") ||
message.contains("java.sql") ||
message.contains("SQLException")) {
log.error("Internal error during webhook delivery: {}", message);
return "Internal server error";
}
// Truncate overly long messages
if (message.length() > 500) {
return message.substring(0, 497) + "...";
}
return message;
}
Now even if something slips through, users see "Internal server error" instead of stack traces.
Testing TransactionTemplate
When mocking, configure the template to execute the lambda:
@Mock
private TransactionTemplate transactionTemplate;
@BeforeEach
void setUp() {
// Make the mock actually execute the lambda
lenient().doAnswer(invocation -> {
Consumer<Object> action = invocation.getArgument(0);
action.accept(null);
return null;
}).when(transactionTemplate).executeWithoutResult(any());
}
Alternative Approaches
1. Inject Self Reference
@Service
public class WebhookService {
@Lazy
@Autowired
private WebhookService self; // Inject the proxy
@Async
public void fireEvent(...) {
self.deliverWebhook(event, webhook); // Goes through proxy
}
@Transactional
public void deliverWebhook(...) { }
}
Works, but feels hacky.
2. Separate Into Two Services
@Service
public class WebhookFiringService {
private final WebhookDeliveryService deliveryService;
@Async
public void fireEvent(...) {
deliveryService.deliverWebhook(...); // Different bean = proxy works
}
}
@Service
public class WebhookDeliveryService {
@Transactional
public void deliverWebhook(...) { }
}
Cleaner separation, but more classes.
3. TransactionTemplate (My Choice)
Explicit, works everywhere, no magic, easy to test. This is what I went with.
The Rules
-
@Transactional doesn't work on self-invocation - Calling
this.method()bypasses the proxy - @async runs in a new thread - No inherited transaction context
- @Modifying needs a transaction - Always, no exceptions
- Never leak internal errors - Sanitize before storing/displaying
- TransactionTemplate is your friend - When proxy magic fails, go explicit
The Debugging Checklist
When you see TransactionRequiredException:
- Is the method called from the same class? (self-invocation)
- Is the caller
@Async? (new thread, no transaction) - Is the method private? (proxies can't intercept)
- Is the class final? (no proxy possible)
Any of these = @Transactional won't work. Use TransactionTemplate.
Ever been bitten by Spring proxy magic? What's your go-to solution for transaction issues in async code?
Building jo4.io - a URL shortener that actually handles webhooks reliably.
Top comments (0)