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

Valery Kharseko
7 hours ago 36d4af9bd7b9f0617f33fa5bb1db2aff574f14d7
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
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
/*
 * 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 2024-2026 3A Systems, LLC.
 */
package org.opends.server.backends.jdbc;
 
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.server.config.server.JDBCBackendCfg;
import org.opends.server.backends.pluggable.PluggableBackendImplTestCase;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.Importer;
import org.opends.server.backends.pluggable.spi.ReadOnlyStorageException;
import org.opends.server.backends.pluggable.spi.ReadOperation;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteOperation;
import org.opends.server.backends.pluggable.spi.WriteableTransaction;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testng.SkipException;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
 
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
 
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.when;
import static org.opends.server.util.StaticUtils.stackTraceToSingleLineString;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
 
 
 
public abstract class TestCase extends PluggableBackendImplTestCase<JDBCBackendCfg> {
 
    JdbcDatabaseContainer container;
 
    @BeforeClass
    @Override
    public void setUp() throws Exception {
        if(DockerClientFactory.instance().isDockerAvailable()) {
            try {
                container = getContainer();
                container.start();
            } catch (Exception e) {
                // The database container could not be started (e.g. a slow/flaky image
                // pull or DB initialization failure on CI). Skip the test instead of
                // failing the whole build - container startup is an infrastructure
                // concern, not a regression in the JDBC backend under test.
                throw new SkipException(getContainerDockerCommand());
            }
        }
        try(Connection con = DriverManager.getConnection(createBackendCfg().getDBDirectory())){
            dropStaleTrees(con);
        } catch (Exception e) {
            throw new SkipException(getContainerDockerCommand());
        }
        super.setUp();
    }
 
    /**
     * Backend test classes sharing one database map the same tree names to the same tables,
     * so a previous run may leave trees behind â€” including entries encrypted with a lost cipher key.
     */
    static void dropStaleTrees(Connection con) throws SQLException {
        final List<String> stale = new ArrayList<>();
        try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) {
            while (rs.next()) {
                final String name = rs.getString("TABLE_NAME");
                if (name.toLowerCase().startsWith("opendj_")) {
                    stale.add(name);
                }
            }
        }
        try (final Statement st = con.createStatement()) {
            for (final String name : stale) {
                st.execute("drop table " + name);
            }
        }
    }
 
    @Override
    protected Backend createBackend() {
        return new Backend();
    }
 
    @Override
    protected JDBCBackendCfg createBackendCfg() {
        return createBackendCfg(getBackendId());
    }
 
    /**
     * A configuration of another backend on the database of this suite: backends sharing one database
     * URL is a configuration nothing forbids, and what one of them clears must be its own tables.
     */
    protected JDBCBackendCfg createBackendCfg(String backendId) {
        JDBCBackendCfg backendCfg = mockCfg(JDBCBackendCfg.class);
        when(backendCfg.getBackendId()).thenReturn(backendId);
        when(backendCfg.getDBDirectory()).thenReturn(getJdbcUrl());
        return backendCfg;
    }
 
    /**
     * The same, reached over a connection string of the caller's own: the pools of this backend are
     * keyed by it, so a case wanting connections established differently - in another schema of the
     * search path, say - asks for them by asking for another url.
     */
    protected JDBCBackendCfg createBackendCfg(String backendId, String jdbcUrl) {
        final JDBCBackendCfg backendCfg = createBackendCfg(backendId);
        when(backendCfg.getDBDirectory()).thenReturn(jdbcUrl);
        return backendCfg;
    }
 
    /**
     * The same, serving the given base DN: what a clear compares the tree stamp of a table against
     * when it says whether the table is this backend's own or another's (#866).
     */
    protected JDBCBackendCfg createBackendCfg(String backendId, DN baseDN) {
        final JDBCBackendCfg backendCfg = createBackendCfg(backendId);
        final TreeSet<DN> baseDNs = new TreeSet<>();
        baseDNs.add(baseDN);
        when(backendCfg.getBaseDN()).thenReturn(baseDNs);
        return backendCfg;
    }
 
    /** Asked of the database itself, by listing its tables, so that no folding rule of the backend is trusted here. */
    protected boolean isExistsTable(String tableName) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
            return isExistingTable(con, tableName);
        }
    }
 
    /** Drops a table behind the back of the storage that owns it, which no code path of the backend does. */
    private void dropTableBehindTheBackend(String tableName) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
             final Statement st = con.createStatement()) {
            st.execute("drop table " + tableName);
        }
    }
 
    /** Clears a backend of a test without letting the failure of the clear replace the failure being reported. */
    protected static void clearQuietly(JDBCStorage storage) {
        try {
            storage.removeStorageFiles();
        } catch (Exception ignored) {
        } finally {
            storage.close();
        }
    }
 
    @AfterClass
    @Override
    public void cleanUp() throws Exception {
        super.cleanUp();
        if(container != null) {
            container.close();
        }
    }
 
    protected abstract JdbcDatabaseContainer<?> getContainer();
 
    protected abstract String getContainerDockerCommand();
 
    protected abstract String getBackendId();
 
    protected abstract String getJdbcUrl();
 
    /**
     * The second property bounding a login is a socket read timeout on mysql, oracle and sql
     * server: in force for the whole life of the connection it would fail every statement slower
     * than it - an import batch, the statistics of a freshly loaded table - so it has to be lifted
     * as soon as the login is through (#872).
     */
    @Test(timeOut = 120000)
    public void testLoginBoundDoesNotOutliveTheLogin() throws Exception {
        final String url = createBackendCfg().getDBDirectory();
        final CachedConnection.ConnectDialect dialect = CachedConnection.ConnectDialect.of(url);
        assertNotNull(dialect, "the dialect of the container is one this backend bounds: " + CachedConnection.safeUrl(url));
        System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "2");
        try {
            // the bound this lifts has to be in force first, or the assertion below holds of a
            // connection that never carried one: established here with the very properties the
            // borrow uses, and read back off the socket of this driver
            final Properties bounding = new Properties();
            assertTrue(dialect.bound(url, bounding, 2),
                "the read bound of the login is not set for this dialect, so there is nothing to lift");
            try (final Connection bounded = DriverManager.getConnection(url, bounding)) {
                assertEquals(bounded.getNetworkTimeout(), 2000,
                    "the property this dialect names does not bound the socket of its login");
            }
 
            // a pooled connection would be handed back without being established again
            CachedConnection.poolOf(url).drainIdle();
            try (final Connection con = CachedConnection.getConnection(url)) {
                assertEquals(con.getNetworkTimeout(), 0, "the read bound of the login is still in force");
            }
        } finally {
            System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY);
        }
    }
 
    /**
     * And the bound that replaces it reaches the socket of the driver: set with setNetworkTimeout
     * once the login is through, it is read back off the connection the pool hands out (#885). The
     * unit tests pin which value is set, on a mock that can only answer that it was asked; this is
     * the driver of a real engine answering that it took it.
     */
    @Test(timeOut = 120000)
    public void testTheStandingReadBoundReachesTheSocket() throws Exception {
        final String url = createBackendCfg().getDBDirectory();
        final int configured = CachedConnection.readTimeoutMillis;
        CachedConnection.readTimeoutMillis = 5000;
        try {
            assertEquals(CachedConnection.standingReadBoundMillis(url), 5000,
                "the url of this container carries a read bound of its own, so no bound of ours is set on it");
            // a pooled connection would be handed back without being established again
            CachedConnection.poolOf(url).drainIdle();
            try (final Connection con = CachedConnection.getConnection(url)) {
                assertEquals(con.getNetworkTimeout(), 5000,
                    "the read bound of this backend did not reach the socket of this driver");
            }
        } finally {
            CachedConnection.readTimeoutMillis = configured;
            // and nothing carrying the bound of this test goes back to the pool the suite goes on using
            CachedConnection.poolOf(url).drainIdle();
        }
    }
 
    private static ByteString key(int i) {
        return ByteString.valueOfUtf8(String.format("key%02d", i));
    }
 
    private static ByteString value(int i) {
        return ByteString.valueOfUtf8("value" + i);
    }
 
    /**
     * openTree() and deleteTree() ask the catalog whether the table of a tree is there. The name is
     * looked up in the form the catalog stores it - an unquoted identifier is folded to upper case
     * on oracle and to lower case on postgresql - so getting that wrong makes a second open try to
     * create a table that is already there, and a second delete drop one that is already gone (#885).
     */
    @Test
    public void testTableOfATreeIsFoundByName() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testCatalogLookup", "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    txn.put(tree, key(1), value(1));
                }
            });
            // the table is there now: opening the tree again must find it, not create it a second time
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            assertEquals(storage.read(new ReadOperation<ByteString>() {
                @Override
                public ByteString run(ReadableTransaction txn) throws Exception {
                    return txn.read(tree, key(1));
                }
            }), value(1));
 
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.deleteTree(tree);
                }
            });
            // and gone now: deleting it again must find nothing rather than drop what is not there
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.deleteTree(tree);
                }
            });
        } finally {
            storage.close();
        }
    }
 
    /**
     * treeExists() has to answer for a table that was never created rather than fail: it is how the
     * compressed schema tells a backend with nothing to migrate from one whose definitions are still
     * under the shared prefix (#873), and every other statement of this storage fails outright on a
     * table that does not exist.
     */
    @Test
    public void testTreeExistsAnswersForAMissingTable() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName present = new TreeName("testTreeExists", "present");
        final TreeName absent = new TreeName("testTreeExists", "absent");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(present, true);
                    assertTrue(txn.treeExists(present));
                    assertFalse(txn.treeExists(absent));
                }
            });
            // the read path has to answer as well: export-ldif and verify-index open read-only, where
            // no tree is created and the question cannot be settled by writing one
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    assertTrue(txn.treeExists(present));
                    assertFalse(txn.treeExists(absent));
                    return null;
                }
            });
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.deleteTree(present);
                    assertFalse(txn.treeExists(present));
                }
            });
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(present);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * The compressed schema definitions of this backend must live in a table of its own. The tree
     * name they used to carry held no backend qualifier, so its table name was a constant that every
     * JDBC backend of every server sharing the database mapped to, and two of them overwrote each
     * other's token definitions there (#873). The backend of this suite has been opened and populated
     * by PluggableBackendImplTestCase#setUp, so its own table exists by now.
     */
    @Test
    public void testCompressedSchemaTableIsQualifiedByBackendId() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            final String shared = JDBCStorage.toTableName(new TreeName("compressed_schema", "compressed_attributes"));
            final String own = JDBCStorage.toTableName(
                    new TreeName("compressed_schema_" + getBackendId(), "compressed_attributes"));
            assertFalse(shared.equals(own), "the qualified tree name must map to a table of its own");
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                assertTrue(isExistingTable(con, own), own + " (this backend's own definitions) is missing");
                assertFalse(isExistingTable(con, shared), shared + " is the table every backend used to share");
            }
        } finally {
            storage.close();
        }
    }
 
    /**
     * Reading a tree must not put it up for removal: a clear drops what the catalog of the backend
     * names (#888), and the compressed schema reads the tree its definitions used to be shared under
     * - which on a shared database is another backend's to keep (#873). Asking whether the tree is
     * there is only the first of those reads: the migration counts it and copies it out too, so one
     * guarded statement would not be enough.
     * <p>
     * The two storages are two backends and not one addressing the same database, which is what the
     * case is about: what a backend owns is recorded in a catalog named after its backend id and
     * outlives the process that opened the tree, so a second storage of the same id would be shown
     * the tree its own earlier open had enrolled - and would be right to be.
     */
    @Test
    public void testProbingATreeDoesNotPutItUpForRemoval() throws Exception {
        final TreeName foreign = new TreeName("testProbe", "foreign");
        final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
        owner.open(AccessMode.READ_WRITE);
        owner.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
                txn.openTree(foreign, true);
                txn.put(foreign, key(1), value(1));
            }
        });
        owner.close();
 
        // a second backend on the same database, which never opened that tree - the shape of two
        // backends addressing one database
        final JDBCStorage other = new JDBCStorage(createBackendCfg(getBackendId() + "_probe"), null);
        try {
            other.open(AccessMode.READ_WRITE);
            other.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    // every read the compressed schema runs against a tree it does not own: it asks
                    // whether the tree is there, counts it, reads a key of it and walks it (#873)
                    assertTrue(txn.treeExists(foreign));
                    assertEquals(txn.getRecordCount(foreign), 1);
                    assertEquals(txn.read(foreign, key(1)), value(1));
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(foreign)) {
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(1));
                    }
                    return null;
                }
            });
            assertFalse(other.listTrees().contains(foreign), "a tree only read must not be listed for removal");
 
            other.removeStorageFiles();
 
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                assertTrue(isExistingTable(con, JDBCStorage.toTableName(foreign)),
                        "clearing one backend dropped a table it had only asked about");
            }
        } finally {
            other.close();
            final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
            try {
                cleanup.open(AccessMode.READ_WRITE);
                cleanup.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(foreign);
                    }
                });
            } catch (Exception ignored) {
            } finally {
                cleanup.close();
            }
        }
    }
 
    /**
     * The other side of the same rule: a cursor reads through the non-enrolling name, but deleting
     * through one writes to the tree, so it is a tree this backend owns and removeStorageFiles()
     * has to be able to name it.
     */
    @Test
    public void testDeletingThroughACursorPutsTheTreeUpForRemoval() throws Exception {
        final TreeName tree = new TreeName("testCursorDelete", "tree");
        final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
        owner.open(AccessMode.READ_WRITE);
        owner.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
                txn.openTree(tree, true);
                txn.put(tree, key(1), value(1));
            }
        });
        owner.close();
 
        // a second storage of the same backend, which never opened that tree itself: the delete takes
        // the enrolling name and the table it writes to is the one the tree names. What puts a tree up
        // for removal is the row its backend's catalog holds (#888) - written by the openTree above and
        // outliving the storage that made it - so this asserts the listing of a backend and not a
        // side effect of the statement, which is what a listing of a catalog can assert
        final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
        try {
            other.open(AccessMode.READ_WRITE);
            other.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        assertTrue(cursor.next());
                        cursor.delete();
                    }
                }
            });
            assertTrue(other.listTrees().contains(tree), "a tree written through a cursor must be listed for removal");
        } finally {
            other.close();
            final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
            try {
                cleanup.open(AccessMode.READ_WRITE);
                cleanup.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {
            } finally {
                cleanup.close();
            }
        }
    }
 
    private static boolean isExistingTable(Connection con, String tableName) throws SQLException {
        try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) {
            while (rs.next()) {
                if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
                    return true;
                }
            }
        }
        return false;
    }
 
    /**
     * A statement of this backend has to end even when another session holds what it needs: a row
     * locked by a transaction that never commits used to park the worker thread that issued the
     * write for good, with nothing in the log to say so (#877).
     */
    @Test(timeOut = 600000)
    public void testWriteBlockedByAnotherSessionGivesUpAtItsBound() throws Exception {
        assertBoundedWhileRowsAreLocked("testStatementBound", JDBCStorage.StatementBound.OPERATION,
            new BlockedOperation() {
                @Override
                public void run(JDBCStorage storage, TreeName tree) throws Exception {
                    storage.write(new WriteOperation() {
                        @Override
                        public void run(WriteableTransaction txn) throws Exception {
                            txn.put(tree, key(1), value(2));
                        }
                    });
                }
            });
    }
 
    /**
     * The bulk class keeps a bound of its own: a count or the delete that empties a tree before an
     * import legitimately takes minutes, so it must not be cut at the bound of an entry read - and
     * must still be able to give up (#877).
     */
    @Test(timeOut = 600000)
    public void testBulkStatementGivesUpAtItsOwnBound() throws Exception {
        assertBoundedWhileRowsAreLocked("testBulkBound", JDBCStorage.StatementBound.BULK,
            new BlockedOperation() {
                @Override
                public void run(JDBCStorage storage, TreeName tree) throws Exception {
                    // the importer is where "delete from <table>" - the bulk class - is reachable:
                    // AbstractTwoPhaseImportStrategy clears every tree before an import writes to it
                    try (final Importer importer = storage.startImport()) {
                        importer.clearTree(tree);
                    }
                }
            });
    }
 
    private interface BlockedOperation {
        void run(JDBCStorage storage, TreeName tree) throws Exception;
    }
 
    /**
     * Whether the failure the operation gave up with is the one its bound produced: the message of
     * a statement classified as having reached its bound names the property that bounded it, and it
     * arrives wrapped in whatever the storage throws to its caller.
     */
    private static boolean namesTheBound(Throwable failure, JDBCStorage.StatementBound bound) {
        return namedInTheChain(failure, bound.property);
    }
 
    /**
     * Whether the statement ran under the socket read timeout alone, which is what
     * {@code timedOut()} says of one whose driver would not take the cancel. That degradation is by
     * design - {@code JDBCStorage.setQueryTimeout()} warns once and carries on - and it is
     * therefore silent: with a ceiling wide enough for the second layer, a run with the first one
     * gone entirely ends at the backstop and passes as the bound doing its work.
     */
    private static boolean ranUnderTheBackstopAlone(Throwable failure) {
        return namedInTheChain(failure, JDBCStorage.BACKSTOP_ALONE);
    }
 
    /** Cause hops walked below, as {@code JDBCStorage} bounds its own classifier: a guard against a cycle. */
    private static final int MAX_CAUSE_HOPS = 16;
 
    private static boolean namedInTheChain(Throwable failure, String text) {
        // bounded by hops rather than by t != t.getCause(), which only catches a cause that is its
        // own: a wrapper re-attaching an exception it has already wrapped makes a cycle of two, and
        // walking that one spins until the harness times the whole suite out
        Throwable t = failure;
        for (int hops = 0; t != null && hops < MAX_CAUSE_HOPS; t = t.getCause(), hops++) {
            if (t.getMessage() != null && t.getMessage().contains(text)) {
                return true;
            }
            if (t == t.getCause()) {
                break;
            }
        }
        return false;
    }
 
    /**
     * Runs the given operation while another session holds every row of the tree in an uncommitted
     * transaction, with only the property of the given class bounding it: the operation must give
     * up inside that bound instead of waiting for a lock that is never released.
     */
    private void assertBoundedWhileRowsAreLocked(String treeId, JDBCStorage.StatementBound bound, BlockedOperation blocked)
            throws Exception {
        final int boundSeconds = 5;
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName(treeId, "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    txn.put(tree, key(1), value(1));
                }
            });
            // another session takes an exclusive lock on every row of the table and keeps it: the
            // same statement clearTree() issues, so it is known to parse on all four dialects
            try (final Connection blocker = DriverManager.getConnection(getJdbcUrl())) {
                blocker.setAutoCommit(false);
                try (final Statement lock = blocker.createStatement()) {
                    lock.executeUpdate("delete from " + storage.getTableName(tree));
                }
                // the rows go back whatever the assertions below do with the run: the cleanup of
                // this method drops the table, which is a bulk statement and unbounded here, so a
                // lock still held would park it until the timeout of the harness and turn one
                // failed assertion into a stalled build
                try {
                    // only the class under test is bounded, so a pass through the other one cannot
                    // be mistaken for the bound working
                    for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
                        System.setProperty(each.property, each == bound ? Integer.toString(boundSeconds) : "0");
                    }
                    // the monotonic clock, which is what timedOut() measures the bound with: a step of
                    // the wall clock can neither lengthen nor shorten what the assertions below allow
                    final long startedAt = System.nanoTime();
                    Exception failure = null;
                    try {
                        blocked.run(storage, tree);
                        fail("the operation must give up while the rows it needs are locked");
                    } catch (Exception expected) {
                        failure = expected; // the bound was reached and the transaction rolled back
                    }
                    final long elapsed = (System.nanoTime() - startedAt) / 1000000L;
                    // The failure has to be the one the bound produces, not any failure at all: an
                    // operation that fell over at once for an unrelated reason would otherwise pass
                    // this test at t=0. timedOut() names the property in the message of everything it
                    // classifies as reaching the bound.
                    assertTrue(namesTheBound(failure, bound), "gave up with " + stackTraceToSingleLineString(failure)
                        + ", which does not name " + bound.property);
                    // And under the layer it is supposed to be under. The ceiling below has to be
                    // wide enough for the second one, since that is what ends the wait on oracle,
                    // and a ceiling that wide cannot tell a working first layer from a missing one:
                    // a driver that stops taking setQueryTimeout degrades to the backstop silently
                    // by design, ends there, and would be scored as the bound doing its work. The
                    // message says which layer it was, so this assertion can too.
                    assertFalse(ranUnderTheBackstopAlone(failure), "the driver would not take a query timeout, so "
                        + "the statement ran under the socket read timeout alone: "
                        + stackTraceToSingleLineString(failure));
                    // And it has to arrive at the bound rather than at something else that happens to
                    // end the wait inside a generous ceiling: with the bound deleted, mysql would still
                    // come back after its own innodb_lock_wait_timeout of 50 s, and the assertion has
                    // to fail then. The ceiling is what the bound really allows a statement, which is
                    // the second layer rather than the property: holdBackstop() arms the socket read
                    // timeout at the bound plus its margin on every engine, not only on oracle, and a
                    // run where the cancel of the driver does not land ends there. Scoring that as a
                    // failure would fail this suite for the second layer doing exactly what it exists
                    // to do - and on oracle, where a session in a row-lock enqueue never acts on the
                    // break its driver sends, that is not an edge case but the normal path.
                    final long ceilingSeconds = boundSeconds + JDBCStorage.BACKSTOP_MARGIN_SECONDS + 10;
                    // with a little slack under the bound: a driver keeps its timer in whole seconds and
                    // may report the cancel a few milliseconds before the bound is arithmetically due,
                    // which is the slack timedOut() classifies such a statement with
                    assertTrue(elapsed >= boundSeconds * 1000L - JDBCStorage.CLOCK_SLACK_MILLIS,
                        "gave up after " + elapsed + " ms, before its bound of "
                        + boundSeconds + " s: something other than the bound ended the wait");
                    assertTrue(elapsed < ceilingSeconds * 1000L, "gave up only after " + elapsed + " ms, past the "
                        + ceilingSeconds + " s this bound of " + boundSeconds + " s allows");
                }finally {
                    // in a catch of its own: a rollback that throws would otherwise replace the
                    // assertion above, and the run would report an unrelated connection problem
                    // instead of the bound that was missed. Nothing is lost by swallowing it - a
                    // session that cannot roll back has no rows left locked either.
                    try {
                        blocker.rollback();
                    } catch (SQLException releasingTheRows) {
                        // the assertions above are the outcome of this test, not this
                    }
                }
            }
        } finally {
            for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
                System.clearProperty(each.property);
            }
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * Forward repositioning inside the already-fetched batch must be served from the buffer without SQL,
     * and batch sizes must grow from "fetchsize.initial" to "fetchsize" on sequential reads (#860).
     */
    @Test
    public void testPositionToKeyOrNextServedFromBuffer() throws Exception {
        System.setProperty("org.openidentityplatform.opendj.jdbc.fetchsize", "8");
        System.setProperty("org.openidentityplatform.opendj.jdbc.fetchsize.initial", "2");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testCursorBuffer", "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    for (int i = 0; i < 40; i++) {
                        txn.put(tree, key(i), value(i));
                    }
                }
            });
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        final JDBCStorage.CursorImpl impl = (JDBCStorage.CursorImpl) cursor;
 
                        assertTrue(cursor.next()); // fetch #1: initial batch of 2 (key00, key01)
                        assertEquals(cursor.getKey(), key(0));
                        assertEquals(impl.fetchCount, 1);
                        assertTrue(cursor.next()); // key01 is buffered
                        assertEquals(impl.fetchCount, 1);
                        assertTrue(cursor.next()); // fetch #2: grown batch of 8 (key02..key09)
                        assertEquals(cursor.getKey(), key(2));
                        assertEquals(impl.fetchCount, 2);
 
                        // forward repositioning within the fetched range must not run SQL
                        assertTrue(cursor.positionToKeyOrNext(key(5)));
                        assertEquals(cursor.getKey(), key(5));
                        assertEquals(cursor.getValue(), value(5));
                        assertEquals(impl.fetchCount, 2);
                        assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("key051"))); // between rows
                        assertEquals(cursor.getKey(), key(6));
                        assertEquals(impl.fetchCount, 2);
                        assertTrue(cursor.positionToKeyOrNext(key(9))); // last buffered row
                        assertEquals(cursor.getKey(), key(9));
                        assertEquals(impl.fetchCount, 2);
 
                        assertTrue(cursor.positionToKeyOrNext(key(20))); // fetch #3: beyond the buffer
                        assertEquals(cursor.getKey(), key(20));
                        assertEquals(impl.fetchCount, 3);
                        assertTrue(cursor.positionToKeyOrNext(key(1))); // fetch #4: backward
                        assertEquals(cursor.getKey(), key(1));
                        assertEquals(impl.fetchCount, 4);
 
                        // emulate DN2ID.ChildrenCursor: reposition to currentKey+0x01 for every row.
                        // Before the fix every reposition re-fetched a full batch: 38 fetches here.
                        final long fetchesBefore = impl.fetchCount;
                        int rows = 1; // standing on key01
                        while (cursor.positionToKeyOrNext(
                                new ByteStringBuilder().appendBytes(cursor.getKey()).appendByte(0x01).toByteString())) {
                            rows++;
                        }
                        assertEquals(rows, 39); // key01..key39
                        assertTrue(impl.fetchCount - fetchesBefore <= 8,
                                "sibling scan took " + (impl.fetchCount - fetchesBefore) + " fetches");
                    }
                    return null;
                }
            });
        } finally {
            System.clearProperty("org.openidentityplatform.opendj.jdbc.fetchsize");
            System.clearProperty("org.openidentityplatform.opendj.jdbc.fetchsize.initial");
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * A storage opened READ_ONLY must still hand out the write transaction {@code RootContainer.open()} asks for
     * there - otherwise the offline export-ldif, verify-index and backendstat fail before reading anything - and
     * that transaction must serve exactly what the open needs and nothing more: opening an existing tree, reads,
     * cursors and record counts, while every mutation, including a delete through a cursor it opened, is
     * refused (#874).
     */
    @Test
    public void testReadOnlyTransactionReadsButRefusesWrites() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testReadOnlyTransaction", "tree");
        final TreeName absent = new TreeName("testReadOnlyTransaction", "absent");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    txn.put(tree, key(0), value(0));
                    txn.put(tree, key(1), value(1));
                }
            });
            storage.close();
 
            storage.open(AccessMode.READ_ONLY);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    // what RootContainer.open() does through this transaction in read-only mode
                    txn.openTree(tree, false);
                    assertEquals(txn.read(tree, key(0)), value(0));
                    assertEquals(txn.getRecordCount(tree), 2);
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(0));
                        try {
                            cursor.delete();
                            fail("delete() through a cursor of a read-only transaction must fail");
                        } catch (UnsupportedOperationException expected) {}
                    }
 
                    assertReadOnly("openTree(createOnDemand)", () -> txn.openTree(absent, true));
                    assertReadOnly("put", () -> txn.put(tree, key(2), value(2)));
                    assertReadOnly("update", () -> txn.update(tree, key(0), old -> value(3)));
                    assertReadOnly("delete", () -> txn.delete(tree, key(0)));
                    assertReadOnly("deleteTree", () -> txn.deleteTree(tree));
                }
            });
 
            // nothing above reached the database
            storage.close();
            storage.open(AccessMode.READ_WRITE);
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    assertEquals(txn.getRecordCount(tree), 2);
                    assertEquals(txn.read(tree, key(0)), value(0));
                    return null;
                }
            });
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    private static void assertReadOnly(String operation, Runnable mutation) {
        try {
            mutation.run();
            fail(operation + " must fail on a read-only storage");
        } catch (ReadOnlyStorageException expected) {}
    }
 
    /** Buffer-served repositioning relies on the database collating keys in unsigned byte order. */
    @Test
    public void testCursorKeyOrderIsUnsigned() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testCursorOrder", "tree");
        final ByteString low = ByteString.valueOfBytes(new byte[] { 0x7F });
        final ByteString high = ByteString.valueOfBytes(new byte[] { (byte) 0x80, 0x01 });
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    txn.put(tree, low, value(1));
                    txn.put(tree, high, value(2));
                }
            });
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        // with a signed collation 0x80 would sort before 0x7F and these would fail
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), low);
                        assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfBytes(new byte[] { (byte) 0x80 })));
                        assertEquals(cursor.getKey(), high);
                        assertFalse(cursor.next());
                        assertTrue(cursor.positionToLastKey());
                        assertEquals(cursor.getKey(), high);
                    }
                    return null;
                }
            });
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * Each table must be stamped with the tree name it stores: table names are opaque SHA-224
     * hashes, so without the comment there is no way to tell the trees apart on the database
     * side (#859). The single quote in the base DN exercises the comment escaping.
     */
    @Test
    public void testTreeNameStoredAsTableComment() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        // a quote and a backslash in the tree name exercise the literal escaping (backslash is an escape character in mysql)
        final TreeName tree = new TreeName("o=comment'te\\st", "dn2id");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            assertEquals(readTableComment(storage.getTableName(tree)), tree.toString());
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    String readTableComment(String tableName) throws Exception {
        final String url = getJdbcUrl();
        final String sql;
        if (url.startsWith("jdbc:postgresql")) {
            sql = "select obj_description('" + tableName + "'::regclass, 'pg_class')";
        } else if (url.startsWith("jdbc:mysql")) {
            sql = "select table_comment from information_schema.tables where table_schema=database() and table_name='" + tableName + "'";
        } else if (url.startsWith("jdbc:oracle")) {
            sql = "select comments from user_tab_comments where table_name='" + tableName.toUpperCase() + "'";
        } else if (url.startsWith("jdbc:sqlserver")) {
            // class=1 is the table itself: major_id is only unique within a class
            sql = "select cast(value as nvarchar(4000)) from sys.extended_properties where class=1 and major_id=object_id('" + tableName + "') and minor_id=0 and name='MS_Description'";
        } else {
            throw new SkipException("no table comment query for " + url);
        }
        try (final Connection con = DriverManager.getConnection(url);
             final Statement st = con.createStatement();
             final ResultSet rs = st.executeQuery(sql)) {
            return rs.next() ? rs.getString(1) : null;
        }
    }
 
    void writeTableComment(String tableName, String comment) throws Exception {
        final String url = getJdbcUrl();
        final String sql;
        if (url.startsWith("jdbc:postgresql") || url.startsWith("jdbc:oracle")) {
            sql = "comment on table " + tableName + " is '" + comment + "'";
        } else if (url.startsWith("jdbc:mysql")) {
            sql = "alter table " + tableName + " comment '" + comment + "'";
        } else if (url.startsWith("jdbc:sqlserver")) {
            // exec arguments must be constants or variables: schema_name() cannot be passed inline
            sql = "declare @s sysname = schema_name()"
                + " exec sys.sp_updateextendedproperty N'MS_Description', N'" + comment + "', N'SCHEMA', @s, N'TABLE', N'" + tableName + "'";
        } else {
            throw new SkipException("no table comment statement for " + url);
        }
        try (final Connection con = DriverManager.getConnection(url);
             final Statement st = con.createStatement()) {
            st.execute(sql);
        }
    }
 
    /**
     * Removes the stored comment, so that the next stamp has to create one rather than replace
     * it: on sql server that is sp_addextendedproperty, which is the statement reported to wait
     * for an uncommitted row of another session.
     */
    void clearTableComment(String tableName) throws Exception {
        final String url = getJdbcUrl();
        if (!url.startsWith("jdbc:sqlserver")) {
            writeTableComment(tableName, "stale"); // the other engines have one statement for both cases
            return;
        }
        try (final Connection con = DriverManager.getConnection(url);
             final Statement st = con.createStatement()) {
            st.execute("declare @s sysname = schema_name()"
                + " exec sys.sp_dropextendedproperty N'MS_Description', N'SCHEMA', @s, N'TABLE', N'" + tableName + "'");
        }
    }
 
    /** The dialect of the database this suite runs against, as the backend detects it from the driver. */
    JDBCStorage.Dialect dialect() {
        final String url = getJdbcUrl();
        if (url.startsWith("jdbc:postgresql")) {
            return JDBCStorage.Dialect.POSTGRES;
        } else if (url.startsWith("jdbc:mysql")) {
            return JDBCStorage.Dialect.MYSQL;
        } else if (url.startsWith("jdbc:oracle")) {
            return JDBCStorage.Dialect.ORACLE;
        } else if (url.startsWith("jdbc:sqlserver")) {
            return JDBCStorage.Dialect.MICROSOFT;
        }
        throw new SkipException("no dialect for " + url);
    }
 
    /**
     * What this connection reports as its lock bound, in the unit and the rendering of its own
     * engine, or null where reading it needs a privilege the test user does not have: oracle
     * keeps ddl_lock_timeout in v$parameter, which an application user cannot select from.
     */
    String sessionLockBound(Connection con) throws Exception {
        final String url = getJdbcUrl();
        final String sql;
        if (url.startsWith("jdbc:postgresql")) {
            sql = "show lock_timeout";
        } else if (url.startsWith("jdbc:mysql")) {
            sql = "select @@session.lock_wait_timeout";
        } else if (url.startsWith("jdbc:sqlserver")) {
            sql = "select @@lock_timeout";
        } else {
            return null;
        }
        try (final Statement st = con.createStatement();
             final ResultSet rs = st.executeQuery(sql)) {
            return rs.next() ? rs.getString(1) : null;
        }
    }
 
    /**
     * Comment statements are DDL (a metadata lock on mysql, a ddl lock on oracle), so a table
     * whose stored comment already matches its tree name must not be re-stamped on subsequent
     * opens - while a stale comment must be refreshed.
     */
    @Test
    public void testCommentStampSkippedWhenAlreadyStored() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("o=commentSkip", "dn2id");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true); // stamps the freshly created table
                }
            });
            assertEquals(readTableComment(storage.getTableName(tree)), tree.toString());
            // UP_TO_DATE and not FAILED: the statement was skipped, not rejected
            assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.UP_TO_DATE, "an up-to-date comment was re-stamped");
            writeTableComment(storage.getTableName(tree), "stale");
            assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.STAMPED, "a stale comment was not re-stamped");
            assertEquals(readTableComment(storage.getTableName(tree)), tree.toString());
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * A stamp must never queue behind another session's transaction. Comment statements take a
     * lock (a metadata lock on mysql, a schema modification lock on sql server) and both engines
     * wait for it without limit by default - lock_wait_timeout is a year, lock_timeout is
     * infinite - so an unbounded stamp could hang the backend open and, on mysql, park every
     * other query on that table behind itself. Whether an uncommitted row of another session
     * conflicts with the statement at all differs between engines and versions, so the assertion
     * is on the timing: the call comes back rather than waiting for that transaction to end.
     */
    @Test(timeOut = 180000)
    public void testCommentStampGivesUpOnLock() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("o=commentLock", "dn2id");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            final String tableName = storage.getTableName(tree);
            clearTableComment(tableName); // no comment stored: the next attempt must issue a statement
            try (final Connection blocker = DriverManager.getConnection(getJdbcUrl())) {
                blocker.setAutoCommit(false);
                try (final PreparedStatement st = blocker.prepareStatement("insert into " + tableName + " (h,k) values (?,?)")) {
                    st.setString(1, String.format("%1$-128s", "blocker").replace(' ', 'x'));
                    st.setBytes(2, new byte[]{1});
                    st.executeUpdate();
                }
                // the row is left uncommitted, so the lock it holds is still there
                final long start = System.currentTimeMillis();
                final JDBCStorage.CommentResult result = storage.commentTable(tree, dialect());
                final long elapsedMs = System.currentTimeMillis() - start;
                blocker.rollback();
                // giving up and stamping anyway are both fine here - the engines differ in whether an
                // uncommitted row of another session conflicts with the comment statement at all.
                // Waiting for that session to finish is what must never happen.
                // the bound is 5 s (COMMENT_LOCK_TIMEOUT_SECONDS): the slack is for the connect and the
                // statement around it, not for a regression of the bound itself
                assertTrue(elapsedMs < 20000, "the comment statement waited " + elapsedMs + " ms for a lock, result " + result);
                if (result == JDBCStorage.CommentResult.STAMPED) { // it reported success: the comment must be there
                    assertEquals(readTableComment(tableName), tree.toString());
                }
            }
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * A failing comment stamp must never disturb the transaction that opened the tree: it used
     * to roll back the caller's connection, silently discarding writes pending in the same
     * transaction (the way DefaultIndex.afterOpen() writes the trusted flag between openTree() calls).
     * The write pending during the failing stamp deliberately targets another tree: a statement
     * left pending on the very table being stamped - a write, or on mysql any statement, since a
     * transaction holds a shared metadata lock on every table it touched - would make the comment
     * statement wait for the caller's own lock, which is a shape no production path has.
     */
    @Test
    public void testCommentFailureLeavesTransactionIntact() throws Exception {
        final TreeName stamped = new TreeName("o=commentFailure", "dn2id");
        final TreeName written = new TreeName("o=commentFailure", "id2entry");
        final JDBCStorage setUp = new JDBCStorage(createBackendCfg(), null);
        try { // create both tables up front, with a storage that stamps them normally
            setUp.open(AccessMode.READ_WRITE);
            setUp.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(stamped, true);
                    txn.openTree(written, true);
                }
            });
        } finally {
            setUp.close();
        }
        final AtomicInteger stampAttempts = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            Connection newStampConnection(Dialect dialect) throws SQLException {
                stampAttempts.incrementAndGet();
                throw new SQLException("injected comment failure"); // no sql state, no vendor code: a rejection, not a failure of the moment
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    // pending in this transaction, and it has to still be pending when the stamp is
                    // attempted: both trees were enrolled by the storage above, so the openTree below
                    // records nothing in the catalog and commits nothing of what is written here
                    txn.put(written, key(1), value(1));
                    txn.openTree(stamped, true); // ...while the comment machinery fails
                }
            });
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    assertEquals(txn.read(written, key(1)), value(1), "failing comment stamp discarded a pending write");
                    return null;
                }
            });
            // the failure is remembered: an unstampable table is not asked again while this backend is open.
            // Counted from what the open itself attempted rather than from one: the open stamps the tree and
            // the catalog of the backend, and how many tables an open has to stamp is not what this is about
            final int attemptsOfTheOpen = stampAttempts.get();
            assertEquals(storage.commentTable(stamped, dialect()), JDBCStorage.CommentResult.FAILED);
            assertEquals(stampAttempts.get(), attemptsOfTheOpen, "a failed stamp was reissued");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(stamped);
                        txn.deleteTree(written);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * A stamp that failed for a reason of the moment - the lock timeout the statement is given,
     * a connection that broke - must be attempted again: only a failure saying that this table
     * cannot be commented at all is remembered, or one contended moment would leave a backend
     * unstamped until it is restarted.
     */
    @Test
    public void testTransientStampFailureIsRetried() throws Exception {
        final TreeName tree = new TreeName("o=transientStamp", "dn2id");
        final AtomicInteger stampAttempts = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            Connection newStampConnection(Dialect dialect) throws SQLException {
                stampAttempts.incrementAndGet();
                throw new SQLException("injected connection failure", "08006"); // connection exception: a failure of the moment
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true); // stamp #1, fails
                }
            });
            assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.FAILED);
            assertEquals(stampAttempts.get(), 2, "a stamp that failed for a reason of the moment was not attempted again");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * Opening a backend opens every tree it holds - about 25 for a stock suffix - and the first
     * open after an upgrade stamps them all: the trees of one open must share one connection
     * rather than make a physical connect each. One per open is what the comment machinery costs,
     * readback included - the readback runs on that same connection, because the thread doing the
     * open is inside a transaction and holding a pooled connection already.
     */
    @Test
    public void testCommentStampsShareOneConnection() throws Exception {
        final TreeName[] trees = {
            new TreeName("o=commentSweep", "dn2id"),
            new TreeName("o=commentSweep", "id2entry"),
            new TreeName("o=commentSweep", "state") };
        final AtomicInteger connects = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            Connection newStampConnection(Dialect dialect) throws SQLException {
                connects.incrementAndGet();
                return super.newStampConnection(dialect);
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true); // freshly created: every one of them is stamped
                    }
                }
            });
            for (final TreeName tree : trees) {
                assertEquals(readTableComment(storage.getTableName(tree)), tree.toString());
            }
            assertEquals(connects.get(), 1, "the stamps of one open did not share a connection");
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true);
                    }
                }
            });
            // three trees, one more connect: the open that finds every comment in place issues no
            // statement and takes no lock, and pays one connection for the whole sweep either way
            assertEquals(connects.get(), 2, "the trees of an open that found every comment in place did not share a connection");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        for (final TreeName tree : trees) {
                            txn.deleteTree(tree);
                        }
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * The bound a stamp connection is given must survive a stamp that failed. Postgres undoes a
     * plain SET when the transaction that ran it is rolled back, and a failed stamp is rolled
     * back with the connection kept and reused - one connection serves every tree of a backend
     * open - so every tree stamped after the first failure used to run with no bound at all,
     * which is what the bound exists to prevent.
     */
    @Test
    public void testLockBoundSurvivesAFailedStamp() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        // no table was ever created for this tree, so its comment statement fails - on a connection
        // that stays usable, which is the case the session rolls back rather than replaces
        final TreeName missing = new TreeName("o=lockBound", "neverCreated");
        try {
            storage.open(AccessMode.READ_WRITE);
            final JDBCStorage.Dialect dialect = dialect();
            final String bound;
            try (final Connection fresh = storage.newStampConnection(dialect)) {
                bound = sessionLockBound(fresh); // what a connection carrying the bound reports
            }
            try (final JDBCStorage.StampSession session = storage.new StampSession()) {
                assertEquals(storage.commentTable(missing, dialect, session), JDBCStorage.CommentResult.FAILED,
                    "stamping a table that does not exist was reported as done");
                if (bound != null) { // oracle: ddl_lock_timeout is only in v$parameter, which the test user cannot read
                    assertEquals(sessionLockBound(session.connection(dialect)), bound,
                        "the lock bound was lost when the failed stamp was rolled back");
                }
            }
        } finally {
            storage.close();
        }
    }
 
    /**
     * A stamp that lost its connection ends the sweep it happened in: every tree behind it needs
     * that same connection, so each would pay the same connect attempt again. That is about 25 of
     * them for a stock suffix, all for a diagnostic aid. Nothing is remembered, so the next open
     * tries again.
     */
    @Test
    public void testConnectionFailureEndsTheSweep() throws Exception {
        final TreeName[] trees = {
            new TreeName("o=sweepGiveUp", "dn2id"),
            new TreeName("o=sweepGiveUp", "id2entry"),
            new TreeName("o=sweepGiveUp", "state") };
        final AtomicInteger stampAttempts = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            Connection newStampConnection(Dialect dialect) throws SQLException {
                stampAttempts.incrementAndGet();
                throw new SQLException("injected connection failure", "08006"); // connection exception: the session is gone
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true);
                    }
                }
            });
            assertEquals(stampAttempts.get(), 1, "a connection that was gone was paid once per tree of the same open");
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true);
                    }
                }
            });
            assertEquals(stampAttempts.get(), 2, "the open after a lost connection did not try again");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        for (final TreeName tree : trees) {
                            txn.deleteTree(tree);
                        }
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * A lock belongs to the table it is held on, so a stamp that gave up on one must not cost the
     * trees behind it their comments: the trees of an open are stamped in a fixed order, and a
     * table left permanently contended by another session would otherwise mean nothing is ever
     * stamped, on any open. Nothing is remembered either - the open that follows stamps the table
     * whose moment has passed.
     * <p>
     * The failure is injected at the readback rather than at the comment statement, which is built
     * inline; what is under test is the classification of the failure and what the sweep does with
     * it, and those do not depend on which of the two statements produced it.
     */
    @Test
    public void testContendedTableDoesNotEndTheSweep() throws Exception {
        final TreeName[] trees = {
            new TreeName("o=sweepContended", "dn2id"),
            new TreeName("o=sweepContended", "id2entry"),
            new TreeName("o=sweepContended", "state") };
        final AtomicInteger contended = new AtomicInteger(1); // the first tree, for one sweep only
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            String readStoredComment(Connection con, Dialect dialect, String tableName) throws SQLException {
                if (tableName.equals(getTableName(trees[0])) && contended.getAndDecrement() > 0) {
                    throw lockTimeoutOf(dialect); // as if another session held this one table
                }
                return super.readStoredComment(con, dialect, tableName);
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true);
                    }
                }
            });
            assertNotEquals(readTableComment(storage.getTableName(trees[0])), trees[0].toString(),
                "the contended table was stamped anyway");
            for (int i = 1; i < trees.length; i++) {
                assertEquals(readTableComment(storage.getTableName(trees[i])), trees[i].toString(),
                    "one contended table cost the trees behind it their comments");
            }
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true);
                    }
                }
            });
            assertEquals(readTableComment(storage.getTableName(trees[0])), trees[0].toString(),
                "a table left unstamped by a contended moment was not stamped by the open that followed");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        for (final TreeName tree : trees) {
                            txn.deleteTree(tree);
                        }
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /** The failure a dialect reports when a statement gave up on the lock bound it was given. */
    static SQLException lockTimeoutOf(JDBCStorage.Dialect dialect) {
        switch (dialect) {
        case POSTGRES:
            return new SQLException("canceling statement due to lock timeout", "55P03");
        case MYSQL:
            return new SQLException("Lock wait timeout exceeded; try restarting transaction", "HY000", 1205);
        case ORACLE:
            return new SQLException("ORA-00054: resource busy and acquire with NOWAIT specified", "61000", 54);
        case MICROSOFT:
            return new SQLException("Lock request time out period exceeded", "HY000", 1222);
        default:
            throw new IllegalStateException("no lock timeout failure for dialect " + dialect);
        }
    }
 
    /**
     * @@sql_mode decides whether a backslash escapes inside the comment literal. It belongs to
     * the session, and the stamps of one open share a connection, so it is asked once for the
     * whole sweep rather than once per tree - and only on mysql, the one engine whose literal
     * depends on it.
     */
    @Test
    public void testSqlModeProbedOncePerSweep() throws Exception {
        final TreeName[] trees = {
            new TreeName("o=sqlModeProbe", "dn2id"),
            new TreeName("o=sqlModeProbe", "id2entry"),
            new TreeName("o=sqlModeProbe", "state") };
        final AtomicInteger probes = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            boolean isMysqlBackslashEscape(Connection con) throws SQLException {
                probes.incrementAndGet();
                return super.isMysqlBackslashEscape(con);
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    for (final TreeName tree : trees) {
                        txn.openTree(tree, true); // freshly created: every one of them is stamped
                    }
                }
            });
            for (final TreeName tree : trees) {
                assertEquals(readTableComment(storage.getTableName(tree)), tree.toString());
            }
            assertEquals(probes.get(), dialect() == JDBCStorage.Dialect.MYSQL ? 1 : 0,
                "the sql mode of one sweep was not asked exactly once");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        for (final TreeName tree : trees) {
                            txn.deleteTree(tree);
                        }
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    // The bounds of a stamp connection are covered by StampConnectionTestCase: what they are worth
    // is whether they reach the driver and whether the driver then gives up on a server that never
    // answers, and neither needs - nor can be staged by - a database container.
 
    /**
     * An import that failed or was cancelled leaves trees holding an incomplete import that is
     * going to be run again: refreshing statistics of it describes data nobody will query, and on
     * oracle it is a full scan per table between the failure and its report.
     */
    @Test
    public void testAbortedImportSkipsStatistics() throws Exception {
        final TreeName tree = new TreeName("o=abortedImport", "dn2id");
        final AtomicInteger refreshes = new AtomicInteger();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) {
            @Override
            boolean updateTableStatistics(Connection con, Collection<TreeName> trees) {
                refreshes.incrementAndGet();
                return super.updateTableStatistics(con, trees);
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            try (final Importer importer = storage.startImport()) {
                importer.put(tree, key(1), value(1));
                importer.aborted(); // what OnDiskMergeImporter reports when the import throws or is cancelled
            }
            assertEquals(refreshes.get(), 0, "statistics were refreshed for an import that was aborted");
            try (final Importer importer = storage.startImport()) {
                importer.put(tree, key(2), value(2));
            }
            assertEquals(refreshes.get(), 1, "statistics were not refreshed for an import that finished");
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * The statistics refresh must be possible to turn off: on oracle it gathers with
     * AUTO_SAMPLE_SIZE, a full scan of every table the import wrote.
     */
    @Test
    public void testStatisticsRefreshCanBeTurnedOff() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("o=statisticsOff", "dn2id");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            System.setProperty(JDBCStorage.STATISTICS_PROPERTY, "false");
            try (final Connection con = CachedConnection.getConnection(getJdbcUrl())) {
                assertFalse(storage.updateTableStatistics(con, Collections.singleton(tree)),
                    "the refresh ran with " + JDBCStorage.STATISTICS_PROPERTY + "=false");
            }
        } finally {
            System.clearProperty(JDBCStorage.STATISTICS_PROPERTY);
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /** deleteTree() must forget the tree: statistics refresh iterates known trees and must skip dropped tables. */
    @Test
    public void testDeleteTreeForgetsTree() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("o=deleteTree", "dn2id");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            assertTrue(storage.listTrees().contains(tree));
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.deleteTree(tree);
                }
            });
            assertFalse(storage.listTrees().contains(tree), "deleteTree() left the tree in the tree-to-table cache");
        } finally {
            storage.close();
        }
    }
 
    /** A bulk import must refresh optimizer statistics: fresh tables were never analyzed (#859). */
    @Test
    public void testImportRefreshesTableStatistics() throws Exception {
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testImportAnalyze", "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            suspendAutomaticStatistics(storage.getTableName(tree));
            try (final Importer importer = storage.startImport()) {
                for (int i = 0; i < 40; i++) {
                    importer.put(tree, key(i), value(i));
                }
            }
            assertTableStatisticsFresh(storage.getTableName(tree));
            // import swallows statistics failures by design: assert directly that the
            // dialect-specific refresh statement is accepted by this database
            try (final Connection con = CachedConnection.getConnection(getJdbcUrl())) {
                assertTrue(storage.updateTableStatistics(con, Collections.singleton(tree)), "statistics refresh reported failures");
            }
        } finally {
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * Suspends the automatic statistics upkeep of the engines that have it, so that what the
     * assertion below sees was produced by the refresh of the import and by nothing else: InnoDB
     * recalculates innodb_table_stats.n_rows on its own (innodb_stats_auto_recalc is on by
     * default), which would let the assertion pass with no "analyze table" ever issued.
     */
    void suspendAutomaticStatistics(String tableName) throws Exception {
        final String url = getJdbcUrl();
        if (!url.startsWith("jdbc:mysql")) {
            return; // nothing refreshes what is asserted below on the other engines within a test run
        }
        try (final Connection con = DriverManager.getConnection(url);
             final Statement st = con.createStatement()) {
            st.execute("alter table " + tableName + " stats_auto_recalc=0");
        }
    }
 
    void assertTableStatisticsFresh(String tableName) throws Exception {
        final String url = getJdbcUrl();
        final String sql;
        if (url.startsWith("jdbc:postgresql")) {
            // reltuples stays -1/0 until the first ANALYZE
            sql = "select reltuples::bigint from pg_class where relname='" + tableName + "'";
        } else if (url.startsWith("jdbc:oracle")) {
            // num_rows stays null until dbms_stats gathers statistics
            sql = "select num_rows from user_tables where table_name='" + tableName.toUpperCase() + "'";
        } else if (url.startsWith("jdbc:mysql")) {
            // n_rows in the persistent stats table is refreshed by ANALYZE TABLE, and - with the
            // automatic recalculation suspended above - by nothing else: it stays 0 without it
            sql = "select n_rows from mysql.innodb_table_stats where database_name=database() and table_name='" + tableName + "'";
        } else if (url.startsWith("jdbc:sqlserver")) {
            // last_updated stays null until the first UPDATE STATISTICS
            sql = "select count(*) from sys.stats s cross apply sys.dm_db_stats_properties(s.object_id, s.stats_id) p"
                + " where s.object_id=object_id('" + tableName + "') and p.last_updated is not null";
        } else {
            throw new SkipException("no statistics query for " + url);
        }
        try (final Connection con = DriverManager.getConnection(url);
             final Statement st = con.createStatement();
             final ResultSet rs = st.executeQuery(sql)) {
            assertTrue(rs.next(), "table " + tableName + " not found");
            final long rows = rs.getLong(1);
            assertFalse(rs.wasNull(), "statistics were never gathered for " + tableName);
            assertTrue(rows > 0, "statistics of " + tableName + " look stale: " + rows);
        }
    }
 
    /** Cursor operations must keep working when the tree spans several "fetchsize" batches. */
    @Test
    public void testCursorCrossesFetchSizeBatches() throws Exception {
        System.setProperty("org.openidentityplatform.opendj.jdbc.fetchsize", "2");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testCursorBatch", "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    for (int i = 0; i < 7; i++) {
                        txn.put(tree, key(i), value(i));
                    }
                }
            });
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        for (int i = 0; i < 7; i++) {
                            assertTrue(cursor.next(), "next() at " + i);
                            assertEquals(cursor.getKey(), key(i));
                            assertEquals(cursor.getValue(), value(i));
                        }
                        assertFalse(cursor.next());
                        assertFalse(cursor.isDefined());
                        try {
                            cursor.getKey();
                            fail("getKey() on undefined cursor must fail");
                        } catch (NoSuchElementException expected) {}
 
                        assertTrue(cursor.positionToKeyOrNext(key(3)));
                        assertEquals(cursor.getKey(), key(3));
                        assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("key031")));
                        assertEquals(cursor.getKey(), key(4));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.next());
                        assertFalse(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("z")));
                        assertFalse(cursor.isDefined());
 
                        assertTrue(cursor.positionToKey(key(5)));
                        assertEquals(cursor.getValue(), value(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.positionToKey(ByteString.valueOfUtf8("key99")));
                        assertFalse(cursor.isDefined());
 
                        assertTrue(cursor.positionToIndex(0));
                        assertEquals(cursor.getKey(), key(0));
                        assertTrue(cursor.positionToIndex(5));
                        assertEquals(cursor.getKey(), key(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.positionToIndex(7));
                        assertFalse(cursor.positionToIndex(-1));
 
                        assertTrue(cursor.positionToLastKey());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.next());
 
                        assertTrue(cursor.positionToKey(key(0)));
                        try {
                            cursor.delete();
                            fail("delete() on read-only cursor must fail");
                        } catch (UnsupportedOperationException expected) {}
                    }
                    return null;
                }
            });
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        assertTrue(cursor.positionToKey(key(3)));
                        cursor.delete();
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(4));
                    }
                    assertNull(txn.read(tree, key(3)));
                    assertEquals(txn.getRecordCount(tree), 6);
                }
            });
        } finally {
            System.clearProperty("org.openidentityplatform.opendj.jdbc.fetchsize");
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
    /**
     * Two or more <em>distinct new</em> keys written into one tree per transaction is the shape the primary key
     * seek made able to deadlock: on the NOT MATCHED path the seek range-locks the gap before the next existing
     * key, that lock is self-incompatible, and the key hash scatters logically ordered keys across the index, so
     * two writers inserting different keys can each end up holding what the other needs. The ascending key order
     * that {@code IndexBuffer} maintains does not help there. Nothing may escape {@link JDBCStorage#write}, which
     * replays the conflict, and no record may be lost to it (#867).
     */
    @Test(timeOut = 600000)
    public void testConcurrentWritersInsertingDistinctKeys() throws Exception {
        final int writers = 4;
        final int rounds = 25;
        final int keysPerTransaction = 3;
        final int seeded = 10;
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testConcurrentInsert", "tree");
        final ExecutorService executor = Executors.newFixedThreadPool(writers);
        try {
            storage.open(AccessMode.READ_WRITE);
            // seeded, so that every insert below takes the NOT MATCHED path with a gap to lock in front of it
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    for (int i = 0; i < seeded; i++) {
                        txn.put(tree, key(i), value(i));
                    }
                }
            });
            final List<Callable<Void>> concurrent = new ArrayList<>();
            for (int writer = 0; writer < writers; writer++) {
                final int id = writer;
                concurrent.add(new Callable<Void>() {
                    @Override
                    public Void call() throws Exception {
                        for (int round = 0; round < rounds; round++) {
                            final int current = round;
                            storage.write(new WriteOperation() {
                                @Override
                                public void run(WriteableTransaction txn) throws Exception {
                                    for (int i = 0; i < keysPerTransaction; i++) {
                                        txn.put(tree,
                                                ByteString.valueOfUtf8(String.format("w%02d-r%03d-k%d", id, current, i)),
                                                value(i));
                                    }
                                }
                            });
                        }
                        return null;
                    }
                });
            }
            for (final Future<Void> written : executor.invokeAll(concurrent)) {
                // a conflict the storage did not replay surfaces here, as it would reach an LDAP client
                written.get();
            }
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    assertEquals(txn.getRecordCount(tree), seeded + writers * rounds * keysPerTransaction);
                    return null;
                }
            });
        } finally {
            executor.shutdownNow();
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
 
    /**
     * removeStorageFiles() has to clear a backend this process has never opened: offline import-ldif
     * configures the backend and calls it before anything opens the root container, so answering from
     * the trees this process happens to have touched dropped nothing at all - an offline
     * "import-ldif --clearBackend" cleared a JDBC backend of nothing (#888).
     */
    @Test
    public void testABackendIsClearedByAProcessThatNeverOpenedIt() throws Exception {
        final TreeName tree = new TreeName("testOfflineClear", "tree");
        // the neighbour serves a base DN of its own, so that its table is one it reports as its own:
        // what this case asserts of the clear next door is then an absence and not a vacuity
        final DN neighbourBaseDN = DN.valueOf("dc=offline-clear-neighbour,dc=com");
        final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "tree");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null);
        final JDBCStorage neighbour =
            new JDBCStorage(createBackendCfg(getBackendId() + "_neighbour", neighbourBaseDN), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    txn.put(tree, key(1), value(1));
                }
            });
            neighbour.open(AccessMode.READ_WRITE);
            neighbour.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(neighbourTree, true);
                    txn.put(neighbourTree, key(1), value(1));
                }
            });
        } catch (Exception e) {
            // the clears of the case below are reached by no failure of this half, and nothing but
            // @BeforeClass ever drops what it leaves behind
            clearQuietly(storage);
            clearQuietly(neighbour);
            throw e;
        } finally {
            storage.close();
            neighbour.close();
        }
 
        // configured and never opened, nothing touched: what BackendImpl.importLDIF holds offline
        final JDBCStorage offline = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null);
        try {
            assertTrue(offline.listTrees().contains(tree),
                "the tree of a backend this process never opened has to be named by its catalog");
 
            offline.removeStorageFiles();
 
            assertFalse(isExistsTable(offline.getTableName(tree)), "the table of the tree survived the clear");
            assertFalse(isExistsTable(offline.getTableName(offline.getCatalogTree())), "the catalog survived the clear");
            final Set<TreeName> cleared = offline.listTrees();
            assertFalse(cleared.contains(tree), "a cleared backend still names its tree");
            assertFalse(cleared.contains(offline.getCatalogTree()), "a cleared backend still names its catalog");
            // the neighbour is named by a catalog of its own: what one backend clears is never another's
            assertTrue(isExistsTable(neighbour.getTableName(neighbourTree)),
                "the clear of one backend dropped the table of another backend of the same database");
            // nor does it report another backend's tables as tables of its own: a table is named after
            // the hash of its tree name and says nothing about whose it is, but it is stamped with that
            // tree name (#866), and the neighbour's trees are trees of no base DN this backend serves
            assertReportsNothingOf(offline, neighbour, neighbourTree);
        } finally {
            // in a finally of their own: a failed assertion above must not leave the tables of either
            // backend behind for the rest of the class, which nothing but @BeforeClass ever drops
            clearQuietly(neighbour);
            clearQuietly(offline);
        }
    }
 
    /**
     * A dropped tree has to leave the catalog together with its table: a row outliving its table
     * would make backendstat name a tree that is not there, and would put a table that is already
     * gone up for removal (#888).
     */
    @Test
    public void testADeletedTreeIsNoLongerNamedByTheCatalog() throws Exception {
        final TreeName kept = new TreeName("testCatalogDelete", "kept");
        final TreeName dropped = new TreeName("testCatalogDelete", "dropped");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_deleted"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(kept, true);
                    txn.openTree(dropped, true);
                }
            });
            final Set<TreeName> opened = storage.listTrees();
            assertTrue(opened.contains(kept) && opened.contains(dropped), "an opened tree is not named by the catalog");
 
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.deleteTree(dropped);
                }
            });
 
            final Set<TreeName> remaining = storage.listTrees();
            assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
            assertFalse(remaining.contains(dropped), "the catalog still names a tree that was deleted");
            // and the removal that follows must not stumble over the tree it no longer names
            storage.removeStorageFiles();
            assertFalse(isExistsTable(storage.getTableName(kept)), "the table of the tree survived the clear");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * A row of the catalog whose table is not there any more must not fail the clear, and must not
     * stop it dropping the rest. Nothing of the backend leaves such a row behind - deleteTree() takes
     * it out in the commit that drops the table - but a table dropped by hand, or a catalog restored
     * from a backup older than the database, leaves exactly this (#888).
     */
    @Test
    public void testAClearSkipsACatalogRowWhoseTableIsGone() throws Exception {
        final TreeName kept = new TreeName("testStaleCatalogRow", "kept");
        final TreeName vanished = new TreeName("testStaleCatalogRow", "vanished");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_stale"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(kept, true);
                    txn.openTree(vanished, true);
                }
            });
            dropTableBehindTheBackend(storage.getTableName(vanished));
            assertTrue(storage.listTrees().contains(vanished),
                "the catalog was expected to go on naming the tree whose table was dropped behind its back");
 
            storage.removeStorageFiles();
 
            assertFalse(isExistsTable(storage.getTableName(kept)),
                "a row of the catalog whose table is gone stopped the clear dropping the rest");
            assertFalse(isExistsTable(storage.getTableName(storage.getCatalogTree())), "the catalog survived the clear");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * Naming a tree in order to read it must never put it up for removal: the tree read may be held
     * by another backend of the same database, which nothing forbids (#873). Only
     * openTree(createOnDemand) enrols.
     */
    @Test
    public void testReadingATreeDoesNotPutItUpForRemoval() throws Exception {
        final TreeName owned = new TreeName("testReadDoesNotEnrol", "owned");
        final TreeName foreign = new TreeName("testReadDoesNotEnrolForeign", "tree");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reader"), null);
        final JDBCStorage owner = new JDBCStorage(createBackendCfg(getBackendId() + "_owner"), null);
        try {
            owner.open(AccessMode.READ_WRITE);
            owner.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(foreign, true);
                    txn.put(foreign, key(1), value(1));
                }
            });
 
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(owned, true); // the catalog of this backend comes into being here
                    txn.openTree(foreign, false); // read, not owned
                }
            });
            assertEquals(storage.read(new ReadOperation<ByteString>() {
                @Override
                public ByteString run(ReadableTransaction txn) throws Exception {
                    return txn.read(foreign, key(1));
                }
            }), value(1), "the tree of the other backend could not be read");
 
            assertFalse(storage.listTrees().contains(foreign), "reading a tree enrolled it in the catalog");
            storage.removeStorageFiles();
            assertTrue(isExistsTable(owner.getTableName(foreign)),
                "the clear dropped a tree this backend had only read");
            assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear");
        } finally {
            clearQuietly(owner);
            clearQuietly(storage);
        }
    }
 
    /**
     * The compressed schema trees named from a literal carry no backend qualifier, so on a database
     * addressed by several backends they are the same pair for all of them: a clear must leave them
     * where they lie (#881). A tool asking a backend what trees it holds has to be shown them all the
     * same, which is what keeps them out of the catalog and inside listTrees().
     */
    @Test
    public void testTheSharedCompressedSchemaTreesAreNamedButNeverCleared() throws Exception {
        final TreeName owned = new TreeName("testSharedCompressedSchema", "owned");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_schema"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(owned, true);
                    // opened, never written to: since #881 no backend of this class makes the literal-named
                    // pair, so this openTree is what creates these two tables - and the finally below is what
                    // removes them again, a clear being required to leave them exactly where they lie
                    for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
                        txn.openTree(shared, true);
                    }
                }
            });
            // both of them: the pair is a hand-copy of two privates of PersistentCompressedSchema, and
            // a literal naming a tree that does not exist would go unseen if one of them were never asked
            // for - the tree it names would be neither shown by listTrees() nor spared by a clear
            final Set<TreeName> named = storage.listTrees();
            for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
                assertTrue(named.contains(shared),
                    "a tool asking this backend for its trees was not shown " + shared);
            }
 
            storage.removeStorageFiles();
 
            for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
                assertTrue(isExistsTable(JDBCStorage.toTableName(shared)),
                    "the clear dropped " + shared + ", which another backend of this database may be the only owner of");
            }
            assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear");
        } finally {
            // the pair is dropped by hand here, and by nothing of the backend: a clear must leave it
            // where it lies, which is the whole of what this case asserts. It is this case's to remove
            // because it is this case that made it - since #881 each backend keeps its definitions in a
            // pair of its own, so the literal-named pair belongs to no backend of this class any more
            // and the openTree above is what created these two tables. Left standing they would be a
            // legacy pair this database does not have, which
            // testCompressedSchemaTableIsQualifiedByBackendId asserts about and TestNG may run after
            // this case as easily as before it
            clearQuietly(storage);
            for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
                try {
                    dropTableBehindTheBackend(JDBCStorage.toTableName(shared));
                } catch (SQLException ignored) { // a case that failed before it made them leaves none to drop
                }
            }
        }
    }
 
    /**
     * The row of a deleted tree must not be left to the enclosing transaction: a terminal failure
     * later in it - write() replays a class 40 conflict and rethrows everything else - would roll the
     * row back over a table that is already gone, and nothing would put it right, a deleted tree not
     * being opened again (#888).
     */
    @Test
    public void testADeletedTreeStaysOutOfTheCatalogWhenItsTransactionFails() throws Exception {
        final TreeName kept = new TreeName("testCatalogDeleteRollback", "kept");
        final TreeName deleted = new TreeName("testCatalogDeleteRollback", "deleted");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_rollback"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(kept, true);
                    txn.openTree(deleted, true);
                }
            });
 
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(deleted);
                        // terminal, and no conflict for write() to replay: everything this transaction
                        // still owes goes back, and the row of the deleted tree must not be part of it
                        throw new IllegalStateException("the transaction of a deleteTree failed");
                    }
                });
                fail("the write was expected to fail");
            } catch (Exception expected) {
                // what the case is about is what the failure left behind
            }
 
            assertFalse(isExistsTable(storage.getTableName(deleted)), "the failed transaction brought a dropped table back");
            final Set<TreeName> remaining = storage.listTrees();
            assertFalse(remaining.contains(deleted),
                "the catalog names a tree whose table the failed transaction left dropped");
            assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * The same, for the branch a deleteTree takes when the table is not there any more: nothing is
     * dropped, so there is no commit of a drop for the row to be carried out of the catalog by, and
     * the commit the delete is given on the catalog's own connection is the whole of what takes it
     * out. Left to the enclosing transaction, the row would go back with it and the catalog would name
     * a tree with no table for good - the state a clear can only skip and report, never repair (#888).
     */
    @Test
    public void testADeletedTreeStaysOutOfTheCatalogWhenItsTableIsAlreadyGone() throws Exception {
        final TreeName kept = new TreeName("testCatalogDeleteNoTable", "kept");
        final TreeName deleted = new TreeName("testCatalogDeleteNoTable", "deleted");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_noTable"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(kept, true);
                    txn.openTree(deleted, true);
                }
            });
            // what an interrupted change of an earlier run leaves: a row of the catalog naming a table
            // that is not there any more. The deleteTree below therefore drops nothing at all
            dropTableBehindTheBackend(storage.getTableName(deleted));
 
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(deleted);
                        // terminal, and no conflict for write() to replay: everything this transaction
                        // still owes goes back, and the row of the deleted tree must not be part of it
                        throw new IllegalStateException("the transaction of a deleteTree failed");
                    }
                });
                fail("the write was expected to fail");
            } catch (Exception expected) {
                // what the case is about is what the failure left behind
            }
 
            final Set<TreeName> remaining = storage.listTrees();
            assertFalse(remaining.contains(deleted),
                "the catalog names a tree whose table was already gone when it was deleted");
            assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * The row of a tree whose table is already standing must not be left to the enclosing transaction
     * either. That is the open which fills the catalog of a backend upgraded from a version keeping
     * none: it creates no table, so nothing else of openTree() commits anything, and a transaction
     * failing after the enrolment would take the whole of it back - leaving a backend whose tables
     * are named by no catalog and whose next clear therefore drops nothing at all (#888).
     * <p>
     * The row is written and committed on a connection of the catalog's own, so this holds on every
     * engine for the same reason: nothing the caller's transaction does - or fails to do - reaches it.
     * On the branch before this one the row rode the caller's connection, and the case was green on
     * postgres for a reason of that engine alone (openTree() asks there for the cursor index of every
     * tree on every open and commits that, carrying the row with it) while the other three lost it.
     */
    @Test
    public void testAReopenedTreeStaysInTheCatalogWhenItsTransactionFails() throws Exception {
        final TreeName tree = new TreeName("testCatalogEnrolRollback", "tree");
        final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null);
        try { // the tables of the backend, made by a storage that then goes away
            setUp.open(AccessMode.READ_WRITE);
            setUp.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
        } finally {
            setUp.close();
        }
        // and the rest of what an installation upgraded to a version keeping a catalog holds: a
        // catalog naming none of those tables
        emptyTheCatalog(setUp.getTableName(setUp.getCatalogTree()));
 
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            assertFalse(storage.listTrees().contains(tree), "the catalog of the case was not emptied");
 
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        // the table is there, so this open creates none: the enrolment is the only thing
                        // this transaction has written when it fails
                        txn.openTree(tree, true);
                        // terminal, and no conflict for write() to replay: everything this transaction
                        // still owes goes back, and the row naming a standing table must not be part of it
                        throw new IllegalStateException("the transaction of an openTree failed");
                    }
                });
                fail("the write was expected to fail");
            } catch (Exception expected) {
                // what the case is about is what the failure left behind
            }
 
            assertTrue(storage.listTrees().contains(tree),
                "the catalog forgot a tree whose table is standing: a clear of this backend would drop nothing");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * A tree the catalog already records at the table this version records it at is not enrolled
     * again by an open: the row would be the row that is already there. A row recording any other
     * table is, though - a removal drops the table the row records, so a row naming one this backend
     * would not create leaves the real table standing, named by nothing and dropped by no clear ever
     * after. Which of the two a row is has to be decided by what it records and not by its presence.
     */
    @Test
    public void testARowRecordingAnotherTableIsEnrolledAgain() throws Exception {
        final TreeName tree = new TreeName("testCatalogStaleRow", "tree");
        final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null);
        try { // the table and its row, by a storage that then goes away
            setUp.open(AccessMode.READ_WRITE);
            setUp.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
        } finally {
            setUp.close();
        }
        final String catalogTable = setUp.getTableName(setUp.getCatalogTree());
        // what a version naming its tables otherwise would have left: a row of the right tree
        // recording a table this one would never create
        recordAnotherTable(catalogTable, "opendj_00000000000000000000000000000000000000000000000000000000");
 
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
 
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                assertEquals(
                    storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree),
                    storage.getTableName(tree),
                    "a row recording a table this backend does not hold was left as it was: its tree is named at a table no clear can drop");
            }
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * A clear which removed no tree of its backend says why, and says it where the one table it did
     * drop was its own catalog: a catalog standing over rows that name nothing - the state a backup
     * restored beside older tables leaves - is one drop and no tree removed, which is the outcome of
     * #888 exactly and not a clear that did something.
     * <p>
     * Decided on the drops of trees and not on every drop for that reason. Counted the other way the
     * line is silent here, since dropping the catalog makes the count one.
     */
    @Test
    public void testAClearWhichRemovedNoTreeSaysWhyEvenWhereItDroppedItsCatalog() throws Exception {
        final DN baseDN = DN.valueOf("dc=clear-catalog-only,dc=com");
        final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
        final ReportingStorage storage =
            new ReportingStorage(createBackendCfg(getBackendId() + "_catalogOnly", baseDN));
        final String catalogTable = storage.getTableName(storage.getCatalogTree());
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(owned, true);
                }
            });
            // the catalog table is there and names nothing, so the clear below has exactly one table to
            // drop - its own - and leaves the tree standing, named by nothing
            emptyTheCatalog(catalogTable);
            storage.close();
 
            storage.removeStorageFiles();
 
            assertFalse(isExistsTable(catalogTable), "the clear left its own catalog table standing");
            assertTrue(isExistsTable(storage.getTableName(owned)),
                "a table named by no catalog was dropped: nothing may be dropped that cannot be attributed");
            storage.assertReported("a clear which dropped its catalog and removed no tree of the backend"
                    + " said nothing about why, which is the silence of #888",
                "removed no tree of this backend", "has to be started once");
        } finally {
            clearQuietly(storage);
            // left standing on purpose above: its catalog is gone, so no clear of this backend names it
            dropTableIfExists(storage.getTableName(owned));
        }
    }
 
    /**
     * A row recording a name outside the namespace this backend names its tables in is passed over
     * rather than reaching a {@code drop table} built from a value read back out of a table - and the
     * clear accounts for it, no other line of its report being able to: what such a row records is
     * outside the {@code opendj} names the scan of what a clear left standing walks, and is dropped
     * by nothing. The row is not there to be read again either - the catalog names itself last, so
     * the clear drops that table with the row still in it - which is why the line is asserted here
     * along with the drop: it is the only surviving copy of what the row said.
     * <p>
     * Nothing this version writes makes such a row, which is why the case makes one by hand.
     */
    @Test
    public void testAClearAccountsForACatalogRowItCannotActOn() throws Exception {
        final TreeName tree = new TreeName("testCatalogForeignRow", "tree");
        final ReportingStorage storage = new ReportingStorage(createBackendCfg(getBackendId() + "_foreignRow"));
        final String tableName = storage.getTableName(tree);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            final String catalogTable = storage.getTableName(storage.getCatalogTree());
            recordAnotherTable(catalogTable, "a_table_of_something_else");
 
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                final List<String> skipped = new ArrayList<>();
                assertFalse(storage.readCatalogRows(con, catalogTable, skipped).containsKey(tree),
                    "a row recording a name no table of this backend goes by was read as a tree to drop");
                assertEquals(skipped.size(), 1, "the row the read passed over was not described to its caller: " + skipped);
                assertTrue(skipped.get(0).contains("a_table_of_something_else"),
                    "what the row records is named by nothing the clear could report: " + skipped);
            }
 
            // the clear still drops what it can: the catalog itself, which it names last
            storage.removeStorageFiles();
            assertTrue(isExistsTable(tableName),
                "the clear dropped the table of a tree its catalog names at another name than that table's");
            assertFalse(isExistsTable(catalogTable),
                "the clear left its own catalog table standing, so the row it passed over is still readable"
                    + " and the line reporting it is not the last copy of what it said");
            // the report itself and not the read behind it: reportSkippedRows() writes to nothing else,
            // so both of its call sites could be deleted and every assertion above would still hold
            storage.assertReported("the row the clear could not act on was reported by no line of it",
                "a_table_of_something_else", "passed over");
        } finally {
            clearQuietly(storage);
            // left standing on purpose above, so this case removes it rather than the next one meeting it
            dropTableIfExists(tableName);
        }
    }
 
    /**
     * A clear drops the table its catalog records for a tree, not one it derives again from the tree
     * name, so that a removal drops what was enrolled even if the naming of tables were ever to
     * change. A row recording no table at all - all a version recording the name alone would have
     * left - falls back to the derived name rather than naming nothing.
     */
    @Test
    public void testAClearDropsTheTableTheCatalogRecords() throws Exception {
        final TreeName tree = new TreeName("testCatalogValue", "tree");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_value"), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                }
            });
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                // asked the way a clear asks it, narrowed to where an unqualified name of the connection
                // resolves: what the removal reads is this and not a lookup of a shape of its own
                final Map<TreeName, String> recorded =
                    storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con));
                assertEquals(recorded.get(tree), storage.getTableName(tree),
                    "the catalog does not record the table holding the tree its row names");
 
                emptyTheRecordedTableNames(storage.getTableName(storage.getCatalogTree()));
                assertEquals(storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree),
                    storage.getTableName(tree),
                    "a row recording no table name did not fall back to the name derived from the tree");
            }
 
            storage.removeStorageFiles();
 
            assertFalse(isExistsTable(storage.getTableName(tree)), "the table the catalog named survived the clear");
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * A clear drops the catalog last, after every tree it names: what names the trees has to outlive
     * them. Dropping a table is DDL, which mysql and oracle commit as they go, so a clear that fails
     * halfway leaves a catalog still naming what is left - and the next attempt finishes it - where one
     * that had dropped the catalog first would leave tables nothing names any more and no clear could
     * ever reach.
     * <p>
     * Taken from the drops themselves and not from the map the loop walks: the map is built with the
     * catalog put last by hand, so an assertion on it would hold of any loop at all - one that sorted
     * the keys, or copied them into a HashSet, included.
     */
    @Test
    public void testAClearDropsTheCatalogAfterEveryTreeItNames() throws Exception {
        final TreeName first = new TreeName("testCatalogDropOrder", "first");
        final TreeName second = new TreeName("testCatalogDropOrder", "second");
        final List<String> order = new ArrayList<>();
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_order"), null) {
            @Override
            void dropTable(Connection con, String tableName) throws SQLException {
                order.add(tableName);
                super.dropTable(con, tableName);
            }
        };
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(first, true);
                    txn.openTree(second, true);
                }
            });
 
            storage.removeStorageFiles();
 
            final String catalogTable = storage.getTableName(storage.getCatalogTree());
            assertTrue(order.contains(storage.getTableName(first)) && order.contains(storage.getTableName(second)),
                "the clear did not drop the tables of the trees its catalog names: " + order);
            assertEquals(order.get(order.size() - 1), catalogTable,
                "the clear dropped the catalog before a tree it names, which no later clear could reach: " + order);
            assertEquals(order.indexOf(catalogTable), order.size() - 1,
                "the catalog was dropped more than once: " + order);
        } finally {
            clearQuietly(storage);
        }
    }
 
    /**
     * What a clear leaves standing it reports, and it reports it as what it is: a table stamped with a
     * tree of a base DN this backend serves is its own and can be removed by hand, while a table of a
     * backend sharing this database (#873) is that backend's business and no part of this outcome.
     * Told apart by the stamp of #866 and by nothing else - a table name is a bare hash.
     */
    @Test
    public void testAClearReportsTheTablesItCanAttributeToThisBackend() throws Exception {
        final DN baseDN = DN.valueOf("dc=clear-report,dc=com");
        final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
        // a base DN of its own, so that the neighbour is a backend that reports this table as its own:
        // what this case asserts about the clear of the other one is then an absence and not a vacuity
        final DN neighbourBaseDN = DN.valueOf("dc=clear-report-neighbour,dc=com");
        final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "id2entry");
        final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reported", baseDN), null);
        final JDBCStorage neighbour =
            new JDBCStorage(createBackendCfg(getBackendId() + "_reportedNeighbour", neighbourBaseDN), null);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(owned, true);
                }
            });
            neighbour.open(AccessMode.READ_WRITE);
            neighbour.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(neighbourTree, true);
                }
            });
            // the state of a backend upgraded from a version keeping no catalog: its tables are there
            // and nothing names them, so the clear that follows drops nothing at all
            dropTableBehindTheBackend(storage.getTableName(storage.getCatalogTree()));
            storage.close();
 
            storage.removeStorageFiles();
 
            assertTrue(isExistsTable(storage.getTableName(owned)),
                "a table named by no catalog was dropped: nothing may be dropped that cannot be attributed");
            try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
                // asked the way a clear asks it, through the same normalisation: a driver naming its
                // catalog with an empty string names no catalog, and a metadata pattern reads that as
                // "the tables belonging to no catalog at all", which would answer nothing
                final JDBCStorage.ClearLeftovers leftovers =
                    storage.leftoverTables(con, JDBCStorage.TableScope.of(storage, con));
                assertNotNull(leftovers, "the database would not say which tables the clear left standing");
                assertTrue(leftovers.ours.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()),
                    "a table of a base DN this backend serves was not reported as its own: " + leftovers.ours);
                assertFalse(leftovers.unattributed.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()),
                    "a table this backend can name was reported as attributable to nobody: " + leftovers.unattributed);
                assertTrue(leftovers.unreadable.isEmpty(),
                    "the stamp of a table this database does give up was reported as unreadable: " + leftovers.unreadable);
            }
            assertReportsNothingOf(storage, neighbour, neighbourTree);
        } finally {
            clearQuietly(neighbour);
            // the catalog of this one is gone, so its clear names nothing: the table it left standing on
            // purpose is dropped here by hand, as the report says such a table has to be
            clearQuietly(storage);
            dropTableIfExists(storage.getTableName(owned));
        }
    }
 
    /**
     * Asserts that the clear of one backend says nothing whatsoever about the tables of another - and
     * that the silence is one about tables the scan does reach: the backend those tables belong to is
     * asked the same question and reports them as its own, so an absence here is a decision and not a
     * scan that enumerated nothing.
     */
    private void assertReportsNothingOf(JDBCStorage cleared, JDBCStorage other, TreeName otherTree) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
            final JDBCStorage.ClearLeftovers leftovers =
                cleared.leftoverTables(con, JDBCStorage.TableScope.of(cleared, con));
            assertNotNull(leftovers, "the database would not say which tables the clear left standing");
            final String reported =
                (leftovers.ours + " " + leftovers.unattributed + " " + leftovers.unreadable).toLowerCase();
            assertFalse(reported.contains(other.getTableName(otherTree).toLowerCase()),
                "the clear of one backend reported the table of another: " + reported);
            assertFalse(reported.contains(other.getTableName(other.getCatalogTree()).toLowerCase()),
                "the clear of one backend reported the catalog of another: " + reported);
 
            final JDBCStorage.ClearLeftovers theirs =
                other.leftoverTables(con, JDBCStorage.TableScope.of(other, con));
            assertNotNull(theirs, "the database would not say which tables the neighbour is holding");
            assertTrue(theirs.ours.toString().toLowerCase().contains(other.getTableName(otherTree).toLowerCase()),
                "the table left unreported is one the scan does not reach at all: " + theirs.ours);
        }
    }
 
    /**
     * A clear which dropped nothing at all says why, where the only thing it had to say is a row it
     * could not act on: the catalog table went between the read of its rows and the loop that drops
     * what they named - an offline tool clearing the same backend, and the one state in which a clear
     * passes a row over and still drops nothing - so the account of it has no drop, no tree that had
     * lost its table and no leftover of this backend to be decided by. The row is what is left, and
     * the line reporting it is the only copy of what that row said.
     * <p>
     * What the case pins is one term of that condition. It pins it on a database holding nothing of
     * anybody else that the scan cannot attribute - the fragments asserted are the counts this case
     * owns, and a neighbour of another suite leaving an unstamped table would make the line fire for
     * a reason of its own rather than fail this.
     */
    @Test
    public void testAClearWhichDroppedNothingSaysWhyWhereARowItPassedOverIsAllItHad() throws Exception {
        final DN baseDN = DN.valueOf("dc=clear-catalog-race,dc=com");
        final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
        final ReportingStorage storage =
            new ReportingStorage(createBackendCfg(getBackendId() + "_catalogRace", baseDN));
        final String catalogTable = storage.getTableName(storage.getCatalogTree());
        final String ownedTable = storage.getTableName(owned);
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(owned, true);
                }
            });
            // the one row of the catalog now records a name outside the namespace this backend names
            // its tables in, so the read passes it over and the catalog names itself alone
            recordAnotherTable(catalogTable, "a_table_of_something_else");
            // and nothing of this backend is left standing for the scan to attribute to it
            dropTableBehindTheBackend(ownedTable);
            storage.close();
 
            // the table goes while the clear is running, which is what leaves the clear with nothing
            // dropped: an offline tool clearing the same backend a moment earlier. At the second
            // lookup and not the first, so that the rows are read before the table goes - the first
            // is catalogTables() asking whether there is a catalog at all
            storage.takeAwayAtLookupNumber(catalogTable, 2);
 
            storage.removeStorageFiles();
 
            assertFalse(isExistsTable(catalogTable), "the catalog table this case takes away was still there");
            storage.assertReported("a clear which dropped nothing at all and passed a row over said nothing"
                    + " about why, which is the silence of #888",
                "the clear removed no tree of this backend", "it dropped 0 table(s) in all",
                "0 of the trees its catalog names had lost their table already",
                "and 0 table(s) of this backend were named by no catalog");
            storage.assertReported("the row the clear could not act on was named by no line of it",
                "a_table_of_something_else", "passed over");
        } finally {
            clearQuietly(storage);
            dropTableIfExists(ownedTable);
            dropTableIfExists(catalogTable);
        }
    }
 
    /**
     * Takes every row out of a catalog, leaving the tables it named standing: what a backend upgraded
     * from a version keeping no catalog holds before its first read-write open fills one in.
     */
    private void emptyTheCatalog(String catalogTable) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
             final Statement st = con.createStatement()) {
            st.executeUpdate("delete from " + catalogTable);
        }
    }
 
    /**
     * Records the given table for every row of a catalog, as a version naming its tables otherwise
     * would have left them: the row names the right tree and a table this version never creates.
     */
    private void recordAnotherTable(String catalogTable, String tableName) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
             final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) {
            statement.setBytes(1, tableName.getBytes(StandardCharsets.UTF_8));
            statement.executeUpdate();
        }
    }
 
    /** Empties the recorded table name of every row of a catalog, as a version recording none would have left it. */
    private void emptyTheRecordedTableNames(String catalogTable) throws SQLException {
        try (final Connection con = DriverManager.getConnection(getJdbcUrl());
             final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) {
            statement.setBytes(1, new byte[0]);
            statement.executeUpdate();
        }
    }
 
    /**
     * Drops a table a clear left standing on purpose, so that it is not left behind for the rest of
     * the class. A failure here is swallowed rather than replacing the failure of the case it cleans
     * up after: what it leaves is dropped by the dropStaleTrees() of the next run of the class.
     */
    private void dropTableIfExists(String tableName) {
        try {
            if (isExistsTable(tableName)) {
                dropTableBehindTheBackend(tableName);
            }
        } catch (SQLException ignored) {
        }
    }
 
    /**
     * A storage which keeps the lines every clear it runs reports, so that a case can hold that
     * account to what it says.
     * <p>
     * Those lines change no state whatsoever, so a case asserting on the database a clear leaves
     * behind passes just as well with all of them deleted - which is how this report came to be
     * changed in three rounds of review with nothing able to fail. Here rather than in the case that
     * needed it first, for the same reason: the next line of the report wants an assertion too, and a
     * helper per case is what got the report where it was. See {@link JDBCStorage#reportClearLine}.
     */
    protected static final class ReportingStorage extends JDBCStorage {
        private final List<String> lines = Collections.synchronizedList(new ArrayList<String>());
 
        private volatile String tableToTakeAway;
        private final AtomicInteger lookupsToLetPass = new AtomicInteger();
 
        ReportingStorage(JDBCBackendCfg cfg) {
            super(cfg, null);
        }
 
        @Override
        void reportClearLine(LocalizableMessage line) {
            lines.add(line.toString());
            super.reportClearLine(line); // and on to the log, which is where an operator meets it
        }
 
        /**
         * Takes the named table away just before the given lookup of it, counting from the next one,
         * so that the lookup answers as another process taking the table a moment earlier would have
         * made it answer. What it models is the one state a clear cannot be put into from outside: a
         * table going between the read of the catalog and the loop that drops what that read named.
         * <p>
         * Which lookup matters, and the count is not decoration: a clear asks about its catalog table
         * twice - once in {@code catalogTables()} to decide whether there is a catalog to read at all,
         * and once in the drop loop, per entry. Taken away before the first, the clear reads no row,
         * passes none over and reports nothing, which is a different case from this one.
         * <p>
         * Dropped on the very connection the lookup is made on, and not on one of the test's own: the
         * clear holds its read of the catalog table until it commits, so a {@code drop table} issued
         * from a second session would queue behind the transaction that is waiting for this call to
         * return. Inside that transaction the drop takes no lock it does not already hold, and it is
         * committed with the loop - or, on the two engines committing DDL as they go, at once.
         */
        void takeAwayAtLookupNumber(String tableName, int nth) {
            lookupsToLetPass.set(nth - 1);
            tableToTakeAway = tableName;
        }
 
        @Override
        boolean isExistsTable(Connection con, JDBCStorage.TableScope scope, String tableName) {
            final String taking = tableToTakeAway;
            if (taking != null && taking.equalsIgnoreCase(tableName)
                    && lookupsToLetPass.getAndDecrement() <= 0) {
                tableToTakeAway = null; // once: every later lookup is answered by the database alone
                try (final PreparedStatement statement = con.prepareStatement("drop table " + taking)) {
                    statement.execute();
                } catch (SQLException e) {
                    throw new IllegalStateException("the table this case takes away could not be dropped", e);
                }
            }
            return super.isExistsTable(con, scope, tableName);
        }
 
        /** Every line reported so far, in the order the clears that reported them ran. */
        List<String> reported() {
            synchronized (lines) {
                return new ArrayList<>(lines);
            }
        }
 
        /**
         * Fails unless one reported line holds every one of the fragments. By fragments and not by the
         * whole line: what a case is entitled to pin is the thing the line is about, and a report
         * asserted word for word is a report nobody may improve the wording of.
         */
        void assertReported(String whatWentUnsaid, String... fragments) {
            for (final String line : reported()) {
                boolean holdsAll = true;
                for (final String fragment : fragments) {
                    holdsAll &= line.contains(fragment);
                }
                if (holdsAll) {
                    return;
                }
            }
            fail(whatWentUnsaid + "; the clear reported: " + reported());
        }
    }
}