Skip to content

Commit a0dd202

Browse files
committed
test(qwp): pin slot retention until worker quiescence
1 parent 5cc68a9 commit a0dd202

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*******************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
26+
27+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
28+
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
29+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
31+
import io.questdb.client.std.Files;
32+
import io.questdb.client.test.tools.TestUtils;
33+
import org.junit.After;
34+
import org.junit.Assert;
35+
import org.junit.Before;
36+
import org.junit.Test;
37+
38+
import java.nio.file.Paths;
39+
import java.util.concurrent.CountDownLatch;
40+
import java.util.concurrent.TimeUnit;
41+
import java.util.concurrent.atomic.AtomicBoolean;
42+
import java.util.concurrent.atomic.AtomicReference;
43+
44+
/**
45+
* Engine-level regression for the shutdown hazard where
46+
* {@link CursorSendEngine#close()} released the slot lock, closed the ring
47+
* and watermark, and unlinked segment files while the shared
48+
* {@link SegmentManager} worker was still mid service pass for the engine's
49+
* ring. A replacement engine could acquire the same slot the moment the
50+
* lock was released, after which the stale worker's abandon/trim path could
51+
* unlink a segment path the replacement was actively writing through —
52+
* store-and-forward data loss after restart.
53+
* <p>
54+
* The fix makes {@code close()} run a quiescence barrier
55+
* ({@link SegmentManager#awaitRingQuiescence}) after {@code deregister} and
56+
* refuse to release any worker-reachable resource (ring, watermark, segment
57+
* files, slot lock) until the barrier confirms the worker cannot touch the
58+
* slot again. On barrier timeout the engine deliberately leaks and a later
59+
* {@code close()} retries the cleanup.
60+
*/
61+
public class CursorSendEngineSlotReacquisitionTest {
62+
63+
private String tmpDir;
64+
65+
@Before
66+
public void setUp() {
67+
tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
68+
"qdb-engine-slot-reacq-" + System.nanoTime()).toString();
69+
Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
70+
}
71+
72+
@After
73+
public void tearDown() {
74+
if (tmpDir == null) return;
75+
rmDirRecursive(tmpDir);
76+
Files.remove(tmpDir);
77+
}
78+
79+
/**
80+
* The structural guarantee: while the manager worker is provably still
81+
* inside a service pass for the engine's ring, {@code close()} must NOT
82+
* hand the slot to anyone else. With the quiescence barrier reverted,
83+
* close() releases the slot lock immediately and the mid-test
84+
* {@code SlotLock.acquire} probe succeeds — failing the test.
85+
*/
86+
@Test(timeout = 30_000L)
87+
public void testCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exception {
88+
TestUtils.assertMemoryLeak(() -> {
89+
long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32);
90+
String slot = tmpDir + "/slot";
91+
// 60 s poll: the worker only acts when explicitly woken, so the
92+
// single pass we park below is the only pass in flight.
93+
SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60));
94+
CountDownLatch workerBlocked = new CountDownLatch(1);
95+
CountDownLatch releaseWorker = new CountDownLatch(1);
96+
AtomicBoolean fired = new AtomicBoolean();
97+
AtomicReference<Throwable> hookErr = new AtomicReference<>();
98+
boolean managerClosed = false;
99+
CursorSendEngine engine = null;
100+
try {
101+
manager.setBeforeInstallSyncHook(() -> {
102+
if (!fired.compareAndSet(false, true)) return;
103+
workerBlocked.countDown();
104+
try {
105+
if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
106+
hookErr.compareAndSet(null,
107+
new AssertionError("timed out waiting for test to release worker"));
108+
}
109+
} catch (Throwable t) {
110+
hookErr.compareAndSet(null, t);
111+
}
112+
});
113+
manager.start();
114+
115+
// Shared manager: ownsManager=false, so engine close() cannot
116+
// fall back on manager.close()'s join — the per-ring barrier
117+
// is the only protection, which is exactly what we pin here.
118+
engine = new CursorSendEngine(slot, segSize, manager);
119+
Assert.assertTrue("worker never reached the install hook",
120+
workerBlocked.await(5, TimeUnit.SECONDS));
121+
122+
// Barrier must time out fast: the worker is parked inside the
123+
// service pass for this engine's ring.
124+
manager.setWorkerJoinTimeoutMillis(50L);
125+
engine.close();
126+
127+
// The slot must still be locked: a replacement engine (or raw
128+
// SlotLock) acquiring it now would race the stale worker.
129+
try {
130+
SlotLock probe = SlotLock.acquire(slot);
131+
probe.close();
132+
Assert.fail("engine.close() released the slot lock while the manager "
133+
+ "worker was still mid service pass for its ring — a "
134+
+ "replacement engine could acquire the slot and have its "
135+
+ "segment files unlinked by the stale worker");
136+
} catch (Exception expected) {
137+
// good — slot retained.
138+
}
139+
140+
// Let the worker finish its pass (it abandons the spare: the
141+
// ring was deregistered by the close attempt above).
142+
releaseWorker.countDown();
143+
manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
144+
145+
// Retry close(): the barrier now succeeds and the full cleanup
146+
// (ring, watermark, unlink, slot lock) must complete.
147+
engine.close();
148+
engine = null;
149+
150+
try (SlotLock probe = SlotLock.acquire(slot)) {
151+
Assert.assertNotNull("slot must be acquirable after a completed close", probe);
152+
} catch (Exception e) {
153+
throw new AssertionError("retried close() did not release the slot lock", e);
154+
}
155+
156+
manager.close();
157+
managerClosed = true;
158+
if (hookErr.get() != null) {
159+
throw new AssertionError("install hook failed", hookErr.get());
160+
}
161+
} finally {
162+
manager.setBeforeInstallSyncHook(null);
163+
releaseWorker.countDown();
164+
if (engine != null) {
165+
try {
166+
engine.close();
167+
} catch (Throwable ignored) {
168+
}
169+
}
170+
if (!managerClosed) {
171+
manager.close();
172+
}
173+
}
174+
});
175+
}
176+
177+
/**
178+
* Plain-positive path: after a normal close (worker quiesces promptly),
179+
* a second engine must be able to acquire and use the same slot.
180+
*/
181+
@Test(timeout = 30_000L)
182+
public void testSameSlotReacquirableAfterNormalClose() throws Exception {
183+
TestUtils.assertMemoryLeak(() -> {
184+
String slot = tmpDir + "/slot";
185+
CursorSendEngine first = new CursorSendEngine(slot, 4L * 1024 * 1024);
186+
first.close();
187+
CursorSendEngine second = new CursorSendEngine(slot, 4L * 1024 * 1024);
188+
try {
189+
Assert.assertFalse("fully-drained close must leave no segments to recover",
190+
second.wasRecoveredFromDisk());
191+
} finally {
192+
second.close();
193+
}
194+
});
195+
}
196+
197+
private static void rmDirRecursive(String dir) {
198+
if (!Files.exists(dir)) return;
199+
long find = Files.findFirst(dir);
200+
if (find <= 0) return;
201+
try {
202+
int rc = 1;
203+
while (rc > 0) {
204+
String name = Files.utf8ToString(Files.findName(find));
205+
if (name != null && !".".equals(name) && !"..".equals(name)) {
206+
String child = dir + "/" + name;
207+
if (!Files.remove(child)) {
208+
rmDirRecursive(child);
209+
Files.remove(child);
210+
}
211+
}
212+
rc = Files.findNext(find);
213+
}
214+
} finally {
215+
Files.findClose(find);
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)