Skip to content

Commit efaf760

Browse files
committed
docs/refactor: resolve all final pass 3 review findings and clean code
1 parent 3f37516 commit efaf760

12 files changed

Lines changed: 206 additions & 52 deletions

File tree

system-design/idempotent-payment-ledger/GATE_CHECKLIST.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@
4747

4848
- [x] Initial failure modes are documented.
4949
- [x] Ledger imbalance limitation and next slice are documented.
50-
- [x] Recovery path is implemented for reconciliation.
50+
- [ ] Recovery path is implemented for reconciliation. (Deferred to transaction reconciliation poller slice)
5151
- [x] Timeout-after-commit scenario is simulated. (Verified via integration tests in RedisPaymentIntakeIntegrationTest)
5252

5353
## Security
5454

55-
- [x] Trust boundary for idempotency keys is documented. (Documented in docs/DESIGN_DOC.md)
56-
- [x] Tenant/auth model is documented. (Documented in docs/DESIGN_DOC.md)
55+
- [ ] Trust boundary for idempotency keys is documented. (Deferred to authentication & risk-control slice)
56+
- [ ] Tenant/auth model is documented. (Deferred to authentication & risk-control slice)
5757

5858
## Engineering Communication
5959

system-design/idempotent-payment-ledger/README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,31 @@ The system must make payment intake retry-safe while preserving auditability and
2929
- Invalid requests must not mutate ledger state.
3030
- The durable adapter uses PostgreSQL uniqueness and Flyway-managed schema as the production-like correctness boundary.
3131

32-
## Implemented Endpoints
33-
34-
Run:
32+
## Running the Application
3533

34+
### 1. Start Infrastructure
35+
Spin up the PostgreSQL and Redis containers:
3636
```bash
3737
docker compose -f system-design/idempotent-payment-ledger/compose.yml up -d
38-
./mvnw -pl system-design/idempotent-payment-ledger spring-boot:run
3938
```
4039

40+
### 2. Run Application
41+
Choose one of the two active profiles to run the application:
42+
43+
* **Default Mode (PostgreSQL + In-Memory Cache)**:
44+
Uses PostgreSQL for durable ledger entry persistence, but keeps idempotency state in a local JVM-memory store. Ideal for fast local development without external cache dependencies.
45+
```bash
46+
./mvnw -pl system-design/idempotent-payment-ledger spring-boot:run
47+
```
48+
49+
* **Production-Like Hybrid Mode (PostgreSQL + Redis)**:
50+
Uses PostgreSQL for durable ledger entry persistence and Redis for distributed locking (`SETNX`) and idempotency caching. Matches production-scale deployments.
51+
```bash
52+
./mvnw -pl system-design/idempotent-payment-ledger spring-boot:run -Dspring-boot.run.profiles=jpa,redis
53+
```
54+
55+
## Implemented Endpoints
56+
4157
Create a payment:
4258

4359
```bash
@@ -79,7 +95,7 @@ suite; these tests fail fast rather than falling back to H2.
7995

8096
## Production Gaps
8197

82-
- The in-memory adapter remains for fast unit-level semantics tests; production-like configuration defaults to Redis for idempotency coordination and JPA/PostgreSQL for ledger durability.
98+
- The in-memory adapter remains for fast unit-level semantics tests; production-like hybrid configuration uses Redis (jpa,redis profile) for idempotency coordination and JPA/PostgreSQL for ledger durability.
8399
- No transactional outbox exists yet.
84100
- No auth or tenant model exist yet.
85101
- Observability features domain metrics for accepted, replayed, and rejected requests, but does not yet emit structured tracing spans.

system-design/idempotent-payment-ledger/compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,14 @@ services:
1313
interval: 5s
1414
timeout: 3s
1515
retries: 20
16+
17+
redis:
18+
image: redis:7-alpine
19+
container_name: idempotent-payment-ledger-redis
20+
ports:
21+
- "6379:6379"
22+
healthcheck:
23+
test: ["CMD", "redis-cli", "ping"]
24+
interval: 5s
25+
timeout: 3s
26+
retries: 20

system-design/idempotent-payment-ledger/docs/ARCHITECT_NOTES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This document outlines the key architectural decisions, design trade-offs, and p
1919
## 2. Concurrency Control & Database-Level Defense-in-Depth
2020

2121
### 2.1. Uniqueness Guarantee (V3 Migration)
22-
To guarantee absolute data integrity under concurrent attempts, a database-level unique constraint `uq_payments_key UNIQUE (tenant_id, idempotency_key)` is enforced on the `payments` table. This serves as the ultimate safety net if the distributed cache layer (Redis) fails or experiences key eviction.
22+
To enforce transaction consistency under concurrent attempts, a database-level unique constraint `uq_payments_key UNIQUE (tenant_id, idempotency_key)` is enforced on the `payments` table. This serves as the safety net if the distributed cache layer (Redis) fails or experiences key eviction.
2323

2424
### 2.2. Concurrency Race Recovery (Look-and-Replay)
2525
When concurrent requests with the same idempotency key bypass the cache layer (e.g., during lock thrashing or cache eviction) and execute the write path simultaneously:
@@ -46,4 +46,4 @@ If the database transaction commits successfully but the subsequent cache comple
4646
Applying the V3 unique constraint to an existing production database with historical transaction data requires pre-flight audits to identify and resolve any duplicate keys. Refer to the [Operations Runbook](OPERATIONS_RUNBOOK.md) for details on pre-migration cleanup queries.
4747

4848
### 4.2. Load Testing & Capacity Estimation
49-
While the core concurrency invariants are mathematically verified, future work should include running synthetic load simulations (e.g., 5,000+ concurrent requests) to benchmark Redis lock performance and compute production RAM allocation requirements based on anticipated throughput.
49+
While the core concurrency invariants are functionally verified under concurrent thread simulations, future work should include running synthetic load simulations (e.g., 5,000+ concurrent requests) to benchmark Redis lock performance and compute production RAM allocation requirements based on anticipated throughput.

system-design/idempotent-payment-ledger/docs/DESIGN_DOC.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ This module uses a pragmatic ports-and-adapters layout. The payment intake use c
6262
flowchart TD
6363
client[Client] --> api[PaymentController]
6464
api --> service[PaymentIntakeService]
65-
service --> idem[IdempotencyStore]
66-
idem -->|existing same payload| replay[Replay stored response]
67-
idem -->|existing different payload| conflict[409 Conflict]
68-
idem -->|new reservation| ledger[LedgerStore]
69-
ledger --> infra[InMemoryLedgerStore]
70-
infra --> debit[Debit payer account]
71-
infra --> credit[Credit merchant account]
72-
credit --> complete[Complete idempotency reservation]
65+
service --> lock[IdempotencyStore / Redis Lock]
66+
lock -->|existing same payload| replay[Replay stored response]
67+
lock -->|existing different payload| conflict[409 Conflict]
68+
lock -->|new reservation| ledger[LedgerStore]
69+
ledger --> jpa[JpaLedgerStore / PostgreSQL]
70+
jpa --> debit[Debit payer account]
71+
jpa --> credit[Credit merchant account]
72+
credit --> complete[Complete idempotency reservation / Redis Unlock]
7373
complete --> response[Return accepted response]
7474
```
7575

system-design/idempotent-payment-ledger/docs/OPERATIONS_RUNBOOK.md

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,24 @@ The system runs on a hybrid database architecture:
1515

1616
### 1. PostgreSQL Database Outage (P0)
1717

18-
* **Symptoms**:
18+
* **Symptoms**:
1919
* API returns HTTP `500 Internal Server Error` with `CannotGetJdbcConnectionException` or `HikariPool-1 - Connection is not available`.
2020
* Micrometer metric `payment.intake.requests{status="failed"}` spikes.
2121
* **Impact**: Core payment intake is entirely blocked. The application cannot write payments or mutate ledger balances.
2222
* **Runbook Actions**:
2323
1. Verify DB container/host status: `docker ps` or verify AWS RDS instance status.
2424
2. If connection pool exhaustion is suspected, inspect active connections:
2525
```sql
26-
SELECT pid, age(clock_timestamp(), query_start), usename, state, query
27-
FROM pg_stat_activity
26+
SELECT pid, age(clock_timestamp(), query_start), usename, state, query
27+
FROM pg_stat_activity
2828
WHERE state != 'idle' AND query NOT LIKE '%pg_stat_activity%';
2929
```
3030
3. Restart database or scale connection pool size if required by adjusting `spring.datasource.hikari.maximum-pool-size` configuration.
3131
4. Note: Redis keys marked `PROCESSING` will naturally expire after 120 seconds. Clients attempting retry during DB outage will receive HTTP `425 Too Early` or HTTP `500`, then automatically transition to clean retries once Postgres recovers.
3232

3333
### 2. Redis Cluster Outage / Cache Eviction (P1)
3434

35-
* **Symptoms**:
35+
* **Symptoms**:
3636
* API returns HTTP `500` or fails to connect to Redis (`RedisConnectionFailureException`).
3737
* Prometheus alert triggers for Redis memory exhaustion or service down.
3838
* **Impact**: External boundary locking is disabled. Concurrency race protection falls back entirely to PostgreSQL unique constraints.
@@ -71,7 +71,7 @@ If a Spring Boot container crashes abruptly *after* reserving a key in Redis but
7171
* **Manual Intervention (Emergency Force-Unlock)**:
7272
If a P0 merchant transaction is blocked and cannot wait 120 seconds:
7373
1. Connect to the Redis instance: `redis-cli`.
74-
2. Locate the stuck key safely using `SCAN` to avoid blocking production:
74+
2. Locate the stuck key safely using `SCAN` to avoid blocking production:
7575
`SCAN 0 MATCH idempotency:* COUNT 1000`
7676
3. Delete the key: `DEL idempotency:<stuck_key>`.
7777
4. Ask the client to retry immediately.
@@ -82,8 +82,8 @@ If a Spring Boot container crashes abruptly *after* reserving a key in Redis but
8282
* **Cause**: The client reused an existing key but changed parameters (e.g. payer, amount, currency), which is an API violation.
8383
* **Action**: Trace client request parameters in logs. Inspect database payments to see what was originally recorded:
8484
```sql
85-
SELECT payment_id, payer_account_id, merchant_account_id, amount, currency, status
86-
FROM payments
85+
SELECT payment_id, payer_account_id, merchant_account_id, amount, currency, status
86+
FROM payments
8787
WHERE idempotency_key = 'offending-key';
8888
```
8989
Confirm that the client needs to generate a fresh, unique `Idempotency-Key` for the new transaction.
@@ -110,23 +110,23 @@ HAVING SUM(CASE WHEN entry_type = 'CREDIT' THEN amount ELSE -amount END) != 0.00
110110

111111
Verify that the stored account balance matches the sum of its credit and debit ledger entries, taking into account the initial seeded balances for test accounts (1000.00 for `acct-payer` and `acct-payer-http`).
112112
```sql
113-
SELECT
113+
SELECT
114114
a.account_id,
115115
a.balance AS current_stored_balance,
116116
(
117-
CASE
118-
WHEN a.account_id IN ('acct-payer', 'acct-payer-http') THEN 1000.0000
119-
ELSE 0.0000
117+
CASE
118+
WHEN a.account_id IN ('acct-payer', 'acct-payer-http') THEN 1000.0000
119+
ELSE 0.0000
120120
END
121121
+ COALESCE(SUM(CASE WHEN le.entry_type = 'CREDIT' THEN le.amount ELSE -le.amount END), 0.0000)
122122
) AS calculated_ledger_balance
123123
FROM accounts a
124124
LEFT JOIN ledger_entries le ON a.account_id = le.account_id
125125
GROUP BY a.account_id, a.balance
126126
HAVING a.balance != (
127-
CASE
128-
WHEN a.account_id IN ('acct-payer', 'acct-payer-http') THEN 1000.0000
129-
ELSE 0.0000
127+
CASE
128+
WHEN a.account_id IN ('acct-payer', 'acct-payer-http') THEN 1000.0000
129+
ELSE 0.0000
130130
END
131131
+ COALESCE(SUM(CASE WHEN le.entry_type = 'CREDIT' THEN le.amount ELSE -le.amount END), 0.0000)
132132
);
@@ -150,15 +150,17 @@ Before executing the **V3 unique constraint migration** (`V3__add_unique_constra
150150
GROUP BY tenant_id, idempotency_key
151151
HAVING COUNT(*) > 1;
152152
```
153-
2. **Mitigation / Deduplication Action**:
154-
If duplicate payments are found, the Flyway migration will fail. You must deduplicate or rename the conflicting keys out-of-band before deploying the V3 application code:
155-
```sql
156-
-- Example deduplication script (keeps the oldest payment row per tenant/key)
157-
DELETE FROM payments a
158-
WHERE a.ctid <> (
159-
SELECT min(b.ctid)
160-
FROM payments b
161-
WHERE a.tenant_id = b.tenant_id
162-
AND a.idempotency_key = b.idempotency_key
163-
);
164-
```
153+
2. **Mitigation & Incident Response Protocol**:
154+
If duplicate combinations are detected, applying the `V3` unique constraint will fail, halting the release pipeline. **DO NOT attempt to delete transaction history directly from the database.** Deleting historical rows will violate audit logs and database foreign key integrity (e.g., from ledger entries).
155+
156+
Instead, execute the following Incident Response protocol:
157+
- **Halt Rollout**: Immediately abort the database migration and notify the on-call and release manager teams.
158+
- **Analyze & Locate Winner**: Identify the duplicate entries and determine which database row is the canonical payment (e.g., matching the success status returned to the customer or payment gateway).
159+
- **Rekey Conflicting Entries**: For the duplicates that are non-canonical, update the `idempotency_key` column to append a suffix (e.g., `_duplicate_v3_migration_incident_<incident_id>`). This preserves audit logs, honors foreign keys, and satisfies the physical uniqueness constraint:
160+
```sql
161+
-- Example suffix rekeying query (safe; preserves historical data for audits)
162+
UPDATE payments
163+
SET idempotency_key = idempotency_key || '_dup_incident_12345'
164+
WHERE payment_id = '<non_canonical_payment_id>';
165+
```
166+
- **Audit & Reconcile**: Verify that the associated ledger transactions and entries remain balanced. Execute compensating accounting entries if any discrepancy is found.

system-design/idempotent-payment-ledger/src/main/java/infra/systemdesign/paymentledger/application/PaymentIntakeService.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import java.nio.charset.StandardCharsets;
1313
import java.security.MessageDigest;
1414
import java.security.NoSuchAlgorithmException;
15-
import java.time.Clock;
1615
import java.util.HexFormat;
1716

1817

@@ -21,17 +20,14 @@ public class PaymentIntakeService {
2120

2221
private final IdempotencyStore idempotencyStore;
2322
private final LedgerStore ledgerStore;
24-
private final Clock clock;
2523
private final MeterRegistry meterRegistry;
2624

2725
public PaymentIntakeService(
2826
IdempotencyStore idempotencyStore,
2927
LedgerStore ledgerStore,
30-
Clock clock,
3128
MeterRegistry meterRegistry) {
3229
this.idempotencyStore = idempotencyStore;
3330
this.ledgerStore = ledgerStore;
34-
this.clock = clock;
3531
this.meterRegistry = meterRegistry;
3632
}
3733

system-design/idempotent-payment-ledger/src/main/java/infra/systemdesign/paymentledger/infrastructure/persistence/JpaLedgerStore.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ public int entryCount() {
178178
@Override
179179
@Transactional(propagation = Propagation.REQUIRED, readOnly = true)
180180
public BigDecimal getAccountBalance(String accountId) {
181-
return accountRepository.findReadOnlyByTenantIdAndAccountId(TENANT_ID, accountId)
181+
return accountRepository.findAccountByTenantIdAndAccountId(TENANT_ID, accountId)
182182
.map(AccountEntity::getBalance)
183183
.orElse(BigDecimal.ZERO);
184184
}

system-design/idempotent-payment-ledger/src/main/java/infra/systemdesign/paymentledger/infrastructure/persistence/repository/AccountJpaRepository.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ public interface AccountJpaRepository extends JpaRepository<AccountEntity, Strin
1111
@Lock(LockModeType.PESSIMISTIC_WRITE)
1212
Optional<AccountEntity> findByTenantIdAndAccountId(String tenantId, String accountId);
1313

14-
Optional<AccountEntity> findReadOnlyByTenantIdAndAccountId(String tenantId, String accountId);
14+
@org.springframework.data.jpa.repository.Query("SELECT a FROM AccountEntity a WHERE a.tenantId = :tenantId AND a.accountId = :accountId")
15+
Optional<AccountEntity> findAccountByTenantIdAndAccountId(
16+
@org.springframework.data.repository.query.Param("tenantId") String tenantId,
17+
@org.springframework.data.repository.query.Param("accountId") String accountId);
1518
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- =============================================================================
2+
-- Idempotent Payment Ledger - PostgreSQL Migration (V4)
3+
-- Clean up historical test accounts seeded in production migration V2
4+
-- =============================================================================
5+
6+
DELETE FROM accounts
7+
WHERE account_id IN ('acct-payer', 'acct-merchant', 'acct-payer-http', 'acct-merchant-http');

0 commit comments

Comments
 (0)