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

19 files modified
2 files added
853 ■■■■■ changed files
.github/benchmark/compare-opendj.sh 26 ●●●●● patch | view | raw | blame | history
.github/workflows/build.yml 4 ●●●●● patch | view | raw | blame | history
.github/workflows/codeql.yml 4 ●●●●● patch | view | raw | blame | history
.github/workflows/docker-scan.yml 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/pom.xml 110 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc 6 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-tuning.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc 16 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb 92 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java 188 ●●●●● patch | view | raw | blame | history
opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/PDBBackendConfiguration.xml 36 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/resource/schema/02-config.ldif 9 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java 111 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/Storage.java 10 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java 219 ●●●●● patch | view | raw | blame | history
.github/benchmark/compare-opendj.sh
@@ -108,13 +108,27 @@
      -Jjmeter.reportgenerator.sample_filter='^(?!ADMIN_CONNECT).*' \
      -l "$out.jtl" -e -o "$out" > "$out.jmeter.out" 2>&1 || true
    docker logs opendj-bench > "$out.docker.log" 2>&1 || true
    # surface distinct error messages to the step log (stderr; stdout carries the version)
    # surface distinct error messages to the step log (stderr; stdout carries the version).
    # The JTL is CSV and JMeter quotes a message that holds a comma (every DN does), so it is
    # read with a CSV parser. Digits are replaced before grouping: the messages carry the entry
    # DN or the elapsed time, and verbatim each failed row would be a kind of its own.
    if [ -f "$out.jtl" ]; then
      local errs
      errs="$(awk -F',' 'NR==1{for(i=1;i<=NF;i++)h[$i]=i; next}
                         tolower($h["success"])=="false"{print $h["label"]" | "$h["responseCode"]" | "$h["responseMessage"]}' \
              "$out.jtl" 2>/dev/null | sort | uniq -c | sort -rn | head -10)"
      [ -z "$errs" ] || { echo "[$out] errors (count | op | code | message):" >&2; echo "$errs" >&2; }
      python3 - "$out.jtl" "$out" >&2 <<'PY' || true
import collections, csv, re, sys
kinds = collections.Counter()
with open(sys.argv[1], newline='') as f:
    for r in csv.DictReader(f):
        if r['success'].strip().lower() == 'false':
            kinds[r['label'], r['responseCode'].strip(),
                  re.sub(r'\d+', 'N', r['responseMessage'].strip())] += 1
if kinds:
    print('[%s] %d failed rows, %d kinds of error (count | op | code | message):'
          % (sys.argv[2], sum(kinds.values()), len(kinds)))
    for (label, code, message), n in kinds.most_common(10):
        print('%7d %s | %s | %s' % (n, label, code, message))
    if len(kinds) > 10:
        print('%7s %d more kinds not shown' % ('...', len(kinds) - 10))
PY
    fi
  else
    echo "ERROR: failed to start image $image" >&2
.github/workflows/build.yml
@@ -858,7 +858,7 @@
          scanners: vuln
          cache: false
      - name: Upload Trivy report to GitHub Security
        uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        # upload even if a preceding step failed, but not without a report to upload
        if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
        with:
@@ -1304,7 +1304,7 @@
          scanners: vuln
          cache: false
      - name: Upload Trivy report to GitHub Security
        uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        # upload even if a preceding step failed, but not without a report to upload
        if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
        with:
.github/workflows/codeql.yml
@@ -85,7 +85,7 @@
          submodules: recursive
      - name: Initialize CodeQL
        uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        with:
          languages: ${{ matrix.language }}
          build-mode: ${{ matrix.build-mode }}
@@ -139,6 +139,6 @@
      # ---------------------------------------------------------------------
      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        with:
          category: "/language:${{ matrix.language }}"
.github/workflows/docker-scan.yml
@@ -51,7 +51,7 @@
          scanners: vuln
          cache: false
      - name: Upload report to GitHub Security
        uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
        uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        # upload even if a preceding step failed, but not without a report to upload
        if: ${{ always() && hashFiles(format('trivy-{0}.sarif', matrix.tag)) != '' }}
        with:
opendj-doc-generated-ref/pom.xml
@@ -34,8 +34,24 @@
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <docTargetVersion>${project.version}</docTargetVersion>
        <sdkDocTargetVersion>${project.version}</sdkDocTargetVersion>
        <!-- The Asciidoctor of asciidoctor-maven-plugin, which the tests of the doc extensions also run on -->
        <asciidoctorj.version>2.5.11</asciidoctorj.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.asciidoctor</groupId>
            <artifactId>asciidoctorj</artifactId>
            <version>${asciidoctorj.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.openidentityplatform.commons</groupId>
            <artifactId>build-tools</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build><finalName>${project.groupId}.${project.artifactId}</finalName>
        <plugins>
@@ -356,6 +372,34 @@
                    </execution>
                </executions>
            </plugin>
            <!-- A pom module compiles and runs no tests by itself: these test the Asciidoctor extensions -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>compile-extension-tests</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>testCompile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <executions>
                    <execution>
                        <id>test-extensions</id>
                        <phase>test</phase>
                        <goals>
                            <goal>test</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
@@ -603,7 +647,73 @@
                                    <outputDirectory>${project.build.directory}/asciidoc/man-pages</outputDirectory>
                                </configuration>
                            </execution>
                            <!--
                              Fail the build when a page would publish an attribute reference as literal
                              text, which neither the PDF below nor the Antora site reports. Every .adoc of
                              the pre-processed sources is rendered on its own, the way Antora renders each
                              chapter as a page (partials aside, see below), so a page that relies on an
                              attribute another chapter defines fails here although the PDF book resolves
                              it. attribute-missing=warn reports a reference to an attribute that is not
                              defined;
                              literal-attribute-references.rb reports one in a verbatim block without
                              subs="+attributes" or in a literal table cell. The HTML output is thrown away.
                              LiteralAttributeReferencesTest renders its pages with the configuration of
                              this execution.
                              The plugin stops at the first page that fails, so fix it and run again to see
                              the next one: mvn -Pdistribution-unix,man-pages -pl opendj-doc-generated-ref
                              asciidoctor:process-asciidoc@check-attribute-references, after a build of this
                              module (both profiles activate by themselves only on Linux). Asciidoctor names
                              no file for a missing attribute: it is the page of the "Converted" line that
                              follows the warning. A literal {name} in text is written \{name}.
                              Maven project properties are attributes here (product.name is {product-name})
                              but not on the site, so a page that uses one passes this check and still shows
                              the braces there.
                              The partials directory is rendered too, although Antora only includes its files
                              and never publishes them as pages: the plugin has no exclusion, and a name that
                              starts with _ would break the includes. So every man page is checked twice, and
                              a partial that relies on an attribute of the chapter that includes it fails
                              here although the site resolves it.
                            -->
                            <execution>
                                <id>check-attribute-references</id>
                                <phase>verify</phase>
                                <goals>
                                    <goal>process-asciidoc</goal>
                                </goals>
                                <configuration>
                                    <requires>
                                        <require>${project.basedir}/src/main/resources/asciidoc/extensions/nested-open-block.rb</require>
                                        <require>${project.basedir}/src/main/resources/asciidoc/extensions/literal-attribute-references.rb</require>
                                    </requires>
                                    <backend>html5</backend>
                                    <sourceDirectory>${project.build.directory}/asciidoc/source</sourceDirectory>
                                    <outputDirectory>${project.build.directory}/asciidoc/attribute-check</outputDirectory>
                                    <preserveDirectories>true</preserveDirectories>
                                    <relativeBaseDir>true</relativeBaseDir>
                                    <sourcemap>true</sourcemap>
                                    <attributes>
                                        <attribute-missing>warn</attribute-missing>
                                        <literal-attribute-sources>${project.build.directory}/asciidoc/source</literal-attribute-sources>
                                    </attributes>
                                    <logHandler>
                                        <failIf>
                                            <severity>WARN</severity>
                                            <containsText>attribute</containsText>
                                        </failIf>
                                    </logHandler>
                                </configuration>
                            </execution>
                        </executions>
                        <dependencies>
                            <dependency>
                                <groupId>org.asciidoctor</groupId>
                                <artifactId>asciidoctorj</artifactId>
                                <version>${asciidoctorj.version}</version>
                            </dependency>
                        </dependencies>
                    </plugin>
                    <!-- Format man pages -->
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc
@@ -79,7 +79,7 @@
To run the OpenDMK installer, use the self-extracting .jar:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ java -jar ~/Downloads/opendmk-1.0-b02-*.jar
$ cd ~/Downloads/
@@ -150,7 +150,7 @@
----
Use a command such as `snmpwalk` to check that the SNMP listen port works:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ snmpwalk -v 2c -c OpenDJ@OpenDJ localhost:11161
SNMPv2-SMI::mib-2.66.1.1.1.1 = STRING: "OpenDJ {opendj-version}..."
@@ -248,7 +248,7 @@
The `status` command takes administrative credentials to read the configuration, as does the control panel:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ status --bindDN "cn=Directory Manager" --bindPassword password
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc
@@ -12,7 +12,7 @@
  information: "Portions copyright [year] [name of copyright owner]".
 
  Copyright 2017 ForgeRock AS.
  Portions Copyright 2024 3A Systems LLC.
  Portions Copyright 2024-2026 3A Systems LLC.
////
:figure-caption!:
@@ -1009,7 +1009,7 @@
 --authzID "u:bjensen" \
 --newPassword '!ABcd$%^'
----
In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath {path}` option to save the result of your interactive session to a file for use in scripts later.
In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath \{path}` option to save the result of your interactive session to a file for use in scripts later.
An attempt to set an invalid password fails as shown in the following example:
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
@@ -808,7 +808,7 @@
When you cannot resolve a problem yourself, and want to ask for help, clearly identify the problem and how you reproduce it, and also the version of OpenDJ you use to reproduce the problem. The version includes both a version number and also a build time stamp:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ dsconfig --version
OpenDJ {opendj-version}
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-tuning.adoc
@@ -248,6 +248,8 @@
----
If write traffic to your directory service occurs in short bursts, and you use database backends of type `pdb`, you can potentially improve short-term performance during the bursts by increasing the `db-checkpointer-wakeup-interval` setting. This setting specifies the maximum length of time between attempts to write a checkpoint to the journal. Longer intervals allow more updates to accumulate in buffers before they are required to be written to disk. The transaction log is still written to disk, but the modified pages are kept in memory longer before being written. Longer intervals potentially cause recovery from an abrupt termination to take more time.
Database backends of type `pdb` resolve two concurrent updates of the same record by rolling one of them back and running it again. This is routine under concurrent write load, for example while index keys shared by many entries, such as their object classes, hold fewer entries than the index entry limit. By default, an update is run again for as long as it keeps being rolled back. The `db-txn-retry-time-limit` setting bounds that time: once it is spent, the operation fails with result code 80 (`other`). Set it only when a bounded wait matters more than the update, for example while you change the indexes or base DNs of a busy backend, which hold their suffix exclusively while they write. The change takes effect without a restart.
[#perf-import]
==== LDIF Import Settings
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
@@ -383,7 +383,7 @@
. Run the `status` command, described in xref:../reference/admin-tools-ref.adoc#status-1[status(1)] in the __Reference__, to make sure your OpenDJ server is working as expected as shown in the following example:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ /path/to/opendj/bin/status
@@ -437,7 +437,7 @@
. Install the OpenDJ directory server package. Use `apt-get install ./<file>.deb` (rather than `dpkg -i`) so the required Java runtime dependency (`default-jre-headless`) is resolved and installed automatically:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ sudo apt-get install ./opendj_{opendj-version}-1_all.deb
----
@@ -463,7 +463,7 @@
. (Optional)  Check OpenDJ directory server status:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ systemctl is-active opendj
active
@@ -525,7 +525,7 @@
. Install the OpenDJ directory server package. Use `dnf install ./<file>.rpm` (rather than `rpm -i`) so the required Java runtime dependency (`java-headless >= 11`) is resolved and installed automatically:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
# dnf install ./opendj-{opendj-version}-1.noarch.rpm
Post Install - initial install
@@ -552,7 +552,7 @@
. (Optional)  Check OpenDJ directory server status:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
# systemctl is-active opendj
active
@@ -623,7 +623,7 @@
* Silent: run the following command (optionally set the installation directory with the `OPENDJ` property):
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\opendj"
----
@@ -701,7 +701,7 @@
. Prepare an installation script:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cat /net/install/dj/1/setup.sh
#!/bin/sh
@@ -756,7 +756,7 @@
. Run your installation script:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ /net/install/dj/1/setup.sh
Archive:  /net/install/dj/opendj-{opendj-version}.zip
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc
@@ -176,7 +176,7 @@
. Uninstall the package, either through __Settings > Apps__ (or __Control Panel > Programs and Features__) by selecting OpenDJ and choosing Uninstall, or from the command-line:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /x opendj-{opendj-version}.msi /quiet
----
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc
@@ -153,7 +153,7 @@
====
The following example upgrades an OpenDJ 2.6.3 directory server, backing up the current server directory in case the upgrade process fails. In this example, the server properties are updated to use Java 11, and the Local DB backend is migrated to a JE backend:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cd /path/to/
$ sed -e "s/default.java-home=.*/default.java-home=\/path\/to\/jdk11/" \
@@ -268,7 +268,7 @@
. Install the newer package (GUI or silent). The installer detects the existing installation â€” the location recorded in the registry by a previous x64 package, or the default directory of the older 32-bit package (`C:\Program Files (x86)\OpenDJ`) â€” and installs into the same directory, so your configured instance data (`config`, `db`, `logs`) is kept and only the program files are replaced. If the older server was installed in a custom directory the installer cannot detect, select that directory in the wizard or pass it explicitly on the command line: rather than installing a fresh server into the default directory while emptying the old one, the installer refuses to continue whenever nothing has recorded where the old server lives and the directory it is about to install into holds no OpenDJ server -- which also catches a mistyped directory name. That refusal also covers an old server that really is installed in `C:\Program Files\OpenDJ`, because the 32-bit packages recorded no location at all â€” and that one case the wizard cannot resolve: choosing the default directory in the wizard leaves the installer with the same values it would have had if you had chosen nothing, so pass `OPENDJ` on the command line instead â€” it can be given with or without `/quiet`, so a wizard installation takes it just as a silent one does. The installer further refuses to install into a directory other than the one it detected, unless the directory you name holds an OpenDJ server itself (see the note below): it replaces an installation in place and cannot move one, so uninstall the existing server first if you want it somewhere else.
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\path\to\opendj"
----
opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc
@@ -13,7 +13,7 @@
  information: "Portions Copyright [year] [name of copyright owner]".
  Copyright 2015-2016 ForgeRock AS.
  Portions Copyright 2024 3A Systems LLC.
  Portions Copyright 2024-2026 3A Systems LLC.
////
@@ -28,7 +28,7 @@
windows-service - register OpenDJ as a Windows Service
== Synopsis
`windows-service` {options}
`windows-service` \{options}
== Description
This utility can be used to run OpenDJ directory server as a Windows Service.
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc
@@ -170,7 +170,7 @@
. Install the example plugin in OpenDJ directory server:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cd /path/to/opendj
opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb
New file
@@ -0,0 +1,92 @@
# The contents of this file are subject to the terms of the Common Development and
# Distribution License (the License). You may not use this file except in compliance with the
# License.
#
# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
# specific language governing permission and limitations under the License.
#
# When distributing Covered Software, include this CDDL Header Notice in each file and include
# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
# Header, with the fields enclosed by brackets [] replaced by your own identifying
# information: "Portions copyright [year] [name of copyright owner]".
#
# Copyright 2026 3A Systems, LLC.
# Warns about an attribute reference that a verbatim block publishes as literal text.
#
# A listing, literal or passthrough block replaces {name} only when its subs include
# attributes (subs="+attributes"). Without them the braces reach the page as they are,
# and Asciidoctor says nothing: `unzip opendj-{opendj-version}.zip` was published that
# way. A literal table cell (l|) never substitutes attributes and takes no subs. A
# reference to an attribute that is not defined at all, outside such a block, is
# Asciidoctor's own warning once attribute-missing is set to warn.
#
# A reference is reported when its name is an attribute at that point of the document,
# an intrinsic one such as {nbsp}, or one that an attribute entry anywhere under the
# directory named by the literal-attribute-sources attribute sets. The last catches a
# page that neither defines the attribute nor substitutes it, and since the directory
# holds the page itself, it also counts a name that the page has unset by the time the
# block stands: such braces are written \{name} in a block with subs="+attributes".
# Braces around any other name are meant literally - {SSHA} password values, {cn} in a
# MakeLDIF template - and are left alone. Without attribute subs a backslash does not
# escape the reference, so \{name} is published with its backslash and is reported too.
require 'set'
class LiteralAttributeReferences < Asciidoctor::Extensions::TreeProcessor
  include Asciidoctor::Logging
  ReferenceRx = /(\\)?\{(\w[\w-]*)\}/
  EntryRx = /^:(\w[\w-]*):/
  @names_by_dir = {}
  # The names that an attribute entry sets in some .adoc file under dir. The directory is
  # the base of the glob, not a part of the pattern, so braces in its path match as such.
  def self.names_in dir
    @names_by_dir[dir] ||= Dir.glob('**/*.adoc', base: dir).each_with_object(Set.new) do |path, names|
      File.foreach((File.join dir, path), encoding: 'UTF-8') {|line| names << $1.downcase if EntryRx =~ line }
    end
  end
  def process document
    dir = document.attr 'literal-attribute-sources'
    names = dir ? (LiteralAttributeReferences.names_in dir) : Set.new
    # The parser has already reset the document attributes to the header, so the entries
    # of the body are replayed in document order, as the converter does, on a copy of
    # them. The document itself is left as the converter expects it: playing the entries
    # back on it would also carry the compat mode of the last one over to the converter.
    # An AsciiDoc table cell is a document of its own.
    attributes_of = Hash.new {|copies, doc| copies[doc] = doc.attributes.dup }
    document.find_by traverse_documents: true do |block|
      attributes = attributes_of[block.document]
      (block.attributes[:attribute_entries] || []).each do |entry|
        entry.negate ? (attributes.delete entry.name) : (attributes[entry.name] = entry.value)
      end
      if Asciidoctor::Table::Cell === block
        # The text of a literal cell only escapes special characters, so its braces stay.
        check block, block.text, attributes, names, 'literal table cell', 'use an a| cell with a listing that has subs="+attributes"' if block.content_model == :verbatim
      elsif Asciidoctor::Block === block &&
          (block.content_model == :verbatim || block.content_model == :raw) && !(block.subs.include? :attributes)
        # subs="attributes" would replace the default subs of the block, so the advice adds to them.
        check block, (block.lines.join Asciidoctor::LF), attributes, names, %(#{block.context} block), 'add subs="+attributes"'
      end
      false
    end
    nil
  end
  def check block, text, attributes, names, what, advice
    text.scan(ReferenceRx) do |escaped, name|
      key = name.downcase
      next unless (attributes.key? key) || (names.include? key) || (Asciidoctor::INTRINSIC_ATTRIBUTES.key? key)
      message = escaped ?
          %(\\{#{name}} is published with its backslash: the #{what} does not substitute attributes, #{advice}) :
          %(attribute {#{name}} is published as literal text: the #{what} does not substitute attributes, #{advice})
      logger.warn message_with_context message, source_location: block.source_location
    end
  end
end
Asciidoctor::Extensions.register do
  tree_processor LiteralAttributeReferences
end
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java
New file
@@ -0,0 +1,188 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2026 3A Systems, LLC.
 */
package org.openidentityplatform.opendj.doc;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Options;
import org.asciidoctor.SafeMode;
import org.asciidoctor.log.LogRecord;
import org.asciidoctor.log.Severity;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
 * Renders pages with the configuration of the check-attribute-references execution of this module's
 * pom - its extensions, its attributes and its failIf - and checks which of them would fail the build.
 * Reading the configuration from the pom keeps the execution itself under test, not only
 * literal-attribute-references.rb.
 */
@Test
public class LiteralAttributeReferencesTest extends ForgeRockTestCase {
    private static final String EXECUTION = "check-attribute-references";
    /** A page that unsets an attribute and then names it in a listing, kept among the sources. */
    private static final String UNSET_PAGE = "= Title\n:u: 1\n\n== Section\n:u!:\n\n----\nx-{u}\n----\n";
    private final List<LogRecord> records = new ArrayList<>();
    private final Map<String, Object> attributes = new LinkedHashMap<>();
    private Asciidoctor asciidoctor;
    private String backend;
    private boolean sourcemap;
    private Severity failSeverity;
    private String failText;
    @BeforeClass
    public void readExecution() throws Exception {
        final File module = new File(System.getProperty("basedir", "."));
        // The pre-processed sources sit under the build directory. Braces and brackets in its path
        // must not turn it into a glob pattern.
        final Path buildDirectory = Files.createTempDirectory(
                Files.createDirectories(new File(module, "target").toPath()), "attribute-check-{1}[1]-");
        final Path sources = Files.createDirectories(buildDirectory.resolve("asciidoc/source/other-guide"));
        Files.write(sources.resolve("chap-other.adoc"), ":elsewhere: 1\n".getBytes(StandardCharsets.UTF_8));
        // The build renders the pages of the directory it collects the entries from, so a page is among them.
        Files.write(sources.resolve("chap-unset.adoc"), UNSET_PAGE.getBytes(StandardCharsets.UTF_8));
        final XPath xpath = XPathFactory.newInstance().newXPath();
        final Element configuration = (Element) xpath.evaluate(
                "//execution[id='" + EXECUTION + "']/configuration",
                DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new File(module, "pom.xml")),
                XPathConstants.NODE);
        assertThat(configuration).as("configuration of the " + EXECUTION + " execution").isNotNull();
        // The fixture stands for both directories, which holds only while they are one.
        assertThat(xpath.evaluate("sourceDirectory", configuration).trim())
                .as("the check renders the directory whose attribute entries it collects")
                .isEqualTo(xpath.evaluate("attributes/literal-attribute-sources", configuration).trim());
        asciidoctor = Asciidoctor.Factory.create();
        asciidoctor.registerLogHandler(records::add);
        final NodeList requires = (NodeList) xpath.evaluate("requires/require", configuration, XPathConstants.NODESET);
        for (int i = 0; i < requires.getLength(); i++) {
            asciidoctor.requireLibrary(requires.item(i).getTextContent().trim()
                    .replace("${project.basedir}", module.getAbsolutePath()));
        }
        final NodeList entries = (NodeList) xpath.evaluate("attributes/*", configuration, XPathConstants.NODESET);
        for (int i = 0; i < entries.getLength(); i++) {
            final Node entry = entries.item(i);
            attributes.put(entry.getNodeName(), entry.getTextContent().trim()
                    .replace("${project.build.directory}", buildDirectory.toString()));
        }
        backend = xpath.evaluate("backend", configuration);
        sourcemap = Boolean.parseBoolean(xpath.evaluate("sourcemap", configuration));
        failSeverity = Severity.valueOf(xpath.evaluate("logHandler/failIf/severity", configuration));
        failText = xpath.evaluate("logHandler/failIf/containsText", configuration);
    }
    @AfterClass(alwaysRun = true)
    public void shutdown() {
        if (asciidoctor != null) {
            asciidoctor.shutdown();
        }
    }
    /** Returns the messages that make the execution fail the build on this page. */
    private List<String> failures(final String page) {
        records.clear();
        asciidoctor.convert(page, Options.builder()
                .safe(SafeMode.UNSAFE)
                .backend(backend)
                .sourcemap(sourcemap)
                .attributes(new LinkedHashMap<>(attributes))
                .toFile(false)
                .build());
        return records.stream()
                .filter(r -> r.getSeverity().ordinal() >= failSeverity.ordinal() && r.getMessage().contains(failText))
                .map(LogRecord::getMessage)
                .collect(Collectors.toList());
    }
    @DataProvider
    public Object[][] failingPages() {
        return new Object[][] {
            { ":v: 1\n\n----\nunzip x-{v}.zip\n----\n",
              "attribute {v} is published as literal text: the listing block does not substitute attributes, "
                      + "add subs=\"+attributes\"" },
            { ":v: 1\n\n literal x-{v}\n", "attribute {v} is published as literal text: the literal block" },
            { ":v: 1\n\n++++\n<p>{v}</p>\n++++\n", "attribute {v} is published as literal text: the pass block" },
            { ":v: 1\n\n|===\nl|cell x-{v}\n|===\n",
              "attribute {v} is published as literal text: the literal table cell does not substitute attributes, "
                      + "use an a| cell with a listing that has subs=\"+attributes\"" },
            { ":v: 1\n\n[cols=\"1l\"]\n|===\n|cell x-{v}\n|===\n", "the literal table cell" },
            { ":v: 1\n\n|===\na|\n----\nx-{v}\n----\n|===\n", "attribute {v} is published as literal text: the listing" },
            { ":v: 1\n\n----\nunzip x-\\{v}.zip\n----\n", "\\{v} is published with its backslash: the listing block" },
            { "----\nPATH{nbsp}x\n----\n", "attribute {nbsp} is published as literal text" },
            // A body entry counts from where it stands, not only a header one.
            { "= Title\n\n== Section\n:late: 1\n\n----\nx-{late}\n----\n", "attribute {late} is published as literal text" },
            // A page that neither defines nor substitutes an attribute another page defines.
            { "----\nx-{elsewhere}\n----\n", "attribute {elsewhere} is published as literal text" },
            // An entry of the page itself counts, even where the page has unset the attribute.
            { UNSET_PAGE, "attribute {u} is published as literal text" },
            { "tool {undefinedthing}\n", "undefinedthing" },
            // The converter starts from the compat mode of the header, not from that of the last entry.
            { "= Title\n\n`{undefinedthing}`\n\n:compat-mode:\n\ny\n", "undefinedthing" },
        };
    }
    @Test(dataProvider = "failingPages")
    public void pageFailsTheBuild(final String page, final String message) {
        assertThat(failures(page)).as(page).anySatisfy(failure -> assertThat(failure).contains(message));
    }
    @DataProvider
    public Object[][] passingPages() {
        return new Object[][] {
            { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-{v}.zip\n----\n" },
            { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-\\{v}.zip\n----\n" },
            { ":v: 1\n\nunzip x-{v}.zip and \\{v}\n" },
            { ":v: 1\n\n|===\na|\n[subs=\"+attributes\"]\n----\nx-{v}\n----\n|===\n" },
            // Braces that are no attribute anywhere are meant literally.
            { "----\nuserPassword: {SSHA}abc\ncn: {cn}\n----\n" },
            { "|===\nl|{SSHA}abc\n|===\n" },
            // A built-in attribute that no entry sets is no longer one where the body has unset it.
            { "= Title\n\n== Section\n:figure-caption!:\n\n----\nx-{figure-caption}\n----\n" },
            // The converter starts again from the header: a reference before a body unset still resolves.
            { "= Title\n:v: 1\n\nx {v}\n\n:v!:\n\ny\n" },
            { "= Title\n:compat-mode:\n\n`{cn}`\n\n:compat-mode!:\n\ny\n" },
        };
    }
    @Test(dataProvider = "passingPages")
    public void pagePassesTheBuild(final String page) {
        assertThat(failures(page)).as(page).isEmpty();
    }
}
opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/PDBBackendConfiguration.xml
@@ -194,6 +194,42 @@
      </ldap:attribute>
    </adm:profile>
  </adm:property>
  <adm:property name="db-txn-retry-time-limit" advanced="true">
    <adm:synopsis>
      Specifies how long a write transaction which the database rolled back is
      run again before the operation which issued it fails.
    </adm:synopsis>
    <adm:description>
      Persistit resolves two transactions which write the same record by rolling
      one of them back once the other commits, and the transaction rolled back is
      then run again. Under concurrent writes this is routine rather than a
      failure: every entry added or deleted rewrites the index keys it shares
      with other entries, such as its object classes, for as long as those keys
      hold fewer entries than the index entry limit. A value of 0 runs the
      transaction again for as long as it keeps being rolled back, the way a
      writer of a JE backend waits for a lock. A positive value bounds that time:
      once it is spent, the operation fails with the result code "other". The
      bound is checked between two runs of the transaction, so a single run can
      outlast it, and a transaction rolled back is always run once more. A bound
      matters to the configuration changes of an index or of a base DN, which
      write while they hold their suffix exclusively, so that every operation on
      that suffix waits for them. Changes to this property take effect with the
      next write transaction.
    </adm:description>
    <adm:default-behavior>
      <adm:defined>
        <adm:value>0s</adm:value>
      </adm:defined>
    </adm:default-behavior>
    <adm:syntax>
      <adm:duration base-unit="ms" lower-limit="0" />
    </adm:syntax>
    <adm:profile name="ldap">
      <ldap:attribute>
        <ldap:name>ds-cfg-db-txn-retry-time-limit</ldap:name>
      </ldap:attribute>
    </adm:profile>
  </adm:property>
  <adm:property name="disk-low-threshold" advanced="true">
      <adm:synopsis>
        Low disk threshold to limit database updates
opendj-server-legacy/resource/schema/02-config.ldif
@@ -4112,6 +4112,12 @@
  SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
  SINGLE-VALUE
  X-ORIGIN 'OpenDJ Directory Server' )
attributeTypes: ( 1.3.6.1.4.1.60142.2.1.1.2
  NAME 'ds-cfg-db-txn-retry-time-limit'
  EQUALITY caseIgnoreMatch
  SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
  SINGLE-VALUE
  X-ORIGIN 'OpenDJ Directory Server' )
objectClasses: ( 1.3.6.1.4.1.26027.1.2.1
  NAME 'ds-cfg-access-control-handler'
  SUP top
@@ -5970,7 +5976,8 @@
        ds-cfg-db-txn-no-sync $
        ds-cfg-disk-full-threshold $
        ds-cfg-disk-low-threshold $
        ds-cfg-db-checkpointer-wakeup-interval )
        ds-cfg-db-checkpointer-wakeup-interval $
        ds-cfg-db-txn-retry-time-limit )
  X-ORIGIN 'OpenDJ Directory Server' )
objectClasses: ( 1.3.6.1.4.1.36733.2.1.2.24
  NAME 'ds-cfg-backend-index'
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java
@@ -105,27 +105,6 @@
  private static final int IMPORT_DB_CACHE_SIZE = 32 * MB;
  /**
   * Number of attempts a {@link WriteableStorageImpl#write} makes before it propagates the conflict to the caller.
   * <p>
   * It is a budget of attempts and not of time, so it is only ever reached by the conflicts that report quickly.
   * PersistIt reports a write-write conflict only once it has waited on it, up to
   * {@code SharedResource.DEFAULT_MAX_WAIT_TIME} - a minute, which this backend never lowers - so a conflict slower
   * to report than {@link #MAX_RETRY_WINDOW_NANOS} spends the whole window inside its first attempt, is granted the
   * single replay that window's exemption guarantees, and gives up on the window after two attempts rather than
   * after this many.
   */
  static final int MAX_RETRIES = 10;
  /**
   * Wall-clock budget the replays of a {@link WriteableStorageImpl#write} may spend, in nanoseconds. It is checked
   * between attempts, so an attempt already running is never interrupted, and never before one replay has been
   * made: the loop returns after at most this window plus two attempts. It bounds the conflicts that are slow to
   * report, which {@link #MAX_RETRIES} alone does not - an operation whose own work takes seconds would otherwise
   * multiply that wait by the attempt count.
   */
  static final long MAX_RETRY_WINDOW_NANOS = 10L * 1000L * 1000L * 1000L; //10 s
  /**
   * Upper bound of the random delay before the second attempt, in milliseconds; it doubles with every attempt. This
   * is the bound of the flat sleep this loop took before it was bounded, so the first replay is delayed exactly as
   * it was and only the later ones back off.
@@ -135,6 +114,10 @@
  /** Upper bound the doubled delay is capped at, in milliseconds. */
  private static final double MAX_SLEEP_ON_RETRY_MS = 1000.0;
  /** Name of the property which bounds the replays of a {@link WriteableStorageImpl#write}, for the reports of it. */
  private static final String RETRY_TIME_LIMIT_PROPERTY =
      PDBBackendCfgDefn.getInstance().getDBTxnRetryTimeLimitPropertyDefinition().getName();
  private static final String VOLUME_NAME = "dj";
  private static final String JOURNAL_NAME = VOLUME_NAME + "_journal";
  /** The buffer / page size used by the PersistIt storage. */
@@ -671,7 +654,10 @@
    {
      final Transaction txn = db.getTransaction();
      final long startedAt = System.nanoTime();
      final long giveUpAt = startedAt + retryWindowNanos;
      //read once, so that a change of the property applies from the next write on rather than to one in flight;
      //0 replays for as long as the conflict lasts
      final long retryTimeLimitMs = config.getDBTxnRetryTimeLimit();
      final long giveUpAt = startedAt + TimeUnit.MILLISECONDS.toNanos(retryTimeLimitMs);
      for (int attempt = 1;; attempt++)
      {
        final RollbackException conflict;
@@ -705,24 +691,26 @@
        // decided and slept for outside the try statement: the sleep used to run before the finally ended the
        // rolled back transaction, holding it open for the whole backoff and lengthening the window every other
        // writer collides with
        //Bounded by time alone, and not by a count of attempts: a rollback is how persistit resolves two
        //transactions writing the same key - it waits for the other one to end and rolls this one back if that
        //one committed - so under concurrent writes to a hot key, such as an index key shared by entries below
        //the index entry limit, a healthy write loses several of these races in a row (#1149)
        //System.nanoTime() - giveUpAt is the overflow safe form of the comparison, and attempt > 1 keeps the
        //window from ending the loop before a single replay: persistit reports a write-write conflict only once
        //limit from ending the loop before a single replay: persistit reports a write-write conflict only once
        //it has waited on it, up to SharedResource.DEFAULT_MAX_WAIT_TIME - a minute, which this backend never
        //lowers - so one attempt can outlast the window on its own, and it is the attempt after that one which
        //lowers - so one attempt can outlast the limit on its own, and it is the attempt after that one which
        //is likeliest to succeed, the transaction that blocked it having just finished
        //one clock sample for both, so that the elapsed time reported is the one the give up was decided on
        final long now = System.nanoTime();
        final boolean capSpent = attempt >= maxRetries;
        if (capSpent || (attempt > 1 && now - giveUpAt >= 0))
        if (retryTimeLimitMs > 0 && attempt > 1 && now - giveUpAt >= 0)
        {
          final long elapsedMs = TimeUnit.NANOSECONDS.toMillis(now - startedAt);
          //which of the two bounds was spent, so that the config change paths - which report this as the trailing
          //cause of a message of their own - say whether raising the attempts or the window is what would have helped
          final String boundSpent = capSpent ? "attempt cap" : "retry window";
          //names the property, so that the config change paths - which report this as the trailing cause of a
          //message of their own - say what would have helped
          final StorageRuntimeException spent = new StorageRuntimeException(
              "pdb: backend '" + config.getBackendId() + "' did not apply the transaction after " + attempt
                  + " attempts in " + elapsedMs + " ms, the " + boundSpent + " being spent; the last conflict was "
                  + conflict);
                  + " attempts in " + elapsedMs + " ms, the " + RETRY_TIME_LIMIT_PROPERTY + " of " + retryTimeLimitMs
                  + " ms being spent; the last conflict was " + conflict);
          // the conflict is suppressed rather than made the cause, because a cause is what every caller strips
          // this message off with: write(WriteOperation) below unwraps a StorageRuntimeException that carries one
          // and throws the cause in its place, and EntryContainer.throwAllowedExceptionTypes:1121 rethrows a
@@ -732,17 +720,17 @@
          spent.addSuppressed(conflict);
          //warned once, at exhaustion only, unlike JDBCStorage which warns on every replay: a conflict is routine
          //on the ordinary add and modify path of this engine and a line per replay would flood the log. It names
          //the bound that was spent for the same reason the exception does, and it is the only rendering that can
          //carry the stack of the conflict: stackTraceToSingleLineString, the form the config change paths report
          //this exception with, walks the causes and never prints a suppressed exception
          //the property that was spent for the same reason the exception does, and it is the only rendering that
          //can carry the stack of the conflict: stackTraceToSingleLineString, the form the config change paths
          //report this exception with, walks the causes and never prints a suppressed exception
          logger.warn(LocalizableMessage.raw("pdb: giving up on the transaction of backend '%s' after %d attempts"
              + " in %d ms, the %s being spent: %s", config.getBackendId(), attempt, elapsedMs, boundSpent,
              stackTraceToSingleLineString(conflict)));
              + " in %d ms, the %s of %d ms being spent: %s", config.getBackendId(), attempt, elapsedMs,
              RETRY_TIME_LIMIT_PROPERTY, retryTimeLimitMs, stackTraceToSingleLineString(conflict)));
          throw spent;
        }
        if (logger.isTraceEnabled())
        {
          logger.trace("pdb: replaying the transaction after %s, attempt %d of %d", conflict, attempt, maxRetries);
          logger.trace("pdb: replaying the transaction after %s, attempt %d", conflict, attempt);
        }
        try
        {
@@ -1030,10 +1018,6 @@
  private long configuredCacheSize;
  private long reservedCacheSize;
  private StorageStatus storageStatus = StorageStatus.working();
  /** Attempt bound of a {@link WriteableStorageImpl#write}, {@link #MAX_RETRIES} outside the tests. */
  private final int maxRetries;
  /** Wall-clock bound of a {@link WriteableStorageImpl#write}, {@link #MAX_RETRY_WINDOW_NANOS} outside the tests. */
  private final long retryWindowNanos;
  /**
   * Creates a new persistit storage with the provided configuration.
@@ -1047,34 +1031,7 @@
  // FIXME: should be package private once importer is decoupled.
  public PDBStorage(final PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException
  {
    this(cfg, serverContext, MAX_RETRIES, MAX_RETRY_WINDOW_NANOS);
  }
  /**
   * Creates a new persistit storage whose replay bounds are the given ones rather than {@link #MAX_RETRIES} and
   * {@link #MAX_RETRY_WINDOW_NANOS}.
   * <p>
   * Only a test builds one of these, and it does so to stop the two bounds racing each other: with the shipped
   * values a run of replays spends a random share of the window on backoff alone, so a test of the attempt cap
   * can be ended by the window on a loaded machine, and a test of the window has to spend seconds of build time
   * to reach it.
   *
   * @param cfg
   *          The configuration.
   * @param serverContext
   *          This server instance context
   * @param maxRetries
   *          Number of attempts a write makes before it propagates the conflict to the caller.
   * @param retryWindowNanos
   *          Wall-clock budget the replays of a write may spend, in nanoseconds.
   * @throws ConfigException if memory cannot be reserved
   */
  PDBStorage(final PDBBackendCfg cfg, ServerContext serverContext, int maxRetries, long retryWindowNanos)
      throws ConfigException
  {
    this.serverContext = serverContext;
    this.maxRetries = maxRetries;
    this.retryWindowNanos = retryWindowNanos;
    backendDirectory = getBackendDirectory(cfg);
    runningDirectoryPermissions = cfg.getDBDirectoryPermissions();
    config = cfg;
@@ -1293,15 +1250,17 @@
  /**
   * {@inheritDoc}
   * <p>
   * A transaction the engine rolled back is replayed, bounded twice: by {@link #MAX_RETRIES} attempts and by the
   * {@link #MAX_RETRY_WINDOW_NANOS} wall-clock window, whichever is spent first - except that the window alone
   * never ends the replays before one has been made. It is bounded because the
   * configuration change paths of the pluggable backend hold an entry container's exclusive lock across this
   * method, and every reader of that suffix then waits - untimed and uninterruptibly - until it returns, so a
   * conflict that never clears would park every worker thread of that suffix rather than fail one operation.
   * A transaction the engine rolled back is replayed for as long as the {@code db-txn-retry-time-limit} of the
   * backend allows - without limit when it is 0, its default - except that the limit never ends the replays before
   * one has been made. A rollback is how persistit resolves two transactions writing the same key, so a healthy
   * write under concurrent load can lose several of them in a row: a count of attempts failed such writes (#1149),
   * and the replays are bounded by time alone. Without a limit a write waits for its conflict to clear the way a
   * JE writer waits for a lock. A limit is for the configuration change paths of the pluggable backend, which hold
   * an entry container's exclusive lock across this method: every reader of that suffix then waits - untimed and
   * uninterruptibly - until it returns.
   * <p>
   * Once the bound is spent the conflict is reported as a {@link StorageRuntimeException} naming the backend, the
   * attempts spent, the time they took and which of the two bounds ran out. It carries the conflict as a
   * Once the limit is spent the conflict is reported as a {@link StorageRuntimeException} naming the backend, the
   * attempts spent, the time they took and the property whose value ran out. It carries the conflict as a
   * suppressed exception rather than as its cause: a cause is unwrapped below and thrown in its place, and
   * {@code EntryContainer.throwAllowedExceptionTypes} likewise passes a {@link StorageRuntimeException} through
   * untouched only while it has no cause. Given a cause, both hand the caller a bare RollbackException instead,
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/Storage.java
@@ -75,10 +75,12 @@
  /**
   * Executes a write operation. In case of a write operation rollback, implementations may replay the write
   * operation rather than propagate the failure: a {@link WriteOperation} is required to be idempotent for
   * exactly that reason. A replay must be bounded - by a number of attempts, by a window of time, or by both -
   * so that a conflict which does not clear reaches the caller instead of being retried forever. The pluggable
   * backend holds locks across this method, up to the exclusive lock of an entry container, and every thread
   * waiting on one of those locks waits for as long as this method does.
   * exactly that reason. A replay may be bounded - by a number of attempts, by a window of time, or by both - so
   * that a conflict which does not clear reaches the caller, or may go on for as long as the conflict lasts, the
   * way a writer of a lock based engine waits for a lock; an engine which resolves every conflict by a rollback
   * should bound it by time only, since a healthy write under concurrent load loses several in a row. The
   * pluggable backend holds locks across this method, up to the exclusive lock of an entry container, and every
   * thread waiting on one of those locks waits for as long as this method does.
   * <p>
   * A caller that mutates state around this method must handle that bound being spent. Removing an entry from an
   * in-memory map before the write so that a replay still finds the work to do, or reading configuration back out
opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java
@@ -71,12 +71,12 @@
public class PDBStorageTest extends DirectoryServerTestCase
{
  /** A window no run of replays can spend, so that a test of the attempt cap is only ever ended by the cap. */
  private static final long UNREACHABLE_RETRY_WINDOW_NANOS = 300L * 1000L * 1000L * 1000L; //5 min
  /** A window a single attempt outlasts, so that a test of the window reaches it without seconds of build time. */
  private static final long SHORT_RETRY_WINDOW_NANOS = 200L * 1000L * 1000L; //200 ms
  /** An attempt long enough to outlast {@link #SHORT_RETRY_WINDOW_NANOS} on its own, in milliseconds. */
  private static final long ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS = 300;
  /** A retry time limit a single attempt outlasts, so that a test of the limit reaches it in under a second. */
  private static final long SHORT_RETRY_TIME_LIMIT_MS = 200;
  /** An attempt long enough to outlast {@link #SHORT_RETRY_TIME_LIMIT_MS} on its own, in milliseconds. */
  private static final long ATTEMPT_LONGER_THAN_SHORT_LIMIT_MS = 300;
  /** The attempt cap #937 shipped, which ordinary concurrent writes spent (#1149). */
  private static final int FORMER_ATTEMPT_CAP = 10;
  /** The buffer pool {@link #testCanAddLargeValues()} writes through, well under the 20% of the other methods. */
  private static final long LARGE_VALUES_DB_CACHE_SIZE = 16L * MB;
@@ -151,19 +151,35 @@
    }
  }
  /**
   * Replaces the storage under test with one bounded by the given values, so that the bound a test is about is
   * the one that ends its replays. With the shipped values the two race: the nine backoffs of a full ladder draw
   * from 50+100+200+400+800+1000x4, so an attempt cap test can be ended by the ten second window instead, and a
   * window test has to make every attempt outlast seconds of that window to reach it.
   */
  private void reopenWithReplayBounds(int maxRetries, long retryWindowNanos) throws Exception
  /** Replaces the storage under test with one whose db-txn-retry-time-limit is the given one. */
  private void reopenWithRetryTimeLimit(long retryTimeLimitMs) throws Exception
  {
    closeAndRemove(storage);
    storage = new PDBStorage(createBackendCfg(), serverContext, maxRetries, retryWindowNanos);
    storage = new PDBStorage(createBackendCfgWithRetryTimeLimit(retryTimeLimitMs), serverContext);
    storage.open(AccessMode.READ_WRITE);
  }
  private static PDBBackendCfg createBackendCfgWithRetryTimeLimit(long retryTimeLimitMs)
  {
    final PDBBackendCfg cfg = createBackendCfg(0L);
    when(cfg.getDBTxnRetryTimeLimit()).thenReturn(retryTimeLimitMs);
    return cfg;
  }
  /**
   * Fails an attempt of a conflict that never clears once {@link #SHORT_RETRY_TIME_LIMIT_MS} should long have ended
   * the replays, with something other than a rollback: a write that ignores the limit would otherwise replay for as
   * long as the default allows, and hang the test rather than fail it.
   */
  private static void failIfReplayedPastTheLimit(int attempt)
  {
    if (attempt > 4)
    {
      throw new IllegalStateException(
          "replayed " + attempt + " times past a retry time limit of " + SHORT_RETRY_TIME_LIMIT_MS + " ms");
    }
  }
  /**
   * The sources are wrapped rather than copied, and the storage is reopened with a buffer pool of
   * {@link #LARGE_VALUES_DB_CACHE_SIZE}: in a JVM of 512 MB each 63 MB array needs a free run of 64 regions,
@@ -318,36 +334,36 @@
    assertThat(storage.getNewExchange(treeName, true)).isNotSameAs(initial);
  }
  /**
   * A rollback is how persistit resolves two transactions writing the same key, so a healthy write under concurrent
   * load loses several in a row - ADD and DELETE did, on the index keys their entries shared, and failed with
   * result 80 once they had lost ten (#1149). With the default configuration, nothing but the conflict clearing
   * ends the replays: neither a count of attempts nor a limit of 0 read as a window already spent.
   */
  @Test
  public void testWriteGivesUpAfterTheAttemptCap() throws Exception
  public void testWriteOutlastsAnyNumberOfConflictsByDefault() throws Exception
  {
    // the shipped cap, against a window the ladder of backoffs cannot reach: on the shipped window those nine
    // backoffs draw from up to 5550 ms, so a loaded machine ends this loop on the window and the cap goes untested
    reopenWithReplayBounds(PDBStorage.MAX_RETRIES, UNREACHABLE_RETRY_WINDOW_NANOS);
    assertThat(createBackendCfg().getDBTxnRetryTimeLimit()).isZero();
    createTree();
    final RollbackException conflict = new RollbackException();
    final int conflicts = FORMER_ATTEMPT_CAP + 1;
    final AtomicInteger attempts = new AtomicInteger();
    try
    storage.write(new WriteOperation()
    {
      storage.write(new WriteOperation()
      @Override
      public void run(WriteableTransaction txn) throws Exception
      {
        @Override
        public void run(WriteableTransaction txn) throws Exception
        // written before the conflict, as a rolled back attempt of a real write would have
        txn.put(treeName, valueOfUtf8("contended"), valueOfUtf8("attempt " + attempts.incrementAndGet()));
        if (attempts.get() <= conflicts)
        {
          attempts.incrementAndGet();
          txn.put(treeName, valueOfUtf8("abandoned"), valueOfUtf8("value"));
          throw conflict;
          throw new RollbackException();
        }
      });
      failBecauseExceptionWasNotThrown(StorageRuntimeException.class);
    }
    catch (StorageRuntimeException e)
    {
      assertThat(e.getSuppressed()).contains(conflict);
    }
    assertThat(attempts.get()).isEqualTo(PDBStorage.MAX_RETRIES);
    assertThat(read("abandoned")).isNull();
      }
    });
    assertThat(attempts.get()).isEqualTo(conflicts + 1);
    assertThat(read("contended")).isEqualTo(valueOfUtf8("attempt " + (conflicts + 1)));
  }
  @Test
@@ -376,13 +392,13 @@
  /**
   * PersistIt reports a write-write conflict only once it has waited on it - up to
   * {@code SharedResource.DEFAULT_MAX_WAIT_TIME}, a minute, which this backend never lowers - so a single attempt
   * can outlast the whole window. Giving up on the window alone would then replay nothing, in the very case where
   * the replay is likeliest to succeed: the transaction that was blocking this one has just finished.
   * can outlast the whole retry time limit. Giving up on the limit alone would then replay nothing, in the very
   * case where the replay is likeliest to succeed: the transaction that was blocking this one has just finished.
   */
  @Test
  public void testWriteIsReplayedOnceWhenTheFirstAttemptOutlastsTheWindow() throws Exception
  public void testWriteIsReplayedOnceWhenTheFirstAttemptOutlastsTheRetryTimeLimit() throws Exception
  {
    reopenWithReplayBounds(PDBStorage.MAX_RETRIES, SHORT_RETRY_WINDOW_NANOS);
    reopenWithRetryTimeLimit(SHORT_RETRY_TIME_LIMIT_MS);
    createTree();
    final AtomicInteger attempts = new AtomicInteger();
@@ -393,7 +409,7 @@
      {
        if (attempts.incrementAndGet() == 1)
        {
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS);
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_LIMIT_MS);
          throw new RollbackException();
        }
        txn.put(treeName, valueOfUtf8("outlasted"), valueOfUtf8("written"));
@@ -407,38 +423,7 @@
  @Test
  public void testExhaustedWriteNamesTheAttemptsItSpent() throws Exception
  {
    // the message is the same at any cap, so this one is spent in two backoffs rather than in the shipped ladder
    final int maxRetries = 3;
    reopenWithReplayBounds(maxRetries, UNREACHABLE_RETRY_WINDOW_NANOS);
    createTree();
    try
    {
      storage.write(new WriteOperation()
      {
        @Override
        public void run(WriteableTransaction txn) throws Exception
        {
          throw new RollbackException();
        }
      });
      failBecauseExceptionWasNotThrown(StorageRuntimeException.class);
    }
    catch (StorageRuntimeException e)
    {
      assertThat(e.getMessage()).contains("PDBStorageTest").contains(maxRetries + " attempts");
      // and which of the two bounds ran out, since the attempt count alone does not say
      assertThat(e.getMessage()).contains("attempt cap");
      // write() unwraps a StorageRuntimeException that carries a cause, which would replace this message with
      // the bare RollbackException, and it is the message the config change paths report
      assertThat(e.getCause()).isNull();
    }
  }
  @Test
  public void testWriteGivesUpOnTheWindowWhenAttemptsAreSlow() throws Exception
  {
    reopenWithReplayBounds(PDBStorage.MAX_RETRIES, SHORT_RETRY_WINDOW_NANOS);
    reopenWithRetryTimeLimit(SHORT_RETRY_TIME_LIMIT_MS);
    createTree();
    final AtomicInteger attempts = new AtomicInteger();
@@ -449,9 +434,8 @@
        @Override
        public void run(WriteableTransaction txn) throws Exception
        {
          attempts.incrementAndGet();
          // a conflict this slow to report spends the wall clock window long before the attempt cap
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS);
          failIfReplayedPastTheLimit(attempts.incrementAndGet());
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_LIMIT_MS);
          throw new RollbackException();
        }
      });
@@ -459,12 +443,83 @@
    }
    catch (StorageRuntimeException e)
    {
      // the window is what ended it, and it says so: an assertion on the attempt count alone would also pass for
      // a give up on attempt 1, which is the regression the attempt > 1 exemption exists to prevent
      assertThat(e.getMessage()).contains("retry window");
      assertThat(e.getMessage()).contains("PDBStorageTest").contains("2 attempts");
      // and the property whose value ran out, since that is what an operator would raise
      assertThat(e.getMessage()).contains("db-txn-retry-time-limit of " + SHORT_RETRY_TIME_LIMIT_MS + " ms");
      // write() unwraps a StorageRuntimeException that carries a cause, which would replace this message with
      // the bare RollbackException, and it is the message the config change paths report
      assertThat(e.getCause()).isNull();
    }
    // one attempt beyond the first: the first spends the window, the exemption grants the replay, and the check
    // after that replay is the one that gives up
  }
  @Test
  public void testWriteGivesUpOnTheRetryTimeLimitWhenAttemptsAreSlow() throws Exception
  {
    reopenWithRetryTimeLimit(SHORT_RETRY_TIME_LIMIT_MS);
    createTree();
    final RollbackException conflict = new RollbackException();
    final AtomicInteger attempts = new AtomicInteger();
    try
    {
      storage.write(new WriteOperation()
      {
        @Override
        public void run(WriteableTransaction txn) throws Exception
        {
          failIfReplayedPastTheLimit(attempts.incrementAndGet());
          txn.put(treeName, valueOfUtf8("abandoned"), valueOfUtf8("value"));
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_LIMIT_MS);
          throw conflict;
        }
      });
      failBecauseExceptionWasNotThrown(StorageRuntimeException.class);
    }
    catch (StorageRuntimeException e)
    {
      assertThat(e.getSuppressed()).contains(conflict);
    }
    // one attempt beyond the first: the first spends the limit, the exemption grants the replay, and the check
    // after that replay is the one that gives up - an assertion on the failure alone would also pass for a give up
    // on attempt 1, which is the regression the attempt > 1 exemption exists to prevent
    assertThat(attempts.get()).isEqualTo(2);
    // and what the attempts wrote is rolled back with them
    assertThat(read("abandoned")).isNull();
  }
  /**
   * The limit is read by every write rather than once by the open, so that an administrator who sets it on a
   * running backend - to bound a configuration change about to be made - has it apply without a restart.
   */
  @Test
  public void testRetryTimeLimitChangedWhileOpenAppliesToTheNextWrite() throws Exception
  {
    createTree();
    final ConfigChangeResult ccr =
        storage.applyConfigurationChange(createBackendCfgWithRetryTimeLimit(SHORT_RETRY_TIME_LIMIT_MS));
    assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
    assertThat(ccr.adminActionRequired()).isFalse();
    final AtomicInteger attempts = new AtomicInteger();
    try
    {
      storage.write(new WriteOperation()
      {
        @Override
        public void run(WriteableTransaction txn) throws Exception
        {
          failIfReplayedPastTheLimit(attempts.incrementAndGet());
          Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_LIMIT_MS);
          throw new RollbackException();
        }
      });
      failBecauseExceptionWasNotThrown(StorageRuntimeException.class);
    }
    catch (StorageRuntimeException e)
    {
      assertThat(e.getMessage()).contains("db-txn-retry-time-limit of " + SHORT_RETRY_TIME_LIMIT_MS + " ms");
    }
    assertThat(attempts.get()).isEqualTo(2);
  }
@@ -521,7 +576,7 @@
  public void testRetryDelayGrowsAndStaysBounded()
  {
    long previousBound = 0;
    for (int attempt = 1; attempt <= PDBStorage.MAX_RETRIES; attempt++)
    for (int attempt = 1; attempt <= FORMER_ATTEMPT_CAP; attempt++)
    {
      long bound = 0;
      for (int i = 0; i < 100; i++)
@@ -543,7 +598,7 @@
    long grown = 0;
    for (int i = 0; i < 100; i++)
    {
      grown = Math.max(grown, PDBStorage.retryDelayMillis(PDBStorage.MAX_RETRIES));
      grown = Math.max(grown, PDBStorage.retryDelayMillis(FORMER_ATTEMPT_CAP));
    }
    assertThat(grown).as("the last attempts still sleep within the first attempt's bound").isGreaterThan(500);
  }