What Spring AI 2.0 Actually Gives You for Transactional Agent Workflows

AI agents that call tools are great, until one step fails halfway through and your data is left in a weird state. I dug into what Spring AI 2.0 actually gives you for transactional agent workflows, and it's not as automatic as the demos make it look. If you're building agents that touch real databases, this one's for...

Three weeks ago I watched an agent call our resume improvement pipeline, timeout on the LLM response, retry the call, and then have that retry succeed right after the first call had already finished writing to Postgres. End result: two "improved resume" jobs marked complete, one candidate record with a half-written skills array, and a support message asking why their resume PDF looked like it got interrupted mid-sentence. Because it had. Here's the part that matters for this article: the retry worked exactly as designed. The HTTP call went out, the network hiccupped, the retry fired, and it got a clean response back. Nothing about the retry mechanism was broken. What was broken was everything around it: no idempotency key, no check for whether the first call had already finished its write, no state machine tracking what "done" even meant. The retry didn't cause the bug. The absence of anything to make the retry safe did. I bring this up first because when Spring AI 2.0 landed with built-in retry configuration as one of its headline reliability features, I had a very specific question in mind: how does a retry help me if the thing that's actually broken is my workflow, not my network call? That question forced me to get precise about something I'd been fuzzy on myself, which is exactly where a rollback stops being possible, and once it stops, what happens to everything else in the workflow that already ran. That second part is what I want to walk through carefully, because it's the part most write-ups skip entirely. Retries Fix Flaky Calls. They Don't Fix Broken Workflows. A retry is useful for exactly one thing: a call that failed for a reason that has nothing to do with your business logic. A 503 because the model provider is momentarily overloaded. A connection reset because of a transient network blip. A rate limit that clears itself in two seconds. In all of those cases, the call itself never completed, so retrying it is safe by definition. Nothing happened the first time, so nothing can double up the second time. That is not what happened in my resume pipeline bug. The first call did complete. It wrote to Postgres. The timeout was a client-side illusion, the response just took longer than my timeout threshold to come back, but the work behind the scenes had already finished. The retry wasn't retrying a failed operation. It was blindly repeating an operation that had already succeeded, because nothing in my code distinguished "this failed" from "this is taking a while and I gave up waiting too early." So the honest answer to "how does a retry help me if something is broken in the workflow" is: it doesn't, and it can't. A retry only helps when the failure is real and the operation is safe to repeat. If your workflow is broken because a step isn't idempotent, or because you don't track what state a job is actually in, retries don't just fail to help, they make things worse. Where a Real Rollback Still Works Before getting into where rollbacks stop working, I want to be precise about where they still do. A database transaction rolls back cleanly when every side effect of that step lives inside the same transactional resource: @Transactional public FitCheckJob createJob(String jobId, MultipartFile resume) { FitCheckJob job = new FitCheckJob(jobId, FitCheckStatus.PENDING); fitCheckRepository.save(job); resumeStorage.persistLocalCopy(job.getId(), resume); return job; } If persistLocalCopy throws, Postgres rolls back the insert, and it's as if the method never ran. This is the same pattern I've used for 25 years across FinTech and healthcare programs, and nothing about agent workflows changes it, as long as every operation in the method touches only the database. The moment a step calls something outside the database, an LLM, a notification service, a third-party API, that's where a real rollback becomes structurally impossible. You can't reach into OpenRouter and un-spend tokens. You can't un-send a WhatsApp message. The call happened. That's a historical fact the moment the response comes back. This is exactly the question I got asked, and it's the right question: if one step in a six-step workflow can't be rolled back, what happens to the other five steps that already ran? Does the whole workflow unwind? Does it just stop where it is? There isn't one answer. There are three, and picking the wrong one for a given step is where most of the pain in agent workflows actually lives. Strategy One: Backward Recovery (Unwind What You Can) This is the closest thing to a "rollback the whole workflow" strategy, and it's borrowed directly from the saga pattern I used on a hospital microservices program years before agents existed. The idea: track every completed step in order, and if a later step fails permanently, walk backward through the completed steps and run a compensating action for each one, in reverse order. The critical detail is that you only compensate steps that are actually compensable. Database writes get undone. External side effects get corrected, not undone, because they can't be undone. public class FeaturedJobPromotionSaga { private final Deque<CompensatingAction> completedSteps = new ArrayDeque<>(); public void execute(FeaturedJobPromotionRequest request) { try { String shortUrl = generateShortUrl(request); completedSteps.push(() -> shortUrlService.revoke(shortUrl)); processSkills(request); completedSteps.push(() -> skillService.rollback(request.getJobId())); sendPromotionNotification(request, shortUrl); completedSteps.push(() -> notificationLog.markSentButUnwound(request.getJobId())); markPromoted(request.getJobId()); } catch (PromotionFailedException ex) { compensate(); throw ex; } } private void compensate() { while (!completedSteps.isEmpty()) { CompensatingAction action = completedSteps.pop(); try { action.run(); } catch (Exception compensationFailure) { log.error("Compensation failed, escalating for manual review", compensationFailure); escalationService.flagForReview(compensationFailure); return; } } } } Notice what the compensation for the notification step actually does. It doesn't unsend the WhatsApp message, because it can't. It marks the log entry as "sent but unwound," which means a human or a follow-up process knows a message went out for a promotion that ultimately didn't complete. That's the honest version of backward recovery. It's not a true rollback for every step. It's a best-effort correction for each step, applied in reverse order, and the moment one compensation can't complete cleanly, you stop and escalate rather than pretending the workflow is now consistent. I use backward recovery when the steps are cheap to compensate and the cost of leaving partial state behind is high. Featured job promotion fits that: if the short URL and skills processing ran but the actual promotion flag never got set, I'd rather revoke the URL and clear the skills than leave a half-promoted job sitting around confusing recruiters. Strategy Two: Forward Recovery (Fix the Failing Step, Leave the Rest Alone) Backward recovery makes sense when undoing earlier steps is cheap. It makes no sense at all when the earlier steps involved an expensive, non-repeatable operation like an LLM call. Unwinding a WhatsApp notification is mildly awkward. Unwinding a completed LLM call and re-running it later because a later step failed is just wasteful, and it's the mistake I almost made early on. Forward recovery means you leave every completed step exactly where it is and focus all your retry effort on the one step that failed: @Transactional public void persistAssessment(String jobId, FitAssessment assessment) { CandidateSummaryJob job = jobRepository.fi...