feat(accounts): add idempotencyKey support with request lifecycle tracking for balance updates#66
Conversation
…cking for balance updates - Add idempotencyKey field to UpdateBalanceRequest DTO - Extend RequestType enum with UPDATE_BALANCE_PESSIMISTIC and UPDATE_BALANCE_OPTIMISTIC - Add createPendingRequestIfAbsent(), completeRequest(), failRequest() to RequestService - Wrap balance operations in AccountService with request lifecycle (PENDING -> COMPLETED|FAILED) - Add V7 migration to update CHECK constraint for new request types - Update Swagger annotations with 409 response for idempotency conflicts - Add unit tests for idempotency scenarios and request lifecycle
… readSavedResponse edge cases
There was a problem hiding this comment.
Pull request overview
Adds idempotency support for account balance updates by introducing an optional idempotencyKey in UpdateBalanceRequest and persisting a corresponding request lifecycle entry, similar to the existing async request tracking model.
Changes:
- Added
idempotencyKeyto balance update DTOs and propagated constructor/signature changes through unit/integration tests. - Introduced new
RequestTypevalues and a Flyway migration to extend therequest.typeCHECK constraint accordingly. - Implemented request creation/completion/failure tracking for
updateBalancePessimisticandupdateBalanceOptimisticflows.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/lt/satsyuk/dto/UpdateBalanceRequest.java | Adds nullable idempotencyKey field to support idempotent balance updates. |
| src/main/java/lt/satsyuk/model/RequestType.java | Adds request types for pessimistic/optimistic balance update tracking. |
| src/main/java/lt/satsyuk/service/RequestService.java | Adds request creation + completion/failure helpers for idempotency-backed request tracking. |
| src/main/java/lt/satsyuk/service/AccountService.java | Wraps balance update operations with request lifecycle tracking and idempotent replay behavior. |
| src/main/java/lt/satsyuk/controller/AccountController.java | Updates controller to use new service signatures and documents 409 response scenarios. |
| src/main/resources/db/migration/V7__add_balance_update_request_types.sql | Extends chk_request_type to allow the new request types. |
| src/test/java/lt/satsyuk/service/RequestServiceTest.java | Adds unit tests for idempotent request creation and conflict scenarios. |
| src/test/java/lt/satsyuk/service/AccountServiceTest.java | Updates tests for new service signatures and adds idempotency replay scenarios. |
| src/test/java/lt/satsyuk/controller/AccountControllerTest.java | Updates controller tests to use the new DTO constructor and service method signatures. |
| src/test/java/lt/satsyuk/api/integrationtest/AccountIntegrationIT.java | Updates integration tests to use the new UpdateBalanceRequest constructor. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add RequestType check during idempotency lookup to prevent cross-type key reuse - Handle DataIntegrityViolationException for concurrent idempotent requests - Add status tracking to CreateRequestResult for proper state handling - Handle PENDING/PROCESSING/FAILED states on idempotent replay - Add AccountUpdateFailedException and AccountUpdateInProgressException - Add GlobalExceptionHandler support for new exceptions - Add i18n messages for account update failure and in-progress states
Review fixes applied (commit 054fad5)Copilot comment 1 — Type check missing during idempotency lookupFixed. Added Copilot comment 2 — Race condition on concurrent idempotent requestsFixed. Wrapped Copilot comment 3 & 4 — Idempotent replay returns null for PENDING/FAILEDFixed.
121/121 unit tests passing. |
igorsatsyuk
left a comment
There was a problem hiding this comment.
Addressed all 4 Copilot review comments. Summary in PR comment.
…named pattern - Remove unused RequestStatus import from AccountService - Replace catch (JsonProcessingException ex) with unnamed pattern _ - Add tests for FAILED/PENDING/PROCESSING idempotent replay paths - Add tests for parseErrorResponse edge cases
… preservation - Fix NPE in DataIntegrityViolationException retry when idempotencyKey is null - Add REQUIRES_NEW propagation to failRequest() to persist FAILED status on rollback - Preserve original error code in AccountUpdateFailedException for faithful replay - Add resolveHttpStatus() to GlobalExceptionHandler for error code mapping - Fix idempotent replay tests to use valid idempotencyKey
Round 2 review fixes (commit 5c49b93)1. NPE in DataIntegrityViolationException retryFixed. Changed idempotencyKey.toString() to String.valueOf(requestId) in the catch block. Uses the already-resolved 2. failRequest() should use REQUIRES_NEWFixed. Added @transactional(propagation = Propagation.REQUIRES_NEW) to ailRequest(). Now FAILED status is committed in a separate transaction, surviving the caller's rollback. 3. FAILED status always returns HTTP 500Fixed.
4. Tests with null idempotencyKey + alreadyExisted=trueFixed. Idempotent replay tests now use UUID.randomUUID() as the idempotencyKey, matching the real implementation path. 127/127 unit tests passing. |
…createPendingRequestIfAbsent - Add REQUIRES_NEW to createPendingRequestIfAbsent so request is committed independently - Wrap failRequest calls in markRequestFailed helper with try-catch to prevent masking original exceptions - Fixes integration test: AccountNotFoundException now propagates instead of RequestNotFoundException
…t in try-catch - Persist NOT_FOUND (40401) for AccountNotFoundException and CONFLICT (40901) for AccountOptimisticLockException instead of BAD_REQUEST for both - Wrap failRequest in markRequestFailed helper with try-catch to prevent masking original exceptions - Add REQUIRES_NEW to createPendingRequestIfAbsent for independent transaction - Rename misleading test to createPendingRequestIfAbsentCreatesNewRequestWhenNoExistingFound
Round 3 fixes (commits 34efb0a + 28d0b41)1. REQUIRES_NEW on createPendingRequestIfAbsentFixed. Added @transactional(propagation = Propagation.REQUIRES_NEW) to ensure the request row is committed independently before any business logic runs. This guarantees visibility for subsequent ailRequest() calls. 2. failRequest masking original exceptionsFixed. Wrapped ailRequest in markRequestFailed helper with try-catch. If marking as FAILED fails, the original exception (AccountNotFoundException, etc.) propagates cleanly instead of being replaced by RequestNotFoundException. 3. Error codes for FAILED replaysFixed.
4. Misleading test nameFixed. Renamed createPendingRequestIfAbsentAllowsSameKeyForDifferentClient to createPendingRequestIfAbsentCreatesNewRequestWhenNoExistingFound to match actual behavior. 127/127 unit tests passing. CI integration test should now pass as well. |
…ng and pessimistic not-found path
… Swagger description - Replace String.equals with jsonEquals (ObjectMapper.readTree) for idempotency payload comparison - Use generic 'Internal server error' message for 5xx failures to prevent info disclosure - Update 409 Swagger description to cover all conflict cases
…acking, consolidate JSON deserialization - Extract executeWithRequestTracking(Supplier) to eliminate duplicated try/catch in pessimistic/optimistic paths - Consolidate readSavedResponse to delegate to parseErrorResponse (removes duplicated ObjectMapper deserialization) - Update test to match new null-return behavior for malformed JSON
…quals and null-key tests - Replace catch (JsonProcessingException ex) with unnamed pattern _ in jsonEquals - Add 6 tests for jsonEquals (null, equal, different, malformed fallback) - Add test for null idempotencyKey path skipping idempotency check
…istic transaction atomicity - Store StoredError(exceptionType, clientId) instead of AppResponse for failed requests - On replay, rethrow original exception type (AccountNotFoundException/AccountOptimisticLockException) so GlobalExceptionHandler localizes identically - Move completeRequest inside TransactionTemplate callback in safeUpdate for atomic balance+status commit - Add GlobalExceptionHandler tests for AccountUpdateFailedException, AccountUpdateInProgressException - Add RequestService tests for type mismatch and DataIntegrityViolationException retry paths
- Delete AccountUpdateFailedException class (no longer used after StoredError replay fix) - Remove handleAccountUpdateFailed from GlobalExceptionHandler - Remove handleAccountUpdateFailed tests from GlobalExceptionHandlerTest
…h blocks - Remove unused resolveHttpStatus from GlobalExceptionHandler - Combine 3 identical catch blocks into single catch (RuntimeException) in pessimistic/optimistic paths - Remove unused doReturn import from AccountServiceTest - Fix immediate return in updateBalanceOptimistic
|



Summary
idempotencyKeytoUpdateBalanceRequestfor idempotent balance change requestsrequesttable with status lifecycle (PENDING → COMPLETED|FAILED)UPDATE_BALANCE_PESSIMISTICandUPDATE_BALANCE_OPTIMISTICrequest typesChanges
Core
UUID idempotencyKeyfieldUPDATE_BALANCE_PESSIMISTIC,UPDATE_BALANCE_OPTIMISTICcreatePendingRequestIfAbsent(),completeRequest(),failRequest()updateBalancePessimisticandupdateBalanceOptimisticwith request lifecycle trackingDatabase
chk_request_typeCHECK constraint to include new typesTests
RequestServiceTestfor idempotency scenariosAccountServiceTestandAccountControllerTestfor new method signaturesAccountIntegrationITfor newUpdateBalanceRequestconstructorIdempotency behavior
Verification
mvn test: 114/114 unit tests passing