Skip to content

Reuse existing transaction managers and keep code compiling for AddTransactionManagerToTaskletAndChunk - #1189

Open
myhau wants to merge 6 commits into
mainfrom
fix/batch-transaction-manager-parameter
Open

myhau wants to merge 6 commits into
mainfrom
fix/batch-transaction-manager-parameter

Conversation

@myhau

@myhau myhau commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

AddTransactionManagerToTaskletAndChunk added PlatformTransactionManager transactionManager to every method calling a one-argument tasklet() or chunk(), even when a transaction manager was already there, and SpringBatch5To6Migration also runs it on code that is already on Spring Batch 6. It now reuses an existing transaction manager parameter and leaves chains that already call transactionManager(..) alone.

Example issues solved

Transaction manager subtype parameter, set later in the chain (gs-batch-processing)

Before

Step step1(DataSourceTransactionManager transactionManager, ItemReader<String> reader, ItemWriter<String> writer) {
    return stepBuilderFactory.get("step1")
            .<String, String>chunk(3)
            .reader(reader)
            .writer(writer)
            .transactionManager(transactionManager)
            .build();
}

After (problem)

Step step1(DataSourceTransactionManager transactionManager, ItemReader<String> reader, ItemWriter<String> writer,
           PlatformTransactionManager transactionManager) { // duplicate parameter, does not compile
    return stepBuilderFactory.get("step1")
            .<String, String>chunk(3)
            .reader(reader)
            .writer(writer)
            .transactionManager(transactionManager)
            .build();
}

After (fixed, unchanged)

Step step1(DataSourceTransactionManager transactionManager, ItemReader<String> reader, ItemWriter<String> writer) {
    return stepBuilderFactory.get("step1")
            .<String, String>chunk(3)
            .reader(reader)
            .writer(writer)
            .transactionManager(transactionManager)
            .build();
}
Transaction manager from a field, set later in the chain

Before

class MyJobConfig {
    StepBuilderFactory stepBuilderFactory;
    PlatformTransactionManager txManager;

    Step myStep(Tasklet tasklet) {
        return stepBuilderFactory.get("myStep")
                .tasklet(tasklet)
                .allowStartIfComplete(true)
                .transactionManager(txManager)
                .build();
    }
}

After (problem)

class MyJobConfig {
    StepBuilderFactory stepBuilderFactory;
    PlatformTransactionManager txManager;

    Step myStep(Tasklet tasklet, PlatformTransactionManager transactionManager) { // unused parameter
        return stepBuilderFactory.get("myStep")
                .tasklet(tasklet)
                .allowStartIfComplete(true)
                .transactionManager(txManager)
                .build();
    }
}

After (fixed, unchanged)

class MyJobConfig {
    StepBuilderFactory stepBuilderFactory;
    PlatformTransactionManager txManager;

    Step myStep(Tasklet tasklet) {
        return stepBuilderFactory.get("myStep")
                .tasklet(tasklet)
                .allowStartIfComplete(true)
                .transactionManager(txManager)
                .build();
    }
}
Transaction manager subtype parameter with another name

Before

Step myStep(Tasklet tasklet, DataSourceTransactionManager txManager) {
    return stepBuilderFactory.get("myStep")
            .tasklet(tasklet)
            .build();
}

After (problem)

Step myStep(Tasklet tasklet, DataSourceTransactionManager txManager, PlatformTransactionManager transactionManager) { // second transaction manager
    return stepBuilderFactory.get("myStep")
            .tasklet(tasklet, transactionManager)
            .build();
}

After (fixed)

Step myStep(Tasklet tasklet, DataSourceTransactionManager txManager) {
    return stepBuilderFactory.get("myStep")
            .tasklet(tasklet, txManager)
            .build();
}

myhau added 2 commits October 19, 2026 10:16
…steps

`AddTransactionManagerToTaskletAndChunk` added `PlatformTransactionManager transactionManager`
to every method calling a one-argument `tasklet(..)` or `chunk(..)`, even when
- a parameter of a `PlatformTransactionManager` subtype was already present, which
  produced a duplicate parameter name or a second transaction manager, or
- a later `.transactionManager(..)` in the same chain already configured the step,
  which left the new parameter unused.

Reuse an existing subtype parameter, skip chains that already set the transaction
manager, and leave calls outside a method (constructors, initializers) unchanged.
…gerToTaskletAndChunk

Instead of running a nested visitor over every method body, the one-argument
`tasklet(..)` or `chunk(..)` call marks its enclosing method, which adds the
parameter after its body has been visited. The three argument templates only
differed in the type of the first argument, so they now share one template.

A call in a method of a local or anonymous class now adds the parameter to that
method, instead of an unused one to the outer method.
The recipe is now a single visitor. A one-argument `tasklet(..)` or
`chunk(..)` call takes the enclosing method's transaction manager
parameter, or creates one that is appended to the method once its body
has been visited, so each builder chain is walked once.

- The method type gets the new parameter's name as well as its type, and
  the kept argument goes through an untyped placeholder.
- A call in a method of a local or anonymous class uses the parameter of
  the method that declares the class instead of changing the inner
  method's signature; an anonymous class in a field initializer is left
  unchanged.
- Methods without a method type are skipped like constructors.
- Tests for lambda and anonymous tasklets, calls inside lambdas, local and
  anonymous classes, constructors and field initializers; the cases that
  expect no change are grouped in a nested NoChange class.
return statement instanceof J.VariableDeclarations &&
TypeUtils.isOfClassType(((J.VariableDeclarations) statement).getType(),
"org.springframework.transaction.PlatformTransactionManager");
private static J.VariableDeclarations newTransactionManagerParameter(Cursor declaration, ExecutionContext ctx) {

@myhau myhau Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(minor)

Do you think these static helpers should move into the visitor?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard to say (now). I tend to have to nudge Claude to inline small methods only used once. For anything beyond that it's nice if they can be static, but start with inlining small single use methods and see what's left.

Cursor classBody = method.getParentTreeCursor();
boolean localOrAnonymous = classBody.getParentTreeCursor().getValue() instanceof J.NewClass ||
classBody.firstEnclosing(J.MethodDeclaration.class) != null;
return localOrAnonymous ? enclosingMethod(method) : method;

@myhau myhau Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think recursion is ok here or do you prefer loops in general?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tend to prefer loops; they're more explicit, easier to follow, and don't lead to deep stacks.

if (chained == null || chained.getSelect() != cursor.getValue()) {
return false;
}
return TRANSACTION_MANAGER_MATCHER.matches(chained) || chainConfiguresTransactionManager(parent);

@myhau myhau Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any "rules" around recursion in Open Rewrite recipes?

here the depth is the number of calls chained after tasklet(..) or chunk(..). Happy to use a loop instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@myhau
myhau marked this pull request as ready for review October 20, 2026 08:32
@myhau
myhau requested a review from timtebeek October 20, 2026 08:42
@myhau myhau changed the title Avoid duplicate transaction manager parameter in AddTransactionManagerToTaskletAndChunk Reuse existing transaction managers and keep code compiling when migrating tasklet() and chunk() Oct 20, 2026
@myhau myhau changed the title Reuse existing transaction managers and keep code compiling when migrating tasklet() and chunk() Reuse existing transaction managers and keep code compiling for AddTransactionManagerToTaskletAndChunk Oct 20, 2026
import static java.util.Collections.singletonList;
import static java.util.Objects.requireNonNull;

public class AddTransactionManagerToTaskletAndChunk extends Recipe {

@myhau myhau Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also thinking about adding some kind of "only migrate if we are on Spring Batch 4" precondition here:

description: Migrate applications built on Spring Batch 4.3 to the latest Spring Batch 5.0 release.
tags:
- spring
- batch
preconditions:
- org.openrewrite.Singleton
recipeList:
- org.openrewrite.java.dependencies.UpgradeDependencyVersion:
groupId: org.springframework.batch

How do you approach this usually?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation of adding the precondition of being on Spring Batch 4, which is the older version here?

Typically we write our recipes to be idempotent, and selectively matching, such that a recipe targeting a method on v4 would be a no-op when run on v5.

I'm still familiarizing myself with the changes you're proposing so hard to say if that applies here; I'm reading & commenting as I move from the top down.

…oTaskletAndChunk

- The parameter template moves into the `computeMessageIfAbsent` lambda,
  and the `Tasklet` and `CompletionPolicy` type names into their matchers.
- `addParameter` is now `withParameter`, the enclosing method cursor is
  `methodCursor`, and the matched call flag is `taskletOrChunk`.
- The call-inside-lambda test is folded into the local and anonymous
  class test.
@myhau

myhau commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

I'm putting it back to draft state, I need to double-check some things.

@myhau
myhau marked this pull request as draft October 20, 2026 09:54
}
"""
""",
spec -> spec.afterRecipe(cu -> {

@myhau myhau Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I wanted to check that the recipe updates the type information along with the printed code, so later recipes that match by type still find the new call

(this one would fail on main)

Any utility I could reuse instead (or existing test that does it better - for inspiration)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine; thanks for calling it out.

@myhau
myhau marked this pull request as ready for review October 20, 2026 12:51
@myhau

myhau commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@timtebeek

I re-opened it now.

I could probably leave a ton of more inline questions, but I will try to infer what good looks like from your changes applied on top (or responses to my existing comments).

if (md.isConstructor() || md.getMethodType() == null) {
return null;
}
parameter = methodCursor.computeMessageIfAbsent(MSG_NEW_PARAMETER, k ->

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this one.

We can walk the body just once, but I haven't seen Cursor messages used in such a way much (new LST element constructed through JavaTemplate). I keep coming back to this one feeling this might not be the best idea.

Please let me know what you think.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm indeed surprised to see an element added to a cursor message, considering that we need to provide a cursor when applying a template that's then potentially different where this is retrieved and swapped in. Makes the code somewhat hard to follow and fragile to changes. Did you consider or explore alternatives?

if (transactionManager == null) {
return mi;
}
return JavaTemplate.builder("#{any()}, #{any(" + PLATFORM_TRANSACTION_MANAGER + ")}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a version of this without any(), but with something like:

JavaTemplate.builder("#{any(" + typeExtractedFromTaskletChunkArgument + ")}, #{any(" + PLATFORM_TRANSACTION_MANAGER + ")}")

Which one would you prefer?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the type arguments in the template string help avoid ambiguity, or missed attribution where the isolated Java fragment that we compile can't attribute types otherwise. In general a more specific Java template string is preferred for that reason, especially when dealing with types from third party libraries; it can be omitted in cases where JDK types are used.

.build();
return stepBuilderFactory.get("unconfigured")
.tasklet(tasklet, transactionManager)
.build();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note:

This recipe on main does the same.

In theory, we could try to fix this one too (use existingManager instead of transactionManager), but here I started thinking about all kinds of other edge cases we could run into and decided to leave as is. Let me know if we should fix this one too.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What hesitations did you have to reuse the field from the wider scope? Because seeing a parameter introduced where there's a field in scope would surprise me as a user of the recipe.

Hard to say how prevalent this would be to be worth the trouble though; so perhaps best to scan if this pattern of having a field is likely before spending considerable time.

@timtebeek

Copy link
Copy Markdown
Member

I've done a quick walk through looking mostly at your comments only; once those are addressed I'll do a deeper pass through the code. I've not pushed up changes this time since you're asking to learn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants