mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
14 hours ago 3800973a69c23deb5c770e8032074244f50f5b9a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.backends.jdbc;
 
import org.forgerock.opendj.server.config.server.JDBCBackendCfg;
import org.opends.server.DirectoryServerTestCase;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
 
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLNonTransientConnectionException;
import java.sql.SQLTimeoutException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
 
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotSame;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
 
/**
 * The bounds of the connection the table comment statements run on, and the classification of a
 * stamp that failed. None of it needs a database: the first two cases are about what the backend
 * hands its driver and what the driver then does with a server that never answers, the last is a
 * pure function of an exception. Keeping them out of the container suites is the point - those
 * skip themselves whole when no docker is reachable, and a bound nothing exercises is a bound
 * that can be deleted without a single test going red.
 */
@SuppressWarnings("javadoc")
public class StampConnectionTestCase extends DirectoryServerTestCase {
 
    /**
     * A ceiling generous enough that a loaded machine cannot cross it, and far below what an
     * unbounded driver does: a login left unbounded against a silent server does not come back at
     * all - it sits in the read of the prelogin answer until something else tears the socket down.
     */
    private static final long GIVE_UP_CEILING_SECONDS = 120;
 
    private ProbeDriver probeDriver;
 
    @BeforeClass
    public void registerProbeDriver() throws SQLException {
        probeDriver = new ProbeDriver();
        DriverManager.registerDriver(probeDriver);
    }
 
    @AfterClass(alwaysRun = true)
    public void deregisterProbeDriver() throws SQLException {
        if (probeDriver != null) {
            DriverManager.deregisterDriver(probeDriver);
        }
    }
 
    private static JDBCStorage storageFor(String url) {
        final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class);
        when(cfg.getBackendId()).thenReturn("stampProbe");
        when(cfg.getDBDirectory()).thenReturn(url);
        return new JDBCStorage(cfg, null);
    }
 
    /**
     * Every dialect must bound both phases of a login attempt: the socket connect, and the reads
     * behind it - of the prelogin handshake, of tls, of authentication. Not one of the four
     * drivers covers both with a single property, the sql server one included: its loginTimeout
     * bounds the connect and leaves the prelogin read open.
     */
    @Test
    public void testEveryDialectDeclaresBothBounds() {
        for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) {
            assertTrue(dialect.connectProperties.size() >= 2,
                "the login of " + dialect + " is not bounded in both phases: " + dialect.connectProperties);
            for (final String name : dialect.connectProperties.stringPropertyNames()) {
                assertTrue(Integer.parseInt(dialect.connectProperties.getProperty(name)) > 0,
                    name + " of " + dialect + " bounds nothing: " + dialect.connectProperties.getProperty(name));
            }
        }
    }
 
    /**
     * The declaration above is worth nothing unless it reaches the driver, and nothing else in the
     * suite notices if it stops doing so: dropping the properties from the connect call leaves
     * every stamp unbounded again, which no round-trip test can see against a database that
     * answers.
     */
    @Test
    public void testStampConnectionHandsItsBoundsToTheDriver() throws Exception {
        final JDBCStorage storage = storageFor(ProbeDriver.URL);
        for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) {
            probeDriver.lastProperties = null;
            storage.newStampConnection(dialect).close();
            final Properties handed = probeDriver.lastProperties;
            assertNotNull(handed, "no properties were handed to the driver for " + dialect);
            for (final Map.Entry<Object, Object> bound : dialect.connectProperties.entrySet()) {
                assertEquals(handed.getProperty((String) bound.getKey()), bound.getValue(),
                    bound.getKey() + " of " + dialect + " did not reach the driver");
            }
            // a driver is free to write into the map it is passed: the declaration must not be it
            assertNotSame(handed, dialect.connectProperties,
                "the driver was handed the declaration of " + dialect + " rather than a copy of it");
        }
    }
 
    /**
     * What the bounds are for: a database that keeps its established connections alive but accepts
     * no new ones - a moved vip, a proxy at its connection limit - usually completes the tcp
     * connect and then goes quiet, which leaves the driver in a read. Unbounded, that hangs the
     * open of a tree; dsconfig create-backend-index opens one on a running server.
     * <p>
     * The four dialects are attempted at once, so the suite pays the bound of the slowest of them
     * rather than the sum of all four.
     */
    @Test
    public void testEveryDriverGivesUpOnASilentServer() throws Exception {
        try (final SilentServer silent = new SilentServer()) {
            final List<Callable<String>> attempts = new ArrayList<>();
            for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) {
                final JDBCStorage storage = storageFor(silent.urlFor(dialect));
                attempts.add(new Callable<String>() {
                    @Override
                    public String call() {
                        final long startedAt = System.nanoTime();
                        try (final Connection con = storage.newStampConnection(dialect)) {
                            return dialect + " connected to a server that never answered";
                        } catch (Exception expected) {
                            // the failure itself is the point: which one it is belongs to the driver
                        }
                        final long tookSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt);
                        return tookSeconds <= GIVE_UP_CEILING_SECONDS ? null
                            : dialect + " took " + tookSeconds + "s to give up on a silent server";
                    }
                });
            }
            final ExecutorService attempted = Executors.newFixedThreadPool(attempts.size());
            try {
                final StringBuilder failures = new StringBuilder();
                for (final Future<String> attempt : attempted.invokeAll(attempts,
                        GIVE_UP_CEILING_SECONDS * 2, TimeUnit.SECONDS)) {
                    final String failure = attempt.isCancelled() // the driver never came back at all
                        ? "a driver did not give up on a silent server within "
                            + (GIVE_UP_CEILING_SECONDS * 2) + "s"
                        : attempt.get();
                    if (failure != null) {
                        failures.append(failure).append('\n');
                    }
                }
                if (failures.length() > 0) {
                    fail(failures.toString());
                }
            } finally {
                attempted.shutdownNow();
            }
        }
    }
 
    /**
     * A stamp that the database rejected is remembered, so that an account which may not comment
     * its tables does not reissue the statement for every tree on every open. A stamp that lost
     * its connection ends the sweep, since every tree behind it needs that same connection. A
     * stamp that gave up on a lock is neither: the lock belongs to that one table.
     */
    @Test
    public void testFailureScopeTellsTheThreeApart() {
        assertEquals(JDBCStorage.failureScope(
                new SQLException("permission denied for table", "42501"), JDBCStorage.Dialect.POSTGRES),
            JDBCStorage.FailureScope.TREE, "a rejected statement was not remembered");
        assertEquals(JDBCStorage.failureScope(
                new SQLException("lock not available", "55P03"), JDBCStorage.Dialect.POSTGRES),
            JDBCStorage.FailureScope.MOMENT, "a lock timeout was not read as one of the moment");
        assertEquals(JDBCStorage.failureScope(
                new SQLException("connection closed", "08006"), JDBCStorage.Dialect.POSTGRES),
            JDBCStorage.FailureScope.SESSION, "a connection exception did not end the sweep");
        assertEquals(JDBCStorage.failureScope(
                new SQLNonTransientConnectionException("socket closed"), JDBCStorage.Dialect.MICROSOFT),
            JDBCStorage.FailureScope.SESSION, "a connection exception of the driver did not end the sweep");
        assertEquals(JDBCStorage.failureScope(
                new SQLTimeoutException("query timed out"), JDBCStorage.Dialect.MYSQL),
            JDBCStorage.FailureScope.MOMENT, "a timeout was not read as a failure of the moment");
    }
 
    /**
     * A driver reports the vendor error of a failed statement as the next exception of a generic
     * one at least as often as it reports it as the cause. Reading only the cause chain classifies
     * a lock timeout as a rejection, which leaves the tree unstamped until the next start over a
     * moment of contention.
     */
    @Test
    public void testFailureScopeWalksBothChains() {
        final SQLException reportedAsTheCause = new SQLException("statement failed",
            new SQLException("lock wait timeout exceeded", "HY000", 1205));
        assertEquals(JDBCStorage.failureScope(reportedAsTheCause, JDBCStorage.Dialect.MYSQL),
            JDBCStorage.FailureScope.MOMENT, "a lock timeout on the cause chain was missed");
 
        final SQLException reportedAsTheNext = new SQLException("statement failed");
        reportedAsTheNext.setNextException(new SQLException("lock wait timeout exceeded", "HY000", 1205));
        assertEquals(JDBCStorage.failureScope(reportedAsTheNext, JDBCStorage.Dialect.MYSQL),
            JDBCStorage.FailureScope.MOMENT, "a lock timeout on the next-exception chain was missed");
 
        // a connection exception anywhere in either chain outweighs the rest: the session is gone
        final SQLException connectionGone = new SQLException("statement failed");
        connectionGone.setNextException(new SQLException("communications link failure", "08S01"));
        assertEquals(JDBCStorage.failureScope(connectionGone, JDBCStorage.Dialect.MYSQL),
            JDBCStorage.FailureScope.SESSION, "a connection exception on the next-exception chain was missed");
 
        // a driver that chains an exception back to itself must not make the walk loop
        final SQLException selfReferring = new SQLException("statement failed");
        selfReferring.setNextException(selfReferring);
        assertEquals(JDBCStorage.failureScope(selfReferring, JDBCStorage.Dialect.MYSQL),
            JDBCStorage.FailureScope.TREE, "a self-referring chain was not walked to an end");
    }
 
    /** Accepts connections and answers nothing at all, the shape of a proxy at its connection limit. */
    private static final class SilentServer implements AutoCloseable {
        private final ServerSocket listening;
        private final List<Socket> accepted = new ArrayList<>();
        private final Thread acceptor;
 
        SilentServer() throws IOException {
            listening = new ServerSocket(0, 16, InetAddress.getLoopbackAddress());
            acceptor = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (!Thread.currentThread().isInterrupted()) {
                        try {
                            final Socket socket = listening.accept();
                            synchronized (accepted) { // held open, and never written to
                                accepted.add(socket);
                            }
                        } catch (IOException closed) {
                            return;
                        }
                    }
                }
            }, "silent-server");
            acceptor.setDaemon(true);
            acceptor.start();
        }
 
        String urlFor(JDBCStorage.Dialect dialect) {
            final String host = listening.getInetAddress().getHostAddress();
            final int port = listening.getLocalPort();
            switch (dialect) {
            case POSTGRES:
                return "jdbc:postgresql://" + host + ":" + port + "/probe?user=probe&password=probe";
            case MYSQL:
                return "jdbc:mysql://" + host + ":" + port + "/probe?user=probe&password=probe";
            case ORACLE:
                return "jdbc:oracle:thin:probe/probe@//" + host + ":" + port + "/probe";
            case MICROSOFT:
                return "jdbc:sqlserver://" + host + ":" + port + ";databaseName=probe;user=probe;password=probe";
            default:
                throw new IllegalStateException("no probe url for dialect " + dialect);
            }
        }
 
        @Override
        public void close() throws IOException {
            acceptor.interrupt();
            listening.close();
            synchronized (accepted) {
                for (final Socket socket : accepted) {
                    try {
                        socket.close();
                    } catch (IOException ignored) {
                    }
                }
            }
        }
    }
 
    /** Records the properties a stamp connection hands its driver, and connects to nothing. */
    private static final class ProbeDriver implements Driver {
        static final String URL = "jdbc:stampprobe:";
 
        volatile Properties lastProperties;
 
        @Override
        public Connection connect(String url, Properties info) throws SQLException {
            if (!acceptsURL(url)) {
                return null; // not ours: DriverManager goes on to the next driver
            }
            lastProperties = info;
            final Connection con = mock(Connection.class);
            when(con.createStatement()).thenReturn(mock(Statement.class));
            return con;
        }
 
        @Override
        public boolean acceptsURL(String url) {
            return url != null && url.startsWith(URL);
        }
 
        @Override
        public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {
            return new DriverPropertyInfo[0];
        }
 
        @Override
        public int getMajorVersion() {
            return 1;
        }
 
        @Override
        public int getMinorVersion() {
            return 0;
        }
 
        @Override
        public boolean jdbcCompliant() {
            return false;
        }
 
        @Override
        public java.util.logging.Logger getParentLogger() throws SQLFeatureNotSupportedException {
            throw new SQLFeatureNotSupportedException();
        }
    }
}