adamsaghy commented on PR #6069:
URL: https://github.com/apache/fineract/pull/6069#issuecomment-5191069482
Note: Considering the nature and size of this PR, AI was also used to review
and highlight areas that requires further refinement / revision.
# Code Review — PR #6069: FINERACT-2621 Upgrade dependencies to Spring Boot
4.1
The import churn is mechanical and low-risk:
- `org.springframework.batch.core.*` repackaging (`JobExecution` →
`job.JobExecution`, `StepExecution` → `step.StepExecution`, `item.*` →
`infrastructure.item.*`, `repeat.*` → `infrastructure.repeat.*`)
- `org.springframework.lang.{NonNull,Nullable}` →
`org.jspecify.annotations.*`
- `spring-boot-starter-web` → `spring-boot-starter-webmvc`, `starter-batch`
→ `starter-batch-jdbc`, `resilience4j-spring-boot3` → `-boot4`
- Boot 4 module splits (`spring-boot-jpa`, `spring-boot-liquibase`,
`spring-boot-gson`, `starter-tomcat-runtime`)
Risk concentrates in two areas: **Spring Batch 5 → 6** and the **Jackson 2 →
3 split**.
## Summary table
| # | Severity | Area | Finding |
|---|---|---|---|
| 1 | **High** | Batch 6 | Execution-context serializer format break, no
data migration |
| 2 | **High** | Batch 6 / COB | Chunk atomicity now conditional on
thread-pool size |
| 3 | **High** | Config | CloudWatch metrics permanently disabled |
| 4 | Medium | Batch 6 | Step-listener comment is wrong; fix causes double
invocation |
| 5 | Medium | Batch 6 | `jobOperator.run(...)` is deprecated for removal |
| 6 | Medium | Batch 6 | `getNextJobParameters` hand-rolled and duplicated |
| 7 | Medium | Batch 6 / COB | Queue capacity silently overridden;
connection-pool pressure |
| 8 | Medium | COB | Reviewer's `AsyncTaskExecutor` fix not applied to
Working Capital config |
| 9 | Medium | Jersey | Write path changed more than claimed (generic type +
stream close) |
| 10 | Medium | Jackson 3 | Unknown-property rejection silently relaxed on a
public API |
| 11 | Medium | Jackson | Dual-Jackson runtime has no guard rail |
| 12 | Medium | Batch 6 | `StepSynchronizationManager` ref-counting far more
contended |
| 13 | Low | Batch 6 | `StuckJobExecutorServiceImpl` NPEs on unknown
execution id |
| 14 | Low | EclipseLink 5 | Fragile unconditional `OffsetDateTime` cast on
JPQL projection |
| 15 | Low | Jackson 3 | `asLong(0)` bakes loan id `0` into COB routing |
| 16 | Low | Docker | `ReservedCodeCacheSize=128m` lowers the JVM default |
| 17 | Low | CI | `POLLING_EVENT_WAIT_TIMEOUT_IN_MS` addition is a no-op |
| 18 | Low | Process | e2e properties still modified despite "moved to
#6175" |
| 19 | Low | Integration tests | `keystoreType JKS` silently dropped with
tomcat11x bump |
| 20 | Low | Tests | Class-wide `Strictness.LENIENT` added |
| 21 | Low | Process | Unrelated churn remains in the PR |
---
# High severity
## 1. Execution-context serializer format break, with no migration path
**File:**
`fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/ScheduledJobRunnerConfig.java`
`Jackson2ExecutionContextStringSerializer` is replaced by
`JacksonExecutionContextStringSerializer`. These produce **mutually
incompatible JSON**:
| Aspect | Batch 5 (`Jackson2...`) | This PR (`Jackson...`) |
|---|---|---|
| Typing scope | `DefaultTyping.NON_FINAL` | `OBJECT_AND_NON_CONCRETE` |
| Type inclusion | `As.PROPERTY` → `"@class": "..."` | `As.WRAPPER_ARRAY` →
`["java.util.HashSet", …]` |
| Unknown properties | `FAIL_ON_UNKNOWN_PROPERTIES = true` | Jackson 3
default: `false` |
| Unsafe base types | `BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES = true` | not set
|
| Modules | `JobParametersModule`, `JavaTimeModule` | java.time is built
into Jackson 3 core (OK) |
The one-argument `activateDefaultTyping(ptv)` used in the new code is a
convenience overload that resolves to `OBJECT_AND_NON_CONCRETE` +
`WRAPPER_ARRAY`:
```java
// tools/jackson/databind/cfg/MapperBuilder.java:1710
public B activateDefaultTyping(PolymorphicTypeValidator subtypeValidator) {
return activateDefaultTyping(subtypeValidator,
DefaultTyping.OBJECT_AND_NON_CONCRETE);
}
// :1727
public B activateDefaultTyping(PolymorphicTypeValidator subtypeValidator,
DefaultTyping dti) {
return activateDefaultTyping(subtypeValidator, dti,
JsonTypeInfo.As.WRAPPER_ARRAY);
}
```
:warning: **RISK** :warning:
Consequently every existing row in `BATCH_JOB_EXECUTION_CONTEXT` and
`BATCH_STEP_EXECUTION_CONTEXT` becomes undeserializable after the upgrade.
### Why this bites immediately
The new `getNextJobParameters()` calls
`jobRepository.getLastJobExecution(lastInstance)`, and Batch 6 eagerly hydrates
contexts on that path:
```java
// SimpleJobRepository:171 (extends SimpleJobExplorer)
public JobExecution getLastJobExecution(JobInstance jobInstance) {
JobExecution lastJobExecution =
jobExecutionDao.getLastJobExecution(jobInstance);
if (lastJobExecution != null) {
fillJobExecutionDependencies(lastJobExecution);
for (StepExecution stepExecution :
lastJobExecution.getStepExecutions()) {
fillStepExecutionDependencies(stepExecution);
}
}
return lastJobExecution;
}
// SimpleJobExplorer
protected void fillJobExecutionDependencies(JobExecution jobExecution) {
...
jobExecution.setExecutionContext(ecDao.getExecutionContext(jobExecution)); //
← deserializes
}
```
**The first post-upgrade run of every scheduled job that has execution
history will attempt to deserialize Batch-5-format context.**
`StuckJobExecutorServiceImpl.resumeStuckJob` → `jobOperator.restart(...)` hits
the same path.
### Recommendation
Pick one and document it in the PR / upgrade notes:
1. A Liquibase changeset that truncates `BATCH_JOB_EXECUTION_CONTEXT` and
`BATCH_STEP_EXECUTION_CONTEXT` (acceptable only if no in-flight jobs are
expected across the upgrade), **or**
2. A serializer that detects and reads both the `@class` property form and
the wrapper-array form.
Silent failure of all scheduled jobs on first startup after upgrade is a bad
outcome for downstream deployments.
### Secondary concern — security posture
The allow-list was widened from Batch 5's explicit `trustedClassNames` to
the whole `org.apache.fineract.` prefix. This is genuinely required
(`Set<BusinessStepNameAndOrder>` and `COBParameter` are stored in partition
contexts), but the two hardening flags Batch 5 set —
`FAIL_ON_UNKNOWN_PROPERTIES` and `BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES` — are
silently gone.
---
## 2. COB chunk atomicity is now conditional on thread-pool size
**Files:**
-
`fineract-cob/src/main/java/org/apache/fineract/cob/COBBusinessStepServiceImpl.java:59`
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBWorkerConfiguration.java:102`
The `@Transactional` added to `COBBusinessStepServiceImpl.run(...)` — and
the explanatory comment above it — is **accurate about the mechanism**.
Confirmed in `ChunkOrientedStep`:
```java
// ChunkOrientedStep:368 — the chunk transaction is bound to the step thread
protected void doExecute(StepExecution stepExecution) throws Exception {
while (this.chunkTracker.get().moreItems() &&
!interrupted(stepExecution)) {
this.transactionTemplate.executeWithoutResult(transactionStatus -> {
...
processNextChunk(transactionStatus, contribution, stepExecution);
...
});
}
}
// ChunkOrientedStep:447 — item processing is fanned out with NO transaction
Future<O> itemProcessingFuture = this.taskExecutor.submit(() -> {
try {
StepSynchronizationManager.register(stepExecution); // step
context only
return processItem(item, contribution);
} finally {
StepSynchronizationManager.close();
}
});
```
`@Transactional` resolves to the `@Primary` `jpaTransactionManager`
(`JpaTransactionConfig:39-40`), which is the same bean the step receives via
the unqualified `@Autowired PlatformTransactionManager` in
`LoanCOBWorkerConfiguration:62`. So the comment's claim that sequential mode
joins the chunk transaction holds.
### The consequences go beyond what the comment states
The step is configured fault-tolerant:
```java
.faultTolerant()
.retry(Exception.class)
.retryLimit(propertyService.getRetryLimit(LoanCOBConstant.JOB_NAME))
.skip(Exception.class)
.skipLimit(propertyService.getChunkSize(LoanCOBConstant.JOB_NAME) + 1)
```
And `doProcess` wraps the processor in a retry template:
```java
// ChunkOrientedStep:721
private @Nullable O doProcess(I item) throws Exception {
if (this.faultTolerant) {
Retryable<O> retryableProcess = new Retryable<>() {
public O execute() throws Throwable {
...
return itemProcessor.process(item);
}
};
return this.retryTemplate.execute(retryableProcess);
}
return this.itemProcessor.process(item);
}
```
:warning: **RISK** :warning:
Therefore, in **concurrent** mode:
- **Retries re-apply committed side effects.** Each retry attempt is a
separately committing transaction. A retried item re-runs the whole COB
business-step chain — accruals, charges, external events — on top of
already-committed effects.
- **Chunk rollback leaves orphaned process writes.** If `writeChunk` fails
and the chunk transaction rolls back, the per-item process writes are already
committed while the writer's lock release and `lastClosedBusinessDate` update
are not. The loan is reprocessed on the next run → double accrual.
- **Write-skip scan mode.** `ChunkOrientedStep.scan()` skips the write for
an item whose process-time writes are committed.
COB correctness now depends on `LOAN_COB_THREAD_POOL_MAX_POOL_SIZE` — which
defaults to `5`, i.e. **concurrent by default**.
FINERACT-2684 is referenced in the comment, but the PR ships the divergence
rather than gating it.
### Recommendation
Treat as a merge blocker independent of the rest of the PR. Options:
1. Force sequential COB (`maxPoolSize = 1`) until FINERACT-2684 is resolved,
restoring Batch 5 atomicity at the cost of throughput.
2. Move the business-step writes into the chunk transaction by keeping
processing on the step thread and parallelising at the *partition* level only.
3. Make COB business steps idempotent and accept per-item commits as the new
contract — but this needs explicit design sign-off, not a comment.
### Related (pre-existing in Batch 6, worth noting)
`processItem(item, contribution)` is called concurrently from N pool threads
against a **single shared `StepContribution`**, whose counters are plain `int`
fields (`incrementFilterCount`, `incrementProcessSkipCount`). Skip and filter
counts will be lossy. This is a Spring Batch 6 issue rather than a Fineract
one, but it affects reported job metrics.
---
## 3. CloudWatch metrics are now permanently off
**File:**
`fineract-provider/src/main/resources/application.properties:427-433`
```properties
management.cloudwatch.metrics.export.enabled=${FINERACT_MANAGEMENT_CLOUDWATCH_ENABLED:false}
management.cloudwatch.metrics.export.namespace=${FINERACT_MANAGEMENT_CLOUDWATCH_NAMESPACE:fineract}
management.cloudwatch.metrics.export.step=${FINERACT_MANAGEMENT_CLOUDWATCH_STEP:1m}
spring.autoconfigure.exclude=${FINERACT_AUTOCONFIGURE_EXCLUDE:io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration,\
io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration,\
io.awspring.cloud.autoconfigure.metrics.CloudWatchExportAutoConfiguration}
```
`spring.autoconfigure.exclude` is **unconditional**. Adding
`CloudWatchExportAutoConfiguration` to it means the three renamed properties
directly above can never take effect — setting
`FINERACT_MANAGEMENT_CLOUDWATCH_ENABLED=true` now does nothing.
:warning: **RISK** :warning:
Either the exclusion is wrong, or the properties are dead and should be
removed. As written it is a silent functional regression for any deployment
using CloudWatch metrics.
---
# Medium severity
## 4. The step-listener comment is factually wrong, and the fix it justifies
causes double invocation
**Files:**
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBWorkerConfiguration.java:111-114`
-
`fineract-working-capital-loan/.../WorkingCapitalLoanCOBWorkerConfiguration.java:98-101`
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanInlineCOBConfig.java:93-96`
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/WorkingCapitalLoanInlineCOBConfig.java:99-103`
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/AbstractLoanItemReader.java:39`
-
`fineract-cob/src/main/java/org/apache/fineract/cob/processor/AbstractItemProcessor.java:42`
The comment repeated in four configs states:
> Batch 6's chunk-oriented step no longer scans item components for
step-listener annotations, so register the reader and processor as
StepExecutionListeners explicitly
This does not match the source. `ChunkOrientedStepBuilder.build()` **does**
scan them:
```java
// ChunkOrientedStepBuilder:400 build()
addAsStreamAndListener(this.reader);
addAsStreamAndListener(this.writer);
if (this.processor != null) {
chunkOrientedStep.setItemProcessor(this.processor);
addAsStreamAndListener(this.processor);
}
// ChunkOrientedStepBuilder:483
private void addAsStreamAndListener(Object itemHandler) {
if (itemHandler instanceof ItemStream itemStream) {
this.streams.add(itemStream);
}
// Register as listener if implements the interface
if (itemHandler instanceof StepListener listener) {
this.stepListeners.add(listener);
// ← branch A
}
// Register as listener if annotated methods are present
if (StepListenerFactoryBean.isListener(itemHandler)) {
StepListener listener =
StepListenerFactoryBean.getListener(itemHandler);
this.stepListeners.add(listener);
// ← branch B
}
}
```
And `isListener` returns `true` as soon as the object implements the
interface — it does **not** require annotations:
```java
// AbstractListenerFactoryBean:isListener
public static boolean isListener(Object target, Class<?> listenerType,
ListenerMetaData[] metaDataValues) {
if (target == null) return false;
if (listenerType.isInstance(target)) return true; // ←
short-circuits here
...
}
```
`StepListenerMetaData` includes `BEFORE_STEP` and `AFTER_STEP` (`:56-57`),
so the branch-B proxy routes `beforeStep`/`afterStep` to the annotated methods.
### Two consequences
**(a) The added `listener(...)` calls are dead code.** `stepListeners` is a
`LinkedHashSet<StepListener>` (`ChunkOrientedStepBuilder:94`), and `build()`
already adds the same instance. Registering it again is a no-op.
**(b) `beforeStep`/`afterStep` now fire twice per step.** This PR added
`implements StepExecutionListener` to both item components **while keeping**
the `@BeforeStep`/`@AfterStep` annotations:
```diff
-public abstract class AbstractLoanItemReader<T extends
AbstractPersistableCustom<Long>> implements ItemReader<T> {
+public abstract class AbstractLoanItemReader<T extends
AbstractPersistableCustom<Long>> implements ItemReader<T>,
StepExecutionListener {
```
Both branch A and branch B therefore fire, adding two distinct objects (the
instance, and the annotation proxy) to the set. Batch 5 registered only the
proxy:
```java
// Batch 5 — SimpleStepBuilder.registerAsStreamsAndListeners
if (StepListenerFactoryBean.isListener(itemHandler)) {
StepListener listener =
StepListenerFactoryBean.getListener(itemHandler); // proxy only
if (listener instanceof StepExecutionListener) {
listener((StepExecutionListener) listener); }
...
}
```
:warning: **RISK** :warning:
So the double invocation is **new in this PR**.
### Practical impact
`LoanItemReader.beforeStep` calls
`BeforeStepLockingItemReaderHelper.filterRemainingData(stepExecution)`, which
issues two SELECTs
(`retrieveAllNonClosedLoansByLastClosedBusinessDateAndMinAndMaxLoanId` +
`findLockIdsByLoanIdInAndLockOwner`). Running twice per partition means:
- Two extra queries per partition per step
- `remainingData` is rebuilt from a **second, possibly different** lock
snapshot
Read-only, so not corrupting — but wasteful and non-deterministic.
`AbstractItemProcessor.beforeStep`/`afterStep` are idempotent, so no impact
there beyond overhead.
### Recommendation
Pick one mechanism, not both:
- Keep `implements StepExecutionListener` and **remove** the
`@BeforeStep`/`@AfterStep` annotations, **and**
- Delete all four blocks of `stepBuilder.listener(reader)` /
`listener(processor)` calls, **and**
- Delete the incorrect comment.
---
## 5. `jobOperator.run(...)` is deprecated for removal
**File:**
`fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobStarter.java:106-111`
```java
// Batch 6 JobOperator.start() silently discards explicit parameters for
jobs that define an
// incrementer and launches the next instance instead; the incrementer is
already applied in
// getNextJobParameters(), so use the parameter-respecting launch inherited
from JobLauncher
@SuppressWarnings("removal")
JobExecution result = jobOperator.run(job, jobParameters);
```
The reasoning is correct. Confirmed:
```java
// JobLauncher.java:41 — the entire interface is deprecated
@Deprecated(since = "6.0", forRemoval = true)
public interface JobLauncher { ... }
// JobOperator.java:44
public interface JobOperator extends JobLauncher { ... }
// SimpleJobOperator.start
public JobExecution start(Job job, JobParameters jobParameters) throws ... {
if (job.getJobParametersIncrementer() != null) {
logger.warn("... Additional parameters will be ignored.");
return startNextInstance(job);
}
return run(job, jobParameters); // ← delegates to run() when no
incrementer
}
```
:warning: **RISK** :warning:
Note the last line: `start()` itself delegates to `run()` **when there is no
incrementer**.
### Recommendation
The forward-compatible fix is to **drop the `JobParametersIncrementer` from
the Fineract job definitions** and supply uniqueness via an explicit parameter
Fineract controls (a run-id or timestamp). Then `start(job, params)` works, no
suppression is needed, and the code survives the next major. Worth doing now
rather than deferring to a breaking upgrade.
Same suppression pattern appears at
`InlineCommonLockableCOBExecutorService.java:143`.
---
## 6. `getNextJobParameters` hand-rolled and duplicated
**Files:**
-
`fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobStarter.java:124-129`
-
`fineract-provider/src/main/java/org/apache/fineract/cob/service/InlineCommonLockableCOBExecutorService.java:214-219`
Two byte-identical private copies:
```java
private JobParameters getNextJobParameters(Job job) {
JobInstance lastInstance =
jobRepository.getLastJobInstance(job.getName());
JobExecution lastExecution = lastInstance == null ? null :
jobRepository.getLastJobExecution(lastInstance);
JobParameters parameters = lastExecution == null ? new JobParameters() :
lastExecution.getJobParameters();
return job.getJobParametersIncrementer() == null ? parameters :
job.getJobParametersIncrementer().getNext(parameters);
}
```
:warning: **RISK** :warning:
Issues:
1. **Duplication** — should be one shared helper.
2. **Lost assertion.** Batch's `JobParametersBuilder.getNextJobParameters`
asserts the incrementer is non-null. This version silently returns the
*previous execution's parameters verbatim* when the incrementer is null, which
surfaces later as a confusing `JobInstanceAlreadyCompleteException` instead of
a clear configuration error.
3. **Eager context deserialization.** `getLastJobExecution` hydrates the
full job execution *and every step execution context* (see finding 1) purely to
read job parameters — which live in `BATCH_JOB_EXECUTION_PARAMS` as plain
columns. Consider `jobExecutionDao`-level access or a targeted query.
---
## 7. Task-executor queue capacity silently overridden; connection-pool
pressure
**Files:**
-
`fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanCOBWorkerConfiguration.java:133`
-
`fineract-working-capital-loan/.../WorkingCapitalLoanCOBWorkerConfiguration.java:125`
```diff
-taskExecutor.setQueueCapacity(propertyService.getThreadPoolQueueCapacity(JobName.LOAN_COB.name()));
+taskExecutor.setQueueCapacity(Math.max(propertyService.getThreadPoolQueueCapacity(JobName.LOAN_COB.name()),
+ propertyService.getChunkSize(JobName.LOAN_COB.name())));
```
The change is **necessary**: `processChunkConcurrently` submits up to
`chunkSize` tasks before blocking on `future.get()`. Defaults are `chunk-size =
100` and `thread-pool-queue-capacity = 20`, so without this you would get
`RejectedExecutionException` → `TaskRejectedException` at task 26.
:warning: **RISK** :warning:
But there are three problems:
1. **It overrides an operator-configured value with no log line.** An
operator setting `LOAN_COB_THREAD_POOL_QUEUE_CAPACITY=20` silently gets 100.
2. **It can pin parallelism to `corePoolSize`.** `ThreadPoolExecutor` only
grows past `corePoolSize` once the queue is *full*. Defaults are `core = max =
5` so this is fine today, but any deployment with `core=5 / max=20` now has
effective parallelism of 5 and `maxPoolSize` becomes inert.
3. **Connection-pool sizing needs review.** Per partition you now hold the
chunk transaction on the step thread **plus** up to `maxPoolSize` independent
per-item transactions (finding 2). Roughly double the Batch 5 demand,
multiplied by concurrent partitions.
### Recommendation
Prefer `CallerRunsPolicy` (or bound submission to the available pool) over
silently enlarging the queue, and log when the configured capacity is
insufficient. Re-check HikariCP `maximumPoolSize` against `partitions × (1 +
maxPoolSize)`.
### Minor
In the Working Capital config the queue capacity reads
`WORKING_CAPITAL_JOB_NAME` while the chunk size reads
`JobName.LOAN_COB.name()`. It is at least consistent with the actual chunk size
used in the step builder, but reads like a copy-paste and deserves a comment or
a fix.
---
## 8. Reviewer's `AsyncTaskExecutor` fix not applied to the Working Capital
config
**File:**
`fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/WorkingCapitalLoanCOBWorkerConfiguration.java:103-105`
The review comment on `LoanCOBWorkerConfiguration` was addressed there:
```java
if (cobTaskExecutor() instanceof AsyncTaskExecutor asyncTaskExecutor) {
stepBuilder.taskExecutor(asyncTaskExecutor);
}
```
But the sibling config still has the exact pattern that was flagged:
```java
if (propertyService.getThreadPoolMaxPoolSize(WORKING_CAPITAL_JOB_NAME) > 1) {
stepBuilder.taskExecutor((AsyncTaskExecutor)
workingCapitalCobTaskExecutor());
}
```
:warning: **RISK** :warning:
`workingCapitalCobTaskExecutor()` returns `SyncTaskExecutor` when
`maxPoolSize == 1`, and `SyncTaskExecutor` implements only `TaskExecutor`. The
cast is safe only because the enclosing condition duplicates the branch inside
the bean method. Should those ever diverge, `ClassCastException`. Apply the
same `instanceof` pattern.
---
## 9. Jersey write path changed more than claimed
**File:**
`fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyJacksonObjectArgumentHandler.java`
```diff
-converter.write(t, genericType, MediaType.APPLICATION_JSON, new
SimpleHttpOutputMessage(entityStream, headers));
+objectMapper.writeValue(entityStream, t);
```
The PR response states the wire format is unchanged because the same
`ObjectMapper` bean is used. That is true of the mapper configuration, but two
behavioural differences remain:
:warning: **RISK** :warning:
### (a) Declared generic type vs runtime type
`AbstractJackson2HttpMessageConverter.writeInternal` serialises against the
**declared generic type** via `objectMapper.writerFor(javaType)`.
`objectMapper.writeValue(out, t)` serialises against the **runtime type** of
`t`. For proxied, subclassed, or narrowed returns this can emit additional
fields that were previously suppressed.
### (b) Stream closing
`writeValue(OutputStream, Object)` routes through
`_writeValueAndClose(...)`, and `JsonGenerator.Feature.AUTO_CLOSE_TARGET` is
enabled by default — so the generator **closes** the underlying stream. The
converter only called `generator.flush()`.
Closing Jersey's `CommittingOutputStream` mid-chain can break
`WriterInterceptor` chains (for example the GZip interceptor).
### (c) Read path mirror
`objectMapper.readValue(entityStream, ...)` likewise closes the source
(`AUTO_CLOSE_SOURCE` default). The anonymous `HttpInputMessage` handed to
`HttpMessageNotReadableException` then exposes an already-consumed stream, so
any error handler that tries to read the body gets nothing.
### Recommendation
Use `objectMapper.writerFor(javaType).writeValue(...)` to preserve
generic-type serialisation, and construct the generator with
`AUTO_CLOSE_TARGET` disabled (or use `writeValue(JsonGenerator, Object)` and
flush without closing). Add contract tests on (i) a generic-typed resource
method and (ii) the GZip response path.
---
## 10. Jackson 3 relaxes unknown-property rejection on a public API
**File:**
`fineract-loan/src/main/java/org/apache/fineract/portfolio/interestpauses/data/InterestPauseRequestDto.java:47,55`
```diff
-return new ObjectMapper().writeValueAsString(this);
+return new JsonMapper().writeValueAsString(this);
...
-return new ObjectMapper().readValue(json, InterestPauseRequestDto.class);
+return new JsonMapper().readValue(json, InterestPauseRequestDto.class);
```
Defaults differ between the two versions:
```java
// tools/jackson/databind/DeserializationFeature.java:150
FAIL_ON_UNKNOWN_PROPERTIES(false),
```
Jackson 2's default was `true`. `fromJson` is the batch-API entry point for
four command strategies:
- `CreateLoanInterestPauseByExternalIdCommandStrategy:62`
- `UpdateLoanInterestPauseByExternalIdCommandStrategy:63`
- `CreateLoanInterestPauseByLoanIdCommandStrategy`
- (and the corresponding update-by-loan-id strategy)
So those endpoints now **silently ignore unrecognised fields** where they
previously failed with `IllegalArgumentException("Error deserializing request
from JSON")`.
Intentional or not, this is an undocumented public API behaviour change.
Either restore strictness explicitly or note the relaxation in the API docs.
All four fields are `String`, so there is no `java.time` format concern
here. (Jackson 3 has java.time built into databind core via
`JavaTimeInitializer`, so the missing `JavaTimeModule` registration is not an
issue.)
:warning: **RISK** :warning:
Separately: constructing a new mapper per call is a pre-existing performance
smell, unchanged by this PR but worth a follow-up.
---
## 11. Dual-Jackson runtime has no guard rail
**File:** `fineract-provider/dependencies.gradle:145-146`
```groovy
// Spring Boot 4 defaults to Jackson 3; re-enable Jackson 2 so the Jersey
REST layer (Jackson-2 island) keeps working
implementation 'org.springframework.boot:spring-boot-jackson2'
```
Current split in `src/main`:
| Namespace | Files |
|---|---|
| `tools.jackson` (Jackson 3) | 23 |
| `com.fasterxml.jackson` (Jackson 2) | 46 |
The shared `com.fasterxml.jackson.annotation` package masks most of the
hazard, but the *databind* annotations are version-specific:
- Jackson 2: `com.fasterxml.jackson.databind.annotation.{JsonSerialize,
JsonDeserialize, JsonNaming, JsonPOJOBuilder}`
- Jackson 3: `tools.jackson.databind.annotation.{...}`
**Jackson 3 ignores Jackson 2's databind annotations entirely.** A DTO
carrying `@JsonSerialize(using = …)` loses its custom serializer, silently, if
it ever crosses onto a Jackson 3 path.
Currently exposed (all on the Jersey side, so safe today):
- `fineract-provider/.../template/domain/Template.java`
- `fineract-provider/.../template/domain/TemplateEntity.java`
- `fineract-provider/.../template/domain/TemplateType.java`
-
`fineract-provider/.../infrastructure/configuration/data/ExternalServicesPropertiesData.java`
- `fineract-provider/.../interoperation/data/InteropRequestData.java`
:warning: **RISK** :warning:
Related: `lombok.config` now carries both versions, so every `@Jacksonized`
type emits both annotation sets:
```properties
lombok.jacksonized.jacksonVersion += 2
lombok.jacksonized.jacksonVersion += 3
```
### Recommendation
Add an ArchUnit rule (or Checkstyle import restriction) pinning the two
islands — e.g. no `tools.jackson` in packages reachable from Jersey resources,
and no `com.fasterxml.jackson.databind` in the Spring MVC / batch /
external-event paths. Without a guard rail the next crossover fails silently.
---
## 12. `StepSynchronizationManager` ref-counting is far more contended
**Files:**
-
`fineract-core/src/main/java/org/springframework/batch/core/scope/context/StepSynchronizationManager.java:52`
-
`fineract-loan/src/main/java/org/apache/fineract/cob/loan/ContextAwareTaskDecorator.java:44`
The `Enhancer.isEnhanced` short-circuit added to the shadowed
`StepSynchronizationManager` is correct and necessary — without it,
re-registering on a pool thread would create a second proxy and miss the
existing context.
:warning: **RISK** :warning:
But registration is now **triple-nested per item**:
1. `ContextAwareTaskDecorator` (added in this PR) —
`StepSynchronizationManager.register(stepContext.getStepExecution())`
2. `ChunkOrientedStep:447` — the submitted lambda registers again
3. `ChunkOrientedStep:726` — `doProcess`'s `Retryable` registers a third time
`SynchronizationManagerSupport` keys a shared map on the `StepExecution` and
guards mutation with global monitors:
```java
private final Map<E, AtomicInteger> counts = new ConcurrentHashMap<>();
private final Map<E, C> contexts = new ConcurrentHashMap<>();
public C register(E execution) {
getCurrent().push(execution);
synchronized (contexts) { context = contexts.computeIfAbsent(execution,
this::createNewContext); }
increment();
return context;
}
private void decrement() {
E current = getCurrent().pop();
if (current != null) {
int remaining = counts.get(current).decrementAndGet(); // ← NPE if
already removed
if (remaining <= 0) {
synchronized (contexts) { contexts.remove(current);
counts.remove(current); }
}
}
}
```
With `chunkSize = 100` across many partitions this means heavy contention on
`synchronized(contexts)` / `synchronized(counts)`, and much wider exposure to
the
[spring-batch#4774](https://github.com/spring-projects/spring-batch/issues/4774)
race that this shadow class exists to work around — including the
`counts.get(current)` NPE when another thread has already zeroed the entry, and
context destruction while a thread still holds it.
### Additional issues in `ContextAwareTaskDecorator`
- **The registration is redundant.** `ChunkOrientedStep` already registers
on the pool thread (step 2 above). Recommend dropping it.
- **Unbalanced `close()` on init failure.** `register` happens *inside* the
try, after `ThreadLocalContextUtil.init(context)`:
```java
return () -> {
try {
ThreadLocalContextUtil.init(context); // ← if this throws…
if (stepContext != null) {
StepSynchronizationManager.register(stepContext.getStepExecution());
}
runnable.run();
} finally {
if (stepContext != null) {
StepSynchronizationManager.close(); // ← …this still
runs
}
ThreadLocalContextUtil.reset();
}
};
```
A leftover registration from a previous task on the same pooled thread
would be decremented.
### Verified as correct — no action needed
Two things I checked that turn out to be fine:
**(a) `TaskDecorator` does apply to `submit(Callable)`.**
`ChunkOrientedStep` uses `taskExecutor.submit(Callable)`, and historically
Spring's `ThreadPoolTaskExecutor.submit` bypassed the decorator. In Spring
Framework 7 the decoration happens inside an anonymous
`ThreadPoolExecutor.execute(Runnable)` override:
```java
// ThreadPoolTaskExecutor:284 (inside initializeExecutor)
public void execute(Runnable command) {
Runnable decorated = command;
if (taskDecorator != null) {
decorated = taskDecorator.decorate(command);
if (decorated != command) { decoratedTaskMap.put(decorated,
command); }
}
super.execute(decorated);
}
```
`AbstractExecutorService.submit(Callable)` wraps into a `FutureTask` and
calls `execute(...)`, so the override fires. **Tenant context does reach COB
worker threads.**
**(b) The new `jobRegistry()` bean is genuinely required and
self-populates.**
```java
// BatchRegistrar:232-235 — only wires jobRegistry if a bean of that name
exists
String jobRegistryRef = batchAnnotation.jobRegistryRef();
if (registry.containsBeanDefinition(jobRegistryRef)) {
beanDefinitionBuilder.addPropertyReference("jobRegistry",
jobRegistryRef);
}
// MapJobRegistry:46,64 — self-populates from all Job beans
public class MapJobRegistry implements JobRegistry,
SmartInitializingSingleton, ApplicationContextAware {
public void afterSingletonsInstantiated() {
Map<String, Job> jobBeans =
this.applicationContext.getBeansOfType(Job.class);
for (Job job : jobBeans.values()) { register(job); }
}
}
```
So `jobRegistry.getJob(name)` works, and the
`null`-instead-of-`NoSuchJobException` handling in `JobRegisterServiceImpl` /
`InlineCommonLockableCOBExecutorService` is correct (`JobRegistry.getJob` is
`@Nullable`).
⚠️ One caveat: `register` throws `DuplicateJobException` →
`IllegalStateException`, so **context startup now fails hard if any two `Job`
beans share a `getName()`**. Worth keeping in mind for custom modules.
**(c) The shadowed classes' API surface matches Batch 6.** Both
`StepSynchronizationManager` and `JobSynchronizationManager` expose exactly
`getContext()`, `register(...)`, `close()`, `release()` in both the Fineract
shadow and the real Batch 6 class — no `NoSuchMethodError` risk from Batch 6
internals calling the shadowed versions.
---
# Low severity / process
## 13. `StuckJobExecutorServiceImpl` NPEs on unknown execution id
**File:**
`fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/StuckJobExecutorServiceImpl.java:59,90`
```diff
-jobOperator.restart(stuckJobId);
+jobOperator.restart(jobRepository.getJobExecution(stuckJobId));
```
`getJobExecution(id)` returns `null` for an unknown id, so `restart(null)`
throws NPE instead of the old, clear `NoSuchJobExecutionException`. Add a null
check with an explicit error.
## 14. Fragile unconditional cast on a JPQL projection
**File:**
`fineract-accounting/src/main/java/org/apache/fineract/accounting/glaccount/jobs/updatetrialbalancedetails/UpdateTrialBalanceDetailsTasklet.java:83`
```diff
-tb.setTransactionDate((LocalDate) row[4]);
+tb.setTransactionDate(((OffsetDateTime) row[4]).toLocalDate());
```
Two observations:
- The cast is unconditional on an `Object[]` JPQL projection, and the
underlying column type differs by dialect — `DATETIME` on MySQL vs `TIMESTAMP
WITH TIME ZONE` on PostgreSQL
(`0025_add_audit_entries_to_journal_entry.xml:28,34`). The author verified
PostgreSQL only. Prefer a defensive conversion helper that handles
`OffsetDateTime` / `Timestamp` / `LocalDateTime`.
- `.toLocalDate()` takes the date **in the stored offset (UTC)** rather than
going through `DateUtils` / the tenant zone. This is likely equivalent to what
EclipseLink 4 returned, so probably not a regression — but it hard-codes
UTC-date semantics at a new call site.
No NPE risk: `created_on_utc` carries a `NOT NULL` constraint (`:61,68`).
:warning: **RISK** :warning:
Let's double check we still store UTC datetime and fetch as UTC same value
on postgres and mysql too!
## 15. `asLong(0)` bakes loan id `0` into COB routing
**Files:**
-
`fineract-provider/.../infrastructure/jobs/filter/LoanCOBFilterHelperImpl.java:198`
-
`fineract-provider/.../infrastructure/jobs/filter/ProgressiveLoanModelCheckerHelper.java:136`
-
`fineract-provider/.../infrastructure/jobs/filter/WorkingCapitalLoanCOBFilterHelperImpl.java:173`
```diff
-return jsonNode.get("loanId").asLong();
+return jsonNode.get("loanId").asLong(0);
```
Faithfully preserves the Jackson 2 behaviour (Jackson 3's no-arg `asLong()`
throws `JsonNodeException` on non-coercible values), and the author's diagnosis
of the `BatchApiTest` failure is credible. But the review point stands: an
unresolved chained-batch reference like `"loanId": "$.loanId"` becomes loan id
`0` and enters COB lock/routing logic. Harmless today (no rows match), but it
masks a real gap. Preserving behaviour is defensible for an upgrade PR — raise
the proper fix (resolve or skip the placeholder) as a follow-up ticket.
Also note the incidental widening flagged in the PR thread:
`ALLOW_UNESCAPED_CONTROL_CHARS` moved from a `LoanCOBFilterHelperImpl`-only
instance field to the shared `COBFilterApiMatcher` base, so it now applies to
all three matchers. Acknowledged by the author; worth a line in the commit
message.
## 16. `ReservedCodeCacheSize=128m` lowers the JVM default
**File:** `config/docker/env/fineract-common.env:60`
```diff
-JAVA_TOOL_OPTIONS="-Xmx1G ... -XX:TieredStopAtLevel=1
-XX:+UseContainerSupport ..."
+JAVA_TOOL_OPTIONS="-Xmx1G ... -XX:TieredStopAtLevel=1
-XX:ReservedCodeCacheSize=128m -XX:+UseContainerSupport ..."
```
The JVM default is 240 MB; this **lowers** it. If the intent was to fit the
1 GB container, be aware the failure mode is `CodeCache is full. Compiler has
been disabled` — a silent and severe throughput cliff rather than a clean OOM.
Please state the reasoning in the PR, and confirm it was measured under a full
COB run.
## 17. CI env addition is a no-op
**File:** `.github/workflows/build-e2e-tests.yml:37`
```yaml
POLLING_EVENT_WAIT_TIMEOUT_IN_MS: 15000
```
This equals the existing default
(`fineract-test-application.properties:40`), and now that `delay-in-ms` /
`interval-in-ms` have been decoupled from this variable (finding 18) it no
longer influences them either. Dead configuration — remove it, or set the value
that was actually intended.
## 18. e2e properties still modified despite "moved to #6175"
**File:**
`fineract-e2e-tests-core/src/test/resources/fineract-test-application.properties`
The PR thread says this file was reverted and moved to #6175, but it is
still modified on the branch:
```diff
-fineract-test.event.delay-in-ms=${POLLING_EVENT_WAIT_TIMEOUT_IN_MS:100}
-fineract-test.event.interval-in-ms=${POLLING_EVENT_WAIT_TIMEOUT_IN_MS:100}
+fineract-test.event.delay-in-ms=${POLLING_EVENT_DELAY_IN_MS:100}
+fineract-test.event.interval-in-ms=${POLLING_EVENT_INTERVAL_IN_MS:100}
-fineract-test.client-read-timeout=${CLIENT_READ_TIMEOUT:60}
+fineract-test.client-read-timeout=${CLIENT_READ_TIMEOUT:120}
```
The `CLIENT_READ_TIMEOUT: 60 → 120` doubling was **not** part of the
reverted set described in the thread and has not been discussed. Doubling an
e2e read timeout in a dependency-upgrade PR usually indicates a latency
regression worth understanding rather than absorbing.
For contrast, these reverts **did** land locally (now import-only):
`BusinessDateWritePlatformServiceImpl`, `IncreaseBusinessDateBy1DayTasklet`,
`IncreaseCobDateBy1DayTasklet` and their tests, plus the e2e event files.
## 19. `keystoreType JKS` silently dropped with the tomcat11x bump
**File:** `integration-tests/build.gradle:87`
```diff
-containerId "tomcat10x"
+containerId "tomcat11x"
...
-property 'cargo.tomcat.connector.keystoreType', 'JKS'
```
The keystore is still `fineract-provider/src/main/resources/keystore.jks`.
If Tomcat 11 / Cargo now defaults to PKCS12, this is a latent HTTPS
misconfiguration that happens to work only because of a fallback. Please state
why it was removed.
## 20. Class-wide `Strictness.LENIENT` added
**File:**
`fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/jobs/generateloanlossprovisioning/GenerateLoanlossProvisioningTaskletTest.java`
```java
@MockitoSettings(strictness = Strictness.LENIENT)
```
Class-wide leniency suppresses unused-stub detection for every test in the
class. The sibling change used per-stub `lenient()` — prefer that here too, and
note which stub actually needed it.
## 21. Unrelated churn remains
Consistent with the maintainer's request to split the PR, these are still
bundled and unrelated to the upgrade:
- `TransactionBoundCacheManager`: private method rename `resetCaches` →
`clearAllCaches` (cosmetic)
- `SavingsAccountTransactionsSearchServiceImpl:142`:
`@SuppressFBWarnings("NP_BOOLEAN_RETURN_NULL")` — the author's JSpecify
explanation is correct (`org.springframework.lang.Nullable` carried a JSR-305
`@CheckForNull` nickname that SpotBugs understood;
`org.jspecify.annotations.Nullable` does not), but the tri-state `Boolean`
deserves its own cleanup ticket
- `mockito-inline` removal across ~8 `dependencies.gradle` files (correct —
it is a no-op on Mockito 5 — but orthogonal)
- `application-test.properties`: added
`fineract.job.retainedEarning-chunk-size`
- `CobPartitioningTest`:
`RestAssured.enableLoggingOfRequestAndResponseIfValidationFails()`
---
# Build and dependency notes
## Justified, but worth tracking
**`build.gradle:98` — buildscript `spring-core` pin.** The author's
verification is sound: the Boot 4 Gradle plugin puts spring-core 7 on the
shared buildscript classpath, and `license-maven-plugin:3.0` calls a
`PropertyPlaceholderHelper(String, String, String, boolean)` constructor that
Spring Framework 7 removed.
```groovy
configurations.classpath {
resolutionStrategy { force 'org.springframework:spring-core:6.2.18' }
}
```
Buildscript-only, no runtime effect. Add a TODO referencing the hierynomus
license plugin issue so it gets removed rather than ossifying.
**`fineract-client/build.gradle:274-281` — JUnit BOM pinned to 5.14.4.**
Reasonable: the generated SDK targets Java 8 and JUnit 6 requires JVM 17+.
Results in two JUnit versions in one build, which is acceptable across separate
modules but should be commented as deliberate (it is).
**`build.gradle:719` — `jcl-over-slf4j` added.** Correct: Spring Framework 7
removed `spring-jcl`, which had been providing `org.apache.commons.logging`,
and `commons-logging` is excluded globally.
**`build.gradle:428-432` — `lombok.config` as a compile task input.** A
genuine correctness fix for incremental/cached builds, and newly load-bearing
given finding 11.
## BOM changes
| Dependency | From | To |
|---|---|---|
| `spring-boot-dependencies` | 3.5.15 | 4.1.0 |
| `spring-boot` Gradle plugin | 3.5.15 | 4.1.0 |
| `eclipselink` / `org.eclipse.persistence.jpa` | 4.0.9 | 5.0.1 |
| `junit-bom` | 5.14.4 | 6.0.3 |
| `jersey-bom` | 3.1.11 | 4.0.2 |
| `springdoc-openapi-starter-webmvc-ui` | 2.8.17 | 3.0.3 |
| `resilience4j-spring-boot3` | — | `resilience4j-spring-boot4` 2.4.0
(pinned; the BOM does not manage the boot4 variant) |
| `jackson-bom` | 2.22.1 | 2.22.1 (retained alongside Jackson 3 from the
Boot 4 BOM) |
Now delegated to the Boot 4 BOM (explicit pins removed): `spring-core`,
`spring-security-core`, `spring-restdocs`, `thymeleaf` (+ `thymeleaf-spring7`),
Tomcat embed (10.1 → 11).
Netty stays pinned on the 4.1 line for MockServer compatibility — unchanged,
still correct.
---
# Recommendation: split the PR
The maintainer's request to split is well-founded, and the review surface
supports a concrete three-way split.
### Part 1 — Mechanical only
Batch package moves, `org.springframework.lang` → JSpecify, `starter-web` →
`starter-webmvc`, Boot 4 module splits, `mockito-inline` removal, BOM bumps
that do not change behaviour. Large but reviewable by inspection.
### Part 2 — Jackson 2 → 3 island
`tools.jackson` migration, `spring-boot-jackson2`, the Jersey handler
rewrite, `lombok.config`. Needs contract tests on the REST wire format.
Findings: **9, 10, 11**
### Part 3 — Spring Batch 6 semantics
`ChunkOrientedStep` transaction boundaries, `@Transactional` on
`COBBusinessStepService`, listener registration, `JobOperator` / `JobRegistry`
/ `getNextJobParameters`, task-executor queue sizing, and the execution-context
serializer plus its data migration.
Findings: **1, 2, 4, 5, 6, 7, 8, 12**
This part needs load testing against a partitioned COB run and is where I
would hold the line. Parts 1 and 2 are mergeable with normal review.
---
# Blocking items
Before merge, regardless of how the PR is split:
1. **Finding 1** — decide and implement the execution-context migration
strategy.
2. **Finding 2** — resolve or explicitly gate the COB atomicity divergence
(FINERACT-2684).
3. **Finding 3** — fix the CloudWatch exclusion/property contradiction.
4. **Finding 4** — remove the double listener registration and the incorrect
comment.
5. **Finding 8** — apply the `AsyncTaskExecutor` fix to the Working Capital
config.
6. **Finding 18** — confirm the actual branch contents against the PR
thread, and explain the `CLIENT_READ_TIMEOUT` doubling.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]