Skip to content

Commit bba1455

Browse files
change from java semaphore to custom AsynSemaphore
1 parent e3eb7c1 commit bba1455

4 files changed

Lines changed: 438 additions & 16 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.marketdata.sdk.internal.http;
2+
3+
import java.util.ArrayDeque;
4+
import java.util.Deque;
5+
import java.util.concurrent.CompletableFuture;
6+
7+
/**
8+
* Async-safe concurrency limiter. Replaces {@link java.util.concurrent.Semaphore} in the HTTP path
9+
* so that {@code executeAsync} never parks the caller's thread when the pool is at capacity — it
10+
* returns a {@link CompletableFuture} that completes when a permit is released by an in-flight
11+
* request. See ADR-007 for the rationale.
12+
*
13+
* <p>Two invariants:
14+
*
15+
* <ol>
16+
* <li>Every permit is accounted for exactly once — it is either in {@link #availablePermits()}
17+
* (free), held by an in-flight caller (and will be released via {@link #release()}), or
18+
* pending in the waiter queue (and will be released by completing the waiter's future).
19+
* <li>{@link CompletableFuture#complete} of a transferred permit always runs <em>outside</em> the
20+
* lock. Completing a future runs the caller's attached callbacks synchronously on the
21+
* releasing thread, and we never want those running while our lock is held.
22+
* </ol>
23+
*
24+
* <p>Cancelled or otherwise-completed waiters are skipped on {@link #release()} so a cancelled
25+
* {@code acquire} doesn't burn a permit.
26+
*/
27+
public final class AsyncSemaphore {
28+
29+
private final Object lock = new Object();
30+
private final Deque<CompletableFuture<Void>> waiters = new ArrayDeque<>();
31+
private int available;
32+
33+
public AsyncSemaphore(int permits) {
34+
if (permits < 0) {
35+
throw new IllegalArgumentException("permits must be >= 0, was " + permits);
36+
}
37+
this.available = permits;
38+
}
39+
40+
/**
41+
* Asynchronously claim a permit.
42+
*
43+
* <p>Fast path: a permit is available, returns an already-completed future. Slow path: pool is
44+
* exhausted, returns a pending future enqueued FIFO; it completes when some in-flight caller
45+
* calls {@link #release()}. Either way, the caller's thread is never parked.
46+
*/
47+
public CompletableFuture<Void> acquire() {
48+
synchronized (lock) {
49+
if (available > 0) {
50+
available--;
51+
return CompletableFuture.completedFuture(null);
52+
}
53+
CompletableFuture<Void> waiter = new CompletableFuture<>();
54+
waiters.addLast(waiter);
55+
return waiter;
56+
}
57+
}
58+
59+
/**
60+
* Release a permit. If a live waiter is enqueued, the permit is transferred to it (its future is
61+
* completed) without going through the counter. Otherwise the counter is incremented.
62+
*/
63+
public void release() {
64+
CompletableFuture<Void> next = null;
65+
synchronized (lock) {
66+
while (!waiters.isEmpty()) {
67+
CompletableFuture<Void> w = waiters.pollFirst();
68+
if (!w.isDone()) {
69+
next = w;
70+
break;
71+
}
72+
}
73+
if (next == null) {
74+
available++;
75+
}
76+
}
77+
if (next != null) {
78+
next.complete(null);
79+
}
80+
}
81+
82+
/** Permits not currently held nor pending in the queue. */
83+
public int availablePermits() {
84+
synchronized (lock) {
85+
return available;
86+
}
87+
}
88+
89+
/** Number of pending waiters on the slow path. Useful for diagnostics and tests. */
90+
public int queueLength() {
91+
synchronized (lock) {
92+
return waiters.size();
93+
}
94+
}
95+
}

src/main/java/com/marketdata/sdk/internal/http/HttpTransport.java

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import java.util.Map;
1919
import java.util.concurrent.CompletableFuture;
2020
import java.util.concurrent.CompletionException;
21-
import java.util.concurrent.Semaphore;
2221
import java.util.concurrent.atomic.AtomicReference;
2322
import org.jspecify.annotations.Nullable;
2423

@@ -49,7 +48,7 @@ public final class HttpTransport implements AutoCloseable {
4948

5049
private final HttpClient httpClient;
5150
private final ObjectMapper jsonMapper;
52-
private final Semaphore concurrencyPermits;
51+
private final AsyncSemaphore concurrencyPermits;
5352
private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>();
5453

5554
private final String baseUrl;
@@ -59,18 +58,32 @@ public final class HttpTransport implements AutoCloseable {
5958

6059
public HttpTransport(
6160
String baseUrl, String apiVersion, String userAgent, @Nullable String token) {
61+
this(baseUrl, apiVersion, userAgent, token, defaultHttpClient());
62+
}
63+
64+
// Package-private constructor used by tests to inject a stubbed HttpClient
65+
// (e.g. one whose sendAsync throws synchronously, to verify permit release).
66+
HttpTransport(
67+
String baseUrl,
68+
String apiVersion,
69+
String userAgent,
70+
@Nullable String token,
71+
HttpClient httpClient) {
6272
this.baseUrl = baseUrl;
6373
this.apiVersion = apiVersion;
6474
this.userAgent = userAgent;
6575
this.token = token;
66-
this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT);
76+
this.concurrencyPermits = new AsyncSemaphore(CONCURRENCY_LIMIT);
6777
this.jsonMapper = new ObjectMapper();
68-
this.httpClient =
69-
HttpClient.newBuilder()
70-
.connectTimeout(CONNECT_TIMEOUT)
71-
.version(HttpClient.Version.HTTP_2)
72-
.followRedirects(HttpClient.Redirect.NORMAL)
73-
.build();
78+
this.httpClient = httpClient;
79+
}
80+
81+
private static HttpClient defaultHttpClient() {
82+
return HttpClient.newBuilder()
83+
.connectTimeout(CONNECT_TIMEOUT)
84+
.version(HttpClient.Version.HTTP_2)
85+
.followRedirects(HttpClient.Redirect.NORMAL)
86+
.build();
7487
}
7588

7689
/** Latest client-level rate-limit snapshot, or {@code null} if no request has succeeded yet. */
@@ -90,19 +103,33 @@ public <T> CompletableFuture<T> executeAsync(RequestSpec spec, Class<T> response
90103
URI uri = buildUri(spec);
91104
HttpRequest request = buildRequest(uri);
92105

106+
// ADR-007: acquire returns a CompletableFuture instead of parking the caller's thread.
107+
// When permits are available the future is already completed (fast path) and thenCompose
108+
// runs synchronously; when the pool is exhausted the future completes later, on the
109+
// thread that calls release() — the caller's thread is never blocked here.
110+
return concurrencyPermits.acquire().thenCompose(unused -> dispatch(uri, request, responseType));
111+
}
112+
113+
private <T> CompletableFuture<T> dispatch(URI uri, HttpRequest request, Class<T> responseType) {
114+
CompletableFuture<HttpResponse<byte[]>> sendFuture;
93115
try {
94-
concurrencyPermits.acquire();
95-
} catch (InterruptedException e) {
96-
Thread.currentThread().interrupt();
116+
sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray());
117+
} catch (Throwable t) {
118+
// sendAsync threw synchronously (e.g. malformed request, internal NPE, OOM).
119+
// The future never formed, so whenComplete will not fire — release the permit
120+
// here to prevent a permanent leak that would degrade the pool to deadlock.
121+
concurrencyPermits.release();
122+
if (t instanceof Error err) {
123+
throw err;
124+
}
97125
return CompletableFuture.failedFuture(
98126
new NetworkError(
99-
"Interrupted while waiting for a concurrency permit",
127+
"Request to " + uri + " failed before dispatch: " + t.getMessage(),
100128
new ErrorContext(null, uri.toString(), null),
101-
e));
129+
t));
102130
}
103131

104-
return httpClient
105-
.sendAsync(request, BodyHandlers.ofByteArray())
132+
return sendFuture
106133
.whenComplete((r, t) -> concurrencyPermits.release())
107134
.handle(
108135
(response, error) -> {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.marketdata.sdk.internal.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.concurrent.CompletableFuture;
9+
import org.junit.jupiter.api.Test;
10+
11+
class AsyncSemaphoreTest {
12+
13+
// ---------- fast path ----------
14+
15+
@Test
16+
void acquireReturnsCompletedFutureWhenPermitsAvailable() {
17+
AsyncSemaphore sem = new AsyncSemaphore(3);
18+
19+
CompletableFuture<Void> a = sem.acquire();
20+
CompletableFuture<Void> b = sem.acquire();
21+
CompletableFuture<Void> c = sem.acquire();
22+
23+
assertThat(a).isCompleted();
24+
assertThat(b).isCompleted();
25+
assertThat(c).isCompleted();
26+
assertThat(sem.availablePermits()).isZero();
27+
assertThat(sem.queueLength()).isZero();
28+
}
29+
30+
// ---------- slow path ----------
31+
32+
@Test
33+
void acquireReturnsPendingFutureWhenPoolExhausted() {
34+
AsyncSemaphore sem = new AsyncSemaphore(2);
35+
sem.acquire();
36+
sem.acquire();
37+
38+
CompletableFuture<Void> waiter = sem.acquire();
39+
40+
assertThat(waiter).isNotCompleted();
41+
assertThat(sem.availablePermits()).isZero();
42+
assertThat(sem.queueLength()).isOne();
43+
}
44+
45+
@Test
46+
void releaseTransfersPermitDirectlyToFirstWaiter() {
47+
AsyncSemaphore sem = new AsyncSemaphore(1);
48+
sem.acquire(); // pool empty
49+
50+
CompletableFuture<Void> w1 = sem.acquire();
51+
CompletableFuture<Void> w2 = sem.acquire();
52+
53+
sem.release();
54+
55+
// The permit goes from the in-flight caller straight to w1 — never re-counted.
56+
assertThat(w1).isCompleted();
57+
assertThat(w2).isNotCompleted();
58+
assertThat(sem.availablePermits()).isZero();
59+
assertThat(sem.queueLength()).isOne();
60+
61+
sem.release();
62+
63+
assertThat(w2).isCompleted();
64+
assertThat(sem.availablePermits()).isZero();
65+
assertThat(sem.queueLength()).isZero();
66+
}
67+
68+
@Test
69+
void releaseWithNoWaitersIncrementsCounter() {
70+
AsyncSemaphore sem = new AsyncSemaphore(2);
71+
sem.acquire();
72+
sem.acquire();
73+
74+
sem.release();
75+
assertThat(sem.availablePermits()).isOne();
76+
77+
sem.release();
78+
assertThat(sem.availablePermits()).isEqualTo(2);
79+
}
80+
81+
// ---------- cancellation ----------
82+
83+
@Test
84+
void cancelledWaiterIsSkippedOnRelease() {
85+
AsyncSemaphore sem = new AsyncSemaphore(1);
86+
sem.acquire(); // pool empty
87+
88+
CompletableFuture<Void> cancelled = sem.acquire();
89+
CompletableFuture<Void> alive = sem.acquire();
90+
cancelled.cancel(false);
91+
92+
sem.release();
93+
94+
// The cancelled waiter is skipped; the next live one gets the permit.
95+
assertThat(alive).isCompleted();
96+
assertThat(sem.queueLength()).isZero();
97+
assertThat(sem.availablePermits()).isZero();
98+
}
99+
100+
@Test
101+
void releaseWhenAllWaitersCancelledFallsBackToCounter() {
102+
AsyncSemaphore sem = new AsyncSemaphore(1);
103+
sem.acquire();
104+
105+
sem.acquire().cancel(false);
106+
sem.acquire().cancel(false);
107+
108+
sem.release();
109+
110+
// No live waiter — the permit goes back to the pool.
111+
assertThat(sem.availablePermits()).isOne();
112+
assertThat(sem.queueLength()).isZero();
113+
}
114+
115+
// ---------- ordering ----------
116+
117+
@Test
118+
void waitersAreServedFifo() {
119+
AsyncSemaphore sem = new AsyncSemaphore(0);
120+
List<Integer> completionOrder = new ArrayList<>();
121+
122+
for (int i = 0; i < 10; i++) {
123+
int id = i;
124+
sem.acquire().thenRun(() -> completionOrder.add(id));
125+
}
126+
127+
for (int i = 0; i < 10; i++) {
128+
sem.release();
129+
}
130+
131+
assertThat(completionOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
132+
}
133+
134+
// ---------- argument validation ----------
135+
136+
@Test
137+
void rejectsNegativeInitialPermits() {
138+
assertThatThrownBy(() -> new AsyncSemaphore(-1))
139+
.isInstanceOf(IllegalArgumentException.class)
140+
.hasMessageContaining("permits");
141+
}
142+
143+
@Test
144+
void zeroInitialPermitsIsValidAndForcesSlowPath() {
145+
AsyncSemaphore sem = new AsyncSemaphore(0);
146+
147+
CompletableFuture<Void> w = sem.acquire();
148+
assertThat(w).isNotCompleted();
149+
150+
sem.release();
151+
assertThat(w).isCompleted();
152+
}
153+
}

0 commit comments

Comments
 (0)