Skip to content

Commit 3e8d2f3

Browse files
authored
Merge pull request #211 from ReinaMaze/lock-timeout
enhance(db): configure lock timeouts for critical transaction flows
2 parents 9ad6a4a + e0bb42d commit 3e8d2f3

File tree

9 files changed

+848
-60
lines changed

9 files changed

+848
-60
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ ENABLE_BOND_EVENTS=false
7676
# ── CORS ─────────────────────────────────────────────────────────────────────
7777
CORS_ORIGIN=*
7878

79+
# ── Database Lock Timeouts (milliseconds) ────────────────────────────────────
80+
# Timeout for read-only queries with minimal contention tolerance
81+
DB_LOCK_TIMEOUT_READONLY=2000
82+
# Timeout for standard read-modify-write operations
83+
DB_LOCK_TIMEOUT_DEFAULT=5000
84+
# Timeout for critical flows requiring extended wait
85+
DB_LOCK_TIMEOUT_CRITICAL=10000
86+
7987
# ── Analytics Materialized View Refresh ──────────────────────────────────────
8088
ANALYTICS_REFRESH_CRON=*/5 * * * *
8189
ANALYTICS_STALENESS_SECONDS=300

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This service is part of [Credence](../README.md). It supports:
99
- Public query API (trust score, bond status, attestations)
1010
- Horizon listener for bond withdrawal events
1111
- Redis-based caching layer
12+
- **Configurable lock timeouts** – Prevents indefinite waits on locked rows with policy-based timeouts and automatic retry
1213
- **Horizon listener / identity state sync** – Reconciles DB with on-chain bond state (see [Identity state sync](#identity-state-sync)).
1314
- Reputation engine (off-chain score from bond data) (future)
1415

src/config/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@ export const envSchema = z.object({
6565
ENABLE_TRUST_SCORING: z
6666
.string()
6767
.default('false')
68-
.transform((val) => val === 'true'),
68+
.transform((val: string) => val === 'true'),
6969
ENABLE_BOND_EVENTS: z
7070
.string()
7171
.default('false')
72-
.transform((val) => val === 'true'),
72+
.transform((val: string) => val === 'true'),
7373

7474
// Horizon (optional)
7575
HORIZON_URL: z.string().url().optional(),

src/db/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './repositories/index.js'
22
export * from './schema.js'
3+
export * from './transaction.js'
34
export { createDatabase } from './connection.js'
45
export { runMigrations } from './migrations.js'
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { Pool } from 'pg'
2+
import { BondsRepository, InsufficientFundsError } from './bondsRepository.js'
3+
import { LockTimeoutError } from '../transaction.js'
4+
import { IdentitiesRepository } from './identitiesRepository.js'
5+
6+
describe('BondsRepository - Lock Timeout', () => {
7+
let pool: Pool
8+
let bondsRepo: BondsRepository
9+
let identitiesRepo: IdentitiesRepository
10+
let testIdentityAddress: string
11+
12+
beforeAll(async () => {
13+
pool = new Pool({
14+
connectionString: process.env.DB_URL,
15+
max: 10,
16+
})
17+
18+
// Create repositories with short timeouts for testing
19+
bondsRepo = new BondsRepository(pool, pool, {
20+
readonly: 500,
21+
default: 1000,
22+
critical: 2000,
23+
})
24+
identitiesRepo = new IdentitiesRepository(pool)
25+
26+
// Setup test identity
27+
const identity = await identitiesRepo.create({
28+
address: `test-${Date.now()}`,
29+
})
30+
testIdentityAddress = identity.address
31+
})
32+
33+
afterAll(async () => {
34+
await pool.end()
35+
})
36+
37+
describe('debit with lock timeout', () => {
38+
it('should successfully debit when no contention', async () => {
39+
const bond = await bondsRepo.create({
40+
identityAddress: testIdentityAddress,
41+
amount: '1000',
42+
startTime: new Date(),
43+
durationDays: 30,
44+
})
45+
46+
const updated = await bondsRepo.debit(bond.id, '100')
47+
48+
expect(updated.amount).toBe('900')
49+
})
50+
51+
it('should throw InsufficientFundsError when amount exceeds balance', async () => {
52+
const bond = await bondsRepo.create({
53+
identityAddress: testIdentityAddress,
54+
amount: '500',
55+
startTime: new Date(),
56+
durationDays: 30,
57+
})
58+
59+
await expect(bondsRepo.debit(bond.id, '600')).rejects.toThrow(
60+
InsufficientFundsError
61+
)
62+
})
63+
64+
it('should handle concurrent debits with retry', async () => {
65+
const bond = await bondsRepo.create({
66+
identityAddress: testIdentityAddress,
67+
amount: '1000',
68+
startTime: new Date(),
69+
durationDays: 30,
70+
})
71+
72+
// Execute concurrent debits
73+
const results = await Promise.all([
74+
bondsRepo.debit(bond.id, '100'),
75+
bondsRepo.debit(bond.id, '200'),
76+
bondsRepo.debit(bond.id, '150'),
77+
])
78+
79+
// All debits should succeed
80+
expect(results).toHaveLength(3)
81+
82+
// Final balance should be 1000 - 100 - 200 - 150 = 550
83+
const final = await bondsRepo.findById(bond.id)
84+
expect(final?.amount).toBe('550')
85+
})
86+
87+
it('should retry on lock timeout and eventually succeed', async () => {
88+
const bond = await bondsRepo.create({
89+
identityAddress: testIdentityAddress,
90+
amount: '1000',
91+
startTime: new Date(),
92+
durationDays: 30,
93+
})
94+
95+
// Hold a lock briefly
96+
const client = await pool.connect()
97+
await client.query('BEGIN')
98+
await client.query('SELECT * FROM bonds WHERE id = $1 FOR UPDATE', [bond.id])
99+
100+
// Release after 500ms
101+
setTimeout(async () => {
102+
await client.query('ROLLBACK')
103+
client.release()
104+
}, 500)
105+
106+
// This should retry and succeed after lock is released
107+
const updated = await bondsRepo.debit(bond.id, '100')
108+
expect(updated.amount).toBe('900')
109+
})
110+
111+
it('should throw LockTimeoutError after max retries', async () => {
112+
const bond = await bondsRepo.create({
113+
identityAddress: testIdentityAddress,
114+
amount: '1000',
115+
startTime: new Date(),
116+
durationDays: 30,
117+
})
118+
119+
// Hold lock for longer than retry window
120+
const client = await pool.connect()
121+
await client.query('BEGIN')
122+
await client.query('SELECT * FROM bonds WHERE id = $1 FOR UPDATE', [bond.id])
123+
124+
try {
125+
// Create repo with very short timeout
126+
const shortTimeoutRepo = new BondsRepository(pool, pool, {
127+
readonly: 100,
128+
default: 100,
129+
critical: 200,
130+
})
131+
132+
await expect(shortTimeoutRepo.debit(bond.id, '100')).rejects.toThrow(
133+
LockTimeoutError
134+
)
135+
} finally {
136+
await client.query('ROLLBACK')
137+
client.release()
138+
}
139+
})
140+
141+
it('should serialize multiple concurrent debits correctly', async () => {
142+
const bond = await bondsRepo.create({
143+
identityAddress: testIdentityAddress,
144+
amount: '10000',
145+
startTime: new Date(),
146+
durationDays: 30,
147+
})
148+
149+
// Execute many concurrent debits
150+
const debitPromises = Array.from({ length: 10 }, (_, i) =>
151+
bondsRepo.debit(bond.id, '100')
152+
)
153+
154+
await Promise.all(debitPromises)
155+
156+
// Final balance should be 10000 - (10 * 100) = 9000
157+
const final = await bondsRepo.findById(bond.id)
158+
expect(final?.amount).toBe('9000')
159+
})
160+
161+
it('should handle mixed success and insufficient funds', async () => {
162+
const bond = await bondsRepo.create({
163+
identityAddress: testIdentityAddress,
164+
amount: '500',
165+
startTime: new Date(),
166+
durationDays: 30,
167+
})
168+
169+
const results = await Promise.allSettled([
170+
bondsRepo.debit(bond.id, '200'),
171+
bondsRepo.debit(bond.id, '200'),
172+
bondsRepo.debit(bond.id, '200'), // This should fail
173+
])
174+
175+
const succeeded = results.filter((r) => r.status === 'fulfilled')
176+
const failed = results.filter((r) => r.status === 'rejected')
177+
178+
expect(succeeded.length).toBe(2)
179+
expect(failed.length).toBe(1)
180+
181+
// Check that the failure is InsufficientFundsError
182+
const failedResult = failed[0] as PromiseRejectedResult
183+
expect(failedResult.reason).toBeInstanceOf(InsufficientFundsError)
184+
})
185+
})
186+
187+
describe('error metadata', () => {
188+
it('should include policy and timeout in LockTimeoutError', async () => {
189+
const bond = await bondsRepo.create({
190+
identityAddress: testIdentityAddress,
191+
amount: '1000',
192+
startTime: new Date(),
193+
durationDays: 30,
194+
})
195+
196+
const client = await pool.connect()
197+
await client.query('BEGIN')
198+
await client.query('SELECT * FROM bonds WHERE id = $1 FOR UPDATE', [bond.id])
199+
200+
try {
201+
const shortTimeoutRepo = new BondsRepository(pool, pool, {
202+
readonly: 100,
203+
default: 100,
204+
critical: 100,
205+
})
206+
207+
await shortTimeoutRepo.debit(bond.id, '100')
208+
} catch (error) {
209+
expect(error).toBeInstanceOf(LockTimeoutError)
210+
const lockError = error as LockTimeoutError
211+
expect(lockError.policy).toBe('critical')
212+
expect(lockError.timeoutMs).toBe(100)
213+
expect(lockError.message).toContain('Lock timeout')
214+
} finally {
215+
await client.query('ROLLBACK')
216+
client.release()
217+
}
218+
})
219+
})
220+
})

0 commit comments

Comments
 (0)