Skip to content

[#993] Register an entry container's configuration listeners only once it has opened - #999

Open
vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-993-entry-container-listeners-on-open
Open

vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-993-entry-container-listeners-on-open

Conversation

@vharseko

@vharseko vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #993.

The defect

EntryContainer registered itself and its two configuration managers as listeners of the backend
configuration from its constructor, and only close() takes them off again. open() caught
StorageRuntimeException alone, while it is declared to throw ConfigException and really does -
an index type the attribute has no matching rule for, an index protecting both its keys and its
values, and (the most reachable of the three) a VLV filter or sort order which does not parse.
Neither RootContainer.openEntryContainer nor openAndRegisterEntryContainers catches it either,
so the container is registered nowhere and nothing will ever call its close().

Two things in the report needed correcting, and both are in the analysis on the
issue
: the
failure is not a NullPointerException - id2entry is assigned by the first statement of the
try - and it is more than five listeners, since every index the failed open got through
registered one of its own. What it costs is worse than an NPE:
ConfigurationHandler.replaceEntry asks every listener on the backend entry whether a change is
acceptable and a single false rejects the whole modify, so an abandoned container can veto a
change on the live backend; and past that gate the entry is already stored, so a failure from one
listener turns a change the live container applied into ERR_CONFIG_FILE_MODIFY_APPLY_FAILED.

The change

EntryContainer

  • the five registrations move from the constructor to the end of a successful open(). A container
    which did not open is not one a configuration change has anything to be applied to, and nothing
    can reach it in between: open() is called before anything holds it.
  • open() catches every failure rather than the storage ones alone, so a ConfigException also
    goes through close().
  • each index is held in its map before it is opened. An attribute index registers its listener at
    the end of open() and a VLV index from its constructor, so the one being opened was not yet one
    close() could find - the hole the StorageRuntimeException catch already had.

RootContainer

  • a failed open() gives back what it took: the entry containers it registered, its own listener,
    and the storage - the last unless it was the storage's own open() that threw. A storage whose
    open failed is not one the root container can close: what that open took before it failed is the
    storage's own to give back (below), and there is no other road to that arm - every root container,
    read only or not, is opened over a storage no root container holds, since BackendImpl opens one
    only while it has none. Nothing else reclaims any of it: newRootContainer throws the instance
    away and BackendConfigManager releases the shared lock without calling closeBackend() for a
    backend which never opened, so a volume left open here is one no later attempt to enable that
    backend can take.
  • an entry container is registered as soon as it has opened, before its highest entry ID is read: a
    container which opened has registered every listener it ever will, and only what the registry
    holds is given back, so a cursor which fails between the two left one nothing holds.
  • openAndRegisterEntryContainers runs inside the write Storage.write may replay, so it gives up
    what a rolled back attempt registered before opening again. Without it an ordinary write-write
    conflict during startup fails the backend with ERR_ENTRY_CONTAINER_ALREADY_REGISTERED and
    leaves that attempt's containers registered - the same shape BackendImpl.changeBaseDNTrees
    already uses for the containers it opens inside a write.

PDBStorage

  • an open() which fails gives back what it took before it failed, as JDBCStorage.open already
    does: the cache size buildConfiguration drew from the memory quota and the listener the
    constructor registered on the backend configuration, and the database itself when the open got
    that far. Every failed enable of a PDB backend - its volume held by the storage a previous failed
    enable left behind, its directory unwritable - drained one cache size for the life of the JVM and
    left a storage answering that backend's configuration changes; and the drained quota is what
    every isConfigurationChangeAcceptable answers from, so a cache size the server has the memory
    for is refused. startImport() shares the give-back.
  • the guard against opening a database which is open runs before anything is taken, so that the
    give-back never has a live database in front of it.
  • close() releases the quota once, whatever follows: a close() after a give-back - or the second
    and third close() importLDIF has always made on the storage of its root container - releases
    nothing more, and it survives a database the failed open registered no monitor for.

The base DN path needs nothing of its own: a container whose open() throws now closes itself, so
the container changeBaseDNTrees never gets to put in created is reclaimed too.

Tests

FailedBackendOpenTest, eight tests, each watched to fail first - against the tree before the change
for the first five, against a mutant of the change for the three which pin what it keeps:

test what it reported
aBackendWhichFailsToOpenLeavesNothingRegistered the root container, the entry container and both configuration managers left registered
anIndexWhichFailsToOpenLeavesNoListenerBehind /dc=com,dc=b993/vlv.vlv1 left registered
aBackendWhichFailsToOpenGivesBackTheStorageItOpened closes: expected 1, was 0
aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt an early return when the storage did not open leaves the root container registered
aReplayedOpenLeavesOneSetOfEntryContainers An entry container named 'dc=com,dc=b993' is alreadly registered
aSecondBaseDNWhichFailsToOpenGivesBackTheFirst the loop of giveUpAfterFailedOpen deleted: the first base DN's container and its five registrations left behind
anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack the entry container and its four manager registrations left behind when the read of the highest entry ID fails
anEntryContainerWhichOpenedIsRegisteredOnce the five registrations deleted at their new site: green everywhere else; registered from the constructor as well: 2 where 1 is expected

PDBStorageTest, three tests, each watched to fail first:

test what it reported before the change
aStorageWhoseOpenFailedGivesBackWhatItTook 76 MB less of the quota available after the failed open
closingAStorageWhoseOpenFailedTakesNothingMore 76 MB more than before once the give-back was in and close() still released a second time
openingAnOpenStorageIsRefusedAndTakesNothing 76 MB less of the quota available after the refusal

Run green with them: PDBTestCase, EncryptedPDBTestCase, ReplayedConfigChangeTest,
OnDiskMergeImporterTest, PersistentCompressedSchemaTest, DN2IDTest, StateTest, ID2EntryTest,
ID2ChildrenCountTest, BulkCursorTest, DefaultIndexTest, ImportLDIFTestCase,
RebuildIndexTestCase, VerifyIndexTestCase.

…n listeners only once it has opened

EntryContainer registered itself and its two configuration managers from its
constructor, and only close() takes them off again. open() caught
StorageRuntimeException alone, so a ConfigException - a VLV filter or sort order
which does not parse, an index type the attribute has no matching rule for - left
a container nothing holds a reference to, registered on the configuration of a
backend which did not start.

Register the five at the end of a successful open() instead, and catch every
failure there rather than the storage ones alone. Hold each index in its map
before opening it: an attribute index registers its listener at the end of
open() and a VLV index from its constructor, so the one being opened was not yet
one close() could find - the hole the existing catch already had.

RootContainer gives back what a failed open took: the entry containers it
registered, its own listener and the storage, the last only when this call is
what opened it. openAndRegisterEntryContainers runs inside a write the storage
may replay, so it now gives up what a rolled back attempt registered before
opening again; without it a write-write conflict failed the backend with
ERR_ENTRY_CONTAINER_ALREADY_REGISTERED.

Fixes OpenIdentityPlatform#993
@vharseko
vharseko requested a review from maximthomas September 9, 2026 13:59
@vharseko vharseko added bug tests Test suites: fixing, enabling, un-disabling labels Sep 9, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The PR closes the leak it names and proves it: four of the five cases are red at BASE exactly as the table says (run here: tests=5 failures=4), and the fifth kills the storageOpened-forced-true mutant.

  • The replay give-back at the top of openAndRegisterEntryContainers closes the ERR_ENTRY_CONTAINER_ALREADY_REGISTERED road traced on #883, and aReplayedOpenLeavesOneSetOfEntryContainers pins it both ways: delete the loop → ALREADY_REGISTERED; unregister without close → stillRegisteredOn(cfg) non-empty.
  • catch (Exception e) { close(); throw e; } is pinned by aBackendWhichFailsToOpenLeavesNothingRegistered through the index listener, and EntryContainer.close() has no throwing path (every callee read), so the original exception is the one that escapes.
  • The issue analysis corrected the report — not an NPE, more than five listeners — before the fix was written.

issue (non-blocking): The entry-container loop in giveUpAfterFailedOpen is pinned by no case — deleting it is green 5/5.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:191-194

Every fixture has one base DN and openEntryContainer runs before registerEntryContainer, so the failing container is never in entryContainers: tests 1-3 iterate an empty map, test 4 fails in storage.open, test 5 succeeds. Measured: the loop removed, FailedBackendOpenTest passes 5/5. The road it exists for — first base DN registered, the second's open fails — is untested. RefusingOneTree matches on getIndexId() alone, so it cannot single out the second container's tree; match on the full name.

// TrackingStorage / RefusingOneTree: refuse by full tree name, not index id alone
if (refused.equals(name.toString()))          // "/dc=com,dc=b993b/id2entry"

@Test
public void aSecondBaseDNWhichFailsToOpenGivesBackTheFirst() throws Exception
{
  final TrackedBackend backend = new TrackedBackend();
  backend.setBackendID(BACKEND_ID);
  final PDBBackendCfg cfg = backendCfg(newTreeSet(BASE_DN, DN.valueOf("dc=b993b,dc=com")));
  backend.configureBackend(cfg, serverContext);
  backend.storage.removeStorageFiles();
  backend.storage.failOpeningTree("/dc=com,dc=b993b/id2entry");
  try
  {
    backend.openBackend();
    fail("the backend was expected not to open with a second base DN whose trees cannot be opened");
  }
  catch (InitializationException expected)
  {
  }
  finally
  {
    backend.storage.close();
  }
  assertThat(stillRegisteredOn(cfg)).isEmpty();        // the first container's five
  assertThat(stillRegisteredOn(indexCfg)).isEmpty();   // and its cn index
}

Pin: with the loop deleted this case must go red on stillRegisteredOn(cfg).


issue (non-blocking): A storage.open() which throws leaves the PDB quota, the PDB change listener and a half-built Persistit behind, and the reason the javadoc gives for not closing it is not the real one.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:137-138, :182-185, :196-199
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1060, :1085-1095, :1172-1199, :1103-1119

Before open0 can throw, PDBStorage has already registered itself as a PDB change listener (constructor, :1060), acquired dbCacheSize from the MemoryQuota (buildConfiguration, :1090/:1095) and assigned db = new Persistit(dbCfg) (:1182); the catches at :1193-1199 only rewrap. With storageOpened == false nothing gives that back, and RootContainer cannot "always close": PDBStorage.close() with db != null and monitor == null NPEs in DirectoryServer.deregisterMonitorProvider(null) (:1107) before db.close() and the quota release. Every failed enable of a PDB backend whose directory is unwritable, whose volume is corrupt or in use drains one dbCacheSize for the JVM's life and leaves a storage answering PDB config changes — #993 one class down. Pre-existing (BASE closed nothing on any failed-open road); JDBCStorage.open self-unwinds (:1059-1076).

The javadoc's rationale — "a read only root container is opened over the very storage instance the backend holds" — is a road that does not exist: every READ_ONLY open (BackendImpl.exportLDIF:620, verifyBackend:727, rebuildBackend:772, importLDIF:685) is gated on mustOpenRootContainer() i.e. rootContainer == null, so no live root container holds the instance, storage.open() genuinely opens it, and closing it on failure is correct. The false arm fires on exactly one road: storage.open() threw.

// PDBStorage.open: give back what buildConfiguration/open0 took before throwing
@Override
public void open(AccessMode accessMode) throws ConfigException, StorageRuntimeException
{
  Reject.ifNull(accessMode, "accessMode must not be null");
  if (isBackendIncomplete(accessMode))
  {
    return;
  }
  boolean opened = false;
  try
  {
    open0(buildConfiguration(accessMode));
    opened = true;
  }
  finally
  {
    if (!opened)
    {
      giveUpFailedOpen();   // db.close() best-effort + db = null; release memQuota; removePDBChangeListener(this)
    }
  }
}

Or: file it as a follow-up against PDBStorage — but in either case rewrite the storageOpened rationale in the giveUpAfterFailedOpen javadoc, the PR body, test 4's javadoc and comment (FailedBackendOpenTest.java:209-211, :229) and the mock message (:474) to the true one: a storage whose open() threw is not one this call can close, and what it took is the storage's own to give back.


issue (non-blocking): A container which opened but fails before registerEntryContainer is reclaimed by nobody.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:288-290

After openEntryContainer returns, the container has registered its five listeners plus one per index; ec.getHighestEntryID(txn) runs before registerEntryContainer(baseDN, ec). A StorageRuntimeException from that cursor read (Persistit.getExchange / Exchange.previous: TreeNotFoundException, PersistitIOException, CorruptVolumeException, TimeoutException) leaves ec a local: giveUpAfterFailedOpen and the replay loop walk entryContainers only, and EntryContainer.open's catch has returned. Not replayed — a fetch never raises RollbackException (both throw sites in Exchange.java are on the store path) — so one leaked set per attempt, and the server continues. Pre-existing window; the PR narrows every other road and leaves this one.

EntryContainer ec = openEntryContainer(baseDN, txn, accessMode);
registerEntryContainer(baseDN, ec);            // held before anything else here can throw
EntryID id = ec.getHighestEntryID(txn);

suggestion (non-blocking): No case asserts that an opened container is registered — the five registrations can be deleted at their new site and the class stays green.

opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java:561-566
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/FailedBackendOpenTest.java:281-333

Measured: the five registrations moved back to the constructor, everything else at head — 5/5 green, because catch (Exception) { close(); } removes what the constructor added. That revert is unkillable by construction (constructor and open() are always paired), but the delete at the new site is pinnable, and stillRegisteredOn uses List.removeAll, which hides a double registration.

// positive twin, after a successful openBackend()
backend.openBackend();
final List<Object> registered = stillRegisteredOn(cfg);
assertThat(registered).filteredOn(l -> l instanceof EntryContainer).hasSize(1);
// the two private cfg managers, each once as add and once as delete listener: 4, not 0 and not 8
assertThat(registered).filteredOn(l -> l.getClass().getSimpleName().endsWith("IndexCfgManager")).hasSize(4);

// stillRegisteredOn: remove one occurrence per removal, so a double registration is visible
for (Object removed : changeRemoved.getAllValues())
{
  registered.remove(removed);
}

suggestion (if-minor): aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt asserts closeCalls() == 0 only; the root container's own listener removal on the storageOpened == false arm is unpinned.

opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/FailedBackendOpenTest.java:232

An early return when !storageOpened at the top of giveUpAfterFailedOpen is green 5/5 by reading: the other four cases have storageOpened == true.

assertThat(backend.storage.closeCalls()).isEqualTo(0);
assertThat(stillRegisteredOn(cfg)).isEmpty();

…ok, and pin the give-back of the root container

Review round 2 of OpenIdentityPlatform#999.

PDBStorage.open() and startImport() give back what the attempt took before it
failed - the cache size buildConfiguration drew from the memory quota, the
listener the constructor registered on the backend configuration, and the
database when the open got that far - as JDBCStorage.open already does. Every
failed enable of a PDB backend drained one cache size for the life of the JVM
and left a storage answering the configuration changes of a backend which is
not running. The guard against a double open runs before anything is taken,
and close() releases the quota once and tolerates a database the failed open
registered no monitor for.

RootContainer registers an entry container as soon as it has opened, before
its highest entry ID is read: a container which opened has registered every
listener it ever will, and only what the registry holds is given back. The
rationale of the storageOpened arm is the true one: the storage's own open()
threw, and what that open took is the storage's own to give back - no root
container is ever opened over a storage another one holds.

FailedBackendOpenTest pins the give-back loop with a second base DN, the
registration of an opened container, the root container's own listener when
the storage did not open, and the positive twin - one registration of each
listener once a container has opened, counted per occurrence. PDBStorageTest
pins the give-back, a close() which follows it, and the refusal of a double
open.
@vharseko

Copy link
Copy Markdown
Member Author

All five taken, in 42f6aa2. Each pin was watched to fail on the mutant it is for before the case
went in, the PDB ones against the tree before the change.

The loop of giveUpAfterFailedOpen - aSecondBaseDNWhichFailsToOpenGivesBackTheFirst: two base
DNs, the second's id2entry refused, RefusingOneTree matching on the full tree name as suggested
(now TrackingTransaction, which also records the trees it opened, so the case asserts that the first
container had opened before the second one failed - the road it is about). Loop deleted:
stillRegisteredOn(cfg) reports the first container and its five registrations.

PDBStorage.open - in this PR rather than a follow-up: the PR claims a failed open gives back
what it took, and on the most reachable road - the volume held by the storage a previous failed
enable left behind, i.e. the second attempt of the very scenario in the issue analysis - that was
false. One correction to the trace: new Persistit(dbCfg) calls initialize() itself, whose
finally releases everything when it did not complete, so an InUseException or a corrupt volume
throws before db is assigned and leaves no half-built Persistit behind; that arm is reached only
when loadVolume or the monitor registration fails after the assignment. What every failed open0
did leave was the quota reservation of buildConfiguration and the listener of the constructor -
exactly as you said. The shape is the one you sketched, with the double-open guard hoisted ahead of
buildConfiguration (it used to take the quota and then refuse) so that the give-back never has a
live database in front of it, and close() now releases the quota once and tolerates a database
without a monitor - a close() after the give-back, or the second and third close() importLDIF
has always made on its root container's storage, must release nothing more. Three cases in
PDBStorageTest, each red first: the quota after a failed open over the volume setUp() holds
(76 MB short), the same followed by close() (76 MB over, once the give-back was in and the release
still ran twice), and the refusal of a double open (76 MB short). The monitor != null arm is the one
thing here with no case: reaching it needs loadVolume to fail on a database which just initialized.

The storageOpened rationale is rewritten to the true one in the javadoc, the case, its comment,
the mock message and the PR body: the false arm is storage.open() threw, and there is no other -
every READ_ONLY and READ_WRITE open in BackendImpl is gated on rootContainer == null, and
BackendStat configures a fresh backend before it asks for one.

The container opened but not registered - registerEntryContainer now runs first;
anEntryContainerWhichOpenedButWasNotRegisteredIsGivenBack refuses the first cursor over id2entry
once an entry container is registered on the configuration (the read of the highest entry ID; the
cursors before it - the emptiness check of open() and the one each untrusted index makes - fail the
open itself, which the container catches). Order reverted: the container and its four manager
registrations left behind.

The positive twin - anEntryContainerWhichOpenedIsRegisteredOnce: one EntryContainer, the two
managers twice each, one listener on the index and on the VLV index configuration.
stillRegisteredOn takes one occurrence off per removal. Registrations deleted at the new site: the
container count is 0; made from the constructor as well: 2 where 1 is expected, and the replay and
second-base-DN cases go red with it.

The listener of the root container on the !storageOpened arm - stillRegisteredOn(cfg) added
to aRootContainerWhichCouldNotOpenTheStorageDoesNotCloseIt; an early return there is red on it.

Green with the change: FailedBackendOpenTest 8/8, PDBStorageTest 13/13, and the set in the PR
body (PDBTestCase, EncryptedPDBTestCase, ReplayedConfigChangeTest, OnDiskMergeImporterTest,
PersistentCompressedSchemaTest, DN2IDTest, StateTest, ID2EntryTest, ID2ChildrenCountTest,
BulkCursorTest, DefaultIndexTest) plus ImportLDIFTestCase, RebuildIndexTestCase and
VerifyIndexTestCase for the import road close() now sees three times.

@vharseko vharseko added java Changes to Java sources index Attribute/VLV index subsystem: build, trust, rebuild, confidentiality labels Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug index Attribute/VLV index subsystem: build, trust, rebuild, confidentiality java Changes to Java sources tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A ConfigException while opening an EntryContainer leaves its five configuration listeners registered on a half-open container

2 participants