Skip to content

feat(accounts): add idempotencyKey support with request lifecycle tracking for balance updates#66

Merged
igorsatsyuk merged 15 commits into
masterfrom
feature/idempotency-key-for-balance-updates
Jun 30, 2026
Merged

feat(accounts): add idempotencyKey support with request lifecycle tracking for balance updates#66
igorsatsyuk merged 15 commits into
masterfrom
feature/idempotency-key-for-balance-updates

Conversation

@igorsatsyuk

Copy link
Copy Markdown
Owner

Summary

  • Add idempotencyKey to UpdateBalanceRequest for idempotent balance change requests
  • Track balance operations in the request table with status lifecycle (PENDING → COMPLETED|FAILED)
  • Add UPDATE_BALANCE_PESSIMISTIC and UPDATE_BALANCE_OPTIMISTIC request types
  • V7 Flyway migration to extend CHECK constraint

Changes

Core

  • UpdateBalanceRequest: added nullable UUID idempotencyKey field
  • RequestType: added UPDATE_BALANCE_PESSIMISTIC, UPDATE_BALANCE_OPTIMISTIC
  • RequestService: added createPendingRequestIfAbsent(), completeRequest(), failRequest()
  • AccountService: wrapped updateBalancePessimistic and updateBalanceOptimistic with request lifecycle tracking
  • AccountController: updated Swagger annotations (409 for idempotency conflict)

Database

  • V7 migration: extends chk_request_type CHECK constraint to include new types

Tests

  • 5 new tests in RequestServiceTest for idempotency scenarios
  • Updated AccountServiceTest and AccountControllerTest for new method signatures
  • Updated AccountIntegrationIT for new UpdateBalanceRequest constructor

Idempotency behavior

Scenario Result
Same key + same payload Returns existing result (no re-execution)
Same key + different payload HTTP 409 Conflict
No key New request with random UUID

Verification

  • mvn test: 114/114 unit tests passing

…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 idempotencyKey to balance update DTOs and propagated constructor/signature changes through unit/integration tests.
  • Introduced new RequestType values and a Flyway migration to extend the request.type CHECK constraint accordingly.
  • Implemented request creation/completion/failure tracking for updateBalancePessimistic and updateBalanceOptimistic flows.

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.

Comment thread src/main/java/lt/satsyuk/service/RequestService.java
Comment thread src/main/java/lt/satsyuk/service/RequestService.java Outdated
Comment thread src/main/java/lt/satsyuk/service/AccountService.java
Comment thread src/main/java/lt/satsyuk/service/AccountService.java
- 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
@igorsatsyuk

Copy link
Copy Markdown
Owner Author

Review fixes applied (commit 054fad5)

Copilot comment 1 — Type check missing during idempotency lookup

Fixed. Added
equest.getType() == type check in createPendingRequestIfAbsent (RequestService.java:110). Same idempotency key now cannot be reused across pessimistic vs optimistic request types.

Copilot comment 2 — Race condition on concurrent idempotent requests

Fixed. Wrapped
equestRepository.save() in try-catch for DataIntegrityViolationException. On conflict, re-reads the existing row and validates type + payload (RequestService.java:127-136).

Copilot comment 3 & 4 — Idempotent replay returns null for PENDING/FAILED

Fixed.

  • CreateRequestResult now includes RequestStatus field
  • Added handleExistingRequest() in AccountService with state-based dispatch:
    • COMPLETED → returns stored AccountResponse
    • FAILED → throws AccountUpdateFailedException (HTTP 500)
    • PENDING/PROCESSING → throws AccountUpdateInProgressException (HTTP 409)
  • Added AccountUpdateFailedException, AccountUpdateInProgressException with GlobalExceptionHandler support
  • Added i18n messages for both new exception types

121/121 unit tests passing.

@igorsatsyuk igorsatsyuk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed all 4 Copilot review comments. Summary in PR comment.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Comment thread src/main/java/lt/satsyuk/service/RequestService.java Outdated
Comment thread src/main/java/lt/satsyuk/service/RequestService.java Outdated
Comment thread src/main/java/lt/satsyuk/service/AccountService.java Outdated
Comment thread src/test/java/lt/satsyuk/service/AccountServiceTest.java Outdated
Comment thread src/test/java/lt/satsyuk/service/AccountServiceTest.java Outdated
…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
@igorsatsyuk

Copy link
Copy Markdown
Owner Author

Round 2 review fixes (commit 5c49b93)

1. NPE in DataIntegrityViolationException retry

Fixed. Changed idempotencyKey.toString() to String.valueOf(requestId) in the catch block. Uses the already-resolved
equestId (which is never null) instead of the nullable parameter.

2. failRequest() should use REQUIRES_NEW

Fixed. 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 500

Fixed.

  • AccountUpdateFailedException now carries �rrorCode from the stored AppResponse
  • handleExistingRequest() extracts the original code via �rrorResponse.code()
  • GlobalExceptionHandler maps error codes to HTTP statuses via
    esolveHttpStatus()
  • Original 404/409 errors are now faithfully replayed

4. Tests with null idempotencyKey + alreadyExisted=true

Fixed. Idempotent replay tests now use UUID.randomUUID() as the idempotencyKey, matching the real implementation path.

127/127 unit tests passing.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Comment thread src/main/java/lt/satsyuk/service/RequestService.java
Comment thread src/main/java/lt/satsyuk/service/AccountService.java Outdated
Comment thread src/main/java/lt/satsyuk/service/AccountService.java Outdated
Comment thread src/test/java/lt/satsyuk/service/RequestServiceTest.java Outdated
…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
@igorsatsyuk

Copy link
Copy Markdown
Owner Author

Round 3 fixes (commits 34efb0a + 28d0b41)

1. REQUIRES_NEW on createPendingRequestIfAbsent

Fixed. 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 exceptions

Fixed. 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 replays

Fixed.

  • AccountNotFoundException → persisted with NOT_FOUND (40401), not BAD_REQUEST
  • AccountOptimisticLockException → persisted with CONFLICT (40901), not BAD_REQUEST
  • Idempotent replays of failed requests now return the correct HTTP status

4. Misleading test name

Fixed. Renamed createPendingRequestIfAbsentAllowsSameKeyForDifferentClient to createPendingRequestIfAbsentCreatesNewRequestWhenNoExistingFound to match actual behavior.

127/127 unit tests passing. CI integration test should now pass as well.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Comment thread src/main/java/lt/satsyuk/service/RequestService.java
Comment thread src/main/java/lt/satsyuk/service/RequestService.java
Comment thread src/main/java/lt/satsyuk/service/AccountService.java
Comment thread src/main/java/lt/satsyuk/service/AccountService.java
Comment thread src/main/java/lt/satsyuk/controller/AccountController.java Outdated
… 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
@igorsatsyuk
igorsatsyuk requested a review from Copilot June 30, 2026 06:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@igorsatsyuk
igorsatsyuk requested a review from Copilot June 30, 2026 06:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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
@sonarqubecloud

Copy link
Copy Markdown

@igorsatsyuk
igorsatsyuk merged commit 5caf953 into master Jun 30, 2026
8 checks passed
@igorsatsyuk
igorsatsyuk deleted the feature/idempotency-key-for-balance-updates branch June 30, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants