Skip to content

Commit 41b08c0

Browse files
Reworked ApiAccessTracker to avoid global bottleneck
1 parent 4aa00c3 commit 41b08c0

File tree

2 files changed

+51
-29
lines changed

2 files changed

+51
-29
lines changed

dd-java-agent/appsec/src/main/java/com/datadog/appsec/api/security/ApiAccessTracker.java

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package com.datadog.appsec.api.security;
22

3-
import java.util.Collections;
4-
import java.util.LinkedHashMap;
3+
import java.util.Deque;
54
import java.util.Map;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import java.util.concurrent.ConcurrentLinkedDeque;
67

78
/**
89
* The ApiAccessTracker class provides a mechanism to track API access events, managing them within
@@ -20,23 +21,20 @@
2021
public class ApiAccessTracker {
2122
private static final int INTERVAL_SECONDS = 30;
2223
private static final int MAX_SIZE = 4096;
23-
private final Map<Long, Long> apiAccessLog; // Map<hash, timestamp>
24+
private final Map<Long, Long> apiAccessMap; // Map<hash, timestamp>
25+
private final Deque<Long> apiAccessQueue; // hashes ordered by access time
2426
private final long expirationTimeInMs;
27+
private final int capacity;
2528

2629
public ApiAccessTracker() {
2730
this(MAX_SIZE, INTERVAL_SECONDS * 1000);
2831
}
2932

3033
public ApiAccessTracker(int capacity, long expirationTimeInMs) {
34+
this.capacity = capacity;
3135
this.expirationTimeInMs = expirationTimeInMs;
32-
this.apiAccessLog =
33-
Collections.synchronizedMap(
34-
new LinkedHashMap<Long, Long>() {
35-
@Override
36-
protected boolean removeEldestEntry(Map.Entry<Long, Long> eldest) {
37-
return size() > capacity;
38-
}
39-
});
36+
this.apiAccessMap = new ConcurrentHashMap<>();
37+
this.apiAccessQueue = new ConcurrentLinkedDeque<>();
4038
}
4139

4240
/**
@@ -54,17 +52,41 @@ public boolean updateApiAccessIfExpired(String route, String method, int statusC
5452
long currentTime = System.currentTimeMillis();
5553
long hash = computeApiHash(route, method, statusCode);
5654

57-
synchronized (apiAccessLog) {
58-
if (apiAccessLog.containsKey(hash)) {
59-
long lastAccessTime = apiAccessLog.get(hash);
60-
if (currentTime - lastAccessTime > expirationTimeInMs) {
61-
apiAccessLog.put(hash, currentTime);
62-
return true;
63-
}
64-
return false;
55+
cleanupExpiredEntries(currentTime);
56+
57+
// New or updated record
58+
boolean isNewOrUpdated = false;
59+
if (!apiAccessMap.containsKey(hash)
60+
|| currentTime - apiAccessMap.get(hash) > expirationTimeInMs) {
61+
apiAccessMap.put(hash, currentTime); // Update timestamp
62+
// move hash to the end of the queue
63+
apiAccessQueue.remove(hash);
64+
apiAccessQueue.addLast(hash);
65+
isNewOrUpdated = true;
66+
}
67+
68+
// Remove the oldest hash if capacity is reached
69+
while (apiAccessQueue.size() > this.capacity) {
70+
Long oldestHash = apiAccessQueue.pollFirst();
71+
if (oldestHash != null) {
72+
apiAccessMap.remove(oldestHash);
73+
}
74+
}
75+
76+
return isNewOrUpdated;
77+
}
78+
79+
private void cleanupExpiredEntries(long currentTime) {
80+
while (!apiAccessQueue.isEmpty()) {
81+
Long oldestHash = apiAccessQueue.peekFirst();
82+
if (oldestHash == null) break;
83+
84+
Long lastAccessTime = apiAccessMap.get(oldestHash);
85+
if (lastAccessTime == null || currentTime - lastAccessTime > expirationTimeInMs) {
86+
apiAccessQueue.pollFirst(); // remove from head
87+
apiAccessMap.remove(oldestHash);
6588
} else {
66-
apiAccessLog.put(hash, currentTime);
67-
return true;
89+
break; // is up-to-date
6890
}
6991
}
7092
}

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/api/security/ApiAccessTrackerTest.groovy

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ class ApiAccessTrackerTest extends DDSpecification {
99

1010
when: "Adding new api access"
1111
tracker.updateApiAccessIfExpired("route1", "GET", 200)
12-
def firstAccessTime = tracker.apiAccessLog.values().iterator().next()
12+
def firstAccessTime = tracker.apiAccessMap.values().iterator().next()
1313

1414
then: "The access is added"
15-
tracker.apiAccessLog.size() == 1
15+
tracker.apiAccessMap.size() == 1
1616

1717
when: "Waiting more than expiration time and adding another access with the same key"
1818
Thread.sleep(1100) // Waiting more than 1 second to ensure expiration
1919
tracker.updateApiAccessIfExpired("route1", "GET", 200)
20-
def secondAccessTime = tracker.apiAccessLog.values().iterator().next()
20+
def secondAccessTime = tracker.apiAccessMap.values().iterator().next()
2121

2222
then: "The access is updated and moved to the end"
23-
tracker.apiAccessLog.size() == 1
23+
tracker.apiAccessMap.size() == 1
2424
secondAccessTime > firstAccessTime
2525
}
2626

@@ -34,9 +34,9 @@ class ApiAccessTrackerTest extends DDSpecification {
3434
tracker.updateApiAccessIfExpired("route2", "POST", 404)
3535

3636
then: "The oldest access is removed"
37-
tracker.apiAccessLog.size() == 1
38-
!tracker.apiAccessLog.containsKey(tracker.computeApiHash("route1", "GET", 200))
39-
tracker.apiAccessLog.containsKey(tracker.computeApiHash("route2", "POST", 404))
37+
tracker.apiAccessMap.size() == 1
38+
!tracker.apiAccessMap.containsKey(tracker.computeApiHash("route1", "GET", 200))
39+
tracker.apiAccessMap.containsKey(tracker.computeApiHash("route2", "POST", 404))
4040
}
4141

4242
def "should not update access if not expired"() {
@@ -50,6 +50,6 @@ class ApiAccessTrackerTest extends DDSpecification {
5050

5151
then: "The access is not updated"
5252
!updatedBeforeExpiration
53-
tracker.apiAccessLog.get(tracker.computeApiHash("route1", "GET", 200)) == updateTime
53+
tracker.apiAccessMap.get(tracker.computeApiHash("route1", "GET", 200)) == updateTime
5454
}
5555
}

0 commit comments

Comments
 (0)