FINERACT-2672: Run standing instructions as a partitioned batch job - #6078
oluexpert99 wants to merge 1 commit into
Conversation
12f13fc to
f994c43
Compare
|
@oluexpert99 I like the idea (based on FINERACT-2672) but i am missing a couple things:
Would you mind to move this whole idea and conversation to the Fineract DEV mail list? It might be interesting conversation and we can finalize the appropriate design over there. |
|
This pull request seems to be stale. Are you still planning to work on it? We will automatically close it in 30 days. |
|
@oluexpert99 Have you had the chance to raise this topic on FINERACT DEV mail list? |
|
@oluexpert99 Please let us know if you are happy to finish this story. |
Hi @adamsaghy , sincere apologies for the delay in response . I am more than happy to close this out . Let me know if I cna proceed |
Thank you. Go ahead. please check out my concerns: |
The Execute Standing Instruction job ran every due instruction inside a single transaction. The step is wired with the JPA transaction manager and AccountTransfersWritePlatformService.transferFunds is @transactional(REQUIRED), so every transfer joined that one transaction. When an instruction failed -- InsufficientAccountBalanceException from the withdrawal leg being the ordinary case -- Spring marked the shared transaction rollback-only and at step commit the whole run was reverted: every transfer that had succeeded was undone, and no history row survived to say so. The tasklet then threw JobExecutionException and failed the job. The defect concealed itself, because the record of the failure was rolled back along with the run that produced it. The job now follows the model the platform already uses for LOAN_COB. A partitioner cuts the due set into ranges of source accounts, workers take a partition each, and each worker runs a chunk-oriented step: - Partitions are cut over distinct source accounts rather than instructions, so every instruction debiting an account stays in one partition and runs sequentially there, and two partitions never contend on the same account row. - The reader pages through its partition by keyset over (priority, id), so only one page is in memory whatever the size of the due set. Keyset rather than offset because executing an instruction stamps its last_run_date and removes it from the due set; an offset would step over as many instructions as the previous page had committed. - A chunk is attempted in one transaction and, if any instruction in it fails, replayed one instruction per transaction. That fallback is Spring Batch's own. It is what now guarantees what this ticket is about: an instruction that fails cannot leave a sibling reverted, because the sibling is re-executed and committed on the replay. - Only transient failures are retried. An account short of funds will not have more of them a moment later, so retrying only delays the run and pads the mandate's history; it is skipped and recorded instead. Skipping is unlimited on purpose: a count-based limit would fail the job on a day when many accounts happen to be short, which is the day it most needs to run. - Execution claims the instruction for the business date before transferring, by conditionally stamping last_run_date. Without the claim, the replay of a rolled-back chunk could pay an instruction that had already paid. Chunk size, partition size, retry limit and pool sizes come from fineract.partitioned-job.partitioned-job-properties, so a deployment whose instructions fail often can set the chunk size to 1 and get per-instruction behaviour without a code change. Also fixed here: the retrieval query sorted ORDER BY atsi.priority DESC while the enum is URGENT(1)..LOW(4), so the job worked through the lowest priorities first; and the history row was written by string-concatenated INSERT, logging the attempted amount as a double rather than the amount actually transferred. History is now a JPA entity and records what moved. The unit tests cover the partitioner, the keyset reader, the due-ness processor, the writer's contract with the chunk, the skip listener's durable failure record, and the claim. The integration test runs the real scheduler job with one under-funded instruction among funded ones and asserts the successful transfer persists while the failing instruction leaves a durable failed row. Design discussed on dev@fineract.apache.org, thread "[DISCUSS] Scaling standing-instruction batch execution (FINERACT-2672 / PR apache#6078)". Signed-off-by: oluexpert99 <farooq@techservicehub.io>
f994c43 to
a51b923
Compare
|
Hi @adamsaghy , |
|
@oluexpert99 I am traveling at the moment, but i will try to review next week. |
|
@oluexpert99 Please rebase |
The Execute Standing Instruction job ran every due instruction inside a single transaction. The step is wired with the JPA transaction manager and AccountTransfersWritePlatformService.transferFunds is @transactional(REQUIRED), so every transfer joined that one transaction. When an instruction failed -- InsufficientAccountBalanceException from the withdrawal leg being the ordinary case -- Spring marked the shared transaction rollback-only and at step commit the whole run was reverted: every transfer that had succeeded was undone, and no history row survived to say so. The tasklet then threw JobExecutionException and failed the job. The defect concealed itself, because the record of the failure was rolled back along with the run that produced it. The job now follows the model the platform already uses for LOAN_COB. A partitioner cuts the due set into ranges of source accounts, workers take a partition each, and each worker runs a chunk-oriented step: - Partitions are cut over distinct source accounts rather than instructions, so every instruction debiting an account stays in one partition and runs sequentially there, and two partitions never contend on the same account row. - The reader pages through its partition by keyset over (priority, id), so only one page is in memory whatever the size of the due set. Keyset rather than offset because executing an instruction stamps its last_run_date and removes it from the due set; an offset would step over as many instructions as the previous page had committed. - A chunk is attempted in one transaction and, if any instruction in it fails, replayed one instruction per transaction. That fallback is Spring Batch's own. It is what now guarantees what this ticket is about: an instruction that fails cannot leave a sibling reverted, because the sibling is re-executed and committed on the replay. - Only transient failures are retried. An account short of funds will not have more of them a moment later, so retrying only delays the run and pads the mandate's history; it is skipped and recorded instead. Skipping is unlimited on purpose: a count-based limit would fail the job on a day when many accounts happen to be short, which is the day it most needs to run. - Execution claims the instruction for the business date before transferring, by conditionally stamping last_run_date. Without the claim, the replay of a rolled-back chunk could pay an instruction that had already paid. Chunk size, partition size and retry limit come from fineract.partitioned-job.partitioned-job-properties, so a deployment whose instructions fail often can set the chunk size to 1 and get per-instruction behaviour without a code change. There is deliberately no thread-pool setting, for the reason FINERACT-2621 records against COB: Batch 6's ChunkOrientedStep keeps the chunk transaction on the step thread and, when a task executor is present, submits the items of a chunk to it. An instruction would then be transferred outside the chunk transaction, which is exactly what the chunk-then-single-item replay above depends on. Concurrency comes from partitioning instead. Also fixed here: the retrieval query sorted ORDER BY atsi.priority DESC while the enum is URGENT(1)..LOW(4), so the job worked through the lowest priorities first; and the history row was written by string-concatenated INSERT, logging the attempted amount as a double rather than the amount actually transferred. History is now a JPA entity and records what moved. The unit tests cover the partitioner, the keyset reader, the due-ness processor, the writer's contract with the chunk, the skip listener's durable failure record, and the claim. The integration test runs the real scheduler job with one under-funded instruction among funded ones and asserts the successful transfer persists while the failing instruction leaves a durable failed row. Design discussed on dev@fineract.apache.org, thread "[DISCUSS] Scaling standing-instruction batch execution (FINERACT-2672 / PR apache#6078)". Signed-off-by: oluexpert99 <farooq@techservicehub.io>
a51b923 to
40b1b27
Compare
The Execute Standing Instruction job ran every due instruction inside a single transaction. The step is
wired with the JPA transaction manager and
AccountTransfersWritePlatformService.transferFundsis@Transactional(REQUIRED), so every transfer joined that one transaction. When an instruction failed —InsufficientAccountBalanceExceptionfrom the withdrawal leg being the ordinary case — Spring marked theshared transaction rollback-only, and at step commit the whole run was reverted: every transfer that had
succeeded was undone, and no history row survived to say so. The tasklet then threw
JobExecutionExceptionand failed the job. The defect concealed itself, because the record of the failure was rolled back along
with the run that produced it.
Following the [DISCUSS] thread on dev@ and @adamsaghy's vote there for "leveraging Spring Batch for
distributed processing", this rebuilds the job on the machinery the platform already uses for
LOAN_COBrather than hand-rolling chunking, retry and parallelism.
What the job looks like now
A manager step partitions the due set; workers take a partition each and run a chunk-oriented step.
StandingInstructionItemReaderpages through its partition; one page in memory whatever the size of the due setStandingInstructionPartitionercuts partitions over distinct source accounts, not instructionsfineract.partitioned-job.partitioned-job-properties[1], all env-overridablePartitioning by source account. Every instruction debiting a given account lands in one partition and
runs sequentially there, so two partitions never contend on the same
m_savings_accountrow. The partitionkey collapses the savings and loan id spaces with
COALESCE; the spaces overlap, so two unrelated accountscan share a key and share a partition, which costs a little parallelism and nothing else. The direction that
matters for correctness holds: one account is never split across two partitions.
Keyset, not offset. Executing an instruction stamps its
last_run_dateand so removes it from the dueset. An offset page would step over as many instructions as the previous page had committed. The reader
therefore pages by keyset over the
(priority, id)sort key.The chunk and its replay. A chunk is attempted in one transaction. If any instruction in it fails, the
chunk rolls back and the step replays it one instruction per transaction. That replay is what now delivers
what this ticket is about: a failing instruction cannot leave a sibling reverted, because the sibling is
re-executed and committed on the replay. It also means an instruction can be presented for execution twice
in one run, which is why
StandingInstructionExecutionService.executeclaims the instruction first, byconditionally stamping
last_run_date, and transfers nothing if the claim finds the instruction already runfor that business date.
Retry and skip. Only transient failures are retried — an account short of funds will not have more of
them a moment later, so retrying only delays the run and pads the mandate's history. It is skipped and
recorded instead, in a transaction of its own so the record outlives the rollback of the transfer. Skipping
is deliberately unlimited: a count-based limit would fail the job on a day when many accounts happen to be
short, which is the day it most needs to run.
A deployment whose instructions fail often enough that the replay costs more than it saves can set
EXECUTE_STANDING_INSTRUCTIONS_CHUNK_SIZE=1and get per-instruction behaviour with no code change.Also fixed here
ORDER BY atsi.priority DESCwhile the enum isURGENT(1)..LOW(4), so the jobworked through the lowest priorities first.
INSERT, recording the attempted amount narrowed toa
double. History is now a JPA entity recording what actually moved (zero on failure), inBigDecimal.Deliberately not in this PR
next_run_date. Liked on the thread, but it is a schema change and dev@ question 1 — how itshould react to backdated /
valid_fromedits — was never answered. Worth its own ticket.credit side does not carry the balance check that makes the debit side contend. Residual contention there
is what the retry is for.
Testing
Unit tests cover the partitioner (including a day with nothing due), the keyset reader (page cursor
advances, a short page ends the partition), the due-ness processor, the writer's contract with the chunk (a
failure must propagate, not be swallowed), the skip listener's durable failure record, and the claim.
The integration test runs the real scheduler job with one under-funded instruction among funded ones, and
asserts the successful transfer persists while the failing instruction leaves a durable
failedhistoryrow — the exact scenario that used to revert the run.
Both new queries were run against live MySQL 8.0 and PostgreSQL 16 tenant databases, and the keyset walk was
checked page by page against the full ordering for overlaps and gaps.
Checklist