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

Valery Kharseko
3 days ago 329d0afabe7be59137b61f6fa95ca052a4f98a67
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
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
/*
 * 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.opends.server.backends.jdbc.JDBCStorage.ArmedLockBound;
import org.opends.server.backends.jdbc.JDBCStorage.Dialect;
import org.opends.server.backends.jdbc.JDBCStorage.LockBound;
import org.opends.server.backends.pluggable.spi.StorageRuntimeException;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
 
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Savepoint;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
 
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
 
/**
 * What a transaction of {@code JDBCStorage.write()} is told to do about a row lock another session
 * holds (#915), and what the replay makes of the failure that ends such a wait.
 * <p>
 * It needs no database: the connection is a mock, so what each engine is told - and what it is told
 * to put back before the connection goes to the next borrower - is pinned wherever the build runs,
 * while the container suites cover a write really queued behind another session's row lock. The
 * replay that this bound exists for is driven end to end in {@code JDBCStorageRetryTest}.
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "jdbc" }, sequential = true)
public class JDBCRowLockBoundTestCase extends DirectoryServerTestCase {
 
    /** A driver name of an engine none of the settings fit, which is what the null dialect stands for. */
    private static final String UNKNOWN_ENGINE = "com.example.jdbc.Connection";
    private static final String MYSQL = "com.mysql.cj.jdbc.ConnectionImpl";
 
    /** What the connection of a case was asked to run, in the order it was asked to run it. */
    private final List<String> issued = new ArrayList<>();
 
    private JDBCStorage storage;
 
    @BeforeMethod
    public void createStorage() {
        storage = new JDBCStorage(backendCfg(), null);
        issued.clear();
    }
 
    private static JDBCBackendCfg backendCfg() {
        final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class);
        when(cfg.getBackendId()).thenReturn("rowLockBound");
        return cfg;
    }
 
    @AfterMethod
    public void clearProperties() {
        System.clearProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY);
        // the cases arming a DDL bound inside this one raise it: the two are decided against each other
        System.clearProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY);
    }
 
    /**
     * The window bounding the replays is a clock, and a clock only bounds them while an attempt is
     * shorter than it: the default is a third of that window, so a write meeting a lock gets three
     * attempts inside it rather than spending the whole window on one and being refused a replay
     * (#903). A default at or past the window would put this back exactly as it was.
     */
    @Test
    public void testTheDefaultLeavesTheReplayWindowRoomForReplays() {
        assertEquals(JDBCStorage.rowLockBoundSeconds(), 3);
        assertTrue(JDBCStorage.rowLockBoundSeconds() * 1000L * 1000L * 1000L * 3 <= JDBCStorage.RETRY_WINDOW_NANOS,
            "the default bound leaves the replay window room for fewer than three attempts");
    }
 
    /** Zero is what a deployment that would rather wait for its row lock sets, and so is a negative value. */
    @Test
    public void testTheBoundIsTurnedOffByZero() {
        System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "0");
        assertEquals(JDBCStorage.rowLockBoundSeconds(), 0);
        System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "-1");
        assertEquals(JDBCStorage.rowLockBoundSeconds(), 0);
    }
 
    /** A value that is not a number keeps the default, so a typo cannot silently unbound the wait. */
    @Test
    public void testAValueThatIsNotANumberKeepsTheDefault() {
        System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "three seconds");
        assertEquals(JDBCStorage.rowLockBoundSeconds(), JDBCStorage.ROW_LOCK_TIMEOUT_SECONDS);
    }
 
    /** And a value past what a bound of this backend can hold is taken down to it, not read as no bound. */
    @Test
    public void testAValueBeyondTheCeilingIsClamped() {
        System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, String.valueOf(Integer.MAX_VALUE));
        assertEquals(JDBCStorage.rowLockBoundSeconds(), JDBCStorage.MAX_BOUND_SECONDS);
    }
 
    /**
     * What each engine is told around the transaction, in the unit its own setting takes: the row lock
     * of mysql is innodb_lock_wait_timeout and never the lock_wait_timeout of a metadata lock, sql
     * server has one LOCK_TIMEOUT for every lock wait of a session, postgres takes a set local that the
     * transaction discards, and oracle has no setting for this wait at all.
     */
    @DataProvider
    public Object[][] engines() {
        return new Object[][] {
            { "postgres takes a set local and needs no value back", Dialect.POSTGRES, "0",
                singletonList("set local lock_timeout = 3000"), true },
            { "mysql bounds the row lock, not the metadata lock", Dialect.MYSQL, "50",
                asList("select @@session.innodb_lock_wait_timeout", "set session innodb_lock_wait_timeout=3",
                    "set session innodb_lock_wait_timeout=50"), true },
            { "sql server replaces the -1 that waits forever", Dialect.MICROSOFT, "-1",
                asList("select @@lock_timeout", "set lock_timeout 3000", "set lock_timeout -1"), true },
            { "oracle has no session setting for a row lock", Dialect.ORACLE, "0", emptyList(), false },
            { "an engine this backend does not know is told nothing", null, "0", emptyList(), false },
        };
    }
 
    @Test(dataProvider = "engines")
    public void testWhatEachEngineIsToldAroundTheTransaction(String name, Dialect dialect, String carries,
            List<String> expected, boolean bounded) throws Exception {
        final Connection con = recording(mock(Connection.class), carries);
 
        final ArmedLockBound armed = storage.armLockBound(con, dialect, LockBound.ROW);
        storage.releaseLockBound(con, dialect, armed);
 
        assertEquals(issued, expected, name);
        assertEquals(armed.bounded, bounded, name + ": the wait was reported as bounded when it is not, or the other"
            + " way round - which is what decides whether the replay may take that wait again");
    }
 
    /**
     * A session that gives up sooner than this bound keeps exactly what it has - the bound is never
     * loosened to ours - and the wait is bounded all the same, which is the whole reason for leaving
     * that value alone. The replay reads that, not whether a statement of ours was issued.
     */
    @DataProvider
    public Object[][] sessionsAlreadyTighter() {
        return new Object[][] {
            { "mysql giving up after a second", Dialect.MYSQL, "1" },
            { "sql server told not to wait at all", Dialect.MICROSOFT, "0" },
        };
    }
 
    @Test(dataProvider = "sessionsAlreadyTighter")
    public void testASessionAlreadyTighterKeepsWhatItHasAndIsStillBounded(String name, Dialect dialect,
            String carries) throws Exception {
        final Connection con = recording(mock(Connection.class), carries);
 
        final ArmedLockBound armed = storage.armLockBound(con, dialect, LockBound.ROW);
        storage.releaseLockBound(con, dialect, armed);
 
        assertEquals(issued, singletonList(dialect == Dialect.MYSQL
            ? "select @@session.innodb_lock_wait_timeout" : "select @@lock_timeout"), name);
        assertTrue(armed.bounded, name + ": a wait the session itself bounds was reported as unbounded");
    }
 
    /** While a session looser than this bound is given ours, and gets its own value back afterwards. */
    @Test
    public void testASessionLooserThanTheBoundIsGivenOursAndGetsItBack() throws Exception {
        final Connection con = recording(mock(Connection.class), "5000");
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
        storage.releaseLockBound(con, Dialect.MICROSOFT, armed);
 
        assertEquals(issued, asList("select @@lock_timeout", "set lock_timeout 3000", "set lock_timeout 5000"));
    }
 
    /** Turned off, nothing is asked of the session and nothing is claimed about the wait. */
    @Test
    public void testTheBoundTurnedOffAsksTheSessionNothing() throws Exception {
        System.setProperty(JDBCStorage.ROW_LOCK_TIMEOUT_PROPERTY, "0");
        final Connection con = recording(mock(Connection.class), "50");
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
        storage.releaseLockBound(con, Dialect.MYSQL, armed);
 
        assertEquals(issued, emptyList());
        assertFalse(armed.bounded, "a wait nothing bounds was reported as bounded");
    }
 
    /**
     * A session answering the readback with something no SET of it would take back is left alone
     * altogether: nothing of ours is ever set where it could not be taken off again.
     */
    @Test
    public void testASessionThatWillNotSayWhatItCarriesIsLeftAlone() throws Exception {
        final Connection con = recording(mock(Connection.class), "unlimited");
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
        storage.releaseLockBound(con, Dialect.MYSQL, armed);
 
        assertEquals(issued, singletonList("select @@session.innodb_lock_wait_timeout"));
        assertFalse(armed.bounded, "a session that never took the bound was reported as bounded");
    }
 
    /**
     * A setting the session refuses leaves the write running as it ran before this bound existed - the
     * bound is an improvement on a wait and never a reason to fail a write that would have gone
     * through - and the wait is not claimed to be bounded, so the replay does not take it again.
     */
    @Test
    public void testASettingTheSessionRefusesLeavesTheWaitUnbounded() throws Exception {
        final Connection con = recording(mock(Connection.class), "50");
        final Statement statement = con.createStatement();
        doAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            throw new SQLException("this session takes no such setting", "42000");
        }).when(statement).execute(anyString());
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
        storage.releaseLockBound(con, Dialect.MYSQL, armed);
 
        assertFalse(armed.bounded, "a setting the session refused was reported as bounding the wait");
        // the value is given back all the same: a setting can reach the server and fail only as the
        // statement carrying it is closed, and giving back a value the session may never have left
        // costs a round trip and changes nothing
        assertEquals(issued, asList("select @@session.innodb_lock_wait_timeout",
            "set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50"));
    }
 
    /**
     * A connection left carrying a bound this backend could not take off again does not go back into
     * the pool: on sql server that setting would cut every lock wait of the next borrower, and a read -
     * which replays nothing, deliberately - would see the error 1222 it ends with.
     */
    @Test
    public void testAConnectionWhoseBoundCouldNotBeTakenOffIsKeptOutOfThePool() throws Exception {
        final AtomicBoolean keptOut = new AtomicBoolean();
        final Connection parent = refusingToGiveTheValueBack(mock(Connection.class), "-1");
        try (final CachedConnection con = new CachedConnection("jdbc:mock", parent) {
            @Override
            void keepOutOfThePool() {
                keptOut.set(true);
                super.keepOutOfThePool();
            }
        }) {
            storage.releaseLockBound(con, Dialect.MICROSOFT,
                storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW));
        }
 
        assertTrue(keptOut.get(), "a connection left carrying our bound was handed back to the pool");
    }
 
    /**
     * Postgres needs a transaction block for a set local to mean anything: outside one the server
     * answers it with a warning no driver raises, so the write would run unbounded while the log read
     * exactly like a bounded one.
     */
    @Test
    public void testPostgresInAutoCommitIsToldNothing() throws Exception {
        final Connection con = recording(mock(Connection.class), "0");
        when(con.getAutoCommit()).thenReturn(true);
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
 
        assertEquals(issued, emptyList());
        assertFalse(armed.bounded, "a set local that reaches no transaction was reported as bounding the wait");
    }
 
    /**
     * A failed setting takes the transaction back to the savepoint in front of it: a statement that
     * fails inside a postgres transaction aborts it, and the write would then fail with 25P02 rather
     * than running as unbounded as it ran before this bound existed.
     */
    @Test
    public void testPostgresTakesASavepointInFrontOfTheSetting() throws Exception {
        final Connection con = recording(mock(Connection.class), "0");
 
        storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
 
        verify(con).setSavepoint();
    }
 
    /**
     * And lets go of it once the setting is on: a savepoint is a subtransaction of the write, and one
     * left open spans the whole attempt - every row that attempt writes would then carry the
     * subtransaction's own xid, which readers of those rows resolve through {@code pg_subtrans}. The
     * {@code set local} survives the release, so the bound it was taken in front of stays on.
     */
    @Test
    public void testPostgresLetsGoOfThatSavepointOnceTheSettingIsOn() throws Exception {
        final Connection con = recording(mock(Connection.class), "0");
        final Savepoint beforeTheBound = mock(Savepoint.class);
        when(con.setSavepoint()).thenReturn(beforeTheBound);
 
        storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
 
        assertEquals(issued, singletonList("set local lock_timeout = 3000"),
            "the bound itself was not issued, or was taken back");
        verify(con).releaseSavepoint(beforeTheBound);
        verify(con, never()).rollback(beforeTheBound);
    }
 
    /**
     * A setting that failed is taken back to that point instead, and the point is not let go of in front
     * of a rollback that still has to reach it.
     */
    @Test
    public void testASettingThatFailedIsTakenBackToThatSavepoint() throws Exception {
        final Connection con = refusingTheSetting(mock(Connection.class), "0");
        final Savepoint beforeTheBound = mock(Savepoint.class);
        when(con.setSavepoint()).thenReturn(beforeTheBound);
 
        final ArmedLockBound armed = storage.armLockBound(con, Dialect.POSTGRES, LockBound.ROW);
 
        assertFalse(armed.bounded, "a setting the session refused was reported as bounding the wait");
        verify(con).rollback(beforeTheBound);
        verify(con, never()).releaseSavepoint(beforeTheBound);
    }
 
    /**
     * The readback is a round trip, and this bound is armed around every write of the server where the
     * DDL bound is armed around an open: it is paid once per pooled connection. Only this backend
     * writes that setting on a connection of this pool, every write puts the value back, and a
     * connection whose restore failed is kept out of the pool - so what was read cannot go stale.
     */
    @Test
    public void testTheReadbackIsPaidOncePerPooledConnection() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                recording(mock(Connection.class), "50"))) {
            storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
            storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
        }
 
        assertEquals(issued, asList("select @@session.innodb_lock_wait_timeout",
            "set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50",
            "set session innodb_lock_wait_timeout=3", "set session innodb_lock_wait_timeout=50"),
            "the value a pooled session carries was read back more than once");
    }
 
    /**
     * A connection that is not one of this pool is asked every time: the memo above is a property of a
     * session this backend owns for its life, and the catalog and stamp connections are not that.
     */
    @Test
    public void testAConnectionOutsideThePoolIsAskedEveryTime() throws Exception {
        final Connection con = recording(mock(Connection.class), "50");
 
        storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
        storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
 
        assertEquals(issued.stream().filter("select @@session.innodb_lock_wait_timeout"::equals).count(), 2L,
            "a connection outside the pool was asked for the value it carries only once");
    }
 
    /**
     * The failure an engine ends a bounded wait with is replayable, and only where this backend bounded
     * that wait: 55P03 and error 1222 are no conflict - the engine rolled nothing back and the blocker
     * is still holding the lock - and what makes them worth replaying is that the wait they cost fits
     * inside the replay window. Where nothing bounded the wait they stay exactly as unreplayable as
     * they were: a bound an operator set for themselves is not a licence for this loop to take that
     * wait again, and a wait nothing bounds is the one thing the window cannot govern (#903).
     */
    @DataProvider
    public Object[][] lockTimeouts() {
        return new Object[][] {
            { "postgres lock_timeout", Dialect.POSTGRES, new SQLException("canceling statement due to lock timeout",
                "55P03") },
            { "sql server LOCK_TIMEOUT", Dialect.MICROSOFT, new SQLException("Lock request time out period exceeded.",
                "HY000", 1222) },
        };
    }
 
    @Test(dataProvider = "lockTimeouts")
    public void testALockTimeoutIsReplayedOnlyWhereThisBackendBoundedTheWait(String name, Dialect dialect,
            SQLException failure) {
        assertEquals(replayReason(failure, dialect, ArmedLockBound.alreadyTighter(LockBound.ROW, 3)),
            "a lock wait of an attempt this backend bounded", name);
        assertNull(replayReason(failure, dialect, ArmedLockBound.none(LockBound.ROW)),
            name + ": a wait this backend put no bound on was replayed on a clock that cannot bound it");
    }
 
    /**
     * A mysql lock wait timeout arrives in class 40, which is the conflict this loop has replayed since
     * #867: it keeps that reason whether or not this bound is armed, since the line should name the
     * strongest thing that can be said of the failure.
     */
    @Test
    public void testAMysqlLockWaitTimeoutStaysTheConflictItWas() {
        final SQLException lockWait = new SQLException("Lock wait timeout exceeded", "40001", 1205);
 
        assertEquals(replayReason(lockWait, MYSQL, false, false, false, Dialect.MYSQL,
            ArmedLockBound.none(LockBound.ROW)), "a conflict");
        assertEquals(replayReason(lockWait, MYSQL, false, false, false, Dialect.MYSQL,
            ArmedLockBound.alreadyTighter(LockBound.ROW, 3)), "a conflict");
    }
 
    /**
     * Not while committing, for the reason a dropped connection is not replayed there: a commit that
     * did not answer leaves the outcome unknown, and this loop must not apply a write twice.
     */
    @Test
    public void testALockTimeoutReportedByTheCommitIsNotReplayed() {
        final SQLException lockTimeout = new SQLException("Lock request time out period exceeded.", "HY000", 1222);
 
        assertNull(replayReason(lockTimeout, UNKNOWN_ENGINE, true, false, false, Dialect.MICROSOFT,
            ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
    }
 
    /** And never once the attempt has committed part of its own work, whatever the failure says. */
    @Test
    public void testALockTimeoutOfAnAttemptThatCommittedPartOfItsWorkIsNotReplayed() {
        final SQLException lockTimeout = new SQLException("Lock request time out period exceeded.", "HY000", 1222);
 
        assertNull(replayReason(lockTimeout, UNKNOWN_ENGINE, false, true, false, Dialect.MICROSOFT,
            ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
    }
 
    /**
     * Read from the failure of the operation only, never from the release of the connection: the
     * rollback that gives a connection back runs after the outcome was decided, so a lock timeout
     * reported there says nothing about the statement that failed.
     */
    @Test
    public void testALockTimeoutOfTheReleaseIsNotReplayed() {
        final SQLException rejected = new SQLException("duplicate key", "23000", 2627);
        rejected.addSuppressed(new SQLException("Lock request time out period exceeded.", "HY000", 1222));
 
        assertNull(replayReason(rejected, Dialect.MICROSOFT, ArmedLockBound.alreadyTighter(LockBound.ROW, 3)));
    }
 
    /**
     * sql server has one {@code LOCK_TIMEOUT} for both waits, and every DDL of this backend but the
     * off-write catalog drop is issued from inside a write - which armed this bound one statement
     * earlier. Read live, that DDL would see the 3 s of the row bound, answer "already tighter" to its
     * own 5 s and run at the row bound instead: {@code DDL_LOCK_TIMEOUT_PROPERTY} would govern no DDL of
     * a write at all. What decides it is the value the deployment set, which the row bound remembered on
     * the connection when it displaced it.
     * <p>
     * The readback itself is still paid, and paid live: what the DDL has to put back is the value the
     * session carried a statement ago - the row bound of this very write - rather than the one the
     * connection was borrowed with, which is why only the row bound is remembered per pooled connection.
     */
    @Test
    public void testTheDdlBoundInsideAWriteIsDecidedAgainstWhatTheDeploymentSet() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "-1"))) {
            final ArmedLockBound row = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
            storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                issued.add("the ddl");
                return null;
            });
            storage.releaseLockBound(con, Dialect.MICROSOFT, row);
        }
 
        assertEquals(issued, asList(
            // the row bound of the write, against what the session carried
            "select @@lock_timeout", "set lock_timeout 3000",
            // the DDL inside it, read live and armed at its own property rather than left at the row bound
            "select @@lock_timeout", "set lock_timeout 5000",
            "the ddl",
            // what the session carried a statement before the DDL, which is the row bound of this write
            "set lock_timeout 3000",
            // and the value the deployment set, once the write is through
            "set lock_timeout -1"));
    }
 
    /**
     * And a DDL that gives up at that bound is reported as what it is, naming its own property - the
     * rename of #885, which a DDL left at the row bound would lose along with the bound: it would arrive
     * as the bare error 1222, sending an operator to neither property.
     */
    @Test
    public void testADdlInsideAWriteThatGivesUpNamesItsOwnProperty() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "-1"))) {
            storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
 
            try {
                storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                    throw new SQLException("Lock request time out period exceeded.", "HY000", 1222);
                });
                fail("a DDL that gave up on its lock went through");
            } catch (SQLException e) {
                assertTrue(e.getMessage().contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY), e.getMessage());
            }
        }
    }
 
    /**
     * And that value is put on the session, not merely decided against. A deployment giving up sooner
     * than the DDL bound keeps its own figure - the argument that leaves oracle alone - but "keeps what
     * it has" is not what the session has once the row bound of this write is on that very setting: at a
     * {@code LOCK_TIMEOUT} of 4 s the DDL would run at the 3 s of the row bound, tighter than either
     * property, and give up as the bare error 1222. What the deployment set goes on for the length of
     * the DDL, and the row bound of the write goes back on behind it.
     */
    @Test
    public void testADdlInsideAWriteRunsAtWhatTheDeploymentSetWhereThatIsTighter() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "4000"))) {
            final ArmedLockBound row = storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
            storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                issued.add("the ddl");
                return null;
            });
            storage.releaseLockBound(con, Dialect.MICROSOFT, row);
        }
 
        assertEquals(issued, asList(
            // the row bound of the write, over a deployment looser than it
            "select @@lock_timeout", "set lock_timeout 3000",
            // the DDL inside it, at the value the deployment set rather than at the row bound it met
            "select @@lock_timeout", "set lock_timeout 4000",
            "the ddl",
            // the row bound of the write back, and the deployment's value once the write is through
            "set lock_timeout 3000", "set lock_timeout 4000"));
    }
 
    /**
     * The same where an operator raised the DDL bound for an index build and the deployment bounds every
     * lock wait of its sessions: 30 s asked for, 10 s allowed, and the row bound of the write is neither.
     */
    @Test
    public void testTheSameWhereTheDdlBoundWasRaisedForAnIndexBuild() throws Exception {
        System.setProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY, "30");
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "10000"))) {
            storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
            storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                issued.add("the ddl");
                return null;
            });
        }
 
        assertEquals(issued, asList(
            "select @@lock_timeout", "set lock_timeout 3000",
            "select @@lock_timeout", "set lock_timeout 10000",
            "the ddl",
            "set lock_timeout 3000"));
    }
 
    /**
     * And a DDL that gives up under that value is left exactly as it arrived: what ended the wait is the
     * deployment's own {@code LOCK_TIMEOUT}, and naming this property for it would send an operator to
     * raise a value that governs nothing while the deployment's own is the tighter one.
     */
    @Test
    public void testADdlThatGaveUpAtTheDeploymentsValueIsNotNamedByThisProperty() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "4000"))) {
            storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
 
            try {
                storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                    throw new SQLException("Lock request time out period exceeded.", "HY000", 1222);
                });
                fail("a DDL that gave up on its lock went through");
            } catch (SQLException e) {
                assertFalse(e.getMessage().contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY),
                    "a wait the deployment's own value ended was reported as this property's doing: " + e.getMessage());
                assertEquals(e.getErrorCode(), 1222, "the failure of the engine was not handed through as it arrived");
            }
        }
    }
 
    /**
     * And the same of a lookup, which is the other kind of work this bound is armed around: the one
     * deciding each drop of a clear wraps whatever it sees in a {@code StorageRuntimeException}
     * ({@code isExistsTable}), so it reaches the rename by the unchecked arm rather than the checked
     * one. Both arms read the same thing - whether the session is carrying this bound's own figure -
     * and keying either of them on "a setting of ours was issued" instead would name this property for
     * a wait the deployment's own value ended.
     */
    @Test
    public void testALookupThatGaveUpAtTheDeploymentsValueIsNotNamedByThisPropertyEither() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                liveLockTimeout(mock(Connection.class), "4000"))) {
            storage.armLockBound(con, Dialect.MICROSOFT, LockBound.ROW);
 
            try {
                storage.withDdlLockBound(con, Dialect.MICROSOFT, () -> {
                    throw new StorageRuntimeException(
                        new SQLException("Lock request time out period exceeded.", "HY000", 1222));
                });
                fail("a lookup that gave up on its lock went through");
            } catch (StorageRuntimeException e) {
                assertFalse(String.valueOf(e.getMessage()).contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY),
                    "a wait the deployment's own value ended was reported as this property's doing: " + e.getMessage());
            }
        }
    }
 
    /**
     * On mysql the two waits are two variables - {@code innodb_lock_wait_timeout} for the row lock,
     * {@code lock_wait_timeout} for the metadata lock a DDL waits for - so what the row bound displaced
     * describes neither the other's session nor its default. Reading it there would leave a DDL waiting
     * a year because a deployment had tightened the row lock to a second.
     */
    @Test
    public void testTheDdlBoundOfAMysqlWriteIsDecidedAgainstItsOwnVariable() throws Exception {
        try (final CachedConnection con = new CachedConnection("jdbc:mock",
                answering(mock(Connection.class),
                    "select @@session.innodb_lock_wait_timeout", "1",
                    "select @@session.lock_wait_timeout", "31536000"))) {
            storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
            storage.withDdlLockBound(con, Dialect.MYSQL, () -> {
                issued.add("the ddl");
                return null;
            });
        }
 
        assertEquals(issued, asList(
            // the row bound: this session gives up sooner than ours would, so it keeps what it has
            "select @@session.innodb_lock_wait_timeout",
            // and the metadata lock is bounded all the same, against the variable that bounds it
            "select @@session.lock_wait_timeout", "set session lock_wait_timeout=5",
            "the ddl",
            "set session lock_wait_timeout=31536000"));
    }
 
    /**
     * The latches these bounds warn through are per bound, not per storage: they are armed by different
     * code on different paths, and an open whose DDL bound this session would not take says nothing
     * about the writes behind it. Through one latch, the first open of a backend would silence every
     * write of it - which is what these two assertions, taken together, keep from happening.
     */
    @Test
    public void testTheWarningLatchesAreOnePerBound() throws Exception {
        final Connection con = refusingTheSetting(mock(Connection.class), "31536000");
 
        storage.armLockBound(con, Dialect.MYSQL, LockBound.DDL);
 
        assertTrue(storage.lockBoundNotSetWarned.get(LockBound.DDL).get(),
            "a setting the session refused was not reported for the bound that was armed");
        assertFalse(storage.lockBoundNotSetWarned.get(LockBound.ROW).get(),
            "the DDL bound of an open silenced the row lock bound of every write behind it");
 
        storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW);
 
        assertTrue(storage.lockBoundNotSetWarned.get(LockBound.ROW).get(),
            "the row lock bound of a write said nothing of its own");
    }
 
    /**
     * The same, for the moment throttling what a bound left behind says - and asked of the row bound,
     * which is the one a single shared moment would leave unsaid: what the two of them have to be is
     * one per bound, in both directions.
     */
    @Test
    public void testTheLatchOfABoundLeftBehindIsOnePerBoundToo() throws Exception {
        final Connection con = refusingToGiveTheValueBack(mock(Connection.class), "31536000");
 
        storage.releaseLockBound(con, Dialect.MYSQL, storage.armLockBound(con, Dialect.MYSQL, LockBound.ROW));
 
        assertTrue(storage.lockBoundLeftBehindWarned.get(LockBound.ROW).get() != 0,
            "a bound that could not be taken off was not reported for the bound that was armed");
        assertEquals(storage.lockBoundLeftBehindWarned.get(LockBound.DDL).get(), 0L,
            "the row lock bound of a write silenced the DDL bound of every open of this backend");
    }
 
    private static String replayReason(SQLException failure, Dialect dialect, ArmedLockBound rowLock) {
        return replayReason(failure, UNKNOWN_ENGINE, false, false, false, dialect, rowLock);
    }
 
    /**
     * The two questions {@code write()} asks after a failed attempt, composed here the way it composes
     * them: the conflict class is read off the failure once and handed to the reason, rather than being
     * asked for again.
     */
    private static String replayReason(SQLException failure, String driver, boolean committing,
            boolean partlyCommitted, boolean connectionClosed, Dialect dialect, ArmedLockBound rowLock) {
        return JDBCStorage.replayReason(JDBCStorage.conflictVerdict(failure, driver).conflict, failure, committing,
            partlyCommitted, connectionClosed, dialect, rowLock);
    }
 
    /**
     * A connection recording every session statement it is given, and answering the readback of the
     * setting with the value a session of that engine carries.
     */
    private Connection recording(final Connection con, final String carries) throws SQLException {
        final Statement statement = mock(Statement.class);
        when(statement.execute(anyString())).thenAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            return false;
        });
        when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            final ResultSet carried = mock(ResultSet.class);
            when(carried.next()).thenReturn(true, false);
            when(carried.getString(1)).thenReturn(carries);
            return carried;
        });
        when(con.createStatement()).thenReturn(statement);
        return con;
    }
 
    /**
     * A connection answering each readback its own value, which is what an engine whose two waits are
     * two variables does: every other fixture here answers one value to every query, so a case over one
     * of them cannot tell the value of one setting from the value of the other.
     *
     * @param answers the query and the value it is answered with, in pairs
     */
    private Connection answering(final Connection con, final String... answers) throws SQLException {
        final Statement statement = mock(Statement.class);
        when(statement.execute(anyString())).thenAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            return false;
        });
        when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
            final String query = (String) invocation.getArguments()[0];
            issued.add(query);
            final ResultSet carried = mock(ResultSet.class);
            when(carried.next()).thenReturn(true, false);
            for (int i = 0; i < answers.length; i += 2) {
                if (answers[i].equals(query)) {
                    when(carried.getString(1)).thenReturn(answers[i + 1]);
                    return carried;
                }
            }
            throw new SQLException("this fixture answers no " + query, "42000");
        });
        when(con.createStatement()).thenReturn(statement);
        return con;
    }
 
    /**
     * A sql server connection answering the readback with what the last {@code set lock_timeout} left on
     * it - a live session rather than a fixed value, which is what a bound armed inside another one
     * meets.
     */
    private Connection liveLockTimeout(final Connection con, final String initially) throws SQLException {
        final AtomicReference<String> carried = new AtomicReference<>(initially);
        final Statement statement = mock(Statement.class);
        when(statement.execute(anyString())).thenAnswer(invocation -> {
            final String sql = (String) invocation.getArguments()[0];
            issued.add(sql);
            if (sql.startsWith("set lock_timeout ")) {
                carried.set(sql.substring("set lock_timeout ".length()));
            }
            return false;
        });
        when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            final ResultSet rows = mock(ResultSet.class);
            when(rows.next()).thenReturn(true, false);
            when(rows.getString(1)).thenReturn(carried.get());
            return rows;
        });
        when(con.createStatement()).thenReturn(statement);
        return con;
    }
 
    /** A connection that says what it carries and will not take the setting of a bound at all. */
    private Connection refusingTheSetting(final Connection con, final String carries) throws SQLException {
        final Statement statement = recording(con, carries).createStatement();
        doAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            throw new SQLException("this session takes no such setting", "42000");
        }).when(statement).execute(anyString());
        return con;
    }
 
    /** A connection that takes the bound and will not take back the value that bound displaced. */
    private Connection refusingToGiveTheValueBack(final Connection con, final String carries) throws SQLException {
        final Statement statement = recording(con, carries).createStatement();
        final AtomicBoolean bound = new AtomicBoolean();
        doAnswer(invocation -> {
            issued.add((String) invocation.getArguments()[0]);
            if (!bound.compareAndSet(false, true)) {
                throw new SQLException("the connection went before the value could be given back", "08006");
            }
            return false;
        }).when(statement).execute(anyString());
        return con;
    }
}