Skip to content

Commit 1e906d1

Browse files
Replay DatabaseCache commit work as one pruned task per transaction (#8193)
## Rationale Big transactions spend seconds after commit reloading cache entries they've already invalidated; this replays each transaction's DatabaseCache work as one task and skips what a later removal or clear undoes. A container delete measured 1,194 post-commit reloads taking 2-17 s, nearly all for ontology caches that the same delete clears. Also adds a DEBUG summary of large commit task queues, since the throttled repeated-task warning can't show what fills one. ## Related Pull Requests - #8156 ## Changes - Replay a transaction's cache removals, clears, and reloads after commit from a single commit task, in the order they happened - Skip reloads that a later removal of the same key, or clear of the same cache, would discard - Log the most common task classes and their run times for large commit task queues at DEBUG ## Tasks - [x] Claude Code Review - [x] Manual Testing @labkey-martyp - [x] Delete container with lots of sample types and datasets (perf) - [x] Stale data check across sessions - [x] Transaction rollback doesn't update cache - [x] Batched cache clears (ClearMaterialSourceCacheTask) - [x] Warning for unbatched cache clears (datasets) - [x] Test Automation --------- Co-authored-by: labkey-tchad <tchad@labkey.com>
1 parent 4d362eb commit 1e906d1

8 files changed

Lines changed: 498 additions & 240 deletions

File tree

‎api/src/org/labkey/api/ApiModule.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@
3737
import org.labkey.api.audit.provider.SystemUpgradeAuditProvider;
3838
import org.labkey.api.audit.query.AbstractAuditDomainKind;
3939
import org.labkey.api.cache.BlockingCache;
40+
import org.labkey.api.cache.CacheManager;
4041
import org.labkey.api.cache.ContainerKeySimpleCache;
4142
import org.labkey.api.cache.SimpleKeyMappingCache;
4243
import org.labkey.api.cache.UserKeySimpleCache;
43-
import org.labkey.api.cache.CacheManager;
4444
import org.labkey.api.collections.ArrayListMap;
4545
import org.labkey.api.collections.CaseInsensitiveHashMap;
4646
import org.labkey.api.collections.CaseInsensitiveHashSet;
@@ -58,6 +58,7 @@
5858
import org.labkey.api.data.BindingTestCase;
5959
import org.labkey.api.data.BooleanFormat;
6060
import org.labkey.api.data.BuilderObjectFactory;
61+
import org.labkey.api.data.CacheReplayCommitTask;
6162
import org.labkey.api.data.CompareType;
6263
import org.labkey.api.data.ConnectionUsage;
6364
import org.labkey.api.data.ContainerDisplayColumn;
@@ -407,6 +408,7 @@ public void registerServlets(ServletContext servletCtx)
407408
BooleanFormat.TestCase.class,
408409
BuilderObjectFactory.TestCase.class,
409410
CacheManager.TestCase.class,
411+
CacheReplayCommitTask.TestCase.class,
410412
CachingDataIterator.ScrollTestCase.class,
411413
CaseInsensitiveHashMap.TestCase.class,
412414
CaseInsensitiveHashSet.TestCase.class,
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/*
2+
* Copyright (c) 2026 LabKey Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.labkey.api.data;
17+
18+
import org.jetbrains.annotations.NotNull;
19+
import org.jetbrains.annotations.Nullable;
20+
import org.junit.Assert;
21+
import org.junit.Test;
22+
import org.labkey.api.data.DbScope.Transaction;
23+
24+
import java.util.ArrayList;
25+
import java.util.HashMap;
26+
import java.util.LinkedHashMap;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.function.Predicate;
30+
31+
/**
32+
* Replays a transaction's DatabaseCache removals, clears, and loads into the shared caches after commit, in the order
33+
* they first happened. One per transaction, so a transaction touching many caches queues one commit task, not one per
34+
* cache and key, and so an operation that a later removal or clear would undo can be dropped before it runs.
35+
*/
36+
public final class CacheReplayCommitTask implements Runnable
37+
{
38+
private enum Kind { RELOAD, REMOVE, FILTER, CLEAR }
39+
40+
private record Op(@NotNull Object cache, @NotNull Kind kind, @Nullable Object key) {}
41+
42+
// Insertion order is replay order; a duplicate keeps the first one's position
43+
private final Map<Op, Runnable> _operations = new LinkedHashMap<>();
44+
private final Map<Object, Integer> _reloadCounts = new HashMap<>();
45+
46+
/** The replay task already queued in the transaction, or a newly queued one */
47+
static CacheReplayCommitTask get(Transaction transaction)
48+
{
49+
return transaction.addCommitTask(new CacheReplayCommitTask(), DbScope.CommitTaskOption.POSTCOMMIT);
50+
}
51+
52+
/** Queues the reload unless the cache already has limit reloads queued */
53+
void reload(@NotNull Object cache, @NotNull Object key, int limit, @NotNull Runnable reload)
54+
{
55+
if (_reloadCounts.getOrDefault(cache, 0) < limit && add(new Op(cache, Kind.RELOAD, key), reload))
56+
_reloadCounts.merge(cache, 1, Integer::sum);
57+
}
58+
59+
void remove(@NotNull Object cache, @NotNull Object key, @NotNull Runnable removal)
60+
{
61+
drop(new Op(cache, Kind.RELOAD, key));
62+
add(new Op(cache, Kind.REMOVE, key), removal);
63+
}
64+
65+
@SuppressWarnings("unchecked")
66+
<K> void removeUsingFilter(@NotNull Object cache, @NotNull Predicate<K> filter, @NotNull Runnable removal)
67+
{
68+
List.copyOf(_operations.keySet()).stream()
69+
.filter(op -> op.kind() == Kind.RELOAD && op.cache().equals(cache) && filter.test((K)op.key()))
70+
.forEach(this::drop);
71+
add(new Op(cache, Kind.FILTER, filter), removal);
72+
}
73+
74+
/**
75+
* Replaces the cache's other operations with one clear at the position of its earliest invalidation. Moving the
76+
* clear later would let a reload of another cache queued in between read this cache's stale entries.
77+
*/
78+
void clear(@NotNull Object cache, @NotNull Runnable clear)
79+
{
80+
Op clearOp = new Op(cache, Kind.CLEAR, null);
81+
Map<Op, Runnable> replay = new LinkedHashMap<>();
82+
_operations.forEach((op, operation) -> {
83+
if (!op.cache().equals(cache))
84+
replay.put(op, operation);
85+
else if (op.kind() != Kind.RELOAD)
86+
replay.putIfAbsent(clearOp, op.kind() == Kind.CLEAR ? operation : clear);
87+
});
88+
replay.putIfAbsent(clearOp, clear);
89+
_operations.clear();
90+
_operations.putAll(replay);
91+
_reloadCounts.remove(cache);
92+
}
93+
94+
private boolean add(Op op, Runnable operation)
95+
{
96+
return _operations.putIfAbsent(op, operation) == null;
97+
}
98+
99+
private void drop(Op reload)
100+
{
101+
if (_operations.remove(reload) != null)
102+
_reloadCounts.merge(reload.cache(), -1, Integer::sum);
103+
}
104+
105+
int size()
106+
{
107+
return _operations.size();
108+
}
109+
110+
@Override
111+
public void run()
112+
{
113+
// Copy guards against concurrent modification only: when commitAndKeepConnection() runs on a transaction that
114+
// isn't the innermost, a reload registers operations here that never run, since clearCommitTasks() discards them
115+
List.copyOf(_operations.values()).forEach(Runnable::run);
116+
}
117+
118+
@Override
119+
public boolean equals(Object o)
120+
{
121+
return o != null && getClass() == o.getClass();
122+
}
123+
124+
@Override
125+
public int hashCode()
126+
{
127+
return getClass().hashCode();
128+
}
129+
130+
// CommitTaskQueue.add() logs this eagerly on every duplicate, so keep it allocation-free
131+
@Override
132+
public String toString()
133+
{
134+
return "DatabaseCache replay";
135+
}
136+
137+
public static class TestCase extends Assert
138+
{
139+
private final List<String> _log = new ArrayList<>();
140+
141+
private Runnable op(String name)
142+
{
143+
return () -> _log.add(name);
144+
}
145+
146+
private List<String> run(CacheReplayCommitTask task)
147+
{
148+
task.run();
149+
return _log;
150+
}
151+
152+
@Test
153+
public void runsOperationsInFirstRegistrationOrder()
154+
{
155+
CacheReplayCommitTask task = new CacheReplayCommitTask();
156+
task.remove("c1", "a", op("remove a"));
157+
task.reload("c1", "b", 10, op("reload b"));
158+
task.clear("c2", op("clear c2"));
159+
task.remove("c1", "a", op("remove a again"));
160+
assertEquals(List.of("remove a", "reload b", "clear c2"), run(task));
161+
}
162+
163+
@Test
164+
public void capsReloadsPerCache()
165+
{
166+
CacheReplayCommitTask task = new CacheReplayCommitTask();
167+
task.reload("c1", "a", 2, op("1a"));
168+
task.reload("c1", "a", 2, op("1a again")); // Duplicate doesn't count against the limit
169+
task.reload("c1", "b", 2, op("1b"));
170+
task.reload("c1", "c", 2, op("1c"));
171+
task.reload("c2", "a", 2, op("2a"));
172+
assertEquals(List.of("1a", "1b", "2a"), run(task));
173+
}
174+
175+
@Test
176+
public void removalDropsEarlierReloadOfTheKey()
177+
{
178+
CacheReplayCommitTask task = new CacheReplayCommitTask();
179+
task.reload("c1", "a", 10, op("reload a"));
180+
task.reload("c1", "b", 10, op("reload b"));
181+
task.reload("c2", "a", 10, op("reload c2 a"));
182+
task.remove("c1", "a", op("remove a"));
183+
task.reload("c1", "a", 10, op("reload a after remove"));
184+
assertEquals(List.of("reload b", "reload c2 a", "remove a", "reload a after remove"), run(task));
185+
}
186+
187+
@Test
188+
public void repeatedRemovalDropsReloadBetweenAndKeepsFirstPosition()
189+
{
190+
CacheReplayCommitTask task = new CacheReplayCommitTask();
191+
task.remove("c1", "a", op("remove a"));
192+
task.reload("c1", "b", 10, op("reload b"));
193+
task.reload("c1", "a", 10, op("reload a"));
194+
task.remove("c1", "a", op("remove a again"));
195+
assertEquals(List.of("remove a", "reload b"), run(task));
196+
}
197+
198+
@Test
199+
public void operationRegisteredDuringReplayDoesNotRun()
200+
{
201+
CacheReplayCommitTask task = new CacheReplayCommitTask();
202+
task.reload("c1", "a", 10, () -> {
203+
_log.add("reload a");
204+
task.reload("c1", "b", 10, op("reload b"));
205+
});
206+
task.remove("c2", "a", op("remove c2 a"));
207+
assertEquals(List.of("reload a", "remove c2 a"), run(task));
208+
assertEquals(3, task.size());
209+
}
210+
211+
@Test
212+
public void droppedReloadFreesItsSlot()
213+
{
214+
CacheReplayCommitTask task = new CacheReplayCommitTask();
215+
task.reload("c1", "a", 1, op("reload a"));
216+
task.remove("c1", "a", op("remove a"));
217+
task.reload("c1", "b", 1, op("reload b"));
218+
assertEquals(List.of("remove a", "reload b"), run(task));
219+
}
220+
221+
@Test
222+
public void filterDropsEarlierMatchingReloads()
223+
{
224+
CacheReplayCommitTask task = new CacheReplayCommitTask();
225+
task.reload("c1", "a1", 2, op("reload a1"));
226+
task.reload("c1", "b1", 2, op("reload b1"));
227+
task.reload("c2", "a1", 2, op("reload c2 a1"));
228+
task.removeUsingFilter("c1", (String key) -> key.startsWith("a"), op("filter"));
229+
task.reload("c1", "a2", 2, op("reload a2")); // Freed slot
230+
assertEquals(List.of("reload b1", "reload c2 a1", "filter", "reload a2"), run(task));
231+
}
232+
233+
@Test
234+
public void clearReplacesTheCachesOperationsAtItsEarliestInvalidation()
235+
{
236+
CacheReplayCommitTask task = new CacheReplayCommitTask();
237+
task.reload("c1", "a", 1, op("reload a"));
238+
task.remove("c1", "b", op("remove b"));
239+
task.removeUsingFilter("c1", (String key) -> false, op("filter"));
240+
task.remove("c2", "b", op("remove c2 b"));
241+
task.clear("c1", op("clear"));
242+
task.reload("c1", "c", 1, op("reload c"));
243+
task.clear("c1", op("clear again"));
244+
task.reload("c1", "d", 1, op("reload d"));
245+
assertEquals(List.of("clear", "remove c2 b", "reload d"), run(task));
246+
}
247+
248+
@Test
249+
public void clearNeverRunsAfterAReloadQueuedBehindAnEarlierInvalidation()
250+
{
251+
CacheReplayCommitTask task = new CacheReplayCommitTask();
252+
task.clear("c1", op("clear c1"));
253+
task.reload("c2", "x", 10, op("reload c2 x"));
254+
task.clear("c1", op("clear c1 again"));
255+
task.remove("c3", "a", op("remove c3 a"));
256+
task.reload("c2", "y", 10, op("reload c2 y"));
257+
task.clear("c3", op("clear c3"));
258+
assertEquals(List.of("clear c1", "reload c2 x", "clear c3", "reload c2 y"), run(task));
259+
}
260+
261+
@Test
262+
public void clearIsAppendedWhenTheCacheHasNoEarlierInvalidation()
263+
{
264+
CacheReplayCommitTask task = new CacheReplayCommitTask();
265+
task.reload("c1", "a", 1, op("reload a"));
266+
task.remove("c2", "b", op("remove c2 b"));
267+
task.clear("c1", op("clear"));
268+
assertEquals(List.of("remove c2 b", "clear"), run(task));
269+
}
270+
271+
@Test
272+
public void allInstancesAreEqual()
273+
{
274+
assertEquals(new CacheReplayCommitTask(), new CacheReplayCommitTask());
275+
assertEquals(new CacheReplayCommitTask().hashCode(), new CacheReplayCommitTask().hashCode());
276+
}
277+
}
278+
}

‎api/src/org/labkey/api/data/CallSiteKeys.java‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,20 @@ final class CallSiteKeys
3939
* Don't extend this to the full stack: a shared helper is reached via many callers, each of which would then get its own key.
4040
*/
4141
String getStackKey(Throwable t)
42+
{
43+
return Arrays.stream(t.getStackTrace()).limit(getCallSiteIndex(t) + 1L).map(StackTraceElement::toString).collect(Collectors.joining("\n"));
44+
}
45+
46+
/** Index of the first frame outside the plumbing classes, capped at {@link #MAX_KEY_FRAMES} */
47+
int getCallSiteIndex(Throwable t)
4248
{
4349
StackTraceElement[] frames = t.getStackTrace();
44-
int lastFrame = 0;
50+
int index = 0;
4551

46-
while (lastFrame < frames.length - 1 && lastFrame < MAX_KEY_FRAMES - 1 && isPlumbingStackFrame(frames[lastFrame]))
47-
lastFrame++;
52+
while (index < frames.length - 1 && index < MAX_KEY_FRAMES - 1 && isPlumbingStackFrame(frames[index]))
53+
index++;
4854

49-
return Arrays.stream(frames).limit(lastFrame + 1L).map(StackTraceElement::toString).collect(Collectors.joining("\n"));
55+
return index;
5056
}
5157

5258
private boolean isPlumbingStackFrame(StackTraceElement frame)

0 commit comments

Comments
 (0)